From eae00292a1f861b964291aa0b3660ccbd8010cae Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 19:04:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=BB=E6=9D=BF=E5=9B=BE?= =?UTF-8?q?=E9=9B=86=E8=87=AA=E5=8A=A8=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成图标图集后自动拆分并持久化独立素材 新增图集工具栏手动拆分入口与权威画布回填 修复连通域切片阅读顺序并补齐前后端测试 同步外部接口与编辑器设计文档 --- .../genarrative-external-v1.openapi.json | 2 +- .../shared-memory/decision-log.md | 2 +- ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 15 +- .../crates/api-server/src/editor_project.rs | 349 +++++++++++++++++- .../api-server/src/modules/editor_project.rs | 12 +- .../src/generated_asset_sheets/sheet.rs | 169 ++++++++- ...CanvasEditorGenerationIntegration.test.tsx | 23 +- .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorView.tsx | 2 + ...ageCanvasSelectedLayerToolbarView.test.tsx | 30 ++ .../ImageCanvasSelectedLayerToolbarView.tsx | 13 + .../image-editor/ImageCanvasStageView.tsx | 3 + .../useImageCanvasGenerationWorkflow.test.tsx | 68 ++++ .../useImageCanvasGenerationWorkflow.ts | 64 ++++ src/index.css | 6 + .../image-editor/editorProjectClient.test.ts | 74 ++++ .../image-editor/editorProjectClient.ts | 29 ++ 18 files changed, 840 insertions(+), 24 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 67596e4dd..ff4d349de 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3090,7 +3090,7 @@ }, "iconImageSrcs": { "type": "array", - "description": "兼容旧客户端的切片素材列表;图标素材生成固定为空,UI 设计图提取素材可返回切片。", + "description": "按图集 alpha 连通域拆分并持久化的独立素材列表。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a669bd569..300d5b4c3 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"` 素材;图标素材生成只保留透明 spritesheet 图集,不再额外铺独立图标。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。 +- 决策:`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` 指向原图集。本条新决策取代“图标素材生成只保留图集”的旧口径。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 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/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index 1829eb851..b226c5050 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -62,7 +62,7 @@ ``` - 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。 -- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。 +- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。图集图层同样提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。 ## 验收点 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 84a19ac5e..d4fc7e27f 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -4,7 +4,7 @@ ## 背景 -图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张绿幕 spritesheet,并在后端扣除绿色背景后作为单个图集铺到画布。 +图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet,在后端去背景后自动拆分为可独立编辑的素材。 ## 入口与画布表现 @@ -12,7 +12,8 @@ - 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。 - 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。 - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 -- 生成完成后删除占位态,只把后端返回的已扣绿 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,不额外拆出 `assetKind: "icon"` 图层。 +- 生成完成后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域拆出的 `assetKind: "icon"` 素材铺到图集右侧。 +- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。 - 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 ## 面板结构 @@ -59,13 +60,14 @@ ## 去背与保存 - 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 -- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。 -- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。 +- 去背后的整张 spritesheet 统一编码为透明 PNG 持久化,再按 alpha 连通域和素材描述顺序拆分。每个切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向原图集资源。 +- 响应通过 `iconImageSrcs` 返回切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 +- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。 ## 前端铺放规则 - spritesheet 图集放在原占位图位置附近。 -- 不再把图集右侧铺开独立图标,用户需要继续编辑时直接操作图集图层。 +- 自动拆分素材从图集右侧开始换行铺放;手动拆分使用相同布局,但保留原图集不变。 - 生成成功后关闭图标素材面板,选中 spritesheet 图集,并打开图层面板。 ## 验收 @@ -76,5 +78,6 @@ - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。 -- 生成成功后画布只出现一个已扣绿的透明 spritesheet 图集,不出现额外独立图标图层。 +- 生成成功后画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层。 +- 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 87b6de8d1..a45674c28 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -18,7 +18,11 @@ use module_assets::{ }; use platform_image::{ DownloadedImage, - generated_asset_sheets::slice_generated_icon_spritesheet_all_by_connected_components, + generated_asset_sheets::{ + GeneratedAssetSheetConnectedIcon, + slice_generated_icon_spritesheet_all_by_connected_components, + slice_generated_icon_spritesheet_by_connected_components, + }, }; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, @@ -109,6 +113,7 @@ const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; 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_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"; @@ -290,6 +295,15 @@ pub struct EditorIconSpritesheetGenerationRequest { pub(crate) canvas_completion: Option, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceRequest { + pub(crate) project_id: String, + pub(crate) source_resource_id: String, + pub(crate) asset_folder_id: Option, + pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorUiDesignAssetExtractionRequest { @@ -568,6 +582,13 @@ pub struct EditorIconSpritesheetGenerationResponse { project: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceResponse { + icon_image_srcs: Vec, + project: EditorProjectPayload, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectPayload { @@ -3151,6 +3172,96 @@ fn decode_editor_removed_background_image( }) } +struct PersistEditorSpritesheetSlicesInput { + owner_user_id: String, + project_id: Option, + asset_folder_id: Option, + source_resource_id: Option, + task_id: String, + prompt: String, + actual_prompt: Option, + model: String, + provider: String, + generation_inputs: Option, + path_kind: &'static str, + asset_kind: &'static str, + persistence_provider: &'static str, +} + +async fn persist_editor_spritesheet_slices( + state: &AppState, + slices: Vec, + input: PersistEditorSpritesheetSlicesInput, +) -> Result, AppError> { + if slices.is_empty() { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集中未识别到可拆分的独立素材。", + })), + ); + } + + let mut results = Vec::with_capacity(slices.len()); + for (index, slice) in slices.into_iter().enumerate() { + let image = DownloadedOpenAiImage { + bytes: slice.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let file_stem = format!("asset-{:03}", index + 1); + let persisted = persist_editor_generated_image( + state, + input.owner_user_id.as_str(), + input.task_id.as_str(), + &image, + input.prompt.as_str(), + input.actual_prompt.as_deref(), + input.asset_kind, + input.path_kind, + file_stem.as_str(), + "asset", + input.persistence_provider, + ) + .await?; + let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let record = persist_editor_generated_asset( + state, + PersistEditorGeneratedAssetInput { + project_id: input.project_id.clone(), + owner_user_id: input.owner_user_id.clone(), + folder_id: input.asset_folder_id.clone(), + label: slice.name.clone(), + image_src: image_src.clone(), + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), + width: slice.width, + height: slice.height, + prompt: input.prompt.clone(), + actual_prompt: input.actual_prompt.clone(), + model: input.model.clone(), + provider: input.provider.clone(), + task_id: input.task_id.clone(), + source_resource_id: input.source_resource_id.clone(), + asset_kind: Some("icon".to_string()), + generation_inputs: input.generation_inputs.clone(), + thumbnail_src: None, + generation_cost_mud_points: 0, + }, + ) + .await?; + results.push(EditorIconSpritesheetIconResponse { + name: slice.name, + image_src, + width: slice.width, + height: slice.height, + resource: record.resource, + asset: record.asset, + }); + } + Ok(results) +} + pub async fn generate_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, @@ -3391,6 +3502,19 @@ 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, @@ -3433,7 +3557,29 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( }, ) .await?; - let icon_image_srcs = Vec::new(); + 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 (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( @@ -3475,6 +3621,135 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( )) } +pub async fn split_editor_icon_spritesheet( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let project_id = payload.project_id.trim().to_string(); + let source_resource_id = payload.source_resource_id.trim().to_string(); + if project_id.is_empty() || source_resource_id.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "projectId 和 sourceResourceId 不能为空。", + })), + ); + } + + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?; + let source_resource = project + .resources + .iter() + .find(|resource| resource.resource_id == source_resource_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "未找到要拆分的图集资源。", + })) + })?; + if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "只有图集素材可以使用拆分功能。", + })), + ); + } + + let reference = + parse_editor_reference_image(&state, owner_user_id.as_str(), source_resource_id.as_str()) + .await?; + let source = DownloadedImage { + bytes: reference.bytes, + mime_type: reference.mime_type, + extension: "png".to_string(), + }; + let slices = + slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": error.to_string(), + })) + })?; + let task_id = build_prefixed_uuid_id("editor-atlas-split-"); + let prompt = source_resource + .prompt + .clone() + .unwrap_or_else(|| "拆分图集素材".to_string()); + let actual_prompt = source_resource.actual_prompt.clone(); + let model = source_resource + .model + .clone() + .unwrap_or_else(|| "connected-components".to_string()); + let provider = source_resource + .provider + .clone() + .unwrap_or_else(|| "Genarrative".to_string()); + let generation_inputs = source_resource.generation_inputs.clone(); + let icon_image_srcs = persist_editor_spritesheet_slices( + &state, + slices, + PersistEditorSpritesheetSlicesInput { + owner_user_id: owner_user_id.clone(), + project_id: Some(project_id.clone()), + asset_folder_id: payload + .asset_folder_id + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), + source_resource_id: Some(source_resource_id), + task_id, + prompt, + actual_prompt, + model, + provider, + generation_inputs, + path_kind: "icon-spritesheet-slices", + asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + persistence_provider: "genarrative", + }, + ) + .await?; + let canvas_items = build_icon_spritesheet_slice_canvas_layer_items( + &payload.canvas_completion, + icon_image_srcs.as_slice(), + ); + let project = complete_editor_canvas_generation_with_items( + &state, + owner_user_id.as_str(), + Some(project_id.as_str()), + Some(&payload.canvas_completion), + canvas_items, + icon_image_srcs + .first() + .and_then(|icon| icon.resource.as_ref()) + .map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())), + ) + .await? + .ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集拆分结果未能写入画布。", + })) + })?; + + Ok(json_success_body( + Some(&request_context), + EditorIconSpritesheetSliceResponse { + icon_image_srcs, + project, + }, + )) +} + pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, @@ -5195,15 +5470,46 @@ fn build_icon_spritesheet_canvas_layer_items( return (items, primary_layer_id); } - let mut cursor_x = items + let start_x = items .iter() .filter_map(|item| Some(item.get("x")?.as_f64()? + item.get("width")?.as_f64()?)) .fold(placeholder.x, f64::max) + 32.0; - let mut cursor_y = placeholder.y; + items.extend(build_icon_slice_canvas_layer_items_from_origin( + completion, + icons, + start_x, + placeholder.y, + )); + + (items, primary_layer_id) +} + +fn build_icon_spritesheet_slice_canvas_layer_items( + completion: &EditorCanvasGenerationCompletionRequest, + icons: &[EditorIconSpritesheetIconResponse], +) -> Vec { + let placeholder = &completion.placeholder; + build_icon_slice_canvas_layer_items_from_origin( + completion, + icons, + placeholder.x + placeholder.width.max(1.0) + 32.0, + placeholder.y, + ) +} + +fn build_icon_slice_canvas_layer_items_from_origin( + completion: &EditorCanvasGenerationCompletionRequest, + icons: &[EditorIconSpritesheetIconResponse], + start_x: f64, + start_y: f64, +) -> Vec { + let placeholder = &completion.placeholder; + let mut items = Vec::new(); + let mut cursor_x = start_x; + let mut cursor_y = start_y; let mut row_height = 0.0_f64; let max_row_width = 560.0_f64; - let start_x = cursor_x; let spacing = 24.0_f64; for icon in icons { @@ -5226,6 +5532,7 @@ fn build_icon_spritesheet_canvas_layer_items( 0, ); if let Some(object) = item.as_object_mut() { + object.insert("title".to_string(), json!(icon.name)); object.insert("x".to_string(), json!(cursor_x)); object.insert("y".to_string(), json!(cursor_y)); object.insert("width".to_string(), json!(width)); @@ -5237,8 +5544,7 @@ fn build_icon_spritesheet_canvas_layer_items( row_height = row_height.max(height); items.push(item); } - - (items, primary_layer_id) + items } fn next_canvas_layer_z_index(items: &[Value]) -> i64 { @@ -8298,6 +8604,33 @@ mod tests { assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); } + #[test] + fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() { + let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({ + "projectId": "project-1", + "sourceResourceId": "resource-atlas-1", + "assetFolderId": "project", + "canvasCompletion": { + "title": "拆分图集", + "placeholder": { + "x": 120.0, + "y": 80.0, + "width": 360.0, + "height": 360.0, + "originalWidth": 1024.0, + "originalHeight": 1024.0 + } + } + })) + .expect("spritesheet slice request should deserialize"); + + assert_eq!(request.project_id, "project-1"); + assert_eq!(request.source_resource_id, "resource-atlas-1"); + assert_eq!(request.asset_folder_id.as_deref(), Some("project")); + assert_eq!(request.canvas_completion.title, "拆分图集"); + assert_eq!(request.canvas_completion.placeholder.width, 360.0); + } + #[test] fn editor_bgfilter_seg_model_parser_defaults_and_rejects_unknown() { assert_eq!( @@ -8407,6 +8740,8 @@ mod tests { "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", "persist_editor_green_screen_source_resource", + "slice_generated_icon_spritesheet_by_connected_components", + "persist_editor_spritesheet_slices", ], ); assert_function_contains_in_order( diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 6cc90c3eb..3842c5c53 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -20,8 +20,9 @@ use crate::{ get_editor_generation_pricing, get_editor_project, list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, remove_editor_image_background, rename_editor_project, save_editor_project_layout, - submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, - update_editor_asset_folder, update_editor_project_resource_showcase, + split_editor_icon_spritesheet, submit_editor_asset_showcase, + toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, + update_editor_project_resource_showcase, }, state::AppState, }; @@ -211,6 +212,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/icon-spritesheets/slices", + post(split_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/editor/ui-designs/assets/extractions", post(extract_editor_ui_design_assets) diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 1115dba86..4d4b46119 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -212,7 +212,7 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components( icon_names.len(), auto_name_all_components, ); - components.sort_by_key(|bounds| (bounds.y0, bounds.x0)); + sort_generated_icon_components_in_visual_rows(&mut components); let icon_names = if auto_name_all_components { (1..=components.len()) .map(|index| format!("素材 {index}")) @@ -314,6 +314,76 @@ fn normalize_generated_icon_components( .collect() } +fn sort_generated_icon_components_in_visual_rows(components: &mut [GeneratedAssetSheetCellBounds]) { + if components.len() < 2 { + return; + } + + let mut component_heights = components + .iter() + .map(|bounds| bounds.height()) + .collect::>(); + component_heights.sort_unstable(); + let typical_height = component_heights[component_heights.len() / 2].max(1); + // 双倍中心坐标避免浮点;3/4 中位高度可容纳顶边不齐,同时保留正常行距。 + let row_center_tolerance_twice = typical_height.saturating_mul(3).div_ceil(2).max(8); + + components.sort_by_key(|bounds| (bounds.center_y_twice(), bounds.x0)); + let mut rows = Vec::::new(); + for bounds in components.iter().copied() { + let center_y_twice = bounds.center_y_twice(); + let closest_row = rows + .iter() + .enumerate() + .filter_map(|(index, row)| { + let distance = row.center_y_twice().abs_diff(center_y_twice); + (distance <= row_center_tolerance_twice).then_some((index, distance)) + }) + .min_by_key(|(_, distance)| *distance) + .map(|(index, _)| index); + if let Some(row_index) = closest_row { + rows[row_index].push(bounds); + } else { + rows.push(GeneratedIconVisualRow::new(bounds)); + } + } + + rows.sort_by_key(GeneratedIconVisualRow::center_y_twice); + let mut ordered = Vec::with_capacity(components.len()); + for mut row in rows { + row.components + .sort_by_key(|bounds| (bounds.x0, bounds.center_y_twice())); + ordered.extend(row.components); + } + components.copy_from_slice(&ordered); +} + +struct GeneratedIconVisualRow { + center_y_twice_sum: u64, + components: Vec, +} + +impl GeneratedIconVisualRow { + fn new(bounds: GeneratedAssetSheetCellBounds) -> Self { + Self { + center_y_twice_sum: u64::from(bounds.center_y_twice()), + components: vec![bounds], + } + } + + fn center_y_twice(&self) -> u32 { + let component_count = self.components.len().max(1) as u64; + u32::try_from(self.center_y_twice_sum / component_count).unwrap_or(u32::MAX) + } + + fn push(&mut self, bounds: GeneratedAssetSheetCellBounds) { + self.center_y_twice_sum = self + .center_y_twice_sum + .saturating_add(u64::from(bounds.center_y_twice())); + self.components.push(bounds); + } +} + fn merge_generated_icon_related_components( mut components: Vec, width: u32, @@ -621,6 +691,17 @@ mod tests { cursor.into_inner() } + fn assert_icon_contains_color(icon: &GeneratedAssetSheetConnectedIcon, color: [u8; 4]) { + let image = image::load_from_memory(icon.bytes.as_slice()) + .expect("icon should decode") + .to_rgba8(); + assert!( + image.pixels().any(|pixel| pixel.0 == color), + "{} should contain {color:?}", + icon.name + ); + } + #[test] fn slices_icon_spritesheet_by_connected_components_in_reading_order() { let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255])); @@ -654,6 +735,88 @@ mod tests { assert!(image::load_from_memory(icons[0].bytes.as_slice()).is_ok()); } + #[test] + fn keeps_left_to_right_order_when_same_row_top_edges_differ() { + let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(112, 56, Rgba([0, 255, 0, 255])); + let left_color = [240, 80, 80, 255]; + let right_color = [80, 120, 240, 255]; + for y in 18..38 { + for x in 10..30 { + sheet.put_pixel(x, y, Rgba(left_color)); + } + } + for y in 8..38 { + for x in 70..92 { + sheet.put_pixel(x, y, Rgba(right_color)); + } + } + + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let icons = slice_generated_icon_spritesheet_by_connected_components( + &source, + &["左侧素材".to_string(), "右侧素材".to_string()], + ) + .expect("same-row icons should slice from left to right"); + + assert_icon_contains_color(&icons[0], left_color); + assert_icon_contains_color(&icons[1], right_color); + } + + #[test] + fn orders_multiple_visual_rows_top_to_bottom_and_left_to_right() { + let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(128, 112, Rgba([0, 255, 0, 255])); + let colors = [ + [240, 80, 80, 255], + [80, 120, 240, 255], + [180, 80, 220, 255], + [250, 190, 40, 255], + ]; + for y in 18..38 { + for x in 10..30 { + sheet.put_pixel(x, y, Rgba(colors[0])); + } + } + for y in 8..38 { + for x in 78..100 { + sheet.put_pixel(x, y, Rgba(colors[1])); + } + } + for y in 72..94 { + for x in 12..34 { + sheet.put_pixel(x, y, Rgba(colors[2])); + } + } + for y in 62..94 { + for x in 80..104 { + sheet.put_pixel(x, y, Rgba(colors[3])); + } + } + + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let icons = slice_generated_icon_spritesheet_by_connected_components( + &source, + &[ + "第一行左侧".to_string(), + "第一行右侧".to_string(), + "第二行左侧".to_string(), + "第二行右侧".to_string(), + ], + ) + .expect("visual rows should slice in reading order"); + + for (icon, color) in icons.iter().zip(colors) { + assert_icon_contains_color(icon, color); + } + } + #[test] fn rejects_when_connected_components_are_fewer_than_icon_names() { let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255])); @@ -837,6 +1000,10 @@ impl GeneratedAssetSheetCellBounds { self.y1.saturating_sub(self.y0).max(1) } + fn center_y_twice(self) -> u32 { + self.y0.saturating_add(self.y1) + } + fn area(self) -> u32 { self.width().saturating_mul(self.height()) } diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index b26be96aa..77b779580 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -2446,7 +2446,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); }); - it('opens icon asset generation panel, only picks icon specs, and lays only the generated spritesheet on canvas', async () => { + it('opens icon asset generation panel, only picks icon specs, and lays out the generated spritesheet slices', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons', title: '图标素材画布', @@ -2489,7 +2489,20 @@ describe('ImageCanvasEditorView generation integration', () => { spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, - iconImageSrcs: [], + iconImageSrcs: [ + { + name: '返回按钮', + imageSrc: 'data:image/png;base64,back', + width: 128, + height: 128, + }, + { + name: '设置按钮', + imageSrc: 'data:image/png;base64,settings', + width: 128, + height: 128, + }, + ], prompt: '图标 prompt', actualPrompt: '图标 prompt', model: 'gemini-3.1-flash-image-preview', @@ -2566,7 +2579,7 @@ describe('ImageCanvasEditorView generation integration', () => { await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ - referenceImageSrc: 'data:image/png;base64,icon-spec', + referenceImageSrc: 'resource-icon-spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', @@ -2588,8 +2601,8 @@ describe('ImageCanvasEditorView generation integration', () => { await waitFor(() => { expect(screen.getByAltText(/图层缩略图:图标素材图集/u)).toBeTruthy(); }); - expect(screen.queryByAltText('画布图片:返回按钮')).toBeNull(); - expect(screen.queryByAltText('画布图片:设置按钮')).toBeNull(); + expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy(); + expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy(); expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull(); expect(screen.getByText('图集')).toBeTruthy(); fireEvent.click( diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 48feebceb..88c14ae62 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -194,6 +194,7 @@ function createStageProps(): ImageCanvasStageViewProps { onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onUiAssetExtractionToolChange: vi.fn(), onUiAssetExtractionModelChange: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 656d8d360..0d6e0fdc0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1339,6 +1339,7 @@ export function ImageCanvasEditorView({ openRedrawPanel, openCropExpandPanel, removeSelectedLayerBackground, + splitSelectedIconSpritesheet, extractUiDesignAssets, pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, @@ -2149,6 +2150,7 @@ export function ImageCanvasEditorView({ onOpenRedrawPanel: openRedrawPanel, onOpenCropExpandPanel: openCropExpandPanel, onRemoveBackground: removeSelectedLayerBackground, + onSplitIconSpritesheet: splitSelectedIconSpritesheet, onExtractUiDesignAssets: extractUiDesignAssets, onUiAssetExtractionToolChange: changeUiAssetExtractionTool, onUiAssetExtractionModelChange: changeUiAssetExtractionModel, diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index fbc0a5bbf..feb573752 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -42,6 +42,7 @@ function renderSelectedToolbar( onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), onDownloadLayer: vi.fn(), @@ -126,6 +127,33 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onExtractUiDesignAssets).toHaveBeenCalledWith(layer); }); + it('renders icon spritesheet splitting after remove background', () => { + const layer = createLayer({ + sourceType: 'generated', + assetKind: 'icon-spritesheet', + }); + const props = renderSelectedToolbar({ selectedLayer: layer }); + const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' }); + const buttons = within(toolbar).getAllByRole('button'); + + expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([ + '裁扩按钮', + '去除背景按钮', + '拆分图集', + '改造', + '下载按钮', + ]); + + const splitButton = within(toolbar).getByRole('button', { + name: '拆分图集', + }); + expect(splitButton.querySelector('.lucide-scissors')).toBeTruthy(); + + fireEvent.click(splitButton); + + expect(props.onSplitIconSpritesheet).toHaveBeenCalledWith(layer); + }); + it('keeps only remodel and download for audio layers', () => { const layer = createLayer({ title: '游戏音效', @@ -258,6 +286,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} onDownloadLayer={vi.fn()} @@ -274,6 +303,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} onDownloadLayer={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index c4593fe40..2f0550523 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -21,6 +21,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; onDownloadLayer: (layer: CanvasLayer) => void; @@ -33,6 +34,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + onSplitIconSpritesheet, onExtractUiDesignAssets, onOpenCharacterAnimationPanel, onDownloadLayer, @@ -115,6 +117,17 @@ export function ImageCanvasSelectedLayerToolbarView({ /> ) : null} + {selectedLayer.assetKind === 'icon-spritesheet' ? ( + } + onClick={() => onSplitIconSpritesheet(selectedLayer)} + > + 拆分图集 + + ) : null} {selectedLayer.assetKind === 'ui-design' ? ( void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void; onUiAssetExtractionModelChange: (model: string) => void; @@ -269,6 +270,7 @@ export function ImageCanvasStageView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + onSplitIconSpritesheet, onExtractUiDesignAssets, onUiAssetExtractionToolChange, onUiAssetExtractionModelChange, @@ -379,6 +381,7 @@ export function ImageCanvasStageView({ onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} onRemoveBackground={onRemoveBackground} + onSplitIconSpritesheet={onSplitIconSpritesheet} onExtractUiDesignAssets={onExtractUiDesignAssets} onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel} onDownloadLayer={onDownloadLayer} diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 5dfbcb6dd..8f3389a92 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -29,6 +29,7 @@ const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn()); const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); +const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const renderCropExpandImageMock = vi.hoisted(() => vi.fn()); const removeImageBackgroundMock = vi.hoisted(() => vi.fn()); @@ -60,6 +61,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorSoundEffect: generateEditorSoundEffectMock, + splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, }; }); @@ -507,6 +509,12 @@ function GenerationWorkflowHarness({ > 去除背景 +