Lock publication materials to gpt-image-2

Enforce gpt-image-2 for publication-material workflows to ensure stable, predictable promotional asset generation. Updates include: docs clarifying the decision and UI behaviour; backend api-server normalize/force model to GPT_IMAGE_2 for kind = "publication-material"; frontend sets publication dialogs to use and display a disabled gpt-image-2 model capsule and computes cost using that model; submission flow no longer remembers or submits nanobanana2 for publication items. Added/updated unit tests and a demo panel test to validate the locked model. Key files changed: docs/* (decision log and editor guides), server-rs/crates/api-server/src/editor_project.rs, several src/components/image-editor/* files and tests.
This commit is contained in:
2026-06-23 15:27:52 +08:00
parent 7830d201c4
commit 650a54f5eb
10 changed files with 129 additions and 28 deletions
@@ -31,6 +31,7 @@ import {
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorImageModel,
@@ -39,6 +40,7 @@ import {
resolveEditorVideoGenerationPixelSize,
SPEC_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
type CanvasSize = { width: number; height: number };
const VIDEO_REFERENCE_LIMITS = {
@@ -298,6 +300,7 @@ export function createPublicationGenerationDialogDraft({
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const frameOriginalSize = PUBLICATION_FRAME_ORIGINAL_SIZE[workflowId];
const workflow = getPublicationMaterialsWorkflow(workflowId);
return {
mode: 'publication',
prompt: '',
@@ -306,6 +309,9 @@ export function createPublicationGenerationDialogDraft({
publicationWorkflowId: workflowId,
publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO },
publicationReferences: [],
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: workflow.aspectRatio,
imageSize: workflow.imageSize,
placeholder: {
x: worldCenter.x - frameOriginalSize.width / 2,
y: worldCenter.y - frameOriginalSize.height / 2,
@@ -354,7 +354,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: expect.stringContaining('【宣发素材类型】游戏首图'),
size: '720x540',
kind: 'publication-material',
model: 'gemini-3.1-flash-image-preview',
model: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,ref'],
@@ -380,7 +380,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
],
},
},
rememberImageModel: 'gemini-3.1-flash-image-preview',
});
const prompt = plan.kind === 'image' ? plan.input.prompt : '';
expect(prompt).toContain('游戏名:重庆洪崖洞火锅');
@@ -467,7 +466,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
);
});
it('builds publication material plans with selected image model', () => {
it('locks publication material plans to gpt-image-2 and does not remember the fixed model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
@@ -480,7 +479,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
gameDescription: '',
},
publicationReferences: [],
imageModel: 'gpt-image-2',
imageModel: 'nanobanana2',
},
layers: [],
nextGeneratedIndex: 4,
@@ -499,7 +498,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
imageSize: '1K',
}),
);
expect(plan.rememberImageModel).toBe('gpt-image-2');
expect(plan.rememberImageModel).toBeUndefined();
});
it('throws when edit source layer is missing', () => {
@@ -322,7 +322,7 @@ export function buildImageGenerationSubmissionPlan({
const workflow = getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
);
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
const publicationPrompt =
buildPublicationMaterialsPrompt(dialog.publicationGameInfo) ||
normalizedPrompt;
@@ -355,7 +355,6 @@ export function buildImageGenerationSubmissionPlan({
dialog.publicationReferences,
),
},
rememberImageModel: imageModel,
};
}
@@ -0,0 +1,71 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import {
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
function PublicationPanelHarness({
onRememberImageModel = vi.fn(),
}: {
onRememberImageModel?: (model: string) => void;
}) {
const [dialog, setDialog] = useState<GenerateDialogState | null>({
mode: 'publication',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
publicationWorkflowId: 'publication-cover-image',
publicationGameInfo: {
gameName: '测试游戏',
gameCategories: '休闲',
gameDescription: '点击消除三个一样的物品',
},
publicationReferences: [],
});
const workflow = getPublicationMaterialsWorkflow('publication-cover-image');
return dialog ? (
<ImageCanvasPublicationMaterialsDemoPanelView
dialog={dialog}
workflow={workflow}
style={{}}
publicationReferenceButtonRef={{ current: null }}
isPublicationReferenceMenuOpen={false}
setGenerateDialog={setDialog}
setIsPublicationReferenceMenuOpen={() => undefined}
setIsPickingPublicationReferenceFromCanvas={() => undefined}
renderEditorPortal={(node) => node}
buildPortalMenuStyle={() => ({})}
onRequestUpload={() => undefined}
onRememberImageModel={onRememberImageModel}
onSubmit={() => undefined}
/>
) : null;
}
describe('ImageCanvasPublicationMaterialsDemoPanelView', () => {
it('locks publication material model selection to gpt-image-2', () => {
const rememberImageModel = vi.fn();
render(<PublicationPanelHarness onRememberImageModel={rememberImageModel} />);
const modelButton = screen.getByRole('button', {
name: '宣发素材模型 gpt-image-2',
}) as HTMLButtonElement;
expect(modelButton.disabled).toBe(true);
expect(modelButton.textContent).not.toContain('nanobanana2');
fireEvent.click(modelButton);
expect(screen.queryByRole('menu', { name: '宣发素材模型选项' })).toBeNull();
expect(rememberImageModel).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
'生成3泥点',
);
});
});
@@ -26,6 +26,7 @@ import {
buildPublicationMaterialsPrompt,
calculateEditorImageModelPrice,
DEFAULT_PUBLICATION_GAME_INFO,
IMAGE_MODEL_GPT_IMAGE_2,
} from './ImageCanvasGenerationModel';
import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
@@ -302,8 +303,12 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
setGenerateDialog={setGenerateDialog}
includeDimensions={false}
onRememberImageModel={onRememberImageModel}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
optionLabelPrefix="宣发素材"
cost={calculateEditorImageModelPrice(dialog.imageModel, workflow.imageSize)}
cost={calculateEditorImageModelPrice(
IMAGE_MODEL_GPT_IMAGE_2,
workflow.imageSize,
)}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}