3D 生成面板开放模型版本选择,参数按模型能力门禁
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m35s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m35s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
- Model3dGenerationValidation.ts(新):前端唯一一份参数校验,逐条复刻 platform-tripo 的 validate_generation_options 与 api-server 的 PricingParamView::validate(模型族能力、分件三个前置条件、face_limit_bounds、平台必显式项),并给出按契约联合类型做键的 MODEL3D_MODEL_TABLE 与禁用原因文案 - Model3dGenerationFormModel:模型版本改为用户可选,可选集只取当前端点有底价的版本;报价与请求体读同一个 resolveModel3dModelVersion;换版本或切档位时按新能力收敛档位与开关并开新一代次;v2.5 的贴图档位不带 textureQuality - Model3dGenerationForm:新增模型版本选择器(照图片面板的浮层选项形态),档位与开关按模型能力禁用并给出 title 原因 - Model3dGenerationModal:定价只读一次,模型选项与报价共用同一份快照 - Model3dGenerationSubmission / useModel3dGenerationTask:提交计划改成 endpoint 判别联合,两个端点各一个收口函数 - editorProjectClient:删掉「endpoint + 联合提交体」的单函数,改为 submitModel3dTextToModelRequest / submitModel3dImageToModelRequest 一个端点一个函数;basePrices 两层键改成契约枚举 - ImageCanvasOptionChoice.tsx(新):把图片面板的浮层选项行抽成共享组件,3D 面板复用 - 测试:新增 Model3dGenerationValidation.test.ts(12 例),FormModel / Submission / Modal / useModel3dGenerationTask 补组合表与菜单用例,共 75 例通过 - 文档:实施计划与里程碑追加本轮口径与证据,decision-log 与 pitfalls 各记两条
This commit is contained in:
@@ -15,7 +15,10 @@ Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-2
|
||||
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`。
|
||||
4. 参数永远全量非空提交:`model`、`texture`、`pbr`、`geometryQuality`、`quad`、`smartLowPoly`、`generateParts` 一律显式给出,不依赖后端默认值(provider 隐式默认会改变价格)。两个「不带该字段」的例外都在 `textureQuality` 上:`texture=false` 时后端显式拒绝贴图档位;v2.5 被 provider 预检拒绝任何 `texture_quality`(缺省按 standard 计价,与面板报价同档),因此这两种情况必须**不带**该字段,而不是给 `null`。
|
||||
4.1 模型版本(2026-09-24 追加)由用户选择,缺省 `v3.1-20260211`。可选集不写死在前端常量里,而是取**当前端点有底价**的版本(定价段的键),与「缺价不可提交」同一条失败关闭口径;报价与请求体读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`,面板显示的价格与发出的 `generation.model` 不可能分叉。
|
||||
4.2 能力组合(2026-09-24 追加)在前端有一份与 provider 对齐的校验:`model3d-generation/Model3dGenerationValidation.ts` 逐条复刻 `platform-tripo::common::validation::validate_generation_options` 与 `api-server::tripo3d::validation::PricingParamView::validate`(模型族能力、分件的前置条件、面数上限边界、平台必显式项)。抄的是规则不是数值,规则冲突以 provider 为准;面板的禁用态与禁用原因都读这一份,`Model3dGenerationFormModel.test.ts` / `Model3dGenerationValidation.test.ts` 用组合表把两边钉在一起。之所以必须抄:契约与公开读模型只暴露价格,不暴露「哪些版本支持哪些参数」,否则用户会选到一个提交后必然被 provider 拒的组合。
|
||||
4.3 客户端提交按端点拆成两个函数(`submitModel3dTextToModelRequest` / `submitModel3dImageToModelRequest`),提交计划是 `endpoint` 判别联合。原先「endpoint + 联合提交体」的单函数允许把图生请求体发到文生地址且照样编译通过,配错端点只能等远端按字段报错。
|
||||
5. 落点照现有画布生成工具的做法:**同时**发 `projectId`(可空,缺失时不发这个字段)与当前素材夹 `assetFolderId` + `assetLabel`,画布占位框 `canvasCompletion: { dialogId, title, placeholder }` 在 `projectId && canvasCompletionPlaceholder` 时带上;发送门禁是「两个落点至少给一个」,不再要求先保存工程。结果因此同时落画布与素材库,没有工程时只落素材库。
|
||||
6. 图生输入照现有参考图来源菜单:从画布中选择 / 上传图片 / 从项目素材中选择,最终收敛成契约允许的 `{ kind: "resource", resourceId }` 或 `{ kind: "asset", assetId }`;只允许一张,不接 multiview。
|
||||
7. `Idempotency-Key` 由前端铸造并绑定当次请求,用户主动重试铸造新键、不复用旧键。落地形态是「尝试代次 + 请求内容指纹」(`Model3dGenerationSubmission.resolveModel3dRequestKey`):同一份内容重复提交(双击)仍命中同一个 operation,内容一变(含换参考图、换上传图、换素材库图片)自动换键;重试换代次即换键。之所以把内容指纹并进键里,是因为会改内容的入口散落在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` 的通用函数中,靠「每处都记得重铸键」迟早漏一处。
|
||||
@@ -28,9 +31,11 @@ Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-2
|
||||
| --- | --- |
|
||||
| `model3d-generation/Model3dGenerationModal.tsx` | 提交面板外壳:两个子项共用的表单、状态区、提交按钮 |
|
||||
| `model3d-generation/Model3dGenerationModal.test.tsx` | 面板行为用例 |
|
||||
| `model3d-generation/Model3dGenerationForm.tsx` | 子项专属字段:文生提示词、图生参考图来源(用例并入 `Model3dGenerationModal.test.tsx`:这些字段只在面板里出现,单独挂一个 harness 只是重复) |
|
||||
| `model3d-generation/Model3dGenerationFormModel.ts` | 档位 → 六个必填参数的唯一映射、价格计算(读运行时定价缓存) |
|
||||
| `model3d-generation/Model3dGenerationFormModel.test.ts` | 映射、价格、缺价不可提交的用例 |
|
||||
| `model3d-generation/Model3dGenerationForm.tsx` | 子项专属字段:文生提示词、图生参考图来源、模型版本选择器与按能力禁用的档位 / 开关(用例并入 `Model3dGenerationModal.test.tsx`:这些字段只在面板里出现,单独挂一个 harness 只是重复) |
|
||||
| `model3d-generation/Model3dGenerationFormModel.ts` | 模型版本与档位 → 六个必填参数的唯一映射、可选模型版本、价格计算(读运行时定价缓存) |
|
||||
| `model3d-generation/Model3dGenerationFormModel.test.ts` | 映射、模型版本收敛、价格、缺价不可提交的用例 |
|
||||
| `model3d-generation/Model3dGenerationValidation.ts` | 与 provider / api-server 对齐的能力校验(模型族、分件前置条件、面数边界、平台必显式项)与原因文案的唯一来源 |
|
||||
| `model3d-generation/Model3dGenerationValidation.test.ts` | 逐条规则与边界用例(对齐 provider 的同名用例) |
|
||||
| `model3d-generation/Model3dGenerationSubmission.ts` | 校验后的请求体组装、落点字段、幂等键铸造(代次 + 内容指纹) |
|
||||
| `model3d-generation/Model3dGenerationSubmission.test.ts` | 请求体全量非空、参考图收敛、幂等键铸造与换键用例 |
|
||||
| `model3d-generation/useModel3dGenerationTask.ts` | 调用客户端 submit、排队、失败与重试收口(复用既有排队链路) |
|
||||
@@ -92,11 +97,25 @@ Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-2
|
||||
|
||||
仍未验证的部分需要环境:`model3d` 定价段写入运行配置后的真实扣费与排队回填、provider 端到端产物。
|
||||
|
||||
### 追加一轮(2026-09-24):模型版本可选 + 参数面按能力门禁 + 端点/提交体类型收口
|
||||
|
||||
| 变更 | 证据 |
|
||||
| --- | --- |
|
||||
| 模型版本可选,可选集只取当前端点有底价的版本 | `Model3dGenerationFormModel.test.ts`(`resolveModel3dModelOptions` 两个端点各一例、缺定价给空集)、`Model3dGenerationModal.test.tsx`(菜单里没有无价的 `P1`,切到 `P2` 后报价跟着换) |
|
||||
| 报价与提交体读同一个版本 | `Model3dGenerationSubmission.test.ts`(`generation.model` 跟对话框走)、`Model3dGenerationFormModel.test.ts`(缺价版本不可提交,不回落默认价) |
|
||||
| 能力组合与 provider 逐条对齐 | `Model3dGenerationValidation.test.ts`(12 例:模型族能力、分件三个前置条件、v3.0/v3.1/v2.5/P1/P2 的面数边界、平台必显式项);`Model3dGenerationFormModel.test.ts`(不支持组合直接判非法) |
|
||||
| 换版本 / 切档位时参数收敛 | `Model3dGenerationFormModel.test.ts`、`Model3dGenerationModal.test.tsx`(高清贴图 → 标准贴图、方形布线自动关掉并禁用) |
|
||||
| 端点与提交体由类型绑死 | `useModel3dGenerationTask.test.tsx`(图生走图生函数、文生函数不被调用);`npm run typecheck` 通过(旧单函数已删除) |
|
||||
| v2.5 的贴图档位不带 `textureQuality` | `Model3dGenerationFormModel.test.ts` |
|
||||
|
||||
本轮验证:`npx vitest run src/components/image-editor/model3d-generation`(75 passed)、`npx vitest run src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx packages/image-canvas-react`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;`npx eslint` 对改动文件无告警。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npx vitest run src/components/image-editor/model3d-generation
|
||||
npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx
|
||||
npx eslint src/components/image-editor/model3d-generation
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
node scripts/check-doc-index.mjs
|
||||
|
||||
@@ -14,7 +14,7 @@ Related: `docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.
|
||||
## 范围
|
||||
|
||||
- 底部工具栏入口与子选项菜单,形态照 `music` / `spec` 的「一项入口 → 浮动子选项 → 各自一个提交面板」。
|
||||
- 两个提交面板:档位化的参数选择、提交前价格展示、文生提示词、图生参考图来源。
|
||||
- 两个提交面板:模型版本选择(只列当前端点有底价的版本)、档位化的参数选择、按模型能力禁用的开关、提交前价格展示、文生提示词、图生参考图来源。
|
||||
- 前端铸造 `Idempotency-Key`,并按契约全量非空提交必填参数。
|
||||
- 画布占位框、入队、任务侧栏、失败与退款提示、终态刷新回填。
|
||||
- 3D 定价段的前端消费:只读实时定价查询。
|
||||
@@ -32,7 +32,7 @@ Related: `docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.
|
||||
## 验收标准
|
||||
|
||||
1. 工具栏出现「生成 3D 模型」,点开只有两个子项,子项文案与任务侧栏 `phaseLabel` 一致。
|
||||
2. 每次提交的请求体包含全部必填参数(含六个定价参数),不存在缺字段或 `undefined`。
|
||||
2. 每次提交的请求体包含全部必填参数(含六个定价参数),不存在缺字段或 `undefined`;`generation.model` 等于面板上选中的模型版本,面板选项不会出现该端点没有底价的版本。
|
||||
3. 价格只来自 `GET /api/editor/generation-pricing` 的 `model3d` 段;该段缺失时两个子面板都不可提交,且不发任何请求。
|
||||
4. 每次提交携带 `Idempotency-Key`;同一次提交重放不产生第二个 operation;用户主动重试铸造新键。
|
||||
5. 提交后画布出现占位框,任务侧栏出现该任务并显示「文生 3D 模型」/「图生 3D 模型」;成功后占位框被正式资源图层替换。
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-24 3D 参数面板开放模型选择:版本取「本端点有底价」的键,能力规则抄 provider,端点与提交体由类型绑死
|
||||
|
||||
- 背景:3D 面板此前把请求体与报价里的 `model` 一起写死成 `DEFAULT_MODEL3D_MODEL_VERSION`(`v3.1-20260211`),而公开读模型的定价段是按 `endpoint × modelVersion` 展开的;后端加载即校验「每个契约版本 × 两个端点都必须有底价」,于是定价表里已配好的 `v3.0` / `v2.5` / `P1` / `P2` 四个版本在前端永远不可达,用户也看不到任何模型选择入口。同一轮还暴露两个类型层面的口子:提交函数签名是「endpoint 联合 + 提交体联合」两个彼此独立的字段,把图生请求体发到文生地址照样编译通过;`Model3dPricingConfig.basePrices` 内层键写成 `string`,写错的版本号在编译期没有任何反馈。
|
||||
- 决策:
|
||||
1. `GenerateDialogState.model3dModel` 成为用户可选参数,缺省 `v3.1-20260211`;面板选项只列**当前端点有底价**的版本(`resolveModel3dModelOptions`),报价与请求体的 `generation.model` 读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`。
|
||||
2. 换版本 / 切档位时按新版本能力收敛参数(高清贴图 → 标准贴图、关掉该版本不支持的开关)并开新一代次,面板上不留已经失效的勾选。
|
||||
3. 前端保留一份与 provider 对齐的参数校验 `model3d-generation/Model3dGenerationValidation.ts`,逐条复刻 `platform-tripo::common::validation::validate_generation_options` 与 `api-server::tripo3d::validation::PricingParamView::validate`:模型族能力、分件的三个前置条件、`face_limit_bounds`、平台必显式项。抄的是规则不是数值,两端顺序一致(平台口径在前),冲突以 provider 为准。
|
||||
4. 提交端点与提交体由类型绑死:`submitModel3dTextToModelRequest` / `submitModel3dImageToModelRequest` 一个端点一个函数,`Model3dSubmissionPlan` 做成 `endpoint` 判别联合;`Model3dPricingConfig.basePrices` 的两层键改成契约枚举(`Partial<Record<Model3dPricingEndpoint, Partial<Record<Model3dModelVersion, Model3dBasePrice>>>>`)。
|
||||
- 原因:价格与参数是同一件事的两面——定价按版本分档,把版本写死等于让其余四个版本的定价成为死配置;面板不给版本选择,用户既看不到这些版本,也不可能用上已经付费配置的档位。校验必须抄一份到前端,是因为契约与公开读模型只暴露「价格」,不暴露「哪个版本支持哪些参数」,不抄就只能等用户点提交后由 provider 的字段级报错来教育。类型收口(判别联合 + 契约枚举做键)让同类端点 / 版本错配在 `tsc` 阶段暴露,而不是等远端按字段报错。
|
||||
- 代价与取舍:前端多了一份 provider 规则镜像,provider 改规则时两边要一起改;用 `Model3dGenerationValidation.test.ts`(12 例,含五个版本的能力与面数边界)与 `Model3dGenerationFormModel.test.ts` 的组合表把两边钉住,冲突时以 provider 为准(失败关闭)。运行期的「契约版本列表」仍只能靠前端常量表 `MODEL3D_MODEL_TABLE`:ts-rs 只导出联合**类型**,导不出运行期数组,所以表写成 `Record<Model3dModelVersion, …>`,契约新增版本后 `npm run contracts:model3d:generate` 会让这里直接编译失败。更干净的做法是后端公开读模型补一份 `modelCapabilities`(版本 → 支持参数 → 加价项),前端就不用镜像规则,本轮未做。
|
||||
- 影响面:`src/components/image-editor/model3d-generation/*`、`src/components/image-editor/ImageCanvasOptionChoice.tsx`(新,选项行抽共享)、`src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx`、`src/components/image-editor/ImageCanvasEditorTypes.ts`、`src/services/image-editor/editorProjectClient.ts`、[实施计划](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[里程碑](../plans/【里程碑】Tripo生成前端入口-2026-09-21.md)。
|
||||
- 验证方式:`npx vitest run src/components/image-editor/model3d-generation`(75 passed)、`npx vitest run src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx`、`npm run typecheck`、`npx eslint`(改动文件无告警)。
|
||||
|
||||
## 2026-09-24 画布底部工具栏改用换行兜溢出,不再靠横向滚动
|
||||
|
||||
- 背景:13 个工具 + 3 个分隔符放进宿主限宽的 `.image-canvas-editor__bottom-toolbar`(`max-width: min(calc(100% - 6.6rem), 34rem)`,约 614px)后必然溢出,共享的 `.genarrative-image-canvas__toolbar` 用 `overflow-x: auto` + `scrollbar-width: thin` 兜住——细到几乎不可见的滚动条让尾部工具(3D 入口等)在桌面端直接「消失」,用户只会看到工具栏被截断。
|
||||
- 决策:共享工具栏改 `flex-wrap: wrap`(只换行、不再滚动),宿主上限 34rem → 42rem 并 `justify-content: center`;AGC 资源画布的宿主覆盖删掉与共享默认重复的 `overflow` 覆盖,只保留限宽。
|
||||
- 原因:工具条是「入口清单」,被截断等于功能不存在;换行是唯一在任意宽度下都能保证每个入口可见且可点的方案,横向滚动在隐藏滚动条的容器里连「还能滚」这件事都传达不到。
|
||||
- 代价与取舍:工具变多时工具栏会占两行、抬高画布底部,视觉上不如一行整齐;换来的是入口不再被容器宽度吃掉。判据固定为 `scrollWidth === clientWidth` 且最后一个按钮完整可见。
|
||||
- 影响面:`packages/image-canvas-react/src/{CanvasChrome.tsx,styles.css}`、`src/index.css`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css`。
|
||||
- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`、`npx vitest run packages/image-canvas-react`;1280px 视口实测不再有横向溢出。
|
||||
|
||||
## 2026-09-24 Tripo 配置改为启动期失败关闭:网关无内置默认值,缺配置的进程直接起不来
|
||||
|
||||
- 背景:`TRIPO_BASE_URL` / `TRIPO_API_KEY` 原来只有请求期一道判断,而请求期根本挡不住部署错误:HTTP 角色收 3D 单时不碰 provider,worker 只在自己真的跑到那个 job 时才构造 `TripoSettings`,于是缺配置的部署形态是「进程起来了、`/healthz` 全绿、用户拿到 202」,失败只在任务记录里现身。同时 `AppConfig::default()` 带着内置网关 `https://openapi.tripo3d.com/v3`,`tripo_settings()` 又把超时钳到 `.max(1)`、重试钳到 `.min(10)`,一个写错的配置会被翻译成「能跑但行为诡异」。
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 隐藏滚动条的横向滚动容器不等于「放得下」
|
||||
|
||||
- **现象**:桌面端画布底部工具栏尾部几个工具(含「生成 3D 模型」入口)看不到,用户反馈成「工具栏变成横向可滚动的了」「是不是限宽了」。
|
||||
- **原因**:共享 `.genarrative-image-canvas__toolbar` 用 `overflow-x: auto` + `scrollbar-width: thin` 处理溢出,宿主 `.image-canvas-editor__bottom-toolbar` 又把宽度限到约 614px;13 个工具必然溢出,但细滚动条在桌面端几乎不可见,也没有「还有更多」的任何提示,尾部工具等同于消失。
|
||||
- **处理(现行口径)**:工具栏 `flex-wrap: wrap`,只换行不滚动;宿主上限放宽并居中,AGC 宿主不再重复覆盖。验收判据固定为 `scrollWidth === clientWidth` 且最后一个按钮完整可见,不能只看「滚动条能不能拖」。
|
||||
- **验证**:`npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx` 与 `packages/image-canvas-react` 用例;1280px 视口实测工具栏单行、尾部工具可见。
|
||||
- **关联**:`packages/image-canvas-react/src/CanvasChrome.tsx`、`packages/image-canvas-react/src/styles.css`、`src/index.css`。
|
||||
|
||||
## 定价按模型版本分档时,前端不能把版本写死成常量
|
||||
|
||||
- **现象**:3D 生成面板没有任何模型选择入口,四个已经在定价表里配好底价的版本(`v3.0` / `v2.5` / `P1` / `P2`)在前端完全不可达;提交的 `generation.model` 永远是 `v3.1-20260211`。
|
||||
- **原因**:请求体与报价都把 `model` 写死成 `DEFAULT_MODEL3D_MODEL_VERSION`,而公开读模型的 `basePrices` 是按 `endpoint × modelVersion` 展开的;面板不做版本选择,也就没人去读那些键,定价配置里多数行成了死数据。
|
||||
- **处理(现行口径)**:版本由用户选,选项取「当前端点有底价」的版本;报价与请求体读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`;换版本时按新版本能力收敛档位与开关并开新一代次。
|
||||
- **验证**:`Model3dGenerationFormModel.test.ts`(`resolveModel3dModelOptions` 两个端点各一例、缺价版本不可提交)、`Model3dGenerationModal.test.tsx`(选项不含无价版本、切版本后报价跟着换)。
|
||||
- **关联**:`src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts`、`Model3dGenerationSubmission.ts`、`src/services/image-editor/editorProjectClient.ts`。
|
||||
|
||||
## 「endpoint 联合 + 提交体联合」是两个独立的联合,配错端点编译能过
|
||||
|
||||
- **现象**:3D 生成提交函数的签名是 `{ endpoint: 'text-to-model' | 'image-to-model'; body: TextRequest | ImageRequest }`,把图生请求体发到文生地址没有任何类型错误,只能等远端按字段报错;同类口子还有定价类型的 `basePrices` 内层键写成 `string`,写错的模型版本号在编译期无反馈。
|
||||
- **原因**:两个联合类型彼此独立,类型系统不要求它们同进同退;只要两边各自合法,任意组合都成立。写死的常量(默认模型版本)也属于同一类问题:类型正确、语义错位。
|
||||
- **处理(现行口径)**:提交函数按端点一分为二(端点字面量与提交体类型在同一个签名里绑死),提交计划改成 `endpoint` 判别联合;`MODEL3D_MODEL_TABLE` 用 `Record<Model3dModelVersion, …>`,定价键改成契约枚举。契约新增版本 / 端点时先编译失败,而不是先上生产。
|
||||
- **验证**:`useModel3dGenerationTask.test.tsx`(图生只走图生函数、文生函数不被调用)、`npm run typecheck`;`Model3dGenerationValidation.test.ts` 覆盖契约声明的 5 个版本。
|
||||
- **关联**:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts`。
|
||||
|
||||
## 面板禁用态不能只靠「后端会拒」兜底
|
||||
|
||||
- **现象**:用户能选到必然被 provider 拒的组合(v2.5 + 贴图档位、P2 + 高清贴图、分件 + 贴图档位),提交后拿到字段级错误,白等一次往返,且错误文案对用户没有指导意义。
|
||||
- **原因**:契约与公开读模型只暴露价格,不暴露「哪个版本支持哪些参数」;前端此前的组合判定只覆盖分件与贴图档位,其余全交给 provider。
|
||||
- **处理(现行口径)**:前端保留 `model3d-generation/Model3dGenerationValidation.ts` 镜像 provider 与平台侧规则(抄规则不抄数值,冲突以 provider 为准),面板的禁用态与禁用原因读同一份常量;长期正解是后端读模型补 `modelCapabilities`,让前端不再维护第二份规则。
|
||||
- **验证**:`Model3dGenerationValidation.test.ts`(12 例,含五个版本的能力与面数边界)、`Model3dGenerationFormModel.test.ts` 的档位 × 开关组合表。
|
||||
- **关联**:`src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts`、`server-rs/crates/platform-tripo/src/common/validation.rs`、`server-rs/crates/api-server/src/tripo3d/validation.rs`。
|
||||
|
||||
## Direct 宿主继续请求不能重发原始用户条目
|
||||
|
||||
原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
EditorSoundEffectModel,
|
||||
} from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
|
||||
import type { Model3dModelVersion } from '../../../packages/shared/src/contracts/model3d';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
@@ -299,6 +300,11 @@ export type GenerateDialogState = {
|
||||
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
|
||||
characterAnimationDurationSeconds?: 4 | 5 | 6;
|
||||
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
|
||||
/**
|
||||
* 3D 模型版本:用户可选,缺省(含老快照)回落到 `DEFAULT_MODEL3D_MODEL_VERSION`。
|
||||
* 报价与请求体都读它,面板上的模型选择器也写它。
|
||||
*/
|
||||
model3dModel?: Model3dModelVersion;
|
||||
model3dTier?: Model3dGenerationTier;
|
||||
model3dQuad?: boolean;
|
||||
model3dSmartLowPoly?: boolean;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveEditorImageSizeLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasOptionChoice } from './ImageCanvasOptionChoice';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
@@ -93,34 +94,6 @@ function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function OptionChoice({
|
||||
children,
|
||||
selected,
|
||||
className,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['image-canvas-editor__option-popover-choice', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={selected}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasGenerationImageOptionsView({
|
||||
dialog,
|
||||
setGenerateDialog,
|
||||
@@ -284,7 +257,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
{selection.options.aspectRatios.map((aspectRatio) => (
|
||||
<OptionChoice
|
||||
<ImageCanvasOptionChoice
|
||||
key={aspectRatio}
|
||||
selected={selection.aspectRatio === aspectRatio}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
@@ -300,7 +273,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{aspectRatio}</span>
|
||||
</OptionChoice>
|
||||
</ImageCanvasOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,7 +283,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{selection.options.imageSizes.map((imageSize) => (
|
||||
<OptionChoice
|
||||
<ImageCanvasOptionChoice
|
||||
key={imageSize}
|
||||
selected={selection.imageSize === imageSize}
|
||||
ariaLabel={formatDimensionOptionLabel(
|
||||
@@ -320,7 +293,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
onClick={() => updateDialog({ imageSize })}
|
||||
>
|
||||
{imageSize}
|
||||
</OptionChoice>
|
||||
</ImageCanvasOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,7 +368,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
{modelOptions.map((option) => {
|
||||
const selected = selectedModel === option.value;
|
||||
return (
|
||||
<OptionChoice
|
||||
<ImageCanvasOptionChoice
|
||||
key={option.value}
|
||||
selected={selected}
|
||||
className="image-canvas-editor__option-popover-choice--model"
|
||||
@@ -413,7 +386,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
data-visible={selected}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</OptionChoice>
|
||||
</ImageCanvasOptionChoice>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 画布参数浮层里的「多选一」选项行:模型、尺寸这类选择共用同一条样式与 `aria-pressed` 语义。
|
||||
*
|
||||
* 抽出来是因为它已经在 2 个面板里重复(生成图片面板的模型菜单、3D 生成面板的模型菜单),
|
||||
* 样式类名一旦各写一份就可能只改到一处。
|
||||
*/
|
||||
export function ImageCanvasOptionChoice({
|
||||
children,
|
||||
selected,
|
||||
className,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['image-canvas-editor__option-popover-choice', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={selected}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import { Check, ChevronDown, Cpu, ImageIcon } from 'lucide-react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
Dispatch,
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { Model3dModelVersion } from '../../../../packages/shared/src/contracts/model3d';
|
||||
import { AutoGrowTextArea } from '../../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
@@ -18,20 +20,32 @@ import type {
|
||||
GenerateDialogState,
|
||||
UploadTarget,
|
||||
} from '../ImageCanvasEditorTypes';
|
||||
import { ImageCanvasOptionChoice } from '../ImageCanvasOptionChoice';
|
||||
import { ImageCanvasReferenceSlot } from '../ImageCanvasReferenceSlot';
|
||||
import { useImageCanvasFloatingOptionDismiss } from '../useImageCanvasFloatingOptionDismiss';
|
||||
import {
|
||||
applyModel3dModelChange,
|
||||
applyModel3dPromptChange,
|
||||
applyModel3dReferenceRemoval,
|
||||
applyModel3dSwitchChange,
|
||||
applyModel3dTierChange,
|
||||
isModel3dTierSupported,
|
||||
MODEL3D_DETAILED_TIER_DISABLED_REASON,
|
||||
MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON,
|
||||
MODEL3D_GENERATION_TIERS,
|
||||
MODEL3D_SWITCH_OPTIONS,
|
||||
MODEL3D_TEXT_PROMPT_MAX_LENGTH,
|
||||
resolveModel3dModelVersion,
|
||||
} from './Model3dGenerationFormModel';
|
||||
import { resolveModel3dModelCapabilities } from './Model3dGenerationValidation';
|
||||
|
||||
type Model3dGenerationFormProps = {
|
||||
dialog: GenerateDialogState;
|
||||
disabled: boolean;
|
||||
/**
|
||||
* 当前端点有底价的模型版本;由面板外壳统一读定价缓存后传入,表单不各自读一次配置。
|
||||
*/
|
||||
modelOptions: ReadonlyArray<{ value: Model3dModelVersion; label: string }>;
|
||||
updateDialog: (
|
||||
updater: (dialog: GenerateDialogState) => GenerateDialogState,
|
||||
) => void;
|
||||
@@ -51,6 +65,7 @@ type Model3dGenerationFormProps = {
|
||||
export function Model3dGenerationForm({
|
||||
dialog,
|
||||
disabled,
|
||||
modelOptions,
|
||||
updateDialog,
|
||||
generationReferenceButtonRef,
|
||||
isGenerationReferenceMenuOpen = false,
|
||||
@@ -65,6 +80,24 @@ export function Model3dGenerationForm({
|
||||
const references = dialog.generationReferences ?? [];
|
||||
const tier = dialog.model3dTier ?? 'textured-standard';
|
||||
const isTextureTier = tier !== 'geometry-only';
|
||||
const model = resolveModel3dModelVersion(dialog.model3dModel);
|
||||
const capabilities = resolveModel3dModelCapabilities(model);
|
||||
const selectedModelLabel =
|
||||
modelOptions.find((option) => option.value === model)?.label ??
|
||||
// 定价段缺了当前版本时选项里查不到它:仍把版本号显示出来,价格区会同时报缺价原因。
|
||||
model;
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelMenuBoundaryRefs = useMemo(() => [modelButtonRef], []);
|
||||
const closeModelMenu = useCallback(() => setIsModelMenuOpen(false), []);
|
||||
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isModelMenuOpen,
|
||||
boundaryRefs: modelMenuBoundaryRefs,
|
||||
onDismiss: closeModelMenu,
|
||||
restoreFocusRef: modelButtonRef,
|
||||
});
|
||||
|
||||
const toggleSwitch = (
|
||||
field: 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts',
|
||||
checked: boolean,
|
||||
@@ -126,34 +159,115 @@ export function Model3dGenerationForm({
|
||||
/>
|
||||
)}
|
||||
<div className="image-canvas-editor__model3d-options">
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
|
||||
<PlatformInlineOptionButton
|
||||
ref={modelButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
|
||||
aria-label={`3D 模型版本 ${selectedModelLabel}`}
|
||||
aria-expanded={isModelMenuOpen}
|
||||
disabled={disabled || modelOptions.length === 0}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => setIsModelMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__model-trigger-label">
|
||||
<span
|
||||
className="image-canvas-editor__model-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Cpu />
|
||||
</span>
|
||||
<span>{selectedModelLabel}</span>
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{isModelMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
|
||||
label="3D 模型版本选项"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(modelButtonRef.current, 'above')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
|
||||
{modelOptions.map((option) => {
|
||||
const selected = model === option.value;
|
||||
return (
|
||||
<ImageCanvasOptionChoice
|
||||
key={option.value}
|
||||
selected={selected}
|
||||
className="image-canvas-editor__option-popover-choice--model"
|
||||
onClick={() => {
|
||||
closeModelMenu();
|
||||
updateDialog((currentDialog) =>
|
||||
applyModel3dModelChange(
|
||||
currentDialog,
|
||||
option.value,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__model-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Cpu />
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
<Check
|
||||
className="image-canvas-editor__option-selected-check"
|
||||
data-visible={selected}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ImageCanvasOptionChoice>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<div
|
||||
className="image-canvas-editor__model3d-tier-group"
|
||||
role="group"
|
||||
aria-label="3D 模型档位"
|
||||
>
|
||||
{MODEL3D_GENERATION_TIERS.map((option) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={option.value}
|
||||
className="image-canvas-editor__model3d-tier"
|
||||
aria-pressed={tier === option.value}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
updateDialog((currentDialog) =>
|
||||
applyModel3dTierChange(currentDialog, option.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
{MODEL3D_GENERATION_TIERS.map((option) => {
|
||||
const blocked = !isModel3dTierSupported(capabilities, option.value);
|
||||
return (
|
||||
<PlatformInlineOptionButton
|
||||
key={option.value}
|
||||
className="image-canvas-editor__model3d-tier"
|
||||
aria-pressed={tier === option.value}
|
||||
// 该版本不支持这个档位时按钮保持可见但不可点:用户要看得到这个档位存在,
|
||||
// 也要能从 title 上知道为什么不能用。
|
||||
disabled={disabled || blocked}
|
||||
title={
|
||||
blocked ? MODEL3D_DETAILED_TIER_DISABLED_REASON : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
updateDialog((currentDialog) =>
|
||||
applyModel3dTierChange(currentDialog, option.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</PlatformInlineOptionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{MODEL3D_SWITCH_OPTIONS.map((option) => {
|
||||
const blocked =
|
||||
const capabilityBlocked = !capabilities[option.capability];
|
||||
const tierBlocked =
|
||||
option.field === 'model3dGenerateParts' && isTextureTier;
|
||||
const blocked = capabilityBlocked || tierBlocked;
|
||||
const blockedReason = tierBlocked
|
||||
? MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON
|
||||
: option.disabledReason;
|
||||
return (
|
||||
<label
|
||||
key={option.field}
|
||||
className="image-canvas-editor__image-style-toggle"
|
||||
title={blocked ? blockedReason : undefined}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -7,18 +7,23 @@ import type {
|
||||
} from '../../../services/image-editor/editorProjectClient';
|
||||
import type { GenerateDialogState } from '../ImageCanvasEditorTypes';
|
||||
import {
|
||||
applyModel3dModelChange,
|
||||
applyModel3dPromptChange,
|
||||
applyModel3dReferenceRemoval,
|
||||
applyModel3dSwitchChange,
|
||||
applyModel3dTierChange,
|
||||
createModel3dGenerationDraftFields,
|
||||
DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
mintModel3dAttemptNonce,
|
||||
MODEL3D_PRICING_UNAVAILABLE_REASON,
|
||||
type Model3dGenerationMode,
|
||||
normalizeModel3dModelVersion,
|
||||
refreshModel3dAttemptNonce,
|
||||
resolveModel3dAddOnKeys,
|
||||
resolveModel3dGenerationParams,
|
||||
resolveModel3dGenerationQuote,
|
||||
resolveModel3dModelOptions,
|
||||
resolveModel3dModelVersion,
|
||||
resolveModel3dPrice,
|
||||
} from './Model3dGenerationFormModel';
|
||||
|
||||
@@ -86,6 +91,7 @@ describe('resolveModel3dGenerationParams', () => {
|
||||
it('每个档位映射成完整参数,档位与开关不留给 provider 隐式默认', () => {
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
@@ -105,6 +111,7 @@ describe('resolveModel3dGenerationParams', () => {
|
||||
});
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier: 'textured-detailed',
|
||||
quad: true,
|
||||
smartLowPoly: true,
|
||||
@@ -126,6 +133,7 @@ describe('resolveModel3dGenerationParams', () => {
|
||||
|
||||
it('纯几何档位没有贴图档位,pbr 显式为 false', () => {
|
||||
const result = resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier: 'geometry-only',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
@@ -144,6 +152,7 @@ describe('resolveModel3dGenerationParams', () => {
|
||||
it('贴图档位不允许分件,这是后端也会拒的组合', () => {
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
@@ -152,6 +161,7 @@ describe('resolveModel3dGenerationParams', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier: 'textured-detailed',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
@@ -169,6 +179,7 @@ describe('resolveModel3dGenerationParams', () => {
|
||||
for (const switches of conflictingSwitches) {
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier: 'geometry-only',
|
||||
quad: switches.quad,
|
||||
smartLowPoly: switches.smartLowPoly,
|
||||
@@ -193,6 +204,7 @@ describe('resolveModel3dAddOnKeys', () => {
|
||||
for (const smartLowPoly of [false, true]) {
|
||||
for (const generateParts of [false, true]) {
|
||||
const result = resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
@@ -218,6 +230,186 @@ describe('resolveModel3dAddOnKeys', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const MULTI_VERSION_PRICING: Model3dPricingConfig = {
|
||||
basePrices: {
|
||||
'text-to-model': {
|
||||
'v3.1-20260211': { noTexture: 100, texture: 200 },
|
||||
'v2.5-20250123': { noTexture: 60, texture: 120 },
|
||||
'P2-20260801': { noTexture: 80, texture: 160 },
|
||||
},
|
||||
'image-to-model': {
|
||||
'v3.1-20260211': { noTexture: 150, texture: 250 },
|
||||
'P2-20260801': { noTexture: 170, texture: 270 },
|
||||
},
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 10,
|
||||
ultraTexture: 20,
|
||||
hdGeometry: 30,
|
||||
quadMesh: 40,
|
||||
smartLowPoly: 50,
|
||||
generateParts: 60,
|
||||
},
|
||||
};
|
||||
|
||||
describe('模型版本', () => {
|
||||
it('缺字段与非法取值都回落到默认版本', () => {
|
||||
expect(resolveModel3dModelVersion(undefined)).toBe(
|
||||
DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
);
|
||||
expect(normalizeModel3dModelVersion(' P2-20260801 ')).toBe('P2-20260801');
|
||||
// 老快照里的退役版本不发给服务端。
|
||||
expect(normalizeModel3dModelVersion('v1.0-20190101')).toBe(
|
||||
DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
);
|
||||
expect(normalizeModel3dModelVersion(null)).toBe(
|
||||
DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it('可选项只给当前端点有底价的版本,顺序按契约声明', () => {
|
||||
expect(
|
||||
resolveModel3dModelOptions({
|
||||
pricing: MULTI_VERSION_PRICING,
|
||||
endpoint: 'text-to-model',
|
||||
}),
|
||||
).toEqual([
|
||||
{ value: DEFAULT_MODEL3D_MODEL_VERSION, label: 'v3.1' },
|
||||
{ value: 'v2.5-20250123', label: 'v2.5' },
|
||||
{ value: 'P2-20260801', label: 'P2' },
|
||||
]);
|
||||
expect(
|
||||
resolveModel3dModelOptions({
|
||||
pricing: MULTI_VERSION_PRICING,
|
||||
endpoint: 'image-to-model',
|
||||
}),
|
||||
).toEqual([
|
||||
{ value: DEFAULT_MODEL3D_MODEL_VERSION, label: 'v3.1' },
|
||||
{ value: 'P2-20260801', label: 'P2' },
|
||||
]);
|
||||
expect(
|
||||
resolveModel3dModelOptions({ pricing: null, endpoint: 'text-to-model' }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('报价按对话框选中的版本取底价', () => {
|
||||
expect(
|
||||
resolveModel3dGenerationQuote(
|
||||
createDialog({ model3dModel: 'P2-20260801' }),
|
||||
MULTI_VERSION_PRICING,
|
||||
),
|
||||
).toEqual({ ok: true, price: 160, endpoint: 'text-to-model' });
|
||||
// 定价段里没有这个版本 → 不可提交,不回落默认版本的价。
|
||||
expect(
|
||||
resolveModel3dGenerationQuote(
|
||||
createDialog({ model3dModel: 'P1-20260311' }),
|
||||
MULTI_VERSION_PRICING,
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('v2.5 的贴图档位不带 textureQuality,其它版本照常给出', () => {
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: 'v2.5-20250123',
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
params: { texture: true, textureQuality: null },
|
||||
});
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
params: { texture: true, textureQuality: 'standard' },
|
||||
});
|
||||
});
|
||||
|
||||
it('版本不支持的能力组合直接判非法,不留给服务端拒', () => {
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: 'P1-20260311',
|
||||
tier: 'textured-detailed',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toEqual({ ok: false, reason: '高清几何只支持 v3.1 / v3.0' });
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: 'P2-20260801',
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: true,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toEqual({ ok: false, reason: '智能低多边形只支持 v3.1 / v3.0' });
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
model: 'v2.5-20250123',
|
||||
tier: 'geometry-only',
|
||||
quad: true,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toEqual({ ok: false, reason: '方形布线只支持 v3.1 / v3.0 / P2' });
|
||||
});
|
||||
|
||||
it('换版本时收敛档位与开关并换代次', () => {
|
||||
const detailed = createDialog({
|
||||
model3dTier: 'textured-detailed',
|
||||
model3dQuad: true,
|
||||
model3dSmartLowPoly: true,
|
||||
});
|
||||
const p2 = applyModel3dModelChange(detailed, 'P2-20260801');
|
||||
expect(p2.model3dModel).toBe('P2-20260801');
|
||||
// 高清贴图只 v3.x 有,降到标准贴图而不是直接掉到纯几何。
|
||||
expect(p2.model3dTier).toBe('textured-standard');
|
||||
expect(p2.model3dQuad).toBe(true);
|
||||
expect(p2.model3dSmartLowPoly).toBe(false);
|
||||
expect(p2.model3dAttemptNonce).not.toBe(detailed.model3dAttemptNonce);
|
||||
|
||||
const v25 = applyModel3dModelChange(detailed, 'v2.5-20250123');
|
||||
expect(v25.model3dTier).toBe('textured-standard');
|
||||
expect(v25.model3dQuad).toBe(false);
|
||||
expect(v25.model3dSmartLowPoly).toBe(false);
|
||||
expect(v25.model3dGenerateParts).toBe(false);
|
||||
});
|
||||
|
||||
it('换版本清掉失败态,切档位也按当前版本收敛', () => {
|
||||
const failed = createDialog({
|
||||
status: 'failed',
|
||||
errorMessage: '3D 模型生成失败,请稍后重试。',
|
||||
model3dModel: 'P2-20260801',
|
||||
});
|
||||
expect(applyModel3dModelChange(failed, 'P2-20260801')).toMatchObject({
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
});
|
||||
// P2 没有高清贴图档位:切过去只能落在标准贴图。
|
||||
expect(
|
||||
applyModel3dTierChange(failed, 'textured-detailed').model3dTier,
|
||||
).toBe('textured-standard');
|
||||
// 纯几何 + 分件在 v3.x 上仍然成立。
|
||||
expect(
|
||||
applyModel3dTierChange(
|
||||
createDialog({ model3dGenerateParts: true }),
|
||||
'geometry-only',
|
||||
).model3dGenerateParts,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel3dPrice', () => {
|
||||
const paramsOf = (
|
||||
tier: 'textured-standard' | 'textured-detailed' | 'geometry-only',
|
||||
@@ -228,6 +420,7 @@ describe('resolveModel3dPrice', () => {
|
||||
} = {},
|
||||
) => {
|
||||
const result = resolveModel3dGenerationParams({
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
tier,
|
||||
quad: switches.quad ?? false,
|
||||
smartLowPoly: switches.smartLowPoly ?? false,
|
||||
|
||||
@@ -13,6 +13,20 @@ import type {
|
||||
GenerateDialogState,
|
||||
Model3dGenerationTier,
|
||||
} from '../ImageCanvasEditorTypes';
|
||||
import {
|
||||
isModel3dModelVersion,
|
||||
MODEL3D_MODEL_VERSIONS,
|
||||
MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED,
|
||||
MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE,
|
||||
MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED,
|
||||
MODEL3D_REASON_QUAD_UNSUPPORTED,
|
||||
MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED,
|
||||
type Model3dModelCapabilities,
|
||||
resolveModel3dModelCapabilities,
|
||||
resolveModel3dModelFamily,
|
||||
resolveModel3dModelLabel,
|
||||
validateModel3dRequestParams,
|
||||
} from './Model3dGenerationValidation';
|
||||
|
||||
export type Model3dGenerationMode =
|
||||
| 'model3d-text-to-model'
|
||||
@@ -48,15 +62,48 @@ export const MODEL3D_GENERATION_TIERS: ReadonlyArray<{
|
||||
{ value: 'geometry-only', label: '纯几何' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 开关与「哪个模型能力决定它可用」绑定:面板不再自己判断哪个版本支持什么,
|
||||
* 只读能力表;禁用原因同样取自校验模块,文案只有一份。
|
||||
*/
|
||||
export const MODEL3D_SWITCH_OPTIONS: ReadonlyArray<{
|
||||
field: 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts';
|
||||
label: string;
|
||||
capability: keyof Pick<
|
||||
Model3dModelCapabilities,
|
||||
'quad' | 'smartLowPoly' | 'generateParts'
|
||||
>;
|
||||
disabledReason: string;
|
||||
}> = [
|
||||
{ field: 'model3dQuad', label: '方形布线' },
|
||||
{ field: 'model3dSmartLowPoly', label: '智能低多边形' },
|
||||
{ field: 'model3dGenerateParts', label: '分件' },
|
||||
{
|
||||
field: 'model3dQuad',
|
||||
label: '方形布线',
|
||||
capability: 'quad',
|
||||
disabledReason: MODEL3D_REASON_QUAD_UNSUPPORTED,
|
||||
},
|
||||
{
|
||||
field: 'model3dSmartLowPoly',
|
||||
label: '智能低多边形',
|
||||
capability: 'smartLowPoly',
|
||||
disabledReason: MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED,
|
||||
},
|
||||
{
|
||||
field: 'model3dGenerateParts',
|
||||
label: '分件',
|
||||
capability: 'generateParts',
|
||||
// 模型侧的原因;「贴图档位不允许分件」是档位侧的原因,见 `MODEL3D_TIER_ONLY_REASON`。
|
||||
disabledReason: MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED,
|
||||
},
|
||||
];
|
||||
|
||||
/** 高清贴图档位的禁用原因:档位同时要求 detailed 贴图与 detailed 几何。 */
|
||||
export const MODEL3D_DETAILED_TIER_DISABLED_REASON =
|
||||
MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED;
|
||||
|
||||
/** 分件在贴图档位下被禁用的原因(与校验模块的分件规则同一句文案)。 */
|
||||
export const MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON =
|
||||
MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE;
|
||||
|
||||
export const MODEL3D_TEXT_PROMPT_MAX_LENGTH = 1024;
|
||||
export const MODEL3D_GENERATION_FRAME_SIZE = { width: 1024, height: 1024 };
|
||||
export const DEFAULT_MODEL3D_MODEL_VERSION: Model3dModelVersion =
|
||||
@@ -78,22 +125,60 @@ export function resolveModel3dEndpoint(
|
||||
return mode === 'model3d-image-to-model' ? 'image-to-model' : 'text-to-model';
|
||||
}
|
||||
|
||||
/** 档位到贴图质量的唯一映射:纯几何档位没有贴图,其余按档位取标准 / 高清。 */
|
||||
/**
|
||||
* 档位到贴图质量的唯一映射。两个「不发这个字段」的例外都来自 provider 预检:
|
||||
*
|
||||
* - 纯几何档位没有贴图,本就无档位可给;
|
||||
* - v2.5 拒绝任何 `textureQuality`,因此它的贴图档位同样不带该字段 ——
|
||||
* 后端按缺省 standard 计价,与面板报价同一个档位。
|
||||
*/
|
||||
function resolveModel3dTextureQuality(
|
||||
model: Model3dModelVersion,
|
||||
tier: Model3dGenerationTier,
|
||||
): Model3dTextureQuality | null {
|
||||
if (tier === 'geometry-only') {
|
||||
return null;
|
||||
}
|
||||
if (resolveModel3dModelFamily(model) === 'v25') {
|
||||
return null;
|
||||
}
|
||||
return tier === 'textured-detailed' ? 'detailed' : 'standard';
|
||||
}
|
||||
|
||||
/** 档位在当前模型下是否可用:不可用时代表单必须先把档位收敛到可用档位。 */
|
||||
export function isModel3dTierSupported(
|
||||
capabilities: Model3dModelCapabilities,
|
||||
tier: Model3dGenerationTier,
|
||||
): boolean {
|
||||
return tier !== 'textured-detailed' || capabilities.detailedTextureTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把档位收敛到当前模型支持的档位:高清贴图 → 标准贴图 → 纯几何,
|
||||
* 逐级降级而不是直接跳到纯几何,避免用户的贴图意图被悄悄改掉。
|
||||
*/
|
||||
export function resolveModel3dSupportedTier(
|
||||
capabilities: Model3dModelCapabilities,
|
||||
tier: Model3dGenerationTier,
|
||||
): Model3dGenerationTier {
|
||||
if (isModel3dTierSupported(capabilities, tier)) {
|
||||
return tier;
|
||||
}
|
||||
return 'textured-standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* 档位 + 开关 → provider 参数。组合合法性**只有一份实现**:拼出参数后交给
|
||||
* `Model3dGenerationValidation` 的规则跑一遍,这里不再写第二份「分件不能配⋯」。
|
||||
*/
|
||||
export function resolveModel3dGenerationParams({
|
||||
model,
|
||||
tier,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
generateParts,
|
||||
}: {
|
||||
model: Model3dModelVersion;
|
||||
tier: Model3dGenerationTier;
|
||||
quad: boolean;
|
||||
smartLowPoly: boolean;
|
||||
@@ -101,30 +186,60 @@ export function resolveModel3dGenerationParams({
|
||||
}):
|
||||
| { ok: true; params: Model3dGenerationParams }
|
||||
| { ok: false; reason: string } {
|
||||
const texture = tier !== 'geometry-only';
|
||||
if (texture && generateParts) {
|
||||
return { ok: false, reason: '分件生成只支持纯几何档位' };
|
||||
}
|
||||
// 与 provider 的 `validate_generation_options` 对齐:generate_parts=true 不能和
|
||||
// quad / smart_low_poly 同时开,前端先挡住,避免出价成功后才在服务端被拒。
|
||||
if (generateParts && (quad || smartLowPoly)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: '分件生成不能与方形布线或智能低多边形同时使用',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
params: {
|
||||
texture,
|
||||
textureQuality: resolveModel3dTextureQuality(tier),
|
||||
geometryQuality: tier === 'textured-detailed' ? 'detailed' : 'standard',
|
||||
quad,
|
||||
smartLowPoly,
|
||||
generateParts,
|
||||
pbr: false,
|
||||
},
|
||||
const params: Model3dGenerationParams = {
|
||||
texture: tier !== 'geometry-only',
|
||||
textureQuality: resolveModel3dTextureQuality(model, tier),
|
||||
geometryQuality: tier === 'textured-detailed' ? 'detailed' : 'standard',
|
||||
quad,
|
||||
smartLowPoly,
|
||||
generateParts,
|
||||
pbr: false,
|
||||
};
|
||||
const failure = validateModel3dRequestParams({ model, ...params });
|
||||
if (failure) {
|
||||
return { ok: false, reason: failure.reason };
|
||||
}
|
||||
return { ok: true, params };
|
||||
}
|
||||
|
||||
/** 提交与报价共用的模型版本:缺字段(老快照)时回落到默认版本。 */
|
||||
export function resolveModel3dModelVersion(
|
||||
model: Model3dModelVersion | null | undefined,
|
||||
): Model3dModelVersion {
|
||||
return model ?? DEFAULT_MODEL3D_MODEL_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板上可选的模型版本:只给当前端点**有底价**的版本。
|
||||
*
|
||||
* 契约的 TS 侧只导出联合类型(`Model3dModelVersion`),运行期没有版本数组,
|
||||
* 因此以 `MODEL3D_MODEL_VERSIONS` 为顺序、以定价段的键为可用性判据:没定价的版本
|
||||
* 选了也出不了价,干脆不出现在选项里(与「缺价不可提交」同一条失败关闭口径)。
|
||||
*/
|
||||
export function resolveModel3dModelOptions({
|
||||
pricing,
|
||||
endpoint,
|
||||
}: {
|
||||
pricing: Model3dPricingConfig | null | undefined;
|
||||
endpoint: Model3dPricingEndpoint;
|
||||
}): ReadonlyArray<{ value: Model3dModelVersion; label: string }> {
|
||||
const pricedVersions = pricing?.basePrices?.[endpoint];
|
||||
if (!pricedVersions) {
|
||||
return [];
|
||||
}
|
||||
return MODEL3D_MODEL_VERSIONS.filter(
|
||||
(version) => pricedVersions[version] != null,
|
||||
).map((value) => ({ value, label: resolveModel3dModelLabel(value) }));
|
||||
}
|
||||
|
||||
/** 老快照里的模型版本可能已经不在契约里,按默认版本处理而不是把它发给服务端。 */
|
||||
export function normalizeModel3dModelVersion(
|
||||
model: string | null | undefined,
|
||||
): Model3dModelVersion {
|
||||
const trimmed = model?.trim();
|
||||
return trimmed && isModel3dModelVersion(trimmed)
|
||||
? trimmed
|
||||
: DEFAULT_MODEL3D_MODEL_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +319,9 @@ export function resolveModel3dGenerationQuote(
|
||||
if (!isModel3dGenerationMode(dialog.mode)) {
|
||||
return { ok: false, reason: MODEL3D_MODE_UNSUPPORTED_REASON };
|
||||
}
|
||||
const model = resolveModel3dModelVersion(dialog.model3dModel);
|
||||
const paramsResult = resolveModel3dGenerationParams({
|
||||
model,
|
||||
tier: dialog.model3dTier ?? 'textured-standard',
|
||||
quad: dialog.model3dQuad ?? false,
|
||||
smartLowPoly: dialog.model3dSmartLowPoly ?? false,
|
||||
@@ -217,7 +334,8 @@ export function resolveModel3dGenerationQuote(
|
||||
const priceResult = resolveModel3dPrice({
|
||||
pricing,
|
||||
endpoint,
|
||||
modelVersion: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
// 报价与提交必须读同一个版本:`buildModel3dSubmissionPlan` 走同一个 resolver。
|
||||
modelVersion: model,
|
||||
params: paramsResult.params,
|
||||
});
|
||||
return priceResult.ok
|
||||
@@ -247,6 +365,7 @@ export function createModel3dGenerationDraftFields(
|
||||
status: 'idle' as const,
|
||||
composerOpen: true,
|
||||
generationReferences: [] as CharacterReferenceImage[],
|
||||
model3dModel: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
model3dTier: 'textured-standard' as Model3dGenerationTier,
|
||||
model3dQuad: false,
|
||||
model3dSmartLowPoly: false,
|
||||
@@ -272,11 +391,44 @@ export function applyModel3dTierChange(
|
||||
dialog: GenerateDialogState,
|
||||
tier: Model3dGenerationTier,
|
||||
): GenerateDialogState {
|
||||
const capabilities = resolveModel3dModelCapabilities(
|
||||
resolveModel3dModelVersion(dialog.model3dModel),
|
||||
);
|
||||
const nextTier = resolveModel3dSupportedTier(capabilities, tier);
|
||||
return withNewAttemptNonce({
|
||||
...resetFailedStatus(dialog),
|
||||
model3dTier: tier,
|
||||
model3dTier: nextTier,
|
||||
model3dGenerateParts:
|
||||
tier === 'geometry-only' ? (dialog.model3dGenerateParts ?? false) : false,
|
||||
nextTier === 'geometry-only' && capabilities.generateParts
|
||||
? (dialog.model3dGenerateParts ?? false)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 换模型版本:换代次(这是会影响价格与 payload 的参数),并按新版本的能力收敛参数 ——
|
||||
* 把档位降到可用档位、把该版本不支持的开关关掉,面板上不留已经失效的勾选。
|
||||
*/
|
||||
export function applyModel3dModelChange(
|
||||
dialog: GenerateDialogState,
|
||||
model: Model3dModelVersion,
|
||||
): GenerateDialogState {
|
||||
const capabilities = resolveModel3dModelCapabilities(model);
|
||||
const tier = resolveModel3dSupportedTier(
|
||||
capabilities,
|
||||
dialog.model3dTier ?? 'textured-standard',
|
||||
);
|
||||
return withNewAttemptNonce({
|
||||
...resetFailedStatus(dialog),
|
||||
model3dModel: model,
|
||||
model3dTier: tier,
|
||||
model3dQuad: capabilities.quad && (dialog.model3dQuad ?? false),
|
||||
model3dSmartLowPoly:
|
||||
capabilities.smartLowPoly && (dialog.model3dSmartLowPoly ?? false),
|
||||
model3dGenerateParts:
|
||||
tier === 'geometry-only' &&
|
||||
capabilities.generateParts &&
|
||||
(dialog.model3dGenerateParts ?? false),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,28 @@ const PRICING: Model3dPricingConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const MULTI_VERSION_PRICING: Model3dPricingConfig = {
|
||||
basePrices: {
|
||||
'text-to-model': {
|
||||
'v3.1-20260211': { noTexture: 100, texture: 200 },
|
||||
'v2.5-20250123': { noTexture: 60, texture: 120 },
|
||||
'P2-20260801': { noTexture: 80, texture: 160 },
|
||||
},
|
||||
'image-to-model': {
|
||||
'v3.1-20260211': { noTexture: 150, texture: 250 },
|
||||
'P2-20260801': { noTexture: 170, texture: 270 },
|
||||
},
|
||||
},
|
||||
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,
|
||||
@@ -107,6 +129,7 @@ function Model3dModalHarness({
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
<output aria-label="当前模型版本">{dialog?.model3dModel ?? '-'}</output>
|
||||
<output aria-label="当前档位">{dialog?.model3dTier ?? '-'}</output>
|
||||
<output aria-label="当前分件">
|
||||
{String(dialog?.model3dGenerateParts)}
|
||||
@@ -299,6 +322,104 @@ describe('Model3dGenerationModal', () => {
|
||||
expect(onSubmit.mock.calls[0][0].model3dAttemptNonce).toBeTruthy();
|
||||
});
|
||||
|
||||
it('模型选择器只列出当前端点有底价的版本,选中后按该版本报价', () => {
|
||||
applyPricing(MULTI_VERSION_PRICING);
|
||||
render(
|
||||
<Model3dModalHarness
|
||||
initialDialog={createDialog('model3d-text-to-model', {
|
||||
prompt: '一把木椅',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(within(submitButton()).getByText('200泥点')).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('3D 模型版本 v3.1'));
|
||||
|
||||
const menu = screen.getByLabelText('3D 模型版本选项');
|
||||
// 只有本端点有底价的版本进选项:P1 有契约、没有价格,选了也出不了价。
|
||||
expect(within(menu).queryByRole('button', { name: 'P1' })).toBeNull();
|
||||
fireEvent.click(within(menu).getByRole('button', { name: 'P2' }));
|
||||
|
||||
expect(screen.getByLabelText('当前模型版本').textContent).toBe(
|
||||
'P2-20260801',
|
||||
);
|
||||
expect(within(submitButton()).getByText('160泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('切到能力更弱的版本时档位与开关一起收敛', () => {
|
||||
applyPricing(MULTI_VERSION_PRICING);
|
||||
render(
|
||||
<Model3dModalHarness
|
||||
initialDialog={createDialog('model3d-text-to-model', {
|
||||
prompt: '一把木椅',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '高清贴图' }));
|
||||
fireEvent.click(screen.getByLabelText('方形布线'));
|
||||
expect(screen.getByLabelText('当前档位').textContent).toBe(
|
||||
'textured-detailed',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('3D 模型版本 v3.1'));
|
||||
fireEvent.click(
|
||||
within(screen.getByLabelText('3D 模型版本选项')).getByRole('button', {
|
||||
name: 'v2.5',
|
||||
}),
|
||||
);
|
||||
|
||||
// v2.5 没有高清贴图档位,也没方形布线:档位降级、开关关掉并禁用。
|
||||
expect(screen.getByLabelText('当前档位').textContent).toBe(
|
||||
'textured-standard',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('方形布线') as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText('智能低多边形') as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.getByLabelText('当前分件').textContent).toBe('false');
|
||||
});
|
||||
|
||||
it('版本不支持的开关保持可见但禁用,并带上原因', () => {
|
||||
applyPricing(MULTI_VERSION_PRICING);
|
||||
render(
|
||||
<Model3dModalHarness
|
||||
initialDialog={createDialog('model3d-text-to-model', {
|
||||
prompt: '一把木椅',
|
||||
model3dModel: 'P2-20260801',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// P2 支持方形布线,不支持智能低模与分件。
|
||||
expect(
|
||||
(screen.getByLabelText('方形布线') as HTMLInputElement).disabled,
|
||||
).toBe(false);
|
||||
const smartLowPoly = screen.getByLabelText('智能低多边形');
|
||||
expect((smartLowPoly as HTMLInputElement).disabled).toBe(true);
|
||||
expect(smartLowPoly.closest('label')?.getAttribute('title')).toContain(
|
||||
'智能低多边形只支持',
|
||||
);
|
||||
});
|
||||
|
||||
it('版本没有底价时不显示兜底价格,也不可提交', () => {
|
||||
applyPricing(MULTI_VERSION_PRICING);
|
||||
render(
|
||||
<Model3dModalHarness
|
||||
initialDialog={createDialog('model3d-text-to-model', {
|
||||
prompt: '一把木椅',
|
||||
model3dModel: 'P1-20260311',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect((submitButton() as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(submitButton()).getByText('—')).toBeTruthy();
|
||||
expect(panel().getByRole('alert').textContent).toContain('定价');
|
||||
});
|
||||
|
||||
it('失败态下主按钮也开新一代,不会重现同一次失败', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const failedDialog = createDialog('model3d-text-to-model', {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MODEL3D_GENERATION_SUBMIT_LABEL,
|
||||
refreshModel3dAttemptNonce,
|
||||
resolveModel3dGenerationQuote,
|
||||
resolveModel3dModelOptions,
|
||||
} from './Model3dGenerationFormModel';
|
||||
import { resolveModel3dImageSource } from './Model3dGenerationSubmission';
|
||||
|
||||
@@ -58,11 +59,15 @@ export function Model3dGenerationModal({
|
||||
onSubmit,
|
||||
}: Model3dGenerationModalProps) {
|
||||
const isGenerating = dialog.status === 'generating';
|
||||
const quote = resolveModel3dGenerationQuote(
|
||||
dialog,
|
||||
readEditorModel3dPricingConfig(),
|
||||
);
|
||||
const isImageMode = dialog.mode === 'model3d-image-to-model';
|
||||
// 定价缓存只读一次:报价与模型选项必须来自同一份快照,否则会出现「报价用的是旧价、
|
||||
// 选项用的是新价」这种半截状态。
|
||||
const pricing = readEditorModel3dPricingConfig();
|
||||
const quote = resolveModel3dGenerationQuote(dialog, pricing);
|
||||
const modelOptions = resolveModel3dModelOptions({
|
||||
pricing,
|
||||
endpoint: isImageMode ? 'image-to-model' : 'text-to-model',
|
||||
});
|
||||
const hasRequiredInput = isImageMode
|
||||
? Boolean(resolveModel3dImageSource(dialog))
|
||||
: Boolean(dialog.prompt.trim());
|
||||
@@ -94,6 +99,7 @@ export function Model3dGenerationModal({
|
||||
<Model3dGenerationForm
|
||||
dialog={dialog}
|
||||
disabled={isGenerating}
|
||||
modelOptions={modelOptions}
|
||||
updateDialog={(updater) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog ? updater(currentDialog) : currentDialog,
|
||||
|
||||
@@ -37,6 +37,27 @@ const PRICING: Model3dPricingConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const MULTI_VERSION_PRICING: Model3dPricingConfig = {
|
||||
basePrices: {
|
||||
'text-to-model': {
|
||||
'v3.1-20260211': { noTexture: 100, texture: 200 },
|
||||
'P2-20260801': { noTexture: 80, texture: 160 },
|
||||
},
|
||||
'image-to-model': {
|
||||
'v3.1-20260211': { noTexture: 150, texture: 250 },
|
||||
'P2-20260801': { noTexture: 170, texture: 270 },
|
||||
},
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 10,
|
||||
ultraTexture: 20,
|
||||
hdGeometry: 30,
|
||||
quadMesh: 40,
|
||||
smartLowPoly: 50,
|
||||
generateParts: 60,
|
||||
},
|
||||
};
|
||||
|
||||
const CANVAS_COMPLETION = {
|
||||
dialogId: 'dialog-3d',
|
||||
title: '3D 模型',
|
||||
@@ -148,6 +169,43 @@ describe('buildModel3dSubmissionPlan', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('提交体跟着对话框选中的模型版本走,且端点与提交体一一配对', () => {
|
||||
const textPlan = buildModel3dSubmissionPlan({
|
||||
dialog: createDialog('model3d-text-to-model', {
|
||||
prompt: '木椅',
|
||||
model3dModel: 'P2-20260801',
|
||||
}),
|
||||
projectId: 'project-1',
|
||||
pricing: MULTI_VERSION_PRICING,
|
||||
});
|
||||
expect(textPlan.ok).toBe(true);
|
||||
if (!textPlan.ok) {
|
||||
return;
|
||||
}
|
||||
expect(textPlan.plan.endpoint).toBe('text-to-model');
|
||||
expect(textPlan.plan.body).toMatchObject({
|
||||
generation: { model: 'P2-20260801', prompt: '木椅' },
|
||||
});
|
||||
|
||||
const imagePlan = buildModel3dSubmissionPlan({
|
||||
dialog: createDialog('model3d-image-to-model', {
|
||||
model3dModel: 'P2-20260801',
|
||||
generationReferences: [canvasReference({ resourceId: 'resource-1' })],
|
||||
}),
|
||||
projectId: 'project-1',
|
||||
pricing: MULTI_VERSION_PRICING,
|
||||
});
|
||||
expect(imagePlan.ok).toBe(true);
|
||||
if (!imagePlan.ok) {
|
||||
return;
|
||||
}
|
||||
expect(imagePlan.plan.endpoint).toBe('image-to-model');
|
||||
expect(imagePlan.plan.body).toMatchObject({
|
||||
source: { kind: 'resource', resourceId: 'resource-1' },
|
||||
generation: { model: 'P2-20260801' },
|
||||
});
|
||||
});
|
||||
|
||||
it('没有素材夹时只给 projectId:素材库落点与素材名都不进请求体', () => {
|
||||
const result = buildModel3dSubmissionPlan({
|
||||
dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }),
|
||||
|
||||
@@ -13,13 +13,12 @@ import type {
|
||||
import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient';
|
||||
import type { GenerateDialogState } from '../ImageCanvasEditorTypes';
|
||||
import {
|
||||
DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
isModel3dGenerationMode,
|
||||
mintModel3dAttemptNonce,
|
||||
MODEL3D_MODE_UNSUPPORTED_REASON,
|
||||
resolveModel3dEndpoint,
|
||||
resolveModel3dGenerationParams,
|
||||
resolveModel3dGenerationQuote,
|
||||
resolveModel3dModelVersion,
|
||||
} from './Model3dGenerationFormModel';
|
||||
|
||||
export const MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE = '请填写 3D 模型描述';
|
||||
@@ -34,8 +33,13 @@ export const MODEL3D_GENERATION_TIMEOUT_MESSAGE =
|
||||
export const MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE =
|
||||
'3D 生成任务已提交,但服务端没有返回任务状态,请稍后重试。';
|
||||
|
||||
export type Model3dSubmissionPlan = {
|
||||
endpoint: Model3dPricingEndpoint;
|
||||
/**
|
||||
* 提交计划按端点做成判别联合:`endpoint` 同时决定提交地址与提交体契约。
|
||||
*
|
||||
* 拆成「endpoint: 联合 + body: 联合」两个独立字段时,图生请求体配文生端点同样能通过
|
||||
* 编译,错误只能等远端按字段报出来。判别联合让调用方分支后拿到确定类型的提交体。
|
||||
*/
|
||||
type Model3dSubmissionPlanBase = {
|
||||
/**
|
||||
* 本次提交使用的尝试代次;调用方必须把它写回 dialog 状态。
|
||||
*
|
||||
@@ -45,10 +49,19 @@ export type Model3dSubmissionPlan = {
|
||||
attemptNonce: string;
|
||||
requestKey: string;
|
||||
title: string;
|
||||
/** 提交体就是端点契约本身:形状错误在编译期暴露,而不是等服务端拒绝。 */
|
||||
body: Model3dTextToModelRequest | Model3dImageToModelRequest;
|
||||
};
|
||||
|
||||
export type Model3dSubmissionPlan =
|
||||
| (Model3dSubmissionPlanBase & {
|
||||
endpoint: Extract<Model3dPricingEndpoint, 'text-to-model'>;
|
||||
/** 提交体就是端点契约本身:形状错误在编译期暴露,而不是等服务端拒绝。 */
|
||||
body: Model3dTextToModelRequest;
|
||||
})
|
||||
| (Model3dSubmissionPlanBase & {
|
||||
endpoint: Extract<Model3dPricingEndpoint, 'image-to-model'>;
|
||||
body: Model3dImageToModelRequest;
|
||||
});
|
||||
|
||||
export function resolveModel3dResultTitle(dialog: GenerateDialogState): string {
|
||||
const prompt = dialog.prompt.trim();
|
||||
if (dialog.mode === 'model3d-text-to-model' && prompt) {
|
||||
@@ -113,7 +126,9 @@ export function buildModel3dSubmissionPlan({
|
||||
if (!quote.ok) {
|
||||
return { ok: false, message: quote.reason };
|
||||
}
|
||||
const model = resolveModel3dModelVersion(dialog.model3dModel);
|
||||
const paramsResult = resolveModel3dGenerationParams({
|
||||
model,
|
||||
tier: dialog.model3dTier ?? 'textured-standard',
|
||||
quad: dialog.model3dQuad ?? false,
|
||||
smartLowPoly: dialog.model3dSmartLowPoly ?? false,
|
||||
@@ -123,12 +138,13 @@ export function buildModel3dSubmissionPlan({
|
||||
return { ok: false, message: paramsResult.reason };
|
||||
}
|
||||
const params = paramsResult.params;
|
||||
const endpoint = resolveModel3dEndpoint(dialog.mode);
|
||||
const attemptNonce = resolveModel3dAttemptNonce(dialog.model3dAttemptNonce);
|
||||
// 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格,
|
||||
// 唯一例外是 texture=false 档位不带 textureQuality(后端显式拒绝这个组合)。
|
||||
// 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格。
|
||||
// 两个例外都来自 provider 预检(`platform-tripo::validate_generation_options`):
|
||||
// texture=false 档位不能带 textureQuality,v2.5 拒绝任何 textureQuality ——
|
||||
// 两者都按「不发该字段」处理,后端缺省按 standard 计价,与面板报价同一档。
|
||||
const generation = {
|
||||
model: DEFAULT_MODEL3D_MODEL_VERSION,
|
||||
model,
|
||||
texture: params.texture,
|
||||
pbr: params.pbr,
|
||||
...(params.textureQuality ? { textureQuality: params.textureQuality } : {}),
|
||||
@@ -152,19 +168,37 @@ export function buildModel3dSubmissionPlan({
|
||||
// 画布占位框是项目资源落点的回填载荷:没有工程就没有画布可以回填。
|
||||
...(trimmedProjectId && canvasCompletion ? { canvasCompletion } : {}),
|
||||
};
|
||||
const finish = (
|
||||
body: Model3dTextToModelRequest | Model3dImageToModelRequest,
|
||||
// 一个端点一个收口函数:端点字面量与提交体类型在同一处配对,判别联合据此收窄。
|
||||
const title = resolveModel3dResultTitle(dialog);
|
||||
const finishTextToModel = (
|
||||
body: Model3dTextToModelRequest,
|
||||
): { ok: true; plan: Model3dSubmissionPlan } => ({
|
||||
ok: true,
|
||||
plan: {
|
||||
endpoint,
|
||||
endpoint: 'text-to-model',
|
||||
attemptNonce,
|
||||
requestKey: resolveModel3dRequestKey({
|
||||
attemptNonce,
|
||||
endpoint,
|
||||
endpoint: 'text-to-model',
|
||||
body,
|
||||
}),
|
||||
title: resolveModel3dResultTitle(dialog),
|
||||
title,
|
||||
body,
|
||||
},
|
||||
});
|
||||
const finishImageToModel = (
|
||||
body: Model3dImageToModelRequest,
|
||||
): { ok: true; plan: Model3dSubmissionPlan } => ({
|
||||
ok: true,
|
||||
plan: {
|
||||
endpoint: 'image-to-model',
|
||||
attemptNonce,
|
||||
requestKey: resolveModel3dRequestKey({
|
||||
attemptNonce,
|
||||
endpoint: 'image-to-model',
|
||||
body,
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
},
|
||||
});
|
||||
@@ -174,14 +208,17 @@ export function buildModel3dSubmissionPlan({
|
||||
if (!source) {
|
||||
return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE };
|
||||
}
|
||||
return finish({ source, generation, ...placement });
|
||||
return finishImageToModel({ source, generation, ...placement });
|
||||
}
|
||||
|
||||
const prompt = dialog.prompt.trim();
|
||||
if (!prompt) {
|
||||
return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE };
|
||||
}
|
||||
return finish({ generation: { ...generation, prompt }, ...placement });
|
||||
return finishTextToModel({
|
||||
generation: { ...generation, prompt },
|
||||
...placement,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Model3dModelVersion } from '../../../../packages/shared/src/contracts/model3d';
|
||||
import {
|
||||
MODEL3D_MODEL_VERSIONS,
|
||||
MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED,
|
||||
MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES,
|
||||
MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE,
|
||||
MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED,
|
||||
MODEL3D_REASON_QUAD_UNSUPPORTED,
|
||||
MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED,
|
||||
MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED,
|
||||
type Model3dGenerationOptions,
|
||||
resolveModel3dModelCapabilities,
|
||||
resolveModel3dModelFamily,
|
||||
resolveModel3dModelLabel,
|
||||
validateModel3dGenerationOptions,
|
||||
validateModel3dPricingParams,
|
||||
validateModel3dRequestParams,
|
||||
} from './Model3dGenerationValidation';
|
||||
|
||||
const H31: Model3dModelVersion = 'v3.1-20260211';
|
||||
const H30: Model3dModelVersion = 'v3.0-20250812';
|
||||
const H25: Model3dModelVersion = 'v2.5-20250123';
|
||||
const P1: Model3dModelVersion = 'P1-20260311';
|
||||
const P2: Model3dModelVersion = 'P2-20260801';
|
||||
|
||||
/** 只给必填与平台必显式项:任何版本都应放行(对齐 provider 的 minimal_options 用例)。 */
|
||||
function minimalOptions(model: Model3dModelVersion): Model3dGenerationOptions {
|
||||
return {
|
||||
model,
|
||||
texture: false,
|
||||
pbr: false,
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('模型版本表', () => {
|
||||
it('覆盖契约声明的 5 个版本,标签取版本族', () => {
|
||||
expect(MODEL3D_MODEL_VERSIONS).toEqual([H31, H30, H25, P1, P2]);
|
||||
expect(MODEL3D_MODEL_VERSIONS.map(resolveModel3dModelLabel)).toEqual([
|
||||
'v3.1',
|
||||
'v3.0',
|
||||
'v2.5',
|
||||
'P1',
|
||||
'P2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('能力面按 provider 的模型族分档', () => {
|
||||
expect(resolveModel3dModelCapabilities(H31)).toEqual({
|
||||
family: resolveModel3dModelFamily(H31),
|
||||
detailedTextureTier: true,
|
||||
quad: true,
|
||||
smartLowPoly: true,
|
||||
generateParts: true,
|
||||
});
|
||||
expect(resolveModel3dModelCapabilities(H25)).toMatchObject({
|
||||
detailedTextureTier: false,
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
});
|
||||
// P1 只能标准贴图 / 纯几何;P2 多一个方形布线,仍不支持智能低模与分件。
|
||||
expect(resolveModel3dModelCapabilities(P1)).toMatchObject({
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
});
|
||||
expect(resolveModel3dModelCapabilities(P2)).toMatchObject({
|
||||
quad: true,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModel3dGenerationOptions', () => {
|
||||
it('最简参数对每个版本都放行', () => {
|
||||
for (const model of MODEL3D_MODEL_VERSIONS) {
|
||||
expect(
|
||||
validateModel3dGenerationOptions(minimalOptions(model)),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('贴图档位只有 v2.5 被 provider 拒绝,其余版本放行', () => {
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(H25),
|
||||
texture: true,
|
||||
textureQuality: 'standard',
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'textureQuality',
|
||||
reason: MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED,
|
||||
});
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(H30),
|
||||
texture: true,
|
||||
textureQuality: 'standard',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('高清几何只 v3.x 支持,其余版本被拒', () => {
|
||||
for (const model of [H31, H30]) {
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(model),
|
||||
texture: true,
|
||||
geometryQuality: 'detailed',
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
for (const model of [H25, P1, P2]) {
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(model),
|
||||
geometryQuality: 'detailed',
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'geometryQuality',
|
||||
reason: MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('方形布线只 v3.x 与 P2 支持', () => {
|
||||
for (const model of [H31, H30, P2]) {
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(model),
|
||||
quad: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
for (const model of [H25, P1]) {
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(model),
|
||||
quad: true,
|
||||
}),
|
||||
).toEqual({ field: 'quad', reason: MODEL3D_REASON_QUAD_UNSUPPORTED });
|
||||
}
|
||||
});
|
||||
|
||||
it('智能低多边形与分件只 v3.x 支持', () => {
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(P2),
|
||||
smartLowPoly: true,
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'smartLowPoly',
|
||||
reason: MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED,
|
||||
});
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(P2),
|
||||
generateParts: true,
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'generateParts',
|
||||
reason: MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it('分件的两个前置条件分开报:贴图 / pbr / 几何模式各自有原因', () => {
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(H31),
|
||||
texture: true,
|
||||
pbr: false,
|
||||
generateParts: true,
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'generateParts',
|
||||
reason: MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE,
|
||||
});
|
||||
expect(
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(H31),
|
||||
generateParts: true,
|
||||
// 平台口径要求 texture=false 时 pbr 显式 false,这里只验证分件与几何模式的冲突。
|
||||
quad: true,
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'generateParts',
|
||||
reason: MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES,
|
||||
});
|
||||
});
|
||||
|
||||
it('面数上限按版本与几何模式给出边界', () => {
|
||||
const within = (
|
||||
model: Model3dModelVersion,
|
||||
faceLimit: number,
|
||||
extra: Partial<Model3dGenerationOptions> = {},
|
||||
) =>
|
||||
validateModel3dGenerationOptions({
|
||||
...minimalOptions(model),
|
||||
faceLimit,
|
||||
...extra,
|
||||
});
|
||||
|
||||
expect(within(H31, 1_500_000)).toBeNull();
|
||||
expect(within(H31, 1_500_001)).not.toBeNull();
|
||||
expect(within(H30, 1_000_000)).toBeNull();
|
||||
expect(within(H30, 1_000_001)).not.toBeNull();
|
||||
expect(within(H25, 500_000)).toBeNull();
|
||||
expect(within(H25, 500_001)).not.toBeNull();
|
||||
expect(within(P1, 49)).not.toBeNull();
|
||||
expect(within(P1, 50)).toBeNull();
|
||||
expect(within(P1, 20_000)).toBeNull();
|
||||
expect(within(P2, 25_000, { quad: true })).toBeNull();
|
||||
expect(within(P2, 25_001, { quad: true })).not.toBeNull();
|
||||
// 智能低模把上限锁到 20k(叠 quad 时 10k)。
|
||||
expect(within(H31, 20_000, { smartLowPoly: true })).toBeNull();
|
||||
expect(within(H31, 20_001, { smartLowPoly: true })).not.toBeNull();
|
||||
expect(within(H31, 10_000, { smartLowPoly: true, quad: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('平台口径与合并校验', () => {
|
||||
it('会改价的参数必须显式给出', () => {
|
||||
expect(
|
||||
validateModel3dPricingParams({
|
||||
model: H31,
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toEqual({ field: 'texture', reason: '必须显式给出是否生成贴图' });
|
||||
expect(
|
||||
validateModel3dPricingParams({
|
||||
model: H31,
|
||||
texture: false,
|
||||
pbr: false,
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
}),
|
||||
).toEqual({ field: 'generateParts', reason: '必须显式给出是否生成分件' });
|
||||
});
|
||||
|
||||
it('texture=false 时不允许贴图档位、pbr 必须显式 false', () => {
|
||||
expect(
|
||||
validateModel3dPricingParams({
|
||||
...minimalOptions(H31),
|
||||
textureQuality: 'standard',
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'textureQuality',
|
||||
reason: 'texture=false 时不允许出现贴图档位',
|
||||
});
|
||||
expect(
|
||||
validateModel3dPricingParams({ ...minimalOptions(H31), pbr: true }),
|
||||
).toEqual({
|
||||
field: 'pbr',
|
||||
reason: 'texture=false 时 pbr 必须显式给 false',
|
||||
});
|
||||
});
|
||||
|
||||
it('合并校验按后端顺序:平台口径在前,provider 能力在后', () => {
|
||||
expect(
|
||||
validateModel3dRequestParams({
|
||||
model: H25,
|
||||
texture: true,
|
||||
pbr: false,
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
textureQuality: 'standard',
|
||||
geometryQuality: 'detailed',
|
||||
}),
|
||||
).toEqual({
|
||||
field: 'textureQuality',
|
||||
reason: MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import type {
|
||||
Model3dCompression,
|
||||
Model3dGeometryQuality,
|
||||
Model3dModelVersion,
|
||||
Model3dTextureQuality,
|
||||
Model3dTextureVersion,
|
||||
} from '../../../../packages/shared/src/contracts/model3d';
|
||||
|
||||
/**
|
||||
* 面板参数的唯一校验实现,逐条对齐后端两份规则:
|
||||
*
|
||||
* 1. `platform-tripo::common::validation::validate_generation_options`:模型族与参数取值的
|
||||
* 能力组合(provider 会拒的组合在这里先被挡下);
|
||||
* 2. `api-server::tripo3d::validation::PricingParamView::validate`:平台口径 —— 会改价的
|
||||
* 参数必须显式给出、贴图与 pbr 不能互相矛盾。
|
||||
*
|
||||
* 为什么要抄一份:契约与公开读模型只暴露「价格」,不暴露「哪些版本可选、每个版本支持
|
||||
* 哪些参数」,面板要在用户点提交前就把会被 provider 拒的组合变成禁用态与原因文案。
|
||||
* 抄的是**规则**,不是数值:判据只看模型族与参数取值,`Model3dGenerationFormModel.test.ts`
|
||||
* 用组合表把两边钉在一起。规则冲突时以 provider 为准(失败关闭),不是以本文件为准。
|
||||
*
|
||||
* 不抄的部分:wire 层收敛(不支持 `geometryQuality` 的族把 `standard` 收敛成不发、
|
||||
* `textureQuality` 缺席时的默认档)由 `platform-tripo` 独家负责,前端的职责只是给出
|
||||
* 契约层合法的参数。
|
||||
*/
|
||||
|
||||
/** provider 的模型族(对齐 `platform-tripo::common::validation::ModelFamily`)。 */
|
||||
export type Model3dModelFamily = 'v31' | 'v30' | 'v25' | 'p1' | 'p2';
|
||||
|
||||
/**
|
||||
* 版本表是唯一一份前端模型元数据,按契约 `Model3dModelVersion` 的声明顺序书写。
|
||||
*
|
||||
* 类型是 `Record<Model3dModelVersion, ...>`:契约新增模型版本后,`npm run
|
||||
* contracts:model3d:generate` 更新 TS 联合类型,这里会直接编译失败,逼迫补齐标签与族,
|
||||
* 而不是等到运行期才发现面板少一个版本。运行期需要「版本列表」时用 `Object.keys`。
|
||||
*/
|
||||
export const MODEL3D_MODEL_TABLE: Record<
|
||||
Model3dModelVersion,
|
||||
{ label: string; family: Model3dModelFamily }
|
||||
> = {
|
||||
'v3.1-20260211': { label: 'v3.1', family: 'v31' },
|
||||
'v3.0-20250812': { label: 'v3.0', family: 'v30' },
|
||||
'v2.5-20250123': { label: 'v2.5', family: 'v25' },
|
||||
'P1-20260311': { label: 'P1', family: 'p1' },
|
||||
'P2-20260801': { label: 'P2', family: 'p2' },
|
||||
};
|
||||
|
||||
/** 契约支持的模型版本(运行期列表,顺序即声明顺序)。 */
|
||||
export const MODEL3D_MODEL_VERSIONS = Object.keys(
|
||||
MODEL3D_MODEL_TABLE,
|
||||
) as Model3dModelVersion[];
|
||||
|
||||
export function isModel3dModelVersion(
|
||||
value: string,
|
||||
): value is Model3dModelVersion {
|
||||
return value in MODEL3D_MODEL_TABLE;
|
||||
}
|
||||
|
||||
export function resolveModel3dModelLabel(model: Model3dModelVersion): string {
|
||||
return MODEL3D_MODEL_TABLE[model].label;
|
||||
}
|
||||
|
||||
export function resolveModel3dModelFamily(
|
||||
model: Model3dModelVersion,
|
||||
): Model3dModelFamily {
|
||||
return MODEL3D_MODEL_TABLE[model].family;
|
||||
}
|
||||
|
||||
/** v3.1 / v3.0:`geometry_quality` 与 `compress` 只在这两个族上存在。 */
|
||||
function isV3Family(family: Model3dModelFamily) {
|
||||
return family === 'v31' || family === 'v30';
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板要用的能力面:只描述「哪个控件该禁用」,规则本身仍只写在下面的校验函数里。
|
||||
*/
|
||||
export type Model3dModelCapabilities = {
|
||||
family: Model3dModelFamily;
|
||||
/** 高清贴图档位:需要 detailed 贴图 + detailed 几何,只有 v3.x 同时满足。 */
|
||||
detailedTextureTier: boolean;
|
||||
quad: boolean;
|
||||
smartLowPoly: boolean;
|
||||
generateParts: boolean;
|
||||
};
|
||||
|
||||
export function resolveModel3dModelCapabilities(
|
||||
model: Model3dModelVersion,
|
||||
): Model3dModelCapabilities {
|
||||
const family = resolveModel3dModelFamily(model);
|
||||
return {
|
||||
family,
|
||||
detailedTextureTier: isV3Family(family),
|
||||
quad: isV3Family(family) || family === 'p2',
|
||||
smartLowPoly: isV3Family(family),
|
||||
generateParts: isV3Family(family),
|
||||
};
|
||||
}
|
||||
|
||||
/** 与后端 `v3_model_labels()` 同口径:文案里的 v3.x 标签取自版本表,不手写第二份版本号。 */
|
||||
const V3_MODEL_LABELS = `${MODEL3D_MODEL_TABLE['v3.1-20260211'].label} / ${MODEL3D_MODEL_TABLE['v3.0-20250812'].label}`;
|
||||
const P2_MODEL_LABEL = MODEL3D_MODEL_TABLE['P2-20260801'].label;
|
||||
const V25_MODEL_LABEL = MODEL3D_MODEL_TABLE['v2.5-20250123'].label;
|
||||
|
||||
/** 被拒原因文案:面板与禁用态共用同一份常量,不再各自抄一份中文。 */
|
||||
export const MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED = `${V25_MODEL_LABEL} 不支持贴图档位,请改用其它版本或纯几何档位`;
|
||||
export const MODEL3D_REASON_FAST_TEXTURE_REQUIRES_V35 =
|
||||
'快速贴图档位必须同时指定贴图版本 v3.5';
|
||||
export const MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED = `高清几何只支持 ${V3_MODEL_LABELS}`;
|
||||
export const MODEL3D_REASON_COMPRESS_UNSUPPORTED = `几何压缩只支持 ${V3_MODEL_LABELS}`;
|
||||
export const MODEL3D_REASON_AUTO_SIZE_UNSUPPORTED = `${V25_MODEL_LABEL} 不支持自动尺寸`;
|
||||
export const MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED = `智能低多边形只支持 ${V3_MODEL_LABELS}`;
|
||||
export const MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED = `分件只支持 ${V3_MODEL_LABELS}`;
|
||||
export const MODEL3D_REASON_QUAD_UNSUPPORTED = `方形布线只支持 ${V3_MODEL_LABELS} / ${P2_MODEL_LABEL}`;
|
||||
export const MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE =
|
||||
'分件生成只支持纯几何档位';
|
||||
export const MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_PBR =
|
||||
'分件生成要求 pbr 显式关闭';
|
||||
export const MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES =
|
||||
'分件生成不能与方形布线或智能低多边形同时使用';
|
||||
export const MODEL3D_REASON_FACE_LIMIT_OUT_OF_RANGE =
|
||||
'面数上限超出当前版本与参数的取值范围';
|
||||
|
||||
/**
|
||||
* 生成参数视图:字段与 `platform-tripo::common::validation::TripoGenerationOptions` 一一对应,
|
||||
* 校验只看这些字段。未在本面板暴露的参数(`faceLimit` / `compress` 等)照样参与校验:
|
||||
* 老快照或将来新入口带出来的取值也必须过同一套规则。
|
||||
*/
|
||||
export type Model3dGenerationOptions = {
|
||||
model: Model3dModelVersion;
|
||||
texture?: boolean | null;
|
||||
pbr?: boolean | null;
|
||||
textureQuality?: Model3dTextureQuality | null;
|
||||
textureVersion?: Model3dTextureVersion | null;
|
||||
geometryQuality?: Model3dGeometryQuality | null;
|
||||
faceLimit?: number | null;
|
||||
autoSize?: boolean | null;
|
||||
quad?: boolean | null;
|
||||
smartLowPoly?: boolean | null;
|
||||
generateParts?: boolean | null;
|
||||
compress?: Model3dCompression | null;
|
||||
};
|
||||
|
||||
/** 校验失败:`field` 用契约请求里的字段名,便于直接定位到面板上的控件。 */
|
||||
export type Model3dGenerationFailure = {
|
||||
field: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const FAST_TEXTURE_VERSION: Model3dTextureVersion = 'v3.5-20260815';
|
||||
|
||||
/** 面数上限边界,与 `platform-tripo::common::validation::face_limit_bounds` 逐条对齐。 */
|
||||
function resolveModel3dFaceLimitBounds(options: Model3dGenerationOptions): {
|
||||
min: number;
|
||||
max: number;
|
||||
} {
|
||||
if (options.smartLowPoly === true) {
|
||||
return options.quad === true
|
||||
? { min: 500, max: 10_000 }
|
||||
: { min: 500, max: 20_000 };
|
||||
}
|
||||
switch (resolveModel3dModelFamily(options.model)) {
|
||||
case 'v25':
|
||||
return { min: 1, max: 500_000 };
|
||||
case 'v30':
|
||||
case 'v31': {
|
||||
const max = resolveModel3dV3FaceLimitMax(options);
|
||||
return { min: 1, max };
|
||||
}
|
||||
case 'p1':
|
||||
return { min: 50, max: 20_000 };
|
||||
case 'p2':
|
||||
return options.quad === true
|
||||
? { min: 48, max: 25_000 }
|
||||
: { min: 48, max: 50_000 };
|
||||
}
|
||||
}
|
||||
|
||||
/** v3.1 的默认上限比 v3.0 高一档,其余分叉相同(quad / detailed 几何各压一档)。 */
|
||||
function resolveModel3dV3FaceLimitMax(options: Model3dGenerationOptions) {
|
||||
if (options.quad === true) {
|
||||
return 150_000;
|
||||
}
|
||||
if (options.geometryQuality === 'detailed') {
|
||||
return 2_000_000;
|
||||
}
|
||||
return options.model === 'v3.0-20250812' ? 1_000_000 : 1_500_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 provider 的顺序返回**第一条**失败;顺序与后端一致,因此同一组参数在两边得到同一个
|
||||
* 被拒字段与同一条规则,用例可以逐条对照。
|
||||
*/
|
||||
export function validateModel3dGenerationOptions(
|
||||
options: Model3dGenerationOptions,
|
||||
): Model3dGenerationFailure | null {
|
||||
const family = resolveModel3dModelFamily(options.model);
|
||||
|
||||
if (options.textureQuality != null && family === 'v25') {
|
||||
return {
|
||||
field: 'textureQuality',
|
||||
reason: MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
options.textureQuality === 'fast' &&
|
||||
options.textureVersion !== FAST_TEXTURE_VERSION
|
||||
) {
|
||||
return {
|
||||
field: 'textureQuality',
|
||||
reason: MODEL3D_REASON_FAST_TEXTURE_REQUIRES_V35,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.geometryQuality === 'detailed' && !isV3Family(family)) {
|
||||
return {
|
||||
field: 'geometryQuality',
|
||||
reason: MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.compress != null && !isV3Family(family)) {
|
||||
return {
|
||||
field: 'compress',
|
||||
reason: MODEL3D_REASON_COMPRESS_UNSUPPORTED,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.autoSize === true && family === 'v25') {
|
||||
return {
|
||||
field: 'autoSize',
|
||||
reason: MODEL3D_REASON_AUTO_SIZE_UNSUPPORTED,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.smartLowPoly === true && !isV3Family(family)) {
|
||||
return {
|
||||
field: 'smartLowPoly',
|
||||
reason: MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.generateParts === true && !isV3Family(family)) {
|
||||
return {
|
||||
field: 'generateParts',
|
||||
reason: MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.quad === true && !(isV3Family(family) || family === 'p2')) {
|
||||
return { field: 'quad', reason: MODEL3D_REASON_QUAD_UNSUPPORTED };
|
||||
}
|
||||
|
||||
// 分件的三个前置条件与 provider 一样分开判:面板一处按档位禁用、一处按开关禁用,
|
||||
// 合并成一条会让用户看不出到底哪个条件不满足。
|
||||
if (options.generateParts === true && options.texture !== false) {
|
||||
return {
|
||||
field: 'generateParts',
|
||||
reason: MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.generateParts === true && options.pbr !== false) {
|
||||
return {
|
||||
field: 'generateParts',
|
||||
reason: MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_PBR,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
options.generateParts === true &&
|
||||
(options.quad === true || options.smartLowPoly === true)
|
||||
) {
|
||||
return {
|
||||
field: 'generateParts',
|
||||
reason: MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.faceLimit != null) {
|
||||
const { min, max } = resolveModel3dFaceLimitBounds(options);
|
||||
if (options.faceLimit < min || options.faceLimit > max) {
|
||||
return {
|
||||
field: 'faceLimit',
|
||||
reason: MODEL3D_REASON_FACE_LIMIT_OUT_OF_RANGE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台侧口径(`api-server::tripo3d::validation`):会改价的参数必须显式给出,
|
||||
* `texture=false` 时不许出现贴图档位、`pbr` 必须显式 `false`。
|
||||
*/
|
||||
export function validateModel3dPricingParams(
|
||||
options: Model3dGenerationOptions,
|
||||
): Model3dGenerationFailure | null {
|
||||
if (options.texture == null) {
|
||||
return { field: 'texture', reason: '必须显式给出是否生成贴图' };
|
||||
}
|
||||
if (options.quad == null) {
|
||||
return { field: 'quad', reason: '必须显式给出是否方形布线' };
|
||||
}
|
||||
if (options.smartLowPoly == null) {
|
||||
return { field: 'smartLowPoly', reason: '必须显式给出是否智能低模' };
|
||||
}
|
||||
if (options.generateParts == null) {
|
||||
return { field: 'generateParts', reason: '必须显式给出是否生成分件' };
|
||||
}
|
||||
if (options.texture === false && options.textureQuality != null) {
|
||||
return {
|
||||
field: 'textureQuality',
|
||||
reason: 'texture=false 时不允许出现贴图档位',
|
||||
};
|
||||
}
|
||||
if (options.texture === false && options.pbr !== false) {
|
||||
return { field: 'pbr', reason: 'texture=false 时 pbr 必须显式给 false' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 平台口径 + provider 口径,按后端顺序跑完,返回第一条失败。 */
|
||||
export function validateModel3dRequestParams(
|
||||
options: Model3dGenerationOptions,
|
||||
): Model3dGenerationFailure | null {
|
||||
return (
|
||||
validateModel3dPricingParams(options) ??
|
||||
validateModel3dGenerationOptions(options)
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
import { MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE } from './Model3dGenerationSubmission';
|
||||
import { useModel3dGenerationTask } from './useModel3dGenerationTask';
|
||||
|
||||
const submitModel3dGenerationRequestMock = vi.hoisted(() => vi.fn());
|
||||
const submitModel3dTextToModelRequestMock = vi.hoisted(() => vi.fn());
|
||||
const submitModel3dImageToModelRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -33,10 +34,18 @@ vi.mock('../../../services/image-editor/editorProjectClient', async () => {
|
||||
>('../../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
submitModel3dGenerationRequest: submitModel3dGenerationRequestMock,
|
||||
submitModel3dTextToModelRequest: submitModel3dTextToModelRequestMock,
|
||||
submitModel3dImageToModelRequest: submitModel3dImageToModelRequestMock,
|
||||
};
|
||||
});
|
||||
|
||||
/** 两个端点各一个客户端函数,用例按当前端点取对应那个 mock。 */
|
||||
function submitRequestMock(endpoint: 'text-to-model' | 'image-to-model') {
|
||||
return endpoint === 'text-to-model'
|
||||
? submitModel3dTextToModelRequestMock
|
||||
: submitModel3dImageToModelRequestMock;
|
||||
}
|
||||
|
||||
const PRICING: Model3dPricingConfig = {
|
||||
basePrices: {
|
||||
'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } },
|
||||
@@ -128,7 +137,8 @@ function renderTask({
|
||||
|
||||
describe('useModel3dGenerationTask', () => {
|
||||
beforeEach(() => {
|
||||
submitModel3dGenerationRequestMock.mockReset();
|
||||
submitModel3dTextToModelRequestMock.mockReset();
|
||||
submitModel3dImageToModelRequestMock.mockReset();
|
||||
applyEditorGenerationPricingConfig({
|
||||
models: EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
model3d: PRICING,
|
||||
@@ -142,7 +152,7 @@ describe('useModel3dGenerationTask', () => {
|
||||
status: QUEUED_STATUS,
|
||||
statusUrl: '/api/runtime/external-generation/jobs/task-1',
|
||||
};
|
||||
submitModel3dGenerationRequestMock.mockResolvedValue(submitResponse);
|
||||
submitModel3dTextToModelRequestMock.mockResolvedValue(submitResponse);
|
||||
const applyQueuedGeneration = vi.fn(async () => true);
|
||||
const { result, onQueuedGenerationTask } = renderTask({
|
||||
dialog: createDialog(),
|
||||
@@ -154,9 +164,8 @@ describe('useModel3dGenerationTask', () => {
|
||||
await result.current.submitModel3dGeneration(createDialog());
|
||||
});
|
||||
|
||||
expect(submitModel3dGenerationRequestMock).toHaveBeenCalledTimes(1);
|
||||
const request = submitModel3dGenerationRequestMock.mock.calls[0][0];
|
||||
expect(request.endpoint).toBe('text-to-model');
|
||||
expect(submitModel3dTextToModelRequestMock).toHaveBeenCalledTimes(1);
|
||||
const request = submitModel3dTextToModelRequestMock.mock.calls[0][0];
|
||||
expect(request.idempotencyKey).toMatch(/^[\x21-\x7e]{1,128}$/);
|
||||
expect(request.body).toMatchObject({
|
||||
generation: {
|
||||
@@ -186,7 +195,7 @@ describe('useModel3dGenerationTask', () => {
|
||||
});
|
||||
|
||||
it('缺代次的老快照兜底铸一个并写回,幂等键跟着这个代次走', async () => {
|
||||
submitModel3dGenerationRequestMock.mockResolvedValue({
|
||||
submitModel3dTextToModelRequestMock.mockResolvedValue({
|
||||
operationId: 'task-1',
|
||||
jobKind: 'model3d_text_to_model',
|
||||
status: QUEUED_STATUS,
|
||||
@@ -199,14 +208,14 @@ describe('useModel3dGenerationTask', () => {
|
||||
await result.current.submitModel3dGeneration(legacyDialog);
|
||||
});
|
||||
|
||||
const request = submitModel3dGenerationRequestMock.mock.calls[0][0];
|
||||
const request = submitModel3dTextToModelRequestMock.mock.calls[0][0];
|
||||
const persistedNonce = result.current.dialog.model3dAttemptNonce;
|
||||
expect(persistedNonce).toBeTruthy();
|
||||
expect(request.idempotencyKey.startsWith(`${persistedNonce}-`)).toBe(true);
|
||||
});
|
||||
|
||||
it('排队链路没接手时回到可再次提交的空闲态', async () => {
|
||||
submitModel3dGenerationRequestMock.mockResolvedValue({
|
||||
submitModel3dTextToModelRequestMock.mockResolvedValue({
|
||||
operationId: 'task-1',
|
||||
jobKind: 'model3d_text_to_model',
|
||||
status: QUEUED_STATUS,
|
||||
@@ -227,7 +236,7 @@ describe('useModel3dGenerationTask', () => {
|
||||
|
||||
it('2xx 但没有任务状态时给出失败提示,而不是静默复位', async () => {
|
||||
// requestJson 在空 body 时返回 null,这里覆盖「成功但没有 status」的形态。
|
||||
submitModel3dGenerationRequestMock.mockResolvedValue(null);
|
||||
submitModel3dTextToModelRequestMock.mockResolvedValue(null);
|
||||
const applyQueuedGeneration = vi.fn(async () => true);
|
||||
const { result } = renderTask({
|
||||
dialog: createDialog(),
|
||||
@@ -246,7 +255,7 @@ describe('useModel3dGenerationTask', () => {
|
||||
});
|
||||
|
||||
it('请求失败时留在面板上展示失败原因', async () => {
|
||||
submitModel3dGenerationRequestMock.mockRejectedValue(
|
||||
submitModel3dTextToModelRequestMock.mockRejectedValue(
|
||||
new Error('3D 模型生成失败,请稍后重试。'),
|
||||
);
|
||||
const { result } = renderTask({ dialog: createDialog() });
|
||||
@@ -276,7 +285,7 @@ describe('useModel3dGenerationTask', () => {
|
||||
await result.current.submitModel3dGeneration(createDialog());
|
||||
});
|
||||
|
||||
expect(submitModel3dGenerationRequestMock).not.toHaveBeenCalled();
|
||||
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
||||
expect(applyQueuedGeneration).not.toHaveBeenCalled();
|
||||
expect(result.current.dialog.status).toBe('failed');
|
||||
expect(result.current.dialog.errorMessage).toBe(
|
||||
@@ -291,7 +300,7 @@ describe('useModel3dGenerationTask', () => {
|
||||
await result.current.submitModel3dGeneration(createDialog());
|
||||
});
|
||||
|
||||
expect(submitModel3dGenerationRequestMock).not.toHaveBeenCalled();
|
||||
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
||||
expect(result.current.dialog.status).toBe('failed');
|
||||
expect(result.current.dialog.errorMessage).toContain('保存当前画布工程');
|
||||
});
|
||||
@@ -303,7 +312,7 @@ describe('useModel3dGenerationTask', () => {
|
||||
status: QUEUED_STATUS,
|
||||
statusUrl: '/api/runtime/external-generation/jobs/task-2',
|
||||
};
|
||||
submitModel3dGenerationRequestMock.mockResolvedValue(submitResponse);
|
||||
submitModel3dTextToModelRequestMock.mockResolvedValue(submitResponse);
|
||||
const { result } = renderTask({
|
||||
dialog: createDialog(),
|
||||
projectId: null,
|
||||
@@ -314,13 +323,45 @@ describe('useModel3dGenerationTask', () => {
|
||||
await result.current.submitModel3dGeneration(createDialog());
|
||||
});
|
||||
|
||||
expect(submitModel3dGenerationRequestMock).toHaveBeenCalledTimes(1);
|
||||
const request = submitModel3dGenerationRequestMock.mock.calls[0][0];
|
||||
expect(submitModel3dTextToModelRequestMock).toHaveBeenCalledTimes(1);
|
||||
const request = submitModel3dTextToModelRequestMock.mock.calls[0][0];
|
||||
expect(request.body).not.toHaveProperty('projectId');
|
||||
expect(request.body).not.toHaveProperty('canvasCompletion');
|
||||
expect(request.body).toMatchObject({ assetFolderId: 'project' });
|
||||
});
|
||||
|
||||
it('图生 3D 走图生客户端函数:端点与提交体不会错配', async () => {
|
||||
const imageMock = submitRequestMock('image-to-model');
|
||||
imageMock.mockResolvedValue({
|
||||
operationId: 'task-3',
|
||||
jobKind: 'model3d_image_to_model',
|
||||
status: QUEUED_STATUS,
|
||||
statusUrl: '/api/runtime/external-generation/jobs/task-3',
|
||||
});
|
||||
const dialog = createDialog({
|
||||
mode: 'model3d-image-to-model',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'reference-1',
|
||||
label: '参考图',
|
||||
src: 'data:image/png;base64,1',
|
||||
resourceId: 'resource-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
const { result } = renderTask({ dialog });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitModel3dGeneration(dialog);
|
||||
});
|
||||
|
||||
expect(imageMock).toHaveBeenCalledTimes(1);
|
||||
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
||||
expect(imageMock.mock.calls[0]?.[0].body).toMatchObject({
|
||||
source: { kind: 'resource', resourceId: 'resource-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('非 3D 对话框不做任何事', async () => {
|
||||
const { result } = renderTask({
|
||||
dialog: createDialog({ mode: 'generate' }),
|
||||
@@ -332,6 +373,6 @@ describe('useModel3dGenerationTask', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(submitModel3dGenerationRequestMock).not.toHaveBeenCalled();
|
||||
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
EditorCanvasGenerationCompletionInput,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../../services/image-editor/editorProjectClient';
|
||||
import { submitModel3dGenerationRequest } from '../../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
submitModel3dImageToModelRequest,
|
||||
submitModel3dTextToModelRequest,
|
||||
} from '../../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
GenerateDialogState,
|
||||
@@ -106,11 +109,18 @@ export function useModel3dGenerationTask({
|
||||
errorMessage: undefined,
|
||||
}));
|
||||
try {
|
||||
const response = await submitModel3dGenerationRequest({
|
||||
endpoint: planResult.plan.endpoint,
|
||||
body: planResult.plan.body,
|
||||
idempotencyKey: planResult.plan.requestKey,
|
||||
});
|
||||
const plan = planResult.plan;
|
||||
// 判别联合在这里收窄:文生与图生各走自己的客户端函数,端点与提交体的配对由类型保证。
|
||||
const response =
|
||||
plan.endpoint === 'text-to-model'
|
||||
? await submitModel3dTextToModelRequest({
|
||||
body: plan.body,
|
||||
idempotencyKey: plan.requestKey,
|
||||
})
|
||||
: await submitModel3dImageToModelRequest({
|
||||
body: plan.body,
|
||||
idempotencyKey: plan.requestKey,
|
||||
});
|
||||
const queueState = model3dQueueStateFromResponse(response);
|
||||
if (!queueState) {
|
||||
// 2xx 但没有任务状态(空 body 或缺 status 的 payload)不能当成功静默复位:
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { EditorSceneGenerationRequest } from '../../../packages/shared/src/
|
||||
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type {
|
||||
Model3dImageToModelRequest,
|
||||
Model3dModelVersion,
|
||||
Model3dTextToModelRequest,
|
||||
} from '../../../packages/shared/src/contracts/model3d';
|
||||
import { requestJson } from '../apiClient';
|
||||
@@ -725,12 +726,6 @@ export type EditorGenerationModelPricing = {
|
||||
|
||||
export type Model3dPricingEndpoint = 'text-to-model' | 'image-to-model';
|
||||
|
||||
/** endpoint 到提交地址的穷尽映射:新增 endpoint 时这里会缺键并报编译错误。 */
|
||||
const EDITOR_MODEL3D_ENDPOINT_API: Record<Model3dPricingEndpoint, string> = {
|
||||
'text-to-model': EDITOR_MODEL3D_TEXT_TO_MODEL_API,
|
||||
'image-to-model': EDITOR_MODEL3D_IMAGE_TO_MODEL_API,
|
||||
};
|
||||
|
||||
export type Model3dAddOnPriceKey =
|
||||
| 'hdTexture'
|
||||
| 'ultraTexture'
|
||||
@@ -744,10 +739,22 @@ export type Model3dAddOnPriceKey =
|
||||
* add-on 按请求参数判定后叠加。整段缺失即 3D 生成不可用(后端 fail closed),
|
||||
* 前端不内置任何兜底数值。
|
||||
*/
|
||||
export type Model3dBasePrice = {
|
||||
noTexture: number;
|
||||
texture: number;
|
||||
};
|
||||
|
||||
export type Model3dPricingConfig = {
|
||||
basePrices?: Record<
|
||||
Model3dPricingEndpoint,
|
||||
Record<string, { noTexture: number; texture: number }>
|
||||
/**
|
||||
* 底价按 `endpoint × modelVersion` 二维展开。两个键都写成契约枚举而不是 `string`:
|
||||
* 后端公开读模型保证「每个契约版本 × 两个端点」都有底价,用联合类型做键能让写错的
|
||||
* 版本号在编译期暴露,也能在契约新增版本后逼着调用方处理新键。
|
||||
*/
|
||||
basePrices?: Partial<
|
||||
Record<
|
||||
Model3dPricingEndpoint,
|
||||
Partial<Record<Model3dModelVersion, Model3dBasePrice>>
|
||||
>
|
||||
> | null;
|
||||
addOnPrices?: Partial<Record<Model3dAddOnPriceKey, number>> | null;
|
||||
};
|
||||
@@ -1737,21 +1744,51 @@ export type Model3dGenerationSubmissionResponse = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 3D 生成提交:两个端点都要求 `Idempotency-Key`,键由调用方铸造并与当次请求内容绑定。
|
||||
* 这里是画布链路里唯一发送该请求头的生成入口。
|
||||
* 3D 文生提交:端点与请求体在同一个函数签名里绑死。
|
||||
*
|
||||
* 单写成「endpoint + 联合请求体」的函数时,端点与提交体是两个彼此独立的联合类型,
|
||||
* 把 image-to-model 的请求体发到 text-to-model 地址同样编译通过 —— 服务端只会给出
|
||||
* 一个与实际错误无关的字段级报错。所以一个端点一个函数,配对关系由类型强制。
|
||||
*
|
||||
* 两个端点都要求 `Idempotency-Key`,键由调用方铸造并与当次请求内容绑定。
|
||||
*/
|
||||
export async function submitModel3dGenerationRequest({
|
||||
endpoint,
|
||||
export async function submitModel3dTextToModelRequest({
|
||||
body,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
endpoint: Model3dPricingEndpoint;
|
||||
// 提交体直接用端点契约:形状不符(落点缺失、generation 结构错)在编译期就会暴露。
|
||||
body: Model3dTextToModelRequest | Model3dImageToModelRequest;
|
||||
body: Model3dTextToModelRequest;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
return postModel3dGenerationRequest(
|
||||
EDITOR_MODEL3D_TEXT_TO_MODEL_API,
|
||||
body,
|
||||
idempotencyKey,
|
||||
);
|
||||
}
|
||||
|
||||
/** 3D 图生提交:与文生提交同形,只换端点与请求体契约。 */
|
||||
export async function submitModel3dImageToModelRequest({
|
||||
body,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
body: Model3dImageToModelRequest;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
return postModel3dGenerationRequest(
|
||||
EDITOR_MODEL3D_IMAGE_TO_MODEL_API,
|
||||
body,
|
||||
idempotencyKey,
|
||||
);
|
||||
}
|
||||
|
||||
async function postModel3dGenerationRequest(
|
||||
url: string,
|
||||
body: Model3dTextToModelRequest | Model3dImageToModelRequest,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return requestJson<Model3dGenerationSubmissionResponse>(
|
||||
EDITOR_MODEL3D_ENDPOINT_API[endpoint],
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user