实现 SFX V2 T1 契约与提示词基础
统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界 固化 52 个音效预设、追加规则及跨语言测试向量 演进共享音频请求响应、duration、Loop 和 V2 metadata 契约 增加 External model 输入矩阵纯函数并保持正式接线归属 T5 补齐前端提交、读取、深拷贝与请求边界回归测试 更新共享计划并标记 T1 完成且当前不可发布
This commit is contained in:
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user