From f1639de5fedfc58706bbdb31bf9da713095cd9a0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 20:29:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87=E5=9B=BE?= =?UTF-8?q?=E9=9B=86=E8=87=AA=E5=8A=A8=E6=8B=86=E5=88=86=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图集带背景原图和透明整图先写入项目资源与账号素材库并回填画布 自动拆分识别或持久化失败时保留图集并返回结构化警告 手动拆分限制图片尺寸、总像素和单次切片数量 同步共享契约、外部 OpenAPI、前端类型、设计文档和回归测试 --- .../genarrative-external-v1.openapi.json | 29 ++ .../shared-memory/decision-log.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 7 +- .../crates/api-server/src/editor_project.rs | 264 +++++++++++++++--- .../api-server/src/external_editor_api.rs | 5 + .../crates/shared-contracts/src/assets.rs | 20 ++ ...anvasGenerationSubmissionWorkflow.test.tsx | 4 + .../image-editor/editorProjectClient.test.ts | 8 + .../image-editor/editorProjectClient.ts | 6 + 9 files changed, 303 insertions(+), 42 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index ff4d349de..97cb3098c 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3064,6 +3064,24 @@ } } }, + "EditorIconSpritesheetSliceWarning": { + "type": "object", + "required": [ + "code", + "reason" + ], + "properties": { + "code": { + "type": "string", + "description": "自动拆分未完成的稳定原因码。" + }, + "reason": { + "type": "string", + "description": "自动拆分未完成的可诊断原因。" + } + }, + "additionalProperties": false + }, "EditorIconSpritesheetGenerationResponse": { "type": "object", "required": [ @@ -3095,6 +3113,17 @@ "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, + "sliceWarning": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorIconSpritesheetSliceWarning" + }, + { + "type": "null" + } + ], + "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。" + }, "prompt": { "type": "string" }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9a7ca4631..8f94d71ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -326,7 +326,7 @@ ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 -- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在保留透明 spritesheet 的同时自动按 alpha 连通域拆分独立图标;`icon-spritesheet` 图层工具栏同时提供不计费的手动拆分,切片写入 OSS、项目资源和账号素材库,并用 `sourceResourceId` 指向原图集。本条新决策取代“图标素材生成只保留图集”的旧口径。 +- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成先把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。本条新决策取代“图标素材生成只保留图集”的旧口径。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。 - 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index d4fc7e27f..388b1147d 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -60,9 +60,10 @@ ## 去背与保存 - 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 -- 去背后的整张 spritesheet 统一编码为透明 PNG 持久化,再按 alpha 连通域和素材描述顺序拆分。每个切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向原图集资源。 -- 响应通过 `iconImageSrcs` 返回切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 -- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。 +- 带背景原图和去背后的透明 spritesheet 都先同时写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。 +- 自动拆分是生成后的 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;用户可在图集工具栏手动重试。 +- 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 +- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。 ## 前端铺放规则 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a45674c28..b1ee0c436 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -103,6 +103,9 @@ 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_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096; +const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048; +const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; @@ -114,6 +117,8 @@ const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit"; const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; @@ -564,6 +569,13 @@ pub struct EditorIconSpritesheetIconResponse { asset: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceWarningResponse { + code: &'static str, + reason: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerationResponse { @@ -571,6 +583,8 @@ pub struct EditorIconSpritesheetGenerationResponse { spritesheet_width: u32, spritesheet_height: u32, icon_image_srcs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + slice_warning: Option, prompt: String, actual_prompt: Option, model: String, @@ -1629,6 +1643,7 @@ pub(crate) async fn generate_editor_image_for_owner( PersistEditorGreenScreenSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), + folder_id: None, label: format!("{}(原图)", role_setting), width: source_width, height: source_height, @@ -3481,12 +3496,17 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &matting_audit, ) .await?; + let asset_folder_id = payload + .asset_folder_id + .clone() + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); let source_record = persist_editor_green_screen_source_resource( state, source_persisted, PersistEditorGreenScreenSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), + folder_id: asset_folder_id.clone(), label: "图标素材图集(原图)".to_string(), width: source_width, height: source_height, @@ -3502,19 +3522,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); - let source = DownloadedImage { - bytes: image.bytes.clone(), - mime_type: image.mime_type.clone(), - extension: image.extension.clone(), - }; - let icon_slices = - slice_generated_icon_spritesheet_by_connected_components(&source, &icon_descriptions) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "editor-icon-spritesheet", - "message": error.to_string(), - })) - })?; let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, @@ -3537,7 +3544,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), - folder_id: payload.asset_folder_id.clone(), + folder_id: asset_folder_id.clone(), label: "图标素材图集".to_string(), image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_persisted.object_key.clone()), @@ -3557,29 +3564,79 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( }, ) .await?; - let icon_image_srcs = persist_editor_spritesheet_slices( - state, - icon_slices, - PersistEditorSpritesheetSlicesInput { - owner_user_id: owner_user_id.clone(), - project_id: payload.project_id.clone(), - asset_folder_id: payload.asset_folder_id.clone(), - source_resource_id: spritesheet_record - .resource - .as_ref() - .map(|resource| resource.resource_id.clone()), - task_id: generated.task_id.clone(), - prompt: prompt.clone(), - actual_prompt: generated.actual_prompt.clone(), - model: generation_options.model.to_string(), - provider: "VectorEngine".to_string(), - generation_inputs: generation_inputs.clone(), - path_kind: "icon-spritesheet-assets", - asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, - persistence_provider: "vector-engine", - }, - ) - .await?; + let slice_source = DownloadedImage { + bytes: image.bytes.clone(), + mime_type: image.mime_type.clone(), + extension: image.extension.clone(), + }; + let (icon_image_srcs, slice_warning) = + match slice_generated_icon_spritesheet_by_connected_components( + &slice_source, + &icon_descriptions, + ) { + Ok(icon_slices) => { + match persist_editor_spritesheet_slices( + state, + icon_slices, + PersistEditorSpritesheetSlicesInput { + owner_user_id: owner_user_id.clone(), + project_id: payload.project_id.clone(), + asset_folder_id: asset_folder_id.clone(), + source_resource_id: spritesheet_record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), + task_id: generated.task_id.clone(), + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + provider: "VectorEngine".to_string(), + generation_inputs: generation_inputs.clone(), + path_kind: "icon-spritesheet-assets", + asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + persistence_provider: "vector-engine", + }, + ) + .await + { + Ok(icon_image_srcs) => (icon_image_srcs, None), + Err(error) => { + let reason = error.body_text(); + tracing::warn!( + provider = "editor-icon-spritesheet", + operation = "persist_automatic_slices", + task_id = %generated.task_id, + reason = %reason, + "图标图集已持久化,但自动拆分素材持久化失败" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, + reason, + }), + ) + } + } + } + Err(error) => { + let reason = error.to_string(); + tracing::warn!( + provider = "editor-icon-spritesheet", + operation = "detect_automatic_slices", + task_id = %generated.task_id, + reason = %reason, + "图标图集已持久化,但自动拆分未识别到全部素材" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, + reason, + }), + ) + } + }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( @@ -3608,6 +3665,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, + slice_warning, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), @@ -3674,6 +3732,7 @@ pub async fn split_editor_icon_spritesheet( mime_type: reference.mime_type, extension: "png".to_string(), }; + validate_editor_icon_spritesheet_manual_source(&source)?; let slices = slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ @@ -3681,6 +3740,7 @@ pub async fn split_editor_icon_spritesheet( "message": error.to_string(), })) })?; + validate_editor_icon_spritesheet_manual_slice_count(slices.len())?; let task_id = build_prefixed_uuid_id("editor-atlas-split-"); let prompt = source_resource .prompt @@ -3750,6 +3810,65 @@ pub async fn split_editor_icon_spritesheet( )) } +fn validate_editor_icon_spritesheet_manual_source( + source: &DownloadedImage, +) -> Result<(), AppError> { + let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) + .with_guessed_format() + .map_err(|error| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("无法识别图集图片格式:{error}"), + })) + })?; + let (width, height) = reader.into_dimensions().map_err(|error| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("无法读取图集图片尺寸:{error}"), + })) + })?; + validate_editor_icon_spritesheet_manual_dimensions(width, height) +} + +fn validate_editor_icon_spritesheet_manual_dimensions( + width: u32, + height: u32, +) -> Result<(), AppError> { + let pixel_count = u64::from(width).saturating_mul(u64::from(height)); + if width == 0 + || height == 0 + || width > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION + || height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION + || pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS + { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集尺寸超过手动拆分限制。", + "width": width, + "height": height, + "maxDimension": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION, + "maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS, + })), + ); + } + Ok(()) +} + +fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> { + if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集识别出的素材数量超过手动拆分限制。", + "sliceCount": slice_count, + "maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES, + })), + ); + } + Ok(()) +} + pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, @@ -3976,6 +4095,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( PersistEditorGreenScreenSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), + folder_id: None, label: format!("{}(原图)", source_label), width: source_width, height: source_height, @@ -4135,6 +4255,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, + slice_warning: None, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), @@ -5907,6 +6028,7 @@ pub(crate) struct PersistedEditorGeneratedImage { struct PersistEditorGreenScreenSourceResourceInput { project_id: Option, owner_user_id: String, + folder_id: Option, label: String, width: u32, height: u32, @@ -6057,7 +6179,7 @@ async fn persist_editor_green_screen_source_resource( PersistEditorGeneratedAssetInput { project_id: input.project_id, owner_user_id: input.owner_user_id, - folder_id: None, + folder_id: input.folder_id, label: input.label, image_src: editor_media_src_from_object_key(persisted.object_key.as_str()), object_key: Some(persisted.object_key), @@ -8041,6 +8163,59 @@ mod tests { assert!(items[2]["x"].as_f64().unwrap() > source_right); } + #[test] + fn editor_canvas_spritesheet_items_keep_sheet_and_source_when_slices_are_empty() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-icon-sheet".to_string()), + title: "图标素材图集".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 100.0, + y: 80.0, + width: 256.0, + height: 256.0, + original_width: 512.0, + original_height: 512.0, + }, + }; + let processed = editor_project_resource_for_canvas_test( + "resource-icon-sheet", + "icon-spritesheet", + 512, + 512, + ); + let source = editor_project_resource_for_canvas_test( + "resource-icon-source", + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + 512, + 512, + ); + + let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( + &completion, + Some(&processed), + Some(&source), + &[], + ); + + assert_eq!(items.len(), 2); + assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet")); + assert_eq!(items[1]["resourceId"], json!("resource-icon-source")); + assert_eq!( + primary_layer_id.as_deref(), + Some("layer-editor-resource-resource-icon-sheet") + ); + } + + #[test] + fn manual_icon_spritesheet_limits_reject_large_images_and_excessive_slices() { + assert!(validate_editor_icon_spritesheet_manual_dimensions(2048, 2048).is_ok()); + assert!(validate_editor_icon_spritesheet_manual_dimensions(4096, 1024).is_ok()); + assert!(validate_editor_icon_spritesheet_manual_dimensions(2049, 2048).is_err()); + assert!(validate_editor_icon_spritesheet_manual_dimensions(4097, 1).is_err()); + assert!(validate_editor_icon_spritesheet_manual_slice_count(64).is_ok()); + assert!(validate_editor_icon_spritesheet_manual_slice_count(65).is_err()); + } + #[test] fn editor_canvas_generation_completion_skips_when_dialog_was_removed() { let layers = json!([ @@ -8386,6 +8561,10 @@ mod tests { spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), + slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, + reason: "图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。".to_string(), + }), prompt: "图标 prompt".to_string(), actual_prompt: Some("图标 prompt".to_string()), model: "gemini-3.1-flash-image-preview".to_string(), @@ -8404,6 +8583,15 @@ mod tests { assert_eq!(payload["priceMudPoints"], json!(12)); assert_eq!(payload["iconImageSrcs"], json!([])); + assert_eq!( + payload["sliceWarning"]["code"], + json!("insufficient-connected-components") + ); + assert!( + payload["sliceWarning"]["reason"] + .as_str() + .is_some_and(|reason| reason.contains("连通域数量不足")) + ); } #[tokio::test] 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 eb5ac1a69..ee6628de6 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -875,6 +875,11 @@ mod tests { .get("/api/external/v1/editor/icon-spritesheets/generations") .is_some() ); + assert_eq!( + parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] + ["sliceWarning"]["anyOf"][0]["$ref"], + "#/components/schemas/EditorIconSpritesheetSliceWarning" + ); assert!( parsed["paths"] .get("/api/external/v1/editor/ui-designs/assets/extractions") diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index c30598dc1..f5af6991f 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -477,6 +477,13 @@ pub struct EditorIconSpritesheetIconPayload { pub height: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceWarningPayload { + pub code: String, + pub reason: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerateResponse { @@ -484,6 +491,8 @@ pub struct EditorIconSpritesheetGenerateResponse { pub spritesheet_width: u32, pub spritesheet_height: u32, pub icon_image_srcs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slice_warning: Option, pub prompt: String, #[serde(skip_serializing_if = "Option::is_none")] pub actual_prompt: Option, @@ -1293,6 +1302,10 @@ mod tests { spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), + slice_warning: Some(EditorIconSpritesheetSliceWarningPayload { + code: "insufficient-connected-components".to_string(), + reason: "连通域数量不足".to_string(), + }), prompt: "图标 prompt".to_string(), actual_prompt: Some("图标 prompt".to_string()), model: "gemini-3.1-flash-image-preview".to_string(), @@ -1304,6 +1317,13 @@ mod tests { assert_eq!(response_payload["priceMudPoints"], json!(12)); assert_eq!(response_payload["iconImageSrcs"], json!([])); + assert_eq!( + response_payload["sliceWarning"], + json!({ + "code": "insufficient-connected-components", + "reason": "连通域数量不足", + }) + ); } #[test] diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 5fcefe7e2..c158a52c0 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2082,6 +2082,10 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { provider: 'VectorEngine', taskId: 'task-icons', iconImageSrcs: [], + sliceWarning: { + code: 'insufficient-connected-components', + reason: '连通域数量不足', + }, }); render( { spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], + sliceWarning: { + code: 'insufficient-connected-components', + reason: '连通域数量不足', + }, prompt: '图标素材 prompt', actualPrompt: '图标素材 prompt', model: 'gemini-3.1-flash-image-preview', @@ -911,6 +915,10 @@ describe('editorProjectClient', () => { }); expect(result.taskId).toBe('icon-spritesheet-task-1'); + expect(result.sliceWarning).toEqual({ + code: 'insufficient-connected-components', + reason: '连通域数量不足', + }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 3199bef6f..38c8a793f 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -300,11 +300,17 @@ export type EditorIconSpritesheetIconResult = { asset?: EditorAssetSnapshot | null; }; +export type EditorIconSpritesheetSliceWarning = { + code: string; + reason: string; +}; + export type EditorIconSpritesheetGenerationResult = { spritesheetImageSrc: string; spritesheetWidth: number; spritesheetHeight: number; iconImageSrcs: EditorIconSpritesheetIconResult[]; + sliceWarning?: EditorIconSpritesheetSliceWarning | null; prompt: string; actualPrompt?: string | null; model: string;