3485bad2d3
- 将所有相关工作流从 "ui-spec" 迁移至 "icon-spec"。 - 优化 `playSetting` 和 `artStyle` 的输入处理逻辑。 - 更新编辑器提示词、提交模型及测试用例以适配 "icon-spec"。 - 在 UI 及后端调用中集成 `EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH` 及相关校验。 - 新增 "icon-spec" 生成与微调的专用 API 路径。 - 移除旧版 "ui-spec" 相关定义及提示词构建器。
1212 lines
33 KiB
TypeScript
1212 lines
33 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: '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: 'duration', 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: 'duration', 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('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,
|
|
});
|
|
});
|
|
});
|