From 0c5aeb5271e5fc87c0679073e82666626b2f6f5a 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 21:14:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8E=8B=E7=BC=A9=E5=8C=85?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E7=9B=AE=E5=BD=95=E6=94=B9=E6=88=90=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=86=99=E6=96=87=E4=BB=B6=E6=97=B6=E6=89=8D?= =?UTF-8?q?=E5=BB=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=95=99=E7=A9=BA=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `useImageCanvasAssetExportWorkflow`:三个导出目录(images / media / 3d_models)从「开包就全建好」改成 `createExportFolderResolver` 按需创建,同一轮导出每个目录只建一次;没有对应内容的压缩包里不再出现空的 `media/`、`3d_models/` - 用例:只导出图片时压缩包里不存在 media / 3d_models 的任何条目 - docs/adr 0003:后果里补上「目录按需创建、空目录不进包」 --- ...资源客户端媒体投影与格式真实性-2026-09-21.md | 2 +- ...useImageCanvasAssetExportWorkflow.test.tsx | 42 ++++++++++++++++++ .../useImageCanvasAssetExportWorkflow.ts | 44 +++++++++---------- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md index c809beb88..9f2144996 100644 --- a/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md +++ b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md @@ -8,7 +8,7 @@ 被否决的做法:让画布或宿主外壳按对象键扩展名自行判断放行(客户端第二份格式清单,查看器扩了格式后画布仍会继续禁用入口);给资源表新增 `model_format` 列(同一真相两个来源,且 provider 上报的格式不可信);预览图缺失时把模型文件直接喂给图片元素(静默坏图、无法诊断,也说不出「该资源没有可预览的模型」这类错误信息),以及拿模型对象冒充 `imageSrc` 的投影回落(同一个坏图问题,只是从后端发生);在画布侧补「`assetKind` 为空但对象键像模型」的扩展名兜底(这种资源本就不该出现,兜底只会掩盖后端投影缺陷)。 -后果:下载 3D 资源时按真实响应头的 MIME 命名、对象键扩展名兜底、最后回落 `glb`,导出目录用 `3d_models/` 与图片、其它媒体分开;类型角标、缩略图和后台渲染器只要读 `imageSrc` 就能拿到预览图(为空即显示占位),不必各自识别媒体语义;查看器包单模型体积上限 64 MiB,超限与 `webgl-unavailable` 都作为失败原因展示给用户,而不是静默失败。 +后果:下载 3D 资源时按真实响应头的 MIME 命名、对象键扩展名兜底、最后回落 `glb`,导出目录用 `3d_models/` 与图片、其它媒体分开,且三个目录都在第一次真要写文件时才创建(没有 3D 内容的压缩包里不会出现空的 `3d_models/`);类型角标、缩略图和后台渲染器只要读 `imageSrc` 就能拿到预览图(为空即显示占位),不必各自识别媒体语义;查看器包单模型体积上限 64 MiB,超限与 `webgl-unavailable` 都作为失败原因展示给用户,而不是静默失败。 多文件模型不在支持范围内:站内 3D 产物是单文件的(`.glb` 自带几何与贴图,`.fbx` 按媒体内嵌处理),不假设会出现「模型文件 + 同目录 buffer / 贴图」这种产物,最多是「模型 + 一个 bin」。因此 `packages/model3d-viewer` 里把模型地址的签名 query 顺带补到同目录兄弟资源地址上的那段逻辑(`resolveModel3dViewerResourceUrl`)只是兜底,不代表查看器支持多文件模型:签名按单个文件签发时,贴到另一个文件上会被云端以签名不匹配拒绝。将来真要支持多文件模型,正确做法是让宿主为每个兄弟文件分别换取各自的临时地址,而不是继续扩展这段拼接。 diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 4308a5e6c..96e3c2f0b 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -470,6 +470,48 @@ describe('useImageCanvasAssetExportWorkflow', () => { } }); + it('没有音频 / 3D 内容时不在压缩包里留空目录', async () => { + let exportedBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:collection-without-empty-folders'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出画布素材' })); + await waitFor(() => expect(exportedBlob).toBeTruthy()); + + const zip = await JSZip.loadAsync(exportedBlob!); + const paths = Object.keys(zip.files); + expect( + paths.filter( + (path) => + path.startsWith('导出项目-画布素材/images/') && !path.endsWith('/'), + ), + ).toEqual(['导出项目-画布素材/images/001-普通图片.png']); + // 目录在第一次写文件时才建:没有音频 / 3D 内容时连空目录都不该出现。 + expect(paths.filter((path) => path.includes('media'))).toEqual([]); + expect(paths.filter((path) => path.includes('3d_models'))).toEqual([]); + } finally { + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + it('prevents concurrent collection exports before React re-renders', async () => { const originalFetch = globalThis.fetch; let resolveFetch: (response: Response) => void = () => {}; diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 9009b84dd..adef39b33 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -75,18 +75,24 @@ function resolveLayerExportFolderName( : EXPORT_FOLDER.media; } -/** zip 目标目录:三类各一个,模型不进图片目录。 */ -function resolveExportTargetFolder( - folderName: CanvasAssetExportFolderName, - folders: { imagesFolder: JSZip; mediaFolder: JSZip; modelsFolder: JSZip }, -) { - if (folderName === EXPORT_FOLDER.models) { - return folders.modelsFolder; - } - if (folderName === EXPORT_FOLDER.media) { - return folders.mediaFolder; - } - return folders.imagesFolder; +/** + * zip 目标目录:三类各一个,模型不进图片目录。 + * + * 目录在第一次真要往里面写文件时才建:`JSZip` 的 `folder()` 一调用就会在压缩包里留下 + * 目录条目,先建好三个目录会让「没有音频的导出」里出现空的 `media/`、`3d_models/` + * (评审 #91)。同一轮导出里每个目录只建一次。 + */ +function createExportFolderResolver(rootFolder: JSZip) { + const createdFolders = new Map(); + return (folderName: CanvasAssetExportFolderName): JSZip => { + const cached = createdFolders.get(folderName); + if (cached) { + return cached; + } + const folder = rootFolder.folder(folderName) ?? rootFolder; + createdFolders.set(folderName, folder); + return folder; + }; } type AssetExportPlan = { @@ -509,14 +515,9 @@ export function useImageCanvasAssetExportWorkflow({ const rootFolderName = `${projectName}-${archiveLabel}`; const zip = new JSZip(); const rootFolder = zip.folder(rootFolderName) ?? zip; - const imagesFolder = - rootFolder.folder(EXPORT_FOLDER.images) ?? rootFolder; - const mediaFolder = - rootFolder.folder(EXPORT_FOLDER.media) ?? rootFolder; // 3D 资源导出的是模型本体(.glb/.gltf/.fbx),混进 images/ 会让人以为 - // zip 里是坏图片,所以单独一个目录。 - const modelsFolder = - rootFolder.folder(EXPORT_FOLDER.models) ?? rootFolder; + // zip 里是坏图片,所以单独一个目录;三个目录都在第一次写文件时才建。 + const resolveExportFolder = createExportFolderResolver(rootFolder); const imageByKey = new Map(); const usedFileNames = new Map(); const plannedKeys = new Set(); @@ -658,10 +659,7 @@ export function useImageCanvasAssetExportWorkflow({ continue; } if (preparedExport.status === 'asset') { - const targetFolder = resolveExportTargetFolder( - preparedExport.folderName, - { imagesFolder, mediaFolder, modelsFolder }, - ); + const targetFolder = resolveExportFolder(preparedExport.folderName); targetFolder.file(preparedExport.fileName, preparedExport.bytes); imageByKey.set(key, { key,