对齐编辑器生成占位与面板参数

统一画布生成占位图标,按图片、规范、角色、图标、UI、视频、音频、宣发和快速编辑区分。

让图片、规范、宣发和视频新建占位按面板尺寸或固定输出尺寸创建,并在参数变化时同步占位大小。

完善快速编辑、音频生成、宣发素材和生成类面板相关契约与测试。

更新生成类面板 Lovart 改造文档和项目决策记录。
This commit is contained in:
2026-06-19 12:15:57 +08:00
parent 160c96a014
commit 0e47a54a91
49 changed files with 2106 additions and 758 deletions
@@ -2367,10 +2367,10 @@
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`、浏览器刷新 smoke。
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-18 编辑器画板音效参数与待生成占位类型化
## 2026-06-18 编辑器画板音效待生成占位类型化
- 背景:`/editor/canvas` 新建视频、角色形象、音效和背景音乐待生成对象时沿用图片占位 icon,音效面板仍把 `type``tempo` 分成两个旧字符串选项,不符合 Lovart 式简洁参数按钮和 BPM 输入需求。
- 决策:画布待生成占位按生成器模式渲染专属空白样式、icon 与右上角标签:视频、角色、音效、背景音乐不再统一使用图片 icon;角标继续按 viewport 反向缩放。编辑器音效请求契约改为 `type: "one-shot" | "loop"``tempo: number | null`BPM 范围 `1-300`,默认 `null`;前端底部只保留一个无标题音效参数按钮,组合显示如 `单次·120BPM`
- 决策:画布待生成占位按生成器模式渲染专属空白样式、icon 与右上角标签:视频、角色、音效、背景音乐不再统一使用图片 icon;角标继续按 viewport 反向缩放。原计划中的 `type + tempo/BPM` 音效参数已在 2026-06-19 被 Vidu `prompt + duration` 契约替代,后续不要再恢复 `单次·120BPM` 入口
- 影响范围:`src/components/image-editor/ImageCanvasWorldView.tsx``ImageCanvasGenerationComposerView.tsx``ImageCanvasEditorTypes.ts``ImageCanvasGenerationSubmissionModel.ts``src/services/image-editor/editorProjectClient.ts``server-rs/crates/shared-contracts/src/assets.rs``server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs``server-rs/crates/platform-audio/src/request.rs`
- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasGenerationModel.test.ts src/services/image-editor/editorProjectClient.test.ts --reporter verbose``cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml``cargo test -p platform-audio --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_sound_effect --manifest-path server-rs/Cargo.toml`
@@ -2380,3 +2380,10 @@
- 决策:点击已生成角色图只选中图层并显示浮动工具栏,不自动弹出重绘、快速编辑或角色动画面板。点击工具栏或右键菜单的 `生成动画` 后,创建 `mode="character-animation"` 的画布 generation dialog,占位走统一避让落点与视口居中;占位使用角色动作 icon、橙色动作配色和右上角 `动作` 标签。角色动画参数面板复用原内容,但作为统一 generation composer 跟随占位底部,宽度对齐图片生成面板。提交成功后以首帧创建 `assetKind="character-animation"` 的角色动作图片图层,右上角标签显示 `动作`
- 影响范围:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``useImageCanvasGenerationSurface.tsx``ImageCanvasCharacterAnimationPanelView.tsx``ImageCanvasWorldView.tsx``ImageCanvasGenerationLayerModel.ts``useImageCanvasGenerationSubmissionWorkflow.ts``src/index.css`
- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx --reporter=dot``npx vitest run src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "character animation" --reporter=dot`
## 2026-06-19 编辑器游戏音效默认改用 Vidu 文生音频
- 背景:VectorEngine Apifox `创建文生音频任务` 文档明确 Vidu `/ent/v2/text2audio` 请求体使用 `model: "audio1.0"``prompt``duration` 和可选 `seed`;编辑器此前把游戏音效提交到 Suno `task: "sound"`,与当前游戏音效默认模型要求不一致。
- 决策:`/editor/canvas``生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt``model: "audio1.0"``duration``priceMudPoints`,面板只显示 `Vidu``2-10` 秒时长选项,默认 `5` 秒;不再发送或展示 `sound``type``tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交和轮询改走 Vidu `/ent/v2/text2audio``/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music``/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。
- 影响范围:`server-rs/crates/platform-audio``server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs``server-rs/crates/shared-contracts/src/assets.rs``src/services/image-editor/editorProjectClient.ts``src/components/image-editor/ImageCanvasGeneration*`
- 验证方式:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio``cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_sound_effect``cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_audio_requests_and_response_use_canvas_audio_shape``npx vitest run src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationModel.test.ts --reporter=dot`
@@ -2,7 +2,7 @@
## 目标
在 Web 图片画布编辑器底部工具栏新增 `宣发素材` 分组入口,用于生成游戏发布物料。点击具体工作流后,不打开说明弹窗,而是在画布内创建一个可拖拽的宣发素材生成卡片;卡片包含游戏名、游戏分类、一句话描述游戏三个输入框、参考图控件、尺寸信息和生成按钮。点击生成后走图片画布现有生图 BFF,自动扣 5 泥点,生成成功后把成品图片放回画布。
在 Web 图片画布编辑器底部工具栏新增 `宣发素材` 分组入口,用于生成游戏发布物料。点击具体工作流后,不打开说明弹窗,而是在画布内创建一个可拖拽的宣发素材生成卡片;卡片包含游戏名、游戏分类、一句话描述游戏三个输入框、参考图控件、尺寸信息和生成按钮。点击生成后走图片画布现有生图 BFF,按当前图片模型计费方案自动扣泥点,生成成功后把成品图片放回画布。
Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 UI 中默认暴露;真实生图请求会把结构化游戏输入和对应工作流约束合并为内部 Prompt。
@@ -12,8 +12,9 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U
- 页面:`/editor/canvas`
- 位置:底部 AI 画布工具栏
- 工具:`宣发素材`
- 工具:`宣发素材`,位于底部工具栏 `生成UI设计图` 按钮右侧。
- 交互:点击工具后弹出独立浮层菜单,菜单项包括 `游戏首图``详情五图``运营海报`;点击菜单项后在画布中心创建对应工作流的生成占位卡片,卡片可拖拽,设置面板锚定在占位卡片下方。
- 设置面板:三个工作流统一对齐生成角色面板结构,顶部为参考图横向槽,主体为游戏名、游戏分类、一句话描述游戏输入区,底部复用生成类面板的模型选择与生成按钮;生成按钮内显示当前模型对应泥点。
- 标题:画布占位卡片左上角使用英文功能名,例如 `Home Image Generator``Detail Gallery Generator``Promo Poster Generator`,与其它生成卡片保持一致。
## 三条独立工作流
@@ -42,8 +43,8 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U
本功能边界只负责图片编辑器里的宣发素材生成:
- 可以维护:工作流框架、Prompt 约束、参考图解析约束、图片后处理验收约束。
- 已落地:画布内生成占位卡片、游戏名 / 游戏分类 / 一句话描述游戏结构化输入、参考图上传 / 从画布选择、尺寸标识、真实生图、5 泥点扣费、成品回画布。
- 详情五图:当前 UI 只有一个生成卡片,一次生成 1 张 `720 x 1280` 竖版详情图,单次扣 5 泥点;需要五图时连续生成并复用同一游戏输入和参考图。
- 已落地:画布内生成占位卡片、游戏名 / 游戏分类 / 一句话描述游戏结构化输入、参考图上传 / 从画布选择、尺寸标识、真实生图、按图片模型计费、成品回画布。
- 详情五图:当前 UI 只有一个生成卡片,一次生成 1 张 `720 x 1280` 竖版详情图,单次按当前图片模型计费;需要五图时连续生成并复用同一游戏输入和参考图。
- 不在 UI 暴露:Prompt 输入摘要、Prompt 约束预览、内部提示词拼接过程。
- 暂不实现:作品发布状态、审核、分享渠道、远程 Genarrative 接口、作品架或广场能力。
- 不修改:SpacetimeDB schema、前端发布页、作品发布系统。
@@ -51,7 +52,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U
## 生成与计费
- 前端生成请求统一通过 `/api/editor/images/generations`
- 宣发素材请求携带 `kind: publication-material`,后端对这个 kind 扣 5 泥点
- 宣发素材请求携带 `kind: publication-material` 与当前图片模型,后端对这个 kind 按归一后的图片模型计费方案扣泥点,不再使用固定价格
- `publication-detail-gallery` 不再携带 `candidateCount: 5`;后端仍兼容多候选请求,但当前宣发素材入口不主动批量生成。
- 尺寸请求必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。VectorEngine 适配层对明确像素值保持原样透传;只有 `16:9``9:16``2k` 等比例 / 档位别名才走 provider 预设映射。
- 参考图在提交 `/api/editor/images/generations` 前由前端压缩成适合生成理解的图片 Data URL,避免原图 Data URL 撑爆 JSON 请求体;后端该路由保留 `12MB` body limit 作为兼容兜底。
@@ -85,6 +85,10 @@
- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。
- 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9 · 2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p``854 x 480`,切换到 `720p` 后同步为 `1280 x 720`;调整参数时保持占位中心点不变。
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。
## 画布悬浮信息
@@ -162,6 +166,7 @@
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 快速编辑生成中的占位图同样只支持键盘 `Delete` / `Backspace` 删除,不新增 UI 删除按钮;删除后异步结果不得再落回画布。
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后固定用 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成 spritesheet,再复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 生成游戏音效面板底部不显示 `type` / `tempo` 字段标题,只有一个音效参数按钮;type 只支持 `one-shot` / `loop`BPM 为 `1-300` 的数字或 `null`,选择后按钮显示 `单次·120BPM` 这类组合值。
@@ -20,11 +20,10 @@
### 生成游戏音效
- `sound`:用户输入的音效提示词。
- `type`:合成选项,只支持 `one-shot` / `loop`UI 显示为 `单次` / `循环`,不展示字段标题
- `tempo`:可空 BPM 数值;默认不填为 `null`,用户可通过拖拉条或数字输入设置,范围 `1-300`
- `type``tempo` 合并为一个 Lovart 式底部参数按钮;无 BPM 时按钮显示 `单次` / `循环`,有 BPM 时显示 `单次·120BPM` 这类组合值
- 提交到 VectorEngine 时映射为 Suno 音效模式字段:`mv``task: "sound"``metadata_params.sound``metadata_params.type``metadata_params.tempo``metadata_params.key: null``mv` 后端固定使用默认 Suno 模型,不在 UI 中展示。
- `prompt`:用户输入的音效提示词,直接作为 Vidu 文生音频请求的 `prompt` 字段
- `model`:固定默认 `audio1.0`UI 显示为 `Vidu`;暂不展示 Suno 文生音效模型入口
- `duration`:Vidu 音频时长,UI 以一个无标题参数按钮展示,选项范围 `2-10` 秒,默认 `5`
- 提交到 VectorEngine 时映射为 Vidu 文生音频字段:`model: "audio1.0"``prompt``duration`、可选 `seed`。当前编辑器音效固定使用 Vidu `audio1.0`,不再走 Suno `task: "sound"` / `metadata_params` 文生音效契约
### 生成游戏背景音乐
@@ -43,12 +42,14 @@
- 新增素材类型:
- `assetKind="sound-effect"`
- `assetKind="background-music"`
- 音频结果以小型音频卡加入画布,卡片使用 `<audio controls>` 播放;音频结果当前只保存为画布资源,不进入图片素材库。
- 音频结果卡片显示用户提示词`时长 mm:ss`;如果上游暂未返回时长,先显示 `时长 --:--`,并在 `<audio>` 元数据加载后用真实 duration 兜底
- 音频结果以小型音频卡加入画布,卡片底部使用 `<audio controls>` 播放;音频结果当前只保存为画布资源,不进入图片素材库。
- 音频结果卡片底部显示用户提示词与播放控件,不在卡片左下角或悬停左上角展示时长
- 音频卡片中央播放区按状态切换:未悬停且未播放时显示音效 / 背景音乐图标,悬停且未播放时显示播放按钮,播放中始终显示暂停按钮。
- generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `<audio>` 裸请求。
- 音频卡片、音频生成占位和元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义。
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标
- 音频图层右上角标签显示在信息按钮左侧,和其他素材卡右上角信息区保持一致。
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。
- 音频图层上方浮动工具栏只保留 `改造``下载按钮`。点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。
## 前端提交契约
@@ -57,9 +58,9 @@
```ts
POST /api/editor/audios/sound-effects/generations
{
sound: string,
type: "one-shot" | "loop",
tempo: number | null,
prompt: string,
model: "audio1.0",
duration: number,
priceMudPoints: 10
}
```
@@ -99,10 +100,9 @@ POST /api/editor/audios/background-music/generations
-`platform-audio` 增加编辑器专用 body builder 和 submit 函数:
- 背景音乐 body 使用 `mv``gpt_description_prompt``make_instrumental`
- Suno 音乐接口路径固定为 `/suno/submit/music``VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`
- 音效 body 使用 Suno 音效契约:`mv``task: "sound"``metadata_params`,其中 `metadata_params.tempo``1-300` 的 BPM 或 `null``metadata_params.key` 固定 `null`
- 编辑器音效提交和轮询使用 Suno 路径,提交 `/suno/submit/music`,查询 `/suno/fetch/{taskId}`;旧 Vidu `/ent/v2/text2audio` 仅保留给历史视觉小说音效链路
- Suno 提交成功后的任务 ID 兼容`data` 字符串、`data.task_id``data.taskId``data.id``task_id``taskId``id` 中读取,避免音效接口只返回 `data` 时误报“上游未返回任务 ID”
- Suno 查询结果可能不直接返回音频 URL,而是在 `data` 中返回歌曲 / 音效 clip id;后端必须兼容 `data` 字符串、对象或数组中的 `id` / `clip_id` / `audioId` / `songId`,再调用 `/suno/act/wav/{clipId}` 提取 `wav_file_url` 等可下载地址。若已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求临时返回上游错误,继续按 `processing` 轮询,不要直接报“尚未返回可下载地址”或“获取 Suno 音效 wav 失败”。
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"``prompt``duration` 和可选 `seed``model``prompt` 为必填,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10`
- 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路,避免误伤 `/api/editor/audios/background-music/generations`
- VectorEngine 音频响应的 `code` 需要兼容 `"success"``"ok"``"0"``"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
-`api-server` 增加编辑器音频 BFF
- `/api/editor/audios/sound-effects/generations`
@@ -113,11 +113,14 @@ POST /api/editor/audios/background-music/generations
- 底部工具栏显示 `生成音乐`
- 点击 `生成音乐` 只出现选项框,不立刻创建占位。
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `sound`;底部只有一个无标题音效参数按钮,内部合并 `type` 和 BPM
- 音效参数只提供 `单次(one-shot)``循环(loop)`BPM 支持拖拉条和数字输入,范围 `1-300`,不填时提交 `tempo: null`
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `prompt`;底部只有一个无标题时长参数按钮。
- 音效时长只提供 `2-10` 秒选项,默认 `5` 秒,提交到 BFF 的字段为 `duration`
- 音效面板模型显示 `Vidu`,提交 `model: "audio1.0"`;不显示 Suno 文生音效模型或 Suno 音效入口。
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,不展示 `make_instrumental`
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`
- 成功后画布新增音频卡,能通过 `<audio controls>` 播放。
- 成功后的音频卡展示提示词;卡片悬停标、信息弹窗和待生成占位均显示时长
- 成功后的音频卡展示提示词;卡片中央按未悬停标、悬停播放、播放中暂停切换,播放控件固定在卡片底部
- 音频卡片、悬停角标和待生成占位不显示时长;信息弹窗显示时长。
- 音频素材浮动工具栏只显示 `改造``下载按钮``改造` 复用对应生成面板且没有参考图组件。
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
- 刷新后 layout 能恢复音频生成器和音频图层。
@@ -2312,10 +2312,12 @@ fn normalize_editor_character_animation_request(
let frame_count = normalize_editor_character_animation_frame_count(payload.frame_count)?;
let duration_seconds =
normalize_editor_character_animation_duration(payload.duration_seconds, frame_count)?;
let expected_price = crate::editor_generation_config::editor_character_animation_mud_points(
resolution,
duration_seconds,
);
let expected_price =
crate::editor_generation_config::editor_character_animation_model_mud_points(
Some(payload.model.as_str()),
resolution,
duration_seconds,
);
if payload.price_mud_points != expected_price {
return Err(editor_character_animation_bad_request(format!(
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}"
@@ -2413,7 +2415,8 @@ fn normalize_editor_video_request(
if payload.sound.trim() != "off" {
return Err(editor_video_bad_request("sound 只支持 off。"));
}
let expected_price = crate::editor_generation_config::editor_video_generation_mud_points(
let expected_price = crate::editor_generation_config::editor_video_model_generation_mud_points(
Some(model),
resolution,
duration_seconds,
);
@@ -2,12 +2,10 @@
///
/// 中文注释:先用 api-server 静态配置收口价格事实源,避免继续把价格散落在
/// 前端常量和具体 handler 内;后续若接后台配置,可只替换本模块读取来源。
const EDITOR_IMAGE_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_SPEC_GENERATION_MUD_POINTS: u32 = 5;
const EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_UI_DESIGN_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_PUBLICATION_MATERIAL_GENERATION_MUD_POINTS: u32 = 5;
const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 5;
pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
pub(crate) const EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND: u32 = 20;
@@ -15,23 +13,98 @@ pub(crate) const EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND: u32 = 20;
pub(crate) const EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
pub(crate) const EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND: u32 = 20;
pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>) -> u32 {
match kind.map(str::trim) {
Some("spec") => EDITOR_SPEC_GENERATION_MUD_POINTS,
Some("character") => EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS,
Some("icon") => EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS,
Some("ui-design") => EDITOR_UI_DESIGN_GENERATION_MUD_POINTS,
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_GENERATION_MUD_POINTS,
_ => EDITOR_IMAGE_GENERATION_MUD_POINTS,
const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0";
const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast";
const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0";
const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni";
const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1";
const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast";
const EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast";
pub(crate) const EDITOR_SOUND_EFFECT_MODEL_VIDU: &str = "audio1.0";
pub(crate) const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO: &str = "chirp-v5";
const EDITOR_SOUND_EFFECT_MODEL_VIDU_MUD_POINTS: u32 = 10;
const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS: u32 = 5;
pub(crate) fn editor_image_model_generation_mud_points(model: Option<&str>) -> u32 {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) | Some(EDITOR_IMAGE_MODEL_NANOBANANA2) => {
EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS
}
_ => EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS,
}
}
pub(crate) fn editor_video_generation_mud_points(resolution: &str, duration_seconds: u32) -> u32 {
pub(crate) fn editor_spec_model_generation_mud_points(model: Option<&str>) -> u32 {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS,
_ => EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS,
}
}
pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>, model: Option<&str>) -> u32 {
match kind.map(str::trim) {
Some("spec") => editor_spec_model_generation_mud_points(model),
Some("character" | "icon" | "ui-design" | "publication-material") => {
editor_image_model_generation_mud_points(model)
}
_ => editor_image_model_generation_mud_points(model),
}
}
fn editor_video_model_resolution_mud_points_per_second(
model: Option<&str>,
resolution: &str,
) -> u32 {
let _normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(
EDITOR_VIDEO_MODEL_SEEDANCE_2
| EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST
| EDITOR_VIDEO_MODEL_KLING_3
| EDITOR_VIDEO_MODEL_KLING_3_OMNI
| EDITOR_VIDEO_MODEL_VEO_3_1
| EDITOR_VIDEO_MODEL_VEO_3_1_FAST,
) => model.unwrap_or_default().trim(),
_ => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
};
let per_second = if resolution == "720p" {
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND
} else {
EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND
};
per_second
}
pub(crate) fn editor_video_model_generation_mud_points(
model: Option<&str>,
resolution: &str,
duration_seconds: u32,
) -> u32 {
let per_second = editor_video_model_resolution_mud_points_per_second(model, resolution);
per_second * duration_seconds
}
pub(crate) fn editor_video_generation_mud_points(resolution: &str, duration_seconds: u32) -> u32 {
let per_second =
editor_video_model_resolution_mud_points_per_second(None, resolution);
per_second * duration_seconds
}
pub(crate) fn editor_character_animation_model_mud_points(
model: Option<&str>,
resolution: &str,
duration_seconds: u32,
) -> u32 {
let _normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST) => {
EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST
}
_ => EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST,
};
let per_second = if resolution == "720p" {
EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND
} else {
EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND
};
per_second * duration_seconds
}
@@ -39,12 +112,23 @@ pub(crate) fn editor_character_animation_mud_points(
resolution: &str,
duration_seconds: u32,
) -> u32 {
let per_second = if resolution == "720p" {
EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND
} else {
EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND
};
per_second * duration_seconds
editor_character_animation_model_mud_points(None, resolution, duration_seconds)
}
pub(crate) fn editor_sound_effect_model_generation_mud_points(model: Option<&str>) -> u32 {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_SOUND_EFFECT_MODEL_VIDU) => EDITOR_SOUND_EFFECT_MODEL_VIDU_MUD_POINTS,
_ => EDITOR_SOUND_EFFECT_MODEL_VIDU_MUD_POINTS,
}
}
pub(crate) fn editor_background_music_model_generation_mud_points(model: Option<&str>) -> u32 {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO) => {
EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS
}
_ => EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS,
}
}
#[cfg(test)]
@@ -64,15 +148,68 @@ mod tests {
}
#[test]
fn editor_image_generation_price_uses_configured_kind_rates() {
assert_eq!(editor_image_generation_mud_points(None), 12);
assert_eq!(editor_image_generation_mud_points(Some("spec")), 5);
assert_eq!(editor_image_generation_mud_points(Some("character")), 12);
assert_eq!(editor_image_generation_mud_points(Some("icon")), 12);
assert_eq!(editor_image_generation_mud_points(Some("ui-design")), 12);
fn editor_video_generation_price_uses_configured_model_rates() {
assert_eq!(
editor_image_generation_mud_points(Some("publication-material")),
editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "480p", 4),
40
);
assert_eq!(
editor_video_model_generation_mud_points(Some("seedance2.0"), "720p", 5),
100
);
assert_eq!(
editor_video_model_generation_mud_points(Some("kling3.0"), "480p", 5),
50
);
assert_eq!(
editor_video_model_generation_mud_points(Some("kling3.0-omni"), "720p", 4),
80
);
assert_eq!(
editor_video_model_generation_mud_points(Some("veo3.1"), "480p", 4),
40
);
assert_eq!(
editor_video_model_generation_mud_points(Some("veo3.1-fast"), "720p", 4),
80
);
}
#[test]
fn editor_audio_generation_price_uses_configured_model_rates() {
assert_eq!(
editor_sound_effect_model_generation_mud_points(Some("audio1.0")),
10
);
assert_eq!(
editor_background_music_model_generation_mud_points(Some("chirp-v5")),
5
);
}
#[test]
fn editor_image_generation_price_uses_configured_kind_rates() {
assert_eq!(editor_image_generation_mud_points(None, None), 12);
assert_eq!(editor_image_generation_mud_points(Some("spec"), None), 5);
assert_eq!(
editor_image_generation_mud_points(Some("character"), None),
12
);
assert_eq!(editor_image_generation_mud_points(Some("icon"), None), 12);
assert_eq!(
editor_image_generation_mud_points(Some("ui-design"), None),
12
);
assert_eq!(
editor_image_generation_mud_points(
Some("publication-material"),
Some("gemini-3.1-flash-image-preview")
),
12
);
assert_eq!(
editor_image_generation_mud_points(Some("publication-material"), Some("gpt-image-2")),
12
);
}
}
@@ -786,8 +786,6 @@ pub async fn generate_editor_image(
legacy_size
};
let normalized_kind = payload.kind.as_deref().map(str::trim);
let configured_price_mud_points =
crate::editor_generation_config::editor_image_generation_mud_points(normalized_kind);
let is_character_generation = matches!(normalized_kind, Some("character"));
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
@@ -857,6 +855,11 @@ pub async fn generate_editor_image(
} else {
image_size
};
let configured_price_mud_points =
crate::editor_generation_config::editor_image_generation_mud_points(
normalized_kind,
Some(generation_options.model),
);
let generate_operation = async {
if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
@@ -1154,8 +1157,6 @@ pub async fn generate_editor_icon_spritesheet(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let expected_price_mud_points =
validate_editor_icon_spritesheet_price(payload.price_mud_points)?;
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
let reference_image = parse_editor_reference_image(payload.reference_image_src.as_str())
.map_err(|error| {
@@ -1170,6 +1171,10 @@ pub async fn generate_editor_icon_spritesheet(
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let expected_price_mud_points = validate_editor_icon_spritesheet_price(
payload.price_mud_points,
Some(generation_options.model),
)?;
let size = generation_options.size.as_str();
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions);
@@ -1328,6 +1333,7 @@ pub async fn extract_editor_ui_design_assets(
task_id: generated.task_id,
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
Some(GPT_IMAGE_2_MODEL),
),
},
))
@@ -1530,9 +1536,12 @@ fn editor_icon_spritesheet_bad_request(message: impl Into<String>) -> AppError {
}))
}
fn validate_editor_icon_spritesheet_price(price_mud_points: u32) -> Result<u32, AppError> {
fn validate_editor_icon_spritesheet_price(
price_mud_points: u32,
model: Option<&str>,
) -> Result<u32, AppError> {
let expected_price_mud_points =
crate::editor_generation_config::editor_image_generation_mud_points(Some("icon"));
crate::editor_generation_config::editor_image_generation_mud_points(Some("icon"), model);
if price_mud_points != expected_price_mud_points {
return Err(editor_icon_spritesheet_bad_request(format!(
"priceMudPoints 与素材生成计费配置不一致,应为 {expected_price_mud_points}"
@@ -2032,8 +2041,19 @@ mod tests {
#[test]
fn editor_icon_spritesheet_request_price_uses_generation_config() {
assert_eq!(validate_editor_icon_spritesheet_price(12).unwrap(), 12);
let error = validate_editor_icon_spritesheet_price(5)
assert_eq!(
validate_editor_icon_spritesheet_price(
12,
Some("gemini-3.1-flash-image-preview")
)
.unwrap(),
12
);
assert_eq!(
validate_editor_icon_spritesheet_price(12, Some("gpt-image-2")).unwrap(),
12
);
let error = validate_editor_icon_spritesheet_price(5, Some("gpt-image-2"))
.expect_err("wrong icon spritesheet price should fail");
assert!(error.body_text().contains("priceMudPoints"));
let response = EditorIconSpritesheetGenerationResponse {
@@ -2053,6 +2073,7 @@ mod tests {
task_id: "task-icons".to_string(),
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
None,
),
};
let payload = serde_json::to_value(response).expect("response should serialize");
@@ -25,16 +25,14 @@ use super::{
types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget},
};
const EDITOR_SOUND_EFFECT_POINTS_COST: u32 = 10;
const EDITOR_BACKGROUND_MUSIC_POINTS_COST: u32 = 5;
const EDITOR_AUDIO_WIDTH: u32 = 420;
const EDITOR_AUDIO_HEIGHT: u32 = 120;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct NormalizedEditorSoundEffectRequest {
pub(super) sound: String,
pub(super) sound_type: String,
pub(super) tempo: Option<u16>,
pub(super) prompt: String,
pub(super) model: String,
pub(super) duration: u8,
pub(super) price_mud_points: u32,
}
@@ -48,46 +46,58 @@ pub(super) struct NormalizedEditorBackgroundMusicRequest {
pub(super) fn normalize_editor_sound_effect_request(
payload: assets::EditorSoundEffectGenerateRequest,
) -> Result<NormalizedEditorSoundEffectRequest, AppError> {
if payload.price_mud_points != EDITOR_SOUND_EFFECT_POINTS_COST {
return Err(editor_audio_bad_request("音效生成泥点消耗不匹配"));
let model = normalize_editor_sound_model(payload.model.as_deref())?;
let expected_price_mud_points =
crate::editor_generation_config::editor_sound_effect_model_generation_mud_points(Some(
model.as_str(),
));
if payload.price_mud_points != expected_price_mud_points {
return Err(editor_audio_bad_request(format!(
"音效生成泥点消耗不匹配,应为 {expected_price_mud_points}"
)));
}
Ok(NormalizedEditorSoundEffectRequest {
sound: platform_audio::normalize_limited_text(
&payload.sound,
"sound",
prompt: platform_audio::normalize_limited_text(
&payload.prompt,
"prompt",
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?,
sound_type: normalize_editor_sound_type(&payload.sound_type)?,
tempo: normalize_editor_sound_tempo(payload.tempo)?,
price_mud_points: payload.price_mud_points,
model,
duration: normalize_editor_sound_duration(payload.duration)?,
price_mud_points: expected_price_mud_points,
})
}
fn normalize_editor_sound_type(value: &str) -> Result<String, AppError> {
let sound_type = platform_audio::normalize_limited_text(value, "type", 80)
.map_err(map_platform_audio_error)?;
if sound_type == "one-shot" || sound_type == "loop" {
return Ok(sound_type);
fn normalize_editor_sound_model(value: Option<&str>) -> Result<String, AppError> {
let model = platform_audio::normalize_optional_text(value)
.unwrap_or_else(|| platform_audio::VIDU_AUDIO_MODEL.to_string());
if model == platform_audio::VIDU_AUDIO_MODEL {
return Ok(model);
}
Err(editor_audio_bad_request(
"音效 type 只支持 one-shot 或 loop",
"编辑器音效暂只支持 Vidu audio1.0 模型",
))
}
fn normalize_editor_sound_tempo(value: Option<u16>) -> Result<Option<u16>, AppError> {
match value {
Some(tempo) if (1..=300).contains(&tempo) => Ok(Some(tempo)),
Some(_) => Err(editor_audio_bad_request("音效 tempo BPM 必须在 1-300 之间")),
None => Ok(None),
fn normalize_editor_sound_duration(value: u8) -> Result<u8, AppError> {
if (2..=10).contains(&value) {
return Ok(value);
}
Err(editor_audio_bad_request("音效 duration 必须在 2-10 秒之间"))
}
pub(super) fn normalize_editor_background_music_request(
payload: assets::EditorBackgroundMusicGenerateRequest,
) -> Result<NormalizedEditorBackgroundMusicRequest, AppError> {
if payload.price_mud_points != EDITOR_BACKGROUND_MUSIC_POINTS_COST {
return Err(editor_audio_bad_request("背景音乐生成泥点消耗不匹配"));
let expected_price_mud_points =
crate::editor_generation_config::editor_background_music_model_generation_mud_points(Some(
platform_audio::SUNO_DEFAULT_MODEL,
));
if payload.price_mud_points != expected_price_mud_points {
return Err(editor_audio_bad_request(format!(
"背景音乐生成泥点消耗不匹配,应为 {expected_price_mud_points}"
)));
}
Ok(NormalizedEditorBackgroundMusicRequest {
gpt_description_prompt: platform_audio::normalize_limited_text(
@@ -97,7 +107,7 @@ pub(super) fn normalize_editor_background_music_request(
)
.map_err(map_platform_audio_error)?,
make_instrumental: true,
price_mud_points: payload.price_mud_points,
price_mud_points: expected_price_mud_points,
})
}
@@ -119,10 +129,10 @@ pub async fn generate_editor_sound_effect(
&http_client,
&settings,
platform_audio::EditorSoundEffectTaskRequest {
sound: normalized.sound.clone(),
sound_type: normalized.sound_type.clone(),
tempo: normalized.tempo,
model: None,
prompt: normalized.prompt.clone(),
duration: normalized.duration,
seed: None,
model: Some(normalized.model.clone()),
},
)
.await
@@ -151,9 +161,9 @@ pub async fn generate_editor_sound_effect(
width: EDITOR_AUDIO_WIDTH,
height: EDITOR_AUDIO_HEIGHT,
source_type: "generated".to_string(),
prompt: normalized.sound.clone(),
actual_prompt: Some(normalized.sound),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
prompt: normalized.prompt.clone(),
actual_prompt: Some(normalized.prompt),
model: normalized.model,
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
@@ -82,58 +82,59 @@ fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effec
}
#[test]
fn editor_sound_effect_request_normalizes_sound_type_tempo_and_price() {
fn editor_sound_effect_request_normalizes_prompt_duration_and_price() {
let normalized =
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
sound: " 金币掉落叮当声 ".to_string(),
sound_type: " one-shot ".to_string(),
tempo: Some(120),
prompt: " 金币掉落叮当声 ".to_string(),
model: None,
duration: 7,
price_mud_points: 10,
})
.expect("editor sound effect request should normalize");
assert_eq!(normalized.sound, "金币掉落叮当声");
assert_eq!(normalized.sound_type, "one-shot");
assert_eq!(normalized.tempo, Some(120));
assert_eq!(normalized.prompt, "金币掉落叮当声");
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
assert_eq!(normalized.duration, 7);
assert_eq!(normalized.price_mud_points, 10);
}
#[test]
fn editor_sound_effect_request_accepts_null_tempo_and_rejects_invalid_type() {
fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
let normalized =
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
sound: "按钮点击".to_string(),
sound_type: "loop".to_string(),
tempo: None,
prompt: "金币掉落叮当声".to_string(),
model: Some(" audio1.0 ".to_string()),
duration: 5,
price_mud_points: 10,
})
.expect("null BPM should keep tempo unset");
assert_eq!(normalized.sound_type, "loop");
assert_eq!(normalized.tempo, None);
.expect("Vidu audio model should be accepted");
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
let error = normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
sound: "按钮点击".to_string(),
sound_type: "ui".to_string(),
tempo: Some(120),
prompt: "金币掉落叮当声".to_string(),
model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()),
duration: 5,
price_mud_points: 10,
})
.expect_err("unsupported sound type should fail");
assert!(error.to_string().contains("one-shot"));
.expect_err("Suno text-to-sound should be disabled for editor sound effects");
assert!(error.to_string().contains("Vidu"));
}
#[test]
fn editor_sound_effect_request_rejects_bpm_outside_documented_range() {
for tempo in [0, 301] {
fn editor_sound_effect_request_rejects_duration_outside_vidu_range() {
for duration in [1, 11] {
let error =
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
sound: "按钮点击".to_string(),
sound_type: "one-shot".to_string(),
tempo: Some(tempo),
prompt: "按钮点击".to_string(),
model: None,
duration,
price_mud_points: 10,
})
.expect_err("BPM outside 1-300 should fail");
.expect_err("duration outside 2-10 seconds should fail");
assert!(error.to_string().contains("1-300"));
assert!(error.to_string().contains("2-10"));
}
}
@@ -168,9 +169,9 @@ fn editor_background_music_request_rejects_prompt_over_documented_limit() {
#[test]
fn editor_audio_rejects_price_mismatch() {
let error = normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
sound: "点击按钮".to_string(),
sound_type: "one-shot".to_string(),
tempo: Some(120),
prompt: "点击按钮".to_string(),
model: None,
duration: 5,
price_mud_points: 5,
})
.expect_err("wrong sound effect price should fail");
@@ -117,7 +117,7 @@ pub async fn submit_editor_sound_effect_task(
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::SunoSoundEffect.submit_path(),
AudioTaskKind::SoundEffect.submit_path(),
body,
"提交编辑器音效任务失败",
)
@@ -127,9 +127,9 @@ pub async fn submit_editor_sound_effect_task(
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
Ok(AudioTaskResponse {
kind: AudioTaskKind::SunoSoundEffect,
kind: AudioTaskKind::SoundEffect,
task_id,
provider: AudioTaskKind::SunoSoundEffect.provider().to_string(),
provider: AudioTaskKind::SoundEffect.provider().to_string(),
status,
})
}
+22 -22
View File
@@ -39,14 +39,16 @@ pub fn build_background_music_task_body(
pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result<Value, AudioError> {
let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let duration = request.duration.clamp(2, 10);
if !(2..=10).contains(&request.duration) {
return Err(AudioError::invalid_request("duration 必须在 2-10 秒之间"));
}
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(VIDU_AUDIO_MODEL.to_string()),
),
("prompt".to_string(), Value::String(prompt)),
("duration".to_string(), json!(duration)),
("duration".to_string(), json!(request.duration)),
]);
if let Some(seed) = request.seed {
body.insert("seed".to_string(), json!(seed));
@@ -74,28 +76,26 @@ pub fn build_editor_background_music_task_body(
pub fn build_editor_sound_effect_task_body(
request: EditorSoundEffectTaskRequest,
) -> Result<Value, AudioError> {
let sound = normalize_limited_text(&request.sound, "sound", VIDU_PROMPT_MAX_CHARS)?;
let sound_type = normalize_limited_text(&request.sound_type, "type", 80)?;
if sound_type != "one-shot" && sound_type != "loop" {
return Err(AudioError::invalid_request("type 只支持 one-shot 或 loop"));
}
if let Some(tempo) = request.tempo {
if !(1..=300).contains(&tempo) {
return Err(AudioError::invalid_request("tempo BPM 必须在 1-300 之间"));
}
let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
if !(2..=10).contains(&request.duration) {
return Err(AudioError::invalid_request("duration 必须在 2-10 秒之间"));
}
let model = normalize_optional_text(request.model.as_deref())
.unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
Ok(json!({
"mv": model,
"task": "sound",
"metadata_params": {
"sound": sound,
"type": sound_type,
"tempo": request.tempo,
"key": null,
},
}))
.unwrap_or_else(|| VIDU_AUDIO_MODEL.to_string());
if model != VIDU_AUDIO_MODEL {
return Err(AudioError::invalid_request(
"编辑器音效暂只支持 Vidu audio1.0 模型",
));
}
let mut body = Map::from_iter([
("model".to_string(), Value::String(model)),
("prompt".to_string(), Value::String(prompt)),
("duration".to_string(), json!(request.duration)),
]);
if let Some(seed) = request.seed {
body.insert("seed".to_string(), json!(seed));
}
Ok(Value::Object(body))
}
pub fn normalize_limited_text(
+3 -3
View File
@@ -66,9 +66,9 @@ pub struct EditorBackgroundMusicTaskRequest {
#[derive(Clone, Debug)]
pub struct EditorSoundEffectTaskRequest {
pub sound: String,
pub sound_type: String,
pub tempo: Option<u16>,
pub prompt: String,
pub duration: u8,
pub seed: Option<u64>,
pub model: Option<String>,
}
@@ -1,6 +1,6 @@
use platform_audio::{
AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest,
EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, VIDU_PROMPT_MAX_CHARS,
AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL,
EditorSoundEffectTaskRequest, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
audio_mime_to_extension, 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,
@@ -127,58 +127,78 @@ fn editor_background_music_request_body_rejects_overlong_gpt_description_prompt(
}
#[test]
fn editor_sound_effect_request_body_uses_sound_type_and_tempo() {
let body = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: " 金币掉落叮当声 ".to_string(),
sound_type: " one-shot ".to_string(),
tempo: Some(120),
model: None,
fn vidu_sound_effect_request_body_uses_text2audio_contract() {
let body = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest {
prompt: " 金币掉落叮当声 ".to_string(),
duration: 5,
seed: None,
})
.expect("editor sound effect body should be valid");
.expect("Vidu sound effect body should be valid");
assert_eq!(body["mv"], SUNO_DEFAULT_MODEL);
assert_eq!(body["task"], "sound");
assert_eq!(body["metadata_params"]["sound"], "金币掉落叮当声");
assert_eq!(body["metadata_params"]["type"], "one-shot");
assert_eq!(body["metadata_params"]["tempo"], 120);
assert_eq!(body["metadata_params"]["key"], serde_json::Value::Null);
assert!(body.get("prompt").is_none());
assert_eq!(AudioTaskKind::SoundEffect.submit_path(), "/ent/v2/text2audio");
assert_eq!(AudioTaskKind::SoundEffect.provider(), "vector-engine-vidu");
assert_eq!(body["model"], VIDU_AUDIO_MODEL);
assert_eq!(body["prompt"], "金币掉落叮当声");
assert_eq!(body["duration"], 5);
assert!(body.get("mv").is_none());
assert!(body.get("task").is_none());
assert!(body.get("metadata_params").is_none());
}
#[test]
fn editor_sound_effect_request_body_keeps_null_tempo() {
let body = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "loop".to_string(),
tempo: None,
model: Some(" chirp-v5-5 ".to_string()),
fn vidu_sound_effect_request_body_rejects_duration_outside_vidu_range_and_keeps_seed() {
let body = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest {
prompt: "按钮确认短促音".to_string(),
duration: 2,
seed: Some(42),
})
.expect("unset BPM should serialize as null");
.expect("Vidu 2 second duration should be accepted");
assert_eq!(body["duration"], 2);
assert_eq!(body["seed"], 42);
assert_eq!(body["mv"], "chirp-v5-5");
assert_eq!(body["metadata_params"]["type"], "loop");
assert_eq!(body["metadata_params"]["tempo"], serde_json::Value::Null);
}
#[test]
fn editor_sound_effect_request_body_rejects_invalid_type_and_tempo() {
let invalid_type = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "ambient".to_string(),
tempo: Some(120),
model: None,
})
.expect_err("platform layer should reject unsupported sound type");
assert!(invalid_type.message().contains("one-shot"));
for tempo in [0, 301] {
let invalid_tempo = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "one-shot".to_string(),
tempo: Some(tempo),
model: None,
for duration in [1, 11] {
let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest {
prompt: "循环环境音".to_string(),
duration,
seed: None,
})
.expect_err("platform layer should reject BPM outside 1-300");
assert!(invalid_tempo.message().contains("1-300"));
.expect_err("duration outside Vidu 2-10 seconds should fail");
assert!(error.message().contains("duration"));
}
}
#[test]
fn editor_sound_effect_request_body_uses_vidu_text2audio_contract() {
let body = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
prompt: " 金币掉落叮当声 ".to_string(),
duration: 7,
seed: Some(42),
model: Some(VIDU_AUDIO_MODEL.to_string()),
})
.expect("editor sound effect should use Vidu body");
assert_eq!(body["model"], VIDU_AUDIO_MODEL);
assert_eq!(body["prompt"], "金币掉落叮当声");
assert_eq!(body["duration"], 7);
assert_eq!(body["seed"], 42);
assert!(body.get("mv").is_none());
assert!(body.get("task").is_none());
assert!(body.get("metadata_params").is_none());
assert!(body.get("type").is_none());
assert!(body.get("tempo").is_none());
}
#[test]
fn editor_sound_effect_request_body_rejects_duration_outside_vidu_range() {
for duration in [1, 11] {
let error = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
prompt: "按钮确认短促音".to_string(),
duration,
seed: None,
model: None,
})
.expect_err("duration outside Vidu 2-10 seconds should fail");
assert!(error.message().contains("duration"));
}
}
+26 -23
View File
@@ -426,10 +426,10 @@ pub struct EditorIconSpritesheetGenerateResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSoundEffectGenerateRequest {
pub sound: String,
#[serde(rename = "type")]
pub sound_type: String,
pub tempo: Option<u16>,
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub duration: u8,
pub price_mud_points: u32,
}
@@ -1160,32 +1160,35 @@ mod tests {
#[test]
fn editor_audio_requests_and_response_use_canvas_audio_shape() {
let sound_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
sound: "金币掉落叮当声".to_string(),
sound_type: "one-shot".to_string(),
tempo: Some(120),
prompt: "金币掉落叮当声".to_string(),
model: Some("audio1.0".to_string()),
duration: 7,
price_mud_points: 10,
})
.expect("sound request should serialize");
assert_eq!(sound_payload["sound"], json!("金币掉落叮当声"));
assert_eq!(sound_payload["type"], json!("one-shot"));
assert_eq!(sound_payload["tempo"], json!(120));
assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声"));
assert_eq!(sound_payload["model"], json!("audio1.0"));
assert_eq!(sound_payload["duration"], json!(7));
assert_eq!(sound_payload["priceMudPoints"], json!(10));
assert!(sound_payload.get("sound").is_none());
assert!(sound_payload.get("type").is_none());
assert!(sound_payload.get("tempo").is_none());
let unset_tempo_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "loop".to_string(),
tempo: None,
let parsed_sound_payload: EditorSoundEffectGenerateRequest =
serde_json::from_value(sound_payload)
.expect("sound request with duration should deserialize");
assert_eq!(parsed_sound_payload.prompt, "金币掉落叮当声");
assert_eq!(parsed_sound_payload.duration, 7);
let unset_model_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
prompt: "按钮确认短促音".to_string(),
model: None,
duration: 5,
price_mud_points: 10,
})
.expect("sound request with unset tempo should serialize");
assert_eq!(unset_tempo_payload["type"], json!("loop"));
assert_eq!(unset_tempo_payload["tempo"], json!(null));
let parsed_unset_tempo: EditorSoundEffectGenerateRequest =
serde_json::from_value(unset_tempo_payload)
.expect("sound request with null tempo should deserialize");
assert_eq!(parsed_unset_tempo.sound_type, "loop");
assert_eq!(parsed_unset_tempo.tempo, None);
.expect("sound request with unset model should serialize");
assert!(unset_model_payload.get("model").is_none());
assert_eq!(unset_model_payload["duration"], json!(5));
let music_payload = serde_json::to_value(EditorBackgroundMusicGenerateRequest {
gpt_description_prompt: "森林冒险背景音乐".to_string(),
@@ -21,7 +21,7 @@ import type {
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import {
EDITOR_GENERATION_MUD_POINT_CONFIG,
calculateEditorImageGenerationPrice,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -172,7 +172,9 @@ export function ImageCanvasBasicGenerationComposerView({
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.image}
cost={calculateEditorImageGenerationPrice({
model: dialog.imageModel,
})}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
@@ -114,6 +114,43 @@ describe('ImageCanvasBottomToolbarView', () => {
}
});
it('把宣发素材入口放在生成UI设计图右侧', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
/>,
);
const toolNames = within(
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label'));
expect(toolNames).toEqual([
'选择工具',
'抓手工具',
'上传工具',
'生成图片',
'生成视频',
'生成音乐',
'生成规范',
'生成角色形象',
'生成图标素材',
'生成UI设计图',
'宣发素材',
]);
});
it('opens and closes bottom option tools on hover', () => {
const openToolOptions = vi.fn();
const closeToolOptions = vi.fn();
@@ -45,7 +45,6 @@ const canvasTools: Array<{
},
{ id: 'video', label: '生成视频', icon: Clapperboard },
{ id: 'music', label: '生成音乐', icon: Music },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
{
id: 'spec',
label: '生成规范',
@@ -55,6 +54,7 @@ const canvasTools: Array<{
{ id: 'character', label: '生成角色形象', icon: UserRound },
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
];
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
@@ -21,7 +21,7 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { EDITOR_GENERATION_MUD_POINT_CONFIG } from './ImageCanvasGenerationModel';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasCharacterGenerationComposerViewProps = {
@@ -291,7 +291,10 @@ export function ImageCanvasCharacterGenerationComposerView({
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.character}
cost={calculateEditorImageGenerationPrice({
kind: 'character',
model: dialog.imageModel,
})}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
@@ -2818,7 +2818,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重绘' }));
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
expect(editDialog).toBeTruthy();
const editPrompt = within(editDialog).getByLabelText('重绘提示词');
@@ -2914,7 +2914,7 @@ describe('ImageCanvasEditorView generation integration', () => {
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '重绘' }));
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
fireEvent.change(within(editDialog).getByLabelText('重绘提示词'), {
target: { value: '改成雨夜灯光' },
@@ -173,8 +173,7 @@ describe('ImageCanvasEditorModel', () => {
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
soundType: 'loop',
soundTempo: 120,
soundDurationSeconds: 8,
generatedLayerId: 'layer-audio',
placeholder: {
x: 100,
@@ -198,8 +197,7 @@ describe('ImageCanvasEditorModel', () => {
id: 'generation-dialog-audio',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
soundType: 'loop',
soundTempo: 120,
soundDurationSeconds: 8,
generatedLayerId: 'layer-audio',
});
});
@@ -337,11 +337,11 @@ export function hydrateCanvasGenerationDialog(
characterAnimationResult: hydrateCharacterAnimationResult(
snapshot.characterAnimationResult,
),
soundType:
snapshot.soundType === 'loop' || snapshot.soundType === 'one-shot'
? snapshot.soundType
: undefined,
soundTempo: bpmOrNull(snapshot.soundTempo),
soundModel: snapshot.soundModel === 'audio1.0' ? 'audio1.0' : undefined,
soundDurationSeconds: soundDurationOrDefault(
snapshot.soundDurationSeconds,
snapshot.audioDurationSeconds,
),
makeInstrumental:
typeof snapshot.makeInstrumental === 'boolean'
? snapshot.makeInstrumental
@@ -511,6 +511,17 @@ export function bpmOrNull(value: unknown) {
return roundedValue >= 1 && roundedValue <= 300 ? roundedValue : undefined;
}
export function soundDurationOrDefault(
value: unknown,
fallbackValue?: unknown,
) {
const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
if (!resolvedValue) {
return undefined;
}
return Math.min(10, Math.max(2, Math.round(resolvedValue)));
}
export function stringOrNull(value: unknown) {
return typeof value === 'string' && value.trim() ? value : null;
}
@@ -202,9 +202,9 @@ export type GenerateDialogState = {
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
characterAnimationDurationSeconds?: 4 | 5 | 6;
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
soundType?: 'one-shot' | 'loop';
// 中文注释:音效 tempo 改为可空 BPM,null 表示用户不指定节奏
soundTempo?: number | null;
soundModel?: 'audio1.0';
// 中文注释:Vidu 文生音频 duration 只允许 2-10 秒,默认 5 秒
soundDurationSeconds?: number;
makeInstrumental?: boolean;
audioDurationSeconds?: number | null;
aspectRatio?: string;
@@ -1,4 +1,4 @@
/* @vitest-environment jsdom */
/* @vitest-environment jsdom */
import {
act,
@@ -510,7 +510,7 @@ describe('ImageCanvasEditorView', () => {
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
});
it('hydrates canvas images from Resolution instead of saved Size', async () => {
@@ -895,7 +895,7 @@ describe('ImageCanvasEditorView', () => {
expect(
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
@@ -231,6 +231,53 @@ describe('ImageCanvasGenerationComposerView', () => {
).toBe(true);
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
});
it.each([
['publication-cover-image', '游戏首图', '720 x 540'],
['publication-detail-gallery', '详情五图', '720 x 1280'],
['publication-promo-poster', '运营海报', '1280 x 720'],
] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => {
renderComposer({
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: workflowId,
publicationGameInfo: {
gameName: '',
gameCategories: '',
gameDescription: '',
},
publicationReferences: [],
imageModel: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '1K',
});
const panel = screen.getByRole('dialog', { name: `${label}生成卡片` });
expect(panel.className).toContain('image-canvas-editor__character-composer');
expect(panel.className).toContain('image-canvas-editor__publication-composer');
expect(
panel.firstElementChild?.className.includes(
'image-canvas-editor__reference-strip',
),
).toBe(true);
expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: `${label}游戏名` }),
).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }),
).toBeTruthy();
expect(panel.textContent).toContain(sizeLabel);
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe(
'生成12泥点',
);
});
it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => {
const onRequestUpload = vi.fn();
const setIsGenerationReferenceMenuOpen = vi.fn();
@@ -361,6 +408,14 @@ describe('ImageCanvasGenerationComposerView', () => {
videoResolution: '480p',
videoMode: 'std',
videoSound: 'off',
placeholder: {
x: 53,
y: 120,
width: 854,
height: 480,
originalWidth: 854,
originalHeight: 480,
},
});
return (
<>
@@ -371,6 +426,18 @@ describe('ImageCanvasGenerationComposerView', () => {
<output aria-label="当前视频模型">{dialog.videoModel}</output>
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
<output aria-label="当前视频占位">
{dialog.placeholder
? [
dialog.placeholder.x,
dialog.placeholder.y,
dialog.placeholder.width,
dialog.placeholder.height,
dialog.placeholder.originalWidth,
dialog.placeholder.originalHeight,
].join(':')
: '-'}
</output>
</>
);
}
@@ -402,6 +469,9 @@ describe('ImageCanvasGenerationComposerView', () => {
).toBeTruthy();
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
expect(screen.getByLabelText('当前视频占位').textContent).toBe(
'-160:0:1280:720:1280:720',
);
expect(screen.getByRole('button', { name: '视频参数 16:9 · 5秒 · 720p' })).toBeTruthy();
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
'生成100泥点',
@@ -417,7 +487,7 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
});
it('生成游戏音效面板把类型和 BPM 合并到一个无标题参数按钮', () => {
it('生成游戏音效面板使用 Vidu duration 参数且不再显示类型和 BPM', () => {
const onSubmitImageGeneration = vi.fn();
function AudioHarness() {
const [dialog, setDialog] = useState<GenerateDialogState>({
@@ -425,8 +495,8 @@ describe('ImageCanvasGenerationComposerView', () => {
prompt: '',
status: 'idle',
composerOpen: true,
soundType: 'one-shot',
soundTempo: null,
soundModel: 'audio1.0',
soundDurationSeconds: 5,
});
return (
<>
@@ -436,8 +506,10 @@ describe('ImageCanvasGenerationComposerView', () => {
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
}
/>
<output aria-label="当前音效型">{dialog.soundType}</output>
<output aria-label="当前BPM">{dialog.soundTempo ?? 'null'}</output>
<output aria-label="当前音效型">{dialog.soundModel}</output>
<output aria-label="当前音效时长">
{dialog.soundDurationSeconds ?? 'null'}
</output>
</>
);
}
@@ -445,46 +517,43 @@ describe('ImageCanvasGenerationComposerView', () => {
render(<AudioHarness />);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(within(panel).getByRole('textbox', { name: 'sound提示词' })).toBeTruthy();
expect(within(panel).getByRole('textbox', { name: 'prompt' })).toBeTruthy();
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
expect(within(panel).getByText('Vidu')).toBeTruthy();
expect(within(panel).queryByText(/Suno/u)).toBeNull();
expect(
within(panel).getByRole('button', { name: '音效参数 单次' }),
within(panel).getByRole('button', { name: '音效时长 5秒' }),
).toBeTruthy();
expect(within(panel).queryByRole('button', { name: /type/u })).toBeNull();
expect(within(panel).queryByRole('button', { name: /tempo/u })).toBeNull();
expect(within(panel).queryByText('单次')).toBeNull();
expect(within(panel).queryByText('循环')).toBeNull();
expect(within(panel).queryByText(/BPM/u)).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe(
'生成10泥点',
);
fireEvent.click(
within(panel).getByRole('button', { name: '音效参数 单次' }),
within(panel).getByRole('button', { name: '音效时长 5秒' }),
);
const optionPanel = screen.getByRole('menu', { name: '音效参数选项' });
fireEvent.click(within(optionPanel).getByRole('button', { name: '循环' }));
fireEvent.change(within(optionPanel).getByLabelText('BPM数值'), {
target: { value: '120' },
});
expect(screen.getByLabelText('当前音效类型').textContent).toBe('loop');
expect(screen.getByLabelText('当前BPM').textContent).toBe('120');
const optionPanel = screen.getByRole('menu', { name: '音效时长选项' });
expect(
screen.getByRole('button', { name: '音效参数 循环·120BPM' }),
within(optionPanel).getByRole('button', { name: '时长 2秒' }),
).toBeTruthy();
fireEvent.change(within(optionPanel).getByLabelText('BPM拖拉条'), {
target: { value: '88' },
});
expect(screen.getByLabelText('当前BPM').textContent).toBe('88');
expect(
screen.getByRole('button', { name: '音效参数 循环·88BPM' }),
within(optionPanel).getByRole('button', { name: '时长 10秒' }),
).toBeTruthy();
expect(within(optionPanel).queryByText(/BPM/u)).toBeNull();
fireEvent.click(within(optionPanel).getByRole('button', { name: '时长 8秒' }));
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
expect(
screen.getByRole('button', { name: '音效时长 8秒' }),
).toBeTruthy();
fireEvent.submit(panel);
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'audio-sound-effect',
soundType: 'loop',
soundTempo: 88,
soundDurationSeconds: 8,
}),
);
});
@@ -36,10 +36,17 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
calculateEditorBackgroundMusicPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
EDITOR_GENERATION_MUD_POINT_CONFIG,
DEFAULT_BACKGROUND_MUSIC_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
SOUND_EFFECT_DURATION_OPTIONS,
EDITOR_VIDEO_DURATION_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
resizeGenerationPlaceholderToVideoSelection,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
@@ -278,7 +285,11 @@ function ImageCanvasVideoGenerationComposerView({
(item) => item.value === dialog.videoModel,
) ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
const supportsReferences = isSeedanceVideoModel(currentModel.value);
const price = calculateEditorVideoPrice(resolution, durationSeconds);
const price = calculateEditorVideoPrice(
currentModel.value,
resolution,
durationSeconds,
);
const isGenerating = dialog.status === 'generating';
const closeVideoFloatingPanels = useCallback(() => {
setOpenPanel(null);
@@ -298,10 +309,10 @@ function ImageCanvasVideoGenerationComposerView({
const updateVideoDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'video'
? {
? resizeGenerationPlaceholderToVideoSelection({
...resetFailedVideoDialogStatus(currentDialog),
...patch,
}
})
: currentDialog,
);
};
@@ -613,35 +624,11 @@ function ImageCanvasVideoGenerationComposerView({
);
}
const SOUND_TYPE_OPTIONS = [
{ label: '单次', value: 'one-shot' },
{ label: '循环', value: 'loop' },
] as const;
const SOUND_BPM_MIN = 1;
const SOUND_BPM_MAX = 300;
const SOUND_BPM_SLIDER_FALLBACK = 120;
function clampSoundBpm(value: number) {
return Math.min(
SOUND_BPM_MAX,
Math.max(SOUND_BPM_MIN, Math.round(value)),
);
}
function parseSoundBpmInput(value: string) {
const normalizedValue = value.trim();
if (!normalizedValue) {
return null;
function normalizeSoundDuration(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? clampSoundBpm(parsedValue) : null;
}
function formatSoundEffectOptionLabel(
typeLabel: string,
soundTempo: number | null | undefined,
) {
return soundTempo ? `${typeLabel}·${soundTempo}BPM` : typeLabel;
return Math.min(10, Math.max(2, Math.round(value)));
}
function resetFailedAudioDialogStatus(dialog: GenerateDialogState) {
@@ -672,21 +659,20 @@ function ImageCanvasAudioGenerationComposerView({
}) {
const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false);
const soundOptionsButtonRef = useRef<HTMLButtonElement | null>(null);
const soundModelButtonRef = useRef<HTMLButtonElement | null>(null);
const isSoundEffect = dialog.mode === 'audio-sound-effect';
const isGenerating = dialog.status === 'generating';
const currentType =
SOUND_TYPE_OPTIONS.find((option) => option.value === dialog.soundType) ??
SOUND_TYPE_OPTIONS[0];
const currentTempo =
typeof dialog.soundTempo === 'number' ? dialog.soundTempo : null;
const soundOptionLabel = formatSoundEffectOptionLabel(
currentType.label,
currentTempo,
);
const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
const currentSoundModel =
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.find(
(option) => option.value === soundModel,
) ?? EDITOR_SOUND_EFFECT_MODEL_OPTIONS[0];
const soundDuration = normalizeSoundDuration(dialog.soundDurationSeconds);
const soundOptionLabel = `${soundDuration}`;
const dialogLabel = isSoundEffect ? '生成游戏音效' : '生成游戏背景音乐';
const cost = isSoundEffect
? EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect
: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic;
? calculateEditorSoundEffectPrice(soundModel)
: calculateEditorBackgroundMusicPrice(DEFAULT_BACKGROUND_MUSIC_MODEL);
useImageCanvasFloatingOptionDismiss({
isOpen: isSoundOptionsOpen,
@@ -705,8 +691,8 @@ function ImageCanvasAudioGenerationComposerView({
);
};
const updateSoundTempo = (nextTempo: number | null) => {
updateAudioDialog({ soundTempo: nextTempo });
const updateSoundDuration = (durationSeconds: number) => {
updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) });
};
return (
@@ -725,7 +711,7 @@ function ImageCanvasAudioGenerationComposerView({
>
<PlatformTextField
variant="textarea"
aria-label={isSoundEffect ? 'sound提示词' : 'gpt_description_prompt'}
aria-label={isSoundEffect ? 'prompt' : 'gpt_description_prompt'}
value={dialog.prompt}
disabled={isGenerating}
placeholder={
@@ -751,77 +737,64 @@ function ImageCanvasAudioGenerationComposerView({
) : null}
<div className="image-canvas-editor__generation-composer-footer">
{isSoundEffect ? (
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<>
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效参数 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
ref={soundModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`音效模型 ${currentSoundModel.label}`}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{currentSoundModel.label}</span>
</span>
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效参数选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_TYPE_OPTIONS.map((option) => (
<VideoOptionChoice
key={option.value}
selected={currentType.value === option.value}
disabled={isGenerating}
ariaLabel={option.label}
onClick={() =>
updateAudioDialog({ soundType: option.value })
}
>
{option.label}
</VideoOptionChoice>
))}
</div>
<div className="image-canvas-editor__audio-bpm-control">
<input
type="range"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? SOUND_BPM_SLIDER_FALLBACK}
disabled={isGenerating}
aria-label="BPM拖拉条"
onChange={(event) =>
updateSoundTempo(
clampSoundBpm(Number(event.target.value)),
)
}
/>
<input
type="number"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? ''}
placeholder="BPM"
disabled={isGenerating}
aria-label="BPM数值"
onChange={(event) =>
updateSoundTempo(
parseSoundBpmInput(event.target.value),
)
}
/>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_EFFECT_DURATION_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={soundDuration === option}
disabled={isGenerating}
ariaLabel={`时长 ${option}`}
onClick={() => updateSoundDuration(option)}
>
{option}
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
</>
) : null}
<PlatformActionButton
type="submit"
@@ -1009,6 +982,7 @@ export function ImageCanvasGenerationComposerView({
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitImageGeneration}
/>
) : null}

Some files were not shown because too many files have changed in this diff Show More