导出压缩包里的目录改成第一次写文件时才建,不再留空目录
- `useImageCanvasAssetExportWorkflow`:三个导出目录(images / media / 3d_models)从「开包就全建好」改成 `createExportFolderResolver` 按需创建,同一轮导出每个目录只建一次;没有对应内容的压缩包里不再出现空的 `media/`、`3d_models/` - 用例:只导出图片时压缩包里不存在 media / 3d_models 的任何条目 - docs/adr 0003:后果里补上「目录按需创建、空目录不进包」
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user