Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
T
k88936 8c2f48d816 统一角色动作画布持久化与交互
前端只从正式 resource 或 asset 的图片序列字段恢复、复用、播放和导出角色动作。
动作 layout 只保存资源引用与 placement,不复制帧、时长、生成输入或 mediaType。
项目生成必须绑定正式资源,无项目放置必须绑定正式账号素材,缺失时失败关闭。
补齐素材库拖放、序列 ZIP、画布播放、保存恢复与生成工作流测试。
2026-08-04 10:21:46 +08:00

615 lines
19 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 {
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;
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 JSONskeleton.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 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(() => {
const hasUsableFrameSource =
(layer.imageSequenceFrames?.length ?? 0) > 0 &&
(layer.imageSequenceDurationMs ?? 0) > 0;
setAssetExportStatus({
tone: 'error',
message: hasUsableFrameSource
? '序列帧导出失败'
: `角色动作素材“${layer.title}”不完整:缺少可用序列帧。`,
});
});
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({
fileName,
width: frame.width,
height: frame.height,
});
successCount += 1;
} catch (error) {
failedFrames.push({
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.imageSequenceDurationMs
? `时长:${layer.imageSequenceDurationMs / 1_000}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]);
const reportAssetError = useCallback((message: string) => {
setAssetExportStatus({ tone: 'error', message });
}, []);
return {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportLayerImage,
reportAssetError,
};
}