Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
T
k88936 31900df2b8 fix: 修复多选逻辑
重构多选逻辑到一个单独的hook
不在最终提交时过滤, 只有选择操作会影响最终结果集合
2026-07-31 19:38:52 +08:00

809 lines
25 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, useRef, useState } from 'react';
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;
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<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((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 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 isExportingAssetsRef = useRef(false);
const exportLayerImage = 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 {
setAssetExportStatus({
tone: 'error',
message: '序列帧导出失败',
});
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 exportAssetCollection = useCallback(
async ({
targetLayers,
archiveLabel,
emptyMessage,
successMessage,
}: {
targetLayers: CanvasLayer[];
archiveLabel: string;
emptyMessage: string;
successMessage: string;
}) => {
if (isExportingAssetsRef.current) {
return;
}
const exportableLayers = targetLayers
.filter((layer) => layer.src.trim().length > 0)
.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<string, CanvasAssetExportImage>();
const usedFileNames = new Map<string, number>();
const plannedKeys = new Set<string>();
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<PreparedAssetExport> => {
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: {
frameIndex:
frame.frameIndex || frameArrayIndex + 1,
fileName,
width: frame.width,
height: frame.height,
},
},
};
} catch (error) {
return {
status: 'error' as const,
failure: {
frameIndex: frame.frameIndex || frameArrayIndex + 1,
error:
error instanceof Error
? error.message
: '序列帧读取失败',
},
};
}
}),
),
);
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.durationSeconds ? `时长:${layer.durationSeconds}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(
(layer) => layer.src.trim().length > 0,
);
if (!exportableLayers.length) {
setAssetExportStatus({
tone: 'info',
message: '请先选择可下载的素材',
});
return;
}
if (exportableLayers.length === 1) {
isExportingAssetsRef.current = true;
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
const downloaded = await exportLayerImage(
exportableLayers[0] ?? null,
);
if (downloaded) {
setAssetExportStatus({
tone: 'success',
message: '选中素材已导出',
});
}
} finally {
isExportingAssetsRef.current = false;
setIsExportingAssets(false);
}
return;
}
await exportAssetCollection({
targetLayers: exportableLayers,
archiveLabel: '选中素材',
emptyMessage: '请先选择可下载的素材',
successMessage: '选中素材已导出',
});
},
[exportAssetCollection, exportLayerImage],
);
return {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportSelectedAssets,
exportLayerImage,
};
}