允许UI素材提取选择模型

为UI素材提取面板接入图片模型选择器,默认使用nanobanana模型。

提取提交链路传递选中模型并按模型计算泥点价格。

后端UI素材提取请求支持模型字段,队列、执行、持久化和响应统一使用选中模型。

同步外部OpenAPI模型字段并补充前端工作流、客户端请求和后端默认模型测试。
This commit is contained in:
2026-06-27 17:46:39 +08:00
parent 2fdceea3f8
commit 8eecd038f5
16 changed files with 309 additions and 71 deletions
@@ -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",
@@ -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<String>,
pub(crate) aspect_ratio: String,
pub(crate) image_size: String,
pub(crate) project_id: Option<String>,
@@ -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<String>) ->
}
fn normalize_editor_ui_design_asset_extraction_options(
model: Option<&str>,
aspect_ratio: &str,
image_size: &str,
) -> Result<EditorGenerationOptions, AppError> {
@@ -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<u32, AppError> {
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"));
}
@@ -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")
@@ -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');
});
@@ -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,
@@ -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}
/>
@@ -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',
@@ -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(
<ImageCanvasUiAssetExtractionOverlayView
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
viewport={{ x: 10, y: 20, scale: 1 }}
state={createState({
marks: [
{
id: 'mark-1',
tool: 'rect',
x: 10,
y: 20,
width: 80,
height: 60,
},
],
})}
onToolChange={vi.fn()}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
onModelChange={onModelChange}
onSubmit={vi.fn()}
/>,
);
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('');
});
@@ -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,
),
)}
</svg>
{!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),
)
: (
<span className="image-canvas-editor__ui-extraction-preview-empty">
</span>
)}
{state.marks.length ? (
state.marks.map((mark, index) =>
renderMarkPreview(mark, index, sourceLayer),
)
) : (
<span className="image-canvas-editor__ui-extraction-preview-empty">
</span>
)}
</div>
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__ui-extraction-panel-footer">
<span
@@ -396,12 +412,65 @@ export function ImageCanvasUiAssetExtractionOverlayView({
>
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`}
</span>
<span
className="image-canvas-editor__readonly-generation-option image-canvas-editor__ui-extraction-model"
aria-label="固定模型"
>
{IMAGE_MODEL_GPT_IMAGE_2}
</span>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__ui-extraction-model">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`提取素材模型 ${selectedModelLabel}`}
aria-expanded={isModelMenuOpen}
disabled={state.status === 'extracting'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsModelMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{selectedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
{isModelMenuOpen ? (
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model"
label="提取素材模型选项"
placement="top-start"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
const selected = state.model === option.value;
return (
<button
key={option.value}
type="button"
className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--model"
aria-pressed={selected}
onClick={() => {
onModelChange(option.value);
setIsModelMenuOpen(false);
}}
>
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{option.label}</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</button>
);
})}
</div>
</PlatformFloatingMenu>
) : null}
</div>
<PlatformActionButton
type="button"
aria-label="提取"
@@ -630,6 +630,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
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({
@@ -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,
@@ -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({
</span>
<span data-testid="ui-extraction">
{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}`
: '-'}
</span>
<span data-testid="ui-extraction-source">
@@ -417,7 +418,10 @@ function GenerationWorkflowHarness({
>
</button>
<button type="button" onClick={() => workflow.openQuickEditPanel(layers[0]!)}>
<button
type="button"
onClick={() => workflow.openQuickEditPanel(layers[0]!)}
>
</button>
<button
@@ -755,6 +759,14 @@ function GenerationWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
workflow.changeUiAssetExtractionModel(IMAGE_MODEL_GPT_IMAGE_2)
}
>
GPT提取模型
</button>
<button
type="button"
onClick={() => {
@@ -1612,7 +1624,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
expect(screen.getByTestId('ui-extraction').textContent).toBe(
'layer-source:rect:0:idle',
'layer-source:rect:0:idle:gemini-3.1-flash-image-preview',
);
expect(screen.getByTestId('ui-extraction-source').textContent).toBe(
'layer-source',
@@ -1621,10 +1633,11 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('tool').textContent).toBe('select');
fireEvent.click(screen.getByRole('button', { name: '选择椭圆框选' }));
fireEvent.click(screen.getByRole('button', { name: '选择GPT提取模型' }));
fireEvent.click(screen.getByRole('button', { name: '框选UI素材' }));
expect(screen.getByTestId('ui-extraction').textContent).toBe(
'layer-source:ellipse:1:idle',
'layer-source:ellipse:1:idle:gpt-image-2',
);
});
@@ -1018,6 +1018,7 @@ export function useImageCanvasGenerationWorkflow({
setQuickEditPanel(null);
const nextSelectionState = createUiAssetExtractionState(sourceLayer.id, {
initialTool: null,
model: DEFAULT_IMAGE_MODEL,
});
quickEditSelectionStateRef.current = nextSelectionState;
setQuickEditSelectionState(nextSelectionState);
@@ -1607,7 +1608,10 @@ export function useImageCanvasGenerationWorkflow({
selectSingleLayer(sourceLayer.id);
setActiveTool('select');
setUiAssetExtractionState(
createUiAssetExtractionState(sourceLayer.id, { initialTool: 'rect' }),
createUiAssetExtractionState(sourceLayer.id, {
initialTool: 'rect',
model: DEFAULT_IMAGE_MODEL,
}),
);
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
},
@@ -1644,6 +1648,28 @@ export function useImageCanvasGenerationWorkflow({
[],
);
const changeUiAssetExtractionModel = useCallback(
(model: string) => {
const normalizedModel = normalizeEditorImageModel(model);
rememberImageModel(normalizedModel);
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
model: normalizedModel,
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[rememberImageModel],
);
const changeQuickEditSelectionTool = useCallback(
(tool: UiAssetExtractionTool | null) => {
updateQuickEditSelectionState((currentState) =>
@@ -1834,6 +1860,7 @@ export function useImageCanvasGenerationWorkflow({
try {
await submitUiDesignAssetExtraction(sourceLayer, {
marks: currentState.marks,
model: currentState.model,
suppressAlert: true,
});
setUiAssetExtractionState(null);
@@ -1961,6 +1988,7 @@ export function useImageCanvasGenerationWorkflow({
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
@@ -2059,6 +2087,7 @@ export function useImageCanvasGenerationWorkflow({
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
+5
View File
@@ -8061,6 +8061,11 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__ui-extraction-model {
grid-column: 2;
justify-self: end;
width: min(15.5rem, 42vw);
}
.image-canvas-editor__ui-extraction-model .image-canvas-editor__option-cluster {
width: 100%;
}
.image-canvas-editor__ui-extraction-hit-area {
@@ -898,6 +898,7 @@ describe('editorProjectClient', () => {
const result = await extractEditorUiDesignAssets({
sourceImageSrc: 'data:image/png;base64,ui-design',
model: 'gpt-image-2',
projectId: 'editor-project-1',
assetFolderId: 'project',
spritesheetLabel: '战斗UI设计图 素材图集',
@@ -917,6 +918,7 @@ describe('editorProjectClient', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceImageSrc: 'data:image/png;base64,ui-design',
model: 'gpt-image-2',
projectId: 'editor-project-1',
generationInputs: {
fields: [{ title: '来源', value: '战斗UI设计图' }],
@@ -168,6 +168,7 @@ export type EditorIconSpritesheetGenerationInput = {
export type EditorUiDesignAssetExtractionInput = {
sourceImageSrc: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
projectId?: string | null;
@@ -790,6 +791,7 @@ export async function extractEditorUiDesignAssets(
EDITOR_UI_DESIGN_ASSET_EXTRACTION_API,
jsonRequest('POST', {
sourceImageSrc: input.sourceImageSrc,
...(input.model ? { model: input.model } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }