修复若干bug

This commit is contained in:
2026-06-19 14:18:56 +08:00
parent 23a6662c62
commit cc3346758c
32 changed files with 722 additions and 156 deletions
@@ -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