图片画布接入 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
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:
@@ -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
@@ -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
|
||||
|
||||
**拼消消**:
|
||||
|
||||
@@ -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 不复用 Hyper3D,at-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 closed(503 `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 closed(503 `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。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
+22
@@ -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
Reference in New Issue
Block a user