From 991fea965f18e8db4ef39782cb4791e855a4675a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Fri, 19 Jun 2026 13:05:00 +0800 Subject: [PATCH] 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. --- .../shared-memory/decision-log.md | 2 +- ...™¨ã€‘生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md | 4 +- .../src/editor_generation_config.rs | 22 +++++++--- .../crates/api-server/src/editor_project.rs | 30 +++++++++++--- .../ImageCanvasGenerationDialogModel.test.ts | 21 ++++++++++ .../ImageCanvasGenerationDialogModel.ts | 29 ++++++++------ .../ImageCanvasGenerationImageOptionsView.tsx | 3 +- .../ImageCanvasGenerationModel.test.ts | 5 ++- .../ImageCanvasGenerationModel.ts | 25 ++++++++++-- ...ageCanvasGenerationSubmissionModel.test.ts | 26 ++++++++++++ .../ImageCanvasGenerationSubmissionModel.ts | 12 +++--- .../ImageCanvasWorldView.test.tsx | 40 +++++++++++++++++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 3 +- 13 files changed, 183 insertions(+), 39 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 29cad5f10..7f1c28468 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-06-19 å›¾ç‰‡ç”»å¸ƒç”ŸæˆæŒ‰é’®ä»·æ ¼ç»Ÿä¸€ç»‘定模型定价é…ç½® - 背景:图片画布的生æˆå›¾ç‰‡ã€ç”Ÿæˆè§†é¢‘ã€ç”Ÿæˆè§„范ã€ç”Ÿæˆè§’色ã€ç”Ÿæˆç´ æã€ç”Ÿæˆ UIã€å®£å‘ç´ æã€å¿«é€Ÿç¼–辑ã€é‡ç»˜å’ŒéŸ³é¢‘生æˆå…¥å£éƒ½åœ¨æŒ‰é’®å†…显示泥点;如果按钮文案ã€å‰ç«¯æäº¤å’ŒåŽç«¯æ ¡éªŒå„自写固定数值,åŽç»­è°ƒæ•´æ¨¡åž‹ä»·æ ¼ä¼šå‡ºçŽ°å±•ç¤ºä»·ã€æäº¤ä»·å’Œæ‰£è´¹ä»·ä¸ä¸€è‡´ã€‚ -- 决策:所有画æ¿ç”ŸæˆæŒ‰é’®ä»·æ ¼å¿…须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价é…ç½®å‡½æ•°è®¡ç®—ï¼›éœ€è¦æäº¤ `priceMudPoints` 的视频ã€è§’色动画ã€å›¾æ ‡ç´ æã€éŸ³æ•ˆå’ŒèƒŒæ™¯éŸ³ä¹ä¹Ÿä½¿ç”¨åŒä¸€å‡½æ•°ã€‚åŽç«¯ç”¨ `server-rs/crates/api-server/src/editor_generation_config.rs` çš„åŒå语义é…ç½®é‡æ–°è®¡ç®—并校验 / æ‰£è´¹ã€‚å½“å‰æ­£å¼æ¨¡åž‹å‡å·²è¦†ç›–定价;`nanobanana2`ã€`nano-banana` åªä½œä¸º UI æ˜¾ç¤ºåæˆ–历å²åˆ«å,ä¸ä½œä¸ºæ­£å¼æ¨¡åž‹ ID 写入定价表。 +- 决策:所有画æ¿ç”ŸæˆæŒ‰é’®ä»·æ ¼å¿…须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价é…ç½®å‡½æ•°è®¡ç®—ï¼›éœ€è¦æäº¤ `priceMudPoints` 的视频ã€è§’色动画ã€å›¾æ ‡ç´ æã€éŸ³æ•ˆå’ŒèƒŒæ™¯éŸ³ä¹ä¹Ÿä½¿ç”¨åŒä¸€å‡½æ•°ã€‚åŽç«¯ç”¨ `server-rs/crates/api-server/src/editor_generation_config.rs` çš„åŒå语义é…ç½®é‡æ–°è®¡ç®—并校验 / æ‰£è´¹ã€‚å½“å‰æ­£å¼æ¨¡åž‹å‡å·²è¦†ç›–å®šä»·ï¼›ç”»æ¿ UI 统一显示 `nanobanana2`,它代表上游真实模型 `gemini-3.1-flash-image-preview`,历å²è¾“入或旧布局中的 `nano-banana` 必须归一到该真实模型 ID åŽå†æäº¤å’Œè®¡è´¹ã€‚ - å½±å“范围:图片画布生æˆç±»é¢æ¿ã€ç”Ÿæˆæäº¤æ¨¡åž‹ã€ç¼–辑器图片 / 视频 / 音频 BFFã€`editor_generation_config` å’Œ Lovart 生æˆç±»é¢æ¿æ–‡æ¡£ã€‚ - éªŒè¯æ–¹å¼ï¼šè¿è¡Œ `npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`,并执行 `npm run typecheck`ã€`npm run check:encoding`ã€`git diff --check`。 - å…³è”æ–‡æ¡£ï¼š`docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md`。 diff --git a/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md b/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md index d2000cfe7..a0ab7c533 100644 --- a/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md +++ b/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md @@ -77,7 +77,7 @@ - 生æˆå›¾æ ‡ç´ æé¢æ¿æäº¤ `POST /api/editor/icon-spritesheets/generations` æ—¶å¿…é¡»æºå¸¦ `priceMudPoints`,å–åŒä¸€ä»½ `icon` 计费é…置;åŽç«¯ç”¨ `editor_generation_config` 校验,ä¸å…许绕过é…置继续生æˆã€‚ - 生æˆè§†é¢‘ã€è§’色动画ã€éŸ³æ•ˆå’ŒèƒŒæ™¯éŸ³ä¹æäº¤æ—¶æºå¸¦çš„ `priceMudPoints` 也必须由åŒä¸€æ¨¡åž‹é…置函数计算,åŽç«¯æŒ‰å½’一åŽçš„æ¨¡åž‹ã€æ¸…æ™°åº¦ã€æ—¶é•¿æˆ–éŸ³é¢‘æ¨¡åž‹é‡æ–°è®¡ç®—并校验。 - `æå–ç´ æ` 是 UI 设计图工具æ åŠ¨ä½œï¼Œä¸æ˜¯é¢æ¿ç”ŸæˆæŒ‰é’®ï¼›å®ƒå›ºå®šä½¿ç”¨ `gpt-image-2` å’Œå›¾æ ‡ç´ ææ‹†åˆ†é“¾è·¯ï¼ŒåŽç«¯å“应里的 `priceMudPoints` ä»å¿…é¡»æ¥è‡ª `editor_generation_config`,ä¸èƒ½å†™æ­»ã€‚ -- åŽ†å² / 测试中出现的 `nanobanana2`ã€`nano-banana` åªæ˜¯ UI æ˜¾ç¤ºåæˆ–旧别åï¼Œä¸æ˜¯æ­£å¼ä¸Šæ¸¸æ¨¡åž‹ ID;正å¼å›¾ç‰‡æ¨¡åž‹ ID 以 `gemini-3.1-flash-image-preview` å’Œ `gpt-image-2` 为准。历å²åˆ«å若继续进入计算函数,åªèƒ½å›žè½åˆ°é»˜è®¤å›¾ç‰‡æ¨¡åž‹ä»·æ ¼ï¼Œä¸èƒ½ä½œä¸ºæ–°å¢žæ­£å¼æ¨¡åž‹ç»•过定价表。 +- ç”»æ¿ UI 统一显示 `nanobanana2`,它代表上游真实模型 `gemini-3.1-flash-image-preview`;历å²è¾“入或旧布局中的 `nano-banana` 必须先归一为 `nanobanana2` 对应的真实模型 ID åŽå†æäº¤å’Œè®¡è´¹ã€‚定价表ä»ä»¥çœŸå®žæ¨¡åž‹ ID `gemini-3.1-flash-image-preview` å’Œ `gpt-image-2` 为准,别åä¸èƒ½ä½œä¸ºæ–°å¢žæ­£å¼æ¨¡åž‹ç»•过定价表。 ## 画布å ä½è½ç‚¹ @@ -128,7 +128,7 @@ 当å‰å¿…须显å¼è¦†ç›–çš„æ­£å¼æ¨¡åž‹å®šä»·é…置: -- 图片类:`gemini-3.1-flash-image-preview`ã€`gpt-image-2`。 +- 图片类:`gemini-3.1-flash-image-preview`(UI 显示与历å²åˆ«å统一为 `nanobanana2`)ã€`gpt-image-2`。 - 规范:`gpt-image-2`。 - 视频:`seedance2.0-fast`ã€`seedance2.0`ã€`kling3.0`ã€`kling3.0-omni`;兼容旧布局回放的 `veo3.1`ã€`veo3.1-fast` 也è¦ä¿ç•™å®šä»·é…置,但å‰ç«¯æ¨¡åž‹èœå•ä¸å±•示。 - 角色动画:`seedance2.0-fast`。 diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index e84fc5382..0e450c82e 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -4,6 +4,8 @@ /// å‰ç«¯å¸¸é‡å’Œå…·ä½“ handler 内;åŽç»­è‹¥æŽ¥åŽå°é…置,å¯åªæ›¿æ¢æœ¬æ¨¡å—è¯»å–æ¥æºã€‚ const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; +const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; +const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; const EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS: u32 = 12; const EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 5; @@ -27,7 +29,10 @@ const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS: u32 = 5; pub(crate) fn editor_image_model_generation_mud_points(model: Option<&str>) -> u32 { match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) | Some(EDITOR_IMAGE_MODEL_NANOBANANA2) => { + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) + | Some(EDITOR_IMAGE_MODEL_NANOBANANA2) + | Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS) + | Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => { EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS } _ => EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS, @@ -84,8 +89,7 @@ pub(crate) fn editor_video_model_generation_mud_points( } pub(crate) fn editor_video_generation_mud_points(resolution: &str, duration_seconds: u32) -> u32 { - let per_second = - editor_video_model_resolution_mud_points_per_second(None, resolution); + let per_second = editor_video_model_resolution_mud_points_per_second(None, resolution); per_second * duration_seconds } @@ -124,9 +128,7 @@ pub(crate) fn editor_sound_effect_model_generation_mud_points(model: Option<&str pub(crate) fn editor_background_music_model_generation_mud_points(model: Option<&str>) -> u32 { match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO) => { - EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS - } + Some(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO) => EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS, _ => EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS, } } @@ -207,6 +209,14 @@ mod tests { ), 12 ); + assert_eq!( + editor_image_generation_mud_points(Some("publication-material"), Some("nanobanana2")), + 12 + ); + assert_eq!( + editor_image_generation_mud_points(Some("publication-material"), Some("nano-banana")), + 12 + ); assert_eq!( editor_image_generation_mud_points(Some("publication-material"), Some("gpt-image-2")), 12 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 983fa044e..def2bea84 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -62,6 +62,8 @@ const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命å画布"; const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024"; const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; +const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; +const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str = "æå–ç”»é¢ä¸­çš„æ‰€æœ‰ç‹¬ç«‹å¹¶æ•´ç†æˆspritesheet"; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; @@ -1001,7 +1003,11 @@ fn normalize_editor_generation_options( ) -> EditorGenerationOptions { let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL, - Some(EDITOR_IMAGE_MODEL_NANOBANANA2) => EDITOR_IMAGE_MODEL_NANOBANANA2, + Some( + EDITOR_IMAGE_MODEL_NANOBANANA2 + | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS + | EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS, + ) => EDITOR_IMAGE_MODEL_NANOBANANA2, // 中文注释:未显å¼ä¼ æ¨¡åž‹çš„æ—§æ™®é€šç”Ÿæˆã€å¿«é€Ÿç¼–辑和生æˆè§„范继续走 gpt-image-2ï¼› // 角色 / 图标素æå…¥å£ç”±å‰ç«¯æ˜¾å¼ä¼ å…¥ nanobanana2 默认值。 None => GPT_IMAGE_2_MODEL, @@ -1885,6 +1891,21 @@ mod tests { assert_eq!(nanobanana.image_size, "0.5K"); assert_eq!(nanobanana.provider_image_size, "512"); + let nanobanana_alias = + normalize_editor_generation_options(Some("nanobanana2"), Some("1:1"), Some("0.5K")); + assert_eq!(nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2); + assert_eq!(nanobanana_alias.size, "512"); + assert_eq!(nanobanana_alias.provider_image_size, "512"); + + let legacy_nanobanana_alias = + normalize_editor_generation_options(Some("nano-banana"), Some("16:9"), Some("2K")); + assert_eq!( + legacy_nanobanana_alias.model, + EDITOR_IMAGE_MODEL_NANOBANANA2 + ); + assert_eq!(legacy_nanobanana_alias.size, "2048"); + assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9"); + let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); assert_eq!(gpt.model, GPT_IMAGE_2_MODEL); assert_eq!(gpt.size, "1024x1536"); @@ -2042,11 +2063,8 @@ mod tests { #[test] fn editor_icon_spritesheet_request_price_uses_generation_config() { assert_eq!( - validate_editor_icon_spritesheet_price( - 12, - Some("gemini-3.1-flash-image-preview") - ) - .unwrap(), + validate_editor_icon_spritesheet_price(12, Some("gemini-3.1-flash-image-preview")) + .unwrap(), 12 ); assert_eq!( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index d89dd3c86..351fc7bf8 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -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 }; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index a1fcfb8c8..988704d90 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -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 { 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 { 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 { 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, diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx index 93e1ccc70..2b54b239c 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx @@ -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[]; diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 4d35535af..53591a484 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -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' }, ]); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index cebc0d7a7..b544a3e06 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -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 diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 780d49c70..70b2697ed 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -15,6 +15,7 @@ import { calculateEditorVideoPrice, CHARACTER_ANIMATION_MODEL, DEFAULT_SOUND_EFFECT_MODEL, + IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; function createLayer(overrides: Partial = {}): 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(); diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 58a6cd331..755dbf9e4 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -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, diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 09f8d89ce..1eef92801 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -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] }); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 9279b1f61..e5755f9a3 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -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,