import { describe, expect, it } from 'vitest'; import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; import { buildCharacterAnimationSubmissionPlan, buildIconSpritesheetGenerationSubmissionPlan, buildImageGenerationSubmissionPlan, } from './ImageCanvasGenerationSubmissionModel'; 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', style: 'none', aspectRatio: '1:1', imageSize: '1K', }, result: { title: '生成图片 3', generationInputs: expect.objectContaining({ version: 2, action: 'image.generate', fields: expect.arrayContaining([ { id: 'prompt', title: '生成提示词', value: '一张发光主视觉' }, ]), references: [], }), }, rememberImageModel: 'gemini-3.1-flash-image-preview', }); }); it('builds structured scene generation plans without assembling backend prompts', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'scene', prompt: ' 雨夜中的欧洲小镇街道 ', status: 'idle', sceneStylePreset: 'watercolor', sceneCustomStyle: '', imageModel: IMAGE_MODEL_NANOBANANA2, aspectRatio: '16:9', imageSize: '1K', generationReferences: [ { id: 'scene-reference-1', label: '场景参考一', src: '/scene-reference-1.png', objectKey: 'users/user-1/scene-reference-1.png', resourceId: 'scene-resource-1', }, { id: 'scene-reference-2', label: '场景参考二', src: '/scene-reference-2.png', objectKey: 'users/user-1/scene-reference-2.png', resourceId: 'scene-resource-2', }, ], }, layers: [], nextGeneratedIndex: 2, }); expect(plan).toMatchObject({ kind: 'scene', normalizedPrompt: '雨夜中的欧洲小镇街道', input: { sceneContent: '雨夜中的欧洲小镇街道', stylePreset: 'watercolor', model: IMAGE_MODEL_NANOBANANA2, aspectRatio: '16:9', imageSize: '1K', referenceImageSrcs: [ 'users/user-1/scene-reference-1.png', 'users/user-1/scene-reference-2.png', ], }, result: { assetKind: 'scene', title: '游戏场景 2', generationInputs: { version: 2, action: 'scene.generate', fields: [ { id: 'prompt', title: '画面内容', value: '雨夜中的欧洲小镇街道', }, { id: 'stylePreset', title: '视觉风格', value: 'watercolor' }, { id: 'model', title: '模型', value: IMAGE_MODEL_NANOBANANA2, }, { id: 'aspectRatio', title: '比例', value: '16:9' }, { id: 'imageSize', title: '尺寸', value: '1K' }, ], references: [ { id: 'reference', title: '场景参考图 1', label: '场景参考一', refType: 'project-resource', refId: 'scene-resource-1', }, { id: 'reference', title: '场景参考图 2', label: '场景参考二', refType: 'project-resource', refId: 'scene-resource-2', }, ], }, }, }); expect(JSON.stringify(plan)).not.toContain('仅生成环境背景'); }); it('requires custom style content for scene generation', () => { expect(() => buildImageGenerationSubmissionPlan({ dialog: { mode: 'scene', prompt: '海边车站', status: 'idle', sceneStylePreset: 'custom', sceneCustomStyle: ' ', }, layers: [], nextGeneratedIndex: 1, }), ).toThrow('请填写自定义画风'); }); it('trims custom asset names and limits them to 80 characters', () => { const customName = ` ${'名'.repeat(81)} `; const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'generate', prompt: '一张发光主视觉', assetLabel: customName, status: 'idle', }, layers: [], nextGeneratedIndex: 3, }); expect(plan).toMatchObject({ kind: 'image', result: { title: '名'.repeat(80) }, }); }); 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, imageModel: 'unknown-model', aspectRatio: '7:5', imageSize: '8K', }, layers: [sourceLayer], nextGeneratedIndex: 4, }); expect(plan).toMatchObject({ kind: 'edit', normalizedPrompt: '修改当前图片', sourceLayer, editInput: { model: IMAGE_MODEL_NANOBANANA2, aspectRatio: '4:3', imageSize: '1K', size: '1024x768', }, generationInputs: expect.objectContaining({ version: 2, action: 'image.edit', fields: expect.arrayContaining([ { id: 'prompt', title: '修改要求', value: '修改当前图片' }, ]), references: [ { id: 'source', title: '参考图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], }), }); }); it('builds edit submission plans from persisted image-edit inputs', () => { const sourceLayer = createLayer({ generationInputs: { version: 2, action: 'image.edit', fields: [ { id: 'prompt', title: '修改要求', value: 'persisted' }, { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 }, { id: 'aspectRatio', title: '比例', value: '2:3' }, { id: 'imageSize', title: '尺寸', value: '2K' }, ], references: [ { id: 'source', title: '参考图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], }, model: IMAGE_MODEL_GPT_IMAGE_2, originalWidth: 2048, originalHeight: 1152, }); const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'edit', prompt: '', status: 'idle', sourceLayerId: sourceLayer.id, imageModel: 'unknown-model', aspectRatio: '7:5', imageSize: '8K', }, layers: [sourceLayer], nextGeneratedIndex: 4, }); expect(plan).toMatchObject({ kind: 'edit', editInput: { model: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '2:3', imageSize: '2K', size: '1365x2048', }, generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ { id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 }, { id: 'aspectRatio', title: '比例', value: '2:3' }, { id: 'imageSize', title: '尺寸', value: '2K' }, ]), }), }); }); it('builds quick-edit canvas generator plans as direct source edits', () => { const sourceLayer = createLayer({ objectKey: 'generated-character-drafts/editor/source.png', assetKind: 'character', originalWidth: 1537, originalHeight: 1025, }); 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', resourceId: 'resource-style-reference', }, ], }, layers: [sourceLayer], nextGeneratedIndex: 4, }); expect(plan).toMatchObject({ kind: 'quick-edit', normalizedPrompt: '把当前图改成雨天', sourceLayer, editInput: { size: '1024x768', model: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,style'], }, result: { title: '源图 快速编辑', assetKind: 'character', generationInputs: expect.objectContaining({ version: 2, action: 'image.edit', fields: expect.arrayContaining([ { id: 'prompt', title: '快速编辑提示词', value: '把当前图改成雨天', }, ]), references: [ { id: 'source', title: '原图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, { id: 'reference', title: '参考图 1', label: '风格参考', refType: 'project-resource', refId: 'resource-style-reference', }, ], }), }, rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, }); }); it('does not send image style from an incomplete quick-edit snapshot', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'quick-edit', prompt: '把当前图改成雨天', status: 'idle', style: 'pixelArt', }, layers: [], nextGeneratedIndex: 4, }); expect(plan.kind).toBe('image'); expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty( 'style', ); }); it('builds icon spec generation plans without a client prompt or backend kind', () => { 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: 'icon-spec', normalizedPrompt: 'AI 生成图片', input: { playSetting: '休闲消除', artStyle: '清爽卡通', referenceId: 'resource-spec-ref', generationInputs: { version: 2, action: 'spec.generate', fields: [ { id: 'specType', title: '规范类型', value: 'icon' }, { id: 'playSetting', title: '玩法设定', value: '休闲消除' }, { id: 'artStyle', title: '美术风格', value: '清爽卡通' }, ], references: [ { id: 'reference', title: '参考图', label: '参考.png', refType: 'project-resource', refId: 'resource-spec-ref', }, ], }, }, result: { assetKind: 'icon-spec', title: '图标规范 8', generationInputs: { version: 2, action: 'spec.generate', fields: [ { id: 'specType', title: '规范类型', value: 'icon' }, { id: 'playSetting', title: '玩法设定', value: '休闲消除' }, { id: 'artStyle', title: '美术风格', value: '清爽卡通' }, ], references: [ { id: 'reference', title: '参考图', label: '参考.png', refType: 'project-resource', refId: 'resource-spec-ref', }, ], }, }, }); expect( plan.kind === 'icon-spec' ? plan.result.generationInputs : null, ).toEqual( expect.objectContaining({ version: 2, action: 'spec.generate', fields: expect.arrayContaining([ { id: 'playSetting', title: '玩法设定', value: '休闲消除' }, { id: 'artStyle', title: '美术风格', value: '清爽卡通' }, ]), references: [ { id: 'reference', title: '参考图', label: '参考.png', refType: 'project-resource', refId: 'resource-spec-ref', }, ], }), ); }); it('does not add optional reference fields to an icon spec without a reference', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'spec', prompt: '', status: 'idle', specType: 'icon', specValues: { playSetting: '消除类派对玩法', artStyle: '糖果玻璃拟物', bodyRatio: '3', characterView: '', customPrompt: '', }, }, layers: [], nextGeneratedIndex: 2, }); expect(plan).toMatchObject({ kind: 'icon-spec', input: { playSetting: '消除类派对玩法', artStyle: '糖果玻璃拟物', generationInputs: { version: 2, action: 'spec.generate', fields: [ { id: 'specType', title: '规范类型', value: 'icon' }, { id: 'playSetting', title: '玩法设定', value: '消除类派对玩法', }, { id: 'artStyle', title: '美术风格', value: '糖果玻璃拟物' }, ], references: [], }, }, result: { assetKind: 'icon-spec', title: '图标规范 2', }, }); expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty( 'referenceImageSrcs', ); expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty( 'prompt', ); }); it('builds character plans with references and remembered model', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'character', prompt: ' 白发骑士 ', status: 'idle', imageModel: 'gpt-image-2', style: 'pixelArt', 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: 'auto', segModel: 'birefnet', style: 'pixelArt', 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( expect.objectContaining({ version: 2, action: 'character.generate', fields: expect.arrayContaining([ { id: 'prompt', title: '角色设定', value: '白发骑士' }, ]), references: [ { id: 'specReference', title: '角色规范', label: '角色规范', refType: 'project-resource', refId: 'resource-character-spec', }, { id: 'reference', 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: expect.objectContaining({ version: 2, action: 'ui-design.generate', fields: expect.arrayContaining([ { id: 'prompt', title: '用户输入', value: '设计移动端商城首页' }, ]), references: [ { id: 'specReference', title: '图标规范', label: 'UI图标规范', refType: 'project-resource', refId: 'resource-ui-spec', }, { id: 'reference', 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', style: 'pixelArt', 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: { assetKind: 'publication-material', title: '10 宣发素材', generationInputs: expect.objectContaining({ version: 2, action: 'publication.generate', fields: expect.arrayContaining([ { id: 'gameName', title: '游戏名', value: '重庆洪崖洞火锅' }, { id: 'gameCategories', title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风', }, { id: 'gameDescription', title: '一句话描述游戏', value: '在一堆物品里点击,找到三个一样的物品将其消除', }, ]), references: [ { id: 'reference', 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: { assetKind: 'publication-material', 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 the spec reference is not registered', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', prompt: '返回按钮', status: 'idle', iconSpecReference: { id: 'local-icon-spec', label: '本地图标规范', src: 'data:image/png;base64,spec', resourceId: 'local-resource-icon-spec', }, }); 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('returns an icon spritesheet error when the complete prompt is too long', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', prompt: '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1), status: 'idle', iconSpecReference: { id: 'icon-spec', label: '图标规范', src: 'data:image/png;base64,spec', resourceId: 'resource-icon-spec', }, }); expect(plan).toEqual({ ok: false, errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`, }); }); it('builds icon spritesheet plans with the complete prompt and references', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', prompt: ' 返回按钮\n\n设置按钮 ', status: 'idle', imageModel: 'gpt-image-2', style: 'pixelArt', aspectRatio: '3:2', imageSize: '2K', assetLabel: ' 冒险游戏图标 ', 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: ['返回按钮\n\n设置按钮'], input: { referenceId: 'resource-icon-spec', referenceImageSrcs: ['data:image/png;base64,ref'], iconDescriptions: ['返回按钮\n\n设置按钮'], model: 'gpt-image-2', screenColor: 'auto', segModel: 'birefnet', style: 'pixelArt', aspectRatio: '3:2', imageSize: '2K', }, generationInputs: expect.objectContaining({ version: 2, action: 'icon.generate', fields: expect.arrayContaining([ { id: 'prompt', title: '素材描述', value: '返回按钮\n\n设置按钮' }, ]), references: [ { id: 'specReference', title: '图标规范', label: '图标规范', refType: 'project-resource', refId: 'resource-icon-spec', }, { id: 'reference', title: '参考图 1', label: '按钮风格参考', refType: 'project-resource', refId: 'resource-icon-ref', }, ], }), rememberImageModel: 'gpt-image-2', resultTitle: '冒险游戏图标', }); }); 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: ' 循环奔跑动作 ', assetLabel: ' 勇者奔跑 ', resolution: '720p', ratio: 'same', frameCount: 48, durationSeconds: 6, status: 'idle', }, sourceLayer, }); expect(plan).toEqual({ promptText: '循环奔跑动作', resultTitle: '勇者奔跑', input: { sourceLayerId: 'character-layer', sourceImageSrc: 'generated/character.png', sourceWidth: 960, sourceHeight: 1280, promptText: '循环奔跑动作', screenColor: 'auto', resolution: '720p', ratio: 'same', frameCount: 48, durationSeconds: 6, model: 'seedance2.0-fast', assetLabel: '勇者奔跑', }, }); }); 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, videoWebSearchEnabled: false, 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', webSearchEnabled: false, 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: expect.objectContaining({ version: 2, action: 'video.generate', fields: expect.arrayContaining([ { id: 'webSearchEnabled', title: '联网搜索', value: false, }, ]), references: [ { id: 'imageReference', title: '参考图 1', label: '参考图.png', refType: 'project-resource', refId: 'resource-video-image-ref', }, { id: 'videoReference', title: '参考视频 1', label: '参考视频.mp4', refType: 'project-resource', refId: 'resource-video-ref', }, { id: 'audioReference', 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, soundDurationMode: 'manual', }, layers: [], nextGeneratedIndex: 4, }); expect(plan).toEqual({ kind: 'audio', audioKind: 'sound-effect', normalizedPrompt: '金币掉落叮当声', input: { prompt: '金币掉落叮当声', model: 'eleven_text_to_sound_v2', duration: 7, loop: false, }, result: { title: '游戏音效 4', generationInputs: { version: 2, action: 'audio.sound-effect.generate', fields: [ { id: 'prompt', title: '用户描述', value: '金币掉落叮当声' }, { id: 'model', title: 'model', value: 'eleven_text_to_sound_v2' }, { id: 'durationMode', title: '时长模式', value: 'manual' }, { id: 'durationSeconds', title: '请求时长', value: 7 }, { id: 'loop', title: 'Loop', value: false }, ], references: [], }, }, }); }); it('preserves frozen auto duration, loop, and manual decimal precision', () => { const automatic = buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-sound-effect', prompt: '环境循环音', status: 'idle', soundDurationMode: 'auto', soundDurationSeconds: 1.23456789, soundLoop: true, }, layers: [], nextGeneratedIndex: 1, }); expect(automatic).toMatchObject({ input: { duration: null, loop: true }, result: { generationInputs: { fields: expect.arrayContaining([ { id: 'durationMode', title: '时长模式', value: 'auto' }, { id: 'durationSeconds', title: '请求时长', value: '自动' }, { id: 'loop', title: 'Loop', value: true }, ]), }, }, }); const manual = buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-sound-effect', prompt: '精确时长音效', status: 'idle', soundDurationMode: 'manual', soundDurationSeconds: 1.23456789, soundLoop: false, }, layers: [], nextGeneratedIndex: 2, }); expect(manual).toMatchObject({ input: { duration: 1.23456789, loop: false }, }); }); it('rejects an all-whitespace sound effect prompt without a fallback', () => { expect(() => buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-sound-effect', prompt: ' \t\r\n ', status: 'idle', }, layers: [], nextGeneratedIndex: 6, }), ).toThrow('音效描述不能为空'); }); 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, canonicalBackgroundMusicPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF', }); expect(plan).toEqual({ kind: 'audio', audioKind: 'background-music', normalizedPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF', input: { gptDescriptionPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF', makeInstrumental: true, }, result: { title: '游戏背景音乐 5', generationInputs: { version: 2, action: 'audio.background-music.generate', fields: [ { id: 'prompt', title: 'gpt_description_prompt', value: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF', }, ], references: [], }, }, }); }); it('does not build a background music plan without a claimed canonical prompt', () => { expect(() => buildImageGenerationSubmissionPlan({ dialog: { mode: 'audio-background-music', prompt: ' 不应原生 trim 或回退默认值 ', status: 'idle', }, layers: [], nextGeneratedIndex: 1, }), ).toThrow('背景音乐提交缺少已确认的提示词'); }); });