Files
Genarrative/server-rs/crates/platform-audio/src/response.rs
T
JenkenB b04b6cee2b 修复画板生成面板与音乐音效链路
统一画板生成类面板的参数选项、热区交互、底部按钮和输入框布局。

新增并完善画板生成音乐入口、音效和背景音乐面板以及音频生成提交流程。

按 VectorEngine Suno 音效文档调整音效请求体、任务 ID 解析、轮询和音频地址解析。

补充 Seedance 视频生成版权限制报错的中文提示。

更新编辑器相关技术文档和回归测试。
2026-06-18 20:24:52 +08:00

223 lines
6.5 KiB
Rust

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 extract_suno_clip_ids(payload: &Value) -> Vec<String> {
let mut clip_ids = Vec::new();
if let Some(data) = payload.get("data") {
collect_suno_clip_ids_from_data(data, &mut clip_ids);
}
let mut deduped = Vec::new();
for clip_id in clip_ids {
if !deduped.contains(&clip_id) {
deduped.push(clip_id);
}
}
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_suno_clip_ids_from_data(value: &Value, output: &mut Vec<String>) {
match value {
Value::Object(object) => {
for (key, value) in object {
// 中文注释:对象里的普通状态 / 任务字段不能按裸字符串当作 clip id,只接受明确的音频 id 字段。
if let Some(raw) = value.as_str()
&& looks_like_suno_clip_id_key(key)
&& is_plausible_suno_clip_id(raw)
{
output.push(raw.trim().to_string());
}
if value.is_object() || value.is_array() {
collect_suno_clip_ids_from_data(value, output);
}
}
}
Value::Array(items) => {
for item in items {
collect_suno_clip_ids_from_data(item, output);
}
}
Value::String(raw) if is_plausible_suno_clip_id(raw) => {
output.push(raw.trim().to_string());
}
_ => {}
}
}
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_suno_clip_id_key(key: &str) -> bool {
let normalized = key.trim().to_ascii_lowercase().replace(['_', '-'], "");
matches!(normalized.as_str(), "id" | "clipid" | "audioid" | "songid")
}
fn is_plausible_suno_clip_id(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& value.len() <= 180
&& !looks_like_http_url(value)
&& !value.chars().any(char::is_whitespace)
}
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")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extracts_suno_clip_ids_from_object_or_array_data() {
let payload = json!({
"code": "success",
"data": [
{ "id": "clip-a", "status": "complete" },
{ "clip_id": "clip-b", "audio_url": "" },
{ "task_id": "task-should-not-be-treated-as-clip" },
{ "nested": { "audioId": "clip-c" } }
]
});
assert_eq!(
extract_suno_clip_ids(&payload),
vec![
"clip-a".to_string(),
"clip-b".to_string(),
"clip-c".to_string()
]
);
}
#[test]
fn extracts_suno_clip_id_from_string_data() {
let payload = json!({
"code": 0,
"data": "clip-only"
});
assert_eq!(
extract_suno_clip_ids(&payload),
vec!["clip-only".to_string()]
);
}
}