diff --git a/docs/project-memory/plans/【实施计划】SFX生成优化V2.0任务拆解-2026-08-06.md b/docs/project-memory/plans/【实施计划】SFX生成优化V2.0任务拆解-2026-08-06.md index 3050177d0..0994edf93 100644 --- a/docs/project-memory/plans/【实施计划】SFX生成优化V2.0任务拆解-2026-08-06.md +++ b/docs/project-memory/plans/【实施计划】SFX生成优化V2.0任务拆解-2026-08-06.md @@ -2,7 +2,7 @@ 日期:`2026-08-06` -状态:`T0 已通过,可以进入 T1` +状态:`T1 已完成,可以进入 T2、T3、T4;当前仍不是可发布切点` 开发分支:`feat/sound_opt` @@ -135,6 +135,8 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏 - 原地演进现有 TypeScript / Rust 音频 DTO:fixed model、nullable 小数 duration、Loop、实际时长和 V2 metadata;不得新增同义 DTO 或平行正式生成契约。 - 实现 External `model` canonicalizer 的纯函数与矩阵测试;实际 OpenAPI / handler 接线属于 T5。 +实施记录(`2026-08-06`):T1 已完成。前后端共享 canonicalization fixture 已锁定 Unicode 边界与 `2048 / 2049` 行为;52 个预设、最小优化 DTO、固定模型、nullable duration、Loop 默认值、响应结果字段和强类型 V2 metadata 已落地。duration 纯校验接受自动 `null` 与手动 `0.5–30s`,拒绝非有限值和越界值。External `model` 当前只落地纯 canonicalizer 与输入矩阵测试,正式定价、预扣、enqueue、OpenAPI 和副作用测试仍严格归属 T5;在 T5 完成前不得发布当前中间态。 + ### T2:一键优化 BFF 和 Worker 翻译 service - 增加登录态 SFX Prompt 优化 BFF,固定 Luna + Medium + `max_output_tokens = 8192` completion 总预算,32 KiB body limit,严格唯一 JSON envelope,不调用音频 provider 或正式计费。 diff --git a/packages/shared/src/contracts/editorAudio.ts b/packages/shared/src/contracts/editorAudio.ts index 17e80a94f..c0373be3b 100644 --- a/packages/shared/src/contracts/editorAudio.ts +++ b/packages/shared/src/contracts/editorAudio.ts @@ -6,3 +6,38 @@ export type BackgroundMusicPromptAssistResponse = { prompt: string; charCount: number; }; + +export type SoundEffectPromptOptimizeRequest = { + currentPrompt: string; +}; + +export type SoundEffectPromptOptimizeResponse = { + prompt: string; + charCount: number; +}; + +export const EDITOR_SOUND_EFFECT_MODEL = 'eleven_text_to_sound_v2' as const; +export const SOUND_EFFECT_DURATION_MIN_SECONDS = 0.5; +export const SOUND_EFFECT_DURATION_MAX_SECONDS = 30; + +export type EditorSoundEffectModel = typeof EDITOR_SOUND_EFFECT_MODEL; + +export type EditorSoundEffectDurationMode = 'auto' | 'manual'; + +export type EditorSoundEffectGenerationRequest = { + prompt: string; + model: EditorSoundEffectModel; + duration?: number | null; + loop?: boolean; +}; + +export type EditorSoundEffectGenerationMetadataV2 = { + schemaVersion: 2; + userPrompt: string; + actualPrompt: string; + model: EditorSoundEffectModel; + durationMode: EditorSoundEffectDurationMode; + requestedDurationSeconds: number | null; + actualDurationSeconds: number; + loop: boolean; +}; diff --git a/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json b/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json new file mode 100644 index 000000000..5919b4387 --- /dev/null +++ b/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json @@ -0,0 +1,50 @@ +[ + { + "name": "plain-ascii-boundary-space", + "input": " thunder crack ", + "prompt": "thunder crack", + "charCount": 13 + }, + { + "name": "unicode-white-space-boundary", + "input": "\u0085\u2003金币叮当\u00a0\r\n", + "prompt": "金币叮当", + "charCount": 4 + }, + { + "name": "internal-white-space-preserved", + "input": "\u2003A \n B\u0085", + "prompt": "A \n B", + "charCount": 5 + }, + { + "name": "zero-width-and-bom-preserved", + "input": "\u0085\u200b\ufeff\u0085", + "prompt": "\u200b\ufeff", + "charCount": 2 + }, + { + "name": "combining-mark-preserved", + "input": "\u2003e\u0301\u00a0", + "prompt": "e\u0301", + "charCount": 2 + }, + { + "name": "zwj-emoji-counts-code-points", + "input": "\n👩‍💻\r", + "prompt": "👩‍💻", + "charCount": 3 + }, + { + "name": "unicode-white-space-only", + "input": "\u0085\u2003\u00a0\r\n", + "prompt": "", + "charCount": 0 + }, + { + "name": "empty", + "input": "", + "prompt": "", + "charCount": 0 + } +] diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index a14b96efd..522a594bc 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -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, diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ea4761c12..4600201dc 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -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, Extension(request_context): Extension, @@ -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", diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 2ddb3fd1a..b85a60baf 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -100,13 +100,10 @@ pub(super) fn normalize_editor_sound_effect_request_with_pricing( ) -> Result { 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 )) } -fn normalize_editor_sound_duration(value: u8) -> Result { - if (2..=10).contains(&value) { - return Ok(value); +fn normalize_editor_sound_duration(value: Option) -> Result { + 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] { diff --git a/server-rs/crates/platform-audio/src/lib.rs b/server-rs/crates/platform-audio/src/lib.rs index 1a1af2d55..c2f7c5ca2 100644 --- a/server-rs/crates/platform-audio/src/lib.rs +++ b/server-rs/crates/platform-audio/src/lib.rs @@ -5,6 +5,7 @@ mod error; mod persist; mod request; mod response; +mod sound_effect_prompt; mod types; pub use background_music_prompt::{ @@ -30,6 +31,12 @@ pub use request::{ pub use response::{ extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status, }; +pub use sound_effect_prompt::{ + SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS, + SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, ValidatedSoundEffectPrompt, + canonicalize_sound_effect_prompt, sound_effect_prompt_code_point_count, + validate_sound_effect_duration_seconds, validate_sound_effect_prompt, +}; pub use types::{ AudioTaskKind, AudioTaskResponse, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS, BackgroundMusicTaskRequest, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, diff --git a/server-rs/crates/platform-audio/src/sound_effect_prompt.rs b/server-rs/crates/platform-audio/src/sound_effect_prompt.rs new file mode 100644 index 000000000..c89137fff --- /dev/null +++ b/server-rs/crates/platform-audio/src/sound_effect_prompt.rs @@ -0,0 +1,56 @@ +use crate::AudioError; + +pub const SOUND_EFFECT_PROMPT_MAX_CODE_POINTS: usize = 2048; +pub const SOUND_EFFECT_DURATION_MIN_SECONDS: f64 = 0.5; +pub const SOUND_EFFECT_DURATION_MAX_SECONDS: f64 = 30.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ValidatedSoundEffectPrompt<'a> { + pub prompt: &'a str, + pub char_count: usize, +} + +/// 只删除首尾 Unicode `White_Space`,与前端 SFX canonicalizer 保持一致。 +/// +/// Rust `char::is_whitespace` 覆盖 U+0085 等 White_Space,同时不会误删 +/// U+200B 与 U+FEFF。内部空白和其它 Unicode scalar value 均保持原样。 +pub fn canonicalize_sound_effect_prompt(prompt: &str) -> &str { + prompt.trim_matches(char::is_whitespace) +} + +pub fn sound_effect_prompt_code_point_count(prompt: &str) -> usize { + canonicalize_sound_effect_prompt(prompt).chars().count() +} + +pub fn validate_sound_effect_prompt( + prompt: &str, +) -> Result, AudioError> { + let prompt = canonicalize_sound_effect_prompt(prompt); + let char_count = prompt.chars().count(); + if char_count == 0 { + return Err(AudioError::invalid_request("prompt 不能为空")); + } + if char_count > SOUND_EFFECT_PROMPT_MAX_CODE_POINTS { + return Err(AudioError::invalid_request(format!( + "prompt 超过 {SOUND_EFFECT_PROMPT_MAX_CODE_POINTS} 字符" + ))); + } + Ok(ValidatedSoundEffectPrompt { prompt, char_count }) +} + +pub fn validate_sound_effect_duration_seconds( + duration_seconds: Option, +) -> Result, AudioError> { + let Some(duration_seconds) = duration_seconds else { + return Ok(None); + }; + if duration_seconds.is_finite() + && (SOUND_EFFECT_DURATION_MIN_SECONDS..=SOUND_EFFECT_DURATION_MAX_SECONDS) + .contains(&duration_seconds) + { + return Ok(Some(duration_seconds)); + } + Err(AudioError::invalid_request(format!( + "duration 必须在 {SOUND_EFFECT_DURATION_MIN_SECONDS}-{SOUND_EFFECT_DURATION_MAX_SECONDS} 秒之间" + ))) +} diff --git a/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs b/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs new file mode 100644 index 000000000..543b1393f --- /dev/null +++ b/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs @@ -0,0 +1,110 @@ +use platform_audio::{ + SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS, + SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, canonicalize_sound_effect_prompt, + sound_effect_prompt_code_point_count, validate_sound_effect_duration_seconds, + validate_sound_effect_prompt, +}; +use serde_json::Value; + +fn shared_canonicalization_fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json" + )) + .expect("shared SFX prompt canonicalization fixture should be valid JSON") +} + +#[test] +fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() { + for case in shared_canonicalization_fixture() + .as_array() + .expect("fixture should be an array") + { + let name = case["name"] + .as_str() + .expect("fixture name should be a string"); + let input = case["input"] + .as_str() + .expect("fixture input should be a string"); + let expected_prompt = case["prompt"] + .as_str() + .expect("fixture prompt should be a string"); + let expected_char_count = case["charCount"] + .as_u64() + .expect("fixture charCount should be an unsigned integer") + as usize; + + let canonical_prompt = canonicalize_sound_effect_prompt(input); + assert_eq!(canonical_prompt, expected_prompt, "fixture={name}"); + assert_eq!( + sound_effect_prompt_code_point_count(input), + expected_char_count, + "fixture={name}" + ); + assert_eq!( + canonicalize_sound_effect_prompt(canonical_prompt), + canonical_prompt, + "canonicalization should be idempotent for fixture={name}" + ); + } +} + +#[test] +fn sound_effect_prompt_uses_the_2048_code_point_boundary_without_truncation() { + let maximum = format!( + "\u{0085}{}\u{2003}", + "😀".repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS) + ); + let accepted = validate_sound_effect_prompt(&maximum) + .expect("2048 canonical code points should be accepted"); + assert_eq!(accepted.char_count, SOUND_EFFECT_PROMPT_MAX_CODE_POINTS); + assert_eq!( + accepted.prompt, + "😀".repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS) + ); + + let overlong = "声".repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1); + let error = validate_sound_effect_prompt(&overlong) + .expect_err("2049 canonical code points should be rejected"); + assert!(error.message().contains("超过 2048 字符")); + assert_eq!( + overlong.chars().count(), + SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1 + ); +} + +#[test] +fn sound_effect_prompt_rejects_empty_canonical_values_without_a_default() { + for prompt in ["", " \t\r\n", "\u{0085}\u{2003}\u{00a0}"] { + let error = validate_sound_effect_prompt(prompt) + .expect_err("empty canonical SFX prompt should be rejected"); + assert_eq!(error.message(), "prompt 不能为空"); + } +} + +#[test] +fn sound_effect_duration_accepts_auto_and_frozen_boundaries() { + assert_eq!( + validate_sound_effect_duration_seconds(None).expect("auto duration should pass"), + None + ); + for duration_seconds in [ + SOUND_EFFECT_DURATION_MIN_SECONDS, + 7.5, + SOUND_EFFECT_DURATION_MAX_SECONDS, + ] { + assert_eq!( + validate_sound_effect_duration_seconds(Some(duration_seconds)) + .expect("manual duration boundary should pass"), + Some(duration_seconds) + ); + } +} + +#[test] +fn sound_effect_duration_rejects_non_finite_and_out_of_range_values() { + for duration_seconds in [0.49, 30.01, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let error = validate_sound_effect_duration_seconds(Some(duration_seconds)) + .expect_err("invalid manual duration should fail"); + assert!(error.message().contains("duration 必须在 0.5-30 秒之间")); + } +} diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 53c65b40c..b6874d524 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -513,13 +513,39 @@ pub struct EditorIconSpritesheetGenerateResponse { pub price_mud_points: u32, } +pub const EDITOR_SOUND_EFFECT_MODEL: &str = "eleven_text_to_sound_v2"; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum EditorSoundEffectDurationMode { + Auto, + Manual, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorSoundEffectGenerationMetadataV2 { + pub schema_version: u8, + pub user_prompt: String, + pub actual_prompt: String, + pub model: String, + pub duration_mode: EditorSoundEffectDurationMode, + pub requested_duration_seconds: Option, + pub actual_duration_seconds: f64, + #[serde(rename = "loop")] + pub loop_enabled: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorSoundEffectGenerateRequest { pub prompt: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - pub duration: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(default, rename = "loop")] + pub loop_enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -562,6 +588,19 @@ pub struct BackgroundMusicPromptAssistResponse { pub char_count: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SoundEffectPromptOptimizeRequest { + pub current_prompt: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SoundEffectPromptOptimizeResponse { + pub prompt: String, + pub char_count: u32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAudioGenerateResponse { @@ -578,10 +617,15 @@ pub struct EditorAudioGenerateResponse { #[serde(skip_serializing_if = "Option::is_none")] pub actual_prompt: Option, pub model: String, + pub provider: String, pub task_id: String, pub price_mud_points: u32, pub audio_kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_seconds: Option, + #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")] + pub loop_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub resource: Option, @@ -1387,8 +1431,9 @@ mod tests { fn editor_audio_requests_and_response_use_canvas_audio_shape() { let sound_payload = serde_json::to_value(EditorSoundEffectGenerateRequest { prompt: "金币掉落叮当声".to_string(), - model: Some("audio1.0".to_string()), - duration: 7, + model: Some(EDITOR_SOUND_EFFECT_MODEL.to_string()), + duration: Some(7.5), + loop_enabled: true, project_id: None, canvas_completion: None, generation_inputs: None, @@ -1397,8 +1442,9 @@ mod tests { }) .expect("sound request should serialize"); assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声")); - assert_eq!(sound_payload["model"], json!("audio1.0")); - assert_eq!(sound_payload["duration"], json!(7)); + assert_eq!(sound_payload["model"], json!(EDITOR_SOUND_EFFECT_MODEL)); + assert_eq!(sound_payload["duration"], json!(7.5)); + assert_eq!(sound_payload["loop"], json!(true)); assert_eq!(sound_payload["assetFolderId"], json!("project")); assert_eq!(sound_payload["assetLabel"], json!("游戏音效 1")); assert!(sound_payload.get("priceMudPoints").is_none()); @@ -1410,12 +1456,14 @@ mod tests { serde_json::from_value(sound_payload) .expect("sound request with duration should deserialize"); assert_eq!(parsed_sound_payload.prompt, "金币掉落叮当声"); - assert_eq!(parsed_sound_payload.duration, 7); + assert_eq!(parsed_sound_payload.duration, Some(7.5)); + assert!(parsed_sound_payload.loop_enabled); let unset_model_payload = serde_json::to_value(EditorSoundEffectGenerateRequest { prompt: "按钮确认短促音".to_string(), model: None, - duration: 5, + duration: None, + loop_enabled: false, project_id: None, canvas_completion: None, generation_inputs: None, @@ -1424,7 +1472,23 @@ mod tests { }) .expect("sound request with unset model should serialize"); assert!(unset_model_payload.get("model").is_none()); - assert_eq!(unset_model_payload["duration"], json!(5)); + assert!(unset_model_payload.get("duration").is_none()); + assert_eq!(unset_model_payload["loop"], json!(false)); + + for nullable_payload in [ + json!({ "prompt": "按钮确认短促音" }), + json!({ + "prompt": "按钮确认短促音", + "model": null, + "duration": null, + }), + ] { + let parsed: EditorSoundEffectGenerateRequest = serde_json::from_value(nullable_payload) + .expect("omitted and explicit null fields should deserialize"); + assert_eq!(parsed.model, None); + assert_eq!(parsed.duration, None); + assert!(!parsed.loop_enabled); + } let music_payload = serde_json::to_value(EditorBackgroundMusicGenerateRequest { gpt_description_prompt: "森林冒险背景音乐".to_string(), @@ -1452,10 +1516,13 @@ mod tests { source_type: "generated".to_string(), prompt: "金币掉落叮当声".to_string(), actual_prompt: Some("金币掉落叮当声".to_string()), - model: "audio1.0".to_string(), + model: EDITOR_SOUND_EFFECT_MODEL.to_string(), + provider: "elevenlabs".to_string(), task_id: "sound-task-1".to_string(), price_mud_points: 10, audio_kind: "sound-effect".to_string(), + duration_seconds: Some(7.42), + loop_enabled: Some(true), project: None, resource: None, asset: Some(json!({ @@ -1481,14 +1548,95 @@ mod tests { ); assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1")); assert_eq!(response_payload["audioKind"], json!("sound-effect")); - assert!(response_payload.get("durationSeconds").is_none()); - assert!(response_payload.get("provider").is_none()); + assert_eq!(response_payload["durationSeconds"], json!(7.42)); + assert_eq!(response_payload["loop"], json!(true)); + assert_eq!(response_payload["provider"], json!("elevenlabs")); assert_eq!( response_payload["asset"]["assetKind"], json!("sound-effect") ); } + #[test] + fn editor_sound_effect_v2_metadata_uses_the_frozen_camel_case_shape() { + let metadata = EditorSoundEffectGenerationMetadataV2 { + schema_version: 2, + user_prompt: "金币落地,轻快可爱".to_string(), + actual_prompt: "A bright, cute coin landing chime".to_string(), + model: EDITOR_SOUND_EFFECT_MODEL.to_string(), + duration_mode: EditorSoundEffectDurationMode::Manual, + requested_duration_seconds: Some(5.0), + actual_duration_seconds: 5.12, + loop_enabled: false, + }; + let payload = serde_json::to_value(&metadata).expect("metadata should serialize"); + assert_eq!( + payload, + json!({ + "schemaVersion": 2, + "userPrompt": "金币落地,轻快可爱", + "actualPrompt": "A bright, cute coin landing chime", + "model": "eleven_text_to_sound_v2", + "durationMode": "manual", + "requestedDurationSeconds": 5.0, + "actualDurationSeconds": 5.12, + "loop": false, + }) + ); + assert_eq!( + serde_json::from_value::(payload) + .expect("metadata should deserialize"), + metadata + ); + } + + #[test] + fn sound_effect_prompt_optimize_contract_uses_the_minimal_camel_case_shape() { + let request = SoundEffectPromptOptimizeRequest { + current_prompt: "\n 金币落地,轻快可爱 ".to_string(), + }; + let request_payload = serde_json::to_value(&request) + .expect("sound effect prompt optimize request should serialize"); + assert_eq!( + request_payload, + json!({ + "currentPrompt": "\n 金币落地,轻快可爱 ", + }) + ); + assert_eq!( + serde_json::from_value::(request_payload) + .expect("sound effect prompt optimize request should deserialize"), + request + ); + assert!( + serde_json::from_value::(json!({ + "currentPrompt": "金币落地", + "targetChars": 2048, + })) + .is_err(), + "clients must not control sound effect optimization targets" + ); + + let response = SoundEffectPromptOptimizeResponse { + prompt: "金币落地时清脆明亮、轻快可爱的金属叮当声".to_string(), + char_count: 23, + }; + let response_payload = serde_json::to_value(&response) + .expect("sound effect prompt optimize response should serialize"); + assert_eq!( + response_payload, + json!({ + "prompt": "金币落地时清脆明亮、轻快可爱的金属叮当声", + "charCount": 23, + }) + ); + assert_eq!( + serde_json::from_value::(response_payload) + .expect("sound effect prompt optimize response should deserialize"), + response + ); + } + #[test] fn background_music_prompt_assist_contract_uses_camel_case_shape() { let request = BackgroundMusicPromptAssistRequest { diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 09306c90b..333c9bd27 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -13,6 +13,7 @@ import { DEFAULT_CANVAS_BACKGROUND_COLOR, dropDeadInlineGenerationPlaceholders, formatCanvasDisplayScalePercent, + generationInputsOrNull, hydrateCanvasGenerationDialog, hydrateLayer, INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS, @@ -971,6 +972,47 @@ describe('ImageCanvasEditorModel', () => { expect(hydrated).not.toHaveProperty('durationSeconds'); }); + it('keeps only valid authoritative SFX V2 metadata in generation inputs', () => { + const soundEffect = { + schemaVersion: 2 as const, + userPrompt: '金币落地,轻快可爱', + actualPrompt: 'A bright, cute coin landing chime', + model: 'eleven_text_to_sound_v2' as const, + durationMode: 'manual' as const, + requestedDurationSeconds: 5, + actualDurationSeconds: 5.12, + loop: false, + }; + expect( + generationInputsOrNull({ fields: [], references: [], soundEffect }), + ).toEqual({ fields: [], references: [], soundEffect }); + + for (const invalidSoundEffect of [ + { ...soundEffect, schemaVersion: 1 }, + { ...soundEffect, model: 'audio1.0' }, + { ...soundEffect, userPrompt: ' 金币落地' }, + { ...soundEffect, actualDurationSeconds: 600.1 }, + { + ...soundEffect, + durationMode: 'auto', + requestedDurationSeconds: 5, + }, + { + ...soundEffect, + durationMode: 'manual', + requestedDurationSeconds: null, + }, + ]) { + expect( + generationInputsOrNull({ + fields: [{ title: '用户描述', value: '金币落地' }], + references: [], + soundEffect: invalidSoundEffect, + }), + ).toBeNull(); + } + }); + it('hydrates character animation sequence fields from the project resource', () => { const layer: CanvasLayer = { id: 'layer-action', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 76670abdd..6dba4dcbf 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1,3 +1,8 @@ +import { + EDITOR_SOUND_EFFECT_MODEL, + SOUND_EFFECT_DURATION_MAX_SECONDS, + SOUND_EFFECT_DURATION_MIN_SECONDS, +} from '../../../packages/shared/src/contracts/editorAudio'; import type { EditorAssetGenerationInputs, EditorAssetLibrarySnapshot, @@ -21,6 +26,7 @@ import type { PerfectPixelOperationSnapshot, SnapCandidate, } from './ImageCanvasEditorTypes'; +import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel'; export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { @@ -2170,6 +2176,7 @@ export function generationInputsOrNull( const snapshot = value as { fields?: unknown; references?: unknown; + soundEffect?: unknown; }; const fields = Array.isArray(snapshot.fields) ? snapshot.fields.flatMap((field) => { @@ -2206,7 +2213,81 @@ export function generationInputsOrNull( }) : []; - return fields.length || references.length ? { fields, references } : null; + const hasSoundEffect = Object.prototype.hasOwnProperty.call( + snapshot, + 'soundEffect', + ); + const soundEffect = hasSoundEffect + ? soundEffectGenerationMetadataOrNull(snapshot.soundEffect) + : null; + if (hasSoundEffect && !soundEffect) { + return null; + } + + return fields.length || references.length || soundEffect + ? { + fields, + references, + ...(soundEffect ? { soundEffect } : {}), + } + : null; +} + +function soundEffectGenerationMetadataOrNull( + value: unknown, +): NonNullable | null { + if (!isSnapshotRecord(value)) { + return null; + } + const userPrompt = + typeof value.userPrompt === 'string' ? value.userPrompt : ''; + const actualPrompt = + typeof value.actualPrompt === 'string' ? value.actualPrompt : ''; + const userPromptValidation = validateSoundEffectPrompt(userPrompt); + const actualPromptValidation = validateSoundEffectPrompt(actualPrompt); + if ( + value.schemaVersion !== 2 || + value.model !== EDITOR_SOUND_EFFECT_MODEL || + (value.durationMode !== 'auto' && value.durationMode !== 'manual') || + typeof value.loop !== 'boolean' || + !userPromptValidation.ok || + userPromptValidation.prompt !== userPrompt || + !actualPromptValidation.ok || + actualPromptValidation.prompt !== actualPrompt || + typeof value.actualDurationSeconds !== 'number' || + !Number.isFinite(value.actualDurationSeconds) || + value.actualDurationSeconds <= 0 || + value.actualDurationSeconds > 600 + ) { + return null; + } + let requestedDurationSeconds: number | null; + if (value.durationMode === 'auto') { + if (value.requestedDurationSeconds !== null) { + return null; + } + requestedDurationSeconds = null; + } else { + if ( + typeof value.requestedDurationSeconds !== 'number' || + !Number.isFinite(value.requestedDurationSeconds) || + value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS || + value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS + ) { + return null; + } + requestedDurationSeconds = value.requestedDurationSeconds; + } + return { + schemaVersion: 2, + userPrompt, + actualPrompt, + model: EDITOR_SOUND_EFFECT_MODEL, + durationMode: value.durationMode, + requestedDurationSeconds, + actualDurationSeconds: value.actualDurationSeconds, + loop: value.loop, + }; } export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index f6d24ab32..974e3bd62 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -1,3 +1,4 @@ +import type { EditorSoundEffectGenerationMetadataV2 } from '../../../packages/shared/src/contracts/editorAudio'; import type { EditorAssetSnapshot, EditorCharacterAnimationFrameCount, @@ -83,6 +84,7 @@ export type CanvasGenerationInputReference = { export type CanvasGenerationInputs = { fields: CanvasGenerationInputField[]; references: CanvasGenerationInputReference[]; + soundEffect?: EditorSoundEffectGenerationMetadataV2; }; export type CanvasLayer = { diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 10e8464cd..c676c3638 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -462,6 +462,7 @@ describe('ImageCanvasGenerationLayerModel', () => { prompt: '金币掉落叮当声', actualPrompt: '金币掉落叮当声', model: 'audio1.0', + provider: 'VectorEngine', taskId: 'sound-task-1', priceMudPoints: 10, audioKind: 'sound-effect', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 55e55d23e..f7bce4471 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -1026,15 +1026,16 @@ describe('ImageCanvasGenerationSubmissionModel', () => { normalizedPrompt: '金币掉落叮当声', input: { prompt: '金币掉落叮当声', - model: 'audio1.0', + model: 'eleven_text_to_sound_v2', duration: 7, + loop: false, }, result: { title: '游戏音效 4', generationInputs: { fields: [ { title: 'prompt', value: '金币掉落叮当声' }, - { title: 'model', value: 'audio1.0' }, + { title: 'model', value: 'eleven_text_to_sound_v2' }, { title: '时长', value: '7秒' }, ], references: [], @@ -1043,38 +1044,18 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); - it('uses the game sound effect fallback for an all-whitespace prompt', () => { - const plan = buildImageGenerationSubmissionPlan({ - dialog: { - mode: 'audio-sound-effect', - prompt: ' \t\r\n ', - status: 'idle', - }, - layers: [], - nextGeneratedIndex: 6, - }); - - expect(plan).toEqual({ - kind: 'audio', - audioKind: 'sound-effect', - normalizedPrompt: '游戏音效', - input: { - prompt: '游戏音效', - model: 'audio1.0', - duration: 5, - }, - result: { - title: '游戏音效 6', - generationInputs: { - fields: [ - { title: 'prompt', value: '游戏音效' }, - { title: 'model', value: 'audio1.0' }, - { title: '时长', value: '5秒' }, - ], - references: [], + it('rejects an all-whitespace sound effect prompt without a fallback', () => { + expect(() => + buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'audio-sound-effect', + prompt: ' \t\r\n ', + status: 'idle', }, - }, - }); + layers: [], + nextGeneratedIndex: 6, + }), + ).toThrow('音效描述不能为空'); }); it('builds game background music audio submission plans with instrumental fixed to true', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index e4168b4e7..8ab4f64d6 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -1,3 +1,4 @@ +import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio'; import type { EditorBackgroundMusicGenerationInput, EditorCharacterAnimationGenerationInput, @@ -32,7 +33,6 @@ import { DEFAULT_EDITOR_BGFILTER_SEG_MODEL, DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, - DEFAULT_SOUND_EFFECT_MODEL, DEFAULT_SPEC_FORM_VALUES, DEFAULT_VIDEO_ASPECT_RATIO, DEFAULT_VIDEO_DURATION_SECONDS, @@ -50,6 +50,7 @@ import { SPEC_TYPE_LABEL, } from './ImageCanvasGenerationModel'; import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; +import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel'; type ImageGenerationSubmissionOptions = { dialog: GenerateDialogState; @@ -135,9 +136,6 @@ function getDialogDefaultPrompt(mode: GenerateDialogState['mode']) { if (mode === 'edit') { return '修改当前图片'; } - if (mode === 'audio-sound-effect') { - return '游戏音效'; - } return 'AI 生成图片'; } @@ -252,8 +250,20 @@ export function buildImageGenerationSubmissionPlan({ }; } - const normalizedPrompt = - dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode); + const soundEffectPrompt = + dialog.mode === 'audio-sound-effect' + ? validateSoundEffectPrompt(dialog.prompt) + : null; + if (soundEffectPrompt && !soundEffectPrompt.ok) { + throw new Error( + soundEffectPrompt.reason === 'empty' + ? '音效描述不能为空' + : '音效描述不能超过 2048 个字符', + ); + } + const normalizedPrompt = soundEffectPrompt + ? soundEffectPrompt.prompt + : dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode); if (dialog.mode === 'edit') { const sourceLayer = layers.find( @@ -533,7 +543,7 @@ export function buildImageGenerationSubmissionPlan({ } if (dialog.mode === 'audio-sound-effect') { - const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL; + const soundModel = EDITOR_SOUND_EFFECT_MODEL; const durationSeconds = typeof dialog.soundDurationSeconds === 'number' ? Math.min(10, Math.max(2, Math.round(dialog.soundDurationSeconds))) @@ -546,6 +556,7 @@ export function buildImageGenerationSubmissionPlan({ prompt: normalizedPrompt, model: soundModel, duration: durationSeconds, + loop: false, }, result: { title: resolveGenerationAssetLabel( diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts index 248ac15e7..77a274efd 100644 --- a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts +++ b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts @@ -190,6 +190,34 @@ describe('ImageCanvasLayerCommandModel', () => { expect(removeCanvasLayers(layers, ['first'])).toEqual([layers[1]]); }); + it('deep-clones authoritative SFX metadata with generation inputs', () => { + const layer = createLayer({ + id: 'sound-effect', + generationInputs: { + fields: [{ title: '用户描述', value: '金币落地' }], + references: [], + soundEffect: { + schemaVersion: 2, + userPrompt: '金币落地', + actualPrompt: 'A coin landing sound', + model: 'eleven_text_to_sound_v2', + durationMode: 'auto', + requestedDurationSeconds: null, + actualDurationSeconds: 4.8, + loop: false, + }, + }, + }); + const clipboard = createCanvasLayerClipboard([layer], [layer.id], 'copy'); + const cloned = clipboard?.layers[0]; + + expect(cloned?.generationInputs).toEqual(layer.generationInputs); + expect(cloned?.generationInputs).not.toBe(layer.generationInputs); + expect(cloned?.generationInputs?.soundEffect).not.toBe( + layer.generationInputs?.soundEffect, + ); + }); + it('moves layer z-indexes with the same commands as the context menu', () => { const layers = [ createLayer({ id: 'bottom', zIndex: 1 }), diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.ts index 5d8940a15..08bdfb6fb 100644 --- a/src/components/image-editor/ImageCanvasLayerCommandModel.ts +++ b/src/components/image-editor/ImageCanvasLayerCommandModel.ts @@ -24,10 +24,14 @@ function cloneLayer(layer: CanvasLayer): CanvasLayer { ...layer, generationInputs: layer.generationInputs ? { + ...layer.generationInputs, fields: layer.generationInputs.fields.map((field) => ({ ...field })), references: layer.generationInputs.references.map((reference) => ({ ...reference, })), + ...(layer.generationInputs.soundEffect + ? { soundEffect: { ...layer.generationInputs.soundEffect } } + : {}), } : layer.generationInputs, }; diff --git a/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts new file mode 100644 index 000000000..19c77cbc4 --- /dev/null +++ b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendSoundEffectPromptPreset, + SOUND_EFFECT_PROMPT_PRESET_GROUPS, + SOUND_EFFECT_PROMPT_PRESETS, +} from './ImageCanvasSoundEffectPresetModel'; + +describe('ImageCanvasSoundEffectPresetModel', () => { + it('freezes 40 event presets and 12 requirements with unique identities', () => { + expect(SOUND_EFFECT_PROMPT_PRESETS).toHaveLength(52); + expect( + SOUND_EFFECT_PROMPT_PRESETS.filter( + (preset) => preset.category === 'event', + ), + ).toHaveLength(40); + expect( + SOUND_EFFECT_PROMPT_PRESETS.filter( + (preset) => preset.category === 'requirement', + ), + ).toHaveLength(12); + expect(new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).size).toBe( + 52, + ); + expect( + new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ label }) => label)).size, + ).toBe(52); + expect( + new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ group }) => group)), + ).toEqual(new Set(SOUND_EFFECT_PROMPT_PRESET_GROUPS)); + }); + + it('locks the visible labels and prompt text in authoritative order', () => { + expect( + SOUND_EFFECT_PROMPT_PRESETS.map(({ category, group, label, prompt }) => [ + category, + group, + label, + prompt, + ]), + ).toEqual([ + ['event', 'ui-operation', '轻触按钮', '柔和的按钮点击声'], + ['event', 'ui-operation', '确认操作', '明亮的确认提示音'], + ['event', 'ui-operation', '返回取消', '轻微下降的取消提示音'], + ['event', 'ui-operation', '页面切换', '快速掠过的界面切换声'], + ['event', 'ui-operation', '通知提醒', '清晰柔和的通知提示音'], + ['event', 'ui-operation', '操作错误', '短促克制的错误提示音'], + ['event', 'pickup-reward', '金币拾取', '金币拾取时清脆的金属叮当声'], + ['event', 'pickup-reward', '道具拾取', '拾取道具时轻快的提示音'], + ['event', 'pickup-reward', '获得奖励', '奖励出现时明亮的提示音'], + [ + 'event', + 'pickup-reward', + '宝箱开启', + '金属锁扣弹开,随后响起明亮的奖励提示音', + ], + [ + 'event', + 'pickup-reward', + '解锁内容', + '锁定状态解除,随后响起解锁提示音', + ], + ['event', 'pickup-reward', '稀有掉落', '稀有物品出现时闪耀的奖励提示音'], + [ + 'event', + 'growth-result', + '物品合成', + '两件物品融合,随后响起明亮的完成提示音', + ], + [ + 'event', + 'growth-result', + '角色升级', + '能量快速上升,随后响起明亮的升级提示音', + ], + [ + 'event', + 'growth-result', + '任务完成', + '任务完成提示音,随后响起简短的奖励音符', + ], + [ + 'event', + 'growth-result', + '成就达成', + '明亮的成就提示音,随后响起简短的庆祝音符', + ], + [ + 'event', + 'growth-result', + '挑战胜利', + '明亮的胜利提示音,随后响起短暂的庆祝音符', + ], + ['event', 'growth-result', '挑战失败', '低沉的失败提示音'], + ['event', 'character-combat', '角色跳跃', '角色轻盈跳起的声音'], + ['event', 'character-combat', '角色落地', '角色落地时轻微的撞击声'], + ['event', 'character-combat', '轻度受击', '轻微撞击的受击声'], + ['event', 'character-combat', '重度受击', '沉重有力的撞击声'], + ['event', 'character-combat', '攻击挥动', '武器快速挥过空气的呼啸声'], + ['event', 'character-combat', '攻击命中', '武器击中目标的清晰撞击声'], + ['event', 'character-combat', '格挡成功', '武器碰撞,随后被挡开的金属声'], + ['event', 'character-combat', '物体破碎', '物体撞击地面后快速破碎的声音'], + ['event', 'skill-status', '技能蓄力', '能量逐渐聚集的低沉嗡鸣声'], + [ + 'event', + 'skill-status', + '魔法释放', + '柔和的魔法能量扩散,带有圆润空灵的闪光声', + ], + [ + 'event', + 'skill-status', + '治疗恢复', + '柔和能量扩散,带有温暖圆润的提示音', + ], + ['event', 'skill-status', '护盾生成', '能量向外展开,形成稳定的护盾声'], + [ + 'event', + 'skill-status', + '瞬间移动', + '能量快速收缩,随后以短促的空气抽离声消失', + ], + [ + 'event', + 'skill-status', + '冰冻技能', + '冰霜能量扩散,随后响起清脆的冻结声', + ], + ['event', 'skill-status', '火焰技能', '火焰迅速喷发,带有短促的燃烧声'], + [ + 'event', + 'skill-status', + '状态强化', + '能量逐渐上升,形成稳定明亮的提示音', + ], + [ + 'event', + 'mechanism-scene-interaction', + '门开启', + '门锁解除,随后厚重的木门缓慢打开', + ], + [ + 'event', + 'mechanism-scene-interaction', + '机关启动', + '机关解锁,随后齿轮开始转动', + ], + [ + 'event', + 'mechanism-scene-interaction', + '拉杆触发', + '拉杆被扳动,随后远处机关启动', + ], + [ + 'event', + 'mechanism-scene-interaction', + '石块移动', + '大型石块缓慢移动时低沉的摩擦声', + ], + [ + 'event', + 'mechanism-scene-interaction', + '传送门开启', + '能量旋转聚集,随后响起持续、空灵的传送门展开声', + ], + [ + 'event', + 'mechanism-scene-interaction', + '倒计时警告', + '逐渐加快的倒计时提示音', + ], + ['requirement', 'style-direction', '休闲可爱', '轻快可爱的卡通风格'], + ['requirement', 'style-direction', '复古街机', '复古街机风格'], + ['requirement', 'style-direction', '科幻电子', '干净的科幻电子音色'], + ['requirement', 'style-direction', '奇幻魔法', '柔和梦幻的魔法音色'], + ['requirement', 'style-direction', '写实自然', '自然真实的声音质感'], + ['requirement', 'style-direction', '卡通夸张', '夸张鲜明的卡通风格'], + ['requirement', 'feedback-requirement', '轻柔反馈', '轻柔克制'], + ['requirement', 'feedback-requirement', '有力反馈', '更有力的撞击感'], + ['requirement', 'feedback-requirement', '短促反馈', '短促的单次声音'], + ['requirement', 'feedback-requirement', '两段递进', '由弱到强的两段变化'], + [ + 'requirement', + 'feedback-requirement', + '干净突出', + '主体声音清晰,减少杂音', + ], + [ + 'requirement', + 'feedback-requirement', + '柔和不刺耳', + '圆润柔和,避免尖锐高频', + ], + ]); + }); + + it('canonicalizes and appends with the frozen Unicode punctuation rule', () => { + const preset = SOUND_EFFECT_PROMPT_PRESETS[40]; + expect(appendSoundEffectPromptPreset('\u0085\u2003', preset)).toBe( + preset.prompt, + ); + expect(appendSoundEffectPromptPreset('金币落地', preset)).toBe( + `金币落地,${preset.prompt}`, + ); + expect(appendSoundEffectPromptPreset('金币落地!\u0085', preset)).toBe( + `金币落地!${preset.prompt}`, + ); + }); + + it('allows duplicates and preserves overlong text without truncation', () => { + const preset = SOUND_EFFECT_PROMPT_PRESETS[0]; + const once = appendSoundEffectPromptPreset('按钮', preset); + expect(appendSoundEffectPromptPreset(once, preset)).toBe( + `按钮,${preset.prompt},${preset.prompt}`, + ); + + const overlong = '声'.repeat(2048); + const appended = appendSoundEffectPromptPreset(overlong, preset); + expect(appended).toBe(`${overlong},${preset.prompt}`); + expect(Array.from(appended).length).toBeGreaterThan(2048); + }); +}); diff --git a/src/components/image-editor/ImageCanvasSoundEffectPresetModel.ts b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.ts new file mode 100644 index 000000000..2f8fa13f4 --- /dev/null +++ b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.ts @@ -0,0 +1,423 @@ +import { canonicalizeSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel'; + +export type SoundEffectPromptPresetCategory = 'event' | 'requirement'; + +export type SoundEffectPromptPresetGroup = + | 'ui-operation' + | 'pickup-reward' + | 'growth-result' + | 'character-combat' + | 'skill-status' + | 'mechanism-scene-interaction' + | 'style-direction' + | 'feedback-requirement'; + +export type SoundEffectPromptPreset = { + id: string; + category: SoundEffectPromptPresetCategory; + group: SoundEffectPromptPresetGroup; + label: string; + prompt: string; +}; + +const UNICODE_PUNCTUATION_CODE_POINT = /^\p{Punctuation}$/u; +const SOUND_EFFECT_PRESET_SEPARATOR = ','; + +export const SOUND_EFFECT_PROMPT_PRESET_GROUPS = [ + 'ui-operation', + 'pickup-reward', + 'growth-result', + 'character-combat', + 'skill-status', + 'mechanism-scene-interaction', + 'style-direction', + 'feedback-requirement', +] as const satisfies readonly SoundEffectPromptPresetGroup[]; + +/** 权威设计「SFX 生成优化 V2.0」冻结的 40 个事件预设与 12 个补充要求。 */ +export const SOUND_EFFECT_PROMPT_PRESETS = [ + { + id: 'button-tap', + category: 'event', + group: 'ui-operation', + label: '轻触按钮', + prompt: '柔和的按钮点击声', + }, + { + id: 'operation-confirm', + category: 'event', + group: 'ui-operation', + label: '确认操作', + prompt: '明亮的确认提示音', + }, + { + id: 'back-cancel', + category: 'event', + group: 'ui-operation', + label: '返回取消', + prompt: '轻微下降的取消提示音', + }, + { + id: 'page-transition', + category: 'event', + group: 'ui-operation', + label: '页面切换', + prompt: '快速掠过的界面切换声', + }, + { + id: 'notification', + category: 'event', + group: 'ui-operation', + label: '通知提醒', + prompt: '清晰柔和的通知提示音', + }, + { + id: 'operation-error', + category: 'event', + group: 'ui-operation', + label: '操作错误', + prompt: '短促克制的错误提示音', + }, + { + id: 'coin-pickup', + category: 'event', + group: 'pickup-reward', + label: '金币拾取', + prompt: '金币拾取时清脆的金属叮当声', + }, + { + id: 'item-pickup', + category: 'event', + group: 'pickup-reward', + label: '道具拾取', + prompt: '拾取道具时轻快的提示音', + }, + { + id: 'reward-received', + category: 'event', + group: 'pickup-reward', + label: '获得奖励', + prompt: '奖励出现时明亮的提示音', + }, + { + id: 'chest-open', + category: 'event', + group: 'pickup-reward', + label: '宝箱开启', + prompt: '金属锁扣弹开,随后响起明亮的奖励提示音', + }, + { + id: 'content-unlock', + category: 'event', + group: 'pickup-reward', + label: '解锁内容', + prompt: '锁定状态解除,随后响起解锁提示音', + }, + { + id: 'rare-drop', + category: 'event', + group: 'pickup-reward', + label: '稀有掉落', + prompt: '稀有物品出现时闪耀的奖励提示音', + }, + { + id: 'item-craft', + category: 'event', + group: 'growth-result', + label: '物品合成', + prompt: '两件物品融合,随后响起明亮的完成提示音', + }, + { + id: 'character-level-up', + category: 'event', + group: 'growth-result', + label: '角色升级', + prompt: '能量快速上升,随后响起明亮的升级提示音', + }, + { + id: 'quest-complete', + category: 'event', + group: 'growth-result', + label: '任务完成', + prompt: '任务完成提示音,随后响起简短的奖励音符', + }, + { + id: 'achievement-unlocked', + category: 'event', + group: 'growth-result', + label: '成就达成', + prompt: '明亮的成就提示音,随后响起简短的庆祝音符', + }, + { + id: 'challenge-victory', + category: 'event', + group: 'growth-result', + label: '挑战胜利', + prompt: '明亮的胜利提示音,随后响起短暂的庆祝音符', + }, + { + id: 'challenge-defeat', + category: 'event', + group: 'growth-result', + label: '挑战失败', + prompt: '低沉的失败提示音', + }, + { + id: 'character-jump', + category: 'event', + group: 'character-combat', + label: '角色跳跃', + prompt: '角色轻盈跳起的声音', + }, + { + id: 'character-land', + category: 'event', + group: 'character-combat', + label: '角色落地', + prompt: '角色落地时轻微的撞击声', + }, + { + id: 'light-hit', + category: 'event', + group: 'character-combat', + label: '轻度受击', + prompt: '轻微撞击的受击声', + }, + { + id: 'heavy-hit', + category: 'event', + group: 'character-combat', + label: '重度受击', + prompt: '沉重有力的撞击声', + }, + { + id: 'attack-swing', + category: 'event', + group: 'character-combat', + label: '攻击挥动', + prompt: '武器快速挥过空气的呼啸声', + }, + { + id: 'attack-hit', + category: 'event', + group: 'character-combat', + label: '攻击命中', + prompt: '武器击中目标的清晰撞击声', + }, + { + id: 'block-success', + category: 'event', + group: 'character-combat', + label: '格挡成功', + prompt: '武器碰撞,随后被挡开的金属声', + }, + { + id: 'object-break', + category: 'event', + group: 'character-combat', + label: '物体破碎', + prompt: '物体撞击地面后快速破碎的声音', + }, + { + id: 'skill-charge', + category: 'event', + group: 'skill-status', + label: '技能蓄力', + prompt: '能量逐渐聚集的低沉嗡鸣声', + }, + { + id: 'magic-cast', + category: 'event', + group: 'skill-status', + label: '魔法释放', + prompt: '柔和的魔法能量扩散,带有圆润空灵的闪光声', + }, + { + id: 'healing', + category: 'event', + group: 'skill-status', + label: '治疗恢复', + prompt: '柔和能量扩散,带有温暖圆润的提示音', + }, + { + id: 'shield-create', + category: 'event', + group: 'skill-status', + label: '护盾生成', + prompt: '能量向外展开,形成稳定的护盾声', + }, + { + id: 'teleport', + category: 'event', + group: 'skill-status', + label: '瞬间移动', + prompt: '能量快速收缩,随后以短促的空气抽离声消失', + }, + { + id: 'ice-skill', + category: 'event', + group: 'skill-status', + label: '冰冻技能', + prompt: '冰霜能量扩散,随后响起清脆的冻结声', + }, + { + id: 'fire-skill', + category: 'event', + group: 'skill-status', + label: '火焰技能', + prompt: '火焰迅速喷发,带有短促的燃烧声', + }, + { + id: 'status-buff', + category: 'event', + group: 'skill-status', + label: '状态强化', + prompt: '能量逐渐上升,形成稳定明亮的提示音', + }, + { + id: 'door-open', + category: 'event', + group: 'mechanism-scene-interaction', + label: '门开启', + prompt: '门锁解除,随后厚重的木门缓慢打开', + }, + { + id: 'mechanism-start', + category: 'event', + group: 'mechanism-scene-interaction', + label: '机关启动', + prompt: '机关解锁,随后齿轮开始转动', + }, + { + id: 'lever-trigger', + category: 'event', + group: 'mechanism-scene-interaction', + label: '拉杆触发', + prompt: '拉杆被扳动,随后远处机关启动', + }, + { + id: 'stone-move', + category: 'event', + group: 'mechanism-scene-interaction', + label: '石块移动', + prompt: '大型石块缓慢移动时低沉的摩擦声', + }, + { + id: 'portal-open', + category: 'event', + group: 'mechanism-scene-interaction', + label: '传送门开启', + prompt: '能量旋转聚集,随后响起持续、空灵的传送门展开声', + }, + { + id: 'countdown-warning', + category: 'event', + group: 'mechanism-scene-interaction', + label: '倒计时警告', + prompt: '逐渐加快的倒计时提示音', + }, + { + id: 'casual-cute', + category: 'requirement', + group: 'style-direction', + label: '休闲可爱', + prompt: '轻快可爱的卡通风格', + }, + { + id: 'retro-arcade', + category: 'requirement', + group: 'style-direction', + label: '复古街机', + prompt: '复古街机风格', + }, + { + id: 'sci-fi-electronic', + category: 'requirement', + group: 'style-direction', + label: '科幻电子', + prompt: '干净的科幻电子音色', + }, + { + id: 'fantasy-magic', + category: 'requirement', + group: 'style-direction', + label: '奇幻魔法', + prompt: '柔和梦幻的魔法音色', + }, + { + id: 'realistic-natural', + category: 'requirement', + group: 'style-direction', + label: '写实自然', + prompt: '自然真实的声音质感', + }, + { + id: 'cartoon-exaggerated', + category: 'requirement', + group: 'style-direction', + label: '卡通夸张', + prompt: '夸张鲜明的卡通风格', + }, + { + id: 'gentle-feedback', + category: 'requirement', + group: 'feedback-requirement', + label: '轻柔反馈', + prompt: '轻柔克制', + }, + { + id: 'strong-feedback', + category: 'requirement', + group: 'feedback-requirement', + label: '有力反馈', + prompt: '更有力的撞击感', + }, + { + id: 'short-feedback', + category: 'requirement', + group: 'feedback-requirement', + label: '短促反馈', + prompt: '短促的单次声音', + }, + { + id: 'two-stage-rise', + category: 'requirement', + group: 'feedback-requirement', + label: '两段递进', + prompt: '由弱到强的两段变化', + }, + { + id: 'clean-prominent', + category: 'requirement', + group: 'feedback-requirement', + label: '干净突出', + prompt: '主体声音清晰,减少杂音', + }, + { + id: 'soft-not-harsh', + category: 'requirement', + group: 'feedback-requirement', + label: '柔和不刺耳', + prompt: '圆润柔和,避免尖锐高频', + }, +] as const satisfies readonly SoundEffectPromptPreset[]; + +function readLastCodePoint(value: string) { + const codePoints = Array.from(value); + return codePoints[codePoints.length - 1]; +} + +export function appendSoundEffectPromptPreset( + currentPrompt: string, + preset: SoundEffectPromptPreset, +) { + const canonicalPrompt = canonicalizeSoundEffectPrompt(currentPrompt); + if (!canonicalPrompt) { + return preset.prompt; + } + const lastCodePoint = readLastCodePoint(canonicalPrompt); + if (lastCodePoint && UNICODE_PUNCTUATION_CODE_POINT.test(lastCodePoint)) { + return `${canonicalPrompt}${preset.prompt}`; + } + return `${canonicalPrompt}${SOUND_EFFECT_PRESET_SEPARATOR}${preset.prompt}`; +} diff --git a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts new file mode 100644 index 000000000..82bb434a7 --- /dev/null +++ b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import soundEffectPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json'; +import { + canonicalizeSoundEffectPrompt, + countSoundEffectPromptCodePoints, + SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, + validateSoundEffectPrompt, +} from './ImageCanvasSoundEffectPromptModel'; + +describe('ImageCanvasSoundEffectPromptModel', () => { + it('matches the shared Unicode canonicalization fixtures', () => { + for (const fixture of soundEffectPromptCanonicalizationCases) { + expect(canonicalizeSoundEffectPrompt(fixture.input), fixture.name).toBe( + fixture.prompt, + ); + expect( + countSoundEffectPromptCodePoints(fixture.input), + fixture.name, + ).toBe(fixture.charCount); + expect( + canonicalizeSoundEffectPrompt( + canonicalizeSoundEffectPrompt(fixture.input), + ), + fixture.name, + ).toBe(fixture.prompt); + } + }); + + it('accepts 2048 code points and rejects 2049 without truncating', () => { + const maximum = `\u0085${'😀'.repeat( + SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, + )}\u2003`; + const accepted = validateSoundEffectPrompt(maximum); + expect(accepted).toEqual({ + ok: true, + prompt: '😀'.repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS), + charCount: SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, + }); + + const overlong = '声'.repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1); + expect(validateSoundEffectPrompt(overlong)).toEqual({ + ok: false, + prompt: overlong, + charCount: SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1, + reason: 'too-long', + }); + }); + + it('rejects empty canonical prompts without adding a default description', () => { + for (const prompt of ['', ' \t\r\n', '\u0085\u2003\u00a0']) { + expect(validateSoundEffectPrompt(prompt)).toEqual({ + ok: false, + prompt: '', + charCount: 0, + reason: 'empty', + }); + } + }); +}); diff --git a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts new file mode 100644 index 000000000..3fb159dc2 --- /dev/null +++ b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts @@ -0,0 +1,55 @@ +export const SOUND_EFFECT_PROMPT_MAX_CODE_POINTS = 2048; + +const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u; +const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u; + +export type SoundEffectPromptValidationResult = + | { + ok: true; + prompt: string; + charCount: number; + } + | { + ok: false; + prompt: string; + charCount: number; + reason: 'empty' | 'too-long'; + }; + +/** + * 只删除首尾 Unicode `White_Space`。内部空白、组合字符、ZWJ、U+200B、U+FEFF + * 及其它 code point 均原样保留;不能替换为语义不同的原生 `trim()`。 + */ +export function canonicalizeSoundEffectPrompt(prompt: string) { + return prompt + .replace(LEADING_UNICODE_WHITE_SPACE, '') + .replace(TRAILING_UNICODE_WHITE_SPACE, ''); +} + +export function countSoundEffectPromptCodePoints(prompt: string) { + return Array.from(canonicalizeSoundEffectPrompt(prompt)).length; +} + +export function validateSoundEffectPrompt( + prompt: string, +): SoundEffectPromptValidationResult { + const canonicalPrompt = canonicalizeSoundEffectPrompt(prompt); + const charCount = Array.from(canonicalPrompt).length; + if (charCount === 0) { + return { + ok: false, + prompt: canonicalPrompt, + charCount, + reason: 'empty', + }; + } + if (charCount > SOUND_EFFECT_PROMPT_MAX_CODE_POINTS) { + return { + ok: false, + prompt: canonicalPrompt, + charCount, + reason: 'too-long', + }; + } + return { ok: true, prompt: canonicalPrompt, charCount }; +} diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index da38e4d8e..d5200a8d3 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1752,9 +1752,9 @@ describe('editorProjectClient', () => { height: 120, sourceType: 'generated', prompt: '金币掉落叮当声', - actualPrompt: '金币掉落叮当声', - model: 'audio1.0', - provider: 'VectorEngine', + actualPrompt: 'A bright coin pickup chime', + model: 'eleven_text_to_sound_v2', + provider: 'elevenlabs', taskId: 'sound-task-1', audioKind: 'sound-effect', asset: { @@ -1771,8 +1771,9 @@ describe('editorProjectClient', () => { const result = await generateEditorSoundEffect({ prompt: '金币掉落叮当声', - model: 'audio1.0', - duration: 7, + model: 'eleven_text_to_sound_v2', + duration: 7.5, + loop: true, assetFolderId: 'project', assetLabel: '游戏音效 1', }); @@ -1786,8 +1787,9 @@ describe('editorProjectClient', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '金币掉落叮当声', - model: 'audio1.0', - duration: 7, + model: 'eleven_text_to_sound_v2', + duration: 7.5, + loop: true, assetFolderId: 'project', assetLabel: '游戏音效 1', }), @@ -1800,24 +1802,23 @@ describe('editorProjectClient', () => { ); }); - it('sends editor sound effect duration to Vidu instead of legacy type and tempo', async () => { + it('canonicalizes omitted duration to null and omitted loop to false', async () => { requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/sfx-null.mp3', width: 420, height: 120, sourceType: 'generated', prompt: '按钮确认短促音', - actualPrompt: '按钮确认短促音', - model: 'audio1.0', - provider: 'VectorEngine', + actualPrompt: 'A short confirmation click', + model: 'eleven_text_to_sound_v2', + provider: 'elevenlabs', taskId: 'sound-task-null', audioKind: 'sound-effect', }); await generateEditorSoundEffect({ prompt: '按钮确认短促音', - model: 'audio1.0', - duration: 5, + model: 'eleven_text_to_sound_v2', }); expect(requestJsonMock).toHaveBeenCalledWith( @@ -1825,8 +1826,9 @@ describe('editorProjectClient', () => { expect.objectContaining({ body: JSON.stringify({ prompt: '按钮确认短促音', - model: 'audio1.0', - duration: 5, + model: 'eleven_text_to_sound_v2', + duration: null, + loop: false, }), }), '生成游戏音效失败', @@ -1834,6 +1836,66 @@ describe('editorProjectClient', () => { ); }); + it('accepts the 0.5 and 30 second sound effect duration boundaries', async () => { + requestJsonMock.mockResolvedValue({}); + + for (const duration of [0.5, 30]) { + await generateEditorSoundEffect({ + prompt: '按钮确认短促音', + model: 'eleven_text_to_sound_v2', + duration, + }); + } + + expect(requestJsonMock).toHaveBeenNthCalledWith( + 1, + '/api/editor/audios/sound-effects/generations', + expect.objectContaining({ + body: JSON.stringify({ + prompt: '按钮确认短促音', + model: 'eleven_text_to_sound_v2', + duration: 0.5, + loop: false, + }), + }), + '生成游戏音效失败', + expect.any(Object), + ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 2, + '/api/editor/audios/sound-effects/generations', + expect.objectContaining({ + body: JSON.stringify({ + prompt: '按钮确认短促音', + model: 'eleven_text_to_sound_v2', + duration: 30, + loop: false, + }), + }), + '生成游戏音效失败', + expect.any(Object), + ); + }); + + it('rejects invalid sound effect durations before the request', async () => { + for (const duration of [ + 0.49, + 30.01, + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + await expect( + generateEditorSoundEffect({ + prompt: '按钮确认短促音', + model: 'eleven_text_to_sound_v2', + duration, + }), + ).rejects.toThrow('游戏音效时长必须在 0.5-30 秒之间'); + } + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + it('generates editor background music through the backend BFF', async () => { const representativeComplexPromptCase = backgroundMusicPromptCanonicalizationCases.find( diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index fe34c74b0..ad75b602b 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -1,6 +1,10 @@ -import type { - BackgroundMusicPromptAssistRequest, - BackgroundMusicPromptAssistResponse, +import { + type BackgroundMusicPromptAssistRequest, + type BackgroundMusicPromptAssistResponse, + type EditorSoundEffectGenerationMetadataV2, + type EditorSoundEffectGenerationRequest, + SOUND_EFFECT_DURATION_MAX_SECONDS, + SOUND_EFFECT_DURATION_MIN_SECONDS, } from '../../../packages/shared/src/contracts/editorAudio'; import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { requestJson } from '../apiClient'; @@ -125,6 +129,7 @@ export type EditorAssetGenerationInputReference = { export type EditorAssetGenerationInputs = { fields: EditorAssetGenerationInputField[]; references: EditorAssetGenerationInputReference[]; + soundEffect?: EditorSoundEffectGenerationMetadataV2; [key: string]: unknown; }; @@ -547,16 +552,14 @@ export type EditorVideoGenerationResult = { queueState?: ExternalGenerationJobStatusRecord | null; }; -export type EditorSoundEffectGenerationInput = { - prompt: string; - model: 'audio1.0'; - duration: number; - projectId?: string | null; - canvasCompletion?: EditorCanvasGenerationCompletionInput | null; - generationInputs?: EditorAssetGenerationInputs | null; - assetFolderId?: string | null; - assetLabel?: string | null; -}; +export type EditorSoundEffectGenerationInput = + EditorSoundEffectGenerationRequest & { + projectId?: string | null; + canvasCompletion?: EditorCanvasGenerationCompletionInput | null; + generationInputs?: EditorAssetGenerationInputs | null; + assetFolderId?: string | null; + assetLabel?: string | null; + }; export type EditorBackgroundMusicGenerationInput = { gptDescriptionPrompt: string; @@ -582,10 +585,12 @@ export type EditorAudioGenerationResult = { prompt: string; actualPrompt?: string | null; model: string; + provider: string; taskId: string; priceMudPoints: number; audioKind: 'sound-effect' | 'background-music'; durationSeconds?: number | null; + loop?: boolean | null; resource?: EditorProjectResourceSnapshot | null; asset?: EditorAssetSnapshot | null; project?: EditorProjectSnapshot | null; @@ -1353,12 +1358,22 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) { export async function generateEditorSoundEffect( input: EditorSoundEffectGenerationInput, ) { + const duration = input.duration ?? null; + if ( + duration !== null && + (!Number.isFinite(duration) || + duration < SOUND_EFFECT_DURATION_MIN_SECONDS || + duration > SOUND_EFFECT_DURATION_MAX_SECONDS) + ) { + throw new Error('游戏音效时长必须在 0.5-30 秒之间'); + } return requestJson( EDITOR_SOUND_EFFECT_GENERATION_API, jsonRequest('POST', { prompt: input.prompt, model: input.model, - duration: input.duration, + duration, + loop: input.loop ?? false, ...(input.projectId ? { projectId: input.projectId } : {}), ...(input.canvasCompletion ? { canvasCompletion: input.canvasCompletion }