From 8daf85df6c495fe85b768218d8590d2c97936711 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 06:31:14 +0000 Subject: [PATCH 01/53] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=AE=9A?= =?UTF-8?q?=E7=A8=BFBGM=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 融合画板音乐权威设计,固定BGM原样Prompt、预设、补全、简化、撤销和提交锁口径。 追加长期决策,明确SFX、字段名、External v1和LLM日志策略保持不变。 --- .../shared-memory/decision-log.md | 8 + ...编辑器】画板音乐生成入口设计-2026-06-18.md | 186 +++++++++++++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0c8bd3164..8d2ae2b0c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5935,3 +5935,11 @@ - Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。 - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 + +## 2026-08-04 图片画布 BGM Prompt 采用单一原文与面板级同步提交锁 + +- 背景:图片画布背景音乐链路原先会在前端、BFF 和 Suno body 构造阶段清理 Prompt,并为空值提供默认回退,无法保证用户输入、正式请求和生成记录完全等值;新增预设和 AI 助手后,还需要明确与现有 SFX、请求字段和生成生命周期的边界。 +- 决策:本规则只适用于 `audio-background-music`,并在该模式内取代 2026-08-03「编辑器持久化的 `prompt` 统一表示规范化用户意图」的泛化表述;其它图片、角色和图标生成语义不变。输入框当前完整原文是唯一最终 BGM Prompt,预设可见文本写入后属于该 Prompt,预设 ID、分组、颜色和内部引导不得进入正式请求。全链路不得 `trim`、规范化、合并换行、删除零宽字符、替换标点或静默截断,200 字上限按 Unicode code point 计算;0 个非 Unicode 空白字符不能生成,1 个可正式生成,至少 2 个且不超限时可 AI 补全。请求字段继续使用 `gptDescriptionPrompt` / `gpt_description_prompt`,不得改名为 `actualPrompt`;`actual_prompt` 继续是资源 / 素材审计字段,但当前 BGM 链路中的输入框、BFF、Suno body、生成记录 `prompt` / `actual_prompt` 和结果最终文本必须等值。AI 补全与 `180 -> 170`、最多两次的一键简化只走登录态内部 BFF,不调用 Suno、不创建任务、不扣正式音乐生成泥点;程序不截断,也不硬编码内容保留规则,成功后只保存一层 Prompt 交换快照。正式生成在点击事件内、任何 `await` 前同步锁定当前 BGM generation dialog,后续重复点击忽略;拒绝时恢复原 Prompt 和既有快照,接受后进入现有 `queued/generating` 占位,不锁整个画布或其它 dialog。 +- 影响范围:画板音乐权威设计、BGM composer 与临时状态模型、`editorProjectClient`、`shared-contracts` 内部助手 DTO、`api-server` 登录态 Prompt 助手与正式 BGM BFF、`platform-audio` Suno body builder,以及 BGM 提交与端到端测试。SFX 继续使用 Vidu `audio1.0`、现有规范化和默认 Prompt;不修改 SpacetimeDB schema、External v1 / OpenAPI、Suno 三字段 body、固定模型 / 泥点展示或现有 LLM 原文日志策略。 +- 验证方式:TypeScript 与 Rust 对空白、换行、零宽字符、组合字符、ZWJ emoji 和 199 / 200 / 201 code point 得出一致计数;端到端断言输入框、BFF、Suno body、记录和响应原文等值;状态测试覆盖预设追加、补全 / 简化失败不覆盖、单层交换撤销、迟到响应和同 dialog 双击只产生一次任务与一次扣费;SFX 请求体、1500 字限制、时长和默认 Prompt 回归不变。文档阶段运行 `npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index 0572907cc..c292376a9 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -2,10 +2,14 @@ 日期:`2026-06-18` +更新时间:`2026-08-04` + ## 范围 本次只在 `/editor/canvas` 图片画布编辑器内新增底部 `生成音乐` 入口,用于生成完整游戏音效或游戏背景音乐。该入口属于画板生成类工具,不新增平台玩法入口、不进入作品发布链路,也不修改现有视觉小说音频生成开关。 +2026-08-04 起,本文增加 BGM Prompt 优化 V1.0 口径。该切片只修改 `audio-background-music`;`audio-sound-effect` 的 UI、Prompt 回退、Vidu `audio1.0` 请求、`2-10` 秒时长和 1500 字限制全部保持不变。共用组件若需要调整,必须按生成器模式隔离行为,不得把 BGM 规则扩散到 SFX。 + ## 入口与交互 1. 底部 AI 画布工具栏新增 `生成音乐`。 @@ -13,7 +17,7 @@ - `生成游戏音效` - `生成游戏背景音乐` 3. 选择某一项后创建独立 `generation-dialog` 画布生成对象,并通过现有 placement 模型避让已有图层和占位。 -4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐同样在右下角显示固定模型胶囊并紧贴生成按钮。 +4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐字段区增加字符计数、可展开预设词条、AI 补全、一键简化和单层撤销,底部仍保留现有动态泥点价格、固定 `Suno` 模型胶囊和生成按钮。 5. 生成中隐藏设置面板,只保留画布中的音频生成占位;失败后恢复面板并展示短错误。 ## 面板字段 @@ -27,10 +31,135 @@ ### 生成游戏背景音乐 -- `gpt_description_prompt`:用户输入的背景音乐提示词。 +- `gpt_description_prompt`:用户输入框当前显示的完整背景音乐提示词,是正式 BGM 生成的唯一最终 Prompt。 - `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。 - 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv`、`gpt_description_prompt`、`make_instrumental: true`。`mv` 后端固定使用默认 Suno 模型,UI 以禁用态模型胶囊显示 `Suno`。 -- `gpt_description_prompt` 按 Apifox 契约限制 200 字,超出时由 BFF 返回参数错误。 +- 前端请求继续使用 `gptDescriptionPrompt`,Rust DTO 继续使用 `gpt_description_prompt`;不得因“唯一最终 Prompt”语义把请求字段改名为 `actualPrompt`。响应中的 `actualPrompt` / `actual_prompt` 继续保留既有生成审计语义。 +- `gpt_description_prompt` 按 Apifox 契约限制 200 个 Unicode code point。前端、BFF 和 `platform-audio` 只校验,不执行 `trim`、Unicode 规范化、空白折叠、换行转换、标点替换或静默截断。 + +## BGM Prompt 优化 V1.0 + +### 唯一最终 Prompt 与字符口径 + +- 预设和 AI 助手都只修改同一个 BGM 输入框。输入框当前显示的完整字符串是唯一最终 Prompt,不维护另一份“实际 Prompt”、选中预设集合或内部拼接结果。 +- 正式生成时,输入框、前端 BFF 请求、Suno body、生成记录 `prompt`、生成记录 `actual_prompt` 和结果响应中的最终文本必须逐 code point 完全一致。 +- 用户 Prompt 原样处理:不执行 `trim`,不转换或合并换行,不删除零宽字符,不执行 NFC 或其它 Unicode 规范化,不折叠空格,不替换标点,不静默截断。 +- 总字符数按输入框实际字符串的 Unicode code point 数计算。TypeScript 使用 `Array.from(prompt).length`,Rust 使用 `prompt.chars().count()`。 +- “有效字符”只用于动作可用性和参数校验,定义为非 Unicode 空白 code point;它不改变原字符串。标点、数字、字母、空格、换行和不可见 code point 都计入 200 字总数。 + +| 当前输入 | AI 补全 | 一键简化 | 正式生成 | +| --- | --- | --- | --- | +| 0 个有效字符 | 禁止 | 禁止 | 禁止 | +| 1 个有效字符且总数不超过 200 | 禁止 | 禁止 | 允许 | +| 至少 2 个有效字符且总数不超过 200 | 允许 | 禁止 | 允许 | +| 总数超过 200 | 禁止 | 允许 | 禁止 | + +### 预设库与追加规则 + +预设分为三组,仅用于颜色和滚动组织;分组、ID、颜色等隐藏元数据不进入 Prompt、Suno 请求或生成记录。写入输入框的是下表右列完整可见文本。 + +| 分组 | 预设 | 写入输入框的文本 | +| --- | --- | --- | +| 用途 | 菜单待机 | 低干扰、适合菜单待机的背景音乐 | +| 用途 | 休闲消除 | 轻快可爱的休闲消除背景音乐 | +| 用途 | 解谜思考 | 安静专注的解谜思考背景音乐 | +| 用途 | 探索冒险 | 温和推进的探索冒险背景音乐 | +| 用途 | 对白场景 | 克制柔和、留出对白空间的背景音乐 | +| 用途 | 战斗前 | 蓄势待发的战斗前背景音乐 | +| 用途 | 胜利结算 | 明亮满足的胜利结算背景音乐 | +| 用途 | 失败结算 | 克制低落的失败结算背景音乐 | +| 用途 | 日常经营 | 轻松有序的日常经营背景音乐 | +| 用途 | 农场经营 | 自然温暖的农场经营背景音乐 | +| 用途 | 校园日常 | 青春轻松的校园日常背景音乐 | +| 用途 | 美食厨房 | 温暖活泼的美食厨房背景音乐 | +| 氛围 | 温暖治愈 | 柔和明亮的治愈背景音乐 | +| 氛围 | 神秘悬疑 | 克制神秘的悬疑背景音乐 | +| 氛围 | 紧张推进 | 稳定推进、逐渐紧张的背景音乐 | +| 场景 | 森林自然 | 清新自然的森林背景音乐 | +| 场景 | 雨夜静谧 | 雨夜静谧、略带神秘感的背景音乐 | +| 场景 | 海洋漂流 | 开阔舒缓的海洋漂流背景音乐 | +| 场景 | 山野远行 | 自由舒展的山野远行背景音乐 | +| 场景 | 糖果乐园 | 甜美活泼的糖果乐园背景音乐 | +| 场景 | 温馨小屋 | 温暖安静的温馨小屋背景音乐 | +| 场景 | 城市夜晚 | 克制迷人的城市夜晚背景音乐 | +| 场景 | 太空科幻 | 空灵未来感的太空科幻背景音乐 | +| 场景 | 赛博街区 | 冷静律动的赛博街区背景音乐 | +| 场景 | 古风幻想 | 空灵雅致的古风幻想背景音乐 | +| 场景 | 童话花园 | 梦幻轻盈的童话花园背景音乐 | +| 场景 | 海底遗迹 | 深邃神秘的海底遗迹背景音乐 | +| 场景 | 沙漠遗迹 | 苍茫神秘的沙漠遗迹背景音乐 | +| 场景 | 熔岩洞穴 | 炽热压迫的熔岩洞穴背景音乐 | +| 场景 | 奇妙博物馆 | 好奇灵动的奇妙博物馆背景音乐 | + +点击预设时: + +1. 输入框原始字符串为空,直接写入该预设的完整可见文本。 +2. 输入框原始字符串非空,检查原文最后一个 Unicode code point。 +3. 最后一个字符属于 Unicode 标点类别时,直接追加预设文本;否则先追加中文句号 `。`,再追加预设文本。 +4. 不对原文执行 `trim`。原文以空格或换行结尾时,句号追加在这些原始字符之后。 +5. 允许重复点击同一预设,每次都按同一规则追加。 +6. 点击任意预设后清除旧撤销快照并隐藏撤销按钮。 +7. 追加后允许超过 200 字,完整文本必须保留;超限只复用现有通用 Prompt 过长状态,不增加预设专用警告。 + +预设滚动交互: + +- 展开后全部词条横向、连续、缓慢循环;收起后隐藏词条并停止可见滚动。 +- 桌面端左侧 15% 悬停区快速向左滚动,中间 70% 立即暂停并稳定展示至少 5 个词条,右侧 15% 快速向右滚动;鼠标移出后恢复默认慢速循环。 +- 左右箭头所在区域也必须触发对应方向的加速滚动,避免箭头可见但悬停无反馈。 +- 循环使用多份词条队列无缝衔接,末端不跳回、不留白,也不提供“换一批”。 +- 三组词条使用协调但可区分的颜色;底部细线标记当前悬停控制区。 +- 组件在触摸环境被渲染时支持横向滚动和箭头操作,不依赖 hover 才能选择预设;本需求不恢复移动端图片画布入口。 +- AI 处理或正式提交期间暂停滚动并禁用展开、收起、箭头和词条点击。 + +### AI 补全 + +- 至少 2 个有效字符且总字符数不超过 200 时允许调用。前端把输入框原始字符串传给登录态内部 BFF,不做前置清理。 +- 默认模型使用现有编辑器 Agent LLM 配置中的 `gpt-5.4-mini`;Prompt 助手复用现有 `LlmClient`,不建立新的平台 LLM 能力。 +- 服务端模板必须要求:保留用户明确的主题、场景、风格、情绪、乐器、能量、韵律、时长、循环和避免项;按场景选择性补足场景、氛围、能量、韵律、乐器、旋律、声音设计、循环和避免项,不为凑全方向堆砌形容词。 +- 用户描述已足够完整时,只补充一至两个与主题匹配的具体声音细节。发现冲突时,优先级为“明确避免项和限制 > 明确玩法用途与场景 > 风格、情绪、能量与韵律 > AI 补充细节”。 +- 输出只允许一条可直接写回输入框的中文 BGM Prompt;不得包含解释、标题、Markdown、JSON、代码块、具体艺人或歌曲模仿要求。 +- 结果必须包含有效字符且不超过 200 个 Unicode code point。校验通过后整段替换输入框;调用失败、结果为空、格式非法或超限时保留原文,不写回部分结果。 +- 补全过程不调用 Suno、不创建正式生成任务、不触发正式音乐生成扣费。 + +### 一键简化 + +- 只在当前 Prompt 总字符数超过 200 时允许调用。点击前保存完整原文,AI 处理中保持输入框原文不变。 +- 第一次简化目标为 180 字;180 不是硬门槛,只要候选不超过 200 字、格式合法且 LLM 判断内容完整即可通过。 +- 第一次候选超过 200 字、格式非法、LLM 判断内容不完整或存在明显残句时,自动进行唯一一次重试,目标为 170 字。 +- 第二次仍不合格时返回失败并保留原文,提示用户手动精简;最多两次 LLM 调用,任何情况下都不得由程序截断到 200 字。 +- 简化模板必须优先保留明确限制、场景与用途、情绪与风格、核心乐器与速度、能量/韵律/旋律、循环结构、区分度较高的声音设计,再删除次要修饰细节;不得改变专有名词、BPM、调性、时长、数值、乐器和明确限制。 +- 程序不抽取关键词,不对原文与候选执行内容硬编码逐项比对。是否保留重要信息、内容是否完整、是否形成明显残句由 LLM 在同一次调用的内部结构化结果中判断。 +- 内部结构化结果同时包含候选 Prompt 与完整性判断;BFF 对外只返回已通过校验的 Prompt 和程序计算的字符数,不暴露内部判断字段。 +- 程序只负责确定性校验:结构可解析、候选包含有效字符、格式合法、实际字符数不超过 200。字符数必须由程序计算,不采信模型报告。 +- 一键简化不维护或恢复“已选预设”元数据;预设已写入输入框的文本只是当前最终 Prompt 的一部分。 + +### 单层撤销 + +- AI 补全和一键简化成功都必须产生一层 Prompt 快照。发起 AI 操作前,以当时的当前 Prompt 建立本次临时快照。 +- AI 成功写回后,该快照成为可撤销版本;AI 失败且原文未变时清除本次临时快照,不生成新的可撤销版本。 +- 用户手动编辑 AI 结果后,撤销仍可用。再次发起 AI 操作时,以当时的当前 Prompt 替换旧快照。 +- 点击预设清除旧快照;正式提交被后端拒绝时,恢复并保留提交前已有快照。 +- 点击撤销时,当前 Prompt 与快照互换;按钮继续可用,允许用户在两个版本间来回切换。 +- 只保存一层,快照只包含 Prompt,不包含预设滚动位置、展开状态、模型、时长或其它参数。 + +### AI 操作与方案 A 提交锁 + +当前 BGM generation dialog 的 Prompt 助手状态为: + +```text +idle +├─ completing +├─ simplifying +└─ submitting +``` + +- `completing` / `simplifying` 开始时以同步 operation ID 取得当前 dialog 操作权;同一操作的后续双击无效。新操作使旧 operation ID 失效,关闭 dialog 时中止请求,dialog ID 或 operation ID 不匹配的迟到响应必须丢弃。 +- AI 处理中输入框只读,禁用预设展开/收起、预设词条、滚动箭头、AI 补全、一键简化、撤销和生成。 +- 点击生成的同步事件内必须在任何 `await` 前把当前 BGM dialog 切换到 `submitting`。提交中锁定该 dialog 的输入框、预设、展开/收起、滚动箭头、AI 操作、撤销、参数控件和生成按钮,忽略后续重复点击。 +- 锁只作用于当前 BGM dialog,不锁整个画布、其它 generation dialog、画布拖动、缩放、图层操作或其它编辑能力。 +- 后端拒绝正式提交时,解除锁,恢复当前面板并保留用户原始 Prompt 与提交前已有撤销快照,继续使用现有正式生成错误展示。 +- 后端接受请求并创建正式生成任务后,`submitting` 结束,当前 dialog 进入现有 `queued/generating` 占位;输入 composer 继续隐藏,同一任务不得再次提交。 +- “提交成功”仅表示后端已接受请求并创建正式任务,不表示 Suno 已完成音乐生成。 ## 画布数据 @@ -54,7 +183,7 @@ - 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。 - 音频图层上方浮动工具栏只保留 `改造` 和 `下载按钮`。点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。 -## 前端提交契约 +## 前端与 BFF 契约 前端新增两个 BFF client: @@ -77,6 +206,35 @@ POST /api/editor/audios/background-music/generations } ``` +BGM Prompt 助手新增两个登录态内部 BFF: + +```ts +POST /api/editor/audios/background-music/prompts/completions +{ + currentPrompt: string +} +``` + +```ts +POST /api/editor/audios/background-music/prompts/simplifications +{ + currentPrompt: string +} +``` + +统一 Prompt 助手响应: + +```ts +{ + prompt: string, + charCount: number +} +``` + +- 客户端不得提交 `maxChars`、`targetChars`、模型名、预设 ID、分组、颜色、内部模板或完整性判断;这些由服务端固定。 +- 两个助手 BFF 不调用 Suno、钱包扣费、正式 generation queue、OSS、素材库或 SpacetimeDB。 +- BGM 正式提交必须使用输入框当前原始 Prompt,不再调用 `dialog.prompt.trim()`,也不再把空 Prompt 回退为“游戏背景音乐”。 + 统一响应: ```ts @@ -102,8 +260,10 @@ POST /api/editor/audios/background-music/generations ## 后端实现 - 在 `shared-contracts/src/assets.rs` 增加编辑器音频请求 / 响应 DTO。 +- 在 `shared-contracts` 增加最小 BGM Prompt 助手请求 / 响应 DTO;前后端共享 `currentPrompt`、`prompt` 和 `charCount` 字段,不把内部 LLM 判断暴露给客户端。 - 在 `platform-audio` 增加编辑器专用 body builder 和 submit 函数: - 背景音乐 body 使用 `mv`、`gpt_description_prompt`、`make_instrumental`。 + - 背景音乐增加“只校验、不清理”的 Prompt 校验:检查至少一个有效字符和最多 200 个 Unicode code point,校验通过后原样构造 Suno body。不得继续复用会执行 `trim` 的 `normalize_limited_text`。 - Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。 - 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"`、`prompt`、`sound: prompt`、`duration` 和可选 `seed`;`model` 和 `prompt` 为文档必填,`sound` 用于兼容线上网关实际校验,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。 - 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`;Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露。 @@ -112,8 +272,15 @@ POST /api/editor/audios/background-music/generations - 在 `api-server` 增加编辑器音频 BFF: - `/api/editor/audios/sound-effects/generations` - `/api/editor/audios/background-music/generations` +- 在 `api-server` 增加登录态内部 BGM Prompt 助手 BFF: + - `POST /api/editor/audios/background-music/prompts/completions` + - `POST /api/editor/audios/background-music/prompts/simplifications` +- Prompt 助手 BFF 使用现有编辑器专用 LLM client 和 `gpt-5.4-mini` 默认配置;补全固定执行一次,简化按 `180 -> 170` 最多两次。服务端负责模板组装、原始字符校验、内部结构化完整性判断和现有 API 错误 envelope。 +- Prompt 助手继续复用 `LlmClient` 现有失败原文日志行为。本需求不增加请求级日志开关、脱敏、metadata-only 模式或相关上线门禁。 +- BGM generation BFF 必须改用“只校验、不清理”的原始 Prompt 校验,删除空 Prompt 默认回退;SFX 继续使用现有规范化、回退、Vidu body 和 1500 字限制。 - BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装;音效 10 泥点,背景音乐 5 泥点。 - 生成音频持久化后返回 OSS `objectKey` 与 `assetObjectId`;前端保存素材库时继续使用 `audioSrc` 作为兼容路径,并把 OSS 身份写入素材记录。 +- 本切片不修改 SpacetimeDB schema,不新增 Prompt 助手持久化表,不向 `/api/external/v1` 暴露助手,也不修改 External v1 OpenAPI 或 `platform-llm` 日志策略。 ## 验收 @@ -134,3 +301,14 @@ POST /api/editor/audios/background-music/generations - 音频素材浮动工具栏只显示 `改造` 与 `下载按钮`,`改造` 复用对应生成面板且没有参考图组件。 - 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。 - 刷新后 layout 能恢复音频生成器和音频图层。 +- BGM 输入框按 Unicode code point 显示 `0 / 200` 计数;0 个有效字符不能生成,1 个有效字符可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,201 个 code point 时只能一键简化。 +- 三组 30 个 BGM 预设按本文固定文案写入或追加;重复点击、Unicode 标点结尾、空格 / 换行结尾和追加后超限都不清理或丢失原文。 +- 预设展开后默认无缝慢速循环,桌面端左 / 中 / 右区域分别加速向左、暂停、加速向右,左右箭头同步加速;组件在触摸环境被渲染时可横向滚动并选择词条,本需求不恢复移动端图片画布入口。 +- AI 补全成功后只写回一条不超过 200 字的中文 Prompt,失败、空值、格式错误或超限结果不修改输入框,也不调用 Suno 或扣除正式音乐生成泥点。 +- 一键简化只处理超过 200 字的 BGM Prompt,按 180 字目标尝试一次,必要时按 170 字目标重试一次;第二次仍失败时保留原文,程序不得截断。 +- AI 补全和简化成功均产生一层交换式撤销快照;手动编辑后仍可撤销,点击预设清除快照,点击撤销可在两个版本间反复互换。 +- AI 操作的旧响应、关闭 dialog 后的响应或其它 dialog 的响应不得覆盖当前 Prompt;同一按钮双击只产生一个有效助手请求。 +- BGM 点击生成后在首个 `await` 前同步锁定当前 dialog;同一 dialog 快速重复点击只产生一次正式请求、一个生成任务和一次扣费,不锁整个画布或其它 dialog。 +- BGM 正式提交不再 `trim` 或回退默认 Prompt;包含首尾空格、LF / CRLF、零宽字符、组合字符或 ZWJ emoji 的合法 Prompt 在输入框、BFF、Suno body、生成记录和结果响应中完全一致。 +- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;SFX 的 Vidu body、默认 Prompt、时长与 1500 字限制无回归。 +- 本切片的定向前端、shared-contracts、`api-server`、`platform-audio` 和端到端 Prompt 等值测试通过,并执行 `npm run typecheck`、对应 Rust 定向测试、`npm run check:encoding` 与 `git diff --check`。 From dafcfbe6b78d2b42da5163c43e5f8316acc9a619 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 07:28:23 +0000 Subject: [PATCH 02/53] =?UTF-8?q?=E5=9F=BA=E7=A1=80=EF=BC=9A=E7=BB=9F?= =?UTF-8?q?=E4=B8=80BGM=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=A7=84=E5=88=99?= =?UTF-8?q?=E4=B8=8E=E5=8A=A9=E6=89=8B=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增前端 Unicode code point 与有效字符计数规则 新增 Rust BGM 原样校验并覆盖生成和补全边界 新增 Prompt 助手 camelCase 内部 DTO 与契约测试 补齐跨语言 Unicode、200 字和 SFX 回归测试 --- .../src/background_music_prompt.rs | 76 +++++++++++++++++ server-rs/crates/platform-audio/src/lib.rs | 6 ++ .../tests/vector_engine_audio.rs | 85 ++++++++++++++++++- .../crates/shared-contracts/src/assets.rs | 52 ++++++++++++ ...geCanvasBackgroundMusicPromptModel.test.ts | 60 +++++++++++++ .../ImageCanvasBackgroundMusicPromptModel.ts | 35 ++++++++ 6 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 server-rs/crates/platform-audio/src/background_music_prompt.rs create mode 100644 src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts diff --git a/server-rs/crates/platform-audio/src/background_music_prompt.rs b/server-rs/crates/platform-audio/src/background_music_prompt.rs new file mode 100644 index 000000000..ba5d43e73 --- /dev/null +++ b/server-rs/crates/platform-audio/src/background_music_prompt.rs @@ -0,0 +1,76 @@ +use crate::{AudioError, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS}; + +const BACKGROUND_MUSIC_GENERATION_PROMPT_FIELD: &str = "gpt_description_prompt"; +const BACKGROUND_MUSIC_COMPLETION_PROMPT_FIELD: &str = "currentPrompt"; +const BACKGROUND_MUSIC_GENERATION_MIN_EFFECTIVE_CHARS: usize = 1; +const BACKGROUND_MUSIC_COMPLETION_MIN_EFFECTIVE_CHARS: usize = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ValidatedBackgroundMusicPrompt<'a> { + /// The exact caller-provided string. Validation never trims or normalizes it. + pub prompt: &'a str, + pub char_count: usize, + pub effective_char_count: usize, +} + +/// Counts Unicode scalar values, matching TypeScript `Array.from(prompt).length`. +pub fn background_music_prompt_char_count(prompt: &str) -> usize { + prompt.chars().count() +} + +/// Counts code points outside the Unicode `White_Space` property. +/// +/// U+0085 is whitespace, while U+200B and U+FEFF remain effective characters. +pub fn background_music_prompt_effective_char_count(prompt: &str) -> usize { + prompt + .chars() + .filter(|character| !character.is_whitespace()) + .count() +} + +pub fn validate_background_music_generation_prompt( + prompt: &str, +) -> Result, AudioError> { + validate_background_music_prompt( + prompt, + BACKGROUND_MUSIC_GENERATION_PROMPT_FIELD, + BACKGROUND_MUSIC_GENERATION_MIN_EFFECTIVE_CHARS, + ) +} + +pub fn validate_background_music_completion_prompt( + prompt: &str, +) -> Result, AudioError> { + validate_background_music_prompt( + prompt, + BACKGROUND_MUSIC_COMPLETION_PROMPT_FIELD, + BACKGROUND_MUSIC_COMPLETION_MIN_EFFECTIVE_CHARS, + ) +} + +fn validate_background_music_prompt<'a>( + prompt: &'a str, + field: &'static str, + min_effective_chars: usize, +) -> Result, AudioError> { + let char_count = background_music_prompt_char_count(prompt); + let effective_char_count = background_music_prompt_effective_char_count(prompt); + + if char_count > SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS { + return Err(AudioError::invalid_request(format!( + "{field} 超过 {} 字符", + SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS + ))); + } + if effective_char_count < min_effective_chars { + return Err(AudioError::invalid_request(format!( + "{field} 至少需要 {min_effective_chars} 个有效字符" + ))); + } + + Ok(ValidatedBackgroundMusicPrompt { + prompt, + char_count, + effective_char_count, + }) +} diff --git a/server-rs/crates/platform-audio/src/lib.rs b/server-rs/crates/platform-audio/src/lib.rs index a686612a7..cb6a3b5dc 100644 --- a/server-rs/crates/platform-audio/src/lib.rs +++ b/server-rs/crates/platform-audio/src/lib.rs @@ -1,3 +1,4 @@ +mod background_music_prompt; mod client; mod download; mod error; @@ -6,6 +7,11 @@ mod request; mod response; mod types; +pub use background_music_prompt::{ + ValidatedBackgroundMusicPrompt, background_music_prompt_char_count, + background_music_prompt_effective_char_count, validate_background_music_completion_prompt, + validate_background_music_generation_prompt, +}; pub use client::{ build_vector_engine_audio_http_client, resolve_audio_task_download_urls, submit_background_music_task, submit_editor_background_music_task, 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 bcdee944d..0aabc1c75 100644 --- a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -1,13 +1,96 @@ use platform_audio::{ AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, - audio_mime_to_extension, build_background_music_task_body, + audio_mime_to_extension, background_music_prompt_char_count, + background_music_prompt_effective_char_count, build_background_music_task_body, build_editor_background_music_task_body, build_editor_sound_effect_task_body, build_sound_effect_task_body, extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_audio_mime_type, normalize_task_status, + validate_background_music_completion_prompt, validate_background_music_generation_prompt, }; use serde_json::json; +#[test] +fn background_music_prompt_counts_unicode_code_points_and_effective_characters() { + let prompt = " \t\n\u{00a0}\u{2003}\u{200b}😀"; + + assert_eq!(background_music_prompt_char_count(prompt), 7); + assert_eq!(background_music_prompt_effective_char_count(prompt), 2); + assert_eq!(background_music_prompt_char_count("\r\n"), 2); + 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}"), 1); + assert_eq!(background_music_prompt_effective_char_count("\u{0085}"), 0); + assert_eq!(background_music_prompt_char_count("\u{feff}"), 1); + assert_eq!(background_music_prompt_effective_char_count("\u{feff}"), 1); +} + +#[test] +fn background_music_generation_prompt_requires_one_effective_character_without_cleaning() { + for prompt in ["", " \t\r\n", "\u{00a0}\u{2003}"] { + let error = validate_background_music_generation_prompt(prompt) + .expect_err("Unicode-whitespace-only prompts should be rejected"); + assert!(error.message().contains("至少需要 1 个有效字符")); + } + + for prompt in ["乐", "\u{200b}", "😀"] { + let validated = validate_background_music_generation_prompt(prompt) + .expect("one non-whitespace code point should be accepted for generation"); + assert_eq!(validated.prompt, prompt); + assert_eq!(validated.char_count, 1); + assert_eq!(validated.effective_char_count, 1); + } + + let prompt = " 音\n\u{200b}😀 "; + let validated = validate_background_music_generation_prompt(prompt) + .expect("valid prompt should retain every original code point"); + assert_eq!(validated.prompt, prompt); + assert_eq!(validated.char_count, 8); + assert_eq!(validated.effective_char_count, 3); +} + +#[test] +fn background_music_completion_prompt_requires_two_effective_characters() { + for prompt in ["", "乐", "乐 \n", "\u{200b}", "😀"] { + let error = validate_background_music_completion_prompt(prompt) + .expect_err("completion should require two effective characters"); + assert!(error.message().starts_with("currentPrompt ")); + assert!(error.message().contains("至少需要 2 个有效字符")); + } + + for prompt in ["音乐", "\u{200b}乐", "😀🎵", "👩‍💻"] { + let validated = validate_background_music_completion_prompt(prompt) + .expect("completion should accept at least two effective code points"); + assert_eq!(validated.prompt, prompt); + assert!(validated.effective_char_count >= 2); + } +} + +#[test] +fn background_music_prompt_validation_accepts_200_and_rejects_201_code_points() { + let max_prompt = "😀".repeat(200); + let generation = validate_background_music_generation_prompt(&max_prompt) + .expect("200 code points should be accepted for generation"); + let completion = validate_background_music_completion_prompt(&max_prompt) + .expect("200 code points should be accepted for completion"); + assert_eq!(generation.prompt, max_prompt); + assert_eq!(generation.char_count, 200); + assert_eq!(completion.char_count, 200); + + let overlong_prompt = "😀".repeat(201); + for error in [ + validate_background_music_generation_prompt(&overlong_prompt) + .expect_err("201 code points should be rejected for generation"), + validate_background_music_completion_prompt(&overlong_prompt) + .expect_err("201 code points should be rejected for completion"), + ] { + assert!(error.message().contains("超过 200 字符")); + } +} + #[test] fn normalizes_audio_mime_type_from_content_type_and_url() { assert_eq!( diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 6186d6253..02c8a6b29 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -542,6 +542,19 @@ pub struct EditorBackgroundMusicGenerateRequest { pub asset_label: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundMusicPromptAssistRequest { + pub current_prompt: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundMusicPromptAssistResponse { + pub prompt: String, + pub char_count: u32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAudioGenerateResponse { @@ -1442,6 +1455,45 @@ mod tests { ); } + #[test] + fn background_music_prompt_assist_contract_uses_camel_case_shape() { + let request = BackgroundMusicPromptAssistRequest { + current_prompt: "\n 森林冒险背景音乐 ".to_string(), + }; + let request_payload = serde_json::to_value(&request) + .expect("background music prompt assist request should serialize"); + assert_eq!( + request_payload, + json!({ + "currentPrompt": "\n 森林冒险背景音乐 ", + }) + ); + assert_eq!( + serde_json::from_value::(request_payload) + .expect("background music prompt assist request should deserialize"), + request + ); + + let response = BackgroundMusicPromptAssistResponse { + prompt: "森林冒险背景音乐,温和推进并适合自然循环".to_string(), + char_count: 20, + }; + let response_payload = serde_json::to_value(&response) + .expect("background music prompt assist response should serialize"); + assert_eq!( + response_payload, + json!({ + "prompt": "森林冒险背景音乐,温和推进并适合自然循环", + "charCount": 20, + }) + ); + assert_eq!( + serde_json::from_value::(response_payload) + .expect("background music prompt assist response should deserialize"), + response + ); + } + #[test] fn character_workflow_cache_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse { diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts new file mode 100644 index 000000000..0cd600dbd --- /dev/null +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { + BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS, + BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS, + BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS, + canCompleteBackgroundMusicPrompt, + canGenerateBackgroundMusicFromPrompt, + countEffectivePromptCodePoints, + countPromptCodePoints, +} from './ImageCanvasBackgroundMusicPromptModel'; + +describe('ImageCanvasBackgroundMusicPromptModel', () => { + it('counts raw Unicode code points without normalizing the prompt', () => { + const combiningText = 'e\u0301'; + const zwjEmoji = '👨‍👩‍👧‍👦'; + const prompt = ` \r\n${combiningText}${zwjEmoji}\u200B\uFEFF `; + + expect(countPromptCodePoints('')).toBe(0); + expect(countPromptCodePoints('\r\n')).toBe(2); + expect(countPromptCodePoints(combiningText)).toBe(2); + expect(countPromptCodePoints(zwjEmoji)).toBe(7); + expect(countPromptCodePoints(prompt)).toBe(15); + expect(prompt).toBe(` \r\n${combiningText}${zwjEmoji}\u200B\uFEFF `); + }); + + it('counts only non-Unicode-White_Space code points 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('allows formal generation for 1-200 code points with effective content', () => { + expect(BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS).toBe(200); + expect(BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS).toBe( + 1, + ); + expect(canGenerateBackgroundMusicFromPrompt('')).toBe(false); + expect(canGenerateBackgroundMusicFromPrompt(' \r\n\u0085')).toBe(false); + expect(canGenerateBackgroundMusicFromPrompt('乐')).toBe(true); + expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(199))).toBe(true); + expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(200))).toBe(true); + expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(201))).toBe(false); + }); + + it('requires 2 effective code points and at most 200 total for AI completion', () => { + expect(BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS).toBe( + 2, + ); + expect(canCompleteBackgroundMusicPrompt(' \r\n\u0085')).toBe(false); + expect(canCompleteBackgroundMusicPrompt('乐')).toBe(false); + expect(canCompleteBackgroundMusicPrompt('乐 ')).toBe(false); + expect(canCompleteBackgroundMusicPrompt('乐曲')).toBe(true); + expect(canCompleteBackgroundMusicPrompt('😀'.repeat(200))).toBe(true); + expect(canCompleteBackgroundMusicPrompt('😀'.repeat(201))).toBe(false); + }); +}); diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts new file mode 100644 index 000000000..05d3075a5 --- /dev/null +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts @@ -0,0 +1,35 @@ +export const BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS = 200; +export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1; +export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2; + +const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u; + +export function countPromptCodePoints(prompt: string) { + return Array.from(prompt).length; +} + +export function countEffectivePromptCodePoints(prompt: string) { + let effectiveCodePoints = 0; + for (const codePoint of prompt) { + if (!UNICODE_WHITE_SPACE_CODE_POINT.test(codePoint)) { + effectiveCodePoints += 1; + } + } + return effectiveCodePoints; +} + +export function canGenerateBackgroundMusicFromPrompt(prompt: string) { + return ( + countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS && + countEffectivePromptCodePoints(prompt) >= + BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS + ); +} + +export function canCompleteBackgroundMusicPrompt(prompt: string) { + return ( + countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS && + countEffectivePromptCodePoints(prompt) >= + BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS + ); +} From 8d269d785d014d2eafd6321c79d32918a467d7ae Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 08:53:55 +0000 Subject: [PATCH 03/53] =?UTF-8?q?=E4=BF=AE=E5=A4=8DBGM=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=E5=A5=91=E7=BA=A6=E4=B8=8E=E5=9F=BA=E7=A1=80=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补全T0一键简化两轮输入、结构化结果和互斥状态规则 新增TypeScript Unicode White_Space canonicalizer与生成、补全、简化状态 新增Rust零分配canonicalizer并让计数和校验返回规范化Prompt 补齐跨语言Unicode及200/201字符边界测试 --- .../shared-memory/decision-log.md | 10 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...编辑器】画板音乐生成入口设计-2026-06-18.md | 107 ++++++----- .../src/background_music_prompt.rs | 34 ++-- server-rs/crates/platform-audio/src/lib.rs | 4 +- .../tests/vector_engine_audio.rs | 150 +++++++++++++--- ...geCanvasBackgroundMusicPromptModel.test.ts | 170 ++++++++++++++++-- .../ImageCanvasBackgroundMusicPromptModel.ts | 20 ++- 8 files changed, 397 insertions(+), 102 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8d2ae2b0c..5c5d1aeb3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5936,10 +5936,10 @@ - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 -## 2026-08-04 图片画布 BGM Prompt 采用单一原文与面板级同步提交锁 +## 2026-08-04 图片画布 BGM Prompt 采用唯一可见规范化文本与面板级同步提交锁 -- 背景:图片画布背景音乐链路原先会在前端、BFF 和 Suno body 构造阶段清理 Prompt,并为空值提供默认回退,无法保证用户输入、正式请求和生成记录完全等值;新增预设和 AI 助手后,还需要明确与现有 SFX、请求字段和生成生命周期的边界。 -- 决策:本规则只适用于 `audio-background-music`,并在该模式内取代 2026-08-03「编辑器持久化的 `prompt` 统一表示规范化用户意图」的泛化表述;其它图片、角色和图标生成语义不变。输入框当前完整原文是唯一最终 BGM Prompt,预设可见文本写入后属于该 Prompt,预设 ID、分组、颜色和内部引导不得进入正式请求。全链路不得 `trim`、规范化、合并换行、删除零宽字符、替换标点或静默截断,200 字上限按 Unicode code point 计算;0 个非 Unicode 空白字符不能生成,1 个可正式生成,至少 2 个且不超限时可 AI 补全。请求字段继续使用 `gptDescriptionPrompt` / `gpt_description_prompt`,不得改名为 `actualPrompt`;`actual_prompt` 继续是资源 / 素材审计字段,但当前 BGM 链路中的输入框、BFF、Suno body、生成记录 `prompt` / `actual_prompt` 和结果最终文本必须等值。AI 补全与 `180 -> 170`、最多两次的一键简化只走登录态内部 BFF,不调用 Suno、不创建任务、不扣正式音乐生成泥点;程序不截断,也不硬编码内容保留规则,成功后只保存一层 Prompt 交换快照。正式生成在点击事件内、任何 `await` 前同步锁定当前 BGM generation dialog,后续重复点击忽略;拒绝时恢复原 Prompt 和既有快照,接受后进入现有 `queued/generating` 占位,不锁整个画布或其它 dialog。 -- 影响范围:画板音乐权威设计、BGM composer 与临时状态模型、`editorProjectClient`、`shared-contracts` 内部助手 DTO、`api-server` 登录态 Prompt 助手与正式 BGM BFF、`platform-audio` Suno body builder,以及 BGM 提交与端到端测试。SFX 继续使用 Vidu `audio1.0`、现有规范化和默认 Prompt;不修改 SpacetimeDB schema、External v1 / OpenAPI、Suno 三字段 body、固定模型 / 泥点展示或现有 LLM 原文日志策略。 -- 验证方式:TypeScript 与 Rust 对空白、换行、零宽字符、组合字符、ZWJ emoji 和 199 / 200 / 201 code point 得出一致计数;端到端断言输入框、BFF、Suno body、记录和响应原文等值;状态测试覆盖预设追加、补全 / 简化失败不覆盖、单层交换撤销、迟到响应和同 dialog 双击只产生一次任务与一次扣费;SFX 请求体、1500 字限制、时长和默认 Prompt 回归不变。文档阶段运行 `npm run check:encoding` 和 `git diff --check`。 +- 背景:图片画布背景音乐链路原先会在前端、BFF 和 Suno body 构造阶段执行不一致的 Prompt 清理,并为空值提供默认回退,可能形成输入框不可见的实际提交文本;新增预设和 AI 助手后,需要统一唯一可见 Prompt、首尾 Unicode 空白 canonicalization,以及与 SFX、请求字段和 External v1 的边界。 +- 决策:本规则只适用于 `/editor/canvas` 的 `audio-background-music`,并在该模式内取代 2026-08-03「编辑器持久化的 `prompt` 统一表示规范化用户意图」的泛化表述;其它图片、角色和图标生成语义不变。输入框是唯一 BGM Prompt 真相,预设可见文本和 AI 成功结果写回后都成为当前 Prompt;不得在输入框之外维护或向 Suno 发送另一份隐藏 Prompt。进入预设追加、AI 补全、简化、撤销或正式生成边界前,只允许执行一项 canonicalization:移除首尾属于 Unicode `White_Space` 属性的 code point,并在后续动作前把结果同步写回同一个输入框;TypeScript 不得用会额外移除 U+FEFF 的 `String.trim()` 代替 Unicode `White_Space` 判定。canonical Prompt 内部的空格、CR / LF 和其它 Unicode 空白保持原位,U+200B、U+FEFF、组合字符、ZWJ emoji 等非 `White_Space` code point 即使位于首尾也必须保留;除此之外不得做 NFC、空白折叠、换行转换、标点替换或静默截断。canonical Prompt 满足 `有效字符数 = 0 ⇔ 总字符数 = 0`,所以任意长度的纯 Unicode `White_Space` 原始输入规范化后都禁止补全、简化和正式生成;只有至少含 1 个有效字符且总数超过 200 才允许简化。预设 ID、分组、颜色、助手系统模板和内部引导不得拼入 Suno Prompt;Prompt 助手可以使用服务端固定模板生成可见结果,但正式 Suno 请求只携带输入框可见的 canonical Prompt。200 字上限和有效字符数都以 canonical Prompt 计算,有效字符定义为非 Unicode `White_Space` code point:0 个不能补全或正式生成;1 个且总数不超过 200 时只能正式生成;至少 2 个且总数不超过 200 时可以补全和正式生成;至少含 1 个有效字符且总数超过 200 时禁止补全和正式生成,只允许简化。请求字段继续使用 `gptDescriptionPrompt` / `gpt_description_prompt`,不得改名为 `actualPrompt`;`actual_prompt` 继续保留资源 / 素材审计语义,canonical 输入框、BFF、队列载荷、Suno body、生成记录 `prompt` / `actual_prompt` 和结果响应 `prompt` / `actualPrompt` 必须等值,BGM 成功响应同时填充后两者。AI 补全与 `180 -> 170`、最多两次的一键简化只走登录态内部 BFF,不调用 Suno、不创建任务、不扣正式音乐生成泥点;简化全程冻结入站 canonical `originalPrompt`,第一次以它为 `currentPrompt`、目标 180,第二次优先以第一次响应对象中可提取且包含有效字符的 canonical 字符串 `prompt` 为 `currentPrompt`、目标 170,即使其它 envelope 字段无效;连对象或字符串 `prompt` 都无法提取,或候选 canonicalize 后不含有效字符时回退原文,同时始终用同一 `originalPrompt` 作保真参照。内部 envelope 固定为 `prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean`;候选仅在四字段结构有效、canonical 后包含有效字符且不超过 200 字、后三个字段依次为 `true / true / false` 时通过。`isDirectWritebackFormat` 表示候选只含一条可直接写回的中文 BGM Prompt,不含解释、标题、Markdown、JSON、代码块、字数报告、处理过程或删改说明;程序只解析字段、canonicalize、计数和执行布尔结果,不用关键词或未定义正则猜测语义判断。程序不截断,也不硬编码内容保留规则,成功后只保存一层 canonical Prompt 交换快照。正式生成在点击事件内、任何 `await` 前同步锁定当前 BGM generation dialog,并完成 canonicalization、写回、校验和请求值冻结,后续重复点击忽略;拒绝时保留 canonical Prompt 和既有快照,接受后进入现有 `queued/generating` 占位,不锁整个画布或其它 dialog。 +- 影响范围:画板音乐权威设计、BGM composer 与临时状态模型、`editorProjectClient`、`shared-contracts` 内部助手 DTO、`api-server` 登录态 Prompt 助手与正式 BGM BFF、正式 generation queue 载荷、`platform-audio` Suno body builder,以及 BGM 提交与端到端测试。SFX 继续使用 Vidu `audio1.0`、现有规范化、默认 Prompt、1500 字限制和时长契约,不应用 BGM 的 canonicalization 或 0 / 1 / 2 有效字符规则;本规则不修改 SpacetimeDB schema,也不改变或扩展 External v1 / OpenAPI 的背景音乐请求、异步语义和路由,Suno 三字段 body、固定模型 / 泥点展示和现有 LLM 原文日志策略不变。 +- 验证方式:TypeScript 与 Rust 对 CR / LF、CRLF、组合字符、ZWJ emoji、U+0085、U+200B、U+FEFF 和 199 / 200 / 201 code point 得出一致结果;首尾 U+0085 等 Unicode `White_Space` 被移除并同步反映到输入框,U+FEFF 与零宽字符不被误删。状态测试分别锁定全空白、0 / 1 / 2 个有效字符、预设追加、补全 / 简化失败不覆盖 canonical Prompt、单层交换撤销、迟到响应和同 dialog 双击;端到端断言 canonical 输入框、BFF、队列载荷、Suno body、记录和响应等值,且不存在隐藏 Prompt。SFX 请求体、默认 Prompt、1500 字限制和时长不变,External v1 契约测试无差异。文档阶段运行 `npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index fa82c0075..66c6f6660 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 +- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中可能存在的后端拼接 Prompt、固定生成模板或模型默认提示词。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 @@ -118,7 +118,7 @@ - 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、taskId、durationSeconds、resolution 和 `priceMudPoints`,不返回生成 provider。 -- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、taskId、duration、歌词和 `priceMudPoints`,不返回生成 provider。 +- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、`prompt`、`actualPrompt`、model、taskId、duration、歌词和 `priceMudPoints`,不返回生成 provider。BGM 成功响应必须同时填充 `prompt` 与 `actualPrompt`,两者都和输入框已经写回的 canonical `gpt_description_prompt` 逐 code point 等值;共享响应类型为兼容 SFX 与历史数据仍可保留 `actualPrompt` 可选,SFX 语义不变。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index c292376a9..1637496e9 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -31,28 +31,33 @@ ### 生成游戏背景音乐 -- `gpt_description_prompt`:用户输入框当前显示的完整背景音乐提示词,是正式 BGM 生成的唯一最终 Prompt。 +- `gpt_description_prompt`:用户输入框当前文本按本文规则清理首尾 Unicode 空白后形成的背景音乐提示词,是正式 BGM 生成的唯一最终 Prompt。 - `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。 - 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv`、`gpt_description_prompt`、`make_instrumental: true`。`mv` 后端固定使用默认 Suno 模型,UI 以禁用态模型胶囊显示 `Suno`。 - 前端请求继续使用 `gptDescriptionPrompt`,Rust DTO 继续使用 `gpt_description_prompt`;不得因“唯一最终 Prompt”语义把请求字段改名为 `actualPrompt`。响应中的 `actualPrompt` / `actual_prompt` 继续保留既有生成审计语义。 -- `gpt_description_prompt` 按 Apifox 契约限制 200 个 Unicode code point。前端、BFF 和 `platform-audio` 只校验,不执行 `trim`、Unicode 规范化、空白折叠、换行转换、标点替换或静默截断。 +- `gpt_description_prompt` 按 Apifox 契约限制 200 个 Unicode code point。前端、BFF 和 `platform-audio` 允许且必须执行同一套首尾 Unicode 空白清理;除此之外不得执行 Unicode 规范化、内部空白折叠、内部换行转换、标点替换或静默截断。 ## BGM Prompt 优化 V1.0 -### 唯一最终 Prompt 与字符口径 +### 唯一可见最终 Prompt、首尾空白与字符口径 -- 预设和 AI 助手都只修改同一个 BGM 输入框。输入框当前显示的完整字符串是唯一最终 Prompt,不维护另一份“实际 Prompt”、选中预设集合或内部拼接结果。 -- 正式生成时,输入框、前端 BFF 请求、Suno body、生成记录 `prompt`、生成记录 `actual_prompt` 和结果响应中的最终文本必须逐 code point 完全一致。 -- 用户 Prompt 原样处理:不执行 `trim`,不转换或合并换行,不删除零宽字符,不执行 NFC 或其它 Unicode 规范化,不折叠空格,不替换标点,不静默截断。 -- 总字符数按输入框实际字符串的 Unicode code point 数计算。TypeScript 使用 `Array.from(prompt).length`,Rust 使用 `prompt.chars().count()`。 -- “有效字符”只用于动作可用性和参数校验,定义为非 Unicode 空白 code point;它不改变原字符串。标点、数字、字母、空格、换行和不可见 code point 都计入 200 字总数。 +- 预设和 AI 助手都只修改同一个 BGM 输入框。“唯一最终 Prompt”约束的是语义来源和用户可见性:不得在正式请求中额外拼入用户看不到的前缀、后缀、预设元数据、分组信息、内部模板或系统提示词;它不要求保留编辑缓冲区中的首尾空白。这里的“用户不可见内容”指应用额外注入的语义文本,不指用户输入或粘贴的零宽字符等不可见 Unicode code point。 +- 从当前字符串两端删除属于 Unicode `White_Space` 属性的 code point 后得到的文本,以下称 canonical Prompt(规范化 Prompt);内部空格、内部换行和其它 code point 保持不变。 +- 用户编辑期间输入框可以暂时保留首尾空格、Tab 和换行。字符计数与动作可用状态基于 canonical Prompt 的非写入式预览计算,不得在每次键入时改写输入框;点击预设、AI 补全、一键简化、撤销或正式生成时,前端才在同步操作阶段计算 canonical Prompt 并写回输入框。 +- 正式生成时,写回后的输入框、前端 BFF 请求、队列请求载荷、Suno body、生成记录 `prompt`、生成记录 `actual_prompt` 和结果响应必须逐 code point 完全一致。 +- 首尾空白清理必须跨语言一致,不能直接混用语义不同的原生函数。TypeScript 按 `\p{White_Space}` 删除两端 code point;Rust 使用 `char::is_whitespace` 删除两端 code point。`U+200B` 和 `U+FEFF` 不属于 Unicode `White_Space`,不得被这一步顺带删除。 +- 除首尾 Unicode 空白清理外,不删除零宽字符,不执行 NFC 或其它 Unicode 规范化,不折叠内部空格,不转换或合并内部换行,不替换标点,不静默截断。 +- 总字符数按规范化后最终 Prompt 的 Unicode code point 数计算。TypeScript 使用 `Array.from(finalPrompt).length`,Rust 使用 `final_prompt.chars().count()`;被删除的首尾 Unicode 空白不计入 200 字。 +- “有效字符”只用于动作可用性和参数校验,定义为最终 Prompt 中的非 Unicode 空白 code point。标点、数字、字母和不可见但不属于 Unicode `White_Space` 的 code point 均计为有效字符;内部空格、内部换行和全部不可见 code point 仍计入 200 字总数。 -| 当前输入 | AI 补全 | 一键简化 | 正式生成 | +| 规范化后的最终 Prompt | AI 补全 | 一键简化 | 正式生成 | | --- | --- | --- | --- | -| 0 个有效字符 | 禁止 | 禁止 | 禁止 | +| 空字符串(总数 0、有效字符数 0) | 禁止 | 禁止 | 禁止 | | 1 个有效字符且总数不超过 200 | 禁止 | 禁止 | 允许 | | 至少 2 个有效字符且总数不超过 200 | 允许 | 禁止 | 允许 | -| 总数超过 200 | 禁止 | 允许 | 禁止 | +| 至少 1 个有效字符且总数超过 200 | 禁止 | 允许 | 禁止 | + +- canonical Prompt 满足不变量:`有效字符数 = 0` 当且仅当 `总字符数 = 0`。原始输入即使完全由 201 个或更多 Unicode `White_Space` 组成,canonicalization 后仍为空字符串,AI 补全、一键简化和正式生成全部禁止;不得按规范化前的长度把全空白输入归入“可简化”状态。 ### 预设库与追加规则 @@ -93,13 +98,14 @@ 点击预设时: -1. 输入框原始字符串为空,直接写入该预设的完整可见文本。 -2. 输入框原始字符串非空,检查原文最后一个 Unicode code point。 -3. 最后一个字符属于 Unicode 标点类别时,直接追加预设文本;否则先追加中文句号 `。`,再追加预设文本。 -4. 不对原文执行 `trim`。原文以空格或换行结尾时,句号追加在这些原始字符之后。 -5. 允许重复点击同一预设,每次都按同一规则追加。 -6. 点击任意预设后清除旧撤销快照并隐藏撤销按钮。 -7. 追加后允许超过 200 字,完整文本必须保留;超限只复用现有通用 Prompt 过长状态,不增加预设专用警告。 +1. 先按统一规则删除输入框文本两端的 Unicode `White_Space` code point,并把结果写回输入框。 +2. 规范化后的字符串为空,直接写入该预设的完整可见文本。 +3. 规范化后的字符串非空,检查其最后一个 Unicode code point。 +4. 最后一个字符属于 Unicode 标点类别时,直接追加预设文本;否则先追加中文句号 `。`,再追加预设文本。 +5. 首尾空白在追加前已经删除;内部空格和内部换行保持原位,不得继续清理。写入输入框的规范化文本、句号和预设可见文本共同构成新的唯一最终 Prompt。 +6. 允许重复点击同一个预设,每次都按同一规则追加。 +7. 点击任意预设后清除旧撤销快照并隐藏撤销按钮。 +8. 追加后允许超过 200 字,完整文本必须保留;超限只复用现有通用 Prompt 过长状态,不增加预设专用警告。 预设滚动交互: @@ -113,34 +119,36 @@ ### AI 补全 -- 至少 2 个有效字符且总字符数不超过 200 时允许调用。前端把输入框原始字符串传给登录态内部 BFF,不做前置清理。 +- 点击 AI 补全时先按统一规则规范化输入框首尾空白并同步写回;至少 2 个有效字符且总字符数不超过 200 时,前端才把这份可见最终 Prompt 传给登录态内部 BFF。 - 默认模型使用现有编辑器 Agent LLM 配置中的 `gpt-5.4-mini`;Prompt 助手复用现有 `LlmClient`,不建立新的平台 LLM 能力。 - 服务端模板必须要求:保留用户明确的主题、场景、风格、情绪、乐器、能量、韵律、时长、循环和避免项;按场景选择性补足场景、氛围、能量、韵律、乐器、旋律、声音设计、循环和避免项,不为凑全方向堆砌形容词。 - 用户描述已足够完整时,只补充一至两个与主题匹配的具体声音细节。发现冲突时,优先级为“明确避免项和限制 > 明确玩法用途与场景 > 风格、情绪、能量与韵律 > AI 补充细节”。 - 输出只允许一条可直接写回输入框的中文 BGM Prompt;不得包含解释、标题、Markdown、JSON、代码块、具体艺人或歌曲模仿要求。 -- 结果必须包含有效字符且不超过 200 个 Unicode code point。校验通过后整段替换输入框;调用失败、结果为空、格式非法或超限时保留原文,不写回部分结果。 +- AI 输出先执行同一套首尾 Unicode 空白规范化,再由程序计算字符数。结果必须包含有效字符且不超过 200 个 Unicode code point;校验通过后整段写回输入框并成为唯一最终 Prompt。调用失败、结果为空、格式非法或超限时保留请求前已经写回的规范化 Prompt,不写回部分结果。 - 补全过程不调用 Suno、不创建正式生成任务、不触发正式音乐生成扣费。 ### 一键简化 -- 只在当前 Prompt 总字符数超过 200 时允许调用。点击前保存完整原文,AI 处理中保持输入框原文不变。 -- 第一次简化目标为 180 字;180 不是硬门槛,只要候选不超过 200 字、格式合法且 LLM 判断内容完整即可通过。 -- 第一次候选超过 200 字、格式非法、LLM 判断内容不完整或存在明显残句时,自动进行唯一一次重试,目标为 170 字。 -- 第二次仍不合格时返回失败并保留原文,提示用户手动精简;最多两次 LLM 调用,任何情况下都不得由程序截断到 200 字。 +- 点击一键简化时先按统一规则规范化输入框首尾空白并同步写回;只在规范化后的当前 Prompt 总字符数超过 200 时允许调用。点击前保存这份规范化后的完整 Prompt,AI 处理中保持输入框不变。 +- 服务端在本次简化中冻结 `originalPrompt` 为入站 canonical Prompt,最多两次调用期间始终不变,作为内容保真参照。第一次调用使用 `currentPrompt = originalPrompt`,目标为 180 字。这里的 `originalPrompt` / `currentPrompt` 是服务端组装 LLM 简化模板时的内部变量;客户端简化 BFF DTO 仍只提交一个 `currentPrompt`,该入站值经 canonicalization 后同时成为内部 `originalPrompt` 和第一次内部 `currentPrompt`。 +- 180 不是硬门槛。每次调用要求返回内部结构化 envelope:`prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean`。envelope 不属于候选文本,也不得写回输入框;响应不能解析为包含上述正确字段类型的对象时,本次候选不通过。 +- 候选“格式合法”专指 `isDirectWritebackFormat = true`:模型确认 canonical 候选只包含一条可直接写回输入框的中文 BGM Prompt,不包含解释、标题、Markdown、JSON、代码块、字数报告、处理过程或删改说明。它与“内部结构可解析”是两个独立校验项;程序不得另用关键词或未定义的正则推断标题、解释等语义格式。 +- 第一次候选未通过任一通过条件时,自动进行唯一一次重试,目标为 170 字。若第一次响应至少能解析为对象并提取字符串 `prompt`,且该字符串 canonicalize 后包含有效字符,第二次使用 `currentPrompt = canonicalize(第一次候选)`,即使第一次因其它字段缺失、超限、格式、完整性或残句判断而不通过;若第一次响应连对象或字符串 `prompt` 都无法提取,或候选 canonicalize 后不含有效字符,第二次回退使用 `currentPrompt = originalPrompt`。第二次调用中的 `originalPrompt` 始终仍是最初入站 canonical Prompt。 +- 每次候选都先执行同一套首尾 Unicode 空白规范化,再计算实际字符数。第二次仍不合格时返回失败并保留请求前已经写回的规范化 Prompt,提示用户手动精简;最多两次 LLM 调用,任何情况下都不得由程序截断到 200 字。 - 简化模板必须优先保留明确限制、场景与用途、情绪与风格、核心乐器与速度、能量/韵律/旋律、循环结构、区分度较高的声音设计,再删除次要修饰细节;不得改变专有名词、BPM、调性、时长、数值、乐器和明确限制。 -- 程序不抽取关键词,不对原文与候选执行内容硬编码逐项比对。是否保留重要信息、内容是否完整、是否形成明显残句由 LLM 在同一次调用的内部结构化结果中判断。 -- 内部结构化结果同时包含候选 Prompt 与完整性判断;BFF 对外只返回已通过校验的 Prompt 和程序计算的字符数,不暴露内部判断字段。 -- 程序只负责确定性校验:结构可解析、候选包含有效字符、格式合法、实际字符数不超过 200。字符数必须由程序计算,不采信模型报告。 +- 程序不抽取关键词,不对规范化后的输入与候选执行内容硬编码逐项比对。是否为直接写回格式、是否保留重要信息且内容完整、是否形成明显残句由 LLM 在同一次调用的三个布尔字段中分别判断。 +- BFF 对外只返回已通过校验的 Prompt 和程序计算的字符数,不暴露三个内部判断字段。 +- 候选只有同时满足以下条件才通过:内部结构可解析且四个字段类型正确;canonical 候选包含有效字符;canonical 候选实际字符数不超过 200;`isDirectWritebackFormat = true`;`isContentComplete = true`;`hasObviousFragment = false`。程序负责解析和检查字段类型、canonicalization、有效字符与实际字符数,并执行三个布尔判断结果;字符数由程序计算且不采信模型报告,程序不自行推断三个语义判断。 - 一键简化不维护或恢复“已选预设”元数据;预设已写入输入框的文本只是当前最终 Prompt 的一部分。 ### 单层撤销 -- AI 补全和一键简化成功都必须产生一层 Prompt 快照。发起 AI 操作前,以当时的当前 Prompt 建立本次临时快照。 -- AI 成功写回后,该快照成为可撤销版本;AI 失败且原文未变时清除本次临时快照,不生成新的可撤销版本。 -- 用户手动编辑 AI 结果后,撤销仍可用。再次发起 AI 操作时,以当时的当前 Prompt 替换旧快照。 -- 点击预设清除旧快照;正式提交被后端拒绝时,恢复并保留提交前已有快照。 -- 点击撤销时,当前 Prompt 与快照互换;按钮继续可用,允许用户在两个版本间来回切换。 -- 只保存一层,快照只包含 Prompt,不包含预设滚动位置、展开状态、模型、时长或其它参数。 +- AI 补全和一键简化成功都必须产生一层 Prompt 快照。发起 AI 操作前,先把当时输入规范化并写回,再以该 canonical Prompt 建立本次临时快照。 +- AI 成功写回后,该快照成为可撤销版本;AI 失败时输入框保留请求前已经写回的 canonical Prompt,清除本次临时快照,不生成新的可撤销版本,也不恢复发起本次操作时已经被替换的旧快照。 +- 用户手动编辑 AI 结果后,撤销仍可用。再次发起 AI 操作时,先规范化并写回当时的当前 Prompt,再以该 canonical Prompt 替换旧快照。 +- 点击预设时先规范化并写回,再清除旧快照;正式提交被后端拒绝时,保留已经写回的 canonical Prompt 和提交前已有快照。 +- 点击撤销时,先把当前输入规范化并写回,再让该 canonical Prompt 与 canonical 快照互换;按钮继续可用,允许用户在两个规范化版本间来回切换。 +- 只保存一层,快照只包含 canonical Prompt,不包含预设滚动位置、展开状态、模型、时长或其它参数。 ### AI 操作与方案 A 提交锁 @@ -155,9 +163,9 @@ idle - `completing` / `simplifying` 开始时以同步 operation ID 取得当前 dialog 操作权;同一操作的后续双击无效。新操作使旧 operation ID 失效,关闭 dialog 时中止请求,dialog ID 或 operation ID 不匹配的迟到响应必须丢弃。 - AI 处理中输入框只读,禁用预设展开/收起、预设词条、滚动箭头、AI 补全、一键简化、撤销和生成。 -- 点击生成的同步事件内必须在任何 `await` 前把当前 BGM dialog 切换到 `submitting`。提交中锁定该 dialog 的输入框、预设、展开/收起、滚动箭头、AI 操作、撤销、参数控件和生成按钮,忽略后续重复点击。 +- 点击生成的同步事件内必须在任何 `await` 前依次完成:立即把当前 BGM dialog 切换到 `submitting`、计算 canonical Prompt 并写回输入框、按 canonical Prompt 校验 0 个有效字符和 200 字上限、冻结本次请求值。前端校验失败时立即解除锁并保留写回后的 canonical Prompt,不发送请求。提交中锁定该 dialog 的输入框、预设、展开/收起、滚动箭头、AI 操作、撤销、参数控件和生成按钮,忽略后续重复点击。 - 锁只作用于当前 BGM dialog,不锁整个画布、其它 generation dialog、画布拖动、缩放、图层操作或其它编辑能力。 -- 后端拒绝正式提交时,解除锁,恢复当前面板并保留用户原始 Prompt 与提交前已有撤销快照,继续使用现有正式生成错误展示。 +- 后端拒绝正式提交时,解除锁,恢复当前面板并保留已经写回的 canonical Prompt 与提交前已有撤销快照,继续使用现有正式生成错误展示。 - 后端接受请求并创建正式生成任务后,`submitting` 结束,当前 dialog 进入现有 `queued/generating` 占位;输入 composer 继续隐藏,同一任务不得再次提交。 - “提交成功”仅表示后端已接受请求并创建正式任务,不表示 Suno 已完成音乐生成。 @@ -180,7 +188,7 @@ idle - generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `