import JSZip from 'jszip'; import { useCallback, useRef, useState } from 'react'; import { hasReadableAssetSource } from '../../services/assetReadUrlService'; import type { CanvasAssetExportImage, CanvasAssetExportMetadata, CanvasLayer, } from './ImageCanvasEditorTypes'; import { blobToUint8Array, buildAnimatedGifPreviewBytes, 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; const ASSET_EXPORT_READ_CONCURRENCY = 4; function hasReadableLayerExportSource(layer: CanvasLayer) { if (layer.mediaType === 'image-sequence') { return getLayerImageSequenceFrames(layer).some((frame) => hasReadableAssetSource(frame.imageSrc, frame.objectKey), ); } return hasReadableAssetSource(layer.src, layer.objectKey); } type AssetExportPlan = { key: string; layer: CanvasLayer; indexedFileName: string; }; type PreparedSequenceFrame = { fileName: string; bytes: Uint8Array; exportedFrame: ExportedImageSequenceFrame; }; type PreparedAssetExport = | { status: 'asset'; plan: AssetExportPlan; blob: Blob; bytes: Uint8Array; folderName: 'images' | 'media'; fileName: string; } | { status: 'sequence'; plan: AssetExportPlan; sequenceFolderName: string; frames: PreparedSequenceFrame[]; failedFrames: Array<{ frameIndex: number; error: string }>; frameCount: number; } | { status: 'error'; plan: AssetExportPlan; file: string; error: string; }; function createBoundedTaskRunner(concurrency: number) { const maximumConcurrency = Math.max(1, Math.floor(concurrency)); const pendingTasks: Array<() => void> = []; let activeTaskCount = 0; return function runBoundedTask(task: () => Promise): Promise { return new Promise((resolve, reject) => { const startTask = () => { activeTaskCount += 1; void task() .then(resolve, reject) .finally(() => { activeTaskCount -= 1; pendingTasks.shift()?.(); }); }; if (activeTaskCount < maximumConcurrency) { startTask(); return; } pendingTasks.push(startTask); }); }; } 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 durationSeconds = layer.imageSequenceDurationMs ? layer.imageSequenceDurationMs / 1_000 : undefined; 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({ fileName, width: frame.width, height: frame.height, }); successCount += 1; } catch (error) { failedFrames.push({ 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}`, `时长:${durationSeconds ? `${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, }, 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', durationSeconds ? `时长:${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 isExportingAssetsRef = useRef(false); const exportLayerImageUnlocked = useCallback( async ( layer: CanvasLayer | null, options: { mode?: ImageSequenceExportMode } = {}, ) => { const exportMode = options.mode ?? 'spine-json'; if (!layer) { return false; } if (layer.mediaType === 'image-sequence') { try { const zipBlob = await buildImageSequenceZip(layer, exportMode); const modeName = exportMode === 'spine-json' ? 'SpineJSON' : 'Sequence'; const exportStamp = formatExportDate(new Date()); const downloaded = triggerBrowserDownload( zipBlob, `${sanitizeExportFilePart(layer.title, '角色动作')}-${modeName}-${exportStamp}.zip`, ); if (!downloaded) { setAssetExportStatus({ tone: 'error', message: '当前浏览器不支持素材下载', }); } return downloaded; } catch { const hasFrameSource = (layer.imageSequenceFrames?.length ?? 0) > 0; setAssetExportStatus({ tone: 'error', message: hasFrameSource ? '序列帧导出失败' : `角色动作素材“${layer.title}”不完整:缺少可用序列帧。`, }); return false; } } try { const blob = await readLayerAssetBlob(layer); const extension = getLayerAssetExtensionFromTypeOrSrc( layer.mediaType ?? 'image', blob.type, layer.objectKey ?? layer.src, ); const downloaded = triggerBrowserDownload( blob, `${sanitizeExportFilePart(layer.title, 'canvas-layer')}-${formatExportDate(new Date())}.${extension}`, ); if (!downloaded) { setAssetExportStatus({ tone: 'error', message: '当前浏览器不支持素材下载', }); } return downloaded; } catch { setAssetExportStatus({ tone: 'error', message: '素材导出失败', }); return false; } }, [], ); const exportLayerImage = useCallback( async ( layer: CanvasLayer | null, options: { mode?: ImageSequenceExportMode } = {}, ) => { if (!layer || isExportingAssetsRef.current) { return false; } isExportingAssetsRef.current = true; setIsExportingAssets(true); setAssetExportStatus(null); try { return await exportLayerImageUnlocked(layer, options); } finally { isExportingAssetsRef.current = false; setIsExportingAssets(false); } }, [exportLayerImageUnlocked], ); const exportAssetCollection = useCallback( async ({ targetLayers, archiveLabel, emptyMessage, successMessage, }: { targetLayers: CanvasLayer[]; archiveLabel: string; emptyMessage: string; successMessage: string; }) => { if (isExportingAssetsRef.current) { return; } const exportableLayers = targetLayers .filter(hasReadableLayerExportSource) .sort((left, right) => left.zIndex - right.zIndex); if (!exportableLayers.length) { setAssetExportStatus({ tone: 'info', message: emptyMessage, }); return; } isExportingAssetsRef.current = true; setIsExportingAssets(true); setAssetExportStatus(null); try { const exportedAt = new Date(); const projectName = sanitizeExportFilePart(projectTitle, '未命名画布'); const rootFolderName = `${projectName}-${archiveLabel}`; 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(); const plannedKeys = new Set(); const exportPlans: AssetExportPlan[] = []; for (const layer of exportableLayers) { const key = getLayerExportKey(layer); if (plannedKeys.has(key)) { continue; } plannedKeys.add(key); const index = exportPlans.length + 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; exportPlans.push({ key, layer, indexedFileName }); } const runBoundedRead = createBoundedTaskRunner( ASSET_EXPORT_READ_CONCURRENCY, ); const preparedExports = await Promise.all( exportPlans.map(async (plan): Promise => { const { layer, indexedFileName } = plan; try { // TODO image sequence (action) logic should be refactored together with its backend data structure impl. if (layer.mediaType === 'image-sequence') { const frames = getLayerImageSequenceFrames(layer); if (!frames.length) { throw new Error('序列帧为空'); } const sequenceFolderName = `sequences/${indexedFileName}`; const preparedFrames = await Promise.all( frames.map((frame, frameArrayIndex) => runBoundedRead(async () => { try { const frameBlob = await readLayerImageSequenceFrameBlob( layer, frame, ); const fileName = getImageSequenceFrameFileName( frame, frameArrayIndex, frameBlob.type, ); return { status: 'success' as const, frame: { fileName, bytes: await blobToUint8Array(frameBlob), exportedFrame: { fileName, width: frame.width, height: frame.height, }, }, }; } catch (error) { return { status: 'error' as const, failure: { frameIndex: frameArrayIndex + 1, error: error instanceof Error ? error.message : '序列帧读取失败', }, }; } }), ), ); // TODO(master 既有行为待讨论): 当前至少一帧成功即导出;需先明确部分失败的顶层状态和原时间点保留契约,再决定整体失败或部分导出。 const successfulFrames = preparedFrames .filter((result) => result.status === 'success') .map((result) => result.frame); if (successfulFrames.length === 0) { throw new Error('序列帧读取失败'); } return { status: 'sequence', plan, sequenceFolderName, frames: successfulFrames, failedFrames: preparedFrames .filter((result) => result.status === 'error') .map((result) => result.failure), frameCount: frames.length, }; } const { blob, bytes } = await runBoundedRead(async () => { const nextBlob = await readLayerAssetBlob(layer); return { blob: nextBlob, bytes: await blobToUint8Array(nextBlob), }; }); const mediaType = layer.mediaType ?? 'image'; const extension = getLayerAssetExtensionFromTypeOrSrc( mediaType, blob.type, layer.objectKey ?? layer.src, ); const folderName = mediaType === 'image' ? 'images' : 'media'; return { status: 'asset', plan, blob, bytes, folderName, fileName: `${indexedFileName}.${extension}`, }; } catch (error) { const mediaType = layer.mediaType ?? 'image'; const extension = getLayerAssetExtensionFromTypeOrSrc( mediaType, '', layer.objectKey ?? layer.src, ); const folderName = mediaType === 'image' ? 'images' : 'media'; return { status: 'error', plan, file: `${folderName}/${indexedFileName}.${extension}`, error: error instanceof Error ? error.message : '素材读取失败', }; } }), ); for (const preparedExport of preparedExports) { const { key, layer } = preparedExport.plan; if (preparedExport.status === 'error') { imageByKey.set(key, { key, file: preparedExport.file, layer, error: preparedExport.error, }); continue; } if (preparedExport.status === 'asset') { const targetFolder = preparedExport.folderName === 'images' ? imagesFolder : mediaFolder; targetFolder.file(preparedExport.fileName, preparedExport.bytes); imageByKey.set(key, { key, file: `${preparedExport.folderName}/${preparedExport.fileName}`, layer, blob: preparedExport.blob, }); continue; } const sequenceFolder = rootFolder.folder(preparedExport.sequenceFolderName) ?? rootFolder; const framesFolder = sequenceFolder.folder('frames') ?? sequenceFolder; preparedExport.frames.forEach((frame) => { framesFolder.file(frame.fileName, frame.bytes); }); const exportedFrames = preparedExport.frames.map( (frame) => frame.exportedFrame, ); sequenceFolder.file( 'metadata.json', JSON.stringify( { title: layer.title, visible: buildLayerVisibleExportMetadata(layer), frameCount: preparedExport.frameCount, exportedFrameCount: exportedFrames.length, failedFrames: preparedExport.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}`, `类型:序列帧`, `帧数:${exportedFrames.length}/${preparedExport.frameCount}`, 'Spine JSON:skeleton.json', layer.imageSequenceDurationMs ? `时长:${layer.imageSequenceDurationMs / 1_000}s` : null, preparedExport.failedFrames.length ? `失败帧数:${preparedExport.failedFrames.length}` : null, ] .filter(Boolean) .join('\n'), ); imageByKey.set(key, { key, file: `${preparedExport.sequenceFolderName}/manifest.txt`, layer, blob: new Blob([], { type: 'application/zip' }), }); } 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 ? '部分素材未能导出' : successMessage, }); } catch { setAssetExportStatus({ tone: 'error', message: '素材导出失败', }); } finally { isExportingAssetsRef.current = false; setIsExportingAssets(false); } }, [projectTitle], ); const exportCanvasAssets = useCallback( () => exportAssetCollection({ targetLayers: layers, archiveLabel: '画布素材', emptyMessage: '当前画布没有可导出的素材', successMessage: '画布素材已导出', }), [exportAssetCollection, layers], ); const exportSelectedAssets = useCallback( async (selectedLayers: CanvasLayer[]) => { if (isExportingAssetsRef.current) { return; } const exportableLayers = selectedLayers.filter( hasReadableLayerExportSource, ); if (!exportableLayers.length) { setAssetExportStatus({ tone: 'info', message: '请先选择可下载的素材', }); return; } if (exportableLayers.length === 1) { const downloaded = await exportLayerImage(exportableLayers[0] ?? null); if (downloaded) { setAssetExportStatus({ tone: 'success', message: '选中素材已导出', }); } return; } await exportAssetCollection({ targetLayers: exportableLayers, archiveLabel: '选中素材', emptyMessage: '请先选择可下载的素材', successMessage: '选中素材已导出', }); }, [exportAssetCollection, exportLayerImage], ); const reportAssetError = useCallback((message: string) => { setAssetExportStatus({ tone: 'error', message }); }, []); return { assetExportStatus, isExportingAssets, exportCanvasAssets, exportSelectedAssets, exportLayerImage, reportAssetError, }; }