diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 76ffb4e23..7fe3e70ee 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -4456,7 +4456,7 @@ "type": "string", "minLength": 1, "maxLength": 2048, - "description": "用户原始语言音效描述。服务端只删除首尾 Unicode White_Space,并按 Unicode code point 校验 1-2048。" + "description": "用户原始语言音效描述。服务端按 ECMAScript String.trim() 语义删除首尾空白和行终止符(包括 U+FEFF,保留首尾 U+0085),再按 Unicode code point 校验 1-2048。" }, "model": { "type": [ 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 62fa519cc..9eacb421d 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 @@ -87,7 +87,7 @@ T0 已通过,T1–T5 可以按本文依赖顺序进入实现;T0 通过不表 ### Prompt 与 LLM 口径 - `userPrompt` 和 `actualPrompt` 上限均为 2048 Unicode code points。 -- 只删除首尾 Unicode `White_Space`,不做 NFC、内部空白折叠、换行转换、标点替换或静默截断。 +- 只按 ECMAScript `String.trim()` 删除首尾空白和行终止符,包含 `U+FEFF`、保留首尾 `U+0085`;不做 NFC、内部空白折叠、换行转换、标点替换或静默截断。 - 一键优化固定 `gpt-5.6-luna`、`reasoning_effort = medium`;Worker 翻译固定同模型、`reasoning_effort = low`。两类请求分别按各自 2048 Unicode code point 候选上限的 4 倍,固定 `max_output_tokens = 8192`;它是隐藏 reasoning token 与可见 JSON 输出 token 共享的 completion 总预算,不是可见正文保证,也不按实际输入长度缩小。两者均不发送 temperature 或 function tools。 - 翻译 envelope 固定 `prompt / isEnglish / isFaithfulTranslation / isDirectGenerationFormat / hasAddedOrRemovedRequirement`,只接受完整 `response.text` 中的唯一 JSON object。 - `isEnglish = true` 作为 LLM 语义判断,程序侧另外要求:候选至少含一个 Script=Latin 的 alphabetic code point,且所有 alphabetic code point 的 Script 均为 Latin;Common / Inherited 数字、标点、空白和符号允许。 @@ -130,7 +130,7 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏 ### T1:Prompt 规则、52 预设、共享契约与 metadata 基础 -- 实现前后端 Unicode canonicalization、code point 计数、2048 边界和无默认 Prompt 回退。 +- 实现前后端 ECMAScript `String.trim()` 等值 canonicalization、code point 计数、2048 边界和无默认 Prompt 回退。 - 增加 40 + 12 预设纯模型,锁定数量、ID、分类和可见文案。 - 原地演进现有 TypeScript / Rust 音频 DTO:fixed model、nullable 小数 duration、Loop、实际时长和 V2 metadata;不得新增同义 DTO 或平行正式生成契约。 - 实现 External `model` canonicalizer 的纯函数与矩阵测试;实际 OpenAPI / handler 接线属于 T5。 @@ -139,6 +139,8 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏 补充验收(`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` 步进小数时长均有固定断言。 +规则修订(`2026-08-07`):SFX Prompt 边界 canonicalization 改为 ECMAScript `String.trim()`;本条覆盖上段“不得执行原生 `trim()`”的旧口径。TypeScript 直接调用 `String.trim()`,Rust 以等值边界字符集合实现;首尾 `U+FEFF` 删除、首尾 `U+0085` 保留,BGM Prompt 与 External `model` 的 Unicode `White_Space` 规则不变。 + ### T2:一键优化 BFF 和 Worker 翻译 service - 增加登录态 SFX Prompt 优化 BFF,固定 Luna + Medium + `max_output_tokens = 8192` completion 总预算,32 KiB body limit,严格唯一 JSON envelope,不调用音频 provider 或正式计费。 @@ -207,7 +209,7 @@ T5 -> T6 | 层级 | 必要覆盖 | | --- | --- | -| canonical | 空 / 全 Unicode White_Space、U+0085、U+200B、U+FEFF、换行、组合字符、ZWJ emoji、2048 / 2049 | +| canonical | 空 / 全 ECMAScript trim 字符(含 U+FEFF)、首尾 U+0085 保留、U+200B、内部 U+FEFF、换行、组合字符、ZWJ emoji、2048 / 2049 | | 预设 | 40 + 12、ID / label 唯一、文案等值、逗号追加、重复、清快照、超限保文 | | 优化 | Luna + Medium + `max_output_tokens=8192` completion 总预算请求体、唯一 JSON、布尔门禁、length 直接失败、content_filter、无 tool call、无候选泄漏、dialog / scope 迟到响应 | | 翻译 | 每轮 Luna + Low + `max_output_tokens=8192` completion 总预算请求体,中文 / 英文 / 中英混合输入,日文 / 韩文 / 西里尔候选,isEnglish + Script 门禁,2048 / 2049,首轮 length 唯一重试、第二轮 length 最终失败且 provider 0 次 | diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c3d9e9b3d..2f9193ba5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6774,3 +6774,9 @@ - 定价兼容:默认配置、api-server 与 SpacetimeDB 值校验同时要求保留 `audio1.0` 和新增 `eleven_text_to_sound_v2`。已存在的 SpacetimeDB 定价快照仅缺新键时,api-server 从当前受控默认 / override 补入该键后读取;其它缺失模型仍失败。该兼容不修改 schema、不在读取时写库,下一次后台保存自然持久化完整矩阵;队列计费、响应和资产成本继续使用入队冻结价格。 - External v1:Rust DTO / handler、OpenAPI、幂等 canonical payload、compact result 与仓库 Agent Skill 同批演进。model 的省略 / null / 空串 / 纯 Unicode White_Space / 包围空白新模型 / 显式新模型统一入队;旧模型和未知非空值在 enqueue 前返回 `400`。完成结果增加实际 `durationSeconds` 与 Loop,继续隐藏 provider、userPrompt 和 actualPrompt,只暴露稳定结果引用。 - 阶段状态:T1–T5 已完成,可以进入 T6;T6 仍需汇总 mock LLM / ElevenLabs / OSS 失败矩阵、计费退款、端到端等值、BGM 回归、API smoke、旧 Vidu 队列 drain 和发布 / 回滚门禁。T5 未执行真实 LLM、ElevenLabs 或其它付费请求,且没有 SpacetimeDB schema、migration 或 bindings 变更。 + +## 2026-08-07 SFX Prompt 边界 canonicalization 改为 ECMAScript trim + +- 决策:SFX 的 `userPrompt` 与 `actualPrompt` 从首尾 Unicode `White_Space` 规则改为 ECMAScript `String.trim()` 语义。TypeScript 直接使用 `String.trim()`;Rust 以等值边界字符集合实现,不能使用语义不同的 Rust `str::trim()`。因此首尾 `U+FEFF` 删除、首尾 `U+0085` 保留,内部空白、内部 `U+FEFF`、`U+200B`、组合字符和 ZWJ emoji 继续保持原样。 +- 范围:只影响 SFX Prompt 的输入、优化候选、Worker 翻译候选、正式请求、metadata 校验与 ElevenLabs body;BGM Prompt 以及 External v1 `model` 的 Unicode `White_Space` canonicalization 不变。 +- 验证:共享 fixture 锁定 ECMAScript 全部首尾删除字符、`U+FEFF` 边界删除与内部保留、`U+0085` 边界保留、内部空白和 `1 / 2048 / 2049` code point;前后端必须共同消费该 fixture。 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index e0feeb152..ebbedbaa4 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -28,7 +28,7 @@ SFX V2 已完成产品与技术口径冻结,T0 已通过,T1–T5 已完成 ### 生成游戏音效 -- `prompt`:用户在输入框确认的原始语言音效描述,语义为 `userPrompt`,继续复用对外请求和持久化字段 `prompt`。消费动作前只删除首尾 Unicode `White_Space` code point,不改写内部空白、换行、标点、零宽字符或 Unicode 形式;按 Unicode code point 计数,合法范围为 `1-2048`。空值和全空白不得回退“游戏音效”。 +- `prompt`:用户在输入框确认的原始语言音效描述,语义为 `userPrompt`,继续复用对外请求和持久化字段 `prompt`。消费动作前按 ECMAScript `String.trim()` 语义只删除首尾空白和行终止符,包含 `U+FEFF`;不改写内部空白、换行、标点、零宽字符或 Unicode 形式。首尾 `U+0085` 不属于该删除集合。按 Unicode code point 计数,合法范围为 `1-2048`。空值和全空白不得回退“游戏音效”。 - `actualPrompt`:Worker 在正式生成时对冻结的 `userPrompt` 进行统一英文化并严格验收后得到的英文 Prompt,继续复用持久化字段 `actual_prompt`。前端不提交 `actualPrompt`,ElevenLabs 只接收验收通过的 `actualPrompt`。 - `model`:新任务固定 `eleven_text_to_sound_v2`,UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示和其它未迁移调用方的兼容标识,不是新编辑器 SFX 任务的 alias 或 fallback。 - `duration`:`null` 表示自动时长,有限数值表示手动时长。首次打开面板默认处于手动模式,手动值默认 `5s`,范围 `0.5-30s`,UI 步进 `0.1s`。切到自动模式后禁用 slider 但保留最近手动值,关闭自动后恢复该值。服务端只校验有限值与范围,不把 UI 步进扩大成 provider 精度限制。 @@ -47,8 +47,8 @@ SFX V2 已完成产品与技术口径冻结,T0 已通过,T1–T5 已完成 ### Prompt 规范化、计数与字段语义 -- `canonicalUserPrompt = 删除当前输入首尾属于 Unicode White_Space 属性的 code point`。TypeScript 使用 `\p{White_Space}` 边界判定,Rust 使用 `char::is_whitespace`;不得直接使用会额外删除 `U+FEFF` 的 JavaScript `String.trim()` 代替该契约。 -- 内部空格、CR / LF、标点、`U+200B`、`U+FEFF`、组合字符和 ZWJ emoji 原样保留;不做 NFC、空白折叠、换行转换、标点替换或静默截断。`U+0085` 在首尾属于应删除空白。 +- `canonicalUserPrompt = 对当前输入执行 ECMAScript String.trim()`。TypeScript 直接使用 `String.trim()`;Rust 使用等值的 ECMAScript 边界字符集合,不能误用会保留 `U+FEFF`、删除 `U+0085` 的 Rust `str::trim()`。 +- 内部空格、CR / LF、标点、`U+200B`、内部 `U+FEFF`、组合字符和 ZWJ emoji 原样保留;首尾 `U+FEFF` 删除,首尾 `U+0085` 保留。不做 NFC、空白折叠、换行转换、标点替换或静默截断。 - 字符数按 Unicode code point 计算。canonical Prompt 为空时禁用一键优化和正式生成,BFF 也返回 `400 BAD_REQUEST`;`1-2048` 允许优化和生成;超过 2048 时完整保留文本但禁用并拒绝请求。 - 输入期间允许暂存边界空白;点击预设、一键优化、撤销或正式生成时才在同步动作内 canonicalize 并写回输入框。 - 持久化语义固定为 `record.prompt = canonical userPrompt`、`record.actual_prompt = validated actualPrompt`,不新增同义数据库列,不得用英文覆盖 `prompt`。 @@ -490,6 +490,7 @@ POST /api/editor/audios/background-music/prompts/simplifications - 点击 `生成游戏音效` 后出现 SFX V2 面板:文本字段为 `prompt`,显示 `0 / 2048` Unicode code point 计数、52 个固定预设、一键优化、单层撤销、自动 / 手动时长和 Loop,右侧固定显示 `ElevenLabs` 模型胶囊和生成按钮。 - SFX 首次打开自动时长关闭、手动时长为 `5s`、Loop 为 false;手动 slider 范围 `0.5-30s`、步进 `0.1s`,自动模式禁用 slider 并保留最近手动值。 - SFX 面板提交 `model: "eleven_text_to_sound_v2"`、canonical `prompt`、`duration: null | number` 和 `loop: boolean`;服务端只向 ElevenLabs 发送 Worker 验收后的英文 `actualPrompt`,不再调用 Vidu 或 Suno 音效契约。 +- SFX canonical 边界测试覆盖首尾 `U+FEFF` 删除、内部 `U+FEFF` 保留、首尾 `U+0085` 保留、内部空白不折叠,以及 `1 / 2048 / 2049` Unicode code point;TypeScript 与 Rust 结果必须等值。 - SFX 一键优化的预设写入、中文逗号追加、重复点击、清快照、超限保文、AI 成功 / 失败 / 迟到响应、交换撤销和当前 dialog 全控件锁定都按 SFX V2 章节验收。 - 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,右侧固定显示 `Suno` 模型胶囊,不展示 `make_instrumental`。 - 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`。 diff --git a/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json b/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json index ca8f56109..1da486ea6 100644 --- a/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json +++ b/packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json @@ -6,22 +6,22 @@ "charCount": 13 }, { - "name": "unicode-white-space-boundary", + "name": "ecmascript-trim-keeps-u0085-boundary", "input": "\u0085\u2003金币叮当\u00a0\r\n", - "prompt": "金币叮当", - "charCount": 4 + "prompt": "\u0085\u2003金币叮当", + "charCount": 6 }, { - "name": "internal-white-space-preserved", + "name": "internal-white-space-and-u0085-preserved", "input": "\u2003A \n B\u0085", - "prompt": "A \n B", - "charCount": 5 + "prompt": "A \n B\u0085", + "charCount": 6 }, { - "name": "zero-width-and-bom-preserved", - "input": "\u0085\u200b\ufeff\u0085", - "prompt": "\u200b\ufeff", - "charCount": 2 + "name": "bom-is-trimmed-at-boundaries-and-preserved-internally", + "input": "\ufeff\u200b音\ufeff效\ufeff", + "prompt": "\u200b音\ufeff效", + "charCount": 4 }, { "name": "combining-mark-preserved", @@ -36,8 +36,8 @@ "charCount": 3 }, { - "name": "unicode-white-space-only", - "input": "\u0085\u2003\u00a0\r\n", + "name": "ecmascript-trim-only", + "input": "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff", "prompt": "", "charCount": 0 }, @@ -54,9 +54,16 @@ "charCount": 1, "validation": "valid" }, + { + "name": "u0085-is-a-valid-code-point", + "input": "\u0085", + "prompt": "\u0085", + "charCount": 1, + "validation": "valid" + }, { "name": "exactly-2048-code-points", - "prefix": "\u0085", + "prefix": "\ufeff", "input": "声", "suffix": "\u2003", "prompt": "声", diff --git a/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs b/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs index a34182200..eb11e6028 100644 --- a/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_sound_effect_prompt_assist.rs @@ -228,11 +228,11 @@ mod tests { #[test] fn optimization_input_uses_sfx_canonical_boundaries() { assert_eq!( - validate_sound_effect_optimization_input("\u{0085}金币落地\u{2003}") + validate_sound_effect_optimization_input("\u{feff}金币落地\u{2003}") .expect("canonical prompt should pass"), "金币落地" ); - assert!(validate_sound_effect_optimization_input(" \n\u{0085}").is_err()); + assert!(validate_sound_effect_optimization_input(" \n\u{feff}").is_err()); assert!( validate_sound_effect_optimization_input( &"声".repeat(platform_audio::SOUND_EFFECT_PROMPT_MAX_CODE_POINTS) @@ -270,7 +270,7 @@ mod tests { #[test] fn optimization_candidate_accepts_only_full_strict_json_with_han() { - let valid = valid_optimization_candidate("\u{0085}清脆的金币落地声\u{2003}"); + let valid = valid_optimization_candidate("\u{feff}清脆的金币落地声\u{2003}"); assert_eq!( inspect_sound_effect_optimization_candidate_text(&format!("\n{valid}\t"), false), Some(SoundEffectPromptOptimizeResponse { @@ -346,7 +346,7 @@ mod tests { "hasGenerationParameterContent": false, }), json!({ - "prompt": " \u{0085} ", + "prompt": " \u{feff} ", "isDirectWritebackFormat": true, "isContentComplete": true, "hasObviousFragment": false, @@ -394,7 +394,7 @@ mod tests { let (status, _) = post_raw_prompt_request(app.clone(), Some(&token), body, true).await; assert_eq!(status, StatusCode::BAD_REQUEST); } - let (status, _) = post_prompt_request(app.clone(), Some(&token), " \u{0085}", true).await; + let (status, _) = post_prompt_request(app.clone(), Some(&token), " \u{feff}", true).await; assert_eq!(status, StatusCode::BAD_REQUEST); let (status, _) = post_prompt_request(app, Some(&token), "金币落地", true).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); @@ -426,7 +426,7 @@ mod tests { let mock = spawn_mock_llm_server(vec![MockLlmResponse { status_line: "200 OK", body: chat_text_response( - valid_optimization_candidate("\u{0085}清脆明亮的金币落地声\u{2003}"), + valid_optimization_candidate("\u{feff}清脆明亮的金币落地声\u{2003}"), Some("stop"), ), }]); @@ -438,7 +438,7 @@ mod tests { .await; let (status, payload) = - post_prompt_request(app, Some(&token), "\u{0085}金币落地\u{2003}", true).await; + post_prompt_request(app, Some(&token), "\u{feff}金币落地\u{2003}", true).await; assert_eq!(status, StatusCode::OK); assert_eq!(payload["ok"], true); assert_eq!(payload["data"]["prompt"], "清脆明亮的金币落地声"); 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 b55755a0b..51a33d48b 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 @@ -1160,7 +1160,7 @@ mod tests { let mut payloads = Vec::new(); for model in accepted_models { let prepared = prepare_editor_sound_effect_queue_job( - sound_effect_payload("\u{0085}按钮点击声\u{2003}", model, None, false), + sound_effect_payload("\u{feff}按钮点击声\u{2003}", model, None, false), &pricing, ) .expect("accepted model form should prepare"); diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs index a7e197166..db7b743dc 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/sound_effect_translation.rs @@ -184,7 +184,7 @@ mod tests { #[test] fn translation_candidate_accepts_only_full_strict_latin_json() { - let valid = valid_translation_candidate("\u{0085}Clean metallic coin pickup.\u{2003}"); + let valid = valid_translation_candidate("\u{feff}Clean metallic coin pickup.\u{2003}"); assert_eq!( inspect_sound_effect_translation_candidate_text(&format!("\n{valid}\t"), false) .as_deref(), @@ -370,7 +370,7 @@ mod tests { state .editor_agent_llm_client() .expect("client should exist"), - "\u{0085}金币拾取\u{2003}", + "\u{feff}金币拾取\u{2003}", ) .await .expect("second business round should pass"); diff --git a/server-rs/crates/platform-audio/src/elevenlabs.rs b/server-rs/crates/platform-audio/src/elevenlabs.rs index 1a69a3916..0904820d5 100644 --- a/server-rs/crates/platform-audio/src/elevenlabs.rs +++ b/server-rs/crates/platform-audio/src/elevenlabs.rs @@ -289,10 +289,10 @@ mod tests { } #[test] - fn canonical_prompt_helper_still_preserves_non_whitespace_invisibles() { + fn canonical_prompt_helper_uses_ecmascript_trim_boundaries() { assert_eq!( - crate::canonicalize_sound_effect_prompt("\u{0085}\u{feff}sound\u{200b}\u{0085}"), - "\u{feff}sound\u{200b}" + crate::canonicalize_sound_effect_prompt("\u{feff}sound\u{200b}\u{feff}"), + "sound\u{200b}" ); } } diff --git a/server-rs/crates/platform-audio/src/sound_effect_prompt.rs b/server-rs/crates/platform-audio/src/sound_effect_prompt.rs index fee9f80b4..8ead239b7 100644 --- a/server-rs/crates/platform-audio/src/sound_effect_prompt.rs +++ b/server-rs/crates/platform-audio/src/sound_effect_prompt.rs @@ -17,12 +17,29 @@ pub struct ValidatedSoundEffectPrompt<'a> { pub char_count: usize, } -/// 只删除首尾 Unicode `White_Space`,与前端 SFX canonicalizer 保持一致。 +/// 按 ECMAScript `String.trim()` 语义删除首尾空白和行终止符,与前端 SFX +/// canonicalizer 保持一致。内部空白和其它 Unicode scalar value 均保持原样。 /// -/// Rust `char::is_whitespace` 覆盖 U+0085 等 White_Space,同时不会误删 -/// U+200B 与 U+FEFF。内部空白和其它 Unicode scalar value 均保持原样。 +/// Rust 原生 `str::trim()` 与 JavaScript 的集合不同:前者会删除 U+0085、保留 +/// U+FEFF;这里必须反过来,删除首尾 U+FEFF、保留首尾 U+0085。 pub fn canonicalize_sound_effect_prompt(prompt: &str) -> &str { - prompt.trim_matches(char::is_whitespace) + prompt.trim_matches(is_ecmascript_trim_boundary) +} + +fn is_ecmascript_trim_boundary(character: char) -> bool { + matches!( + character, + '\u{0009}'..='\u{000D}' + | '\u{0020}' + | '\u{00A0}' + | '\u{1680}' + | '\u{2000}'..='\u{200A}' + | '\u{2028}'..='\u{2029}' + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' + ) } pub fn sound_effect_prompt_code_point_count(prompt: &str) -> usize { 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 57dacf9e9..456a29b49 100644 --- a/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs +++ b/server-rs/crates/platform-audio/tests/sound_effect_prompt.rs @@ -15,7 +15,7 @@ fn shared_canonicalization_fixture() -> Value { } #[test] -fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() { +fn sound_effect_prompt_matches_shared_ecmascript_trim_fixtures() { for case in shared_canonicalization_fixture() .as_array() .expect("fixture should be an array") @@ -71,7 +71,7 @@ fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() { #[test] fn sound_effect_prompt_uses_the_2048_code_point_boundary_without_truncation() { let maximum = format!( - "\u{0085}{}\u{2003}", + "\u{feff}{}\u{2003}", "😀".repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS) ); let accepted = validate_sound_effect_prompt(&maximum) @@ -94,7 +94,7 @@ fn sound_effect_prompt_uses_the_2048_code_point_boundary_without_truncation() { #[test] fn sound_effect_prompt_rejects_empty_canonical_values_without_a_default() { - for prompt in ["", " \t\r\n", "\u{0085}\u{2003}\u{00a0}"] { + for prompt in ["", " \t\r\n", "\u{feff}\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 不能为空"); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 4b629df58..691a38281 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -991,6 +991,7 @@ describe('ImageCanvasEditorModel', () => { { ...soundEffect, schemaVersion: 1 }, { ...soundEffect, model: 'audio1.0' }, { ...soundEffect, userPrompt: ' 金币落地' }, + { ...soundEffect, userPrompt: '\uFEFF金币落地,轻快可爱' }, { ...soundEffect, actualDurationSeconds: 600.1 }, { ...soundEffect, diff --git a/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts index 1fcfb148c..d3e4f89b5 100644 --- a/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts +++ b/src/components/image-editor/ImageCanvasSoundEffectPresetModel.test.ts @@ -248,15 +248,15 @@ describe('ImageCanvasSoundEffectPresetModel', () => { ]); }); - it('canonicalizes and appends with the frozen Unicode punctuation rule', () => { + it('canonicalizes and appends with the frozen ECMAScript trim rule', () => { const preset = SOUND_EFFECT_PROMPT_PRESETS[40]; - expect(appendSoundEffectPromptPreset('\u0085\u2003', preset)).toBe( + expect(appendSoundEffectPromptPreset('\uFEFF\u2003', preset)).toBe( preset.prompt, ); expect(appendSoundEffectPromptPreset('金币落地', preset)).toBe( `金币落地,${preset.prompt}`, ); - expect(appendSoundEffectPromptPreset('金币落地!\u0085', preset)).toBe( + expect(appendSoundEffectPromptPreset('金币落地!\uFEFF', preset)).toBe( `金币落地!${preset.prompt}`, ); }); diff --git a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts index bf8c399c2..1f06112f1 100644 --- a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts +++ b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.test.ts @@ -39,7 +39,7 @@ describe('ImageCanvasSoundEffectPromptModel', () => { }); it('accepts 2048 code points and rejects 2049 without truncating', () => { - const maximum = `\u0085${'😀'.repeat( + const maximum = `\uFEFF${'😀'.repeat( SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, )}\u2003`; const accepted = validateSoundEffectPrompt(maximum); @@ -59,7 +59,7 @@ describe('ImageCanvasSoundEffectPromptModel', () => { }); it('rejects empty canonical prompts without adding a default description', () => { - for (const prompt of ['', ' \t\r\n', '\u0085\u2003\u00a0']) { + for (const prompt of ['', ' \t\r\n', '\uFEFF\u2003\u00a0']) { expect(validateSoundEffectPrompt(prompt)).toEqual({ ok: false, prompt: '', @@ -74,7 +74,7 @@ describe('ImageCanvasSoundEffectPromptModel', () => { const started = model.beginOperation({ dialogId: 'dialog-a', status: 'optimizing', - prompt: '\u0085金币落地声\u3000', + prompt: '\uFEFF金币落地声\u3000', }); expect(started.started).toBe(true); expect(started.canonicalPrompt).toBe('金币落地声'); diff --git a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts index 2b9503643..fc12ceae9 100644 --- a/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts +++ b/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts @@ -6,9 +6,6 @@ import type { export const SOUND_EFFECT_PROMPT_MAX_CODE_POINTS = 2048; const SOUND_EFFECT_PROMPT_OPERATION_ID_PREFIX = 'sound-effect-prompt-operation'; -const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u; -const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u; - export type SoundEffectPromptValidationResult = | { ok: true; @@ -57,13 +54,11 @@ export function toSoundEffectGenerationDialog( } /** - * 只删除首尾 Unicode `White_Space`。内部空白、组合字符、ZWJ、U+200B、U+FEFF - * 及其它 code point 均原样保留;不能替换为语义不同的原生 `trim()`。 + * 按 JavaScript `String.trim()` 删除首尾 ECMAScript 空白和行终止符。内部空白、组合字符、 + * ZWJ、U+200B 及其它 code point 均原样保留;首尾 U+FEFF 会被删除,U+0085 则保留。 */ export function canonicalizeSoundEffectPrompt(prompt: string) { - return prompt - .replace(LEADING_UNICODE_WHITE_SPACE, '') - .replace(TRAILING_UNICODE_WHITE_SPACE, ''); + return prompt.trim(); } export function countSoundEffectPromptCodePoints(prompt: string) { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index f81210b13..96da5148e 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1938,7 +1938,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialDialog={{ id: 'dialog-sound', mode: 'audio-sound-effect', - prompt: '\u0085\uFEFF金币掉落叮当声\uFEFF\u0085', + prompt: '\uFEFF金币掉落叮当声\uFEFF', status: 'idle', composerOpen: true, soundDurationSeconds: 7, @@ -1960,7 +1960,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { await waitFor(() => { expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( expect.objectContaining({ - prompt: '\uFEFF金币掉落叮当声\uFEFF', + prompt: '金币掉落叮当声', duration: 7, projectId: 'editor-project-audio', assetFolderId: 'project', @@ -1999,7 +1999,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialDialog={{ id: 'dialog-sound-empty', mode: 'audio-sound-effect', - prompt: '\u0085 \n\t\u0085', + prompt: '\uFEFF \n\t\uFEFF', status: 'idle', composerOpen: true, }} @@ -2031,7 +2031,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialDialog={{ id: 'dialog-sfx-claim', mode: 'audio-sound-effect', - prompt: '\u0085循环环境声\u3000', + prompt: '\uFEFF循环环境声\u3000', status: 'idle', composerOpen: true, soundModel: 'eleven_text_to_sound_v2', diff --git a/src/components/image-editor/useImageCanvasSoundEffectPromptAssist.test.tsx b/src/components/image-editor/useImageCanvasSoundEffectPromptAssist.test.tsx index 7ed310d39..2418e14e7 100644 --- a/src/components/image-editor/useImageCanvasSoundEffectPromptAssist.test.tsx +++ b/src/components/image-editor/useImageCanvasSoundEffectPromptAssist.test.tsx @@ -148,7 +148,7 @@ describe('useImageCanvasSoundEffectPromptAssist', () => { const { result } = renderHook(() => useHarness({ initialDialogs: [ - createDialog('dialog-a', '\u0085金币落地声\u3000', { + createDialog('dialog-a', '\uFEFF金币落地声\u3000', { status: 'failed', errorMessage: '旧错误', }), @@ -319,7 +319,7 @@ describe('useImageCanvasSoundEffectPromptAssist', () => { const { result } = renderHook(() => useHarness({ initialDialogs: [ - createDialog('dialog-a', ' \u0085循环环境声\u3000', { + createDialog('dialog-a', ' \uFEFF循环环境声\u3000', { soundDurationMode: 'auto', soundDurationSeconds: 7.3, soundLoop: true,