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:
@@ -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`。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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