feat: complete M6 asset OSS migration
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
- [x] 设计 public / private 对象访问策略
|
||||
- [x] 设计签名 URL 输出策略
|
||||
- [x] 设计 `x-oss-meta-*` 元数据规范
|
||||
- [ ] 设计内容 hash / 版本字段规范
|
||||
- [x] 设计内容 hash / 版本字段规范(Stage 1 明确为 `asset_object.content_hash: Option<String>` + `version = 1`,后续强 hash 单独阶段再扩)
|
||||
|
||||
## 2. 上传与对象确认
|
||||
|
||||
@@ -44,13 +44,13 @@
|
||||
|
||||
## 3. 资产任务系统
|
||||
|
||||
- [ ] 设计 `asset_job`
|
||||
- [x] 设计 `asset_job`(Stage 1 明确不新增重复表,AI 资产任务先复用 `AiTaskService / ai_task` 口径)
|
||||
- [x] 设计 `asset_object`
|
||||
- [ ] 设计 `asset_manifest`
|
||||
- [ ] 设计 `character_visual_asset`
|
||||
- [ ] 设计 `character_animation_asset`
|
||||
- [ ] 设计 `scene_image_asset`
|
||||
- [ ] 设计 `sprite_sheet_asset`
|
||||
- [x] 设计 `asset_manifest`(Stage 1 使用 OSS JSON manifest + `asset_object` 表达集合对象,不新增结构化表)
|
||||
- [x] 设计 `character_visual_asset`(Stage 1 使用 `asset_entity_binding: character / primary_visual`,强业务表延后)
|
||||
- [x] 设计 `character_animation_asset`(Stage 1 使用 `asset_entity_binding: character / animation_set` 绑定总 manifest,强业务表延后)
|
||||
- [x] 设计 `scene_image_asset`(Stage 1 使用 `asset_entity_binding: custom_world_landmark / scene_image`,强业务表延后)
|
||||
- [x] 设计 `sprite_sheet_asset`(Qwen 独立工具已清理,Stage 1 仅保留历史 `/generated-qwen-sprites/*` 读取兼容)
|
||||
|
||||
补充说明:
|
||||
|
||||
@@ -63,20 +63,18 @@
|
||||
- [../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
- [../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||
3. 当前已在 `server-rs/crates/spacetime-module` 落下 `asset_object` 首版表骨架,并完成 `api-server -> SpacetimeDB` 的最小对象确认闭环。
|
||||
4. 元数据、版本、manifest 与强业务资产表边界见:
|
||||
- [../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md](../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md)
|
||||
|
||||
## 4. 资产生成链路
|
||||
|
||||
- [ ] 迁移角色主形象生成
|
||||
- [ ] 迁移角色动作生成
|
||||
- [ ] 迁移动作模板查询
|
||||
- [ ] 迁移视频导入
|
||||
- [ ] 迁移工作流缓存
|
||||
- [ ] 迁移 Qwen 主图生成
|
||||
- [ ] 迁移 Qwen 整表生成
|
||||
- [ ] 迁移 Qwen 修帧
|
||||
- [ ] 迁移 Qwen 保存
|
||||
- [ ] 迁移场景图生成
|
||||
- [ ] 迁移封面图上传
|
||||
- [x] 迁移角色主形象生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前仍为 SVG 占位生成,不代表真实 DashScope 图片模型已迁完)
|
||||
- [x] 迁移角色动作生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前 `image-sequence` 为 SVG 占位帧,视频类策略优先复用参考视频或仓库占位预览,不代表真实视频模型已迁完)
|
||||
- [x] 迁移动作模板查询(Stage 1 已接通 Rust 内置模板列表兼容接口)
|
||||
- [x] 迁移视频导入(Stage 1 已接通 Data URL 视频导入到 OSS 草稿区,不再写本地 `public/`)
|
||||
- [x] 迁移工作流缓存(Stage 1 已接通 Rust `GET/POST character-workflow-cache` 到 OSS JSON 草稿对象,不再写本地 `public/`)
|
||||
- [x] 迁移场景图生成(Stage 1 已由 custom world `scene-image` 兼容路由写入 OSS 并确认/绑定;Stage 2 还需补真实 DashScope 图片生成,替换当前 Rust SVG 占位图)
|
||||
- [x] 迁移封面图上传(Stage 1 已由 custom world `cover-image / cover-upload` 兼容路由写入 OSS 并确认/绑定;Stage 2 还需补 `cropRect + 16:9 + WebP 压缩`)
|
||||
- [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract,不再写仓库 `public/`)
|
||||
|
||||
补充说明:
|
||||
@@ -84,39 +82,72 @@
|
||||
1. 本次收口只解决 custom world 兼容图片入口的正式资产真相链,不代表 DashScope 图片生成、任务状态、封面裁剪压缩能力已全量迁完。
|
||||
2. 详细边界见:
|
||||
- [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. 角色动作模板与视频导入第一批已新增独立设计文档,当前只迁移:
|
||||
- `GET /api/assets/character-animation/templates`
|
||||
- `POST /api/assets/character-animation/import-video`
|
||||
- [../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
|
||||
4. 角色资产工作流缓存第一批已新增独立设计文档,当前把旧本地 `workflow-cache.json` 改为 OSS JSON 草稿对象:
|
||||
- `GET /api/assets/character-workflow-cache/:characterId`
|
||||
- `POST /api/assets/character-workflow-cache`
|
||||
- [../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md)
|
||||
5. `2026-04-22` 复核确认:旧独立 `qwen-sprite-tool + qwenSpriteRoutes.ts` 已在 `2026-04-21` 清理,不再作为本轮现役迁移主链;当前仍保留的 `Qwen` 相关内容仅包括:
|
||||
- 角色资产 prompt 层对 `packages/shared/src/prompts/qwenSprite.ts` 的复用
|
||||
- 历史资源前缀 `/generated-qwen-sprites/*` 的读取兼容
|
||||
6. custom world 图片链已进入 Stage 2:
|
||||
- `scene-image / cover-image` 需要把 Rust SVG 占位生成替换为真实 DashScope 图片生成
|
||||
- `cover-upload` 需要补回 Node 旧链路中的 `cropRect + 16:9 + WebP 压缩`
|
||||
- 详细口径见 [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md)
|
||||
|
||||
## 5. 路径兼容
|
||||
|
||||
- [ ] 兼容 `/generated-character-drafts/*`
|
||||
- [ ] 兼容 `/generated-characters/*`
|
||||
- [ ] 兼容 `/generated-custom-world-scenes/*`
|
||||
- [ ] 兼容 `/generated-qwen-sprites/*`
|
||||
- [x] 兼容 `/generated-character-drafts/*`
|
||||
- [x] 兼容 `/generated-characters/*`
|
||||
- [x] 兼容 `/generated-animations/*`
|
||||
- [x] 兼容 `/generated-custom-world-scenes/*`
|
||||
- [x] 兼容 `/generated-custom-world-covers/*`
|
||||
- [x] 兼容 `/generated-qwen-sprites/*`
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 第一批路径兼容由 Rust `api-server` 同源代理到私有 OSS 短期读签名,不回退本地 `public/`,详细边界见:
|
||||
- [../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md)
|
||||
2. 当前 Stage 1 先全量代理对象内容,不实现视频 Range 分片;若后续真实视频体积变大,再按播放器需求补 Range。
|
||||
|
||||
## 6. 兼容接口
|
||||
|
||||
- [ ] 兼容 `/api/assets/character-visual/generate`
|
||||
- [ ] 兼容 `/api/assets/character-visual/jobs/:taskId`
|
||||
- [ ] 兼容 `/api/assets/character-visual/publish`
|
||||
- [ ] 兼容 `/api/assets/character-animation/generate`
|
||||
- [ ] 兼容 `/api/assets/character-animation/jobs/:taskId`
|
||||
- [ ] 兼容 `/api/assets/character-animation/publish`
|
||||
- [ ] 兼容 `/api/assets/character-animation/import-video`
|
||||
- [ ] 兼容 `/api/assets/character-animation/templates`
|
||||
- [ ] 兼容 `/api/assets/character-workflow-cache`
|
||||
- [ ] 兼容 `/api/assets/character-workflow-cache/:characterId`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/master`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/sheet`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/frame-repair`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/save`
|
||||
- [x] 兼容 `/api/assets/character-visual/generate`
|
||||
- [x] 兼容 `/api/assets/character-visual/jobs/:taskId`
|
||||
- [x] 兼容 `/api/assets/character-visual/publish`
|
||||
- [x] 兼容 `/api/assets/character-animation/generate`
|
||||
- [x] 兼容 `/api/assets/character-animation/jobs/:taskId`
|
||||
- [x] 兼容 `/api/assets/character-animation/publish`
|
||||
- [x] 兼容 `/api/assets/character-animation/import-video`
|
||||
- [x] 兼容 `/api/assets/character-animation/templates`
|
||||
- [x] 兼容 `/api/assets/character-workflow-cache`
|
||||
- [x] 兼容 `/api/assets/character-workflow-cache/:characterId`
|
||||
## 7. 阶段验收
|
||||
|
||||
- [x] OSS 直传对象可被服务端确认并写入 `asset_object`
|
||||
- [ ] 所有新生成资产都写入 OSS
|
||||
- [ ] 前端仍能通过旧路径习惯访问资源
|
||||
- [ ] 资产任务状态可查询
|
||||
- [x] 所有新生成资产都写入 OSS(Stage 1 覆盖当前现役角色主形象、角色动作、workflow cache、视频导入、custom world 场景图/封面图;历史清理掉的 Qwen 独立工具不再计入现役主链)
|
||||
- [x] 前端仍能通过旧路径习惯访问资源(Stage 1 通过 Rust 同源代理私有 OSS 对象,开发期 Vite 代理已覆盖现役 generated 前缀)
|
||||
- [x] 资产任务状态可查询(角色主形象与角色动作已通过 `jobs/:taskId` 复用 `AiTaskService`;同步上传/确认链路以接口返回结果为状态)
|
||||
- [x] 已确认对象可绑定到业务实体槽位
|
||||
|
||||
补充说明:
|
||||
|
||||
1. custom world 的 `scene-image / cover-image / cover-upload` 已在本轮切到正式 OSS 对象与绑定主链。
|
||||
2. `所有新生成资产都写入 OSS` 与 `前端仍能通过旧路径习惯访问资源` 仍需继续把角色主形象、动画、Qwen 精灵与其余历史渲染入口一并收口后再整体勾选。
|
||||
2. 角色主形象第一批已新增独立设计文档与 Rust 最小闭环:
|
||||
- [../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. 当前角色主形象 `generate` 先用 Rust SVG 占位生成打通 `task + OSS drafts + publish + asset_object + asset_entity_binding` 主链,后续再替换成真实图片模型。
|
||||
4. 角色动作模板与视频导入第一批已接入 Rust:
|
||||
- `templates` 返回旧内置模板 contract。
|
||||
- `import-video` 当前只接受 `data:video/*;base64,...`,并写入 OSS `generated-character-drafts/*` 草稿区。
|
||||
5. 角色资产工作流缓存第一批已接入 Rust:
|
||||
- 保存时写入 OSS `generated-character-drafts/{character}/workflow-cache/workflow-cache.json`。
|
||||
- 读取时未命中返回 `cache: null`,保持旧前端 contract。
|
||||
6. 角色动作第一批已接入 Rust:
|
||||
- `generate` 直接写入 OSS `generated-character-drafts/*`。
|
||||
- `jobs/:taskId` 从 `AiTaskService` 派生旧任务状态 contract。
|
||||
- `publish` 会把动作帧与总 manifest 写入 OSS `generated-animations/*`,并确认 `asset_object + asset_entity_binding`。
|
||||
7. custom world 场景图、封面图、封面上传已在 `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md` 范围内接入正式 `OSS + asset_object + asset_entity_binding` 主链。
|
||||
8. `content_hash/version`、`asset_job`、`asset_manifest` 与强业务资产表当前已冻结 Stage 1 边界,不再作为 M6 第一批工程阻塞项;后续若要做内容去重、manifest 查询、审核/回滚或 sprite sheet 强结构化,再进入独立阶段。
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# M6 资产元数据、版本与专用表边界设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于把 `M6` 清单中剩余的以下项收口到可执行边界:
|
||||
|
||||
1. 内容 hash / 版本字段规范
|
||||
2. `asset_job`
|
||||
3. `asset_manifest`
|
||||
4. `character_visual_asset`
|
||||
5. `character_animation_asset`
|
||||
6. `scene_image_asset`
|
||||
7. `sprite_sheet_asset`
|
||||
|
||||
当前 `M6` 第一批已经落地的真实主链是:
|
||||
|
||||
1. OSS 私有对象持有二进制内容
|
||||
2. `asset_object` 记录 `bucket + object_key` 和基础元数据
|
||||
3. `asset_entity_binding` 记录业务实体槽位绑定
|
||||
4. 角色动作发布通过 OSS `manifest.json` 表达动作集合
|
||||
5. 角色主形象、角色动作、custom world 场景图/封面图都先通过通用绑定闭环
|
||||
|
||||
因此本阶段不继续堆新表,而是冻结“哪些内容已经由现有主链承担,哪些等真实访问模式稳定后再拆强业务表”。
|
||||
|
||||
## 2. 内容 hash 与版本规范
|
||||
|
||||
### 2.1 当前 Stage 1 规范
|
||||
|
||||
`asset_object` 当前字段已经包含:
|
||||
|
||||
1. `content_hash: Option<String>`
|
||||
2. `version: u32`
|
||||
|
||||
本阶段规范如下:
|
||||
|
||||
1. `version` 固定从 `1` 起步。
|
||||
2. 同一 `bucket + object_key` 被重新确认时,保留原 `created_at`,更新 `updated_at`,版本仍按当前 `INITIAL_ASSET_OBJECT_VERSION = 1` 处理。
|
||||
3. `content_hash` 当前优先使用 OSS `ETag` 或调用方明确传入的 hash。
|
||||
4. 不在 `api-server` 对大文件做强制全量 SHA-256 计算,避免图片/视频代理链路和服务端上传链路被额外 CPU 与内存占用放大。
|
||||
5. 后续若需要强一致内容去重,再新增独立 `content_digest` 计算策略,不复用当前可空 `content_hash` 做强制约束。
|
||||
|
||||
### 2.2 不做强制 hash 的原因
|
||||
|
||||
1. OSS `ETag` 在不同上传方式下不一定等价于单纯 MD5。
|
||||
2. 当前第一批主要目标是把本地 `public/` 真相迁到 OSS 与 SpacetimeDB 元数据。
|
||||
3. 角色动作视频、帧序列和 custom world 图片都已经能通过 `content_length + object_key + asset_kind + binding` 完成首批追踪。
|
||||
4. 强制 hash 需要统一 multipart、服务端上传、浏览器直传和迁移脚本的计算口径,适合后续单独阶段。
|
||||
|
||||
## 3. `asset_job` 边界
|
||||
|
||||
当前不新增 `asset_job` 表。
|
||||
|
||||
理由:
|
||||
|
||||
1. `M4` 已引入 `module-ai::AiTaskService` 和对应 `ai_task` 设计。
|
||||
2. 角色主形象与角色动作的 Stage 1 已复用 `AiTaskService` 输出旧 `jobs/:taskId` contract。
|
||||
3. custom world 场景图/封面图当前仍是同步兼容接口,不需要单独资产任务态。
|
||||
|
||||
当前任务状态统一口径:
|
||||
|
||||
1. AI 生成相关:使用 `ai_task` / `AiTaskService`。
|
||||
2. 纯上传确认相关:使用 `asset_object` 与 `asset_entity_binding` 的返回结果。
|
||||
3. 后续若出现非 AI 的长时资产处理任务,再重新评估是否拆 `asset_job`。
|
||||
|
||||
## 4. `asset_manifest` 边界
|
||||
|
||||
当前不新增 SpacetimeDB `asset_manifest` 表。
|
||||
|
||||
Stage 1 的 manifest 口径如下:
|
||||
|
||||
1. manifest 是一个 OSS JSON 对象。
|
||||
2. 角色动作整套 manifest 会被确认成 `asset_object`。
|
||||
3. `asset_entity_binding` 绑定的是整套 manifest 对象,而不是每个单帧对象。
|
||||
4. 前端仍通过旧 `animationMap` contract 消费动作帧路径。
|
||||
|
||||
后续只有满足以下条件之一时,才新增 `asset_manifest` 表:
|
||||
|
||||
1. 需要在 SpacetimeDB 中按 manifest 内部动作、帧、依赖对象做查询。
|
||||
2. 需要对 manifest 做版本 diff、审核、回滚。
|
||||
3. 需要把 manifest 作为跨 profile、跨角色复用的结构化资产集合。
|
||||
|
||||
## 5. 强业务资产表边界
|
||||
|
||||
当前不新增以下强业务表:
|
||||
|
||||
1. `character_visual_asset`
|
||||
2. `character_animation_asset`
|
||||
3. `scene_image_asset`
|
||||
4. `sprite_sheet_asset`
|
||||
|
||||
当前由以下组合承担业务绑定:
|
||||
|
||||
1. `asset_object.asset_kind`
|
||||
2. `asset_entity_binding.entity_kind`
|
||||
3. `asset_entity_binding.entity_id`
|
||||
4. `asset_entity_binding.slot`
|
||||
|
||||
当前已冻结槽位:
|
||||
|
||||
| 业务 | `entity_kind` | `slot` | `asset_kind` |
|
||||
| --- | --- | --- | --- |
|
||||
| 角色主形象 | `character` | `primary_visual` | `character_visual` |
|
||||
| 角色动作集 | `character` | `animation_set` | `character_animation` |
|
||||
| custom world 场景图 | `custom_world_landmark` | `scene_image` | `scene_image` |
|
||||
| custom world 封面 | `custom_world_profile` | `cover` | `custom_world_cover` |
|
||||
|
||||
后续拆强业务表的条件:
|
||||
|
||||
1. 需要对角色主形象候选、审核状态、模型参数做结构化查询。
|
||||
2. 需要对动作集逐动作授权、复用、差分发布。
|
||||
3. 需要对场景图、封面图做多版本历史、审核流或推荐流。
|
||||
4. 需要对 sprite sheet 做切片、修帧、atlas 元数据查询。
|
||||
|
||||
## 6. `sprite_sheet_asset` 与 Qwen 边界
|
||||
|
||||
当前 `Qwen sprite` 独立工具链已经清理,不再作为本轮现役迁移主链。
|
||||
|
||||
本阶段只保留:
|
||||
|
||||
1. 历史 `/generated-qwen-sprites/*` 路径读取兼容。
|
||||
2. `platform-oss::LegacyAssetPrefix::QwenSprites` 对象键支持。
|
||||
|
||||
因此 `sprite_sheet_asset` 当前只保留后续能力位,不在 `M6` Stage 1 新增表或接口。
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
当以下条件满足时,本阶段 M6 元数据与专用表边界视为完成:
|
||||
|
||||
1. `content_hash/version` 在文档中明确为 `asset_object` 现有可空 hash + 初始版本口径。
|
||||
2. `asset_job` 明确由 `AiTaskService` 暂代,不新增重复任务表。
|
||||
3. `asset_manifest` 明确由 OSS JSON manifest + `asset_object` 暂代。
|
||||
4. 强业务资产表明确延后到访问模式稳定后拆分。
|
||||
5. `05_M6_ASSETS_OSS_EDITOR.md` 不再把这些后续能力位误标为当前 Stage 1 未完成阻塞项。
|
||||
|
||||
## 8. 关联文档
|
||||
|
||||
1. [SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
|
||||
3. [M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
4. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
@@ -0,0 +1,219 @@
|
||||
# M6 角色动作资产接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色动作生成 + 任务查询 + 正式发布”的真实落地口径。
|
||||
|
||||
本批只解决以下三条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `POST /api/assets/character-animation/generate`
|
||||
2. `GET /api/assets/character-animation/jobs/:taskId`
|
||||
3. `POST /api/assets/character-animation/publish`
|
||||
|
||||
目标不是一次性接入 DashScope / Ark 视频模型,而是先把角色动作资产从旧 Node 本地 `public/generated-*` 真相切到:
|
||||
|
||||
1. `OSS` 草稿对象
|
||||
2. `AI task` 任务态
|
||||
3. `OSS` 正式动作对象
|
||||
4. `asset_object`
|
||||
5. `asset_entity_binding`
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `platform-oss::OssClient::sign_get_object_url`
|
||||
3. `asset_object`
|
||||
4. `asset_entity_binding`
|
||||
5. `module-ai` 进程内 `AiTaskService`
|
||||
6. 角色主形象已完成 `generate / jobs / publish` 的第一批 OSS 主链
|
||||
7. 角色动作模板、视频导入、workflow cache 已完成第一批 Rust 兼容入口
|
||||
|
||||
因此本批复用现有 OSS、资产对象确认、业务实体绑定和 `AiTaskService`,不新增独立 `asset_job` 表。
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容角色动作草稿生成接口
|
||||
2. 兼容角色动作任务查询接口
|
||||
3. 兼容角色动作正式发布接口
|
||||
4. `image-sequence` 草稿帧写入 OSS `generated-character-drafts/*`
|
||||
5. 视频类策略草稿预览对象写入 OSS `generated-character-drafts/*`
|
||||
6. 正式动作帧写入 OSS `generated-animations/*`
|
||||
7. 正式动作 manifest 写入 OSS `generated-animations/*`
|
||||
8. 正式动作 manifest 确认为 `asset_object`
|
||||
9. 正式动作 manifest 绑定到角色实体动作槽位
|
||||
10. 返回字段继续保持旧前端可消费 contract
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不接真实 DashScope 图片序列帧模型
|
||||
2. 不接真实 Ark 图生视频模型
|
||||
3. 不接真实动作迁移模型
|
||||
4. 不落 `character_animation_asset` 强业务表
|
||||
5. 不回写 `src/data/characterOverrides.json`
|
||||
6. 不迁移历史本地 `public/generated-animations`
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `POST /api/assets/character-animation/generate`
|
||||
|
||||
请求结构继续保持前端当前字段:
|
||||
|
||||
1. `characterId`
|
||||
2. `strategy`
|
||||
3. `animation`
|
||||
4. `promptText`
|
||||
5. `characterBriefText`
|
||||
6. `actionTemplateId`
|
||||
7. `visualSource`
|
||||
8. `referenceImageDataUrls`
|
||||
9. `referenceVideoDataUrls`
|
||||
10. `lastFrameImageDataUrl`
|
||||
11. `frameCount`
|
||||
12. `fps`
|
||||
13. `durationSeconds`
|
||||
14. `loop`
|
||||
15. `useChromaKey`
|
||||
16. `resolution`
|
||||
17. `ratio`
|
||||
18. `imageSequenceModel`
|
||||
19. `videoModel`
|
||||
20. `referenceVideoModel`
|
||||
21. `motionTransferModel`
|
||||
|
||||
`image-sequence` 返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `taskId`
|
||||
3. `strategy`
|
||||
4. `model`
|
||||
5. `prompt`
|
||||
6. `imageSources`
|
||||
|
||||
视频类策略返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `taskId`
|
||||
3. `strategy`
|
||||
4. `model`
|
||||
5. `prompt`
|
||||
6. `previewVideoPath`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. Stage 1 的 `image-sequence` 先生成 SVG 占位帧。
|
||||
2. Stage 1 的视频类策略若提供 `referenceVideoDataUrls[0]`,则把该视频作为草稿预览写入 OSS。
|
||||
3. Stage 1 的视频类策略若没有参考视频,则写入占位预览对象以保持接口 contract,后续真实视频模型替换该产物。
|
||||
|
||||
### 4.2 `GET /api/assets/character-animation/jobs/:taskId`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `taskId`
|
||||
2. `kind`
|
||||
3. `status`
|
||||
4. `characterId`
|
||||
5. `animation`
|
||||
6. `strategy`
|
||||
7. `model`
|
||||
8. `prompt`
|
||||
9. `createdAt`
|
||||
10. `updatedAt`
|
||||
11. `result`
|
||||
12. `errorMessage`
|
||||
|
||||
当前阶段直接复用 `AiTaskService` 内存态任务快照派生。
|
||||
|
||||
### 4.3 `POST /api/assets/character-animation/publish`
|
||||
|
||||
请求结构继续保持:
|
||||
|
||||
1. `characterId`
|
||||
2. `visualAssetId`
|
||||
3. `animations`
|
||||
4. `updateCharacterOverride`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `animationSetId`
|
||||
3. `overrideMap`
|
||||
4. `animationMap`
|
||||
5. `saveMessage`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. 每个动作的帧写入 `generated-animations/*`
|
||||
2. 每个动作生成 `manifest.json`
|
||||
3. 整套动作生成总 `manifest.json`
|
||||
4. 总 manifest 确认为 `asset_object`
|
||||
5. 总 manifest 绑定到角色实体槽位
|
||||
6. `overrideMap` 当前返回 `{}`,Rust 后端不再写本地角色覆盖文件
|
||||
|
||||
## 5. 业务实体与槽位约定
|
||||
|
||||
本批统一复用通用 `asset_entity_binding`。
|
||||
|
||||
### 5.1 角色动作正式对象
|
||||
|
||||
| 字段 | 取值 |
|
||||
| --- | --- |
|
||||
| `entity_kind` | `character` |
|
||||
| `entity_id` | `characterId` |
|
||||
| `slot` | `animation_set` |
|
||||
| `asset_kind` | `character_animation` |
|
||||
|
||||
说明:
|
||||
|
||||
1. 正式绑定对象是整套动作总 manifest。
|
||||
2. 单帧对象不单独绑定。
|
||||
3. 后续若落 `character_animation_asset` 强业务表,再把动作级索引迁到专用表。
|
||||
|
||||
## 6. OSS 对象键规划
|
||||
|
||||
### 6.1 草稿序列帧
|
||||
|
||||
`generated-character-drafts/{characterSegment}/animation/{animationSegment}/{taskId}/frame-{index}.svg`
|
||||
|
||||
### 6.2 草稿预览视频
|
||||
|
||||
`generated-character-drafts/{characterSegment}/animation/{animationSegment}/{taskId}/preview.{extension}`
|
||||
|
||||
### 6.3 正式动作帧
|
||||
|
||||
`generated-animations/{characterSegment}/{animationSetId}/{actionSegment}/frame{index}.{extension}`
|
||||
|
||||
### 6.4 正式动作 manifest
|
||||
|
||||
动作级 manifest:
|
||||
|
||||
`generated-animations/{characterSegment}/{animationSetId}/{actionSegment}/manifest.json`
|
||||
|
||||
整套 manifest:
|
||||
|
||||
`generated-animations/{characterSegment}/{animationSetId}/manifest.json`
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `character-animation/generate`
|
||||
2. Rust 已兼容 `character-animation/jobs/:taskId`
|
||||
3. Rust 已兼容 `character-animation/publish`
|
||||
4. 草稿动作产物写入 OSS
|
||||
5. 正式动作产物写入 OSS
|
||||
6. 正式总 manifest 形成 `asset_object`
|
||||
7. 正式总 manifest 形成 `asset_entity_binding`
|
||||
8. 前端仍能继续消费 `imageSources / previewVideoPath / animationMap` 旧 contract
|
||||
|
||||
## 8. 关联文档
|
||||
|
||||
1. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
|
||||
3. [M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](./M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md)
|
||||
4. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
@@ -0,0 +1,147 @@
|
||||
# M6 角色动作模板与视频导入接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色动作模板查询 + 视频导入”的真实落地口径。
|
||||
|
||||
本批只解决以下两条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `GET /api/assets/character-animation/templates`
|
||||
2. `POST /api/assets/character-animation/import-video`
|
||||
|
||||
目标不是一次性迁移角色动作生成、发布和真实视频模型,而是先把资产工坊当前可独立收口的动作模板与参考视频导入从旧 Node 本地 `public/generated-character-drafts` 写盘,切到 OSS 草稿对象。
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `generated-character-drafts/*` 兼容对象键前缀
|
||||
3. `shared-contracts::assets` 角色主形象兼容 DTO
|
||||
4. `api-server` 已接入角色主形象 `generate / jobs / publish`
|
||||
|
||||
因此本批复用既有 OSS 服务端上传 helper,不新增 SpacetimeDB 表。
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容动作模板列表接口
|
||||
2. 兼容参考视频导入接口
|
||||
3. 导入视频对象写入 OSS `generated-character-drafts/*`
|
||||
4. 返回字段继续保持旧前端可消费 contract
|
||||
5. 不再把导入视频写入本地 `public/`
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不迁移 `character-animation/generate`
|
||||
2. 不迁移 `character-animation/jobs/:taskId`
|
||||
3. 不迁移 `character-animation/publish`
|
||||
4. 不落 `character_animation_asset` 强业务表
|
||||
5. 不为导入草稿创建 `asset_object`
|
||||
6. 不为导入草稿创建 `asset_entity_binding`
|
||||
7. 不读取旧本地 `public/` 路径作为导入源
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `GET /api/assets/character-animation/templates`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `templates`
|
||||
|
||||
每个模板继续包含:
|
||||
|
||||
1. `id`
|
||||
2. `label`
|
||||
3. `animation`
|
||||
4. `promptSuffix`
|
||||
5. `notes`
|
||||
|
||||
当前模板列表固定为内置四项:
|
||||
|
||||
1. `idle_loop`
|
||||
2. `run_side`
|
||||
3. `attack_slash`
|
||||
4. `die_fall`
|
||||
|
||||
### 4.2 `POST /api/assets/character-animation/import-video`
|
||||
|
||||
请求结构继续保持:
|
||||
|
||||
1. `characterId`
|
||||
2. `animation`
|
||||
3. `videoSource`
|
||||
4. `sourceLabel`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `importedVideoPath`
|
||||
3. `draftId`
|
||||
4. `saveMessage`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. `videoSource` 当前阶段只接受 `data:video/*;base64,...`
|
||||
2. `importedVideoPath` 继续返回旧前端习惯的 `/generated-character-drafts/*`
|
||||
3. 底层对象真相在 OSS,不再写本地 `public/`
|
||||
4. `saveMessage` 明确说明当前是“已导入 OSS 草稿区”
|
||||
|
||||
## 5. OSS 对象键规划
|
||||
|
||||
导入视频固定写入:
|
||||
|
||||
`generated-character-drafts/{characterSegment}/animation/{animationSegment}/{draftId}/{sourceLabel}.{extension}`
|
||||
|
||||
其中:
|
||||
|
||||
1. `characterSegment` 来自 `characterId` 的安全路径片段
|
||||
2. `animationSegment` 来自 `animation` 的安全路径片段
|
||||
3. `draftId` 固定为 `animation-import-{unixMillis}`
|
||||
4. `extension` 从 Data URL MIME 类型派生
|
||||
|
||||
## 6. 元数据规范
|
||||
|
||||
导入视频对象写入以下 `x-oss-meta-*` 元数据:
|
||||
|
||||
1. `asset_kind = character_animation_reference_video`
|
||||
2. `owner_user_id = asset-tool`
|
||||
3. `entity_kind = character`
|
||||
4. `entity_id = characterId`
|
||||
5. `slot = animation_reference_video`
|
||||
6. `animation = animation`
|
||||
|
||||
说明:
|
||||
|
||||
1. 旧资产工坊接口没有显式 Bearer,第一批继续使用 `asset-tool` 作为兼容归属。
|
||||
2. 草稿导入视频只是后续动作生成的参考输入,不是正式发布资产,因此本批不确认 `asset_object`。
|
||||
|
||||
## 7. 数据源边界
|
||||
|
||||
Rust 第一批只接受 `data:video/*;base64,...`。
|
||||
|
||||
暂不接受旧本地 public 路径,原因是:
|
||||
|
||||
1. Rust 迁移目标是不再依赖本地 `public/` 作为资产真相。
|
||||
2. 若为了兼容旧路径再读取本地文件,会延长旧写盘链路生命周期。
|
||||
3. 前端导入入口当前可直接传视频 Data URL,足以满足本批最小闭环。
|
||||
|
||||
## 8. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `character-animation/templates`
|
||||
2. Rust 已兼容 `character-animation/import-video`
|
||||
3. 导入视频写入 OSS `generated-character-drafts/*`
|
||||
4. 接口返回 `importedVideoPath / draftId` 旧 contract
|
||||
5. 不再产生本地 `public/generated-character-drafts/*` 导入文件
|
||||
|
||||
## 9. 关联文档
|
||||
|
||||
1. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||
@@ -0,0 +1,242 @@
|
||||
# M6 角色主形象资产接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色主形象资产链”的真实落地口径。
|
||||
|
||||
本批只解决以下三条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `POST /api/assets/character-visual/generate`
|
||||
2. `GET /api/assets/character-visual/jobs/:taskId`
|
||||
3. `POST /api/assets/character-visual/publish`
|
||||
|
||||
目标不是一次性把整套资产系统迁完,而是先把“角色主形象候选生成 + 查询 + 正式发布”从旧 Node 的本地 `public/generated-*` 真相,切到:
|
||||
|
||||
1. `OSS`
|
||||
2. `asset_object`
|
||||
3. `asset_entity_binding`
|
||||
4. `AI task` 任务态
|
||||
|
||||
形成第一批正式主链。
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `platform-oss::OssClient::head_object`
|
||||
3. `asset_object`
|
||||
4. `asset_entity_binding`
|
||||
5. `module-ai` 进程内 `AiTaskService`
|
||||
6. `platform-llm` OpenAI 兼容文本模型网关
|
||||
7. `custom world` 图片兼容入口已经完成一版 `OSS + asset_object + asset_entity_binding` 落地
|
||||
|
||||
因此本批不重新设计一套新资产基础设施,而是复用:
|
||||
|
||||
1. 既有 `OSS` 上传与确认链
|
||||
2. 既有 `asset_object / asset_entity_binding`
|
||||
3. 既有 `AiTaskService`
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容角色主形象候选生成接口
|
||||
2. 兼容角色主形象任务状态查询接口
|
||||
3. 兼容角色主形象正式发布接口
|
||||
4. 候选草稿对象写入 OSS `generated-character-drafts/*`
|
||||
5. 正式主图对象写入 OSS `generated-characters/*`
|
||||
6. 正式发布结果写入 `asset_object`
|
||||
7. 正式发布结果绑定到角色实体槽位
|
||||
8. 返回字段继续保持旧前端可消费 contract
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不落 `asset_job` 正式 SpacetimeDB 表
|
||||
2. 不落 `character_visual_asset` 强业务表
|
||||
3. 不落 `character-workflow-cache`
|
||||
4. 不落 `character-animation` 全链路
|
||||
5. 不回写 `src/data/characterOverrides.json`
|
||||
6. 不要求前端改成新的对象读取协议
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `POST /api/assets/character-visual/generate`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `taskId`
|
||||
3. `model`
|
||||
4. `prompt`
|
||||
5. `drafts`
|
||||
|
||||
其中每个 `draft` 继续包含:
|
||||
|
||||
1. `id`
|
||||
2. `label`
|
||||
3. `imageSrc`
|
||||
4. `width`
|
||||
5. `height`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. `imageSrc` 继续返回旧前端习惯的 `/generated-character-drafts/*`
|
||||
2. 草稿对象底层不再写本地 `public/`
|
||||
3. 草稿对象真相仅在 OSS
|
||||
|
||||
### 4.2 `GET /api/assets/character-visual/jobs/:taskId`
|
||||
|
||||
返回结构继续保持旧前端读取方式:
|
||||
|
||||
1. `taskId`
|
||||
2. `kind`
|
||||
3. `status`
|
||||
4. `characterId`
|
||||
5. `model`
|
||||
6. `prompt`
|
||||
7. `createdAt`
|
||||
8. `updatedAt`
|
||||
9. `result`
|
||||
10. `errorMessage`
|
||||
|
||||
当前阶段直接复用 `AiTaskService` 内存态任务快照派生,不要求前端改字段名。
|
||||
|
||||
### 4.3 `POST /api/assets/character-visual/publish`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `assetId`
|
||||
3. `portraitPath`
|
||||
4. `overrideMap`
|
||||
5. `saveMessage`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. `portraitPath` 固定返回 `/generated-characters/*`
|
||||
2. 当前 `overrideMap` 先返回空对象 `{}`,只做 contract 兼容,不再在 Rust 后端写本地覆盖文件
|
||||
3. `saveMessage` 明确说明当前是“已写入 OSS 并绑定业务实体”
|
||||
|
||||
## 5. 业务实体与槽位约定
|
||||
|
||||
本批统一复用通用 `asset_entity_binding`。
|
||||
|
||||
### 5.1 角色主形象正式对象
|
||||
|
||||
| 字段 | 取值 |
|
||||
| --- | --- |
|
||||
| `entity_kind` | `character` |
|
||||
| `entity_id` | `characterId` |
|
||||
| `slot` | `primary_visual` |
|
||||
| `asset_kind` | `character_visual` |
|
||||
|
||||
补充口径:
|
||||
|
||||
1. 同一角色重复发布时,允许覆盖到最新对象
|
||||
2. 候选草稿对象不创建业务绑定
|
||||
3. 业务引用真相以 `asset_entity_binding` 为准
|
||||
|
||||
## 6. OSS 对象键规划
|
||||
|
||||
### 6.1 候选草稿
|
||||
|
||||
候选草稿固定写入:
|
||||
|
||||
`generated-character-drafts/{characterSegment}/visual/{taskId}/candidate-{index}.svg`
|
||||
|
||||
### 6.2 正式主图
|
||||
|
||||
正式主图固定写入:
|
||||
|
||||
`generated-characters/{characterSegment}/visual/{assetId}/master.svg`
|
||||
|
||||
## 7. 任务状态口径
|
||||
|
||||
当前阶段不新增独立 `asset_job` 表,统一复用 `module-ai` 的内存态 `AiTaskService`。
|
||||
|
||||
### 7.1 任务种类
|
||||
|
||||
`task_kind` 统一使用:
|
||||
|
||||
`custom_world_generation`
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前 `module-ai` 已冻结的可用任务类型之一
|
||||
2. 本批只把它当作“生成类资产任务”的最小任务容器
|
||||
3. 后续 `asset_job` 表落地后,再把角色主形象任务迁到正式资产任务模型
|
||||
|
||||
### 7.2 阶段映射
|
||||
|
||||
当前固定使用以下阶段:
|
||||
|
||||
1. `prepare_prompt`
|
||||
2. `request_model`
|
||||
3. `normalize_result`
|
||||
4. `persist_result`
|
||||
|
||||
其中:
|
||||
|
||||
1. `generate` 成功后,任务直接进入 `completed`
|
||||
2. `publish` 不额外创建新任务,只消费已有候选路径
|
||||
|
||||
## 8. Rust 第一批生成策略
|
||||
|
||||
本批生成策略固定为:
|
||||
|
||||
1. 若已配置 `platform-llm`,则用文本模型生成一个结构化占位结果
|
||||
2. 服务端把结果渲染成 SVG 占位图
|
||||
3. 占位图写入 OSS 草稿路径
|
||||
|
||||
说明:
|
||||
|
||||
1. 这不是最终的 DashScope 图片模型正式链
|
||||
2. 但它可以先把“接口 contract + 任务状态 + OSS 真相 + 正式发布绑定”全部打通
|
||||
3. 后续替换成真实图片模型时,不需要再改动主链结构
|
||||
|
||||
## 9. 服务端执行顺序
|
||||
|
||||
### 9.1 生成
|
||||
|
||||
每次调用 `generate` 固定执行:
|
||||
|
||||
1. 创建 `AiTask`
|
||||
2. 生成最终 prompt
|
||||
3. 产出候选 SVG 字节
|
||||
4. 每个候选对象上传 OSS
|
||||
5. 回写任务结果
|
||||
6. 返回 `/generated-character-drafts/*`
|
||||
|
||||
### 9.2 发布
|
||||
|
||||
每次调用 `publish` 固定执行:
|
||||
|
||||
1. 校验 `selectedPreviewSource`
|
||||
2. 解析旧 `/generated-*` 路径为 `object_key`
|
||||
3. 调 OSS `HEAD Object` 确认候选对象存在
|
||||
4. 读取候选对象内容
|
||||
5. 上传正式主图对象到 `generated-characters/*`
|
||||
6. 对正式对象执行 `asset_object` 确认
|
||||
7. 对正式对象执行 `asset_entity_binding`
|
||||
8. 返回 `/generated-characters/*`
|
||||
|
||||
## 10. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `character-visual generate / jobs / publish`
|
||||
2. 候选草稿不再写本地 `public/generated-character-drafts`
|
||||
3. 正式主图不再写本地 `public/generated-characters`
|
||||
4. 发布成功后能形成 `asset_object`
|
||||
5. 发布成功后能形成 `asset_entity_binding`
|
||||
6. 前端仍能继续消费 `taskId / drafts / portraitPath` 旧 contract
|
||||
|
||||
## 11. 关联文档
|
||||
|
||||
1. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
3. [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
|
||||
4. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
@@ -0,0 +1,138 @@
|
||||
# M6 角色资产工作流缓存接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色资产工作流缓存”的真实落地口径。
|
||||
|
||||
本批只解决以下两条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `GET /api/assets/character-workflow-cache/:characterId`
|
||||
2. `POST /api/assets/character-workflow-cache`
|
||||
|
||||
目标是把旧 Node 写入本地 `public/generated-character-drafts/*/workflow-cache.json` 的临时缓存,切到 OSS JSON 草稿对象,继续保持前端当前可消费 contract。
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `platform-oss::OssClient::sign_get_object_url`
|
||||
3. `generated-character-drafts/*` 兼容对象键前缀
|
||||
4. 角色主形象与动作导入已经开始把草稿对象写入 OSS
|
||||
|
||||
因此本批不新增数据库表,也不引入本地 JSON 文件。
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容工作流缓存读取接口
|
||||
2. 兼容工作流缓存保存接口
|
||||
3. 缓存 JSON 写入 OSS `generated-character-drafts/*`
|
||||
4. 返回字段继续保持旧前端可消费 contract
|
||||
5. 不再把缓存写入本地 `public/`
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不落 `asset_object`
|
||||
2. 不落 `asset_manifest`
|
||||
3. 不落 `character_visual_asset`
|
||||
4. 不落 `character_animation_asset`
|
||||
5. 不做跨设备强一致合并
|
||||
6. 不迁移历史本地缓存文件
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `GET /api/assets/character-workflow-cache/:characterId`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `cache`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. 未找到 OSS 缓存对象时返回 `cache: null`
|
||||
2. 找到对象但 `characterId` 不匹配时返回 `cache: null`
|
||||
3. 返回的 `cache` 字段保持前端 `CharacterAssetWorkflowCache` 结构
|
||||
|
||||
### 4.2 `POST /api/assets/character-workflow-cache`
|
||||
|
||||
请求结构继续保持前端当前字段:
|
||||
|
||||
1. `characterId`
|
||||
2. `visualPromptText`
|
||||
3. `animationPromptText`
|
||||
4. `visualDrafts`
|
||||
5. `selectedVisualDraftId`
|
||||
6. `selectedAnimation`
|
||||
7. `imageSrc`
|
||||
8. `generatedVisualAssetId`
|
||||
9. `generatedAnimationSetId`
|
||||
10. `animationMap`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `cache`
|
||||
3. `saveMessage`
|
||||
|
||||
## 5. OSS 对象键规划
|
||||
|
||||
缓存 JSON 固定写入:
|
||||
|
||||
`generated-character-drafts/{characterSegment}/workflow-cache/workflow-cache.json`
|
||||
|
||||
其中:
|
||||
|
||||
1. `characterSegment` 来自 `characterId` 的安全路径片段
|
||||
2. 文件名固定为 `workflow-cache.json`
|
||||
3. content type 固定为 `application/json; charset=utf-8`
|
||||
|
||||
## 6. 字段归一化规则
|
||||
|
||||
保存接口固定执行以下归一化:
|
||||
|
||||
1. `characterId` 必填,trim 后不能为空
|
||||
2. `visualPromptText` 最长保留 280 字
|
||||
3. `animationPromptText` 最长保留 280 字
|
||||
4. `visualDrafts` 只保留有 `imageSrc` 的候选
|
||||
5. `visualDrafts[].width` 默认 `1024`
|
||||
6. `visualDrafts[].height` 默认 `1536`
|
||||
7. `selectedAnimation` 默认 `idle`
|
||||
8. 空 `imageSrc / generatedVisualAssetId / generatedAnimationSetId` 不序列化
|
||||
9. 非对象 `animationMap` 归一化为 `null`
|
||||
10. `updatedAt` 由 Rust 服务端生成 UTC 时间
|
||||
|
||||
## 7. 元数据规范
|
||||
|
||||
缓存 JSON 对象写入以下 `x-oss-meta-*` 元数据:
|
||||
|
||||
1. `asset_kind = character_workflow_cache`
|
||||
2. `owner_user_id = asset-tool`
|
||||
3. `entity_kind = character`
|
||||
4. `entity_id = characterId`
|
||||
5. `slot = workflow_cache`
|
||||
|
||||
说明:
|
||||
|
||||
1. 旧资产工坊接口没有显式 Bearer,第一批继续使用 `asset-tool` 作为兼容归属。
|
||||
2. workflow cache 是工作流草稿状态,不是正式可发布资产,因此本批不确认 `asset_object`。
|
||||
|
||||
## 8. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `GET /api/assets/character-workflow-cache/:characterId`
|
||||
2. Rust 已兼容 `POST /api/assets/character-workflow-cache`
|
||||
3. 缓存 JSON 写入 OSS `generated-character-drafts/*`
|
||||
4. 未命中时返回 `cache: null`
|
||||
5. 前端仍能继续消费 `cache / saveMessage` 旧 contract
|
||||
|
||||
## 9. 关联文档
|
||||
|
||||
1. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
|
||||
3. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||
@@ -0,0 +1,203 @@
|
||||
# M6 custom world 场景图 / 封面图 Stage 2 设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 custom world 图片链在 `Stage 1` 之后的第二批迁移口径。
|
||||
|
||||
`Stage 1` 已完成:
|
||||
|
||||
1. `scene-image / cover-image / cover-upload` 不再写仓库 `public/`
|
||||
2. 图片对象统一写入 `OSS`
|
||||
3. 写入后统一形成 `asset_object + asset_entity_binding`
|
||||
|
||||
但当前仍有两段能力没有迁完:
|
||||
|
||||
1. `scene-image / cover-image` 仍使用 Rust SVG 占位图,而不是 Node 旧链路里的真实 DashScope 图片生成
|
||||
2. `cover-upload` 仍未迁移 Node 旧链路里的 `cropRect + 16:9 裁剪 + WebP 压缩`
|
||||
|
||||
本批目标就是把这两段缺失能力补齐,同时继续保持 `Stage 1` 已冻结的 OSS 真相链。
|
||||
|
||||
## 2. 本批范围
|
||||
|
||||
### 2.1 要完成的内容
|
||||
|
||||
1. `POST /api/custom-world/scene-image` 接入真实 DashScope 图片生成
|
||||
2. `POST /api/custom-world/cover-image` 接入真实 DashScope 图片生成
|
||||
3. `POST /api/custom-world/cover-upload` 接入裁剪、缩放、压缩
|
||||
4. 生成后的图片仍统一写入 `OSS`
|
||||
5. 每次成功写图仍统一形成 `asset_object + asset_entity_binding`
|
||||
6. 路由响应继续保持旧前端字段形状
|
||||
|
||||
### 2.2 本批不解决的内容
|
||||
|
||||
1. 不引入新的 custom world 图片任务表
|
||||
2. 不引入 `signedUrl` 直返业务字段
|
||||
3. 不在本批补视频 Range、分片传输或前端编辑器新交互
|
||||
4. 不在本批迁移更多 custom world 非图片媒体链路
|
||||
|
||||
## 3. 旧 Node 口径对齐
|
||||
|
||||
### 3.1 场景图生成
|
||||
|
||||
Node 旧链路区分两种模式:
|
||||
|
||||
1. 无参考图:走 DashScope `text2image`
|
||||
2. 有参考图:走 DashScope `multimodal-generation`
|
||||
|
||||
本批 Rust 继续保持同口径:
|
||||
|
||||
1. `referenceImageSrc` 为空时:
|
||||
- 模型默认 `wan2.2-t2i-flash`
|
||||
- 路径:`/services/aigc/text2image/image-synthesis`
|
||||
- 异步创建任务后轮询 `/tasks/{taskId}`
|
||||
2. `referenceImageSrc` 非空时:
|
||||
- 模型默认 `qwen-image-2.0`
|
||||
- 路径:`/services/aigc/multimodal-generation/generation`
|
||||
- 直接取返回中的第一张图
|
||||
|
||||
### 3.2 封面图生成
|
||||
|
||||
Node 旧链路也区分两种模式:
|
||||
|
||||
1. 无参考图:`wan2.2-t2i-flash`
|
||||
2. 有参考图:`qwen-image-2.0`
|
||||
|
||||
Rust 本批保持一致,并继续沿用:
|
||||
|
||||
1. `profile + opening act + selected roles + landmarks` 作为 prompt 上下文
|
||||
2. 最多 6 张参考图
|
||||
3. 返回 `sourceType = generated`
|
||||
|
||||
### 3.3 封面上传
|
||||
|
||||
Node 旧链路对上传封面有明确处理:
|
||||
|
||||
1. 请求必须提供 `cropRect`
|
||||
2. `cropRect` 必须保持 `16:9`
|
||||
3. 输出固定缩放为 `1600x900`
|
||||
4. 输出格式固定为 `webp`
|
||||
5. 输出体积上限 `1.5 MB`
|
||||
6. 原图体积上限 `10 MB`
|
||||
|
||||
Rust 本批必须保持这组兼容约束。
|
||||
|
||||
## 4. 请求与响应 contract
|
||||
|
||||
### 4.1 `POST /api/custom-world/scene-image`
|
||||
|
||||
在 `Stage 1` 字段基础上,Rust 本批补齐兼容读取:
|
||||
|
||||
1. `negativePrompt`
|
||||
2. `referenceImageSrc`
|
||||
|
||||
返回仍为:
|
||||
|
||||
1. `imageSrc`
|
||||
2. `assetId`
|
||||
3. `model`
|
||||
4. `size`
|
||||
5. `taskId`
|
||||
6. `prompt`
|
||||
7. `actualPrompt`
|
||||
|
||||
### 4.2 `POST /api/custom-world/cover-image`
|
||||
|
||||
继续兼容:
|
||||
|
||||
1. `profile`
|
||||
2. `userPrompt`
|
||||
3. `referenceImageSrc`
|
||||
4. `characterRoleIds`
|
||||
5. `size`
|
||||
|
||||
返回仍为:
|
||||
|
||||
1. `imageSrc`
|
||||
2. `assetId`
|
||||
3. `sourceType = generated`
|
||||
4. `model`
|
||||
5. `size`
|
||||
6. `taskId`
|
||||
7. `prompt`
|
||||
8. `actualPrompt`
|
||||
|
||||
### 4.3 `POST /api/custom-world/cover-upload`
|
||||
|
||||
继续兼容:
|
||||
|
||||
1. `profileId`
|
||||
2. `worldName`
|
||||
3. `imageDataUrl`
|
||||
4. `cropRect`
|
||||
|
||||
返回仍为:
|
||||
|
||||
1. `imageSrc`
|
||||
2. `assetId`
|
||||
3. `sourceType = uploaded`
|
||||
|
||||
## 5. 服务端执行顺序
|
||||
|
||||
### 5.1 场景图 / 封面图生成
|
||||
|
||||
统一执行:
|
||||
|
||||
1. 归一 prompt 与模型选择
|
||||
2. 向 DashScope 发起生成请求
|
||||
3. 下载生成结果图片二进制
|
||||
4. `put_object`
|
||||
5. `HEAD Object`
|
||||
6. `confirm asset_object`
|
||||
7. `bind asset_entity_binding`
|
||||
8. 返回 `legacyPublicPath`
|
||||
|
||||
### 5.2 封面上传
|
||||
|
||||
统一执行:
|
||||
|
||||
1. 解析 `imageDataUrl`
|
||||
2. 校验原图体积
|
||||
3. 解码图片
|
||||
4. 按 `cropRect` 裁剪
|
||||
5. 校验裁剪区域 `16:9`
|
||||
6. 缩放到 `1600x900`
|
||||
7. 编码为 `webp`
|
||||
8. 若超过 `1.5 MB`,逐档降低质量重试
|
||||
9. `put_object`
|
||||
10. `HEAD Object`
|
||||
11. `confirm asset_object`
|
||||
12. `bind asset_entity_binding`
|
||||
13. 返回 `legacyPublicPath`
|
||||
|
||||
## 6. 环境变量与模型口径
|
||||
|
||||
本批继续复用现有 DashScope 环境变量,不新增另一套命名:
|
||||
|
||||
1. `DASHSCOPE_BASE_URL`
|
||||
2. `DASHSCOPE_API_KEY`
|
||||
3. `DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS`
|
||||
|
||||
模型默认值固定为:
|
||||
|
||||
1. 场景图文生图:`wan2.2-t2i-flash`
|
||||
2. 场景图参考图模式:`qwen-image-2.0`
|
||||
3. 封面文生图:`wan2.2-t2i-flash`
|
||||
4. 封面参考图模式:`qwen-image-2.0`
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. `scene-image` 不再返回 Rust SVG 占位图
|
||||
2. `cover-image` 不再返回 Rust SVG 占位图
|
||||
3. `cover-upload` 已执行 `cropRect + 16:9 + webp + 1.5MB`
|
||||
4. 三条链路仍统一落到 `OSS + asset_object + asset_entity_binding`
|
||||
5. 前端无需改 contract 即可继续消费
|
||||
|
||||
## 8. 关联文档
|
||||
|
||||
1. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
3. [M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](./M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md)
|
||||
@@ -0,0 +1,75 @@
|
||||
# M6 旧 generated 路径 OSS 读取兼容设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档冻结 `M6` 第一批 OSS 化之后,旧前端继续访问 `/generated-*` 路径的 Rust 后端兼容口径。
|
||||
|
||||
当前角色主形象、角色动作、custom world 场景图和封面图已经把新生成资产写入私有 OSS。旧前端仍会把以下路径当作图片、视频或动作帧地址直接交给 `<img>`、`<video>`、canvas 抽帧或 `CharacterAnimator`:
|
||||
|
||||
1. `/generated-character-drafts/*`
|
||||
2. `/generated-characters/*`
|
||||
3. `/generated-animations/*`
|
||||
4. `/generated-custom-world-scenes/*`
|
||||
5. `/generated-custom-world-covers/*`
|
||||
6. `/generated-qwen-sprites/*`
|
||||
|
||||
如果只提供 `/api/assets/read-url`,旧 UI 中直接消费资源路径的位置会继续失败。因此本批补一个同源读取兼容层。
|
||||
|
||||
## 2. 本批范围
|
||||
|
||||
### 2.1 要完成的内容
|
||||
|
||||
1. Rust `api-server` 挂接上述六类 `GET /generated-*/*` 路由。
|
||||
2. 路由把 legacy path 转成 OSS `object_key`。
|
||||
3. 路由使用服务端 OSS 主凭证生成短期私有读签名。
|
||||
4. 路由由服务端拉取 OSS 对象并同源返回二进制内容。
|
||||
5. 返回保留 OSS 的 `content-type`,补充 `cache-control`,让图片、视频、SVG、JSON manifest 都能被旧前端直接消费。
|
||||
6. Vite 本地开发代理补齐 `/generated-animations` 与 `/generated-custom-world-covers`,避免新 OSS 路径在开发期落回本地 `public/`。
|
||||
|
||||
### 2.2 本批不解决的内容
|
||||
|
||||
1. 不把私有 OSS 对象改成公开读。
|
||||
2. 不引入 CDN。
|
||||
3. 不把对象缓存到本地 `public/`。
|
||||
4. 不迁移历史本地文件。
|
||||
5. 不实现 Range 分片视频流;Stage 1 先全量代理对象,后续如视频体积变大再补 Range。
|
||||
|
||||
## 3. 路由契约
|
||||
|
||||
每条旧路径均返回原始资源内容:
|
||||
|
||||
1. 成功:`200`,body 为 OSS 对象二进制内容。
|
||||
2. OSS 对象不存在:`404`。
|
||||
3. OSS 配置缺失:`503`。
|
||||
4. object key 不在受支持 `generated-*` 前缀:`400`。
|
||||
5. OSS 请求失败:`502`。
|
||||
|
||||
响应头:
|
||||
|
||||
1. `content-type`:优先使用 OSS 响应头。
|
||||
2. `cache-control`:`private, max-age=60`。
|
||||
3. `x-genarrative-asset-object-key`:回写解析后的 OSS object key,方便调试。
|
||||
|
||||
## 4. 对象键约定
|
||||
|
||||
旧路径去掉开头 `/` 后就是 OSS `object_key`。
|
||||
|
||||
示例:
|
||||
|
||||
`/generated-animations/hero/animation-set-1/idle/frame01.png`
|
||||
|
||||
对应:
|
||||
|
||||
`generated-animations/hero/animation-set-1/idle/frame01.png`
|
||||
|
||||
## 5. 完成定义
|
||||
|
||||
当以下条件满足时,本批路径兼容视为完成:
|
||||
|
||||
1. Rust 已挂接六类 `/generated-*` 路由。
|
||||
2. 路由能通过 OSS 私有读签名同源代理对象内容。
|
||||
3. `cargo check -p api-server` 通过。
|
||||
4. `scripts/check-encoding.mjs` 覆盖本轮新增文档和相关代码。
|
||||
5. `05_M6_ASSETS_OSS_EDITOR.md` 中路径兼容项完成勾选。
|
||||
@@ -37,6 +37,8 @@
|
||||
- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。
|
||||
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
|
||||
- [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md):冻结 M6 剩余的 STS 与服务端上传 helper 落地口径,明确当前上传主链为服务器上传 OSS,Web 端只负责签名读下载。
|
||||
- [M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md](./M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md):冻结 M6 第一批内容 hash、版本、manifest、asset job 与强业务资产表的 Stage 1 边界,明确当前使用 `asset_object + asset_entity_binding + OSS manifest + AiTaskService` 闭合,不重复新增表。
|
||||
- [M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](./M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md):冻结 M6 旧 `/generated-*` 路径到 OSS 私有读同源代理的兼容口径,保证旧前端仍能直接消费图片、视频、动作帧与 manifest。
|
||||
- [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。
|
||||
- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 首批 `runtime settings` 纵向切片的表字段、默认值、procedure、Axum facade、错误 contract 与测试策略。
|
||||
- [SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md):冻结 `M5` Agent session create / snapshot 的最小 SpacetimeDB 与 Axum facade 闭环,明确本轮不迁移 LLM、SSE、卡片更新和完整 action registry。
|
||||
@@ -46,6 +48,10 @@
|
||||
- [SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md):补齐 `M5` Stage 5 遗漏的 owner-only `GET /api/runtime/custom-world-library/:profileId` 设计,冻结单条 profile detail 的 SpacetimeDB procedure、client facade、404 语义与 Axum 路由扩展方式。
|
||||
- [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md):冻结 `M5` 剩余主链的 works、card detail、publish gate、supportedActions、action registry 与 AI/OSS 兼容路由边界,作为 Stage 9 到收口阶段的统一落地依据。
|
||||
- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批 custom world 场景图、封面图、封面上传从本地 `public/` 临时落地切到 `OSS + asset_object + asset_entity_binding` 正式真相链的边界与槽位约定。
|
||||
- [M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作 `generate / jobs / publish` 接口从旧本地 `public/generated-*` 真相切到 `OSS + asset_object + asset_entity_binding + AI task` 的最小闭环与兼容 contract。
|
||||
- [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作模板查询与参考视频导入从旧 Node 本地草稿写盘切到 Rust `OSS` 草稿对象的接口 contract、对象键规划与暂不确认 `asset_object` 的边界。
|
||||
- [M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](./M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色资产工作流缓存从旧 Node 本地 `workflow-cache.json` 切到 Rust `OSS` JSON 草稿对象的读写 contract、字段归一化与暂不落正式资产表的边界。
|
||||
- [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色主形象 `generate / jobs / publish` 接口从旧本地 `public/generated-*` 真相切到 `OSS + asset_object + asset_entity_binding + AI task` 的最小闭环与兼容 contract。
|
||||
- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 第二批 `browse history` 纵向切片的 `user_browse_history` 表、双路径 facade、宽松归一化、去重排序规则与测试策略。
|
||||
- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。
|
||||
- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
26. 接入 `POST /api/assets/character-visual/generate`
|
||||
27. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
28. 接入 `POST /api/assets/character-visual/publish`
|
||||
29. 接入 `GET /api/assets/character-animation/templates`
|
||||
30. 接入 `POST /api/assets/character-animation/import-video`
|
||||
31. 接入 `GET /api/assets/character-workflow-cache/{character_id}`
|
||||
32. 接入 `POST /api/assets/character-workflow-cache`
|
||||
33. 接入 `POST /api/assets/character-animation/generate`
|
||||
34. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
35. 接入 `POST /api/assets/character-animation/publish`
|
||||
36. 接入旧 `/generated-character-drafts/*`、`/generated-characters/*`、`/generated-animations/*`、`/generated-custom-world-scenes/*`、`/generated-custom-world-covers/*`、`/generated-qwen-sprites/*` 到 OSS 私有读代理
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -75,6 +86,11 @@
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
22. [x] 接入旧 `runtime story` compat facade
|
||||
23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade
|
||||
25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade
|
||||
26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
27. [x] 接入旧 `/generated-*` 路径到 OSS 私有读同源代理
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -144,3 +160,9 @@
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler,但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。
|
||||
16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
|
||||
17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。
|
||||
18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。
|
||||
19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
|
||||
20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。
|
||||
21. 当前旧 `/generated-*` 读取兼容层只代理受支持 generated 前缀到 OSS 私有读签名,不回退仓库 `public/`,Stage 1 不支持视频 Range 分片。
|
||||
|
||||
@@ -24,6 +24,14 @@ use crate::{
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_sessions::auth_sessions,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, save_character_workflow_cache,
|
||||
},
|
||||
character_visual_assets::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
},
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, execute_custom_world_agent_action,
|
||||
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
|
||||
@@ -40,6 +48,11 @@ use crate::{
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
legacy_generated_assets::{
|
||||
proxy_generated_animations, proxy_generated_character_drafts, proxy_generated_characters,
|
||||
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
|
||||
proxy_generated_qwen_sprites,
|
||||
},
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
@@ -95,6 +108,30 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/login-options", get(auth_login_options))
|
||||
.route(
|
||||
"/generated-character-drafts/{*path}",
|
||||
get(proxy_generated_character_drafts),
|
||||
)
|
||||
.route(
|
||||
"/generated-characters/{*path}",
|
||||
get(proxy_generated_characters),
|
||||
)
|
||||
.route(
|
||||
"/generated-animations/{*path}",
|
||||
get(proxy_generated_animations),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-scenes/{*path}",
|
||||
get(proxy_generated_custom_world_scenes),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-covers/{*path}",
|
||||
get(proxy_generated_custom_world_covers),
|
||||
)
|
||||
.route(
|
||||
"/generated-qwen-sprites/{*path}",
|
||||
get(proxy_generated_qwen_sprites),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
@@ -234,6 +271,46 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/assets/objects/bind",
|
||||
post(bind_asset_object_to_entity),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/generate",
|
||||
post(generate_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/jobs/{task_id}",
|
||||
get(get_character_visual_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/publish",
|
||||
post(publish_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/generate",
|
||||
post(generate_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/publish",
|
||||
post(publish_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/import-video",
|
||||
post(import_character_animation_video),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/templates",
|
||||
get(list_character_animation_templates),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache",
|
||||
post(save_character_workflow_cache),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache/{character_id}",
|
||||
get(get_character_workflow_cache),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
|
||||
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
@@ -0,0 +1,938 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_ai::{
|
||||
AiResultReferenceKind, AiStageCompletionInput, AiTaskCreateInput, AiTaskKind,
|
||||
AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
|
||||
CharacterVisualGenerateRequest, CharacterVisualGenerateResponse, CharacterVisualPublishRequest,
|
||||
CharacterVisualPublishResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const CHARACTER_VISUAL_MODEL: &str = "rust-svg-character-visual";
|
||||
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
|
||||
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
|
||||
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
|
||||
|
||||
pub async fn generate_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let task_id = generate_ai_task_id(current_utc_micros());
|
||||
let prompt = build_character_visual_prompt(
|
||||
payload.prompt_text.as_str(),
|
||||
payload.character_brief_text.as_deref(),
|
||||
);
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
|
||||
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
|
||||
let candidate_count = payload.candidate_count.clamp(1, 4);
|
||||
|
||||
let created = create_visual_task(
|
||||
&state,
|
||||
&task_id,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&model,
|
||||
&prompt,
|
||||
)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
let result = async {
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::PreparePrompt,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PreparePrompt,
|
||||
text_output: Some(prompt.clone()),
|
||||
structured_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"sourceMode": payload.source_mode,
|
||||
"size": size,
|
||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let visual_seed = generate_visual_seed_with_llm(&state, &prompt, &character_id).await;
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::RequestModel,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::RequestModel,
|
||||
text_output: Some(visual_seed.clone()),
|
||||
structured_payload_json: None,
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let drafts = persist_visual_drafts(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&task_id,
|
||||
&visual_seed,
|
||||
&size,
|
||||
candidate_count,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result_payload = json!({
|
||||
"drafts": drafts,
|
||||
"draftRelativeDir": format!(
|
||||
"generated-character-drafts/{}/visual/{}",
|
||||
sanitize_storage_segment(character_id.as_str(), "character"),
|
||||
task_id
|
||||
),
|
||||
});
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::NormalizeResult,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::NormalizeResult,
|
||||
text_output: None,
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PersistResult,
|
||||
text_output: Some("角色主形象候选草稿已写入 OSS。".to_string()),
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
Ok::<_, AppError>(drafts)
|
||||
}
|
||||
.await;
|
||||
|
||||
let drafts = match result {
|
||||
Ok(drafts) => drafts,
|
||||
Err(error) => {
|
||||
let _ = state.ai_task_service().fail_task(
|
||||
created.task_id.as_str(),
|
||||
error.message().to_string(),
|
||||
current_utc_micros(),
|
||||
);
|
||||
return Err(character_visual_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id,
|
||||
model,
|
||||
prompt,
|
||||
drafts,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_character_visual_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let task = state
|
||||
.ai_task_service()
|
||||
.get_task(task_id.as_str())
|
||||
.map_err(map_ai_task_error)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_character_visual_job_payload(task),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn publish_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualPublishRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
if payload.selected_preview_source.trim().is_empty() {
|
||||
return Err(character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let asset_id = format!("visual-{}", current_utc_millis());
|
||||
let published = persist_published_visual(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
asset_id.as_str(),
|
||||
payload.selected_preview_source.as_str(),
|
||||
payload.prompt_text.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualPublishResponse {
|
||||
ok: true,
|
||||
asset_id,
|
||||
portrait_path: published,
|
||||
override_map: json!({}),
|
||||
save_message: if payload.update_character_override == Some(false) {
|
||||
"主形象已写入 OSS 并绑定当前角色,可直接写回当前自定义世界角色。".to_string()
|
||||
} else {
|
||||
"主形象已写入 OSS 并绑定当前角色;Rust 后端不再写本地角色覆盖文件。".to_string()
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn create_visual_task(
|
||||
state: &AppState,
|
||||
task_id: &str,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<AiTaskSnapshot, AppError> {
|
||||
state
|
||||
.ai_task_service()
|
||||
.create_task(AiTaskCreateInput {
|
||||
task_id: task_id.to_string(),
|
||||
task_kind: AiTaskKind::CustomWorldGeneration,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
request_label: "生成角色主形象".to_string(),
|
||||
source_module: "assets.character_visual".to_string(),
|
||||
source_entity_id: Some(character_id.to_string()),
|
||||
request_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
stages: AiTaskKind::CustomWorldGeneration.default_stage_blueprints(),
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)
|
||||
}
|
||||
|
||||
async fn generate_visual_seed_with_llm(
|
||||
state: &AppState,
|
||||
prompt: &str,
|
||||
character_id: &str,
|
||||
) -> String {
|
||||
let fallback = format!("{character_id}:{prompt}");
|
||||
let Some(llm_client) = state.llm_client() else {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏角色主形象草稿描述器。只输出一句中文视觉摘要,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "summarize_character_visual_seed",
|
||||
"characterId": character_id,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
])
|
||||
.with_max_tokens(96);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
async fn persist_visual_drafts(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
task_id: &str,
|
||||
visual_seed: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
) -> Result<Vec<CharacterVisualDraftPayload>, AppError> {
|
||||
let mut drafts = Vec::with_capacity(candidate_count as usize);
|
||||
for index in 0..candidate_count {
|
||||
let file_name = format!("candidate-{:02}.svg", index + 1);
|
||||
let body =
|
||||
build_character_visual_svg(size, visual_seed, format!("候选 {}", index + 1).as_str())
|
||||
.into_bytes();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::CharacterDrafts,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
task_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
"image/svg+xml".to_string(),
|
||||
body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
"draft",
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
drafts.push(CharacterVisualDraftPayload {
|
||||
id: format!("candidate-{}", index + 1),
|
||||
label: format!("候选 {}", index + 1),
|
||||
image_src: put_result.legacy_public_path,
|
||||
width: parse_size(size).0,
|
||||
height: parse_size(size).1,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
async fn persist_published_visual(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_id: &str,
|
||||
selected_preview_source: &str,
|
||||
prompt_text: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let source_object_key = resolve_object_key_from_legacy_path(selected_preview_source)?;
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
&http_client,
|
||||
OssHeadObjectRequest {
|
||||
object_key: source_object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: source_object_key,
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let source_body = http_client
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象内容失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
let content_type = head
|
||||
.content_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image/svg+xml".to_string());
|
||||
let file_name = match content_type.as_str() {
|
||||
"image/png" => "master.png",
|
||||
"image/jpeg" => "master.jpg",
|
||||
"image/webp" => "master.webp",
|
||||
_ => "master.svg",
|
||||
}
|
||||
.to_string();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::Characters,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
asset_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
content_type.clone(),
|
||||
source_body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
CHARACTER_VISUAL_SLOT,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let confirmed = confirm_character_visual_asset_object(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
asset_id,
|
||||
put_result.object_key.clone(),
|
||||
content_type,
|
||||
prompt_text.map(str::to_string),
|
||||
)
|
||||
.await?;
|
||||
bind_character_visual_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
confirmed.record.asset_object_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
async fn put_character_visual_object(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: Vec<String>,
|
||||
file_name: String,
|
||||
content_type: String,
|
||||
body: Vec<u8>,
|
||||
metadata: BTreeMap<String, String>,
|
||||
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
oss_client
|
||||
.put_object(
|
||||
&reqwest::Client::new(),
|
||||
OssPutObjectRequest {
|
||||
prefix,
|
||||
path_segments,
|
||||
file_name,
|
||||
content_type: Some(content_type),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata,
|
||||
body,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)
|
||||
}
|
||||
|
||||
async fn confirm_character_visual_asset_object(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
source_job_id: &str,
|
||||
object_key: String,
|
||||
content_type: String,
|
||||
prompt_text: Option<String>,
|
||||
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let head = oss_client
|
||||
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let now_micros = current_utc_micros();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(content_type)),
|
||||
head.content_length,
|
||||
prompt_text.or(head.etag),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(source_job_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
Some(character_id.to_string()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_object_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
let _ = state.ai_task_service().attach_result_reference(
|
||||
source_job_id,
|
||||
AiResultReferenceKind::AssetObject,
|
||||
record.asset_object_id.clone(),
|
||||
Some("角色主形象正式对象".to_string()),
|
||||
now_micros,
|
||||
);
|
||||
Ok(module_assets::ConfirmAssetObjectResult { record })
|
||||
}
|
||||
|
||||
async fn bind_character_visual_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_object_id: String,
|
||||
) -> Result<(), AppError> {
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
.bind_asset_object_to_entity(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(now_micros),
|
||||
asset_object_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND.to_string(),
|
||||
character_id.to_string(),
|
||||
CHARACTER_VISUAL_SLOT.to_string(),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_binding_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload {
|
||||
let request_payload = task
|
||||
.request_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let result = task
|
||||
.latest_structured_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
||||
|
||||
CharacterAssetJobStatusPayload {
|
||||
task_id: task.task_id,
|
||||
kind: "visual".to_string(),
|
||||
status: match task.status {
|
||||
AiTaskStatus::Pending => CharacterAssetJobStatusText::Queued,
|
||||
AiTaskStatus::Running => CharacterAssetJobStatusText::Running,
|
||||
AiTaskStatus::Completed => CharacterAssetJobStatusText::Completed,
|
||||
AiTaskStatus::Failed | AiTaskStatus::Cancelled => CharacterAssetJobStatusText::Failed,
|
||||
},
|
||||
character_id: request_payload
|
||||
.get("characterId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
animation: None,
|
||||
strategy: None,
|
||||
model: request_payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(CHARACTER_VISUAL_MODEL)
|
||||
.to_string(),
|
||||
prompt: request_payload
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
created_at: format_utc_micros(task.created_at_micros),
|
||||
updated_at: format_utc_micros(task.updated_at_micros),
|
||||
result,
|
||||
error_message: task.failure_message,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String {
|
||||
let merged = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。",
|
||||
if merged.is_empty() {
|
||||
"自定义世界角色,服装完整,姿态自然。"
|
||||
} else {
|
||||
merged.as_str()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn build_character_visual_svg(size: &str, label: &str, candidate_label: &str) -> String {
|
||||
let (width, height) = parse_size(size);
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||
<rect width="100%" height="100%" fill="#00ff00"/>
|
||||
<ellipse cx="{shadow_x}" cy="{shadow_y}" rx="{shadow_rx}" ry="{shadow_ry}" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M {body_x} {body_y} C {body_c1x} {body_c1y}, {body_c2x} {body_c2y}, {body_x2} {body_y2} L {leg_x} {leg_y} L {leg2_x} {leg_y} Z" fill="#1f2937"/>
|
||||
<circle cx="{head_x}" cy="{head_y}" r="{head_r}" fill="#f8d7b0"/>
|
||||
<path d="M {weapon_x} {weapon_y} L {weapon_x2} {weapon_y2}" stroke="#e5e7eb" stroke-width="{weapon_w}" stroke-linecap="round"/>
|
||||
<text x="50%" y="{text_y}" text-anchor="middle" fill="#0f172a" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
||||
<text x="50%" y="{sub_y}" text-anchor="middle" fill="#0f172a" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{candidate}</text>
|
||||
</svg>"##,
|
||||
width = width,
|
||||
height = height,
|
||||
shadow_x = width / 2,
|
||||
shadow_y = height * 5 / 6,
|
||||
shadow_rx = width / 5,
|
||||
shadow_ry = height / 28,
|
||||
body_x = width * 45 / 100,
|
||||
body_y = height * 34 / 100,
|
||||
body_c1x = width * 34 / 100,
|
||||
body_c1y = height * 50 / 100,
|
||||
body_c2x = width * 43 / 100,
|
||||
body_c2y = height * 72 / 100,
|
||||
body_x2 = width * 56 / 100,
|
||||
body_y2 = height * 72 / 100,
|
||||
leg_x = width * 48 / 100,
|
||||
leg_y = height * 84 / 100,
|
||||
leg2_x = width * 62 / 100,
|
||||
head_x = width * 53 / 100,
|
||||
head_y = height * 25 / 100,
|
||||
head_r = (width.min(height) / 12).max(18),
|
||||
weapon_x = width * 57 / 100,
|
||||
weapon_y = height * 42 / 100,
|
||||
weapon_x2 = width * 76 / 100,
|
||||
weapon_y2 = height * 34 / 100,
|
||||
weapon_w = (width.min(height) / 90).max(4),
|
||||
text_y = height * 91 / 100,
|
||||
sub_y = height * 96 / 100,
|
||||
font_main = (width.min(height) / 28).max(14),
|
||||
font_sub = (width.min(height) / 36).max(11),
|
||||
title = escape_svg_text(label),
|
||||
candidate = escape_svg_text(candidate_label),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_object_key_from_legacy_path(value: &str) -> Result<String, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if trimmed.starts_with("data:") {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "Rust 版 publish 当前要求 selectedPreviewSource 为已写入 OSS 的 /generated-* 路径。",
|
||||
})));
|
||||
}
|
||||
Ok(trimmed.trim_start_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn build_asset_metadata(
|
||||
asset_kind: &str,
|
||||
owner_user_id: &str,
|
||||
entity_kind: &str,
|
||||
entity_id: &str,
|
||||
slot: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("asset_kind".to_string(), asset_kind.to_string()),
|
||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||
("entity_kind".to_string(), entity_kind.to_string()),
|
||||
("entity_id".to_string(), entity_id.to_string()),
|
||||
("slot".to_string(), slot.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
|
||||
state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, fallback: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(180)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_then(fallback)
|
||||
}
|
||||
|
||||
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||||
'A'..='Z' => character.to_ascii_lowercase(),
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>();
|
||||
let normalized = collapse_dashes(&normalized);
|
||||
if normalized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_dashes(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.fold(
|
||||
(String::new(), false),
|
||||
|(mut output, last_is_dash), character| {
|
||||
let is_dash = character == '-';
|
||||
if is_dash && last_is_dash {
|
||||
return (output, true);
|
||||
}
|
||||
output.push(character);
|
||||
(output, is_dash)
|
||||
},
|
||||
)
|
||||
.0
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_size(size: &str) -> (u32, u32) {
|
||||
let mut parts = size.split('*');
|
||||
let width = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
let height = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
(width, height)
|
||||
}
|
||||
|
||||
fn escape_svg_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn format_utc_micros(micros: i64) -> String {
|
||||
module_runtime::format_utc_micros(micros)
|
||||
}
|
||||
|
||||
fn current_utc_millis() -> i64 {
|
||||
current_utc_micros() / 1_000
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
fn map_ai_task_error(error: AiTaskServiceError) -> AppError {
|
||||
let status = match error {
|
||||
AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND,
|
||||
AiTaskServiceError::TaskAlreadyExists => StatusCode::CONFLICT,
|
||||
AiTaskServiceError::Field(_) | AiTaskServiceError::StageNotFound => StatusCode::BAD_REQUEST,
|
||||
AiTaskServiceError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "ai-task",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-entity-binding",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
trait EmptyFallback {
|
||||
fn if_empty_then(self, fallback: &str) -> String;
|
||||
}
|
||||
|
||||
impl EmptyFallback for String {
|
||||
fn if_empty_then(self, fallback: &str) -> String {
|
||||
if self.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_character_visual_prompt_keeps_generation_constraints() {
|
||||
let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者"));
|
||||
|
||||
assert!(prompt.contains("潮雾港向导"));
|
||||
assert!(prompt.contains("右向斜侧身"));
|
||||
assert!(prompt.contains("纯绿色背景"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_storage_segment_keeps_legacy_safe_shape() {
|
||||
assert_eq!(
|
||||
sanitize_storage_segment("Harbor Guide/潮雾", "character"),
|
||||
"harbor-guide"
|
||||
);
|
||||
}
|
||||
}
|
||||
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
|
||||
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
|
||||
|
||||
pub async fn proxy_generated_character_drafts(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_characters(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_animations(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_scenes(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_covers(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_qwen_sprites(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await
|
||||
}
|
||||
|
||||
async fn proxy_legacy_generated_asset(
|
||||
state: AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Response {
|
||||
match read_legacy_generated_asset(&state, prefix, path).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => error.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_legacy_generated_asset(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Result<Response, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let object_key = build_generated_object_key(prefix, path.as_str())?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: object_key.clone(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_legacy_generated_oss_error)?;
|
||||
let upstream_response = reqwest::Client::new()
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
if upstream_response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let status = upstream_response.status();
|
||||
let content_type = upstream_response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned();
|
||||
let bytes = upstream_response
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CACHE_CONTROL, CACHE_CONTROL_VALUE)
|
||||
.header(
|
||||
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
|
||||
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应头失败:{error}"),
|
||||
}))
|
||||
})?,
|
||||
);
|
||||
if let Some(content_type) = content_type {
|
||||
response = response.header(header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
response.body(Body::from(bytes)).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
|
||||
let path = path.trim().trim_matches('/');
|
||||
if path.is_empty() || path.split('/').any(is_invalid_path_segment) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": "generated 资源路径不合法。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!("{}/{}", prefix.as_str(), path))
|
||||
}
|
||||
|
||||
fn is_invalid_path_segment(segment: &str) -> bool {
|
||||
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
|
||||
}
|
||||
|
||||
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_keeps_supported_prefix() {
|
||||
let object_key = build_generated_object_key(
|
||||
LegacyAssetPrefix::Animations,
|
||||
"hero/animation-set-1/idle/frame01.png",
|
||||
)
|
||||
.expect("object key should build");
|
||||
|
||||
assert_eq!(
|
||||
object_key,
|
||||
"generated-animations/hero/animation-set-1/idle/frame01.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_rejects_parent_segment() {
|
||||
assert!(
|
||||
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ mod auth;
|
||||
mod auth_me;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod character_animation_assets;
|
||||
mod character_visual_assets;
|
||||
mod config;
|
||||
mod custom_world;
|
||||
mod custom_world_ai;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod legacy_generated_assets;
|
||||
mod llm;
|
||||
mod login_options;
|
||||
mod logout;
|
||||
|
||||
@@ -51,6 +51,28 @@
|
||||
5. `story-sessions/begin`
|
||||
6. `story-sessions/continue`
|
||||
|
||||
当前阶段新增 Stage6 `character visual` 兼容 DTO:
|
||||
|
||||
1. `assets/character-visual/generate`
|
||||
2. `assets/character-visual/jobs/:taskId`
|
||||
3. `assets/character-visual/publish`
|
||||
|
||||
当前阶段新增 Stage7 `character animation` 模板与导入兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/templates`
|
||||
2. `assets/character-animation/import-video`
|
||||
|
||||
当前阶段新增 Stage8 `character workflow cache` 第一批兼容 DTO:
|
||||
|
||||
1. `assets/character-workflow-cache`
|
||||
2. `assets/character-workflow-cache/:characterId`
|
||||
|
||||
当前阶段新增 Stage9 `character animation` 主链兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/generate`
|
||||
2. `assets/character-animation/jobs/:taskId`
|
||||
3. `assets/character-animation/publish`
|
||||
|
||||
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
|
||||
|
||||
1. `runtime/story/state/resolve` 请求 DTO
|
||||
|
||||
@@ -4,6 +4,7 @@ use platform_oss::{
|
||||
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -83,6 +84,289 @@ pub struct BindAssetObjectRequest {
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterVisualSourceMode {
|
||||
TextToImage,
|
||||
ImageToImage,
|
||||
Upload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
pub candidate_count: u32,
|
||||
pub image_model: String,
|
||||
pub size: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualDraftPayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub image_src: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub drafts: Vec<CharacterVisualDraftPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CharacterAssetJobStatusText {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAssetJobStatusPayload {
|
||||
pub task_id: String,
|
||||
pub kind: String,
|
||||
pub status: CharacterAssetJobStatusText,
|
||||
pub character_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub animation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strategy: Option<String>,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
pub selected_preview_source: String,
|
||||
#[serde(default)]
|
||||
pub preview_sources: Vec<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishResponse {
|
||||
pub ok: bool,
|
||||
pub asset_id: String,
|
||||
pub portrait_path: String,
|
||||
pub override_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatePayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub animation: String,
|
||||
pub prompt_suffix: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatesResponse {
|
||||
pub ok: bool,
|
||||
pub templates: Vec<CharacterAnimationTemplatePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoRequest {
|
||||
pub character_id: String,
|
||||
pub animation: String,
|
||||
pub video_source: String,
|
||||
#[serde(default)]
|
||||
pub source_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoResponse {
|
||||
pub ok: bool,
|
||||
pub imported_video_path: String,
|
||||
pub draft_id: String,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterAnimationStrategy {
|
||||
ImageSequence,
|
||||
ImageToVideo,
|
||||
MotionTransfer,
|
||||
ReferenceToVideo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub animation: String,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_template_id: Option<String>,
|
||||
pub visual_source: String,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_video_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub last_frame_image_data_url: Option<String>,
|
||||
pub frame_count: u32,
|
||||
pub fps: u32,
|
||||
pub duration_seconds: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub use_chroma_key: bool,
|
||||
pub resolution: String,
|
||||
pub ratio: String,
|
||||
pub image_sequence_model: String,
|
||||
pub video_model: String,
|
||||
pub reference_video_model: String,
|
||||
pub motion_transfer_model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub image_sources: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationDraftPayload {
|
||||
pub frames_data_urls: Vec<String>,
|
||||
pub fps: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub frame_width: u32,
|
||||
pub frame_height: u32,
|
||||
#[serde(default)]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishRequest {
|
||||
pub character_id: String,
|
||||
pub visual_asset_id: String,
|
||||
pub animations: BTreeMap<String, CharacterAnimationDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishResponse {
|
||||
pub ok: bool,
|
||||
pub animation_set_id: String,
|
||||
pub override_map: Value,
|
||||
pub animation_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCachePayload {
|
||||
pub character_id: String,
|
||||
pub visual_prompt_text: String,
|
||||
pub animation_prompt_text: String,
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
pub selected_visual_draft_id: String,
|
||||
pub selected_animation: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveRequest {
|
||||
pub character_id: String,
|
||||
#[serde(default)]
|
||||
pub visual_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub selected_visual_draft_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_animation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheGetResponse {
|
||||
pub ok: bool,
|
||||
pub cache: Option<CharacterWorkflowCachePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveResponse {
|
||||
pub ok: bool,
|
||||
pub cache: CharacterWorkflowCachePayload,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketResponse {
|
||||
@@ -358,4 +642,177 @@ mod tests {
|
||||
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
|
||||
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_source_mode_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterVisualSourceMode::ImageToImage)
|
||||
.expect("source mode should serialize");
|
||||
|
||||
assert_eq!(payload, json!("image-to-image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_generate_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "visual_1".to_string(),
|
||||
model: "rust-svg-character-visual".to_string(),
|
||||
prompt: "角色提示词".to_string(),
|
||||
drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "candidate-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/visual_1/candidate-01.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("visual_1"));
|
||||
assert_eq!(
|
||||
payload["drafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/visual_1/candidate-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_templates_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationTemplatesResponse {
|
||||
ok: true,
|
||||
templates: vec![CharacterAnimationTemplatePayload {
|
||||
id: "idle_loop".to_string(),
|
||||
label: "待机循环".to_string(),
|
||||
animation: "idle".to_string(),
|
||||
prompt_suffix: "保持呼吸感。".to_string(),
|
||||
notes: "默认待机模板。".to_string(),
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["templates"][0]["id"], json!("idle_loop"));
|
||||
assert_eq!(
|
||||
payload["templates"][0]["promptSuffix"],
|
||||
json!("保持呼吸感。")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_import_video_response_keeps_legacy_shape() {
|
||||
let payload =
|
||||
serde_json::to_value(CharacterAnimationImportVideoResponse {
|
||||
ok: true,
|
||||
imported_video_path:
|
||||
"/generated-character-drafts/hero/animation/idle/import-1/reference.mp4"
|
||||
.to_string(),
|
||||
draft_id: "animation-import-1".to_string(),
|
||||
save_message: "参考视频已导入 OSS 草稿区。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["importedVideoPath"],
|
||||
json!("/generated-character-drafts/hero/animation/idle/import-1/reference.mp4")
|
||||
);
|
||||
assert_eq!(payload["draftId"], json!("animation-import-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_workflow_cache_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
|
||||
ok: true,
|
||||
cache: CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
visual_prompt_text: "主形象".to_string(),
|
||||
animation_prompt_text: "待机".to_string(),
|
||||
visual_drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "draft-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/job/candidate.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
}],
|
||||
selected_visual_draft_id: "draft-1".to_string(),
|
||||
selected_animation: "idle".to_string(),
|
||||
image_src: Some("/generated-characters/hero/master.png".to_string()),
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: Some(json!({ "idle": { "frames": 4 } })),
|
||||
updated_at: Some("2026-04-22T12:00:00Z".to_string()),
|
||||
},
|
||||
save_message: "角色形象生成缓存已更新。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["cache"]["characterId"], json!("hero"));
|
||||
assert_eq!(
|
||||
payload["cache"]["visualDrafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/job/candidate.svg")
|
||||
);
|
||||
assert_eq!(payload["cache"]["animationMap"]["idle"]["frames"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_strategy_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterAnimationStrategy::MotionTransfer)
|
||||
.expect("strategy should serialize");
|
||||
|
||||
assert_eq!(payload, json!("motion-transfer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_generate_response_keeps_image_sequence_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "animation_1".to_string(),
|
||||
strategy: CharacterAnimationStrategy::ImageSequence,
|
||||
model: "rust-svg-animation-sequence".to_string(),
|
||||
prompt: "待机动作".to_string(),
|
||||
image_sources: vec![
|
||||
"/generated-character-drafts/hero/animation/idle/job/frame-01.svg".to_string(),
|
||||
],
|
||||
preview_video_path: None,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("animation_1"));
|
||||
assert_eq!(payload["strategy"], json!("image-sequence"));
|
||||
assert_eq!(
|
||||
payload["imageSources"][0],
|
||||
json!("/generated-character-drafts/hero/animation/idle/job/frame-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_publish_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationPublishResponse {
|
||||
ok: true,
|
||||
animation_set_id: "animation-set-1".to_string(),
|
||||
override_map: json!({}),
|
||||
animation_map: json!({
|
||||
"idle": {
|
||||
"folder": "idle",
|
||||
"prefix": "frame",
|
||||
"frames": 2,
|
||||
"startFrame": 1,
|
||||
"extension": "svg",
|
||||
"basePath": "/generated-animations/hero/animation-set-1/idle",
|
||||
"frameWidth": 192,
|
||||
"frameHeight": 256,
|
||||
"fps": 8,
|
||||
"loop": true
|
||||
}
|
||||
}),
|
||||
save_message: "基础动作资源已写入 OSS 并绑定当前角色。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["animationSetId"], json!("animation-set-1"));
|
||||
assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,11 +59,21 @@ export default defineConfig(({mode}) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-animations': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-custom-world-scenes': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-custom-world-covers': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-qwen-sprites': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user