图片画布接入 3D 资源:新增 model3d 类别、3D 预览与 3D 生成入口
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s

- 后端资源投影按 asset_kind 分叉,新增 project_editor_client_media_src 与 EDITOR_MODEL3D_ASSET_KIND:model3d 只投影预览图,模型本体只留 objectKey,预览缺失才回落历史口径
- tripo3d 存储新增按字节魔数识别模型 content_type 与扩展名映射,不采信 provider 声明与下载响应头
- editor_project_storage 把 model3d 加入 EDITOR_CANVAS_ASSET_KINDS 白名单
- 前端 CanvasAssetKind 与 image-canvas-core 类型新增 model3d:画布卡片渲染预览图或 3D 占位,类型角标新增「3D模型」,覆盖资格只在它与空类别之间成立
- 新增 model3d-preview 子目录(Model3dViewerModal / Model3dViewerModel),Tailwind 内联、不新增 CSS,失败原因分超限 / 格式不可识别 / 无 WebGL2 三类展示
- 选中工具条新增只读 preview-model3d 能力位,仅 objectKey 为空时置灰;打开预览同时选中图层并暂停画布快捷键与舞台交互
- 查看器包源对象 format 改为可选,新增「声明 Content-Type → 字节魔数 → 地址扩展名」三级格式判定并导出支持格式清单
- 导出工作流新增 3d_models/ 目录,模型下载按真实响应头 MIME 定扩展名、对象键扩展名兜底
- 新增 3D 生成入口:底部工具栏浮动子选项与文生 / 图生 3D 面板,落 projectResource + canvasCompletion,复用占位框与任务侧栏
- 3D 定价只读实时定价查询,段缺失即入口不可提交;幂等键由前端铸造,用户重试必须换键
- 新增 ADR 0003 / 0004 与实施计划、里程碑文档;更新 Tripo 技术方案、后端架构、CONTEXT 术语、decision-log 与 pitfalls
- .gitignore 忽略仓库根目录手工下载的 3D 样例模型
This commit is contained in:
2026-09-22 10:10:00 +08:00
parent 9fc55a5cac
commit 61fe4c48af
64 changed files with 4964 additions and 119 deletions
+5
View File
@@ -82,3 +82,8 @@ scripts/loadtest/data/*.local.json
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt
# 仓库根目录下手工下载的 3D 样例模型(Tripo 联调 / 查看器排查用),不入库
/*.glb
/*.gltf
/*.fbx
+26
View File
@@ -88,6 +88,32 @@ _Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求
image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。
_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串
**3D 模型生成工具**:
图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**
_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话
**3D 生成定价**:
3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。
_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行
### 3D 模型资源预览
**3D 模型资源**:
类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。
_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型
**模型预览图**:
3D 模型资源的静态代表图,落在资源投影的 `imageSrc``thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。
_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相
**模型本体**:
3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。
_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式
**3D 查看器预览**:
在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。
_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源
### Puzzle Clear
**拼消消**:
+2
View File
@@ -79,6 +79,8 @@
- [Tripo SDK 隔离与 Provider DTO 边界 ADR](./adr/【ADR】0001-Tripo%20SDK隔离与Provider%20DTO边界-2026-09-18.md):固定 SDK 只存在于 `platform-tripo` 内部,provider task 与产品资源结果分层。
- [Tripo 生成 API 集成边界与提交时定价 ADR](./adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md):全新 API 不复用 Hyper3Dat-most-once submit,按提交时定价真实扣费。
- [3D 资源客户端媒体投影与格式真实性 ADR](./adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md):3D 资源的客户端投影按类别分叉,模型内容类型按字节识别,格式判定只在查看器包内(声明类型 → 字节魔数 → 地址扩展名),画布不判格式。
- [3D 生成入口的价格真相与幂等身份 ADR](./adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md):3D 生成入口的价格只读实时定价查询,缺段即不可提交;幂等键由前端铸造,重试必须换键。
- [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md)
- [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。
@@ -0,0 +1,13 @@
# 【ADR】0003-3D 资源客户端媒体投影与格式真实性-2026-09-21
状态:已接受
3D 生成结果在资源行里同时持有三个字段:`objectKey` 是模型本体,`imageSrc``thumbnailSrc` 是预览图。客户端可见媒体投影不能再沿用「`objectKey` 非空就以它充当 `imageSrc`」的历史口径,否则画布卡片、素材缩略图与后台都会把 `.glb` 交给图片元素加载;因此投影改为按 `asset_kind` 分叉:`model3d` 只把预览图投影给客户端(依次取 `thumbnail_src``image_src`),模型本体只留在 `objectKey` 里交给 3D 查看器,其它类别保持原样,两个来源都缺失时才回落到历史口径。
模型格式的真相同样只保留一处:后端写入 `asset_object.content_type` 之前按字节魔数识别真实产物(GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头、glTF 的 JSON 正文),对象键扩展名由该内容类型派生而不是反向推断。客户端不复制这份格式清单,也不在画布层按扩展名预先判定放行:画布只认「`assetKind=model3d``objectKey` 非空」,真正的格式判定跟着解析器走——`packages/model3d-viewer` 依次采信读接口声明的 `Content-Type`、字节魔数、地址扩展名,格式在该包里是可选输入,三者都判不出来时以 `unsupported-format` 失败,并把原因与支持的格式清单显示在模态里,而不是把入口藏起来去让用户猜。
被否决的做法:让画布或宿主外壳按对象键扩展名自行判断放行(客户端第二份格式清单,查看器扩了格式后画布仍会继续禁用入口);给资源表新增 `model_format` 列(同一真相两个来源,且 provider 上报的格式不可信);预览图缺失时把模型文件直接喂给图片元素(静默坏图、无法诊断,也说不出「该资源没有可预览的模型」这类错误信息);在画布侧补「`assetKind` 为空但对象键像模型」的扩展名兜底(这种资源本就不该出现,兜底只会掩盖后端投影缺陷)。
后果:下载 3D 资源时按真实响应头的 MIME 命名、对象键扩展名兜底、最后回落 `glb`,导出目录用 `3d_models/` 与图片、其它媒体分开;类型角标、缩略图和后台渲染器只要读 `imageSrc` 就能拿到预览图,不必各自识别媒体语义;查看器包单模型体积上限 64 MiB,超限与 `webgl-unavailable` 都作为失败原因展示给用户,而不是静默失败。
未进入项:AGC 项目画布自带的两份格式判断(`isResourceModelFbx``octet-stream` 当 FBX、资源卡预览用扩展名正则选解析器)本期不动,后续统一收敛到 `packages/model3d-viewer`;素材库行、后台 renderer、精选 read model 与公开 grant 的 3D 展示同样未进入。
@@ -0,0 +1,13 @@
# 【ADR】0004-3D 生成入口的价格真相与幂等身份-2026-09-21
状态:已接受
图片画布新增的 3D 生成入口,在提交前展示的泥点价格**只**来自编辑器生成定价查询(`GET /api/editor/generation-pricing`)返回的 `model3d` 段,前端不内置任何 3D 价格兜底数值;该段缺失时入口不可提交,既不发请求也不静默按 0 计价。这与图片 / 视频 / 音频一族相反——那些工具在前端保留内置兜底配置(`EDITOR_MODEL_MUD_POINT_CONFIG`),接口异常时价格照常展示。3D 刻意反着做:单次成本高一个量级,且后端在价格段缺失时是 fail closed503 `model3d-pricing-unavailable`),前端若拿兜底数值展示,用户会看到「面板有价、提交被拒」,或者更糟——按错的数字被扣费。
被否决的做法:**新增后端 quote / 预检接口**(价格权威,但要新增公开 API、契约与规范流程,而现有请求参数组合已足以在客户端算出同一结果);**前端写死 3D 兜底价格**(与后端 fail closed 冲突,价格出现两个真相)。
3D 生成入口同时是画布链路里**第一个必须自带 `Idempotency-Key` 的生成工具**:既有的画布生成 POST 只带 `x-request-id`,且显式禁止自动重试。两个 3D submit 端点缺少该头一律 400,因此键由前端铸造并绑定当次请求内容;用户主动重试必须铸造新键,因为同键同请求只会返回原来那个(已完成或已失败的)operation,复用旧键的「重试」不会真的重跑。键与 payload 都由服务端参与指纹,用户改了参数即视为新请求。
落地形态:键 = 尝试代次 + 请求内容指纹(`Model3dGenerationSubmission.resolveModel3dRequestKey`)。服务端按「键 + payload 指纹」dedupe(同键同请求返原 operation,同键不同请求 409),而会改请求内容的入口散落在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` 的通用函数里,因此把内容指纹并进键,而不是靠每处变更都记得重铸键。代次只负责让「用户主动重试」与上一次尝试区分开。
后果:`model3d` 定价段是 3D 入口的唯一价格真相,补价必须先落配置再开入口;前端必须保留一份「档位 → 六个必填参数」的映射与加和逻辑,它与后端 `model3d_add_ons` 是两份实现,靠同一组参数组合的用例对齐;提交接口不扣费,价格在提交时确定并写入 job,真正扣费发生在 worker 起跑、调用 provider 之前,失败按 attempt 冲正。
@@ -0,0 +1,113 @@
# Tripo 生成前端入口实施计划
Version: 1.0
Status: active
Date: 2026-09-21
Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md`
Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md`
## 边界
新代码只落在 `src/components/image-editor/model3d-generation/`,按 `src/components/image-editor/model3d-preview/` 的命名与切分方式组织:深目录、小文件、不带 `ImageCanvas` 前缀、测试文件与被测文件同名。画布其余部分只做必要接线(工具栏入口、子选项菜单、对话框模式、请求客户端)。不改后端、不改 schema、不新增公开 API、不接 `/api/external/v1`
## 已确认口径
1. 宿主是画布生成链路,不新建独立生成页 / 结果页 / runtime / 作品架;3D 是资产型产出。
2. 入口形态照 `music` / `spec`:工具栏一项入口 → 浮动子选项 → 各自一个提交面板。子项文案沿用后端 `phaseLabel`:「文生 3D 模型」「图生 3D 模型」。
3. 价格**只**来自 `GET /api/editor/generation-pricing``model3d` 段(前端已有 `loadEditorGenerationPricing` 与运行时缓存)。段缺失即两个子面板都不可提交且不发请求;前端不写任何 3D 兜底数值,也不把缺价当 0。
4. 参数永远全量非空提交:`model``texture``pbr``geometryQuality``quad``smartLowPoly``generateParts` 一律显式给出,不依赖后端默认值(provider 隐式默认会改变价格)。唯一例外是 `textureQuality`:后端 `api-server::tripo3d::validation` 显式拒绝 `texture=false` 时出现贴图档位,因此纯几何档位必须**不带**该字段,而不是给 `null`
5. 落点固定 `target = { kind: "projectResource", projectId, canvasCompletion: { dialogId, title, placeholder } }`,照现有生成工具的做法(发送门禁为 `projectId && canvasCompletionPlaceholder`)。
6. 图生输入照现有参考图来源菜单:从画布中选择 / 上传图片 / 从项目素材中选择,最终收敛成契约允许的 `{ kind: "resource", resourceId }``{ kind: "asset", assetId }`;只允许一张,不接 multiview。
7. `Idempotency-Key` 由前端铸造并绑定当次请求,用户主动重试铸造新键、不复用旧键。落地形态是「尝试代次 + 请求内容指纹」(`Model3dGenerationSubmission.resolveModel3dRequestKey`):同一份内容重复提交(双击)仍命中同一个 operation,内容一变(含换参考图、换上传图、换素材库图片)自动换键;重试换代次即换键。之所以把内容指纹并进键里,是因为会改内容的入口散落在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` 的通用函数中,靠「每处都记得重铸键」迟早漏一处。
8. 失败与退款沿用既有链路:文案由后端归一为「3D 模型生成失败,请稍后重试。」,侧栏与面板展示失败原因与已退还泥点。
9. 不做项见里程碑「不做」一节。
## 文件切分
| 文件 | 职责 |
| --- | --- |
| `model3d-generation/Model3dGenerationModal.tsx` | 提交面板外壳:两个子项共用的表单、状态区、提交按钮 |
| `model3d-generation/Model3dGenerationModal.test.tsx` | 面板行为用例 |
| `model3d-generation/Model3dGenerationForm.tsx` | 子项专属字段:文生提示词、图生参考图来源 |
| `model3d-generation/Model3dGenerationForm.test.tsx` | 字段与来源菜单用例 |
| `model3d-generation/Model3dGenerationFormModel.ts` | 档位 → 六个必填参数的唯一映射、价格计算(读运行时定价缓存) |
| `model3d-generation/Model3dGenerationFormModel.test.ts` | 映射、价格、缺价不可提交的用例 |
| `model3d-generation/Model3dGenerationSubmission.ts` | 校验后的请求体组装、`target`、幂等键铸造(代次 + 内容指纹) |
| `model3d-generation/Model3dGenerationSubmission.test.ts` | 请求体全量非空、参考图收敛、幂等键铸造与换键用例 |
| `model3d-generation/useModel3dGenerationTask.ts` | 调用客户端 submit、排队、失败与重试收口(复用既有排队链路) |
| `model3d-generation/useModel3dGenerationTask.test.tsx` | 入队、失败、缺价与缺工程不发请求的用例 |
外溢接线(各一处):
| 文件 | 接线 |
| --- | --- |
| `src/components/image-editor/ImageCanvasBottomToolbarView.tsx` | 新增工具栏入口(走 `isToolbarOptionTool` 那一档) |
| `src/components/image-editor/useImageCanvasGenerationSurface.tsx` | 子选项菜单与面板渲染分支 |
| `src/components/image-editor/ImageCanvasEditorTypes.ts` | `CanvasTool``CanvasGenerationAction``GenerateDialogState.mode` 增加 3D 分支 |
| `src/services/image-editor/editorProjectClient.ts` | 两个 submit 函数(含 `Idempotency-Key`)、定价 DTO 补 `model3d` 段 |
| `src/components/image-editor/ImageCanvasGenerationModel.ts` | 图生 3D 的单参考图槽位上限(`usesSingleImageReferenceSlot` / `resolveDialogExtraImageReferenceLimit` |
| `src/components/image-editor/ImageCanvasGenerationDialogModel.ts` | 画布点选与项目素材选中进入 `model3d-image-to-model` 的参考图字段;单槽位下素材库选择是替换 |
| `src/components/image-editor/ImageCanvasUploadModel.ts` | 上传图片进入 `model3d-image-to-model` 的参考图字段 |
后三处是必须的:参考图来源菜单(从画布中选择 / 上传图片 / 从项目素材中选择)的三条路径都按显式 mode 列表分流,新工具漏在列表外会表现为「点了菜单没反应」。
## 步骤
1. **定价段接入**
- `editorProjectClient.ts``EditorGenerationPricingConfig``model3d` 段(形状对齐 Rust `Model3dPricingConfig``basePrices` + `addOnPrices`),运行时缓存同步保存。
- 验收:payload 有段时面板能算出价格;无段时两个子面板都不可提交,且不发请求。
2. **档位与参数映射**
- `Model3dGenerationFormModel.ts` 定义用户可见档位与开关到六个必填参数的唯一映射,以及价格加和;不得在别处再写一份。
- 验收:同一组合在用例中给出与后端 `model3d_add_ons` 相同的 add-on 集合。
3. **请求与幂等身份**
- 两个客户端函数分别打 `/api/assets/tripo/text-to-model``/api/assets/tripo/image-to-model`,带 Bearer、`Content-Type: application/json``Idempotency-Key`,请求体类型直接消费 `packages/shared/src/contracts/model3d/*`
- `target` 固定 `projectResource` 分支并携带 `canvasCompletion`;图生把选中图片收敛成 `resource` / `asset` 引用。
- 验收:请求体全量非空;同一次提交重放同键;重试换新键。
4. **入口与子选项**
- 工具栏入口 + 浮动子选项菜单 + 对话框两个 mode,形态与 `music` / `spec` 一致。
- 验收:点开只有两个子项;选中后进入对应面板。
5. **提交与回填**
- 复用既有排队路径:置 `status: 'generating'`、下发占位框、`waitForEditorGenerationQueue` 轮询、终态 `loadEditorProject` + `applyProjectSnapshot` 回填。
- 验收:提交后画布出现占位框、任务侧栏出现任务且阶段文案正确、成功后占位框被正式资源图层替换。
6. **失败、退款与重试**
- 面板与侧栏展示失败文案与已退还泥点;重试按钮铸造新键重新提交。
- 验收:失败不残留占位框;重试产生新 operation。
7. **收口**
- 生成结果可立即用既有「3D 预览」打开;补齐里程碑验收标准 1 至 8 的证据。
- 验收:`npm run typecheck`、定向 vitest、`npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 全部通过。
## 进展(2026-09-21
代码已落地,全部集中在 `src/components/image-editor/model3d-generation/` 与上表接线处;未改后端、未改 schema、未新增公开 API。
| 里程碑验收标准 | 证据 |
| --- | --- |
| 1 工具栏入口只有两个子项 | `ImageCanvasBottomToolbarView.test.tsx``useImageCanvasGenerationSurface.test.tsx``ImageCanvasEditorShellView.test.tsx`;子项文案与后端 `phaseLabel` 对照 |
| 2 请求体全量非空 | `Model3dGenerationSubmission.test.ts`(逐字段断言;纯几何档位断言 `textureQuality` 缺席);`Model3dGenerationFormModel.test.ts` 与后端 `model3d_add_ons` 组合对齐 |
| 3 价格只读实时查询、缺段不可提交 | `Model3dGenerationFormModel.test.ts``Model3dGenerationModal.test.tsx`(缺段时按钮禁用、显示原因、不发请求)、`useModel3dGenerationTask.test.tsx`(不发请求) |
| 4 `Idempotency-Key` 与重试换键 | `Model3dGenerationSubmission.test.ts`(同内容同键、换内容换键、重试换代次换键、键可作请求头) |
| 5 占位框与终态回填 | `useModel3dGenerationTask.test.tsx`(以对话框 id 交给 `applyQueuedEditorGenerationProject`);真实排队回填需联调环境 |
| 6 失败与退款文案 | `Model3dGenerationModal.test.tsx``useModel3dGenerationTask.test.tsx`;退款提示由既有任务侧栏承接 |
| 7 图生只接受一张平台内图片 | `ImageCanvasGenerationDialogModel.test.ts``projectAssetReferencePickerModel.test.ts``ImageCanvasUploadModel.test.ts``Model3dGenerationSubmission.test.ts`(上传未登记、空白 id 都不构成 `source` |
| 8 产物可用既有 3D 预览打开 | 由 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](./【实施计划】Tripo生成结果前端预览接入-2026-09-21.md) 交付;本入口只负责产出 `model3d` 资源 |
仍未验证的部分需要环境:`model3d` 定价段写入运行配置后的真实扣费与排队回填、provider 端到端产物。
## 验证命令
```bash
npx vitest run src/components/image-editor/model3d-generation
npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx
npm run typecheck
npm run check:encoding
node scripts/check-doc-index.mjs
git diff --check
```
## 风险与回退
- **缺价即不可用**`model3d` 定价段未写入运行环境时,本期交付的入口表现为「不可提交」。这是设计如此(见 ADR 0004),不是缺陷;但演示与验收需要先落配置。
- **前端等待窗口**:客户端排队轮询上限 20 分钟,后端 3D 长任务超时 1800 秒;排队叠加执行可能顶到前端窗口,需要按真实耗时确认是否调整或改由任务侧栏兜底。
- **worker 并发**3D 任务在单次 attempt 内阻塞数分钟,默认并发 2,会占住并发位。
- **价格两份实现**:前端档位映射与后端 add-on 判定必须靠同一组参数组合的用例对齐,否则出现「面板一个价、账单另一个价」。
- **回退点**:删除工具栏接线与 `model3d-generation/` 新文件即可回退;后端、schema、定价配置均不受影响。
@@ -0,0 +1,43 @@
# Tripo 生成结果前端预览接入实施计划-2026-09-21
对应主规范:`docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md`;架构决策:`docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md`
## 交付结果
Tripo 生成的 3D 资源在图片画布里作为 `model3d` 类别落地:卡片显示预览图,选中后在工具条上出现「3D 预览」,打开桌面模态把模型本体交给 `packages/model3d-viewer` 渲染;资源行的客户端媒体投影改为按类别分叉,模型本体不再被图片入口加载。
## 已确认口径
1. `model3d` 是新的 `CanvasAssetKind`,画布媒体类型仍是 `image`:卡片、缩略图消费预览图,模型本体只进查看器。
2. 工具条入口落在资源上(选中图层的浮动工具条),不动信息角标;同族图层的像素级编辑组(裁扩、去背景、完美像素、改造)整体不适用。
3. 工具条按钮只在 `objectKey` 为空时置灰(标题「该资源没有可预览的 3D 模型文件」);画布不判格式,格式判不出来是查看器的事,模态里显示失败原因,把错误信息交给用户而不是让他猜功能是否上线。
4. 类型角标新增「3D模型」,与其它类别互为独立媒体族;覆盖资格只在 `model3d` 与空类别之间成立,图片族不能覆盖成 3D。
5. 后端 `imageSrc` / `thumbnailSrc` 都是预览图、`objectKey` 是模型本体;投影由 `project_editor_client_media_src` 统一决定,预览缺失才回落到历史口径。
6. 下载 3D 资源导出模型本体:MIME 优先、对象键扩展名兜底、最后回落 `glb`
7. 本期只做画布工具条入口,素材库行、后台 renderer、精选 read model、公开 grant 与 External v1 不进入。
8. 画布编辑器不承诺移动端,查看器模态固定 `lg` 尺寸、高度 `min(58vh, 30rem)`,不自动旋转,页脚「重置视角」仅在查看器状态为 `ready` 时可用。
9. 后端写入 `content_type` 前按字节魔数识别 3D 产物;查看器单模型上限 64 MiB;客户端不抄格式清单,格式判定在 `packages/model3d-viewer` 内按「声明 `Content-Type` → 字节魔数 → 地址扩展名」进行,判不出来即 `unsupported-format`
10. 「哪些宿主能开 3D 预览」通过工具条的 `preview-model3d` capability 位表达,AGC 项目画布本期不进入该能力集合。
11. 打开模态时同时选中该图层;画布快捷键与舞台交互在元数据面板或 3D 预览模态打开期间暂停。
12. 导出 3D 图层落到 `3d_models/` 目录(图片 `images/`、其它媒体 `media/`),文件名按真实响应头 MIME 定扩展名。
13. 失败原因逐条翻译给用户:超限(64 MiB)、格式不可识别(列出支持格式)、环境不支持 WebGL2;预览中不自动旋转。
## 落地改动
- 后端:`editor_project.rs` 新增 `project_editor_client_media_src``EDITOR_MODEL3D_ASSET_KIND`,改写资源、精选快照与素材三处投影;`tripo3d/storage.rs` 新增 `sniff_model_content_type` / `resolve_artifact_content_type` 与模型扩展名映射;`editor_project_storage.rs``model3d` 加入 `EDITOR_CANVAS_ASSET_KINDS`(白名单与前端标签选项同步)。
- 前端:`CanvasAssetKind``image-canvas-core` 类型同步新增 `model3d`;新增 `src/components/image-editor/model3d-preview/``Model3dViewerModel.ts` + `Model3dViewerModal.tsx`Tailwind 内联、无新增 CSS);画布卡片新增 3D 图层渲染(预览图或占位);工具条新增 `preview-model3d` 动作;导出工作流新增 `3d_models/` 目录与模型扩展名映射。
- 查看器包:`packages/model3d-viewer` 的源对象 `format` 改为可选,新增按声明 `Content-Type` / 字节魔数 / 地址扩展名三级判定格式的解析函数,并导出支持的格式清单供模态文案复用。
- 文档:新增本计划、ADR 0003,补 `CONTEXT.md` 术语,修正 Tripo 技术方案与后端架构文档里「`model3d` 是后端-only 类别 / 不进 `EDITOR_CANVAS_ASSET_KINDS`」的过期口径。
## 验证
- `npx vitest run src/components/image-editor` 中与本变更相关的用例(工具条、画布视图、编辑器模型、导出工作流、`model3d-preview`)全部通过;仓储内存不可用的既有用例在本地环境失败,与本次改动无关。
- 后端 `cargo test --locked -p api-server` 的媒体投影与 Tripo 存储用例通过。
- `npm run typecheck``npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check`
## 风险与未进入项
- glTF 的多文件兄弟资源(`.bin`、贴图)暂不解析,只支持自包含模型;provider 若返回多文件 glTF,查看器会加载失败并提示。
- 一张模型只显示一张预览图;多视角、动画播放、材质切换与模型信息(面数、体积)都不在本次范围。
- 素材库行、后台列表、精选卡片与公开 grant 未接 3D renderer,只保证字段正确、不承诺展示。
- AGC 项目画布自带两份格式判断(`isResourceModelFbx``octet-stream` 当 FBX、资源卡预览的扩展名正则)本期不动,后续统一收敛到 `packages/model3d-viewer`
@@ -0,0 +1,51 @@
# Tripo 生成前端入口
Version: 1.0
Status: active
Date: 2026-09-21
Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md`
Implementation Plan: `docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md`
Related: `docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md``docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`
## 目标
在图片画布工程 `/editor/canvas` 的底部工具栏新增「生成 3D 模型」入口,点开后给出「文生 3D 模型」与「图生 3D 模型」两个子项,按画布既有生成链路提交到 tripo3d 的两个 submit API;产物以 `model3d` 画布资源落地,并可直接用既有的「3D 预览」查看。
## 范围
- 底部工具栏入口与子选项菜单,形态照 `music` / `spec` 的「一项入口 → 浮动子选项 → 各自一个提交面板」。
- 两个提交面板:档位化的参数选择、提交前价格展示、文生提示词、图生参考图来源。
- 前端铸造 `Idempotency-Key`,并按契约全量非空提交必填参数。
- 画布占位框、入队、任务侧栏、失败与退款提示、终态刷新回填。
- 3D 定价段的前端消费:只读实时定价查询。
- 契约消费:`packages/shared/src/contracts/model3d/*` 只消费,不手写 TS。
## 不做
- 不新建独立生成页、结果页、runtime、作品架、广场、作品统计;3D 是资产型产出,不进玩法闭环。
- 不接 multiview-to-model、Splat、rig、animation、texture、convert。
- 不做素材库落点入口(前端不暴露 `target = assetLibrary`)、后台 renderer、精选 read model、公开 grant、External v1。
- 不接画布 Agent 对话调度。
- 不改后端、不改 schema、不新增公开 API。
- 不在前端内置任何 3D 价格兜底数值;不承诺移动端画布(跟随现状)。
## 验收标准
1. 工具栏出现「生成 3D 模型」,点开只有两个子项,子项文案与任务侧栏 `phaseLabel` 一致。
2. 每次提交的请求体包含全部必填参数(含六个定价参数),不存在缺字段或 `undefined`
3. 价格只来自 `GET /api/editor/generation-pricing``model3d` 段;该段缺失时两个子面板都不可提交,且不发任何请求。
4. 每次提交携带 `Idempotency-Key`;同一次提交重放不产生第二个 operation;用户主动重试铸造新键。
5. 提交后画布出现占位框,任务侧栏出现该任务并显示「文生 3D 模型」/「图生 3D 模型」;成功后占位框被正式资源图层替换。
6. 失败时面板与侧栏展示后端归一文案,并显示已退还泥点。
7. 图生只接受一张平台内图片(画布资源或素材库资产),不接受 URL、data URL 或本地字节直传。
8. 生成的 `model3d` 资源可立即用既有「3D 预览」打开。
9. `npm run typecheck`、定向 vitest、`npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 通过。
## 进展
实现已落地(2026-09-21),逐条证据见 [`【实施计划】Tripo生成前端入口-2026-09-21.md`](./【实施计划】Tripo生成前端入口-2026-09-21.md) 的「进展」一节。验收标准 1 至 4、7 已由定向 vitest 覆盖;5、6 的排队回填与退款展示复用既有链路并已覆盖交接点,真实表现需在写入 `model3d` 定价段的联调环境确认;8 由预览接入计划交付。
## 依赖
- 后端里程碑 [`【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md`](./【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md) 的路由与 worker 分支已上线。
- `model3d` 定价段已写入运行环境配置;未写入时本入口保持不可提交(fail closed),这不是本期缺陷。
@@ -8909,3 +8909,27 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 边界:预检只按主键定点查引用,禁止拉取当前用户的完整工程列表或素材库,也禁止在提交路径上执行 provider 副作用(上传 `file_token`、submit)。该口径适用于所有「服务端站内引用 → provider 输入」的生成入口,Tripo 是首个按此收口的类别。
- 验证:`cargo test -p api-server` 1085 通过(含新增的 `tripo3d::image_source` 源码钉住用例,断言预检与定点解析只调用窄查询、不含列工程 / 列素材库 / 读正文 / 上传);`cargo check -p api-server` 无 tripo3d 警告;`npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 通过。真实 provider 端到端仍未跑。
- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成Worker执行链路与API路由](../plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md)。
## 2026-09-21 3D 资源的客户端媒体投影按类别分叉,模型格式真相只在内容类型与查看器能力各一处
- 背景:Tripo 生成的 `model3d` 资源同时持有模型本体(`objectKey`)与预览图(`imageSrc` / `thumbnailSrc`),而客户端媒体投影历史口径是「`objectKey` 非空就以它充当 `imageSrc`」,于是画布卡片、素材缩略图与后台都会把 `.glb` 交给图片渲染器。
- 决策:投影收口成一个函数(`project_editor_client_media_src`):`model3d` 只投影预览图(`thumbnail_src``image_src`),模型本体只留在 `objectKey`;其它类别保持原口径,预览缺失才回落。写入 `asset_object.content_type` 前按字节魔数识别真实产物(GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头、glTF 的 JSON 正文),对象键扩展名由内容类型派生。客户端不抄格式清单、也不按扩展名预判放行:画布只认「`assetKind=model3d``objectKey` 非空」,格式判定跟着解析器走——`packages/model3d-viewer` 依次采信读接口声明的 `Content-Type`、字节魔数、地址扩展名,格式在该包里是可选输入,判不出来即 `unsupported-format` 并把原因显示在模态里。
- 边界:`model3d` 是独立媒体族,标签覆盖只在它与空类别之间成立;快速编辑与改造都不进入。画布工具条新增只读 capability 位 `preview-model3d`,AGC 项目画布不声明;导出 3D 图层落 `3d_models/` 目录;AGC 项目画布自带的两份格式判断本期不动,后续统一收敛到查看器包;素材库行、后台 renderer、精选 read model、公开 grant 与 External v1 本期不进入。
- 验证:`cargo test --locked -p api-server` 的媒体投影与 Tripo 存储用例通过;前端 `model3d-preview`、选中工具条、画布视图、编辑器模型与导出工作流用例通过;`npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 通过。真实 provider 端到端与素材库 / 后台 renderer 仍未接。
- 关联文档:[ADR 0003](../../adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成结果前端预览接入](../plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)。
## 2026-09-21 3D 生成入口落在画布生成链路,价格只读实时定价查询,幂等键由前端铸造
- 背景:Tripo 两个 submit API 与 worker 已就绪,需要在前端开一个「生成 3D 模型」入口。3D 是资产型产出(没有 runtime、单局、胜负、作品架语义),而画布已经把 `model3d` 当作 `CanvasAssetKind`,并由只读「3D 预览」承接消费。
- 决策:宿主固定为图片画布工程 `/editor/canvas` 的底部工具栏,形态照 `music` / `spec` 的「一项入口 → 浮动子选项 → 各自一个提交面板」,两个子项为「文生 3D 模型」「图生 3D 模型」(文案对齐后端 `phaseLabel`);落点固定 `projectResource` + `canvasCompletion`,复用既有占位框、入队、任务侧栏与终态刷新回填;参数永远全量非空提交;图生只接受一张平台内图片(画布资源或素材库资产,经既有参考图来源菜单收敛成 `resource` / `asset` 引用)。不新建独立生成页 / 结果页 / runtime / 作品架 / 广场 / 统计,不接 multiview,不接画布 Agent 对话,不做素材库落点入口(前端不暴露 `target = assetLibrary`)。
- 价格与幂等(见 ADR 0004):提交前价格只来自 `GET /api/editor/generation-pricing``model3d` 段,段缺失即入口不可提交且不发请求,前端零 3D 兜底数值;`Idempotency-Key` 由前端铸造(画布链路首次需要该头),用户主动重试必须铸造新键——同键同 payload 只会返回原 operation,而 Tripo 队列 `max_attempts = 1`,不存在「同键重跑」。
- 边界:该条记录的是文档先行阶段(ADR 0004、里程碑与实施计划、`CONTEXT.md` 术语「3D 模型生成工具」与「3D 生成定价」);前端实现已在同日落地,见下一条。后端 `model3d` 定价段仍未写入运行环境配置,入口上线前必须先把数值落到配置。
- 关联文档:[ADR 0004](../../adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md)、[里程碑 Tripo生成前端入口](../plans/【里程碑】Tripo生成前端入口-2026-09-21.md)、[实施计划 Tripo生成前端入口](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。
## 2026-09-21 3D 生成入口实现落地:新代码收在 `model3d-generation/` 深目录,幂等键按内容定址
- 背景:按上一条决策实现画布侧 3D 生成入口。既要沿用既有生成链路(占位框、入队、任务侧栏、终态回填),又要求新代码不混进画布那些千行大文件。
- 决策:新代码全部落在 `src/components/image-editor/model3d-generation/``Model3dGenerationFormModel` / `Model3dGenerationSubmission` / `useModel3dGenerationTask` / `Model3dGenerationForm` / `Model3dGenerationModal`,测试文件与被测文件同名、不带 `ImageCanvas` 前缀),画布只做工具栏入口、子选项菜单、对话框 mode、定价读取与请求客户端的接线。`Idempotency-Key` 的落地形态是「尝试代次 + 请求内容指纹」,而不是「在每处参数变更里重铸键」——后者要覆盖参考图的上传 / 画布点选 / 素材库三条路径,漏一处就会出现同键换内容的 409。图生 3D 的参考图上限收口成 `usesSingleImageReferenceSlot`,单槽位下素材库选择是替换而不是追加。
- 边界:不改后端、不改 schema、不新增公开 API;不动素材库落点、后台 renderer、精选 read model、公开 grant、External v1 与画布 Agent 对话;移动端画布不承诺(跟随现状)。
- 验证:`src/components/image-editor/model3d-generation` 四个测试文件 38 个用例通过;`ImageCanvasGenerationModel` / `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` / `projectAssetReferencePickerModel` 的定向用例通过;`npm run typecheck``npx eslint``model3d-generation` 与接线文件)、`npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 通过。真实扣费与排队回填需在写入 `model3d` 定价段的联调环境验证。
- 关联文档:[实施计划 Tripo生成前端入口](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[ADR 0004](../../adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。
@@ -5718,3 +5718,35 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:改 `spacetime-client` 的模块前先 `rg -n '#\\[path = "active/' server-rs/crates/spacetime-client/src/active.rs` 确认该模块的真实文件;只有 `active/` 下的文件参与编译。历史死副本 `src/external_generation.rs` 已在 `ac08ccbe3` 删除,不要再以「两边都改一遍」的方式维持同步。
- **验证**`cargo check -p spacetime-client``cargo check -p api-server` 同时通过,且 `rg --files server-rs/crates/spacetime-client/src` 中同名文件只剩一份。
- **关联**`server-rs/crates/spacetime-client/src/active.rs``server-rs/crates/spacetime-client/src/active/external_generation.rs``server-rs/crates/spacetime-client/src/active/mapper.rs`
## 2026-09-21 「`objectKey` 非空就覆盖 `imageSrc`」的投影会把模型文件当图片
- **风险**3D 资源(`assetKind=model3d`)的 `objectKey` 是模型本体、`imageSrc` 是预览图,但后端历史投影不分类别地用 `objectKey` 覆盖 `imageSrc`,于是画布卡片、素材缩略图、后台列表会一起去加载 `.glb`
- **处理(现行口径)**:客户端可见媒体投影按 `asset_kind` 分叉(`model3d` 只投影预览图,模型本体只留 `objectKey`),模型格式的权威是写入前按字节魔数识别出的 `content_type`,扩展名由它派生。画布图层没有内容类型字段,所以画布只按「`assetKind=model3d``objectKey` 非空」放行,格式判定交给查看器包(声明 `Content-Type` → 字节魔数 → 地址扩展名,判不出来即 `unsupported-format` 并展示原因);下载侧用真实响应头 MIME、对象键扩展名兜底。
- **易错点**:① 不要为了「预览缺失也能开」把模型文件喂给图片元素,预览缺失应显示占位与错误信息;② 不要新增 `model_format` 列,也不要在画布 / 素材库各抄一份格式清单或在画布侧按扩展名预判放行,两处真相一定会分叉;③ `EDITOR_CANVAS_ASSET_KINDS` 与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 必须同步,任一侧多出或少一份都算接入未完成;④ 不要在 `assetKind` 为空时按扩展名兜底(后端投影缺陷会被掩盖),也不要直接照抄 AGC 画布那两份旧判断。
- **关联**`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/tripo3d/storage.rs``src/components/image-editor/model3d-preview/`
## 2026-09-21 复用旧 `Idempotency-Key` 的「重试」不会重跑 3D 生成
- **现象**3D 生成失败后,用同一次请求(同 `Idempotency-Key` + 同 payload)再提交一次,接口返回 accepted 与**同一个** `operationId`,但任务永远停在原来的失败终态,用户以为「已经重试过了」。
- **成因**:两个 Tripo submit 端点把 `Idempotency-Key` 当作唯一幂等身份(dedupe key = sha256(owner + job_kind + key)),同键同 payload 直接返回既有 operation,同键不同 payload 才 409;而 Tripo job 固定 `max_attempts = 1`,失败即终态。因此这个键上不存在「再跑一次」的可能。
- **处理(现行口径)**:用户主动重试必须铸造新的 `Idempotency-Key`(画布侧由前端生成并绑定当次请求内容,改参数即视为新请求),不要把它做成「复用同键的可重试」。
- **实现口径**:画布侧的键是「尝试代次 + 请求内容指纹」两段拼成(`resolveModel3dRequestKey`)。不要改成「只在档位 / 开关 / 提示词的 onChange 里重铸键」——参考图还有上传、画布点选、素材库选择三条路径,漏一条就会出现同键换内容的 409;也不要去掉代次,那样用户点「重试」会回到原来那个终态 operation。
- **验证**`cargo test -p api-server` 的 tripo3d 队列用例;前端 `model3d-generation` 的重试用例断言两次提交的键不同。
- **关联**`server-rs/crates/api-server/src/tripo3d/queue.rs``server-rs/crates/api-server/src/editor_generation_queue.rs``src/components/image-editor/model3d-generation/`
## 2026-09-21 3D 生成价格不能照抄图片模型的「前端内置兜底」
- **风险**:图片 / 视频 / 音频一族在前端有内置兜底定价(`EDITOR_MODEL_MUD_POINT_CONFIG`),定价接口异常时价格照常显示。3D 入口若照抄这条习惯,会在 `model3d` 段缺失时继续展示一个价格,而后端是 fail closed503 `model3d-pricing-unavailable`,不扣费不入队),用户看到「面板有价、提交被拒」。
- **处理(现行口径)**3D 价格只读 `GET /api/editor/generation-pricing``model3d` 段;该段缺失即入口不可提交且不发请求,既不写死数值也不按 0 元放行。前端只保留「档位 → 六个必填参数」的映射与加和逻辑,所有数值来自查询。
- **易错点**:① 缺价时不要「先发请求让后端兜底」,那会把 503 变成用户可见的失败;② 不要拿图片模型的 `prices` 给 3D 兜底;③ 前端这份加和逻辑与后端 `model3d_add_ons` 是两份实现,必须用同一组参数组合的用例对齐。
- **关联**`docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md``src/services/image-editor/editorProjectClient.ts``src/components/image-editor/ImageCanvasGenerationModel.ts`
## 2026-09-21 画布新生成工具漏在「参考图来源」的 mode 列表外,表现为点了菜单没反应
- **现象**:新工具面板里的参考图来源菜单(从画布中选择 / 上传图片 / 从项目素材中选择)点下去没有任何反应,既不报错也不进参考图;`ImageCanvasGenerationSurface` 的入口与子选项菜单却一切正常。
- **成因**:参考图的三条写入路径都按**显式 mode 列表**分流,而不是按「谁的对话框打开了」:画布点选走 `ImageCanvasGenerationDialogModel.appendGenerationReference`,上传走 `ImageCanvasUploadModel.applyGenerationReferenceUpload``target === 'generation-reference'`),素材库走 `ImageCanvasGenerationDialogModel.replaceProjectAssetPickerReferences`。三处各自维护一份 `dialog.mode === 'generate' || 'scene' || ...` 列表,新 mode 不在列表里就被安静地原样返回。
- **处理(现行口径)**:新增画布生成工具时,除工具栏与子选项菜单外,必须把新 mode 同步进这三处,并在 `resolveDialogExtraImageReferenceLimit` 给出参考图上限;单图槽位(如 `model3d-image-to-model`)用 `usesSingleImageReferenceSlot` 标注,素材库选择按替换而不是追加处理。改完必须有用例覆盖「点选 / 上传 / 素材库各进一次参考图字段」。
- **易错点**:① 不要只在面板里 `console.warn` 或靠 UI 文案兜底,这类遗漏在自动检查里是静默的;② 单槽位工具不要让「画布点选 vs 素材库选择」互相挤占——留着旧的会让用户这次的选择看起来没生效;③ 参考图上限不要沿用图片生成的默认值(图生 3D 只接受一张)。
- **验证**`src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts``src/components/image-editor/ImageCanvasUploadModel.test.ts``src/components/image-editor/projectAssetReferencePickerModel.test.ts``src/components/image-editor/ImageCanvasGenerationModel.test.ts` 中针对 `model3d-image-to-model` 的用例。
- **关联**`src/components/image-editor/ImageCanvasGenerationDialogModel.ts``src/components/image-editor/ImageCanvasUploadModel.ts``src/components/image-editor/ImageCanvasGenerationModel.ts``docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md`
@@ -13,7 +13,7 @@
- 不接 multiview-to-model、Gaussian Splat、rig、animation、texture、convert 等其它 Tripo 能力。
- 不进入 `/api/external/v1`,不改 External OpenAPI,不新增外部 scope 或 API Key 语义。
- 不复用、不修改、不删除 `platform-hyper3d` 及其 `/api/assets/hyper3d/*` 路由与契约;本能力是全新 API,不是 Hyper3D 的 provider 替换。
- 不接前端与 `packages/model3d-viewer`
- 本后端里程碑不接前端与 `packages/model3d-viewer`;前端入口与结果预览分别由 [`【里程碑】Tripo生成前端入口-2026-09-21.md`](../project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md) 与 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](../project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md) 承接
- 不提供取消能力:客户端停止查询,worker 继续推进到 provider 终态。
- 本期不改 `platform-oss` 为流式或分片上传,沿用现有完整字节写入路径;流式上传作为后续 TODO。
@@ -153,26 +153,26 @@ thumbnailSrc = 预览图
width/height = 预览图像素尺寸
```
模型格式与体积由 `AssetObject` 承担(`content_type``content_length``content_hash`),因此不新增 `model_format``size_bytes``poly_count` 等列。
模型格式与体积由 `AssetObject` 承担(`content_type``content_length``content_hash`),因此不新增 `model_format``size_bytes``poly_count` 等列。`content_type` 在写入前按字节魔数识别真实产物(GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头、glTF 的 JSON 正文),不直接采信 provider 返回或下载响应头声明的类型;对象键扩展名由该内容类型派生。客户端不复制这份格式清单:画布只按「`assetKind=model3d``objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。
`external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。
## assetKind 接入清单对照
对照 [`【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 的「新增编辑器 `assetKind` 接入清单」,`model3d` 是**后端-only 类别**:本期不接编辑器前端,只对实际经过的链路做决策,其余行显式登记为不进入
对照 [`【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 的「新增编辑器 `assetKind` 接入清单」,`model3d` 现已接图片画布的前端预览入口(工具条「3D 预览」+ 查看器模态,实施计划见 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](../project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)),但除画布预览外的前端面与后台面仍未进入。下表逐行登记本期口径
| 清单触碰点 | 本期口径 |
| --- | --- |
| 资源 / 素材 `assetKind` 与 upsert 参数 | `assetKind = "model3d"` 由 worker 在服务端写入,调用方无法指定;Rust 侧 `asset_kind` 是自由字符串,不动共享契约的闭集枚举 |
| schema 与 migration | 不新增类别专属列,复用 `imageSrc` / `thumbnailSrc` / `AssetObject` 元数据;无 `model3d` 存量数据,不写数据迁移 |
| 原子落库 `operation_kind` 白名单 | **必须**把两个 Tripo job kind 加入 `EDITOR_GENERATION_OPERATION_KINDS`,否则 `persist_editor_generation_result_and_return` 整笔事务被拒;这是本期唯一必须改的白名单 |
| 画布回填 | `projectResource``canvasCompletion` 只走 placement-only`build_editor_canvas_resource_placement_item`),layer 只拿 `layerId` / `resourceId``assetKind``src``objectKey` 全部归资源所有;因此 `model3d` 不进 `EDITOR_CANVAS_ASSET_KINDS` |
| 用户标签覆盖资格 | 允许。该白名单必须与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步,本期无前端入口 |
| 快速编辑资格 | 不允许。3D 结果没有图片快速编辑语义,不进入快速编辑正向白名单 |
| “改造” capability 与 V2 配方 | 不进入改造 allowlist,不定义 V2 `action` / `fields[].id` / `references[].id`,不发 `canvasCompletion` 之外的可重放配方 |
| 画布回填 | `projectResource``canvasCompletion` 只走 placement-only`build_editor_canvas_resource_placement_item`),layer 只拿 `layerId` / `resourceId``assetKind``src``objectKey` 全部归资源所有;`model3d` 已按「白名单与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步」的口径加入 `EDITOR_CANVAS_ASSET_KINDS` |
| 用户标签覆盖资格 | 允许,但只在 `model3d` 与空类别之间成立:3D 资源自成一族媒体语义,图片族不能覆盖成 3D,反之亦然 |
| 快速编辑资格 | 不允许。3D 结果没有图片快速编辑语义,不进入快速编辑正向白名单;画布上「3D 预览」是只读查看能力,不是快速编辑 |
| “改造” capability 与 V2 配方 | 不进入改造 allowlist,不定义 V2 `action` / `fields[].id` / `references[].id`,不发 `canvasCompletion` 之外的可重放配方;画布对 `model3d` 关闭改造入口 |
| 素材库 / 后台 / 精选 read model / 公开 grant / External OpenAPI | 本期不改。素材库与后台沿用现有按 `assetKind` 派生 renderer 的行为,`model3d` 未接 renderer 前只保证字段正确,不承诺展示;公开 grant 与 External v1 均不涉及 |
清单第 3 条要求的“用户标签覆盖”与“快速编辑”两项资格评估,以及 capability 决策,结论均为**不进入**,理由是本期为后端-only 集成、无前端入口、3D 无 quick-edit 语义。后续接前端时,四项都必须重新评估并单独补齐前端白名单与 renderer。
清单第 3 条要求的“用户标签覆盖”与“快速编辑”两项资格评估互不推导:标签覆盖进入(限 `model3d` 与空类别互转),快速编辑与改造均不进入,理由是 3D 资源没有像素级编辑语义。画布入口只新增只读的「3D 预览」capability 位,由宿主按 `supportedActions` 声明可用;素材库行、后台 renderer 与精选 read model 仍未接,后续进入时必须单独补齐 renderer 与验收
## 兼容与迁移
@@ -191,11 +191,14 @@ width/height = 预览图像素尺寸
7. submit 成功但 checkpoint 写入失败的 attempt 终态失败且不自动重提。
8. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。
9. 完成结果按端点严格类型返回,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL。
10. `assetLibrary` 分支产出 `assetId``projectResource` 分支产出 `resourceId`,且 `assetKind=model3d``AssetObject.content_type` 为模型 mime、`content_length` 与实际对象一致。
11. 真实 Provider smoke 覆盖 text-to-model 与 image-to-model 各一次,下载字节数与 `Content-Length` 一致
12. 定向测试、`cargo check``npm run check:spacetime-schema``npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 全部通过
10. `assetLibrary` 分支产出 `assetId``projectResource` 分支产出 `resourceId`,且 `assetKind=model3d``AssetObject.content_type`按字节识别出的模型 mime、`content_length` 与实际对象一致。
11. 客户端媒体投影对 `model3d` 只暴露预览图:`imageSrc` / `thumbnailSrc` 指向预览对象、`objectKey` 指向模型本体;预览缺失时才回落到模型对象,且该回落不得让图片入口加载模型文件
12. 格式判定的顺序在查看器包内可被用例钉住:声明 `Content-Type` 优先于字节魔数、字节魔数优先于地址扩展名;三者都判不出来时模态展示 `unsupported-format` 原因与支持的格式清单,画布侧不做任何格式判断、也不在 `assetKind` 为空时按扩展名兜底
13. 真实 Provider smoke 覆盖 text-to-model 与 image-to-model 各一次,下载字节数与 `Content-Length` 一致。
14. 定向测试、`cargo check``npm run check:spacetime-schema``npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 全部通过。
## 未决问题
- add-on 价格目前是跨模型统一一份;若 Tripo 后续对某个模型版本差异化加价,再补“按模型版本覆盖”一层,现在不预留。
- 前端与 `packages/model3d-viewer` 的接入另行安排,不在本规范范围
- 3D 模型的多文件 glTF 兄弟资源(`.bin`、贴图)、多视角与模型信息展示仍未安排;画布侧接入见 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](../project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)
- AGC 项目画布自带两份格式判断(`resourceModelScene.ts``isResourceModelFbx``octet-stream` 当 FBX、`resourceCardPreviewModel.ts` 的资源卡预览按扩展名正则选解析器),本期不动,后续统一收敛到 `packages/model3d-viewer`;素材库行、后台 renderer、精选 read model 与公开 grant 的 3D 展示同样未进入。
@@ -498,7 +498,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- `assetKind` 是数据库、Rust DTO 和对外 JSON 的可选语义类别真相;普通静态图片必须为空。不要新增或返回并列的 `mediaType`;前端 renderer 可以保留内部派生媒体类型,但不得回写后端。
- 每次新增 `assetKind` 都必须分别完成“用户标签覆盖”和“快速编辑”两项资格评估;两者互不推导。可被用户选择不等于可快速编辑,属于图片媒体族也不等于自动进入快速编辑正向白名单。
- `assetKind` 只描述素材类别,不代表生成配方可执行或允许“改造”;新增类别必须单独完成上表的 capability 决策和验收。
- 不走编辑器前端的后端生成类别(当前只有 Tripo 3D 的 `model3d`同样适用本清单:四项资格评估、原子落库 `operation_kind` 白名单和画布回填口径都必须在对应技术方案里显式登记,未登记即视为未接入,不能靠“前端还没入口”跳过。生成类结果落库前 `EDITOR_GENERATION_OPERATION_KINDS` 必须已包含该 job kind,否则整笔事务被拒;画布回填只允许走 placement-only 口径(`build_editor_canvas_resource_placement_item`,不得把后端-only 类别写进 `EDITOR_CANVAS_ASSET_KINDS`,因为该白名单必须与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步。
- 编辑器前端暂无入口的后端生成类别同样适用本清单:四项资格评估、原子落库 `operation_kind` 白名单和画布回填口径都必须在对应技术方案里显式登记,未登记即视为未接入,不能靠“前端还没入口”跳过。生成类结果落库前 `EDITOR_GENERATION_OPERATION_KINDS` 必须已包含该 job kind,否则整笔事务被拒;画布回填只允许走 placement-only 口径(`build_editor_canvas_resource_placement_item``EDITOR_CANVAS_ASSET_KINDS` 必须与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步:前端已有标签入口而白名单缺项、或白名单多出前端不认的类别,都算接入未完成。Tripo 3D 的 `model3d` 已按该口径进入两端(见 `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md`),其素材库行与后台 renderer 仍未进入
- 只是新增分类或 renderer 且现有媒体字段足够时,不改 schema。只有必须跨刷新、复用、审核或公开保留的数据才新增类别专属字段。
- legacy 数据必须先通过有界、可审计、带 dry-run/hash/apply 门禁的数据库迁移收口;迁移后的 api-server、mapper、主站、后台和画布只读取正式字段,不保留运行时 fallback。
+7 -3
View File
@@ -15,7 +15,8 @@ export type CanvasAssetKind =
| 'video'
| 'sound-effect'
| 'background-music'
| 'scene';
| 'scene'
| 'model3d';
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
@@ -143,7 +144,8 @@ export type CanvasTool =
| 'icon'
| 'publication'
| 'ui-design'
| 'scene';
| 'scene'
| 'model3d';
export type CanvasGenerationDialogMode =
| 'generate'
@@ -157,7 +159,9 @@ export type CanvasGenerationDialogMode =
| 'character-animation'
| 'video'
| 'audio-sound-effect'
| 'audio-background-music';
| 'audio-background-music'
| 'model3d-text-to-model'
| 'model3d-image-to-model';
export type GenerateDialogState = {
id?: string;
+5
View File
@@ -3,6 +3,11 @@ export {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
MODEL3D_VIEWER_SUPPORTED_FORMATS,
resolveModel3dViewerFormat,
resolveModel3dViewerFormatFromBytes,
resolveModel3dViewerFormatFromMimeType,
resolveModel3dViewerFormatFromUrl,
} from './loader';
export type { Model3DViewerHandle, Model3DViewerProps } from './Model3DViewer';
export { Model3DViewer } from './Model3DViewer';
+82 -1
View File
@@ -4,6 +4,9 @@ import {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
resolveModel3dViewerFormat,
resolveModel3dViewerFormatFromBytes,
resolveModel3dViewerFormatFromMimeType,
} from './loader';
describe('模型格式承诺范围', () => {
@@ -27,7 +30,7 @@ describe('模型格式承诺范围', () => {
});
describe('模型体积上限', () => {
it('超过 32 MiB 判定为超限', () => {
it('超过 64 MiB 判定为超限', () => {
expect(isModel3dViewerOverSize(0)).toBe(false);
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES)).toBe(false);
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES + 1)).toBe(
@@ -39,3 +42,81 @@ describe('模型体积上限', () => {
expect(isModel3dViewerOverSize(Number.NaN)).toBe(false);
});
});
const GLB_MAGIC = new Uint8Array([0x67, 0x6c, 0x54, 0x46, 0x02, 0x00, 0x00]);
const FBX_MAGIC = new Uint8Array([
0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61, 0x20, 0x46, 0x42, 0x58, 0x20, 0x42,
0x69, 0x6e, 0x61, 0x72, 0x79, 0x00,
]);
describe('模型格式判定', () => {
it('声明类型按子串匹配各家拼法', () => {
expect(resolveModel3dViewerFormatFromMimeType('model/gltf-binary')).toBe(
'glb',
);
expect(resolveModel3dViewerFormatFromMimeType('model/gltf+json')).toBe(
'gltf',
);
expect(
resolveModel3dViewerFormatFromMimeType('application/x-fbx; charset=x'),
).toBe('fbx');
expect(
resolveModel3dViewerFormatFromMimeType('application/octet-stream'),
).toBeNull();
expect(resolveModel3dViewerFormatFromMimeType(null)).toBeNull();
});
it('字节魔数认出自包含的三种容器', () => {
expect(resolveModel3dViewerFormatFromBytes(GLB_MAGIC)).toBe('glb');
expect(resolveModel3dViewerFormatFromBytes(FBX_MAGIC)).toBe('fbx');
expect(
resolveModel3dViewerFormatFromBytes(
new TextEncoder().encode('\n {"asset":{"version":"2.0"}}'),
),
).toBe('gltf');
expect(
resolveModel3dViewerFormatFromBytes(
new TextEncoder().encode('\uFEFF{"asset":{}}'),
),
).toBe('gltf');
expect(
resolveModel3dViewerFormatFromBytes(new Uint8Array([0x00, 0x01])),
).toBeNull();
});
it('顺序是声明 → 魔数 → 地址扩展名', () => {
// 声明正确时轮不到魔数。
expect(
resolveModel3dViewerFormat({
declaredType: 'model/gltf-binary',
url: 'https://x/model.fbx',
bytes: FBX_MAGIC,
}),
).toBe('glb');
// 声明写歪(provider 给 octet-stream、扩展名也不对)时按魔数纠正。
expect(
resolveModel3dViewerFormat({
declaredType: 'application/octet-stream',
url: 'https://x/model.obj',
bytes: GLB_MAGIC,
}),
).toBe('glb');
// 没声明也没字节时只剩扩展名这条派生线索。
expect(
resolveModel3dViewerFormat({ url: 'https://x/model.glb?sig=1' }),
).toBe('glb');
expect(resolveModel3dViewerFormat({ url: 'https://x/model.FBX' })).toBe(
'fbx',
);
});
it('三条线索都给不出答案时返回 null,交给加载流程报 unsupported-format', () => {
expect(
resolveModel3dViewerFormat({
declaredType: 'application/octet-stream',
url: 'https://x/model.obj',
bytes: new Uint8Array([0x00, 0x01]),
}),
).toBeNull();
});
});
+160 -26
View File
@@ -5,24 +5,149 @@ import type { Model3dViewerFailureReason } from './status';
export type Model3DViewerFormat = 'glb' | 'gltf' | 'fbx';
export type Model3DViewerSource =
| { kind: 'url'; url: string; format: Model3DViewerFormat }
| { kind: 'bytes'; data: ArrayBuffer; format: Model3DViewerFormat };
/**
* 调用方已知格式时给出;不传就由加载流程按「声明类型 → 字节魔数 → 地址扩展名」判定。
*/
| { kind: 'url'; url: string; format?: Model3DViewerFormat }
| { kind: 'bytes'; data: ArrayBuffer; format?: Model3DViewerFormat };
const SUPPORTED_FORMATS: readonly Model3DViewerFormat[] = [
'glb',
'gltf',
'fbx',
export const MODEL3D_VIEWER_SUPPORTED_FORMATS: readonly Model3DViewerFormat[] =
['glb', 'gltf', 'fbx'];
/**
* 声明类型里的模型格式判据。
*
* 声明来自对象自己的内容类型:read-bytes 响应头、OSS 对象 metadata 或
* `asset_object.content_type`,它们都是同一份值。provider 仍会写别的拼法
* `model/gltf+json`、`application/x-fbx`、`binary/octet-stream`),所以按子串匹配。
*/
const MODEL3D_VIEWER_MIME_MARKERS: ReadonlyArray<
readonly [marker: string, format: Model3DViewerFormat]
> = [
['gltf-binary', 'glb'],
['glb', 'glb'],
['gltf', 'gltf'],
['fbx', 'fbx'],
];
/** 单模型可渲染上限;超过后整体失败,不做半渲染。 */
export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 32 * 1024 * 1024;
const MODEL3D_VIEWER_GLB_MAGIC = [0x67, 0x6c, 0x54, 0x46];
const MODEL3D_VIEWER_FBX_MAGIC = [
0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61, 0x20, 0x46, 0x42, 0x58, 0x20, 0x42,
0x69, 0x6e, 0x61, 0x72, 0x79,
];
const MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT =
MODEL3D_VIEWER_SUPPORTED_FORMATS.join(' / ');
function matchesModel3dViewerMagic(
bytes: Uint8Array,
magic: readonly number[],
) {
if (bytes.length < magic.length) {
return false;
}
return magic.every((value, index) => bytes[index] === value);
}
function isModel3dViewerJsonStart(bytes: Uint8Array) {
for (const byte of bytes) {
// BOM 与空白之后是 `{` 才当 glTF JSON:多文件 glTF 的正文就是一个 JSON 文档。
if (byte === 0xef || byte === 0xbb || byte === 0xbf) {
continue;
}
if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d) {
continue;
}
return byte === 0x7b;
}
return false;
}
/** 声明类型里的模型格式;不是可渲染格式时返回 null。 */
export function resolveModel3dViewerFormatFromMimeType(
declaredType: string | null | undefined,
): Model3DViewerFormat | null {
const normalized = declaredType?.split(';')[0]?.trim().toLowerCase() ?? '';
if (!normalized) {
return null;
}
return (
MODEL3D_VIEWER_MIME_MARKERS.find(([marker]) =>
normalized.includes(marker),
)?.[1] ?? null
);
}
/** 按字节魔数识别格式;声明不可信时以它为准,识别不出返回 null。 */
export function resolveModel3dViewerFormatFromBytes(
bytes: ArrayBuffer | Uint8Array,
): Model3DViewerFormat | null {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
if (matchesModel3dViewerMagic(view, MODEL3D_VIEWER_GLB_MAGIC)) {
return 'glb';
}
if (matchesModel3dViewerMagic(view, MODEL3D_VIEWER_FBX_MAGIC)) {
return 'fbx';
}
if (isModel3dViewerJsonStart(view)) {
return 'gltf';
}
return null;
}
/** 地址扩展名里的模型格式;它只是派生线索,不是格式真相。 */
export function resolveModel3dViewerFormatFromUrl(
url: string | null | undefined,
): Model3DViewerFormat | null {
const withoutQuery =
(url?.trim() ?? '').split(/[?#]/u)[0]?.toLowerCase() ?? '';
const match = /\.([a-z0-9]+)$/u.exec(withoutQuery);
const extension = match?.[1] ?? null;
return isModel3dViewerFormatSupported(extension) ? extension : null;
}
/**
* 模型格式的判定顺序:声明 → 字节魔数 → 地址扩展名。
*
* 声明(响应内容类型 / OSS metadata / `asset_object.content_type`)是主线索,魔数兜住
* 写歪的声明,扩展名是同源派生出来的最后一条线索。三处都判不出就返回 null,由加载流程
* 报 `unsupported-format`,宿主把原因显示给用户 —— 不猜、不半渲染。
*/
export function resolveModel3dViewerFormat({
declaredType,
url,
bytes,
}: {
declaredType?: string | null;
url?: string | null;
bytes?: ArrayBuffer | Uint8Array | null;
}): Model3DViewerFormat | null {
const fromDeclaredType = resolveModel3dViewerFormatFromMimeType(declaredType);
if (fromDeclaredType) {
return fromDeclaredType;
}
const fromBytes = bytes ? resolveModel3dViewerFormatFromBytes(bytes) : null;
if (fromBytes) {
return fromBytes;
}
return resolveModel3dViewerFormatFromUrl(url);
}
export function isModel3dViewerFormatSupported(
value: unknown,
): value is Model3DViewerFormat {
return SUPPORTED_FORMATS.includes(value as Model3DViewerFormat);
return MODEL3D_VIEWER_SUPPORTED_FORMATS.includes(
value as Model3DViewerFormat,
);
}
/**
* 单模型可渲染上限;超过后整体失败,不做半渲染。
*
* 64 MiB 而不是更小:3D 生成产物带 HD 纹理时本来就在几十 MB 量级,卡在 32 MiB
* 会让一整档结果永远预览不到;而完整读入后送进 WebGL 的内存代价仍在可控范围。
*/
export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 64 * 1024 * 1024;
export function isModel3dViewerOverSize(byteLength: number): boolean {
return (
Number.isFinite(byteLength) && byteLength > MODEL3D_VIEWER_MAX_MODEL_BYTES
@@ -69,13 +194,16 @@ function assertModel3dViewerByteLength(byteLength: number) {
}
}
/** 取模型字节:url 走一次 fetch,bytes 直接复用调用方已读入的字节。 */
export async function readModel3dViewerSourceBytes(
/**
* 取模型字节与声明类型:url 走一次 fetch(顺带取响应的 `Content-Type`,它就是对象
* 自己的内容类型),bytes 直接复用调用方已读入的字节。
*/
export async function readModel3dViewerSource(
source: Model3DViewerSource,
): Promise<ArrayBuffer> {
): Promise<{ data: ArrayBuffer; declaredType: string | null }> {
if (source.kind === 'bytes') {
assertModel3dViewerByteLength(source.data.byteLength);
return source.data;
return { data: source.data, declaredType: null };
}
let response: Response;
@@ -109,7 +237,7 @@ export async function readModel3dViewerSourceBytes(
);
}
assertModel3dViewerByteLength(data.byteLength);
return data;
return { data, declaredType: response.headers.get('content-type') };
}
function resolveModel3dViewerBasePath(source: Model3DViewerSource): string {
@@ -128,21 +256,29 @@ export async function loadModel3dViewerObject(
source: Model3DViewerSource,
isAborted: () => boolean = () => false,
): Promise<ThreeTypes.Object3D> {
if (!isModel3dViewerFormatSupported(source.format)) {
throw new Model3dViewerLoadError(
'unsupported-format',
`不支持的模型格式:${String(source.format)}`,
);
}
const data = await readModel3dViewerSourceBytes(source);
const { data, declaredType } = await readModel3dViewerSource(source);
if (isAborted()) {
throw new Model3dViewerAbortedError();
}
// 调用方声明的格式就是最明确的一层声明;没给才落回「响应内容类型 → 魔数 → 扩展名」。
const format = isModel3dViewerFormatSupported(source.format)
? source.format
: resolveModel3dViewerFormat({
declaredType,
url: source.kind === 'url' ? source.url : null,
bytes: data,
});
if (!format) {
throw new Model3dViewerLoadError(
'unsupported-format',
`无法识别模型格式,只支持 ${MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT}`,
);
}
const basePath = resolveModel3dViewerBasePath(source);
try {
if (source.format === 'fbx') {
if (format === 'fbx') {
const { FBXLoader } = await import(
'three/examples/jsm/loaders/FBXLoader.js'
);
@@ -154,9 +290,7 @@ export async function loadModel3dViewerObject(
);
const loader = new GLTFLoader();
const payload: string | ArrayBuffer =
source.format === 'gltf'
? new TextDecoder().decode(new Uint8Array(data))
: data;
format === 'gltf' ? new TextDecoder().decode(new Uint8Array(data)) : data;
const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>(
(resolve, reject) => {
loader.parse(payload, basePath, resolve, reject);
@@ -9255,8 +9255,12 @@ fn editor_canvas_payload_from_record(
pub(crate) fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
let image_src = project_editor_client_media_src(
record.asset_kind.as_deref(),
record.image_src,
record.object_key.as_deref(),
None,
);
let formal_image_sequence_frames = record
.image_sequence_frames
.as_ref()
@@ -9308,8 +9312,12 @@ pub(crate) fn editor_project_resource_payload_from_record(
fn editor_project_resource_payload_from_showcase_record(
record: EditorShowcaseAssetRecord,
) -> EditorProjectResourcePayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
let image_src = project_editor_client_media_src(
record.asset_kind.as_deref(),
record.image_src,
record.object_key.as_deref(),
record.thumbnail_src.as_deref(),
);
let formal_image_sequence_frames = record
.image_sequence_frames
.as_ref()
@@ -9724,8 +9732,12 @@ pub(crate) fn editor_asset_folder_payload_from_record(
}
pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
let image_src = project_editor_client_media_src(
record.asset_kind.as_deref(),
record.image_src,
record.object_key.as_deref(),
record.thumbnail_src.as_deref(),
);
let formal_image_sequence_frames = record
.image_sequence_frames
.as_ref()
@@ -11635,6 +11647,38 @@ pub(crate) fn editor_media_src_from_object_key(object_key: &str) -> String {
format!("/{}", object_key.trim().trim_start_matches('/'))
}
/// 3D 生成产物的类别:模型本体在 `object_key`,卡片与缩略图消费预览图。
pub(crate) const EDITOR_MODEL3D_ASSET_KIND: &str = "model3d";
/// 客户端可见媒体投影:唯一决定 payload 的 `imageSrc` 指向哪个对象。
///
/// - `model3d``imageSrc` 与 `thumbnailSrc` 都是预览图,模型本体只留在 `objectKey`。
/// 预览依次取 `thumbnail_src`、存储的 `image_src`。模型与预览是两个对象,不能让
/// `object_key` 覆盖 `image_src`,否则画布卡片、素材缩略图和后台都会把模型文件当图片加载。
/// - 其它类别:沿用「`object_key` 非空即以它为 `imageSrc`」的历史口径。
/// - 两个来源都缺失时回落到历史口径,保证「有对象就有 `imageSrc`」的不变量不退化。
fn project_editor_client_media_src(
asset_kind: Option<&str>,
image_src: String,
object_key: Option<&str>,
thumbnail_src: Option<&str>,
) -> String {
if asset_kind.map(str::trim) == Some(EDITOR_MODEL3D_ASSET_KIND) {
let preview_src = thumbnail_src
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
let image_src = image_src.trim();
(!image_src.is_empty()).then(|| image_src.to_string())
});
if let Some(preview_src) = preview_src {
return normalize_editor_record_media_src(preview_src, None);
}
}
normalize_editor_record_media_src(image_src, object_key)
}
fn normalize_editor_record_media_src(image_src: String, object_key: Option<&str>) -> String {
if let Some(media_src) = object_key
.map(str::trim)
@@ -14846,6 +14890,65 @@ mod tests {
);
}
#[test]
fn editor_client_media_projection_keeps_model3d_preview_over_model_object_key() {
// 3D 资源的模型本体在 object_key,卡片与缩略图必须拿到预览图:投影不能像其它
// 类别那样让 object_key 覆盖 imageSrc,否则所有图片入口都会去加载 .glb。
assert_eq!(
project_editor_client_media_src(
Some("model3d"),
"/generated-character-drafts/editor/model3d/task-1/preview.webp".to_string(),
Some("generated-character-drafts/editor/model3d/task-1/model.glb"),
None,
),
"/generated-character-drafts/editor/model3d/task-1/preview.webp"
);
assert_eq!(
project_editor_client_media_src(
Some("model3d"),
"".to_string(),
Some("generated-character-drafts/editor/model3d/task-1/model.glb"),
Some("/generated-character-drafts/editor/model3d/task-1/preview.png"),
),
"/generated-character-drafts/editor/model3d/task-1/preview.png"
);
}
#[test]
fn editor_client_media_projection_falls_back_when_model3d_has_no_preview() {
assert_eq!(
project_editor_client_media_src(
Some("model3d"),
"".to_string(),
Some("generated-character-drafts/editor/model3d/task-1/model.glb"),
Some(" "),
),
"/generated-character-drafts/editor/model3d/task-1/model.glb"
);
}
#[test]
fn editor_client_media_projection_keeps_legacy_object_key_precedence_for_other_kinds() {
assert_eq!(
project_editor_client_media_src(
Some("scene"),
"/generated-character-drafts/editor/scene.png".to_string(),
Some("generated-character-drafts/editor/scene-object.png"),
Some("/generated-character-drafts/editor/scene-thumb.png"),
),
"/generated-character-drafts/editor/scene-object.png"
);
assert_eq!(
project_editor_client_media_src(
None,
"/generated-character-drafts/editor/plain.png".to_string(),
None,
None,
),
"/generated-character-drafts/editor/plain.png"
);
}
fn test_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord {
EditorShowcaseAssetRecord {
showcase_id: "showcase-1".to_string(),
@@ -30,8 +30,8 @@ use crate::{
use super::{errors::oss_unavailable, job::MODEL3D_PROVIDER_KIND, provider::TRIPO_PROVIDER};
/// 3D 结果的语义类别;编辑器画布不消费它,但资源 / 素材行必须带上
pub(crate) const MODEL3D_ASSET_KIND: &str = "model3d";
/// 3D 结果的语义类别;取值与 api-server 媒体投影共用同一常量,避免两处字面量分叉
pub(crate) const MODEL3D_ASSET_KIND: &str = crate::editor_project::EDITOR_MODEL3D_ASSET_KIND;
/// `asset_object.entity_kind`:说明这个对象属于 3D 生成产物。
const MODEL3D_OBJECT_ENTITY_KIND: &str = "model3d";
@@ -89,7 +89,7 @@ pub(crate) async fn store_model3d_artifact(
.oss_client()
.ok_or_else(|| oss_unavailable("OSS 未完成环境变量配置,无法写入 3D 产物。"))?;
let http_client = state.editor_oss_http_client();
let content_type = normalize_artifact_content_type(content_type, slot);
let content_type = resolve_artifact_content_type(content_type, slot, bytes.as_slice());
let sha256 = sha256_hex(bytes.as_slice());
let file_name = format!(
"{}.{}",
@@ -183,9 +183,52 @@ fn normalize_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> Stri
normalized
}
/// glb 的容器魔数(小端 `glTF`);`model/gltf+json` 的判据是首个非空白字节为 `{`。
const GLB_MAGIC: &[u8] = b"glTF";
/// FBX 二进制头,固定为 `Kaydara FBX Binary` 加两个空格与 NUL;截到前缀即可判定。
const FBX_MAGIC: &[u8] = b"Kaydara FBX Binary";
/// 按字节魔数识别模型格式。字节是唯一不会被 provider 说慌的真相来源。
fn sniff_model_content_type(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(GLB_MAGIC) {
return Some("model/gltf-binary");
}
if bytes.starts_with(FBX_MAGIC) {
// `model/fbx` 不是注册媒体类型,但它是本项目自己的产物词汇,客户端按
// `model/` 族里的 `fbx` 子串判定,比 `application/octet-stream` 可读得多。
return Some("model/fbx");
}
let first_meaningful = bytes
.strip_prefix(&[0xEF, 0xBB, 0xBF][..])
.unwrap_or(bytes)
.iter()
.copied()
.find(|byte| !byte.is_ascii_whitespace());
if first_meaningful == Some(b'{') {
return Some("model/gltf+json");
}
None
}
/// 产物 content type:模型先嗅探字节,嗅不出来才信 provider 声明。
///
/// provider 对二进制产物常给 `application/octet-stream`,归一后会被写成
/// `model/gltf-binary` + `.glb`;客户端按这份 content type 判定能不能预览,说错就是
/// 拿 GLTFLoader 去解 FBX。预览图不嗅探:它已经有维度解析这条更硬的正向校验。
fn resolve_artifact_content_type(raw: &str, slot: Model3dArtifactSlot, bytes: &[u8]) -> String {
if slot == Model3dArtifactSlot::Model {
if let Some(sniffed) = sniff_model_content_type(bytes) {
return sniffed.to_string();
}
}
normalize_artifact_content_type(raw, slot)
}
fn artifact_extension(content_type: &str, slot: Model3dArtifactSlot) -> &'static str {
match content_type {
"model/gltf-binary" => "glb",
"model/gltf+json" => "gltf",
"model/fbx" | "application/x-fbx" => "fbx",
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
@@ -198,3 +241,125 @@ fn sha256_hex(bytes: &[u8]) -> String {
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
const GLB_BYTES: &[u8] = b"glTF\x02\x00\x00\x00rest-of-glb";
const FBX_BYTES: &[u8] = b"Kaydara FBX Binary \x00\x1a\x00rest-of-fbx";
const GLTF_JSON_BYTES: &[u8] = b"\xEF\xBB\xBF\n {\"asset\":{\"version\":\"2.0\"}}";
const UNKNOWN_BYTES: &[u8] = b"\x00\x01not-a-model";
#[test]
fn model_content_type_follows_bytes_over_declaration() {
// provider 对二进制产物常给 application/octet-stream。归一后是 model/gltf-binary
// 一个 FBX 结果就会以 .glb 落库,客户端按它判定预览必然解析失败。
let content_type = resolve_artifact_content_type(
"application/octet-stream",
Model3dArtifactSlot::Model,
FBX_BYTES,
);
assert_eq!(content_type, "model/fbx");
assert_eq!(
artifact_extension(content_type.as_str(), Model3dArtifactSlot::Model),
"fbx"
);
}
#[test]
fn model_content_type_follows_bytes_even_when_declaration_is_wrong() {
// 声明说 glb、字节是 fbx 时以字节为准:这份 content type 会进 asset_object
// 前端拿它决定禁用 3D 预览与否,不能让它说错。
assert_eq!(
resolve_artifact_content_type(
"model/gltf-binary",
Model3dArtifactSlot::Model,
FBX_BYTES,
),
"model/fbx"
);
}
#[test]
fn model_content_type_recognizes_each_supported_container() {
assert_eq!(
resolve_artifact_content_type(
"application/octet-stream",
Model3dArtifactSlot::Model,
GLB_BYTES,
),
"model/gltf-binary"
);
assert_eq!(
resolve_artifact_content_type(
"application/octet-stream",
Model3dArtifactSlot::Model,
GLTF_JSON_BYTES,
),
"model/gltf+json"
);
assert_eq!(
artifact_extension("model/gltf+json", Model3dArtifactSlot::Model),
"gltf"
);
}
#[test]
fn model_content_type_keeps_declaration_when_bytes_are_unknown() {
// 嗅不出来时保持既有行为:声明可用就信声明,否则回落 glb。
assert_eq!(
resolve_artifact_content_type(
"application/octet-stream",
Model3dArtifactSlot::Model,
UNKNOWN_BYTES,
),
"model/gltf-binary"
);
assert_eq!(
resolve_artifact_content_type(
"model/gltf-binary",
Model3dArtifactSlot::Model,
UNKNOWN_BYTES,
),
"model/gltf-binary"
);
assert_eq!(
artifact_extension("binary/octet-stream", Model3dArtifactSlot::Model),
"glb"
);
}
#[test]
fn preview_content_type_is_not_sniffed() {
// 预览图不按模型魔数嗅探:即使字节恰好长得像模型,也走图片的既有归一。
assert_eq!(
resolve_artifact_content_type(
"application/octet-stream",
Model3dArtifactSlot::Preview,
GLB_BYTES,
),
"image/webp"
);
assert_eq!(
resolve_artifact_content_type(
"image/jpeg; charset=binary",
Model3dArtifactSlot::Preview,
GLB_BYTES,
),
"image/jpeg"
);
}
#[test]
fn artifact_extension_maps_known_fbx_spellings() {
assert_eq!(
artifact_extension("application/x-fbx", Model3dArtifactSlot::Model),
"fbx"
);
assert_eq!(
artifact_extension("image/png", Model3dArtifactSlot::Preview),
"png"
);
}
}
@@ -57,7 +57,9 @@ const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [
"assetKind",
"generationInputs",
];
const EDITOR_CANVAS_ASSET_KINDS: [&str; 12] = [
// 该白名单必须与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步:3D 模型资源在画布上
// 允许用户把标签在 `model3d` 与未知之间切换,因此它和图片类别一样必须在这里登记。
const EDITOR_CANVAS_ASSET_KINDS: [&str; 13] = [
"spec",
"scene",
"character",
@@ -70,6 +72,7 @@ const EDITOR_CANVAS_ASSET_KINDS: [&str; 12] = [
"video",
"sound-effect",
"background-music",
"model3d",
];
const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"];
const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending";
@@ -271,6 +271,22 @@ describe('EditorAgentConversationPanelView', () => {
zIndex: 2,
sourceType: 'uploaded',
},
{
id: 'layer-model3d',
resourceId: 'resource-model3d',
title: '三维角色',
src: '/generated/model3d/preview.webp',
objectKey: 'generated/editor/model3d/task-1/model.glb',
assetKind: 'model3d',
x: 0,
y: 0,
width: 256,
height: 256,
originalWidth: 256,
originalHeight: 256,
zIndex: 3,
sourceType: 'generated',
},
]}
assets={[
{
@@ -313,6 +329,12 @@ describe('EditorAgentConversationPanelView', () => {
name: '选择画布图片 未入库图层',
}),
).toBeNull();
// 3D 资源的画布媒体类型也是 image,但资源身份是模型本体,不能当参考图。
expect(
within(attachmentDialog).queryByRole('checkbox', {
name: '选择画布图片 三维角色',
}),
).toBeNull();
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯',
@@ -33,8 +33,20 @@ type AttachmentUpdater =
currentAttachments: EditorAgentAttachmentRef[],
) => EditorAgentAttachmentRef[]);
/**
* 3D 模型资源不能当参考图。
*
* 它的 `mediaType` 就是 `image`(画布上用预览图渲染),但资源身份是模型本体,
* `objectKey` 指向 `.glb`。参考图管线按 objectKey 取对象、按 `image/*` 校验,
* 放进来只会得到一个打不开的附件;"把 3D 结果的渲染图当参考图"是另一个产品决策。
*/
function isModel3dSource(assetKind: string | null | undefined) {
return assetKind === 'model3d';
}
function isImageLayer(layer: CanvasLayer) {
return (
!isModel3dSource(layer.assetKind) &&
(layer.mediaType ?? 'image') === 'image' &&
Boolean(layer.resourceId?.trim()) &&
layer.src.trim()
@@ -42,7 +54,11 @@ function isImageLayer(layer: CanvasLayer) {
}
function isImageAsset(asset: EditorAsset) {
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
return (
!isModel3dSource(asset.assetKind) &&
(asset.mediaType ?? 'image') === 'image' &&
asset.src.trim()
);
}
function createCanvasAttachmentOptions(
@@ -15,11 +15,13 @@ describe('ImageCanvasBottomToolbarView', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
const model3dToolWrapRef = createRef<HTMLSpanElement>();
const view = render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
model3dToolWrapRef={model3dToolWrapRef}
effectiveTool="generate"
onSwitchTool={switchTool}
onOpenToolOptions={vi.fn()}
@@ -36,6 +38,7 @@ describe('ImageCanvasBottomToolbarView', () => {
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
model3dToolWrapRef,
},
};
}
@@ -48,6 +51,7 @@ describe('ImageCanvasBottomToolbarView', () => {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
model3dToolWrapRef: RefObject<HTMLSpanElement | null>;
},
) {
return (
@@ -55,6 +59,7 @@ describe('ImageCanvasBottomToolbarView', () => {
specToolWrapRef={refs.specToolWrapRef}
musicToolWrapRef={refs.musicToolWrapRef}
publicationToolWrapRef={refs.publicationToolWrapRef}
model3dToolWrapRef={refs.model3dToolWrapRef}
effectiveTool={effectiveTool}
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
@@ -73,10 +78,10 @@ describe('ImageCanvasBottomToolbarView', () => {
);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
).toHaveLength(12);
).toHaveLength(13);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
).toHaveLength(2);
).toHaveLength(3);
const toolExpectations = [
['选择工具', 'select'],
@@ -89,6 +94,7 @@ describe('ImageCanvasBottomToolbarView', () => {
['生成角色形象', 'character'],
['生成图标素材', 'icon'],
['生成UI设计图', 'ui-design'],
['生成 3D 模型', 'model3d'],
['宣发素材', 'publication'],
['生成游戏场景', 'scene'],
] as const;
@@ -5,6 +5,7 @@ import {
} from '@genarrative/image-canvas-react';
import {
AppWindow,
Box,
Clapperboard,
ClipboardList,
Grid2X2,
@@ -22,12 +23,16 @@ import type { RefObject } from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTool } from './ImageCanvasEditorTypes';
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
type ToolbarOptionTool = Extract<
CanvasTool,
'music' | 'spec' | 'publication' | 'model3d'
>;
type ImageCanvasBottomToolbarViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
model3dToolWrapRef: RefObject<HTMLSpanElement | null>;
effectiveTool: CanvasTool;
highlight?: boolean;
onSwitchTool: (tool: CanvasTool) => void;
@@ -62,11 +67,22 @@ const canvasTools: Array<{
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
{ id: 'scene', label: '生成游戏场景', icon: Mountain },
{
id: 'model3d',
label: '生成 3D 模型',
icon: Box,
separatorBefore: true,
},
{ id: 'publication', label: '宣发素材', icon: Megaphone },
];
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
return tool === 'music' || tool === 'spec' || tool === 'publication';
return (
tool === 'music' ||
tool === 'spec' ||
tool === 'publication' ||
tool === 'model3d'
);
}
function hasPersistentPressedState(tool: CanvasTool) {
@@ -77,6 +93,7 @@ export function ImageCanvasBottomToolbarView({
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
model3dToolWrapRef,
effectiveTool,
highlight = false,
onSwitchTool,
@@ -106,7 +123,9 @@ export function ImageCanvasBottomToolbarView({
? specToolWrapRef
: id === 'music'
? musicToolWrapRef
: publicationToolWrapRef
: id === 'model3d'
? model3dToolWrapRef
: publicationToolWrapRef
}
className={[
'image-canvas-editor__bottom-toolbar-option-wrap',
@@ -1157,6 +1157,52 @@ describe('ImageCanvasEditorModel', () => {
);
expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false);
expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true);
expect(isCanvasAssetKindOverrideCompatible('model3d', 'model3d')).toBe(
true,
);
expect(isCanvasAssetKindOverrideCompatible('model3d', null)).toBe(true);
expect(isCanvasAssetKindOverrideCompatible('model3d', 'character')).toBe(
false,
);
expect(isCanvasAssetKindOverrideCompatible(null, 'model3d')).toBe(false);
});
it('hydrates a model3d layer with the preview src and the model object key', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-model3d',
resourceId: 'resource-model3d',
title: '三维角色',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
new Map([
[
'resource-model3d',
{
imageSrc: '/read/model3d-preview.webp',
objectKey: 'generated/editor/model3d/task-1/model.glb',
assetKind: 'model3d',
},
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-model3d',
mediaType: 'image',
resourceAssetKind: 'model3d',
assetKind: 'model3d',
});
// 画布卡片消费预览图,模型本体只留在 objectKey 里交给 3D 查看器。
expect(hydrated?.src).toContain('model3d-preview.webp');
expect(hydrated?.objectKey).toContain('model.glb');
});
it('hydrates audio display duration only from resource generation inputs', () => {
@@ -1875,11 +1875,15 @@ type CanvasAssetMediaFamily =
| 'image'
| 'character-animation'
| 'video'
| 'audio';
| 'audio'
| 'model3d';
function resolveCanvasAssetMediaFamily(
assetKind: CanvasAssetKind | null | undefined,
): CanvasAssetMediaFamily {
if (assetKind === 'model3d') {
return 'model3d';
}
if (assetKind === 'character-animation') {
return 'character-animation';
}
@@ -2150,6 +2154,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
value === 'icon' ||
value === 'icon-spritesheet' ||
value === 'icon-spec' ||
value === 'model3d' ||
value === 'publication-material' ||
value === 'ui-design' ||
value === 'video' ||
@@ -2189,7 +2194,9 @@ function isCanvasGenerationDialogMode(
value === 'character-animation' ||
value === 'video' ||
value === 'audio-sound-effect' ||
value === 'audio-background-music'
value === 'audio-background-music' ||
value === 'model3d-text-to-model' ||
value === 'model3d-image-to-model'
);
}

Some files were not shown because too many files have changed in this diff Show More