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