Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library
This commit is contained in:
@@ -2555,6 +2555,74 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"canvasCompletion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorCanvasGenerationCompletion"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "带项目上下文生成时,服务端据此直接写入画布完成态并返回最新项目快照。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorCanvasGenerationCompletion": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"dialogId",
|
||||
"title",
|
||||
"placeholder"
|
||||
],
|
||||
"properties": {
|
||||
"dialogId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"placeholder": {
|
||||
"$ref": "#/components/schemas/EditorCanvasGenerationPlaceholder"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorCanvasGenerationPlaceholder": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"originalWidth",
|
||||
"originalHeight"
|
||||
],
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number"
|
||||
},
|
||||
"y": {
|
||||
"type": "number"
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"originalWidth": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"originalHeight": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2687,6 +2755,17 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"project": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorProject"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-21 图片画布生成完成态由后端写入画布布局
|
||||
|
||||
- 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。
|
||||
- 决策:图片生成请求在有项目上下文时携带 `canvasCompletion`(生成器 `dialogId`、结果标题和占位框);`api-server` 在生成成功并创建 `editor_project_resource` / `editor_asset` 后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层,把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存 layout 后返回最新项目快照。前端只应用后端快照刷新显示,不再把生成完成态作为正式业务真相,也不在项目加载时根据资源行推断完成态。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、图片画布生成提交工作流、项目快照 hydrate / persistence、图片画布技术方案和排障记录。
|
||||
- 验证方式:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-21 图片画布参考图元数据只保存项目内行引用
|
||||
|
||||
- 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。
|
||||
@@ -59,9 +67,9 @@
|
||||
## 2026-06-18 `/creation` 独立为陶泥儿创作工具主页
|
||||
|
||||
- 背景:图片画布项目已经成为独立项目资产,旧“创作”站内 Tab 和一级“草稿”入口不能清晰表达桌面端创作工具主页与项目管理入口。
|
||||
- 决策:桌面端新增独立 `/creation` 创作工具主页;顶级“创作”入口跳转 `/creation`,原“草稿”入口替换为“项目”并跳转 `/project`。移动端隐藏“创作”和“项目”入口,但不拦截直接 URL。页面文案统一使用“陶泥儿”,外部品牌只作为设计参考,不进入用户可见 UI、测试名、产品文案或验收口径。`陶泥儿精选` 是用户素材瀑布流,不展示玩法入口列表;创作入口事实源继续来自 `/api/creation-entry/config`,项目入口通过 `createEditorProject` 和 `/editor/canvas?projectid=xxx` 链路进入画布。
|
||||
- 决策:桌面端新增独立 `/creation` 创作工具主页,桌面端直接打开站点首页 `/` 时也默认展示新版创作主页;顶级“创作”入口跳转 `/creation`,原“草稿”入口替换为“项目”并跳转 `/project`。移动端首页 `/` 继续保持原推荐首页,移动端隐藏“创作”和“项目”入口,但不拦截直接 URL。页面文案统一使用“陶泥儿”,外部品牌只作为设计参考,不进入用户可见 UI、测试名、产品文案或验收口径。`陶泥儿精选` 是用户素材瀑布流,不展示玩法入口列表;创作入口事实源继续来自 `/api/creation-entry/config`,项目入口通过 `createEditorProject` 和 `/editor/canvas?projectid=xxx` 链路进入画布。
|
||||
- 影响范围:平台入口导航、`SelectionStage` 路由、`/creation` 首页、`/project` 项目入口、`/editor/canvas` 项目打开链路、“我的”页快捷入口和创作入口相关测试。
|
||||
- 验证方式:`/creation` 解析为创作主页,`/creation/<play>` 仍进入对应玩法工作台;桌面导航显示“创作 / 项目”且不显示“草稿”;移动端底部不显示“创作 / 项目”;页面不出现外站品牌或 `Discord` 字样;新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- 验证方式:桌面 `/` 初始阶段和 `/creation` 解析为创作主页,移动端 `/` 仍是推荐首页,`/creation/<play>` 仍进入对应玩法工作台;桌面导航显示“创作 / 项目”且不显示“草稿”;移动端底部不显示“创作 / 项目”;页面不出现外站品牌或 `Discord` 字样;新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界
|
||||
@@ -72,6 +80,14 @@
|
||||
- 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。
|
||||
|
||||
## 2026-06-21 图片画布生成视频参数扩展
|
||||
|
||||
- 背景:编辑器画布生成视频需要开放更多 Lovart 式参数,同时保留模型能力边界;`seedance2.0-fast` 不支持 `1080p`,联网搜索暂没有可确认的 Ark 视频生成 body 字段。
|
||||
- 决策:生成视频参数面板支持比例 `16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 21:9`,时长为 4 到 15 秒整数 slider,清晰度支持 `480p / 720p / 1080p`;`seedance2.0-fast` 不展示可用 `1080p`,从其它模型的 `1080p` 切回 Fast 时自动降到 `720p`,后端也拒绝 `seedance2.0-fast + 1080p`。静音只作为一个 toggle 展示,默认有声并映射 `sound=on` / Ark `generate_audio=true`;关闭静音时传 `sound=off` / `generate_audio=false`。`webSearchEnabled` 默认随请求提交为 `true`,但前端不展示联网搜索开关,后端当前只接收契约字段,不向 Ark 透传未知参数。
|
||||
- 影响范围:图片画布生成视频面板、生成提交模型、画布项目快照恢复、`editorProjectClient`、`shared-contracts`、`api-server` 编辑器视频 BFF、编辑器技术文档。
|
||||
- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts`、`cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 2026-06-18 图片画布生成音乐入口作为音频图层接入
|
||||
|
||||
- 背景:图片画布底部生成工具需要补齐游戏音效和游戏背景音乐生成,既要复用现有 Lovart 式画布生成器快照、占位避让和持久化,又不能把音频能力并入图片素材库或视觉小说专用音频开关。
|
||||
|
||||
@@ -87,6 +87,22 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"`;`cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`server-rs/crates/api-server/src/modules/play_flow.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 图片编辑器角色动画抽帧不要采到视频尾点
|
||||
|
||||
- 现象:画板角色图点击 `生成动画` 后,Ark 视频已生成并上传 OSS,但后端返回 `ffmpeg 已执行但未产出动作帧文件(requestId:...)`。
|
||||
- 原因:FFmpeg 在 `-ss` 采样时间落到视频尾点附近时可能退出码仍为 `0`,但实际输出 `0` 帧;如果后端按 `duration - 0.001` 抽最后一帧,低帧率或短视频很容易踩到不可解码尾点。
|
||||
- 处理:角色动画抽帧按目标帧数预留一个采样步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,不要采 `3.999s`;`ffmpeg` 返回成功但无输出文件时,错误 details 保留 `targetSeconds`、`stdout`、`stderr` 和输出路径,用户主文案保持简短。
|
||||
- 验证:`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`,其中 `editor_character_animation_extracts_final_sample_from_short_video` 应覆盖本机 FFmpeg 8 的 0 帧回归。
|
||||
- 关联:`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 图片编辑器生成长请求完成态必须由后端写入画布
|
||||
|
||||
- 现象:画板角色形象等生成请求已经在服务端返回 `200`,OSS 中也已有 `generated-character-drafts/.../image.png`,但用户刷新或页面重载后仍看到旧生成卡片停在“生成中”。
|
||||
- 原因:生成是一次长 HTTP 请求,浏览器在请求完成前刷新或重新挂载时会丢失原页面的成功回调;如果完成态只靠前端回调把结果图层写回 `editor_canvas.layers_json`,服务端虽然已经创建 `editor_project_resource` / `editor_asset`,但布局里的 `generation-dialog` 仍可能停在 `status="generating"` 且没有 `generatedLayerId`。如果之后从素材库把同一私有素材加回画布,前端再次创建项目资源时若提交 signed URL / Data URL,还会触发 `413`,进一步阻断资源行绑定。
|
||||
- 处理:图片生成提交必须在有项目上下文时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框);`api-server` 生成成功并创建资源后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层、把生成器改回 `idle` 并写入 `generatedLayerId`,再沿用后端当前 viewport 保存 layout 并返回最新项目快照。前端只应用该快照刷新显示,不在加载时根据资源行推断完成态;有项目上下文但后端没有返回快照时也不得本地补结果图层。为已有 `objectKey` 的图层创建项目资源时,`imageSrc` 只提交 `/<objectKey>`,不要提交 signed URL / Data URL。
|
||||
- 验证:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml` 覆盖后端完成态写 layout;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 覆盖前端提交 `canvasCompletion`、应用后端快照、项目加载不推断完成态和 `objectKey` 资源创建不提交大 URL。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL
|
||||
|
||||
- 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@
|
||||
## 目标
|
||||
|
||||
- 新增桌面端独立 `/creation` 创作工具主页。
|
||||
- 桌面端直接打开站点首页 `/` 时默认展示新版创作主页内容;移动端继续保持原推荐首页。
|
||||
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。
|
||||
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口。
|
||||
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
@@ -82,7 +83,14 @@ Tab:
|
||||
- UI:每个列表项展示游戏 UI 图和该 UI 图拆出的子素材组合。
|
||||
- 音乐:展示背景音乐和音效。
|
||||
- 美宣:展示美宣类图片和视频素材。
|
||||
- 特效、场景、游戏:v1 只在有真实数据时展示,无数据时不造假卡片。
|
||||
- 特效、场景、游戏:v1 暂不实装生成链路,只在有真实数据时展示,无数据时不造假卡片,也不在 UI 中写“暂不实装”说明。
|
||||
|
||||
交互口径:
|
||||
|
||||
- 点击任一精选列表项后打开接近全屏的素材预览弹窗。
|
||||
- 弹窗中部展示当前素材,图片使用 contain 完整展示,视频和音频展示可播放控件。
|
||||
- 弹窗底部固定显示当前组合的缩略图导航,点击缩略图切换当前素材。
|
||||
- 弹窗底部同时展示该列表项的提示词、作者名和生成总成本。
|
||||
|
||||
每个列表项需要标注:
|
||||
|
||||
@@ -112,6 +120,7 @@ Tab:
|
||||
|
||||
- 新增 `SelectionStage = 'creation-home'`。
|
||||
- `/creation` 映射到 `creation-home`。
|
||||
- 桌面端初始打开 `/` 时也进入 `creation-home`;移动端初始打开 `/` 仍进入 `platform` 推荐首页。
|
||||
- `/creation/<play>` 现有玩法工作台路由保持原有 stage 映射。
|
||||
- 桌面端点击“创作”时调用 `pushAppHistoryPath('/creation')`。
|
||||
- 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`。
|
||||
@@ -124,6 +133,7 @@ Tab:
|
||||
### 路由与导航
|
||||
|
||||
- 增加 `/creation` 路由映射和测试。
|
||||
- 增加桌面 `/` 初始阶段映射测试,确认不会影响移动端首页。
|
||||
- 调整桌面导航文案与点击行为。
|
||||
- 移动端导航移除“创作”和“项目”入口。
|
||||
- 保持现有 `/creation/<play>` 工作台、生成页、结果页和运行态路由不回归。
|
||||
@@ -172,6 +182,7 @@ git diff --check
|
||||
|
||||
浏览器回归:
|
||||
|
||||
- 桌面打开站点首页 `/`,首屏显示新版创作主页。
|
||||
- 桌面打开首页,点击“创作”进入 `/creation`。
|
||||
- `/creation` 显示 `陶泥儿 - 开启全民精品游戏创作` 和 `陶泥儿精选`。
|
||||
- 页面不出现外站品牌或 `Discord` 字样。
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
|
||||
- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。
|
||||
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换到 `720p` 后同步为 `1280 x 720`;调整参数时保持占位中心点不变。
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
|
||||
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
|
||||
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
```text
|
||||
生成图片 / 生成角色形象 / 生成图标素材 / 生成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
|
||||
生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20,不支持 1080p;seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80
|
||||
角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点
|
||||
生成音效:10 泥点
|
||||
生成背景音乐:5 泥点
|
||||
@@ -139,7 +139,7 @@
|
||||
## 生成视频模型与接口
|
||||
|
||||
- 底部 `生成视频` 面板提交到 `POST /api/editor/videos/generations`。
|
||||
- 固定 `16:9`、静音、标准模式;结果作为 `mediaType="video"`、`assetKind="video"` 的视频图层加入画布,并在画布中使用原生 `<video>` 播放控件渲染,不再走图片元素。
|
||||
- 比例可选 `16:9`、`9:16`、`1:1`、`4:3`、`3:4`、`21:9`;时长为 4 到 15 秒整数 slider;清晰度支持 `480p`、`720p`、`1080p`,但 `seedance2.0-fast` 不支持 `1080p`。静音为单个 toggle,默认有声;`webSearchEnabled` 默认提交为 `true` 但不在前端显示,后端当前不透传未知 Ark 字段。结果作为 `mediaType="video"`、`assetKind="video"` 的视频图层加入画布,并在画布中使用原生 `<video>` 播放控件渲染,不再走图片元素。
|
||||
- 支持模型:
|
||||
- `seedance2.0-fast`
|
||||
- `seedance2.0`
|
||||
@@ -159,7 +159,7 @@
|
||||
- 参考音频支持 URL / Base64 / `asset://` / 画板资源路径;格式为 wav、mp3,单个文件不超过 15MB,单个时长 [2, 15] 秒,最多 3 段且总时长不超过 15 秒。
|
||||
- 前端上传参考视频 / 参考音频必须走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 入库;前端状态保存 signed URL 供预览,同时保存 `objectKey` / `assetObjectId`,提交生成时优先使用 `objectKey`。
|
||||
- 后端接收画板资源 `objectKey` 后统一重新签名为 Ark 可读 URL,再按文档构造 `content`:`image_url` + `role=reference_image`、`video_url` + `role=reference_video`、`audio_url` + `role=reference_audio`。
|
||||
- 画板生成视频当前固定无声,因此 Ark 请求必须显式带 `generate_audio:false`,不能只依赖前端 `sound=off` 或上游默认值。
|
||||
- 画板生成视频按静音 toggle 映射 Ark `generate_audio`:`sound=on` 时传 `true`,`sound=off` 时传 `false`,不能只依赖上游默认值。
|
||||
- Seedance 2.0 文档提示不支持直接上传含真人人脸的参考图 / 视频;当前画板尚未做真人脸授权证明、来源声明或服务端人脸拦截,后续开放真人素材前必须补授权/来源确认链路。
|
||||
|
||||
## 验收
|
||||
@@ -188,4 +188,4 @@
|
||||
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
|
||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
|
||||
- Seedance 2.0 参考视频 / 音频上传不产生 `data:video/*` / 大体积音频 JSON;提交请求优先携带 `objectKey`,后端 Ark body 含 `generate_audio:false` 且不含未确认的 `mode` 字段。
|
||||
- Seedance 2.0 参考视频 / 音频上传不产生 `data:video/*` / 大体积音频 JSON;提交请求优先携带 `objectKey`,后端 Ark body 按 `sound` 映射 `generate_audio`,且不含未确认的 `mode` 字段。
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
### 抽帧与 OSS 存储
|
||||
|
||||
- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。
|
||||
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
|
||||
- 每帧必须执行绿幕去背,输出透明背景 PNG。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端回填角色动作结果时,必须以 `previewVideoPath` 创建 `mediaType: "video"`、`assetKind: "character-animation"` 图层,复用视频素材的 `<video controls preload="metadata" playsInline>` 预览和视频下载 / 导出口径;不得把 `frames[0].imageSrc` 作为图层主 `src`。
|
||||
|
||||
@@ -2504,8 +2504,11 @@ fn normalize_editor_video_request(
|
||||
if payload.mode.trim() != "std" {
|
||||
return Err(editor_video_bad_request("mode 只支持 std。"));
|
||||
}
|
||||
if payload.sound.trim() != "off" {
|
||||
return Err(editor_video_bad_request("sound 只支持 off。"));
|
||||
let generate_audio = normalize_editor_video_sound(payload.sound.as_str())?;
|
||||
if model == EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST && resolution == "1080p" {
|
||||
return Err(editor_video_bad_request(
|
||||
"seedance2.0-fast 不支持 1080p 清晰度。",
|
||||
));
|
||||
}
|
||||
let expected_price = crate::editor_generation_config::editor_video_model_generation_mud_points(
|
||||
Some(model),
|
||||
@@ -2532,6 +2535,7 @@ fn normalize_editor_video_request(
|
||||
aspect_ratio: aspect_ratio.to_string(),
|
||||
resolution: resolution.to_string(),
|
||||
duration_seconds,
|
||||
generate_audio,
|
||||
price_mud_points: expected_price,
|
||||
width,
|
||||
height,
|
||||
@@ -2552,8 +2556,7 @@ fn build_editor_video_ark_request_body(
|
||||
"resolution": request.resolution,
|
||||
"ratio": request.aspect_ratio,
|
||||
"duration": request.duration_seconds,
|
||||
// 中文注释:Seedance 2.0 文档里声音生成默认为开启;画板当前视频入口固定无声,必须显式关闭。
|
||||
"generate_audio": false,
|
||||
"generate_audio": request.generate_audio,
|
||||
"watermark": false,
|
||||
}))
|
||||
}
|
||||
@@ -2821,7 +2824,14 @@ fn resolve_editor_video_provider_model(model: &str) -> &str {
|
||||
fn normalize_editor_video_aspect_ratio(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"16:9" => Ok("16:9"),
|
||||
_ => Err(editor_video_bad_request("aspectRatio 只支持 16:9。")),
|
||||
"9:16" => Ok("9:16"),
|
||||
"1:1" => Ok("1:1"),
|
||||
"4:3" => Ok("4:3"),
|
||||
"3:4" => Ok("3:4"),
|
||||
"21:9" => Ok("21:9"),
|
||||
_ => Err(editor_video_bad_request(
|
||||
"aspectRatio 只支持 16:9、9:16、1:1、4:3、3:4 或 21:9。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2829,22 +2839,48 @@ fn normalize_editor_video_resolution(value: &str) -> Result<&'static str, AppErr
|
||||
match value.trim() {
|
||||
"480p" => Ok("480p"),
|
||||
"720p" => Ok("720p"),
|
||||
_ => Err(editor_video_bad_request("resolution 只支持 480p 或 720p。")),
|
||||
"1080p" => Ok("1080p"),
|
||||
_ => Err(editor_video_bad_request(
|
||||
"resolution 只支持 480p、720p 或 1080p。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_video_duration(value: u32) -> Result<u32, AppError> {
|
||||
match value {
|
||||
4 | 5 => Ok(value),
|
||||
_ => Err(editor_video_bad_request("durationSeconds 只支持 4 或 5。")),
|
||||
if (4..=15).contains(&value) {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(editor_video_bad_request(
|
||||
"durationSeconds 只支持 4 到 15 秒的整数。",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_video_sound(value: &str) -> Result<bool, AppError> {
|
||||
match value.trim() {
|
||||
"on" => Ok(true),
|
||||
"off" => Ok(false),
|
||||
_ => Err(editor_video_bad_request("sound 只支持 on 或 off。")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_editor_video_size(aspect_ratio: &str, resolution: &str) -> (u32, u32) {
|
||||
match (aspect_ratio, resolution) {
|
||||
("16:9", "720p") => (1280, 720),
|
||||
_ => (854, 480),
|
||||
}
|
||||
let height = match resolution {
|
||||
"1080p" => 1080,
|
||||
"720p" => 720,
|
||||
_ => 480,
|
||||
};
|
||||
let (ratio_width, ratio_height) = match aspect_ratio {
|
||||
"9:16" => (9, 16),
|
||||
"1:1" => (1, 1),
|
||||
"4:3" => (4, 3),
|
||||
"3:4" => (3, 4),
|
||||
"21:9" => (21, 9),
|
||||
_ => (16, 9),
|
||||
};
|
||||
let width = ((height * ratio_width) + ratio_height / 2) / ratio_height;
|
||||
let even_width = if width % 2 == 0 { width } else { width + 1 };
|
||||
(even_width, height)
|
||||
}
|
||||
|
||||
fn require_editor_video_settings(
|
||||
@@ -3199,9 +3235,7 @@ fn probe_video_duration_seconds_or_known(
|
||||
) -> Result<f64, AppError> {
|
||||
match probe_video_duration_seconds(input_path, extraction_settings) {
|
||||
Ok(duration_seconds) => Ok(duration_seconds),
|
||||
Err(error)
|
||||
if is_ffprobe_start_error(&error, extraction_settings.ffprobe_path.as_str()) =>
|
||||
{
|
||||
Err(error) if is_ffprobe_start_error(&error, extraction_settings.ffprobe_path.as_str()) => {
|
||||
if let Some(duration_seconds) = known_duration_seconds
|
||||
.filter(|duration_seconds| duration_seconds.is_finite() && *duration_seconds > 0.0)
|
||||
{
|
||||
@@ -3231,13 +3265,20 @@ fn compute_sample_time_seconds(
|
||||
let sample_start = duration_seconds * sample_start_ratio as f64;
|
||||
let sample_end = duration_seconds * sample_end_ratio as f64;
|
||||
let sample_window = (sample_end - sample_start).max(0.001);
|
||||
let progress = if loop_mode {
|
||||
frame_index as f64 / frame_count.max(1) as f64
|
||||
let frame_count_f64 = frame_count.max(1) as f64;
|
||||
let progress = if loop_mode || frame_count <= 1 || sample_end_ratio >= 1.0 {
|
||||
frame_index as f64 / frame_count_f64
|
||||
} else {
|
||||
frame_index as f64 / frame_count.saturating_sub(1).max(1) as f64
|
||||
};
|
||||
let frame_margin_seconds = (sample_window / frame_count_f64)
|
||||
.max(0.05)
|
||||
.min(sample_window);
|
||||
let safe_sample_end = (sample_end - frame_margin_seconds)
|
||||
.max(sample_start)
|
||||
.min((duration_seconds - 0.001).max(0.0));
|
||||
|
||||
(sample_start + sample_window * progress).min((duration_seconds - 0.001).max(0.0))
|
||||
(sample_start + sample_window * progress).min(safe_sample_end)
|
||||
}
|
||||
|
||||
fn extract_video_frame_to_png(
|
||||
@@ -3246,7 +3287,7 @@ fn extract_video_frame_to_png(
|
||||
target_seconds: f64,
|
||||
extraction_settings: &BackendFrameExtractionSettings,
|
||||
) -> Result<(), AppError> {
|
||||
run_process_with_timeout(
|
||||
let output = run_process_with_timeout(
|
||||
&extraction_settings.ffmpeg_path,
|
||||
&[
|
||||
"-y",
|
||||
@@ -3265,10 +3306,16 @@ fn extract_video_frame_to_png(
|
||||
)?;
|
||||
|
||||
if !output_path.is_file() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "character-animation",
|
||||
"message": "ffmpeg 已执行但未产出动作帧文件。",
|
||||
"targetSeconds": target_seconds,
|
||||
"outputPath": output_path.to_string_lossy(),
|
||||
"stderr": stderr,
|
||||
"stdout": stdout,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -4591,6 +4638,7 @@ struct NormalizedEditorVideoRequest {
|
||||
aspect_ratio: String,
|
||||
resolution: String,
|
||||
duration_seconds: u32,
|
||||
generate_audio: bool,
|
||||
price_mud_points: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -4891,15 +4939,73 @@ mod tests {
|
||||
#[test]
|
||||
fn editor_character_animation_price_depends_on_resolution_and_duration() {
|
||||
assert_eq!(
|
||||
crate::editor_generation_config::editor_character_animation_mud_points("480p", 4),
|
||||
crate::editor_generation_config::editor_character_animation_model_mud_points(
|
||||
Some(EDITOR_CHARACTER_ANIMATION_MODEL),
|
||||
"480p",
|
||||
4,
|
||||
),
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
crate::editor_generation_config::editor_character_animation_mud_points("720p", 6),
|
||||
crate::editor_generation_config::editor_character_animation_model_mud_points(
|
||||
Some(EDITOR_CHARACTER_ANIMATION_MODEL),
|
||||
"720p",
|
||||
6,
|
||||
),
|
||||
120
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_samples_last_frame_before_decode_tail() {
|
||||
let last_frame_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false);
|
||||
|
||||
assert!((last_frame_seconds - 3.875).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_extracts_final_sample_from_short_video() {
|
||||
if Command::new("ffmpeg").arg("-version").output().is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let temp_dir = create_animation_temp_dir().expect("temp dir should be created");
|
||||
let input_path = temp_dir.join("short-preview.mp4");
|
||||
let output_path = temp_dir.join("raw-frame-32.png");
|
||||
let make_video_status = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=size=160x120:rate=8:duration=4",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
input_path.to_string_lossy().as_ref(),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.expect("ffmpeg should start");
|
||||
if !make_video_status.success() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return;
|
||||
}
|
||||
let target_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false);
|
||||
let settings = BackendFrameExtractionSettings {
|
||||
ffmpeg_path: "ffmpeg".to_string(),
|
||||
ffprobe_path: "ffprobe".to_string(),
|
||||
timeout_ms: 10_000,
|
||||
};
|
||||
|
||||
let result =
|
||||
extract_video_frame_to_png(&input_path, &output_path, target_seconds, &settings);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
result.expect("safe final sample should produce a frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_uses_known_duration_when_ffprobe_is_missing() {
|
||||
let settings = BackendFrameExtractionSettings {
|
||||
@@ -4908,12 +5014,9 @@ mod tests {
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let duration = probe_video_duration_seconds_or_known(
|
||||
Path::new("preview.mp4"),
|
||||
&settings,
|
||||
Some(6.0),
|
||||
)
|
||||
.expect("known duration should cover a missing ffprobe binary");
|
||||
let duration =
|
||||
probe_video_duration_seconds_or_known(Path::new("preview.mp4"), &settings, Some(6.0))
|
||||
.expect("known duration should cover a missing ffprobe binary");
|
||||
|
||||
assert_eq!(duration, 6.0);
|
||||
}
|
||||
@@ -4926,12 +5029,9 @@ mod tests {
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let error = probe_video_duration_seconds_or_known(
|
||||
Path::new("preview.mp4"),
|
||||
&settings,
|
||||
None,
|
||||
)
|
||||
.expect_err("missing ffprobe should still fail without a trusted duration");
|
||||
let error =
|
||||
probe_video_duration_seconds_or_known(Path::new("preview.mp4"), &settings, None)
|
||||
.expect_err("missing ffprobe should still fail without a trusted duration");
|
||||
|
||||
assert!(error.body_text().contains("无法启动进程"));
|
||||
}
|
||||
@@ -4946,6 +5046,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 100,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
@@ -4971,6 +5072,7 @@ mod tests {
|
||||
resolution: "720p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 80,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
@@ -4993,6 +5095,7 @@ mod tests {
|
||||
resolution: "720p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 96,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
@@ -5005,6 +5108,51 @@ mod tests {
|
||||
assert_ne!(normalized.provider_model, CHARACTER_ANIMATION_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_supports_extra_ratios_duration_range_and_non_fast_1080p() {
|
||||
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: "生成竖版电影感镜头。".to_string(),
|
||||
model: EDITOR_VIDEO_MODEL_SEEDANCE_2.to_string(),
|
||||
aspect_ratio: "9:16".to_string(),
|
||||
duration_seconds: 15,
|
||||
resolution: "1080p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "on".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 720,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
})
|
||||
.expect("seedance standard 1080p request should normalize");
|
||||
|
||||
assert_eq!(normalized.aspect_ratio, "9:16");
|
||||
assert_eq!(normalized.width, 608);
|
||||
assert_eq!(normalized.height, 1080);
|
||||
assert!(normalized.generate_audio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_rejects_seedance_fast_1080p() {
|
||||
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: "快速生成 1080p 镜头。".to_string(),
|
||||
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
|
||||
aspect_ratio: "16:9".to_string(),
|
||||
duration_seconds: 4,
|
||||
resolution: "1080p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "on".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 160,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
})
|
||||
.expect_err("seedance fast should reject 1080p");
|
||||
|
||||
assert!(error.body_text().contains("seedance2.0-fast 不支持 1080p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_normalizes_seedance_multimodal_references() {
|
||||
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
@@ -5015,6 +5163,7 @@ mod tests {
|
||||
resolution: "720p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 100,
|
||||
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
|
||||
reference_video_srcs: vec![
|
||||
@@ -5048,6 +5197,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 40,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: vec!["data:video/mp4;base64,video".to_string()],
|
||||
@@ -5069,6 +5219,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 40,
|
||||
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
|
||||
reference_video_srcs: vec!["https://assets.example.com/reference.mp4".to_string()],
|
||||
@@ -5108,6 +5259,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 40,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: vec![
|
||||
@@ -5145,6 +5297,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 48,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
@@ -5200,6 +5353,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 40,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
pub(crate) const EDITOR_VIDEO_GENERATION_1080P_MUD_POINTS_PER_SECOND: u32 = 40;
|
||||
|
||||
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;
|
||||
@@ -72,19 +73,20 @@ fn editor_video_model_resolution_mud_points_per_second(
|
||||
) => model.unwrap_or_default().trim(),
|
||||
_ => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
|
||||
};
|
||||
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),
|
||||
let (rate_480p, rate_720p, rate_1080p) = match normalized_model {
|
||||
EDITOR_VIDEO_MODEL_SEEDANCE_2 => (12, 24, 48),
|
||||
EDITOR_VIDEO_MODEL_KLING_3 => (15, 30, 60),
|
||||
EDITOR_VIDEO_MODEL_KLING_3_OMNI => (20, 40, 80),
|
||||
_ => (
|
||||
EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND,
|
||||
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND,
|
||||
EDITOR_VIDEO_GENERATION_1080P_MUD_POINTS_PER_SECOND,
|
||||
),
|
||||
};
|
||||
if resolution == "720p" {
|
||||
rate_720p
|
||||
} else {
|
||||
rate_480p
|
||||
match resolution {
|
||||
"1080p" => rate_1080p,
|
||||
"720p" => rate_720p,
|
||||
_ => rate_480p,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +99,6 @@ pub(crate) fn editor_video_model_generation_mud_points(
|
||||
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,
|
||||
@@ -121,13 +118,6 @@ pub(crate) fn editor_character_animation_model_mud_points(
|
||||
per_second * duration_seconds
|
||||
}
|
||||
|
||||
pub(crate) fn editor_character_animation_mud_points(
|
||||
resolution: &str,
|
||||
duration_seconds: u32,
|
||||
) -> u32 {
|
||||
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,
|
||||
@@ -148,14 +138,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_price_uses_configured_resolution_rates() {
|
||||
assert_eq!(editor_character_animation_mud_points("480p", 4), 40);
|
||||
assert_eq!(editor_character_animation_mud_points("720p", 6), 120);
|
||||
assert_eq!(
|
||||
editor_character_animation_model_mud_points(Some("seedance2.0-fast"), "480p", 4),
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
editor_character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6),
|
||||
120
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_generation_price_uses_configured_resolution_rates() {
|
||||
assert_eq!(editor_video_generation_mud_points("480p", 4), 40);
|
||||
assert_eq!(editor_video_generation_mud_points("720p", 5), 100);
|
||||
assert_eq!(
|
||||
editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "480p", 4),
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "720p", 5),
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2108,45 +2108,6 @@ pub(crate) fn apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_metadata_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
previous_level_name: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
|
||||
draft.levels[target_index].level_name = metadata.level_name.clone();
|
||||
if metadata.ui_background_prompt.is_some() {
|
||||
draft.levels[target_index].ui_background_prompt = metadata.ui_background_prompt.clone();
|
||||
}
|
||||
|
||||
if target_index == 0 {
|
||||
apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft,
|
||||
metadata,
|
||||
previous_level_name,
|
||||
updated_at_micros,
|
||||
);
|
||||
} else {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft: &mut PuzzleResultDraftRecord,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
@@ -2196,45 +2157,3 @@ pub(crate) fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResu
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace_puzzle_session_draft_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
draft: PuzzleResultDraftRecord,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
session.draft = Some(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_ui_background_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
prompt: String,
|
||||
image_src: String,
|
||||
image_object_key: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
let level = &mut draft.levels[target_index];
|
||||
level.ui_background_prompt = Some(prompt);
|
||||
level.ui_background_image_src = Some(image_src);
|
||||
level.ui_background_image_object_key = image_object_key;
|
||||
if target_index == 0 {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
session.progress_percent = session.progress_percent.max(96);
|
||||
session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
@@ -356,6 +356,8 @@ pub struct EditorVideoGenerateRequest {
|
||||
pub resolution: String,
|
||||
pub mode: String,
|
||||
pub sound: String,
|
||||
#[serde(default = "default_editor_video_web_search_enabled")]
|
||||
pub web_search_enabled: bool,
|
||||
pub price_mud_points: u32,
|
||||
#[serde(default)]
|
||||
pub reference_image_srcs: Vec<String>,
|
||||
@@ -365,6 +367,10 @@ pub struct EditorVideoGenerateRequest {
|
||||
pub reference_audio_srcs: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_editor_video_web_search_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorVideoGenerateResponse {
|
||||
@@ -1048,6 +1054,7 @@ mod tests {
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 100,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
@@ -1059,6 +1066,7 @@ mod tests {
|
||||
assert_eq!(payload["durationSeconds"], json!(5));
|
||||
assert_eq!(payload["priceMudPoints"], json!(100));
|
||||
assert_eq!(payload["model"], json!("kling3.0-omni"));
|
||||
assert_eq!(payload["webSearchEnabled"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1071,6 +1079,7 @@ mod tests {
|
||||
resolution: "720p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
price_mud_points: 100,
|
||||
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
|
||||
reference_video_srcs: vec![
|
||||
|
||||
+78
-11
@@ -3,9 +3,27 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from './App';
|
||||
import { resolveInitialSelectionStageFromPath } from './routing/appPageRoutes';
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('./components/platform-entry/PlatformEntryFlowShell', async () => {
|
||||
const React = await import('react');
|
||||
@@ -14,18 +32,30 @@ vi.mock('./components/platform-entry/PlatformEntryFlowShell', async () => {
|
||||
>;
|
||||
|
||||
return {
|
||||
PlatformEntryFlowShell: ({ setSelectionStage }: PlatformEntryFlowShellProps) =>
|
||||
PlatformEntryFlowShell: ({
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
}: PlatformEntryFlowShellProps) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
setSelectionStage('image-editor', {
|
||||
path: '/editor/canvas?projectid=project-from-test',
|
||||
});
|
||||
'div',
|
||||
null,
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'selection-stage' },
|
||||
selectionStage,
|
||||
),
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
setSelectionStage('image-editor', {
|
||||
path: '/editor/canvas?projectid=project-from-test',
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
'打开最近项目',
|
||||
'打开最近项目',
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -34,8 +64,45 @@ vi.mock('./RpgRuntimeApp', () => ({
|
||||
RpgRuntimeApp: () => <div>运行态</div>,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('resolveInitialSelectionStageFromPath', () => {
|
||||
it('opens the desktop root route on the creation home stage', () => {
|
||||
expect(resolveInitialSelectionStageFromPath('/', true)).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the mobile root route on the platform stage', () => {
|
||||
expect(resolveInitialSelectionStageFromPath('/', false)).toBe('platform');
|
||||
});
|
||||
|
||||
it('keeps explicit routes mapped to their own stages on desktop', () => {
|
||||
expect(resolveInitialSelectionStageFromPath('/creation/puzzle', true)).toBe(
|
||||
'puzzle-agent-workspace',
|
||||
);
|
||||
expect(resolveInitialSelectionStageFromPath('/creation', true)).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App navigation history', () => {
|
||||
it('renders the desktop home URL with the creation home stage', () => {
|
||||
mockMatchMedia(true);
|
||||
window.history.replaceState(null, '', '/');
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps project canvas navigation as one history entry with projectid', async () => {
|
||||
mockMatchMedia(true);
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const pushStateSpy = vi.spyOn(window.history, 'pushState');
|
||||
const user = userEvent.setup();
|
||||
|
||||
+10
-3
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
import { getInitialPlatformDesktopLayout } from './components/platform-entry/platformEntryResponsive';
|
||||
import type {
|
||||
CustomWorldRuntimeLaunchOptions,
|
||||
SelectionStage,
|
||||
@@ -19,8 +20,8 @@ import {
|
||||
normalizeAppPath,
|
||||
pushAppHistoryPath,
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolveInitialSelectionStageFromPath,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
} from './routing/appPageRoutes';
|
||||
import type { RpgRuntimeAppIntent } from './RpgRuntimeApp';
|
||||
import type { CustomWorldProfile } from './types';
|
||||
@@ -59,7 +60,10 @@ export default function App() {
|
||||
isRpgRuntimeRoute(window.location.pathname),
|
||||
);
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
),
|
||||
);
|
||||
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
@@ -84,7 +88,10 @@ export default function App() {
|
||||
|
||||
setIsRuntimeActive(false);
|
||||
setRawSelectionStage(
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ type ResolvedAssetImageProps = Omit<
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
objectKey?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetImage({
|
||||
src,
|
||||
objectKey,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
refreshKey,
|
||||
@@ -21,6 +23,7 @@ export function ResolvedAssetImage({
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
|
||||
objectKey,
|
||||
refreshKey,
|
||||
});
|
||||
const normalizedSource = src?.trim() ?? '';
|
||||
|
||||
@@ -7,17 +7,20 @@ type ResolvedAssetVideoProps = Omit<
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
objectKey?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetVideo({
|
||||
src,
|
||||
objectKey,
|
||||
fallbackSrc,
|
||||
refreshKey,
|
||||
...rest
|
||||
}: ResolvedAssetVideoProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||
objectKey,
|
||||
refreshKey,
|
||||
});
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
|
||||
@@ -258,6 +258,80 @@ describe('CreationLandingView', () => {
|
||||
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens a fullscreen asset preview modal with thumbnail navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-character',
|
||||
folderId: 'folder-1',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-animation',
|
||||
folderId: 'folder-1',
|
||||
label: '待机动画',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'idle animation prompt',
|
||||
actualPrompt: null,
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
taskId: 'task-2',
|
||||
assetKind: 'character-animation',
|
||||
priceMudPoints: 40,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '角色英雄',
|
||||
refType: 'asset',
|
||||
refId: 'asset-character',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '角色' }));
|
||||
await user.click(await screen.findByText('角色英雄'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '角色英雄' });
|
||||
expect(within(dialog).getByText('生成总成本')).toBeTruthy();
|
||||
expect(within(dialog).getByText('60泥点')).toBeTruthy();
|
||||
expect(within(dialog).getByText('创作者A')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByLabelText('素材缩略图导航'),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '查看待机动画' }));
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '查看待机动画' }).getAttribute(
|
||||
'aria-current',
|
||||
),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorAssetSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { buildCreationShowcaseItems } from './creationShowcaseModel';
|
||||
|
||||
function createAsset(
|
||||
overrides: Partial<EditorAssetSnapshot> & Record<string, unknown>,
|
||||
): EditorAssetSnapshot {
|
||||
return {
|
||||
assetId: 'asset-1',
|
||||
folderId: 'folder-1',
|
||||
label: '素材',
|
||||
imageSrc: 'data:image/png;base64,asset',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: null,
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createLibrary(assets: EditorAssetSnapshot[]): EditorAssetLibrarySnapshot {
|
||||
return {
|
||||
folders: [],
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
describe('creationShowcaseModel', () => {
|
||||
it('groups pack items by the same referenced spec image and sums known cost', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'spec-1',
|
||||
label: '角色规范图',
|
||||
assetKind: 'spec',
|
||||
prompt: '规范提示词',
|
||||
priceMudPoints: 5,
|
||||
authorName: '创作者A',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '角色主图',
|
||||
assetKind: 'character',
|
||||
priceMudPoints: 12,
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '角色提示词' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '角色规范图',
|
||||
refType: 'asset',
|
||||
refId: 'spec-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('角色规范图');
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'角色规范图',
|
||||
'角色主图',
|
||||
]);
|
||||
expect(items[0]?.prompt).toBe('规范提示词');
|
||||
expect(items[0]?.author).toBe('创作者A');
|
||||
expect(items[0]?.cost).toBe('17泥点');
|
||||
});
|
||||
|
||||
it('groups assets when references point to the source project resource', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'spec-1',
|
||||
label: '项目规范图',
|
||||
assetKind: 'spec',
|
||||
sourceResourceId: 'resource-spec-1',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '项目角色图',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '项目角色提示词' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '项目规范图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-spec-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'项目规范图',
|
||||
'项目角色图',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups character animations under the referenced character image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
assetKind: 'character',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('游戏角色图');
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'游戏角色图',
|
||||
'待机动画',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups UI design extraction assets under the referenced UI image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'ui',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
prompt: 'UI提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('游戏UI图');
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'游戏UI图',
|
||||
'UI子素材图集',
|
||||
'开始按钮',
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts a spritesheet task only once when UI children share the same task', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'ui',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
taskId: 'task-ui-design',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
model: 'gpt-image-2',
|
||||
taskId: 'task-ui-extract',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
model: 'gpt-image-2',
|
||||
taskId: 'task-ui-extract',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('32泥点');
|
||||
});
|
||||
|
||||
it('keeps music assets as audio preview items', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'music',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'music-1',
|
||||
label: '背景音乐',
|
||||
imageSrc: '/generated-editor-audios/music.mp3',
|
||||
assetKind: 'background-music',
|
||||
prompt: '舒缓背景音乐',
|
||||
priceMudPoints: '5',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
fallbackAuthorName: '当前用户',
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.previews[0]?.mediaType).toBe('audio');
|
||||
expect(items[0]?.author).toBe('当前用户');
|
||||
expect(items[0]?.cost).toBe('5泥点');
|
||||
});
|
||||
|
||||
it('keeps character animation videos out of the marketing tab', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'marketing',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
label: '角色动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'poster-1',
|
||||
label: '上线美宣视频',
|
||||
assetKind: 'publication-material',
|
||||
imageSrc: '/generated-editor-videos/poster.mp4',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('上线美宣视频');
|
||||
expect(items[0]?.previews[0]?.mediaType).toBe('video');
|
||||
});
|
||||
|
||||
it('infers costs from generated asset kinds when stored price is absent', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
imageSrc: '/generated-editor-assets/character.png',
|
||||
objectKey: 'generated-editor-assets/character.png',
|
||||
assetKind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
objectKey: 'generated-editor-videos/animation.mp4',
|
||||
model: 'seedance2.0-fast',
|
||||
durationSeconds: 6,
|
||||
generationInputs: {
|
||||
fields: [{ title: '清晰度', value: '720p' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('140泥点');
|
||||
expect(items[0]?.previews[0]?.objectKey).toBe(
|
||||
'generated-editor-assets/character.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -420,18 +420,26 @@ export function hydrateCanvasGenerationDialog(
|
||||
typeof snapshot.videoModel === 'string'
|
||||
? snapshot.videoModel
|
||||
: undefined,
|
||||
videoAspectRatio: stringOrUndefined(snapshot.videoAspectRatio),
|
||||
videoAspectRatio: videoAspectRatioOrUndefined(snapshot.videoAspectRatio),
|
||||
videoResolution:
|
||||
snapshot.videoResolution === '480p' || snapshot.videoResolution === '720p'
|
||||
snapshot.videoResolution === '480p' ||
|
||||
snapshot.videoResolution === '720p' ||
|
||||
snapshot.videoResolution === '1080p'
|
||||
? snapshot.videoResolution
|
||||
: undefined,
|
||||
videoDurationSeconds:
|
||||
snapshot.videoDurationSeconds === 4 ||
|
||||
snapshot.videoDurationSeconds === 5
|
||||
? snapshot.videoDurationSeconds
|
||||
typeof snapshot.videoDurationSeconds === 'number'
|
||||
? Math.min(15, Math.max(4, Math.round(snapshot.videoDurationSeconds)))
|
||||
: undefined,
|
||||
videoMode: snapshot.videoMode === 'std' ? 'std' : undefined,
|
||||
videoSound: snapshot.videoSound === 'off' ? 'off' : undefined,
|
||||
videoSound:
|
||||
snapshot.videoSound === 'on' || snapshot.videoSound === 'off'
|
||||
? snapshot.videoSound
|
||||
: undefined,
|
||||
videoWebSearchEnabled:
|
||||
typeof snapshot.videoWebSearchEnabled === 'boolean'
|
||||
? snapshot.videoWebSearchEnabled
|
||||
: undefined,
|
||||
characterAnimationResolution:
|
||||
snapshot.characterAnimationResolution === '480p' ||
|
||||
snapshot.characterAnimationResolution === '720p'
|
||||
@@ -618,13 +626,24 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
|
||||
}
|
||||
|
||||
export type CanvasLayerResourceMetadata = {
|
||||
resourceId?: string | null;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
sourceType?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: string | null;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
generationInputs?: unknown;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
|
||||
@@ -699,6 +718,17 @@ export function stringOrUndefined(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function videoAspectRatioOrUndefined(value: unknown) {
|
||||
return value === '16:9' ||
|
||||
value === '9:16' ||
|
||||
value === '1:1' ||
|
||||
value === '4:3' ||
|
||||
value === '3:4' ||
|
||||
value === '21:9'
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function hydrateCharacterAnimationResult(
|
||||
value: unknown,
|
||||
): EditorCharacterAnimationGenerationResult | undefined {
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorVideoAspectRatio,
|
||||
EditorVideoModel,
|
||||
EditorVideoResolution,
|
||||
EditorVideoSoundMode,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
|
||||
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
|
||||
@@ -201,11 +204,12 @@ export type GenerateDialogState = {
|
||||
uiDesignSpecReference?: CharacterReferenceImage | null;
|
||||
imageModel?: string;
|
||||
videoModel?: EditorVideoModel;
|
||||
videoAspectRatio?: string;
|
||||
videoResolution?: '480p' | '720p';
|
||||
videoDurationSeconds?: 4 | 5;
|
||||
videoAspectRatio?: EditorVideoAspectRatio;
|
||||
videoResolution?: EditorVideoResolution;
|
||||
videoDurationSeconds?: number;
|
||||
videoMode?: 'std';
|
||||
videoSound?: 'off';
|
||||
videoSound?: EditorVideoSoundMode;
|
||||
videoWebSearchEnabled?: boolean;
|
||||
characterAnimationResolution?: EditorCharacterAnimationResolution;
|
||||
characterAnimationRatio?: EditorCharacterAnimationRatio;
|
||||
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
|
||||
|
||||
@@ -493,7 +493,7 @@ export function ImageCanvasEditorView() {
|
||||
setViewport,
|
||||
],
|
||||
);
|
||||
const { projectId, appendCanvasLayersWithResources } =
|
||||
const { projectId, appendCanvasLayersWithResources, applyProjectSnapshot } =
|
||||
useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
@@ -615,6 +615,7 @@ export function ImageCanvasEditorView() {
|
||||
projectId,
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
});
|
||||
const {
|
||||
setQuickEditPanel,
|
||||
|
||||
@@ -491,7 +491,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成视频' });
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
|
||||
within(panel).getByRole('button', {
|
||||
name: '视频参数 16:9 · 4秒 · 480p',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }),
|
||||
@@ -501,13 +503,18 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
|
||||
within(panel).getByRole('button', {
|
||||
name: '视频参数 16:9 · 4秒 · 480p',
|
||||
}),
|
||||
);
|
||||
const paramsPanel = screen.getByRole('menu', {
|
||||
name: '视频参数选项',
|
||||
});
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '时长 5秒' }));
|
||||
fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' }));
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '视频参数选项' }),
|
||||
@@ -517,7 +524,14 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
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: '视频参数 16:9 · 5秒 · 720p' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(paramsPanel)
|
||||
.getByRole('button', { name: '清晰度 1080p' })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成100泥点',
|
||||
);
|
||||
|
||||
@@ -41,11 +41,17 @@ import {
|
||||
DEFAULT_BACKGROUND_MUSIC_MODEL,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
DEFAULT_VIDEO_ASPECT_RATIO,
|
||||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||||
DEFAULT_VIDEO_SOUND,
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
EDITOR_VIDEO_ASPECT_RATIO_OPTIONS,
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||||
EDITOR_VIDEO_DURATION_OPTIONS,
|
||||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||||
resizeGenerationPlaceholderToVideoSelection,
|
||||
SOUND_EFFECT_DURATION_OPTIONS,
|
||||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||||
EDITOR_VIDEO_RESOLUTION_OPTIONS,
|
||||
VIDEO_MODEL_SEEDANCE_2_FAST,
|
||||
resizeGenerationPlaceholderToVideoSelection,
|
||||
SPEC_TYPE_LABEL,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
|
||||
@@ -277,8 +283,14 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
const [openPanel, setOpenPanel] = useState<'params' | 'model' | null>(null);
|
||||
const paramsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const normalizeVideoDuration = (value: number) =>
|
||||
Math.min(15, Math.max(4, Math.round(value)));
|
||||
const resolution = dialog.videoResolution ?? '480p';
|
||||
const durationSeconds = dialog.videoDurationSeconds ?? 4;
|
||||
const durationSeconds = normalizeVideoDuration(
|
||||
dialog.videoDurationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS,
|
||||
);
|
||||
const soundMode = dialog.videoSound ?? DEFAULT_VIDEO_SOUND;
|
||||
const aspectRatio = dialog.videoAspectRatio ?? DEFAULT_VIDEO_ASPECT_RATIO;
|
||||
const currentModel =
|
||||
EDITOR_VIDEO_MODEL_OPTIONS.find(
|
||||
(item) => item.value === dialog.videoModel,
|
||||
@@ -319,6 +331,10 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
const updateVideoModel = (model: GenerateDialogState['videoModel']) => {
|
||||
updateVideoDialog({
|
||||
videoModel: model,
|
||||
videoResolution:
|
||||
model === VIDEO_MODEL_SEEDANCE_2_FAST && dialog.videoResolution === '1080p'
|
||||
? '720p'
|
||||
: dialog.videoResolution,
|
||||
...(isSeedanceVideoModel(model) ? {} : { generationReferences: [] }),
|
||||
});
|
||||
};
|
||||
@@ -384,7 +400,7 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
<PlatformInlineOptionButton
|
||||
ref={paramsButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
|
||||
aria-label={`视频参数 16:9 · ${durationSeconds}秒 · ${resolution}`}
|
||||
aria-label={`视频参数 ${aspectRatio} · ${durationSeconds}秒 · ${resolution}`}
|
||||
aria-expanded={openPanel === 'params'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
@@ -394,7 +410,8 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
)
|
||||
}
|
||||
>
|
||||
16:9 · {durationSeconds}秒 · {resolution}
|
||||
{aspectRatio} · {durationSeconds}秒 · {resolution} ·{' '}
|
||||
{soundMode === 'off' ? '静音' : '有声'}
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'params'
|
||||
? renderEditorPortal(
|
||||
@@ -413,46 +430,46 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
比例
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
<VideoOptionChoice
|
||||
selected
|
||||
disabled={isGenerating}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
ariaLabel="比例 16:9"
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__ratio-wireframe"
|
||||
data-ratio="16:9"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>16:9</span>
|
||||
</VideoOptionChoice>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{EDITOR_VIDEO_ASPECT_RATIO_OPTIONS.map((option) => (
|
||||
<VideoOptionChoice
|
||||
key={option}
|
||||
selected={aspectRatio === option}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`比例 ${option}`}
|
||||
onClick={() =>
|
||||
updateVideoDialog({
|
||||
videoAspectRatio: option,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</VideoOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
时长
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{EDITOR_VIDEO_DURATION_OPTIONS.map((option) => {
|
||||
const nextDuration =
|
||||
Number(option.value) === 5 ? 5 : 4;
|
||||
return (
|
||||
<VideoOptionChoice
|
||||
key={option.value}
|
||||
selected={durationSeconds === nextDuration}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`时长 ${option.label}`}
|
||||
onClick={() =>
|
||||
updateVideoDialog({
|
||||
videoDurationSeconds: nextDuration,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</VideoOptionChoice>
|
||||
);
|
||||
})}
|
||||
<div className="image-canvas-editor__video-duration-slider">
|
||||
<input
|
||||
type="range"
|
||||
min={4}
|
||||
max={15}
|
||||
step={1}
|
||||
value={durationSeconds}
|
||||
disabled={isGenerating}
|
||||
aria-label="视频时长"
|
||||
onChange={(event) =>
|
||||
updateVideoDialog({
|
||||
videoDurationSeconds: normalizeVideoDuration(
|
||||
Number(event.target.value),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{durationSeconds}秒</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
@@ -460,19 +477,50 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
清晰度
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{(['480p', '720p'] as const).map((option) => (
|
||||
<VideoOptionChoice
|
||||
key={option}
|
||||
selected={resolution === option}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`清晰度 ${option}`}
|
||||
onClick={() =>
|
||||
updateVideoDialog({ videoResolution: option })
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</VideoOptionChoice>
|
||||
))}
|
||||
{EDITOR_VIDEO_RESOLUTION_OPTIONS.map((option) => {
|
||||
const available =
|
||||
currentModel.value !== VIDEO_MODEL_SEEDANCE_2_FAST ||
|
||||
option !== '1080p';
|
||||
return (
|
||||
<VideoOptionChoice
|
||||
key={option}
|
||||
selected={resolution === option}
|
||||
disabled={isGenerating || !available}
|
||||
ariaLabel={`清晰度 ${option}`}
|
||||
onClick={() =>
|
||||
available
|
||||
? updateVideoDialog({
|
||||
videoResolution: option,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</VideoOptionChoice>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
声音
|
||||
</span>
|
||||
<div className="image-canvas-editor__video-toggle-row">
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__video-toggle"
|
||||
aria-pressed={soundMode === 'off'}
|
||||
disabled={isGenerating}
|
||||
onClick={() =>
|
||||
updateVideoDialog({
|
||||
videoSound:
|
||||
soundMode === 'off' ? 'on' : 'off',
|
||||
})
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<strong>静音</strong>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user