合并画板图集自动拆分

This commit is contained in:
2026-07-13 19:22:33 +08:00
18 changed files with 840 additions and 24 deletions
@@ -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,
+6
View File
@@ -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,
) {