6277a823ba
新增动作图层导出二级菜单 序列帧导出改为ZIP并生成GIF预览 兼容历史项目封面资源字段
605 lines
18 KiB
TypeScript
605 lines
18 KiB
TypeScript
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<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 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,
|
||
};
|
||
}
|