3D 预览产物 content type 改为按字节识别

- storage.rs 新增 sniff_preview_content_type(PNG / JPEG / WebP),resolve_artifact_content_type 改为按槽位判定:预览与模型一样先嗅探字节,嗅不出才回落声明值(最终仍回落 image/webp)
- 修掉 provider / CDN 不写或写错 Content-Type 时 PNG 预览被写成 image/webp 并传播到 OSS metadata、asset_object.content_type 与生成结果的问题;对象键扩展名随之正确
- 定向用例重写为「字节优先 / 未知回落」两组,替换原先钉住「预览不嗅探」的用例
- 技术方案与决策记录同步该口径,并记录与其它图片链路(bgfilter / VectorEngine / 生成图落 OSS 适配器)的一致性对照
This commit is contained in:
2026-09-23 15:28:23 +08:00
parent 1fa413a352
commit c64bfc3867
3 changed files with 100 additions and 24 deletions
@@ -51,7 +51,7 @@ source = { kind: "resource", resourceId } | { kind: "asset", assetId }
引用解析分两步,提交与执行互不代替:**提交时只做元数据预检**——按 `source.kind` 的分支做定点归属校验,确认记录类型与对象键存在,不读图片正文、不调用 provider,因此跨 owner、未登记、已删除与 `kind` 不符的引用都在扣费与入队之前返回 400;跨 owner、未登记与已删除对外收敛成同一句不可用,不泄漏他人 ID 是否存在,`kind` 与解析结果不符则单独报错(两个值都由调用方给出,不涉及探测他人数据);**worker 执行时重新确认同一事实**,通过后才读一次图片正文并上传换 `file_token`。预检只按主键定点查引用,不允许拉取当前用户的完整工程列表或素材库。
**结果落点**(两个端点都必填)与其它生成接口同形,是平坦的可选字段,不用 tagged enum;客户端不必为“落项目还是落素材库”多拼一层判别结构:
**结果落点**(两个端点至少给一个)与其它生成接口同形,是平坦的可选字段,不用 tagged enum;客户端不必为“落项目还是落素材库”多拼一层判别结构:
```text
projectId? 项目资源落点
@@ -60,9 +60,11 @@ assetLabel? 素材名称,缺省用平台默认名
canvasCompletion? 画布占位框回填,只在项目资源落点下生效
```
`projectId``assetFolderId` 二选一且只能给一个:两个都不给、两个都给都拒绝;`assetLabel` 只在素材库落点下有默认值,`canvasCompletion` `assetFolderId` 同现拒绝。不存在“都不给就默认落素材库”的口径。
`projectId``assetFolderId` 至少给一个,并且允许同时给:两个都不给直接 400;同时给时画布资源行与素材行两条都写,与图片等画布生成工具完全一致(画布链路本来就会把“当前素材夹”一起发出去,见下)。`assetLabel` 只在素材库落点下生效,缺省用本次结果标题;`canvasCompletion` 是项目资源落点的回填载荷,“没有 `projectId` 却带占位框”被拒绝。不存在“都不给就默认落素材库”的口径。
落点预检与其它付费编辑器生成共用同一条路径:提交时调只读 `preflight_editor_generation_target_and_return`,按认证 owner 对给定的那个落点做定点归属校验(不再为了一个目录 ID 读整个素材库),跨 owner / 已删除 / 不存在收敛成同一句 400;预检传下去的就是 trim 后的原值,校验值与入队、落库的值逐字一致。`project` 与 owner 默认素材目录的归一由落库 procedure 负责,因此本期不做 API 层的 `folder-*` → 默认目录改写——那需要把归一值一路带进入队载荷,等真正打开素材库落点入口时再补
落点预检与其它付费编辑器生成共用同一条路径:提交时调只读 `preflight_editor_generation_target_and_return`,按认证 owner 对给出的落点逐个定点归属校验(两个都给就两个都查,不再为了一个目录 ID 读整个素材库),跨 owner / 已删除 / 不存在收敛成同一句 400。
**归一在预检之前完成并写回请求**:项目 ID 与素材夹 ID 都 trim,`project` / 旧 `folder-*` 走与图片画布同一个 `normalize_generated_asset_folder_id` 映射到当前 owner 的默认素材夹,素材名走同一个 `resolve_editor_generated_asset_label`trim + 截断 + 缺省「3D 模型」)。入队的就是归一后的请求,因此预检值 = 队列载荷 = 落库值;模块侧落库原有的 `normalize_editor_generation_default_asset_folders` 继续兜底老队列载荷里的 `project`
**定价相关参数在 API 层必填并做组合校验**`texture``textureQuality``geometryQuality``quad``smartLowPoly``generateParts` 必须显式给出。`texture=false` 时禁止出现 `textureQuality`,且 `pbr` 必须显式 `false``texture=true``textureQuality` 必填。理由是 provider 的隐式默认值会直接改变价格,一旦依赖默认值,报价与扣费会在“调用方少传字段”时分叉。
@@ -92,7 +94,7 @@ ImageToModelResult = { modelArtifact, renderedPreview }
每个 artifact 只携带正式资源引用与对象元数据,不携带 provider 临时 URL。
**完成结果的读取**:与其它画布生成任务同形——结果落在画布 / 素材行,客户端靠读回拿到。项目资源落点的 worker 写画布 placement(预览图层),客户端在任务终态后用 `loadEditorProject` 复读项目快照,按 `layer.objectKey` 打开模型;素材库落点写素材行。标准消费者的队列结果只回来源身份,**不透传 `result`**,因此 `/api/runtime/external-generation/jobs/{jobId}``result` 对 3D 为空——图片等其它画布生成任务也是如此。上面那份严格 tagged enum 是 worker 落库时构造的内部结果值:它只携带正式资源引用与对象元数据,不携带 provider 事实。
**完成结果的读取**:与其它画布生成任务同形——结果落在画布 / 素材行,客户端靠读回拿到。项目资源落点的 worker 写画布 placement(预览图层),客户端在任务终态后用 `loadEditorProject` 复读项目快照,按 `layer.objectKey` 打开模型;素材库落点写素材行(画布链路两个落点都发,所以一次 3D 生成通常两条都写)。结果里的落点引用与请求落点同形:`target` 不再是被判别结构包着的分支,而是平坦的 `resourceId` / `assetId`,哪个落点写了行就带哪个 ID。前端画布链路的结果消费沿用既有画布路径(项目快照 + `applyGeneratedProjectSnapshot` 顺带刷新素材库),素材库行的 3D 缩略图按预览图渲染、不拿模型对象换签名地址。标准消费者的队列结果只回来源身份,**不透传 `result`**,因此 `/api/runtime/external-generation/jobs/{jobId}``result` 对 3D 为空——图片等其它画布生成任务也是如此。上面那份严格 tagged enum 是 worker 落库时构造的内部结果值:它只携带正式资源引用与对象元数据,不携带 provider 事实。
## 定价与扣费
@@ -159,7 +161,7 @@ thumbnailSrc = 预览图
width/height = 预览图像素尺寸
```
模型格式与体积由 `AssetObject` 承担(`content_type``content_length``content_hash`),因此不新增 `model_format``size_bytes``poly_count` 等列。`content_type` 在写入前按字节魔数识别真实产物GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary`glTF 的 JSON 正文,不直接采信 provider 返回或下载响应头声明的类型对象键扩展名由该内容类型派生。客户端不复制这份格式清单:画布只按「`assetKind=model3d``objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。
模型格式与体积由 `AssetObject` 承担(`content_type``content_length``content_hash`),因此不新增 `model_format``size_bytes``poly_count` 等列。两个槽位的 `content_type` 在写入前按字节识别真实产物:模型按魔数认 GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary`glTF 的 JSON 正文,预览按魔数认 PNG / JPEG / WebP不直接采信 provider 返回或下载响应头声明的类型对象键扩展名由该内容类型派生。只有字节判不出来时才回落到声明值(预览最终回落 `image/webp`)。客户端不复制这份格式清单:画布只按「`assetKind=model3d``objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。
`external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。
@@ -176,9 +178,9 @@ width/height = 预览图像素尺寸
| 用户标签覆盖资格 | 允许,但只在 `model3d` 与空类别之间成立:3D 资源自成一族媒体语义,图片族不能覆盖成 3D,反之亦然 |
| 快速编辑资格 | 不允许。3D 结果没有图片快速编辑语义,不进入快速编辑正向白名单;画布上「3D 预览」是只读查看能力,不是快速编辑 |
| “改造” capability 与 V2 配方 | 不进入改造 allowlist,不定义 V2 `action` / `fields[].id` / `references[].id`,不发 `canvasCompletion` 之外的可重放配方;画布对 `model3d` 关闭改造入口 |
| 素材库 / 后台 / 精选 read model / 公开 grant / External OpenAPI | 本期不改。素材库与后台沿用现有`assetKind` 派生 renderer 的行为,`model3d` 未接 renderer 前只保证字段正确,不承诺展示;公开 grant 与 External v1 不涉及 |
| 素材库 / 后台 / 精选 read model / 公开 grant / External OpenAPI | 素材库行已`assetKind` 派生 renderer 的口径接上 `model3d` 分支(缩略图走预览图,模型本体不进图片入口);后台列表、精选 read model、公开 grant 与 External v1 不涉及,只保证字段正确 |
清单第 3 条要求的“用户标签覆盖”与“快速编辑”两项资格评估互不推导:标签覆盖进入(限 `model3d` 与空类别互转),快速编辑与改造均不进入,理由是 3D 资源没有像素级编辑语义。画布入口只新增只读的「3D 预览」capability 位,由宿主按 `supportedActions` 声明可用;素材库行后台 renderer 与精选 read model 仍未接,后续进入时必须单独补齐 renderer 与验收。
清单第 3 条要求的“用户标签覆盖”与“快速编辑”两项资格评估互不推导:标签覆盖进入(限 `model3d` 与空类别互转),快速编辑与改造均不进入,理由是 3D 资源没有像素级编辑语义。画布入口只新增只读的「3D 预览」capability 位,由宿主按 `supportedActions` 声明可用;素材库行已接预览渲染与拖入画布,后台 renderer 与精选 read model 仍未接,后续进入时必须单独补齐 renderer 与验收。
## 兼容与迁移
@@ -197,7 +199,7 @@ width/height = 预览图像素尺寸
7. submit 成功但 checkpoint 写入失败的 attempt 终态失败且不自动重提。
8. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。
9. worker 落库时构造的完成结果按端点严格类型化,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL;标准消费者的队列结果不透传它(与其它画布生成任务一致,结果靠画布 / 素材读回)。
10. 素材库落点产出 `assetId`;项目资源落点产出 `resourceId`,且 `assetKind=model3d``AssetObject.content_type` 按字节识别出的模型 mime、`content_length` 与实际对象一致。
10. 素材库落点产出 `assetId`;项目资源落点产出 `resourceId`,且 `assetKind=model3d`模型与预览的 `AssetObject.content_type` 都是按字节识别出的 mime(预览不再一律写成 `image/webp``content_length` 与实际对象一致。
11. 客户端媒体投影对 `model3d` 只暴露预览图:`imageSrc` / `thumbnailSrc` 指向预览对象、`objectKey` 指向模型本体;预览缺失时才回落到模型对象,且该回落不得让图片入口加载模型文件。
12. 格式判定的顺序在查看器包内可被用例钉住:声明 `Content-Type` 优先于字节魔数、字节魔数优先于地址扩展名;三者都判不出来时模态展示 `unsupported-format` 原因与支持的格式清单,画布侧不做任何格式判断、也不在 `assetKind` 为空时按扩展名兜底。
13. 真实 Provider smoke 覆盖 text-to-model 与 image-to-model 各一次,下载字节数与 `Content-Length` 一致。
@@ -207,4 +209,4 @@ width/height = 预览图像素尺寸
- 加价项已按端点各持一份价目(当前数值相同);若 Tripo 后续对某个模型版本差异化加价,再补“按模型版本覆盖”一层,现在不预留。
- 3D 模型的多文件 glTF 兄弟资源(`.bin`、贴图)、多视角与模型信息展示仍未安排;画布侧接入见 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](../project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)。
- AGC 项目画布自带两份格式判断(`resourceModelScene.ts``isResourceModelFbx``octet-stream` 当 FBX、`resourceCardPreviewModel.ts` 的资源卡预览按扩展名正则选解析器),本期不动,后续统一收敛到 `packages/model3d-viewer`素材库行、后台 renderer、精选 read model 与公开 grant 的 3D 展示同样未进入。
- AGC 项目画布自带两份格式判断(`resourceModelScene.ts``isResourceModelFbx``octet-stream` 当 FBX、`resourceCardPreviewModel.ts` 的资源卡预览按扩展名正则选解析器),本期不动,后续统一收敛到 `packages/model3d-viewer`;后台 renderer、精选 read model 与公开 grant 的 3D 展示同样未进入(素材库行只做预览图渲染与拖入画布,行内不开 3D 查看器)