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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user