Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx
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

1179 lines
40 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={() => {
void workflow.exportCanvasAssets();
void workflow.exportLayerImage(layers[0] ?? null);
}}
>
同时导出画布和单图
</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('exports selected object-key-only assets through the private byte reader', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith('/api/assets/read-url?')) {
return new Response(null, { status: 404 });
}
if (url.startsWith('/api/assets/read-bytes?')) {
const objectKey = new URL(url, 'http://localhost').searchParams.get(
'objectKey',
);
return new Response(new Blob([objectKey ?? ''], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
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:object-key-only-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('object-key-a', {
title: '私有素材A',
src: '',
objectKey: 'generated/editor/private-a.png',
zIndex: 0,
}),
createLayer('object-key-b', {
title: '私有素材B',
src: '',
objectKey: 'generated/editor/private-b.png',
zIndex: 1,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '下载多个选中素材' }));
await waitFor(() => expect(exportedBlob).toBeTruthy());
const byteReadUrls = fetchMock.mock.calls
.map(([input]) => String(input))
.filter((url) => url.startsWith('/api/assets/read-bytes?'));
expect(byteReadUrls).toEqual(
expect.arrayContaining([
expect.stringContaining(
'objectKey=generated%2Feditor%2Fprivate-a.png',
),
expect.stringContaining(
'objectKey=generated%2Feditor%2Fprivate-b.png',
),
]),
);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(
zip.file('导出项目-选中素材/images/001-私有素材A.png'),
).toBeTruthy();
expect(
zip.file('导出项目-选中素材/images/002-私有素材B.png'),
).toBeTruthy();
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('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('uses the same mutex for collection and direct layer exports', 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:shared-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:画布素材已导出',
);
});
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('exports frame-only image sequences from selected and canvas entry points', 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:frame-only-${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('frame-only-sequence', {
title: '仅帧序列',
src: '',
objectKey: null,
mediaType: 'image-sequence',
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,c2VxdWVuY2U=',
width: 100,
height: 80,
},
],
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '下载一个选中素材' }));
await waitFor(() => expect(downloads).toHaveLength(1));
expect(downloads[0]?.name).toMatch(
/^仅帧序列-SpineJSON-\d{8}-\d{6}\.zip$/u,
);
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
await waitFor(() => expect(downloads).toHaveLength(2));
const zip = await JSZip.loadAsync(downloads[1]!.blob);
expect(
zip.file(
'导出项目-画布素材/sequences/001-仅帧序列/frames/frame-01.png',
),
).toBeTruthy();
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('exports distinct object-key-only private image sequences with separate metadata', async () => {
const originalFetch = globalThis.fetch;
const frameContents = new Map([
['private/animation-a/frame-01.png', 'animation-a-frame'],
['private/animation-b/frame-01.png', 'animation-b-frame'],
]);
const fetchMock = vi.fn(async (url: string) => {
const requestUrl = new URL(url, 'http://localhost');
if (requestUrl.pathname === '/api/assets/read-url') {
return new Response(null, { status: 404 });
}
if (requestUrl.pathname === '/api/assets/read-bytes') {
const objectKey = requestUrl.searchParams.get('objectKey') ?? '';
const content = frameContents.get(objectKey);
return content
? new Response(content, {
headers: { 'Content-Type': 'image/png' },
})
: new Response(null, { status: 404 });
}
return new Response(null, { status: 404 });
});
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:private-sequences';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('private-sequence-a', {
title: '私有动作 A',
src: '',
objectKey: null,
taskId: null,
sourceResourceId: null,
mediaType: 'image-sequence',
imageSequenceFrames: [
{
imageSrc: '',
objectKey: 'private/animation-a/frame-01.png',
width: 100,
height: 80,
},
],
zIndex: 1,
}),
createLayer('private-sequence-b', {
title: '私有动作 B',
src: '',
objectKey: null,
taskId: null,
sourceResourceId: null,
mediaType: 'image-sequence',
imageSequenceFrames: [
{
imageSrc: '',
objectKey: 'private/animation-b/frame-01.png',
width: 100,
height: 80,
},
],
zIndex: 2,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
await waitFor(() => expect(exportedBlob).toBeTruthy());
const zip = await JSZip.loadAsync(exportedBlob!);
const firstFramePath =
'导出项目-画布素材/sequences/001-私有动作 A/frames/frame-01.png';
const secondFramePath =
'导出项目-画布素材/sequences/002-私有动作 B/frames/frame-01.png';
expect(await zip.file(firstFramePath)!.async('text')).toBe(
'animation-a-frame',
);
expect(await zip.file(secondFramePath)!.async('text')).toBe(
'animation-b-frame',
);
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.layers).toHaveLength(2);
expect(
metadata.layers.map((layer: { file: string }) => layer.file),
).toEqual([
'sequences/001-私有动作 A/manifest.txt',
'sequences/002-私有动作 B/manifest.txt',
]);
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
).toContain('素材数量:2');
for (const objectKey of frameContents.keys()) {
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
`/api/assets/read-bytes?objectKey=${encodeURIComponent(objectKey)}`,
),
expect.any(Object),
);
}
} 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: [
{
imageSrc: 'data:image/png;base64,c2VxMQ==',
width: 1024,
height: 1024,
},
{
imageSrc: 'data:image/png;base64,c2VxMg==',
width: 1024,
height: 1024,
},
],
imageSequenceDurationMs: 4_000,
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).toBeUndefined();
expect(metadata.layers[4].visible.generationInputs).toBeNull();
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: [
{
imageSrc: 'data:image/png;base64,c2VxMQ==',
width: 1024,
height: 1024,
},
{
imageSrc: 'data:image/png;base64,c2VxMg==',
width: 1024,
height: 1024,
},
],
imageSequenceDurationMs: 4_000,
}),
]}
/>,
);
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: [
{
imageSrc: 'data:image/png;base64,c2VxMQ==',
width: 1024,
height: 1024,
},
{
imageSrc: 'data:image/png;base64,c2VxMg==',
width: 1024,
height: 1024,
},
],
imageSequenceDurationMs: 4_000,
}),
]}
/>,
);
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;
}
});
});