import JSZip from 'jszip'; import { useCallback, useState } from 'react'; import type { CanvasAssetExportImage, CanvasAssetExportMetadata, CanvasLayer, } from './ImageCanvasEditorTypes'; import { buildAnimatedGifPreviewBytes, blobToUint8Array, buildLayerExportMetadata, buildLayerVisibleExportMetadata, buildSpineImageSequenceJson, buildSpineImageSequenceReadme, type ExportedImageSequenceFrame, formatExportDate, getImageSequenceFrameFileName, getLayerAssetExtensionFromTypeOrSrc, getLayerExportKey, getLayerImageSequenceFrames, readLayerAssetBlob, readLayerImageSequenceFrameBlob, sanitizeExportFilePart, } from './ImageCanvasExportModel'; export type AssetExportStatus = { tone: 'info' | 'success' | 'error'; message: string; }; export type ImageSequenceExportMode = 'spine-json' | 'sequence-with-preview'; type UseImageCanvasAssetExportWorkflowOptions = { layers: CanvasLayer[]; projectId: string | null; projectTitle: string; }; const GIF_PREVIEW_MAX_EDGE = 320; function triggerBrowserDownload(blob: Blob, downloadName: string) { if ( typeof URL.createObjectURL !== 'function' || typeof URL.revokeObjectURL !== 'function' ) { return false; } const downloadUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = downloadName; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(downloadUrl); return true; } function createFallbackPreviewBlob(frameBlob: Blob) { return frameBlob.size ? new Blob([frameBlob], { type: 'image/gif' }) : new Blob([], { type: 'image/gif' }); } function resolveGifPreviewSize(width: number, height: number) { const safeWidth = Math.max(1, Math.round(width)); const safeHeight = Math.max(1, Math.round(height)); const scale = Math.min( 1, GIF_PREVIEW_MAX_EDGE / Math.max(safeWidth, safeHeight), ); return { width: Math.max(1, Math.round(safeWidth * scale)), height: Math.max(1, Math.round(safeHeight * scale)), }; } async function buildAnimatedSequencePreview( frameBlobs: Blob[], size: { width: number; height: number }, durationSeconds: number | null | undefined, ) { const firstFrameBlob = frameBlobs[0]; if (!firstFrameBlob?.size) { return createFallbackPreviewBlob(firstFrameBlob ?? new Blob()); } try { if (typeof createImageBitmap === 'undefined') { return createFallbackPreviewBlob(firstFrameBlob); } const firstFrameImage = await createImageBitmap(firstFrameBlob); const canvas = document.createElement('canvas'); canvas.width = size.width || firstFrameImage.width; canvas.height = size.height || firstFrameImage.height; const context = canvas.getContext('2d'); if (!context) { return createFallbackPreviewBlob(firstFrameBlob); } const { width, height } = resolveGifPreviewSize( size.width || firstFrameImage.width, size.height || firstFrameImage.height, ); canvas.width = width; canvas.height = height; const previewFrames = []; for (const frameBlob of frameBlobs) { const frameImage = frameBlob === firstFrameBlob ? firstFrameImage : await createImageBitmap(frameBlob); context.clearRect(0, 0, width, height); context.drawImage(frameImage, 0, 0, width, height); previewFrames.push({ width, height, rgba: context.getImageData(0, 0, width, height).data, }); } const duration = typeof durationSeconds === 'number' && durationSeconds > 0 ? durationSeconds : frameBlobs.length; const delayCentiseconds = (duration / Math.max(1, frameBlobs.length)) * 100; return new Blob( [ buildAnimatedGifPreviewBytes({ width, height, frames: previewFrames, frameDelayCentiseconds: delayCentiseconds, }), ], { type: 'image/gif', }, ); } catch { return createFallbackPreviewBlob(firstFrameBlob); } } async function buildImageSequenceZip( layer: CanvasLayer, mode: ImageSequenceExportMode, ) { const frames = getLayerImageSequenceFrames(layer); if (!frames.length) { throw new Error('序列帧为空'); } const zip = new JSZip(); const framesFolder = zip.folder('frames') ?? zip; const failedFrames: Array<{ frameIndex: number; error: string; }> = []; const exportedFrames: ExportedImageSequenceFrame[] = []; let successCount = 0; const frameBlobs: Blob[] = []; for (const [index, frame] of frames.entries()) { try { const blob = await readLayerImageSequenceFrameBlob(layer, frame); frameBlobs.push(blob); const fileName = getImageSequenceFrameFileName(frame, index, blob.type); framesFolder.file(fileName, await blobToUint8Array(blob)); exportedFrames.push({ frameIndex: frame.frameIndex || index + 1, fileName, width: frame.width, height: frame.height, }); successCount += 1; } catch (error) { failedFrames.push({ frameIndex: frame.frameIndex || index + 1, error: error instanceof Error ? error.message : '序列帧读取失败', }); } } if (successCount === 0) { throw new Error('序列帧读取失败'); } const sequenceManifestLines = [ `素材:${layer.title}`, `类型:${mode === 'spine-json' ? 'Spine JSON' : '序列帧'}`, `帧数:${successCount}/${frames.length}`, `时长:${layer.durationSeconds ? `${layer.durationSeconds}s` : '0s'}`, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ].filter(Boolean); if (mode === 'sequence-with-preview') { if (frameBlobs.length) { const previewBlob = await buildAnimatedSequencePreview( frameBlobs, { width: layer.width, height: layer.height, }, layer.durationSeconds, ); zip.file('preview.gif', await blobToUint8Array(previewBlob)); } zip.file( 'metadata.json', JSON.stringify( { title: layer.title, visible: buildLayerVisibleExportMetadata(layer), frameCount: frames.length, exportedFrameCount: successCount, failedFrames, }, null, 2, ), ); zip.file('manifest.txt', sequenceManifestLines.join('\n')); return zip.generateAsync({ type: 'blob' }); } zip.file( 'metadata.json', JSON.stringify( { title: layer.title, visible: buildLayerVisibleExportMetadata(layer), frameCount: frames.length, exportedFrameCount: successCount, failedFrames, }, null, 2, ), ); zip.file( 'skeleton.json', JSON.stringify( buildSpineImageSequenceJson({ layer, frames: exportedFrames }), null, 2, ), ); zip.file('README.md', buildSpineImageSequenceReadme(layer)); zip.file( 'manifest.txt', [ `素材:${layer.title}`, `类型:序列帧`, `帧数:${successCount}/${frames.length}`, 'Spine JSON:skeleton.json', layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ] .filter(Boolean) .join('\n'), ); return zip.generateAsync({ type: 'blob' }); } export function useImageCanvasAssetExportWorkflow({ layers, projectTitle, }: UseImageCanvasAssetExportWorkflowOptions) { const [assetExportStatus, setAssetExportStatus] = useState(null); const [isExportingAssets, setIsExportingAssets] = useState(false); const exportLayerImage = useCallback( ( layer: CanvasLayer | null, options: { mode?: ImageSequenceExportMode } = {}, ) => { const exportMode = options.mode ?? 'spine-json'; if (!layer) { return; } if (layer.mediaType === 'image-sequence') { void buildImageSequenceZip(layer, exportMode) .then((zipBlob) => { const modeName = exportMode === 'spine-json' ? 'SpineJSON' : 'Sequence'; const downloaded = triggerBrowserDownload( zipBlob, `${sanitizeExportFilePart(layer.title, '角色动作')}-${modeName}.zip`, ); if (!downloaded) { setAssetExportStatus({ tone: 'error', message: '当前浏览器不支持素材下载', }); } }) .catch(() => { setAssetExportStatus({ tone: 'error', message: '序列帧导出失败', }); }); return; } void readLayerAssetBlob(layer) .then((blob) => { const extension = getLayerAssetExtensionFromTypeOrSrc( layer.mediaType ?? 'image', blob.type, layer.objectKey ?? layer.src, ); const downloaded = triggerBrowserDownload( blob, `${sanitizeExportFilePart(layer.title, 'canvas-layer')}.${extension}`, ); if (!downloaded) { setAssetExportStatus({ tone: 'error', message: '当前浏览器不支持素材下载', }); } }) .catch(() => { setAssetExportStatus({ tone: 'error', message: '素材导出失败', }); }); }, [], ); 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 mediaFolder = rootFolder.folder('media') ?? rootFolder; const imageByKey = new Map(); const usedFileNames = new Map(); 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 { if (layer.mediaType === 'image-sequence') { const frames = getLayerImageSequenceFrames(layer); if (!frames.length) { throw new Error('序列帧为空'); } const sequenceFolderName = `sequences/${indexedFileName}`; const sequenceFolder = rootFolder.folder(sequenceFolderName) ?? rootFolder; const framesFolder = sequenceFolder.folder('frames') ?? sequenceFolder; const failedFrames: Array<{ frameIndex: number; error: string; }> = []; const exportedFrames: ExportedImageSequenceFrame[] = []; let successCount = 0; for (const [frameArrayIndex, frame] of frames.entries()) { try { const frameBlob = await readLayerImageSequenceFrameBlob( layer, frame, ); const fileName = getImageSequenceFrameFileName( frame, frameArrayIndex, frameBlob.type, ); framesFolder.file(fileName, await blobToUint8Array(frameBlob)); exportedFrames.push({ frameIndex: frame.frameIndex || frameArrayIndex + 1, fileName, width: frame.width, height: frame.height, }); successCount += 1; } catch (error) { failedFrames.push({ frameIndex: frame.frameIndex || frameArrayIndex + 1, error: error instanceof Error ? error.message : '序列帧读取失败', }); } } if (successCount === 0) { throw new Error('序列帧读取失败'); } sequenceFolder.file( 'metadata.json', JSON.stringify( { title: layer.title, visible: buildLayerVisibleExportMetadata(layer), frameCount: frames.length, exportedFrameCount: successCount, failedFrames, }, null, 2, ), ); sequenceFolder.file( 'skeleton.json', JSON.stringify( buildSpineImageSequenceJson({ layer, frames: exportedFrames }), null, 2, ), ); sequenceFolder.file( 'README.md', buildSpineImageSequenceReadme(layer), ); sequenceFolder.file( 'manifest.txt', [ `素材:${layer.title}`, `类型:序列帧`, `帧数:${successCount}/${frames.length}`, 'Spine JSON:skeleton.json', layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ] .filter(Boolean) .join('\n'), ); imageByKey.set(key, { key, file: `${sequenceFolderName}/manifest.txt`, layer, blob: new Blob([], { type: 'application/zip' }), }); continue; } const blob = await readLayerAssetBlob(layer); const mediaType = layer.mediaType ?? 'image'; const extension = getLayerAssetExtensionFromTypeOrSrc( mediaType, blob.type, layer.objectKey ?? layer.src, ); const folderName = mediaType === 'image' ? 'images' : 'media'; const targetFolder = mediaType === 'image' ? imagesFolder : mediaFolder; const file = `${folderName}/${indexedFileName}.${extension}`; imageByKey.set(key, { key, file, layer, blob, }); targetFolder.file( `${indexedFileName}.${extension}`, await blobToUint8Array(blob), ); } catch (error) { const mediaType = layer.mediaType ?? 'image'; const extension = getLayerAssetExtensionFromTypeOrSrc( mediaType, '', layer.objectKey ?? layer.src, ); const folderName = mediaType === 'image' ? 'images' : 'media'; imageByKey.set(key, { key, file: `${folderName}/${indexedFileName}.${extension}`, 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 = { 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) => ({ title: image.layer.title, 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, projectTitle]); return { assetExportStatus, isExportingAssets, exportCanvasAssets, exportLayerImage, }; }