合并画板图集自动拆分
This commit is contained in:
@@ -3090,7 +3090,7 @@
|
||||
},
|
||||
"iconImageSrcs": {
|
||||
"type": "array",
|
||||
"description": "兼容旧客户端的切片素材列表;图标素材生成固定为空,UI 设计图提取素材可返回切片。",
|
||||
"description": "按图集 alpha 连通域拆分并持久化的独立素材列表。",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
|
||||
}
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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"` 图标图层继续放到画布。图集图层同样提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。
|
||||
|
||||
## 验收点
|
||||
|
||||
|
||||
@@ -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` 校验错误,不继续调用上游生成。
|
||||
|
||||
@@ -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<EditorCanvasGenerationCompletionRequest>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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<EditorProjectPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorIconSpritesheetSliceResponse {
|
||||
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
|
||||
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<String>,
|
||||
asset_folder_id: Option<String>,
|
||||
source_resource_id: Option<String>,
|
||||
task_id: String,
|
||||
prompt: String,
|
||||
actual_prompt: Option<String>,
|
||||
model: String,
|
||||
provider: String,
|
||||
generation_inputs: Option<Value>,
|
||||
path_kind: &'static str,
|
||||
asset_kind: &'static str,
|
||||
persistence_provider: &'static str,
|
||||
}
|
||||
|
||||
async fn persist_editor_spritesheet_slices(
|
||||
state: &AppState,
|
||||
slices: Vec<GeneratedAssetSheetConnectedIcon>,
|
||||
input: PersistEditorSpritesheetSlicesInput,
|
||||
) -> Result<Vec<EditorIconSpritesheetIconResponse>, 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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorIconSpritesheetSliceRequest>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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(
|
||||
|
||||
@@ -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<AppState> {
|
||||
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)
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<GeneratedIconVisualRow>::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<GeneratedAssetSheetCellBounds>,
|
||||
}
|
||||
|
||||
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<GeneratedAssetSheetIconComponent>,
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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' ? (
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="拆分图集"
|
||||
title="拆分图集"
|
||||
icon={<Scissors className="h-4 w-4" />}
|
||||
onClick={() => onSplitIconSpritesheet(selectedLayer)}
|
||||
>
|
||||
<span>拆分图集</span>
|
||||
</PlatformIconButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' ? (
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
|
||||
@@ -140,6 +140,7 @@ export type ImageCanvasStageViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => 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}
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
去除背景
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void workflow.splitSelectedIconSpritesheet(layers[0]!)}
|
||||
>
|
||||
拆分图集
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1771,6 +1779,66 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('splits an icon spritesheet through the project endpoint and applies the project snapshot', async () => {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const project = {
|
||||
projectId: 'project-1',
|
||||
title: '未命名画布',
|
||||
canvas: {
|
||||
canvasId: 'project-1:canvas:default',
|
||||
projectId: 'project-1',
|
||||
title: '默认画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
},
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
};
|
||||
splitEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||||
iconImageSrcs: [],
|
||||
project,
|
||||
});
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
assetKind: 'icon-spritesheet',
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拆分图集' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetFolderId: undefined,
|
||||
canvasCompletion: {
|
||||
title: '拆分图集',
|
||||
placeholder: {
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(project);
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
||||
});
|
||||
|
||||
it('resolves private character image before removing background', async () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,resolved-character',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
createEditorProjectResource,
|
||||
splitEditorIconSpritesheet,
|
||||
type EditorAssetSnapshot,
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
@@ -614,6 +615,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setTaskListRefreshKey((key) => key + 1);
|
||||
}, []);
|
||||
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
|
||||
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
useState(false);
|
||||
@@ -1692,6 +1694,66 @@ export function useImageCanvasGenerationWorkflow({
|
||||
],
|
||||
);
|
||||
|
||||
const splitSelectedIconSpritesheet = useCallback(
|
||||
async (sourceLayer: CanvasLayer) => {
|
||||
if (
|
||||
sourceLayer.assetKind !== 'icon-spritesheet' ||
|
||||
!projectId ||
|
||||
!applyProjectSnapshot ||
|
||||
splittingIconSpritesheetLayerIdsRef.current.has(sourceLayer.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
splittingIconSpritesheetLayerIdsRef.current.add(sourceLayer.id);
|
||||
closeGenerationTransientState();
|
||||
setImageContextMenu(null);
|
||||
setMetadataLayer(null);
|
||||
setCropExpandPanel(null);
|
||||
setQuickEditPanel(null);
|
||||
try {
|
||||
const result = await splitEditorIconSpritesheet({
|
||||
projectId,
|
||||
sourceResourceId: sourceLayer.resourceId,
|
||||
assetFolderId,
|
||||
canvasCompletion: {
|
||||
title: '拆分图集',
|
||||
placeholder: {
|
||||
x: sourceLayer.x,
|
||||
y: sourceLayer.y,
|
||||
width: sourceLayer.width,
|
||||
height: sourceLayer.height,
|
||||
originalWidth: sourceLayer.originalWidth,
|
||||
originalHeight: sourceLayer.originalHeight,
|
||||
},
|
||||
},
|
||||
});
|
||||
applyProjectSnapshot(result.project);
|
||||
setActiveTool('select');
|
||||
setActiveSidebarPanel('layers');
|
||||
} catch (error) {
|
||||
window.alert(
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: '拆分图集失败',
|
||||
);
|
||||
} finally {
|
||||
splittingIconSpritesheetLayerIdsRef.current.delete(sourceLayer.id);
|
||||
}
|
||||
},
|
||||
[
|
||||
applyProjectSnapshot,
|
||||
assetFolderId,
|
||||
closeGenerationTransientState,
|
||||
projectId,
|
||||
setActiveSidebarPanel,
|
||||
setActiveTool,
|
||||
setCropExpandPanel,
|
||||
setImageContextMenu,
|
||||
setMetadataLayer,
|
||||
setQuickEditPanel,
|
||||
],
|
||||
);
|
||||
|
||||
const pickCharacterSpecFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (!isCharacterSpecReferenceLayer(layer)) {
|
||||
@@ -2444,6 +2506,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openCropExpandPanel,
|
||||
startCropExpandFrameResize,
|
||||
removeSelectedLayerBackground,
|
||||
splitSelectedIconSpritesheet,
|
||||
taskListRefreshKey,
|
||||
isTaskSidebarOpen,
|
||||
toggleTaskSidebar: () => setIsTaskSidebarOpen((open) => !open),
|
||||
@@ -2543,6 +2606,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
quickEditPanel,
|
||||
quickEditSourceLayer,
|
||||
removeSelectedLayerBackground,
|
||||
splitSelectedIconSpritesheet,
|
||||
submitCharacterAnimation,
|
||||
submitCropExpand,
|
||||
submitIconSpritesheetGeneration,
|
||||
|
||||
@@ -9575,6 +9575,12 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar {
|
||||
position: fixed;
|
||||
top: 7.25rem;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar {
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
removeEditorImageBackground,
|
||||
renameEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
splitEditorIconSpritesheet,
|
||||
submitEditorAssetShowcase,
|
||||
toggleEditorShowcaseAssetLike,
|
||||
updateEditorAsset,
|
||||
@@ -988,6 +989,79 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('splits an existing icon spritesheet into project assets', async () => {
|
||||
const project = {
|
||||
projectId: 'editor-project-1',
|
||||
title: '未命名画布',
|
||||
canvas: {
|
||||
canvasId: 'editor-project-1:canvas:default',
|
||||
projectId: 'editor-project-1',
|
||||
title: '默认画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
},
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
};
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
iconImageSrcs: [
|
||||
{
|
||||
name: '素材 1',
|
||||
imageSrc: '/generated/atlas/asset-001.png',
|
||||
width: 128,
|
||||
height: 128,
|
||||
},
|
||||
],
|
||||
project,
|
||||
});
|
||||
|
||||
const result = await splitEditorIconSpritesheet({
|
||||
projectId: 'editor-project-1',
|
||||
sourceResourceId: 'resource-atlas-1',
|
||||
assetFolderId: 'project',
|
||||
canvasCompletion: {
|
||||
title: '拆分图集',
|
||||
placeholder: {
|
||||
x: 120,
|
||||
y: 80,
|
||||
width: 360,
|
||||
height: 360,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.project).toBe(project);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/icon-spritesheets/slices',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: 'editor-project-1',
|
||||
sourceResourceId: 'resource-atlas-1',
|
||||
assetFolderId: 'project',
|
||||
canvasCompletion: {
|
||||
title: '拆分图集',
|
||||
placeholder: {
|
||||
x: 120,
|
||||
y: 80,
|
||||
width: 360,
|
||||
height: 360,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'拆分图集失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts UI design assets through the dedicated backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||||
|
||||
@@ -12,6 +12,8 @@ const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
|
||||
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
|
||||
'/api/editor/icon-spritesheets/generations';
|
||||
const EDITOR_ICON_SPRITESHEET_SLICE_API =
|
||||
'/api/editor/icon-spritesheets/slices';
|
||||
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_API =
|
||||
'/api/editor/ui-designs/assets/extractions';
|
||||
const EDITOR_CHARACTER_ANIMATION_GENERATION_API =
|
||||
@@ -204,6 +206,13 @@ export type EditorIconSpritesheetGenerationInput = {
|
||||
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetSliceInput = {
|
||||
projectId: string;
|
||||
sourceResourceId: string;
|
||||
assetFolderId?: string | null;
|
||||
canvasCompletion: EditorCanvasGenerationCompletionInput;
|
||||
};
|
||||
|
||||
export type EditorUiDesignAssetExtractionInput = {
|
||||
sourceImageSrc: string;
|
||||
model?: string;
|
||||
@@ -308,6 +317,11 @@ export type EditorIconSpritesheetGenerationResult = {
|
||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetSliceResult = {
|
||||
iconImageSrcs: EditorIconSpritesheetIconResult[];
|
||||
project: EditorProjectSnapshot;
|
||||
};
|
||||
|
||||
export type EditorCharacterAnimationResolution = '480p' | '720p';
|
||||
|
||||
export type EditorCharacterAnimationRatio =
|
||||
@@ -926,6 +940,21 @@ export async function generateEditorIconSpritesheet(
|
||||
);
|
||||
}
|
||||
|
||||
export async function splitEditorIconSpritesheet(
|
||||
input: EditorIconSpritesheetSliceInput,
|
||||
) {
|
||||
return requestJson<EditorIconSpritesheetSliceResult>(
|
||||
EDITOR_ICON_SPRITESHEET_SLICE_API,
|
||||
jsonRequest('POST', {
|
||||
projectId: input.projectId,
|
||||
sourceResourceId: input.sourceResourceId,
|
||||
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
|
||||
canvasCompletion: input.canvasCompletion,
|
||||
}),
|
||||
'拆分图集失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function extractEditorUiDesignAssets(
|
||||
input: EditorUiDesignAssetExtractionInput,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user