Files
Genarrative/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts
T
JenkenB 9c7b3ad16d Image editor: Seedance2.0 refs and audio changes
Adds Seedance 2.0 reference-media constraints and forbids video data URLs; updates docs/decision-log and pitfalls to record the new rules. Introduces typed audio params (type: one-shot|loop, tempo: number|null), generator-specific placeholder styles/avoidance and UI changes for generation panels. Implements related front-end changes (new crop/ raster edit components, many image-editor models, tests and workflow hooks) and server-side contract/handler updates across api-server, shared-contracts, platform-audio, platform-image and vector_engine_audio_generation to support the new submission/asset flow and validations.
2026-06-18 17:31:35 +08:00

542 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
CanvasLayer,
CharacterAnimationPanelState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendGenerationReference,
assignCharacterSpecReference,
assignIconSpecReference,
assignUiDesignSpecReference,
closeGenerateComposerDialog,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createQuickEditPanelDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
formatIconDescriptionsText,
hideGeneratedLayerComposerAfterBlur,
parseIconDescriptionsText,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
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,
placeholder: {
x: -10,
y: -30,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
});
expect(
createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }),
).toMatchObject({
mode: 'spec',
specType: 'icon',
specValues: {
playSetting: '休闲小游戏',
artStyle: '清爽卡通',
},
placeholder: {
x: -80,
y: 22.5,
width: 560,
height: 315,
},
});
});
it('creates character and icon generation drafts with 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',
imageModel: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
characterSpecReference: null,
characterReferences: [],
placeholder: {
x: 270,
y: 150,
width: 420,
height: 420,
},
});
expect(
createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'unknown-model',
}),
).toMatchObject({
mode: 'icon',
imageModel: 'unknown-model',
aspectRatio: '1:1',
imageSize: '1K',
prompt: '返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮',
iconSpecReference: null,
iconDescriptions: [
'返回按钮',
'设置按钮',
'下一关按钮',
'提示按钮',
'原图按钮',
'冻结按钮',
],
placeholder: {
x: 300,
y: 180,
width: 360,
height: 360,
},
});
});
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: 'off',
placeholder: {
x: 200,
y: 202.5,
width: 560,
height: 315,
originalWidth: 1280,
originalHeight: 720,
},
});
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: 200,
y: 202.5,
width: 560,
height: 315,
},
});
});
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',
status: 'idle',
composerOpen: true,
soundType: 'one-shot',
soundTempo: null,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
});
expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }))
.toMatchObject({
mode: 'audio-background-music',
status: 'idle',
composerOpen: true,
makeInstrumental: true,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
},
});
});
it('creates edit, quick-edit, and character animation panel drafts', () => {
const sourceLayer = createLayer({
prompt: '原图提示',
model: 'gpt-image-2',
assetKind: 'character',
});
expect(createEditDialogDraft(sourceLayer)).toEqual({
mode: 'edit',
prompt: '原图提示,在保持主体结构的基础上优化画面细节',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
});
expect(createQuickEditPanelDraft(sourceLayer)).toEqual({
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
});
expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({
sourceLayerId: 'layer-source',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
});
expect(createCharacterAnimationPanelDraft(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: '参考图' });
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
composerOpen: true,
characterSpecReference: {
id: 'canvas-layer-source',
label: '参考图',
src: 'data:image/png;base64,source',
},
}),
);
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);
});
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: ['剑', '盾', '药水'],
}),
);
expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([
'剑',
'盾',
'药水',
'宝箱',
'钥匙',
]);
expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾');
});
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,
});
});
});