新增动作导出二级菜单与序列帧zip预览

- 图层上下文菜单支持动作序列层导出二级菜单(序列帧zip / Spine zip)

- 导出流程新增序列帧导出模式参数,默认保持 spine-json

- 序列帧导出 zip 增加 manifest、metadata 与 preview.gif 产出

- 为导出、上下文菜单和命令层新增模式参数透传

- 完善相关单元测试覆盖新菜单与 zip 内容断言
This commit is contained in:
2026-06-25 18:59:04 +08:00
parent e24781e983
commit 960568364e
9 changed files with 322 additions and 65 deletions
@@ -3,8 +3,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -33,6 +33,7 @@ function renderContextMenus(
canvasClipboard: null,
imageContextMenu: null,
imageContextMenuLayer: null,
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
onPasteCanvasClipboard: vi.fn(),
@@ -82,7 +83,10 @@ describe('ImageCanvasContextMenusView', () => {
});
it('renders layer context commands and forwards layer operations', () => {
const layer = createLayer({ assetKind: 'character' });
const layer = createLayer({
assetKind: 'character',
mediaType: 'image-sequence',
});
const props = renderContextMenus({
contextMenu: {
kind: 'layer',
@@ -93,6 +97,7 @@ describe('ImageCanvasContextMenusView', () => {
},
canvasClipboard: { layers: [layer], mode: 'copy' },
imageContextMenuLayer: layer,
contextMenuLayer: layer,
contextShouldShowLayer: true,
contextShouldUnlockLayer: true,
});
@@ -104,6 +109,8 @@ describe('ImageCanvasContextMenusView', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
fireEvent.click(screen.getByRole('menuitem', { name: '解锁' }));
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Spine 导出(zip' }));
fireEvent.click(screen.getByRole('menuitem', { name: '序列帧导出(zip' }));
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
@@ -114,6 +121,14 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onToggleContextLayerVisibility).toHaveBeenCalledTimes(1);
expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1);
expect(props.onFlipContextLayers).toHaveBeenCalledWith('x');
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ mode: 'spine-json' }),
);
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ mode: 'sequence-with-preview' }),
);
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
@@ -13,6 +13,7 @@ type ImageCanvasContextMenusViewProps = {
canvasClipboard: CanvasClipboard | null;
imageContextMenu: ImageContextMenuState | null;
imageContextMenuLayer: CanvasLayer | null;
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
@@ -24,7 +25,9 @@ type ImageCanvasContextMenusViewProps = {
onToggleContextLayerVisibility: () => void;
onToggleContextLayerLock: () => void;
onFlipContextLayers: (axis: 'x' | 'y') => void;
onExportContextLayer: () => void;
onExportContextLayer: (options?: {
mode?: 'spine-json' | 'sequence-with-preview';
}) => void;
onDeleteContextLayers: () => void;
onDeleteLayerById: (layerId: string | null) => void;
onCloseContextMenu: () => void;
@@ -42,6 +45,7 @@ export function ImageCanvasContextMenusView({
canvasClipboard,
imageContextMenu,
imageContextMenuLayer,
contextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
onPasteCanvasClipboard,
@@ -64,6 +68,8 @@ export function ImageCanvasContextMenusView({
onOpenLayerMetadata,
onOpenCharacterAnimationPanel,
}: ImageCanvasContextMenusViewProps) {
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
return (
<>
{contextMenu ? (
@@ -221,9 +227,38 @@ export function ImageCanvasContextMenusView({
>
</button>
<button type="button" role="menuitem" onClick={onExportContextLayer}>
</button>
{isImageSequenceLayer ? (
<PlatformFloatingMenu
label="导出为"
placement="bottom-start"
style={{ margin: 0 }}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() =>
onExportContextLayer({
mode: 'sequence-with-preview',
})
}
>
zip
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => onExportContextLayer({ mode: 'spine-json' })}
>
Spine zip
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>
) : (
<button
type="button"
role="menuitem"
onClick={() => onExportContextLayer()}
>
</button>
)}
<hr />
{imageContextMenuLayer ? (
<>
@@ -146,6 +146,7 @@ function createStageProps(): ImageCanvasStageViewProps {
canvasClipboard: null,
imageContextMenu: null,
imageContextMenuLayer: null,
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canUndo: false,
@@ -35,7 +35,6 @@ import {
resolveContextMenuPosition,
} from './ImageCanvasEditorModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
import type {
AssetPointerDragState,
CanvasAssetKind,
@@ -55,6 +54,7 @@ import {
getSelectedGenerationDialogIds,
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useCanvasHistory } from './useCanvasHistory';
import {
@@ -888,6 +888,10 @@ export function ImageCanvasEditorView() {
clearGenerationPanelsAfterBlur,
getCanvasPointFromClient,
});
const contextMenuLayer =
contextMenu?.kind === 'layer'
? layers.find((layer) => layer.id === contextMenu.layerId) ?? null
: null;
const {
canvasClipboard,
pasteCanvasClipboard,
@@ -1434,6 +1438,7 @@ export function ImageCanvasEditorView() {
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(),
contextMenuLayer,
onQuickEditSelectionToolChange: changeQuickEditSelectionTool,
onQuickEditSelectionPointerStart: startQuickEditSelectionPointer,
onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer,
@@ -66,6 +66,7 @@ export type ImageCanvasStageViewProps = {
canvasClipboard: CanvasClipboard | null;
imageContextMenu: ImageContextMenuState | null;
imageContextMenuLayer: CanvasLayer | null;
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
canUndo: boolean;
@@ -137,7 +138,9 @@ export type ImageCanvasStageViewProps = {
onToggleContextLayerVisibility: () => void;
onToggleContextLayerLock: () => void;
onFlipContextLayers: (axis: 'x' | 'y') => void;
onExportContextLayer: () => void;
onExportContextLayer: (options?: {
mode?: 'spine-json' | 'sequence-with-preview';
}) => void;
onDeleteContextLayers: () => void;
onDeleteLayerById: (layerId: string | null) => void;
onCloseContextMenu: () => void;
@@ -194,6 +197,7 @@ export function ImageCanvasStageView({
canvasClipboard,
imageContextMenu,
imageContextMenuLayer,
contextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
canUndo,
@@ -362,6 +366,7 @@ export function ImageCanvasStageView({
canvasClipboard={canvasClipboard}
imageContextMenu={imageContextMenu}
imageContextMenuLayer={imageContextMenuLayer}
contextMenuLayer={contextMenuLayer}
contextShouldShowLayer={contextShouldShowLayer}
contextShouldUnlockLayer={contextShouldUnlockLayer}
onPasteCanvasClipboard={onPasteCanvasClipboard}
@@ -65,6 +65,24 @@ function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
>
</button>
<button
type="button"
onClick={() =>
workflow.exportLayerImage(layers[0] ?? null, {
mode: 'sequence-with-preview',
})
}
>
zip
</button>
<button
type="button"
onClick={() =>
workflow.exportLayerImage(layers[0] ?? null, { mode: 'spine-json' })
}
>
Spine zip
</button>
</div>
);
}
@@ -515,4 +533,75 @@ describe('useImageCanvasAssetExportWorkflow', () => {
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('downloads character animation layers as sequence zip with gif preview', async () => {
let exportedBlob: Blob | null = null;
let downloadName = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:sequence-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('sequence-export', {
title: '角色动作/跑步',
src: 'data:image/png;base64,c2VxMQ==',
mediaType: 'image-sequence',
assetKind: 'character-animation',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: 'data:image/png;base64,c2VxMQ==',
width: 1024,
height: 1024,
},
{
frameIndex: 2,
imageSrc: 'data:image/png;base64,c2VxMg==',
width: 1024,
height: 1024,
},
],
durationSeconds: 4,
}),
]}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '导出序列帧zip' }),
);
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toBe('角色动作 跑步-Sequence.zip');
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('frames/frame-01.png')).toBeTruthy();
expect(zip.file('frames/frame-02.png')).toBeTruthy();
expect(zip.file('preview.gif')).toBeTruthy();
expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧');
expect(await readZipText(zip, 'metadata.json')).toContain('"frameCount": 2');
expect(zip.file('skeleton.json')).toBeNull();
expect(zip.file('README.md')).toBeNull();
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
});
@@ -28,6 +28,8 @@ export type AssetExportStatus = {
message: string;
};
export type ImageSequenceExportMode = 'spine-json' | 'sequence-with-preview';
type UseImageCanvasAssetExportWorkflowOptions = {
layers: CanvasLayer[];
projectId: string | null;
@@ -52,7 +54,53 @@ function triggerBrowserDownload(blob: Blob, downloadName: string) {
return true;
}
async function buildImageSequenceZip(layer: CanvasLayer) {
function createFallbackPreviewBlob(frameBlob: Blob) {
return frameBlob.size
? new Blob([frameBlob], { type: 'image/gif' })
: new Blob([], { type: 'image/gif' });
}
async function buildAnimatedSequencePreview(frameBlob: Blob, size: { width: number; height: number }) {
if (!frameBlob.size) {
return createFallbackPreviewBlob(frameBlob);
}
try {
if (typeof createImageBitmap === 'undefined') {
return createFallbackPreviewBlob(frameBlob);
}
const frameImage = await createImageBitmap(frameBlob);
const canvas = document.createElement('canvas');
canvas.width = size.width || frameImage.width;
canvas.height = size.height || frameImage.height;
const context = canvas.getContext('2d');
if (!context) {
return createFallbackPreviewBlob(frameBlob);
}
const width = Math.max(1, size.width || frameImage.width);
const height = Math.max(1, size.height || frameImage.height);
canvas.width = width;
canvas.height = height;
context.drawImage(frameImage, 0, 0, width, height);
return await new Promise<Blob>((resolve) => {
canvas.toBlob(
(blob) => {
resolve(blob ?? createFallbackPreviewBlob(frameBlob));
},
'image/gif',
);
});
} catch {
return createFallbackPreviewBlob(frameBlob);
}
}
async function buildImageSequenceZip(
layer: CanvasLayer,
mode: ImageSequenceExportMode,
) {
const frames = getLayerImageSequenceFrames(layer);
if (!frames.length) {
throw new Error('序列帧为空');
@@ -65,10 +113,14 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
}> = [];
const exportedFrames: ExportedImageSequenceFrame[] = [];
let successCount = 0;
let firstFrameBlob: Blob | null = null;
for (const [index, frame] of frames.entries()) {
try {
const blob = await readLayerImageSequenceFrameBlob(layer, frame);
if (!firstFrameBlob) {
firstFrameBlob = blob;
}
const fileName = getImageSequenceFrameFileName(frame, index, blob.type);
framesFolder.file(fileName, await blobToUint8Array(blob));
exportedFrames.push({
@@ -90,6 +142,40 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
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 (firstFrameBlob) {
const previewBlob = await buildAnimatedSequencePreview(firstFrameBlob, {
width: layer.width,
height: layer.height,
});
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(
@@ -138,16 +224,50 @@ export function useImageCanvasAssetExportWorkflow({
useState<AssetExportStatus | null>(null);
const [isExportingAssets, setIsExportingAssets] = useState(false);
const exportLayerImage = useCallback((layer: CanvasLayer | null) => {
if (!layer) {
return;
}
if (layer.mediaType === 'image-sequence') {
void buildImageSequenceZip(layer)
.then((zipBlob) => {
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(() => {
setAssetExportStatus({
tone: 'error',
message: '序列帧导出失败',
});
});
return;
}
void readLayerAssetBlob(layer)
.then((blob) => {
const extension = getLayerAssetExtensionFromTypeOrSrc(
layer.mediaType ?? 'image',
blob.type,
layer.objectKey ?? layer.src,
);
const downloaded = triggerBrowserDownload(
zipBlob,
`${sanitizeExportFilePart(layer.title, '角色动作')}-SpineJSON.zip`,
blob,
`${sanitizeExportFilePart(layer.title, 'canvas-layer')}.${extension}`,
);
if (!downloaded) {
setAssetExportStatus({
@@ -159,36 +279,12 @@ export function useImageCanvasAssetExportWorkflow({
.catch(() => {
setAssetExportStatus({
tone: 'error',
message: '序列帧导出失败',
message: '素材导出失败',
});
});
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) {
@@ -33,7 +33,10 @@ function LayerCommandsHarness({
onDeleteLayerSideEffects = vi.fn(),
onDeleteGenerationDialogSideEffects = vi.fn(),
}: {
exportLayerImage?: (layer: CanvasLayer | null) => void;
exportLayerImage?: (
layer: CanvasLayer | null,
options?: { mode?: 'spine-json' | 'sequence-with-preview' },
) => void;
onDeleteLayerSideEffects?: (layerId: string) => void;
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
}) {
@@ -330,6 +333,7 @@ describe('useImageCanvasLayerCommands', () => {
fireEvent.click(screen.getByRole('button', { name: '导出' }));
expect(exportLayerImage).toHaveBeenCalledWith(
expect.objectContaining({ id: 'third' }),
expect.any(Object),
);
expect(screen.getByTestId('context').textContent).toBe('closed');
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
@@ -1,8 +1,8 @@
import {
useCallback,
useState,
type Dispatch,
type SetStateAction,
useCallback,
useState,
} from 'react';
import type {
@@ -12,6 +12,8 @@ import type {
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
type CanvasLayerFlipAxis,
type CanvasLayerMoveMode,
createCanvasLayerClipboard,
duplicateCanvasLayers,
flipCanvasLayers,
@@ -24,13 +26,12 @@ import {
toggleCanvasLayersVisibility,
ungroupCanvasLayers,
updateCanvasLayersByIds,
type CanvasLayerFlipAxis,
type CanvasLayerMoveMode,
} from './ImageCanvasLayerCommandModel';
import {
getCanvasGenerationDialogIdFromSelectionId,
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
import type { ImageSequenceExportMode } from './useImageCanvasAssetExportWorkflow';
type LayerCommandsOptions = {
layers: CanvasLayer[];
@@ -50,7 +51,10 @@ type LayerCommandsOptions = {
selectSingleLayer: (layerId: string | null) => void;
onDeleteLayerSideEffects: (targetLayerId: string) => void;
onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void;
exportLayerImage: (layer: CanvasLayer | null) => void;
exportLayerImage: (
layer: CanvasLayer | null,
options?: { mode?: ImageSequenceExportMode },
) => void;
};
function createGroupId() {
@@ -405,19 +409,22 @@ export function useImageCanvasLayerCommands({
setMetadataLayer,
]);
const exportContextLayer = useCallback(() => {
const targetIds = getContextTargetLayerIds();
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
exportLayerImage(targetLayer ?? null);
setContextMenu(null);
setImageContextMenu(null);
}, [
exportLayerImage,
getContextTargetLayerIds,
layers,
setContextMenu,
setImageContextMenu,
]);
const exportContextLayer = useCallback(
(options: { mode?: ImageSequenceExportMode } = {}) => {
const targetIds = getContextTargetLayerIds();
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
exportLayerImage(targetLayer ?? null, options);
setContextMenu(null);
setImageContextMenu(null);
},
[
exportLayerImage,
getContextTargetLayerIds,
layers,
setContextMenu,
setImageContextMenu,
],
);
const deleteLayerById = useCallback(
(targetLayerId: string | null) => {