Files
Genarrative/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts
T
k88936 4c0fdf1806
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Backend tests (push) Successful in 3m52s
Project CI / Native shell tests (push) Successful in 14m46s
修正画布改造工具恢复链路 (#158)
在文档中明确改造特殊情况的处理

修复fallback到快速编辑的行为:
旧版图片按 assetKind 与 mediaType 选择工具,未知图片回落默认生成工具并保留旧版告警,而不是all in fallback到快速编辑

清理冗余代码:
移除 QuickEditPanelState 的 redraw 模式、旧提交分支及无调用结果层构造器

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/158
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-11 12:36:30 +08:00

1881 lines
54 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
CanvasLayer,
CharacterAnimationPanelState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendGenerationReference,
assignCharacterSpecReference,
assignIconSpecReference,
assignUiDesignSpecReference,
closeGenerateComposerDialog,
createAudioRedrawGenerationDialogDraft,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationGenerationDialogDraft,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createLayerGenerationDialogDraft,
createLegacyImageRedrawGenerationDialogDraft,
createPublicationGenerationDialogDraft,
createQuickEditGenerationDialogDraft,
createQuickEditPanelDraft,
createSameSourceGenerationDialogDraft,
createSceneGenerationDialogDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): 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('ImageCanvasGenerationDialogModel', () => {
it('does not legacy-fallback from a hydrated invalid versioned recipe', () => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
generationInputs: null,
generationInputsHydrationState: 'invalid-versioned',
prompt: '不能误当旧版配方',
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toBeNull();
});
it('creates centered drafts for image and spec generation dialogs', () => {
const canvasSize = { width: 1000, height: 800 };
const viewport = { x: 100, y: 40, scale: 2 };
expect(createGenerateDialogDraft({ canvasSize, viewport })).toMatchObject({
mode: 'generate',
status: 'idle',
composerOpen: true,
style: 'none',
placeholder: {
x: -312,
y: -332,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
},
});
expect(
createSpecDialogDraft({ canvasSize, viewport, specType: 'character' }),
).toMatchObject({
mode: 'spec',
specType: 'character',
specValues: {
playSetting: '',
artStyle: '',
bodyRatio: '3',
},
});
expect(
createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }),
).toMatchObject({
mode: 'spec',
specType: 'icon',
specValues: {
playSetting: '',
artStyle: '',
},
placeholder: {
x: -824,
y: -396,
width: 2048,
height: 1152,
originalWidth: 2048,
originalHeight: 1152,
},
});
});
it('creates publication material drafts with the workflow output size', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId: 'publication-promo-poster',
}),
).toMatchObject({
mode: 'publication',
publicationWorkflowId: 'publication-promo-poster',
placeholder: {
x: -160,
y: 0,
width: 1280,
height: 720,
originalWidth: 1280,
originalHeight: 720,
},
});
});
it('creates scene generation dialogs with product defaults', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createSceneGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'scene',
prompt: '',
status: 'idle',
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
placeholder: {
originalWidth: 1024,
originalHeight: 576,
},
});
});
it('creates character and icon generation drafts with the selected model dimensions', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'gpt-image-2',
}),
).toMatchObject({
mode: 'character',
style: 'none',
imageModel: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
characterSpecReference: null,
characterReferences: [],
placeholder: {
x: -32,
y: -152,
width: 1024,
height: 1024,
},
});
expect(
createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'unknown-model',
}),
).toMatchObject({
mode: 'icon',
style: 'none',
imageModel: 'unknown-model',
aspectRatio: '1:1',
imageSize: '1K',
prompt: '',
iconSpecReference: null,
iconDescriptions: [],
placeholder: {
x: -32,
y: -152,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
},
});
});
it('creates video and UI design drafts restored from the original generation entry work', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createVideoGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'video',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: 'seedance2.0-fast',
videoAspectRatio: '16:9',
videoResolution: '480p',
videoDurationSeconds: 4,
videoMode: 'std',
videoSound: 'on',
videoWebSearchEnabled: true,
placeholder: {
x: 53,
y: 120,
width: 854,
height: 480,
originalWidth: 854,
originalHeight: 480,
},
});
expect(
createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'gpt-image-2',
}),
).toMatchObject({
mode: 'ui-design',
imageModel: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '1K',
uiDesignSpecReference: null,
placeholder: {
x: -32,
y: 72,
width: 1024,
height: 576,
},
});
});
it('creates audio generation drafts for sound effects and background music', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createSoundEffectGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 5,
soundLoop: false,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
});
expect(
createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'audio-background-music',
prompt: '',
status: 'idle',
composerOpen: true,
makeInstrumental: true,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
},
});
});
it('creates audio remodel drafts from generated audio layers without references', () => {
const soundLayer = createLayer({
id: 'layer-sfx',
title: '游戏音效',
mediaType: 'audio',
assetKind: 'sound-effect',
prompt: '【系统内置】完整音效提示词:金币收集音,自动混音,内部参数。',
generationInputs: {
fields: [
{ title: 'prompt', value: '金币收集音' },
{ title: 'model', value: 'audio1.0' },
{ title: '时长', value: '7秒' },
],
references: [],
},
});
const musicLayer = createLayer({
id: 'layer-music',
title: '游戏背景音乐',
mediaType: 'audio',
assetKind: 'background-music',
prompt: '',
generationInputs: {
fields: [{ title: 'gpt_description_prompt', value: '地牢循环音乐' }],
references: [],
},
});
expect(createAudioRedrawGenerationDialogDraft(soundLayer)).toMatchObject({
mode: 'audio-sound-effect',
sourceLayerId: 'layer-sfx',
prompt: '金币收集音',
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 7,
soundLoop: false,
composerOpen: true,
placeholder: {
x: 472,
y: 140,
width: 420,
height: 120,
},
});
expect(createAudioRedrawGenerationDialogDraft(musicLayer)).toMatchObject({
mode: 'audio-background-music',
sourceLayerId: 'layer-music',
prompt: '地牢循环音乐',
makeInstrumental: true,
composerOpen: true,
placeholder: {
x: 472,
y: 140,
width: 420,
height: 120,
},
});
expect(createAudioRedrawGenerationDialogDraft(createLayer())).toBeNull();
});
it('keeps audio remodel prompts blank when no user input snapshot exists', () => {
expect(
createAudioRedrawGenerationDialogDraft(
createLayer({
mediaType: 'audio',
assetKind: 'sound-effect',
prompt: '【系统内置】完整音效提示词',
generationInputs: { fields: [], references: [] },
}),
),
).toMatchObject({
mode: 'audio-sound-effect',
prompt: '',
});
expect(
createAudioRedrawGenerationDialogDraft(
createLayer({
mediaType: 'audio',
assetKind: 'background-music',
title: '游戏背景音乐',
prompt: '【系统内置】完整背景音乐提示词',
generationInputs: {
fields: [{ title: '时长', value: '10秒' }],
references: [],
},
}),
),
).toMatchObject({
mode: 'audio-background-music',
prompt: '',
});
});
it('restores authoritative SFX V2 prompt, duration mode, and loop on redraw', () => {
const draft = createAudioRedrawGenerationDialogDraft(
createLayer({
id: 'layer-sfx-v2',
mediaType: 'audio',
assetKind: 'sound-effect',
prompt: '不应覆盖权威用户描述',
generationInputs: {
fields: [
{ title: '用户描述', value: '金币落地' },
{ title: '实际英文提示词', value: 'A bright coin landing chime' },
{ title: '时长', value: '7.42秒' },
{ title: 'Loop', value: '开启' },
],
references: [],
soundEffect: {
schemaVersion: 2,
userPrompt: '金币落地',
actualPrompt: 'A bright coin landing chime',
model: 'eleven_text_to_sound_v2',
durationMode: 'auto',
requestedDurationSeconds: null,
actualDurationSeconds: 7.42,
loop: true,
},
},
}),
);
expect(draft).toMatchObject({
mode: 'audio-sound-effect',
sourceLayerId: 'layer-sfx-v2',
prompt: '金币落地',
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 5,
soundLoop: true,
});
});
it('creates edit, quick-edit, and character animation panel drafts', () => {
const sourceLayer = createLayer({
prompt: '【系统内置】完整拼接提示词:原图提示,自动画质优化。',
model: 'gpt-image-2',
assetKind: 'character',
generationInputs: {
fields: [{ title: '生成提示词', value: '原图提示' }],
references: [],
},
});
expect(createEditDialogDraft(sourceLayer)).toEqual({
id: 'edit-dialog:layer-source',
mode: 'edit',
prompt: '原图提示',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
});
expect(createQuickEditPanelDraft(sourceLayer)).toEqual({
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
aspectRatio: '4:3',
imageSize: '1K',
model: 'gpt-image-2',
quickEditReferences: [],
status: 'idle',
});
expect(
createLegacyImageRedrawGenerationDialogDraft({
sourceLayer,
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({ mode: 'generate', prompt: '原图提示' });
expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({
sourceLayerId: 'layer-source',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
});
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
});
it('restores persisted image parameters when creating edit drafts', () => {
const sourceLayer = createLayer({
model: IMAGE_MODEL_NANOBANANA2,
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '修改要求', value: '改成雨天' },
{ id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ id: 'aspectRatio', title: '比例', value: '2:3' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
],
references: [],
},
});
expect(createEditDialogDraft(sourceLayer)).toMatchObject({
prompt: '改成雨天',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
});
expect(
createEditDialogDraft(sourceLayer, {
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
}),
).toMatchObject({
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
});
});
it('preserves the resolved asset label in quick-edit dialog snapshots', () => {
expect(
createQuickEditGenerationDialogDraft({
sourceLayer: createLayer(),
prompt: '让视频下雨',
assetLabel: '雨天版本',
}),
).toMatchObject({
mode: 'quick-edit',
prompt: '让视频下雨',
assetLabel: '雨天版本',
});
});
it('restores remodel prompts from user-visible generation input snapshots only', () => {
const cases: Array<{
fields: NonNullable<CanvasLayer['generationInputs']>['fields'];
expectedPrompt: string;
}> = [
{
fields: [{ title: '视频描述', value: '用户的视频描述' }],
expectedPrompt: '用户的视频描述',
},
{
fields: [{ title: '角色设定', value: '用户的角色设定' }],
expectedPrompt: '用户的角色设定',
},
{
fields: [{ title: '用户输入', value: '用户的 UI 需求' }],
expectedPrompt: '用户的 UI 需求',
},
{
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
expectedPrompt: '返回按钮\n设置按钮',
},
{
fields: [{ title: '自定义规范提示词', value: '用户自定义规范' }],
expectedPrompt: '用户自定义规范',
},
{
fields: [
{ title: '玩法设定', value: '平台跳跃' },
{ title: '美术风格', value: '像素风' },
],
expectedPrompt: '玩法设定:平台跳跃\n美术风格:像素风',
},
{
fields: [
{ title: '游戏名', value: '星海远征' },
{ title: '游戏分类', value: '冒险' },
{ title: '一句话描述游戏', value: '驾驶飞船探索星云' },
],
expectedPrompt:
'游戏名:星海远征\n游戏分类:冒险\n一句话描述游戏:驾驶飞船探索星云',
},
{
fields: [{ title: '动作描述', value: '待机动作' }],
expectedPrompt: '待机动作',
},
];
for (const testCase of cases) {
expect(
createLegacyImageRedrawGenerationDialogDraft({
sourceLayer: createLayer({
prompt: '【系统内置】完整拼接提示词,包含默认规则和模型约束。',
generationInputs: {
fields: testCase.fields,
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}).prompt,
).toBe(testCase.expectedPrompt);
}
expect(
createLegacyImageRedrawGenerationDialogDraft({
sourceLayer: createLayer({
prompt: '【系统内置】固定素材提取提示词',
generationInputs: {
fields: [
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
},
{ title: 'model', value: 'gpt-image-2' },
],
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}).prompt,
).toBe('');
});
it('creates same-source generator drafts for generated image layers', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
const characterDraft = createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
assetKind: 'character',
generationInputs: {
fields: [
{ title: '角色设定', value: '红发骑士' },
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
{ title: '抠图模型', value: '动漫风格 anime-seg' },
],
references: [],
},
}),
canvasSize,
viewport,
mode: 'redraw',
});
expect(characterDraft).toMatchObject({
mode: 'character',
prompt: '红发骑士',
sourceLayerId: 'layer-source',
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
});
const publicationDraft = createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
assetKind: 'publication-material',
originalWidth: 720,
originalHeight: 1280,
generationInputs: {
fields: [
{ title: '游戏名', value: '星海远征' },
{ title: '游戏分类', value: '冒险' },
{ title: '一句话描述游戏', value: '驾驶飞船探索星云' },
],
references: [],
},
}),
canvasSize,
viewport,
mode: 'redraw',
});
expect(publicationDraft).toMatchObject({
mode: 'publication',
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '星海远征',
gameCategories: '冒险',
gameDescription: '驾驶飞船探索星云',
},
});
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer(),
canvasSize,
viewport,
mode: 'redraw',
}),
).toBeNull();
});
it('restores the plain image generator when a source generation dialog exists', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
prompt: '【系统内置】普通提示词',
generationInputs: {
fields: [{ title: '生成提示词', value: '普通图' }],
references: [],
},
}),
canvasSize,
viewport,
sourceDialog: {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '普通图',
status: 'idle',
generatedLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
style: 'pixelArt',
},
mode: 'redraw',
}),
).toMatchObject({
mode: 'generate',
prompt: '普通图',
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
style: 'none',
placeholder: {
x: 472,
y: 140,
width: 1365,
height: 2048,
originalWidth: 1365,
originalHeight: 2048,
},
});
});
it.each([
['character', 'character'],
['icon', 'icon-spritesheet'],
['ui-design', 'ui-design'],
] as const)(
'restores %s remodel placeholders to the selected 2K dimensions',
(mode, assetKind) => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({ assetKind }),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
sourceDialog: {
id: `generation-dialog-${mode}`,
mode,
prompt: '来源生成器',
status: 'idle',
generatedLayerId: 'layer-source',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
},
mode: 'redraw',
}),
).toMatchObject({
mode,
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
placeholder: {
x: 472,
y: 140,
width: 1365,
height: 2048,
originalWidth: 1365,
originalHeight: 2048,
},
});
},
);
it('restores a plain generated layer from its own metadata', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createLayerGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
prompt: '图层提示词',
model: 'gpt-image-2',
generationInputs: {
fields: [{ title: '生成提示词', value: '元数据提示词' }],
references: [],
},
}),
canvasSize,
viewport,
}),
).toMatchObject({
mode: 'generate',
prompt: '元数据提示词',
generatedLayerId: 'layer-source',
imageModel: 'gpt-image-2',
placeholder: {
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
},
});
});
it('restores a tagged uploaded layer from its current asset kind', () => {
expect(
createLayerGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'uploaded',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '黑衣剑士' }],
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({
mode: 'character',
prompt: '黑衣剑士',
generatedLayerId: 'layer-source',
});
});
it('preserves 2K dimensions when reopening a persisted character layer', () => {
expect(
createLayerGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
assetKind: 'character',
model: IMAGE_MODEL_NANOBANANA2,
originalWidth: 2048,
originalHeight: 2048,
generationInputs: {
fields: [{ title: '角色设定', value: '香水师角色' }],
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({
mode: 'character',
prompt: '香水师角色',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '2K',
generatedLayerId: 'layer-source',
});
});
it('restores video and audio generators from media layer metadata', () => {
const context = {
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
};
expect(
createLayerGenerationDialogDraft({
...context,
sourceLayer: createLayer({
title: '技能演示',
mediaType: 'video',
assetKind: 'video',
sourceType: 'generated',
generationInputs: {
fields: [{ title: '视频描述', value: '火球飞行动画' }],
references: [],
},
}),
}),
).toMatchObject({
mode: 'video',
prompt: '火球飞行动画',
generatedLayerId: 'layer-source',
});
expect(
createLayerGenerationDialogDraft({
...context,
sourceLayer: createLayer({
title: '金币音效',
mediaType: 'audio',
assetKind: 'sound-effect',
sourceType: 'generated',
generationInputs: {
fields: [{ title: 'prompt', value: '金币弹出声' }],
references: [],
},
}),
}),
).toMatchObject({
mode: 'audio-sound-effect',
prompt: '金币弹出声',
generatedLayerId: 'layer-source',
});
expect(
createLayerGenerationDialogDraft({
...context,
sourceLayer: createLayer({
title: '背景音乐',
mediaType: 'audio',
assetKind: 'background-music',
sourceType: 'generated',
generationInputs: {
fields: [
{ title: 'gpt_description_prompt', value: '轻松森林音乐' },
],
references: [],
},
}),
}),
).toMatchObject({
mode: 'audio-background-music',
prompt: '轻松森林音乐',
generatedLayerId: 'layer-source',
});
});
it('restores V2 video and sound parameters by stable ids instead of display labels', () => {
const imageReference = createLayer({
id: 'layer-image-reference',
resourceId: 'resource-image-reference',
title: '视觉参考',
});
const audioReference = createLayer({
id: 'layer-audio-reference',
resourceId: 'resource-audio-reference',
title: '音轨参考',
mediaType: 'audio',
});
const videoReference = createLayer({
id: 'layer-video-reference',
resourceId: 'resource-video-reference',
title: '源视频',
mediaType: 'video',
});
const context = {
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
availableLayers: [imageReference, videoReference, audioReference],
};
expect(
createLayerGenerationDialogDraft({
...context,
sourceLayer: createLayer({
sourceType: 'generated',
mediaType: 'video',
generationInputs: {
version: 2,
action: 'video.generate',
fields: [
{ id: 'prompt', title: '可随时改名', value: '角色追逐镜头' },
{ id: 'model', title: '不用作键', value: 'seedance2.0-fast' },
{ id: 'aspectRatio', title: '显示快照', value: '9:16' },
{ id: 'resolution', title: '显示快照', value: '1080p' },
{ id: 'durationSeconds', title: '显示快照', value: 8 },
{ id: 'sound', title: '显示快照', value: 'off' },
{ id: 'webSearchEnabled', title: '显示快照', value: false },
],
references: [
{
id: 'imageReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-image-reference',
},
{
id: 'videoReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-video-reference',
},
{
id: 'audioReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-audio-reference',
},
],
},
}),
}),
).toMatchObject({
mode: 'video',
prompt: '角色追逐镜头',
videoModel: 'seedance2.0-fast',
videoAspectRatio: '9:16',
videoResolution: '480p',
videoDurationSeconds: 8,
videoSound: 'off',
videoWebSearchEnabled: false,
generationReferences: [
expect.objectContaining({
id: 'canvas-layer-image-reference',
label: '视觉参考',
}),
expect.objectContaining({
id: 'canvas-layer-video-reference',
label: '源视频',
mediaType: 'video',
}),
expect.objectContaining({
id: 'canvas-layer-audio-reference',
label: '音轨参考',
mediaType: 'audio',
}),
],
});
expect(
createLayerGenerationDialogDraft({
...context,
sourceLayer: createLayer({
sourceType: 'generated',
mediaType: 'audio',
generationInputs: {
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ id: 'prompt', title: '非 prompt 标题', value: '短促点击音' },
{
id: 'model',
title: '非 model 标题',
value: 'eleven_text_to_sound_v2',
},
{ id: 'durationMode', title: '非模式标题', value: 'manual' },
{ id: 'durationSeconds', title: '非 duration 标题', value: 7 },
{ id: 'loop', title: '非 Loop 标题', value: true },
],
references: [],
},
}),
}),
).toMatchObject({
mode: 'audio-sound-effect',
prompt: '短促点击音',
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 7,
soundLoop: true,
});
});
it('treats references outside the current canvas as unavailable', () => {
const availableReference = createLayer({
id: 'layer-available-reference',
resourceId: 'resource-available-reference',
});
const sourceLayer = createLayer({
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [{ id: 'prompt', title: '生成提示词', value: '场景图' }],
references: [
{
id: 'reference',
title: '画布参考图',
refType: 'project-resource',
refId: 'resource-available-reference',
},
{
id: 'reference',
title: '面板上传参考图',
refType: 'project-resource',
refId: 'resource-panel-upload',
},
],
},
});
expect(
getUnavailableNormalizedGenerationInputReferences(sourceLayer, [
availableReference,
]),
).toEqual([
expect.objectContaining({
title: '面板上传参考图',
refId: 'resource-panel-upload',
}),
]);
});
it('keeps valid V2 fields ahead of conflicting legacy dialog and resource fallbacks', () => {
expect(
createLayerGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
prompt: '资源旧提示词',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [
{
id: 'model',
title: '任意标题',
value: IMAGE_MODEL_GPT_IMAGE_2,
},
{ id: 'aspectRatio', title: '任意标题', value: '2:3' },
{ id: 'imageSize', title: '任意标题', value: '2K' },
],
references: [],
},
}),
sourceDialog: {
id: 'legacy-dialog',
mode: 'generate',
prompt: '历史对话框提示词',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
},
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({
mode: 'generate',
prompt: '',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
});
});
it('restores scene.generate V2 inputs into the scene composer', () => {
const sceneReference = createLayer({
id: 'layer-scene-reference',
resourceId: 'resource-scene-reference',
title: '车站参考',
});
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-scene-result',
resourceId: 'resource-scene-result',
sourceType: 'generated',
assetKind: 'scene',
generationInputs: {
version: 2,
action: 'scene.generate',
fields: [
{ id: 'prompt', title: '任意标题', value: '雨夜海边车站' },
{ id: 'stylePreset', title: '任意标题', value: 'custom' },
{ id: 'customStyle', title: '任意标题', value: '复古像素风' },
{
id: 'model',
title: '任意标题',
value: IMAGE_MODEL_GPT_IMAGE_2,
},
{ id: 'aspectRatio', title: '任意标题', value: '16:9' },
{ id: 'imageSize', title: '任意标题', value: '2K' },
],
references: [
{
id: 'reference',
title: '任意标题',
refType: 'project-resource',
refId: 'resource-scene-reference',
},
],
},
}),
availableLayers: [sceneReference],
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'scene',
prompt: '雨夜海边车站',
sceneStylePreset: 'custom',
sceneCustomStyle: '复古像素风',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
generationReferences: [
expect.objectContaining({
id: 'canvas-layer-scene-reference',
label: '车站参考',
}),
],
});
});
it('maps legacy ui spec.generate inputs to the unified icon spec composer', () => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
assetKind: 'icon-spec',
generationInputs: {
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'ui' },
{ id: 'playSetting', title: '玩法设定', value: '消除玩法' },
{ id: 'artStyle', title: '美术风格', value: '糖果玻璃' },
],
references: [],
},
}),
availableLayers: [],
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'spec',
specType: 'icon',
specValues: {
playSetting: '消除玩法',
artStyle: '糖果玻璃',
},
});
});
it('restores the narrowly scoped legacy scene recipe into the scene composer', () => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
assetKind: 'scene',
model: IMAGE_MODEL_GPT_IMAGE_2,
originalWidth: 2048,
originalHeight: 1152,
generationInputs: {
fields: [
{ title: '画面内容', value: '雨夜海边车站' },
{ title: '视觉风格', value: '清透水彩' },
],
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'scene',
prompt: '雨夜海边车站',
sceneStylePreset: 'watercolor',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
});
});
it('does not restore in-place image edits as remodel generators', () => {
const editSource = createLayer({
id: 'layer-edit-source',
resourceId: 'resource-edit-source',
});
const styleReference = createLayer({
id: 'layer-style-reference',
resourceId: 'resource-style-reference',
title: '风格参考',
});
const editResult = createLayer({
id: 'layer-edit-result',
resourceId: 'resource-edit-result',
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '提示词', value: '改成雨天' },
{ id: 'model', title: '模型', value: IMAGE_MODEL_NANOBANANA2 },
{ id: 'aspectRatio', title: '比例', value: '16:9' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 'resource-edit-source',
},
{
id: 'reference',
title: '参考图 1',
refType: 'project-resource',
refId: 'resource-style-reference',
},
],
},
});
expect(
createLayerGenerationDialogDraft({
sourceLayer: editResult,
availableLayers: [editSource, styleReference],
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toBeNull();
});
it('uses the V2 source reference ahead of a conflicting legacy animation source', () => {
const v2Source = createLayer({
id: 'layer-v2-character',
resourceId: 'resource-v2-character',
assetKind: 'character',
});
const legacySource = createLayer({
id: 'layer-legacy-character',
resourceId: 'resource-legacy-character',
assetKind: 'character',
});
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-animation-result',
sourceType: 'generated',
assetKind: 'character-animation',
sourceResourceId: 'resource-legacy-character',
generationInputs: {
version: 2,
action: 'character-animation.generate',
fields: [{ id: 'prompt', title: '任意标题', value: '循环奔跑' }],
references: [
{
id: 'source',
title: '任意标题',
refType: 'project-resource',
refId: 'resource-v2-character',
},
],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
sourceAnimationLayer: legacySource,
availableLayers: [legacySource, v2Source],
}),
).toMatchObject({
mode: 'character-animation',
prompt: '循环奔跑',
sourceLayerId: 'layer-v2-character',
});
});
it('inherits and normalizes quick edit model parameters from the source', () => {
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nano-banana',
}),
),
).toMatchObject({
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '4:3',
imageSize: '1K',
});
expect(
createQuickEditPanelDraft(createLayer({ model: 'nanobanana2' }), {
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
}),
).toMatchObject({
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
});
expect(
createQuickEditPanelDraft(
createLayer({ originalWidth: 512, originalHeight: 512 }),
{ imageModel: IMAGE_MODEL_GPT_IMAGE_2, imageSize: '0.5K' },
).imageSize,
).toBe('1K');
expect(
createQuickEditPanelDraft(createLayer({ model: 'legacy-image-model' })),
).toMatchObject({ model: IMAGE_MODEL_NANOBANANA2 });
});
it('creates character animation generation dialog drafts for character layers', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
const characterLayer = createLayer({
id: 'layer-character',
assetKind: 'character',
});
expect(
createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: characterLayer,
}),
).toMatchObject({
mode: 'character-animation',
sourceLayerId: 'layer-character',
status: 'idle',
composerOpen: true,
characterAnimationResolution: '480p',
characterAnimationRatio: 'same',
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
placeholder: {
x: 270,
y: 150,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
});
expect(
createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: createLayer(),
}),
).toBeNull();
});
it('resets failed character and icon dialogs when picking references', () => {
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '请选择角色规范',
characterReferences: [],
};
const sourceLayer = createLayer({ title: '参考图' });
const characterSpecLayer = createLayer({
title: '角色规范',
assetKind: 'spec',
});
expect(
assignCharacterSpecReference(characterDialog, characterSpecLayer),
).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
composerOpen: true,
characterSpecReference: expect.objectContaining({
id: 'canvas-layer-source',
label: '角色规范',
src: 'data:image/png;base64,source',
resourceId: 'resource-source',
}),
}),
);
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toBe(
characterDialog,
);
expect(
assignCharacterSpecReference(
characterDialog,
createLayer({ title: '角色形象', assetKind: 'character' }),
),
).toBe(characterDialog);
expect(
appendCharacterReference(characterDialog, sourceLayer),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
characterReferences: [{ label: '参考图' }],
});
const iconDialog: GenerateDialogState = {
mode: 'icon',
prompt: '',
status: 'failed',
errorMessage: '请选择图标规范',
};
expect(
assignIconSpecReference(
iconDialog,
createLayer({ assetKind: 'icon-spec' }),
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
iconSpecReference: { id: 'canvas-layer-source' },
});
expect(assignIconSpecReference(iconDialog, sourceLayer)).toBe(iconDialog);
expect(
assignIconSpecReference(
iconDialog,
createLayer({
title: '旧UI素材规范',
assetKind: 'spec',
generationInputs: {
fields: [
{
title: '玩法设定',
value: '生成一张完整游戏UI规范汇总设定展板',
},
],
references: [],
},
}),
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
iconSpecReference: { label: '旧UI素材规范' },
});
});
it('routes picked canvas references to spec, image, video, and UI design fields', () => {
const sourceLayer = createLayer({ title: '参考图' });
const videoLayer = createLayer({
id: 'layer-video',
title: '动作参考',
src: 'https://signed.example.com/reference.mp4',
mediaType: 'video',
objectKey:
'generated-character-drafts/editor/seedance-references/video/reference.mp4',
assetObjectId: 'asset-object-video',
});
expect(
appendGenerationReference(
{
mode: 'spec',
prompt: '',
status: 'failed',
errorMessage: '失败',
},
sourceLayer,
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
specReference: { label: '参考图' },
});
expect(
appendGenerationReference(
{
mode: 'video',
prompt: '',
status: 'idle',
generationReferences: [],
},
sourceLayer,
),
).toMatchObject({
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(
{
mode: 'video',
prompt: '',
status: 'idle',
videoModel: 'seedance2.0-fast',
generationReferences: [],
},
videoLayer,
),
).toMatchObject({
generationReferences: [
{
label: '动作参考',
mediaType: 'video',
objectKey:
'generated-character-drafts/editor/seedance-references/video/reference.mp4',
assetObjectId: 'asset-object-video',
},
],
});
const imageDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
generationReferences: [],
};
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(
imageDialog,
);
const specDialog: GenerateDialogState = {
mode: 'spec',
prompt: '',
status: 'idle',
};
expect(appendGenerationReference(specDialog, videoLayer)).toBe(specDialog);
const sceneDialog: GenerateDialogState = {
mode: 'scene',
prompt: '',
status: 'idle',
generationReferences: [],
};
const sceneWithReference = appendGenerationReference(
sceneDialog,
sourceLayer,
);
expect(sceneWithReference).toMatchObject({
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(
sceneWithReference,
createLayer({ id: 'other' }),
),
).toMatchObject({
generationReferences: [{ label: '参考图' }, { label: '源图' }],
});
const uiDialog: GenerateDialogState = {
mode: 'ui-design',
prompt: '',
status: 'failed',
errorMessage: '失败',
};
expect(assignUiDesignSpecReference(uiDialog, sourceLayer)).toBe(uiDialog);
expect(
assignUiDesignSpecReference(
uiDialog,
createLayer({ title: '图标规范', assetKind: 'icon-spec' }),
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
uiDesignSpecReference: { label: '图标规范' },
});
});
it('limits Seedance canvas-picked references by media type', () => {
const videoDialog: GenerateDialogState = {
mode: 'video',
prompt: '',
status: 'idle',
videoModel: 'seedance2.0',
generationReferences: Array.from({ length: 3 }, (_, index) => ({
id: `video-${index}`,
label: `视频${index + 1}`,
src: `generated-character-drafts/video-${index}.mp4`,
mediaType: 'video',
})),
};
expect(
appendGenerationReference(
videoDialog,
createLayer({
id: 'video-extra',
title: '额外视频',
mediaType: 'video',
src: 'generated-character-drafts/video-extra.mp4',
}),
),
).toMatchObject({
generationReferences: [
{ label: '视频1' },
{ label: '视频2' },
{ label: '视频3' },
],
});
expect(
appendGenerationReference(
videoDialog,
createLayer({
id: 'audio-first',
title: '旁白',
mediaType: 'audio',
src: 'generated-character-drafts/audio.mp3',
}),
),
).toMatchObject({
generationReferences: [
{ label: '视频1' },
{ label: '视频2' },
{ label: '视频3' },
{ label: '旁白', mediaType: 'audio' },
],
});
});
it('caps image references before a sixth reference can enter submission state', () => {
const existingReferences = Array.from({ length: 5 }, (_, index) => ({
id: `reference-${index}`,
label: `参考图${index + 1}`,
src: `generated-character-drafts/editor/reference-${index}.png`,
}));
const imageDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
generationReferences: existingReferences,
};
expect(
appendGenerationReference(
imageDialog,
createLayer({ id: 'reference-6', title: '参考图6' }),
),
).toMatchObject({
generationReferences: existingReferences,
});
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'idle',
characterSpecReference: existingReferences[0],
characterReferences: existingReferences.slice(1),
};
expect(
appendCharacterReference(
characterDialog,
createLayer({ id: 'character-reference-6', title: '角色参考图5' }),
),
).toMatchObject({
characterReferences: existingReferences.slice(1),
});
const iconDialog: GenerateDialogState = {
mode: 'icon',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
generationReferences: existingReferences.slice(0, 4),
};
expect(
appendGenerationReference(
iconDialog,
createLayer({ id: 'icon-reference-5', title: '图标参考图5' }),
),
).toMatchObject({
generationReferences: existingReferences.slice(0, 4),
});
});
it('reserves the primary-image slot before character and UI specs are selected', () => {
const references = Array.from({ length: 5 }, (_, index) => ({
id: `reference-${index}`,
label: `参考图${index + 1}`,
src: `/reference-${index}.png`,
}));
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'idle',
characterReferences: references.slice(0, 4),
};
const uiDialog: GenerateDialogState = {
mode: 'ui-design',
prompt: '',
status: 'idle',
generationReferences: references.slice(0, 4),
};
expect(
appendCharacterReference(
characterDialog,
createLayer({ id: 'character-reference-5' }),
),
).toMatchObject({ characterReferences: references.slice(0, 4) });
expect(
appendGenerationReference(
uiDialog,
createLayer({ id: 'ui-reference-5' }),
),
).toMatchObject({ generationReferences: references.slice(0, 4) });
});
it('updates failed spec and icon dialog fields back to idle state', () => {
const specDialog: GenerateDialogState = {
mode: 'spec',
prompt: '',
status: 'failed',
errorMessage: '生成失败',
specType: 'custom',
specValues: {
playSetting: '',
artStyle: '',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
};
expect(
updateSpecFormDialogValue(specDialog, 'customPrompt', '新规范'),
).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
specValues: expect.objectContaining({
customPrompt: '新规范',
bodyRatio: '3',
}),
}),
);
const iconDialog: GenerateDialogState = {
mode: 'icon',
prompt: '',
status: 'failed',
errorMessage: '描述错误',
iconDescriptions: ['旧描述'],
};
expect(
updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水'),
).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
prompt: '剑\n盾、药水',
iconDescriptions: [],
}),
);
});
it('updates character animation duration and composer visibility', () => {
const failedPanel: CharacterAnimationPanelState = {
sourceLayerId: 'layer-character',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'failed',
errorMessage: '失败',
};
expect(updateCharacterAnimationDurationPanel(failedPanel, '48')).toEqual({
...failedPanel,
frameCount: 48,
durationSeconds: 6,
status: 'idle',
errorMessage: undefined,
});
expect(updateCharacterAnimationDurationPanel(failedPanel, '999')).toBe(
failedPanel,
);
const generateDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
};
expect(hideGeneratedLayerComposerAfterBlur(generateDialog)).toEqual({
...generateDialog,
composerOpen: false,
});
expect(closeGenerateComposerDialog(generateDialog)).toEqual({
...generateDialog,
composerOpen: false,
});
});
});