修复图片修改参数恢复与提交一致性
- 图片修改面板优先恢复历史模型、比例和尺寸,并保留显式设置优先级 - 图片修改提交计划统一归一化参数并下发 model、aspectRatio、imageSize 和 size - 画布完成占位使用实际目标输出尺寸 - 补充参数恢复回归测试与编辑器方案说明
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user