From db9f452cae8f3a1882f77ff4452a0558c5947499 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 6 Aug 2026 14:07:05 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20SFX=20V2=20T1=20=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复音频 compact 结果与 Agent reconcile 契约 统一正式 SFX 提交流程的 Unicode canonicalization 锁定队列 duration 与 Loop JSON 形态 收紧 Rust V2 metadata 构造和反序列化不变量 补齐共享边界、52 个预设 ID 和任意小数时长测试 --- ...计划】SFX生成优化V2.0任务拆解-2026-08-06.md | 2 + .../sound-effect-prompt-canonicalization.json | 25 +++ .../crates/api-server/src/editor_agent/mod.rs | 3 + .../api-server/src/editor_agent/reconcile.rs | 9 + .../api-server/src/editor_agent/tool.rs | 3 +- .../src/external_generation_worker.rs | 90 +++++++- .../tests/sound_effect_prompt.rs | 28 ++- .../crates/shared-contracts/src/assets.rs | 193 ++++++++++++++++-- .../ImageCanvasSoundEffectPresetModel.test.ts | 54 +++++ .../ImageCanvasSoundEffectPromptModel.test.ts | 30 ++- ...anvasGenerationSubmissionWorkflow.test.tsx | 31 ++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 12 +- 12 files changed, 435 insertions(+), 45 deletions(-) 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 0994edf93..d5e667ce6 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 @@ -137,6 +137,8 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏 实施记录(`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 完成前不得发布当前中间态。 +补充验收(`2026-08-06`):音频 compact 结果保留完整 DTO 必填的 `provider`,SFX / BGM 均通过真实 compact → Agent reconcile 回归;正式 SFX 提交流程不再执行原生 `trim()` 或默认 Prompt 回退。Rust V2 metadata 只能经校验构造并拒绝错误版本、模型、时长组合与实际时长;共享 fixture 直接锁定 `1 / 2048 / 2049`,52 个预设 ID 和非 `0.1s` 步进小数时长均有固定断言。 + ### 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/test-fixtures/sound-effect-prompt-canonicalization.json b/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json index 5919b4387..ca8f56109 100644 --- a/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json +++ b/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json @@ -46,5 +46,30 @@ "input": "", "prompt": "", "charCount": 0 + }, + { + "name": "one-code-point-boundary", + "input": "声", + "prompt": "声", + "charCount": 1, + "validation": "valid" + }, + { + "name": "exactly-2048-code-points", + "prefix": "\u0085", + "input": "声", + "suffix": "\u2003", + "prompt": "声", + "repeat": 2048, + "charCount": 2048, + "validation": "valid" + }, + { + "name": "over-limit-2049-code-points", + "input": "声", + "prompt": "声", + "repeat": 2049, + "charCount": 2049, + "validation": "too-long" } ] diff --git a/server-rs/crates/api-server/src/editor_agent/mod.rs b/server-rs/crates/api-server/src/editor_agent/mod.rs index 55a9b2d17..c7a62010f 100644 --- a/server-rs/crates/api-server/src/editor_agent/mod.rs +++ b/server-rs/crates/api-server/src/editor_agent/mod.rs @@ -11,3 +11,6 @@ pub use api::{ create_editor_agent_conversation, delete_editor_agent_conversation, get_editor_agent_conversation, list_editor_agent_conversations, }; + +#[cfg(test)] +pub(crate) use reconcile::reconcile_completed_editor_agent_tool_call_for_test; diff --git a/server-rs/crates/api-server/src/editor_agent/reconcile.rs b/server-rs/crates/api-server/src/editor_agent/reconcile.rs index 27a3bcec0..7eb67342b 100644 --- a/server-rs/crates/api-server/src/editor_agent/reconcile.rs +++ b/server-rs/crates/api-server/src/editor_agent/reconcile.rs @@ -207,6 +207,15 @@ fn reconcile_completed_editor_agent_tool_call( Ok(()) } +#[cfg(test)] +pub(crate) fn reconcile_completed_editor_agent_tool_call_for_test( + message: &mut EditorAgentMessage, + result_payload_json: Option<&str>, +) -> Result<(), String> { + reconcile_completed_editor_agent_tool_call(message, result_payload_json) + .map_err(|error| format!("{error:?}")) +} + #[cfg(test)] mod tests { use super::*; 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 522a594bc..60fcac80d 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -1554,8 +1554,9 @@ mod tests { assert_eq!(job.price_mud_points, 5); assert_eq!( job.payload["duration"], - GenerateSoundEffectTool::DEFAULT_DURATION + json!(f64::from(GenerateSoundEffectTool::DEFAULT_DURATION)) ); + assert_eq!(job.payload["loop"], json!(false)); assert_eq!(job.payload["projectId"], "project-1"); assert_eq!(job.payload["generationInputs"]["toolCallMessageId"], 7); } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 04ef53ec6..e1174fdc7 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1251,9 +1251,15 @@ fn compact_editor_generation_result(mut result: Value) -> Value { let Some(object) = result.as_object_mut() else { return result; }; - // 紧凑结果会回传给普通用户的 Agent 工具调用卡片,不能把供应商或内部后处理实现 - // 当作可见生成信息下发。正常的用户可见模型仍然保留,以便卡片恢复原有展示。 - object.remove("provider"); + // 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏 + // 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。 + let is_audio_result = object + .get("audioKind") + .and_then(Value::as_str) + .is_some_and(|kind| matches!(kind, "sound-effect" | "background-music")); + if !is_audio_result { + object.remove("provider"); + } if object .get("model") .and_then(Value::as_str) @@ -2069,6 +2075,84 @@ mod tests { } } + #[test] + fn editor_agent_audio_compact_results_remain_reconcileable() { + use crate::editor_agent::reconcile_completed_editor_agent_tool_call_for_test; + use platform_editor_agent::{ + agent::tools::{ + generate_background_music::GenerateBackgroundMusicTool, + generate_sound_effect::GenerateSoundEffectTool, + }, + framework::tool::Tool, + }; + use shared_contracts::editor_agent::{EditorAgentMessage, EditorAgentToolCallStatus}; + + for (tool_name, prompt, audio_kind) in [ + (GenerateSoundEffectTool::NAME, "按钮点击声", "sound-effect"), + ( + GenerateBackgroundMusicTool::NAME, + "森林背景音乐", + "background-music", + ), + ] { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.request_payload_json = json!({ + "generationInputs": { "source": "editor-agent" }, + }) + .to_string(); + let response = json!({ + "ok": true, + "audioSrc": "/generated/audio.mp3", + "width": 420, + "height": 120, + "sourceType": "generated", + "prompt": prompt, + "model": "audio1.0", + "provider": "vectorengine", + "taskId": "task-1", + "priceMudPoints": 5, + "audioKind": audio_kind, + }); + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker compact payload should serialize"); + assert_eq!( + payload["editor-agent-tool-call-result"]["provider"], + json!("vectorengine") + ); + let mut message: EditorAgentMessage = serde_json::from_value(json!({ + "id": 1, + "role": "system", + "text": "waiting", + "attachments": [], + "toolCall": { + "toolName": tool_name, + "status": "not_completed", + "args": { "prompt": prompt }, + "displayArgs": { + "stringArgs": [], + "imageArgs": [], + "extras": { "priceMudPoints": 5 } + }, + "externalJobId": "job-1", + "images": [], + "audios": [] + }, + "createdAt": "2026-08-06T00:00:00Z" + })) + .expect("pending audio Agent message should deserialize"); + let payload_json = payload.to_string(); + reconcile_completed_editor_agent_tool_call_for_test( + &mut message, + Some(payload_json.as_str()), + ) + .expect("worker compact audio result should reconcile"); + let tool_call = message.tool_call.expect("tool call should remain present"); + assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed); + assert_eq!(tool_call.audios[0].audio_src, "/generated/audio.mp3"); + } + } + #[test] fn editor_agent_spritesheet_result_keeps_all_persisted_slices() { let mut job = external_generation_job_record_fixture(Some("lease-1")); diff --git a/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs b/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs index 543b1393f..57a3b33ca 100644 --- a/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs +++ b/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs @@ -22,21 +22,29 @@ fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() { let name = case["name"] .as_str() .expect("fixture name should be a string"); - let input = case["input"] + let input_unit = case["input"] .as_str() .expect("fixture input should be a string"); - let expected_prompt = case["prompt"] + let expected_prompt_unit = case["prompt"] .as_str() .expect("fixture prompt should be a string"); + let repeat = case.get("repeat").and_then(Value::as_u64).unwrap_or(1) as usize; + let input = format!( + "{}{}{}", + case.get("prefix").and_then(Value::as_str).unwrap_or(""), + input_unit.repeat(repeat), + case.get("suffix").and_then(Value::as_str).unwrap_or("") + ); + let expected_prompt = expected_prompt_unit.repeat(repeat); 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); + 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), + sound_effect_prompt_code_point_count(&input), expected_char_count, "fixture={name}" ); @@ -45,6 +53,17 @@ fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() { canonical_prompt, "canonicalization should be idempotent for fixture={name}" ); + match case.get("validation").and_then(Value::as_str) { + Some("valid") => { + validate_sound_effect_prompt(&input) + .unwrap_or_else(|error| panic!("fixture={name}: {error}")); + } + Some("too-long") => { + validate_sound_effect_prompt(&input) + .expect_err("shared over-limit fixture should be rejected"); + } + _ => {} + } } } @@ -89,6 +108,7 @@ fn sound_effect_duration_accepts_auto_and_frozen_boundaries() { ); for duration_seconds in [ SOUND_EFFECT_DURATION_MIN_SECONDS, + 1.23456789, 7.5, SOUND_EFFECT_DURATION_MAX_SECONDS, ] { diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index b6874d524..953bc4bd9 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -522,18 +522,128 @@ pub enum EditorSoundEffectDurationMode { Manual, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, 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, + schema_version: u8, + user_prompt: String, + actual_prompt: String, + model: String, + duration_mode: EditorSoundEffectDurationMode, + requested_duration_seconds: Option, + actual_duration_seconds: f64, #[serde(rename = "loop")] - pub loop_enabled: bool, + loop_enabled: bool, +} + +impl EditorSoundEffectGenerationMetadataV2 { + pub fn try_new( + user_prompt: String, + actual_prompt: String, + duration_mode: EditorSoundEffectDurationMode, + requested_duration_seconds: Option, + actual_duration_seconds: f64, + loop_enabled: bool, + ) -> Result { + let requested_duration_is_valid = match duration_mode { + EditorSoundEffectDurationMode::Auto => requested_duration_seconds.is_none(), + EditorSoundEffectDurationMode::Manual => requested_duration_seconds + .is_some_and(|duration| duration.is_finite() && (0.5..=30.0).contains(&duration)), + }; + if !requested_duration_is_valid { + return Err("SFX V2 metadata 的时长模式与请求时长不一致"); + } + if !actual_duration_seconds.is_finite() + || actual_duration_seconds <= 0.0 + || actual_duration_seconds > 600.0 + { + return Err("SFX V2 metadata 的实际时长无效"); + } + Ok(Self { + schema_version: 2, + user_prompt, + actual_prompt, + model: EDITOR_SOUND_EFFECT_MODEL.to_string(), + duration_mode, + requested_duration_seconds, + actual_duration_seconds, + loop_enabled, + }) + } + + pub const fn schema_version(&self) -> u8 { + self.schema_version + } + + pub fn user_prompt(&self) -> &str { + self.user_prompt.as_str() + } + + pub fn actual_prompt(&self) -> &str { + self.actual_prompt.as_str() + } + + pub fn model(&self) -> &str { + self.model.as_str() + } + + pub const fn duration_mode(&self) -> EditorSoundEffectDurationMode { + self.duration_mode + } + + pub const fn requested_duration_seconds(&self) -> Option { + self.requested_duration_seconds + } + + pub const fn actual_duration_seconds(&self) -> f64 { + self.actual_duration_seconds + } + + pub const fn loop_enabled(&self) -> bool { + self.loop_enabled + } +} + +impl<'de> Deserialize<'de> for EditorSoundEffectGenerationMetadataV2 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Payload { + schema_version: u8, + user_prompt: String, + actual_prompt: String, + model: String, + duration_mode: EditorSoundEffectDurationMode, + requested_duration_seconds: Option, + actual_duration_seconds: f64, + #[serde(rename = "loop")] + loop_enabled: bool, + } + + let payload = Payload::deserialize(deserializer)?; + if payload.schema_version != 2 { + return Err(D::Error::custom("SFX metadata 只支持 schemaVersion 2")); + } + if payload.model != EDITOR_SOUND_EFFECT_MODEL { + return Err(D::Error::custom(format!( + "SFX V2 metadata 只支持模型 {EDITOR_SOUND_EFFECT_MODEL}" + ))); + } + Self::try_new( + payload.user_prompt, + payload.actual_prompt, + payload.duration_mode, + payload.requested_duration_seconds, + payload.actual_duration_seconds, + payload.loop_enabled, + ) + .map_err(D::Error::custom) + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1559,16 +1669,29 @@ mod tests { #[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 metadata = EditorSoundEffectGenerationMetadataV2::try_new( + "金币落地,轻快可爱".to_string(), + "A bright, cute coin landing chime".to_string(), + EditorSoundEffectDurationMode::Manual, + Some(5.0), + 5.12, + false, + ) + .expect("valid SFX V2 metadata should construct"); + assert_eq!(metadata.schema_version(), 2); + assert_eq!(metadata.model(), EDITOR_SOUND_EFFECT_MODEL); + assert_eq!(metadata.user_prompt(), "金币落地,轻快可爱"); + assert_eq!( + metadata.actual_prompt(), + "A bright, cute coin landing chime" + ); + assert_eq!( + metadata.duration_mode(), + EditorSoundEffectDurationMode::Manual + ); + assert_eq!(metadata.requested_duration_seconds(), Some(5.0)); + assert_eq!(metadata.actual_duration_seconds(), 5.12); + assert!(!metadata.loop_enabled()); let payload = serde_json::to_value(&metadata).expect("metadata should serialize"); assert_eq!( payload, @@ -1584,10 +1707,40 @@ mod tests { }) ); assert_eq!( - serde_json::from_value::(payload) + serde_json::from_value::(payload.clone()) .expect("metadata should deserialize"), metadata ); + + for invalid in [ + json!({ "schemaVersion": 1 }), + json!({ "model": "audio1.0" }), + json!({ + "durationMode": "auto", + "requestedDurationSeconds": 5.0, + }), + json!({ + "durationMode": "manual", + "requestedDurationSeconds": null, + }), + json!({ "actualDurationSeconds": 600.1 }), + ] { + let mut invalid_payload = payload.clone(); + invalid_payload + .as_object_mut() + .expect("metadata payload should be an object") + .extend( + invalid + .as_object() + .expect("invalid override should be an object") + .clone(), + ); + assert!( + serde_json::from_value::(invalid_payload) + .is_err(), + "invalid metadata override should be rejected: {invalid}" + ); + } } #[test] diff --git a/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts index 19c77cbc4..1fcfb148c 100644 --- a/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts +++ b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts @@ -22,6 +22,60 @@ describe('ImageCanvasSoundEffectPresetModel', () => { expect(new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).size).toBe( 52, ); + expect(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).toEqual([ + 'button-tap', + 'operation-confirm', + 'back-cancel', + 'page-transition', + 'notification', + 'operation-error', + 'coin-pickup', + 'item-pickup', + 'reward-received', + 'chest-open', + 'content-unlock', + 'rare-drop', + 'item-craft', + 'character-level-up', + 'quest-complete', + 'achievement-unlocked', + 'challenge-victory', + 'challenge-defeat', + 'character-jump', + 'character-land', + 'light-hit', + 'heavy-hit', + 'attack-swing', + 'attack-hit', + 'block-success', + 'object-break', + 'skill-charge', + 'magic-cast', + 'healing', + 'shield-create', + 'teleport', + 'ice-skill', + 'fire-skill', + 'status-buff', + 'door-open', + 'mechanism-start', + 'lever-trigger', + 'stone-move', + 'portal-open', + 'countdown-warning', + 'casual-cute', + 'retro-arcade', + 'sci-fi-electronic', + 'fantasy-magic', + 'realistic-natural', + 'cartoon-exaggerated', + 'gentle-feedback', + 'strong-feedback', + 'short-feedback', + 'two-stage-rise', + 'clean-prominent', + 'soft-not-harsh', + ]); expect( new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ label }) => label)).size, ).toBe(52); diff --git a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts index 82bb434a7..3fa25dff6 100644 --- a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts +++ b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts @@ -11,19 +11,29 @@ import { describe('ImageCanvasSoundEffectPromptModel', () => { it('matches the shared Unicode canonicalization fixtures', () => { for (const fixture of soundEffectPromptCanonicalizationCases) { - expect(canonicalizeSoundEffectPrompt(fixture.input), fixture.name).toBe( - fixture.prompt, + const repeat = fixture.repeat ?? 1; + const input = `${fixture.prefix ?? ''}${fixture.input.repeat(repeat)}${fixture.suffix ?? ''}`; + const expectedPrompt = fixture.prompt.repeat(repeat); + expect(canonicalizeSoundEffectPrompt(input), fixture.name).toBe( + expectedPrompt, + ); + expect(countSoundEffectPromptCodePoints(input), fixture.name).toBe( + fixture.charCount, ); expect( - countSoundEffectPromptCodePoints(fixture.input), + canonicalizeSoundEffectPrompt(canonicalizeSoundEffectPrompt(input)), fixture.name, - ).toBe(fixture.charCount); - expect( - canonicalizeSoundEffectPrompt( - canonicalizeSoundEffectPrompt(fixture.input), - ), - fixture.name, - ).toBe(fixture.prompt); + ).toBe(expectedPrompt); + if (fixture.validation === 'valid') { + expect(validateSoundEffectPrompt(input).ok, fixture.name).toBe(true); + } else if (fixture.validation === 'too-long') { + expect(validateSoundEffectPrompt(input), fixture.name).toEqual({ + ok: false, + prompt: expectedPrompt, + charCount: fixture.charCount, + reason: 'too-long', + }); + } } }); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 9b5db6ebf..4a7ae1980 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1898,7 +1898,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialDialog={{ id: 'dialog-sound', mode: 'audio-sound-effect', - prompt: ' 金币掉落叮当声 ', + prompt: '\u0085\uFEFF金币掉落叮当声\uFEFF\u0085', status: 'idle', composerOpen: true, soundDurationSeconds: 7, @@ -1920,7 +1920,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { await waitFor(() => { expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( expect.objectContaining({ - prompt: '金币掉落叮当声', + prompt: '\uFEFF金币掉落叮当声\uFEFF', duration: 7, projectId: 'editor-project-audio', assetFolderId: 'project', @@ -1953,6 +1953,33 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); }); + it('rejects an empty canonical sound effect prompt without a fallback', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(generateEditorSoundEffectMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('tracked-dialog').textContent).toBe( + 'audio-sound-effect::failed:open:音效描述不能为空', + ); + }); + expect(screen.getByTestId('tracked-dialog').textContent).not.toContain( + '游戏音效', + ); + }); + it('claims and canonicalizes one background music submission before the first await', async () => { const representativeComplexPromptCase = backgroundMusicPromptCanonicalizationCases.find( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 8239bedf6..c74e99063 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -83,6 +83,7 @@ import { buildImageGenerationSubmissionPlan, resolveGenerationAssetLabel, } from './ImageCanvasGenerationSubmissionModel'; +import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel'; import type { UiAssetExtractionMark, UiAssetExtractionState, @@ -1882,14 +1883,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({ currentBackgroundMusicSubmissionScopeRef.current.version, } : null; + const soundEffectPrompt = + dialog.mode === 'audio-sound-effect' + ? validateSoundEffectPrompt(dialog.prompt) + : null; const normalizedPrompt = backgroundMusicSubmission?.prompt ?? + soundEffectPrompt?.prompt ?? (dialog.prompt.trim() || - (dialog.mode === 'edit' - ? '修改当前图片' - : dialog.mode === 'audio-sound-effect' - ? '游戏音效' - : 'AI 生成图片')); + (dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片')); if (!backgroundMusicSubmission && canvasDialog) { updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ ...currentDialog,