Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
T
k88936 0d2c81a3ce 画布导出的目录名单一来源
- src/components/image-editor/useImageCanvasAssetExportWorkflow.ts:images / media / 3d_models 三个目录名提成 EXPORT_FOLDER 常量,建目录、挑目录与按层分流共用同一份字面量(写错目录名不会编译报错,只会在 zip 里悄悄换目录)
2026-09-24 17:33:02 +08:00

875 lines
27 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 { hasReadableAssetSource } from '../../services/assetReadUrlService';
import type {
CanvasAssetExportImage,
CanvasAssetExportMetadata,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
blobToUint8Array,
buildAnimatedGifPreviewBytes,
buildLayerExportMetadata,
buildLayerVisibleExportMetadata,
buildSpineImageSequenceJson,
buildSpineImageSequenceReadme,
type ExportedImageSequenceFrame,
formatExportDate,
getImageSequenceFrameFileName,
getLayerAssetDownloadExtension,
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);
}
/**
* 导出目录名。名字在「挑目录」「建目录」「按层分流」三处都要用,
* 只留这一份字面量:写错目录名不会编译报错,只会在 zip 里悄悄换目录。
*/
const EXPORT_FOLDER = {
images: 'images',
media: 'media',
models: '3d_models',
} as const;
type CanvasAssetExportFolderName =
(typeof EXPORT_FOLDER)[keyof typeof EXPORT_FOLDER];
/** 导出目录按媒体族分:图片 `images/`、音视频 `media/`、3D 模型 `3d_models/`。 */
function resolveLayerExportFolderName(
layer: CanvasLayer,
): CanvasAssetExportFolderName {
if (layer.assetKind === 'model3d') {
return EXPORT_FOLDER.models;
}
return (layer.mediaType ?? 'image') === 'image'
? EXPORT_FOLDER.images
: EXPORT_FOLDER.media;
}
/** zip 目标目录:三类各一个,模型不进图片目录。 */
function resolveExportTargetFolder(
folderName: CanvasAssetExportFolderName,
folders: { imagesFolder: JSZip; mediaFolder: JSZip; modelsFolder: JSZip },
) {
if (folderName === EXPORT_FOLDER.models) {
return folders.modelsFolder;
}
if (folderName === EXPORT_FOLDER.media) {
return folders.mediaFolder;
}
return folders.imagesFolder;
}
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: CanvasAssetExportFolderName;
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 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<AssetExportStatus | null>(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 = getLayerAssetDownloadExtension(layer, blob.type);
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(EXPORT_FOLDER.images) ?? rootFolder;
const mediaFolder =
rootFolder.folder(EXPORT_FOLDER.media) ?? rootFolder;
// 3D 资源导出的是模型本体(.glb/.gltf/.fbx),混进 images/ 会让人以为
// zip 里是坏图片,所以单独一个目录。
const modelsFolder =
rootFolder.folder(EXPORT_FOLDER.models) ?? 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: {
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 extension = getLayerAssetDownloadExtension(
layer,
blob.type,
);
const folderName = resolveLayerExportFolderName(layer);
return {
status: 'asset',
plan,
blob,
bytes,
folderName,
fileName: `${indexedFileName}.${extension}`,
};
} catch (error) {
const extension = getLayerAssetDownloadExtension(layer, '');
const folderName = resolveLayerExportFolderName(layer);
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 = resolveExportTargetFolder(
preparedExport.folderName,
{ imagesFolder, mediaFolder, modelsFolder },
);
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,
};
}