0e47a54a91
统一画布生成占位图标,按图片、规范、角色、图标、UI、视频、音频、宣发和快速编辑区分。 让图片、规范、宣发和视频新建占位按面板尺寸或固定输出尺寸创建,并在参数变化时同步占位大小。 完善快速编辑、音频生成、宣发素材和生成类面板相关契约与测试。 更新生成类面板 Lovart 改造文档和项目决策记录。
531 lines
17 KiB
Rust
531 lines
17 KiB
Rust
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"));
|
||
}
|
||
}
|