实现 SFX V2 T1 契约与提示词基础

统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界
固化 52 个音效预设、追加规则及跨语言测试向量
演进共享音频请求响应、duration、Loop 和 V2 metadata 契约
增加 External model 输入矩阵纯函数并保持正式接线归属 T5
补齐前端提交、读取、深拷贝与请求边界回归测试
更新共享计划并标记 T1 完成且当前不可发布
This commit is contained in:
2026-08-06 13:05:48 +00:00
parent 6fd731f56c
commit cd80c085ff
24 changed files with 1559 additions and 97 deletions
@@ -1028,7 +1028,8 @@ impl EditorAgentTool for GenerateSoundEffectTool {
let payload = EditorSoundEffectGenerateRequest {
prompt: args.prompt,
model: Some(args.model),
duration: args.duration,
duration: Some(f64::from(args.duration)),
loop_enabled: false,
project_id: Some(context.conversation.project_id.clone()),
canvas_completion: Some(build_editor_agent_canvas_completion(
context.project,
@@ -768,6 +768,32 @@ pub async fn generate_external_editor_video(
Ok(external_generation_accepted_response(&request_context, job))
}
/// T1 先冻结 External v1 的 model 输入矩阵;实际在定价、预扣和入队前接线属于 T5。
#[allow(dead_code)]
fn canonicalize_external_editor_sound_effect_model(
value: Option<&str>,
) -> Result<&'static str, AppError> {
let normalized = value
.map(|value| value.trim_matches(char::is_whitespace))
.filter(|value| !value.is_empty());
match normalized {
None => Ok(shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL),
Some(value) if value == shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL => {
Ok(shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL)
}
Some(_) => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"field": "model",
"message": format!(
"model 只支持 {}",
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL
),
})),
),
}
}
pub async fn generate_external_editor_sound_effect(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1025,6 +1051,39 @@ fn serialize_external_editor_image_sequence_frames(
mod tests {
use super::*;
#[test]
fn external_sound_effect_model_canonicalizer_freezes_the_t1_matrix() {
for accepted in [
None,
Some(""),
Some(" \t\r\n"),
Some("\u{0085}\u{2003}\u{00a0}"),
Some("eleven_text_to_sound_v2"),
Some("\u{0085} eleven_text_to_sound_v2 \u{2003}"),
] {
assert_eq!(
canonicalize_external_editor_sound_effect_model(accepted)
.expect("accepted model form should canonicalize"),
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL,
"accepted={accepted:?}"
);
}
for rejected in [
"audio1.0",
"AUDIO1.0",
"Eleven_text_to_sound_v2",
"eleven_text_to_sound_v3",
"\u{200b}",
"\u{feff}",
] {
let error = canonicalize_external_editor_sound_effect_model(Some(rejected))
.expect_err("legacy and unknown non-empty models should be rejected");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("model"));
}
}
const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [
"ExternalEditorAssetCreateRequest",
"ExternalEditorProjectResourceCreateRequest",
@@ -100,13 +100,10 @@ pub(super) fn normalize_editor_sound_effect_request_with_pricing(
) -> Result<NormalizedEditorSoundEffectRequest, AppError> {
let model = normalize_editor_sound_model(payload.model.as_deref())?;
let expected_price_mud_points = pricing.sound_effect_model_mud_points(Some(model.as_str()));
let prompt = platform_audio::validate_sound_effect_prompt(&payload.prompt)
.map_err(map_platform_audio_error)?;
Ok(NormalizedEditorSoundEffectRequest {
prompt: platform_audio::normalize_limited_text(
&payload.prompt,
"prompt",
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?,
prompt: prompt.prompt.to_string(),
model,
duration: normalize_editor_sound_duration(payload.duration)?,
price_mud_points: expected_price_mud_points,
@@ -124,9 +121,12 @@ fn normalize_editor_sound_model(value: Option<&str>) -> Result<String, AppError>
))
}
fn normalize_editor_sound_duration(value: u8) -> Result<u8, AppError> {
if (2..=10).contains(&value) {
return Ok(value);
fn normalize_editor_sound_duration(value: Option<f64>) -> Result<u8, AppError> {
let value = value.unwrap_or(f64::from(
platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
));
if value.is_finite() && value.fract() == 0.0 && (2.0..=10.0).contains(&value) {
return Ok(value as u8);
}
Err(editor_audio_bad_request("音效 duration 必须在 2-10 秒之间"))
}
@@ -406,9 +406,12 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
prompt: normalized.prompt.clone(),
actual_prompt: Some(normalized.prompt),
model: normalized.model,
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "sound-effect".to_string(),
duration_seconds: None,
loop_enabled: None,
project: completed_project,
resource,
asset,
@@ -550,9 +553,12 @@ fn build_editor_background_music_generate_response(
prompt: normalized.gpt_description_prompt.clone(),
actual_prompt: Some(normalized.gpt_description_prompt),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "background-music".to_string(),
duration_seconds: None,
loop_enabled: None,
project,
resource,
asset,
@@ -801,7 +807,8 @@ mod tests {
assets::EditorSoundEffectGenerateRequest {
prompt: prompt.into(),
model: model.map(str::to_string),
duration,
duration: Some(f64::from(duration)),
loop_enabled: false,
project_id: Some("project-1".to_string()),
canvas_completion: None,
generation_inputs: None,
@@ -964,13 +971,13 @@ mod tests {
#[test]
fn editor_sound_effect_normalizer_enforces_prompt_and_duration_boundaries() {
let max_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS);
let max_prompt = "声".repeat(platform_audio::SOUND_EFFECT_PROMPT_MAX_CODE_POINTS);
let normalized = normalize_editor_sound_effect_request(sound_effect_payload(
format!(" {max_prompt} "),
None,
2,
))
.expect("1500 canonical code points and the 2-second boundary should pass");
.expect("2048 canonical code points and the 2-second boundary should pass");
assert_eq!(normalized.prompt, max_prompt);
assert_eq!(normalized.duration, 2);
@@ -979,10 +986,10 @@ mod tests {
.expect("the 10-second boundary should pass");
assert_eq!(normalized.duration, 10);
let overlong_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS + 1);
let overlong_prompt = "声".repeat(platform_audio::SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1);
let error =
normalize_editor_sound_effect_request(sound_effect_payload(overlong_prompt, None, 5))
.expect_err("1501 canonical code points should fail");
.expect_err("2049 canonical code points should fail");
assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST);
for duration in [1, 11] {