Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
T
k88936 498b905bd8
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 15m48s
优化素材库多选操作 (#114)
<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>
2026-08-08 11:11:25 +08:00

841 lines
26 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,
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 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 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 JSONskeleton.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,
};
}