Files
Genarrative/src/components/image-editor/ImageCanvasExportModel.test.ts
T
kdletters 1176c71f72 角色动作素材改为序列帧
将角色动作结果图层主类型改为 image-sequence,并持久化完整帧列表与预览视频来源

在画布中增加序列帧播放器,展示透明 PNG 帧而不是预览视频

将角色动作单素材下载和画布素材导出改为序列帧 ZIP/frames 目录

更新角色动作、导出方案和项目记忆文档,补齐相关前端测试
2026-06-22 20:49:51 +08:00

214 lines
6.0 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
buildLayerExportMetadata,
dataUrlToBlob,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerAssetExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerAssetBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
describe('ImageCanvasExportModel', () => {
it('normalizes export file names and dates', () => {
expect(sanitizeExportFilePart(' 角色/草图:*? ', 'fallback')).toBe(
'角色 草图',
);
expect(sanitizeExportFilePart(' ', 'fallback')).toBe('fallback');
expect(formatExportDate(new Date('2026-06-17T01:02:03.000Z'))).toBe(
'20260617',
);
});
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: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
],
}),
).toBe('image-sequence:sequence-task');
});
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({
layerId: 'layer-1',
title: '导出图层',
file: 'images/001-layer.png',
mediaType: 'image',
sourceType: 'generated',
prompt: '生成提示',
actualPrompt: '实际提示',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
objectKey: 'generated/layer.png',
assetObjectId: undefined,
sourceResourceId: 'source-resource',
sourceAssetId: 'asset-1',
exportError: undefined,
canvas: {
x: 10,
y: 20,
width: 512,
height: 512,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 3,
groupId: 'group-1',
hidden: true,
locked: false,
flipX: true,
flipY: false,
},
});
});
it('reads private object-key assets through signed URLs before exporting', async () => {
const originalFetch = globalThis.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/video.mp4',
signedUrl: 'https://oss.example.com/generated/video.mp4?x-oss-signature=1',
expiresAt: '2026-06-20T00:00:00.000Z',
},
},
error: null,
meta: { apiVersion: '2026-06-16' },
}),
);
}
if (url === 'https://oss.example.com/generated/video.mp4?x-oss-signature=1') {
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: '/generated-editor-videos/video.mp4',
mediaType: 'video',
objectKey: 'generated/video.mp4',
}),
);
expect(blob.type).toBe('video/mp4');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/assets/read-url?objectKey=generated%2Fvideo.mp4'),
expect.any(Object),
);
expect(fetchMock).toHaveBeenCalledWith(
'https://oss.example.com/generated/video.mp4?x-oss-signature=1',
);
} finally {
globalThis.fetch = originalFetch;
}
});
});
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',
groupId: 'group-1',
hidden: true,
locked: false,
flipX: true,
flipY: false,
...overrides,
};
}