Normalize nanobanana aliases to real model

Unify UI aliases for the Nanobanana image model by mapping display and legacy names to the real model ID (gemini-3.1-flash-image-preview). Added normalizeEditorImageModel and used it across generation, dialog, submission and workflow code paths to ensure consistent model selection and pricing. Server-side editor_generation_config and editor_project were updated to accept the display alias (nanobanana2) and legacy alias (nano-banana) for validation/pricing. Tests and docs were added/updated to cover alias normalization and related UI behavior; minor code formatting tweaks included.
This commit is contained in:
2026-06-19 13:05:00 +08:00
parent 4af8ab22d0
commit 991fea965f
13 changed files with 183 additions and 39 deletions
@@ -5,6 +5,10 @@ import type {
CharacterAnimationPanelState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
import {
appendCharacterReference,
appendGenerationReference,
@@ -342,6 +346,23 @@ describe('ImageCanvasGenerationDialogModel', () => {
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
});
it('normalizes legacy nanobanana model aliases in quick edit drafts', () => {
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nano-banana',
}),
).model,
).toBe(IMAGE_MODEL_NANOBANANA2);
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nanobanana2',
}),
).model,
).toBe(IMAGE_MODEL_NANOBANANA2);
});
it('creates character animation generation dialog drafts for character layers', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
@@ -27,6 +27,7 @@ import {
DEFAULT_VIDEO_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
@@ -126,9 +127,10 @@ function appendLimitedSeedanceCanvasReference(
}
function resolveImageDimensionDefaults(imageModel: string) {
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
normalizedImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
return {
aspectRatio: dimensionOptions.aspectRatios[0],
@@ -209,9 +211,10 @@ export function createCharacterGenerationDialogDraft({
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
model: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
@@ -222,7 +225,7 @@ export function createCharacterGenerationDialogDraft({
composerOpen: true,
characterSpecReference: null,
characterReferences: [],
imageModel,
imageModel: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
@@ -246,9 +249,10 @@ export function createIconGenerationDialogDraft({
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
model: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
@@ -259,7 +263,7 @@ export function createIconGenerationDialogDraft({
composerOpen: true,
iconSpecReference: null,
iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS],
imageModel,
imageModel: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
@@ -498,9 +502,10 @@ export function createUiDesignGenerationDialogDraft({
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
model: normalizedImageModel,
aspectRatio: '16:9',
imageSize: dimensionDefaults.imageSize,
});
@@ -510,7 +515,7 @@ export function createUiDesignGenerationDialogDraft({
status: 'idle',
composerOpen: true,
uiDesignSpecReference: null,
imageModel,
imageModel: normalizedImageModel,
aspectRatio: '16:9',
imageSize: dimensionDefaults.imageSize,
placeholder: {
@@ -549,7 +554,7 @@ export function createQuickEditPanelDraft(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL,
model: normalizeEditorImageModel(sourceLayer.model),
quickEditReferences: [],
status: 'idle',
};
@@ -573,7 +578,7 @@ export function createQuickEditGenerationDialogDraft({
composerOpen: false,
sourceLayerId: sourceLayer.id,
generationReferences: references.slice(0, 8),
imageModel: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL,
imageModel: normalizeEditorImageModel(sourceLayer.model),
imageSize: formatImageSizeValue(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
@@ -17,6 +17,7 @@ import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -56,7 +57,7 @@ function getImageDimensionOptions(model: string | null | undefined) {
}
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
const model = dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2;
const model = normalizeEditorImageModel(dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2);
const options = getImageDimensionOptions(model);
const aspectRatios = options.aspectRatios as readonly string[];
const imageSizes = options.imageSizes as readonly string[];
@@ -238,7 +238,10 @@ describe('ImageCanvasGenerationModel', () => {
expect(buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }))
.toBe('自定义');
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
{ label: 'nano-banana', value: 'nano-banana' },
{ label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL },
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
{ label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL },
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
@@ -75,6 +75,11 @@ export const PUBLICATION_FRAME_DISPLAY_SIZE: Record<
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
IMAGE_MODEL_NANOBANANA2,
'nanobanana2',
'nano-banana',
]);
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
@@ -407,11 +412,25 @@ export function buildQuickEditSizeOptions(currentSize: string) {
return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS]));
}
export function normalizeEditorImageModel(
model: string | null | undefined,
) {
const normalizedModel = model?.trim();
if (!normalizedModel) {
return DEFAULT_IMAGE_MODEL;
}
if (IMAGE_MODEL_NANOBANANA_ALIASES.has(normalizedModel)) {
return IMAGE_MODEL_NANOBANANA2;
}
return normalizedModel;
}
export function buildQuickEditModelOptions(currentModel: string) {
const normalizedCurrentModel = normalizeEditorImageModel(currentModel);
const options = [...QUICK_EDIT_MODEL_OPTIONS];
return options.some((option) => option.value === currentModel)
return options.some((option) => option.value === normalizedCurrentModel)
? options
: [{ label: currentModel, value: currentModel }, ...options];
: [{ label: normalizedCurrentModel, value: normalizedCurrentModel }, ...options];
}
export function buildCharacterSpecPrompt(values: SpecFormValues) {
@@ -590,7 +609,7 @@ export function calculateEditorVideoPrice(
export function calculateEditorImageModelPrice(
model: string | null | undefined,
) {
const normalizedModel = (model ?? DEFAULT_IMAGE_MODEL).trim();
const normalizedModel = normalizeEditorImageModel(model);
return (
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG
@@ -15,6 +15,7 @@ import {
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
@@ -66,6 +67,31 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('normalizes legacy nanobanana aliases before submitting image generation', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'generate',
prompt: '一张明亮图片',
status: 'idle',
imageModel: 'nanobanana2',
aspectRatio: '16:9',
imageSize: '2K',
},
layers: [],
nextGeneratedIndex: 5,
});
expect(plan).toMatchObject({
kind: 'image',
input: {
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '2K',
},
rememberImageModel: IMAGE_MODEL_NANOBANANA2,
});
});
it('builds edit submission plans with the source layer snapshot', () => {
const sourceLayer = createLayer();
@@ -34,12 +34,12 @@ import {
DEFAULT_BACKGROUND_MUSIC_MODEL,
CHARACTER_ANIMATION_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
ICON_DESCRIPTION_LIMIT,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
SPEC_GENERATION_ASPECT_RATIO,
@@ -250,7 +250,7 @@ export function buildImageGenerationSubmissionPlan({
dialog.characterSpecReference?.src,
...(dialog.characterReferences ?? []).map((reference) => reference.src),
].filter((src): src is string => Boolean(src));
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const imageModel = normalizeEditorImageModel(dialog.imageModel);
return {
kind: 'image',
normalizedPrompt,
@@ -276,7 +276,7 @@ export function buildImageGenerationSubmissionPlan({
}
if (dialog.mode === 'ui-design') {
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter(
(src): src is string => Boolean(src),
);
@@ -307,7 +307,7 @@ export function buildImageGenerationSubmissionPlan({
const workflow = getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
);
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const publicationPrompt =
buildPublicationMaterialsPrompt(dialog.publicationGameInfo) ||
normalizedPrompt;
@@ -435,7 +435,7 @@ export function buildImageGenerationSubmissionPlan({
};
}
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const imageModel = normalizeEditorImageModel(dialog.imageModel);
return {
kind: 'image',
normalizedPrompt,
@@ -487,7 +487,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
};
}
const rememberImageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
return {
ok: true,
iconDescriptions,
@@ -69,6 +69,7 @@ function renderWorldView(
onLayerMouseEnter: vi.fn(),
onLayerMouseLeave: vi.fn(),
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
@@ -145,6 +146,29 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByText('角色')).toBeTruthy();
});
it('shows unknown tag for untagged layers and lets users choose a tag', () => {
const layer = createLayer({ assetKind: null });
const { props } = renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
const unknownTag = within(layerButton).getByRole('button', {
name: '素材标签:未知',
});
expect(unknownTag.textContent).toBe('未知');
expect(unknownTag.style.getPropertyValue('--image-canvas-editor-inverse-scale')).toBe(
`${1 / props.viewport.scale}`,
);
fireEvent.click(unknownTag);
const menu = screen.getByRole('menu', { name: '选择素材标签' });
fireEvent.click(within(menu).getByRole('menuitem', { name: '角色' }));
expect(props.onUpdateLayerAssetKind).toHaveBeenCalledWith(layer.id, 'character');
expect(props.onLayerClick).not.toHaveBeenCalled();
});
it('renders snap guides, marquee and floating generation status', () => {
renderWorldView({
snapGuide: { vertical: 288, horizontal: 344 },
@@ -572,6 +596,22 @@ describe('ImageCanvasWorldView', () => {
).toBe(inverseScale);
});
it('keeps blank generation placeholder icons readable when the canvas is zoomed out', () => {
const dialog = createGenerationDialog({ id: 'dialog-icon-scale' });
renderWorldView({
viewport: { x: 0, y: 0, scale: 0.25 },
canvasGenerationDialogs: [dialog],
generateDialog: null,
});
const frame = screen.getByRole('button', { name: '图像生成占位图' });
expect(
(frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement)
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe('4');
});
it('uses a plain information icon for metadata corner actions', () => {
const layer = createLayer();
const { props } = renderWorldView({ layers: [layer] });
@@ -47,6 +47,7 @@ import {
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
isCanvasGenerationDialog,
normalizeEditorImageModel,
normalizeQuickEditPromptSourceReference,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
@@ -670,7 +671,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
prompt: normalizedPrompt,
size: quickEditPanel.size,
kind: 'quick-edit',
model: quickEditPanel.model,
model: normalizeEditorImageModel(quickEditPanel.model),
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,