Files
Genarrative/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts
T
k88936 a03f13d0a5
Project CI / Repository checks (push) Successful in 1m28s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 13m58s
Fix/动作 前端展示下载+后端数据结构+精选 的问题 (#117)
原问题

```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```

- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs`   手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试

- 移除preview_video_path字段,  reason:
>preview video
    → separate resource/asset with assetKind="video"
  final transparent action
    → assetKind="character-animation"
    → source_resource_id points to the preview-video resource
    → image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复

- 清除副产品preview video的generation_input_json,  因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造

仍存在的共性问题: #134

- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变

画布放置:
before:
![shotmd-1785207959-compressed.webp](/attachments/e56454f0-49a3-4a8a-951f-c21960975f99)
after:
![shotmd-1785231365-compressed.webp](/attachments/886f07df-c25b-4ecc-acc9-160e1795a991)
精选模块: 主页展示, 审核部分UI:
![shotmd-1785500046-compressed.webp](/attachments/9ec3b679-0045-494a-9f20-134f7e73fecf)
![shotmd-1785499780-compressed.webp](/attachments/42396e63-5f26-4853-a367-0f3c15111add)
这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-06 16:57:38 +08:00

1310 lines
36 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,
createPublicationGenerationDialogDraft,
createQuickEditGenerationDialogDraft,
createQuickEditPanelDraft,
createRedrawPanelDraft,
createSameSourceGenerationDialogDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
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('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: 'ui' }),
).toMatchObject({
mode: 'spec',
specType: 'ui',
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 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: 'audio1.0',
soundDurationSeconds: 5,
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: 'audio1.0',
soundDurationSeconds: 7,
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('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({
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
aspectRatio: '4:3',
imageSize: '1K',
model: 'gpt-image-2',
quickEditReferences: [],
status: 'idle',
});
expect(createRedrawPanelDraft(sourceLayer).prompt).toBe('原图提示');
expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({
sourceLayerId: 'layer-source',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
});
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
});
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(
createRedrawPanelDraft(
createLayer({
prompt: '【系统内置】完整拼接提示词,包含默认规则和模型约束。',
generationInputs: {
fields: testCase.fields,
references: [],
},
}),
).prompt,
).toBe(testCase.expectedPrompt);
}
expect(
createRedrawPanelDraft(
createLayer({
prompt: '【系统内置】固定素材提取提示词',
generationInputs: {
fields: [
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
},
{ title: 'model', value: 'gpt-image-2' },
],
references: [],
},
}),
).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('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 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,
});
});
});