diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index fab2f3f46..e95f10cce 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -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" } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 73d8e5f26..5fe718b42 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 83c3108aa..ddea4931e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.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`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index a134012c5..b7e13c8ce 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -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` 校验错误,不继续调用上游生成。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a621208fc..314d83be2 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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 { + 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] diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 42fe67d42..8bfc456e1 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -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] diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 157951b83..3dd7d2930 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -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', () => { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 3bbea8a43..e18833299 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -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( { '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( { 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',