Files
Genarrative/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx
k88936 a03f13d0a5
Project CI / Repository checks (push) Successful in 1m28s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 13m58s
Fix/动作 前端展示下载+后端数据结构+精选 的问题 (#117)
原问题

```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```

- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs`   手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试

- 移除preview_video_path字段,  reason:
>preview video
    → separate resource/asset with assetKind="video"
  final transparent action
    → assetKind="character-animation"
    → source_resource_id points to the preview-video resource
    → image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复

- 清除副产品preview video的generation_input_json,  因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造

仍存在的共性问题: #134

- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变

画布放置:
before:
![shotmd-1785207959-compressed.webp](/attachments/e56454f0-49a3-4a8a-951f-c21960975f99)
after:
![shotmd-1785231365-compressed.webp](/attachments/886f07df-c25b-4ecc-acc9-160e1795a991)
精选模块: 主页展示, 审核部分UI:
![shotmd-1785500046-compressed.webp](/attachments/9ec3b679-0045-494a-9f20-134f7e73fecf)
![shotmd-1785499780-compressed.webp](/attachments/42396e63-5f26-4853-a367-0f3c15111add)
这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-06 16:57:38 +08:00

634 lines
22 KiB
TypeScript
Raw Permalink 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={() => 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: [
{
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}\.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).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: [
{
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).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 JSONskeleton.json',
);
expect(await readZipText(zip, 'README.md')).toContain('Spine JSON');
const spineJson = JSON.parse(await readZipText(zip, 'skeleton.json'));
expect(spineJson.skeleton).toMatchObject({
images: './frames/',
width: 1024,
height: 1024,
});
expect(spineJson.skins[0].attachments.character.animation_0001).toEqual({
type: 'region',
path: 'frame-01',
x: 0,
y: 512,
width: 1024,
height: 1024,
});
const metadata = JSON.parse(await readZipText(zip, 'metadata.json'));
expect(metadata.title).toBe('角色动作/跑步');
expect(metadata.visible.type).toBe('角色动作序列帧');
expect(metadata.visible.resolution).toBe('100 x 80 px');
expect(metadata.taskId).toBeUndefined();
expect(metadata.previewVideoPath).toBeUndefined();
expect(metadata.failedFrames).toEqual([]);
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('downloads character animation layers as sequence zip with gif preview', async () => {
Object.defineProperty(globalThis, 'createImageBitmap', {
configurable: true,
value: vi.fn(async () => ({ width: 1024, height: 1024 })),
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
clearRect: vi.fn(),
drawImage: vi.fn(),
getImageData: vi.fn(
(_x: number, _y: number, width: number, height: number) => {
const data = new Uint8ClampedArray(width * height * 4);
for (let index = 0; index < data.length; index += 4) {
data[index] = 255;
data[index + 3] = 255;
}
return { data };
},
),
} as unknown as CanvasRenderingContext2D);
let exportedBlob: Blob | null = null;
let downloadName = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:sequence-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('sequence-export', {
title: '角色动作/跑步',
src: 'data:image/png;base64,c2VxMQ==',
mediaType: 'image-sequence',
assetKind: 'character-animation',
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,c2VxMQ==',
width: 1024,
height: 1024,
},
{
imageSrc: 'data:image/png;base64,c2VxMg==',
width: 1024,
height: 1024,
},
],
imageSequenceDurationMs: 4_000,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出序列帧zip' }));
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).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;
}
});
});