Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
T
kdletters 6277a823ba 调整动作导出与项目封面兼容
新增动作图层导出二级菜单

序列帧导出改为ZIP并生成GIF预览

兼容历史项目封面资源字段
2026-06-25 19:58:12 +08:00

605 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 JSONskeleton.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<AssetExportStatus | null>(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<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 {
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 JSONskeleton.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,
};
}