21b05f9446
兼容生成配方未来字段与历史完美像素快照 补齐参数回退、旧版恢复告警和本地参考图上传 收紧生成任务幂等比较与裁扩来源归属 同步画布编辑器设计文档和回归测试
536 lines
15 KiB
TypeScript
536 lines
15 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('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-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: [
|
|
{
|
|
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({
|
|
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('uses the reference fallback label when export metadata is blank', () => {
|
|
const metadata = buildLayerExportMetadata(
|
|
buildLayer({
|
|
generationInputs: {
|
|
fields: [],
|
|
references: [
|
|
{
|
|
title: '参考图',
|
|
label: ' ',
|
|
refType: 'asset',
|
|
refId: 'asset-reference',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
'images/001-layer.png',
|
|
);
|
|
|
|
expect(metadata.visible.generationInputs?.references[0]?.label).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: '/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-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,
|
|
};
|
|
}
|