498b905bd8
<video src="attachments/2377fc8b-50e4-4919-ba41-5027b957709f" title="2026-07-27 16-15-53.mp4" controls></video> - 实现shift ctrl的范围选扩选 - 实现多选下载(为一个zip) - 原本有搜索和文件夹折叠展开功能, 我的处理是: 只作为方便查找的用途. 最终操作会有"目前一些(比如待删除)元素被隐藏了,请再确认"的提示. result: ```shell root@k88936-t14p: ~/Downloads# unzip 未命名画布-选中素材-20260806-195631.zip Archive: 未命名画布-选中素材-20260806-195631.zip creating: 未命名画布-选中素材/ creating: 未命名画布-选中素材/images/ creating: 未命名画布-选中素材/media/ extracting: 未命名画布-选中素材/media/001-角色动作(原始视频).mp4 creating: 未命名画布-选中素材/sequences/ creating: 未命名画布-选中素材/sequences/002-角色动作/ creating: 未命名画布-选中素材/sequences/002-角色动作/frames/ extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-01.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-02.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-03.png ... extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-31.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-32.png extracting: 未命名画布-选中素材/sequences/002-角色动作/metadata.json extracting: 未命名画布-选中素材/sequences/002-角色动作/skeleton.json extracting: 未命名画布-选中素材/sequences/002-角色动作/README.md extracting: 未命名画布-选中素材/sequences/002-角色动作/manifest.txt extracting: 未命名画布-选中素材/metadata.json extracting: 未命名画布-选中素材/manifest.txt ``` ~~动作的导出存在很大的问题, 在另一个pr #117 解决~~ --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/114 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
841 lines
26 KiB
TypeScript
841 lines
26 KiB
TypeScript
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,
|
||
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;
|
||
|
||
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);
|
||
}
|
||
|
||
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 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 = 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 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('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: {
|
||
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 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.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,
|
||
};
|
||
}
|