允许UI素材提取选择模型
为UI素材提取面板接入图片模型选择器,默认使用nanobanana模型。 提取提交链路传递选中模型并按模型计算泥点价格。 后端UI素材提取请求支持模型字段,队列、执行、持久化和响应统一使用选中模型。 同步外部OpenAPI模型字段并补充前端工作流、客户端请求和后端默认模型测试。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user