Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx
T
k88936 f7a191b3d9 并发读取素材导出内容
使用共享四路并发读取器限制素材和序列帧网络读取

预先稳定规划去重文件名并按规划顺序写入 ZIP

覆盖外层素材并发、并发上限和逆序完成帧顺序
2026-07-27 16:42:03 +08:00

862 lines
29 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.
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import JSZip from 'jszip';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
function createLayer(
id: string,
overrides: Partial<CanvasLayer> = {},
): CanvasLayer {
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `data:image/png;base64,${btoa(`layer-${id.replace(/[^\w-]/gu, '-')}`)}`,
x: 10,
y: 20,
width: 100,
height: 80,
originalWidth: 100,
originalHeight: 80,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
async function readZipText(zip: JSZip, path: string) {
const file = zip.file(path);
if (!file) {
throw new Error(`Missing zip file: ${path}`);
}
return file.async('text');
}
function readAscii(bytes: Uint8Array, start: number, end: number) {
return Array.from(bytes.slice(start, end), (byte) =>
String.fromCharCode(byte),
).join('');
}
function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
const workflow = useImageCanvasAssetExportWorkflow({
layers,
projectId: 'project-export',
projectTitle: '导出项目',
});
return (
<div>
<span data-testid="exporting">{String(workflow.isExportingAssets)}</span>
<span data-testid="status">
{workflow.assetExportStatus
? `${workflow.assetExportStatus.tone}:${workflow.assetExportStatus.message}`
: '-'}
</span>
<button
type="button"
onClick={() => {
void workflow.exportCanvasAssets();
}}
>
导出画布素材
</button>
<button
type="button"
onClick={() => {
void workflow.exportCanvasAssets();
void workflow.exportCanvasAssets();
}}
>
连续导出画布素材
</button>
<button
type="button"
onClick={() => workflow.exportLayerImage(layers[0] ?? null)}
>
导出单图
</button>
<button
type="button"
onClick={() => void workflow.exportSelectedAssets(layers.slice(0, 1))}
>
下载一个选中素材
</button>
<button
type="button"
onClick={() => void workflow.exportSelectedAssets(layers.slice(0, 2))}
>
下载多个选中素材
</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>
);
}
describe('useImageCanvasAssetExportWorkflow', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('downloads one selected asset directly and packages multiple selected assets', async () => {
const downloads: Array<{ blob: Blob; name: string }> = [];
let currentBlob: Blob | null = null;
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
currentBlob = blob;
return `blob:selected-${downloads.length}`;
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
if (currentBlob) {
downloads.push({ blob: currentBlob, name: this.download });
}
},
);
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('asset-a', { title: '素材A', zIndex: 0 }),
createLayer('asset-b', { title: '素材B', zIndex: 1 }),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '下载一个选中素材' }));
await waitFor(() => expect(downloads).toHaveLength(1));
expect(downloads[0]?.name).toMatch(/^素材A-\d{8}-\d{6}\.png$/u);
expect(screen.getByTestId('status').textContent).toBe(
'success:选中素材已导出',
);
fireEvent.click(screen.getByRole('button', { name: '下载多个选中素材' }));
await waitFor(() => expect(downloads).toHaveLength(2));
expect(downloads[1]?.name).toMatch(
/^导出项目-选中素材-\d{8}-\d{6}\.zip$/u,
);
const zip = await JSZip.loadAsync(downloads[1]!.blob);
expect(zip.file('导出项目-选中素材/images/001-素材A.png')).toBeTruthy();
expect(zip.file('导出项目-选中素材/images/002-素材B.png')).toBeTruthy();
expect(screen.getByTestId('status').textContent).toBe(
'success:选中素材已导出',
);
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('prevents concurrent collection exports before React re-renders', async () => {
const originalFetch = globalThis.fetch;
let resolveFetch: (response: Response) => void = () => {};
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
globalThis.fetch = fetchMock as typeof fetch;
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:mutex-export'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
try {
render(
<ExportWorkflowHarness
layers={[createLayer('slow-export', { src: '/slow-export.png' })]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '连续导出画布素材' }));
expect(fetchMock).toHaveBeenCalledTimes(1);
resolveFetch(new Response(new Blob(['image'], { type: 'image/png' })));
await waitFor(() => {
expect(screen.getByTestId('status').textContent).toBe(
'success:画布素材已导出',
);
});
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('reads layers and sequence frames with bounded concurrency and stable zip order', async () => {
const originalFetch = globalThis.fetch;
const pendingResponses = new Map<string, () => void>();
let activeRequestCount = 0;
let maximumRequestCount = 0;
const fetchMock = vi.fn((url: string) => {
activeRequestCount += 1;
maximumRequestCount = Math.max(maximumRequestCount, activeRequestCount);
return new Promise<Response>((resolve) => {
pendingResponses.set(url, () => {
activeRequestCount -= 1;
pendingResponses.delete(url);
resolve(
new Response(url, { headers: { 'content-type': 'image/png' } }),
);
});
});
});
globalThis.fetch = fetchMock as typeof fetch;
let exportedBlob: Blob | null = null;
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:concurrent-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('sequence-concurrent', {
title: '并发序列',
zIndex: 0,
mediaType: 'image-sequence',
imageSequenceFrames: Array.from({ length: 5 }, (_, index) => ({
frameIndex: index + 1,
imageSrc: `/frame-${index + 1}.png`,
width: 100,
height: 80,
})),
}),
createLayer('outer-layer', {
title: '外层素材',
src: '/outer-layer.png',
zIndex: 1,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4));
expect(maximumRequestCount).toBe(4);
pendingResponses.get('/frame-4.png')?.();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(5));
pendingResponses.get('/frame-2.png')?.();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(6));
expect(fetchMock).toHaveBeenCalledWith(
'/outer-layer.png',
expect.any(Object),
);
expect(maximumRequestCount).toBe(4);
[...pendingResponses.values()].reverse().forEach((resolve) => resolve());
await waitFor(() => expect(exportedBlob).toBeTruthy());
const zip = await JSZip.loadAsync(exportedBlob!);
const framesPrefix = '导出项目-画布素材/sequences/001-并发序列/frames/';
expect(
Object.keys(zip.files).filter(
(path) => path.startsWith(framesPrefix) && path.endsWith('.png'),
),
).toEqual(
Array.from(
{ length: 5 },
(_, index) => `${framesPrefix}frame-0${index + 1}.png`,
),
);
expect(await readZipText(zip, `${framesPrefix}frame-02.png`)).toBe(
'/frame-2.png',
);
expect(
zip.file('导出项目-画布素材/images/002-外层素材.png'),
).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('exports canvas assets into a zip with metadata and failed image records', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
const pathname = new URL(url, 'http://localhost').pathname;
if (pathname === '/generated-ok.png') {
return new Response(new Blob(['generated'], { type: 'image/png' }));
}
if (pathname === '/generated-video.mp4') {
return new Response(new Blob(['video'], { type: 'video/mp4' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
let exportedBlob: Blob | null = null;
let downloadName = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:editor-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('asset-a', {
title: '素材 A',
sourceAssetId: 'asset-a',
zIndex: 1,
hidden: true,
locked: true,
flipX: true,
}),
createLayer('asset-a-duplicate', {
title: '素材 A 副本',
sourceAssetId: 'asset-a',
zIndex: 2,
}),
createLayer('generated', {
title: '生成图',
src: '/generated-ok.png',
sourceType: 'generated',
prompt: '明亮主视觉',
actualPrompt: '后端拼接提示词',
provider: 'VectorEngine',
taskId: 'task-987',
assetObjectId: 'asset-object-visible',
sourceResourceId: 'resource-generated',
sourceAssetId: 'asset-generated',
generationInputs: {
fields: [
{ title: '生成提示词', value: '明亮主视觉' },
{ title: '提取提示词', value: '系统固定提取提示词' },
],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
zIndex: 3,
}),
createLayer('video', {
title: '生成视频',
src: '/generated-video.mp4',
mediaType: 'video',
sourceType: 'generated',
zIndex: 4,
}),
createLayer('audio', {
title: '胜利音效',
src: 'data:audio/wav;base64,YXVkaW8=',
mediaType: 'audio',
objectKey: 'generated-audio.wav',
sourceType: 'generated',
zIndex: 5,
}),
createLayer('sequence', {
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,
previewVideoPath:
'/generated-character-drafts/editor/preview.mp4',
sourceType: 'generated',
zIndex: 6,
}),
createLayer('failed', {
title: '失败图',
src: '/missing.png',
zIndex: 7,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}-\d{6}\.zip$/u);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/media/003-生成视频.mp4')).toBeTruthy();
expect(zip.file('导出项目-画布素材/media/004-胜利音效.wav')).toBeTruthy();
expect(
zip.file(
'导出项目-画布素材/sequences/005-角色动作/frames/frame-01.png',
),
).toBeTruthy();
expect(
zip.file(
'导出项目-画布素材/sequences/005-角色动作/frames/frame-02.png',
),
).toBeTruthy();
expect(
zip.file('导出项目-画布素材/sequences/005-角色动作/manifest.txt'),
).toBeTruthy();
expect(
zip.file('导出项目-画布素材/sequences/005-角色动作/skeleton.json'),
).toBeTruthy();
expect(
await readZipText(
zip,
'导出项目-画布素材/sequences/005-角色动作/README.md',
),
).toContain('Spine JSON');
expect(zip.file('导出项目-画布素材/images/006-失败图.png')).toBeNull();
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.projectId).toBeUndefined();
expect(metadata.layers).toHaveLength(7);
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
expect(metadata.layers[0].canvas).toBeUndefined();
expect(metadata.layers[0].visible.type).toBe('上传图片');
expect(metadata.layers[0].visible.resolution).toBe('100 x 80 px');
expect(metadata.layers[2].visible.type).toBe('生成图片');
expect(metadata.layers[2].visible.model).toBe('-');
expect(metadata.layers[2].visible.task).toBe('987');
expect(metadata.layers[2].visible.object).toBe('asset-object-visible');
expect(metadata.layers[2].visible.generationInputs).toEqual({
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
});
expect(JSON.stringify(metadata)).not.toContain('系统固定提取提示词');
expect(metadata.layers[2].sourceType).toBeUndefined();
expect(metadata.layers[2].prompt).toBeUndefined();
expect(metadata.layers[2].actualPrompt).toBeUndefined();
expect(metadata.layers[2].provider).toBeUndefined();
expect(metadata.layers[2].sourceResourceId).toBeUndefined();
expect(metadata.layers[2].sourceAssetId).toBeUndefined();
expect(metadata.layers[3].file).toBe('media/003-生成视频.mp4');
expect(metadata.layers[3].visible.type).toBe('生成视频');
expect(metadata.layers[4].file).toBe('media/004-胜利音效.wav');
expect(metadata.layers[4].visible.type).toBe('生成音频');
expect(metadata.layers[4].visible.duration).toBe('--:--');
expect(metadata.layers[5].file).toBe(
'sequences/005-角色动作/manifest.txt',
);
expect(metadata.layers[5].visible.type).toBe('角色动作序列帧');
const sequenceMetadata = JSON.parse(
await readZipText(
zip,
'导出项目-画布素材/sequences/005-角色动作/metadata.json',
),
);
expect(sequenceMetadata.visible.type).toBe('角色动作序列帧');
expect(sequenceMetadata.taskId).toBeUndefined();
expect(sequenceMetadata.previewVideoPath).toBeUndefined();
expect(sequenceMetadata.failedFrames).toEqual([]);
const sequenceSpineJson = JSON.parse(
await readZipText(
zip,
'导出项目-画布素材/sequences/005-角色动作/skeleton.json',
),
);
expect(sequenceSpineJson.skeleton.images).toBe('./frames/');
expect(
sequenceSpineJson.skins[0].attachments.character.animation_0001.path,
).toBe('frame-01');
expect(
sequenceSpineJson.animations.animation.slots.character.attachment,
).toEqual([
{ time: 0, name: 'animation_0001' },
{ time: 2, name: 'animation_0002' },
{ time: 4, name: 'animation_0001' },
]);
expect(metadata.layers[6].file).toBeNull();
expect(metadata.layers[6].exportError).toContain('404');
expect(metadata.failedImages).toHaveLength(1);
expect(metadata.failedImages[0]).toEqual({
title: '失败图',
error: expect.stringContaining('404'),
});
expect(metadata.failedImages[0].key).toBeUndefined();
expect(metadata.failedImages[0].src).toBeUndefined();
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
).toContain('失败素材数量:1');
expect(screen.getByTestId('status').textContent).toBe(
'error:部分素材未能导出',
);
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('reports empty exports and supports direct layer image downloads', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('/api/assets/read-bytes?')) {
return new Response(new Blob(['private'], { type: 'image/png' }));
}
if (url.startsWith('/generated/video.webm?token=1')) {
return new Response(new Blob(['video'], { type: 'video/webm' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
let downloadedBlob: Blob | null = null;
let downloadName = '';
let downloadHref = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
downloadedBlob = blob;
return 'blob:direct-layer-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
downloadHref = this.href;
},
);
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('single-export', {
title: '单图/导出:*?',
src: '/generated/private.png',
objectKey: 'generated/private.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
await waitFor(() => {
expect(downloadName).toMatch(/^单图 导出-\d{8}-\d{6}\.png$/u);
});
expect(downloadHref).toBe('blob:direct-layer-export');
expect(downloadedBlob).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
'/api/assets/read-bytes?objectKey=generated%2Fprivate.png',
),
expect.any(Object),
);
expect(
fetchMock.mock.calls.some(([url]) =>
String(url).includes('x-oss-signature'),
),
).toBe(false);
render(
<ExportWorkflowHarness
layers={[
createLayer('single-video-export', {
title: '视频导出',
src: '/generated/video.webm?token=1',
mediaType: 'video',
objectKey: null,
}),
]}
/>,
);
fireEvent.click(
screen.getAllByRole('button', { name: '导出单图' }).at(-1)!,
);
await waitFor(() => {
expect(downloadName).toMatch(/^视频导出-\d{8}-\d{6}\.webm$/u);
});
render(<ExportWorkflowHarness layers={[]} />);
fireEvent.click(
screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!,
);
await waitFor(() => {
expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe(
'info:当前画布没有可导出的素材',
);
});
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('downloads character animation layers as a zipped image sequence', 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: '导出单图' }));
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toMatch(
/^角色动作 跑步-SpineJSON-\d{8}-\d{6}\.zip$/u,
);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('frames/frame-01.png')).toBeTruthy();
expect(zip.file('frames/frame-02.png')).toBeTruthy();
expect(await readZipText(zip, 'manifest.txt')).toContain(
'Spine JSONskeleton.json',
);
expect(await readZipText(zip, 'README.md')).toContain('Spine JSON');
const spineJson = JSON.parse(await readZipText(zip, 'skeleton.json'));
expect(spineJson.skeleton).toMatchObject({
images: './frames/',
width: 1024,
height: 1024,
});
expect(spineJson.skins[0].attachments.character.animation_0001).toEqual({
type: 'region',
path: 'frame-01',
x: 0,
y: 512,
width: 1024,
height: 1024,
});
const metadata = JSON.parse(await readZipText(zip, 'metadata.json'));
expect(metadata.title).toBe('角色动作/跑步');
expect(metadata.visible.type).toBe('角色动作序列帧');
expect(metadata.visible.resolution).toBe('100 x 80 px');
expect(metadata.taskId).toBeUndefined();
expect(metadata.previewVideoPath).toBeUndefined();
expect(metadata.failedFrames).toEqual([]);
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('downloads character animation layers as sequence zip with gif preview', async () => {
Object.defineProperty(globalThis, 'createImageBitmap', {
configurable: true,
value: vi.fn(async () => ({ width: 1024, height: 1024 })),
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
clearRect: vi.fn(),
drawImage: vi.fn(),
getImageData: vi.fn(
(_x: number, _y: number, width: number, height: number) => {
const data = new Uint8ClampedArray(width * height * 4);
for (let index = 0; index < data.length; index += 4) {
data[index] = 255;
data[index + 3] = 255;
}
return { data };
},
),
} as unknown as CanvasRenderingContext2D);
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).toMatch(
/^角色动作 跑步-Sequence-\d{8}-\d{6}\.zip$/u,
);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('frames/frame-01.png')).toBeTruthy();
expect(zip.file('frames/frame-02.png')).toBeTruthy();
const previewGif = await zip.file('preview.gif')!.async('uint8array');
expect(readAscii(previewGif, 0, 6)).toBe('GIF89a');
expect(readAscii(previewGif, 0, previewGif.length)).toContain(
'NETSCAPE2.0',
);
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 (globalThis as unknown as { createImageBitmap?: unknown })
.createImageBitmap;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
});