feat: complete M6 asset OSS migration

This commit is contained in:
2026-04-22 16:35:41 +08:00
parent d5627c536d
commit 4c8ba535e4
18 changed files with 4911 additions and 40 deletions

View File

@@ -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] 所有新生成资产都写入 OSSStage 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 强结构化,再进入独立阶段。

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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` 中路径兼容项完成勾选。

View File

@@ -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 落地口径,明确当前上传主链为服务器上传 OSSWeb 端只负责签名读下载。
- [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 后端的实施方案与验收口径。

View File

@@ -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 分片。

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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"
);
}
}

View 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()
);
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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,