合并生成参数恢复与改造入口修复

合入 PR 135 的 GenerationInputs V2 稳定字段与严格恢复链路
统一缺失来源时保留改造入口并在点击后复检拒绝
保留主分支 SFX V2 自动时长、Loop 与权威元数据语义
将队列引用槽位与场景配方修复移植到 PR 137 的模块结构
This commit is contained in:
2026-08-08 22:03:50 +08:00
38 changed files with 5278 additions and 708 deletions
@@ -31,6 +31,7 @@ import {
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
@@ -60,6 +61,22 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
}
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 };
@@ -475,6 +492,41 @@ describe('ImageCanvasGenerationDialogModel', () => {
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({
@@ -882,6 +934,408 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
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: 'audio1.0' },
{ id: 'durationSeconds', title: '非 duration 标题', value: 7 },
],
references: [],
},
}),
}),
).toMatchObject({
mode: 'audio-sound-effect',
prompt: '短促点击音',
soundModel: 'audio1.0',
soundDurationSeconds: 7,
});
});
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('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('restores quick-edit references and the persisted image model', () => {
const editSource = createLayer({
id: 'layer-edit-source',
resourceId: 'resource-edit-source',
});
const styleReference = createLayer({
id: 'layer-style-reference',
resourceId: 'resource-style-reference',
title: '风格参考',
});
expect(
createLayerGenerationDialogDraft({
sourceLayer: 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',
},
],
},
}),
availableLayers: [editSource, styleReference],
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({
mode: 'quick-edit',
prompt: '改成雨天',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '2K',
generationReferences: [
expect.objectContaining({
id: 'canvas-layer-style-reference',
label: '风格参考',
}),
],
});
});
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(
@@ -1135,7 +1589,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(sceneWithReference, createLayer({ id: 'other' })),
appendGenerationReference(
sceneWithReference,
createLayer({ id: 'other' }),
),
).toMatchObject({
generationReferences: [{ label: '参考图' }, { label: '源图' }],
});