diff --git a/docs/README.md b/docs/README.md index b9a0be0c9..518c38c74 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,8 @@ - [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md) - [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md) +- [画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md) +- [BGM 提示词优化 T6 测试与发布门禁](./【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md) - [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) - [图片画布撤销范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 52495c2f6..2153d3059 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5982,6 +5982,16 @@ --- +## 2026-08-05 BGM 正式提交保持站内 canonical 载荷与分层回调所有权 + +- 决策:登录态站内 BGM queue / inline 在分流前生成唯一 canonical payload,queue serializer 与 Suno body 只消费其中的 canonical `gptDescriptionPrompt` 并固定 `makeInstrumental=true`。正式 BGM POST 遇到 retryable HTTP 状态或 transport error 不由客户端自动重发,避免一次点击产生重复任务或扣费。 +- 回调所有权:正式任务接受后,钱包刷新只校验账号;任务列表通知同时校验账号与项目;dialog、canvas、asset 和 layer 写回校验账号、项目、scope version 与原 BGM dialog。dialog 删除或同账号切项目不应阻止账号级钱包刷新,账号切换即使暂时保留相同 project ID 也不得触发旧账号的任务列表回调。 +- 外部边界:上述 canonical payload 只属于登录态站内链路。External v1 继续保留调用方原始 BGM payload,并按原始 payload 执行既有 Idempotency-Key 等值语义;不能把 canonical 等价值误判为相同重放。 +- 影响范围:图片画布 BGM 正式提交与回调门禁;不新增持久提交锁、自动重试、计费设计或 External v1 契约变化。 +- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 + +--- + ## 2026-08-04 BGM 撤销按钮按快照存在性显示、按面板锁定禁用 - 背景:需求《BGM生成优化需求 V1.0》第三节要求“AI 开始处理”时撤销按钮显示但禁用,权威设计也只要求处理中禁用撤销,但没有写明可见性;T4 界面方案据此把渲染条件收窄为“存在可撤销快照”,而状态模型在发起 AI 操作时会把可撤销快照转为本次临时快照,两者叠加会让撤销按钮在处理期间消失,与需求不一致。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 86144242b..72e5a5c4f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4106,3 +4106,10 @@ - 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 `Host`、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。 - 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize`、`tools/list`、`resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。 - 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 + +## 异步任务接受后的刷新回调不能统一套用 dialog 所有权(2026-08-05) + +- 现象:正式生成任务已被后端接受,用户随后删除 dialog 或切换项目,任务仍继续并可能扣费,但钱包和任务列表没有刷新;反向问题是账号切换时若 project ID 暂时相同,旧任务可能刷新新账号的任务列表。 +- 原因:把 dialog / canvas 的完整 UI 所有权同时用于账号级钱包和账号内项目级任务列表,或者任务列表只比较 project ID,没有校验账号。 +- 处理:按副作用分层校验。钱包只比较账号;任务列表比较账号加项目;dialog、canvas、asset 和 layer 写回继续比较账号、项目、scope version 与原 dialog。正式请求已接受后,删除 UI 状态不等于取消后端任务。 +- 验证:分别覆盖删除 dialog、同账号切项目、账号 A 切到账号 B 且 project ID 保持相同,以及原账号原项目原 dialog 仍有效的正常回写。 diff --git a/docs/【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md b/docs/【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md new file mode 100644 index 000000000..b07bbd139 --- /dev/null +++ b/docs/【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md @@ -0,0 +1,146 @@ +# BGM 生成提示词优化 T6 测试与发布门禁实施记录 + +日期:`2026-08-05` + +状态:`实施完成,待提交;当前工作树门禁通过` + +## 文档定位 + +本文承接 BGM 生成提示词优化 V1.0 的 T6 实施边界、实际测试覆盖和发布门禁记录。产品与技术规则以[画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md)为准;长期稳定边界同步记录在 `docs/project-memory/shared-memory/decision-log.md` 和 `pitfalls.md`。 + +本文不重新解释原始需求,不把测试便利转化为产品规则,也不作为生产部署授权。 + +## 实施基线与边界 + +- T0–T5 已完成并提交;T5 提交为 `3b69b4886`。 +- 用户已于 2026-08-05 完成人工全链路浏览器测试,并确认未发现阻断性问题。 +- 当前分支通过 merge commit `69426ee61` 包含 `origin/master@0cc257ce6`;实施结束时相对该 ref 为 `0 behind / 12 ahead`。 +- T6 只补测试、共用 fixture、文档和发布门禁。唯一生产代码调整是把既有 inline BGM 响应字段原样抽为同文件私有构造函数,供生产路径与测试共同调用。 + +明确未做: + +- 不修改 BGM / SFX UI、Prompt 业务规则、助手模板、字符或 body 上限、限流、埋点、价格、计费、队列、重试或持久化语义。 +- 不新增 Idempotency-Key、服务端 dedupe、持久提交锁或跨页面恢复机制。 +- 不修改 SpacetimeDB schema、migration、bindings 或表目录。 +- 不修改 External v1 路由、DTO、状态码、异步语义、OpenAPI 或 worker。 +- 不修改 `platform-llm` 原文日志策略。 +- 不挂载或清理历史 `vector_engine_audio_generation/tests.rs`。 +- 不执行生产部署,也不重复创建可能扣费的真实 BGM 任务。 + +## 实际实施 + +### 共用 canonicalization 向量 + +新增: + +```text +packages/shared/test-fixtures/background-music-prompt-canonicalization.json +``` + +共 10 个 case,由 TypeScript 与 Rust 共同消费,覆盖: + +- 完整 Unicode `White_Space` 边界和 U+0085; +- 内部 LF、CRLF 与空白保持; +- U+200B、U+FEFF 保持; +- 组合字符不做 NFC; +- ZWJ emoji 按 code point 计数; +- 标点、数字、ASCII 与 canonicalization 幂等性。 + +代表性 case `representative-complex` 的 canonical `charCount=16`、`effectiveCharCount=13`。同一 case 用于前端正式请求、成功结果 layer、client JSON body、站内 queue serializer、inline 响应和 Suno body 等值断言。 + +### 前端与 SFX 回归 + +只修改既有测试文件: + +- `ImageCanvasBackgroundMusicPromptModel.test.ts` +- `useImageCanvasGenerationSubmissionWorkflow.test.tsx` +- `ImageCanvasGenerationSubmissionModel.test.ts` +- `editorProjectClient.test.ts` + +新增或收紧的证据: + +- Prompt model 对全部共用 fixture 逐项断言 canonical Prompt、总字符数、有效字符数和幂等性。 +- BGM 正式请求与成功 layer 的 `prompt`、`actualPrompt`、唯一 `gpt_description_prompt` 均等于代表性 canonical Prompt。 +- 正式 BGM client JSON body 保持 canonical Prompt 原值。 +- SFX 全空白 Prompt 继续回退“游戏音效”,并保持 `audio1.0` 与默认 5 秒。 + +未修改前端生产实现。 + +### 后端与平台回归 + +修改: + +- `server-rs/crates/platform-audio/tests/vector_engine_audio.rs` +- `server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs` + +覆盖: + +- `platform-audio` 消费全部共用 fixture,并保留 BGM 长度、资格和 SFX 回归。 +- Suno body 继续精确包含 `mv`、`gpt_description_prompt`、`make_instrumental` 三个字段。 +- 已挂载 generation 模块覆盖 BGM 空字符串、全 Unicode 空白、1 / 200 / 201 code point。 +- 登录态 queue serializer 与 inline 完成响应保持 canonical Prompt 等值。 +- 已挂载 SFX 测试覆盖唯一 `audio1.0`、Prompt 规范化、1500 / 1501、2 / 10 / 1 / 11 秒和动态价格。 + +生产路径只新增同文件私有 `build_editor_background_music_generate_response` 抽取;字段、DTO、分支和业务语义不变。 + +## 验证结果 + +|门禁|结果| +|---|---| +|前端 11 个定向测试文件|`259/259`| +|shared-contracts BGM DTO|`1/1`| +|`platform-audio`|`29/29`| +|`platform-llm` 未完成原因与普通文本降级|两个定向测试均通过| +|`api-server background_music`|`36/36`| +|已挂载 generation 模块|`6/6`| +|`editor_sound_effect`|`2/2`,不再是空过滤器| +|External v1|`7/7`| +|External BGM|`2/2`| +|`cargo check -p api-server --all-targets`|通过| +|TypeScript typecheck|通过| +|变更文件 ESLint|通过| +|Rust 格式|通过| +|编码检查|5190 个文件通过| +|`git diff --check`|通过| + +Rust 输出只有既有 dead-code warning,没有新增失败。 + +## 运行态与人工门禁 + +本轮实际验证: + +|服务|地址|门禁|结果| +|---|---|---|---| +|SpacetimeDB|`http://127.0.0.1:3101`|`GET /v1/ping`|HTTP 200| +|BgFilter worker|`http://127.0.0.1:8083`|`GET /readyz`|HTTP 200| +|api-server|`http://127.0.0.1:8082`|`GET /healthz`|HTTP 200| + +API 安全响应摘要: + +```json +{"ok":true,"service":"genarrative-api-server"} +``` + +本轮启动的进程树已按归属清理,三个端口均已关闭。 + +人工门禁如实记录为:`2026-08-05,用户人工全链路浏览器测试完成,未发现阻断性问题`。T6 没有把该结论扩写为未提供的逐项观察数据,也没有重复创建可能扣费的正式任务。 + +## 发布判定与交接 + +当前工作树相对已核对的 `origin/master@0cc257ce6` 满足 T6 本地发布门禁: + +- TypeScript 与 Rust 共同消费同一 canonicalization fixture。 +- 前端、shared-contracts、Prompt 助手、generation、`platform-llm`、`platform-audio`、SFX 和 External v1 定向门禁通过。 +- 跨层等值证据使用同一代表性 Prompt,没有隐藏字段或二次改写。 +- API 健康检查与用户人工浏览器门禁通过。 +- diff 不包含原始需求文件、SpacetimeDB schema、External OpenAPI 或 `platform-llm` 日志策略修改。 + +当前尚未提交。提交后应在真实提交上复跑相同门禁;后续 push、PR、四个 required jobs 和生产部署是独立动作,不由 T6 自动执行。 + +## BGM 助手模型试验 + +2026-08-05 起,补全和简化的请求级模型改为 `gpt-5.6-luna`;画布 Agent 其它调用继续使用 `gpt-5.4-mini`。该调整只作用于两个 BGM Prompt 助手路由,不改变共享编辑器 Agent 的默认模型。 + +- 本地 API mock 链路 `27/27` 通过,并确认请求 body 的 `model` 为 `gpt-5.6-luna`。 +- 真实 VectorEngine smoke 已尝试:`GET /v1/models` 与 `POST /v1/chat/completions` 均在建立 HTTP 连接前以 `fetch failed` 结束,没有返回状态码或模型响应;该结果只能说明当前环境网络不可达,不能判定 `gpt-5.6-luna` 被上游拒绝或支持。 +- 网络恢复后重试成功:补全和简化各发送一条最小 Chat Completions 请求,均返回 HTTP 200、`model=gpt-5.6-luna`、`finish_reason=stop`,并解析出完整四字段 JSON object;补全候选 `126` code points,简化候选 `79` code points。由此确认当前模型和两条助手请求形态可以跑通。 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index cd257d788..48f8ade15 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -2,7 +2,7 @@ 日期:`2026-06-18` -更新时间:`2026-08-04` +更新时间:`2026-08-05` ## 范围 @@ -122,7 +122,7 @@ ### AI 补全 - 点击 AI 补全时先按统一规则规范化输入框首尾空白并同步写回;至少 2 个有效字符且总字符数不超过 200 时,前端才把这份可见最终 Prompt 传给登录态内部 BFF。 -- 默认模型使用现有编辑器 Agent LLM 配置中的 `gpt-5.4-mini`;Prompt 助手复用现有 `LlmClient`,不建立新的平台 LLM 能力。 +- Prompt 助手当前使用专用请求模型 `gpt-5.6-luna`;画布 Agent 本身仍使用现有编辑器 Agent LLM 配置中的 `gpt-5.4-mini`。两者复用现有 `LlmClient`,不建立新的平台 LLM 能力。 - 服务端模板必须要求:保留用户明确的主题、场景、风格、情绪、乐器、能量、韵律、时长、循环和避免项;按场景选择性补足场景、氛围、能量、韵律、乐器、旋律、声音设计、循环和避免项,不为凑全方向堆砌形容词。 - 用户描述已足够完整时,只补充一至两个与主题匹配的具体声音细节。发现冲突时,优先级为“明确避免项和限制 > 明确玩法用途与场景 > 风格、情绪、能量与韵律 > AI 补充细节”。 - 内部 envelope 的 `prompt` 字段只允许包含一条可直接写回输入框的中文 BGM Prompt;候选文本本身不得包含解释、标题、Markdown、JSON、代码块、具体艺人或歌曲模仿要求。 @@ -198,6 +198,7 @@ idle - 锁只作用于当前 BGM dialog,不锁整个画布、其它 generation dialog、画布拖动、缩放、图层操作或其它编辑能力。 - 后端拒绝正式提交时,解除锁并保留已经写回的 canonical Prompt 与提交前已有撤销快照,继续使用现有正式生成错误展示。原 dialog 仍是当前面板时恢复面板;用户已归档或切走时只记录失败并保持关闭,不自动激活旧面板。 - 后端接受请求并创建正式生成任务后,`submitting` 结束;原账号、项目和 dialog 仍匹配时进入现有 `queued/generating` 占位并隐藏输入 composer。若 dialog 已删除或账号 / 项目已切换,正式任务继续,但旧回调不得按同名 dialog ID 写入新 scope。 +- 正式任务接受后的异步回调按副作用作用域分别校验:钱包刷新只校验原账号仍是当前账号;任务列表通知同时校验原账号和原项目仍是当前账号与项目;dialog、canvas、asset 和 layer 写回继续校验账号、项目、scope version 与原 BGM dialog。删除 dialog 不得阻止同账号钱包刷新或同账号同项目任务列表通知,切换账号不得让旧任务触发新账号页面回调。 - “提交成功”仅表示后端已接受请求并创建正式任务,不表示 Suno 已完成音乐生成。 - 上述“接受后切占位”以现役默认 queue 模式的任务创建响应为观察点;兼容 inline 模式没有中间接受响应,沿用现有 HTTP 终态响应作为客户端可观察结算点,不为此新增协议。 @@ -324,7 +325,7 @@ POST /api/editor/audios/background-music/prompts/simplifications - 在 `api-server` 增加登录态内部 BGM Prompt 助手 BFF: - `POST /api/editor/audios/background-music/prompts/completions` - `POST /api/editor/audios/background-music/prompts/simplifications` -- Prompt 助手 BFF 在入站和 LLM 候选出站边界执行 BGM canonicalization;服务端字符数、0 / 1 / 2 个有效字符规则、正式生成 200 字限制和简化 201–2000 字资格都基于 canonical Prompt。助手使用现有编辑器专用 LLM client、`gpt-5.4-mini` 默认配置和显式 OpenAI Chat 协议;补全固定执行一个业务语义轮,简化按 `180 -> 170` 最多两个业务语义轮,并按“一键简化”章节冻结 `originalPrompt`、派生每轮 `currentPrompt`。`LlmClient` 在单轮内部执行的 transport retry 不计入业务语义轮数,简化第一轮 transport、超时或上游失败不进入 170 字轮。服务端负责模板组装、在正文解析或候选提取前检查 `finish_reason`、canonical 字符校验、对完整 `response.text` 中单个 JSON object 的 `serde_json` 全量解析、补全与简化共用的内部 envelope 校验、执行格式 / 完整性 / 残句三个布尔判断和现有 API 错误 envelope;不自行猜测三个语义判断,也不向客户端返回未通过候选。助手不发送 function tools,不接受 tool call,不从代码块或解释中截取 JSON,不自动修复,也不做运行时双协议 fallback。 +- Prompt 助手 BFF 在入站和 LLM 候选出站边界执行 BGM canonicalization;服务端字符数、0 / 1 / 2 个有效字符规则、正式生成 200 字限制和简化 201–2000 字资格都基于 canonical Prompt。助手使用现有编辑器专用 LLM client、`gpt-5.6-luna` 请求模型和显式 OpenAI Chat 协议;画布 Agent 其它调用仍使用 `gpt-5.4-mini`。补全固定执行一个业务语义轮,简化按 `180 -> 170` 最多两个业务语义轮,并按“一键简化”章节冻结 `originalPrompt`、派生每轮 `currentPrompt`。`LlmClient` 在单轮内部执行的 transport retry 不计入业务语义轮数,简化第一轮 transport、超时或上游失败不进入 170 字轮。服务端负责模板组装、在正文解析或候选提取前检查 `finish_reason`、canonical 字符校验、对完整 `response.text` 中单个 JSON object 的 `serde_json` 全量解析、补全与简化共用的内部 envelope 校验、执行格式 / 完整性 / 残句三个布尔判断和现有 API 错误 envelope;不自行猜测三个语义判断,也不向客户端返回未通过候选。助手不发送 function tools,不接受 tool call,不从代码块或解释中截取 JSON,不自动修复,也不做运行时双协议 fallback。 - `finish_reason` 检查复用并公开 `platform-llm` 现有 API-kind-aware 未完成原因 predicate;不得在 `LlmClient` 全局拒绝普通纯文本响应,也不得改变其它调用方既有的长文本降级行为。 - 两个助手路由使用各自的 `32 KiB` body limit,并在 `tracking.rs` 中注册上述 User-scope 成功事件;不增加助手专属限流器、本地额度计数或功能级 `429`。 - Prompt 助手继续复用 `LlmClient` 现有失败原文日志行为。本需求不增加请求级日志开关、脱敏、metadata-only 模式或相关上线门禁。 @@ -364,7 +365,7 @@ POST /api/editor/audios/background-music/prompts/simplifications - 撤销按钮按“单层撤销”一节的矩阵逐行验收:初始与无快照时隐藏;AI 处理期间显示并禁用,且仍在可访问树中,不得用隐藏或视觉伪装代替禁用;成功后启用,失败后隐藏,手动编辑后仍启用,点击预设后隐藏;`submitting` 期间有快照显示并禁用、无快照隐藏,解除锁定后按快照恢复启用或隐藏;连续点击撤销在两个版本间互换且保持启用。 - AI 操作的旧响应、关闭 dialog 后的响应或其它 dialog 的响应不得覆盖当前 Prompt;同一按钮双击只产生一个有效助手请求。 - BGM 点击生成后在首个 `await` 前同步锁定当前 dialog;同一 dialog 快速重复点击只产生一次正式请求、一个生成任务和一次扣费,retryable HTTP 状态或 transport error 也不由 client 自动重发,不锁整个画布或其它 dialog。 -- BGM 提交期间归档或切走面板不会取消已发出的正式请求;失败时旧面板不抢回焦点。删除 dialog 或切换账号 / 项目后,后端已经创建的正式任务继续处理,但旧异步回调不得修改新 scope 的同 ID dialog 或本地结果层;本需求不新增跨 scope 的恢复或刷新机制。 +- BGM 提交期间归档或切走面板不会取消已发出的正式请求;失败时旧面板不抢回焦点。删除 dialog 或切换账号 / 项目后,后端已经创建的正式任务继续处理;钱包回调只允许作用于原账号仍为当前账号的页面,任务列表回调只允许作用于原账号与原项目仍为当前账号与项目的页面,dialog / canvas / asset / layer 写回还必须匹配原 scope version 与原 BGM dialog。本需求不新增跨 scope 的恢复或刷新机制。 - BGM 正式提交会删除首尾 Unicode `White_Space` 并同步写回输入框,不回退默认 Prompt;首尾 U+0085 等 `White_Space` 被删除,内部空格和 LF / CRLF 原样保留,U+200B、U+FEFF、组合字符和 ZWJ emoji 不被误删。canonical Prompt 在输入框、BFF、队列载荷、Suno body、生成记录和结果响应中完全一致,且没有用户不可见的前缀、后缀或模板。 - BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。 - BGM 助手入口测试覆盖 canonical 2000 字允许简化、2001 字返回 `400` 且不调用 LLM;两个助手路由 body 超过 `32 KiB` 时返回 `413`;连续合法请求不因本功能新增限流器返回 `429`。 diff --git a/packages/shared/test-fixtures/background-music-prompt-canonicalization.json b/packages/shared/test-fixtures/background-music-prompt-canonicalization.json new file mode 100644 index 000000000..9cd12d2f9 --- /dev/null +++ b/packages/shared/test-fixtures/background-music-prompt-canonicalization.json @@ -0,0 +1,72 @@ +[ + { + "name": "empty", + "input": "", + "prompt": "", + "charCount": 0, + "effectiveCharCount": 0 + }, + { + "name": "unicode-white-space-only", + "input": "\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000", + "prompt": "", + "charCount": 0, + "effectiveCharCount": 0 + }, + { + "name": "unicode-white-space-boundaries", + "input": "\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000A \u0009\u000D\u000A\u0085\u00A0\u3000B\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000", + "prompt": "A \u0009\u000D\u000A\u0085\u00A0\u3000B", + "charCount": 9, + "effectiveCharCount": 2 + }, + { + "name": "u0085-boundary-with-non-white-invisibles", + "input": "\u0085\u200B内容\uFEFF\u0085", + "prompt": "\u200B内容\uFEFF", + "charCount": 4, + "effectiveCharCount": 4 + }, + { + "name": "non-white-invisibles-at-boundaries", + "input": "\u200B\uFEFF", + "prompt": "\u200B\uFEFF", + "charCount": 2, + "effectiveCharCount": 2 + }, + { + "name": "combining-sequence-without-nfc", + "input": "\u0020\u0085e\u0301\u3000", + "prompt": "e\u0301", + "charCount": 2, + "effectiveCharCount": 2 + }, + { + "name": "zwj-emoji", + "input": "\u00A0👨‍👩‍👧‍👦\u3000", + "prompt": "👨‍👩‍👧‍👦", + "charCount": 7, + "effectiveCharCount": 7 + }, + { + "name": "internal-crlf-and-white-space", + "input": "\u0085A \u0009\u000D\u000A\u0085\u00A0\u3000B\u0085", + "prompt": "A \u0009\u000D\u000A\u0085\u00A0\u3000B", + "charCount": 9, + "effectiveCharCount": 2 + }, + { + "name": "punctuation-digits-and-ascii", + "input": "\u0020\u0085。1A\u3000", + "prompt": "。1A", + "charCount": 3, + "effectiveCharCount": 3 + }, + { + "name": "representative-complex", + "input": "\u0085\u2003e\u0301\u000D\u000A👨‍👩‍👧‍👦 \u200B\uFEFF森林\u00A0", + "prompt": "e\u0301\u000D\u000A👨‍👩‍👧‍👦 \u200B\uFEFF森林", + "charCount": 16, + "effectiveCharCount": 13 + } +] diff --git a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs index fac8eba53..2a0927048 100644 --- a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs @@ -4,7 +4,7 @@ use axum::{ http::StatusCode, response::Response, }; -use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmClient, LlmMessage, LlmRunRequest, LlmRunResponse}; +use platform_llm::{LlmClient, LlmMessage, LlmRunRequest, LlmRunResponse}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::assets::{ @@ -25,6 +25,7 @@ use crate::{ }; const BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS: u32 = 1024; +const BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL: &str = "gpt-5.6-luna"; const BACKGROUND_MUSIC_PROMPT_MAX_CHARS: usize = platform_audio::SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS; const BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS: usize = @@ -210,7 +211,7 @@ fn build_background_music_prompt_llm_request( LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) - .with_model(EDITOR_AGENT_GPT5_MODEL) + .with_model(BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL) .with_openai_chat() .with_max_output_tokens(BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS) } @@ -466,7 +467,7 @@ mod tests { let request = build_background_music_prompt_llm_request("系统".to_string(), "用户".to_string()); - assert_eq!(request.model.as_deref(), Some(EDITOR_AGENT_GPT5_MODEL)); + assert_eq!(request.model.as_deref(), Some("gpt-5.6-luna")); assert_eq!(request.api_kind, LlmApiKind::OpenAiChat); assert_eq!( request.max_output_tokens, @@ -875,7 +876,10 @@ mod tests { .request_line .starts_with("POST /v1/chat/completions HTTP/1.1") ); - assert_eq!(requests[0].body["model"], EDITOR_AGENT_GPT5_MODEL); + assert_eq!( + requests[0].body["model"], + BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL + ); assert!(requests[0].body.get("tools").is_none()); assert!(requests[0].body.get("tool_choice").is_none()); let input = user_message_json(&requests[0].body); @@ -1431,7 +1435,7 @@ mod tests { } json!({ "id": "chat_bgm_prompt_assist", - "model": EDITOR_AGENT_GPT5_MODEL, + "model": BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL, "choices": [choice], }) .to_string() 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 53c454994..7cd993e20 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 @@ -529,6 +529,35 @@ pub(crate) async fn enqueue_editor_background_music_generation_for_owner( .await } +fn build_editor_background_music_generate_response( + audio_src: String, + generated: shared_contracts::creation_audio::GeneratedAudioAssetResponse, + normalized: NormalizedEditorBackgroundMusicRequest, + project: Option, + resource: Option, + asset: Option, +) -> assets::EditorAudioGenerateResponse { + assets::EditorAudioGenerateResponse { + ok: true, + audio_src, + object_key: generated.object_key, + asset_object_id: generated.asset_object_id, + width: EDITOR_AUDIO_WIDTH, + height: EDITOR_AUDIO_HEIGHT, + source_type: "generated".to_string(), + prompt: normalized.gpt_description_prompt.clone(), + actual_prompt: Some(normalized.gpt_description_prompt), + model: platform_audio::SUNO_DEFAULT_MODEL.to_string(), + task_id: generated.task_id, + price_mud_points: normalized.price_mud_points, + audio_kind: "background-music".to_string(), + project, + resource, + asset, + queue_state: None, + } +} + pub(crate) async fn generate_editor_background_music_for_owner( state: AppState, request_context: RequestContext, @@ -586,6 +615,7 @@ pub(crate) async fn generate_editor_background_music_for_owner( .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let audio_src = generated .audio_src + .clone() .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址")) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let (resource, asset) = persist_editor_generated_media_asset( @@ -678,25 +708,14 @@ pub(crate) async fn generate_editor_background_music_for_owner( Ok(json_success_body( Some(&request_context), - assets::EditorAudioGenerateResponse { - ok: true, + build_editor_background_music_generate_response( audio_src, - object_key: generated.object_key, - asset_object_id: generated.asset_object_id, - width: EDITOR_AUDIO_WIDTH, - height: EDITOR_AUDIO_HEIGHT, - source_type: "generated".to_string(), - prompt: normalized.gpt_description_prompt.clone(), - actual_prompt: Some(normalized.gpt_description_prompt), - model: platform_audio::SUNO_DEFAULT_MODEL.to_string(), - task_id: generated.task_id, - price_mud_points: normalized.price_mud_points, - audio_kind: "background-music".to_string(), - project: completed_project, + generated, + normalized, + completed_project, resource, asset, - queue_state: None, - }, + ), )) } @@ -727,15 +746,38 @@ fn editor_audio_bad_request(message: impl Into) -> AppError { #[cfg(test)] mod tests { + use serde::Deserialize; use serde_json::Value; use shared_contracts::assets; use super::{ - CanonicalEditorBackgroundMusicSubmissionPayload, normalize_editor_background_music_request, - prepare_editor_background_music_queue_job, + CanonicalEditorBackgroundMusicSubmissionPayload, NormalizedEditorBackgroundMusicRequest, + build_editor_background_music_generate_response, normalize_editor_background_music_request, + normalize_editor_sound_effect_request, prepare_editor_background_music_queue_job, }; use crate::editor_generation_queue::serialize_editor_generation_job_payload_for_test; + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + struct BackgroundMusicPromptFixture { + name: String, + input: String, + prompt: String, + char_count: usize, + effective_char_count: usize, + } + + fn background_music_prompt_fixture(name: &str) -> BackgroundMusicPromptFixture { + let fixtures: Vec = serde_json::from_str(include_str!( + "../../../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json" + )) + .expect("shared BGM prompt fixtures should be valid JSON"); + fixtures + .into_iter() + .find(|fixture| fixture.name == name) + .unwrap_or_else(|| panic!("missing shared BGM prompt fixture: {name}")) + } + fn background_music_payload( prompt: impl Into, make_instrumental: bool, @@ -751,42 +793,67 @@ mod tests { } } + fn sound_effect_payload( + prompt: impl Into, + model: Option<&str>, + duration: u8, + ) -> assets::EditorSoundEffectGenerateRequest { + assets::EditorSoundEffectGenerateRequest { + prompt: prompt.into(), + model: model.map(str::to_string), + duration, + project_id: Some("project-1".to_string()), + canvas_completion: None, + generation_inputs: None, + asset_folder_id: None, + asset_label: None, + } + } + #[test] fn editor_background_music_normalization_uses_canonical_generation_prompt() { - let prompt = "\u{0085} \u{2003}森林 \r\n\u{200b}\u{feff}音乐 \u{00a0}"; - let normalized = - normalize_editor_background_music_request(background_music_payload(prompt, false)) - .expect("canonical BGM prompt should be accepted"); + let representative = background_music_prompt_fixture("representative-complex"); + let normalized = normalize_editor_background_music_request(background_music_payload( + representative.input, + false, + )) + .expect("canonical BGM prompt should be accepted"); - assert_eq!( - normalized.gpt_description_prompt, - "森林 \r\n\u{200b}\u{feff}音乐" - ); + assert_eq!(normalized.gpt_description_prompt, representative.prompt); assert!(normalized.make_instrumental); - for invalid_prompt in [" \r\n\u{0085}", &"乐".repeat(201)] { + for valid_prompt in ["A".to_string(), "乐".repeat(200)] { + let normalized = normalize_editor_background_music_request(background_music_payload( + valid_prompt.clone(), + true, + )) + .expect("1-200 canonical code points should be accepted"); + assert_eq!(normalized.gpt_description_prompt, valid_prompt); + } + + let unicode_white_space = background_music_prompt_fixture("unicode-white-space-only").input; + for invalid_prompt in [String::new(), unicode_white_space, "乐".repeat(201)] { let error = normalize_editor_background_music_request(background_music_payload( invalid_prompt, true, )) - .expect_err("invalid canonical BGM prompt should be rejected"); + .expect_err("empty, whitespace-only, and 201-code-point prompts should be rejected"); assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST); } } #[test] fn logged_in_background_music_queue_preparation_uses_canonical_prompt_and_frozen_price() { + let representative = background_music_prompt_fixture("representative-complex"); let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) .expect("default model pricing should be valid"); - let prepared = - CanonicalEditorBackgroundMusicSubmissionPayload::new(background_music_payload( - "\u{0085}\u{2003}森林 \r\n\u{200b}\u{feff}音乐\u{00a0}", - false, - )) - .expect("logged-in submission should canonicalize") - .into_queue_job(&pricing) - .expect("logged-in queue job should prepare"); + let prepared = CanonicalEditorBackgroundMusicSubmissionPayload::new( + background_music_payload(representative.input, false), + ) + .expect("logged-in submission should canonicalize") + .into_queue_job(&pricing) + .expect("logged-in queue job should prepare"); let request_payload_json = serialize_editor_generation_job_payload_for_test(&prepared.payload) .expect("the actual queue serializer should accept the canonical payload"); @@ -795,7 +862,23 @@ mod tests { assert_eq!( request_payload["gptDescriptionPrompt"], - "森林 \r\n\u{200b}\u{feff}音乐" + representative.prompt + ); + assert_eq!( + platform_audio::background_music_prompt_char_count( + request_payload["gptDescriptionPrompt"] + .as_str() + .expect("queue prompt should be a string"), + ), + representative.char_count, + ); + assert_eq!( + platform_audio::background_music_prompt_effective_char_count( + request_payload["gptDescriptionPrompt"] + .as_str() + .expect("queue prompt should be a string"), + ), + representative.effective_char_count, ); assert_eq!(request_payload["makeInstrumental"], true); assert!(request_payload.get("priceMudPoints").is_none()); @@ -805,6 +888,114 @@ mod tests { ); } + #[test] + fn editor_background_music_inline_response_keeps_canonical_prompt_equal() { + let representative = background_music_prompt_fixture("representative-complex"); + let response = build_editor_background_music_generate_response( + "/generated-editor-audios/background-music.wav".to_string(), + shared_contracts::creation_audio::GeneratedAudioAssetResponse { + kind: + shared_contracts::creation_audio::CreationAudioGenerationKind::BackgroundMusic, + task_id: "task-background-music-1".to_string(), + provider: platform_audio::VECTOR_ENGINE_PROVIDER.to_string(), + status: "completed".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + object_key: Some("generated/editor/background-music.wav".to_string()), + asset_kind: Some("editor_background_music".to_string()), + audio_src: Some("/generated-editor-audios/background-music.wav".to_string()), + }, + NormalizedEditorBackgroundMusicRequest { + gpt_description_prompt: representative.prompt.clone(), + make_instrumental: true, + price_mud_points: 5, + }, + Some(serde_json::json!({ "projectId": "project-1" })), + Some(serde_json::json!({ "resourceId": "resource-1" })), + Some(serde_json::json!({ "assetId": "asset-1" })), + ); + + assert_eq!(response.prompt, representative.prompt); + assert_eq!( + response.actual_prompt.as_deref(), + Some(response.prompt.as_str()) + ); + let serialized = + serde_json::to_value(response).expect("inline response should serialize to JSON"); + assert_eq!(serialized["prompt"], serialized["actualPrompt"]); + assert_eq!(serialized["prompt"], representative.prompt); + } + + #[test] + fn editor_sound_effect_normalizer_preserves_model_prompt_and_dynamic_price() { + let pricing = + crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) + .expect("default model pricing should be valid"); + let normalized = normalize_editor_sound_effect_request(sound_effect_payload( + " 金币掉落叮当声 ", + None, + 7, + )) + .expect("default Vidu sound effect request should normalize"); + + assert_eq!(normalized.prompt, "金币掉落叮当声"); + assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL); + assert_eq!(normalized.duration, 7); + assert_eq!( + normalized.price_mud_points, + pricing.sound_effect_model_mud_points(Some(platform_audio::VIDU_AUDIO_MODEL)) + ); + + let explicit_model = normalize_editor_sound_effect_request(sound_effect_payload( + "按钮确认短促音", + Some(" audio1.0 "), + 5, + )) + .expect("the only supported Vidu model should be accepted"); + assert_eq!(explicit_model.model, platform_audio::VIDU_AUDIO_MODEL); + + let error = normalize_editor_sound_effect_request(sound_effect_payload( + "按钮确认短促音", + Some(platform_audio::SUNO_DEFAULT_MODEL), + 5, + )) + .expect_err("Suno text-to-sound should remain unavailable for editor SFX"); + assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST); + } + + #[test] + fn editor_sound_effect_normalizer_enforces_prompt_and_duration_boundaries() { + let max_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS); + 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"); + assert_eq!(normalized.prompt, max_prompt); + assert_eq!(normalized.duration, 2); + + let normalized = + normalize_editor_sound_effect_request(sound_effect_payload("环境循环音", None, 10)) + .expect("the 10-second boundary should pass"); + assert_eq!(normalized.duration, 10); + + let overlong_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS + 1); + let error = + normalize_editor_sound_effect_request(sound_effect_payload(overlong_prompt, None, 5)) + .expect_err("1501 canonical code points should fail"); + assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST); + + for duration in [1, 11] { + let error = normalize_editor_sound_effect_request(sound_effect_payload( + "环境循环音", + None, + duration, + )) + .expect_err("duration outside the existing 2-10 range should fail"); + assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST); + } + } + #[test] fn external_background_music_queue_preparation_preserves_the_raw_payload() { let pricing = diff --git a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs index 585159141..d966359b9 100644 --- a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -12,6 +12,13 @@ use platform_audio::{ }; use serde_json::json; +fn background_music_prompt_canonicalization_fixture() -> serde_json::Value { + serde_json::from_str(include_str!( + "../../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json" + )) + .expect("background music prompt canonicalization fixture should be valid JSON") +} + #[test] fn background_music_simplification_limit_is_ten_times_the_generation_limit() { assert_eq!( @@ -22,59 +29,52 @@ fn background_music_simplification_limit_is_ten_times_the_generation_limit() { } #[test] -fn background_music_prompt_canonicalization_is_idempotent() { - let prompt = " \t\u{0085}\u{00a0}音 \r\n\u{200b}\u{feff}😀\u{2003}\n "; - let canonical = canonicalize_background_music_prompt(prompt); +fn background_music_prompt_canonicalization_matches_shared_fixture() { + let fixture = background_music_prompt_canonicalization_fixture(); + let cases = fixture + .as_array() + .expect("background music prompt canonicalization fixture should be an array"); - assert_eq!(canonical, "音 \r\n\u{200b}\u{feff}😀"); - assert_eq!(canonicalize_background_music_prompt(canonical), canonical); -} + for case in cases { + let name = case["name"] + .as_str() + .expect("fixture case name should be a string"); + let input = case["input"] + .as_str() + .expect("fixture case input should be a string"); + let expected_prompt = case["prompt"] + .as_str() + .expect("fixture case prompt should be a string"); + let expected_char_count = case["charCount"] + .as_u64() + .expect("fixture case charCount should be an unsigned integer") + as usize; + let expected_effective_char_count = case["effectiveCharCount"] + .as_u64() + .expect("fixture case effectiveCharCount should be an unsigned integer") + as usize; + let canonical_prompt = canonicalize_background_music_prompt(input); -#[test] -fn background_music_prompt_canonicalization_matches_unicode_white_space_test_vector() { - let unicode_white_space: String = [ - '\u{0009}', '\u{000a}', '\u{000b}', '\u{000c}', '\u{000d}', '\u{0020}', '\u{0085}', - '\u{00a0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', '\u{2004}', - '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200a}', '\u{2028}', - '\u{2029}', '\u{202f}', '\u{205f}', '\u{3000}', - ] - .into_iter() - .collect(); - let internal_white_space = "A \t\r\n\u{0085}\u{00a0}\u{3000}B"; - let prompt = format!("{unicode_white_space}{internal_white_space}{unicode_white_space}"); - - assert_eq!(unicode_white_space.chars().count(), 25); - assert!(unicode_white_space.chars().all(char::is_whitespace)); - assert_eq!( - canonicalize_background_music_prompt(&prompt), - internal_white_space - ); -} - -#[test] -fn background_music_prompt_counts_canonical_unicode_code_points_and_effective_characters() { - let prompt = " \t\n\u{00a0}\u{2003}\u{200b}😀\u{0085}"; - let internal_whitespace = "A \t\n\u{00a0}\u{2003}\u{0085} B"; - - assert_eq!(background_music_prompt_char_count(prompt), 2); - assert_eq!(background_music_prompt_effective_char_count(prompt), 2); - assert_eq!(background_music_prompt_char_count(internal_whitespace), 9); - assert_eq!( - background_music_prompt_effective_char_count(internal_whitespace), - 2 - ); - assert_eq!(background_music_prompt_char_count("\r\n"), 0); - assert_eq!(background_music_prompt_effective_char_count("\r\n"), 0); - assert_eq!(background_music_prompt_char_count("e\u{0301}"), 2); - assert_eq!(background_music_prompt_effective_char_count("e\u{0301}"), 2); - assert_eq!(background_music_prompt_char_count("👩‍💻"), 3); - assert_eq!(background_music_prompt_effective_char_count("👩‍💻"), 3); - assert_eq!(background_music_prompt_char_count("\u{0085}"), 0); - assert_eq!(background_music_prompt_effective_char_count("\u{0085}"), 0); - assert_eq!(background_music_prompt_char_count("\u{200b}"), 1); - assert_eq!(background_music_prompt_effective_char_count("\u{200b}"), 1); - assert_eq!(background_music_prompt_char_count("\u{feff}"), 1); - assert_eq!(background_music_prompt_effective_char_count("\u{feff}"), 1); + assert_eq!( + canonical_prompt, expected_prompt, + "canonical prompt mismatch for fixture case {name}" + ); + assert_eq!( + background_music_prompt_char_count(input), + expected_char_count, + "char count mismatch for fixture case {name}" + ); + assert_eq!( + background_music_prompt_effective_char_count(input), + expected_effective_char_count, + "effective char count mismatch for fixture case {name}" + ); + assert_eq!( + canonicalize_background_music_prompt(canonical_prompt), + canonical_prompt, + "canonicalization should be idempotent for fixture case {name}" + ); + } } #[test] @@ -286,19 +286,28 @@ fn sound_effect_request_rejects_overlong_prompt() { #[test] fn editor_background_music_request_body_uses_gpt_description_prompt_and_instrumental() { + let fixture = background_music_prompt_canonicalization_fixture(); + let representative_case = fixture + .as_array() + .expect("background music prompt canonicalization fixture should be an array") + .iter() + .find(|case| case["name"].as_str() == Some("representative-complex")) + .expect("fixture should contain the representative-complex case"); + let input = representative_case["input"] + .as_str() + .expect("representative-complex input should be a string"); + let expected_prompt = representative_case["prompt"] + .as_str() + .expect("representative-complex prompt should be a string"); let body = build_editor_background_music_task_body(EditorBackgroundMusicTaskRequest { - gpt_description_prompt: "\u{0085}\u{2003}轻快森林 \r\n\u{200b}\u{feff}冒险背景音乐\u{00a0}" - .to_string(), + gpt_description_prompt: input.to_string(), make_instrumental: true, model: None, }) .expect("editor background music body should be valid"); assert_eq!(body["mv"], SUNO_DEFAULT_MODEL); - assert_eq!( - body["gpt_description_prompt"], - "轻快森林 \r\n\u{200b}\u{feff}冒险背景音乐" - ); + assert_eq!(body["gpt_description_prompt"], expected_prompt); assert_eq!(body["make_instrumental"], true); assert_eq!( body.as_object() diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts index 13176a001..e7df762cd 100644 --- a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json'; import { BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS, BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS, @@ -15,86 +16,15 @@ import { } from './ImageCanvasBackgroundMusicPromptModel'; describe('ImageCanvasBackgroundMusicPromptModel', () => { - it('removes only leading and trailing Unicode White_Space code points', () => { - const unicodeWhiteSpace = [ - '\u0009', - '\u000A', - '\u000B', - '\u000C', - '\u000D', - '\u0020', - '\u0085', - '\u00A0', - '\u1680', - '\u2000', - '\u2001', - '\u2002', - '\u2003', - '\u2004', - '\u2005', - '\u2006', - '\u2007', - '\u2008', - '\u2009', - '\u200A', - '\u2028', - '\u2029', - '\u202F', - '\u205F', - '\u3000', - ].join(''); - const internalWhiteSpace = 'A \t\r\n\u0085\u00A0\u3000B'; - - expect( - canonicalizeBackgroundMusicPrompt( - `${unicodeWhiteSpace}${internalWhiteSpace}${unicodeWhiteSpace}`, - ), - ).toBe(internalWhiteSpace); - expect( - canonicalizeBackgroundMusicPrompt('\u0085\u200B内容\uFEFF\u0085'), - ).toBe('\u200B内容\uFEFF'); - expect(canonicalizeBackgroundMusicPrompt('\u200B\uFEFF')).toBe( - '\u200B\uFEFF', - ); - }); - - it('is idempotent without normalizing internal Unicode content', () => { - const combiningText = 'e\u0301'; - const zwjEmoji = '👨‍👩‍👧‍👦'; - const prompt = ` \u0085${combiningText}\r\n${zwjEmoji}\u200B\uFEFF\u3000`; - const canonicalPrompt = `${combiningText}\r\n${zwjEmoji}\u200B\uFEFF`; - - expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe(canonicalPrompt); - expect(canonicalizeBackgroundMusicPrompt(canonicalPrompt)).toBe( - canonicalPrompt, - ); - expect(canonicalPrompt).toContain(combiningText); - expect(canonicalPrompt).toContain('\r\n'); - expect(canonicalPrompt).toContain(zwjEmoji); - expect(canonicalPrompt).toContain('\u200B\uFEFF'); - }); - - it('counts code points from the canonical prompt', () => { - const combiningText = 'e\u0301'; - const zwjEmoji = '👨‍👩‍👧‍👦'; - const prompt = ` \r\n${combiningText}\r\n${zwjEmoji}\u200B\uFEFF `; - - expect(countPromptCodePoints('')).toBe(0); - expect(countPromptCodePoints('\r\n')).toBe(0); - expect(countPromptCodePoints(combiningText)).toBe(2); - expect(countPromptCodePoints(zwjEmoji)).toBe(7); - expect(countPromptCodePoints(prompt)).toBe(13); - expect(prompt).toBe(` \r\n${combiningText}\r\n${zwjEmoji}\u200B\uFEFF `); - }); - - it('counts only non-Unicode-White_Space code points in the canonical prompt as effective', () => { - expect(countEffectivePromptCodePoints(' \t\r\n\u0085\u00A0\u3000')).toBe(0); - expect(countEffectivePromptCodePoints('e\u0301')).toBe(2); - expect(countEffectivePromptCodePoints('👨‍👩‍👧‍👦')).toBe(7); - expect(countEffectivePromptCodePoints('\uFEFF')).toBe(1); - expect(countEffectivePromptCodePoints('\u200B')).toBe(1); - expect(countEffectivePromptCodePoints('。1A')).toBe(3); - }); + it.each(backgroundMusicPromptCanonicalizationCases)( + 'canonicalizes shared fixture case $name', + ({ input, prompt, charCount, effectiveCharCount }) => { + expect(canonicalizeBackgroundMusicPrompt(input)).toBe(prompt); + expect(countPromptCodePoints(input)).toBe(charCount); + expect(countEffectivePromptCodePoints(input)).toBe(effectiveCharCount); + expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe(prompt); + }, + ); it('allows formal generation for 1-200 code points with effective content', () => { expect(BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS).toBe(200); diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 298e4cfde..1f91e1650 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -1039,32 +1039,35 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); - it('uses 5 seconds as default game sound effect duration', () => { + it('uses the game sound effect fallback for an all-whitespace prompt', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-sound-effect', - prompt: '按钮确认短促音', + prompt: ' \t\r\n ', status: 'idle', }, layers: [], nextGeneratedIndex: 6, }); - expect(plan).toMatchObject({ + expect(plan).toEqual({ kind: 'audio', audioKind: 'sound-effect', + normalizedPrompt: '游戏音效', input: { - prompt: '按钮确认短促音', + prompt: '游戏音效', model: 'audio1.0', duration: 5, }, result: { + title: '游戏音效 6', generationInputs: { fields: [ - { title: 'prompt', value: '按钮确认短促音' }, + { title: 'prompt', value: '游戏音效' }, { title: 'model', value: 'audio1.0' }, { title: 'duration', value: '5秒' }, ], + references: [], }, }, }); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 1543658a4..c1ac9be27 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -10,6 +10,7 @@ import { import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json'; import { ApiClientError } from '../../services/apiClient'; import type { CanvasGenerationDialogState, @@ -1814,18 +1815,27 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); it('claims and canonicalizes one background music submission before the first await', async () => { - const canonicalPrompt = '\uFEFF森林冒险\n夜晚\uFEFF'; + const representativeComplexPromptCase = + backgroundMusicPromptCanonicalizationCases.find( + ({ name }) => name === 'representative-complex', + ); + if (!representativeComplexPromptCase) { + throw new Error('missing representative-complex prompt fixture'); + } + const canonicalPrompt = representativeComplexPromptCase.prompt; const generation = createDeferred>(); + const onAppendCanvasLayers = vi.fn<[CanvasLayer[]], void>(); generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise); render( { fireEvent.click(submitButton); expect(generateEditorBackgroundMusicMock).toHaveBeenCalledTimes(1); - expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith( - expect.objectContaining({ - gptDescriptionPrompt: canonicalPrompt, - makeInstrumental: true, - projectId: 'editor-project-bgm', - generationInputs: { - fields: [ - { - title: 'gpt_description_prompt', - value: canonicalPrompt, - }, - ], - references: [], + expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith({ + gptDescriptionPrompt: canonicalPrompt, + makeInstrumental: true, + projectId: 'editor-project-bgm', + generationInputs: { + fields: [ + { + title: 'gpt_description_prompt', + value: canonicalPrompt, + }, + ], + references: [], + }, + assetFolderId: 'project', + assetLabel: '游戏背景音乐 1', + canvasCompletion: { + dialogId: 'dialog-bgm', + title: '游戏背景音乐 1', + placeholder: { + x: 200, + y: 160, + width: 420, + height: 120, + originalWidth: 420, + originalHeight: 120, }, - }), - ); + }, + }); expect(screen.getByTestId('tracked-dialog').textContent).toContain( `audio-background-music:${canonicalPrompt}:idle:open`, ); @@ -1891,6 +1913,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('layers').textContent).toContain( 'background-music', ); + expect(onAppendCanvasLayers).toHaveBeenCalledTimes(1); + }); + const generatedLayer = onAppendCanvasLayers.mock.calls[0]?.[0]?.[0]; + expect(generatedLayer?.prompt).toBe(canonicalPrompt); + expect(generatedLayer?.actualPrompt).toBe(canonicalPrompt); + expect(generatedLayer?.generationInputs).toEqual({ + fields: [ + { + title: 'gpt_description_prompt', + value: canonicalPrompt, + }, + ], + references: [], }); }); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index e0e5c7922..34b5fdae0 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json'; import { completeEditorBackgroundMusicPrompt, createEditorAsset, @@ -1582,13 +1583,21 @@ describe('editorProjectClient', () => { }); it('generates editor background music through the backend BFF', async () => { + const representativeComplexPromptCase = + backgroundMusicPromptCanonicalizationCases.find( + ({ name }) => name === 'representative-complex', + ); + if (!representativeComplexPromptCase) { + throw new Error('missing representative-complex prompt fixture'); + } + const canonicalPrompt = representativeComplexPromptCase.prompt; requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3', width: 420, height: 120, sourceType: 'generated', - prompt: '森林冒险背景音乐', - actualPrompt: '森林冒险背景音乐', + prompt: canonicalPrompt, + actualPrompt: canonicalPrompt, model: 'chirp-v4', provider: 'VectorEngine', taskId: 'music-task-1', @@ -1596,7 +1605,7 @@ describe('editorProjectClient', () => { }); const result = await generateEditorBackgroundMusic({ - gptDescriptionPrompt: '森林冒险背景音乐', + gptDescriptionPrompt: canonicalPrompt, makeInstrumental: true, assetFolderId: 'project', assetLabel: '游戏背景音乐 1', @@ -1609,7 +1618,7 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - gptDescriptionPrompt: '森林冒险背景音乐', + gptDescriptionPrompt: canonicalPrompt, makeInstrumental: true, assetFolderId: 'project', assetLabel: '游戏背景音乐 1',