From 453e351fc6dfb23d6538ce4b739b527d9b315e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 19:51:47 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E8=B5=84=E6=BA=90=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=9B=BE=E6=97=B6=E5=B0=81=E9=9D=A2=E7=95=99?= =?UTF-8?q?=E7=A9=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8B=BF=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E9=A1=B6=E4=B8=8A=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server:`project_editor_client_media_src` 对 `model3d` 在没有预览(`thumbnail_src` 与存储的 `image_src` 都缺)时直接返回空,不再回落到模型对象;用例改成断言空串,并补「存储的 image_src 是空白」一例 - docs/adr 0003:写明这是唯一允许「有对象但 `imageSrc` 为空」的类别,消费方按空值显示占位;被否决项里补上「拿模型对象冒充 imageSrc 的投影回落」 - docs/technical Tripo 集成方案:资源表说明改成「有预览才填」,验收项 11 改成「预览缺失时留空、不得回落成模型对象」 - 画布侧 `Model3dViewerModel` 的注释同步:历史布局里仍可能留着指向模型本体的地址,图片元素不能加载它 --- ...资源客户端媒体投影与格式真实性-2026-09-21.md | 6 ++-- ...技术方案】Tripo 3D生成API集成-2026-09-21.md | 6 ++-- .../crates/api-server/src/editor_project.rs | 28 ++++++++++++++----- .../model3d-preview/Model3dViewerModel.ts | 5 ++-- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md index 00b9df2bd..c809beb88 100644 --- a/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md +++ b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md @@ -2,13 +2,13 @@ 状态:已接受 -3D 生成结果在资源行里同时持有三个字段:`objectKey` 是模型本体,`imageSrc` 与 `thumbnailSrc` 是预览图。客户端可见媒体投影不能再沿用「`objectKey` 非空就以它充当 `imageSrc`」的历史口径,否则画布卡片、素材缩略图与后台都会把 `.glb` 交给图片元素加载;因此投影改为按 `asset_kind` 分叉:`model3d` 只把预览图投影给客户端(依次取 `thumbnail_src`、`image_src`),模型本体只留在 `objectKey` 里交给 3D 查看器,其它类别保持原样,两个来源都缺失时才回落到历史口径。 +3D 生成结果在资源行里同时持有三个字段:`objectKey` 是模型本体,`imageSrc` 与 `thumbnailSrc` 是预览图。客户端可见媒体投影不能再沿用「`objectKey` 非空就以它充当 `imageSrc`」的历史口径,否则画布卡片、素材缩略图与后台都会把 `.glb` 交给图片元素加载;因此投影改为按 `asset_kind` 分叉:`model3d` 只把预览图投影给客户端(依次取 `thumbnail_src`、`image_src`),模型本体只留在 `objectKey` 里交给 3D 查看器;`model3d` 的两个预览来源都缺失时 `imageSrc` 留空(消费方显示占位图),这是唯一允许「有对象但 `imageSrc` 为空」的类别。其它类别保持原样,两个来源都缺失时仍回落到历史口径。 模型格式的真相同样只保留一处:后端写入 `asset_object.content_type` 之前按字节魔数识别真实产物(GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头、glTF 的 JSON 正文),对象键扩展名由该内容类型派生而不是反向推断。客户端不复制这份格式清单,也不在画布层按扩展名预先判定放行:画布只认「`assetKind=model3d` 且 `objectKey` 非空」,真正的格式判定跟着解析器走——`packages/model3d-viewer` 依次采信读接口声明的 `Content-Type`、字节魔数、地址扩展名,格式在该包里是可选输入,三者都判不出来时以 `unsupported-format` 失败,并把原因与支持的格式清单显示在模态里,而不是把入口藏起来去让用户猜。 -被否决的做法:让画布或宿主外壳按对象键扩展名自行判断放行(客户端第二份格式清单,查看器扩了格式后画布仍会继续禁用入口);给资源表新增 `model_format` 列(同一真相两个来源,且 provider 上报的格式不可信);预览图缺失时把模型文件直接喂给图片元素(静默坏图、无法诊断,也说不出「该资源没有可预览的模型」这类错误信息);在画布侧补「`assetKind` 为空但对象键像模型」的扩展名兜底(这种资源本就不该出现,兜底只会掩盖后端投影缺陷)。 +被否决的做法:让画布或宿主外壳按对象键扩展名自行判断放行(客户端第二份格式清单,查看器扩了格式后画布仍会继续禁用入口);给资源表新增 `model_format` 列(同一真相两个来源,且 provider 上报的格式不可信);预览图缺失时把模型文件直接喂给图片元素(静默坏图、无法诊断,也说不出「该资源没有可预览的模型」这类错误信息),以及拿模型对象冒充 `imageSrc` 的投影回落(同一个坏图问题,只是从后端发生);在画布侧补「`assetKind` 为空但对象键像模型」的扩展名兜底(这种资源本就不该出现,兜底只会掩盖后端投影缺陷)。 -后果:下载 3D 资源时按真实响应头的 MIME 命名、对象键扩展名兜底、最后回落 `glb`,导出目录用 `3d_models/` 与图片、其它媒体分开;类型角标、缩略图和后台渲染器只要读 `imageSrc` 就能拿到预览图,不必各自识别媒体语义;查看器包单模型体积上限 64 MiB,超限与 `webgl-unavailable` 都作为失败原因展示给用户,而不是静默失败。 +后果:下载 3D 资源时按真实响应头的 MIME 命名、对象键扩展名兜底、最后回落 `glb`,导出目录用 `3d_models/` 与图片、其它媒体分开;类型角标、缩略图和后台渲染器只要读 `imageSrc` 就能拿到预览图(为空即显示占位),不必各自识别媒体语义;查看器包单模型体积上限 64 MiB,超限与 `webgl-unavailable` 都作为失败原因展示给用户,而不是静默失败。 多文件模型不在支持范围内:站内 3D 产物是单文件的(`.glb` 自带几何与贴图,`.fbx` 按媒体内嵌处理),不假设会出现「模型文件 + 同目录 buffer / 贴图」这种产物,最多是「模型 + 一个 bin」。因此 `packages/model3d-viewer` 里把模型地址的签名 query 顺带补到同目录兄弟资源地址上的那段逻辑(`resolveModel3dViewerResourceUrl`)只是兜底,不代表查看器支持多文件模型:签名按单个文件签发时,贴到另一个文件上会被云端以签名不匹配拒绝。将来真要支持多文件模型,正确做法是让宿主为每个兄弟文件分别换取各自的临时地址,而不是继续扩展这段拼接。 diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index 0710e599c..951576b0d 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md @@ -157,12 +157,12 @@ price = <端点段>.versionPrices[modelVersion][texture ? "texture" : "noTexture ```text assetKind = "model3d" objectKey = 模型对象 -imageSrc = 预览图稳定引用(该列非空,必须填预览) +imageSrc = 预览图稳定引用(有预览才填;没有预览时留空,不拿模型对象顶封面) 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 正文,预览按魔数认 PNG / JPEG / WebP;不直接采信 provider 返回或下载响应头声明的类型,对象键扩展名由该内容类型派生。只有字节判不出来时才采信声明值,且声明值必须落在该槽位的白名单内(模型:`model/gltf-binary`、`model/gltf+json`、`model/fbx`、`application/x-fbx`;预览:`image/png`、`image/jpeg`、`image/jpg`、`image/webp`);两边都对不上就直接按上游内容不合法失败并退款,不做任何兜底 —— 放行 `text/plain` 这类未知类型只会落成「错的 content type + 拼出来的扩展名」。客户端不复制这份格式清单:画布只按「`assetKind=model3d` 且 `objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。这里的顺序是「信任后端的检测结果」:`content_type` 在后端写入前已经按字节核过,所以客户端以声明为先;字节魔数只兜住声明缺失或认不出来(例如 `application/octet-stream`)的情况,不负责纠正一个「认得出来但不对」的声明。 +模型格式与体积由 `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 返回或下载响应头声明的类型,对象键扩展名由该内容类型派生。只有字节判不出来时才采信声明值,且声明值必须落在该槽位的白名单内(模型:`model/gltf-binary`、`model/gltf+json`、`model/fbx`、`application/x-fbx`;预览:`image/png`、`image/jpeg`、`image/jpg`、`image/webp`);两边都对不上就直接按上游内容不合法失败并退款,不做任何兜底 —— 放行 `text/plain` 这类未知类型只会落成「错的 content type + 拼出来的扩展名」。客户端不复制这份格式清单:画布只按「`assetKind=model3d` 且 `objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。这里的顺序是「信任后端检测结果」:`content_type` 在后端写入前已经按字节核过,所以客户端以声明为先;字节魔数只兜住声明缺失或认不出来(例如 `application/octet-stream`)的情况,不负责纠正一个「认得出来但不对」的声明。 `external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。 @@ -201,7 +201,7 @@ width/height = 预览图像素尺寸 8. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。 9. worker 落库时构造的完成结果按端点严格类型化,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL;标准消费者的队列结果不透传它(与其它画布生成任务一致,结果靠画布 / 素材读回)。 10. 素材库落点产出 `assetId`;项目资源落点产出 `resourceId`,且 `assetKind=model3d`、模型与预览的 `AssetObject.content_type` 都是按字节识别出的 mime(预览不再一律写成 `image/webp`)、`content_length` 与实际对象一致。 -11. 客户端媒体投影对 `model3d` 只暴露预览图:`imageSrc` / `thumbnailSrc` 指向预览对象、`objectKey` 指向模型本体;预览缺失时才回落到模型对象,且该回落不得让图片入口加载模型文件。 +11. 客户端媒体投影对 `model3d` 只暴露预览图:`imageSrc` / `thumbnailSrc` 指向预览对象、`objectKey` 指向模型本体;预览缺失时 `imageSrc` 留空(消费方显示占位图),不得回落成模型对象——那只会让图片入口去加载 `.glb`。 12. 格式判定的顺序在查看器包内可被用例钉住:声明 `Content-Type` 优先于字节魔数、字节魔数优先于地址扩展名;三者都判不出来时模态展示 `unsupported-format` 原因与支持的格式清单,画布侧不做任何格式判断、也不在 `assetKind` 为空时按扩展名兜底。 13. 真实 Provider smoke 覆盖 text-to-model 与 image-to-model 各一次,下载字节数与 `Content-Length` 一致。 14. 定向测试、`cargo check`、`npm run check:spacetime-schema`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 全部通过。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index f74c796a4..81b720402 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -11362,8 +11362,10 @@ pub(crate) const EDITOR_MODEL3D_ASSET_KIND: &str = "model3d"; /// - `model3d`:`imageSrc` 与 `thumbnailSrc` 都是预览图,模型本体只留在 `objectKey`。 /// 预览依次取 `thumbnail_src`、存储的 `image_src`。模型与预览是两个对象,不能让 /// `object_key` 覆盖 `image_src`,否则画布卡片、素材缩略图和后台都会把模型文件当图片加载。 -/// - 其它类别:沿用「`object_key` 非空即以它为 `imageSrc`」的历史口径。 -/// - 两个来源都缺失时回落到历史口径,保证「有对象就有 `imageSrc`」的不变量不退化。 +/// 两个来源都缺失时 `imageSrc` 留空:这是唯一允许「有对象但 `imageSrc` 为空」的类别, +/// 消费方按空值渲染占位图,不拿 `.glb` 的位置冒充封面。 +/// - 其它类别:沿用「`object_key` 非空即以它为 `imageSrc`」的历史口径,两个来源都缺失时 +/// 回落到它,保证这些类别的「有对象就有 `imageSrc`」不变量不退化。 fn project_editor_client_media_src( asset_kind: Option<&str>, image_src: String, @@ -11379,9 +11381,10 @@ fn project_editor_client_media_src( let image_src = image_src.trim(); (!image_src.is_empty()).then(|| image_src.to_string()) }); - if let Some(preview_src) = preview_src { - return normalize_editor_record_media_src(preview_src, None); - } + return preview_src + .map(|preview_src| normalize_editor_record_media_src(preview_src, None)) + // 没有预览就留空:模型文件不是封面,宁可由消费方显示占位图。 + .unwrap_or_default(); } normalize_editor_record_media_src(image_src, object_key) } @@ -14413,8 +14416,10 @@ mod tests { ); } + /// 3D 没有预览图时封面留空:模型文件(.glb)不是图片,拿它顶封面只会得到裂图。 + /// 这是唯一允许「有对象但 imageSrc 为空」的类别,前端按空值渲染占位。 #[test] - fn editor_client_media_projection_falls_back_when_model3d_has_no_preview() { + fn editor_client_media_projection_leaves_model3d_cover_empty_without_preview() { assert_eq!( project_editor_client_media_src( Some("model3d"), @@ -14422,7 +14427,16 @@ mod tests { Some("generated-character-drafts/editor/model3d/task-1/model.glb"), Some(" "), ), - "/generated-character-drafts/editor/model3d/task-1/model.glb" + "" + ); + assert_eq!( + project_editor_client_media_src( + Some("model3d"), + " ".to_string(), + Some("generated-character-drafts/editor/model3d/task-1/model.glb"), + None, + ), + "" ); } diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModel.ts b/src/components/image-editor/model3d-preview/Model3dViewerModel.ts index e2595ee9d..8c8600bdd 100644 --- a/src/components/image-editor/model3d-preview/Model3dViewerModel.ts +++ b/src/components/image-editor/model3d-preview/Model3dViewerModel.ts @@ -23,8 +23,9 @@ const MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT = /** * 判断一个媒体地址是否其实指向模型本体。 * - * 后端投影正常情况下把 `imageSrc` 指到预览图;预览图缺失时它会回落到模型对象, - * 此时图片元素不能去加载模型文件,必须改显示 3D 占位。 + * 后端投影只把预览图放进 `imageSrc`(没有预览就留空,不再拿模型对象顶封面), + * 但历史布局与旧快照里可能还留着指向模型本体的地址:图片元素不能去加载模型文件, + * 必须改显示 3D 占位。 */ export function isModel3dFileSource(value: string | null | undefined) { return resolveModel3dViewerFormat({ url: value }) !== null;