拆分图片画布素材导出工作流
新增画布素材导出 hook 和单测 主视图改为通过导出 hook 处理单图和整包下载 更新图片画布前端拆分文档和 TRACKING 回归记录
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
|
||||
import JSZip from 'jszip';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
@@ -85,15 +84,6 @@ import {
|
||||
resolveContextMenuPosition,
|
||||
serializeLayer,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import {
|
||||
blobToUint8Array,
|
||||
buildLayerExportMetadata,
|
||||
formatExportDate,
|
||||
getImageExtensionFromTypeOrSrc,
|
||||
getLayerExportKey,
|
||||
readLayerImageBlob,
|
||||
sanitizeExportFilePart,
|
||||
} from './ImageCanvasExportModel';
|
||||
import {
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
@@ -132,8 +122,6 @@ import {
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasAssetExportImage,
|
||||
CanvasAssetExportMetadata,
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
@@ -156,6 +144,7 @@ import type {
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||
|
||||
@@ -255,11 +244,6 @@ export function ImageCanvasEditorView() {
|
||||
const [projectRenameError, setProjectRenameError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [assetExportStatus, setAssetExportStatus] = useState<{
|
||||
tone: 'info' | 'success' | 'error';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isExportingAssets, setIsExportingAssets] = useState(false);
|
||||
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||||
useState<SidebarPanel | null>('assets');
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
@@ -687,6 +671,16 @@ export function ImageCanvasEditorView() {
|
||||
viewport,
|
||||
openEditorLoginModal,
|
||||
});
|
||||
const {
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
exportCanvasAssets,
|
||||
exportLayerImage,
|
||||
} = useImageCanvasAssetExportWorkflow({
|
||||
layers,
|
||||
projectId,
|
||||
projectTitle,
|
||||
});
|
||||
const {
|
||||
uploadInputRef,
|
||||
setUploadTarget,
|
||||
@@ -1207,15 +1201,7 @@ export function ImageCanvasEditorView() {
|
||||
const exportContextLayer = () => {
|
||||
const targetIds = getContextTargetLayerIds();
|
||||
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
|
||||
if (!targetLayer) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = targetLayer.src;
|
||||
link.download = `${sanitizeExportFilePart(targetLayer.title, 'canvas-layer')}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
exportLayerImage(targetLayer ?? null);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
@@ -1241,156 +1227,6 @@ export function ImageCanvasEditorView() {
|
||||
};
|
||||
addAssetLayerRef.current = addAssetLayer;
|
||||
|
||||
const exportCanvasAssets = async () => {
|
||||
if (isExportingAssets) {
|
||||
return;
|
||||
}
|
||||
const exportableLayers = layers
|
||||
.filter((layer) => layer.src.trim().length > 0)
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
if (!exportableLayers.length) {
|
||||
setAssetExportStatus({
|
||||
tone: 'info',
|
||||
message: '当前画布没有可导出的素材',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportingAssets(true);
|
||||
setAssetExportStatus(null);
|
||||
|
||||
try {
|
||||
const exportedAt = new Date();
|
||||
const projectName = sanitizeExportFilePart(projectTitle, '未命名画布');
|
||||
const rootFolderName = `${projectName}-画布素材`;
|
||||
const zip = new JSZip();
|
||||
const rootFolder = zip.folder(rootFolderName) ?? zip;
|
||||
const imagesFolder = rootFolder.folder('images') ?? rootFolder;
|
||||
const imageByKey = new Map<string, CanvasAssetExportImage>();
|
||||
const usedFileNames = new Map<string, number>();
|
||||
|
||||
for (const layer of exportableLayers) {
|
||||
const key = getLayerExportKey(layer);
|
||||
if (imageByKey.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const index = imageByKey.size + 1;
|
||||
const safeTitle = sanitizeExportFilePart(layer.title, '画布素材');
|
||||
const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`;
|
||||
const duplicateCount = usedFileNames.get(baseFileName) ?? 0;
|
||||
usedFileNames.set(baseFileName, duplicateCount + 1);
|
||||
const indexedFileName =
|
||||
duplicateCount > 0
|
||||
? `${baseFileName}-${duplicateCount + 1}`
|
||||
: baseFileName;
|
||||
|
||||
try {
|
||||
const blob = await readLayerImageBlob(layer);
|
||||
const extension = getImageExtensionFromTypeOrSrc(
|
||||
blob.type,
|
||||
layer.src,
|
||||
);
|
||||
const file = `images/${indexedFileName}.${extension}`;
|
||||
imageByKey.set(key, {
|
||||
key,
|
||||
file,
|
||||
layer,
|
||||
blob,
|
||||
});
|
||||
imagesFolder.file(
|
||||
`${indexedFileName}.${extension}`,
|
||||
await blobToUint8Array(blob),
|
||||
);
|
||||
} catch (error) {
|
||||
imageByKey.set(key, {
|
||||
key,
|
||||
file: `images/${indexedFileName}.png`,
|
||||
layer,
|
||||
error: error instanceof Error ? error.message : '图片读取失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const failedImages = [...imageByKey.values()].filter(
|
||||
(image) => image.error,
|
||||
);
|
||||
const successfulImages = [...imageByKey.values()].filter(
|
||||
(image) => image.blob,
|
||||
);
|
||||
if (!successfulImages.length) {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '素材导出失败',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata: CanvasAssetExportMetadata = {
|
||||
projectId,
|
||||
projectTitle,
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
layers: exportableLayers.map((layer) => {
|
||||
const image = imageByKey.get(getLayerExportKey(layer));
|
||||
return buildLayerExportMetadata(
|
||||
layer,
|
||||
image?.blob ? image.file : null,
|
||||
image?.error,
|
||||
);
|
||||
}),
|
||||
failedImages: failedImages.map((image) => ({
|
||||
key: image.key,
|
||||
title: image.layer.title,
|
||||
src: image.layer.src,
|
||||
error: image.error ?? '图片读取失败',
|
||||
})),
|
||||
};
|
||||
const manifest = [
|
||||
`项目:${projectTitle}`,
|
||||
`导出时间:${metadata.exportedAt}`,
|
||||
`素材数量:${successfulImages.length}`,
|
||||
`图层数量:${exportableLayers.length}`,
|
||||
failedImages.length ? `失败素材数量:${failedImages.length}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2));
|
||||
rootFolder.file('manifest.txt', manifest);
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
if (
|
||||
typeof URL.createObjectURL !== 'function' ||
|
||||
typeof URL.revokeObjectURL !== 'function'
|
||||
) {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '当前浏览器不支持素材下载',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = URL.createObjectURL(zipBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
setAssetExportStatus({
|
||||
tone: failedImages.length ? 'error' : 'success',
|
||||
message: failedImages.length ? '部分素材未能导出' : '画布素材已导出',
|
||||
});
|
||||
} catch {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '素材导出失败',
|
||||
});
|
||||
} finally {
|
||||
setIsExportingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startProjectRename = () => {
|
||||
setProjectRenameValue(projectTitle);
|
||||
setProjectRenameError(null);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import JSZip from 'jszip';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||
|
||||
function createLayer(
|
||||
id: string,
|
||||
overrides: Partial<CanvasLayer> = {},
|
||||
): CanvasLayer {
|
||||
return {
|
||||
id,
|
||||
resourceId: `resource-${id}`,
|
||||
title: id,
|
||||
src: `data:image/png;base64,${btoa(`layer-${id.replace(/[^\w-]/gu, '-')}`)}`,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
originalWidth: 100,
|
||||
originalHeight: 80,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function readZipText(zip: JSZip, path: string) {
|
||||
const file = zip.file(path);
|
||||
if (!file) {
|
||||
throw new Error(`Missing zip file: ${path}`);
|
||||
}
|
||||
return file.async('text');
|
||||
}
|
||||
|
||||
function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
|
||||
const workflow = useImageCanvasAssetExportWorkflow({
|
||||
layers,
|
||||
projectId: 'project-export',
|
||||
projectTitle: '导出项目',
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="exporting">{String(workflow.isExportingAssets)}</span>
|
||||
<span data-testid="status">
|
||||
{workflow.assetExportStatus
|
||||
? `${workflow.assetExportStatus.tone}:${workflow.assetExportStatus.message}`
|
||||
: '-'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void workflow.exportCanvasAssets();
|
||||
}}
|
||||
>
|
||||
导出画布素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.exportLayerImage(layers[0] ?? null)}
|
||||
>
|
||||
导出单图
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasAssetExportWorkflow', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exports canvas assets into a zip with metadata and failed image records', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url === '/generated-ok.png') {
|
||||
return new Response(new Blob(['generated'], { type: 'image/png' }));
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
let exportedBlob: Blob | null = null;
|
||||
let downloadName = '';
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((blob: Blob) => {
|
||||
exportedBlob = blob;
|
||||
return 'blob:editor-export';
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
downloadName = this.download;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
render(
|
||||
<ExportWorkflowHarness
|
||||
layers={[
|
||||
createLayer('asset-a', {
|
||||
title: '素材 A',
|
||||
sourceAssetId: 'asset-a',
|
||||
zIndex: 1,
|
||||
hidden: true,
|
||||
locked: true,
|
||||
flipX: true,
|
||||
}),
|
||||
createLayer('asset-a-duplicate', {
|
||||
title: '素材 A 副本',
|
||||
sourceAssetId: 'asset-a',
|
||||
zIndex: 2,
|
||||
}),
|
||||
createLayer('generated', {
|
||||
title: '生成图',
|
||||
src: '/generated-ok.png',
|
||||
sourceType: 'generated',
|
||||
prompt: '明亮主视觉',
|
||||
zIndex: 3,
|
||||
}),
|
||||
createLayer('failed', {
|
||||
title: '失败图',
|
||||
src: '/missing.png',
|
||||
zIndex: 4,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportedBlob).toBeTruthy();
|
||||
});
|
||||
expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u);
|
||||
|
||||
const zip = await JSZip.loadAsync(exportedBlob!);
|
||||
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
|
||||
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
|
||||
expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();
|
||||
|
||||
const metadata = JSON.parse(
|
||||
await readZipText(zip, '导出项目-画布素材/metadata.json'),
|
||||
);
|
||||
expect(metadata.projectId).toBe('project-export');
|
||||
expect(metadata.layers).toHaveLength(4);
|
||||
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
|
||||
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
|
||||
expect(metadata.layers[0].canvas.hidden).toBe(true);
|
||||
expect(metadata.layers[0].canvas.locked).toBe(true);
|
||||
expect(metadata.layers[0].canvas.flipX).toBe(true);
|
||||
expect(metadata.layers[2].sourceType).toBe('generated');
|
||||
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
|
||||
expect(metadata.layers[3].file).toBeNull();
|
||||
expect(metadata.layers[3].exportError).toContain('404');
|
||||
expect(metadata.failedImages).toHaveLength(1);
|
||||
expect(
|
||||
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
|
||||
).toContain('失败素材数量:1');
|
||||
expect(screen.getByTestId('status').textContent).toBe(
|
||||
'error:部分素材未能导出',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
|
||||
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports empty exports and supports direct layer image downloads', async () => {
|
||||
let downloadName = '';
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
downloadName = this.download;
|
||||
},
|
||||
);
|
||||
render(
|
||||
<ExportWorkflowHarness
|
||||
layers={[createLayer('single-export', { title: '单图/导出:*?' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
|
||||
|
||||
expect(downloadName).toBe('单图 导出.png');
|
||||
|
||||
render(<ExportWorkflowHarness layers={[]} />);
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe(
|
||||
'info:当前画布没有可导出的素材',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
207
src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
Normal file
207
src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import JSZip from 'jszip';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CanvasAssetExportImage,
|
||||
CanvasAssetExportMetadata,
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
blobToUint8Array,
|
||||
buildLayerExportMetadata,
|
||||
formatExportDate,
|
||||
getImageExtensionFromTypeOrSrc,
|
||||
getLayerExportKey,
|
||||
readLayerImageBlob,
|
||||
sanitizeExportFilePart,
|
||||
} from './ImageCanvasExportModel';
|
||||
|
||||
type AssetExportStatus = {
|
||||
tone: 'info' | 'success' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type UseImageCanvasAssetExportWorkflowOptions = {
|
||||
layers: CanvasLayer[];
|
||||
projectId: string | null;
|
||||
projectTitle: string;
|
||||
};
|
||||
|
||||
export function useImageCanvasAssetExportWorkflow({
|
||||
layers,
|
||||
projectId,
|
||||
projectTitle,
|
||||
}: UseImageCanvasAssetExportWorkflowOptions) {
|
||||
const [assetExportStatus, setAssetExportStatus] =
|
||||
useState<AssetExportStatus | null>(null);
|
||||
const [isExportingAssets, setIsExportingAssets] = useState(false);
|
||||
|
||||
const exportLayerImage = useCallback((layer: CanvasLayer | null) => {
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = layer.src;
|
||||
link.download = `${sanitizeExportFilePart(layer.title, 'canvas-layer')}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}, []);
|
||||
|
||||
const exportCanvasAssets = useCallback(async () => {
|
||||
if (isExportingAssets) {
|
||||
return;
|
||||
}
|
||||
const exportableLayers = layers
|
||||
.filter((layer) => layer.src.trim().length > 0)
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
if (!exportableLayers.length) {
|
||||
setAssetExportStatus({
|
||||
tone: 'info',
|
||||
message: '当前画布没有可导出的素材',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportingAssets(true);
|
||||
setAssetExportStatus(null);
|
||||
|
||||
try {
|
||||
const exportedAt = new Date();
|
||||
const projectName = sanitizeExportFilePart(projectTitle, '未命名画布');
|
||||
const rootFolderName = `${projectName}-画布素材`;
|
||||
const zip = new JSZip();
|
||||
const rootFolder = zip.folder(rootFolderName) ?? zip;
|
||||
const imagesFolder = rootFolder.folder('images') ?? rootFolder;
|
||||
const imageByKey = new Map<string, CanvasAssetExportImage>();
|
||||
const usedFileNames = new Map<string, number>();
|
||||
|
||||
for (const layer of exportableLayers) {
|
||||
const key = getLayerExportKey(layer);
|
||||
if (imageByKey.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const index = imageByKey.size + 1;
|
||||
const safeTitle = sanitizeExportFilePart(layer.title, '画布素材');
|
||||
const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`;
|
||||
const duplicateCount = usedFileNames.get(baseFileName) ?? 0;
|
||||
usedFileNames.set(baseFileName, duplicateCount + 1);
|
||||
const indexedFileName =
|
||||
duplicateCount > 0
|
||||
? `${baseFileName}-${duplicateCount + 1}`
|
||||
: baseFileName;
|
||||
|
||||
try {
|
||||
const blob = await readLayerImageBlob(layer);
|
||||
const extension = getImageExtensionFromTypeOrSrc(
|
||||
blob.type,
|
||||
layer.src,
|
||||
);
|
||||
const file = `images/${indexedFileName}.${extension}`;
|
||||
imageByKey.set(key, {
|
||||
key,
|
||||
file,
|
||||
layer,
|
||||
blob,
|
||||
});
|
||||
imagesFolder.file(
|
||||
`${indexedFileName}.${extension}`,
|
||||
await blobToUint8Array(blob),
|
||||
);
|
||||
} catch (error) {
|
||||
imageByKey.set(key, {
|
||||
key,
|
||||
file: `images/${indexedFileName}.png`,
|
||||
layer,
|
||||
error: error instanceof Error ? error.message : '图片读取失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const failedImages = [...imageByKey.values()].filter(
|
||||
(image) => image.error,
|
||||
);
|
||||
const successfulImages = [...imageByKey.values()].filter(
|
||||
(image) => image.blob,
|
||||
);
|
||||
if (!successfulImages.length) {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '素材导出失败',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata: CanvasAssetExportMetadata = {
|
||||
projectId,
|
||||
projectTitle,
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
layers: exportableLayers.map((layer) => {
|
||||
const image = imageByKey.get(getLayerExportKey(layer));
|
||||
return buildLayerExportMetadata(
|
||||
layer,
|
||||
image?.blob ? image.file : null,
|
||||
image?.error,
|
||||
);
|
||||
}),
|
||||
failedImages: failedImages.map((image) => ({
|
||||
key: image.key,
|
||||
title: image.layer.title,
|
||||
src: image.layer.src,
|
||||
error: image.error ?? '图片读取失败',
|
||||
})),
|
||||
};
|
||||
const manifest = [
|
||||
`项目:${projectTitle}`,
|
||||
`导出时间:${metadata.exportedAt}`,
|
||||
`素材数量:${successfulImages.length}`,
|
||||
`图层数量:${exportableLayers.length}`,
|
||||
failedImages.length ? `失败素材数量:${failedImages.length}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2));
|
||||
rootFolder.file('manifest.txt', manifest);
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
if (
|
||||
typeof URL.createObjectURL !== 'function' ||
|
||||
typeof URL.revokeObjectURL !== 'function'
|
||||
) {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '当前浏览器不支持素材下载',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = URL.createObjectURL(zipBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
setAssetExportStatus({
|
||||
tone: failedImages.length ? 'error' : 'success',
|
||||
message: failedImages.length ? '部分素材未能导出' : '画布素材已导出',
|
||||
});
|
||||
} catch {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '素材导出失败',
|
||||
});
|
||||
} finally {
|
||||
setIsExportingAssets(false);
|
||||
}
|
||||
}, [isExportingAssets, layers, projectId, projectTitle]);
|
||||
|
||||
return {
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
exportCanvasAssets,
|
||||
exportLayerImage,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user