统一SFX提示词 trim 规范化

将 SFX Prompt 首尾规范化改为 ECMAScript String.trim 语义并同步 Rust 等值实现

更新共享边界 fixture、前后端与 Worker 回归测试

同步权威设计、实施计划、决策日志和 External OpenAPI 描述
This commit is contained in:
2026-08-07 07:27:52 +00:00
parent 392741215c
commit 5c316fc680
17 changed files with 89 additions and 60 deletions
@@ -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": [
@@ -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 均为 LatinCommon / Inherited 数字、标点、空白和符号允许。
@@ -130,7 +130,7 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏
### T1Prompt 规则、52 预设、共享契约与 metadata 基础
- 实现前后端 Unicode canonicalization、code point 计数、2048 边界和无默认 Prompt 回退。
- 实现前后端 ECMAScript `String.trim()` 等值 canonicalization、code point 计数、2048 边界和无默认 Prompt 回退。
- 增加 40 + 12 预设纯模型,锁定数量、ID、分类和可见文案。
- 原地演进现有 TypeScript / Rust 音频 DTOfixed 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 次 |
@@ -6774,3 +6774,9 @@
- 定价兼容:默认配置、api-server 与 SpacetimeDB 值校验同时要求保留 `audio1.0` 和新增 `eleven_text_to_sound_v2`。已存在的 SpacetimeDB 定价快照仅缺新键时,api-server 从当前受控默认 / override 补入该键后读取;其它缺失模型仍失败。该兼容不修改 schema、不在读取时写库,下一次后台保存自然持久化完整矩阵;队列计费、响应和资产成本继续使用入队冻结价格。
- External v1Rust 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 bodyBGM Prompt 以及 External v1 `model` 的 Unicode `White_Space` canonicalization 不变。
- 验证:共享 fixture 锁定 ECMAScript 全部首尾删除字符、`U+FEFF` 边界删除与内部保留、`U+0085` 边界保留、内部空白和 `1 / 2048 / 2049` code point;前后端必须共同消费该 fixture。
@@ -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 pointTypeScript 与 Rust 结果必须等值。
- SFX 一键优化的预设写入、中文逗号追加、重复点击、清快照、超限保文、AI 成功 / 失败 / 迟到响应、交换撤销和当前 dialog 全控件锁定都按 SFX V2 章节验收。
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,右侧固定显示 `Suno` 模型胶囊,不展示 `make_instrumental`
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`
@@ -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": "声",
@@ -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"], "清脆明亮的金币落地声");
@@ -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");
@@ -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");
@@ -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}"
);
}
}
@@ -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 {
@@ -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 不能为空");
@@ -991,6 +991,7 @@ describe('ImageCanvasEditorModel', () => {
{ ...soundEffect, schemaVersion: 1 },
{ ...soundEffect, model: 'audio1.0' },
{ ...soundEffect, userPrompt: ' 金币落地' },
{ ...soundEffect, userPrompt: '\uFEFF金币落地,轻快可爱' },
{ ...soundEffect, actualDurationSeconds: 600.1 },
{
...soundEffect,
@@ -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}`,
);
});
@@ -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('金币落地声');
@@ -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) {
@@ -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',
@@ -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,