修正图标素材只生成透明图集

后端图标素材生成只持久化扣绿后的 spritesheet 图集。

响应兼容保留 iconImageSrcs 但图标生成固定返回空数组。

前端测试改为验证只回填图集且不生成独立图标层。

同步 OpenAPI、编辑器文档和项目记忆中的图标素材生成约定。
This commit is contained in:
2026-06-27 18:47:34 +08:00
parent 2c43e2accc
commit a6671e7da7
9 changed files with 141 additions and 198 deletions
@@ -2935,7 +2935,6 @@
"spritesheetImageSrc",
"spritesheetWidth",
"spritesheetHeight",
"iconImageSrcs",
"prompt",
"model",
"provider",
@@ -2956,6 +2955,7 @@
},
"iconImageSrcs": {
"type": "array",
"description": "兼容旧客户端的切片素材列表;图标素材生成固定为空,UI 设计图提取素材可返回切片。",
"items": {
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
}
@@ -140,7 +140,7 @@
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,返回结构复用图标 spritesheet 响应。图标生成与 UI 提取都必须把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留扣绿后的 spritesheet 图集,不再额外铺独立图标
- 影响范围:图片画布浮动工具栏、编辑器图片生成 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`
@@ -2818,8 +2818,8 @@
## 2026-06-16 图片画布图标素材与角色生成支持双图片模型
- 背景:图片画布需要一次生成多枚 UI 图标素材,并保证生成后能按用户输入顺序命名、拆成独立透明素材铺回画布;角色形象生成也需要和图标素材共用同一套图片模型选择、比例和大小口径。
- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"``生成角色形象``生成图标素材` 均支持 VectorEngine `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`,用户在两类面板中切换过模型后下一次打开继续沿用上次模型。前端提交 `model``aspectRatio``imageSize`;后端不再按图标数量分 `512x512/1024x1024`,而是按模型归一尺寸:`nanobanana2``/v1beta/models/{model}:generateContent`,把参考图写成 `inline_data`,并在 `generationConfig.imageConfig` 写入比例和大小,`0.5K``"512"``gpt-image-2` 无参考图走 generations,有参考图走 edits,按文档支持的 `size` 字符串映射。图标素材先生成绿幕 spritesheet,再由 `platform-image` 绿幕去背并按 8 邻域连通域从上到下、从左到右拆分。成品图标图层写入 `assetKind="icon"`角色图层写入 `assetKind="character"`
- 背景:图片画布需要一次生成多枚 UI 图标素材,并以一个透明图集回填画布;角色形象生成也需要和图标素材共用同一套图片模型选择、比例和大小口径。
- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"``生成角色形象``生成图标素材` 均支持 VectorEngine `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`,用户在两类面板中切换过模型后下一次打开继续沿用上次模型。前端提交 `model``aspectRatio``imageSize`;后端不再按图标数量分 `512x512/1024x1024`,而是按模型归一尺寸:`nanobanana2``/v1beta/models/{model}:generateContent`,把参考图写成 `inline_data`,并在 `generationConfig.imageConfig` 写入比例和大小,`0.5K``"512"``gpt-image-2` 无参考图走 generations,有参考图走 edits,按文档支持的 `size` 字符串映射。图标素材先生成绿幕 spritesheet,再由 `platform-image` 绿幕去背后作为 `assetKind="icon-spritesheet"` 图集回填;角色图层写入 `assetKind="character"`
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/services/image-editor/editorProjectClient.ts``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/platform-image/src/vector_engine/*``server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、图片画布技术方案。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/services/image-editor/editorProjectClient.test.ts``cargo test -p api-server editor_generation_dimensions_follow_model_options --manifest-path server-rs/Cargo.toml``cargo test -p platform-image nanobanana_generate_content --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -782,11 +782,11 @@
- 验证:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/``server-rs/crates/api-server/src/generated_asset_sheets.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 图片画布图标素材切片不要把断开的高光阴影当独立图标
## 图片画布 UI 提取素材切片不要把断开的高光阴影当独立图标
- 现象:图片画布生成图标素材或提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
- 现象:图片画布提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
- 原因:图标 spritesheet 切片按 alpha 连通域识别素材,模型常把软阴影、高光、小星星等画成与主体断开的透明块;如果直接逐连通域出图,小碎片会抢占图标顺序,主体也会缺边缘装饰。
- 处理:在 `platform-image``sheet.rs` 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。
- 处理:在 `platform-image``sheet.rs` 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。`生成图标素材` 入口只回填扣绿后的整张图集,不再拆分独立图标。
- 验证:`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml` 覆盖断开的高光合并和孤立小碎片过滤;调用方补跑 `cargo test -p api-server editor_icon --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs``server-rs/crates/api-server/src/editor_project.rs`
@@ -4,7 +4,7 @@
## 背景
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张绿幕 spritesheet再自动去背、按连通域拆分为独立图标素材并铺到画布。
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张绿幕 spritesheet并在后端扣除绿色背景后作为单个图集铺到画布。
## 入口与画布表现
@@ -12,8 +12,8 @@
- 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。
- 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
- 生成完成后删除占位态,把后端返回的 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,再把拆分出的每个独立图标素材作为画布图片图层铺开,图集与图标之间、图标彼此之间不重叠,并保留少量间距
- 图标素材图层写入 `assetKind: "icon"`;图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
- 生成完成后删除占位态,把后端返回的已扣绿 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,不额外拆出 `assetKind: "icon"` 图层
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
## 面板结构
@@ -55,19 +55,16 @@
<素材描述按中文顿号拼接>
```
## 去背与拆分
## 去背与保存
- 后端收到 spritesheet 后先复用 `platform-image::generated_asset_sheets` 的绿幕去背能力。
- 去背后基于 alpha 可见像素执行 8 邻域连通域检测
- 连通域按从上到下、从左到右排序;排序结果依次绑定面板中的素材描述名称
- 每个连通域裁剪时保留少量透明边距,并输出独立透明 PNG Data URL。
- 若模型返回的连通域数量少于素材描述数量,接口返回失败,不在画布上铺半成品,避免名称和图标错位。
- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化
- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材
## 前端铺放规则
- spritesheet 图集放在原占位图左上附近。
- 第一张拆分图标放在 spritesheet 图集右侧,后续图标按行铺开,图标之间保留约 `24px` 世界坐标间距
- 每个图标图层标题使用对应素材描述文本。
- spritesheet 图集放在原占位图位置附近。
- 不再把图集右侧铺开独立图标,用户需要继续编辑时直接操作图集图层
- 生成成功后关闭图标素材面板,选中 spritesheet 图集,并打开图层面板。
## 验收
@@ -77,5 +74,5 @@
- 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述。
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
- 图标素材生成请求必须带 `model``aspectRatio``imageSize``nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2` 请求必须包含文档映射后的 `size`
- 生成成功后画布出现 spritesheet 图集和按描述命名的多个透明图标素材图层,图层之间不重叠
- 生成成功后画布出现一个已扣绿的透明 spritesheet 图集,不出现额外独立图标图层
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints``nanobanana2 1K` 应为 `12``gpt-image-2 1K` 应为 `3``gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
@@ -14,8 +14,8 @@ use platform_image::{
DownloadedImage,
generated_asset_sheets::{
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options,
apply_generated_asset_sheet_green_screen_alpha,
slice_generated_icon_spritesheet_all_by_connected_components,
slice_generated_icon_spritesheet_by_connected_components,
},
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
@@ -90,7 +90,6 @@ 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_IMAGE_ASSET_KIND: &str = "editor_icon_image";
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_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
@@ -1804,24 +1803,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
"message": "VectorEngine 未返回图标 spritesheet",
}))
})?;
let image = prepare_editor_icon_spritesheet_image_for_persist(image)?;
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.as_slice(),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-icon-spritesheet",
"message": error.to_string(),
}))
})?;
let generation_inputs = payload.generation_inputs.clone();
let owner_user_id = caller.owner_user_id;
let spritesheet_persisted = persist_editor_generated_image(
@@ -1862,64 +1847,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
},
)
.await?;
let mut icon_image_srcs = Vec::new();
for icon in icon_slices {
let icon_index = icon_image_srcs.len() + 1;
let icon_name = icon.name.clone();
let icon_width = icon.width;
let icon_height = icon.height;
let icon_image = DownloadedOpenAiImage {
bytes: icon.bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icon_file_stem = format!("icon-{icon_index:03}");
let icon_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&icon_image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_ICON_IMAGE_ASSET_KIND,
"icons",
icon_file_stem.as_str(),
"icon",
)
.await?;
let icon_image_src = editor_media_src_from_object_key(icon_persisted.object_key.as_str());
let icon_record = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: payload.asset_folder_id.clone(),
label: icon_name.clone(),
image_src: icon_image_src.clone(),
object_key: Some(icon_persisted.object_key.clone()),
asset_object_id: Some(icon_persisted.asset_object_id.clone()),
width: icon_width,
height: icon_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon".to_string()),
generation_inputs: generation_inputs.clone(),
},
)
.await?;
icon_image_srcs.push(EditorIconSpritesheetIconResponse {
name: icon_name,
image_src: icon_image_src,
width: icon_width,
height: icon_height,
resource: icon_record.resource,
asset: icon_record.asset,
});
}
let icon_image_srcs = Vec::new();
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_icon_spritesheet_canvas_layer_items(
@@ -3547,6 +3475,33 @@ fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String
)
}
fn prepare_editor_icon_spritesheet_image_for_persist(
image: DownloadedOpenAiImage,
) -> Result<DownloadedOpenAiImage, AppError> {
let decoded = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-icon-spritesheet",
"message": format!("图标素材图集解码失败:{error}"),
}))
})?;
let processed = apply_generated_asset_sheet_green_screen_alpha(decoded);
let mut encoded = std::io::Cursor::new(Vec::new());
processed
.write_to(&mut encoded, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-icon-spritesheet",
"message": format!("图标素材图集去绿失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool) -> String {
let mut prompt = vec![
"生成玩法UI原型图".to_string(),
@@ -4367,6 +4322,37 @@ mod tests {
assert_eq!(decoded.get_pixel(5, 5).0[3], 255);
}
#[test]
fn editor_icon_spritesheet_is_persisted_as_transparent_png() {
let width = 12;
let height = 12;
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 4..8 {
for x in 4..8 {
image.put_pixel(x, y, image::Rgba([188, 82, 45, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, image::ImageFormat::Png)
.expect("test image should encode");
let processed = prepare_editor_icon_spritesheet_image_for_persist(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("spritesheet should process");
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed image should decode")
.to_rgba8();
assert_eq!(processed.mime_type, "image/png");
assert_eq!(processed.extension, "png");
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
assert_eq!(decoded.get_pixel(5, 5).0[3], 255);
}
#[test]
fn editor_character_image_general_cutout_removes_muted_green_background() {
let width = 20;
@@ -4915,14 +4901,7 @@ mod tests {
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: vec![EditorIconSpritesheetIconResponse {
name: "返回按钮".to_string(),
image_src: "data:image/png;base64,back".to_string(),
width: 128,
height: 128,
resource: None,
asset: None,
}],
icon_image_srcs: Vec::new(),
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
@@ -4940,6 +4919,7 @@ mod tests {
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(payload["priceMudPoints"], json!(12));
assert_eq!(payload["iconImageSrcs"], json!([]));
}
#[test]
@@ -1248,12 +1248,7 @@ mod tests {
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: vec![EditorIconSpritesheetIconPayload {
name: "返回按钮".to_string(),
image_src: "data:image/png;base64,back".to_string(),
width: 128,
height: 128,
}],
icon_image_srcs: Vec::new(),
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
@@ -1264,10 +1259,7 @@ mod tests {
.expect("response should serialize");
assert_eq!(response_payload["priceMudPoints"], json!(12));
assert_eq!(
response_payload["iconImageSrcs"][0]["name"],
json!("返回按钮")
);
assert_eq!(response_payload["iconImageSrcs"], json!([]));
}
#[test]
@@ -70,6 +70,16 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
renderUiDesignAssetExtractionMarkedImageMock,
}));
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
const actual = await vi.importActual<
typeof import('./ImageCanvasProjectCoverSnapshotModel')
>('./ImageCanvasProjectCoverSnapshotModel');
return {
...actual,
createProjectCoverSnapshotBlob: vi.fn(async () => null),
};
});
describe('ImageCanvasEditorView generation integration', () => {
setupImageCanvasEditorViewTestLifecycle({
generateEditorImageMock,
@@ -1488,14 +1498,7 @@ describe('ImageCanvasEditorView generation integration', () => {
spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model',
spritesheetWidth: 1024,
spritesheetHeight: 1024,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back',
width: 128,
height: 128,
},
],
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 prompt',
model: 'gpt-image-2',
@@ -2241,7 +2244,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
});
it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => {
it('opens icon asset generation panel, only picks icon specs, and lays only the generated spritesheet on canvas', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icons',
title: '图标素材画布',
@@ -2284,20 +2287,7 @@ describe('ImageCanvasEditorView generation integration', () => {
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back-icon',
width: 96,
height: 96,
},
{
name: '设置按钮',
imageSrc: 'data:image/png;base64,setting-icon',
width: 96,
height: 96,
},
],
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 prompt',
model: 'gemini-3.1-flash-image-preview',
@@ -2324,7 +2314,7 @@ describe('ImageCanvasEditorView generation integration', () => {
name: '素材描述',
}) as HTMLTextAreaElement
).value,
).toBe('返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮');
).toBe('');
fireEvent.click(
within(iconPanel).getByRole('button', { name: '图标规范' }),
@@ -2371,34 +2361,37 @@ describe('ImageCanvasEditorView generation integration', () => {
);
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,icon-spec',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-icons',
assetFolderId: 'project',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '素材描述',
value: '返回按钮\n设置按钮',
}),
]),
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,icon-spec',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-icons',
assetFolderId: 'project',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '素材描述',
value: '返回按钮\n设置按钮',
}),
]),
}),
}),
}),
);
);
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy();
expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy();
expect(screen.getByAltText(//u)).toBeTruthy();
});
expect(screen.queryByAltText('画布图片:返回按钮')).toBeNull();
expect(screen.queryByAltText('画布图片:设置按钮')).toBeNull();
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
expect(screen.getAllByText('图')).toHaveLength(2);
expect(screen.getByText('图')).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
screen.getByRole('button', { name: /.*/u }),
);
const iconInfoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
@@ -2573,14 +2566,7 @@ describe('ImageCanvasEditorView generation integration', () => {
spritesheetImageSrc: 'data:image/png;base64,cmVuYW1lZC1zaGVldA==',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [
{
name: '背包按钮',
imageSrc: 'data:image/png;base64,YmFncGFjay1pY29u',
width: 96,
height: 96,
},
],
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 prompt',
model: 'gemini-3.1-flash-image-preview',
@@ -2651,20 +2637,23 @@ describe('ImageCanvasEditorView generation integration', () => {
);
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
iconDescriptions: ['背包按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
assetFolderId: 'project',
}),
);
await waitFor(() => {
expect(screen.getByAltText('画布图片:背包按钮')).toBeTruthy();
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
iconDescriptions: ['背包按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
assetFolderId: 'project',
}),
);
});
await waitFor(() => {
expect(screen.getByAltText(//u)).toBeTruthy();
});
expect(screen.queryByAltText('画布图片:背包按钮')).toBeNull();
});
it('exits character generation canvas picking with Escape', () => {
@@ -1537,10 +1537,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
iconImageSrcs: [
createIconResult('返回按钮', 'data:image/png;base64,back'),
createIconResult('设置按钮', 'data:image/png;base64,setting'),
],
iconImageSrcs: [],
});
render(
<SubmissionWorkflowHarness
@@ -1586,11 +1583,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'layer-icon-spritesheet-1:图标素材图集 1:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-2:返回按钮:-:icon',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-3:设置按钮:-:icon',
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-icon-2',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
@@ -1616,9 +1610,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
iconImageSrcs: [
createIconResult('返回按钮', 'data:image/png;base64,back'),
],
iconImageSrcs: [],
});
render(
<SubmissionWorkflowHarness
@@ -780,14 +780,7 @@ describe('editorProjectClient', () => {
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back',
width: 96,
height: 96,
},
],
iconImageSrcs: [],
prompt: '图标素材 prompt',
actualPrompt: '图标素材 prompt',
model: 'gemini-3.1-flash-image-preview',