refactor: extract platform media crates
This commit is contained in:
13
server-rs/crates/platform-audio/Cargo.toml
Normal file
13
server-rs/crates/platform-audio/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "platform-audio"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
platform-oss = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
255
server-rs/crates/platform-audio/src/client.rs
Normal file
255
server-rs/crates/platform-audio/src/client.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use std::error::Error;
|
||||
|
||||
use reqwest::header;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::response::{
|
||||
extract_audio_urls, extract_string_by_path, find_first_string_by_key, normalize_task_status,
|
||||
};
|
||||
use crate::{
|
||||
AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
|
||||
SoundEffectTaskRequest, VectorEngineAudioSettings, build_background_music_task_body,
|
||||
build_sound_effect_task_body,
|
||||
};
|
||||
|
||||
pub fn build_vector_engine_audio_http_client(
|
||||
settings: &VectorEngineAudioSettings,
|
||||
) -> Result<reqwest::Client, AudioError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(
|
||||
settings.request_timeout_ms.max(1),
|
||||
))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AudioError::invalid_config(format!(
|
||||
"构造 VectorEngine 音频生成 HTTP 客户端失败:{error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn submit_background_music_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
request: BackgroundMusicTaskRequest,
|
||||
) -> Result<AudioTaskResponse, AudioError> {
|
||||
let body = build_background_music_task_body(request)?;
|
||||
let response = post_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
AudioTaskKind::BackgroundMusic.submit_path(),
|
||||
body,
|
||||
"提交 Suno 背景音乐任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = extract_string_by_path(&response, &["data"])
|
||||
.or_else(|| find_first_string_by_key(&response, "task_id"))
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| {
|
||||
AudioError::missing_audio("提交 Suno 背景音乐任务失败:上游未返回任务 ID")
|
||||
})?;
|
||||
|
||||
Ok(AudioTaskResponse {
|
||||
kind: AudioTaskKind::BackgroundMusic,
|
||||
task_id,
|
||||
provider: AudioTaskKind::BackgroundMusic.provider().to_string(),
|
||||
status: "submitted".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn submit_sound_effect_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
request: SoundEffectTaskRequest,
|
||||
) -> Result<AudioTaskResponse, AudioError> {
|
||||
let body = build_sound_effect_task_body(request)?;
|
||||
let response = post_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
AudioTaskKind::SoundEffect.submit_path(),
|
||||
body,
|
||||
"提交 Vidu 音效任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = find_first_string_by_key(&response, "task_id")
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| AudioError::missing_audio("提交 Vidu 音效任务失败:上游未返回任务 ID"))?;
|
||||
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
|
||||
|
||||
Ok(AudioTaskResponse {
|
||||
kind: AudioTaskKind::SoundEffect,
|
||||
task_id,
|
||||
provider: AudioTaskKind::SoundEffect.provider().to_string(),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_audio_task_payload(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
kind: AudioTaskKind,
|
||||
task_id: &str,
|
||||
) -> Result<Value, AudioError> {
|
||||
get_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
&kind.fetch_path(task_id),
|
||||
match kind {
|
||||
AudioTaskKind::BackgroundMusic => "查询 Suno 背景音乐任务失败",
|
||||
AudioTaskKind::SoundEffect => "查询 Vidu 音效任务失败",
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn resolve_audio_task_download_urls(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
kind: AudioTaskKind,
|
||||
task_id: &str,
|
||||
) -> Result<(String, Vec<String>), AudioError> {
|
||||
let task_payload = fetch_audio_task_payload(http_client, settings, kind, task_id).await?;
|
||||
let status = normalize_task_status(
|
||||
find_first_string_by_key(&task_payload, "status")
|
||||
.or_else(|| find_first_string_by_key(&task_payload, "state"))
|
||||
.or_else(|| find_first_string_by_key(&task_payload, "Status"))
|
||||
.as_deref()
|
||||
.unwrap_or(""),
|
||||
);
|
||||
let mut audio_urls = extract_audio_urls(&task_payload);
|
||||
if kind == AudioTaskKind::BackgroundMusic && audio_urls.is_empty() {
|
||||
if let Some(clip_id) = extract_string_by_path(&task_payload, &["data"]).and_then(|value| {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}) {
|
||||
let wav_payload = get_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
&format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())),
|
||||
"获取 Suno wav 音频失败",
|
||||
)
|
||||
.await?;
|
||||
audio_urls = extract_audio_urls(&wav_payload);
|
||||
}
|
||||
}
|
||||
Ok((status, audio_urls))
|
||||
}
|
||||
|
||||
async fn get_vector_engine_json(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
path: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AudioError> {
|
||||
let response = http_client
|
||||
.get(format!(
|
||||
"{}{}",
|
||||
settings.base_url.trim_end_matches('/'),
|
||||
path
|
||||
))
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(failure_context, path, error))?;
|
||||
parse_vector_engine_response(response, failure_context).await
|
||||
}
|
||||
|
||||
async fn post_vector_engine_json(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
path: &str,
|
||||
body: Value,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AudioError> {
|
||||
let response = http_client
|
||||
.post(format!(
|
||||
"{}{}",
|
||||
settings.base_url.trim_end_matches('/'),
|
||||
path
|
||||
))
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(failure_context, path, error))?;
|
||||
parse_vector_engine_response(response, failure_context).await
|
||||
}
|
||||
|
||||
async fn parse_vector_engine_response(
|
||||
response: reqwest::Response,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AudioError> {
|
||||
let status = response.status();
|
||||
let raw_text = response.text().await.map_err(|error| {
|
||||
AudioError::request(
|
||||
format!("{failure_context}:读取响应失败:{error}"),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
Some(status.as_u16()),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AudioError::upstream(
|
||||
failure_context.to_string(),
|
||||
status.as_u16(),
|
||||
truncate_raw(raw_text.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
let payload = serde_json::from_str::<Value>(&raw_text).map_err(|error| {
|
||||
AudioError::response_parse(
|
||||
format!("{failure_context}:解析响应失败:{error}"),
|
||||
truncate_raw(raw_text.as_str()),
|
||||
)
|
||||
})?;
|
||||
if let Some(code) = payload.get("code").and_then(Value::as_str)
|
||||
&& !matches!(
|
||||
code.trim().to_ascii_lowercase().as_str(),
|
||||
"success" | "succeeded" | "ok"
|
||||
)
|
||||
{
|
||||
return Err(AudioError::upstream(
|
||||
payload
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(failure_context)
|
||||
.to_string(),
|
||||
status.as_u16(),
|
||||
truncate_raw(raw_text.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn map_reqwest_error(failure_context: &str, endpoint: &str, error: reqwest::Error) -> AudioError {
|
||||
AudioError::request(
|
||||
format!("{failure_context}:{error}"),
|
||||
Some(endpoint.to_string()),
|
||||
error.is_timeout(),
|
||||
error.is_connect(),
|
||||
error.is_request(),
|
||||
error.is_body(),
|
||||
error.status().map(|status| status.as_u16()),
|
||||
Error::source(&error).map(ToString::to_string),
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_raw(raw_text: &str) -> String {
|
||||
raw_text.chars().take(800).collect()
|
||||
}
|
||||
118
server-rs/crates/platform-audio/src/download.rs
Normal file
118
server-rs/crates/platform-audio/src/download.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::error::Error;
|
||||
|
||||
use reqwest::header;
|
||||
|
||||
use crate::{AudioError, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES};
|
||||
|
||||
pub fn normalize_audio_mime_type(content_type: &str, audio_url: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| value.starts_with("audio/"))
|
||||
.unwrap_or("");
|
||||
match mime_type {
|
||||
"audio/mpeg" | "audio/mp3" => "audio/mpeg".to_string(),
|
||||
"audio/wav" | "audio/wave" | "audio/x-wav" => "audio/wav".to_string(),
|
||||
"audio/ogg" => "audio/ogg".to_string(),
|
||||
"audio/webm" => "audio/webm".to_string(),
|
||||
"audio/aac" => "audio/aac".to_string(),
|
||||
"audio/flac" => "audio/flac".to_string(),
|
||||
"audio/mp4" | "audio/x-m4a" => "audio/mp4".to_string(),
|
||||
_ => mime_type_from_audio_url(audio_url),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio_mime_to_extension(mime_type: &str) -> &'static str {
|
||||
match mime_type {
|
||||
"audio/wav" => "wav",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/webm" => "webm",
|
||||
"audio/aac" => "aac",
|
||||
"audio/flac" => "flac",
|
||||
"audio/mp4" => "m4a",
|
||||
_ => "mp3",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_generated_audio(
|
||||
http_client: &reqwest::Client,
|
||||
audio_url: &str,
|
||||
_provider: &str,
|
||||
) -> Result<DownloadedAudio, AudioError> {
|
||||
let response = http_client.get(audio_url).send().await.map_err(|error| {
|
||||
AudioError::request(
|
||||
format!("下载生成音频失败:{error}"),
|
||||
Some(audio_url.to_string()),
|
||||
error.is_timeout(),
|
||||
error.is_connect(),
|
||||
error.is_request(),
|
||||
error.is_body(),
|
||||
error.status().map(|status| status.as_u16()),
|
||||
Error::source(&error).map(ToString::to_string),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("audio/mpeg")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
AudioError::request(
|
||||
format!("读取生成音频内容失败:{error}"),
|
||||
Some(audio_url.to_string()),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AudioError::upstream(
|
||||
format!("下载生成音频失败:HTTP {}", status.as_u16()),
|
||||
status.as_u16(),
|
||||
truncate_raw(""),
|
||||
));
|
||||
}
|
||||
if body.is_empty() || body.len() > MAX_GENERATED_AUDIO_BYTES {
|
||||
return Err(AudioError::missing_audio("生成音频内容为空或超过大小上限"));
|
||||
}
|
||||
|
||||
let mime_type = normalize_audio_mime_type(&content_type, audio_url);
|
||||
Ok(DownloadedAudio {
|
||||
extension: audio_mime_to_extension(&mime_type).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn mime_type_from_audio_url(audio_url: &str) -> String {
|
||||
let path = audio_url
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if path.ends_with(".wav") {
|
||||
"audio/wav".to_string()
|
||||
} else if path.ends_with(".ogg") {
|
||||
"audio/ogg".to_string()
|
||||
} else if path.ends_with(".webm") {
|
||||
"audio/webm".to_string()
|
||||
} else if path.ends_with(".aac") {
|
||||
"audio/aac".to_string()
|
||||
} else if path.ends_with(".flac") {
|
||||
"audio/flac".to_string()
|
||||
} else if path.ends_with(".m4a") {
|
||||
"audio/mp4".to_string()
|
||||
} else {
|
||||
"audio/mpeg".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_raw(raw_text: &str) -> String {
|
||||
raw_text.chars().take(800).collect()
|
||||
}
|
||||
167
server-rs/crates/platform-audio/src/error.rs
Normal file
167
server-rs/crates/platform-audio/src/error.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::VECTOR_ENGINE_PROVIDER;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AudioStatusHint {
|
||||
BadRequest,
|
||||
ServiceUnavailable,
|
||||
BadGateway,
|
||||
GatewayTimeout,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AudioError {
|
||||
InvalidConfig {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
},
|
||||
InvalidRequest {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
},
|
||||
Request {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
endpoint: Option<String>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
status_code: Option<u16>,
|
||||
source: Option<String>,
|
||||
},
|
||||
Upstream {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
upstream_status: u16,
|
||||
raw_excerpt: String,
|
||||
},
|
||||
ResponseParse {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
raw_excerpt: String,
|
||||
},
|
||||
MissingAudio {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AudioError {
|
||||
pub fn provider(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidConfig { provider, .. }
|
||||
| Self::InvalidRequest { provider, .. }
|
||||
| Self::Request { provider, .. }
|
||||
| Self::Upstream { provider, .. }
|
||||
| Self::ResponseParse { provider, .. }
|
||||
| Self::MissingAudio { provider, .. } => provider,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::InvalidConfig { message, .. }
|
||||
| Self::InvalidRequest { message, .. }
|
||||
| Self::Request { message, .. }
|
||||
| Self::Upstream { message, .. }
|
||||
| Self::ResponseParse { message, .. }
|
||||
| Self::MissingAudio { message, .. } => message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_hint(&self) -> AudioStatusHint {
|
||||
match self {
|
||||
Self::InvalidConfig { .. } => AudioStatusHint::ServiceUnavailable,
|
||||
Self::InvalidRequest { .. } => AudioStatusHint::BadRequest,
|
||||
Self::Request {
|
||||
timeout,
|
||||
status_code,
|
||||
..
|
||||
} if *timeout => AudioStatusHint::GatewayTimeout,
|
||||
Self::Request { status_code, .. }
|
||||
if status_code.is_some_and(|status| status >= 500) =>
|
||||
{
|
||||
AudioStatusHint::BadGateway
|
||||
}
|
||||
Self::Upstream { .. } | Self::ResponseParse { .. } | Self::MissingAudio { .. } => {
|
||||
AudioStatusHint::BadGateway
|
||||
}
|
||||
Self::Request { .. } => AudioStatusHint::BadGateway,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_config(message: impl Into<String>) -> Self {
|
||||
Self::InvalidConfig {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self::InvalidRequest {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(
|
||||
message: impl Into<String>,
|
||||
endpoint: Option<String>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
status_code: Option<u16>,
|
||||
source: Option<String>,
|
||||
) -> Self {
|
||||
Self::Request {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
endpoint,
|
||||
timeout,
|
||||
connect,
|
||||
request,
|
||||
body,
|
||||
status_code,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upstream(
|
||||
message: impl Into<String>,
|
||||
upstream_status: u16,
|
||||
raw_excerpt: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::Upstream {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
upstream_status,
|
||||
raw_excerpt: raw_excerpt.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn response_parse(message: impl Into<String>, raw_excerpt: impl Into<String>) -> Self {
|
||||
Self::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
raw_excerpt: raw_excerpt.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn missing_audio(message: impl Into<String>) -> Self {
|
||||
Self::MissingAudio {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AudioError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for AudioError {}
|
||||
32
server-rs/crates/platform-audio/src/lib.rs
Normal file
32
server-rs/crates/platform-audio/src/lib.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
mod client;
|
||||
mod download;
|
||||
mod error;
|
||||
mod persist;
|
||||
mod request;
|
||||
mod response;
|
||||
mod types;
|
||||
|
||||
pub use client::{
|
||||
build_vector_engine_audio_http_client, resolve_audio_task_download_urls,
|
||||
submit_background_music_task, submit_sound_effect_task,
|
||||
};
|
||||
pub use download::{audio_mime_to_extension, download_generated_audio, normalize_audio_mime_type};
|
||||
pub use error::{AudioError, AudioStatusHint};
|
||||
pub use persist::{
|
||||
GeneratedAudioPersistInput, GeneratedAudioPersistTarget, prepare_generated_audio_put_request,
|
||||
};
|
||||
pub use request::{
|
||||
build_background_music_task_body, build_sound_effect_task_body, normalize_limited_text,
|
||||
normalize_limited_text_allow_empty, normalize_optional_text,
|
||||
};
|
||||
pub use response::{
|
||||
extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status,
|
||||
};
|
||||
pub use types::{
|
||||
AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES,
|
||||
SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS,
|
||||
SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER, VECTOR_ENGINE_SUNO_PROVIDER,
|
||||
VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
|
||||
VectorEngineAudioSettings,
|
||||
};
|
||||
106
server-rs/crates/platform-audio/src/persist.rs
Normal file
106
server-rs/crates/platform-audio/src/persist.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
|
||||
|
||||
use crate::{AudioTaskKind, DownloadedAudio};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeneratedAudioPersistTarget {
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
pub profile_id: Option<String>,
|
||||
pub storage_prefix: LegacyAssetPrefix,
|
||||
pub storage_scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeneratedAudioPersistInput {
|
||||
pub owner_user_id: String,
|
||||
pub task_id: String,
|
||||
pub task_kind: AudioTaskKind,
|
||||
pub target: GeneratedAudioPersistTarget,
|
||||
pub audio: DownloadedAudio,
|
||||
}
|
||||
|
||||
pub fn prepare_generated_audio_put_request(
|
||||
input: GeneratedAudioPersistInput,
|
||||
) -> OssPutObjectRequest {
|
||||
let file_name = format!(
|
||||
"{}-{}.{}",
|
||||
input.task_kind.file_stem(),
|
||||
input.task_id,
|
||||
input.audio.extension
|
||||
);
|
||||
OssPutObjectRequest {
|
||||
prefix: input.target.storage_prefix,
|
||||
path_segments: vec![
|
||||
input.target.storage_scope.clone(),
|
||||
input
|
||||
.target
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "draft".to_string()),
|
||||
input.target.entity_id.clone(),
|
||||
input.target.slot.clone(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|segment| sanitize_audio_path_segment(segment.as_str(), "audio"))
|
||||
.collect(),
|
||||
file_name,
|
||||
content_type: Some(input.audio.mime_type),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: build_audio_asset_metadata(
|
||||
input.owner_user_id.as_str(),
|
||||
input.target.profile_id.as_deref(),
|
||||
&input.target,
|
||||
input.task_kind,
|
||||
),
|
||||
body: input.audio.bytes,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_audio_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
profile_id: Option<&str>,
|
||||
target: &GeneratedAudioPersistTarget,
|
||||
task_kind: AudioTaskKind,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut metadata = BTreeMap::from([
|
||||
("asset-kind".to_string(), target.asset_kind.clone()),
|
||||
("owner-user-id".to_string(), owner_user_id.to_string()),
|
||||
("entity-kind".to_string(), target.entity_kind.clone()),
|
||||
("entity-id".to_string(), target.entity_id.clone()),
|
||||
("slot".to_string(), target.slot.clone()),
|
||||
("provider".to_string(), task_kind.provider().to_string()),
|
||||
]);
|
||||
if let Some(profile_id) = profile_id {
|
||||
metadata.insert("profile-id".to_string(), profile_id.to_string());
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn sanitize_audio_path_segment(raw: &str, fallback: &str) -> String {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let collapsed = normalized
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if collapsed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
collapsed.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
94
server-rs/crates/platform-audio/src/request.rs
Normal file
94
server-rs/crates/platform-audio/src/request.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
AudioError, BackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS,
|
||||
SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VIDU_AUDIO_MODEL,
|
||||
VIDU_PROMPT_MAX_CHARS,
|
||||
};
|
||||
|
||||
pub fn build_background_music_task_body(
|
||||
request: BackgroundMusicTaskRequest,
|
||||
) -> Result<Value, AudioError> {
|
||||
let prompt =
|
||||
normalize_limited_text_allow_empty(&request.prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
|
||||
let title = normalize_limited_text(&request.title, "title", SUNO_TITLE_MAX_CHARS)?;
|
||||
let tags = request
|
||||
.tags
|
||||
.as_deref()
|
||||
.map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS))
|
||||
.transpose()?;
|
||||
let model = normalize_optional_text(request.model.as_deref())
|
||||
.unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
|
||||
|
||||
let mut body = Map::from_iter([
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("mv".to_string(), Value::String(model)),
|
||||
("title".to_string(), Value::String(title)),
|
||||
("task".to_string(), Value::String("generate".to_string())),
|
||||
(
|
||||
"make_instrumental".to_string(),
|
||||
Value::Bool(request.instrumental),
|
||||
),
|
||||
]);
|
||||
if let Some(tags) = tags {
|
||||
body.insert("tags".to_string(), Value::String(tags));
|
||||
}
|
||||
Ok(Value::Object(body))
|
||||
}
|
||||
|
||||
pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result<Value, AudioError> {
|
||||
let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
|
||||
let duration = request.duration.clamp(2, 10);
|
||||
let mut body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(VIDU_AUDIO_MODEL.to_string()),
|
||||
),
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("duration".to_string(), json!(duration)),
|
||||
]);
|
||||
if let Some(seed) = request.seed {
|
||||
body.insert("seed".to_string(), json!(seed));
|
||||
}
|
||||
Ok(Value::Object(body))
|
||||
}
|
||||
|
||||
pub fn normalize_limited_text(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
max_chars: usize,
|
||||
) -> Result<String, AudioError> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err(AudioError::invalid_request(format!("{field} 不能为空")));
|
||||
}
|
||||
if normalized.chars().count() > max_chars {
|
||||
return Err(AudioError::invalid_request(format!(
|
||||
"{field} 超过 {} 字符",
|
||||
max_chars
|
||||
)));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn normalize_limited_text_allow_empty(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
max_chars: usize,
|
||||
) -> Result<String, AudioError> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.chars().count() > max_chars {
|
||||
return Err(AudioError::invalid_request(format!(
|
||||
"{field} 超过 {} 字符",
|
||||
max_chars
|
||||
)));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
125
server-rs/crates/platform-audio/src/response.rs
Normal file
125
server-rs/crates/platform-audio/src/response.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn normalize_task_status(status: &str) -> String {
|
||||
let normalized = status.trim().to_ascii_lowercase().replace(' ', "_");
|
||||
match normalized.as_str() {
|
||||
"finish" | "finished" | "complete" | "completed" | "success" | "succeeded" => {
|
||||
"completed".to_string()
|
||||
}
|
||||
"" => "processing".to_string(),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_pending_task_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
"created" | "pending" | "queued" | "processing" | "running" | "submitted" | "started"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_failed_task_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
"failed" | "error" | "canceled" | "cancelled" | "rejected" | "expired"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn extract_audio_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_audio_url_strings(payload, &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if key.eq_ignore_ascii_case(target_key)
|
||||
&& let Some(text) = value.as_str()
|
||||
{
|
||||
return Some(text.trim().to_string());
|
||||
}
|
||||
if let Some(found) = find_first_string_by_key(value, target_key) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|item| find_first_string_by_key(item, target_key)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_string_by_path(value: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for key in path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
current.as_str().map(str::trim).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn collect_audio_url_strings(value: &Value, output: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if let Some(raw) = value.as_str()
|
||||
&& looks_like_audio_url_key(key)
|
||||
&& looks_like_http_url(raw)
|
||||
{
|
||||
output.push(raw.trim().to_string());
|
||||
}
|
||||
collect_audio_url_strings(value, output);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_audio_url_strings(item, output);
|
||||
}
|
||||
}
|
||||
Value::String(raw) if looks_like_http_url(raw) && looks_like_audio_url(raw) => {
|
||||
output.push(raw.trim().to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_audio_url_key(key: &str) -> bool {
|
||||
let normalized = key.trim().to_ascii_lowercase();
|
||||
normalized.contains("audio")
|
||||
|| normalized.contains("wav")
|
||||
|| normalized.contains("mp3")
|
||||
|| normalized.contains("fileurl")
|
||||
|| normalized == "url"
|
||||
|| normalized.ends_with("_url")
|
||||
|| normalized.ends_with("url")
|
||||
}
|
||||
|
||||
fn looks_like_http_url(value: &str) -> bool {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
value.starts_with("http://") || value.starts_with("https://")
|
||||
}
|
||||
|
||||
fn looks_like_audio_url(value: &str) -> bool {
|
||||
let value = value
|
||||
.trim()
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
value.ends_with(".mp3")
|
||||
|| value.ends_with(".wav")
|
||||
|| value.ends_with(".m4a")
|
||||
|| value.ends_with(".aac")
|
||||
|| value.ends_with(".ogg")
|
||||
|| value.ends_with(".webm")
|
||||
|| value.ends_with(".flac")
|
||||
}
|
||||
87
server-rs/crates/platform-audio/src/types.rs
Normal file
87
server-rs/crates/platform-audio/src/types.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AudioTaskKind {
|
||||
BackgroundMusic,
|
||||
SoundEffect,
|
||||
}
|
||||
|
||||
impl AudioTaskKind {
|
||||
pub fn provider(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER,
|
||||
Self::SoundEffect => VECTOR_ENGINE_VIDU_PROVIDER,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit_path(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => "/suno/submit/music",
|
||||
Self::SoundEffect => "/ent/v2/text2audio",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_path(self, task_id: &str) -> String {
|
||||
match self {
|
||||
Self::BackgroundMusic => format!("/suno/fetch/{}", urlencoding::encode(task_id)),
|
||||
Self::SoundEffect => {
|
||||
format!("/ent/v2/tasks/{}/creations", urlencoding::encode(task_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_stem(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => "background-music",
|
||||
Self::SoundEffect => "sound-effect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BackgroundMusicTaskRequest {
|
||||
pub prompt: String,
|
||||
pub title: String,
|
||||
pub tags: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub instrumental: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SoundEffectTaskRequest {
|
||||
pub prompt: String,
|
||||
pub duration: u8,
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioTaskResponse {
|
||||
pub kind: AudioTaskKind,
|
||||
pub task_id: String,
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VectorEngineAudioSettings {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DownloadedAudio {
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
pub const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno";
|
||||
pub const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu";
|
||||
pub const SUNO_DEFAULT_MODEL: &str = "chirp-v4";
|
||||
pub const VIDU_AUDIO_MODEL: &str = "audio1.0";
|
||||
pub const SUNO_PROMPT_MAX_CHARS: usize = 5_000;
|
||||
pub const SUNO_TITLE_MAX_CHARS: usize = 80;
|
||||
pub const SUNO_TAGS_MAX_CHARS: usize = 160;
|
||||
pub const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
|
||||
pub const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5;
|
||||
pub const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024;
|
||||
84
server-rs/crates/platform-audio/tests/vector_engine_audio.rs
Normal file
84
server-rs/crates/platform-audio/tests/vector_engine_audio.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use platform_audio::{
|
||||
AudioTaskKind, BackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, VIDU_PROMPT_MAX_CHARS,
|
||||
audio_mime_to_extension, build_background_music_task_body, build_sound_effect_task_body,
|
||||
extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_audio_mime_type,
|
||||
normalize_task_status,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalizes_audio_mime_type_from_content_type_and_url() {
|
||||
assert_eq!(
|
||||
normalize_audio_mime_type("audio/x-wav; charset=utf-8", "https://x/a.bin"),
|
||||
"audio/wav"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_audio_mime_type("application/octet-stream", "https://x/a.m4a?token=1"),
|
||||
"audio/mp4"
|
||||
);
|
||||
assert_eq!(audio_mime_to_extension("audio/mp4"), "m4a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_nested_audio_urls() {
|
||||
let payload = json!({
|
||||
"Response": {
|
||||
"Status": "FINISH",
|
||||
"Task": {
|
||||
"Output": {
|
||||
"FileInfos": [
|
||||
{ "FileUrl": "https://cdn.example.test/audio.wav" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
extract_audio_urls(&payload),
|
||||
vec!["https://cdn.example.test/audio.wav".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_task_status_is_stable() {
|
||||
assert_eq!(normalize_task_status("FINISH"), "completed");
|
||||
assert!(is_pending_task_status("processing"));
|
||||
assert!(is_failed_task_status("failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_request_body_uses_default_model_and_optional_instrumental_flag() {
|
||||
let body = build_background_music_task_body(BackgroundMusicTaskRequest {
|
||||
prompt: " 风里的木琴 ".to_string(),
|
||||
title: " 林间 ".to_string(),
|
||||
tags: Some(" warm, wood ".to_string()),
|
||||
model: None,
|
||||
instrumental: true,
|
||||
})
|
||||
.expect("request body should be valid");
|
||||
|
||||
assert_eq!(body["prompt"], "风里的木琴");
|
||||
assert_eq!(body["title"], "林间");
|
||||
assert_eq!(body["tags"], "warm, wood");
|
||||
assert_eq!(body["mv"], SUNO_DEFAULT_MODEL);
|
||||
assert_eq!(body["task"], "generate");
|
||||
assert_eq!(body["make_instrumental"], true);
|
||||
assert_eq!(
|
||||
AudioTaskKind::BackgroundMusic.provider(),
|
||||
"vector-engine-suno"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_effect_request_rejects_overlong_prompt() {
|
||||
let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1);
|
||||
let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest {
|
||||
prompt,
|
||||
duration: 5,
|
||||
seed: None,
|
||||
})
|
||||
.expect_err("long prompt should fail");
|
||||
|
||||
assert!(error.message().contains("prompt 超过"));
|
||||
}
|
||||
Reference in New Issue
Block a user