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
- 后端资源投影按 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 样例模型
1382 lines
47 KiB
TypeScript
1382 lines
47 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={() => {
|
||
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;
|
||
}
|
||
});
|
||
});
|