对齐编辑器生成占位与面板参数
统一画布生成占位图标,按图片、规范、角色、图标、UI、视频、音频、宣发和快速编辑区分。 让图片、规范、宣发和视频新建占位按面板尺寸或固定输出尺寸创建,并在参数变化时同步占位大小。 完善快速编辑、音频生成、宣发素材和生成类面板相关契约与测试。 更新生成类面板 Lovart 改造文档和项目决策记录。
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user