diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index c47701afa..ea143b4fe 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -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` 的落位尺寸也应与实际输出目标分辨率一致,避免使用源图尺寸伪造改造结果的参数。 - 不再在底部常驻展开全部可选项。 ## 泥点显示 diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 242b4bca5..d4902c362 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -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({ diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 2472b44f7..52be26854 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -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, }; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index faf1c626d..a2bdf1e7b 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -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', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 09d0c518d..8126b0e30 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -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( ); } +function resolveSupportedFieldValue({ + preferredValues, + supportedValues, + fallbackValues, + defaultValue, +}: { + preferredValues: Array; + 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, }, ), }; diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index ad8998ff6..e5381e360 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -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,