From c64bfc3867e3375e8fe92907d7ef612bacedc1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 15:28:23 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E9=A2=84=E8=A7=88=E4=BA=A7=E7=89=A9=20con?= =?UTF-8?q?tent=20type=20=E6=94=B9=E4=B8=BA=E6=8C=89=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 适配器)的一致性对照 --- .../shared-memory/decision-log.md | 10 ++ ...技术方案】Tripo 3D生成API集成-2026-09-21.md | 20 ++-- .../crates/api-server/src/tripo3d/storage.rs | 94 ++++++++++++++++--- 3 files changed, 100 insertions(+), 24 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index aa270a746..b17b9051b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9403,3 +9403,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`server-rs/crates/api-server/src/tripo3d/image_source.rs`(收敛分支、新增 `image_source_object_key_missing`、模块文档与定向用例)、[实施计划 Tripo生成Worker执行链路与API路由](../plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md)。 - 验证方式:`cargo test --locked -p api-server tripo3d::image_source`(4 passed,含 `image_source_resolution_only_collapses_unavailable_references` 覆盖 400 / 404 收敛与 403 / 409 / 500 保留、`image_source_resolution_reports_missing_object_key_as_server_side_failure` 断言 502 与 reason)、`rustfmt --check`。 - 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成Worker执行链路与API路由](../plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md)。 + +## 2026-09-23 3D 预览产物 content type 改为按字节识别:只信 provider 声明的做法作废 + +- 背景:`tripo3d/storage.rs` 的 `Preview` 槽位原先不做字节嗅探,`application/octet-stream` 或空 `Content-Type` 一律写成 `image/webp` + `.webp` 文件名;而 `worker.rs` 的 `preview_dimensions` 早已用 `ImageReader::with_guessed_format()` 解出过真实格式(PNG / JPEG)却没往下传。Tripo 的渲染图 CDN 不写或写错 `Content-Type` 时,PNG 预览就会带着 `image/webp` 进 OSS metadata、`asset_object.content_type` 与生成结果的 `Model3dGeneratedArtifact.content_type`。 +- 决策:预览槽位与模型槽位同处一个判定点,按字节魔数识别 PNG / JPEG / WebP 后再决定 content type;识别范围外(含模型字节、非图片字节)保持既有回落(声明值可用就用声明值,否则 `image/webp`)。模型槽位行为不变。 +- 原因:**浏览器能容忍,不代表记录值可以继续说谎**。`` 会按魔数嗅探,所以前端预览一直没坏;但 `assets.rs` 的 `"contentType"` 出参、素材库下载命名、后台与将来的服务端处理都信任 `asset_object.content_type`,对象键扩展名也由它派生。仓库同类图片链路的现行口径都是「字节是真相」——bgfilter 用 `guess_format` 定 mime 并把声明头只当一致性校验、VectorEngine 生成图走 `infer_image_mime_type`、生成图落 OSS 适配器的后缀与 PUT `content_type` 都取自嗅探结果;只有嗅不动的视频(`character_animation_assets.rs`)与回读已存对象的 HEAD 才信 header。本次把预览槽位对齐到这条口径,而不是删字段:删字段只会把 `image/webp` 换成同样错的 `application/octet-stream`,还要付 `Model3dGeneratedArtifact.content_type` 的破坏性契约变更代价。 +- 代价与取舍:多一次只读文件头的嗅探(不解码像素);worker 的 `preview_dimensions` 与 storage 的嗅探仍是两处调用,但前者是「必须能解码」的正交校验、后者是写库前的类型归一,判定点只有一个(`resolve_artifact_content_type`)。嗅探不到的格式不猜,仍回落 `image/webp`,因此不会因为一张少见格式的渲染图而失败退款。 +- 影响面:`server-rs/crates/api-server/src/tripo3d/storage.rs`(新增 `sniff_preview_content_type`、`resolve_artifact_content_type` 改为按槽位判定、定向用例重写为「字节优先 / 未知回落」两组)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。 +- 验证方式:`cargo test --locked -p api-server tripo3d::`(56 passed,含 `preview_content_type_follows_bytes_over_declaration` 与 `preview_content_type_keeps_declaration_when_bytes_are_unknown`)、`rustfmt --check`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`。 +- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成API契约与数据模型](../plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md)。 diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index ab4a838d7..ac724e190 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md @@ -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 查看器)。 diff --git a/server-rs/crates/api-server/src/tripo3d/storage.rs b/server-rs/crates/api-server/src/tripo3d/storage.rs index c2f693042..d50ffa118 100644 --- a/server-rs/crates/api-server/src/tripo3d/storage.rs +++ b/server-rs/crates/api-server/src/tripo3d/storage.rs @@ -210,15 +210,43 @@ fn sniff_model_content_type(bytes: &[u8]) -> Option<&'static str> { None } -/// 产物 content type:模型先嗅探字节,嗅不出来才信 provider 声明。 +/// 预览图按字节魔数识别格式。识别范围就是线上真正会拿到的三种渲染图格式,其余返回 `None` +/// 走声明值回落 —— 不为了少见的格式去猜一个可能同样错的类型。 +fn sniff_preview_content_type(bytes: &[u8]) -> Option<&'static str> { + let format = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .format()?; + match format { + image::ImageFormat::Png => Some("image/png"), + image::ImageFormat::Jpeg => Some("image/jpeg"), + image::ImageFormat::WebP => Some("image/webp"), + _ => None, + } +} + +/// 产物 content type:两个槽位都先嗅探字节,嗅不出来才信 provider 声明。 /// /// provider 对二进制产物常给 `application/octet-stream`,归一后会被写成 /// `model/gltf-binary` + `.glb`;客户端按这份 content type 判定能不能预览,说错就是 -/// 拿 GLTFLoader 去解 FBX。预览图不嗅探:它已经有维度解析这条更硬的正向校验。 +/// 拿 GLTFLoader 去解 FBX。预览图同理:provider / CDN 常把 PNG 渲染图标成 +/// `application/octet-stream` 或干脆不写 `Content-Type`,照抄声明就会把 `image/webp` +/// 写进 OSS metadata 与 `asset_object.content_type`,素材库下载命名与后台等非浏览器消费方 +/// 都会拿到错的类型。浏览器 `` 自己能按魔数嗅探,但那不是让记录值继续说谎的理由。 +/// +/// 这里不把 worker 的 `preview_dimensions` 猜出的格式传下来:那一步是「必须能解码」的正交校验, +/// 与写库时的类型归一各管一件事,重复的只是读文件头这一下,换来判定点只有一个。 fn resolve_artifact_content_type(raw: &str, slot: Model3dArtifactSlot, bytes: &[u8]) -> String { - if slot == Model3dArtifactSlot::Model { - if let Some(sniffed) = sniff_model_content_type(bytes) { - return sniffed.to_string(); + match slot { + Model3dArtifactSlot::Model => { + if let Some(sniffed) = sniff_model_content_type(bytes) { + return sniffed.to_string(); + } + } + Model3dArtifactSlot::Preview => { + if let Some(sniffed) = sniff_preview_content_type(bytes) { + return sniffed.to_string(); + } } } normalize_artifact_content_type(raw, slot) @@ -250,6 +278,9 @@ mod tests { const FBX_BYTES: &[u8] = b"Kaydara FBX Binary \x00\x1a\x00rest-of-fbx"; const GLTF_JSON_BYTES: &[u8] = b"\xEF\xBB\xBF\n {\"asset\":{\"version\":\"2.0\"}}"; const UNKNOWN_BYTES: &[u8] = b"\x00\x01not-a-model"; + const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\nrest-of-png"; + const JPEG_BYTES: &[u8] = b"\xFF\xD8\xFF\xE0rest-of-jpeg"; + const WEBP_BYTES: &[u8] = b"RIFF\x24\x00\x00\x00WEBPVP8 rest-of-webp"; #[test] fn model_content_type_follows_bytes_over_declaration() { @@ -331,8 +362,49 @@ mod tests { } #[test] - fn preview_content_type_is_not_sniffed() { - // 预览图不按模型魔数嗅探:即使字节恰好长得像模型,也走图片的既有归一。 + fn preview_content_type_follows_bytes_over_declaration() { + // Tripo 的渲染图常带 application/octet-stream(或被 CDN 标错):照抄声明会把 PNG + // 写成 .webp,类型与扩展名一起错到 asset_object.content_type 与素材库下载命名上。 + for (bytes, declared, expected) in [ + (PNG_BYTES, "application/octet-stream", "image/png"), + (PNG_BYTES, "", "image/png"), + (PNG_BYTES, "image/jpeg", "image/png"), + (JPEG_BYTES, "application/octet-stream", "image/jpeg"), + (WEBP_BYTES, "application/octet-stream", "image/webp"), + ] { + assert_eq!( + resolve_artifact_content_type(declared, Model3dArtifactSlot::Preview, bytes), + expected, + "声明 {declared} 的预览图必须按字节判定" + ); + } + // 类型与对象键扩展名同源于这一次判定:PNG 预览落库是 preview.png。 + assert_eq!( + artifact_extension("image/png", Model3dArtifactSlot::Preview), + "png" + ); + } + + #[test] + fn preview_content_type_keeps_declaration_when_bytes_are_unknown() { + // 嗅不出图片格式时保持既有行为:声明可用就信声明,否则回落 webp。 + assert_eq!( + resolve_artifact_content_type( + "image/jpeg; charset=binary", + Model3dArtifactSlot::Preview, + UNKNOWN_BYTES, + ), + "image/jpeg" + ); + assert_eq!( + resolve_artifact_content_type( + "application/octet-stream", + Model3dArtifactSlot::Preview, + UNKNOWN_BYTES, + ), + "image/webp" + ); + // 模型字节不是预览图格式,不参与预览判定,仍走图片归一。 assert_eq!( resolve_artifact_content_type( "application/octet-stream", @@ -341,14 +413,6 @@ mod tests { ), "image/webp" ); - assert_eq!( - resolve_artifact_content_type( - "image/jpeg; charset=binary", - Model3dArtifactSlot::Preview, - GLB_BYTES, - ), - "image/jpeg" - ); } #[test]