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,