修复图片修改参数恢复与提交一致性

- 图片修改面板优先恢复历史模型、比例和尺寸,并保留显式设置优先级

- 图片修改提交计划统一归一化参数并下发 model、aspectRatio、imageSize 和 size

- 画布完成占位使用实际目标输出尺寸

- 补充参数恢复回归测试与编辑器方案说明
This commit is contained in:
2026-08-04 18:00:23 +08:00
parent 78307de9a1
commit 59a69f063d
6 changed files with 294 additions and 25 deletions
@@ -70,6 +70,7 @@
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
- 宣发素材的 `游戏首图``详情五图``运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`
- 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。提交时必须同时传递 `model / aspectRatio / imageSize`;后端按模型选择 provider 协议:`nanobanana2` 使用 `generateContent + inline_data``gpt-image-2` 使用 `/v1/images/edits` multipart,不能把 nanobanana 模型 ID 发往 GPT edits 端点。
- 图片改造入口也要保持同样约束:恢复历史参数时优先使用 `generationInputs` 中保存的模型、比例和尺寸,失败回退到当前关联参数后再映射为实际 `size`,并一并回传到编辑请求;`canvasCompletion` 的落位尺寸也应与实际输出目标分辨率一致,避免使用源图尺寸伪造改造结果的参数。
- 不再在底部常驻展开全部可选项。
## 泥点显示
@@ -419,6 +419,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({
@@ -1682,21 +1682,30 @@ export function createEditDialogDraft(
imageSize?: string;
} = {},
): GenerateDialogState {
const imageModel = normalizeEditorImageModel(
options.imageModel ?? sourceLayer.model,
const decodedInputs = decodeCanvasGenerationInputs(
sourceLayer.generationInputs,
);
const aspectRatio =
options.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const imageSize =
options.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const normalizedSourceLayer = decodedInputs.ok
? { ...sourceLayer, generationInputs: decodedInputs.inputs }
: sourceLayer;
const persistedImageModel = decodedInputs.ok
? getNormalizedString(normalizedSourceLayer, 'model') || undefined
: undefined;
const persistedAspectRatio = decodedInputs.ok
? getNormalizedString(normalizedSourceLayer, 'aspectRatio') || undefined
: undefined;
const persistedImageSize = decodedInputs.ok
? getNormalizedString(normalizedSourceLayer, 'imageSize') || undefined
: undefined;
const resolvedOptions = createQuickEditPanelDraft(sourceLayer, {
imageModel:
options.imageModel ??
persistedImageModel ??
sourceLayer.model ??
undefined,
aspectRatio: options.aspectRatio ?? persistedAspectRatio,
imageSize: options.imageSize ?? persistedImageSize,
});
return {
id: `edit-dialog:${sourceLayer.id}`,
mode: 'edit',
@@ -1704,9 +1713,9 @@ export function createEditDialogDraft(
status: 'idle',
composerOpen: true,
sourceLayerId: sourceLayer.id,
imageModel,
aspectRatio,
imageSize,
imageModel: resolvedOptions.model,
aspectRatio: resolvedOptions.aspectRatio,
imageSize: resolvedOptions.imageSize,
};
}
@@ -119,6 +119,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: '',
status: 'idle',
sourceLayerId: sourceLayer.id,
imageModel: 'unknown-model',
aspectRatio: '7:5',
imageSize: '8K',
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
@@ -128,6 +131,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
kind: 'edit',
normalizedPrompt: '修改当前图片',
sourceLayer,
editInput: {
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '4:3',
imageSize: '1K',
size: '1024x768',
},
generationInputs: expect.objectContaining({
version: 2,
action: 'image.edit',
@@ -147,6 +156,64 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('builds edit submission plans from persisted image-edit inputs', () => {
const sourceLayer = createLayer({
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '修改要求', value: 'persisted' },
{ id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ id: 'aspectRatio', title: '比例', value: '2:3' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
],
references: [
{
id: 'source',
title: '参考图',
label: '源图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
model: IMAGE_MODEL_GPT_IMAGE_2,
originalWidth: 2048,
originalHeight: 1152,
});
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: sourceLayer.id,
imageModel: 'unknown-model',
aspectRatio: '7:5',
imageSize: '8K',
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
});
expect(plan).toMatchObject({
kind: 'edit',
editInput: {
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
size: '1365x2048',
},
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
{ id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ id: 'aspectRatio', title: '比例', value: '2:3' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
]),
}),
});
});
it('builds quick-edit canvas generator plans as direct source edits', () => {
const sourceLayer = createLayer({
objectKey: 'generated-character-drafts/editor/source.png',
@@ -31,6 +31,7 @@ import {
CHARACTER_ANIMATION_MODEL,
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
@@ -165,12 +166,151 @@ function resolveOptionalFieldWithFallback<T extends string>(
);
}
function resolveSupportedFieldValue({
preferredValues,
supportedValues,
fallbackValues,
defaultValue,
}: {
preferredValues: Array<string | null | undefined>;
supportedValues: readonly string[];
fallbackValues: readonly string[];
defaultValue: string;
}) {
for (const value of preferredValues) {
const normalized = value?.trim();
if (normalized && supportedValues.includes(normalized)) {
return normalized;
}
}
return (
fallbackValues.find((fallbackValue) =>
supportedValues.includes(fallbackValue),
) ?? defaultValue
);
}
function resolveImageEditFieldValueFromSourceLayer(
sourceLayer: CanvasLayer,
fieldId: string,
) {
const rawValue = sourceLayer.generationInputs?.fields.find(
(field) => field.id === fieldId,
)?.value;
return typeof rawValue === 'string' ? rawValue : null;
}
function resolveImageEditModel({
sourceLayer,
dialogModel,
}: {
sourceLayer: CanvasLayer;
dialogModel?: string | null;
}) {
const normalizedDialogModel = dialogModel
? normalizeEditorImageModel(dialogModel)
: null;
const normalizedSourceLayerModel = sourceLayer.model
? normalizeEditorImageModel(sourceLayer.model)
: null;
const resolvedModelFromLayer = resolveImageEditFieldValueFromSourceLayer(
sourceLayer,
'model',
);
const normalizedLayerPersistedModel = resolvedModelFromLayer
? normalizeEditorImageModel(resolvedModelFromLayer)
: null;
return [
normalizedDialogModel,
normalizedLayerPersistedModel,
normalizedSourceLayerModel,
].find((candidateModel) =>
EDITOR_IMAGE_MODEL_OPTIONS.some(
(option) => option.value === candidateModel,
),
);
}
function resolveImageEditGeometry({
sourceLayer,
dialogImageModel,
dialogAspectRatio,
dialogImageSize,
}: {
sourceLayer: CanvasLayer;
dialogImageModel?: string | null;
dialogAspectRatio?: string | null;
dialogImageSize?: string | null;
}) {
const model = resolveImageEditModel({
sourceLayer,
dialogModel: dialogImageModel,
});
const selectedModel = model ?? DEFAULT_IMAGE_MODEL;
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
selectedModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ??
EDITOR_IMAGE_DIMENSION_OPTIONS[
DEFAULT_IMAGE_MODEL as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
];
const supportedAspectRatios =
dimensionOptions.aspectRatios as readonly string[];
const supportedImageSizes = dimensionOptions.imageSizes as readonly string[];
const persistedAspectRatio = resolveImageEditFieldValueFromSourceLayer(
sourceLayer,
'aspectRatio',
);
const persistedImageSize = resolveImageEditFieldValueFromSourceLayer(
sourceLayer,
'imageSize',
);
const inferredAspectRatio = inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const inferredImageSize = inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const defaultImageSize = supportedImageSizes.includes('1K')
? '1K'
: (supportedImageSizes[0] ?? '1K');
const aspectRatio = resolveSupportedFieldValue({
preferredValues: [dialogAspectRatio, persistedAspectRatio],
supportedValues: supportedAspectRatios,
fallbackValues: [inferredAspectRatio, ...dimensionOptions.aspectRatios],
defaultValue: '1:1',
});
const imageSize = resolveSupportedFieldValue({
preferredValues: [dialogImageSize, persistedImageSize],
supportedValues: supportedImageSizes,
fallbackValues: [inferredImageSize, ...dimensionOptions.imageSizes],
defaultValue: defaultImageSize,
});
const outputSize = resolveEditorImageGenerationPixelSize({
model: selectedModel,
aspectRatio,
imageSize,
});
return {
model: selectedModel,
aspectRatio,
imageSize,
outputSize,
};
}
export type ImageGenerationSubmissionPlan =
| {
kind: 'edit';
normalizedPrompt: string;
sourceLayer: CanvasLayer;
resultTitle: string;
editInput: Pick<
EditorImageEditInput,
'size' | 'model' | 'aspectRatio' | 'imageSize'
>;
generationInputs: CanvasGenerationInputs;
}
| {
@@ -268,6 +408,12 @@ export function buildImageGenerationSubmissionPlan({
if (!sourceLayer) {
throw new Error('未找到要修改的图片');
}
const geometry = resolveImageEditGeometry({
sourceLayer,
dialogImageModel: dialog.imageModel,
dialogAspectRatio: dialog.aspectRatio,
dialogImageSize: dialog.imageSize,
});
return {
kind: 'edit',
normalizedPrompt,
@@ -276,14 +422,20 @@ export function buildImageGenerationSubmissionPlan({
dialog.assetLabel,
`${sourceLayer.title} 修改结果`,
),
editInput: {
size: `${geometry.outputSize.width}x${geometry.outputSize.height}`,
model: geometry.model,
aspectRatio: geometry.aspectRatio,
imageSize: geometry.imageSize,
},
generationInputs: buildEditGenerationInputs(
'修改要求',
normalizedPrompt,
sourceLayer,
{
model: dialog.imageModel,
aspectRatio: dialog.aspectRatio,
imageSize: dialog.imageSize,
model: geometry.model,
aspectRatio: geometry.aspectRatio,
imageSize: geometry.imageSize,
},
),
};
@@ -1835,6 +1835,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
'image',
projectId,
);
const editPlaceholderSize =
getCanvasCompletionPlaceholderSizeFromPlan({
sourceLayer: submissionPlan.sourceLayer,
outputSize: submissionPlan.editInput.size,
});
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const editCanvasCompletionPlaceholder =
@@ -1842,17 +1847,17 @@ export function useImageCanvasGenerationSubmissionWorkflow({
buildRightSideCanvasCompletionPlaceholder(
submissionPlan.sourceLayer,
{
width: submissionPlan.sourceLayer.originalWidth,
height: submissionPlan.sourceLayer.originalHeight,
originalWidth: submissionPlan.sourceLayer.originalWidth,
originalHeight: submissionPlan.sourceLayer.originalHeight,
width: editPlaceholderSize.width,
height: editPlaceholderSize.height,
originalWidth: editPlaceholderSize.width,
originalHeight: editPlaceholderSize.height,
},
);
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
size: `${submissionPlan.sourceLayer.originalWidth}x${submissionPlan.sourceLayer.originalHeight}`,
...submissionPlan.editInput,
projectId,
assetKind: submissionPlan.sourceLayer.assetKind,
generationInputs: submissionPlan.generationInputs,