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: [ { 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({ 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('filters built-in prompts from exported visible generation inputs', () => { const metadata = buildLayerExportMetadata( buildLayer({ 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: 'duration', value: '5秒' }, ], references: [], }, }), 'images/001-layer.png', ); expect(metadata.visible.generationInputs).toEqual({ fields: [ { title: '用户输入', value: '用户填写的视觉要求' }, { title: '游戏分类', value: '游戏音效' }, { title: 'duration', value: '5秒' }, ], references: [], }); }); it('builds a Spine JSON attachment timeline from image sequence frames', () => { const layer = buildLayer({ mediaType: 'image-sequence', originalWidth: 192, originalHeight: 256, durationSeconds: 0.5, }); const spineJson = buildSpineImageSequenceJson({ layer, frames: [ { frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 }, { frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 }, { frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 }, { frameIndex: 4, 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('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: [ { frameIndex: 1, imageSrc: '/generated-editor-frames/frame-1.png', objectKey: 'generated/frame-1.png', width: 512, height: 512, }, ], }), { frameIndex: 1, 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 { 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, }; }