From 8eecd038f532d57472e5f963910cec1a6386413d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 27 Jun 2026 17:46:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=81=E8=AE=B8UI=E7=B4=A0=E6=9D=90=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E9=80=89=E6=8B=A9=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为UI素材提取面板接入图片模型选择器,默认使用nanobanana模型。 提取提交链路传递选中模型并按模型计算泥点价格。 后端UI素材提取请求支持模型字段,队列、执行、持久化和响应统一使用选中模型。 同步外部OpenAPI模型字段并补充前端工作流、客户端请求和后端默认模型测试。 --- .../genarrative-external-v1.openapi.json | 5 + .../crates/api-server/src/editor_project.rs | 104 ++++++++++++---- .../api-server/src/external_editor_api.rs | 4 + .../ImageCanvasEditorShellView.test.tsx | 6 +- .../image-editor/ImageCanvasEditorView.tsx | 2 + .../image-editor/ImageCanvasStageView.tsx | 3 + .../ImageCanvasUiAssetExtractionModel.ts | 4 +- ...anvasUiAssetExtractionOverlayView.test.tsx | 68 +++++++++-- ...mageCanvasUiAssetExtractionOverlayView.tsx | 115 ++++++++++++++---- ...anvasGenerationSubmissionWorkflow.test.tsx | 3 + ...ImageCanvasGenerationSubmissionWorkflow.ts | 5 + .../useImageCanvasGenerationWorkflow.test.tsx | 21 +++- .../useImageCanvasGenerationWorkflow.ts | 31 ++++- src/index.css | 5 + .../image-editor/editorProjectClient.test.ts | 2 + .../image-editor/editorProjectClient.ts | 2 + 16 files changed, 309 insertions(+), 71 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 9225908cf..fab2f3f46 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2842,6 +2842,11 @@ "type": "string", "description": "UI 设计图 Data URL。" }, + "model": { + "type": "string", + "default": "gemini-3.1-flash-image-preview", + "description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时默认使用 nanobanana。" + }, "aspectRatio": { "type": "string", "const": "1:1", diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 99e1ec0fc..a621208fc 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -80,8 +80,7 @@ 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,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。"; +const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str = "仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。"; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; @@ -241,6 +240,7 @@ pub struct EditorIconSpritesheetGenerationRequest { #[serde(rename_all = "camelCase")] pub struct EditorUiDesignAssetExtractionRequest { pub(crate) source_image_src: String, + pub(crate) model: Option, pub(crate) aspect_ratio: String, pub(crate) image_size: String, pub(crate) project_id: Option, @@ -1969,11 +1969,13 @@ pub async fn extract_editor_ui_design_assets( let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let generation_options = normalize_editor_ui_design_asset_extraction_options( + payload.model.as_deref(), payload.aspect_ratio.as_str(), payload.image_size.as_str(), )?; let price_mud_points = u64::from(resolve_editor_ui_design_asset_extraction_price( &state, + Some(generation_options.model), Some(generation_options.image_size), )?); let source_entity_id = editor_generation_source_entity_id( @@ -2016,11 +2018,13 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( })) })?; let generation_options = normalize_editor_ui_design_asset_extraction_options( + payload.model.as_deref(), payload.aspect_ratio.as_str(), payload.image_size.as_str(), )?; let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price( state, + Some(generation_options.model), Some(generation_options.image_size), )?; let settings = require_openai_image_settings(state)?.with_external_api_audit_context( @@ -2038,17 +2042,35 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "editor_ui_design_asset_extraction", request_context.request_id(), u64::from(expected_price_mud_points), - create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - GPT_IMAGE_2_MODEL, - EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT, - None, - generation_options.size.as_str(), - 1, - &[reference_image], - "图片画布提取UI设计图素材 spritesheet", - ), + async { + if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + create_openai_nanobanana_generate_content( + &http_client, + &settings, + generation_options.model, + EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT, + None, + generation_options.aspect_ratio, + generation_options.provider_image_size, + &[reference_image], + "图片画布提取UI设计图素材 spritesheet", + ) + .await + } else { + create_openai_image_edit_with_references_and_model( + &http_client, + &settings, + generation_options.model, + EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT, + None, + generation_options.size.as_str(), + 1, + &[reference_image], + "图片画布提取UI设计图素材 spritesheet", + ) + .await + } + }, ) .await?; let image = generated.images.into_iter().next().ok_or_else(|| { @@ -2105,7 +2127,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( height: spritesheet_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), - model: GPT_IMAGE_2_MODEL.to_string(), + model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), source_resource_id: None, @@ -2154,7 +2176,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( height: icon_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), - model: GPT_IMAGE_2_MODEL.to_string(), + model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), source_resource_id: None, @@ -2201,7 +2223,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( icon_image_srcs, prompt, actual_prompt: generated.actual_prompt, - model: GPT_IMAGE_2_MODEL.to_string(), + model: generation_options.model.to_string(), provider: "VectorEngine", task_id: generated.task_id, price_mud_points: expected_price_mud_points, @@ -3472,6 +3494,7 @@ fn editor_ui_design_asset_extraction_bad_request(message: impl Into) -> } fn normalize_editor_ui_design_asset_extraction_options( + model: Option<&str>, aspect_ratio: &str, image_size: &str, ) -> Result { @@ -3489,8 +3512,12 @@ fn normalize_editor_ui_design_asset_extraction_options( )); } }; + let model = model + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(EDITOR_IMAGE_MODEL_NANOBANANA2); Ok(normalize_editor_generation_options( - Some(GPT_IMAGE_2_MODEL), + Some(model), Some("1:1"), Some(normalized_image_size), )) @@ -3498,6 +3525,7 @@ fn normalize_editor_ui_design_asset_extraction_options( fn resolve_editor_ui_design_asset_extraction_price( state: &AppState, + model: Option<&str>, image_size: Option<&str>, ) -> Result { let expected_price_mud_points = state @@ -3508,7 +3536,7 @@ fn resolve_editor_ui_design_asset_extraction_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("icon"), Some(GPT_IMAGE_2_MODEL), image_size); + .image_generation_mud_points(Some("icon"), model, image_size); Ok(expected_price_mud_points) } @@ -4915,15 +4943,17 @@ mod tests { } #[test] - fn editor_ui_design_asset_extraction_price_uses_selected_gpt_image_size() { + fn editor_ui_design_asset_extraction_price_uses_selected_model_and_size() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({ "sourceImageSrc": "data:image/png;base64,AAAA", + "model": "gpt-image-2", "aspectRatio": "1:1", "imageSize": "2K" })) .expect("UI extraction request should deserialize dimensions"); let generation_options = normalize_editor_ui_design_asset_extraction_options( + request.model.as_deref(), request.aspect_ratio.as_str(), request.image_size.as_str(), ) @@ -4934,20 +4964,40 @@ mod tests { assert_eq!( resolve_editor_ui_design_asset_extraction_price( &state, + Some(generation_options.model), Some(generation_options.image_size) ) .unwrap(), 5 ); - let ratio_error = match normalize_editor_ui_design_asset_extraction_options("16:9", "2K") { - Ok(_) => panic!("UI extraction ratio should stay square"), - Err(error) => error, - }; + let default_generation = + normalize_editor_ui_design_asset_extraction_options(None, "1:1", "1K") + .expect("default UI extraction options should pass"); + assert_eq!(default_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2); + let blank_model_generation = + normalize_editor_ui_design_asset_extraction_options(Some(" "), "1:1", "1K") + .expect("blank UI extraction model should use default"); + assert_eq!(blank_model_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2); + assert_eq!( + resolve_editor_ui_design_asset_extraction_price( + &state, + Some(default_generation.model), + Some(default_generation.image_size), + ) + .unwrap(), + 12 + ); + let ratio_error = + match normalize_editor_ui_design_asset_extraction_options(None, "16:9", "2K") { + Ok(_) => panic!("UI extraction ratio should stay square"), + Err(error) => error, + }; assert!(ratio_error.body_text().contains("aspectRatio")); - let size_error = match normalize_editor_ui_design_asset_extraction_options("1:1", "0.5K") { - Ok(_) => panic!("UI extraction size should be 1K or 2K"), - Err(error) => error, - }; + let size_error = + match normalize_editor_ui_design_asset_extraction_options(None, "1:1", "0.5K") { + Ok(_) => panic!("UI extraction size should be 1K or 2K"), + Err(error) => error, + }; assert!(size_error.body_text().contains("imageSize")); } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 84c8f97f2..829b22d23 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -883,6 +883,10 @@ mod tests { ui_extraction_schema["properties"]["imageSize"]["enum"], json!(["1K", "2K"]) ); + assert_eq!( + ui_extraction_schema["properties"]["model"]["default"], + "gemini-3.1-flash-image-preview" + ); assert!( ui_extraction_schema["properties"] .get("priceMudPoints") diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 642c22568..a387cca5e 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -180,6 +180,7 @@ function createStageProps(): ImageCanvasStageViewProps { onRemoveBackground: vi.fn(), onExtractUiDesignAssets: vi.fn(), onUiAssetExtractionToolChange: vi.fn(), + onUiAssetExtractionModelChange: vi.fn(), onUiAssetExtractionPointerStart: vi.fn(), onUiAssetExtractionPointerMove: vi.fn(), onUiAssetExtractionPointerEnd: vi.fn(), @@ -294,6 +295,7 @@ describe('ImageCanvasEditorShellView', () => { quickEditSelectionState: { sourceLayerId: layer.id, tool: 'rect' as const, + model: 'gemini-3.1-flash-image-preview', marks: [], draftMark: null, status: 'idle' as const, @@ -317,9 +319,7 @@ describe('ImageCanvasEditorShellView', () => { const toolbar = screen.getByRole('toolbar', { name: '快速编辑框选工具', }); - fireEvent.click( - within(toolbar).getByRole('button', { name: '椭圆框选' }), - ); + fireEvent.click(within(toolbar).getByRole('button', { name: '椭圆框选' })); expect(handleQuickEditToolChange).toHaveBeenCalledWith('ellipse'); }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 32e6fa65a..b868ecc17 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -880,6 +880,7 @@ export function ImageCanvasEditorView() { quickEditSelectionState, quickEditSelectionSourceLayer, changeUiAssetExtractionTool, + changeUiAssetExtractionModel, changeQuickEditSelectionTool, startUiAssetExtractionPointer, startQuickEditSelectionPointer, @@ -1497,6 +1498,7 @@ export function ImageCanvasEditorView() { onRemoveBackground: removeSelectedLayerBackground, onExtractUiDesignAssets: extractUiDesignAssets, onUiAssetExtractionToolChange: changeUiAssetExtractionTool, + onUiAssetExtractionModelChange: changeUiAssetExtractionModel, onUiAssetExtractionPointerStart: startUiAssetExtractionPointer, onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer, onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 1ee98f767..d0ba015b2 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -119,6 +119,7 @@ export type ImageCanvasStageViewProps = { onRemoveBackground: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void; + onUiAssetExtractionModelChange: (model: string) => void; onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerEnd: () => void; @@ -232,6 +233,7 @@ export function ImageCanvasStageView({ onRemoveBackground, onExtractUiDesignAssets, onUiAssetExtractionToolChange, + onUiAssetExtractionModelChange, onUiAssetExtractionPointerStart, onUiAssetExtractionPointerMove, onUiAssetExtractionPointerEnd, @@ -345,6 +347,7 @@ export function ImageCanvasStageView({ onPointerStart={onUiAssetExtractionPointerStart} onPointerMove={onUiAssetExtractionPointerMove} onPointerEnd={onUiAssetExtractionPointerEnd} + onModelChange={onUiAssetExtractionModelChange} onSubmit={onSubmitUiAssetExtraction} /> diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts b/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts index 0662ae4d4..82a33b342 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts @@ -27,6 +27,7 @@ export type UiAssetExtractionMark = export type UiAssetExtractionState = { sourceLayerId: string; tool: UiAssetExtractionTool | null; + model: string; marks: UiAssetExtractionMark[]; draftMark: UiAssetExtractionMark | null; status: 'idle' | 'extracting' | 'failed'; @@ -44,11 +45,12 @@ export type UiAssetExtractionImageSize = export function createUiAssetExtractionState( sourceLayerId: string, - options: { initialTool?: UiAssetExtractionTool | null } = {}, + options: { initialTool?: UiAssetExtractionTool | null; model: string }, ): UiAssetExtractionState { return { sourceLayerId, tool: options.initialTool === undefined ? 'rect' : options.initialTool, + model: options.model, marks: [], draftMark: null, status: 'idle', diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx index a1629d9fb..a5dd39d12 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx @@ -5,6 +5,10 @@ import type { ImgHTMLAttributes } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { + IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_NANOBANANA2, +} from './ImageCanvasGenerationModel'; import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel'; import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView'; @@ -60,6 +64,7 @@ function createState( return { sourceLayerId: 'layer-source', tool: 'rect', + model: IMAGE_MODEL_NANOBANANA2, marks: [], draftMark: null, status: 'idle', @@ -93,9 +98,7 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { ); expect(within(toolbar).queryByText('框选素材')).toBeNull(); expect(within(toolbar).queryByText('0')).toBeNull(); - expect( - within(toolbar).queryByRole('button', { name: '提取' }), - ).toBeNull(); + expect(within(toolbar).queryByRole('button', { name: '提取' })).toBeNull(); expect( within(toolbar).getByRole('button', { name: '矩形框选' }), ).toBeTruthy(); @@ -107,9 +110,7 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { .getByRole('button', { name: '矩形框选' }) .getAttribute('aria-pressed'), ).toBe('true'); - fireEvent.click( - within(toolbar).getByRole('button', { name: '矩形框选' }), - ); + fireEvent.click(within(toolbar).getByRole('button', { name: '矩形框选' })); fireEvent.click( within(toolbar).getByRole('button', { name: '画笔自由框选' }), ); @@ -118,7 +119,7 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { expect(handleToolChange).toHaveBeenNthCalledWith(2, 'brush'); }); - it('renders UI extraction tools on the right and a bottom composer with previews and gpt-image-2 pricing', () => { + it('renders UI extraction tools on the right and a bottom composer with previews and selected model pricing', () => { const marks = Array.from({ length: 7 }, (_, index) => ({ id: `mark-${index + 1}`, tool: 'rect' as const, @@ -153,11 +154,56 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { ).toBeTruthy(); expect(within(panel).getAllByLabelText(/框选区域预览/u)).toHaveLength(7); expect(within(panel).getByLabelText('计划规格').textContent).toBe('1:1·2K'); - expect(within(panel).getByLabelText('固定模型').textContent).toContain('gpt-image-2'); + expect( + within(panel).getByRole('button', { name: '提取素材模型 nanobanana2' }), + ).toBeTruthy(); expect( within(panel).queryByRole('button', { name: '取消框选' }), ).toBeNull(); - expect(within(panel).getByRole('button', { name: '提取' }).textContent).toContain('5泥点'); + expect( + within(panel).getByRole('button', { name: '提取' }).textContent, + ).toContain('24泥点'); + }); + + it('changes the UI extraction model from the model menu', () => { + const onModelChange = vi.fn(); + + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: '提取素材模型 nanobanana2' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole( + 'button', + { name: 'gpt-image-2' }, + ), + ); + + expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); }); it('crops mark previews from the same source coordinates as the red selection', () => { @@ -235,9 +281,7 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { expect(previewImage.getAttribute('data-object-key')).toBe( 'generated-editor-assets/project-1/source.png', ); - expect(previewImage.getAttribute('data-refresh-key')).toBe( - 'task-private', - ); + expect(previewImage.getAttribute('data-refresh-key')).toBe('task-private'); expect(previewImage.getAttribute('data-fallback-src')).toBe(''); }); diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx index 59e02ca15..1c5bb8ae7 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx @@ -1,14 +1,25 @@ -import { Circle, Paintbrush, Square } from 'lucide-react'; +import { + Check, + ChevronDown, + Circle, + Cpu, + Paintbrush, + Square, +} from 'lucide-react'; import type { ComponentType, SVGProps } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; +import { useState } from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu'; import { PlatformIconButton } from '../common/PlatformIconButton'; +import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; import { calculateEditorIconSpritesheetPrice, - IMAGE_MODEL_GPT_IMAGE_2, + EDITOR_IMAGE_MODEL_OPTIONS, + getEditorImageModelDisplayName, } from './ImageCanvasGenerationModel'; import type { UiAssetExtractionMark, @@ -30,6 +41,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = { onPointerStart: (point: { x: number; y: number }) => void; onPointerMove: (point: { x: number; y: number }) => void; onPointerEnd: () => void; + onModelChange?: (model: string) => void; onSubmit?: () => void; }; @@ -91,10 +103,7 @@ function resolveMarkPreviewBounds( }) => { const left = Math.min(Math.max(bounds.x, 0), originalWidth); const top = Math.min(Math.max(bounds.y, 0), originalHeight); - const right = Math.min( - Math.max(bounds.x + bounds.width, 0), - originalWidth, - ); + const right = Math.min(Math.max(bounds.x + bounds.width, 0), originalWidth); const bottom = Math.min( Math.max(bounds.y + bounds.height, 0), originalHeight, @@ -257,8 +266,10 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onPointerStart, onPointerMove, onPointerEnd, + onModelChange = () => {}, onSubmit, }: ImageCanvasUiAssetExtractionOverlayViewProps) { + const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); if (!sourceLayer || !state) { return null; } @@ -269,9 +280,10 @@ export function ImageCanvasUiAssetExtractionOverlayView({ state.marks.length, ); const extractionPrice = calculateEditorIconSpritesheetPrice( - IMAGE_MODEL_GPT_IMAGE_2, + state.model, extractionPlan.imageSize, ); + const selectedModelLabel = getEditorImageModelDisplayName(state.model); const screenFrame = { left: viewport.x + sourceLayer.x * viewport.scale, top: viewport.y + sourceLayer.y * viewport.scale, @@ -358,7 +370,11 @@ export function ImageCanvasUiAssetExtractionOverlayView({ className="image-canvas-editor__ui-extraction-hit-area" /> {marks.map((mark, index) => - renderMark(mark, index, showIndexLabels && index < state.marks.length), + renderMark( + mark, + index, + showIndexLabels && index < state.marks.length, + ), )} {!isQuickEdit ? ( @@ -379,15 +395,15 @@ export function ImageCanvasUiAssetExtractionOverlayView({ className="image-canvas-editor__ui-extraction-preview-list" aria-label="预览列表" > - {state.marks.length - ? state.marks.map((mark, index) => - renderMarkPreview(mark, index, sourceLayer), - ) - : ( - - 框选后显示预览 - - )} + {state.marks.length ? ( + state.marks.map((mark, index) => + renderMarkPreview(mark, index, sourceLayer), + ) + ) : ( + + 框选后显示预览 + + )}
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`} - - {IMAGE_MODEL_GPT_IMAGE_2} - +
+ } + onClick={() => setIsModelMenuOpen((open) => !open)} + > + + + {selectedModelLabel} + + + {isModelMenuOpen ? ( + event.stopPropagation()} + > +
+ {EDITOR_IMAGE_MODEL_OPTIONS.map((option) => { + const selected = state.model === option.value; + return ( + + ); + })} +
+
+ ) : null} +
{ initialQuickEditSelectionState={{ sourceLayerId: 'layer-source', tool: 'rect', + model: 'gemini-3.1-flash-image-preview', marks: [ { id: 'quick-edit-selection-mark-1', @@ -1695,6 +1696,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'data:image/png;base64,ui-design.png', + model: 'gemini-3.1-flash-image-preview', }), ); }); @@ -1805,6 +1807,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'data:image/png;base64,marked-ui-design', + model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '2K', generationInputs: expect.objectContaining({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index c9d5e97ba..c898de934 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -56,6 +56,7 @@ import { buildUiDesignAssetExtractionGenerationInputs, buildVideoGenerationInputs, CHARACTER_ANIMATION_MODEL, + DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, ICON_FRAME_DISPLAY_SIZE, ICON_FRAME_ORIGINAL_SIZE, @@ -88,6 +89,7 @@ type CanvasGenerationDialogUpdater = ( type UiDesignAssetExtractionOptions = { marks?: UiAssetExtractionMark[]; + model?: string; suppressAlert?: boolean; }; @@ -730,6 +732,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const generated = await runEditorGenerationWithWalletRefresh( extractEditorUiDesignAssets({ sourceImageSrc, + model: normalizeEditorImageModel( + options.model ?? DEFAULT_IMAGE_MODEL, + ), projectId, generationInputs, assetFolderId, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 37c2dabfc..39e484cd3 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -14,6 +14,7 @@ import type { } from './ImageCanvasEditorTypes'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; +import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); @@ -226,7 +227,7 @@ function GenerationWorkflowHarness({ {workflow.uiAssetExtractionState - ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}` + ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}:${workflow.uiAssetExtractionState.model}` : '-'} @@ -417,7 +418,10 @@ function GenerationWorkflowHarness({ > 写入普通图来源生成器 - +