import { afterEach, describe, expect, it } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { CanvasGenerationDialogState, CanvasLayer, } from './ImageCanvasEditorTypes'; import { applyEditorGenerationPricingConfig, BACKGROUND_MUSIC_MODEL_SUNO, buildCharacterGenerationInputs, buildEditGenerationInputs, buildIconGenerationInputs, buildImageGenerationInputs, buildQuickEditGenerationInputs, buildQuickEditModelOptions, buildSceneGenerationInputs, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, buildUiDesignAssetExtractionGenerationInputs, buildVideoGenerationInputs, calculateCharacterAnimationPrice, calculateEditorBackgroundMusicPrice, calculateEditorIconSpritesheetPrice, calculateEditorImageGenerationPrice, calculateEditorImageModelPrice, calculateEditorSoundEffectPrice, calculateEditorSpecGenerationPrice, calculateEditorUiDesignPrice, calculateEditorVideoPrice, canOpenRedrawPanel, CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, CHARACTER_ANIMATION_MODEL, decodeCanvasGenerationInputs, DEFAULT_IMAGE_MODEL, DEFAULT_SOUND_EFFECT_MODEL, DEFAULT_VIDEO_MODEL, EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG, EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG, EDITOR_IMAGE_MODEL_OPTIONS, EDITOR_MODEL_MUD_POINT_CONFIG, EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG, EDITOR_SOUND_EFFECT_MODEL_OPTIONS, EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG, EDITOR_VIDEO_MODEL_OPTIONS, formatEditorGenerationInputFieldValue, getGenerationFrameAriaLabel, getGenerationFrameLabel, IMAGE_MODEL_GPT_IMAGE_2, IMAGE_MODEL_NANOBANANA2, isNormalizedCanvasGenerationInputs, isQuickEditSupportedLayer, resolveCharacterAnimationSourceImageSrc, resolveEditorImageGenerationPixelSize, resolveImageGenerationErrorMessage, VIDEO_MODEL_KLING_3, VIDEO_MODEL_KLING_3_OMNI, VIDEO_MODEL_SEEDANCE_2, VIDEO_MODEL_VEO_3_1, VIDEO_MODEL_VEO_3_1_FAST, } from './ImageCanvasGenerationModel'; describe('ImageCanvasGenerationModel', () => { afterEach(() => { applyEditorGenerationPricingConfig({ models: EDITOR_MODEL_MUD_POINT_CONFIG, }); }); it('展示时长时收敛到两位小数且不改动其他字段', () => { const format = (title: string, value: string) => formatEditorGenerationInputFieldValue({ title, value }); expect(format('时长', '8.306938775510204秒')).toBe('8.31秒'); expect(format('时长', '3.4512秒')).toBe('3.45秒'); // 整数与一位小数不补零,"自动"这类无数字取值原样保留。 expect(format('时长', '5秒')).toBe('5秒'); expect(format('时长', '2.5秒')).toBe('2.5秒'); expect(format('时长', '自动')).toBe('自动'); expect(format('duration', '8.306938775510204s')).toBe('8.31s'); // 非时长字段一律原样返回,不能误伤提示词里的数字。 expect(format('用户描述', '持续 8.306938775510204 秒的低鸣')).toBe( '持续 8.306938775510204 秒的低鸣', ); }); it('为所有编辑器生成模型提供显式定价配置', () => { expect( EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter( (model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG), ), ).toEqual([]); expect( EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter( (model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG), ), ).toEqual([]); expect( EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter( (model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG), ), ).toEqual([]); expect( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K'], ).toBe(3); expect( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'], ).toBe(5); expect( EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL], ).toBeTruthy(); expect( EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL], ).toBe(5); expect( EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ BACKGROUND_MUSIC_MODEL_SUNO ], ).toBe(12); // 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。 expect( EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1], ).toBeTruthy(); expect( EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST], ).toBeTruthy(); }); it.each([ [IMAGE_MODEL_NANOBANANA2, '1K', '1:1', 1024, 1024], [IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 683], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1365], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152], ] as const)( '%s 的 %s %s 占位尺寸与统一业务交付尺寸一致', (model, imageSize, aspectRatio, width, height) => { expect( resolveEditorImageGenerationPixelSize({ model, aspectRatio, imageSize, }), ).toEqual({ width, height }); }, ); it('所有生成按钮价格都从模型定价配置推导', () => { expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'], ); expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe( 3, ); expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe( 5, ); expect( calculateEditorImageGenerationPrice({ kind: 'character', model: IMAGE_MODEL_GPT_IMAGE_2, imageSize: '1K', }), ).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']); expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'], ); expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'], ); expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'], ); expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe( EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5, ); expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '480p', 4)).toBe( EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2]['480p'] * 4, ); expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '480p', 4)).toBe( EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3]['480p'] * 4, ); expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '720p', 5)).toBe(150); expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe( 200, ); expect( calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6), ).toBe( EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] * 6, ); expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe( EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL], ); expect( calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO), ).toBe( EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ BACKGROUND_MUSIC_MODEL_SUNO ], ); }); it('支持用后端下发的模型定价覆盖按钮泥点', () => { applyEditorGenerationPricingConfig({ models: { ...EDITOR_MODEL_MUD_POINT_CONFIG, [IMAGE_MODEL_GPT_IMAGE_2]: { unit: 'perGeneration', prices: { '1K': 29, '2K': 58, }, }, [VIDEO_MODEL_SEEDANCE_2]: { unit: 'perSecond', prices: { ...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2], '720p': 26, }, }, [DEFAULT_SOUND_EFFECT_MODEL]: { unit: 'perGeneration', price: 15, }, [BACKGROUND_MUSIC_MODEL_SUNO]: { unit: 'perGeneration', price: 9, }, }, }); expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe( 29, ); expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe( 58, ); expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe( 130, ); expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe( 15, ); expect( calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO), ).toBe(9); }); it('builds user-facing generation input snapshots instead of backend prompts', () => { expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({ version: 2, action: 'image.generate', fields: [ { id: 'prompt', title: '生成提示词', value: '一张明亮主视觉' }, { id: 'model', title: '模型', value: 'gemini-3.1-flash-image-preview', }, { id: 'style', title: '风格', value: 'none' }, { id: 'aspectRatio', title: '比例', value: '1:1' }, { id: 'imageSize', title: '尺寸', value: '1K' }, ], references: [], }); expect( buildSpecGenerationInputs('character', { playSetting: '平台跳跃', artStyle: '像素风', bodyRatio: '3', characterView: '右向三分之二侧身', customPrompt: '', }), ).toEqual( expect.objectContaining({ version: 2, action: 'spec.generate', fields: [ { id: 'specType', title: '规范类型', value: 'character' }, { id: 'playSetting', title: '玩法设定', value: '平台跳跃' }, { id: 'artStyle', title: '美术风格', value: '像素风' }, { id: 'bodyRatio', title: '头身比', value: '3' }, { id: 'characterView', title: '角色视角', value: '右向三分之二侧身' }, ], references: [], }), ); expect( buildSpecGenerationInputs('icon', { playSetting: '休闲消除', artStyle: '清爽卡通', bodyRatio: '3', characterView: '', customPrompt: '', }), ).toEqual( expect.objectContaining({ version: 2, action: 'spec.generate', fields: [ { id: 'specType', title: '规范类型', value: 'icon' }, { id: 'playSetting', title: '玩法设定', value: '休闲消除' }, { id: 'artStyle', title: '美术风格', value: '清爽卡通' }, ], references: [], }), ); }); it('builds character, icon and edit reference snapshots', () => { const sourceLayer = buildSourceLayer(); expect( buildCharacterGenerationInputs( '主角骑士', { id: 'spec', label: '角色规范', src: '/spec.png', resourceId: 'resource-spec', }, [ { id: 'ref-1', label: '盔甲参考', src: '/armor.png', resourceId: 'resource-armor', }, ], ), ).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-spec', }, { id: 'reference', title: '常规参考图 1', label: '盔甲参考', refType: 'project-resource', refId: 'resource-armor', }, ], }), ); expect( buildIconGenerationInputs(['返回按钮', '设置按钮'], { id: 'icon-spec', label: '图标规范', src: '/icon-spec.png', resourceId: 'resource-icon-spec', }), ).toEqual( expect.objectContaining({ version: 2, action: 'icon.generate', fields: expect.arrayContaining([ { id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' }, ]), references: [ { id: 'specReference', title: '图标规范', label: '图标规范', refType: 'project-resource', refId: 'resource-icon-spec', }, ], }), ); expect( buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer), ).toEqual( 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', }, ], }), ); expect( buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [ { id: 'ref-ui', label: 'UI风格参考', src: '/ui-style.png', resourceId: 'resource-ui-style', }, ]), ).toEqual( expect.objectContaining({ version: 2, action: 'ui-design.extract-assets', fields: expect.arrayContaining([ expect.objectContaining({ id: 'prompt', title: '提取提示词', }), ]), references: [ { id: 'source', title: 'UI设计图', label: '原图', refType: 'project-resource', refId: 'resource-source', }, { id: 'reference', title: '参考图 1', label: 'UI风格参考', refType: 'project-resource', refId: 'resource-ui-style', }, ], }), ); expect( buildQuickEditGenerationInputs( '快速编辑提示词', '让图2下雨', sourceLayer, [ { id: 'ref-1', label: '天空', src: '/sky.png', resourceId: 'resource-sky', }, ], ), ).toEqual( expect.objectContaining({ version: 2, action: 'image.edit', fields: expect.arrayContaining([ { id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' }, ]), references: [ { id: 'source', title: '原图', label: '原图', refType: 'project-resource', refId: 'resource-source', }, { id: 'reference', title: '参考图 1', label: '天空', refType: 'project-resource', refId: 'resource-sky', }, ], }), ); }); it('only exposes redraw for executable generation metadata', () => { const generatedLayer = { ...buildSourceLayer(), sourceType: 'generated' as const, }; expect( canOpenRedrawPanel({ ...generatedLayer, generationInputs: buildImageGenerationInputs('生成城堡'), }), ).toBe(true); expect( canOpenRedrawPanel({ ...generatedLayer, assetKind: 'scene', generationInputs: buildSceneGenerationInputs( '雨夜车站', 'watercolor', '', [], { model: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '16:9', imageSize: '2K', }, ), }), ).toBe(true); expect( canOpenRedrawPanel({ ...generatedLayer, assetKind: 'scene', generationInputs: { fields: [{ title: '画面内容', value: '历史场景' }], references: [], }, }), ).toBe(true); expect( canOpenRedrawPanel({ ...generatedLayer, generationInputs: { fields: [{ title: '生成提示词', value: '旧版城堡' }], references: [], }, }), ).toBe(true); expect( canOpenRedrawPanel({ ...generatedLayer, generationInputs: null, }), ).toBe(false); expect( canOpenRedrawPanel({ ...generatedLayer, generationInputs: { version: 2, fields: [{ title: '生成提示词', value: '公开展示文本' }], references: [], }, }), ).toBe(false); expect( canOpenRedrawPanel({ ...generatedLayer, generationInputs: { version: 2, action: 'unknown.generate' as never, fields: [{ id: 'prompt', title: '提示词', value: '未知动作' }], references: [], }, }), ).toBe(false); expect( canOpenRedrawPanel({ ...generatedLayer, sourceType: 'uploaded', generationInputs: buildImageGenerationInputs('上传文件'), }), ).toBe(true); for (const action of [ 'image.edit', 'image.perfect-pixel', 'spritesheet.split', 'image.remove-background', 'image.crop-expand', ] as const) { expect( canOpenRedrawPanel({ ...generatedLayer, generationInputs: { version: 2, action, fields: [], references: [], }, }), ).toBe(false); } expect( canOpenRedrawPanel({ ...generatedLayer, taskId: 'pixel-art-snap-legacy-dialog', generationInputs: buildImageGenerationInputs('历史继承配方'), }), ).toBe(false); }); it('rejects malformed persisted generation metadata without throwing', () => { expect( isNormalizedCanvasGenerationInputs({ version: 2, action: 'image.generate', fields: [{ id: 42, title: '生成提示词', value: '城堡' } as never], references: [], }), ).toBe(false); expect( isNormalizedCanvasGenerationInputs({ version: 2, action: 'image.generate', fields: [{ id: 'prompt', title: '生成提示词', value: '城堡' }], references: [ { id: 'source', title: '原图', refType: 'project-resource', refId: 7, } as never, ], }), ).toBe(false); expect(() => canOpenRedrawPanel({ ...buildSourceLayer(), sourceType: 'generated', generationInputs: { fields: [{ title: 42, value: '旧数据' } as never], references: [], }, }), ).not.toThrow(); expect( canOpenRedrawPanel({ ...buildSourceLayer(), sourceType: 'generated', generationInputs: { version: 2, action: 'image.generate', fields: [{ id: 42, title: '生成提示词', value: '损坏数据' } as never], references: [], }, }), ).toBe(true); }); it('stores the video web-search setting in reusable metadata', () => { expect( buildVideoGenerationInputs('镜头推进', [], { webSearchEnabled: false, }).fields, ).toContainEqual({ id: 'webSearchEnabled', title: '联网搜索', value: false, }); }); it('keeps valid V2 recipes and supported image aliases canonical', () => { const valid = decodeCanvasGenerationInputs( buildImageGenerationInputs('森林城堡', [], { model: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '16:9', imageSize: '2K', }), ); expect(valid).toMatchObject({ ok: true, warnings: [] }); const alias = decodeCanvasGenerationInputs({ version: 2, action: 'image.generate', fields: [ { id: 'prompt', title: '生成提示词', value: '像素村庄' }, { id: 'model', title: '模型', value: 'nanobanana2' }, { id: 'aspectRatio', title: '比例', value: '4:3' }, { id: 'imageSize', title: '尺寸', value: '0.5K' }, ], references: [], }); expect(alias).toMatchObject({ ok: true, warnings: [expect.objectContaining({ fieldIds: ['style'] })], }); expect( alias.ok ? alias.inputs.fields.find((field) => field.id === 'model')?.value : null, ).toBe(IMAGE_MODEL_NANOBANANA2); }); it('falls invalid image and workflow parameters back within V2', () => { const image = decodeCanvasGenerationInputs({ version: 2, action: 'image.generate', fields: [ { id: 'prompt', title: '生成提示词', value: '森林城堡' }, { id: 'model', title: '模型', value: 'removed-image-model' }, { id: 'style', title: '风格', value: 'oil' }, { id: 'aspectRatio', title: '比例', value: '7:5' }, { id: 'imageSize', title: '尺寸', value: '8K' }, ], references: [], }); expect(image.ok).toBe(true); if (!image.ok) { throw new Error('图片 V2 解码失败'); } expect( Object.fromEntries( image.inputs.fields.map((field) => [field.id, field.value]), ), ).toMatchObject({ model: DEFAULT_IMAGE_MODEL, style: 'none', aspectRatio: '1:1', imageSize: '1K', }); expect(image.warnings).toEqual( expect.arrayContaining([ expect.objectContaining({ fieldIds: ['model'] }), expect.objectContaining({ fieldIds: ['style'] }), expect.objectContaining({ fieldIds: ['aspectRatio'] }), expect.objectContaining({ fieldIds: ['imageSize'] }), ]), ); expect(image.warnings[0]?.message).toBe( CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, ); const workflow = decodeCanvasGenerationInputs({ version: 2, action: 'publication.generate', fields: [ { id: 'workflowId', title: '宣发类型', value: 'retired-workflow' }, ], references: [], }); expect(workflow).toMatchObject({ ok: true, inputs: { version: 2, action: 'publication.generate', fields: [ { id: 'workflowId', title: '宣发类型', value: 'publication-cover-image', }, ], }, warnings: [expect.objectContaining({ fieldIds: ['workflowId'] })], }); }); it('falls retired and incompatible video parameters back to current defaults', () => { const decoded = decodeCanvasGenerationInputs({ version: 2, action: 'video.generate', fields: [ { id: 'prompt', title: '视频描述', value: '追逐镜头' }, { id: 'model', title: '模型', value: 'veo3.1' }, { id: 'aspectRatio', title: '比例', value: '7:5' }, { id: 'resolution', title: '清晰度', value: '1080p' }, { id: 'durationSeconds', title: '时长', value: 99 }, { id: 'sound', title: '声音', value: 'surround' }, { id: 'webSearchEnabled', title: '联网搜索', value: 'yes' }, ], references: [], }); expect(decoded.ok).toBe(true); if (!decoded.ok) { throw new Error('视频 V2 解码失败'); } expect( Object.fromEntries( decoded.inputs.fields.map((field) => [field.id, field.value]), ), ).toMatchObject({ model: DEFAULT_VIDEO_MODEL, aspectRatio: '16:9', resolution: '480p', durationSeconds: 4, sound: 'on', webSearchEnabled: true, }); expect(decoded.warnings.map((warning) => warning.fieldIds)).toEqual( expect.arrayContaining([ ['model'], ['aspectRatio'], ['resolution'], ['durationSeconds'], ['sound'], ['webSearchEnabled'], ]), ); }); it('materializes and warns about missing action parameters', () => { const decoded = decodeCanvasGenerationInputs({ version: 2, action: 'video.generate', fields: [{ id: 'prompt', title: '视频描述', value: '追逐镜头' }], references: [], }); expect(decoded).toMatchObject({ ok: true, inputs: { fields: expect.arrayContaining([ expect.objectContaining({ id: 'model', value: DEFAULT_VIDEO_MODEL }), expect.objectContaining({ id: 'durationSeconds', value: 4 }), ]), }, warnings: expect.arrayContaining([ expect.objectContaining({ fieldIds: ['model'] }), expect.objectContaining({ fieldIds: ['durationSeconds'] }), ]), }); const animation = decodeCanvasGenerationInputs({ version: 2, action: 'character-animation.generate', fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }], references: [], }); expect(animation).toMatchObject({ ok: true, inputs: { fields: expect.arrayContaining([ expect.objectContaining({ id: 'frameCount', value: 32 }), expect.objectContaining({ id: 'durationSeconds', value: 4 }), ]), }, warnings: expect.arrayContaining([ expect.objectContaining({ fieldIds: ['frameCount', 'durationSeconds'], }), ]), }); }); it('falls invalid sound and character animation parameters back to complete defaults', () => { const sound = decodeCanvasGenerationInputs({ version: 2, action: 'audio.sound-effect.generate', fields: [ { id: 'prompt', title: 'prompt', value: '点击音' }, { id: 'model', title: 'model', value: 'retired-audio-model' }, { id: 'durationSeconds', title: 'duration', value: 31 }, ], references: [], }); expect(sound).toMatchObject({ ok: true, inputs: { fields: expect.arrayContaining([ expect.objectContaining({ id: 'model', value: 'eleven_text_to_sound_v2', }), expect.objectContaining({ id: 'durationSeconds', value: 5 }), expect.objectContaining({ id: 'durationMode', value: 'manual' }), expect.objectContaining({ id: 'loop', value: false }), ]), }, }); const animation = decodeCanvasGenerationInputs({ version: 2, action: 'character-animation.generate', fields: [ { id: 'prompt', title: '动作描述', value: '挥手' }, { id: 'resolution', title: '清晰度', value: '1080p' }, { id: 'ratio', title: '比例', value: '7:5' }, { id: 'frameCount', title: '帧数', value: 40 }, { id: 'durationSeconds', title: '时长', value: 6 }, ], references: [], }); expect(animation).toMatchObject({ ok: true, inputs: { fields: expect.arrayContaining([ expect.objectContaining({ id: 'resolution', value: '480p' }), expect.objectContaining({ id: 'ratio', value: 'same' }), expect.objectContaining({ id: 'frameCount', value: 32 }), expect.objectContaining({ id: 'durationSeconds', value: 4 }), ]), }, warnings: expect.arrayContaining([ expect.objectContaining({ fieldIds: ['resolution'] }), expect.objectContaining({ fieldIds: ['ratio'] }), expect.objectContaining({ fieldIds: ['frameCount', 'durationSeconds'], }), ]), }); }); it('keeps custom prompts and quick edit options stable', () => { expect( buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }), ).toBe('自定义'); expect(buildQuickEditModelOptions('nano-banana')).toEqual([ { label: 'nanobanana2', value: 'gemini-3.1-flash-image-preview', }, { label: 'gpt-image-2', value: 'gpt-image-2' }, ]); expect(buildQuickEditModelOptions('nanobanana2')).toEqual([ { label: 'nanobanana2', value: 'gemini-3.1-flash-image-preview', }, { label: 'gpt-image-2', value: 'gpt-image-2' }, ]); expect( isQuickEditSupportedLayer({ ...buildSourceLayer(), mediaType: 'image', assetKind: null, }), ).toBe(true); expect( isQuickEditSupportedLayer({ ...buildSourceLayer(), assetKind: 'icon', }), ).toBe(false); expect( isQuickEditSupportedLayer({ ...buildSourceLayer(), assetKind: 'icon-spritesheet', }), ).toBe(true); expect( isQuickEditSupportedLayer({ ...buildSourceLayer(), assetKind: 'scene', resourceAssetKind: 'icon', }), ).toBe(true); expect( isQuickEditSupportedLayer({ ...buildSourceLayer(), mediaType: 'image-sequence', assetKind: 'character-animation', }), ).toBe(false); expect( isQuickEditSupportedLayer({ ...buildSourceLayer(), mediaType: 'audio', assetKind: 'sound-effect', }), ).toBe(false); expect( isQuickEditSupportedLayer({ ...buildSourceLayer(), mediaType: 'video', assetKind: 'video', }), ).toBe(true); }); it('adds reference image semantics and snapshots for spec generation references', () => { expect( buildSpecPrompt( 'custom', { ...blankSpecValues, customPrompt: '生成一张怪兽规范图' }, true, ), ).toContain('生成一张怪兽规范图'); expect( buildSpecGenerationInputs( 'custom', { ...blankSpecValues, customPrompt: '生成一张怪兽规范图' }, { id: 'spec-ref', label: '参考.png', src: '/ref.png', resourceId: 'resource-ref', }, ), ).toEqual({ version: 2, action: 'spec.generate', fields: [ { id: 'specType', title: '规范类型', value: 'custom' }, { id: 'customPrompt', title: '自定义规范提示词', value: '生成一张怪兽规范图', }, ], references: [ { id: 'reference', title: '参考图', label: '参考.png', refType: 'project-resource', refId: 'resource-ref', }, ], }); }); it('uses objectKey for character animation references before falling back to src', () => { expect( resolveCharacterAnimationSourceImageSrc({ ...buildSourceLayer(), objectKey: 'generated/character.png', }), ).toBe('generated/character.png'); expect(resolveCharacterAnimationSourceImageSrc(buildSourceLayer())).toBe( '/source.png', ); }); it('uses the extracted transparent frame when reusing a character animation sequence layer', () => { expect( resolveCharacterAnimationSourceImageSrc({ ...buildSourceLayer(), assetKind: 'character-animation', mediaType: 'image-sequence', src: '/generated-character-drafts/editor/frame01.png', thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { imageSrc: '/generated-character-drafts/editor/frame01.png', objectKey: 'generated-character-drafts/editor/frame01.png', width: 512, height: 512, }, { imageSrc: '/generated-character-drafts/editor/frame02.png', width: 512, height: 512, }, ], }), ).toBe('generated-character-drafts/editor/frame01.png'); }); it('maps generation dialog mode and authorization errors to user-facing copy', () => { const iconDialog: CanvasGenerationDialogState = { id: 'dialog-icon', mode: 'icon', prompt: '', status: 'idle', }; expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图'); expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator'); expect( getGenerationFrameAriaLabel({ ...iconDialog, mode: 'quick-edit', }), ).toBe('快速编辑生成占位图'); expect(getGenerationFrameLabel({ ...iconDialog, mode: 'quick-edit' })).toBe( 'Quick Edit Generator', ); expect( buildSoundEffectGenerationInputs( '金币掉落叮当声', 'eleven_text_to_sound_v2', 7, true, ), ).toEqual({ 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: true }, ], references: [], }); expect( resolveImageGenerationErrorMessage( new ApiClientError({ message: '未授权访问(requestId: one)', status: 401, code: 'UNAUTHORIZED', }), ), ).toBe('请先登录后再生成图片'); expect( resolveImageGenerationErrorMessage( new ApiClientError({ message: '请求冲突(requestId: wallet-one)', status: 409, code: 'CONFLICT', details: { provider: 'profile-wallet' }, }), ), ).toBe('泥点余额不足,请充值后再生成'); expect( resolveImageGenerationErrorMessage( new ApiClientError({ message: '请求冲突(requestId: conflict-one)', status: 409, code: 'CONFLICT', }), ), ).toBe('生成任务未完成,请稍后重试'); }); }); const blankSpecValues = { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '', customPrompt: '', }; function buildSourceLayer(): CanvasLayer { return { id: 'layer-source', resourceId: 'resource-source', title: '原图', src: '/source.png', x: 0, y: 0, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: 1, sourceType: 'uploaded', }; }