Files
Genarrative/src/components/image-editor/ImageCanvasExportModel.test.ts
T
k88936 498b905bd8
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 15m48s
优化素材库多选操作 (#114)
<video src="attachments/2377fc8b-50e4-4919-ba41-5027b957709f" title="2026-07-27 16-15-53.mp4" controls></video>

- 实现shift ctrl的范围选扩选
- 实现多选下载(为一个zip)
- 原本有搜索和文件夹折叠展开功能, 我的处理是: 只作为方便查找的用途. 最终操作会有"目前一些(比如待删除)元素被隐藏了,请再确认"的提示.

result:
```shell
root@k88936-t14p: ~/Downloads# unzip 未命名画布-选中素材-20260806-195631.zip
Archive:  未命名画布-选中素材-20260806-195631.zip
   creating: 未命名画布-选中素材/
   creating: 未命名画布-选中素材/images/
   creating: 未命名画布-选中素材/media/
 extracting: 未命名画布-选中素材/media/001-角色动作(原始视频).mp4
   creating: 未命名画布-选中素材/sequences/
   creating: 未命名画布-选中素材/sequences/002-角色动作/
   creating: 未命名画布-选中素材/sequences/002-角色动作/frames/
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-01.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-02.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-03.png
...
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-31.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-32.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/metadata.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/skeleton.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/README.md
 extracting: 未命名画布-选中素材/sequences/002-角色动作/manifest.txt
 extracting: 未命名画布-选中素材/metadata.json
 extracting: 未命名画布-选中素材/manifest.txt
 ```

~~动作的导出存在很大的问题, 在另一个pr #117 解决~~

---------

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

547 lines
16 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
buildAnimatedGifPreviewBytes,
buildLayerExportMetadata,
buildSpineImageSequenceJson,
buildSpineImageSequenceReadme,
dataUrlToBlob,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerAssetExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerAssetBlob,
readLayerImageSequenceFrameBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import {
DEFAULT_ICON_DESCRIPTIONS,
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
} from './ImageCanvasGenerationModel';
describe('ImageCanvasExportModel', () => {
it('formats export timestamps through seconds', () => {
expect(formatExportDate(new Date(2026, 6, 27, 9, 8, 7))).toBe(
'20260727-090807',
);
});
it('builds a real animated GIF preview from sequence frames', () => {
const gifBytes = buildAnimatedGifPreviewBytes({
width: 2,
height: 1,
frameDelayCentiseconds: 12,
frames: [
{
width: 2,
height: 1,
rgba: Uint8Array.from([255, 0, 0, 255, 0, 0, 0, 0]),
},
{
width: 2,
height: 1,
rgba: Uint8Array.from([0, 255, 0, 255, 0, 0, 255, 255]),
},
],
});
expect(readAscii(gifBytes, 0, 6)).toBe('GIF89a');
expect(readAscii(gifBytes, 0, gifBytes.length)).toContain('NETSCAPE2.0');
expect(countGifImageDescriptors(gifBytes)).toBe(2);
expect(gifBytes.at(-1)).toBe(0x3b);
});
it('rejects GIF preview frames with mismatched dimensions', () => {
expect(() =>
buildAnimatedGifPreviewBytes({
width: 2,
height: 2,
frameDelayCentiseconds: 12,
frames: [
{
width: 1,
height: 2,
rgba: Uint8Array.from([255, 0, 0, 255, 0, 0, 255, 255]),
},
],
}),
).toThrow('GIF 预览帧尺寸不一致');
});
it('normalizes export file names and dates', () => {
expect(sanitizeExportFilePart(' 角色/草图:*? ', 'fallback')).toBe(
'角色 草图',
);
expect(sanitizeExportFilePart(' ', 'fallback')).toBe('fallback');
expect(formatExportDate(new Date(2026, 5, 17, 9, 2, 3))).toBe(
'20260617-090203',
);
});
it('chooses stable image export keys by persistence identity', () => {
expect(
getLayerExportKey({
...buildLayer(),
assetObjectId: 'object-1',
objectKey: 'object-key',
sourceAssetId: 'asset-1',
src: 'data:image/png;base64,one',
}),
).toBe('object-1');
expect(
getLayerExportKey({
...buildLayer(),
objectKey: 'object-key',
sourceAssetId: 'asset-1',
src: 'data:image/png;base64,one',
}),
).toBe('object-key');
expect(
getLayerExportKey({
...buildLayer(),
mediaType: 'image-sequence',
taskId: 'sequence-task',
imageSequenceFrames: [
{
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
],
}),
).toBe('image-sequence:task:sequence-task');
expect(
getLayerExportKey({
...buildLayer(),
mediaType: 'image-sequence',
taskId: null,
sourceResourceId: null,
src: '',
imageSequenceFrames: [
{
imageSrc: '',
objectKey: 'private/animation-a/frame-01.png',
width: 1024,
height: 1024,
},
],
}),
).toBe(
'image-sequence:frames:["object-key:private/animation-a/frame-01.png"]',
);
});
it('detects image extensions from content type before falling back to src', () => {
expect(getImageExtensionFromTypeOrSrc('image/jpeg', '/image.png')).toBe(
'jpg',
);
expect(getImageExtensionFromTypeOrSrc('', '/image.webp?x=1')).toBe('webp');
expect(getImageExtensionFromTypeOrSrc('', '/image.unknown')).toBe('png');
expect(
getLayerAssetExtensionFromTypeOrSrc('video', 'video/mp4', '/video.png'),
).toBe('mp4');
expect(
getLayerAssetExtensionFromTypeOrSrc(
'video',
'',
'/generated-character-drafts/editor/preview.mp4',
),
).toBe('mp4');
expect(
getLayerAssetExtensionFromTypeOrSrc(
'audio',
'',
'/generated-audios/effect.wav?token=1',
),
).toBe('wav');
expect(
getLayerAssetExtensionFromTypeOrSrc(
'image-sequence',
'',
'/generated-character-drafts/editor/frame01.png',
),
).toBe('zip');
});
it('converts data URLs and builds layer metadata for manifest files', async () => {
const blob = dataUrlToBlob('data:text/plain;base64,SGVsbG8=');
expect(blob.type).toBe('text/plain');
expect(await blob.text()).toBe('Hello');
expect(
buildLayerExportMetadata(buildLayer(), 'images/001-layer.png'),
).toEqual({
title: '导出图层',
file: 'images/001-layer.png',
visible: {
type: '生成图片',
generationInputs: {
fields: [{ title: '生成提示词', value: '清爽游戏按钮' }],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
model: 'gpt-image-2',
task: '1',
object: 'generated/layer.png',
resolution: '1024 x 1024 px',
},
exportError: undefined,
});
});
it('preserves game scene semantics in exported layer metadata', () => {
const metadata = buildLayerExportMetadata(
buildLayer({ assetKind: 'scene' }),
'images/001-scene.png',
);
expect(metadata.visible.type).toBe('游戏场景');
});
it('filters built-in prompts from exported visible generation inputs', () => {
const metadata = buildLayerExportMetadata(
buildLayer({
model: 'gpt-image-2',
generationInputs: {
fields: [
{ title: '生成提示词', value: 'AI 生成图片' },
{ title: '修改要求', value: '修改当前图片' },
{ title: 'prompt', value: '游戏音效' },
{
title: 'gpt_description_prompt',
value: '游戏背景音乐',
},
{
title: '提取提示词',
value: UI_DESIGN_ASSET_EXTRACTION_PROMPT,
},
{
title: '素材描述',
value: DEFAULT_ICON_DESCRIPTIONS.join('\n'),
},
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: '时长', value: '5秒' },
{ title: '处理模型', value: 'birefnet' },
],
references: [],
},
}),
'images/001-layer.png',
);
expect(metadata.visible.generationInputs).toEqual({
fields: [
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: '时长', value: '5秒' },
],
references: [],
});
expect(metadata.visible.model).toBe('gpt-image-2');
});
it('builds a Spine JSON attachment timeline from image sequence frames', () => {
const layer = buildLayer({
mediaType: 'image-sequence',
originalWidth: 192,
originalHeight: 256,
imageSequenceDurationMs: 500,
});
const spineJson = buildSpineImageSequenceJson({
layer,
frames: [
{ fileName: 'frame-01.png', width: 192, height: 256 },
{ fileName: 'frame-02.png', width: 192, height: 256 },
{ fileName: 'frame-03.png', width: 192, height: 256 },
{ fileName: 'frame-04.png', width: 192, height: 256 },
],
});
expect(spineJson.skeleton).toMatchObject({
images: './frames/',
fps: 8,
width: 192,
height: 256,
});
expect(spineJson.bones).toEqual([{ name: 'root' }]);
expect(spineJson.slots[0]).toEqual({
name: 'character',
bone: 'root',
attachment: 'animation_0001',
});
expect(
spineJson.skins[0]!.attachments.character.animation_0001,
).toMatchObject({
type: 'region',
path: 'frame-01',
y: 128,
width: 192,
height: 256,
});
expect(spineJson.animations.animation.slots.character.attachment).toEqual([
{ time: 0, name: 'animation_0001' },
{ time: 0.125, name: 'animation_0002' },
{ time: 0.25, name: 'animation_0003' },
{ time: 0.375, name: 'animation_0004' },
{ time: 0.5, name: 'animation_0001' },
]);
expect(buildSpineImageSequenceReadme(layer)).toContain('skeleton.json');
});
it('reads private object-key assets through same-origin bytes before exporting', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('/api/assets/read-bytes?')) {
return new Response(new Blob(['video'], { type: 'video/mp4' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const blob = await readLayerAssetBlob(
buildLayer({
src: '',
mediaType: 'video',
objectKey: 'generated/video.mp4',
}),
);
expect(blob.type).toBe('video/mp4');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
'/api/assets/read-bytes?objectKey=generated%2Fvideo.mp4',
),
expect.any(Object),
);
expect(fetchMock).not.toHaveBeenCalledWith(
expect.stringContaining('x-oss-signature'),
expect.anything(),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('falls back when OSS returns 206 but its response body fails during export', async () => {
const originalFetch = globalThis.fetch;
const signedResponseBlob = vi
.fn()
.mockRejectedValue(new TypeError('Failed to fetch'));
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('/api/assets/read-url?')) {
return new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated/export-body-failure.png',
signedUrl: 'https://signed.example.com/export-body-failure.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-06-16',
routeVersion: '2026-06-16',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
}
if (url === 'https://signed.example.com/export-body-failure.png') {
return {
ok: true,
status: 206,
statusText: 'Partial Content',
headers: new Headers({
'Content-Range': 'bytes 0-2/3',
'Content-Type': 'image/png',
}),
blob: signedResponseBlob,
} as unknown as Response;
}
if (url.startsWith('/api/assets/read-bytes?')) {
return new Response(new Blob(['fallback'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const blob = await readLayerAssetBlob(
buildLayer({
src: '/generated-editor-images/export-body-failure.png',
objectKey: 'generated/export-body-failure.png',
}),
);
expect(signedResponseBlob).toHaveBeenCalledTimes(1);
await expect(blob.text()).resolves.toBe('fallback');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
'/api/assets/read-bytes?objectKey=generated%2Fexport-body-failure.png',
),
expect.any(Object),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('reads private image-sequence frame object keys through same-origin bytes', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('/api/assets/read-bytes?')) {
return new Response(new Blob(['frame'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const blob = await readLayerImageSequenceFrameBlob(
buildLayer({
mediaType: 'image-sequence',
imageSequenceFrames: [
{
imageSrc: '/generated-editor-frames/frame-1.png',
objectKey: 'generated/frame-1.png',
width: 512,
height: 512,
},
],
}),
{
imageSrc: '/generated-editor-frames/frame-1.png',
objectKey: 'generated/frame-1.png',
width: 512,
height: 512,
},
);
expect(blob).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
'/api/assets/read-bytes?objectKey=generated%2Fframe-1.png',
),
expect.any(Object),
);
} finally {
globalThis.fetch = originalFetch;
}
});
});
function readAscii(bytes: Uint8Array, start: number, end: number) {
return Array.from(bytes.slice(start, end), (byte) =>
String.fromCharCode(byte),
).join('');
}
function skipGifDataSubBlocks(bytes: Uint8Array, offset: number) {
let cursor = offset;
while (cursor < bytes.length) {
const blockLength = bytes[cursor] ?? 0;
cursor += 1;
if (blockLength === 0) {
return cursor;
}
cursor += blockLength;
}
return cursor;
}
function countGifImageDescriptors(bytes: Uint8Array) {
let cursor = 13 + 256 * 3;
let count = 0;
while (cursor < bytes.length) {
const blockType = bytes[cursor];
if (blockType === 0x3b) {
return count;
}
if (blockType === 0x21) {
const label = bytes[cursor + 1];
if (label === 0xf9) {
const blockLength = bytes[cursor + 2] ?? 0;
cursor += 3 + blockLength + 1;
continue;
}
cursor = skipGifDataSubBlocks(
bytes,
cursor + 3 + (bytes[cursor + 2] ?? 0),
);
continue;
}
if (blockType === 0x2c) {
count += 1;
const packed = bytes[cursor + 9] ?? 0;
cursor += 10;
if (packed & 0x80) {
cursor += 3 * 2 ** ((packed & 0x07) + 1);
}
cursor += 1;
cursor = skipGifDataSubBlocks(bytes, cursor);
continue;
}
throw new Error(`Unknown GIF block: ${blockType}`);
}
return count;
}
function buildLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
resourceId: 'resource-1',
title: '导出图层',
src: 'data:image/png;base64,one',
x: 10,
y: 20,
width: 512,
height: 512,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 3,
sourceType: 'generated',
prompt: '生成提示',
actualPrompt: '实际提示',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
objectKey: 'generated/layer.png',
sourceResourceId: 'source-resource',
sourceAssetId: 'asset-1',
generationInputs: {
fields: [{ title: '生成提示词', value: '清爽游戏按钮' }],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
groupId: 'group-1',
hidden: true,
locked: false,
flipX: true,
flipY: false,
...overrides,
};
}