6277a823ba
新增动作图层导出二级菜单 序列帧导出改为ZIP并生成GIF预览 兼容历史项目封面资源字段
639 lines
22 KiB
TypeScript
639 lines
22 KiB
TypeScript
/* @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={() => workflow.exportLayerImage(layers[0] ?? null)}
|
||
>
|
||
导出单图
|
||
</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('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}\.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).toBe('单图 导出.png');
|
||
});
|
||
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).toBe('视频导出.webm');
|
||
});
|
||
|
||
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).toBe('角色动作 跑步-SpineJSON.zip');
|
||
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 JSON:skeleton.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).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();
|
||
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;
|
||
}
|
||
});
|
||
});
|