9c7b3ad16d
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.
542 lines
14 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|