import { describe, expect, it } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildCharacterAnimationSubmissionPlan, buildIconSpritesheetGenerationSubmissionPlan, buildImageGenerationSubmissionPlan, } from './ImageCanvasGenerationSubmissionModel'; import { IMAGE_MODEL_GPT_IMAGE_2, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; function createLayer(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: 1024, originalHeight: 768, zIndex: 2, sourceType: 'uploaded', ...overrides, }; } describe('ImageCanvasGenerationSubmissionModel', () => { it('builds normal image generation submission plans', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'generate', prompt: ' 一张发光主视觉 ', status: 'idle', }, layers: [], nextGeneratedIndex: 3, }); expect(plan).toMatchObject({ kind: 'image', normalizedPrompt: '一张发光主视觉', input: { prompt: '一张发光主视觉', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', }, result: { title: '生成图片 3', generationInputs: { fields: [{ title: '生成提示词', value: '一张发光主视觉' }], references: [], }, }, rememberImageModel: 'gemini-3.1-flash-image-preview', }); }); it('normalizes legacy nanobanana aliases before submitting image generation', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'generate', prompt: '一张明亮图片', status: 'idle', imageModel: 'nanobanana2', aspectRatio: '16:9', imageSize: '2K', }, layers: [], nextGeneratedIndex: 5, }); expect(plan).toMatchObject({ kind: 'image', input: { model: IMAGE_MODEL_NANOBANANA2, aspectRatio: '16:9', imageSize: '2K', }, rememberImageModel: IMAGE_MODEL_NANOBANANA2, }); }); it('builds edit submission plans with the source layer snapshot', () => { const sourceLayer = createLayer(); const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'edit', prompt: '', status: 'idle', sourceLayerId: sourceLayer.id, }, layers: [sourceLayer], nextGeneratedIndex: 4, }); expect(plan).toMatchObject({ kind: 'edit', normalizedPrompt: '修改当前图片', sourceLayer, generationInputs: { fields: [{ title: '修改要求', value: '修改当前图片' }], references: [ { title: '参考图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], }, }); }); it('builds quick-edit canvas generator plans as direct source edits', () => { const sourceLayer = createLayer({ objectKey: 'generated-character-drafts/editor/source.png', assetKind: 'character', }); const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'quick-edit', prompt: '把当前图改成雨天', status: 'idle', sourceLayerId: sourceLayer.id, imageModel: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '4:3', imageSize: '1K', generationReferences: [ { id: 'style-reference', label: '风格参考', src: 'data:image/png;base64,style', }, ], }, layers: [sourceLayer], nextGeneratedIndex: 4, }); expect(plan).toMatchObject({ kind: 'quick-edit', normalizedPrompt: '把当前图改成雨天', sourceLayer, editInput: { size: '1024x768', model: IMAGE_MODEL_GPT_IMAGE_2, }, result: { title: '源图 快速编辑', assetKind: 'character', generationInputs: { fields: [{ title: '快速编辑提示词', value: '把当前图改成雨天' }], references: [], }, }, rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, }); }); it('builds spec generation plans with reference prompt semantics', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'spec', prompt: '', status: 'idle', specType: 'icon', specValues: { playSetting: '休闲消除', artStyle: '清爽卡通', bodyRatio: '3', characterView: '', customPrompt: '', }, specReference: { id: 'spec-ref', label: '参考.png', src: 'data:image/png;base64,ref', resourceId: 'resource-spec-ref', }, }, layers: [], nextGeneratedIndex: 8, }); expect(plan).toMatchObject({ kind: 'image', normalizedPrompt: 'AI 生成图片', input: { size: '2048x1152', model: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', kind: 'spec', referenceImageSrcs: ['data:image/png;base64,ref'], prompt: expect.stringContaining('参考图生成规范'), }, result: { assetKind: 'icon-spec', title: '图标规范 8', }, }); expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( { fields: [ { title: '玩法设定', value: '休闲消除' }, { title: '美术风格', value: '清爽卡通' }, ], references: [ { title: '参考图', label: '参考.png', refType: 'project-resource', refId: 'resource-spec-ref', }, ], }, ); }); it('treats the renamed UI spec entry as an icon spec asset', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'spec', prompt: '', status: 'idle', specType: 'ui', specValues: { playSetting: '消除类派对玩法', artStyle: '糖果玻璃拟物', bodyRatio: '3', characterView: '', customPrompt: '', }, }, layers: [], nextGeneratedIndex: 2, }); expect(plan).toMatchObject({ kind: 'image', result: { assetKind: 'icon-spec', title: '图标规范 2', }, }); expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty( 'referenceImageSrcs', ); expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain( '参考图生成规范', ); }); it('builds character plans with references and remembered model', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'character', prompt: ' 白发骑士 ', status: 'idle', imageModel: 'gpt-image-2', screenColor: '#FFD6C2', aspectRatio: '2:3', imageSize: '2K', characterSpecReference: { id: 'spec', label: '角色规范', src: 'data:image/png;base64,spec', resourceId: 'resource-character-spec', }, characterReferences: [ { id: 'ref-1', label: '盔甲参考', src: 'data:image/png;base64,armor', resourceId: 'resource-armor', }, ], }, layers: [], nextGeneratedIndex: 9, }); expect(plan).toMatchObject({ kind: 'image', normalizedPrompt: '白发骑士', input: { prompt: '白发骑士', kind: 'character', model: 'gpt-image-2', screenColor: '#FFD6C2', aspectRatio: '2:3', imageSize: '2K', referenceImageSrcs: [ 'data:image/png;base64,spec', 'data:image/png;base64,armor', ], }, result: { assetKind: 'character', title: '角色形象 9', }, rememberImageModel: 'gpt-image-2', }); expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( { fields: [ { title: '角色设定', value: '白发骑士' }, { title: '抠图背景色', value: '暖浅桃色 #FFD6C2' }, ], references: [ { title: '角色规范', label: '角色规范', refType: 'project-resource', refId: 'resource-character-spec', }, { title: '常规参考图 1', label: '盔甲参考', refType: 'project-resource', refId: 'resource-armor', }, ], }, ); }); it('locks UI design generation submissions to gpt-image-2', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'ui-design', prompt: ' 设计移动端商城首页 ', status: 'idle', imageModel: 'nanobanana2', aspectRatio: '16:9', imageSize: '0.5K', uiDesignSpecReference: { id: 'ui-spec', label: 'UI图标规范', src: 'data:image/png;base64,ui-spec', resourceId: 'resource-ui-spec', }, generationReferences: [ { id: 'ui-ref', label: '界面风格参考', src: 'data:image/png;base64,ui-ref', resourceId: 'resource-ui-ref', }, ], }, layers: [], nextGeneratedIndex: 6, }); expect(plan).toMatchObject({ kind: 'image', normalizedPrompt: '设计移动端商城首页', input: { kind: 'ui-design', model: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '16:9', imageSize: '1K', referenceImageSrcs: [ 'data:image/png;base64,ui-spec', 'data:image/png;base64,ui-ref', ], }, result: { assetKind: 'ui-design', title: 'UI设计图 6', generationInputs: { fields: [{ title: '用户输入', value: '设计移动端商城首页' }], references: [ { title: '图标规范', label: 'UI图标规范', refType: 'project-resource', refId: 'resource-ui-spec', }, { title: '参考图 1', label: '界面风格参考', refType: 'project-resource', refId: 'resource-ui-ref', }, ], }, }, rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, }); }); it('builds publication material plans with structured game fields', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'publication', prompt: '', status: 'idle', publicationGameInfo: { gameName: ' 重庆洪崖洞火锅 ', gameCategories: '抓大鹅、休闲、治愈、手绘风', gameDescription: '在一堆物品里点击,找到三个一样的物品将其消除', }, publicationReferences: [ { id: 'ref-1', label: '参考图', src: 'data:image/png;base64,ref', resourceId: 'resource-publication-ref', }, ], }, layers: [], nextGeneratedIndex: 10, }); expect(plan).toEqual({ kind: 'image', normalizedPrompt: [ '游戏名:重庆洪崖洞火锅', '游戏分类:抓大鹅、休闲、治愈、手绘风', '一句话描述游戏:在一堆物品里点击,找到三个一样的物品将其消除', ].join('\n'), input: { prompt: expect.stringContaining('【宣发素材类型】游戏首图'), size: '720x540', kind: 'publication-material', model: 'gpt-image-2', aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,ref'], }, result: { title: '10 宣发素材', generationInputs: { fields: [ { title: '游戏名', value: '重庆洪崖洞火锅' }, { title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' }, { title: '一句话描述游戏', value: '在一堆物品里点击,找到三个一样的物品将其消除', }, ], references: [ { title: '宣发参考图 1', label: '参考图', refType: 'project-resource', refId: 'resource-publication-ref', }, ], }, }, }); const prompt = plan.kind === 'image' ? plan.input.prompt : ''; expect(prompt).toContain('游戏名:重庆洪崖洞火锅'); expect(prompt).toContain('标题文字必须直接进入画面'); expect(prompt).toContain('输出检查:720 x 540,4:3'); }); it('builds detail gallery publication plans as one 720p portrait image', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'publication', prompt: '', status: 'idle', publicationWorkflowId: 'publication-detail-gallery', publicationGameInfo: { gameName: '云朵消消乐', gameCategories: '', gameDescription: '', }, }, layers: [], nextGeneratedIndex: 11, }); expect(plan).toMatchObject({ kind: 'image', input: { prompt: expect.stringContaining('【宣发素材类型】详情页单图'), size: '720x1280', kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', }, result: { title: '11 宣发素材', }, }); expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( '本次只生成一张 720 x 1280 竖版详情图', ); expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( '不是五图合集或拼贴', ); }); it('builds promo poster publication plans as 720p landscape images', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'publication', prompt: '', status: 'idle', publicationWorkflowId: 'publication-promo-poster', publicationGameInfo: { gameName: '火锅节活动', gameCategories: '', gameDescription: '', }, }, layers: [], nextGeneratedIndex: 12, }); expect(plan).toMatchObject({ kind: 'image', input: { prompt: expect.stringContaining('【宣发素材类型】运营海报'), size: '1280x720', kind: 'publication-material', aspectRatio: '16:9', imageSize: '1K', }, result: { title: '12 宣发素材', }, }); expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( '16:9 横版 720p,建议 1280 x 720', ); expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( '此处换上你的游戏二维码', ); expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( '方块内部上方用黑色小字', ); }); it('locks publication material plans to gpt-image-2 and does not remember the fixed model', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'publication', prompt: '', status: 'idle', publicationWorkflowId: 'publication-promo-poster', publicationGameInfo: { gameName: '星际塔防', gameCategories: '', gameDescription: '', }, publicationReferences: [], imageModel: 'nanobanana2', }, layers: [], nextGeneratedIndex: 4, }); expect(plan.kind).toBe('image'); if (plan.kind !== 'image') { return; } expect(plan.input).toEqual( expect.objectContaining({ kind: 'publication-material', model: 'gpt-image-2', size: '1280x720', aspectRatio: '16:9', imageSize: '1K', }), ); expect(plan.rememberImageModel).toBeUndefined(); }); it('throws when edit source layer is missing', () => { expect(() => buildImageGenerationSubmissionPlan({ dialog: { mode: 'edit', prompt: '修图', status: 'idle', sourceLayerId: 'missing-layer', }, layers: [], nextGeneratedIndex: 1, }), ).toThrow('未找到要修改的图片'); }); it('returns an icon spritesheet error when the spec reference is missing', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', prompt: '', status: 'idle', iconDescriptions: ['返回按钮'], }); expect(plan).toEqual({ ok: false, errorMessage: '请选择图标规范', }); }); it('returns an icon spritesheet error when descriptions are empty', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', prompt: '', status: 'idle', iconSpecReference: { id: 'icon-spec', label: '图标规范', src: 'data:image/png;base64,spec', resourceId: 'resource-icon-spec', }, iconDescriptions: [' ', '\n'], }); expect(plan).toEqual({ ok: false, errorMessage: '请填写素材描述', }); }); it('builds icon spritesheet plans with trimmed descriptions and references', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', prompt: ' 返回按钮 \n\n设置按钮', status: 'idle', imageModel: 'gpt-image-2', screenColor: '#7FB3FF', aspectRatio: '3:2', imageSize: '2K', iconSpecReference: { id: 'icon-spec', label: '图标规范', src: 'data:image/png;base64,spec', resourceId: 'resource-icon-spec', }, generationReferences: [ { id: 'icon-ref', label: '按钮风格参考', src: 'data:image/png;base64,ref', resourceId: 'resource-icon-ref', }, ], iconDescriptions: ['旧返回', '旧设置'], }); expect(plan).toEqual({ ok: true, iconDescriptions: ['返回按钮', '设置按钮'], input: { referenceImageSrc: 'data:image/png;base64,spec', referenceImageSrcs: ['data:image/png;base64,ref'], iconDescriptions: ['返回按钮', '设置按钮'], model: 'gpt-image-2', screenColor: '#7FB3FF', aspectRatio: '3:2', imageSize: '2K', }, generationInputs: { fields: [ { title: '素材描述', value: '返回按钮\n设置按钮' }, { title: '抠图背景色', value: '中度天蓝 #7FB3FF' }, ], references: [ { title: '图标规范', label: '图标规范', refType: 'project-resource', refId: 'resource-icon-spec', }, { title: '参考图 1', label: '按钮风格参考', refType: 'project-resource', refId: 'resource-icon-ref', }, ], }, rememberImageModel: 'gpt-image-2', }); }); it('builds character animation plans with trimmed prompt and object key source', () => { const sourceLayer = createLayer({ id: 'character-layer', title: '角色图', objectKey: 'generated/character.png', assetKind: 'character', originalWidth: 960, originalHeight: 1280, }); const plan = buildCharacterAnimationSubmissionPlan({ panel: { sourceLayerId: 'character-layer', promptText: ' 循环奔跑动作 ', resolution: '720p', ratio: 'same', frameCount: 48, durationSeconds: 6, status: 'idle', }, sourceLayer, }); expect(plan).toEqual({ promptText: '循环奔跑动作', input: { sourceLayerId: 'character-layer', sourceImageSrc: 'generated/character.png', sourceWidth: 960, sourceHeight: 1280, promptText: '循环奔跑动作', resolution: '720p', ratio: 'same', frameCount: 48, durationSeconds: 6, model: 'seedance2.0-fast', }, }); }); it('builds seedance video plans with multimodal references', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'video', prompt: ' 根据参考生成角色宣传片 ', status: 'idle', videoModel: 'seedance2.0-fast', videoResolution: '720p', videoDurationSeconds: 5, generationReferences: [ { id: 'image-ref', label: '参考图.png', src: 'data:image/png;base64,image', mediaType: 'image', mimeType: 'image/png', sizeBytes: 1024, resourceId: 'resource-video-image-ref', }, { id: 'video-ref', label: '参考视频.mp4', src: 'https://signed.example.com/video.mp4', mediaType: 'video', mimeType: 'video/mp4', sizeBytes: 2048, durationSeconds: 4, objectKey: 'generated-character-drafts/editor/seedance-references/video/video.mp4', resourceId: 'resource-video-ref', }, { id: 'audio-ref', label: '参考音频.mp3', src: 'https://signed.example.com/audio.mp3', mediaType: 'audio', mimeType: 'audio/mpeg', sizeBytes: 512, durationSeconds: 3, objectKey: 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', resourceId: 'resource-audio-ref', }, ], }, layers: [], nextGeneratedIndex: 6, }); expect(plan).toMatchObject({ kind: 'video', input: { model: 'seedance2.0-fast', referenceImageSrcs: ['data:image/png;base64,image'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], referenceAudioSrcs: [ 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', ], }, result: { generationInputs: { references: [ { title: '参考图 1', label: '参考图.png', refType: 'project-resource', refId: 'resource-video-image-ref', }, { title: '参考视频 1', label: '参考视频.mp4', refType: 'project-resource', refId: 'resource-video-ref', }, { title: '参考音频 1', label: '参考音频.mp3', refType: 'project-resource', refId: 'resource-audio-ref', }, ], }, }, }); }); it('does not submit references to non seedance video models', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'video', prompt: '镜头推进', status: 'idle', videoModel: 'kling3.0', generationReferences: [ { id: 'image-ref', label: '参考图.png', src: 'data:image/png;base64,image', mediaType: 'image', }, ], }, layers: [], nextGeneratedIndex: 2, }); expect(plan.kind).toBe('video'); if (plan.kind === 'video') { expect(plan.input).not.toHaveProperty('referenceImageSrcs'); expect(plan.input).not.toHaveProperty('referenceVideoSrcs'); expect(plan.input).not.toHaveProperty('referenceAudioSrcs'); } }); it('rejects seedance video plans that only contain audio references', () => { expect(() => buildImageGenerationSubmissionPlan({ dialog: { mode: 'video', prompt: '根据音频生成视频', status: 'idle', videoModel: 'seedance2.0', generationReferences: [ { id: 'audio-ref', label: '旁白.wav', src: 'data:audio/wav;base64,audio', mediaType: 'audio', durationSeconds: 4, }, ], }, layers: [], nextGeneratedIndex: 2, }), ).toThrow('参考音频必须搭配参考图片或参考视频'); }); it('rejects seedance video plans that exceed reference count limits', () => { expect(() => buildImageGenerationSubmissionPlan({ dialog: { mode: 'video', prompt: '生成视频', status: 'idle', videoModel: 'seedance2.0-fast', generationReferences: Array.from({ length: 10 }, (_, index) => ({ id: `image-${index}`, label: `参考图${index}.png`, src: `data:image/png;base64,${index}`, mediaType: 'image' as const, })), }, layers: [], nextGeneratedIndex: 2, }), ).toThrow('参考图片最多 9 张'); }); it('builds video generation plans with seedance fast as the fallback model', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'video', prompt: ' 角色绕场一周 ', status: 'idle', generationReferences: [], videoResolution: '480p', videoDurationSeconds: 4, }, layers: [], nextGeneratedIndex: 3, }); expect(plan).toMatchObject({ kind: 'video', normalizedPrompt: '角色绕场一周', input: { model: 'seedance2.0-fast', }, result: { title: '生成视频 3', }, }); }); it('builds video generation plans with explicit seedance standard model', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'video', prompt: ' 生成电影感开场动画 ', status: 'idle', videoModel: 'seedance2.0', videoResolution: '720p', videoDurationSeconds: 5, }, layers: [], nextGeneratedIndex: 7, }); expect(plan).toMatchObject({ kind: 'video', normalizedPrompt: '生成电影感开场动画', input: { prompt: '生成电影感开场动画', model: 'seedance2.0', resolution: '720p', durationSeconds: 5, }, result: { title: '生成视频 7', }, }); }); it('builds game sound effect audio submission plans', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-sound-effect', prompt: ' 金币掉落叮当声 ', status: 'idle', soundDurationSeconds: 7, }, layers: [], nextGeneratedIndex: 4, }); expect(plan).toEqual({ kind: 'audio', audioKind: 'sound-effect', normalizedPrompt: '金币掉落叮当声', input: { prompt: '金币掉落叮当声', model: 'audio1.0', duration: 7, }, result: { title: '游戏音效 4', generationInputs: { fields: [ { title: 'prompt', value: '金币掉落叮当声' }, { title: 'model', value: 'audio1.0' }, { title: 'duration', value: '7秒' }, ], references: [], }, }, }); }); it('uses 5 seconds as default game sound effect duration', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-sound-effect', prompt: '按钮确认短促音', status: 'idle', }, layers: [], nextGeneratedIndex: 6, }); expect(plan).toMatchObject({ kind: 'audio', audioKind: 'sound-effect', input: { prompt: '按钮确认短促音', model: 'audio1.0', duration: 5, }, result: { generationInputs: { fields: [ { title: 'prompt', value: '按钮确认短促音' }, { title: 'model', value: 'audio1.0' }, { title: 'duration', value: '5秒' }, ], }, }, }); }); it('builds game background music audio submission plans with instrumental fixed to true', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-background-music', prompt: ' 温暖轻快的森林冒险背景音乐 ', status: 'idle', }, layers: [], nextGeneratedIndex: 5, }); expect(plan).toEqual({ kind: 'audio', audioKind: 'background-music', normalizedPrompt: '温暖轻快的森林冒险背景音乐', input: { gptDescriptionPrompt: '温暖轻快的森林冒险背景音乐', makeInstrumental: true, }, result: { title: '游戏背景音乐 5', generationInputs: { fields: [ { title: 'gpt_description_prompt', value: '温暖轻快的森林冒险背景音乐', }, ], references: [], }, }, }); }); });