b04b6cee2b
统一画板生成类面板的参数选项、热区交互、底部按钮和输入框布局。 新增并完善画板生成音乐入口、音效和背景音乐面板以及音频生成提交流程。 按 VectorEngine Suno 音效文档调整音效请求体、任务 ID 解析、轮询和音频地址解析。 补充 Seedance 视频生成版权限制报错的中文提示。 更新编辑器相关技术文档和回归测试。
223 lines
6.5 KiB
Rust
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()]
|
|
);
|
|
}
|
|
}
|