Spine序列帧修复与功能收紧
This commit is contained in:
@@ -6948,3 +6948,10 @@
|
||||
- Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。
|
||||
- Provider Schema 决策:`agent.route_manifest.missingAssetSlots` 不再广告 OpenAI-compatible 代理拒绝的 `uniqueItems`;Runtime 继续排序去重,Schema 子集门禁新增该关键字,真实 Provider smoke 必须在发布前证明工具目录可被接受。
|
||||
- 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。
|
||||
|
||||
## 2026-08-13 Spine 序列帧多模态与视频直接转换
|
||||
|
||||
- 同一 Spine 序列帧面板固定分为“AI生成/改造”和“视频直接转换”两个标签,旧项目默认恢复 AI。AI 视频以 `motion` 表示动作视频、`appearance` 表示可选角色外观图;上传序列帧 PNG 只作为一张完整参考图,不做网格识别。
|
||||
- 视频直接转换走内部 `editor_character_animation_video_conversion` durable job,FFprobe + FFmpeg 覆盖完整源时长,采样率最高 8 FPS、最低 1 FPS,产出 2–120 帧;480p/720p 是只缩不放的最大长边。该路径固定免费、保留背景,不调用 Seedance 或 BgFilter,也不复制保存源视频。
|
||||
- 直接转换仍落 `character-animation` / `image-sequence`,配方 action 为 `character-animation.convert`,来源槽位为 `source`;最多 120 帧时首帧 item 同时承载最终 resource/asset。原子生成上限和角色动画拆帧上限扩为 120,但图标图集公开切片继续保持 64,既有 JSON 大小门禁不放宽。
|
||||
- `/api/external/v1` 不开放上述内部字段或直接转换路由,不修改 OpenAPI;本次不改 SpacetimeDB 表结构、迁移或生成 bindings。
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
# Art Agent 多模态 Spine 序列帧动画生成方案
|
||||
|
||||
状态:已实施,待合并
|
||||
更新时间:`2026-08-11`
|
||||
更新时间:`2026-08-13`
|
||||
|
||||
## 2026-08-13 多模态输入与直接转换补充
|
||||
|
||||
- 同一面板分为“AI生成/改造”和“视频直接转换”两个标签,旧配方默认恢复 AI;两个标签独立保留草稿;标签为分段按钮样式(明显可点击、带选中态),不再像标题。
|
||||
- AI 视频以 `motion` 表示动作视频、`appearance` 表示可选角色外观图(可多张);Provider 的 `reference_video` 负责动作、`reference_image` 负责外观。动作视频已选中后,外观图是可选的普通图片参考,不要求画布图层标记为 `character`;上传序列帧 PNG 作为一张完整参考图,不识别网格或拆帧。
|
||||
- 直接转换使用内部 `/api/editor/character-animations/video-conversions` 与 durable job `editor_character_animation_video_conversion`,固定免费、保留背景,不调用 Seedance、BgFilter 或其它去背景模型,也不重复保存源视频。**去背景暂缓**:直接转换的源视频背景非纯色,本地键色抠图不适用,待确定方案后再做。
|
||||
- 源视频仅接受 owner-scoped MP4/MOV,限制 50MB、2–15 秒。FFprobe 读取旋转后的显示尺寸、精确时长与平均 FPS;FFmpeg 覆盖完整时间轴,采样率为 `clamp(min(源平均 FPS, 8), 1, 8)`,产出 2–120 帧。
|
||||
- 480p/720p 表示最大长边,只缩小不放大。正式 `imageSequenceDurationMs` 等于探测时长,播放器、普通 ZIP 与 Spine JSON 的单帧时长统一按 `imageSequenceDurationMs ÷ frameCount`。
|
||||
- 直接转换仍落 `character-animation` / `image-sequence`,配方 action 为 `character-animation.convert`,来源槽位为 `source`。最多 120 帧时第一帧同时承载最终 resource/asset;角色动画拆帧允许 120 项,图标图集公开切片继续限制 64 项,既有 512 KiB/2 MiB JSON 门禁不放宽。
|
||||
- `/api/external/v1` 保持原图片模式,不暴露内部新增字段或直接转换接口,不修改 OpenAPI。
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
@@ -25,12 +35,13 @@
|
||||
2. **不要新增新的序列帧媒体类型。**继续使用:
|
||||
- `assetKind = "character-animation"`;
|
||||
- `mediaType = "image-sequence"`;
|
||||
- `generationInputs.action = "character-animation.generate"`。
|
||||
- AI 生成使用 `generationInputs.action = "character-animation.generate"`;
|
||||
- 视频直接转换使用 `generationInputs.action = "character-animation.convert"`。
|
||||
3. **扩展现有角色动作生成链路,而不是重写。**现有链路已经覆盖图片首帧驱动、Seedance 预览视频、抽帧、逐帧透明化、OSS、原子落库、队列恢复和失败退款。
|
||||
4. **附件 ZIP 导出已经实现。**当前 `buildSpineImageSequenceJson` 与 `useImageCanvasAssetExportWorkflow` 生成的目录、`metadata.json`、`skeleton.json`、`README.md` 和 `manifest.txt` 与附件示例一致,只需要回归验证和入口文案调整。
|
||||
5. **本期不应承诺“真正的 Spine 骨骼动画”。**UI 和文案应写“Spine 序列帧动画”或“Spine JSON 序列帧包”,避免用户误认为会自动拆件和蒙皮。
|
||||
6. **1080p 暂不开放。**当前服务端明确拒绝 `seedance2.0-fast + 1080p`;应等供应商能力、成本和逐帧处理资源验证后再开放。
|
||||
7. **关键需求口径已经冻结并完成实施。**视频模式采用“参考视频 + 文本生成新视频后抽帧”,默认 480p × 4 秒为 40 泥点,最终帧自动透明化,拆帧只进入素材库;完整决策见第 13、16 节。
|
||||
7. **关键需求口径已经冻结并完成实施。**AI 视频模式采用“动作视频 + 可选角色外观图 + 文本”生成新视频后抽帧,按现有规格计费且最终帧自动透明化;视频直接转换固定免费、保留背景;拆帧只进入素材库。完整决策见第 13、16 节。
|
||||
|
||||
### 2.2 为什么不是新玩法接入
|
||||
|
||||
@@ -82,7 +93,7 @@ manifest.txt
|
||||
|
||||
现有角色动作能力已经包含:
|
||||
|
||||
- 角色图片右键或浮动工具栏“生成动画”;
|
||||
- 统一从底部工具栏“Spine序列帧动画”进入;不再从图层右键菜单或选中图层浮动工具栏暴露独立入口;当前选中视频(或带预览视频的序列帧)时,底部入口默认打开“视频直接转换”,其它情况默认打开“AI生成/改造”;
|
||||
- `character-animation` 生成占位框;
|
||||
- 动作描述;
|
||||
- 待机、行走、奔跑、跳跃、攻击、受击、倒下预设;
|
||||
@@ -509,10 +520,12 @@ flowchart TD
|
||||
|
||||
`ImageCanvasBottomToolbarView.tsx`:
|
||||
|
||||
- 在“生成视频”后新增“Spine 序列帧动画”;
|
||||
- 点击后在视口中心创建独立 `character-animation` generation dialog;
|
||||
- 不在当前面板下方追加内容;
|
||||
- 移动端保持横向滚动或现有工具栏收纳规则。
|
||||
- 在“生成视频”后保留唯一的“Spine 序列帧动画”入口;
|
||||
- 点击后在视口中心创建统一的 `character-animation` generation dialog;
|
||||
- 统一面板内同时承载“AI生成/改造”和“视频直接转换”两个标签;
|
||||
- 当前选中视频或带预览视频的序列帧时默认进入“视频直接转换”,当前选中角色图时默认进入“AI生成/改造”,无适用选中图层时默认进入“AI生成/改造”;
|
||||
- 右键菜单和选中图层浮动工具栏不再提供“生成动画”或“视频转换为Spine序列帧动画”平行入口;
|
||||
- 不在当前面板下方追加内容;移动端保持横向滚动或现有工具栏收纳规则。
|
||||
|
||||
`CanvasTool` 增加 `character-animation`,但不新增页面路由。
|
||||
|
||||
@@ -755,19 +768,20 @@ idle
|
||||
|
||||
在 P0/P1 决策冻结后,本功能至少满足:
|
||||
|
||||
1. 底部工具栏最右侧出现“Spine 序列帧动画”,与前一个工具之间不增加分割线。
|
||||
2. 无参考、角色图片参考、视频参考三种合法提交都由后端权威校验。
|
||||
3. 画布选择非角色图片时提示“请选择角色图”;上传图片规则按最终确认口径执行。
|
||||
4. 请求只携带稳定对象引用,不携带 Data URL / blob URL。
|
||||
5. 生成中刷新页面后任务和占位框可恢复。
|
||||
6. 失败不产生残缺序列,且按现有 durable billing 规则退款。
|
||||
7. 完成后项目 resource 和账号 asset 均为 `character-animation`,帧顺序稳定。
|
||||
8. 序列图层可循环播放并显示当前帧/总帧数。
|
||||
9. 元数据展示类型、输入、Model、Resolution、Task。
|
||||
10. 改造可恢复原输入模式、参考、动作、背景和规格,并生成新资源。
|
||||
11. 拆帧不重复上传已有 PNG。
|
||||
12. Spine ZIP 与附件目录结构一致,`skeleton.json` 能通过现有 validator。
|
||||
13. 存量角色动作项目、External v1 图片模式和普通视频生成不回归。
|
||||
1. 底部工具栏最右侧出现“Spine 序列帧动画”,与前一个工具之间不增加分割线;Spine 序列帧不再从右键菜单或选中图层浮动工具栏提供平行入口。
|
||||
2. 点击底部入口后,统一面板同时提供“AI生成/改造”和“视频直接转换”;选中视频默认进入直接转换,选中角色图默认进入 AI,无适用选中图层时默认进入 AI。
|
||||
3. 无参考、角色图片参考、视频参考三种合法提交都由后端权威校验。
|
||||
4. 画布选择非角色图片时按当前多模态参考规则处理;上传图片规则按最终确认口径执行。
|
||||
5. 请求只携带稳定对象引用,不携带 Data URL / blob URL。
|
||||
6. 生成中刷新页面后任务和占位框可恢复。
|
||||
7. 失败不产生残缺序列,且按现有 durable billing 规则退款。
|
||||
8. 完成后项目 resource 和账号 asset 均为 `character-animation`,帧顺序稳定。
|
||||
9. 序列图层可循环播放并显示当前帧/总帧数。
|
||||
10. 元数据展示类型、输入、Model、Resolution、Task。
|
||||
11. 改造可恢复原输入模式、参考、动作、背景和规格,并生成新资源。
|
||||
12. 拆帧不重复上传已有 PNG。
|
||||
13. Spine ZIP 与附件目录结构一致,`skeleton.json` 能通过现有 validator。
|
||||
14. 存量角色动作项目、External v1 图片模式和普通视频生成不回归。
|
||||
|
||||
## 15. 验证命令建议
|
||||
|
||||
@@ -808,7 +822,7 @@ git diff --check
|
||||
|
||||
本方案已按推荐 v1 口径落地:
|
||||
|
||||
- 底部工具栏已新增 `Spine序列帧动画` 入口;入口使用“播放器 + 帧格”图标,位于同一底栏的最右侧,与前一个工具之间不增加分割线,也不使用独立按钮容器;画布中的序列帧生成占位卡片复用同一个图标组件,不再显示旧的小人图标;底栏桌面宽度按完整内容自适应,不再因第十三个按钮出现横向滚动条,窄屏仍可横向滑动但不显示 scrollbar;
|
||||
- 底部工具栏保留唯一的 `Spine序列帧动画` 入口;选中视频或带预览视频的序列帧时默认进入“视频直接转换”,选中角色图或无适用选中图层时默认进入“AI生成/改造”;右键菜单和选中图层浮动工具栏不再提供平行入口;入口使用“播放器 + 帧格”图标,位于同一底栏的最右侧,与前一个工具之间不增加分割线,也不使用独立按钮容器;画布中的序列帧生成占位卡片复用同一个图标组件,不再显示旧的小人图标;底栏桌面宽度按完整内容自适应,不再因第十三个按钮出现横向滚动条,窄屏仍可横向滑动但不显示 scrollbar;
|
||||
- 文本、角色图片、参考视频三种模式统一使用 `character-animation` generation dialog;单参考限制按当前 dialog 语义判断而不是依赖工具栏 `activeTool`,上传前只接收一个文件;图片、视频、音频参考的异步探测、OSS 上传、持久化和回填均绑定发起时的 dialog 身份与分媒体容量 reservation,切换面板后不把结果写入新面板;其他多参考生成面板仍保持原能力;
|
||||
- Seedance 2.0 Fast 支持 480p / 720p,不开放 1080p;
|
||||
- 背景键色冻结为白 `#FFFFFF`、绿 `#00FF00`、蓝 `#0000FF`、紫 `#8000FF`,最终产物继续自动透明化;新独立面板使用显式键色,旧角色图层“生成动画”入口继续由后端自动配色,前端在没有显式键色时不再伪装成绿色已选;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
- 日期:2026-08-12
|
||||
- 状态:设计稿 v3(简化:唯一新增能力是"从角色设定生成多向角色图",拆分与动画全走现有能力)
|
||||
- 实现状态:**核心已实现**(多向图生成:后端 `character-sheet` kind + 前端角色面板开关,2026-08-12)
|
||||
- 拆分集成:角色多方向图(action=`character-sheet.generate`)经既有「拆分图集」切出的单视角切片按 `character` 落库,可直接作为动画参考(`editor_spritesheet_slice_record_asset_kind`,`editor_project_icon.rs`)
|
||||
- 方向固定为**八方向**(上、下、左、右 + 四角,围绕一圈排布,3×3 九宫格中心留空);提示词按八向围绕一圈约束排布,降低拆分粘连概率。
|
||||
- 拆分:复用既有「拆分图集」的「连通块定位 + 外接矩形截图」算法(`slice_generated_icon_spritesheet_all_by_connected_components`),不新增像素级连通块掩码;切出的单视角切片按 `icon` 落库(`split_editor_icon_spritesheet`),不改 `character`。
|
||||
- 关联:`【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md`
|
||||
|
||||
## 1. 背景
|
||||
@@ -14,11 +15,11 @@
|
||||
|
||||
## 2. 范围
|
||||
|
||||
**唯一新增能力**:从角色设定(角色规范图 / 已有单视角角色图)生成一张**多方向角色图**(四视图默认)。
|
||||
**唯一新增能力**:从角色设定(角色规范图 / 已有单视角角色图)生成一张**多方向角色图**(八方向:上、下、左、右 + 四角,围绕一圈排布)。
|
||||
|
||||
**其余全部复用现有能力,不做额外保障:**
|
||||
- 图集类型:复用现有 `icon-spritesheet`(「图集」),不新增资产类型。
|
||||
- 拆分:复用现有「拆分图集」(连通块拆分,`split_editor_icon_spritesheet`),**手动触发**,不自动拆分。
|
||||
- 拆分:复用现有「拆分图集」(`split_editor_icon_spritesheet`,连通块定位 + 外接矩形截图),**手动触发**,不自动拆分。
|
||||
- 方向/数量:**不保证**。生成错了用户重新抽卡;方向标签用户自己看。
|
||||
- 动画:对拆分出的单视角图,按现有动画面板逐个生成。
|
||||
|
||||
@@ -31,7 +32,7 @@
|
||||
【新增】生成多方向角色图 → 产出 icon-spritesheet 图集
|
||||
│
|
||||
▼
|
||||
【现有】用户手动「拆分图集」(连通块)→ N 张单视角图
|
||||
【现有】用户手动「拆分图集」→ N 张单视角图(按 icon 落库)
|
||||
│
|
||||
▼
|
||||
【现有】选单视角图 → 图生视频 → 抽帧 → Spine序列帧
|
||||
@@ -39,17 +40,18 @@
|
||||
|
||||
## 4. 新增能力:生成多方向角色图
|
||||
|
||||
- 入口:在「生成角色形象」面板加一个按钮「生成多方向角色图」;或复用「生成图标素材」面板切到角色多向模式。建议前者。
|
||||
- 入口:在「生成角色形象」面板底部,作为「多方向图」可勾选项,与「像素艺术」并列;勾选后本次生成走 `character-sheet` 图集模式。
|
||||
- 链路:复用图集生成链路(`generate_editor_icon_spritesheet_for_owner`),模型 `nanobanana2`。
|
||||
- 输出:一张 `icon-spritesheet` 图集,四个方向(前/后/左/右),同一角色、方向之间纯色底间隔、彼此分离。
|
||||
- 输出:一张 `icon-spritesheet` 图集,八个方向(正面、右前、右侧、右后、背面、左后、左侧、左前),按 3×3 九宫格围绕一圈排布、中心留空,同一角色、方向之间纯色底充分间隔、彼此分离。
|
||||
- 提示词是质量关键(草案):
|
||||
|
||||
```
|
||||
根据角色设定生成游戏角色的多方向视图图集。
|
||||
画面中只有同一个角色,按四个方向排布:正面、背面、左侧、右侧,每个方向一个完整角色,方向之间相互分离、互不重叠、互不接触。
|
||||
四个方向必须是同一个角色,脸型、发型、服装、配色、武器、头身比、体型全程一致,禁止漂移、换装、变形。
|
||||
每个方向角色完整居中,不裁切头顶、手脚或武器;四个方向底部基线对齐,画面占比一致。
|
||||
背景为单一纯色(绿幕 #00FF00 / 白底 #FFFFFF),平整无纹理、无渐变、无阴影、无地面、无道具;角色之间由纯色背景间隔,便于后续拆分。
|
||||
根据角色设定生成游戏角色的八方向视图图集。
|
||||
画面中只有同一个角色,共八个方向:正面、右前、右侧、右后、背面、左后、左侧、左前,每个方向一个完整角色。
|
||||
八个方向必须是同一个角色,脸型、发型、服装、配色、武器、头身比、体型全程一致,禁止漂移、换装、变形。
|
||||
八个方向按三乘三九宫格围绕一圈排布:上、下、左、右以及四个角落各放一个方向,九宫格中心留空,不放置角色。
|
||||
每个方向角色完整居中,不裁切头顶、手脚或武器;各方向底部基线对齐,画面占比一致。
|
||||
背景为单一纯色(绿幕 #00FF00),平整无纹理、无渐变、无阴影、无地面、无道具;角色之间由纯色背景充分间隔,互不接触、互不重叠,便于后续拆分。
|
||||
禁止额外角色、分身、宠物、文字、水印、边框、编号、网格线。
|
||||
角色设定:{}
|
||||
```
|
||||
@@ -65,7 +67,7 @@
|
||||
## 6. 边界
|
||||
|
||||
- 生成质量靠提示词;图不对 → 用户重新抽卡,**不做数量/方向校验**。
|
||||
- 连通块若粘连(方向贴太近)→ 拆分数量不对 → 重新抽卡或人工处理,不自动兜底。
|
||||
- 外接矩形若粘连(方向贴太近)→ 拆分数量不对 → 重新抽卡或人工处理,不自动兜底;提示词以「围绕一圈 + 纯色背景充分间隔」降低粘连概率。
|
||||
- 成本:多向图一次图像生成;拆分 0 provider 成本;每个方向动画各一次(现有计价)。
|
||||
- 旧链路(直接拿单视角角色图做动画)不变。
|
||||
|
||||
@@ -77,12 +79,12 @@
|
||||
|
||||
## 8. 决策点(留给产品)
|
||||
|
||||
- 四视图 vs 八方向(当前建议四视图:前/后/左/右)。
|
||||
- 入口位置(「生成角色形象」面板按钮 vs 复用图标图集面板)。
|
||||
- 多向图背景色(绿幕/白底)。
|
||||
- 方向数已定**八方向**(上、下、左、右 + 四角,围绕一圈;不再提供四视角选项)。
|
||||
- 入口已定:角色面板底部「多方向图」可勾选项,与「像素艺术」并列。
|
||||
- 多向图背景色(当前固定绿幕 #00FF00)。
|
||||
|
||||
## 9. 改动文件
|
||||
|
||||
- 前端:`ImageCanvasGenerationModel.ts`(多向图提示词/常量)、`ImageCanvasGenerationDialogModel.ts`、`ImageCanvasGenerationSubmissionModel.ts`、`ImageCanvasGenerationComposerView.tsx`(入口按钮)。
|
||||
- 后端:`editor_project_icon.rs`(多向图生成提示词分支)或 `editor_project.rs`;`shared-contracts`(如需 DTO 字段)。
|
||||
- 无需动拆分逻辑、动画逻辑、存储层。
|
||||
- 前端:`ImageCanvasGenerationModel.ts`(多向图提示词/常量)、`ImageCanvasGenerationDialogModel.ts`、`ImageCanvasGenerationSubmissionModel.ts`、`ImageCanvasCharacterGenerationComposerView.tsx`(底部「多方向图」勾选项)、`ImageCanvasGenerationImageOptionsView.tsx`(`extraToggles` 插槽)。
|
||||
- 后端:`editor_project.rs`(`build_editor_character_multi_view_sheet_prompt` 八向围绕一圈提示词)。
|
||||
- 无需动拆分逻辑、动画逻辑、存储层;切片按 `icon` 落库,不加 `character` 分支。
|
||||
|
||||
@@ -1823,6 +1823,29 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_character_animation_video_conversion_requires_bearer_auth() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/character-animations/video-conversions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"sourceVideoSrc": "users/owner/source.mp4",
|
||||
"resolution": "480p"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_character_animation_frame_split_requires_bearer_auth() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -930,7 +930,7 @@ mod tests {
|
||||
for (source, expected_calls) in [
|
||||
(include_str!("editor_project.rs"), 4),
|
||||
(include_str!("editor_project_icon.rs"), 2),
|
||||
(include_str!("character_animation_assets.rs"), 2),
|
||||
(include_str!("character_animation_assets.rs"), 3),
|
||||
(
|
||||
include_str!("vector_engine_audio_generation/generation.rs"),
|
||||
2,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -371,7 +371,6 @@ impl EditorAgentTool for GenerateImageTool {
|
||||
seg_model: None,
|
||||
aspect_ratio: Some(args.aspect_ratio),
|
||||
image_size: Some(args.image_size),
|
||||
character_sheet_views: None,
|
||||
reference_image_srcs: Some(reference_image_srcs),
|
||||
project_id: Some(context.conversation.project_id.clone()),
|
||||
asset_kind: Some("editor_agent_generated_image".to_string()),
|
||||
@@ -480,7 +479,6 @@ impl EditorAgentTool for GenerateCharacterTool {
|
||||
seg_model: Some("birefnet".to_string()),
|
||||
aspect_ratio: Some(args.aspect_ratio),
|
||||
image_size: Some(args.image_size),
|
||||
character_sheet_views: None,
|
||||
reference_image_srcs: Some(reference_image_srcs),
|
||||
project_id: Some(context.conversation.project_id.clone()),
|
||||
asset_kind: Some("character".to_string()),
|
||||
@@ -591,7 +589,6 @@ impl EditorAgentTool for GenerateUiDesignTool {
|
||||
seg_model: None,
|
||||
aspect_ratio: Some(args.aspect_ratio),
|
||||
image_size: Some(args.image_size),
|
||||
character_sheet_views: None,
|
||||
reference_image_srcs: Some(reference_image_srcs),
|
||||
project_id: Some(context.conversation.project_id.clone()),
|
||||
asset_kind: Some("ui-design".to_string()),
|
||||
|
||||
@@ -20,6 +20,10 @@ pub(crate) const EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND: &str =
|
||||
"editor_ui_design_asset_extraction";
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND: &str =
|
||||
"editor_character_animation_generation";
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND: &str =
|
||||
"editor_character_animation_video_conversion";
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND: &str =
|
||||
"editor_character_animation_background_removal";
|
||||
pub(crate) const EDITOR_VIDEO_GENERATION_JOB_KIND: &str = "editor_video_generation";
|
||||
pub(crate) const EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND: &str = "editor_sound_effect_generation";
|
||||
pub(crate) const EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND: &str =
|
||||
|
||||
@@ -350,9 +350,6 @@ pub struct EditorImageGenerationRequest {
|
||||
pub(crate) seg_model: Option<String>,
|
||||
pub(crate) aspect_ratio: Option<String>,
|
||||
pub(crate) image_size: Option<String>,
|
||||
/// 角色多方向图集的视角数:4 或 8;仅 kind=character-sheet 时生效,缺省 4。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) character_sheet_views: Option<u32>,
|
||||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||||
pub(crate) project_id: Option<String>,
|
||||
pub(crate) asset_kind: Option<String>,
|
||||
@@ -491,6 +488,46 @@ pub struct EditorUiDesignAssetExtractionRequest {
|
||||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
|
||||
}
|
||||
|
||||
const EDITOR_GENERATION_OPERATION_ID_MAX_BYTES: usize = 128;
|
||||
|
||||
/// 将请求级 request id 收敛为可持久化的编辑器 operation id。
|
||||
///
|
||||
/// request id 会进入 SpacetimeDB 幂等键、资产对象标识和画布层标识,不能把任意
|
||||
/// header 内容直接带入这些边界。这里只允许客户端和队列实际使用的稳定 ID 字符集,
|
||||
/// 超限或包含路径/控制字符时拒绝请求,而不是静默改写导致幂等键碰撞。
|
||||
pub(crate) fn validate_editor_generation_operation_id(
|
||||
operation_id: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let operation_id = operation_id.trim();
|
||||
if operation_id.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-generation-operation",
|
||||
"message": "编辑器生成 request id 不能为空。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if operation_id.len() > EDITOR_GENERATION_OPERATION_ID_MAX_BYTES {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-generation-operation",
|
||||
"message": format!(
|
||||
"编辑器生成 request id 不能超过 {EDITOR_GENERATION_OPERATION_ID_MAX_BYTES} 个字节。"
|
||||
),
|
||||
})));
|
||||
}
|
||||
if !operation_id.chars().all(|character| {
|
||||
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | ':')
|
||||
}) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-generation-operation",
|
||||
"message": "编辑器生成 request id 含有不支持的字符。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(operation_id.to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct EditorGenerationOperationContext {
|
||||
pub(crate) operation_kind: String,
|
||||
@@ -588,9 +625,10 @@ impl EditorGenerationCaller {
|
||||
"message": format!("序列化编辑器生成幂等请求失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let operation_id = validate_editor_generation_operation_id(request_context.request_id())?;
|
||||
self.operation = Some(EditorGenerationOperationContext {
|
||||
operation_kind: operation_kind.to_string(),
|
||||
operation_id: request_context.request_id().trim().to_string(),
|
||||
operation_id,
|
||||
operation_fingerprint: editor_generation_request_fingerprint(
|
||||
operation_kind,
|
||||
request_payload_json.as_str(),
|
||||
@@ -2521,7 +2559,6 @@ pub async fn generate_editor_scene(
|
||||
seg_model: None,
|
||||
aspect_ratio: Some(generation_options.aspect_ratio.to_string()),
|
||||
image_size: Some(generation_options.image_size.to_string()),
|
||||
character_sheet_views: None,
|
||||
reference_image_srcs: (!payload.reference_image_srcs.is_empty())
|
||||
.then_some(payload.reference_image_srcs),
|
||||
project_id: payload.project_id,
|
||||
@@ -2821,16 +2858,6 @@ where
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
let is_character_generation = matches!(normalized_kind, Some("character"));
|
||||
let is_character_sheet_generation = matches!(normalized_kind, Some("character-sheet"));
|
||||
if is_character_sheet_generation
|
||||
&& !matches!(payload.character_sheet_views.unwrap_or(4), 4 | 8)
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-generation",
|
||||
"message": "characterSheetViews 只支持 4 或 8。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character"));
|
||||
let (image_style, mut generation_warning) =
|
||||
normalize_editor_image_generation_style(payload.style.as_deref(), pixel_art_supported);
|
||||
@@ -3039,7 +3066,7 @@ where
|
||||
screen_color.expect("character generation should have screen color"),
|
||||
)
|
||||
} else if is_character_sheet_generation {
|
||||
// 多方向图集以纯绿底间隔各方向,供连通块拆分;不参与编辑器的背景自动选色。
|
||||
// 多方向图集以纯绿底间隔各方向,便于后续拆分;不参与编辑器的背景自动选色。
|
||||
build_editor_character_multi_view_sheet_prompt(
|
||||
role_setting.as_str(),
|
||||
crate::editor_green_screen::EditorScreenBackgroundColor {
|
||||
@@ -3049,7 +3076,6 @@ where
|
||||
green: 255,
|
||||
blue: 0,
|
||||
},
|
||||
payload.character_sheet_views.unwrap_or(4),
|
||||
)
|
||||
} else if is_ui_design_generation {
|
||||
build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references)
|
||||
@@ -5919,10 +5945,10 @@ pub(crate) async fn remove_editor_image_background_for_owner(
|
||||
))
|
||||
}
|
||||
|
||||
struct EditorBackgroundRemovalImage {
|
||||
image: DownloadedOpenAiImage,
|
||||
width: u32,
|
||||
height: u32,
|
||||
pub(crate) struct EditorBackgroundRemovalImage {
|
||||
pub(crate) image: DownloadedOpenAiImage,
|
||||
pub(crate) width: u32,
|
||||
pub(crate) height: u32,
|
||||
}
|
||||
|
||||
fn validate_editor_pixel_art_snap_canvas_completion(
|
||||
@@ -7022,7 +7048,7 @@ fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration {
|
||||
.saturating_add(EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW)
|
||||
}
|
||||
|
||||
async fn request_editor_background_removal_image_with_bgfilter_worker(
|
||||
pub(crate) async fn request_editor_background_removal_image_with_bgfilter_worker(
|
||||
state: &AppState,
|
||||
source_object_key: &str,
|
||||
audit: &crate::external_api_audit::ExternalApiAuditContext,
|
||||
@@ -7833,7 +7859,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
),
|
||||
path_kind: "ui-design-assets",
|
||||
asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND,
|
||||
record_asset_kind: "icon",
|
||||
persistence_provider: "vector-engine",
|
||||
},
|
||||
)
|
||||
@@ -10894,17 +10919,16 @@ fn build_editor_character_image_prompt(
|
||||
fn build_editor_character_multi_view_sheet_prompt(
|
||||
role_setting: &str,
|
||||
screen_color: EditorScreenBackgroundColor,
|
||||
view_count: u32,
|
||||
) -> String {
|
||||
let (view_count_text, view_layout) = match view_count {
|
||||
8 => ("八", "正面、右前、右侧、右后、背面、左后、左侧、左前"),
|
||||
_ => ("四", "正面、背面、左侧、右侧"),
|
||||
};
|
||||
// 八方向围绕一圈:正面、右前、右侧、右后、背面、左后、左侧、左前,按 3×3 九宫格外圈
|
||||
// 排布、中心留空,方向之间由纯色背景充分间隔,使后续外接矩形拆分不易粘连。
|
||||
const VIEW_LAYOUT: &str = "正面、右前、右侧、右后、背面、左后、左侧、左前";
|
||||
vec![
|
||||
format!(
|
||||
"根据角色设定生成游戏角色的多方向视图图集。画面中只有同一个角色,按{view_count_text}个方向排布:{view_layout},每个方向一个完整角色,方向之间相互分离、互不重叠、互不接触。\
|
||||
{view_count_text}个方向必须是同一个角色,脸型、发型、服装、配色、武器、头身比、体型全程一致,禁止漂移、换装、变形。\
|
||||
每个方向角色完整居中,不裁切头顶、手脚或武器;各方向底部基线对齐,画面占比一致。{};角色之间由纯色背景间隔,便于后续拆分。\
|
||||
"根据角色设定生成游戏角色的八方向视图图集。画面中只有同一个角色,共八个方向:{VIEW_LAYOUT},每个方向一个完整角色。\
|
||||
八个方向必须是同一个角色,脸型、发型、服装、配色、武器、头身比、体型全程一致,禁止漂移、换装、变形。\
|
||||
八个方向按三乘三九宫格围绕一圈排布:上、下、左、右以及四个角落各放一个方向,九宫格中心留空,不放置角色。\
|
||||
每个方向角色完整居中,不裁切头顶、手脚或武器;各方向底部基线对齐,画面占比一致。{};角色之间由纯色背景充分间隔,互不接触、互不重叠,便于后续拆分。\
|
||||
禁止额外角色、分身、宠物、文字、水印、边框、编号、网格线。",
|
||||
editor_green_screen_character_prompt_clause(screen_color)
|
||||
),
|
||||
@@ -12351,6 +12375,25 @@ mod tests {
|
||||
thread,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn editor_generation_operation_id_accepts_stable_request_id_characters() {
|
||||
assert_eq!(
|
||||
validate_editor_generation_operation_id(" web-abc_123.v1:retry ").unwrap(),
|
||||
"web-abc_123.v1:retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_operation_id_rejects_unsafe_or_oversized_request_ids() {
|
||||
for request_id in ["", " ", "request/id", "request id", "request\n id"] {
|
||||
assert!(
|
||||
validate_editor_generation_operation_id(request_id).is_err(),
|
||||
"request id should be rejected: {request_id:?}"
|
||||
);
|
||||
}
|
||||
assert!(validate_editor_generation_operation_id(&"x".repeat(129)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_image_prompt_validation_stays_outside_prompt_builder_execution() {
|
||||
assert!(validate_editor_image_generation_prompt("生成森林场景").is_ok());
|
||||
@@ -16618,38 +16661,20 @@ mod tests {
|
||||
green: 255,
|
||||
blue: 0,
|
||||
},
|
||||
4,
|
||||
);
|
||||
|
||||
assert!(prompt.contains("多方向视图图集"));
|
||||
assert!(prompt.contains("四个方向排布:正面、背面、左侧、右侧"));
|
||||
assert!(prompt.contains("方向之间相互分离、互不重叠、互不接触"));
|
||||
assert!(prompt.contains("八方向视图图集"));
|
||||
assert!(prompt.contains("共八个方向:正面、右前、右侧、右后、背面、左后、左侧、左前"));
|
||||
assert!(prompt.contains("八个方向必须是同一个角色"));
|
||||
assert!(prompt.contains("按三乘三九宫格围绕一圈排布"));
|
||||
assert!(prompt.contains("九宫格中心留空"));
|
||||
assert!(prompt.contains("脸型、发型、服装、配色、武器、头身比、体型全程一致"));
|
||||
assert!(prompt.contains("背景固定为单一纯色背景 纯绿色 #00FF00"));
|
||||
assert!(prompt.contains("角色之间由纯色背景间隔,便于后续拆分"));
|
||||
assert!(prompt.contains("角色之间由纯色背景充分间隔,互不接触、互不重叠"));
|
||||
assert!(prompt.contains("禁止额外角色、分身、宠物、文字、水印、边框、编号、网格线"));
|
||||
assert!(prompt.contains("角色设定:日系魔法少女"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_multi_view_sheet_prompt_supports_eight_directions() {
|
||||
let prompt = build_editor_character_multi_view_sheet_prompt(
|
||||
"日系魔法少女",
|
||||
crate::editor_green_screen::EditorScreenBackgroundColor {
|
||||
label: "纯绿色",
|
||||
hex: "#00FF00",
|
||||
red: 0,
|
||||
green: 255,
|
||||
blue: 0,
|
||||
},
|
||||
8,
|
||||
);
|
||||
|
||||
assert!(prompt.contains("八个方向排布:正面、右前、右侧、右后、背面、左后、左侧、左前"));
|
||||
assert!(prompt.contains("八个方向必须是同一个角色"));
|
||||
assert!(prompt.contains("每个方向角色完整居中"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_sheet_kind_routes_to_sheet_prompt_and_icon_spritesheet_asset_kind() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -638,7 +638,6 @@ fn build_icon_spec_image_request(
|
||||
seg_model: None,
|
||||
aspect_ratio: Some(ICON_SPEC_ASPECT_RATIO.to_string()),
|
||||
image_size: Some(ICON_SPEC_IMAGE_SIZE.to_string()),
|
||||
character_sheet_views: None,
|
||||
reference_image_srcs: payload.reference_id.map(|reference_id| vec![reference_id]),
|
||||
project_id: payload.project_id,
|
||||
asset_kind: Some("icon-spec".to_string()),
|
||||
@@ -841,9 +840,6 @@ pub(crate) struct PersistEditorSpritesheetSlicesInput {
|
||||
pub(crate) generation_inputs: Option<Value>,
|
||||
pub(crate) path_kind: &'static str,
|
||||
pub(crate) asset_kind: &'static str,
|
||||
/// 切片资源/素材记录的 asset_kind(前端据此判断素材语义)。图标图集默认 "icon";
|
||||
/// 角色多方向图集拆分传 "character",使单视角切片可直接作为角色动画参考。
|
||||
pub(crate) record_asset_kind: &'static str,
|
||||
pub(crate) persistence_provider: &'static str,
|
||||
}
|
||||
|
||||
@@ -964,7 +960,7 @@ pub(crate) async fn persist_editor_spritesheet_slices(
|
||||
provider: Some(input.provider.clone()),
|
||||
task_id: Some(input.task_id.clone()),
|
||||
source_resource_id: normalize_optional_string(input.source_resource_id.clone()),
|
||||
asset_kind: Some(input.record_asset_kind.to_string()),
|
||||
asset_kind: Some("icon".to_string()),
|
||||
generation_inputs_json: generation_inputs_json.clone(),
|
||||
updated_at_micros: now_micros,
|
||||
image_sequence_frames_json: None,
|
||||
@@ -994,7 +990,7 @@ pub(crate) async fn persist_editor_spritesheet_slices(
|
||||
model: Some(input.model.clone()),
|
||||
provider: Some(input.provider.clone()),
|
||||
task_id: Some(input.task_id.clone()),
|
||||
asset_kind: Some(input.record_asset_kind.to_string()),
|
||||
asset_kind: Some("icon".to_string()),
|
||||
generation_inputs_json: generation_inputs_json.clone(),
|
||||
source_resource_id: resource_id.clone(),
|
||||
generation_cost_mud_points: 0,
|
||||
@@ -1967,7 +1963,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
),
|
||||
path_kind: "icon-spritesheet-assets",
|
||||
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
|
||||
record_asset_kind: "icon",
|
||||
persistence_provider: "vector-engine",
|
||||
},
|
||||
)
|
||||
@@ -2175,17 +2170,6 @@ pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// 图集切片记录 asset_kind:角色多方向图集(action=character-sheet.generate)拆出的
|
||||
/// 单视角切片按 character 落库(可直接作动画参考),其余图集按 icon。
|
||||
fn editor_spritesheet_slice_record_asset_kind(source_generation_inputs: Option<&Value>) -> &'static str {
|
||||
source_generation_inputs
|
||||
.and_then(|inputs| inputs.get("action"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|action| action == "character-sheet.generate")
|
||||
.then_some("character")
|
||||
.unwrap_or("icon")
|
||||
}
|
||||
|
||||
pub async fn split_editor_icon_spritesheet(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -2325,10 +2309,6 @@ pub async fn split_editor_icon_spritesheet(
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Genarrative".to_string());
|
||||
let generation_inputs = source_resource.generation_inputs.clone();
|
||||
// 中文注释:角色多方向图集(action=character-sheet.generate)拆分出的单视角切片按
|
||||
// character 落库,使其可直接作为角色动画参考;普通图标图集仍按 icon。
|
||||
let slice_record_asset_kind =
|
||||
editor_spritesheet_slice_record_asset_kind(source_resource.generation_inputs.as_ref());
|
||||
let icon_image_srcs = persist_editor_spritesheet_slices(
|
||||
&state,
|
||||
slices,
|
||||
@@ -2348,7 +2328,6 @@ pub async fn split_editor_icon_spritesheet(
|
||||
generation_inputs,
|
||||
path_kind: "icon-spritesheet-slices",
|
||||
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
|
||||
record_asset_kind: slice_record_asset_kind,
|
||||
persistence_provider: "genarrative",
|
||||
},
|
||||
)
|
||||
@@ -2698,32 +2677,6 @@ pub(crate) fn validate_editor_icon_spritesheet_dimensions(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sprite_slice_record_asset_kind_marks_character_sheet_slices_as_character() {
|
||||
assert_eq!(
|
||||
editor_spritesheet_slice_record_asset_kind(None),
|
||||
"icon"
|
||||
);
|
||||
assert_eq!(
|
||||
editor_spritesheet_slice_record_asset_kind(Some(&json!({
|
||||
"action": "icon.generate"
|
||||
}))),
|
||||
"icon"
|
||||
);
|
||||
assert_eq!(
|
||||
editor_spritesheet_slice_record_asset_kind(Some(&json!({
|
||||
"action": "character-sheet.generate"
|
||||
}))),
|
||||
"character"
|
||||
);
|
||||
assert_eq!(
|
||||
editor_spritesheet_slice_record_asset_kind(Some(&json!({
|
||||
"version": 2
|
||||
}))),
|
||||
"icon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_genre_serde_uses_chinese_slugs() {
|
||||
for genre in GameGenre::ALL {
|
||||
|
||||
@@ -2,6 +2,7 @@ use axum::http::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
EDITOR_ASSET_LABEL_MAX_CHARS, EditorCharacterAnimationGenerateRequest,
|
||||
EditorCharacterAnimationVideoConversionRequest,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -16,6 +17,131 @@ pub(crate) const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast";
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_REFERENCE_VIDEO_MAX_BYTES: u64 = 50 * 1024 * 1024;
|
||||
const EDITOR_CHARACTER_ANIMATION_REFERENCE_VIDEO_MIN_DURATION_SECONDS: f64 = 2.0;
|
||||
const EDITOR_CHARACTER_ANIMATION_REFERENCE_VIDEO_MAX_DURATION_SECONDS: f64 = 15.0;
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES: u32 = 120;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct EditorCharacterAnimationVideoConversionPlan {
|
||||
pub(crate) duration_seconds: f64,
|
||||
pub(crate) duration_ms: u64,
|
||||
pub(crate) source_fps: f64,
|
||||
pub(crate) sampling_fps: f64,
|
||||
pub(crate) frame_count: u32,
|
||||
pub(crate) frame_width: u32,
|
||||
pub(crate) frame_height: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_editor_character_animation_video_conversion_resolution(
|
||||
value: &str,
|
||||
) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"480p" => Ok("480p"),
|
||||
"720p" => Ok("720p"),
|
||||
_ => Err(editor_character_animation_bad_request(
|
||||
"resolution 只支持 480p 或 720p。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_editor_character_animation_video_conversion_plan(
|
||||
duration_seconds: f64,
|
||||
source_fps: f64,
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
resolution: &str,
|
||||
) -> Result<EditorCharacterAnimationVideoConversionPlan, AppError> {
|
||||
validate_editor_character_animation_reference_video_duration(duration_seconds)?;
|
||||
if !source_fps.is_finite() || source_fps <= 0.0 {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"源视频缺少有效平均帧率。",
|
||||
));
|
||||
}
|
||||
if source_width == 0 || source_height == 0 {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"源视频缺少有效显示尺寸。",
|
||||
));
|
||||
}
|
||||
let max_long_edge =
|
||||
match normalize_editor_character_animation_video_conversion_resolution(resolution)? {
|
||||
"480p" => 480_u32,
|
||||
"720p" => 720_u32,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let source_long_edge = source_width.max(source_height);
|
||||
let scale = (f64::from(max_long_edge) / f64::from(source_long_edge)).min(1.0);
|
||||
let frame_width = (f64::from(source_width) * scale).round().max(1.0) as u32;
|
||||
let frame_height = (f64::from(source_height) * scale).round().max(1.0) as u32;
|
||||
let sampling_fps = source_fps.min(8.0).max(1.0);
|
||||
let frame_count = (duration_seconds * sampling_fps).round().clamp(
|
||||
2.0,
|
||||
f64::from(EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES),
|
||||
) as u32;
|
||||
Ok(EditorCharacterAnimationVideoConversionPlan {
|
||||
duration_seconds,
|
||||
duration_ms: (duration_seconds * 1_000.0).round().max(1.0) as u64,
|
||||
source_fps,
|
||||
sampling_fps,
|
||||
frame_count,
|
||||
frame_width,
|
||||
frame_height,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_editor_character_animation_ffprobe_frame_rate(value: &str) -> Option<f64> {
|
||||
let value = value.trim();
|
||||
if let Some((numerator, denominator)) = value.split_once('/') {
|
||||
let numerator = numerator.trim().parse::<f64>().ok()?;
|
||||
let denominator = denominator.trim().parse::<f64>().ok()?;
|
||||
return (numerator.is_finite()
|
||||
&& denominator.is_finite()
|
||||
&& numerator > 0.0
|
||||
&& denominator > 0.0)
|
||||
.then_some(numerator / denominator);
|
||||
}
|
||||
value
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
}
|
||||
|
||||
pub(crate) fn build_authoritative_editor_character_animation_video_conversion_inputs(
|
||||
client_generation_inputs: Option<Value>,
|
||||
payload: &EditorCharacterAnimationVideoConversionRequest,
|
||||
source_object_key: &str,
|
||||
plan: EditorCharacterAnimationVideoConversionPlan,
|
||||
) -> Result<Value, AppError> {
|
||||
let resolution =
|
||||
normalize_editor_character_animation_video_conversion_resolution(&payload.resolution)?;
|
||||
let source_object_key = source_object_key.trim().trim_start_matches('/').to_string();
|
||||
if source_object_key.is_empty() {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"源视频缺少稳定 objectKey。",
|
||||
));
|
||||
}
|
||||
let mut result = client_generation_inputs
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
result.insert("version".to_string(), json!(2));
|
||||
result.insert("action".to_string(), json!("character-animation.convert"));
|
||||
result.insert(
|
||||
"fields".to_string(),
|
||||
json!([
|
||||
{ "id": "sourceVideoSrc", "title": "源视频", "value": source_object_key },
|
||||
{ "id": "resolution", "title": "最大长边", "value": resolution },
|
||||
{ "id": "actualWidth", "title": "实际宽度", "value": plan.frame_width },
|
||||
{ "id": "actualHeight", "title": "实际高度", "value": plan.frame_height },
|
||||
{ "id": "sourceDurationMs", "title": "源时长", "value": plan.duration_ms },
|
||||
{ "id": "sourceFps", "title": "源帧率", "value": plan.source_fps },
|
||||
{ "id": "frameCount", "title": "实际帧数", "value": plan.frame_count },
|
||||
{ "id": "samplingFps", "title": "采样率", "value": plan.sampling_fps },
|
||||
{ "id": "processingMethod", "title": "处理方式", "value": "视频直接转换" }
|
||||
]),
|
||||
);
|
||||
result.insert(
|
||||
"references".to_string(),
|
||||
json!([{ "id": "source", "title": "源视频" }]),
|
||||
);
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum EditorCharacterAnimationInputMode {
|
||||
@@ -80,6 +206,10 @@ pub(crate) fn normalize_editor_character_animation_request_with_pricing(
|
||||
);
|
||||
let reference_src = trim_optional_text(payload.reference_src.as_deref())
|
||||
.or_else(|| trim_optional_text(Some(payload.source_image_src.as_str())));
|
||||
let has_appearance_references = payload
|
||||
.appearance_reference_srcs
|
||||
.iter()
|
||||
.any(|src| !src.trim().is_empty());
|
||||
if input_mode == EditorCharacterAnimationInputMode::Text && reference_src.is_some() {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"文本模式不能携带参考素材。",
|
||||
@@ -90,14 +220,21 @@ pub(crate) fn normalize_editor_character_animation_request_with_pricing(
|
||||
"图片或视频模式必须提供参考素材。",
|
||||
));
|
||||
}
|
||||
if has_appearance_references && input_mode != EditorCharacterAnimationInputMode::Video {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"appearanceReferenceSrcs 仅支持视频模式。",
|
||||
));
|
||||
}
|
||||
let prompt_text = payload
|
||||
.prompt_text
|
||||
.trim()
|
||||
.chars()
|
||||
.take(4000)
|
||||
.collect::<String>();
|
||||
if prompt_text.is_empty() {
|
||||
return Err(editor_character_animation_bad_request("动画描述不能为空。"));
|
||||
if prompt_text.is_empty() && reference_src.is_none() {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"动画描述和参考素材不能同时为空。",
|
||||
));
|
||||
}
|
||||
let resolution = normalize_editor_character_animation_resolution(payload.resolution.as_str())?;
|
||||
let ratio = normalize_editor_character_animation_ratio(payload.ratio.as_str())?;
|
||||
@@ -250,14 +387,18 @@ pub(crate) fn validate_editor_character_animation_reference_content_type(
|
||||
.to_ascii_lowercase();
|
||||
let matches = match expected_kind {
|
||||
EditorCharacterAnimationReferenceMediaKind::Image => normalized.starts_with("image/"),
|
||||
EditorCharacterAnimationReferenceMediaKind::Video => normalized.starts_with("video/"),
|
||||
EditorCharacterAnimationReferenceMediaKind::Video => {
|
||||
normalized == "video/mp4" || normalized == "video/quicktime"
|
||||
}
|
||||
};
|
||||
if matches {
|
||||
return Ok(());
|
||||
}
|
||||
let message = match expected_kind {
|
||||
EditorCharacterAnimationReferenceMediaKind::Image => "图片模式只能使用真实图片素材。",
|
||||
EditorCharacterAnimationReferenceMediaKind::Video => "视频模式只能使用真实视频素材。",
|
||||
EditorCharacterAnimationReferenceMediaKind::Video => {
|
||||
"视频模式只能使用真实 MP4 或 MOV 视频素材。"
|
||||
}
|
||||
};
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -324,13 +465,15 @@ pub(crate) fn build_authoritative_editor_character_animation_generation_inputs(
|
||||
payload: &EditorCharacterAnimationGenerateRequest,
|
||||
input_mode: EditorCharacterAnimationInputMode,
|
||||
reference_object_key: Option<&str>,
|
||||
appearance_object_keys: &[String],
|
||||
) -> Result<Value, AppError> {
|
||||
const AUTHORITATIVE_FIELD_IDS: [&str; 11] = [
|
||||
const AUTHORITATIVE_FIELD_IDS: [&str; 12] = [
|
||||
"inputMode",
|
||||
"referenceSrc",
|
||||
"referenceMediaType",
|
||||
"referenceWidth",
|
||||
"referenceHeight",
|
||||
"appearanceReferenceSrc",
|
||||
"prompt",
|
||||
"backgroundColor",
|
||||
"resolution",
|
||||
@@ -345,8 +488,10 @@ pub(crate) fn build_authoritative_editor_character_animation_generation_inputs(
|
||||
.chars()
|
||||
.take(4000)
|
||||
.collect::<String>();
|
||||
if prompt.is_empty() {
|
||||
return Err(editor_character_animation_bad_request("动画描述不能为空。"));
|
||||
if prompt.is_empty() && reference_object_key.is_none() && appearance_object_keys.is_empty() {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"动画描述和参考素材不能同时为空。",
|
||||
));
|
||||
}
|
||||
let resolution = normalize_editor_character_animation_resolution(payload.resolution.as_str())?;
|
||||
// generationInputs 保存用户请求语义;same 不能被 provider 使用的具体比例覆盖。
|
||||
@@ -390,7 +535,30 @@ pub(crate) fn build_authoritative_editor_character_animation_generation_inputs(
|
||||
json!({ "id": "referenceWidth", "title": "参考宽度", "value": payload.source_width }),
|
||||
json!({ "id": "referenceHeight", "title": "参考高度", "value": payload.source_height }),
|
||||
]);
|
||||
reference_slots.push(json!({ "id": "source", "title": reference_title }));
|
||||
reference_slots.push(json!({
|
||||
"id": if input_mode == EditorCharacterAnimationInputMode::Video { "motion" } else { "source" },
|
||||
"title": reference_title
|
||||
}));
|
||||
}
|
||||
if !appearance_object_keys.is_empty() {
|
||||
if input_mode != EditorCharacterAnimationInputMode::Video {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"角色外观图仅支持视频模式。",
|
||||
));
|
||||
}
|
||||
for appearance_object_key in appearance_object_keys {
|
||||
let appearance_object_key =
|
||||
appearance_object_key.trim().trim_start_matches('/');
|
||||
if appearance_object_key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
fields.push(json!({
|
||||
"id": "appearanceReferenceSrc",
|
||||
"title": "角色外观",
|
||||
"value": appearance_object_key,
|
||||
}));
|
||||
reference_slots.push(json!({ "id": "appearance", "title": "角色外观" }));
|
||||
}
|
||||
}
|
||||
fields.push(json!({ "id": "prompt", "title": "动作描述", "value": prompt }));
|
||||
|
||||
@@ -705,6 +873,7 @@ mod tests {
|
||||
input_mode: None,
|
||||
reference_media_type: None,
|
||||
reference_src: None,
|
||||
appearance_reference_srcs: Vec::new(),
|
||||
prompt_text: "待机呼吸".to_string(),
|
||||
background_color: None,
|
||||
screen_color: None,
|
||||
@@ -717,11 +886,52 @@ mod tests {
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
appearance_source_resource_ids: Vec::new(),
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_conversion_plan_honors_fractional_fps_rotation_size_and_no_upscale() {
|
||||
assert!(
|
||||
(parse_editor_character_animation_ffprobe_frame_rate("24000/1001").unwrap() - 23.976)
|
||||
.abs()
|
||||
< 0.001
|
||||
);
|
||||
let plan = build_editor_character_animation_video_conversion_plan(
|
||||
2.5,
|
||||
24_000.0 / 1_001.0,
|
||||
360,
|
||||
640,
|
||||
"720p",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((plan.frame_width, plan.frame_height), (360, 640));
|
||||
assert_eq!(plan.frame_count, 20);
|
||||
assert_eq!(plan.duration_ms, 2_500);
|
||||
assert_eq!(plan.sampling_fps, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_conversion_plan_downscales_and_caps_at_120_frames() {
|
||||
let plan =
|
||||
build_editor_character_animation_video_conversion_plan(15.0, 60.0, 1920, 1080, "480p")
|
||||
.unwrap();
|
||||
assert_eq!((plan.frame_width, plan.frame_height), (480, 270));
|
||||
assert_eq!(plan.frame_count, 120);
|
||||
assert_eq!(plan.sampling_fps, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_conversion_plan_uses_one_fps_floor_and_two_frame_minimum() {
|
||||
let plan =
|
||||
build_editor_character_animation_video_conversion_plan(2.0, 0.5, 320, 180, "480p")
|
||||
.unwrap();
|
||||
assert_eq!(plan.sampling_fps, 1.0);
|
||||
assert_eq!(plan.frame_count, 2);
|
||||
}
|
||||
|
||||
fn generation_field<'a>(inputs: &'a Value, id: &str) -> Option<&'a Value> {
|
||||
inputs
|
||||
.get("fields")
|
||||
@@ -893,7 +1103,7 @@ mod tests {
|
||||
assert!(
|
||||
video_error
|
||||
.body_text()
|
||||
.contains("视频模式只能使用真实视频素材")
|
||||
.contains("视频模式只能使用真实 MP4 或 MOV 视频素材")
|
||||
);
|
||||
|
||||
let image_error = validate_editor_character_animation_reference_content_type(
|
||||
@@ -1017,6 +1227,7 @@ mod tests {
|
||||
&request,
|
||||
EditorCharacterAnimationInputMode::Text,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.expect("text request should always produce a recoverable recipe");
|
||||
|
||||
@@ -1072,6 +1283,7 @@ mod tests {
|
||||
&request,
|
||||
EditorCharacterAnimationInputMode::Image,
|
||||
Some("/users/owner/hero.png"),
|
||||
&[],
|
||||
)
|
||||
.expect("authoritative image recipe should build");
|
||||
|
||||
@@ -1131,6 +1343,7 @@ mod tests {
|
||||
&request,
|
||||
EditorCharacterAnimationInputMode::Image,
|
||||
Some("users/owner/hero.png"),
|
||||
&[],
|
||||
)
|
||||
.expect("legacy image recipe should preserve automatic background semantics");
|
||||
|
||||
@@ -1151,6 +1364,7 @@ mod tests {
|
||||
&video_request,
|
||||
EditorCharacterAnimationInputMode::Video,
|
||||
Some("users/owner/action.mp4"),
|
||||
&[],
|
||||
)
|
||||
.expect("video recipe should persist the default green background");
|
||||
assert_eq!(
|
||||
|
||||
@@ -152,6 +152,7 @@ impl From<ExternalEditorCharacterAnimationGenerateRequest>
|
||||
input_mode: None,
|
||||
reference_media_type: None,
|
||||
reference_src: None,
|
||||
appearance_reference_srcs: Vec::new(),
|
||||
prompt_text: payload.prompt_text,
|
||||
background_color: None,
|
||||
screen_color: payload.screen_color,
|
||||
@@ -164,6 +165,7 @@ impl From<ExternalEditorCharacterAnimationGenerateRequest>
|
||||
canvas_completion: payload.canvas_completion,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
source_resource_id: payload.source_resource_id,
|
||||
appearance_source_resource_ids: Vec::new(),
|
||||
asset_folder_id: payload.asset_folder_id,
|
||||
asset_label: payload.asset_label,
|
||||
}
|
||||
@@ -1553,6 +1555,27 @@ mod tests {
|
||||
.expect_err("External v1 不应接收内部三模态扩展字段");
|
||||
|
||||
assert!(error.to_string().contains("inputMode"));
|
||||
|
||||
let appearance_error =
|
||||
serde_json::from_value::<ExternalEditorCharacterAnimationGenerateRequest>(json!({
|
||||
"sourceLayerId": "layer-character",
|
||||
"sourceImageSrc": "editor/uploads/character.png",
|
||||
"sourceWidth": 512,
|
||||
"sourceHeight": 512,
|
||||
"promptText": "原地待机",
|
||||
"resolution": "480p",
|
||||
"ratio": "same",
|
||||
"frameCount": 32,
|
||||
"durationSeconds": 4,
|
||||
"model": "seedance2.0-fast",
|
||||
"appearanceReferenceSrc": "editor/uploads/appearance.png"
|
||||
}))
|
||||
.expect_err("External v1 不应接收内部角色外观字段");
|
||||
assert!(
|
||||
appearance_error
|
||||
.to_string()
|
||||
.contains("appearanceReferenceSrc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,12 +29,16 @@ const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str =
|
||||
use crate::{
|
||||
asset_billing::with_external_generation_billing_attempt_context,
|
||||
character_animation_assets::{
|
||||
convert_editor_character_animation_video_for_owner,
|
||||
generate_editor_character_animation_for_owner, generate_editor_video_for_owner,
|
||||
remove_editor_character_animation_background_for_owner,
|
||||
},
|
||||
config::AppConfig,
|
||||
editor_generation_queue::{
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_ICON_SPEC_GENERATION_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND, EDITOR_ICON_SPEC_GENERATION_JOB_KIND,
|
||||
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND,
|
||||
EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||
@@ -958,6 +962,64 @@ async fn process_external_generation_job_once(
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<
|
||||
shared_contracts::assets::EditorCharacterAnimationVideoConversionRequest,
|
||||
>(job.request_payload_json.as_str())
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("视频直接转换任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job, provider_deadline);
|
||||
match convert_editor_character_animation_video_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
editor_generation_worker_caller(&worker_id, &job)?,
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<
|
||||
shared_contracts::assets::EditorCharacterAnimationBackgroundRemovalRequest,
|
||||
>(job.request_payload_json.as_str())
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("Spine序列帧去背景任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job, provider_deadline);
|
||||
match remove_editor_character_animation_background_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
editor_generation_worker_caller(&worker_id, &job)?,
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_VIDEO_GENERATION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<
|
||||
shared_contracts::assets::EditorVideoGenerateRequest,
|
||||
@@ -1633,6 +1695,8 @@ fn external_generation_worker_job_timeout(config: &AppConfig, job_kind: &str) ->
|
||||
| EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
|
||||
| EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
|
||||
| EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND
|
||||
| EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND
|
||||
| EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND
|
||||
| EDITOR_VIDEO_GENERATION_JOB_KIND => config.external_generation_worker_long_job_timeout,
|
||||
_ => config.external_generation_worker_job_timeout,
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ use crate::{
|
||||
assets::get_asset_history,
|
||||
auth::require_bearer_auth,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, generate_editor_character_animation, generate_editor_video,
|
||||
get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, save_character_workflow_cache,
|
||||
convert_editor_character_animation_video, generate_character_animation,
|
||||
generate_editor_character_animation, generate_editor_video, get_character_animation_job,
|
||||
get_character_workflow_cache, import_character_animation_video,
|
||||
list_character_animation_templates, publish_character_animation,
|
||||
remove_editor_character_animation_background, save_character_workflow_cache,
|
||||
split_editor_character_animation_frames,
|
||||
},
|
||||
character_visual_assets::{
|
||||
@@ -129,12 +130,24 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/character-animations/video-conversions",
|
||||
post(convert_editor_character_animation_video).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/character-animations/frame-splits",
|
||||
post(split_editor_character_animation_frames).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/character-animations/background-removals",
|
||||
post(remove_editor_character_animation_background).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/videos/generations",
|
||||
post(generate_editor_video).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -104,6 +104,11 @@ pub struct OssHeadObjectRequest {
|
||||
pub object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssDeleteObjectRequest {
|
||||
pub object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssPutObjectRequest {
|
||||
pub prefix: LegacyAssetPrefix,
|
||||
@@ -218,6 +223,7 @@ pub struct OssClient {
|
||||
pub enum OssRequestOperation {
|
||||
Put,
|
||||
Head,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -841,6 +847,51 @@ impl OssClient {
|
||||
result
|
||||
}
|
||||
|
||||
/// 删除服务端生成但尚未登记为正式资产的对象。404 视为幂等成功。
|
||||
pub async fn delete_object(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
request: OssDeleteObjectRequest,
|
||||
) -> Result<(), OssError> {
|
||||
let started_at = Instant::now();
|
||||
let object_key = normalize_object_key(&request.object_key)?;
|
||||
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
|
||||
.map_err(|error| {
|
||||
request_error(
|
||||
OssRequestOperation::Delete,
|
||||
&format!("构造 OSS 对象 URL 失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let response = send_signed_request(
|
||||
client,
|
||||
&self.config,
|
||||
Method::DELETE,
|
||||
Some(&object_key),
|
||||
target_url,
|
||||
OssRequestOperation::Delete,
|
||||
)
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() && status != reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(request_status_error(
|
||||
OssRequestOperation::Delete,
|
||||
status.as_u16(),
|
||||
format!("OSS DeleteObject 失败,状态码:{status}"),
|
||||
));
|
||||
}
|
||||
info!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "delete_object",
|
||||
bucket = %self.config.bucket(),
|
||||
endpoint = %self.config.endpoint(),
|
||||
object_key,
|
||||
status = status.as_u16(),
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS DeleteObject 删除完成"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。
|
||||
pub async fn put_object(
|
||||
&self,
|
||||
|
||||
@@ -325,6 +325,9 @@ pub struct EditorCharacterAnimationGenerateRequest {
|
||||
pub reference_media_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference_src: Option<String>,
|
||||
/// 视频动作参考之外的可选角色外观图片。仅 inputMode=video 时允许,可多张。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub appearance_reference_srcs: Vec<String>,
|
||||
pub prompt_text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_color: Option<String>,
|
||||
@@ -344,12 +347,57 @@ pub struct EditorCharacterAnimationGenerateRequest {
|
||||
pub generation_inputs: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_resource_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub appearance_source_resource_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_folder_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationVideoConversionRequest {
|
||||
pub source_video_src: String,
|
||||
pub resolution: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation_inputs: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_resource_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_folder_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationVideoConversionResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub processing_method: String,
|
||||
pub frames: Vec<EditorCharacterAnimationFramePayload>,
|
||||
pub frame_count: u32,
|
||||
pub duration_ms: u64,
|
||||
pub frame_width: u32,
|
||||
pub frame_height: u32,
|
||||
pub source_fps: f64,
|
||||
pub sampling_fps: f64,
|
||||
pub price_mud_points: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resource: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationFramePayload {
|
||||
@@ -406,6 +454,43 @@ pub struct EditorCharacterAnimationFrameSplitResponse {
|
||||
pub assets: Vec<EditorAssetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationBackgroundRemovalRequest {
|
||||
pub project_id: String,
|
||||
pub source_layer_id: String,
|
||||
pub source_resource_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_folder_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_label: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation_inputs: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationBackgroundRemovalResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub frames: Vec<EditorCharacterAnimationFramePayload>,
|
||||
pub frame_count: u32,
|
||||
pub duration_ms: u64,
|
||||
pub frame_width: u32,
|
||||
pub frame_height: u32,
|
||||
pub price_mud_points: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resource: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetPayload {
|
||||
@@ -1376,6 +1461,7 @@ mod tests {
|
||||
input_mode: Some("video".to_string()),
|
||||
reference_media_type: Some("video".to_string()),
|
||||
reference_src: Some("generated/editor/reference.mp4".to_string()),
|
||||
appearance_reference_srcs: vec!["generated/editor/appearance.png".to_string()],
|
||||
prompt_text: "待机呼吸".to_string(),
|
||||
background_color: Some("purple".to_string()),
|
||||
screen_color: None,
|
||||
@@ -1399,6 +1485,7 @@ mod tests {
|
||||
}),
|
||||
generation_inputs: None,
|
||||
source_resource_id: Some("resource-source".to_string()),
|
||||
appearance_source_resource_ids: vec!["resource-appearance".to_string()],
|
||||
asset_folder_id: Some("project".to_string()),
|
||||
asset_label: Some("勇者奔跑".to_string()),
|
||||
})
|
||||
@@ -1412,6 +1499,10 @@ mod tests {
|
||||
json!("generated/editor/reference.mp4")
|
||||
);
|
||||
assert_eq!(payload["backgroundColor"], json!("purple"));
|
||||
assert_eq!(
|
||||
payload["appearanceReferenceSrcs"],
|
||||
json!(["generated/editor/appearance.png"])
|
||||
);
|
||||
assert_eq!(
|
||||
payload["sourceImageSrc"],
|
||||
json!("/generated-characters/hero/master.png")
|
||||
@@ -1421,6 +1512,10 @@ mod tests {
|
||||
assert_eq!(payload["projectId"], json!("editor-project-1"));
|
||||
assert_eq!(payload["canvasCompletion"]["dialogId"], json!("dialog-1"));
|
||||
assert_eq!(payload["sourceResourceId"], json!("resource-source"));
|
||||
assert_eq!(
|
||||
payload["appearanceSourceResourceIds"],
|
||||
json!(["resource-appearance"])
|
||||
);
|
||||
assert_eq!(payload["assetFolderId"], json!("project"));
|
||||
assert_eq!(payload["assetLabel"], json!("勇者奔跑"));
|
||||
}
|
||||
@@ -1443,6 +1538,66 @@ mod tests {
|
||||
assert_eq!(payload["assetLabel"], json!("奔跑动作"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_video_conversion_uses_discriminated_shape() {
|
||||
let payload = serde_json::to_value(EditorCharacterAnimationVideoConversionRequest {
|
||||
source_video_src: "users/owner/source.mov".to_string(),
|
||||
resolution: "720p".to_string(),
|
||||
project_id: Some("project-1".to_string()),
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: Some("resource-video".to_string()),
|
||||
asset_folder_id: Some("project".to_string()),
|
||||
asset_label: Some("跑步视频转换".to_string()),
|
||||
})
|
||||
.expect("request should serialize");
|
||||
assert_eq!(payload["sourceVideoSrc"], "users/owner/source.mov");
|
||||
assert_eq!(payload["resolution"], "720p");
|
||||
assert!(payload.get("promptText").is_none());
|
||||
assert!(payload.get("model").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_120_frame_response_stays_within_queue_json_gate() {
|
||||
let frames = (0..120)
|
||||
.map(|index| EditorCharacterAnimationFramePayload {
|
||||
image_src: format!(
|
||||
"/generated-character-animations/editor/video-conversions/task/frame{:03}.png",
|
||||
index + 1
|
||||
),
|
||||
object_key: Some(format!(
|
||||
"generated-character-animations/editor/video-conversions/task/frame{:03}.png",
|
||||
index + 1
|
||||
)),
|
||||
asset_object_id: Some(format!("assetobj_{:064}", index + 1)),
|
||||
width: 720,
|
||||
height: 405,
|
||||
})
|
||||
.collect();
|
||||
let payload = EditorCharacterAnimationVideoConversionResponse {
|
||||
ok: true,
|
||||
task_id: "task-max-frames".to_string(),
|
||||
processing_method: "视频直接转换".to_string(),
|
||||
frames,
|
||||
frame_count: 120,
|
||||
duration_ms: 15_000,
|
||||
frame_width: 720,
|
||||
frame_height: 405,
|
||||
source_fps: 29.970_029_97,
|
||||
sampling_fps: 8.0,
|
||||
price_mud_points: 0,
|
||||
project: None,
|
||||
resource: None,
|
||||
asset: None,
|
||||
queue_state: None,
|
||||
};
|
||||
let bytes = serde_json::to_vec(&payload).expect("response should serialize");
|
||||
assert!(
|
||||
bytes.len() < 512 * 1024,
|
||||
"120 frame payload must fit queue gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_response_includes_frames_and_preview_video() {
|
||||
let payload = serde_json::to_value(EditorCharacterAnimationGenerateResponse {
|
||||
|
||||
@@ -17,13 +17,14 @@ const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE: &str = "active";
|
||||
const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK: &str = "rolled_back";
|
||||
const EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS: usize = 16;
|
||||
const EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS: usize = 64;
|
||||
const EDITOR_CHARACTER_ANIMATION_SLICE_BATCH_MAX_ITEMS: usize = 120;
|
||||
const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420;
|
||||
const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120;
|
||||
const EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE: u32 = 25;
|
||||
const EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE: u32 = 5;
|
||||
// 透明图集最多产出 64 个切片,另有 provider 原图和透明整图两个正式 item。
|
||||
const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 66;
|
||||
const EDITOR_GENERATION_OPERATION_KINDS: [&str; 10] = [
|
||||
// 角色动画视频转换最多 120 帧,第一帧同时承载最终 resource/asset,事务正好 120 项。
|
||||
const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 120;
|
||||
const EDITOR_GENERATION_OPERATION_KINDS: [&str; 11] = [
|
||||
"editor_image_generation",
|
||||
"editor_icon_spec_generation",
|
||||
"editor_image_edit",
|
||||
@@ -31,6 +32,7 @@ const EDITOR_GENERATION_OPERATION_KINDS: [&str; 10] = [
|
||||
"editor_icon_spritesheet_generation",
|
||||
"editor_ui_design_asset_extraction",
|
||||
"editor_character_animation_generation",
|
||||
"editor_character_animation_video_conversion",
|
||||
"editor_video_generation",
|
||||
"editor_sound_effect_generation",
|
||||
"editor_background_music_generation",
|
||||
@@ -3449,7 +3451,9 @@ fn validate_editor_generation_result_shape(
|
||||
return Err("生成结果 completed_at_micros 必须为正数".to_string());
|
||||
}
|
||||
if !(1..=EDITOR_GENERATION_RESULT_MAX_ITEMS).contains(&input.items.len()) {
|
||||
return Err("生成结果 item 数量必须在 1 到 66 之间".to_string());
|
||||
return Err(format!(
|
||||
"生成结果 item 数量必须在 1 到 {EDITOR_GENERATION_RESULT_MAX_ITEMS} 之间"
|
||||
));
|
||||
}
|
||||
let result_project_id = editor_generation_result_project_id(input)?;
|
||||
let mut slots = BTreeSet::new();
|
||||
@@ -5103,8 +5107,19 @@ fn validate_editor_spritesheet_slice_batch(
|
||||
group_task_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let item_count = input.items.len();
|
||||
if !(1..=EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS).contains(&item_count) {
|
||||
return Err("图集切片批次产物数量必须在 1 到 64 之间".to_string());
|
||||
let is_character_animation_batch = input.items.iter().all(|item| {
|
||||
matches!(
|
||||
item.asset_object.asset_kind.trim(),
|
||||
EDITOR_CHARACTER_ANIMATION_ASSET_KIND | EDITOR_CHARACTER_ANIMATION_OBJECT_KIND
|
||||
)
|
||||
});
|
||||
let max_items = if is_character_animation_batch {
|
||||
EDITOR_CHARACTER_ANIMATION_SLICE_BATCH_MAX_ITEMS
|
||||
} else {
|
||||
EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS
|
||||
};
|
||||
if !(1..=max_items).contains(&item_count) {
|
||||
return Err(format!("图集切片批次产物数量必须在 1 到 {max_items} 之间"));
|
||||
}
|
||||
if usize::try_from(input.expected_asset_count).ok() != Some(item_count) {
|
||||
return Err("图集切片批次产物数量与预期不一致".to_string());
|
||||
@@ -14084,7 +14099,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_result_shape_accepts_sixty_four_slices_plus_two_source_items() {
|
||||
fn editor_generation_result_shape_accepts_exactly_one_hundred_twenty_items() {
|
||||
let mut input = editor_generation_result_input();
|
||||
input.items.clear();
|
||||
for index in 0..EDITOR_GENERATION_RESULT_MAX_ITEMS {
|
||||
@@ -14138,8 +14153,8 @@ mod tests {
|
||||
"editor_image_generation",
|
||||
"job-1",
|
||||
)
|
||||
.expect_err("more than the full spritesheet bundle must fail")
|
||||
.contains("1 到 66")
|
||||
.expect_err("more than the maximum generation bundle must fail")
|
||||
.contains("1 到 120")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15685,6 +15700,45 @@ mod tests {
|
||||
.expect("complete batch should pass before transaction writes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_split_accepts_120_but_icon_split_remains_64() {
|
||||
let mut character_batch = spritesheet_slice_batch();
|
||||
character_batch.items = (0..120).map(spritesheet_slice_item).collect();
|
||||
character_batch.expected_asset_count = 120;
|
||||
for item in &mut character_batch.items {
|
||||
item.asset_object.asset_kind = EDITOR_CHARACTER_ANIMATION_OBJECT_KIND.to_string();
|
||||
let asset = item.asset.as_mut().expect("asset");
|
||||
asset.group_task_expected_asset_count = Some(120);
|
||||
}
|
||||
validate_editor_spritesheet_slice_batch(
|
||||
&character_batch,
|
||||
"user-1",
|
||||
"task-1",
|
||||
Some("group-task-1"),
|
||||
)
|
||||
.expect("character animation should allow 120 frames");
|
||||
|
||||
let mut icon_batch = spritesheet_slice_batch();
|
||||
icon_batch.items = (0..65).map(spritesheet_slice_item).collect();
|
||||
icon_batch.expected_asset_count = 65;
|
||||
for item in &mut icon_batch.items {
|
||||
item.asset
|
||||
.as_mut()
|
||||
.expect("asset")
|
||||
.group_task_expected_asset_count = Some(65);
|
||||
}
|
||||
assert!(
|
||||
validate_editor_spritesheet_slice_batch(
|
||||
&icon_batch,
|
||||
"user-1",
|
||||
"task-1",
|
||||
Some("group-task-1"),
|
||||
)
|
||||
.expect_err("icon spritesheet must remain capped at 64")
|
||||
.contains("1 到 64")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spritesheet_slice_batch_accepts_reused_source_objects_under_split_operation() {
|
||||
let mut batch = spritesheet_slice_batch();
|
||||
|
||||
@@ -227,6 +227,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
|
||||
it('renders completed frame count and failed error state', () => {
|
||||
const result = {
|
||||
resultType: 'ai' as const,
|
||||
taskId: 'task-a',
|
||||
model: 'seedance2.0-fast' as const,
|
||||
prompt: '行走动作',
|
||||
|
||||
@@ -93,9 +93,6 @@ function CharacterGenerationHarness({
|
||||
<output aria-label="当前多方向角色图">
|
||||
{String(dialog.characterSheetEnabled === true)}
|
||||
</output>
|
||||
<output aria-label="当前多方向图视角数">
|
||||
{dialog.characterSheetViewSet ?? '4'}
|
||||
</output>
|
||||
<output aria-label="选择角色规范">{String(isPickingSpec)}</output>
|
||||
<output aria-label="选择常规参考图">{String(isPickingReference)}</output>
|
||||
</div>
|
||||
@@ -135,57 +132,21 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: '多方向角色图' });
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
const toggle = screen.getByRole('checkbox', { name: '多方向图' });
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.getByLabelText('当前多方向角色图').textContent).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '多方向角色图' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
).toBe('true');
|
||||
(screen.getByRole('checkbox', { name: '多方向图' }) as HTMLInputElement)
|
||||
.checked,
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '多方向角色图' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '多方向图' }));
|
||||
expect(screen.getByLabelText('当前多方向角色图').textContent).toBe('false');
|
||||
});
|
||||
|
||||
it('shows the view-set dropdown only while the sheet is enabled', () => {
|
||||
render(
|
||||
<CharacterGenerationHarness
|
||||
initialDialog={createDialog({ characterSheetEnabled: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /多方向图视角/ })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '多方向角色图' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '多方向图视角 四方向' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switches the character sheet view set between four and eight', () => {
|
||||
render(
|
||||
<CharacterGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
characterSheetEnabled: true,
|
||||
characterSheetViewSet: '4',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '多方向图视角 四方向' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '八方向' }));
|
||||
|
||||
expect(screen.getByLabelText('当前多方向图视角数').textContent).toBe('8');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '多方向图视角 八方向' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates character prompt, clears failed state and submits', () => {
|
||||
const submitCharacter = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ChevronDown, ClipboardList, ImagePlus, LayoutGrid } from 'lucide-react';
|
||||
import { ClipboardList, ImagePlus } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
@@ -104,8 +101,6 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
||||
const [isViewSetMenuOpen, setIsViewSetMenuOpen] = useState(false);
|
||||
const viewSetButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen,
|
||||
boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef],
|
||||
@@ -114,25 +109,6 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
},
|
||||
});
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isViewSetMenuOpen,
|
||||
boundaryRefs: [viewSetButtonRef],
|
||||
onDismiss: () => setIsViewSetMenuOpen(false),
|
||||
restoreFocusRef: viewSetButtonRef,
|
||||
});
|
||||
const setViewSet = (value: '4' | '8') => {
|
||||
setIsViewSetMenuOpen(false);
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterSheetViewSet: value,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
};
|
||||
const selectedViewSetLabel =
|
||||
(dialog.characterSheetViewSet ?? '4') === '8' ? '八方向' : '四方向';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -240,30 +216,6 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
isAdd
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
/>
|
||||
<ImageCanvasReferenceSlot
|
||||
tone="character-sheet"
|
||||
icon={<LayoutGrid className="h-4 w-4" aria-hidden="true" />}
|
||||
label="多方向图"
|
||||
ariaLabel="多方向角色图"
|
||||
title={
|
||||
dialog.characterSheetEnabled === true
|
||||
? '多方向角色图(开启)'
|
||||
: '多方向角色图'
|
||||
}
|
||||
disabled={dialog.status === 'generating'}
|
||||
pressed={dialog.characterSheetEnabled === true}
|
||||
onClick={() =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterSheetEnabled:
|
||||
currentDialog.characterSheetEnabled !== true,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
@@ -351,64 +303,28 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
onSubmit(dialog);
|
||||
}
|
||||
}}
|
||||
extraToggles={
|
||||
<label className="image-canvas-editor__image-style-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dialog.characterSheetEnabled === true}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterSheetEnabled: event.target.checked,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>多方向图</span>
|
||||
</label>
|
||||
}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
styleControl={
|
||||
dialog.characterSheetEnabled === true ? (
|
||||
<div className="image-canvas-editor__option-popover-anchor">
|
||||
<PlatformInlineOptionButton
|
||||
ref={viewSetButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--viewset"
|
||||
aria-label={`多方向图视角 ${selectedViewSetLabel}`}
|
||||
aria-expanded={isViewSetMenuOpen}
|
||||
disabled={dialog.status === 'generating'}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => setIsViewSetMenuOpen((open) => !open)}
|
||||
>
|
||||
{selectedViewSetLabel}
|
||||
</PlatformInlineOptionButton>
|
||||
{isViewSetMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
|
||||
label="多方向图视角数"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
viewSetButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-sections">
|
||||
<div className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
视角数
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{(['4', '8'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="image-canvas-editor__option-popover-choice"
|
||||
aria-pressed={
|
||||
(dialog.characterSheetViewSet ?? '4') ===
|
||||
value
|
||||
}
|
||||
onClick={() => setViewSet(value)}
|
||||
>
|
||||
{value === '4' ? '四方向' : '八方向'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,6 @@ function renderContextMenus(
|
||||
onFitLayers: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenLayerMetadata: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -160,7 +159,6 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
|
||||
expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(1);
|
||||
@@ -179,9 +177,8 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect.objectContaining({ mode: 'spine-json' }),
|
||||
);
|
||||
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
|
||||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -319,19 +316,6 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the standalone image menu after opening character animation', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const props = renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the image context menu when no canvas context menu is open', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const props = renderContextMenus({
|
||||
|
||||
@@ -53,7 +53,6 @@ type ImageCanvasContextMenusViewProps = {
|
||||
onFitLayers: () => void;
|
||||
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
||||
onOpenLayerMetadata: (layer: CanvasLayer) => void;
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
type MeasuredMenuLayout = {
|
||||
@@ -94,7 +93,6 @@ export function ImageCanvasContextMenusView({
|
||||
onFitLayers,
|
||||
onOpenQuickEditPanel,
|
||||
onOpenLayerMetadata,
|
||||
onOpenCharacterAnimationPanel,
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
const layerCopyBlockedTitle =
|
||||
@@ -525,19 +523,6 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
查看图片信息
|
||||
</button>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenCharacterAnimationPanel(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
生成动画
|
||||
</button>
|
||||
) : null}
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
@@ -585,17 +570,6 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
查看图片信息
|
||||
</PlatformFloatingMenuItem>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenCharacterAnimationPanel(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
生成动画
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onDeleteLayerById(imageContextMenuLayer.id)}
|
||||
|
||||
@@ -3452,20 +3452,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
|
||||
fireEvent.click(characterLayer.closest('button')!);
|
||||
expect(screen.getByText('角色')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Spine序列帧动画生成' }),
|
||||
).toBeNull();
|
||||
fireEvent.contextMenu(characterLayer.closest('button')!, {
|
||||
clientX: 260,
|
||||
clientY: 220,
|
||||
});
|
||||
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spine序列帧动画' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: '角色动作生成占位图' }),
|
||||
).toBeTruthy();
|
||||
@@ -3540,21 +3534,24 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', { name: '序列帧参数选项' }),
|
||||
).getByRole('button', { name: '720p' }),
|
||||
within(screen.getByRole('group', { name: '序列帧参数选项' })).getByRole(
|
||||
'button',
|
||||
{ name: '720p' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', { name: '序列帧参数选项' }),
|
||||
).getByRole('button', { name: '16:9' }),
|
||||
within(screen.getByRole('group', { name: '序列帧参数选项' })).getByRole(
|
||||
'button',
|
||||
{ name: '16:9' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', { name: '序列帧参数选项' }),
|
||||
).getByRole('button', {
|
||||
name: '48帧·6秒',
|
||||
}),
|
||||
within(screen.getByRole('group', { name: '序列帧参数选项' })).getByRole(
|
||||
'button',
|
||||
{
|
||||
name: '48帧·6秒',
|
||||
},
|
||||
),
|
||||
);
|
||||
const resizedPlaceholder = screen.getByRole('button', {
|
||||
name: '角色动作生成占位图',
|
||||
|
||||
@@ -1339,6 +1339,103 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates a character-animation preview video through its source resource', () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated/sequence/frame-1.png',
|
||||
objectKey: 'generated/sequence/frame-1.png',
|
||||
width: 480,
|
||||
height: 480,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated/sequence/frame-2.png',
|
||||
objectKey: 'generated/sequence/frame-2.png',
|
||||
width: 480,
|
||||
height: 480,
|
||||
},
|
||||
];
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-sequence',
|
||||
resourceId: 'resource-sequence',
|
||||
title: '角色动作',
|
||||
sourceType: 'generated',
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-sequence',
|
||||
{
|
||||
resourceId: 'resource-sequence',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
objectKey: frames[0]!.objectKey,
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
taskId: 'animation-task-1',
|
||||
sourceResourceId: 'resource-preview-video',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-preview-video',
|
||||
{
|
||||
resourceId: 'resource-preview-video',
|
||||
imageSrc: '/generated/video/preview.mp4',
|
||||
objectKey: 'generated/video/preview.mp4',
|
||||
assetKind: 'video',
|
||||
taskId: 'animation-task-1',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
assetKind: 'character-animation',
|
||||
mediaType: 'image-sequence',
|
||||
previewVideoPath: 'generated/video/preview.mp4',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not hydrate an older action reference as a generated preview video', () => {
|
||||
const frames = [
|
||||
{ imageSrc: '/generated/sequence/frame-1.png', width: 480, height: 480 },
|
||||
{ imageSrc: '/generated/sequence/frame-2.png', width: 480, height: 480 },
|
||||
];
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-sequence',
|
||||
resourceId: 'resource-sequence',
|
||||
title: '角色动作',
|
||||
sourceType: 'generated',
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-sequence',
|
||||
{
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
taskId: 'animation-task-2',
|
||||
sourceResourceId: 'resource-action-video',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-action-video',
|
||||
{
|
||||
imageSrc: '/generated/source/action.mp4',
|
||||
objectKey: 'generated/source/action.mp4',
|
||||
assetKind: 'video',
|
||||
taskId: 'source-task-1',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated?.previewVideoPath).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an action layer when its project resource lacks formal sequence fields', () => {
|
||||
const frames = [
|
||||
{
|
||||
@@ -1637,6 +1734,100 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips independent character animation AI and conversion drafts', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'dialog-character-animation-drafts',
|
||||
mode: 'character-animation',
|
||||
prompt: '保留 AI 提示词',
|
||||
status: 'idle',
|
||||
characterAnimationWorkflow: 'video-conversion',
|
||||
characterAnimationBackgroundColor: 'green',
|
||||
characterAnimationResolution: '480p',
|
||||
characterAnimationConversionResolution: '720p',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'motion',
|
||||
label: '动作视频',
|
||||
src: 'users/user-1/motion.mp4',
|
||||
objectKey: 'users/user-1/motion.mp4',
|
||||
mediaType: 'video',
|
||||
resourceId: 'resource-motion',
|
||||
},
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
{
|
||||
id: 'appearance',
|
||||
label: '角色外观',
|
||||
src: 'users/user-1/appearance.png',
|
||||
objectKey: 'users/user-1/appearance.png',
|
||||
resourceId: 'resource-appearance',
|
||||
},
|
||||
],
|
||||
characterAnimationConversionVideoReference: {
|
||||
id: 'conversion-source',
|
||||
label: '直接转换源视频',
|
||||
src: 'users/user-1/source.mov',
|
||||
objectKey: 'users/user-1/source.mov',
|
||||
mediaType: 'video',
|
||||
resourceId: 'resource-conversion-source',
|
||||
},
|
||||
};
|
||||
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [dialog],
|
||||
});
|
||||
const { generationDialogs } = splitCanvasLayoutItems(
|
||||
layout,
|
||||
new Map([
|
||||
[
|
||||
'resource-motion',
|
||||
{
|
||||
imageSrc: 'users/user-1/motion.mp4',
|
||||
objectKey: 'users/user-1/motion.mp4',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-appearance',
|
||||
{
|
||||
imageSrc: 'users/user-1/appearance.png',
|
||||
objectKey: 'users/user-1/appearance.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-conversion-source',
|
||||
{
|
||||
imageSrc: 'users/user-1/source.mov',
|
||||
objectKey: 'users/user-1/source.mov',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(generationDialogs[0]).toMatchObject({
|
||||
prompt: '保留 AI 提示词',
|
||||
characterAnimationWorkflow: 'video-conversion',
|
||||
characterAnimationBackgroundColor: 'green',
|
||||
characterAnimationResolution: '480p',
|
||||
characterAnimationConversionResolution: '720p',
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
objectKey: 'users/user-1/motion.mp4',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
expect.objectContaining({
|
||||
objectKey: 'users/user-1/appearance.png',
|
||||
}),
|
||||
],
|
||||
characterAnimationConversionVideoReference: expect.objectContaining({
|
||||
objectKey: 'users/user-1/source.mov',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the operation ledger out of the layout and restores it from the local ledger', () => {
|
||||
const dialogId = 'dialog-perfect-pixel-round-trip';
|
||||
const operation = buildPerfectPixelOperation(dialogId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isEditorSceneStylePreset } from '../../../packages/shared/src/contracts
|
||||
import type {
|
||||
EditorAssetGenerationInputs,
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationResult,
|
||||
EditorPixelArtSnapInput,
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
@@ -722,6 +722,12 @@ function serializeDialogReferences(
|
||||
generationReferences: serializeGenerationReferences(
|
||||
dialog.generationReferences,
|
||||
),
|
||||
characterAnimationAppearanceReferences: serializeGenerationReferences(
|
||||
dialog.characterAnimationAppearanceReferences,
|
||||
),
|
||||
characterAnimationConversionVideoReference: serializeGenerationReference(
|
||||
dialog.characterAnimationConversionVideoReference,
|
||||
),
|
||||
characterSpecReference: serializeGenerationReference(
|
||||
dialog.characterSpecReference,
|
||||
),
|
||||
@@ -1274,6 +1280,34 @@ export function hydrateCanvasGenerationDialog(
|
||||
typeof snapshot.videoWebSearchEnabled === 'boolean'
|
||||
? snapshot.videoWebSearchEnabled
|
||||
: undefined,
|
||||
characterAnimationWorkflow:
|
||||
snapshot.characterAnimationWorkflow === 'video-conversion'
|
||||
? 'video-conversion'
|
||||
: snapshot.characterAnimationWorkflow === 'ai'
|
||||
? 'ai'
|
||||
: undefined,
|
||||
characterAnimationAppearanceReferences: hydrateCharacterReferences(
|
||||
snapshot.characterAnimationAppearanceReferences,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
characterAnimationConversionVideoReference: hydrateCharacterReference(
|
||||
snapshot.characterAnimationConversionVideoReference,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
characterAnimationConversionResolution:
|
||||
snapshot.characterAnimationConversionResolution === '480p' ||
|
||||
snapshot.characterAnimationConversionResolution === '720p'
|
||||
? snapshot.characterAnimationConversionResolution
|
||||
: undefined,
|
||||
characterAnimationBackgroundColor:
|
||||
snapshot.characterAnimationBackgroundColor === 'white' ||
|
||||
snapshot.characterAnimationBackgroundColor === 'green' ||
|
||||
snapshot.characterAnimationBackgroundColor === 'blue' ||
|
||||
snapshot.characterAnimationBackgroundColor === 'purple'
|
||||
? snapshot.characterAnimationBackgroundColor
|
||||
: undefined,
|
||||
characterAnimationResolution:
|
||||
snapshot.characterAnimationResolution === '480p' ||
|
||||
snapshot.characterAnimationResolution === '720p'
|
||||
@@ -1409,6 +1443,33 @@ export function hydrateLayer(
|
||||
const src = isFormalCharacterAnimation
|
||||
? firstSequenceFrame || resource?.imageSrc || ''
|
||||
: snapshotSrc || firstSequenceFrame || resource?.imageSrc || '';
|
||||
const sequencePreviewVideoPath = isFormalCharacterAnimation
|
||||
? (() => {
|
||||
const previewResourceId = stringOrNull(resource?.sourceResourceId);
|
||||
const previewResource = previewResourceId
|
||||
? resourcesById.get(previewResourceId)
|
||||
: undefined;
|
||||
if (previewResource?.assetKind !== 'video') {
|
||||
return undefined;
|
||||
}
|
||||
// 正式序列帧的 sourceResourceId 应指向同一次操作登记的预览视频。
|
||||
// 如果它实际指向更早的动作参考视频,不能把参考视频当成生成结果播放;
|
||||
// 这也避免历史错误 lineage 在刷新后悄悄改变“底层视频”。
|
||||
const animationTaskId = stringOrNull(resource?.taskId);
|
||||
const previewTaskId = stringOrNull(previewResource.taskId);
|
||||
if (
|
||||
animationTaskId &&
|
||||
previewTaskId &&
|
||||
animationTaskId !== previewTaskId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
stringOrNull(previewResource.objectKey) ??
|
||||
stringOrNull(previewResource.imageSrc)
|
||||
);
|
||||
})()
|
||||
: undefined;
|
||||
const title =
|
||||
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
|
||||
if (!resourceId || !layerId || !src) {
|
||||
@@ -1503,7 +1564,7 @@ export function hydrateLayer(
|
||||
? imageSequenceFrames
|
||||
: undefined,
|
||||
previewVideoPath: isFormalCharacterAnimation
|
||||
? null
|
||||
? (sequencePreviewVideoPath ?? null)
|
||||
: stringOrNull(snapshot.previewVideoPath),
|
||||
prompt: isFormalCharacterAnimation
|
||||
? stringOrNull(resource?.prompt)
|
||||
@@ -2023,25 +2084,43 @@ function videoAspectRatioOrUndefined(value: unknown) {
|
||||
|
||||
function hydrateCharacterAnimationResult(
|
||||
value: unknown,
|
||||
): EditorCharacterAnimationGenerationResult | undefined {
|
||||
): EditorCharacterAnimationResult | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const result = value as Partial<EditorCharacterAnimationGenerationResult>;
|
||||
const result = value as Record<string, unknown> &
|
||||
Partial<EditorCharacterAnimationResult>;
|
||||
if (
|
||||
typeof result.taskId !== 'string' ||
|
||||
result.model !== 'seedance2.0-fast' ||
|
||||
typeof result.prompt !== 'string' ||
|
||||
typeof result.previewVideoPath !== 'string' ||
|
||||
!Array.isArray(result.frames) ||
|
||||
typeof result.frameCount !== 'number' ||
|
||||
typeof result.durationSeconds !== 'number' ||
|
||||
typeof result.fps !== 'number' ||
|
||||
typeof result.priceMudPoints !== 'number'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return result as EditorCharacterAnimationGenerationResult;
|
||||
if (result.resultType === 'video-conversion') {
|
||||
return result.processingMethod === '视频直接转换' &&
|
||||
typeof result.durationMs === 'number' &&
|
||||
typeof result.frameWidth === 'number' &&
|
||||
typeof result.frameHeight === 'number' &&
|
||||
typeof result.sourceFps === 'number' &&
|
||||
typeof result.samplingFps === 'number' &&
|
||||
result.priceMudPoints === 0
|
||||
? (result as EditorCharacterAnimationResult)
|
||||
: undefined;
|
||||
}
|
||||
// 旧项目在判别字段引入前只持久化 AI 结果;按原契约恢复并补上判别字段。
|
||||
if (
|
||||
(result.resultType === undefined || result.resultType === 'ai') &&
|
||||
result.model === 'seedance2.0-fast' &&
|
||||
typeof result.prompt === 'string' &&
|
||||
typeof result.previewVideoPath === 'string' &&
|
||||
typeof result.durationSeconds === 'number' &&
|
||||
typeof result.fps === 'number'
|
||||
) {
|
||||
return { ...result, resultType: 'ai' } as EditorCharacterAnimationResult;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hydrateImageSequenceFrames(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user