Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx
T
k88936 61fe4c48af
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
图片画布接入 3D 资源:新增 model3d 类别、3D 预览与 3D 生成入口
- 后端资源投影按 asset_kind 分叉,新增 project_editor_client_media_src 与 EDITOR_MODEL3D_ASSET_KIND:model3d 只投影预览图,模型本体只留 objectKey,预览缺失才回落历史口径
- tripo3d 存储新增按字节魔数识别模型 content_type 与扩展名映射,不采信 provider 声明与下载响应头
- editor_project_storage 把 model3d 加入 EDITOR_CANVAS_ASSET_KINDS 白名单
- 前端 CanvasAssetKind 与 image-canvas-core 类型新增 model3d:画布卡片渲染预览图或 3D 占位,类型角标新增「3D模型」,覆盖资格只在它与空类别之间成立
- 新增 model3d-preview 子目录(Model3dViewerModal / Model3dViewerModel),Tailwind 内联、不新增 CSS,失败原因分超限 / 格式不可识别 / 无 WebGL2 三类展示
- 选中工具条新增只读 preview-model3d 能力位,仅 objectKey 为空时置灰;打开预览同时选中图层并暂停画布快捷键与舞台交互
- 查看器包源对象 format 改为可选,新增「声明 Content-Type → 字节魔数 → 地址扩展名」三级格式判定并导出支持格式清单
- 导出工作流新增 3d_models/ 目录,模型下载按真实响应头 MIME 定扩展名、对象键扩展名兜底
- 新增 3D 生成入口:底部工具栏浮动子选项与文生 / 图生 3D 面板,落 projectResource + canvasCompletion,复用占位框与任务侧栏
- 3D 定价只读实时定价查询,段缺失即入口不可提交;幂等键由前端铸造,用户重试必须换键
- 新增 ADR 0003 / 0004 与实施计划、里程碑文档;更新 Tripo 技术方案、后端架构、CONTEXT 术语、decision-log 与 pitfalls
- .gitignore 忽略仓库根目录手工下载的 3D 样例模型
2026-09-22 10:10:00 +08:00

1382 lines
47 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('downloads a 3D model layer with the model extension from the response MIME', 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?')) {
return new Response(new Uint8Array([0x67, 0x6c, 0x54, 0x46]), {
headers: { 'content-type': 'model/gltf-binary' },
});
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
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:model3d-export';
}),
});
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('model-3d', {
title: '三维角色',
src: '',
// 扩展名与响应 MIME 故意不一致:下载命名必须以 MIME 为准。
objectKey: 'generated/editor/model3d/task-1/model.fbx',
assetKind: 'model3d',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
await waitFor(() => expect(downloads).toHaveLength(1));
expect(downloads[0]?.blob.type).toContain('model/gltf-binary');
expect(downloads[0]?.name).toMatch(/^三维角色-\d{8}-\d{6}\.glb$/u);
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('falls back to the object key extension when the 3D response has no model MIME', 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?')) {
return new Response(new Uint8Array([0x4b, 0x61, 0x79, 0x64]), {
headers: { 'content-type': 'application/octet-stream' },
});
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
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:model3d-extension-fallback';
}),
});
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('model-fbx', {
title: '三维角色',
src: '',
objectKey: 'generated/editor/model3d/task-1/model.fbx',
assetKind: 'model3d',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
await waitFor(() => expect(downloads).toHaveLength(1));
expect(downloads[0]?.name).toMatch(/^三维角色-\d{8}-\d{6}\.fbx$/u);
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('packages 3D model layers into the 3d_models directory', 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?')) {
return new Response(new Uint8Array([0x67, 0x6c, 0x54, 0x46]), {
headers: { 'content-type': 'model/gltf-binary' },
});
}
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:collection-with-model3d';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('plain-image', {
title: '普通图片',
src: 'data:image/png;base64,cG5n',
}),
createLayer('model-3d', {
title: '三维角色',
src: '',
objectKey: 'generated/editor/model3d/task-1/model.glb',
assetKind: 'model3d',
zIndex: 2,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
await waitFor(() => expect(exportedBlob).toBeTruthy());
const zip = await JSZip.loadAsync(exportedBlob!);
const files = Object.keys(zip.files);
expect(
files.filter(
(file) =>
file.startsWith('导出项目-画布素材/3d_models/') &&
!file.endsWith('/'),
),
).toEqual(['导出项目-画布素材/3d_models/002-三维角色.glb']);
expect(
files.filter(
(file) =>
file.startsWith('导出项目-画布素材/images/') && !file.endsWith('/'),
),
).toEqual(['导出项目-画布素材/images/001-普通图片.png']);
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(
metadata.layers.map((layer: { file: string }) => layer.file),
).toEqual(['images/001-普通图片.png', '3d_models/002-三维角色.glb']);
} 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 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: [
{
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;
}
});
});