合并最新主线并统一画布素材合同
Project CI / Repository checks (pull_request) Successful in 1m10s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 3m39s
Project CI / Native shell tests (pull_request) Successful in 13m22s

合并主线背景音乐与角色动作素材能力
保留无限画布共享渲染和历史恢复合同
统一正式序列帧与素材类型字段
补齐后端、SpacetimeDB 与 CI 回归验证
This commit is contained in:
2026-08-07 10:17:41 +08:00
156 changed files with 22143 additions and 1456 deletions
+3
View File
@@ -22,6 +22,9 @@
- [客户端素材创作无限画布阶段一合同](./technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md)
- [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md)
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md)
- [画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md)
- [音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.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)
+224 -15
View File
@@ -2051,6 +2051,47 @@
},
"ExternalEditorAssetCreateRequest": {
"type": "object",
"allOf": [
{
"if": {
"properties": {
"assetKind": {
"const": "character-animation"
}
},
"required": [
"assetKind"
]
},
"then": {
"required": [
"imageSequenceFrames",
"imageSequenceDurationMs"
],
"properties": {
"generationInputs": {
"$ref": "#/components/schemas/ExternalCharacterAnimationGenerationInputs"
}
}
},
"else": {
"not": {
"anyOf": [
{
"required": [
"imageSequenceFrames"
]
},
{
"required": [
"imageSequenceDurationMs"
]
}
]
}
}
}
],
"required": [
"folderId",
"label",
@@ -2132,16 +2173,71 @@
"type": [
"string",
"null"
]
],
"description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。"
},
"imageSequenceFrames": {
"type": "array",
"minItems": 2,
"items": {
"$ref": "#/components/schemas/EditorImageSequenceFrame"
}
},
"imageSequenceDurationMs": {
"type": "integer",
"minimum": 1,
"description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。"
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
"$ref": "#/components/schemas/JsonValue",
"description": "可重放的生成输入。assetKind=character-animation 时不得包含 characterAnimation、frames、previewVideoPath、frameCount、fps 或 durationSeconds 等旧运行字段;正式媒体数据必须写入 imageSequenceFrames 和 imageSequenceDurationMs。服务端会移除 screenColorHex、mattingProvider、mattingModel 等内部处理审计字段。"
}
},
"additionalProperties": false
},
"ExternalEditorProjectResourceCreateRequest": {
"type": "object",
"allOf": [
{
"if": {
"properties": {
"assetKind": {
"const": "character-animation"
}
},
"required": [
"assetKind"
]
},
"then": {
"required": [
"imageSequenceFrames",
"imageSequenceDurationMs"
],
"properties": {
"generationInputs": {
"$ref": "#/components/schemas/ExternalCharacterAnimationGenerationInputs"
}
}
},
"else": {
"not": {
"anyOf": [
{
"required": [
"imageSequenceFrames"
]
},
{
"required": [
"imageSequenceDurationMs"
]
}
]
}
}
}
],
"required": [
"imageSrc",
"width",
@@ -2215,14 +2311,71 @@
"type": [
"string",
"null"
]
],
"description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。"
},
"imageSequenceFrames": {
"type": "array",
"minItems": 2,
"items": {
"$ref": "#/components/schemas/EditorImageSequenceFrame"
}
},
"imageSequenceDurationMs": {
"type": "integer",
"minimum": 1,
"description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。"
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
"$ref": "#/components/schemas/JsonValue",
"description": "可重放的生成输入。assetKind=character-animation 时不得包含 characterAnimation、frames、previewVideoPath、frameCount、fps 或 durationSeconds 等旧运行字段;正式媒体数据必须写入 imageSequenceFrames 和 imageSequenceDurationMs。服务端会移除 screenColorHex、mattingProvider、mattingModel 等内部处理审计字段。"
}
},
"additionalProperties": false
},
"ExternalCharacterAnimationGenerationInputs": {
"not": {
"anyOf": [
{
"type": "object",
"required": [
"characterAnimation"
]
},
{
"type": "object",
"required": [
"frames"
]
},
{
"type": "object",
"required": [
"previewVideoPath"
]
},
{
"type": "object",
"required": [
"frameCount"
]
},
{
"type": "object",
"required": [
"fps"
]
},
{
"type": "object",
"required": [
"durationSeconds"
]
}
]
},
"description": "assetKind=character-animation 时 generationInputs 不得包含旧运行字段;正式媒体数据使用 imageSequenceFrames 和 imageSequenceDurationMs。"
},
"ExternalEditorAssetUpdateRequest": {
"type": "object",
"properties": {
@@ -2545,7 +2698,19 @@
"type": [
"string",
"null"
]
],
"description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。"
},
"imageSequenceFrames": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EditorImageSequenceFrame"
}
},
"imageSequenceDurationMs": {
"type": "integer",
"minimum": 1,
"description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。"
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
@@ -2688,6 +2853,18 @@
"sourceType": {
"type": "string"
},
"imageSequenceFrames": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EditorImageSequenceFrame"
},
"description": "assetKind=character-animation 时的完整可播放帧集合。"
},
"imageSequenceDurationMs": {
"type": "integer",
"minimum": 1,
"description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。"
},
"prompt": {
"type": [
"string",
@@ -2716,7 +2893,8 @@
"type": [
"string",
"null"
]
],
"description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。"
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
@@ -3852,7 +4030,7 @@
"string",
"null"
],
"description": "角色动作绿幕预览视频写入账号素材库的文件夹;省略时写入默认项目素材文件夹。"
"description": "角色动作预览视频和最终透明序列帧素材写入账号素材库的文件夹;省略时写入默认项目素材文件夹。"
},
"assetLabel": {
"type": [
@@ -3865,22 +4043,29 @@
},
"additionalProperties": false
},
"EditorCharacterAnimationFrame": {
"EditorImageSequenceFrame": {
"type": "object",
"required": [
"frameIndex",
"imageSrc",
"objectKey",
"assetObjectId",
"width",
"height"
],
"properties": {
"frameIndex": {
"type": "integer",
"minimum": 0
},
"imageSrc": {
"type": "string"
},
"objectKey": {
"type": "string",
"minLength": 1,
"description": "帧对应的稳定 OSS Object Key;服务端据此重建持久 imageSrc,不能只提交临时签名 URL。"
},
"assetObjectId": {
"type": "string",
"minLength": 1,
"description": "帧对应的稳定资产对象 ID。"
},
"width": {
"type": "integer",
"minimum": 1
@@ -3889,7 +4074,9 @@
"type": "integer",
"minimum": 1
}
}
},
"additionalProperties": false,
"description": "图片序列帧。数组位置是唯一播放顺序,不携带额外序号字段;每帧必须同时携带 objectKey 与 assetObjectId,imageSrc 按 objectKey 规范化为持久站内路径。"
},
"EditorCharacterAnimationGenerationResponse": {
"type": "object",
@@ -3926,7 +4113,7 @@
"frames": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EditorCharacterAnimationFrame"
"$ref": "#/components/schemas/EditorImageSequenceFrame"
}
},
"frameCount": {
@@ -3964,6 +4151,28 @@
],
"description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。"
},
"resource": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorProjectResource"
},
{
"type": "null"
}
],
"description": "最终透明角色动作对应的项目资源。携带 projectId 时,画布图层必须直接引用其 resourceId,不得再次创建重复资源。"
},
"asset": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorAsset"
},
{
"type": "null"
}
],
"description": "最终透明角色动作素材。assetKind 为 character-animation,并直接包含序列帧字段。"
},
"queueState": {
"anyOf": [
{
@@ -0,0 +1,188 @@
# 音频生成 Composer 恢复共享分流方案
日期:`2026-08-06`
状态:`已实施`
## 一、目标
图片画布的音效与背景音乐恢复使用同一个音频 composer。`ImageCanvasGenerationComposerView.tsx` 内只保留一个 `ImageCanvasAudioGenerationComposerView`,并在组件内定义:
```ts
const isSoundEffect = dialog.mode === 'audio-sound-effect';
```
`isSoundEffect === true` 渲染现有 SFX 分支,`isSoundEffect === false` 渲染现有 BGM V1 分支。删除完整的独立 BGM composer 文件,但保留确有独立职责的 BGM 纯模型、助手 controller 和预设跑马灯组件。
这次只调整前端视图组织方式,不改变任何生成契约、业务规则、请求时序、计费或持久化语义。
## 二、范围与非目标
### 2.1 必须保留的 BGM 能力
- `gpt_description_prompt` 可见原文、Unicode `White_Space` canonicalization 和 200 字生成限制。
- 30 个预设、字符计数、AI 补全、一键简化和单层交换式撤销。
- AI 处理锁、同步正式提交锁、`generating` 锁和迟到响应隔离。
- 稳定 dialog ID、账号 / 项目 scope 校验和按 dialog ID 写回。
- 固定 `Suno`、当前动态泥点价格和隐藏 `make_instrumental` 的现状。
### 2.2 必须保持不变的现有 SFX 能力
- 固定 Vidu `audio1.0`,Prompt 同值映射到现有 `prompt + sound` 请求字段。
- `2–10` 秒、步长 `1` 秒、默认 `5` 秒。
- 当前 Prompt 规范化、全空白时回退“游戏音效”、1500 字限制、价格和失败态。
- 现有提交 callback、生成占位和完成链路。
- SFX 中不出现 Suno、BGM 预设、AI 补全、一键简化、BGM 字符计数或撤销。
### 2.3 本次明确不做
- 不实现 SFX V2 独有的一键优化、自动中译英、ElevenLabs、自动时长、30 秒、Loop 或 SFX 预设。
- 不修改 BGM 或 SFX 需求原文。
- 不修改 BFF、队列、`platform-audio`、External v1、OpenAPI、SpacetimeDB schema、计费或重试。
- 不把 BGM Prompt controller 泛化为音频通用 controller。
- 不新建配置驱动的 composer 框架、第二套音频组件或其它顺带重构。
## 三、当前问题
当前 `ImageCanvasGenerationComposerView.tsx` 分别渲染 SFX 内部组件和 `ImageCanvasBackgroundMusicGenerationComposerView.tsx`。这层完整视图拆分让同一个音频入口形成两套 composer 边界,而 SFX V2 需求已经表明预设、AI 写回、撤销和锁定等交互并非 BGM 永久独占,继续用“BGM 专属交互较多”作为完整组件分叉理由不再成立。
同时,最近一次合并把共享架构中的 `isSoundEffect` 条件表达式带回了当前 SFX-only 组件,却没有带回变量定义,形成 `isSoundEffect is not defined`。该问题不是增加一个局部常量后就可以收口的长期架构问题;本次重构应恢复单一音频组件,使变量与它控制的两个分支重新处于同一组件边界。
## 四、目标结构
```text
ImageCanvasEditorView
└─ useImageCanvasGenerationSurface
└─ ImageCanvasGenerationComposerView
└─ ImageCanvasAudioGenerationComposerView
├─ isSoundEffect === true → 现有 SFX UI 与行为
└─ isSoundEffect === false → 现有 BGM V1 UI 与行为
└─ ImageCanvasBackgroundMusicPresetMarquee
```
继续保留的 BGM 专项模块:
- `ImageCanvasBackgroundMusicPromptModel.ts`:canonicalization、计数、动作资格和 dialog 类型收窄。
- `useImageCanvasBackgroundMusicPromptAssist.ts`:按 dialog ID 隔离的异步助手状态与提交锁。
- `ImageCanvasBackgroundMusicPresetModel.ts`:30 个预设与追加规则。
- `ImageCanvasBackgroundMusicPresetMarquee.tsx`:展开、滚动、hover、触摸和 reduced-motion。
删除的完整视图模块:
- `ImageCanvasBackgroundMusicGenerationComposerView.tsx`
- `ImageCanvasBackgroundMusicGenerationComposerView.test.tsx`
删除测试文件不等于删除覆盖;其中全部用例必须迁入总 composer 测试。
## 五、实现边界
### 5.1 单一渲染入口
`ImageCanvasGenerationComposerView` 对 `audio-sound-effect` 与 `audio-background-music` 只保留一个渲染条件和一个 `ImageCanvasAudioGenerationComposerView` 调用。组件内部以 `isSoundEffect` 选择两棵现有表单子树,不为本次重构新造配置层。
共享组件使用基于 dialog ID 与 mode 的稳定 `key`。连续切换 BGM dialog 时,预设展开、滚动、hover 和触摸状态不得继承;从 SFX 切到 BGM 时,音效时长菜单状态也不得泄漏。
### 5.2 Hook 与类型安全
- React Hook 不得放进 `isSoundEffect` 条件分支。`useState`、`useRef`、`useId` 和 `useImageCanvasFloatingOptionDismiss` 保持固定调用顺序。
- `GenerateDialogState` 不是可判别联合。BGM 分支继续通过 `toBackgroundMusicGenerationDialog` 取得带稳定 ID 的 `BackgroundMusicGenerationDialogState`。
- BGM 缺少稳定 ID 或 `backgroundMusicPromptAssist` 时失败关闭,不渲染不完整的 BGM 表单;SFX 不依赖这两个条件。
### 5.3 两套状态写回不得合并
- SFX 继续走现有 `setGenerateDialog` 路径,保持按 mode 更新、失败态复位和时长写回语义。
- BGM 继续走 `updateCanvasGenerationDialogById(dialog.id, updater)`,不能降级成只比较 mode。助手响应、预设、撤销和提交锁仍按稳定 dialog ID 隔离。
- `backgroundMusicPromptAssist` 可以继续由 surface 传给共享 composer,但只允许 BGM 分支读取或调用。
### 5.4 锁定与提交资格不得串用
|分支|锁定条件|生成资格|
|---|---|---|
|SFX|沿用现有 `dialog.status === 'generating'`|沿用现有 SFX 提交入口和默认 Prompt 规则|
|BGM|AI processing、`submitting` 或 `generating` 任一成立|沿用 canonical Prompt 的有效字符与 `1–200` code point 规则|
BGM 使用 `PlatformTextField + readOnly + aria-invalid`;SFX 继续使用 `AutoGrowTextArea + disabled`。本次不统一这两个输入控件,也不改错误、可访问名称或按钮文案。
## 六、预计代码改动
|文件|最小改动|
|---|---|
|`ImageCanvasGenerationComposerView.tsx`|恢复共享 `ImageCanvasAudioGenerationComposerView` 与 `isSoundEffect`;移入现有 BGM JSX、常量和依赖;删除独立 BGM import 与双渲染入口|
|`ImageCanvasGenerationComposerView.test.tsx`|迁入独立 BGM composer 的全部测试,并增加双 mode 隔离与切换回归|
|`ImageCanvasBackgroundMusicGenerationComposerView.tsx`|删除|
|`ImageCanvasBackgroundMusicGenerationComposerView.test.tsx`|覆盖迁完后删除|
|`useImageCanvasBackgroundMusicPromptAssist.ts`|只修正指向独立 composer 的历史注释;不改 controller 行为|
以下生产文件预计不改:`useImageCanvasGenerationSurface.tsx` 的现有 props 接线、BGM Prompt / 预设模型、预设跑马灯、submission workflow、submission model、dialog model、`src/index.css` 的现有 BGM 样式,以及全部后端代码。
如实施时发现必须超出该清单才能保持现有行为,应先停下并重新确认边界,不能借本次组件归并顺带重构。
## 七、测试迁移与回归矩阵
### 7.1 BGM 原覆盖完整迁移
独立组件测试中的下列覆盖必须逐项迁入 `ImageCanvasGenerationComposerView.test.tsx`:
- canonical preview 计数、超限展示和不改写输入框。
- 0 / 1 / 2 个有效字符及 200 / 201 / 2000 / 2001 边界。
- 补全、简化、撤销和预设按正确 dialog ID 路由。
- `preparePreset` 拒绝时不写回,预设成功时沿用追加和清快照语义。
- 助手错误与生成错误的展示顺序。
- Suno、泥点价格、生成允许 / 拒绝。
- `completing`、`simplifying`、`submitting`、`generating` 锁定。
- 完整单层撤销按钮矩阵和现有可访问属性。
### 7.2 mode 隔离与切换
- SFX 渲染时不存在 BGM 控件,也不存在本次明确排除的 SFX V2 控件。
- BGM 渲染时不存在 Vidu 和音效时长控件。
- SFX 仍保持 `audio1.0`、2–10 秒、默认 5 秒、当前价格和既有提交参数。
- BGM dialog A 展开预设后切到 dialog B,局部展开与滚动状态不继承。
- SFX 与 BGM 相互切换时,菜单、锁和 Prompt 助手状态不跨 mode 泄漏。
- 缺少稳定 ID 或 controller 的 BGM 失败关闭;同样条件不影响 SFX 正常渲染。
### 7.3 最小验证命令
```powershell
npm run test -- src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts src/components/image-editor/ImageCanvasBackgroundMusicPresetModel.test.ts src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.test.tsx src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx
npm run test -- src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
npm run typecheck
npm run check:encoding
git diff --check
```
删除旧测试文件后,还要确认测试收集清单中不再引用它。若本次改动触发其它现有图片画布测试失败,只修复由共享 composer 归并直接造成的回归,不扩大到无关模块。
## 八、实施顺序
1. 在总 composer 内恢复共享音频组件、`isSoundEffect` 和单一音频渲染入口。
2. 原样迁入 BGM 分支,保留按 ID 写回、锁定、错误顺序、Suno 与预设行为。
3. 迁移独立 BGM 组件测试,并补齐 SFX/BGM 隔离与切换用例。
4. 删除独立 BGM composer 及其测试文件,修正相关注释与文档引用。
5. 运行定向测试、typecheck、编码检查和差异检查;实现完成后再把实际结果补入 T6 后续记录。
## 九、完成定义
- 两个音频 mode 均从同一个 `ImageCanvasAudioGenerationComposerView` 渲染,且组件内存在唯一的 `isSoundEffect` 分流。
- 独立完整 BGM composer 文件和独立测试文件已删除,原测试覆盖无遗漏地迁入总 composer。
- BGM V1 所有已交付能力与正式提交语义不变。
- 现有 SFX UI、Prompt、Vidu、时长、价格、校验和提交链路无回归。
- 没有实现任何 SFX V2 独有功能,没有修改需求原文或后端契约。
- 规定的定向测试、typecheck、编码检查和 `git diff --check` 全部通过。
## 十、实施结果
2026-08-06 已按本文边界完成:
- `ImageCanvasGenerationComposerView.tsx` 恢复唯一 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM 继续按稳定 dialog ID 写回,SFX 继续走原 `setGenerateDialog` 路径。
- 删除 `ImageCanvasBackgroundMusicGenerationComposerView.tsx`,保留 BGM Prompt / 预设纯模型、助手 controller 和预设跑马灯。
- 把独立组件全部用例迁入 `ImageCanvasGenerationComposerView.test.tsx` 后删除旧测试文件;总 composer 现有 50 项测试同时覆盖 BGM 完整动作矩阵和 SFX/BGM 控件、状态、dialog 切换隔离。
- 只修正 `useImageCanvasBackgroundMusicPromptAssist.ts` 的组件归属注释;没有修改 controller、surface、submission workflow、样式、后端、契约或需求原文,也没有实现 SFX V2 独有功能。
实际验证:
- Prompt / 预设 / controller / 总 composer:`121/121`。
- surface 与 submission workflow:`72/72`。
- `npm run typecheck`、变更文件 ESLint、Prettier、`npm run check:encoding` 和 `git diff --check`:全部通过。
2026-08-06 已执行共享 composer 归并后的浏览器视觉 smoke,未发现阻断性问题;本轮未创建真实音频生成任务或产生扣费。
File diff suppressed because one or more lines are too long
@@ -62,6 +62,14 @@
- 验证:使用 deferred commit/layout Promise,依次在请求后切项目、切 run/overview、新开 session、改选择和改筛选;断言 manifest 只更新对应项目,新资源仍进入投影/布局,但所有失效守卫都不切中央状态、不改选择、不清查询。条件未变化且资源可见时才自动定位。
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。
## Chat 生成预算字段不能按模型名猜测或失败后自动重放
- 现象:同一个 OpenAI-compatible Chat endpoint 调用 reasoning 模型时返回 `Unsupported parameter: max_tokens`;直接把全局请求字段改成 `max_completion_tokens` 后,旧兼容网关又可能拒绝新字段。
- 原因:内部生成预算语义与上游 wire dialect 被混在一起。Chat 当前字段是 `max_completion_tokens`,旧兼容层仍只接受 `max_tokens`;Responses 和 Anthropic 又分别使用自己的字段。模型名、base URL 和 `OpenAiCompatible` 标签都不能证明 endpoint 能力,收到 `400` 后重发还可能重复计费。
- 处理:在 `LlmConfig` 上显式声明 Chat token budget field capability;通用兼容配置默认 legacy,已验证的 VectorEngine 专用 client opt-in `max_completion_tokens`,每次只发送一个字段。内部 `max_output_tokens` 与 AGC 持久指纹键 `maxOutputTokens` 保持不变。
- 验证:序列化测试分别断言 modern / legacy Chat 只出现选定字段,请求级 model override 不改变字段;Responses 继续只发 `max_output_tokens`,Anthropic 继续只发 `max_tokens`;AppState 测试断言 VectorEngine client 已显式启用 modern capability。
- 关联:`server-rs/crates/platform-llm/src/lib.rs`、`server-rs/crates/api-server/src/state.rs`、`scripts/test-ve-llm.mjs`、Issue #143。
## Runtime 状态写失败不能发生在公开失败消息之前
- 现象:用户提交长任务后只看到运行失败或任务直接消失,聊天里一条有用消息都没有;另一些失败又同时出现 Runtime event 和 conversation 两条近似提示。
@@ -4078,6 +4086,35 @@
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
- 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。
## 角色动作不能靠素材主图或通用生成输入恢复
- 现象:角色动作在整画布导出时正常,但从素材库单项下载只得到第一帧 PNG,拖回画布也成为普通静态图片。
- 原因:`editor_asset.image_src` 只指向首帧;若 worker 把完整帧集塞进 `generation_inputs_json`,素材 DTO、用户输入清洗或画布布局任一层丢字段,就会退化成 PNG。再增加一个 `mediaType` 只能掩盖结果字段没有落到正式资源的问题。
- 处理:worker 只把完整帧集与图片序列毫秒时长写入 `editor_project_resource` / `editor_asset` 的 `image_sequence_frames_json`、`image_sequence_duration_ms`;数组位置是唯一帧序,不保存 `frameIndex`,帧数和 FPS 均按需派生。`assetKind=character-animation` 决定序列渲染。素材映射、单项下载和拖回画布只读取这两个正式字段,项目 resource 在保存 / 刷新后继续作为主真相;动作 layout 只保留资源引用和 placement,不再复制正式媒体结果。账号素材提交精选审核时,`editor_showcase_asset` 必须冻结复制相同字段,公开 read model 只返回正式字段。外部 helper 只调用一次动作生成接口并直接使用响应 `resource` / `asset`。
- 画布回填:角色动作会形成“原角色资源 → 预览视频资源 → 最终序列资源”的血缘链。生成响应必须返回已经持久化的最终 resource,前端图层直接使用其 `resourceId`;不能继续构造 `local-resource-character-animation-*`,否则 `appendCanvasLayersWithResources` 会再次创建重复资源。新图层的 `sourceResourceId` 同时使用最终 resource 的直接来源(预览视频 resource),不能继续沿用请求中的原角色 resource;否则结构化保存会在已生成并计费后因血缘不一致而拒绝。修复时只替换资源关联与血缘字段,不要顺带把动作图层显示尺寸从生成占位尺寸改成原始帧分辨率。
- 历史处理:不要再在 read mapper 增加 `generationInputs` / layout fallback。使用 migration operator procedure 按 `asset → project-resource → showcase → canvas` 迁移;只有动作身份已由 `assetKind`、正式字段、嵌套 `characterAnimation` 或权威对象证明后,才解释顶层 `frames/durationSeconds`,否则会把无关任意 JSON 误分类。同一 task 可能同时存在误标为动作的预览 MP4 和最终首帧 PNG,候选查找必须先按权威对象类型做计划态分类,排除视频并要求唯一正式图片序列,不能按原始 `assetKind` 计数。账号素材仍有旧帧、但后来拖入画布的 project-resource 只剩清洗后 `fields/references` 时,project-resource dry-run 必须按同 owner / task / 首帧对象精确消费 asset 计划态结果;apply 仍要求前置 asset scope 已物理完成。canvas 判断已有 resource 是否为动作时也必须消费 project-resource 的计划态类型:旧库误标为动作、但权威对象证明为 preview MP4 且 layout 本身是 video 的图层直接跳过动作清理;layout 明确为 `image-sequence` 却指向该视频时继续形成 blocker,资源规划本身有 blocker 时也不得静默跳过。正式序列还要逐帧用稳定对象路径匹配同 owner / task 的已登记图片对象并补齐 `objectKey/assetObjectId`。迁移不得验证 layout 复制的 `sourceResourceId`:历史 layer 可能仍指向原角色,而最终素材已指向预览资源;清理副本后采用最终素材的 DB 血缘即可,新生成链路仍保持严格校验。正式与旧版结果冲突、候选为零或多个均形成 blocker;脚本诊断应直接打印 scope、ID、原因、owner/project/task、对象身份和来源资源,不能只报 blocker ID。普通 layer 顶层 `mediaType` 在迁移和响应清洗时删除,但嵌套生成参考的 `mediaType` 保留。
- 新写入与验证:动作 `generationInputs` 出现 `characterAnimation/frames/previewVideoPath/frameCount/fps/durationSeconds/screenColorHex`,或正式帧出现 `frameIndex`,HTTP 与 storage 双层拒绝;其它 asset kind 的任意 JSON 不受该动作门禁影响。测试覆盖两种历史 JSON、无关顶层同名字段、正式/旧版相等与冲突、可选帧引用合并、screen color 和 frameIndex 清理、预览 MP4 重分类、幂等、blocker/hash apply、画布 placement 清理/资源补建、正式字段缺失失败关闭和 helper 单请求。
## 图片序列时长不要复用通用媒体秒数
- 现象:把角色动作、视频、音频和上传媒体都写进通用 `duration_seconds`,随后又尝试用持久化 `frame_count/fps/duration_seconds` 互相校验,造成取整口径、生成参数和实际播放时长彼此污染。
- 原因:角色动作需要的是一组图片完整播放一次的精确时长;视频 / 音频的 `durationSeconds` 是生成请求或临时运行态参数。帧数已经由数组长度唯一确定,FPS 也可按需要推导,无需维护三份可冲突真相。
- 处理:资源 / 素材只保存 `image_sequence_frames_json` 与 `image_sequence_duration_ms`,精选审核快照只冻结复制这两个正式字段。角色动作要求至少两帧且毫秒时长大于 0;播放器按 `时长毫秒 / 数组长度` 计算间隔,Spine 导出时再换算秒数并推导 FPS。音频 / 视频 `durationSeconds` 不映射到这两个字段。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`。
## 精选角色动作显示首帧还要检查前端 renderer 与逐帧授权
- 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。
- 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。
- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;单帧换签或解码失败时跳过该帧、暂停全帧失败的序列并提供显式重试,不能长期显示空白或旧帧。卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。
- 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。
## 可复用资源回填必须保持时间戳单调
- 现象:延迟重试携带比既有行更旧的调用方时间,回填图片序列字段时若无条件写入,会使 `updated_at` 倒退,导致基于时间戳的同步看不到更新或排序错误。
- 处理:同源图片序列字段只允许 `None → Some`,非空冲突失败关闭;发生回填时 `updated_at = max(existing.updated_at, request_timestamp)`。legacy 音频 repair 不派生资源级图片序列或通用时长,重放继续精确匹配。
## 历史钱包消费不能从最近流水或通用订单快照推算
- 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
@@ -4340,6 +4377,12 @@
- 验证:自动测试使用真实公开 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 仍有效的正常回写。
## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05)
- 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。
@@ -4389,3 +4432,10 @@
- 原因:客户端虽在重试中复用 `x-request-id`,队列入口却用随机 job id 生成 dedupe key;前端允许无限追加,api-server 和 provider 用 `.take(...)` 静默截断;`generationInputs.references` 被当成可信持久 provenance。
- 处理:主站生成 POST 禁止自动重试,把显式复用的稳定 request id 接到队列唯一键并校验 replay payload;所有边界显式拒绝超限,前端还要预留主图槽位、统计在途上传,并在上传完成前拒绝模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除和面板切换 / 关闭;reservation 必须绑定原面板上下文,批量部分失败时不能丢弃已经持久化的成功项。入队、完美像素及直接创建资源 / 素材时删除客户端 references,执行时按真实参考源和 owner 资源记录重建权威引用。历史任务比较必须兼容仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较误报冲突。
- 验证:覆盖同键同 payload / 不同 payload、普通图片第 6 张、带主图的 GPT-image-2 第 5 张额外引用、provider 6 / 15 张边界、伪造引用删除和 owned 资源 / 素材重建。
## 共享音频 Composer 架构冲突不能按单行选边(2026-08-06)
- 现象:master 的音频 composer 同时承载 SFX 与 BGM,并在组件内定义 `isSoundEffect`;功能分支把 BGM 拆成独立组件后,原组件变成 SFX-only。合并时只把 master 的条件占位表达式带回 SFX-only 组件,没有带回变量定义,最终在测试渲染阶段报 `isSoundEffect is not defined`。
- 原因:冲突两侧代表不同组件架构,逐行保留看似有用的 JSX 会把一个架构中的局部条件拼进另一个架构。import 排序、格式检查和只覆盖单一 mode 的测试都不能证明这种组合成立。
- 处理:先确定权威组件边界,再按完整调用链解决冲突。图片画布音频入口当前决策是恢复一个共享 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM/SFX 的 validator、写回、锁和提交契约仍分别保持。不要只补一个常量后继续维持已经废弃的双 composer 边界。
- 验证:同时渲染 `audio-sound-effect` 与 `audio-background-music`,覆盖两个 mode 的正向控件和互斥负向断言、dialog / mode 切换、BGM 稳定 ID 与 controller 缺失的失败关闭,并运行 `ImageCanvasGenerationComposerView.test.tsx` 与 typecheck。
File diff suppressed because one or more lines are too long
@@ -872,7 +872,7 @@ V1.21 对标 Codex CLI 的 `model_context_window`、`model_auto_compact_token_li
### 配置与预算
- `llm` 新增 `contextWindowTokens / autoCompactTokenLimit / toolOutputTokenLimit`,发布默认分别为 `128000 / 64000 / 12000`;`agentLlm.<agentId>` 复用现有 patch 继承,显式 Agent 值覆盖全局。三项都必须大于 0,自动阈值必须小于 context window,并为当前请求的 `maxOutputTokens` 与固定安全余量留下空间。
- `llm` 新增 `contextWindowTokens / autoCompactTokenLimit / toolOutputTokenLimit`,发布默认分别为 `128000 / 64000 / 12000`;`agentLlm.<agentId>` 复用现有 patch 继承,显式 Agent 值覆盖全局。三项都必须大于 0,自动阈值必须小于 context window,并为当前请求的生成 token 预算与固定安全余量留下空间。既有配置和持久协议键 `maxOutputTokens` 保持冻结以兼容恢复;它表示包含可见输出与隐藏 reasoning token 的生成侧预算,不表示输入加输出总量,也不保证可见正文长度。
- Runtime 在发送 tool-plan、context-compaction 或 final-reply 前,按消息、multimodal 文本和 function schema 的规范序列化字符数做保守 token 估算;Provider 返回 usage 时再记录真实 `prompt/completion/total`。估算只用于提前门禁,不能伪装成 Provider 计费事实。
- 单条 observation 进入模型上下文前按 `toolOutputTokenLimit` 收紧;完整命令输出仍留在 owning Agent 的私有 sidecar,通过既有分页工具读取。公共状态只显示估算 token、最近真实 usage、阈值、压缩次数和时间,不显示被压缩正文。
@@ -258,7 +258,7 @@ Agent Runtime 负责:
- v4 在压缩状态校验通过后迁移为当前格式;v3 在原身份、任务、project revision、verification gate 和结构化计划校验通过后,从当前 Runtime/Goal sidecar 补齐 Goal 快照并继续;v2 先按 V1.17 规则补齐结构化计划,再补 Goal;后续 checkpoint 统一写 v5。v1、缺失既有 gate 关联或无法证明 Goal 快照的记录不自动迁移。Provider pause 中断/返回边界先持久化 continuation;该恢复快照把 `goalStatus` 设为恢复后的 `active`,避免 resume 后用 paused 上下文自相矛盾。
- stale continuation 必须清空旧 actions 与 fallback response,保留 blocker、loop 位置、窗口进度和结构化计划;`contextStalled` 一旦成立,同 run 重规划和重启不得清除。动态 revision 数字、时间戳与验证命令输出不构成独立进展;重复 stale 最迟在相邻窗口指纹重复时以 `loop-budget-exhausted` 终止。单文件最多 128 KiB、最多 12 条 observation;写入前统一限长并过滤敏感内容和项目绝对路径。revision 与验证资格仍以锁内独立文件为准,bundle 只是 Runtime 私有恢复上下文,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。
- 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。
- 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新:后台 planning 使用 4,000 输出 token,最终回复使用 2,400,并继续叠加最多 3 次 EmptyResponse 重试。推理档位不再硬编码为 `low`:planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort`,`agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high`,`default` 表示不向 Provider 发送推理档位。
- 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新,2026-08-06 仅澄清 token 口径:后台 planning 使用 4,000 生成 token 预算,最终回复使用 2,400;预算包含可见输出与 Provider 可能使用的隐藏 reasoning token,不等于可见正文长度。既有配置与持久协议键 `maxOutputTokens` 保持冻结,Provider adapter 再按协议映射为 Chat endpoint capability 选定的 `max_completion_tokens` 或 legacy `max_tokens`、Responses `max_output_tokens`、Anthropic `max_tokens`;AGC 通用自定义网关当前保持 legacy 默认。本条原有“最多 3 次 EmptyResponse 重试”已由本文后续 2026-07-15 的 V1.18 收口条目取代。推理档位不再硬编码为 `low`:planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort`,`agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high`,`default` 表示不向 Provider 发送推理档位。
- 2026-07-11 补充,2026-07-15 由 V1.17 更新:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / planUpdate / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求;同一次 planning 的私有 repair 请求可携带限长且经过统一敏感信息过滤的上一条模型输出或 function call 预览与协议错误,以便 Provider 真正修正格式。`.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 公共审计只写 attempt/maxAttempts、protocol,以及错误、输出/调用体预览、callId 和 functionName 的 SHA-256、字符数或计数,不保存原始模型正文、错误或 function arguments。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。旧文本协议可省略 `planUpdate`,但只能继续走 legacy `plan` fallback。
- 2026-07-12 补充,2026-07-15 由 V1.17 更新,2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新,2026-08-03 收紧 strict 边界:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schema;Runtime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`(schema 字段名为 `input_schema`)。`strict` 能力不从 `apiKind` 推断:AGC 只对无凭据 / 自定义端口 / 路径的官方 HTTPS endpoint 和 Claude 4.5+ 版本化 model id 显式开启,旧模型、未知别名和兼容网关默认关闭。开启后使用官方支持关键词白名单生成 Anthropic 专用传输 schema,已知不支持约束只从传输副本剔除,调用方原 schema 保持不变;未知关键词、不可解析 / 递归 `$ref` 和 strict 工具 / optional / union 请求级复杂度超限时该工具保持 non-strict,不能因完整 AGC 工具集超限让整次请求被上游拒绝。工具数组最后一项携带 `cache_control: {"type":"ephemeral"}` 作为 prompt cache breakpoint;非流式和流式 usage 都将 `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` 合并为 prompt tokens。`tool_choice` 使用对象形态(`Auto → {"type":"auto"}`、`Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`。planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 仍在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools;协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`。
- 2026-07-11 调整,2026-07-15 由 V1.17 更新:工具计划五个顶层字段均为必填并拒绝未知顶层字段;`thinkingSummary`、结构化计划的 `explanation / step` 与 `action.tool` 必须非空。`planUpdate` 只接受 `null` 或最多 8 个唯一步骤,状态限于 `pending / in_progress / completed` 且至多一个 `in_progress`。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 只有在 verification、process/join/delivery 和结构化计划完成门禁都通过后才表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。
File diff suppressed because one or more lines are too long
@@ -56,6 +56,8 @@ v1 只开放以下能力:
- `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。
- `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`。
手工调用 `POST /api/external/v1/editor/assets` 或 `POST /api/external/v1/editor/projects/{projectId}/resources` 创建 `assetKind=character-animation` 记录时,`generationInputs` 只保存可重放的生成输入,不接受 `characterAnimation`、`frames`、`previewVideoPath`、`frameCount`、`fps`、`durationSeconds` 等旧运行字段;完整正式帧序列和总时长必须分别写入 `imageSequenceFrames` 与 `imageSequenceDurationMs`。`screenColorHex`、`mattingProvider`、`mattingModel` 属于内部处理审计字段,External v1 会在持久化前移除。角色动作生成接口已经直接返回正式 resource / asset,正常调用方不应再手工复制第一帧创建重复记录。
provider 原图已保存但透明背景处理最终失败时,worker 保留原图稳定引用,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回。调用方应展示告警,但不得把 completed 任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍使任务失败。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥;风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,compact result 不得丢弃任一条。
## 异步提交、查询与幂等
@@ -0,0 +1,152 @@
# 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` 日志策略修改。
本节记录门禁时,相关改动尚未提交;之后已以 `aeb5894b9` 提交并推送。后续 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。由此确认当前模型和两条助手请求形态可以跑通。
## 2026-08-06 组件架构后续修订
本文前述 T6 数字与文件范围是当时实施完成后的历史记录,不因后续组件归并而改写。新的权威方向是让 SFX 与 BGM 恢复使用 `ImageCanvasGenerationComposerView.tsx` 内同一个音频 composer,并由 `isSoundEffect` 分流;详见[音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md)。
该修订已于 2026-08-06 实施:`ImageCanvasGenerationComposerView.tsx` 恢复唯一共享音频 composer 和 `isSoundEffect` 分流,独立完整 BGM composer 及其测试文件已删除,原用例全部迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过。现有 SFX 的 Vidu、2–10 秒、默认 5 秒、Prompt 回退、1500 字、价格与提交路径未改;本轮未实现 SFX V2、未修改后端或需求原文,也未执行浏览器视觉测试或创建真实付费任务。
@@ -92,6 +92,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database <database> --server-url <url> --limit 5 --completed-before-micros <micros>`;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。
角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database <database> --server-url <url>`,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope。确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。
自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
@@ -589,7 +591,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit.
同一 API release 随包依赖还必须包含 `scripts/check-pingora-release-readiness.mjs` 与 `scripts/check-pingora-canary-live.mjs`。前者在 current release 上以 `--release-runtime-only` 汇总运行时复核,后者支撑目标 Nginx canary live smoke;缺少任一脚本时不能进入直连切换窗口。
`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build/<version>/spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。
`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build/<version>/spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-normalize-editor-character-actions.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。
三个 SCM Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Secret File 的原文只存在于 Jenkins Credentials;credential ID、参数默认值和定时 / 发布行为以仓库 Jenkinsfile 为事实源,不能只改 Job UI,因为 Declarative Pipeline 下一次载入会重写参数定义。旧 Secret Text `genarrative-spacetime-bootstrap-secret-dev` 继续保留给 Database Import / Export,不得原地改类型或删除。
@@ -111,7 +111,7 @@
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate-image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`。
- 用户的当前消息确实在确认或取消一条已存在且仍为 pending 的工具调用时,画布 Agent 只引导使用该卡片的确认 / 取消按钮,本条确认 / 取消意图不产生新 tool call。这条边界必须使用“匹配 pending 调用时如何处理”的正向、条件化描述,不得改写成“不得重新发起相同工具调用”一类全局否定话术:实测中模型会把这类否定句过度泛化为拒绝后续新请求。已 cancelled 的卡片不再处理;用户明确要求修改、重做或发起新任务时必须允许新 tool call,pending 卡片也不阻塞无关的新请求。
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- 画布 Agent 规划请求使用 Chat Completions 和 1024 生成 token 预算;VectorEngine 专用 client 发送 `max_completion_tokens`,预算包含可见输出与隐藏 reasoning token,不等于可见正文长度。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
- runner 失败必须返回显式的 `PromptRunError { error, partial_outputs }`,不得只返回终态错误而丢弃本轮已产生的文本或工具事实。prompt 执行使用 `AgentMemory::begin_staged` 创建行为等价且写入隔离的 `StagedAgentMemory` 事务,限长、摘要、脱敏等 append 规则必须在本轮 completion 前生效;成功或已发生工具活动时必须显式调用 `commit()`,直接 drop staged transaction 表示回滚,不得统一复制成 `VecMemory` 或仅替换 box 冒充持久化提交。本轮无工具活动失败时回滚 staged 用户消息、助手文本和不可解析响应;已有工具活动时在末尾追加 terminal error closure 后提交。外部 drop / abort 若尚无工具活动则回滚并保持原 committed memory;若工具已完成则提交结果与取消闭环,若工具仍在执行则提交“已启动、结果未知”事实与取消闭环,后续必须先 reconcile 再决定是否重试。
- `ToolFailure` 必须以结构化工具失败输出暴露给 harness 调用方:调用方能读取 `kind`、`retryable`、`fatal` 和工具返回的原始 `output`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。
@@ -163,7 +163,7 @@
### Prompt 与生成契约
- 前端提交到 `POST /api/editor/character-animations/generations`。
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。`sourceImageSrc` 只允许传当前账号的 `objectKey`、项目资源 ID 或素材 ID;未持久化的本地临时图片必须先上传 OSS,不能提交 Data URL 或 Blob URL。后端解析稳定引用后必须校验对象属于当前 owner,再读取 OSS 内容。
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。携带 `canvasCompletion` 时必须同时提供非空 `projectId`,该校验必须在解析来源、调用 provider、持久化和预扣泥点之前完成,禁止无项目画布完成请求在产生费用或产物后才失败。`sourceImageSrc` 只允许传当前账号的 `objectKey`、项目资源 ID 或素材 ID;未持久化的本地临时图片必须先上传 OSS,不能提交 Data URL 或 Blob URL。后端解析稳定引用后必须校验对象属于当前 owner,再读取 OSS 内容。
- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 `doubao-seedance-2-0-fast-260128`。
- 后端在内联执行和队列入队前都会拒绝 Data URL / Blob URL;路由使用默认 JSON body limit,不再为内联图片请求单独放宽。
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
@@ -182,9 +182,11 @@
- 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。
- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续父流程只持 object key,并为每帧向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,不直接签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。每帧请求分别携带按父剩余绝对预算派生的 `maxQueueWaitMs` 和公式化 `callBudgetMs`;子 worker 在默认 `Q=2048` admission 保险丝和 `Semaphore(N=16)` 约束下排队,取得 provider permit 后才启动调用预算,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。真实 provider attempt 按 `N × est × 2` 派生,调用预算按 `2 × attempt + 1s` 派生;冻结 `est=5000ms` 时分别为 `160s / 321s`,排队不侵蚀最多两次顺序 attempt 的完整窗口,`32 / 40 / 48` 帧也不再增加单帧 attempt。成功图片由子 worker 以内部 HTTP 二进制 body 返回父流程,不在子侧落 OSS;BgFilter 最终失败且父业务预算仍有效时,由父流程进入 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。
- BgFilter、阿里云或本地键色返回透明结果后,父流程继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边;最终帧 OSS 与业务写回仍由父流程完成。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合向内部 worker 提交,允许乱序完成并最终按 `frameIndex` 排序;BgFilter provider 的实际在途请求受唯一 worker 的 `N / Q` 限制。任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合向内部 worker 提交,允许乱序完成并最终按后端内部 `frame_index` 排序;该索引只服务并发收口、OSS 命名、日志和错误定位,不写入正式 asset / resource 帧对象。BgFilter provider 的实际在途请求受唯一 worker 的 `N / Q` 限制。任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
- 画板前端回填角色动作结果时,以正式 resource / asset 的 `assetKind: "character-animation"` 选择序列帧播放器并以首帧作为图层主图;带项目上下文的生成必须返回正式 project resource,图层直接使用其 `resourceId`。无项目放置只允许绑定响应中的正式账号素材,不从生成结果 `frames` 构造自包含 `local-resource-character-animation-*`;此时图层的 `sourceResourceId` 继续使用正式账号素材记录的直接来源,不回退成请求中的原角色资源。预览视频作为独立 `assetKind: "video"` 资源保存,但不复制进最终动作 layout。
- 角色动作图层在画布中循环展示透明 PNG 帧;刷新恢复只从 project resource 的 `imageSequenceFrames/imageSequenceDurationMs` 读取正式结果。动作 layer layout 只保存 `layerId/resourceId` 和几何、层级、可见性等放置信息,不保存 `mediaType`、动作帧、时长、预览、生成输入或资源元数据副本;资源缺字段时按损坏数据失败关闭。
- 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。
- 角色动作生成 BFF 在同一请求内保存两类账号素材:上游预览 MP4 使用 `assetKind=video` 并承载生成成本,最终透明帧集使用 `assetKind=character-animation` 且派生成本为 0;响应中的 `resource` / `asset` 固定指向最终透明帧集对应的项目资源与账号素材。调用方必须直接使用它们,不得再以第一帧调用资源或素材创建接口。
- 最终项目资源和账号素材只把 `imageSequenceFrames/imageSequenceDurationMs` 写入正式媒体字段,并以 `sourceResourceId` 指向独立预览视频资源;正式帧对象和角色动作生成响应都不含 `frameIndex`,数组位置是唯一播放顺序,帧数和 FPS 均按需派生。`generationInputs` 只保存 `fields/references` 等用户可重放输入,`screenColorHex` 和动作运行结果均不落入其中。存量 `characterAnimation`、已确认动作行的 helper 顶层字段、`frameIndex`、误标预览 MP4 及动作 layout 副本由受 migration operator 限制的四阶段数据库 procedure 一次性规范化;迁移按权威对象类型排除同 task 的预览视频、只接受唯一最终图片序列并逐帧补齐登记对象身份,且不把 layout 复制的 `sourceResourceId` 当作历史血缘证据。CLI tuple 与 HTTP object 两种 procedure 返回都必须先归一为脚本内部统一的 `batch_sha256`,分页还必须拒绝未推进或重复出现的 cursor,避免安全校验误拒绝或生产迁移无限循环。该宽容只属于迁移;新生成仍严格使用“原角色资源 → 预览视频资源 → 最终序列资源”直接来源链。上线完成后 api-server、后台、Web 和外部 helper 不再读取 legacy fallback。新动作写入若提交旧运行字段或 `frameIndex`,HTTP 与 SpacetimeDB storage 均明确拒绝。
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、透明化处理和帧素材落盘流程。
@@ -2,10 +2,16 @@
日期:`2026-06-18`
更新时间:`2026-08-06`
## 范围
本次只在 `/editor/canvas` 图片画布编辑器内新增底部 `生成音乐` 入口,用于生成完整游戏音效或游戏背景音乐。该入口属于画板生成类工具,不新增平台玩法入口、不进入作品发布链路,也不修改现有视觉小说音频生成开关。
2026-08-04 起,本文增加 BGM Prompt 优化 V1.0 口径。该切片只修改 `audio-background-music`;`audio-sound-effect` 的 UI、Prompt 回退、Vidu `audio1.0` 请求、`2-10` 秒时长和 1500 字限制全部保持不变。音效与背景音乐使用同一个音频 composer,并由组件内的 `isSoundEffect = dialog.mode === 'audio-sound-effect'` 隔离行为;不得把 BGM 规则扩散到 SFX。
2026-08-06 的组件架构修订只撤销完整 BGM composer 的独立视图边界。共享音频 composer 的 SFX 分支保留当前 Vidu、时长、默认 Prompt、1500 字和价格行为,BGM 分支保留本文规定的预设、计数、AI 补全 / 简化、撤销、锁定、canonical Prompt 和 Suno 行为。BGM 纯状态模型、助手 controller 与预设跑马灯仍可保持独立职责;本轮不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设等独有能力。具体实施边界见[音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md)。
## 入口与交互
1. 底部 AI 画布工具栏新增 `生成音乐`。
@@ -13,7 +19,7 @@
- `生成游戏音效`
- `生成游戏背景音乐`
3. 选择某一项后创建独立 `generation-dialog` 画布生成对象,并通过现有 placement 模型避让已有图层和占位。
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐同样在右下角显示固定模型胶囊并紧贴生成按钮。
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐字段区增加字符计数、可展开预设词条、AI 补全、一键简化和单层撤销,底部仍保留现有动态泥点价格、固定 `Suno` 模型胶囊和生成按钮。
5. 生成中隐藏设置面板,只保留画布中的音频生成占位;失败后恢复面板并展示短错误。
## 面板字段
@@ -27,10 +33,176 @@
### 生成游戏背景音乐
- `gpt_description_prompt`:用户输入的背景音乐提示词。
- `gpt_description_prompt`:用户输入框当前文本按本文规则清理首尾 Unicode 空白后形成的背景音乐提示词,是正式 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` 允许且必须执行同一套首尾 Unicode 空白清理;除此之外不得执行 Unicode 规范化、内部空白折叠、内部换行转换、标点替换或静默截断。
## BGM Prompt 优化 V1.0
### 唯一可见最终 Prompt、首尾空白与字符口径
- 预设和 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 字总数。
| 规范化后的最终 Prompt | AI 补全 | 一键简化 | 正式生成 |
| --- | --- | --- | --- |
| 空字符串(总数 0、有效字符数 0) | 禁止 | 禁止 | 禁止 |
| 1 个有效字符且总数不超过 200 | 禁止 | 禁止 | 允许 |
| 至少 2 个有效字符且总数不超过 200 | 允许 | 禁止 | 允许 |
| 至少 1 个有效字符且总数为 201–2000 | 禁止 | 允许 | 禁止 |
| 总数超过 2000 | 禁止 | 禁止 | 禁止 |
- canonical Prompt 满足不变量:`有效字符数 = 0` 当且仅当 `总字符数 = 0`。原始输入即使完全由 201 个或更多 Unicode `White_Space` 组成,canonicalization 后仍为空字符串,AI 补全、一键简化和正式生成全部禁止;不得按规范化前的长度把全空白输入归入“可简化”状态。
- 一键简化最大输入固定为正式生成合法上限的 10 倍,即 `200 × 10 = 2000` 个 canonical Unicode code point。超过 2000 时必须完整保留当前文本供用户手动编辑,不得截断或丢失,但 AI 补全、一键简化和正式生成都不可用。
### 预设库与追加规则
预设分为三组,仅用于颜色和滚动组织;分组、ID、颜色等隐藏元数据不进入 Prompt、Suno 请求或生成记录。写入输入框的是下表右列完整可见文本。
| 分组 | 预设 | 写入输入框的文本 |
| --- | --- | --- |
| 用途 | 菜单待机 | 低干扰、适合菜单待机的背景音乐 |
| 用途 | 休闲消除 | 轻快可爱的休闲消除背景音乐 |
| 用途 | 解谜思考 | 安静专注的解谜思考背景音乐 |
| 用途 | 探索冒险 | 温和推进的探索冒险背景音乐 |
| 用途 | 对白场景 | 克制柔和、留出对白空间的背景音乐 |
| 用途 | 战斗前 | 蓄势待发的战斗前背景音乐 |
| 用途 | 胜利结算 | 明亮满足的胜利结算背景音乐 |
| 用途 | 失败结算 | 克制低落的失败结算背景音乐 |
| 用途 | 日常经营 | 轻松有序的日常经营背景音乐 |
| 用途 | 农场经营 | 自然温暖的农场经营背景音乐 |
| 用途 | 校园日常 | 青春轻松的校园日常背景音乐 |
| 用途 | 美食厨房 | 温暖活泼的美食厨房背景音乐 |
| 氛围 | 温暖治愈 | 柔和明亮的治愈背景音乐 |
| 氛围 | 神秘悬疑 | 克制神秘的悬疑背景音乐 |
| 氛围 | 紧张推进 | 稳定推进、逐渐紧张的背景音乐 |
| 场景 | 森林自然 | 清新自然的森林背景音乐 |
| 场景 | 雨夜静谧 | 雨夜静谧、略带神秘感的背景音乐 |
| 场景 | 海洋漂流 | 开阔舒缓的海洋漂流背景音乐 |
| 场景 | 山野远行 | 自由舒展的山野远行背景音乐 |
| 场景 | 糖果乐园 | 甜美活泼的糖果乐园背景音乐 |
| 场景 | 温馨小屋 | 温暖安静的温馨小屋背景音乐 |
| 场景 | 城市夜晚 | 克制迷人的城市夜晚背景音乐 |
| 场景 | 太空科幻 | 空灵未来感的太空科幻背景音乐 |
| 场景 | 赛博街区 | 冷静律动的赛博街区背景音乐 |
| 场景 | 古风幻想 | 空灵雅致的古风幻想背景音乐 |
| 场景 | 童话花园 | 梦幻轻盈的童话花园背景音乐 |
| 场景 | 海底遗迹 | 深邃神秘的海底遗迹背景音乐 |
| 场景 | 沙漠遗迹 | 苍茫神秘的沙漠遗迹背景音乐 |
| 场景 | 熔岩洞穴 | 炽热压迫的熔岩洞穴背景音乐 |
| 场景 | 奇妙博物馆 | 好奇灵动的奇妙博物馆背景音乐 |
点击预设时:
1. 先按统一规则删除输入框文本两端的 Unicode `White_Space` code point,并把结果写回输入框。
2. 规范化后的字符串为空,直接写入该预设的完整可见文本。
3. 规范化后的字符串非空,检查其最后一个 Unicode code point。
4. 最后一个字符属于 Unicode 标点类别时,直接追加预设文本;否则先追加中文句号 `。`,再追加预设文本。
5. 首尾空白在追加前已经删除;内部空格和内部换行保持原位,不得继续清理。写入输入框的规范化文本、句号和预设可见文本共同构成新的唯一最终 Prompt。
6. 允许重复点击同一个预设,每次都按同一规则追加。
7. 点击任意预设后清除旧撤销快照并隐藏撤销按钮。
8. 追加后允许超过 200 字,完整文本必须保留;超限只复用现有通用 Prompt 过长状态,不增加预设专用警告。
预设滚动交互:
- 展开后全部词条横向、连续、缓慢循环;收起后隐藏词条并停止可见滚动。
- 桌面端左侧 15% 悬停区快速向左滚动,中间 70% 立即暂停并稳定展示至少 5 个词条,右侧 15% 快速向右滚动;鼠标移出后恢复默认慢速循环。
- 左右箭头所在区域也必须触发对应方向的加速滚动,避免箭头可见但悬停无反馈。
- 循环使用多份词条队列无缝衔接,末端不跳回、不留白,也不提供“换一批”。
- 三组词条使用协调但可区分的颜色;底部细线标记当前悬停控制区。
- 组件在触摸环境被渲染时支持横向滚动和箭头操作,不依赖 hover 才能选择预设;本需求不恢复移动端图片画布入口。
- AI 处理或正式提交期间暂停滚动并禁用展开、收起、箭头和词条点击。
### AI 补全
- 点击 AI 补全时先按统一规则规范化输入框首尾空白并同步写回;至少 2 个有效字符且总字符数不超过 200 时,前端才把这份可见最终 Prompt 传给登录态内部 BFF。
- Prompt 助手当前使用专用请求模型 `gpt-5.6-luna`,请求级 `reasoning_effort` 固定为 `medium`;画布 Agent 本身仍使用现有编辑器 Agent LLM 配置中的 `gpt-5.4-mini`。两者复用现有 `LlmClient`,不建立新的平台 LLM 能力。
- 服务端模板必须要求:保留用户明确的主题、场景、风格、情绪、乐器、能量、韵律、时长、循环和避免项;按场景选择性补足场景、氛围、能量、韵律、乐器、旋律、声音设计、循环和避免项,不为凑全方向堆砌形容词。
- 用户描述已足够完整时,只补充一至两个与主题匹配的具体声音细节。发现冲突时,优先级为“明确避免项和限制 > 明确玩法用途与场景 > 风格、情绪、能量与韵律 > AI 补充细节”。
- 内部 envelope 的 `prompt` 字段只允许包含一条可直接写回输入框的中文 BGM Prompt;候选文本本身不得包含解释、标题、Markdown、JSON、代码块、具体艺人或歌曲模仿要求。
- 补全与简化共用同一份内部结构化 envelope:`prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean`。助手显式使用现有 OpenAI Chat 协议,envelope 由完整 `response.text` 中唯一一个 JSON object 承载;服务端只允许 `serde_json` 对完整文本做全量解析,允许 JSON object 外围存在 JSON whitespace,但不接受代码块、前后解释、多个 JSON 值或从字符串中截取 object,也不执行自动修复。助手请求不发送 function tools,不做运行时双协议 fallback;响应出现 tool call 时同样视为结构非法。envelope 只用于服务端解析和判断,不属于候选文本,不写回输入框,也不通过 BFF 暴露给客户端。
- 服务端必须在解析 envelope、canonicalize 候选或提取任何 `prompt` 前检查 OpenAI Chat `finish_reason`。去除外围空白并忽略 ASCII 大小写后,`length` 和 `content_filter` 都属于未完成响应;即使正文恰好是合法四字段 JSON,也不得接受、解析或提取候选。`finish_reason` 缺失、为空或为未知自定义值时,不因该字段单独拒绝,继续执行其余结构与候选门禁;不得改为只允许 `stop`。
- 补全遇到 `finish_reason = length` 或 `content_filter` 时均直接失败,不写回、不泄漏响应正文或候选,且仍只执行一个业务语义轮。
- 补全候选先执行同一套首尾 Unicode 空白规范化,再由程序计算字符数。候选只有在四字段结构有效、canonical `prompt` 包含有效字符且不超过 200 个 Unicode code point,并且三个布尔字段依次为 `true / true / false` 时才通过;程序不使用关键词或未定义正则猜测候选格式、完整性或残句。通过后整段写回输入框并成为唯一最终 Prompt。
- 补全只执行一个业务语义轮。`LlmClient` 在该轮内部按现有配置执行的 transport retry 不计为新增业务语义轮;该轮最终发生 transport、超时或上游失败时直接返回失败,不再发起内容修复轮。
- 调用失败、结果为空、结构或判断不合格、格式非法或超限时保留请求前已经写回的规范化 Prompt,不写回部分结果;错误响应不得包含未通过候选或内部 envelope。
- 补全过程不调用 Suno、不创建正式生成任务、不触发正式音乐生成扣费。
### 一键简化
- 点击一键简化时先按统一规则规范化输入框首尾空白并同步写回;只在规范化后的当前 Prompt 至少含 1 个有效字符且总字符数为 201–2000 时允许调用。超过 2000 时完整保留文本,但不得调用简化 BFF。点击前保存这份规范化后的完整 Prompt,AI 处理中保持输入框不变。
- 服务端在本次简化中冻结 `originalPrompt` 为入站 canonical Prompt,最多两个业务语义轮期间始终不变,作为内容保真参照。第一次业务语义轮使用 `currentPrompt = originalPrompt`,目标为 180 字。这里的 `originalPrompt` / `currentPrompt` 是服务端组装 LLM 简化模板时的内部变量;客户端简化 BFF DTO 仍只提交一个 `currentPrompt`,该入站值经 canonicalization 后同时成为内部 `originalPrompt` 和第一次内部 `currentPrompt`。每个业务语义轮内部由 `LlmClient` 按现有配置执行的 transport retry 不增加业务语义轮数。
- 180 不是硬门槛。简化使用与补全相同的四字段内部结构化 envelope;envelope 不属于候选文本,也不得写回输入框或通过 BFF 暴露。响应不能解析为包含上述正确字段类型的对象时,本次候选不通过。
- 候选“格式合法”专指 `isDirectWritebackFormat = true`:模型确认 canonical 候选只包含一条可直接写回输入框的中文 BGM Prompt,不包含解释、标题、Markdown、JSON、代码块、字数报告、处理过程或删改说明。它与“内部结构可解析”是两个独立校验项;程序不得另用关键词或未定义的正则推断标题、解释等语义格式。
- 第一次业务语义轮成功取得上游响应、但候选未通过任一通过条件时,自动进行唯一一个第二业务语义轮,目标为 170 字。只有当完整 `response.text` 已按上述规则全量解析为单个 JSON object 时,才允许从该 object 读取字符串 `prompt`;禁止从未完整解析的文本、代码块、解释或畸形 JSON 中做子串提取。若可提取的字符串 `prompt` canonicalize 后包含有效字符,第二次使用 `currentPrompt = canonicalize(第一次候选)`,即使第一次因其它字段缺失、超限、格式、完整性或残句判断而不通过;若完整响应无法解析为单个 object、object 中没有字符串 `prompt`,或候选 canonicalize 后不含有效字符,第二次回退使用 `currentPrompt = originalPrompt`。第二次业务语义轮中的 `originalPrompt` 始终仍是最初入站 canonical Prompt。
- 第一轮 `finish_reason = length` 时整份响应不可信,不得接受或提取其中的 `prompt`;它只按“成功取得响应但候选不合格”进入唯一的 170 字轮,第二轮必须使用 `currentPrompt = originalPrompt`。第一轮 `finish_reason = content_filter` 时直接失败,不进入第二轮。第二轮出现 `length` 或 `content_filter` 时最终失败,不得发起第三轮。该规则优先于上一条的一般候选提取与回退规则。
- 第一次业务语义轮最终发生 transport、超时或上游失败时直接返回失败,不进入 170 字内容修复轮;这类失败只应用该轮内部既有的 `LlmClient` transport retry。
- 每次候选都先执行同一套首尾 Unicode 空白规范化,再计算实际字符数。第二次仍不合格时返回失败并保留请求前已经写回的规范化 Prompt,提示用户手动精简;错误响应不得包含第一次或第二次未通过候选、可提取的 `prompt` 或内部 envelope。最多两个业务语义轮,任何情况下都不得由程序截断到 200 字。
- 简化模板必须优先保留明确限制、场景与用途、情绪与风格、核心乐器与速度、能量/韵律/旋律、循环结构、区分度较高的声音设计,再删除次要修饰细节;不得改变专有名词、BPM、调性、时长、数值、乐器和明确限制。
- 程序不抽取关键词,不对规范化后的输入与候选执行内容硬编码逐项比对。是否为直接写回格式、是否保留重要信息且内容完整、是否形成明显残句由 LLM 在同一次调用的三个布尔字段中分别判断。
- BFF 成功时只返回已通过校验的 Prompt 和程序计算的字符数,不暴露三个内部判断字段;失败时使用现有 API 错误 envelope,且不返回任何候选或内部判断字段。
- 候选只有同时满足以下条件才通过:内部结构可解析且四个字段类型正确;canonical 候选包含有效字符;canonical 候选实际字符数不超过 200;`isDirectWritebackFormat = true`;`isContentComplete = true`;`hasObviousFragment = false`。程序负责解析和检查字段类型、canonicalization、有效字符与实际字符数,并执行三个布尔判断结果;字符数由程序计算且不采信模型报告,程序不自行推断三个语义判断。
- 一键简化不维护或恢复“已选预设”元数据;预设已写入输入框的文本只是当前最终 Prompt 的一部分。
### 单层撤销
- AI 补全和一键简化成功都必须产生一层 Prompt 快照。发起 AI 操作前,先把当时输入规范化并写回,再以该 canonical Prompt 建立本次临时快照。
- AI 成功写回后,该快照成为可撤销版本;AI 失败时输入框保留请求前已经写回的 canonical Prompt,清除本次临时快照,不生成新的可撤销版本,也不恢复发起本次操作时已经被替换的旧快照。
- 用户手动编辑 AI 结果后,撤销仍可用。再次发起 AI 操作时,先规范化并写回当时的当前 Prompt,再以该 canonical Prompt 替换旧快照。
- 点击预设时先规范化并写回,再清除旧快照;正式提交被后端拒绝时,保留已经写回的 canonical Prompt 和提交前已有快照。
- 点击撤销时,先把当前输入规范化并写回,再让该 canonical Prompt 与 canonical 快照互换;按钮继续可用,允许用户在两个规范化版本间来回切换。
- 只保存一层,快照只包含 canonical Prompt,不包含预设滚动位置、展开状态、模型、时长或其它参数。
撤销按钮的可见性与可用性:
- 可见条件为存在可撤销快照或本次 AI 操作的临时快照;启用条件为可见且当前 BGM 面板未处于 `completing`、`simplifying`、`submitting` 或现有 `generating` 锁定状态。发起 AI 操作时可撤销快照被本次临时快照取代,因此“处理中没有可撤销快照”不等于“没有快照”,不得据此隐藏按钮。
- AI 处理期间按钮显示并禁用,不得隐藏。禁用必须使用真实禁用态并保留按钮在 DOM 与可访问树中的位置,不得用隐藏、透明度或其它视觉伪装代替,也不得在处理前后改变按钮占位。
- 完全没有快照时隐藏,不显示灰色占位按钮。
| 状态 | 可撤销快照 | 本次临时快照 | 撤销按钮 |
| --- | --- | --- | --- |
| 初始进入面板、没有历史快照 | 无 | 无 | 隐藏 |
| AI 开始处理,首次或再次均相同 | 无 | 有 | 显示并禁用 |
| AI 处理成功并写回输入框 | 有 | 无 | 显示并启用 |
| AI 处理失败、原文未改变 | 无 | 无 | 隐藏 |
| 用户手动编辑 AI 结果 | 有 | 无 | 显示并启用 |
| 点击预设词条 | 无 | 无 | 隐藏 |
| 点击生成提交且已有快照 | 有 | 无 | 显示并禁用 |
| 点击生成提交且没有快照 | 无 | 无 | 隐藏 |
| 提交成功或失败后解除锁定 | 有或无 | 无 | 有快照时显示并启用,否则隐藏 |
| 点击撤销 | 有,与当前文本互换 | 无 | 保持显示并启用 |
- 再次发起 AI 操作时仍按上面的规则用当时的 canonical Prompt 替换旧快照;不得为了让处理期间按钮可见而保留旧快照,那会与“AI 失败不恢复已被替换的旧快照”冲突,并让失败后的撤销指向不相关内容。
- 视图只按上表决定显示与启用;是否真正执行撤销仍由状态层在非 `idle` 或无快照时拒绝,两处不得各写一套判定。
### 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`、计算 canonical Prompt 并写回输入框、按 canonical Prompt 校验 0 个有效字符和 200 字上限、冻结本次请求值。前端校验失败时立即解除锁并保留写回后的 canonical Prompt,不发送请求。提交中锁定该 dialog 的输入框、预设、展开/收起、滚动箭头、AI 操作、撤销、参数控件和生成按钮,忽略后续重复点击。
- 锁只作用于当前 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 终态响应作为客户端可观察结算点,不为此新增协议。
## 画布数据
@@ -51,10 +223,10 @@
- generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `<audio>` 裸请求。
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。
- 音频图层右上角标签显示在信息按钮左侧,和其他素材卡右上角信息区保持一致。
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段;BGM 快照保存输入框已经写回的 canonical Prompt,不保存助手系统模板、内部引导或预设元数据。
- 音频图层上方浮动工具栏只保留 `改造` 和 `下载按钮`。点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。
## 前端提交契约
## 前端与 BFF 契约
前端新增两个 BFF client:
@@ -63,8 +235,7 @@ POST /api/editor/audios/sound-effects/generations
{
prompt: string,
model: "audio1.0",
duration: number,
priceMudPoints: 10
duration: number
}
```
@@ -72,11 +243,46 @@ POST /api/editor/audios/sound-effects/generations
POST /api/editor/audios/background-music/generations
{
gptDescriptionPrompt: string,
makeInstrumental: true,
priceMudPoints: 5
makeInstrumental: true
}
```
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、分组、颜色、内部模板或格式 / 完整性 / 残句判断;这些由服务端固定或由内部 LLM 结果产生。
- 两个助手 BFF 不调用 Suno、钱包扣费、正式 generation queue、OSS、素材库,也不在 handler 中同步执行 SpacetimeDB 业务写入;成功路由的通用 tracking 仍由现有本机 outbox 异步承接。
- 两个助手请求中的 `currentPrompt` 必须是前端已经写回输入框的 canonical Prompt;响应 `prompt` 也必须先执行同一 canonicalization,`charCount` 是响应 canonical Prompt 的 Unicode code point 数。
- 补全与简化成功响应都只包含上面的 `prompt` / `charCount` 业务字段;内部四字段 envelope、`originalPrompt`、目标字数、业务语义轮信息和未通过候选均不得出现在成功或失败响应中。失败继续使用现有 API 错误 envelope。
- 简化 BFF 只接受总字符数为 201–2000 且至少含 1 个有效字符的 canonical `currentPrompt`;超过 2000 返回现有 `400 BAD_REQUEST` 错误 envelope,并标记字段 `currentPrompt`。
- 两个助手路由都设置 `32 KiB` HTTP 请求体上限。请求体超过该上限时保留 Axum `413 PAYLOAD_TOO_LARGE`,不得被通用 JSON rejection 降为 `400`;字符上限与原始 body 上限分别校验,不能互相替代。
- 不增加 Prompt 助手专属的用户级、IP 级、时间窗口或令牌桶限流,也不新增本功能主动产生的 `429` / `Retry-After`。现有 api-server 全局并发背压、前端防重复操作、Nginx 保护和上游真实 `429` 的安全映射保持不变。
- 两个成功路由必须进入 `tracking.rs` 显式静态映射:补全使用 `event_key = editor_background_music_prompt_completion`,简化使用 `event_key = editor_background_music_prompt_simplification`;两者都使用 `module_key = editor`、User scope。普通 route tracking 继续只记录成功响应,不在助手 handler 中新增同步埋点副作用。
- BGM 正式提交必须使用输入框已经写回的 canonical Prompt,不得额外拼接用户不可见内容,也不得把空 Prompt 回退为“游戏背景音乐”。
- BGM 正式 POST 不使用现有允许 unsafe method 的自动重试,保证一次点击不会由 client 内部重发。本文不新增 Idempotency-Key、持久提交账本或服务端 dedupe;其它生成 mode 的 retry 行为保持不变。
统一响应:
```ts
@@ -99,11 +305,17 @@ POST /api/editor/audios/background-music/generations
}
```
- BGM 成功响应必须同时填充 `prompt` 和 `actualPrompt`,两者都与本次 canonical Prompt 逐 code point 等值,不得携带助手模板、内部引导或隐藏前后缀;共享响应类型为兼容 SFX 与历史数据仍可保留 `actualPrompt` 可选。
- 默认 queue 模式的首次生成响应继续只携带现有 `queueState`;上面的完整音频响应是 inline 或 worker 内部完成边界,不要求给 queue 首次响应或普通 metadata-only job result 新增 Prompt 字段。
## 后端实现
- 在 `shared-contracts/src/assets.rs` 增加编辑器音频请求 / 响应 DTO。
- 在 `shared-contracts` 增加最小 BGM Prompt 助手请求 / 响应 DTO;前后端共享 `currentPrompt`、`prompt` 和 `charCount` 字段,不把内部 LLM 判断暴露给客户端。
- 前端、`api-server` 与 `platform-audio` 必须实现同一 BGM canonicalization 语义并复用同一组跨语言测试向量:只删除首尾 Unicode `White_Space`,保留内部空白和全部其它 code point。该操作必须幂等,不得直接混用语义不同的 TypeScript / Rust 原生 `trim`。
- 在 `platform-audio` 增加编辑器专用 body builder 和 submit 函数:
- 背景音乐 body 使用 `mv`、`gpt_description_prompt`、`make_instrumental`。
- 背景音乐在 body builder 边界防御性执行幂等 canonicalization,再按 canonical Prompt 检查至少一个有效字符和最多 200 个 Unicode code point;校验通过后用 canonical Prompt 构造 Suno body。不得复用语义不同的 `normalize_limited_text`,不得提供默认 Prompt。
- 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 +324,17 @@ POST /api/editor/audios/background-music/generations
- 在 `api-server` 增加编辑器音频 BFF:
- `/api/editor/audios/sound-effects/generations`
- `/api/editor/audios/background-music/generations`
- BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装;音效 10 泥点,背景音乐 5 泥点。
- 在 `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.6-luna` 请求模型、固定 `reasoning_effort=medium` 和显式 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 模式或相关上线门禁。
- BGM generation BFF 在入站时防御性执行同一幂等 canonicalization,规范化后校验有效字符和 200 字限制。登录态站内 handler 在 queue / inline 分流前把 canonical Prompt 和固定 `make_instrumental:true` 写入本次请求值,确保正式 generation queue 请求载荷、持久化记录和内部完成响应等值;删除空 Prompt 默认回退。该站内收口不改变 External v1 的路由、OpenAPI、Idempotency-Key 或 payload 等值语义。助手模板只用于生成输入框可见候选,不得进入正式队列或 Suno 请求。SFX 继续使用现有规范化、回退、Vidu body 和 1500 字限制。
- BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装。客户端不提交 BGM `priceMudPoints`;服务端按现役动态定价配置解析并在入队时冻结本次价格,后续计费、资产成本和内部完成响应复用该冻结值。T5 不修改价格或计费规则。
- 生成音频持久化后返回 OSS `objectKey` 与 `assetObjectId`;前端保存素材库时继续使用 `audioSrc` 作为兼容路径,并把 OSS 身份写入素材记录。
- 本切片不修改 SpacetimeDB schema,不新增 Prompt 助手持久化表,不向 `/api/external/v1` 暴露助手,也不修改 External v1 OpenAPI 或 `platform-llm` 日志策略。
## 验收
@@ -134,3 +355,23 @@ POST /api/editor/audios/background-music/generations
- 音频素材浮动工具栏只显示 `改造` 与 `下载按钮`,`改造` 复用对应生成面板且没有参考图组件。
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
- 刷新后 layout 能恢复音频生成器和音频图层。
- BGM 输入框按 canonical Prompt 的 Unicode code point 显示 `0 / 200` 计数和动作状态,但编辑期间不因计数而改写输入框;canonical Prompt 为空时三个动作全部禁止,1 个有效字符且不超限时可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,至少含 1 个有效字符且为 201–2000 个 code point 时只能一键简化,超过 2000 时三个动作全部禁止且完整保留文本。200 / 201 个纯 Unicode `White_Space` 原始输入均先归一为空,不能简化。
- 三组 30 个 BGM 预设按本文固定文案写入或追加;点击前先删除首尾 Unicode `White_Space` 并写回,再基于规范化结果判断空值和末尾标点。重复点击和追加后超限不得丢失 canonical Prompt,内部空格和内部换行保持原位。
- 预设展开后默认无缝慢速循环,桌面端左 / 中 / 右区域分别加速向左、暂停、加速向右,左右箭头同步加速;组件在触摸环境被渲染时可横向滚动并选择词条,本需求不恢复移动端图片画布入口。
- AI 补全的输入和候选都先 canonicalize;补全只执行一个业务语义轮,候选必须通过共用四字段 envelope、有效字符、200 字和 `true / true / false` 三个判断后,才写回一条中文 canonical Prompt。失败、空值、结构或判断错误、格式错误或超限结果保留请求前已经写回的 canonical Prompt,错误响应不暴露候选,也不调用 Suno 或扣除正式音乐生成泥点。
- 一键简化资格、180 / 170 目标后的实际字符数和候选校验都基于 canonical Prompt;第一次使用冻结的 `originalPrompt`,第二次优先处理第一次成功响应中可提取的非空 canonical 候选,否则回退处理 `originalPrompt`,且两个业务语义轮都以同一 `originalPrompt` 作保真参照。第一轮 transport、超时或上游失败直接失败,不进入第二轮;每轮内部的 `LlmClient` transport retry 不增加业务语义轮数。候选必须同时通过结构、有效字符、200 字、格式、完整性和残句校验;第二次仍失败时保留请求前已经写回的 canonical Prompt,不暴露任一未通过候选,程序不得截断。
- OpenAI Chat `finish_reason = length / content_filter` 的正文即使形成合法四字段 JSON 也不能通过或被提取;补全遇到两者均直接失败,简化第一轮 `length` 只以冻结的 `originalPrompt` 进入 170 字轮、第一轮 `content_filter` 直接失败,第二轮遇到任一未完成 reason 都最终失败。缺失、空值和未知自定义 reason 继续执行其余门禁。
- Prompt 助手协议测试必须证明请求显式使用 OpenAI Chat 且不发送 function tools;只接受完整 `response.text` 全量解析所得的单个 JSON object。外围 JSON whitespace 可以通过,代码块、前后解释、多个 JSON 值、畸形 JSON、仅能子串提取的 object 和任意 tool call 均失败,不触发自动修复或运行时协议 fallback。
- Prompt 助手轮次测试必须区分业务语义轮和单轮内部 transport retry:补全始终只有一个业务语义轮;简化只有第一轮成功返回但候选不合格时才进入 170 字轮,第一轮 transport、超时或上游失败不进入第二轮。
- AI 补全和简化成功均产生一层 canonical Prompt 交换式撤销快照;手动编辑后仍可撤销,点击预设清除快照,点击撤销前先规范化当前输入并可在两个 canonical 版本间反复互换。
- 撤销按钮按“单层撤销”一节的矩阵逐行验收:初始与无快照时隐藏;AI 处理期间显示并禁用,且仍在可访问树中,不得用隐藏或视觉伪装代替禁用;成功后启用,失败后隐藏,手动编辑后仍启用,点击预设后隐藏;`submitting` 期间有快照显示并禁用、无快照隐藏,解除锁定后按快照恢复启用或隐藏;连续点击撤销在两个版本间互换且保持启用。
- AI 操作的旧响应、关闭 dialog 后的响应或其它 dialog 的响应不得覆盖当前 Prompt;同一按钮双击只产生一个有效助手请求。
- BGM 点击生成后在首个 `await` 前同步锁定当前 dialog;同一 dialog 快速重复点击只产生一次正式请求、一个生成任务和一次扣费,retryable HTTP 状态或 transport error 也不由 client 自动重发,不锁整个画布或其它 dialog。
- 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`。
- 两个助手成功路由分别产生 `editor_background_music_prompt_completion` / `editor_background_music_prompt_simplification` tracking event,均为 `module_key = editor`、User scope;失败响应沿用普通 route tracking 只记录成功的现状。
- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;SFX 的 Vidu body、默认 Prompt、时长与 1500 字限制无回归。
- `audio-sound-effect` 与 `audio-background-music` 必须由同一个音频 composer 渲染,并在组件内通过 `isSoundEffect` 分支。SFX 不得渲染 BGM 控件或尚未实施的 SFX V2 控件,BGM 不得渲染 Vidu 与音效时长控件;两个 mode 相互切换时,菜单、预设滚动、锁和助手状态不得跨分支泄漏。
- 本切片的定向前端、shared-contracts、`api-server`、`platform-audio` 和端到端 Prompt 等值测试通过,并执行 `npm run typecheck`、对应 Rust 定向测试、`npm run check:encoding` 与 `git diff --check`。