diff --git a/.gitignore b/.gitignore index 2770d44c8..f03609b85 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 75f3160e8..e42ef7a15 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 **拼消消**: diff --git a/docs/README.md b/docs/README.md index 91b6c89cf..591ac42c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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` 幂等边界和实施验收。 diff --git a/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md new file mode 100644 index 000000000..7bb1092f4 --- /dev/null +++ b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md @@ -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 展示同样未进入。 diff --git a/docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md b/docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md new file mode 100644 index 000000000..4bd06d529 --- /dev/null +++ b/docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md @@ -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 冲正。 diff --git a/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md new file mode 100644 index 000000000..a00b04c7c --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md @@ -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、定价配置均不受影响。 diff --git a/docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md new file mode 100644 index 000000000..389d705e6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md @@ -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`。 diff --git a/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md b/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md new file mode 100644 index 000000000..c5f8c3a90 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md @@ -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),这不是本期缺陷。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ba80ad8ee..0acf714f7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c2ba880c9..19b676d3f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.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`。 diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index 4fd75711f..a59df576d 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-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 展示同样未进入。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 311f4fc0e..54e788551 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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。 diff --git a/packages/image-canvas-core/src/types.ts b/packages/image-canvas-core/src/types.ts index c5f95e997..be88f7dd1 100644 --- a/packages/image-canvas-core/src/types.ts +++ b/packages/image-canvas-core/src/types.ts @@ -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; diff --git a/packages/model3d-viewer/src/index.ts b/packages/model3d-viewer/src/index.ts index ab8123843..a03952e50 100644 --- a/packages/model3d-viewer/src/index.ts +++ b/packages/model3d-viewer/src/index.ts @@ -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'; diff --git a/packages/model3d-viewer/src/loader.test.ts b/packages/model3d-viewer/src/loader.test.ts index 3d78d4323..810d7c889 100644 --- a/packages/model3d-viewer/src/loader.test.ts +++ b/packages/model3d-viewer/src/loader.test.ts @@ -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(); + }); +}); diff --git a/packages/model3d-viewer/src/loader.ts b/packages/model3d-viewer/src/loader.ts index 574d8c153..92756f569 100644 --- a/packages/model3d-viewer/src/loader.ts +++ b/packages/model3d-viewer/src/loader.ts @@ -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 { +): 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 { - 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); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5cc9a1fe0..4a25c87a1 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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(), diff --git a/server-rs/crates/api-server/src/tripo3d/storage.rs b/server-rs/crates/api-server/src/tripo3d/storage.rs index c56bded92..f3190ce2a 100644 --- a/server-rs/crates/api-server/src/tripo3d/storage.rs +++ b/server-rs/crates/api-server/src/tripo3d/storage.rs @@ -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" + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 96a736bf9..839583046 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -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"; diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 7dcace2fd..6a08db8e4 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -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: '选择画布图片 一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯', diff --git a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts index a3a9c0e69..6ef2b1aaf 100644 --- a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts +++ b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts @@ -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( diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index ed6b97b37..3f7b955fe 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -15,11 +15,13 @@ describe('ImageCanvasBottomToolbarView', () => { const specToolWrapRef = createRef(); const musicToolWrapRef = createRef(); const publicationToolWrapRef = createRef(); + const model3dToolWrapRef = createRef(); const view = render( { specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, }, }; } @@ -48,6 +51,7 @@ describe('ImageCanvasBottomToolbarView', () => { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; }, ) { 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; diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx index 013985cf0..edf7e87f7 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx @@ -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; +type ToolbarOptionTool = Extract< + CanvasTool, + 'music' | 'spec' | 'publication' | 'model3d' +>; type ImageCanvasBottomToolbarViewProps = { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; 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', diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index bc0c61b66..28445faef 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -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', () => { diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 93b168bbf..1d499809e 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -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' ); } diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index a5f337aa4..99fa2fad4 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -6,7 +6,12 @@ import { screen, within, } from '@testing-library/react'; -import { createRef, type ReactElement, type ReactNode } from 'react'; +import { + type ComponentProps, + createRef, + type ReactElement, + type ReactNode, +} from 'react'; import { describe, expect, it, vi } from 'vitest'; import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; @@ -16,6 +21,7 @@ import type { ImageCanvasMetadataModalViewProps } from './ImageCanvasMetadataMod import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView'; import type { ImageCanvasStageViewProps } from './ImageCanvasStageView'; import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView'; +import { Model3dViewerModal } from './model3d-preview/Model3dViewerModal'; function render(ui: ReactElement) { return testingLibraryRender(ui, { @@ -148,6 +154,7 @@ function createStageProps(): ImageCanvasStageViewProps { specToolWrapRef: createRef(), musicToolWrapRef: createRef(), publicationToolWrapRef: createRef(), + model3dToolWrapRef: createRef(), isPanning: false, effectiveTool: 'select', canvasBackgroundColor: '#f8fafc', @@ -234,6 +241,7 @@ function createStageProps(): ImageCanvasStageViewProps { onQuickEditSelectionPointerMove: vi.fn(), onQuickEditSelectionPointerEnd: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), + onPreviewModel3d: vi.fn(), onDownloadLayer: vi.fn(), onPasteCanvasClipboard: vi.fn(), onCopyContextLayers: vi.fn(), @@ -276,6 +284,15 @@ function createMetadataProps( }; } +function createModel3dViewerProps( + layer: CanvasLayer | null = null, +): ComponentProps { + return { + layer, + onClose: vi.fn(), + }; +} + describe('ImageCanvasEditorShellView', () => { it('composes upload input, topbar, stage and metadata modal', () => { const handleUploadInputChange = vi.fn(); @@ -289,6 +306,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={createStageProps()} metadataProps={createMetadataProps(createLayer())} + model3dViewerProps={createModel3dViewerProps()} />, ); @@ -319,6 +337,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={createStageProps()} metadataProps={createMetadataProps()} + model3dViewerProps={createModel3dViewerProps()} />, ); @@ -340,6 +359,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={createStageProps()} metadataProps={createMetadataProps(createLayer())} + model3dViewerProps={createModel3dViewerProps()} />, ); @@ -389,6 +409,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={stageProps} metadataProps={createMetadataProps()} + model3dViewerProps={createModel3dViewerProps()} />, ); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.tsx b/src/components/image-editor/ImageCanvasEditorShellView.tsx index c3a6dc0af..7478423c0 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.tsx @@ -4,6 +4,7 @@ import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; import { ImageCanvasTopbarView } from './ImageCanvasTopbarView'; +import { Model3dViewerModal } from './model3d-preview/Model3dViewerModal'; type AssetDragPreview = { x: number; @@ -21,6 +22,7 @@ type ImageCanvasEditorShellViewProps = { topbarProps: ComponentProps; stageProps: ComponentProps; metadataProps: ComponentProps; + model3dViewerProps: ComponentProps; }; export function ImageCanvasEditorShellView({ @@ -33,6 +35,7 @@ export function ImageCanvasEditorShellView({ topbarProps, stageProps, metadataProps, + model3dViewerProps, }: ImageCanvasEditorShellViewProps) { const uploadInputLabel = uploadAccept.includes('video') || uploadAccept.includes('audio') @@ -83,6 +86,7 @@ export function ImageCanvasEditorShellView({ + ); } diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 9e1664d77..f3f8c917b 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -29,6 +29,7 @@ export type CanvasAssetKind = | 'icon' | 'icon-spritesheet' | 'icon-spec' + | 'model3d' | 'publication-material' | 'ui-design' | 'video' @@ -188,7 +189,8 @@ export type CanvasTool = | 'icon' | 'publication' | 'ui-design' - | 'scene'; + | 'scene' + | 'model3d'; export type SidebarPanel = 'assets' | 'layers'; @@ -260,7 +262,9 @@ export type GenerateDialogState = { | 'video' | 'audio-sound-effect' | 'audio-background-music' - | 'scene'; + | 'scene' + | 'model3d-text-to-model' + | 'model3d-image-to-model'; prompt: string; assetLabel?: string; status: 'idle' | 'generating' | 'pending-confirmation' | 'failed'; @@ -295,6 +299,16 @@ export type GenerateDialogState = { characterAnimationFrameCount?: EditorCharacterAnimationFrameCount; characterAnimationDurationSeconds?: 4 | 5 | 6; characterAnimationResult?: EditorCharacterAnimationGenerationResult; + model3dTier?: Model3dGenerationTier; + model3dQuad?: boolean; + model3dSmartLowPoly?: boolean; + model3dGenerateParts?: boolean; + /** + * 3D 生成的尝试代次:真正的 `Idempotency-Key` 由它与请求内容指纹拼成(见 + * `model3d-generation/Model3dGenerationSubmission.ts`)。代次只负责区分「用户主动重试」, + * 因此重试必定换键,而内容一变(含换参考图)也会自动换键。 + */ + model3dAttemptNonce?: string; soundModel?: EditorSoundEffectModel | 'audio1.0'; soundDurationMode?: EditorSoundEffectDurationMode; soundDurationSeconds?: number; @@ -327,6 +341,14 @@ export type GenerateDialogState = { }; }; +/** + * 3D 模型档位:唯一给用户看的参数面,每个档位固定映射成 provider 的完整贴图与几何组合。 + */ +export type Model3dGenerationTier = + | 'textured-standard' + | 'textured-detailed' + | 'geometry-only'; + export type CanvasGenerationDialogMode = Exclude< GenerateDialogState['mode'], 'edit' diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8aca4173e..ca9f0462b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -398,6 +398,7 @@ export function ImageCanvasEditorView({ const specToolWrapRef = useRef(null); const musicToolWrapRef = useRef(null); const publicationToolWrapRef = useRef(null); + const model3dToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const generationReferenceButtonRef = useRef(null); @@ -505,6 +506,8 @@ export function ImageCanvasEditorView({ const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); + const [model3dPreviewLayer, setModel3dPreviewLayer] = + useState(null); const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] = useState(null); const [imageContextMenu, setImageContextMenu] = @@ -1438,6 +1441,7 @@ export function ImageCanvasEditorView({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, generationReferenceButtonRef, @@ -2294,8 +2298,15 @@ export function ImageCanvasEditorView({ ], ); + // 模态打开时画布不再接收交互:信息弹窗与 3D 预览都盖在画布上,若快捷键仍然生效, + // Delete/Backspace 会删掉模态背后那个正在被查看的图层(预览入口还会顺手选中它)。 + const isCanvasInteractionPaused = + isAccountPaymentModalOpen || + Boolean(metadataLayer) || + Boolean(model3dPreviewLayer); + useImageCanvasKeyboardShortcuts({ - isInteractionPaused: isAccountPaymentModalOpen, + isInteractionPaused: isCanvasInteractionPaused, generateDialogRef, selectedLayerIdRef, selectedLayerIdsRef, @@ -2550,7 +2561,8 @@ export function ImageCanvasEditorView({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, - isInteractionPaused: isAccountPaymentModalOpen, + model3dToolWrapRef, + isInteractionPaused: isCanvasInteractionPaused, isPanning, effectiveTool, canvasBackgroundColor, @@ -2663,6 +2675,10 @@ export function ImageCanvasEditorView({ onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer, onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer, onOpenCharacterAnimationPanel: openCharacterAnimationPanel, + onPreviewModel3d: (layer: CanvasLayer) => { + setModel3dPreviewLayer(layer); + selectSingleLayer(layer.id); + }, onDownloadLayer: exportLayerImage, onPasteCanvasClipboard: pasteCanvasOrSystemClipboard, onCopyContextLayers: copyContextLayers, @@ -2714,6 +2730,10 @@ export function ImageCanvasEditorView({ layer: metadataLayer, onClose: () => setMetadataLayer(null), }} + model3dViewerProps={{ + layer: model3dPreviewLayer, + onClose: () => setModel3dPreviewLayer(null), + }} /> ) : null} + {(generateDialog?.mode === 'model3d-text-to-model' || + generateDialog?.mode === 'model3d-image-to-model') && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + {(generateDialog?.mode === 'audio-sound-effect' || generateDialog?.mode === 'audio-background-music') && generateDialog.composerOpen !== false && diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index c1b69ca4e..50272e3be 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -1537,6 +1537,48 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('routes a picked canvas image into the single 3D image-to-model slot', () => { + const sourceLayer = createLayer({ title: '参考图' }); + const videoLayer = createLayer({ id: 'layer-video', mediaType: 'video' }); + + const next = appendGenerationReference( + { + mode: 'model3d-image-to-model', + prompt: '', + status: 'failed', + errorMessage: '失败', + }, + sourceLayer, + ); + + expect(next).toMatchObject({ + mode: 'model3d-image-to-model', + status: 'idle', + composerOpen: true, + generationReferences: [ + expect.objectContaining({ + id: 'canvas-layer-source', + label: '参考图', + resourceId: 'resource-source', + }), + ], + }); + // 单图槽位:再点一张不会顶掉已选中的那张,用户先删再加。 + expect( + appendGenerationReference( + next, + createLayer({ id: 'layer-other' }), + )?.generationReferences?.map((reference) => reference.id), + ).toEqual(['canvas-layer-source']); + // 非图片图层不构成 3D 输入。 + expect( + appendGenerationReference( + { mode: 'model3d-image-to-model', prompt: '', status: 'idle' }, + videoLayer, + )?.generationReferences, + ).toBeUndefined(); + }); + it('routes picked canvas references to spec, image, video, and UI design fields', () => { const sourceLayer = createLayer({ title: '参考图' }); const videoLayer = createLayer({ diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c6a5fb0f..3004ae7f5 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -55,8 +55,14 @@ import { resolveEditorImageGenerationPixelSize, resolveEditorVideoGenerationPixelSize, SPEC_FRAME_ORIGINAL_SIZE, + usesSingleImageReferenceSlot, } from './ImageCanvasGenerationModel'; import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; +import { + createModel3dGenerationDraftFields, + MODEL3D_GENERATION_FRAME_SIZE, + type Model3dGenerationMode, +} from './model3d-generation/Model3dGenerationFormModel'; import { isProjectAssetPickerReference } from './projectAssetReferencePickerModel'; type CanvasSize = { width: number; height: number }; @@ -505,6 +511,30 @@ export function createSoundEffectGenerationDialogDraft({ }; } +export function createModel3dGenerationDialogDraft({ + canvasSize, + viewport, + mode, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + mode: Model3dGenerationMode; +}): Omit { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + const frameSize = MODEL3D_GENERATION_FRAME_SIZE; + return { + ...createModel3dGenerationDraftFields(mode), + placeholder: { + x: worldCenter.x - frameSize.width / 2, + y: worldCenter.y - frameSize.height / 2, + width: frameSize.width, + height: frameSize.height, + originalWidth: frameSize.width, + originalHeight: frameSize.height, + }, + }; +} + export function createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport, @@ -2183,7 +2213,8 @@ export function appendGenerationReference( dialog?.mode === 'scene' || dialog?.mode === 'quick-edit' || dialog?.mode === 'icon' || - dialog?.mode === 'ui-design' + dialog?.mode === 'ui-design' || + dialog?.mode === 'model3d-image-to-model' ) { if (getReferenceMediaType(layer) !== 'image') { return dialog; @@ -2225,16 +2256,20 @@ export function replaceProjectAssetPickerReferences( dialog.mode !== 'scene' && dialog.mode !== 'quick-edit' && dialog.mode !== 'icon' && - dialog.mode !== 'ui-design') + dialog.mode !== 'ui-design' && + dialog.mode !== 'model3d-image-to-model') ) { return dialog; } return { ...resetFailedGenerationDialog(dialog), generationReferences: appendLimitedImageReferences( - (dialog.generationReferences ?? []).filter( - (reference) => !isProjectAssetPickerReference(reference), - ), + // 单槽对话框(图生 3D)里素材库选择是替换:留着画布点选的那张只会把这次选择挤掉。 + usesSingleImageReferenceSlot(dialog) + ? [] + : (dialog.generationReferences ?? []).filter( + (reference) => !isProjectAssetPickerReference(reference), + ), references, resolveDialogExtraImageReferenceLimit(dialog), ), diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 5a08c5c05..ad6692869 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -69,6 +69,16 @@ describe('ImageCanvasGenerationModel', () => { }); }); + it('caps the 3D image-to-model dialog at a single reference', () => { + expect( + resolveDialogExtraImageReferenceLimit({ + mode: 'model3d-image-to-model', + prompt: '', + status: 'idle', + }), + ).toBe(1); + }); + it('uses the fixed spec model provider limit for multi-reference spec dialogs', () => { expect( resolveDialogExtraImageReferenceLimit({ diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 4929dcedd..b1209eb64 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -462,11 +462,23 @@ export function resolveExtraImageReferenceLimit( ); } +/** + * 只有一个参考图槽位的对话类型:图生 3D 的输入是契约里的单个 `source`。 + * 这类对话框里「再从素材库选一张」是替换而不是追加。 + */ +export function usesSingleImageReferenceSlot(dialog: GenerateDialogState) { + return dialog.mode === 'model3d-image-to-model'; +} + export function resolveDialogExtraImageReferenceLimit( dialog: GenerateDialogState, model: string | null | undefined = dialog.imageModel ?? (dialog.mode === 'spec' ? SPEC_GENERATION_MODEL : undefined), ) { + // 图生 3D 的输入是契约里的单个 `source`,面板也只给一个槽位。 + if (usesSingleImageReferenceSlot(dialog)) { + return 1; + } if (dialog.mode === 'icon') { return resolveExtraImageReferenceLimit( model, @@ -794,6 +806,9 @@ export function getLayerKindLabel(layer: CanvasLayer) { if (layer.assetKind === 'scene') { return '游戏场景'; } + if (layer.assetKind === 'model3d') { + return '3D模型'; + } if (layer.assetKind === 'video' || layer.mediaType === 'video') { return '视频'; } @@ -1051,9 +1066,26 @@ export function applyEditorGenerationPricingConfig( ) { runtimeEditorGenerationPricingConfig = { models: cloneModelPricing(pricing.models), + // 3D 段刻意没有内置兜底:段缺失就是段缺失,入口据此判不可提交,不拿旧数值顶上。 + ...(pricing.model3d + ? { + model3d: { + basePrices: pricing.model3d.basePrices + ? { ...pricing.model3d.basePrices } + : null, + addOnPrices: pricing.model3d.addOnPrices + ? { ...pricing.model3d.addOnPrices } + : null, + }, + } + : {}), }; } +export function readEditorModel3dPricingConfig() { + return runtimeEditorGenerationPricingConfig.model3d ?? null; +} + function cloneModelPricing( value: T, ): EditorGenerationPricingConfig['models'] { @@ -1636,6 +1668,11 @@ export function canOpenRedrawPanel(layer: CanvasLayer) { if (layer.taskId?.startsWith('pixel-art-snap-')) { return false; } + // 3D 生成产物没有「基于本图重绘」语义:画布上的是预览图,模型本体不在 + // 图片生成链路的输入里。 + if (layer.assetKind === 'model3d') { + return false; + } const action = layer.generationInputs?.action; if (action !== undefined) { return isRemixableCanvasGenerationAction(action); @@ -2288,7 +2325,9 @@ export function isCanvasGenerationDialog( dialog.mode === 'character-animation' || dialog.mode === 'video' || dialog.mode === 'audio-sound-effect' || - dialog.mode === 'audio-background-music'), + dialog.mode === 'audio-background-music' || + dialog.mode === 'model3d-text-to-model' || + dialog.mode === 'model3d-image-to-model'), ); } @@ -2304,6 +2343,12 @@ export function getGenerationFrameAriaLabel( if (dialog.mode === 'spec') { return '规范生成占位图'; } + if ( + dialog.mode === 'model3d-text-to-model' || + dialog.mode === 'model3d-image-to-model' + ) { + return '3D 模型生成占位图'; + } if (dialog.mode === 'icon') { return '图标素材生成占位图'; } diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index a2f6a607e..4340bb967 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -60,6 +60,7 @@ function renderSelectedToolbar( onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), + onPreviewModel3d: vi.fn(), onDownloadLayer: vi.fn(), ...overrides, }; @@ -743,6 +744,75 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy(); }); + it('shows the 3D preview action for a model3d layer with a model object', () => { + const layer = createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + const props = renderSelectedToolbar({ selectedLayer: layer }); + + const previewButton = screen.getByRole('button', { name: '3D 预览' }); + expect(previewButton.getAttribute('title')).toBe('在 3D 查看器中预览'); + expect((previewButton as HTMLButtonElement).disabled).toBe(false); + + fireEvent.click(previewButton); + expect(props.onPreviewModel3d).toHaveBeenCalledWith(layer); + }); + + it('keeps the 3D preview action clickable when the object key extension is unknown', () => { + // 画布不判格式:判不出来是查看器读模型时报的错,工具条不替它拦人。 + renderSelectedToolbar({ + selectedLayer: createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.obj', + }), + }); + + const previewButton = screen.getByRole('button', { name: '3D 预览' }); + expect(previewButton.getAttribute('title')).toBe('在 3D 查看器中预览'); + expect((previewButton as HTMLButtonElement).disabled).toBe(false); + }); + + it('keeps the 3D preview action visible but disabled without a model file', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ assetKind: 'model3d' }), + }); + + expect( + ( + screen.getByRole('button', { + name: '3D 预览', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it('hides the 3D preview action when the host does not declare the capability', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }), + supportedActions: new Set(['quick-edit', 'download']), + }); + + expect(screen.queryByRole('button', { name: '3D 预览' })).toBeNull(); + }); + + it('hides the raster editing group for model3d layers', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }), + }); + + expect(screen.queryByRole('button', { name: '裁扩按钮' })).toBeNull(); + expect(screen.queryByRole('button', { name: '去除背景按钮' })).toBeNull(); + expect(screen.queryByRole('button', { name: '完美像素' })).toBeNull(); + expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); + }); + it('renders nothing without a selected layer or toolbar position', () => { const { rerender } = render( { onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} + onPreviewModel3d={vi.fn()} onDownloadLayer={vi.fn()} />, ); @@ -782,6 +853,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} + onPreviewModel3d={vi.fn()} onDownloadLayer={vi.fn()} />, ); diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index f61909628..f48ed49b1 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,6 +1,7 @@ import { CanvasChromeButton } from '@genarrative/image-canvas-react'; import { OverflowActions } from '@genarrative/shared/components'; import { + Box, Crop, Download, Grid2X2, @@ -36,6 +37,7 @@ export type ImageCanvasSelectedToolbarAction = | 'split-icon-spritesheet' | 'extract-ui-design' | 'character-animation' + | 'preview-model3d' | 'download'; type ImageCanvasSelectedLayerToolbarViewProps = { @@ -67,6 +69,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; + onPreviewModel3d: (layer: CanvasLayer) => void; onDownloadLayer: (layer: CanvasLayer) => void; }; @@ -99,6 +102,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onSplitIconSpritesheet, onExtractUiDesignAssets, onOpenCharacterAnimationPanel, + onPreviewModel3d, onDownloadLayer, }: ImageCanvasSelectedLayerToolbarViewProps) { if (!selectedLayer || !selectedToolbarStyle) { @@ -173,7 +177,14 @@ export function ImageCanvasSelectedLayerToolbarView({ ); } + // 3D 资源的图层画的是预览图,像素级编辑改不到模型本体,整组栅格编辑不适用。 + const isModel3dLayer = selectedLayer.assetKind === 'model3d'; + // 「能不能预览」不在工具条判:这里只看有没有模型对象(资源身份,由后端写入保证), + // 模型格式与可解析性交给查看器读响应与字节判定,判不出来在模态里说原因。 + const hasModel3dSource = + isModel3dLayer && Boolean(selectedLayer.objectKey?.trim()); const canRasterEdit = + !isModel3dLayer && selectedLayer.mediaType !== 'video' && selectedLayer.mediaType !== 'image-sequence' && selectedLayer.assetKind !== 'character-animation'; @@ -197,13 +208,16 @@ export function ImageCanvasSelectedLayerToolbarView({ const showExtractUiDesign = selectedLayer.assetKind === 'ui-design' && isActionSupported('extract-ui-design', true); + const showPreviewModel3d = + isModel3dLayer && isActionSupported('preview-model3d', true); const hasEditingActions = showCropExpand || showRemoveBackground || showPerfectPixel || showSplitIconSpritesheet || showExtractUiDesign || - showCharacterAnimation; + showCharacterAnimation || + showPreviewModel3d; return (
生成动画 ) : null} + {showPreviewModel3d ? ( + } + disabled={!hasModel3dSource} + onClick={() => onPreviewModel3d(selectedLayer)} + > + 3D 预览 + + ) : null} {canRedraw ? ( <> {showQuickEdit || hasEditingActions ? divider : null} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index cea8dfeab..e23ab7d38 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -51,6 +51,7 @@ export type ImageCanvasStageViewProps = { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; isInteractionPaused?: boolean; isPanning: boolean; effectiveTool: CanvasTool; @@ -170,6 +171,7 @@ export type ImageCanvasStageViewProps = { onQuickEditSelectionPointerMove: (point: { x: number; y: number }) => void; onQuickEditSelectionPointerEnd: () => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; + onPreviewModel3d: (layer: CanvasLayer) => void; onDownloadLayer: (layer: CanvasLayer) => void; onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void; onCopyContextLayers: (options?: { cut?: boolean }) => void; @@ -201,10 +203,10 @@ export type ImageCanvasStageViewProps = { onMinimapPointerDown: (event: ReactPointerEvent) => void; onSwitchTool: (tool: CanvasTool) => void; onOpenToolOptions: ( - tool: Extract, + tool: Extract, ) => void; onCloseToolOptions: ( - tool: Extract, + tool: Extract, ) => void; }; @@ -213,6 +215,7 @@ export function ImageCanvasStageView({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, isInteractionPaused = false, isPanning, effectiveTool, @@ -311,6 +314,7 @@ export function ImageCanvasStageView({ onQuickEditSelectionPointerMove, onQuickEditSelectionPointerEnd, onOpenCharacterAnimationPanel, + onPreviewModel3d, onDownloadLayer, onPasteCanvasClipboard, onCopyContextLayers, @@ -433,6 +437,7 @@ export function ImageCanvasStageView({ onSplitIconSpritesheet={onSplitIconSpritesheet} onExtractUiDesignAssets={onExtractUiDesignAssets} onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel} + onPreviewModel3d={onPreviewModel3d} onDownloadLayer={onDownloadLayer} /> @@ -562,6 +567,7 @@ export function ImageCanvasStageView({ specToolWrapRef={specToolWrapRef} musicToolWrapRef={musicToolWrapRef} publicationToolWrapRef={publicationToolWrapRef} + model3dToolWrapRef={model3dToolWrapRef} effectiveTool={effectiveTool} highlight={isToolbarGuideVisible} onSwitchTool={onSwitchTool} diff --git a/src/components/image-editor/ImageCanvasUploadModel.test.ts b/src/components/image-editor/ImageCanvasUploadModel.test.ts index 6119d5214..e77795164 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.test.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.test.ts @@ -696,6 +696,28 @@ describe('ImageCanvasUploadModel', () => { expect(layer.y).toBe(-7.5); }); + it('applies an uploaded image to the single 3D image-to-model slot', () => { + const reference = { + id: 'upload-generation-reference-0', + label: '参考图1', + src: 'data:image/png;base64,ref', + resourceId: 'resource-upload-1', + }; + + expect( + applyGenerationReferenceUpload({ + dialog: createDialog({ mode: 'model3d-image-to-model' }), + target: 'generation-reference', + references: [reference], + }), + ).toMatchObject({ + mode: 'model3d-image-to-model', + status: 'idle', + errorMessage: undefined, + generationReferences: [reference], + }); + }); + it('resizes upload canvas layers around the same canvas point', () => { const resizedLayer = resizeUploadCanvasLayerToImage({ layer: createLayer(), diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index 0528a0193..db802c554 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -250,7 +250,8 @@ export function applyGenerationReferenceUpload({ dialog?.mode === 'scene' || dialog?.mode === 'video' || dialog?.mode === 'icon' || - dialog?.mode === 'ui-design' + dialog?.mode === 'ui-design' || + dialog?.mode === 'model3d-image-to-model' ? { ...setFailedGenerationIdle(dialog), generationReferences: diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index bd03cd7ab..7b6405d1f 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -825,8 +825,9 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) - .parentElement! - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + .parentElement!.style.getPropertyValue( + '--image-canvas-editor-inverse-scale', + ), ).toBe(inverseScale); expect( ( @@ -1193,6 +1194,82 @@ describe('ImageCanvasWorldView', () => { ); }); + it('keeps the 3D model label inside its own media family', () => { + const imageLayer = createLayer({ + resourceAssetKind: null, + assetKind: 'character', + }); + const { unmount } = renderWorldView({ layers: [imageLayer] }); + + fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' })); + + const modelItem = within( + screen.getByRole('menu', { name: '选择素材标签' }), + ).getByRole('menuitem', { name: '3D模型' }); + expect(modelItem.hasAttribute('disabled')).toBe(true); + expect(modelItem.title).toBe('该标签与素材的媒体类型不兼容'); + + unmount(); + + const modelLayer = createLayer({ + resourceAssetKind: 'model3d', + assetKind: 'model3d', + src: 'generated/editor/model3d/task-1/preview.webp', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + renderWorldView({ layers: [modelLayer] }); + + fireEvent.click(screen.getByRole('button', { name: '素材标签:3D模型' })); + + const modelMenu = screen.getByRole('menu', { name: '选择素材标签' }); + expect( + within(modelMenu) + .getByRole('menuitem', { name: '3D模型' }) + .hasAttribute('disabled'), + ).toBe(false); + expect( + within(modelMenu) + .getByRole('menuitem', { name: '角色' }) + .hasAttribute('disabled'), + ).toBe(true); + expect( + within(modelMenu) + .getByRole('menuitem', { name: '动作' }) + .hasAttribute('disabled'), + ).toBe(true); + }); + + it('draws a 3D placeholder instead of loading the model file as an image', () => { + const layer = createLayer({ + assetKind: 'model3d', + title: '三维角色', + src: 'generated/editor/model3d/task-1/model.glb', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + renderWorldView({ layers: [layer] }); + + expect(screen.queryByAltText('画布图片:三维角色')).toBeNull(); + expect( + screen.getByRole('button', { name: '素材标签:3D模型' }), + ).toBeTruthy(); + }); + + it('renders the model3d preview image without resolving the model object key', () => { + const layer = createLayer({ + assetKind: 'model3d', + title: '三维角色', + src: 'generated/editor/model3d/task-1/preview.webp', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + renderWorldView({ layers: [layer] }); + + expect(screen.getByAltText('画布图片:三维角色')).toBeTruthy(); + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( + 'generated/editor/model3d/task-1/preview.webp', + expect.objectContaining({ objectKey: null }), + ); + }); + it('hides resolution and duration text for focused audio generation frames', () => { const dialog = createGenerationDialog({ id: 'dialog-audio', diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a9e1e9622..a2283a39d 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -6,6 +6,7 @@ import { import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, + Box, Clapperboard, ClipboardList, Grid2X2, @@ -71,6 +72,7 @@ import { getCanvasMediaPreviewMarker, } from './ImageCanvasMediaModel'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; +import { isModel3dFileSource } from './model3d-preview/Model3dViewerModel'; function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) { switch (dialog.mode) { @@ -213,45 +215,103 @@ function getLayerMetadataMediaLabel(layer: CanvasLayer) { if (layer.mediaType === 'image-sequence') { return '序列帧'; } + // 3D 资源的画布卡片是预览图,但资源语义是模型,信息入口不能按图片命名。 + if (layer.assetKind === 'model3d') { + return '3D模型'; + } return '图片'; } +/** + * 类型角标的样式类。 + * + * 每一项直接给出完整类名而不是"修饰符名":既有类别继续用 `index.css` 里已有的 + * `image-canvas-editor__kind-badge--*` 规则,新增类别可以直接写 Tailwind 工具类, + * 不必为了一个颜色再往全局样式表里加规则。`!` 前缀是必需的 —— 手写规则不在 + * Tailwind 的 utilities 层里,层外的声明永远压过层内工具类。 + */ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{ value: CanvasAssetKind | null; label: string; - className: string; + badgeClassName: string; }> = [ - { value: null, label: '未知', className: 'unknown' }, - { value: 'spec', label: '规范', className: 'spec' }, - { value: 'character', label: '角色', className: 'character' }, + { + value: null, + label: '未知', + badgeClassName: 'image-canvas-editor__kind-badge--unknown', + }, + { + value: 'spec', + label: '规范', + badgeClassName: 'image-canvas-editor__kind-badge--spec', + }, + { + value: 'character', + label: '角色', + badgeClassName: 'image-canvas-editor__kind-badge--character', + }, { value: 'character-animation', label: '动作', - className: 'character-animation', + badgeClassName: 'image-canvas-editor__kind-badge--character-animation', + }, + { + value: 'icon', + label: '图标', + badgeClassName: 'image-canvas-editor__kind-badge--icon', + }, + { + value: 'icon-spritesheet', + label: '图集', + badgeClassName: 'image-canvas-editor__kind-badge--icon-spritesheet', + }, + { + value: 'icon-spec', + label: '图标规范', + badgeClassName: 'image-canvas-editor__kind-badge--icon-spec', }, - { value: 'icon', label: '图标', className: 'icon' }, - { value: 'icon-spritesheet', label: '图集', className: 'icon-spritesheet' }, - { value: 'icon-spec', label: '图标规范', className: 'icon-spec' }, { value: 'publication-material', label: '宣发素材', - className: 'publication-material', + badgeClassName: 'image-canvas-editor__kind-badge--publication-material', + }, + { + value: 'ui-design', + label: 'UI设计', + badgeClassName: 'image-canvas-editor__kind-badge--ui-design', + }, + { + value: 'scene', + label: '场景', + badgeClassName: 'image-canvas-editor__kind-badge--scene', + }, + { + value: 'video', + label: '视频', + badgeClassName: 'image-canvas-editor__kind-badge--video', + }, + { + value: 'model3d', + label: '3D模型', + badgeClassName: + '!bg-[rgba(30,64,175,0.94)] !shadow-[0_8px_18px_rgba(30,64,175,0.26)]', + }, + { + value: 'sound-effect', + label: '音效', + badgeClassName: 'image-canvas-editor__kind-badge--sound-effect', }, - { value: 'ui-design', label: 'UI设计', className: 'ui-design' }, - { value: 'scene', label: '场景', className: 'scene' }, - { value: 'video', label: '视频', className: 'video' }, - { value: 'sound-effect', label: '音效', className: 'sound-effect' }, { value: 'background-music', label: '背景音乐', - className: 'background-music', + badgeClassName: 'image-canvas-editor__kind-badge--background-music', }, ]; const UNKNOWN_ASSET_KIND_TAG_OPTION = CANVAS_ASSET_KIND_TAG_OPTIONS[0] as { value: null; label: string; - className: string; + badgeClassName: string; }; function getLayerKindTagMeta(layer: CanvasLayer) { @@ -672,14 +732,17 @@ function ImageCanvasVideoLayer({ function ImageCanvasImageLayer({ layer, mediaTransform, + objectKey = layer.objectKey, }: { layer: CanvasLayer; mediaTransform?: string; + /** 解析签名地址时使用的对象引用;3D 资源必须置空,否则会去读模型本体。 */ + objectKey?: string | null; }) { const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl( layer.src, { - objectKey: layer.objectKey, + objectKey, refreshKey: layer.taskId ?? layer.resourceId, }, ); @@ -723,6 +786,44 @@ function ImageCanvasImageLayer({ ); } +/** + * 3D 资源卡片:只画预览图,模型本体交给 3D 查看器。 + * + * 预览必须单独按 `layer.src` 取签名地址(`objectKey` 是模型本体,带上它会把 `.glb` + * 当图片交给 ``)。预览图缺失时后端把 `src` 回落到模型对象,这里识别出来只画 + * 占位,不去请求模型文件。 + */ +function ImageCanvasModel3dLayer({ + layer, + mediaTransform, +}: { + layer: CanvasLayer; + mediaTransform?: string; +}) { + const hasPreview = + Boolean(layer.src?.trim()) && !isModel3dFileSource(layer.src); + + if (hasPreview) { + return ( + + ); + } + + return ( + + ); +} + function getLayerImageSequenceFrames(layer: CanvasLayer) { return layer.imageSequenceFrames ?? []; } @@ -994,6 +1095,11 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ layer={layer} mediaTransform={mediaTransform} /> + ) : layer.assetKind === 'model3d' ? ( + ) : ( diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx b/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx new file mode 100644 index 000000000..a830daff5 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx @@ -0,0 +1,217 @@ +import { ImageIcon } from 'lucide-react'; +import type { + CSSProperties, + Dispatch, + ReactNode, + RefObject, + SetStateAction, +} from 'react'; + +import { AutoGrowTextArea } from '../../common/AutoGrowTextArea'; +import { + PlatformFloatingMenu, + PlatformFloatingMenuItem, +} from '../../common/PlatformFloatingMenu'; +import { PlatformInlineOptionButton } from '../../common/PlatformInlineOptionButton'; +import type { + CharacterReferenceImage, + GenerateDialogState, + UploadTarget, +} from '../ImageCanvasEditorTypes'; +import { ImageCanvasReferenceSlot } from '../ImageCanvasReferenceSlot'; +import { + applyModel3dPromptChange, + applyModel3dReferenceRemoval, + applyModel3dSwitchChange, + applyModel3dTierChange, + MODEL3D_GENERATION_TIERS, + MODEL3D_SWITCH_OPTIONS, + MODEL3D_TEXT_PROMPT_MAX_LENGTH, +} from './Model3dGenerationFormModel'; + +type Model3dGenerationFormProps = { + dialog: GenerateDialogState; + disabled: boolean; + updateDialog: ( + updater: (dialog: GenerateDialogState) => GenerateDialogState, + ) => void; + generationReferenceButtonRef?: RefObject; + isGenerationReferenceMenuOpen?: boolean; + setIsGenerationReferenceMenuOpen?: Dispatch>; + setIsPickingGenerationReferenceFromCanvas?: Dispatch>; + onRequestUpload: (target: UploadTarget) => void; + onRequestProjectAssetPicker?: () => void; + renderEditorPortal: (node: ReactNode) => ReactNode; + buildPortalMenuStyle: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; +}; + +export function Model3dGenerationForm({ + dialog, + disabled, + updateDialog, + generationReferenceButtonRef, + isGenerationReferenceMenuOpen = false, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, + onRequestUpload, + onRequestProjectAssetPicker, + renderEditorPortal, + buildPortalMenuStyle, +}: Model3dGenerationFormProps) { + const isImageMode = dialog.mode === 'model3d-image-to-model'; + const references = dialog.generationReferences ?? []; + const tier = dialog.model3dTier ?? 'textured-standard'; + const isTextureTier = tier !== 'geometry-only'; + const toggleSwitch = ( + field: 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts', + checked: boolean, + ) => { + updateDialog((currentDialog) => + applyModel3dSwitchChange(currentDialog, { [field]: checked }), + ); + }; + const removeReference = (reference: CharacterReferenceImage) => { + updateDialog((currentDialog) => + applyModel3dReferenceRemoval(currentDialog, reference.id), + ); + }; + + return ( + <> + {isImageMode ? ( +
+ {references.map((reference) => ( +
+ ) : ( + + updateDialog((currentDialog) => + applyModel3dPromptChange(currentDialog, value), + ) + } + /> + )} +
+
+ {MODEL3D_GENERATION_TIERS.map((option) => ( + + updateDialog((currentDialog) => + applyModel3dTierChange(currentDialog, option.value), + ) + } + > + {option.label} + + ))} +
+ {MODEL3D_SWITCH_OPTIONS.map((option) => { + const blocked = + option.field === 'model3dGenerateParts' && isTextureTier; + return ( + + ); + })} +
+ {isImageMode && + isGenerationReferenceMenuOpen && + generationReferenceButtonRef + ? renderEditorPortal( + + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(true); + }} + > + 从画布中选择 + + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestUpload('generation-reference'); + }} + > + 上传图片 + + {onRequestProjectAssetPicker ? ( + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestProjectAssetPicker(); + }} + > + 从项目素材中选择 + + ) : null} + , + ) + : null} + + ); +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts new file mode 100644 index 000000000..8b6815d0d --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest'; + +import type { Model3dTextureQuality } from '../../../../packages/shared/src/contracts/model3d'; +import type { + Model3dAddOnPriceKey, + Model3dPricingConfig, +} from '../../../services/image-editor/editorProjectClient'; +import type { GenerateDialogState } from '../ImageCanvasEditorTypes'; +import { + applyModel3dPromptChange, + applyModel3dReferenceRemoval, + applyModel3dSwitchChange, + applyModel3dTierChange, + createModel3dGenerationDraftFields, + mintModel3dAttemptNonce, + MODEL3D_PRICING_UNAVAILABLE_REASON, + type Model3dGenerationMode, + refreshModel3dAttemptNonce, + resolveModel3dAddOnKeys, + resolveModel3dGenerationParams, + resolveModel3dGenerationQuote, + resolveModel3dPrice, +} from './Model3dGenerationFormModel'; + +function createDialog( + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields('model3d-text-to-model'), + ...overrides, + } as GenerateDialogState; +} + +/** + * 与后端 `api-server::tripo3d::pricing::model3d_add_ons` 逐条对齐的期望值: + * 前端只有这一份映射,价格差异必须在这里被用例挡住。 + */ +function expectedAddOns({ + texture, + textureQuality, + geometryQuality, + quad, + smartLowPoly, + generateParts, +}: { + texture: boolean; + textureQuality: Model3dTextureQuality; + geometryQuality: 'standard' | 'detailed'; + quad: boolean; + smartLowPoly: boolean; + generateParts: boolean; +}): Model3dAddOnPriceKey[] { + return [ + ...(texture && textureQuality === 'detailed' ? ['hdTexture' as const] : []), + ...(texture && textureQuality === 'extreme' + ? ['ultraTexture' as const] + : []), + ...(geometryQuality === 'detailed' ? ['hdGeometry' as const] : []), + ...(quad ? ['quadMesh' as const] : []), + ...(smartLowPoly ? ['smartLowPoly' as const] : []), + ...(generateParts ? ['generateParts' as const] : []), + ]; +} + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { + 'v3.1-20260211': { noTexture: 100, texture: 200 }, + }, + 'image-to-model': { + 'v3.1-20260211': { noTexture: 150, texture: 250 }, + }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +describe('resolveModel3dGenerationParams', () => { + it('每个档位映射成完整参数,档位与开关不留给 provider 隐式默认', () => { + expect( + resolveModel3dGenerationParams({ + tier: 'textured-standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }), + ).toEqual({ + ok: true, + params: { + texture: true, + textureQuality: 'standard', + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: false, + pbr: false, + }, + }); + expect( + resolveModel3dGenerationParams({ + tier: 'textured-detailed', + quad: true, + smartLowPoly: true, + generateParts: false, + }), + ).toEqual({ + ok: true, + params: { + texture: true, + textureQuality: 'detailed', + geometryQuality: 'detailed', + quad: true, + smartLowPoly: true, + generateParts: false, + pbr: false, + }, + }); + }); + + it('纯几何档位没有贴图档位,pbr 显式为 false', () => { + const result = resolveModel3dGenerationParams({ + tier: 'geometry-only', + quad: false, + smartLowPoly: false, + generateParts: true, + }); + expect(result.ok).toBe(true); + expect(result.ok && result.params).toMatchObject({ + texture: false, + textureQuality: null, + geometryQuality: 'standard', + generateParts: true, + pbr: false, + }); + }); + + it('贴图档位不允许分件,这是后端也会拒的组合', () => { + expect( + resolveModel3dGenerationParams({ + tier: 'textured-standard', + quad: false, + smartLowPoly: false, + generateParts: true, + }).ok, + ).toBe(false); + expect( + resolveModel3dGenerationParams({ + tier: 'textured-detailed', + quad: false, + smartLowPoly: false, + generateParts: true, + }).ok, + ).toBe(false); + }); +}); + +describe('resolveModel3dAddOnKeys', () => { + it('与后端 add-on 判定在全部档位与开关组合上一致', () => { + for (const tier of [ + 'textured-standard', + 'textured-detailed', + 'geometry-only', + ] as const) { + for (const quad of [false, true]) { + for (const smartLowPoly of [false, true]) { + for (const generateParts of [false, true]) { + const result = resolveModel3dGenerationParams({ + tier, + quad, + smartLowPoly, + generateParts, + }); + if (!result.ok) { + continue; + } + expect(resolveModel3dAddOnKeys(result.params)).toEqual( + expectedAddOns({ + texture: result.params.texture, + textureQuality: result.params.textureQuality ?? 'standard', + geometryQuality: result.params.geometryQuality, + quad, + smartLowPoly, + generateParts, + }), + ); + } + } + } + } + }); +}); + +describe('resolveModel3dPrice', () => { + const paramsOf = ( + tier: 'textured-standard' | 'textured-detailed' | 'geometry-only', + switches: { + quad?: boolean; + smartLowPoly?: boolean; + generateParts?: boolean; + } = {}, + ) => { + const result = resolveModel3dGenerationParams({ + tier, + quad: switches.quad ?? false, + smartLowPoly: switches.smartLowPoly ?? false, + generateParts: switches.generateParts ?? false, + }); + if (!result.ok) { + throw new Error('档位组合必须合法'); + } + return result.params; + }; + + it('底价按端点与是否有贴图取,add-on 逐项相加', () => { + expect( + resolveModel3dPrice({ + pricing: PRICING, + endpoint: 'image-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('textured-standard'), + }), + ).toEqual({ ok: true, price: 250 }); + expect( + resolveModel3dPrice({ + pricing: PRICING, + endpoint: 'text-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('geometry-only'), + }), + ).toEqual({ ok: true, price: 100 }); + }); + + it('缺段、缺底价、缺 add-on 一律不可提交,不按 0 放行', () => { + expect( + resolveModel3dPrice({ + pricing: null, + endpoint: 'text-to-model', + params: paramsOf('textured-standard'), + modelVersion: 'v3.1-20260211', + }), + ).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON }); + expect( + resolveModel3dPrice({ + pricing: { ...PRICING, basePrices: null }, + endpoint: 'text-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('textured-standard'), + }).ok, + ).toBe(false); + expect( + resolveModel3dPrice({ + // 纯几何 + 分件只叠 generateParts 一个 add-on,缺的就是它。 + pricing: { ...PRICING, addOnPrices: { quadMesh: 40 } }, + endpoint: 'text-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('geometry-only', { generateParts: true }), + }), + ).toEqual({ ok: false, reason: '3D 模型定价缺少 generateParts 价格' }); + expect( + resolveModel3dPrice({ + pricing: PRICING, + endpoint: 'text-to-model', + modelVersion: 'v3.0-20250812', + params: paramsOf('textured-standard'), + }).ok, + ).toBe(false); + }); +}); + +describe('resolveModel3dGenerationQuote', () => { + it('文生与图生各取自己的端点价格', () => { + expect(resolveModel3dGenerationQuote(createDialog(), PRICING)).toEqual({ + ok: true, + price: 200, + endpoint: 'text-to-model', + }); + expect( + resolveModel3dGenerationQuote( + createDialog({ mode: 'model3d-image-to-model' }), + PRICING, + ), + ).toEqual({ ok: true, price: 250, endpoint: 'image-to-model' }); + }); + + it('定价段缺失时两个子项都拿不到价格', () => { + for (const mode of [ + 'model3d-text-to-model', + 'model3d-image-to-model', + ] as Model3dGenerationMode[]) { + expect( + resolveModel3dGenerationQuote(createDialog({ mode }), null), + ).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON }); + } + }); +}); + +describe('3D 生成面板的参数变更', () => { + it('换档位时贴图档位强制关掉分件,并换代次', () => { + const geometryDialog = createDialog({ + model3dTier: 'geometry-only', + model3dGenerateParts: true, + }); + const textured = applyModel3dTierChange( + geometryDialog, + 'textured-detailed', + ); + expect(textured.model3dGenerateParts).toBe(false); + expect(textured.model3dAttemptNonce).not.toBe( + geometryDialog.model3dAttemptNonce, + ); + + const back = applyModel3dTierChange(textured, 'geometry-only'); + expect(back.model3dTier).toBe('geometry-only'); + expect(back.model3dAttemptNonce).not.toBe(textured.model3dAttemptNonce); + }); + + it('改开关、改提示词、主动重试都会换代次,并清掉失败态', () => { + const failedDialog = createDialog({ + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + }); + expect( + applyModel3dSwitchChange(failedDialog, { model3dQuad: true }), + ).toMatchObject({ model3dQuad: true, status: 'idle' }); + expect( + applyModel3dSwitchChange(failedDialog, { model3dQuad: true }) + .model3dAttemptNonce, + ).not.toBe(failedDialog.model3dAttemptNonce); + expect(applyModel3dPromptChange(failedDialog, '一把木椅')).toMatchObject({ + prompt: '一把木椅', + status: 'idle', + errorMessage: undefined, + }); + expect( + applyModel3dPromptChange(failedDialog, '一把木椅').model3dAttemptNonce, + ).not.toBe(failedDialog.model3dAttemptNonce); + expect(refreshModel3dAttemptNonce(failedDialog)).toMatchObject({ + status: 'idle', + errorMessage: undefined, + }); + expect( + refreshModel3dAttemptNonce(failedDialog).model3dAttemptNonce, + ).not.toBe(failedDialog.model3dAttemptNonce); + }); + + it('删掉参考图回到空槽位并清掉失败态', () => { + const dialog = createDialog({ + mode: 'model3d-image-to-model', + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + generationReferences: [ + { id: 'reference-1', label: '参考图', src: 'data:image/png;base64,1' }, + { id: 'reference-2', label: '参考图', src: 'data:image/png;base64,2' }, + ], + }); + + const next = applyModel3dReferenceRemoval(dialog, 'reference-1'); + + expect(next.generationReferences?.map((reference) => reference.id)).toEqual( + ['reference-2'], + ); + expect(next).toMatchObject({ status: 'idle', errorMessage: undefined }); + expect( + applyModel3dReferenceRemoval(next, 'reference-2').generationReferences, + ).toEqual([]); + }); + + it('每次铸造的代次互不相同', () => { + const nonces = new Set( + Array.from({ length: 20 }, () => mintModel3dAttemptNonce()), + ); + expect(nonces.size).toBe(20); + for (const nonce of nonces) { + expect(nonce).toMatch(/^model3d-[0-9a-zA-Z-]+$/); + } + }); +}); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts new file mode 100644 index 000000000..bc24041bf --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts @@ -0,0 +1,312 @@ +import type { + Model3dGeometryQuality, + Model3dModelVersion, + Model3dTextureQuality, +} from '../../../../packages/shared/src/contracts/model3d'; +import type { + Model3dAddOnPriceKey, + Model3dPricingConfig, + Model3dPricingEndpoint, +} from '../../../services/image-editor/editorProjectClient'; +import type { + CharacterReferenceImage, + GenerateDialogState, + Model3dGenerationTier, +} from '../ImageCanvasEditorTypes'; + +export type Model3dGenerationMode = + | 'model3d-text-to-model' + | 'model3d-image-to-model'; + +export const MODEL3D_GENERATION_MODES: readonly Model3dGenerationMode[] = [ + 'model3d-text-to-model', + 'model3d-image-to-model', +]; + +export function isModel3dGenerationMode( + mode: GenerateDialogState['mode'], +): mode is Model3dGenerationMode { + return (MODEL3D_GENERATION_MODES as readonly string[]).includes(mode); +} + +export const MODEL3D_GENERATION_DIALOG_LABEL = '3D 模型生成'; +export const MODEL3D_GENERATION_SUBMIT_LABEL = '生成 3D 模型'; +export const MODEL3D_PRICING_UNAVAILABLE_REASON = '3D 模型定价尚未配置'; + +/** + * 档位是唯一给用户看的参数面:每个档位固定映射成 provider 的完整贴图 / 几何组合, + * 用户不直接面对 textureQuality / geometryQuality 这类会改价的枚举。 + */ +export const MODEL3D_GENERATION_TIERS: ReadonlyArray<{ + value: Model3dGenerationTier; + label: string; +}> = [ + { value: 'textured-standard', label: '标准贴图' }, + { value: 'textured-detailed', label: '高清贴图' }, + { value: 'geometry-only', label: '纯几何' }, +]; + +export const MODEL3D_SWITCH_OPTIONS: ReadonlyArray<{ + field: 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts'; + label: string; +}> = [ + { field: 'model3dQuad', label: '方形布线' }, + { field: 'model3dSmartLowPoly', label: '智能低多边形' }, + { field: 'model3dGenerateParts', label: '分件' }, +]; + +export const MODEL3D_TEXT_PROMPT_MAX_LENGTH = 1024; +export const MODEL3D_IMAGE_PROMPT_MAX_LENGTH = 512; +export const MODEL3D_GENERATION_FRAME_SIZE = { width: 1024, height: 1024 }; +export const DEFAULT_MODEL3D_MODEL_VERSION: Model3dModelVersion = + 'v3.1-20260211'; + +export type Model3dGenerationParams = { + texture: boolean; + textureQuality: Model3dTextureQuality | null; + geometryQuality: Model3dGeometryQuality; + quad: boolean; + smartLowPoly: boolean; + generateParts: boolean; + pbr: false; +}; + +export function resolveModel3dEndpoint( + mode: Model3dGenerationMode, +): Model3dPricingEndpoint { + return mode === 'model3d-image-to-model' ? 'image-to-model' : 'text-to-model'; +} + +export function resolveModel3dGenerationParams({ + tier, + quad, + smartLowPoly, + generateParts, +}: { + tier: Model3dGenerationTier; + quad: boolean; + smartLowPoly: boolean; + generateParts: boolean; +}): + | { ok: true; params: Model3dGenerationParams } + | { ok: false; reason: string } { + const texture = tier !== 'geometry-only'; + if (texture && generateParts) { + return { ok: false, reason: '分件生成只支持纯几何档位' }; + } + return { + ok: true, + params: { + texture, + textureQuality: texture + ? tier === 'textured-detailed' + ? 'detailed' + : 'standard' + : null, + geometryQuality: tier === 'textured-detailed' ? 'detailed' : 'standard', + quad, + smartLowPoly, + generateParts, + pbr: false, + }, + }; +} + +/** + * 与后端 `model3d_add_ons` 逐条对齐的加价判定;这里是前端唯一一份实现。 + */ +export function resolveModel3dAddOnKeys( + params: Model3dGenerationParams, +): Model3dAddOnPriceKey[] { + const keys: Model3dAddOnPriceKey[] = []; + if (params.texture && params.textureQuality === 'detailed') { + keys.push('hdTexture'); + } + if (params.texture && params.textureQuality === 'extreme') { + keys.push('ultraTexture'); + } + if (params.geometryQuality === 'detailed') { + keys.push('hdGeometry'); + } + if (params.quad) { + keys.push('quadMesh'); + } + if (params.smartLowPoly) { + keys.push('smartLowPoly'); + } + if (params.generateParts) { + keys.push('generateParts'); + } + return keys; +} + +function readPrice(value: number | undefined) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** + * 价格只来自实时定价查询的 `model3d` 段:缺段、缺底价或缺 add-on 一律不可提交, + * 不回退内置数值、也不按 0 放行。 + */ +export function resolveModel3dPrice({ + pricing, + endpoint, + modelVersion, + params, +}: { + pricing: Model3dPricingConfig | null | undefined; + endpoint: Model3dPricingEndpoint; + modelVersion: Model3dModelVersion; + params: Model3dGenerationParams; +}): { ok: true; price: number } | { ok: false; reason: string } { + if (!pricing) { + return { ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON }; + } + const basePriceRow = pricing.basePrices?.[endpoint]?.[modelVersion]; + const basePrice = readPrice( + params.texture ? basePriceRow?.texture : basePriceRow?.noTexture, + ); + if (basePrice === null) { + return { ok: false, reason: '3D 模型定价缺少该档位底价' }; + } + let price = basePrice; + for (const key of resolveModel3dAddOnKeys(params)) { + const addOnPrice = readPrice(pricing.addOnPrices?.[key]); + if (addOnPrice === null) { + return { ok: false, reason: `3D 模型定价缺少 ${key} 价格` }; + } + price += addOnPrice; + } + return { ok: true, price }; +} + +export function resolveModel3dGenerationQuote( + dialog: GenerateDialogState, + pricing: Model3dPricingConfig | null | undefined, +): + | { ok: true; price: number; endpoint: Model3dPricingEndpoint } + | { ok: false; reason: string } { + if (!isModel3dGenerationMode(dialog.mode)) { + return { ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON }; + } + const paramsResult = resolveModel3dGenerationParams({ + tier: dialog.model3dTier ?? 'textured-standard', + quad: dialog.model3dQuad ?? false, + smartLowPoly: dialog.model3dSmartLowPoly ?? false, + generateParts: dialog.model3dGenerateParts ?? false, + }); + if (!paramsResult.ok) { + return { ok: false, reason: paramsResult.reason }; + } + const endpoint = resolveModel3dEndpoint(dialog.mode); + const priceResult = resolveModel3dPrice({ + pricing, + endpoint, + modelVersion: DEFAULT_MODEL3D_MODEL_VERSION, + params: paramsResult.params, + }); + return priceResult.ok + ? { ok: true, price: priceResult.price, endpoint } + : { ok: false, reason: priceResult.reason }; +} + +/** + * 尝试代次:一次「打开面板 / 改参数 / 主动重试」算一代。真正的 `Idempotency-Key` 由 + * `Model3dGenerationSubmission` 把它与请求内容指纹拼起来,所以这里不必穷举所有会改内容的入口。 + */ +export function mintModel3dAttemptNonce(): string { + const randomUuid = globalThis.crypto?.randomUUID; + const suffix = + typeof randomUuid === 'function' + ? randomUuid.call(globalThis.crypto) + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + return `model3d-${suffix}`; +} + +export function createModel3dGenerationDraftFields( + mode: Model3dGenerationMode, +) { + return { + mode, + prompt: '', + status: 'idle' as const, + composerOpen: true, + generationReferences: [] as CharacterReferenceImage[], + model3dTier: 'textured-standard' as Model3dGenerationTier, + model3dQuad: false, + model3dSmartLowPoly: false, + model3dGenerateParts: false, + model3dAttemptNonce: mintModel3dAttemptNonce(), + }; +} + +function resetFailedStatus(dialog: GenerateDialogState): GenerateDialogState { + return dialog.status === 'failed' + ? { ...dialog, status: 'idle', errorMessage: undefined } + : dialog; +} + +/** + * 参数一改就换代次:用户这次的意图已经变了,下一次提交属于新的一代。 + */ +function withNewAttemptNonce(dialog: GenerateDialogState): GenerateDialogState { + return { ...dialog, model3dAttemptNonce: mintModel3dAttemptNonce() }; +} + +export function applyModel3dTierChange( + dialog: GenerateDialogState, + tier: Model3dGenerationTier, +): GenerateDialogState { + return withNewAttemptNonce({ + ...resetFailedStatus(dialog), + model3dTier: tier, + model3dGenerateParts: + tier === 'geometry-only' ? (dialog.model3dGenerateParts ?? false) : false, + }); +} + +export function applyModel3dSwitchChange( + dialog: GenerateDialogState, + patch: Partial< + Pick< + GenerateDialogState, + 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts' + > + >, +): GenerateDialogState { + return withNewAttemptNonce({ ...resetFailedStatus(dialog), ...patch }); +} + +export function applyModel3dPromptChange( + dialog: GenerateDialogState, + prompt: string, +): GenerateDialogState { + return withNewAttemptNonce({ ...resetFailedStatus(dialog), prompt }); +} + +/** 删掉当前参考图:图生 3D 只有一个槽位,删掉即回到空槽位重新选。 */ +export function applyModel3dReferenceRemoval( + dialog: GenerateDialogState, + referenceId: string, +): GenerateDialogState { + return { + ...resetFailedStatus(dialog), + errorMessage: undefined, + generationReferences: (dialog.generationReferences ?? []).filter( + (reference) => reference.id !== referenceId, + ), + }; +} + +/** + * 用户主动重试:同键同请求只会拿回原来那个终态 operation,所以重试必须开新一代。 + */ +export function refreshModel3dAttemptNonce( + dialog: GenerateDialogState, +): GenerateDialogState { + return { + ...withNewAttemptNonce(dialog), + status: 'idle', + errorMessage: undefined, + }; +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationModal.test.tsx b/src/components/image-editor/model3d-generation/Model3dGenerationModal.test.tsx new file mode 100644 index 000000000..b4b57f8e2 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationModal.test.tsx @@ -0,0 +1,301 @@ +/* @vitest-environment jsdom */ + +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { type ReactNode, useRef, useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import { setPlainTextEditorValue } from '../../common/AutoGrowTextArea.test-utils'; +import type { + CharacterReferenceImage, + GenerateDialogState, + UploadTarget, +} from '../ImageCanvasEditorTypes'; +import { + applyEditorGenerationPricingConfig, + EDITOR_MODEL_MUD_POINT_CONFIG, +} from '../ImageCanvasGenerationModel'; +import { createModel3dGenerationDraftFields } from './Model3dGenerationFormModel'; +import { Model3dGenerationModal } from './Model3dGenerationModal'; + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } }, + 'image-to-model': { 'v3.1-20260211': { noTexture: 150, texture: 250 } }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +function applyPricing(model3d: Model3dPricingConfig | null) { + applyEditorGenerationPricingConfig({ + models: EDITOR_MODEL_MUD_POINT_CONFIG, + ...(model3d ? { model3d } : {}), + }); +} + +function createDialog( + mode: 'model3d-text-to-model' | 'model3d-image-to-model', + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields(mode), + ...overrides, + } as GenerateDialogState; +} + +function canvasReference( + overrides: Partial = {}, +): CharacterReferenceImage { + return { + id: 'reference-1', + src: '/generated-character-drafts/editor/ref.webp', + label: '参考图', + resourceId: 'resource-1', + ...overrides, + }; +} + +function Model3dModalHarness({ + initialDialog, + onSubmit = vi.fn(), + onRequestUpload = vi.fn(), + renderEditorPortal = (node: ReactNode) => node, +}: { + initialDialog: GenerateDialogState; + onSubmit?: (dialog: GenerateDialogState) => void; + onRequestUpload?: (target: UploadTarget) => void; + renderEditorPortal?: (node: ReactNode) => ReactNode; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false); + const [isPickingReference, setIsPickingReference] = useState(false); + const referenceButtonRef = useRef(null); + + return ( +
+ {dialog ? ( + [0]['setGenerateDialog'] + } + generationReferenceButtonRef={referenceButtonRef} + isGenerationReferenceMenuOpen={isReferenceMenuOpen} + setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen} + setIsPickingGenerationReferenceFromCanvas={setIsPickingReference} + onRequestUpload={onRequestUpload} + renderEditorPortal={renderEditorPortal} + onSubmit={onSubmit} + /> + ) : null} + {dialog?.model3dTier ?? '-'} + + {String(dialog?.model3dGenerateParts)} + + + {dialog?.generationReferences?.length ?? 0} + + + {isPickingReference ? 'picking' : 'idle'} + + + {dialog?.model3dAttemptNonce ?? '-'} + +
+ ); +} + +function submitButton() { + return screen.getByRole('button', { name: '生成 3D 模型' }); +} + +/** 面板内的查询:harness 的 `` 也是 role=status,不能直接按全局角色查。 */ +function panel() { + return within(screen.getByRole('dialog', { name: '3D 模型生成' })); +} + +describe('Model3dGenerationModal', () => { + beforeEach(() => { + applyPricing(PRICING); + }); + + afterEach(() => { + cleanup(); + }); + + it('有定价与描述时展示泥点价格并可提交', () => { + const onSubmit = vi.fn(); + render( + , + ); + + expect(within(submitButton()).getByText('200泥点')).toBeTruthy(); + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(submitButton()); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('描述为空时不可提交', () => { + render( + , + ); + // 价格只取决于参数,不取决于输入是否齐备;缺的是描述,因此按钮不可点但价格照常展示。 + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + expect(within(submitButton()).getByText('200泥点')).toBeTruthy(); + }); + + it('输入描述后可提交,且改描述会换代次', () => { + render( + , + ); + const before = screen.getByLabelText('当前代次').textContent; + + setPlainTextEditorValue(screen.getByLabelText('模型描述'), '一把木椅'); + + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByLabelText('当前代次').textContent).not.toBe(before); + }); + + it('定价段缺失时展示原因、不发请求,也不显示兜底价格', () => { + applyPricing(null); + const onSubmit = vi.fn(); + render( + , + ); + + expect(panel().getByRole('alert').textContent).toContain( + '3D 模型定价尚未配置', + ); + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + expect(within(submitButton()).getByText('—')).toBeTruthy(); + fireEvent.click(submitButton()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('图生 3D 没有参考图时不可提交,选中后按单图定价', () => { + render( + , + ); + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(screen.getByLabelText('添加参考图')); + expect(screen.getByLabelText('参考图来源')).toBeTruthy(); + fireEvent.click(screen.getByText('从画布中选择')); + expect(screen.getByLabelText('画布选择参考图').textContent).toBe('picking'); + }); + + it('已有参考图时展示图片与删除入口,删除后回到空槽位', () => { + render( + , + ); + + expect(screen.getByLabelText('当前参考图数量').textContent).toBe('1'); + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + expect(within(submitButton()).getByText('250泥点')).toBeTruthy(); + + fireEvent.click(screen.getByLabelText('删除参考图')); + expect(screen.getByLabelText('当前参考图数量').textContent).toBe('0'); + expect(screen.getByLabelText('添加参考图')).toBeTruthy(); + }); + + it('切到纯几何档位后可开分件,切回贴图档位自动关掉', () => { + render( + , + ); + const generateParts = screen.getByLabelText('分件') as HTMLInputElement; + expect(generateParts.disabled).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: '纯几何' })); + expect(screen.getByLabelText('当前档位').textContent).toBe('geometry-only'); + expect((screen.getByLabelText('分件') as HTMLInputElement).disabled).toBe( + false, + ); + + fireEvent.click(screen.getByLabelText('分件')); + expect(screen.getByLabelText('当前分件').textContent).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '高清贴图' })); + expect(screen.getByLabelText('当前分件').textContent).toBe('false'); + // 底价 200 + 高清贴图 10 + 高清几何 30。 + expect(within(submitButton()).getByText('240泥点')).toBeTruthy(); + }); + + it('生成中禁用表单只展示生成中,失败后展示原因与重试', () => { + const onSubmit = vi.fn(); + const { unmount } = render( + , + ); + expect(panel().getByRole('status').textContent).toContain('生成中'); + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + unmount(); + + const failedDialog = createDialog('model3d-text-to-model', { + prompt: '一把木椅', + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + }); + render( + , + ); + + expect(panel().getByRole('alert').textContent).toContain( + '3D 模型生成失败,请稍后重试。', + ); + fireEvent.click(screen.getByRole('button', { name: '重试生成 3D 模型' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + // 重试必须开新一代,否则同键同请求只会拿回原来那个终态 operation。 + expect(onSubmit.mock.calls[0][0].model3dAttemptNonce).not.toBe( + failedDialog.model3dAttemptNonce, + ); + expect(onSubmit.mock.calls[0][0].model3dAttemptNonce).toBeTruthy(); + }); +}); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationModal.tsx b/src/components/image-editor/model3d-generation/Model3dGenerationModal.tsx new file mode 100644 index 000000000..6ce576bab --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationModal.tsx @@ -0,0 +1,178 @@ +import type { + CSSProperties, + Dispatch, + ReactNode, + RefObject, + SetStateAction, +} from 'react'; + +import { PlatformActionButton } from '../../common/PlatformActionButton'; +import { PlatformStatusMessage } from '../../common/PlatformStatusMessage'; +import type { + GenerateDialogState, + UploadTarget, +} from '../ImageCanvasEditorTypes'; +import { readEditorModel3dPricingConfig } from '../ImageCanvasGenerationModel'; +import { useImageCanvasFloatingOptionDismiss } from '../useImageCanvasFloatingOptionDismiss'; +import { Model3dGenerationForm } from './Model3dGenerationForm'; +import { + MODEL3D_GENERATION_DIALOG_LABEL, + MODEL3D_GENERATION_SUBMIT_LABEL, + refreshModel3dAttemptNonce, + resolveModel3dGenerationQuote, +} from './Model3dGenerationFormModel'; +import { resolveModel3dImageSource } from './Model3dGenerationSubmission'; + +type Model3dGenerationModalProps = { + dialog: GenerateDialogState; + style: CSSProperties; + setGenerateDialog: Dispatch>; + generationReferenceButtonRef?: RefObject; + isGenerationReferenceMenuOpen?: boolean; + setIsGenerationReferenceMenuOpen?: Dispatch>; + setIsPickingGenerationReferenceFromCanvas?: Dispatch>; + onRequestUpload: (target: UploadTarget) => void; + onRequestProjectAssetPicker?: () => void; + renderEditorPortal?: (node: ReactNode) => ReactNode; + buildPortalMenuStyle?: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; + hasPendingImageReferenceUploads?: boolean; + onSubmit: (dialog: GenerateDialogState) => void; +}; + +export function Model3dGenerationModal({ + dialog, + style, + setGenerateDialog, + generationReferenceButtonRef, + isGenerationReferenceMenuOpen = false, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, + onRequestUpload, + onRequestProjectAssetPicker, + renderEditorPortal = (node) => node, + buildPortalMenuStyle = () => ({}), + hasPendingImageReferenceUploads = false, + onSubmit, +}: Model3dGenerationModalProps) { + const isGenerating = dialog.status === 'generating'; + const quote = resolveModel3dGenerationQuote( + dialog, + readEditorModel3dPricingConfig(), + ); + const isImageMode = dialog.mode === 'model3d-image-to-model'; + const hasRequiredInput = isImageMode + ? Boolean(resolveModel3dImageSource(dialog)) + : Boolean(dialog.prompt.trim()); + const canSubmit = + quote.ok && + hasRequiredInput && + !isGenerating && + !hasPendingImageReferenceUploads; + + useImageCanvasFloatingOptionDismiss({ + isOpen: isImageMode && isGenerationReferenceMenuOpen, + boundaryRefs: [generationReferenceButtonRef], + onDismiss: () => setIsGenerationReferenceMenuOpen?.(false), + }); + + return ( +
event.stopPropagation()} + > + + setGenerateDialog((currentDialog) => + currentDialog ? updater(currentDialog) : currentDialog, + ) + } + generationReferenceButtonRef={generationReferenceButtonRef} + isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen} + setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen} + setIsPickingGenerationReferenceFromCanvas={ + setIsPickingGenerationReferenceFromCanvas + } + onRequestUpload={onRequestUpload} + onRequestProjectAssetPicker={onRequestProjectAssetPicker} + renderEditorPortal={renderEditorPortal} + buildPortalMenuStyle={buildPortalMenuStyle} + /> +
+ onSubmit(dialog)} + > + {isGenerating ? ( + '生成中' + ) : ( + <> + {MODEL3D_GENERATION_SUBMIT_LABEL} + + {quote.ok ? `${quote.price}泥点` : '—'} + + + )} + + {dialog.status === 'failed' ? ( + onSubmit(refreshModel3dAttemptNonce(dialog))} + > + 重试 + + ) : null} +
+ {!quote.ok ? ( + + {quote.reason} + + ) : null} + {isGenerating ? ( + + 生成中 + + ) : null} + {dialog.status === 'failed' && quote.ok ? ( + + {dialog.errorMessage} + + ) : null} +
+ ); +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts new file mode 100644 index 000000000..3fc42c3e6 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest'; + +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import type { + CharacterReferenceImage, + GenerateDialogState, +} from '../ImageCanvasEditorTypes'; +import { + createModel3dGenerationDraftFields, + refreshModel3dAttemptNonce, +} from './Model3dGenerationFormModel'; +import { + buildModel3dSubmissionPlan, + MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE, + MODEL3D_PROJECT_REQUIRED_MESSAGE, + MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE, + resolveModel3dImageSource, + resolveModel3dRequestKey, + resolveModel3dResultTitle, +} from './Model3dGenerationSubmission'; + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } }, + 'image-to-model': { 'v3.1-20260211': { noTexture: 150, texture: 250 } }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +const CANVAS_COMPLETION = { + dialogId: 'dialog-3d', + title: '3D 模型', + placeholder: { + x: 1, + y: 2, + width: 1024, + height: 1024, + originalWidth: 1024, + originalHeight: 1024, + }, +}; + +function createDialog( + mode: 'model3d-text-to-model' | 'model3d-image-to-model', + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields(mode), + ...overrides, + } as GenerateDialogState; +} + +function canvasReference( + overrides: Partial = {}, +): CharacterReferenceImage { + return { + id: 'reference-1', + src: '/generated-character-drafts/editor/ref.webp', + label: '参考图', + ...overrides, + }; +} + +describe('resolveModel3dImageSource', () => { + it('画布资源与素材库资产各收敛成契约允许的一种引用', () => { + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ resourceId: 'resource-1' })], + }), + ), + ).toEqual({ kind: 'resource', resourceId: 'resource-1' }); + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ sourceAssetId: 'asset-1' })], + }), + ), + ).toEqual({ kind: 'asset', assetId: 'asset-1' }); + }); + + it('还没登记上传的本地图片不构成 source,空白 id 同样不算', () => { + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [canvasReference()], + }), + ), + ).toBeNull(); + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [ + canvasReference({ resourceId: ' ', sourceAssetId: '' }), + ], + }), + ), + ).toBeNull(); + expect( + resolveModel3dImageSource(createDialog('model3d-image-to-model')), + ).toBeNull(); + }); +}); + +describe('buildModel3dSubmissionPlan', () => { + it('文生 3D 的请求体全量给出价格相关参数与落点', () => { + const result = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-text-to-model', { prompt: ' 一把木椅 ' }), + projectId: 'project-1', + canvasCompletion: CANVAS_COMPLETION, + pricing: PRICING, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.plan.endpoint).toBe('text-to-model'); + expect(result.plan.body).toEqual({ + generation: { + model: 'v3.1-20260211', + prompt: '一把木椅', + texture: true, + textureQuality: 'standard', + pbr: false, + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }, + target: { + kind: 'projectResource', + projectId: 'project-1', + canvasCompletion: CANVAS_COMPLETION, + }, + }); + }); + + it('图生 3D 带 source、不带提示词;纯几何档位不带 textureQuality', () => { + const result = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-image-to-model', { + model3dTier: 'geometry-only', + model3dGenerateParts: true, + generationReferences: [canvasReference({ sourceAssetId: ' asset-1 ' })], + }), + projectId: 'project-1', + pricing: PRICING, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.plan.endpoint).toBe('image-to-model'); + expect(result.plan.body).toEqual({ + source: { kind: 'asset', assetId: 'asset-1' }, + generation: { + model: 'v3.1-20260211', + texture: false, + pbr: false, + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: true, + }, + target: { kind: 'projectResource', projectId: 'project-1' }, + }); + expect(result.plan.body).not.toHaveProperty('prompt'); + // 后端显式拒绝 texture=false + textureQuality 的组合,缺位是契约要求。 + expect( + result.plan.body.generation as Record, + ).not.toHaveProperty('textureQuality'); + }); + + it('缺工程、缺输入、缺定价都不组装请求', () => { + const textDialog = createDialog('model3d-text-to-model'); + expect( + buildModel3dSubmissionPlan({ + dialog: { ...textDialog, prompt: '木椅' }, + projectId: ' ', + pricing: PRICING, + }), + ).toEqual({ ok: false, message: MODEL3D_PROJECT_REQUIRED_MESSAGE }); + expect( + buildModel3dSubmissionPlan({ + dialog: textDialog, + projectId: 'project-1', + pricing: PRICING, + }), + ).toEqual({ ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE }); + expect( + buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-image-to-model'), + projectId: 'project-1', + pricing: PRICING, + }), + ).toEqual({ ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE }); + expect( + buildModel3dSubmissionPlan({ + dialog: { ...textDialog, prompt: '木椅' }, + projectId: 'project-1', + pricing: null, + }), + ).toEqual({ ok: false, message: '3D 模型定价尚未配置' }); + }); +}); + +describe('resolveModel3dResultTitle', () => { + it('文生用截断后的描述做标题,图生与空描述用统一标题', () => { + const longPrompt = '圆'.repeat(30); + expect( + resolveModel3dResultTitle( + createDialog('model3d-text-to-model', { prompt: longPrompt }), + ), + ).toBe('圆'.repeat(24)); + expect( + resolveModel3dResultTitle(createDialog('model3d-text-to-model')), + ).toBe('3D 模型'); + expect( + resolveModel3dResultTitle( + createDialog('model3d-image-to-model', { prompt: '木椅' }), + ), + ).toBe('3D 模型'); + }); +}); + +describe('Idempotency-Key', () => { + const buildKey = (dialog: GenerateDialogState) => { + const result = buildModel3dSubmissionPlan({ + dialog, + projectId: 'project-1', + pricing: PRICING, + }); + if (!result.ok) { + throw new Error('用例里的请求必须可提交'); + } + return result.plan.requestKey; + }; + + it('同一份内容重复提交继续用同一个键,双击不会开出两个 operation', () => { + const dialog = createDialog('model3d-text-to-model', { prompt: '木椅' }); + expect(buildKey(dialog)).toBe(buildKey(dialog)); + }); + + it('键值可以直接当请求头用:可打印 ASCII 且不超长', () => { + const key = buildKey( + createDialog('model3d-text-to-model', { prompt: '木椅' }), + ); + expect(key).toMatch(/^[\x21-\x7e]+$/); + expect(key.length).toBeLessThanOrEqual(128); + }); + + it('内容一变就换键:改提示词、换参考图、换落点都不复用旧键', () => { + const dialog = createDialog('model3d-text-to-model', { prompt: '木椅' }); + const original = buildKey(dialog); + expect(buildKey({ ...dialog, prompt: '木凳' })).not.toBe(original); + expect( + buildKey({ ...dialog, model3dAttemptNonce: 'model3d-next' }), + ).not.toBe(original); + expect( + buildKey({ + ...createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ resourceId: 'resource-1' })], + }), + }), + ).not.toBe( + buildKey({ + ...createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ resourceId: 'resource-2' })], + }), + }), + ); + }); + + it('同一个代次下换参考图也会换键——参考图是内容的一部分', () => { + const shared = { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields('model3d-image-to-model'), + model3dAttemptNonce: 'model3d-fixed', + } as GenerateDialogState; + const before = buildKey({ + ...shared, + generationReferences: [canvasReference({ resourceId: 'resource-1' })], + }); + const after = buildKey({ + ...shared, + generationReferences: [canvasReference({ resourceId: 'resource-2' })], + }); + expect(after).not.toBe(before); + expect(after.startsWith('model3d-fixed-')).toBe(true); + expect(before.startsWith('model3d-fixed-')).toBe(true); + }); + + it('用户主动重试换新一代,因此换键', () => { + const dialog = createDialog('model3d-text-to-model', { + prompt: '木椅', + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + }); + const retried = refreshModel3dAttemptNonce(dialog); + expect(buildKey(retried)).not.toBe(buildKey(dialog)); + }); + + it('没有代次(老会话)时现场铸造,键仍然可用', () => { + const key = buildKey({ + ...createDialog('model3d-text-to-model', { prompt: '木椅' }), + model3dAttemptNonce: undefined, + }); + expect(key.startsWith('model3d-')).toBe(true); + expect( + resolveModel3dRequestKey({ + attemptNonce: null, + endpoint: 'text-to-model', + body: { generation: { model: 'v3.1-20260211' } }, + }), + ).toMatch(/^[\x21-\x7e]{1,128}$/); + }); + + it('字段顺序不同但内容相同的请求得到同一个指纹', () => { + const left = resolveModel3dRequestKey({ + attemptNonce: 'model3d-fixed', + endpoint: 'text-to-model', + body: { generation: { texture: true, model: 'v3.1-20260211' } }, + }); + const right = resolveModel3dRequestKey({ + attemptNonce: 'model3d-fixed', + endpoint: 'text-to-model', + body: { generation: { model: 'v3.1-20260211', texture: true } }, + }); + expect(right).toBe(left); + }); +}); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts new file mode 100644 index 000000000..ca77f7e0e --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts @@ -0,0 +1,215 @@ +import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration'; +import type { Model3dGenerationSource } from '../../../../packages/shared/src/contracts/model3d'; +import type { + EditorCanvasGenerationCompletionInput, + Model3dGenerationSubmissionResponse, + Model3dPricingEndpoint, +} from '../../../services/image-editor/editorProjectClient'; +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import type { GenerateDialogState } from '../ImageCanvasEditorTypes'; +import { + DEFAULT_MODEL3D_MODEL_VERSION, + isModel3dGenerationMode, + mintModel3dAttemptNonce, + MODEL3D_PRICING_UNAVAILABLE_REASON, + resolveModel3dEndpoint, + resolveModel3dGenerationParams, + resolveModel3dGenerationQuote, +} from './Model3dGenerationFormModel'; + +/** 提交只做校验与入队,毫秒级返回,因此不沿用生成链路的分钟级超时。 */ +export const MODEL3D_SUBMISSION_TIMEOUT_MS = 60_000; + +export const MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE = '请填写 3D 模型描述'; +export const MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE = + '请先选择一张画布图片或素材库图片'; +export const MODEL3D_PROJECT_REQUIRED_MESSAGE = + '请先保存当前画布工程,再生成 3D 模型'; + +export type Model3dSubmissionPlan = { + endpoint: Model3dPricingEndpoint; + requestKey: string; + title: string; + body: Record; +}; + +export function resolveModel3dResultTitle(dialog: GenerateDialogState): string { + const prompt = dialog.prompt.trim(); + if (dialog.mode === 'model3d-text-to-model' && prompt) { + return prompt.slice(0, 24); + } + return '3D 模型'; +} + +/** + * 图生 3D 只接受站内的画布资源或素材库资产:本地上传必须先经既有上传登记链路 + * 拿到 resourceId / assetId,才可能构成一个合法 `source`。 + */ +export function resolveModel3dImageSource( + dialog: GenerateDialogState, +): Model3dGenerationSource | null { + const reference = dialog.generationReferences?.[0]; + if (!reference) { + return null; + } + const resourceId = reference.resourceId?.trim(); + if (resourceId) { + return { kind: 'resource', resourceId }; + } + const assetId = reference.sourceAssetId?.trim(); + if (assetId) { + return { kind: 'asset', assetId }; + } + return null; +} + +export function buildModel3dSubmissionPlan({ + dialog, + projectId, + canvasCompletion, + pricing, +}: { + dialog: GenerateDialogState; + projectId?: string | null; + canvasCompletion?: EditorCanvasGenerationCompletionInput | null; + pricing?: Model3dPricingConfig | null; +}): { ok: true; plan: Model3dSubmissionPlan } | { ok: false; message: string } { + if (!isModel3dGenerationMode(dialog.mode)) { + return { ok: false, message: MODEL3D_PRICING_UNAVAILABLE_REASON }; + } + const trimmedProjectId = projectId?.trim(); + if (!trimmedProjectId) { + return { ok: false, message: MODEL3D_PROJECT_REQUIRED_MESSAGE }; + } + const quote = resolveModel3dGenerationQuote(dialog, pricing); + if (!quote.ok) { + return { ok: false, message: quote.reason }; + } + const paramsResult = resolveModel3dGenerationParams({ + tier: dialog.model3dTier ?? 'textured-standard', + quad: dialog.model3dQuad ?? false, + smartLowPoly: dialog.model3dSmartLowPoly ?? false, + generateParts: dialog.model3dGenerateParts ?? false, + }); + if (!paramsResult.ok) { + return { ok: false, message: paramsResult.reason }; + } + const params = paramsResult.params; + const prompt = dialog.prompt.trim(); + const source = + dialog.mode === 'model3d-image-to-model' + ? resolveModel3dImageSource(dialog) + : null; + if (dialog.mode === 'model3d-text-to-model' && !prompt) { + return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE }; + } + if (dialog.mode === 'model3d-image-to-model' && !source) { + return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE }; + } + const endpoint = resolveModel3dEndpoint(dialog.mode); + const body: Record = { + ...(source ? { source } : {}), + // 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格, + // 唯一例外是 texture=false 档位不带 textureQuality(后端显式拒绝这个组合)。 + generation: { + model: DEFAULT_MODEL3D_MODEL_VERSION, + ...(dialog.mode === 'model3d-text-to-model' ? { prompt } : {}), + texture: params.texture, + pbr: params.pbr, + ...(params.textureQuality + ? { textureQuality: params.textureQuality } + : {}), + geometryQuality: params.geometryQuality, + quad: params.quad, + smartLowPoly: params.smartLowPoly, + generateParts: params.generateParts, + }, + target: { + kind: 'projectResource', + projectId: trimmedProjectId, + ...(canvasCompletion ? { canvasCompletion } : {}), + }, + }; + return { + ok: true, + plan: { + endpoint, + requestKey: resolveModel3dRequestKey({ + attemptNonce: dialog.model3dAttemptNonce, + endpoint, + body, + }), + title: resolveModel3dResultTitle(dialog), + body, + }, + }; +} + +/** + * 幂等键 = 尝试代次 + 请求内容指纹。 + * + * 服务端按「键 + payload 指纹」dedupe:同键同请求返回原 operation,同键不同请求 409。 + * 因此键必须跟着内容走,而会改内容的入口散落在好几处通用函数里(换参考图、换上传图、 + * 换项目素材都发生在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel`)。 + * 把内容指纹并进键里,这些入口就不需要各自记得重铸键:内容一变,键必变; + * 而同一份内容重复提交(双击)仍会命中同一个 operation。 + */ +export function resolveModel3dRequestKey({ + attemptNonce, + endpoint, + body, +}: { + attemptNonce?: string | null; + endpoint: Model3dPricingEndpoint; + body: Record; +}): string { + const nonce = attemptNonce?.trim() || mintModel3dAttemptNonce(); + return `${nonce}-${fingerprintModel3dRequest(endpoint, body)}`; +} + +function canonicalizeModel3dRequestValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeModel3dRequestValue); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, canonicalizeModel3dRequestValue(entry)]), + ); + } + return value; +} + +/** FNV-1a 64 位,只用来给请求内容定址,不承担任何安全语义。 */ +function fingerprintModel3dRequest( + endpoint: Model3dPricingEndpoint, + body: Record, +) { + const text = `${endpoint}\n${JSON.stringify( + canonicalizeModel3dRequestValue(body), + )}`; + let hash = 0xcbf29ce484222325n; + for (let index = 0; index < text.length; index += 1) { + hash ^= BigInt(text.charCodeAt(index)); + hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn; + } + return hash.toString(16).padStart(16, '0'); +} + +export function model3dQueueStateFromResponse( + response: Model3dGenerationSubmissionResponse | null | undefined, +): ExternalGenerationJobStatusRecord | null { + return response?.status ?? null; +} + +export function resolveModel3dGenerationErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + if (typeof error === 'string' && error.trim()) { + return error.trim(); + } + return '生成 3D 模型失败,请稍后重试。'; +} diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx new file mode 100644 index 000000000..d251ece37 --- /dev/null +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx @@ -0,0 +1,265 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook } from '@testing-library/react'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration'; +import type { + EditorCanvasGenerationCompletionInput, + Model3dGenerationSubmissionResponse, +} from '../../../services/image-editor/editorProjectClient'; +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import type { + CanvasGenerationDialogState, + GenerateDialogState, +} from '../ImageCanvasEditorTypes'; +import { + applyEditorGenerationPricingConfig, + EDITOR_MODEL_MUD_POINT_CONFIG, +} from '../ImageCanvasGenerationModel'; +import { + createModel3dGenerationDraftFields, + MODEL3D_PRICING_UNAVAILABLE_REASON, +} from './Model3dGenerationFormModel'; +import { useModel3dGenerationTask } from './useModel3dGenerationTask'; + +const submitModel3dGenerationRequestMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../services/image-editor/editorProjectClient', async () => { + const actual = await vi.importActual< + typeof import('../../../services/image-editor/editorProjectClient') + >('../../../services/image-editor/editorProjectClient'); + return { + ...actual, + submitModel3dGenerationRequest: submitModel3dGenerationRequestMock, + }; +}); + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } }, + 'image-to-model': { 'v3.1-20260211': { noTexture: 150, texture: 250 } }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +const QUEUED_STATUS: ExternalGenerationJobStatusRecord = { + operationId: 'task-1', + status: 'queued', + phaseLabel: '排队中', + phaseDetail: '等待执行', + progress: 0, + updatedAtMicros: 1, +}; + +const PLACEHOLDER: NonNullable< + EditorCanvasGenerationCompletionInput['placeholder'] +> = { + x: 0, + y: 0, + width: 1024, + height: 1024, + originalWidth: 1024, + originalHeight: 1024, +}; + +function createDialog( + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields('model3d-text-to-model'), + prompt: '一把木椅', + placeholder: PLACEHOLDER, + ...overrides, + } as GenerateDialogState; +} + +function renderTask({ + dialog, + projectId = 'project-1', + applyQueuedGeneration = vi.fn(async () => true), +}: { + dialog: GenerateDialogState; + projectId?: string | null; + applyQueuedGeneration?: (...args: unknown[]) => Promise; +}) { + const onQueuedGenerationTask = vi.fn(); + const view = renderHook(() => { + const [dialogs, setDialogs] = useState([ + dialog as CanvasGenerationDialogState, + ]); + const { submitModel3dGeneration } = useModel3dGenerationTask({ + projectId, + updateCanvasGenerationDialogById: (dialogId, updater) => { + setDialogs((currentDialogs) => + currentDialogs.map((currentDialog) => + currentDialog.id === dialogId + ? (updater(currentDialog) ?? currentDialog) + : currentDialog, + ), + ); + }, + getGeneratingDialogPlaceholder: (currentDialog) => + currentDialog.placeholder ?? null, + applyQueuedGeneration: applyQueuedGeneration as never, + onQueuedGenerationTask, + }); + return { + submitModel3dGeneration, + // 用例里只会有一个对话框,收窄成非空便于断言。 + dialog: dialogs[0] as GenerateDialogState, + }; + }); + return { ...view, onQueuedGenerationTask }; +} + +describe('useModel3dGenerationTask', () => { + beforeEach(() => { + submitModel3dGenerationRequestMock.mockReset(); + applyEditorGenerationPricingConfig({ + models: EDITOR_MODEL_MUD_POINT_CONFIG, + model3d: PRICING, + }); + }); + + it('提交成功后交给既有排队链路,并以对话框 id 结算画布占位', async () => { + const submitResponse: Model3dGenerationSubmissionResponse = { + operationId: 'task-1', + jobKind: 'model3d_text_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-1', + }; + submitModel3dGenerationRequestMock.mockResolvedValue(submitResponse); + const applyQueuedGeneration = vi.fn(async () => true); + const { result, onQueuedGenerationTask } = renderTask({ + dialog: createDialog(), + applyQueuedGeneration, + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(submitModel3dGenerationRequestMock).toHaveBeenCalledTimes(1); + const request = submitModel3dGenerationRequestMock.mock.calls[0][0]; + expect(request.endpoint).toBe('text-to-model'); + expect(request.idempotencyKey).toMatch(/^[\x21-\x7e]{1,128}$/); + expect(request.body).toMatchObject({ + generation: { + prompt: '一把木椅', + model: 'v3.1-20260211', + texture: true, + textureQuality: 'standard', + pbr: false, + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }, + target: { kind: 'projectResource', projectId: 'project-1' }, + }); + + expect(applyQueuedGeneration).toHaveBeenCalledTimes(1); + const [, queuedProjectId, , onQueued, , , completionDialogId] = + applyQueuedGeneration.mock.calls[0] as unknown as unknown[]; + expect(queuedProjectId).toBe('project-1'); + expect(completionDialogId).toBe('dialog-3d'); + expect(onQueued).toBe(onQueuedGenerationTask); + expect(result.current.dialog.status).toBe('generating'); + expect(result.current.dialog.composerOpen).toBe(false); + }); + + it('排队链路没接手时回到可再次提交的空闲态', async () => { + submitModel3dGenerationRequestMock.mockResolvedValue({ + operationId: 'task-1', + jobKind: 'model3d_text_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-1', + }); + const { result } = renderTask({ + dialog: createDialog(), + applyQueuedGeneration: vi.fn(async () => false), + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(result.current.dialog.status).toBe('idle'); + expect(result.current.dialog.composerOpen).toBe(true); + }); + + it('请求失败时留在面板上展示失败原因', async () => { + submitModel3dGenerationRequestMock.mockRejectedValue( + new Error('3D 模型生成失败,请稍后重试。'), + ); + const { result } = renderTask({ dialog: createDialog() }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toBe( + '3D 模型生成失败,请稍后重试。', + ); + expect(result.current.dialog.composerOpen).toBe(true); + }); + + it('定价段缺失时不发请求,只把不可提交的原因写回面板', async () => { + applyEditorGenerationPricingConfig({ + models: EDITOR_MODEL_MUD_POINT_CONFIG, + }); + const applyQueuedGeneration = vi.fn(async () => true); + const { result } = renderTask({ + dialog: createDialog(), + applyQueuedGeneration, + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(submitModel3dGenerationRequestMock).not.toHaveBeenCalled(); + expect(applyQueuedGeneration).not.toHaveBeenCalled(); + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toBe( + MODEL3D_PRICING_UNAVAILABLE_REASON, + ); + }); + + it('画布工程还没保存时不发请求', async () => { + const { result } = renderTask({ dialog: createDialog(), projectId: null }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(submitModel3dGenerationRequestMock).not.toHaveBeenCalled(); + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toContain('保存当前画布工程'); + }); + + it('非 3D 对话框不做任何事', async () => { + const { result } = renderTask({ + dialog: createDialog({ mode: 'generate' }), + }); + + await act(async () => { + await result.current.submitModel3dGeneration( + createDialog({ mode: 'generate' }), + ); + }); + + expect(submitModel3dGenerationRequestMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts new file mode 100644 index 000000000..609cb97a1 --- /dev/null +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts @@ -0,0 +1,149 @@ +import { useCallback } from 'react'; + +import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration'; +import type { + EditorCanvasGenerationCompletionInput, + EditorProjectSnapshot, +} from '../../../services/image-editor/editorProjectClient'; +import { submitModel3dGenerationRequest } from '../../../services/image-editor/editorProjectClient'; +import type { + CanvasGenerationDialogState, + GenerateDialogState, +} from '../ImageCanvasEditorTypes'; +import { readEditorModel3dPricingConfig } from '../ImageCanvasGenerationModel'; +import { isModel3dGenerationMode } from './Model3dGenerationFormModel'; +import { + buildModel3dSubmissionPlan, + model3dQueueStateFromResponse, + resolveModel3dGenerationErrorMessage, + resolveModel3dResultTitle, +} from './Model3dGenerationSubmission'; + +type ApplyQueuedGeneration = ( + response: { queueState?: ExternalGenerationJobStatusRecord | null }, + projectId: string | null | undefined, + applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined, + onQueuedGenerationTask?: () => void, + onWalletBalanceMayHaveChanged?: () => void, + onGenerationWarning?: (message: string) => void, + completionDialogId?: string | null, +) => Promise; + +type UpdateCanvasGenerationDialogById = ( + dialogId: string, + updater: ( + dialog: CanvasGenerationDialogState, + ) => CanvasGenerationDialogState | null, +) => void; + +/** + * 3D 生成的提交编排:只负责校验、入队、轮询与收口,请求构造在 + * `Model3dGenerationSubmission`,画布与任务侧栏复用既有链路。 + */ +export function useModel3dGenerationTask({ + projectId, + updateCanvasGenerationDialogById, + applyProjectSnapshot, + getGeneratingDialogPlaceholder, + applyQueuedGeneration, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + onGenerationWarning, +}: { + projectId?: string | null; + updateCanvasGenerationDialogById: UpdateCanvasGenerationDialogById; + applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; + getGeneratingDialogPlaceholder: ( + dialog: GenerateDialogState, + ) => EditorCanvasGenerationCompletionInput['placeholder'] | null | undefined; + applyQueuedGeneration: ApplyQueuedGeneration; + onQueuedGenerationTask?: () => void; + onWalletBalanceMayHaveChanged?: () => void; + onGenerationWarning?: (message: string) => void; +}) { + const submitModel3dGeneration = useCallback( + async (dialog: GenerateDialogState) => { + const dialogId = dialog.id; + if (!dialogId || !isModel3dGenerationMode(dialog.mode)) { + return; + } + const placeholder = getGeneratingDialogPlaceholder(dialog); + const canvasCompletion = + projectId && placeholder + ? { + dialogId, + title: resolveModel3dResultTitle(dialog), + placeholder, + } + : null; + const planResult = buildModel3dSubmissionPlan({ + dialog, + projectId, + canvasCompletion, + pricing: readEditorModel3dPricingConfig(), + }); + if (!planResult.ok) { + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'failed', + composerOpen: true, + errorMessage: planResult.message, + })); + return; + } + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'generating', + composerOpen: false, + errorMessage: undefined, + })); + try { + const response = await submitModel3dGenerationRequest({ + endpoint: planResult.plan.endpoint, + body: planResult.plan.body, + idempotencyKey: planResult.plan.requestKey, + }); + const queueState = model3dQueueStateFromResponse(response); + if ( + queueState && + (await applyQueuedGeneration( + { queueState }, + projectId, + applyProjectSnapshot, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + onGenerationWarning, + dialogId, + )) + ) { + return; + } + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'idle', + composerOpen: true, + errorMessage: undefined, + })); + } catch (error) { + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'failed', + composerOpen: true, + errorMessage: resolveModel3dGenerationErrorMessage(error), + })); + } + }, + [ + applyProjectSnapshot, + applyQueuedGeneration, + getGeneratingDialogPlaceholder, + onGenerationWarning, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + projectId, + updateCanvasGenerationDialogById, + ], + ); + + return { submitModel3dGeneration }; +} diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx b/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx new file mode 100644 index 000000000..b7dc2312c --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx @@ -0,0 +1,198 @@ +/* @vitest-environment jsdom */ + +import type { Model3dViewerStatusDetail } from '@genarrative/model3d-viewer'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CanvasLayer } from '../ImageCanvasEditorTypes'; +import { Model3dViewerModal } from './Model3dViewerModal'; + +const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn()); +const viewerResetViewMock = vi.hoisted(() => vi.fn()); +const model3dViewerMock = vi.hoisted(() => vi.fn()); +const reportedStatus = vi.hoisted(() => ({ + status: 'ready' as 'idle' | 'loading' | 'ready' | 'failed', + detail: {} as Model3dViewerStatusDetail, +})); + +vi.mock('../../../hooks/useResolvedAssetReadUrl', () => ({ + useResolvedAssetReadUrl: useResolvedAssetReadUrlMock, +})); + +// 只替换渲染器:格式判定必须走查看器包的真实实现,画布不另存一份格式表。 +vi.mock('@genarrative/model3d-viewer', async () => { + const actual = await vi.importActual< + typeof import('@genarrative/model3d-viewer') + >('@genarrative/model3d-viewer'); + return { ...actual, Model3DViewer: model3dViewerMock }; +}); + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-3d', + resourceId: 'resource-3d', + title: '3D 模型', + // 投影后的 imageSrc 是预览图,模型本体只在 objectKey 上。 + src: '/generated-character-drafts/editor/model3d/task-1/preview.webp', + objectKey: 'generated-character-drafts/editor/model3d/task-1/model.glb', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + assetKind: 'model3d', + ...overrides, + }; +} + +function renderModal(layer: CanvasLayer | null, onClose = vi.fn()) { + const result = render(); + return { ...result, onClose }; +} + +describe('Model3dViewerModal', () => { + beforeEach(() => { + model3dViewerMock.mockReset(); + viewerResetViewMock.mockReset(); + useResolvedAssetReadUrlMock.mockReset(); + reportedStatus.status = 'ready'; + reportedStatus.detail = {}; + useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({ + resolvedUrl: source, + isResolving: false, + shouldResolve: Boolean(source), + })); + // 最小替身:透出「有没有传 format」、拿得到 resetView 句柄、并按用例上报状态。 + model3dViewerMock.mockImplementation( + ({ + ref, + source, + errorText, + onStatusChange, + }: { + ref?: { current: { resetView: () => void } | null }; + source: { kind: 'url'; url: string; format?: string }; + errorText?: string; + onStatusChange?: ( + status: 'idle' | 'loading' | 'ready' | 'failed', + detail: Model3dViewerStatusDetail, + ) => void; + }) => { + // 状态回调必须发生在挂载后:渲染期回调会被 React 丢弃,测出来的按钮状态是假的。 + useEffect(() => { + if (ref) { + ref.current = { resetView: viewerResetViewMock }; + } + if (reportedStatus.status !== 'idle') { + onStatusChange?.(reportedStatus.status, reportedStatus.detail); + } + }, [ref, onStatusChange]); + return ( + <> + + {source.url} + + {errorText ?? ''} + + ); + }, + ); + }); + + it('用 objectKey 解析模型地址,且不向查看器预判格式', () => { + renderModal(createLayer()); + + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( + 'generated-character-drafts/editor/model3d/task-1/model.glb', + { + objectKey: 'generated-character-drafts/editor/model3d/task-1/model.glb', + refreshKey: 'resource-3d:model3d', + }, + ); + const viewer = screen.getByTestId('model3d-viewer'); + expect(viewer.textContent).toBe( + 'generated-character-drafts/editor/model3d/task-1/model.glb', + ); + // 画布不判格式:format 必须缺席,由查看器按响应内容类型与字节判定。 + expect(viewer.getAttribute('data-format')).toBe(''); + expect(screen.getByRole('dialog', { name: '3D模型预览' })).toBeTruthy(); + }); + + it('判不出格式时把查看器的原因翻给用户', () => { + reportedStatus.status = 'failed'; + reportedStatus.detail = { reason: 'unsupported-format' }; + + renderModal(createLayer({ objectKey: 'editor/model3d/task-1/model.obj' })); + + expect(screen.getByTestId('model3d-viewer-error').textContent).toBe( + '无法识别模型格式,只支持 glb / gltf / fbx。', + ); + }); + + it('超过体积上限时报出查看器的上限', () => { + reportedStatus.status = 'failed'; + reportedStatus.detail = { reason: 'too-large', byteLength: 70_000_000 }; + + renderModal(createLayer()); + + expect(screen.getByTestId('model3d-viewer-error').textContent).toBe( + '模型超过 64 MiB,无法预览。', + ); + }); + + it('没有 objectKey 时不拿预览图当模型地址', () => { + renderModal(createLayer({ objectKey: undefined })); + + expect(screen.queryByTestId('model3d-viewer')).toBeNull(); + expect(screen.getByText('该资源没有可预览的 3D 模型文件。')).toBeTruthy(); + }); + + it('签名地址未就绪时给出状态提示而不是空舞台', () => { + useResolvedAssetReadUrlMock.mockImplementation(() => ({ + resolvedUrl: '', + isResolving: true, + shouldResolve: true, + })); + + renderModal(createLayer()); + + expect(screen.getByRole('status').textContent).toBe('正在准备模型地址…'); + expect(screen.queryByTestId('model3d-viewer')).toBeNull(); + }); + + it('重置视角只在查看器就绪后可点', () => { + renderModal(createLayer()); + + const resetButton = screen.getByRole('button', { + name: '重置视角', + }) as HTMLButtonElement; + expect(resetButton.disabled).toBe(false); + + fireEvent.click(resetButton); + expect(viewerResetViewMock).toHaveBeenCalledTimes(1); + }); + + it('未就绪时重置视角不可点', () => { + reportedStatus.status = 'idle'; + + renderModal(createLayer()); + + expect( + (screen.getByRole('button', { name: '重置视角' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it('未选中图层时不渲染面板', () => { + renderModal(null); + + expect(screen.queryByRole('dialog', { name: '3D模型预览' })).toBeNull(); + }); +}); diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx b/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx new file mode 100644 index 000000000..c3934ea0a --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx @@ -0,0 +1,176 @@ +import { + Model3DViewer, + type Model3DViewerHandle, + type Model3DViewerSource, + type Model3DViewerStatus, + type Model3dViewerStatusDetail, +} from '@genarrative/model3d-viewer'; +import { useRef, useState } from 'react'; + +import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl'; +import { PlatformActionButton } from '../../common/PlatformActionButton'; +import { UnifiedModal } from '../../common/UnifiedModal'; +import type { CanvasLayer } from '../ImageCanvasEditorTypes'; +import { resolveModel3dViewerFailureText } from './Model3dViewerModel'; + +export type Model3dViewerModalProps = { + layer: CanvasLayer | null; + onClose: () => void; +}; + +/** + * 面板该显示的提示文案;返回 null 表示可以进查看器。用早返回而不是嵌套三元, + * 每个分支对应一个前置条件,读起来是清单而不是括号树。 + */ +function resolveModel3dViewerHint({ + hasModelSource, + isResolving, + resolvedUrl, +}: { + hasModelSource: boolean; + isResolving: boolean; + resolvedUrl: string; +}) { + if (!hasModelSource) { + return '该资源没有可预览的 3D 模型文件。'; + } + if (isResolving) { + return '正在准备模型地址…'; + } + if (!resolvedUrl) { + return '模型地址暂不可用,请稍后重试。'; + } + return null; +} + +type Model3dViewerUrlSource = Extract; + +/** 查看器来源:模型对象与它的签名地址。格式由查看器按读响应与字节判定。 */ +function resolveModel3dViewerSource({ + objectKey, + resolvedUrl, +}: { + objectKey: string; + resolvedUrl: string; +}): Model3dViewerUrlSource | null { + if (!objectKey || !resolvedUrl) { + return null; + } + return { kind: 'url', url: resolvedUrl }; +} + +const MODEL3D_VIEWER_FAILURE_FALLBACK_TEXT = '模型加载失败,请关闭后重试。'; + +/** + * 3D 模型预览模态:只做「取地址 + 交给查看器」,不解析模型、不产出缩略图。 + * + * 模型本体只认 `objectKey`:投影后的 `imageSrc` 是预览图,拿它当模型地址会得到一个 + * 图片文件。`objectKey` 缺失就直接判不可预览 —— 宁可少一次"也许能开",也不让同一份 + * 字段在同一期承担两种语义。 + * + * 格式不在这一层判定:查看器读模型时按「响应内容类型 → 字节魔数 → 地址扩展名」判定, + * 判不出来就报 unsupported-format,这里把失败原因翻译成用户能读的文案。 + */ +export function Model3dViewerModal({ + layer, + onClose, +}: Model3dViewerModalProps) { + const modelObjectKey = layer?.objectKey?.trim() ?? ''; + const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(modelObjectKey, { + objectKey: layer?.objectKey, + refreshKey: `${layer?.taskId ?? layer?.resourceId ?? ''}:model3d`, + }); + const viewerRef = useRef(null); + const layerIdentity = layer?.id ?? null; + // 查看器状态连同"它属于哪个模型"一起存:换了模型,旧状态自动失效。 + // 用 effect 在切换时重置不行 —— 它在挂载后运行,会盖掉查看器刚上报的 ready。 + const [viewerStatus, setViewerStatus] = useState<{ + identity: string; + status: Model3DViewerStatus; + detail: Model3dViewerStatusDetail | null; + } | null>(null); + + const modelSource = resolveModel3dViewerSource({ + objectKey: modelObjectKey, + resolvedUrl, + }); + const viewerIdentity = modelSource + ? `${layerIdentity ?? ''}:${modelSource.url}` + : null; + const canResetView = + Boolean(viewerIdentity) && + viewerStatus?.identity === viewerIdentity && + viewerStatus.status === 'ready'; + const hintText = resolveModel3dViewerHint({ + hasModelSource: Boolean(modelObjectKey), + isResolving, + resolvedUrl, + }); + const errorText = resolveModel3dViewerFailureText( + viewerStatus?.identity === viewerIdentity ? viewerStatus.detail : null, + MODEL3D_VIEWER_FAILURE_FALLBACK_TEXT, + ); + + return ( + viewerRef.current?.resetView()} + > + 重置视角 + + } + > + {/* 查看器要一个非零尺寸的容器才能量出画布尺寸;画布编辑器只支持桌面端,按视口限高。 */} +
+ {hintText ? ( +

+ {hintText} +

+ ) : null} + {modelSource ? ( + + setViewerStatus((current) => { + if (!viewerIdentity) { + return current; + } + if ( + current?.identity === viewerIdentity && + current.status === status && + current.detail?.reason === detail?.reason && + current.detail?.message === detail?.message + ) { + return current; + } + return { identity: viewerIdentity, status, detail }; + }) + } + /> + ) : null} +
+
+ ); +} diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModel.test.ts b/src/components/image-editor/model3d-preview/Model3dViewerModel.test.ts new file mode 100644 index 000000000..b0c848418 --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModel.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + isModel3dFileSource, + resolveModel3dDownloadExtension, + resolveModel3dViewerFailureText, +} from './Model3dViewerModel'; + +describe('isModel3dFileSource', () => { + it('把带模型扩展名的地址当成模型本体,查询串不影响判定', () => { + expect(isModel3dFileSource('generated/editor/model.glb')).toBe(true); + expect(isModel3dFileSource('/read/model.FBX?sig=1')).toBe(true); + expect(isModel3dFileSource('https://x/model.gltf#a')).toBe(true); + }); + + it('预览图与空值不是模型本体', () => { + expect(isModel3dFileSource('generated/editor/preview.webp')).toBe(false); + expect(isModel3dFileSource('')).toBe(false); + expect(isModel3dFileSource(null)).toBe(false); + expect(isModel3dFileSource(undefined)).toBe(false); + }); +}); + +describe('resolveModel3dDownloadExtension', () => { + it('真实响应头的 MIME 优先于对象键扩展名', () => { + expect( + resolveModel3dDownloadExtension( + 'generated/editor/model.fbx', + 'model/gltf-binary', + ), + ).toBe('glb'); + expect( + resolveModel3dDownloadExtension( + 'generated/editor/model.glb', + 'model/fbx', + ), + ).toBe('fbx'); + }); + + it('拿不到可辨识的 MIME 时按对象键扩展名', () => { + expect( + resolveModel3dDownloadExtension( + 'generated/editor/model.fbx', + 'application/octet-stream', + ), + ).toBe('fbx'); + expect(resolveModel3dDownloadExtension('model.gltf', '')).toBe('gltf'); + }); + + it('两条线索都给不出答案时回落 glb', () => { + expect(resolveModel3dDownloadExtension('generated/editor/model', '')).toBe( + 'glb', + ); + expect(resolveModel3dDownloadExtension(null, null)).toBe('glb'); + }); +}); + +describe('resolveModel3dViewerFailureText', () => { + const fallback = '模型加载失败,请关闭后重试。'; + + it('超限时报出查看器上限', () => { + expect( + resolveModel3dViewerFailureText({ reason: 'too-large' }, fallback), + ).toContain('64 MiB'); + }); + + it('格式判不出时报出支持范围', () => { + expect( + resolveModel3dViewerFailureText( + { reason: 'unsupported-format' }, + fallback, + ), + ).toContain('glb / gltf / fbx'); + }); + + it('WebGL 不可用时说明是环境问题', () => { + expect( + resolveModel3dViewerFailureText( + { reason: 'webgl-unavailable' }, + fallback, + ), + ).toContain('WebGL2'); + }); + + it('其余原因与缺失 detail 都用兜底文案', () => { + expect( + resolveModel3dViewerFailureText({ reason: 'load-failed' }, fallback), + ).toBe(fallback); + expect(resolveModel3dViewerFailureText(null, fallback)).toBe(fallback); + expect(resolveModel3dViewerFailureText(undefined, fallback)).toBe(fallback); + }); +}); diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModel.ts b/src/components/image-editor/model3d-preview/Model3dViewerModel.ts new file mode 100644 index 000000000..c5d30f338 --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModel.ts @@ -0,0 +1,64 @@ +import { + MODEL3D_VIEWER_MAX_MODEL_BYTES, + MODEL3D_VIEWER_SUPPORTED_FORMATS, + type Model3dViewerStatusDetail, + resolveModel3dViewerFormat, +} from '@genarrative/model3d-viewer'; + +/** + * 3D 资源在画布上的媒体语义:卡片与素材缩略图消费预览图,模型本体只在查看器里加载。 + * 模型地址来自资源行的 `objectKey`。 + * + * 「模型是什么格式、能不能解析」的真相在查看器包里(声明类型 → 字节魔数 → 地址扩展名), + * 画布不判格式、也不抄格式清单:它只回答「这个地址是不是模型本体」和「下载该按什么扩展名 + * 命名」这两个不需要解析器的问题,且都复用包里那一份判定。 + */ + +const MODEL3D_VIEWER_MAX_MIB = Math.round( + MODEL3D_VIEWER_MAX_MODEL_BYTES / (1024 * 1024), +); +const MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT = + MODEL3D_VIEWER_SUPPORTED_FORMATS.join(' / '); + +/** + * 判断一个媒体地址是否其实指向模型本体。 + * + * 后端投影正常情况下把 `imageSrc` 指到预览图;预览图缺失时它会回落到模型对象, + * 此时图片元素不能去加载模型文件,必须改显示 3D 占位。 + */ +export function isModel3dFileSource(value: string | null | undefined) { + return resolveModel3dViewerFormat({ url: value }) !== null; +} + +/** + * 模型下载扩展名:真实响应头的 MIME 优先,其次地址扩展名,最后回落 glb(后端模型产物的 + * 默认扩展名)。只有 `blob.type` 这类真实响应头才带得动第一层判据。 + */ +export function resolveModel3dDownloadExtension( + value: string | null | undefined, + mimeType?: string | null, +) { + return ( + resolveModel3dViewerFormat({ declaredType: mimeType, url: value }) ?? 'glb' + ); +} + +/** + * 查看器失败时给用户看的文案:按失败原因分开说,用早返回写成一列判断。 + * 原因缺失(宿主没拿到 detail)时用调用方的兜底文案。 + */ +export function resolveModel3dViewerFailureText( + detail: Model3dViewerStatusDetail | null | undefined, + fallbackText: string, +) { + if (detail?.reason === 'too-large') { + return `模型超过 ${MODEL3D_VIEWER_MAX_MIB} MiB,无法预览。`; + } + if (detail?.reason === 'unsupported-format') { + return `无法识别模型格式,只支持 ${MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT}。`; + } + if (detail?.reason === 'webgl-unavailable') { + return '当前环境不支持 WebGL2,无法预览模型。'; + } + return fallbackText; +} diff --git a/src/components/image-editor/projectAssetReferencePickerModel.test.ts b/src/components/image-editor/projectAssetReferencePickerModel.test.ts index 057be4c02..f6e643276 100644 --- a/src/components/image-editor/projectAssetReferencePickerModel.test.ts +++ b/src/components/image-editor/projectAssetReferencePickerModel.test.ts @@ -243,6 +243,35 @@ describe('参考图弹窗素材筛选与快照', () => { expect(next?.composerOpen).toBe(true); }); + it('replaces the 3D image-to-model source with the picked project asset', () => { + const assets = [createAsset({ id: 'asset-hero', label: '主角立绘' })]; + const dialog = { + mode: 'model3d-image-to-model', + prompt: '', + status: 'failed', + errorMessage: '失败', + composerOpen: false, + generationReferences: [ + { + id: 'canvas-layer-1', + label: '画布点选', + src: 'https://example.test/canvas.png', + mediaType: 'image', + }, + ], + } satisfies GenerateDialogState; + + const next = replaceProjectAssetPickerReferences( + dialog, + projectAssetPickerReferences(assets, ['asset-hero']), + ); + + expect( + next?.generationReferences?.map((reference) => reference.id), + ).toEqual(['project-asset-asset-hero']); + expect(next).toMatchObject({ status: 'idle', composerOpen: true }); + }); + it('leaves dialogs outside the reference-composer modes untouched', () => { const dialog = { mode: 'video', diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 3d714997c..4308a5e6c 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -267,6 +267,209 @@ describe('useImageCanvasAssetExportWorkflow', () => { } }); + it('downloads a 3D model layer with the model extension from the response MIME', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/assets/read-url?')) { + return new Response(null, { status: 404 }); + } + if (url.startsWith('/api/assets/read-bytes?')) { + return new Response(new Uint8Array([0x67, 0x6c, 0x54, 0x46]), { + headers: { 'content-type': 'model/gltf-binary' }, + }); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + const downloads: Array<{ blob: Blob; name: string }> = []; + let currentBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + currentBlob = blob; + return 'blob:model3d-export'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + if (currentBlob) { + downloads.push({ blob: currentBlob, name: this.download }); + } + }, + ); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出单图' })); + await waitFor(() => expect(downloads).toHaveLength(1)); + + expect(downloads[0]?.blob.type).toContain('model/gltf-binary'); + expect(downloads[0]?.name).toMatch(/^三维角色-\d{8}-\d{6}\.glb$/u); + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + + it('falls back to the object key extension when the 3D response has no model MIME', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/assets/read-url?')) { + return new Response(null, { status: 404 }); + } + if (url.startsWith('/api/assets/read-bytes?')) { + return new Response(new Uint8Array([0x4b, 0x61, 0x79, 0x64]), { + headers: { 'content-type': 'application/octet-stream' }, + }); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + const downloads: Array<{ blob: Blob; name: string }> = []; + let currentBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + currentBlob = blob; + return 'blob:model3d-extension-fallback'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + if (currentBlob) { + downloads.push({ blob: currentBlob, name: this.download }); + } + }, + ); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出单图' })); + await waitFor(() => expect(downloads).toHaveLength(1)); + + expect(downloads[0]?.name).toMatch(/^三维角色-\d{8}-\d{6}\.fbx$/u); + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + + it('packages 3D model layers into the 3d_models directory', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/assets/read-url?')) { + return new Response(null, { status: 404 }); + } + if (url.startsWith('/api/assets/read-bytes?')) { + return new Response(new Uint8Array([0x67, 0x6c, 0x54, 0x46]), { + headers: { 'content-type': 'model/gltf-binary' }, + }); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + let exportedBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:collection-with-model3d'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出画布素材' })); + await waitFor(() => expect(exportedBlob).toBeTruthy()); + + const zip = await JSZip.loadAsync(exportedBlob!); + const files = Object.keys(zip.files); + expect( + files.filter( + (file) => + file.startsWith('导出项目-画布素材/3d_models/') && + !file.endsWith('/'), + ), + ).toEqual(['导出项目-画布素材/3d_models/002-三维角色.glb']); + expect( + files.filter( + (file) => + file.startsWith('导出项目-画布素材/images/') && !file.endsWith('/'), + ), + ).toEqual(['导出项目-画布素材/images/001-普通图片.png']); + const metadata = JSON.parse( + await readZipText(zip, '导出项目-画布素材/metadata.json'), + ); + expect( + metadata.layers.map((layer: { file: string }) => layer.file), + ).toEqual(['images/001-普通图片.png', '3d_models/002-三维角色.glb']); + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + it('prevents concurrent collection exports before React re-renders', async () => { const originalFetch = globalThis.fetch; let resolveFetch: (response: Response) => void = () => {}; diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 5433ed802..aefc6acc3 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -17,7 +17,7 @@ import { type ExportedImageSequenceFrame, formatExportDate, getImageSequenceFrameFileName, - getLayerAssetExtensionFromTypeOrSrc, + getLayerAssetDownloadExtension, getLayerExportKey, getLayerImageSequenceFrames, readLayerAssetBlob, @@ -50,6 +50,32 @@ function hasReadableLayerExportSource(layer: CanvasLayer) { return hasReadableAssetSource(layer.src, layer.objectKey); } +type CanvasAssetExportFolderName = 'images' | 'media' | '3d_models'; + +/** 导出目录按媒体族分:图片 `images/`、音视频 `media/`、3D 模型 `3d_models/`。 */ +function resolveLayerExportFolderName( + layer: CanvasLayer, +): CanvasAssetExportFolderName { + if (layer.assetKind === 'model3d') { + return '3d_models'; + } + return (layer.mediaType ?? 'image') === 'image' ? 'images' : 'media'; +} + +/** zip 目标目录:三类各一个,模型不进图片目录。 */ +function resolveExportTargetFolder( + folderName: CanvasAssetExportFolderName, + folders: { imagesFolder: JSZip; mediaFolder: JSZip; modelsFolder: JSZip }, +) { + if (folderName === '3d_models') { + return folders.modelsFolder; + } + if (folderName === 'media') { + return folders.mediaFolder; + } + return folders.imagesFolder; +} + type AssetExportPlan = { key: string; layer: CanvasLayer; @@ -68,7 +94,7 @@ type PreparedAssetExport = plan: AssetExportPlan; blob: Blob; bytes: Uint8Array; - folderName: 'images' | 'media'; + folderName: CanvasAssetExportFolderName; fileName: string; } | { @@ -390,11 +416,7 @@ export function useImageCanvasAssetExportWorkflow({ try { const blob = await readLayerAssetBlob(layer); - const extension = getLayerAssetExtensionFromTypeOrSrc( - layer.mediaType ?? 'image', - blob.type, - layer.objectKey ?? layer.src, - ); + const extension = getLayerAssetDownloadExtension(layer, blob.type); const downloaded = triggerBrowserDownload( blob, `${sanitizeExportFilePart(layer.title, 'canvas-layer')}-${formatExportDate(new Date())}.${extension}`, @@ -476,6 +498,9 @@ export function useImageCanvasAssetExportWorkflow({ const rootFolder = zip.folder(rootFolderName) ?? zip; const imagesFolder = rootFolder.folder('images') ?? rootFolder; const mediaFolder = rootFolder.folder('media') ?? rootFolder; + // 3D 资源导出的是模型本体(.glb/.gltf/.fbx),混进 images/ 会让人以为 + // zip 里是坏图片,所以单独一个目录。 + const modelsFolder = rootFolder.folder('3d_models') ?? rootFolder; const imageByKey = new Map(); const usedFileNames = new Map(); const plannedKeys = new Set(); @@ -579,13 +604,11 @@ export function useImageCanvasAssetExportWorkflow({ bytes: await blobToUint8Array(nextBlob), }; }); - const mediaType = layer.mediaType ?? 'image'; - const extension = getLayerAssetExtensionFromTypeOrSrc( - mediaType, + const extension = getLayerAssetDownloadExtension( + layer, blob.type, - layer.objectKey ?? layer.src, ); - const folderName = mediaType === 'image' ? 'images' : 'media'; + const folderName = resolveLayerExportFolderName(layer); return { status: 'asset', plan, @@ -595,13 +618,8 @@ export function useImageCanvasAssetExportWorkflow({ fileName: `${indexedFileName}.${extension}`, }; } catch (error) { - const mediaType = layer.mediaType ?? 'image'; - const extension = getLayerAssetExtensionFromTypeOrSrc( - mediaType, - '', - layer.objectKey ?? layer.src, - ); - const folderName = mediaType === 'image' ? 'images' : 'media'; + const extension = getLayerAssetDownloadExtension(layer, ''); + const folderName = resolveLayerExportFolderName(layer); return { status: 'error', plan, @@ -624,10 +642,10 @@ export function useImageCanvasAssetExportWorkflow({ continue; } if (preparedExport.status === 'asset') { - const targetFolder = - preparedExport.folderName === 'images' - ? imagesFolder - : mediaFolder; + const targetFolder = resolveExportTargetFolder( + preparedExport.folderName, + { imagesFolder, mediaFolder, modelsFolder }, + ); targetFolder.file(preparedExport.fileName, preparedExport.bytes); imageByKey.set(key, { key, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 6232f4241..5ecb125d8 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -89,6 +89,8 @@ import type { } from './ImageCanvasUiAssetExtractionModel'; import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel'; import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel'; +import { isModel3dGenerationMode } from './model3d-generation/Model3dGenerationFormModel'; +import { useModel3dGenerationTask } from './model3d-generation/useModel3dGenerationTask'; import type { BackgroundMusicPromptAssistController } from './useImageCanvasBackgroundMusicPromptAssist'; import type { SoundEffectPromptAssistController } from './useImageCanvasSoundEffectPromptAssist'; @@ -1596,9 +1598,24 @@ export function useImageCanvasGenerationSubmissionWorkflow({ [projectId, updateCanvasGenerationDialogById], ); + const { submitModel3dGeneration } = useModel3dGenerationTask({ + projectId, + updateCanvasGenerationDialogById, + applyProjectSnapshot, + getGeneratingDialogPlaceholder, + applyQueuedGeneration: applyQueuedEditorGenerationProject, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + onGenerationWarning, + }); + const submitImageGeneration = useCallback( async (dialog: GenerateDialogState) => { const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; + if (canvasDialog && isModel3dGenerationMode(canvasDialog.mode)) { + await submitModel3dGeneration(canvasDialog); + return; + } const backgroundMusicDialog = canvasDialog?.mode === 'audio-background-music' ? canvasDialog : null; const soundEffectDialog = @@ -2493,6 +2510,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, + submitModel3dGeneration, ], ); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index 6b2469ffc..ad35dd8b6 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -118,6 +118,7 @@ function GenerationSurfaceHarness() { const specToolWrapRef = useRef(null); const musicToolWrapRef = useRef(null); const publicationToolWrapRef = useRef(null); + const model3dToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); @@ -142,6 +143,7 @@ function GenerationSurfaceHarness() { specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, iconSpecButtonRef, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 25c9e2dc9..c734f1886 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -57,7 +57,10 @@ type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; -type ToolbarOptionTool = Extract; +type ToolbarOptionTool = Extract< + CanvasTool, + 'music' | 'spec' | 'publication' | 'model3d' +>; type ImageCanvasGenerationSurfaceOptions = { layers: CanvasLayer[]; @@ -70,6 +73,7 @@ type ImageCanvasGenerationSurfaceOptions = { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; @@ -175,6 +179,7 @@ export function useImageCanvasGenerationSurface({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, iconSpecButtonRef, @@ -318,6 +323,7 @@ export function useImageCanvasGenerationSurface({ generationWorkflow.setIsMusicMenuOpen(tool === 'music'); generationWorkflow.setIsSpecMenuOpen(tool === 'spec'); generationWorkflow.setIsPublicationMenuOpen(tool === 'publication'); + generationWorkflow.setIsModel3dMenuOpen(tool === 'model3d'); }, [clearToolbarOptionCloseTimer, generationWorkflow], ); @@ -334,6 +340,10 @@ export function useImageCanvasGenerationSurface({ generationWorkflow.setIsPublicationMenuOpen(false); return; } + if (tool === 'model3d') { + generationWorkflow.setIsModel3dMenuOpen(false); + return; + } generationWorkflow.setIsSpecMenuOpen(false); }, TOOLBAR_OPTION_CLOSE_DELAY_MS); }, @@ -369,6 +379,11 @@ export function useImageCanvasGenerationSurface({ setActiveTool('publication'); return true; } + if (tool === 'model3d') { + openToolbarOptionMenu('model3d'); + setActiveTool('model3d'); + return true; + } if (tool === 'character') { generationWorkflow.openCharacterGenerationDialog(); return true; @@ -636,6 +651,31 @@ export function useImageCanvasGenerationSurface({ , ) : null} + {generationWorkflow.isModel3dMenuOpen + ? renderEditorPortal( + openToolbarOptionMenu('model3d')} + onPointerLeave={() => closeToolbarOptionMenu('model3d')} + onFocus={() => openToolbarOptionMenu('model3d')} + onBlur={() => closeToolbarOptionMenu('model3d')} + > + + 文生 3D 模型 + + + 图生 3D 模型 + + , + ) + : null} ; function getCanvasToolForGenerationMode( @@ -1216,6 +1219,7 @@ export function useImageCanvasGenerationWorkflow({ const [isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas] = useState(false); const [isMusicMenuOpen, setIsMusicMenuOpen] = useState(false); + const [isModel3dMenuOpen, setIsModel3dMenuOpen] = useState(false); const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(false); const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] = useState(false); @@ -1485,6 +1489,7 @@ export function useImageCanvasGenerationWorkflow({ setIsUiDesignSpecMenuOpen(false); setIsPickingUiDesignSpecFromCanvas(false); setIsMusicMenuOpen(false); + setIsModel3dMenuOpen(false); setIsPublicationMenuOpen(false); setIsPublicationReferenceMenuOpen(false); setIsPickingPublicationReferenceFromCanvas(false); @@ -1741,6 +1746,38 @@ export function useImageCanvasGenerationWorkflow({ viewport, ]); + const openModel3dTextToModelDialog = useCallback(() => { + openPlacedCanvasGenerationDialog( + createModel3dGenerationDialogDraft({ + canvasSize, + viewport, + mode: 'model3d-text-to-model', + }), + ); + activateCanvasGenerationEntry('model3d'); + }, [ + activateCanvasGenerationEntry, + canvasSize, + openPlacedCanvasGenerationDialog, + viewport, + ]); + + const openModel3dImageToModelDialog = useCallback(() => { + openPlacedCanvasGenerationDialog( + createModel3dGenerationDialogDraft({ + canvasSize, + viewport, + mode: 'model3d-image-to-model', + }), + ); + activateCanvasGenerationEntry('model3d'); + }, [ + activateCanvasGenerationEntry, + canvasSize, + openPlacedCanvasGenerationDialog, + viewport, + ]); + const openEditDialog = useCallback( (sourceLayer: CanvasLayer) => { setMetadataLayer(null); @@ -4539,6 +4576,10 @@ export function useImageCanvasGenerationWorkflow({ setIsUiDesignSpecMenuOpen, isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas, + isModel3dMenuOpen, + setIsModel3dMenuOpen, + openModel3dTextToModelDialog, + openModel3dImageToModelDialog, isMusicMenuOpen, setIsMusicMenuOpen, isPublicationMenuOpen, @@ -4643,6 +4684,10 @@ export function useImageCanvasGenerationWorkflow({ isPickingUiDesignSpecFromCanvas, isUiDesignSpecMenuOpen, isMusicMenuOpen, + isModel3dMenuOpen, + setIsModel3dMenuOpen, + openModel3dTextToModelDialog, + openModel3dImageToModelDialog, isTaskSidebarOpen, taskListRefreshKey, refreshTaskList, diff --git a/src/index.css b/src/index.css index 322c9e7ea..a2c320e8c 100644 --- a/src/index.css +++ b/src/index.css @@ -6768,6 +6768,43 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { gap: 0.18rem 0.72rem; } +.image-canvas-editor__generation-composer--model3d { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto auto; + width: min(34rem, calc(100% - 1.5rem)); + gap: 0.3rem 0.72rem; +} + +.image-canvas-editor__model3d-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem 0.85rem; + grid-column: 1 / -1; +} + +.image-canvas-editor__model3d-tier-group { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.16rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.05); +} + +.image-canvas-editor__model3d-tier { + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; +} + +.image-canvas-editor__model3d-tier[aria-pressed='true'] { + background: rgba(255, 255, 255, 0.94); + color: #0f172a; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); +} + .image-canvas-editor__generation-composer--video { grid-template-rows: auto auto auto; gap: 0.18rem 0.72rem; diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 55003d328..0ba254b3c 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -56,9 +56,13 @@ const EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZATION_API = * 覆盖简化两个业务语义轮加单轮 transport retry 的服务端预算,同时为浏览器到 BFF 的 * 悬挂连接提供有界退出;超时后走既有失败路径恢复 idle,不写回候选。 */ +/** 3D 提交只做校验与入队,毫秒级返回,不沿用生成链路的分钟级超时。 */ +export const EDITOR_MODEL3D_SUBMISSION_TIMEOUT_MS = 60_000; export const EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000; export const EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS = 180_000; const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing'; +const EDITOR_MODEL3D_TEXT_TO_MODEL_API = '/api/assets/tripo/text-to-model'; +const EDITOR_MODEL3D_IMAGE_TO_MODEL_API = '/api/assets/tripo/image-to-model'; const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; const EDITOR_IMAGE_REFERENCE_LIMIT = 5; const EDITOR_QUICK_EDIT_REFERENCE_LIMIT = 9; @@ -715,8 +719,32 @@ export type EditorGenerationModelPricing = { prices?: Record; }; +export type Model3dPricingEndpoint = 'text-to-model' | 'image-to-model'; + +export type Model3dAddOnPriceKey = + | 'hdTexture' + | 'ultraTexture' + | 'hdGeometry' + | 'quadMesh' + | 'smartLowPoly' + | 'generateParts'; + +/** + * 3D 生成的定价段:底价按 `endpoint × modelVersion × 是否有贴图` 拆分, + * add-on 按请求参数判定后叠加。整段缺失即 3D 生成不可用(后端 fail closed), + * 前端不内置任何兜底数值。 + */ +export type Model3dPricingConfig = { + basePrices?: Record< + Model3dPricingEndpoint, + Record + > | null; + addOnPrices?: Partial> | null; +}; + export type EditorGenerationPricingConfig = { models: Record; + model3d?: Model3dPricingConfig | null; }; export type EditorProjectSnapshot = { @@ -1691,6 +1719,46 @@ export function optimizeEditorSoundEffectPrompt( ); } +export type Model3dGenerationSubmissionResponse = { + operationId: string; + jobKind: string; + status: ExternalGenerationJobStatusRecord; + statusUrl: string; +}; + +/** + * 3D 生成提交:两个端点都要求 `Idempotency-Key`,键由调用方铸造并与当次请求内容绑定。 + * 这里是画布链路里唯一发送该请求头的生成入口。 + */ +export async function submitModel3dGenerationRequest({ + endpoint, + body, + idempotencyKey, +}: { + endpoint: Model3dPricingEndpoint; + body: Record; + idempotencyKey: string; +}) { + return requestJson( + endpoint === 'image-to-model' + ? EDITOR_MODEL3D_IMAGE_TO_MODEL_API + : EDITOR_MODEL3D_TEXT_TO_MODEL_API, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(body), + }, + '提交 3D 模型生成失败', + { + timeoutMs: EDITOR_MODEL3D_SUBMISSION_TIMEOUT_MS, + ...EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, + }, + ); +} + export async function generateEditorBackgroundMusic( input: EditorBackgroundMusicGenerationInput, ) {