导出压缩包里的目录改成第一次写文件时才建,不再留空目录

- `useImageCanvasAssetExportWorkflow`:三个导出目录(images / media / 3d_models)从「开包就全建好」改成 `createExportFolderResolver` 按需创建,同一轮导出每个目录只建一次;没有对应内容的压缩包里不再出现空的 `media/`、`3d_models/`
- 用例:只导出图片时压缩包里不存在 media / 3d_models 的任何条目
- docs/adr 0003:后果里补上「目录按需创建、空目录不进包」
This commit is contained in:
2026-09-24 21:14:19 +08:00
parent 2204df139c
commit 0c5aeb5271
3 changed files with 64 additions and 24 deletions
@@ -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`)只是兜底,不代表查看器支持多文件模型:签名按单个文件签发时,贴到另一个文件上会被云端以签名不匹配拒绝。将来真要支持多文件模型,正确做法是让宿主为每个兄弟文件分别换取各自的临时地址,而不是继续扩展这段拼接。
@@ -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(
<ExportWorkflowHarness
layers={[createLayer('plain-image', { title: '普通图片' })]}
/>,
);
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 = () => {};
@@ -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<CanvasAssetExportFolderName, JSZip>();
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<string, CanvasAssetExportImage>();
const usedFileNames = new Map<string, number>();
const plannedKeys = new Set<string>();
@@ -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,