Spine序列帧修复与功能收紧

This commit is contained in:
2026-08-13 01:05:57 +08:00
parent 132bf7db35
commit d6cb68cf13
56 changed files with 4995 additions and 922 deletions
@@ -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 jobFFprobe + FFmpeg 覆盖完整源时长,采样率最高 8 FPS、最低 1 FPS,产出 2120 帧;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、215 秒。FFprobe 读取旋转后的显示尺寸、精确时长与平均 FPS;FFmpeg 覆盖完整时间轴,采样率为 `clamp(min(源平均 FPS, 8), 1, 8)`,产出 2120 帧。
- 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` 分支
+23
View File
@@ -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(
+51
View File
@@ -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