import { describe, expect, it } from 'vitest'; import type { CanvasGenerationInputs, CanvasLayer, } from './ImageCanvasEditorTypes'; import { createAudioResultLayer, createCharacterAnimationResultLayer, createGeneratedResultLayer, createIconSpritesheetResultLayers, createQuickEditResultLayer, createVideoResultLayer, } from './ImageCanvasGenerationLayerModel'; function createGenerated(overrides = {}) { return { imageSrc: 'data:image/png;base64,generated', width: 1024, height: 1024, sourceType: 'generated' as const, prompt: '生成提示词', actualPrompt: '实际提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-generated', objectKey: 'generated/object.png', assetObjectId: 'asset-object-generated', ...overrides, }; } function createSourceLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-source', resourceId: 'resource-source', title: '源图', src: 'data:image/png;base64,source', x: 120, y: 140, width: 320, height: 240, originalWidth: 1536, originalHeight: 1024, zIndex: 2, sourceType: 'generated', groupId: 'group-a', assetKind: 'spec', ...overrides, }; } function createGenerationInputs(): CanvasGenerationInputs { return { fields: [{ title: '生成提示词', value: '生成提示词' }], references: [], }; } describe('ImageCanvasGenerationLayerModel', () => { it('creates generated result layers centered on the active placeholder', () => { const layer = createGeneratedResultLayer({ generated: createGenerated({ width: 2048, height: 1152 }), generatedIndex: 7, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, frame: { x: 80, y: 60, width: 560, height: 315, originalWidth: 2048, originalHeight: 1152, }, assetKind: 'spec', title: '图标规范 7', generationInputs: createGenerationInputs(), }); expect(layer).toMatchObject({ id: 'layer-generated-7', resourceId: 'local-resource-generated-7', title: '图标规范 7', x: -664, y: -358.5, width: 2048, height: 1152, originalWidth: 2048, originalHeight: 1152, zIndex: 17, sourceType: 'generated', assetKind: 'spec', prompt: '生成提示词', actualPrompt: '实际提示词', objectKey: 'generated/object.png', assetObjectId: 'asset-object-generated', }); expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词'); }); it('keeps publication material result dimensions constrained by the active placeholder', () => { const layer = createGeneratedResultLayer({ generated: createGenerated({ width: 1024, height: 1024 }), generatedIndex: 8, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, frame: { x: 180, y: 120, width: 360, height: 640, originalWidth: 720, originalHeight: 1280, }, assetKind: 'publication-material', title: '8 宣发素材', generationInputs: createGenerationInputs(), }); expect(layer).toMatchObject({ id: 'layer-generated-8', title: '8 宣发素材', width: 720, height: 1280, originalWidth: 720, originalHeight: 1280, assetKind: 'publication-material', }); }); it('keeps publication cover image results at the 4:3 workflow dimensions', () => { const layer = createGeneratedResultLayer({ generated: createGenerated({ width: 1024, height: 1024 }), generatedIndex: 9, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, frame: { x: 100, y: 100, width: 480, height: 360, originalWidth: 720, originalHeight: 540, }, assetKind: 'publication-material', title: '9 宣发素材', generationInputs: createGenerationInputs(), }); expect(layer).toMatchObject({ width: 720, height: 540, originalWidth: 720, originalHeight: 540, assetKind: 'publication-material', }); }); it('uses backend-persisted resource and asset snapshots for generated layers', () => { const generationInputs = createGenerationInputs(); const layer = createGeneratedResultLayer({ generated: createGenerated({ resource: { resourceId: 'resource-generated-bff', projectId: 'editor-project-1', imageSrc: 'data:image/png;base64,generated', objectKey: 'generated/from-resource.png', assetObjectId: 'asset-object-from-resource', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character', generationInputs, }, asset: { assetId: 'asset-generated-bff', folderId: 'user-1:asset-folder:project', label: '角色形象', imageSrc: 'data:image/png;base64,generated', objectKey: 'generated/from-asset.png', assetObjectId: 'asset-object-from-asset', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character', generationInputs, }, }), generatedIndex: 9, canvasSize: { width: 900, height: 640 }, viewport: { x: 0, y: 0, scale: 1 }, assetKind: 'spec', generationInputs: { fields: [{ title: '旧输入', value: '本地兜底' }], references: [], }, }); expect(layer).toMatchObject({ resourceId: 'resource-generated-bff', sourceAssetId: 'asset-generated-bff', objectKey: 'generated/from-resource.png', assetObjectId: 'asset-object-from-resource', assetKind: 'character', }); expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词'); expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-generated-bff'); }); it('creates edit result layers beside the source image', () => { const sourceLayer = createSourceLayer(); const layer = createGeneratedResultLayer({ generated: createGenerated({ prompt: '修改要求' }), generatedIndex: 8, canvasSize: { width: 900, height: 640 }, viewport: { x: 0, y: 0, scale: 1 }, sourceLayer, generationInputs: createGenerationInputs(), }); expect(layer).toMatchObject({ id: 'layer-edit-8', resourceId: 'local-resource-edit-8', title: '源图 修改结果', x: 472, y: 140, width: 1024, height: 1024, sourceResourceId: 'resource-source', prompt: '修改要求', }); }); it('creates quick edit layers centered on the generating placeholder', () => { const sourceLayer = createSourceLayer(); const layer = createQuickEditResultLayer({ generated: createGenerated({ width: 1536, height: 1024 }), generatedIndex: 10, sourceLayer, generationInputs: createGenerationInputs(), frame: { x: 600, y: 300, width: 320, height: 240, originalWidth: 1536, originalHeight: 1024, }, }); expect(layer).toMatchObject({ id: 'layer-quick-edit-10', x: -8, y: -92, width: 1536, height: 1024, }); }); it('creates quick edit layers with source group and asset kind preserved', () => { const sourceLayer = createSourceLayer(); const layer = createQuickEditResultLayer({ generated: createGenerated({ width: 1536, height: 1024 }), generatedIndex: 9, sourceLayer, generationInputs: createGenerationInputs(), }); expect(layer).toMatchObject({ id: 'layer-quick-edit-9', resourceId: 'local-resource-quick-edit-9', title: '源图 快速编辑', x: 472, y: 140, width: 1536, height: 1024, originalWidth: 1536, originalHeight: 1024, sourceResourceId: 'resource-source', groupId: 'group-a', assetKind: 'spec', }); }); it('creates wrapped icon layers with icon metadata', () => { const layers = createIconSpritesheetResultLayers({ generated: { spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], prompt: '图标 prompt', actualPrompt: '图标 actual prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', priceMudPoints: 20, }, iconResults: [ { name: '返回按钮', imageSrc: 'data:image/png;base64,back', width: 256, height: 256, }, { name: '设置按钮', imageSrc: 'data:image/png;base64,settings', width: 512, height: 256, }, { name: '提示按钮', imageSrc: 'data:image/png;base64,hint', width: 128, height: 128, }, ], startIndex: 11, canvasSize: { width: 900, height: 640 }, viewport: { x: 0, y: 0, scale: 1 }, frame: { x: 200, y: 100, width: 360, height: 360, originalWidth: 512, originalHeight: 512, }, generationInputs: createGenerationInputs(), }); expect(layers).toHaveLength(4); expect(layers[0]).toMatchObject({ id: 'layer-icon-spritesheet-11', title: '图标素材图集 11', x: 200, y: 100, width: 512, height: 512, assetKind: 'icon-spritesheet', prompt: '图标 prompt', actualPrompt: '图标 actual prompt', }); expect(layers[1]).toMatchObject({ id: 'layer-icon-12', title: '返回按钮', x: 744, y: 100, width: 256, height: 256, assetKind: 'icon', prompt: '图标 prompt', actualPrompt: '图标 actual prompt', }); expect(layers[2]).toMatchObject({ id: 'layer-icon-13', title: '设置按钮', x: 744, y: 380, width: 512, height: 256, assetKind: 'icon', }); expect(layers[3]).toMatchObject({ id: 'layer-icon-14', title: '提示按钮', x: 744, y: 660, width: 128, height: 128, assetKind: 'icon', }); }); it('keeps the spritesheet layer when icon results are missing', () => { const layers = createIconSpritesheetResultLayers({ generated: { spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], prompt: '图标 prompt', actualPrompt: '图标 actual prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', priceMudPoints: 20, }, iconResults: undefined, startIndex: 11, canvasSize: { width: 900, height: 640 }, viewport: { x: 0, y: 0, scale: 1 }, generationInputs: createGenerationInputs(), }); expect(layers).toHaveLength(1); expect(layers[0]).toMatchObject({ id: 'layer-icon-spritesheet-11', title: '图标素材图集 11', assetKind: 'icon-spritesheet', src: 'data:image/png;base64,sheet', }); }); it('creates audio result layers centered on the active placeholder', () => { const layer = createAudioResultLayer({ generated: { audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3', objectKey: 'generated-character-drafts/editor-audios/sfx.mp3', assetObjectId: 'assetobj-audio-1', width: 420, height: 120, sourceType: 'generated', prompt: '金币掉落叮当声', actualPrompt: '金币掉落叮当声', model: 'audio1.0', provider: 'VectorEngine', taskId: 'sound-task-1', priceMudPoints: 10, audioKind: 'sound-effect', durationSeconds: 75, }, generatedIndex: 14, title: '游戏音效 14', canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, frame: { x: 80, y: 60, width: 420, height: 120, originalWidth: 420, originalHeight: 120, }, generationInputs: { fields: [{ title: 'prompt', value: '金币掉落叮当声' }], references: [], }, }); expect(layer).toMatchObject({ id: 'layer-audio-14', resourceId: 'local-resource-audio-14', title: '游戏音效 14', src: '/generated-character-drafts/editor-audios/sfx.mp3', mediaType: 'audio', assetKind: 'sound-effect', x: 80, y: 60, width: 420, height: 120, originalWidth: 420, originalHeight: 120, sourceType: 'generated', prompt: '金币掉落叮当声', model: 'audio1.0', provider: 'VectorEngine', taskId: 'sound-task-1', objectKey: 'generated-character-drafts/editor-audios/sfx.mp3', assetObjectId: 'assetobj-audio-1', durationSeconds: 75, }); }); it('creates video result layers with OSS asset identity', () => { const layer = createVideoResultLayer({ generated: { videoSrc: '/generated-character-drafts/editor-videos/video.mp4', objectKey: 'generated-character-drafts/editor-videos/video.mp4', assetObjectId: 'assetobj-video-1', width: 1280, height: 720, sourceType: 'generated', prompt: '开场动画', actualPrompt: '开场动画', model: 'seedance2.0-fast', provider: 'VectorEngine', taskId: 'video-task-1', priceMudPoints: 40, }, generatedIndex: 15, title: '生成视频 15', canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, generationInputs: createGenerationInputs(), }); expect(layer).toMatchObject({ id: 'layer-video-15', src: '/generated-character-drafts/editor-videos/video.mp4', mediaType: 'video', assetKind: 'video', objectKey: 'generated-character-drafts/editor-videos/video.mp4', assetObjectId: 'assetobj-video-1', }); }); it('creates character animation result layers as playable image sequences', () => { const layer = createCharacterAnimationResultLayer({ generated: { taskId: 'character-animation-task-1', model: 'seedance2.0-fast', prompt: '待机动作', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame1.png', width: 1024, height: 1024, }, ], frameCount: 32, durationSeconds: 4, fps: 8, priceMudPoints: 40, }, generatedIndex: 15, title: '角色动作', canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, frame: { x: 80, y: 60, width: 420, height: 420, originalWidth: 1024, originalHeight: 1024, }, generationInputs: { fields: [{ title: '动作描述', value: '待机动作' }], references: [], }, }); expect(layer).toMatchObject({ id: 'layer-character-animation-15', resourceId: 'local-resource-character-animation-15', title: '角色动作', src: '/generated-character-drafts/editor/frame1.png', mediaType: 'image-sequence', thumbnailSrc: '/generated-character-drafts/editor/frame1.png', imageSequenceFrames: [ { frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame1.png', width: 1024, height: 1024, }, ], previewVideoPath: '/generated-character-drafts/editor/preview.mp4', x: -222, y: -242, width: 1024, height: 1024, originalWidth: 1024, originalHeight: 1024, zIndex: 25, sourceType: 'generated', prompt: '待机动作', actualPrompt: '待机动作', model: 'seedance2.0-fast', provider: 'ark', taskId: 'character-animation-task-1', assetKind: 'character-animation', durationSeconds: 4, }); expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作'); }); });