修复若干bug
This commit is contained in:
@@ -2400,6 +2400,6 @@
|
||||
## 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 兜底逻辑。
|
||||
- 决策:`/editor/canvas` 的 `生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt`、`model: "audio1.0"`、`duration` 和 `priceMudPoints`,面板只显示 `Vidu` 与 `2-10` 秒时长选项,默认 `5` 秒;不再展示 `type`、`tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交到 VectorEngine 时对内 `prompt` 同步映射为上游 body 的 `prompt` 与 `sound`,兼容 Apifox 文档和线上网关实际 `missing field sound` 校验;提交和轮询改走 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`。
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## Vidu 文生音频线上网关可能要求 sound 字段
|
||||
|
||||
- 现象:画板点击 `生成游戏音效` 后,请求返回 `Failed to deserialize the JSON body into the target type: missing field sound`。
|
||||
- 原因:VectorEngine Apifox `创建文生音频任务` 文档仍写 `/ent/v2/text2audio` 使用 `model + prompt + duration`,但线上 Vidu 网关曾按 `sound` 字段反序列化;只发送 `prompt` 会被上游拦截在 JSON 解析阶段。
|
||||
- 处理:前端和 BFF 对内继续使用用户语义更清晰的 `prompt`;`platform-audio` 转发到 VectorEngine Vidu 时同时发送 `prompt` 与 `sound`,两者值保持一致。不要把 UI 改回 Suno `task: "sound"`、`type`、`tempo` 或 BPM。
|
||||
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio` 中音效请求体测试必须同时断言 `prompt` 与 `sound`;必要时用线上生成音效 smoke 确认不再出现 `missing field sound`。
|
||||
- 关联:`server-rs/crates/platform-audio/src/request.rs`、`server-rs/crates/platform-audio/tests/vector_engine_audio.rs`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。
|
||||
|
||||
## Suno 任务完成不代表已经拿到 wav 下载地址
|
||||
|
||||
- 现象:画板生成背景音乐时,前端报 `音频生成尚未返回可下载地址(requestId:...)`;画板生成音效时,前端可能报 `获取 Suno 音效 wav 失败(requestId:...)`。上游任务可能已经完成,但 wav 下载地址还没就绪。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
|
||||
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
|
||||
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
|
||||
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
|
||||
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
|
||||
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
|
||||
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。
|
||||
|
||||
@@ -117,12 +117,9 @@
|
||||
## 第一版计费配置
|
||||
|
||||
```text
|
||||
生成图片:12 泥点
|
||||
生成规范:5 泥点
|
||||
生成角色形象:12 泥点
|
||||
生成图标素材:12 泥点
|
||||
生成UI设计图:12 泥点
|
||||
生成视频:480p 每秒 10 泥点,720p 每秒 20 泥点
|
||||
生成图片 / 生成角色形象 / 生成图标素材 / 生成UI设计图 / 宣发素材 / 快速编辑 / 重绘:nanobanana2 为 12 泥点,gpt-image-2 为 20 泥点
|
||||
生成规范:固定 gpt-image-2,5 泥点
|
||||
生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20;seedance2.0 为 480p 每秒 12 / 720p 每秒 24;kling3.0 为 480p 每秒 15 / 720p 每秒 30;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40
|
||||
角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点
|
||||
生成音效:10 泥点
|
||||
生成背景音乐:5 泥点
|
||||
@@ -130,9 +127,9 @@
|
||||
|
||||
当前必须显式覆盖的正式模型定价配置:
|
||||
|
||||
- 图片类:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)、`gpt-image-2`。
|
||||
- 图片类:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)为 12 泥点;`gpt-image-2` 为 20 泥点。
|
||||
- 规范:`gpt-image-2`。
|
||||
- 视频:`seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`;兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留定价配置,但前端模型菜单不展示。
|
||||
- 视频:`seedance2.0-fast` 为 10 / 20 泥点每秒;`seedance2.0` 为 12 / 24 泥点每秒;`kling3.0` 为 15 / 30 泥点每秒;`kling3.0-omni` 为 20 / 40 泥点每秒。兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留 10 / 20 泥点每秒定价配置,但前端模型菜单不展示。
|
||||
- 角色动画:`seedance2.0-fast`。
|
||||
- 音效:`audio1.0`。
|
||||
- 背景音乐:`chirp-v5`。
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
|
||||
### 生成游戏音效
|
||||
|
||||
- `prompt`:用户输入的音效提示词,直接作为 Vidu 文生音频请求的 `prompt` 字段。
|
||||
- `prompt`:用户输入的音效提示词。前端与 BFF 对内仍使用 `prompt`,提交到 VectorEngine Vidu 时同时写入 `prompt` 与 `sound` 同值,兼容线上网关对 `sound` 字段的反序列化要求。
|
||||
- `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` 文生音效契约。
|
||||
- 提交到 VectorEngine 时映射为 Vidu 文生音频字段:`model: "audio1.0"`、`prompt`、`sound: prompt`、`duration`、可选 `seed`。当前编辑器音效固定使用 Vidu `audio1.0`,不再走 Suno `task: "sound"` / `metadata_params` 文生音效契约。
|
||||
|
||||
### 生成游戏背景音乐
|
||||
|
||||
@@ -42,8 +42,9 @@
|
||||
- 新增素材类型:
|
||||
- `assetKind="sound-effect"`
|
||||
- `assetKind="background-music"`
|
||||
- 音频结果以小型音频卡加入画布,卡片底部使用 `<audio controls>` 播放;音频结果当前只保存为画布资源,不进入图片素材库。
|
||||
- 音频结果卡片底部显示用户提示词与播放控件,不在卡片左下角或悬停左上角展示时长。
|
||||
- 音频结果以小型音频卡加入画布,卡片底部使用融入卡片的自定义播放器组件承载进度、时间和音量,播放 / 暂停只收口到卡片中央图标按钮;音频结果当前只保存为画布资源,不进入图片素材库。
|
||||
- 音频结果卡片底部显示用户提示词与播放器辅助控件,不在卡片左下角或悬停左上角展示时长。
|
||||
- 音频结果卡片的提示词固定显示在卡片左上角;底部播放器辅助控件仅在鼠标悬停音频卡片时从下方滑入显示,移出后向下滑出收起。
|
||||
- 音频卡片中央播放区按状态切换:未悬停且未播放时显示音效 / 背景音乐图标,悬停且未播放时显示播放按钮,播放中始终显示暂停按钮。
|
||||
- generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `<audio>` 裸请求。
|
||||
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。
|
||||
@@ -100,7 +101,7 @@ 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 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"`、`prompt`、`duration` 和可选 `seed`;`model` 和 `prompt` 为必填,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
|
||||
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"`、`prompt`、`sound: prompt`、`duration` 和可选 `seed`;`model` 和 `prompt` 为文档必填,`sound` 用于兼容线上网关实际校验,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
|
||||
- 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`;Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露。
|
||||
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路,避免误伤 `/api/editor/audios/background-music/generations`。
|
||||
- VectorEngine 音频响应的 `code` 需要兼容 `"success"`、`"ok"`、`"0"`、`"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
|
||||
@@ -115,11 +116,12 @@ POST /api/editor/audios/background-music/generations
|
||||
- 点击 `生成音乐` 只出现选项框,不立刻创建占位。
|
||||
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `prompt`;底部左侧只有一个无标题时长参数按钮,右侧为固定 `Vidu` 模型胶囊和生成按钮。
|
||||
- 音效时长只提供 `2-10` 秒选项,默认 `5` 秒,提交到 BFF 的字段为 `duration`。
|
||||
- 音效面板模型显示 `Vidu`,提交 `model: "audio1.0"`;不显示 Suno 文生音效模型或 Suno 音效入口。
|
||||
- 音效面板模型显示 `Vidu`,提交 `model: "audio1.0"`;后端转发到 Vidu 时同时携带 `prompt` 和 `sound`;不显示 Suno 文生音效模型或 Suno 音效入口。
|
||||
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,右侧固定显示 `Suno` 模型胶囊,不展示 `make_instrumental`。
|
||||
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`。
|
||||
- 成功后画布新增音频卡,能通过 `<audio controls>` 播放。
|
||||
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,播放控件固定在卡片底部。
|
||||
- 成功后画布新增音频卡,能通过卡片中央播放按钮播放,底部进度、时间和音量控件可操作。
|
||||
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,不在底部重复显示播放 / 暂停按钮,播放器辅助控件直接融入卡片底部。
|
||||
- 鼠标未悬停音频卡时底部播放器辅助控件不可见,悬停时从底部滑动显现;提示词位于卡片左上角。
|
||||
- 音频卡片、悬停角标和待生成占位不显示时长;信息弹窗显示时长。
|
||||
- 音频素材浮动工具栏只显示 `改造` 与 `下载按钮`,`改造` 复用对应生成面板且没有参考图组件。
|
||||
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
|
||||
|
||||
@@ -4784,7 +4784,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 50,
|
||||
price_mud_points: 100,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
@@ -4796,7 +4796,7 @@ mod tests {
|
||||
assert_eq!(normalized.provider_model, EDITOR_VIDEO_MODEL_KLING_3_OMNI);
|
||||
assert_eq!(normalized.width, 854);
|
||||
assert_eq!(normalized.height, 480);
|
||||
assert_eq!(normalized.price_mud_points, 50);
|
||||
assert_eq!(normalized.price_mud_points, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4831,7 +4831,7 @@ mod tests {
|
||||
resolution: "720p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 80,
|
||||
price_mud_points: 96,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
@@ -4983,7 +4983,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 40,
|
||||
price_mud_points: 48,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: vec!["data:audio/wav;base64,audio".to_string()],
|
||||
|
||||
@@ -7,6 +7,7 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
|
||||
const EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS: u32 = 12;
|
||||
const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 20;
|
||||
const EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 5;
|
||||
|
||||
pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
|
||||
@@ -29,8 +30,8 @@ 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)
|
||||
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_MUD_POINTS,
|
||||
Some(EDITOR_IMAGE_MODEL_NANOBANANA2)
|
||||
| Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS)
|
||||
| Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => {
|
||||
EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS
|
||||
@@ -60,7 +61,7 @@ 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()) {
|
||||
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
|
||||
@@ -71,12 +72,16 @@ fn editor_video_model_resolution_mud_points_per_second(
|
||||
) => 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
|
||||
let (rate_480p, rate_720p) = match normalized_model {
|
||||
EDITOR_VIDEO_MODEL_SEEDANCE_2 => (12, 24),
|
||||
EDITOR_VIDEO_MODEL_KLING_3 => (15, 30),
|
||||
EDITOR_VIDEO_MODEL_KLING_3_OMNI => (20, 40),
|
||||
_ => (
|
||||
EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND,
|
||||
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND,
|
||||
),
|
||||
};
|
||||
per_second
|
||||
if resolution == "720p" { rate_720p } else { rate_480p }
|
||||
}
|
||||
|
||||
pub(crate) fn editor_video_model_generation_mud_points(
|
||||
@@ -157,15 +162,15 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
editor_video_model_generation_mud_points(Some("seedance2.0"), "720p", 5),
|
||||
100
|
||||
120
|
||||
);
|
||||
assert_eq!(
|
||||
editor_video_model_generation_mud_points(Some("kling3.0"), "480p", 5),
|
||||
50
|
||||
75
|
||||
);
|
||||
assert_eq!(
|
||||
editor_video_model_generation_mud_points(Some("kling3.0-omni"), "720p", 4),
|
||||
80
|
||||
160
|
||||
);
|
||||
assert_eq!(
|
||||
editor_video_model_generation_mud_points(Some("veo3.1"), "480p", 4),
|
||||
@@ -219,7 +224,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
editor_image_generation_mud_points(Some("publication-material"), Some("gpt-image-2")),
|
||||
12
|
||||
20
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2068,8 +2068,8 @@ mod tests {
|
||||
12
|
||||
);
|
||||
assert_eq!(
|
||||
validate_editor_icon_spritesheet_price(12, Some("gpt-image-2")).unwrap(),
|
||||
12
|
||||
validate_editor_icon_spritesheet_price(20, Some("gpt-image-2")).unwrap(),
|
||||
20
|
||||
);
|
||||
let error = validate_editor_icon_spritesheet_price(5, Some("gpt-image-2"))
|
||||
.expect_err("wrong icon spritesheet price should fail");
|
||||
|
||||
@@ -47,7 +47,10 @@ pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result<V
|
||||
"model".to_string(),
|
||||
Value::String(VIDU_AUDIO_MODEL.to_string()),
|
||||
),
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("prompt".to_string(), Value::String(prompt.clone())),
|
||||
// 中文注释:VectorEngine Apifox 当前仍写 prompt,但线上 Vidu 网关曾返回
|
||||
// missing field sound;同时发送 sound 作为同义字段,兼容网关实际反序列化。
|
||||
("sound".to_string(), Value::String(prompt)),
|
||||
("duration".to_string(), json!(request.duration)),
|
||||
]);
|
||||
if let Some(seed) = request.seed {
|
||||
@@ -89,7 +92,9 @@ pub fn build_editor_sound_effect_task_body(
|
||||
}
|
||||
let mut body = Map::from_iter([
|
||||
("model".to_string(), Value::String(model)),
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("prompt".to_string(), Value::String(prompt.clone())),
|
||||
// 中文注释:同上,编辑器音效也必须带 sound,避免上游按 sound 字段解析时报缺字段。
|
||||
("sound".to_string(), Value::String(prompt)),
|
||||
("duration".to_string(), json!(request.duration)),
|
||||
]);
|
||||
if let Some(seed) = request.seed {
|
||||
|
||||
@@ -142,6 +142,7 @@ fn vidu_sound_effect_request_body_uses_text2audio_contract() {
|
||||
assert_eq!(AudioTaskKind::SoundEffect.provider(), "vector-engine-vidu");
|
||||
assert_eq!(body["model"], VIDU_AUDIO_MODEL);
|
||||
assert_eq!(body["prompt"], "金币掉落叮当声");
|
||||
assert_eq!(body["sound"], "金币掉落叮当声");
|
||||
assert_eq!(body["duration"], 5);
|
||||
assert!(body.get("mv").is_none());
|
||||
assert!(body.get("task").is_none());
|
||||
@@ -182,6 +183,7 @@ fn editor_sound_effect_request_body_uses_vidu_text2audio_contract() {
|
||||
|
||||
assert_eq!(body["model"], VIDU_AUDIO_MODEL);
|
||||
assert_eq!(body["prompt"], "金币掉落叮当声");
|
||||
assert_eq!(body["sound"], "金币掉落叮当声");
|
||||
assert_eq!(body["duration"], 7);
|
||||
assert_eq!(body["seed"], 42);
|
||||
assert!(body.get("mv").is_none());
|
||||
|
||||
@@ -1040,7 +1040,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 50,
|
||||
price_mud_points: 100,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
@@ -1049,7 +1049,7 @@ mod tests {
|
||||
|
||||
assert_eq!(payload["aspectRatio"], json!("16:9"));
|
||||
assert_eq!(payload["durationSeconds"], json!(5));
|
||||
assert_eq!(payload["priceMudPoints"], json!(50));
|
||||
assert_eq!(payload["priceMudPoints"], json!(100));
|
||||
assert_eq!(payload["model"], json!("kling3.0-omni"));
|
||||
}
|
||||
|
||||
@@ -1101,7 +1101,7 @@ mod tests {
|
||||
task_id: "task-1".to_string(),
|
||||
duration_seconds: 5,
|
||||
resolution: "480p".to_string(),
|
||||
price_mud_points: 50,
|
||||
price_mud_points: 100,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
const submitButton = within(panel).getByRole('button', { name: '生成' });
|
||||
expect(submitButton.textContent).toBe('生成12泥点');
|
||||
expect(submitButton.textContent).toBe('生成20泥点');
|
||||
});
|
||||
|
||||
it('clicks the parent generation panel to collapse an opened option panel', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
|
||||
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
|
||||
|
||||
function createDialog(
|
||||
patch: Partial<GenerateDialogState> = {},
|
||||
@@ -136,6 +137,33 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the submit price when switching the character image model', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<CharacterGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
imageModel: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
})}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成12泥点',
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
|
||||
expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成20泥点',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes character reference menus to canvas picking, spec creation and upload', () => {
|
||||
const openSpecDialog = vi.fn();
|
||||
const requestUpload = vi.fn();
|
||||
|
||||
@@ -1095,7 +1095,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'icon-gpt-model-1',
|
||||
priceMudPoints: 12,
|
||||
priceMudPoints: 20,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await screen.findByAltText('画布图片:拼图素材');
|
||||
@@ -1161,6 +1161,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
priceMudPoints: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -128,6 +128,31 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates manually selected publication material tags from saved layout', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-publication',
|
||||
resourceId: 'resource-publication',
|
||||
title: '宣发图',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
zIndex: 5,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'publication-material',
|
||||
},
|
||||
new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: 'layer-publication',
|
||||
assetKind: 'publication-material',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
|
||||
@@ -666,6 +666,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
||||
value === 'icon' ||
|
||||
value === 'icon-spritesheet' ||
|
||||
value === 'icon-spec' ||
|
||||
value === 'publication-material' ||
|
||||
value === 'ui-design' ||
|
||||
value === 'video' ||
|
||||
value === 'sound-effect' ||
|
||||
|
||||
@@ -159,6 +159,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onLayerMouseEnter: vi.fn(),
|
||||
onLayerMouseLeave: vi.fn(),
|
||||
onOpenLayerMetadata: vi.fn(),
|
||||
onUpdateLayerAssetKind: vi.fn(),
|
||||
onGenerationFramePointerDown: vi.fn(),
|
||||
onActivateGenerationDialog: vi.fn(),
|
||||
onCropExpandHandlePointerDown: vi.fn(),
|
||||
|
||||
@@ -1031,6 +1031,37 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('saves manually selected layer tags in the canvas layout', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: '拼图素材',
|
||||
assetKind: 'character',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers Lovart-style zoom menu commands', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasAssetKind,
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -734,6 +735,30 @@ export function ImageCanvasEditorView() {
|
||||
setPendingGenerationDeleteDialog(null);
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
}, [pendingGenerationDeleteDialog, removeCanvasGenerationDialog]);
|
||||
const updateLayerAssetKind = useCallback(
|
||||
(layerId: string, assetKind: CanvasAssetKind | null) => {
|
||||
const targetLayer = layersRef.current.find((layer) => layer.id === layerId);
|
||||
if (!targetLayer || targetLayer.assetKind === assetKind) {
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory();
|
||||
const nextLayer = {
|
||||
...targetLayer,
|
||||
assetKind,
|
||||
};
|
||||
setLayers((currentLayers) =>
|
||||
currentLayers.map((layer) =>
|
||||
layer.id === layerId ? { ...layer, assetKind } : layer,
|
||||
),
|
||||
);
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layerId ? nextLayer : currentLayer,
|
||||
);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[captureCanvasHistory],
|
||||
);
|
||||
|
||||
useImageCanvasKeyboardShortcuts({
|
||||
generateDialogRef,
|
||||
@@ -967,6 +992,7 @@ export function ImageCanvasEditorView() {
|
||||
setMetadataLayer(layer);
|
||||
selectSingleLayer(layer.id);
|
||||
},
|
||||
onUpdateLayerAssetKind: updateLayerAssetKind,
|
||||
onGenerationFramePointerDown: handleGenerationFramePointerDown,
|
||||
onActivateGenerationDialog: activateCanvasGenerationDialog,
|
||||
onCropExpandHandlePointerDown:
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成12泥点',
|
||||
'生成20泥点',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -485,6 +485,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0');
|
||||
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成150泥点',
|
||||
);
|
||||
});
|
||||
|
||||
it('生成游戏音效面板使用 Vidu duration 参数且不再显示类型和 BPM', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import {
|
||||
calculateEditorImageGenerationPrice,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
@@ -29,7 +30,7 @@ function ImageOptionsHarness({
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={12}
|
||||
cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel })}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成角色形象"
|
||||
/>
|
||||
@@ -160,6 +161,9 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成角色形象' }).textContent).toBe(
|
||||
'生成20泥点',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders option menus and shows mud point text in the submit button', () => {
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
|
||||
);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(20);
|
||||
expect(
|
||||
calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
@@ -111,8 +112,11 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '480p', 4)).toBe(
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3]['480p'] * 4,
|
||||
);
|
||||
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '720p', 5)).toBe(
|
||||
150,
|
||||
);
|
||||
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe(
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI]['720p'] * 5,
|
||||
200,
|
||||
);
|
||||
expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6))
|
||||
.toBe(
|
||||
|
||||
@@ -83,7 +83,8 @@ const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
|
||||
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
|
||||
// 中文注释:gpt-image-2 作为更高成本图片模型独立计费,避免切换模型后按钮价格不变。
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: 20,
|
||||
} as const;
|
||||
export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec,
|
||||
@@ -284,11 +285,23 @@ const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'],
|
||||
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'],
|
||||
} as const;
|
||||
const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': 12,
|
||||
'720p': 24,
|
||||
} as const;
|
||||
const EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': 15,
|
||||
'720p': 30,
|
||||
} as const;
|
||||
const EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': 20,
|
||||
'720p': 40,
|
||||
} as const;
|
||||
export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
|
||||
[VIDEO_MODEL_SEEDANCE_2_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG,
|
||||
// 中文注释:前端不展示 Veo 入口,但类型与后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
|
||||
[VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
|
||||
@@ -186,6 +186,9 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K');
|
||||
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成20泥点',
|
||||
);
|
||||
});
|
||||
|
||||
it('disables generation controls and renders failure state', () => {
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成12泥点',
|
||||
'生成20泥点',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user