Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library

This commit is contained in:
2026-06-22 13:52:43 +08:00
41 changed files with 3622 additions and 491 deletions
@@ -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-25 泥点
生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20seedance2.0 为 480p 每秒 12 / 720p 每秒 24kling3.0 为 480p 每秒 15 / 720p 每秒 30kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40
生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20,不支持 1080pseedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60kling3.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
View File
@@ -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
View File
@@ -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(),
),
);
};
+3
View File
@@ -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() ?? '';
+3
View File
@@ -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