Files
Genarrative/server-rs/crates/platform-audio/src/client.rs
T
JenkenB 0e47a54a91 对齐编辑器生成占位与面板参数
统一画布生成占位图标,按图片、规范、角色、图标、UI、视频、音频、宣发和快速编辑区分。

让图片、规范、宣发和视频新建占位按面板尺寸或固定输出尺寸创建,并在参数变化时同步占位大小。

完善快速编辑、音频生成、宣发素材和生成类面板相关契约与测试。

更新生成类面板 Lovart 改造文档和项目决策记录。
2026-06-19 12:15:57 +08:00

531 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::error::Error;
use reqwest::header;
use serde_json::Value;
use crate::response::{
extract_audio_urls, extract_string_by_path, extract_suno_clip_ids, find_first_string_by_key,
is_failed_task_status, normalize_task_status,
};
use crate::{
AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SoundEffectTaskRequest,
VectorEngineAudioSettings, build_background_music_task_body,
build_editor_background_music_task_body, build_editor_sound_effect_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_submit_task_id(&response).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 = extract_submit_task_id(&response)
.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,
})
}
pub async fn submit_editor_background_music_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: EditorBackgroundMusicTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_editor_background_music_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::BackgroundMusic.submit_path(),
body,
"提交编辑器背景音乐任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response).ok_or_else(|| {
AudioError::missing_audio("提交编辑器背景音乐任务失败:上游未返回任务 ID")
})?;
Ok(AudioTaskResponse {
kind: AudioTaskKind::BackgroundMusic,
task_id,
provider: AudioTaskKind::BackgroundMusic.provider().to_string(),
status: "submitted".to_string(),
})
}
pub async fn submit_editor_sound_effect_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: EditorSoundEffectTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_editor_sound_effect_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::SoundEffect.submit_path(),
body,
"提交编辑器音效任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response)
.ok_or_else(|| AudioError::missing_audio("提交编辑器音效任务失败:上游未返回任务 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 音效任务失败",
AudioTaskKind::SunoSoundEffect => "查询 Suno 音效任务失败",
},
)
.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 matches!(
kind,
AudioTaskKind::BackgroundMusic | AudioTaskKind::SunoSoundEffect
) && audio_urls.is_empty()
{
let clip_ids = extract_suno_clip_ids(&task_payload);
let has_clip_ids = !clip_ids.is_empty();
let mut latest_wav_error = None;
for clip_id in clip_ids {
let wav_payload = match get_vector_engine_json(
http_client,
settings,
&format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())),
match kind {
AudioTaskKind::SunoSoundEffect => "获取 Suno 音效 wav 失败",
_ => "获取 Suno wav 音频失败",
},
)
.await
{
Ok(payload) => {
latest_wav_error = None;
payload
}
Err(error) => {
latest_wav_error = Some(error);
continue;
}
};
audio_urls = extract_audio_urls(&wav_payload);
if !audio_urls.is_empty() {
break;
}
}
// 中文注释:Suno 任务完成后可能先返回 clip id,/suno/act/wav/{clip_id}
// 可能返回 404/业务错误或 wav_file_url 仍为 null。此时不能把任务判成失败,
// 让上层轮询继续等待;真正失败仍交给 fetch 任务状态判断。
if let Some(status) = resolve_suno_status_after_wav_probe(
&status,
has_clip_ids,
&audio_urls,
latest_wav_error,
) {
return Ok((status, audio_urls));
}
}
Ok((status, audio_urls))
}
fn should_wait_for_suno_wav_audio(status: &str, has_clip_ids: bool, audio_urls: &[String]) -> bool {
audio_urls.is_empty() && has_clip_ids && !is_failed_task_status(status)
}
fn resolve_suno_status_after_wav_probe(
status: &str,
has_clip_ids: bool,
audio_urls: &[String],
_latest_wav_error: Option<AudioError>,
) -> Option<String> {
if should_wait_for_suno_wav_audio(status, has_clip_ids, audio_urls) {
return Some("processing".to_string());
}
None
}
async fn get_vector_engine_json(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
path: &str,
failure_context: &str,
) -> Result<Value, AudioError> {
let endpoint = vector_engine_audio_endpoint_url(settings, path);
let response = http_client
.get(endpoint.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, endpoint.as_str(), 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 endpoint = vector_engine_audio_endpoint_url(settings, path);
let response = http_client
.post(endpoint.as_str())
.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, endpoint.as_str(), error))?;
parse_vector_engine_response(response, failure_context).await
}
pub(crate) fn vector_engine_audio_endpoint_url(
settings: &VectorEngineAudioSettings,
path: &str,
) -> String {
let base_url = normalize_vector_engine_audio_base_url(settings.base_url.as_str());
let path = path.trim();
if path.starts_with('/') {
format!("{base_url}{path}")
} else {
format!("{base_url}/{path}")
}
}
fn normalize_vector_engine_audio_base_url(base_url: &str) -> String {
let normalized = base_url.trim().trim_end_matches('/');
// 中文注释:Suno / Vidu 音频接口在 VectorEngine 根路径下,不能拼成 /v1/suno/*。
normalized
.strip_suffix("/v1")
.unwrap_or(normalized)
.trim_end_matches('/')
.to_string()
}
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(
build_upstream_error_message(failure_context, status.as_u16(), raw_text.as_str()),
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")
&& !is_success_response_code(code)
{
return Err(AudioError::upstream(
extract_upstream_payload_message(&payload)
.unwrap_or_else(|| 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()
}
fn extract_submit_task_id(response: &Value) -> Option<String> {
// 中文注释:Suno 音频提交接口文档未声明响应结构,实际可能直接把任务 ID 放在 data 字符串或 data 对象中。
extract_string_by_path(response, &["data"])
.or_else(|| extract_string_by_path(response, &["data", "task_id"]))
.or_else(|| extract_string_by_path(response, &["data", "taskId"]))
.or_else(|| extract_string_by_path(response, &["data", "id"]))
.or_else(|| find_first_string_by_key(response, "task_id"))
.or_else(|| find_first_string_by_key(response, "taskId"))
.or_else(|| find_first_string_by_key(response, "id"))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn build_upstream_error_message(failure_context: &str, status: u16, raw_text: &str) -> String {
let excerpt = truncate_raw(raw_text);
if excerpt.trim().is_empty() {
return format!("{failure_context}:上游 HTTP {status}");
}
format!("{failure_context}:上游 HTTP {status}:{excerpt}")
}
fn is_success_response_code(code: &Value) -> bool {
match code {
Value::String(code) => matches!(
code.trim().to_ascii_lowercase().as_str(),
"success" | "succeeded" | "ok" | "0" | "200"
),
Value::Number(code) => code.as_i64().is_some_and(|code| code == 0 || code == 200),
_ => false,
}
}
fn extract_upstream_payload_message(payload: &Value) -> Option<String> {
payload
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
payload
.get("error")
.and_then(|value| match value {
Value::String(message) => Some(message.as_str()),
Value::Object(object) => object
.get("message")
.and_then(Value::as_str)
.or_else(|| object.get("detail").and_then(Value::as_str)),
_ => None,
})
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToOwned::to_owned)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(base_url: &str) -> VectorEngineAudioSettings {
VectorEngineAudioSettings {
base_url: base_url.to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
}
}
#[test]
fn audio_endpoint_urls_strip_v1_for_root_level_suno_paths() {
assert_eq!(
vector_engine_audio_endpoint_url(
&settings("https://api.vectorengine.cn"),
"/suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
assert_eq!(
vector_engine_audio_endpoint_url(
&settings("https://api.vectorengine.cn/v1"),
"/suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
assert_eq!(
vector_engine_audio_endpoint_url(
&settings(" https://api.vectorengine.cn/v1/ "),
"suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
}
#[test]
fn vector_engine_audio_success_codes_accept_numeric_and_string_values() {
for code in [
Value::String("success".to_string()),
Value::String("200".to_string()),
Value::String("0".to_string()),
Value::from(200),
Value::from(0),
] {
assert!(is_success_response_code(&code), "{code}");
}
assert!(!is_success_response_code(&Value::String(
"invalid_request".to_string()
)));
}
#[test]
fn submit_task_id_extracts_suno_data_string_and_nested_ids() {
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"code": 200,
"data": "suno-task-1"
})),
Some("suno-task-1".to_string())
);
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"code": 200,
"data": {
"id": "suno-task-2"
}
})),
Some("suno-task-2".to_string())
);
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"taskId": "legacy-task-1"
})),
Some("legacy-task-1".to_string())
);
}
#[test]
fn suno_clip_ids_without_wav_url_keep_task_pending() {
assert!(should_wait_for_suno_wav_audio(
"completed",
true,
&Vec::new()
));
assert!(!should_wait_for_suno_wav_audio("failed", true, &Vec::new()));
assert!(!should_wait_for_suno_wav_audio(
"completed",
true,
&["https://cdn.example.test/audio.wav".to_string()]
));
}
#[test]
fn suno_wav_probe_error_keeps_completed_task_pending() {
let status = resolve_suno_status_after_wav_probe(
"completed",
true,
&Vec::new(),
Some(AudioError::upstream(
"获取 Suno 音效 wav 失败",
502,
r#"{"message":"wav not ready"}"#,
)),
);
assert_eq!(status.as_deref(), Some("processing"));
}
}