新增图集连通域与可配置网格切分
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m37s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 4m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m31s
Project CI / Backend tests (push) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m2s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m45s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Native shell tests (push) Failing after 2m34s
Project CI / Frontend tests (push) Successful in 4m52s

增加 connected-components 与 grid 切分模式

支持 gridX/gridY 并同步 API、MCP、Skill、AGC 客户端

移除固定 2x2 图集切分契约与文档
This commit is contained in:
kdletters
2026-09-15 20:06:36 +08:00
parent 29e84a77d1
commit dd24690b02
28 changed files with 475 additions and 106 deletions
@@ -19,6 +19,12 @@ image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices.
When `agc_generate_image` is used with `kind="art-spritesheet"`, pass
`sliceMode="connected-components"` (the default alpha-connectivity splitter)
or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried
through the client request and returned result; do not infer it from the number
of slices.
## Authorization boundary
`agc_tools` is an AGC client-owned bridge to the AGC backend. In the normal client build it uses the current client login session and account routes; the user and model never need to provide, configure, paste, create, or rotate an API Key, Token, Cookie, URL, or `.env` value. If the tool returns `401` or `403`, report only that the AGC client login or permission state is unavailable, stop the operation, and do not ask the user for credentials or expose an internal URL.
@@ -15,6 +15,7 @@
- On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request.
- `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent.
- `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent.
- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata.
- General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim.
- `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities.
- `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys.
@@ -549,6 +549,7 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value {
}
"agc_generate_image" => {
copy_string(object, "kind", &mut out);
copy_string(object, "sliceMode", &mut out);
copy_string(object, "aspectRatio", &mut out);
copy_string(object, "imageSize", &mut out);
copy_string(object, "assetName", &mut out);
@@ -2783,6 +2783,9 @@ async fn generate_direct_taonier_art_asset_at(
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
@@ -2104,6 +2104,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
"imageSize",
"assetName",
"outputPath",
"sliceMode",
"gridX",
"gridY",
],
)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
@@ -2145,6 +2148,44 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
.transpose()?
.unwrap_or_else(|| "AI 生成图片".to_string());
let output_path = bridge_optional_bounded_string(arguments, "outputPath", 512)?;
let slice_mode = arguments
.get("sliceMode")
.map(|_| bridge_bounded_string(arguments, "sliceMode", 32))
.transpose()?;
if slice_mode
.as_deref()
.is_some_and(|mode| !matches!(mode, "connected-components" | "grid"))
{
return Err("工具参数 sliceMode 只允许 connected-components 或 grid".to_string());
}
let grid_x = arguments
.get("gridX")
.map(|_| {
arguments
.get("gridX")
.and_then(Value::as_u64)
.map(|value| value as u32)
.ok_or_else(|| "工具参数 gridX 必须是整数".to_string())
})
.transpose()?;
let grid_y = arguments
.get("gridY")
.map(|_| {
arguments
.get("gridY")
.and_then(Value::as_u64)
.map(|value| value as u32)
.ok_or_else(|| "工具参数 gridY 必须是整数".to_string())
})
.transpose()?;
if slice_mode.as_deref() == Some("grid") && (grid_x.is_none() || grid_y.is_none()) {
return Err("grid 模式必须同时提供 gridX 与 gridY".to_string());
}
if grid_x.is_some_and(|value| !(1..=32).contains(&value))
|| grid_y.is_some_and(|value| !(1..=32).contains(&value))
{
return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string());
}
let options = PlatformArtAssetGenerationOptions {
output_path,
aspect_ratio,
@@ -2153,6 +2194,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
asset_label: asset_name.clone(),
replace_existing: false,
slice_count: None,
slice_mode,
grid_x,
grid_y,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -244,6 +244,24 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string",
"maxLength": 512,
"description": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件"
},
"sliceMode": {
"type": "string",
"enum": ["connected-components", "grid"],
"default": "connected-components",
"description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分"
},
"gridX": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"description": "grid 模式横向网格数量"
},
"gridY": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"description": "grid 模式纵向网格数量"
}
},
"required": ["prompt"],
@@ -1014,6 +1032,9 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
"imageSize",
"assetName",
"outputPath",
"sliceMode",
"gridX",
"gridY",
],
) {
return mcp_tool_result(error, Vec::new(), true);
@@ -1041,6 +1062,7 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
("imageSize", 4),
("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS),
("outputPath", 512),
("sliceMode", 32),
] {
if arguments.get(field).is_some() {
if let Err(error) = bounded_tool_string(arguments, field, max_chars) {
@@ -2226,6 +2248,10 @@ mod tests {
assert!(image_tool["description"]
.as_str()
.is_some_and(|description| description.contains("不是本工具的限制")));
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
let edit_tool = specs["tools"]
.as_array()
.expect("tool array")
@@ -413,6 +413,9 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
pub(crate) asset_label: String,
pub(crate) replace_existing: bool,
pub(crate) slice_count: Option<usize>,
pub(crate) slice_mode: Option<String>,
pub(crate) grid_x: Option<u32>,
pub(crate) grid_y: Option<u32>,
}
impl Default for PlatformArtAssetGenerationOptions {
@@ -425,6 +428,9 @@ impl Default for PlatformArtAssetGenerationOptions {
asset_label: "AI 游戏首版美术素材".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
}
}
}
@@ -1574,7 +1580,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
warning: Option<String>,
slice_warning: Option<String>,
slices: Vec<PreparedPlatformArtAssetSlice>,
spritesheet_slice_layout: Option<String>,
spritesheet_slice_mode: Option<String>,
generation_route: String,
generation_kind: String,
reference_resource_ids: Vec<String>,
@@ -2213,11 +2219,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
/// 而任何输入不同(提示词、输出路径、比例、尺寸、类型、标签、严格切片)都是另一个
/// 动作,必须各自独立成槽,才能在同一项目里同时在途。
///
/// **字段集合与取值方式必须与升级前逐字节一致**:升级前遗留账本里持久化的
/// `actionFingerprint` 就是这个材料的历史哈希,改动材料会让旧账本无法按精确动作被
/// 识别与迁移(见 `adopt_legacy_standalone_platform_art_generation_runtime_state_at`)。
/// 已知边界:`slice_count` 不进身份(与升级前一致),仅切片数不同的两条图集请求仍落到
/// 同一槽,第二条在账本请求正文校验处失败关闭,不会二次 POST。
/// 升级前遗留账本仍由旧材料函数定位;新请求把显式切分模式纳入身份,避免同一图集
/// 请求在网格与连通域之间误复用。`slice_count` 继续保持历史兼容语义,不进身份。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
@@ -2229,6 +2232,9 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
asset_label: &'a str,
replace_existing: bool,
require_slices: bool,
slice_mode: Option<&'a str>,
grid_x: Option<u32>,
grid_y: Option<u32>,
}
/// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`
@@ -2265,6 +2271,9 @@ fn standalone_platform_art_generation_runtime_context(
asset_label: &options.asset_label,
replace_existing: options.replace_existing,
require_slices,
slice_mode: options.slice_mode.as_deref(),
grid_x: options.grid_x,
grid_y: options.grid_y,
})
.map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?;
let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes));
@@ -2811,6 +2820,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
"referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"sliceCount": options.slice_count,
"sliceMode": options.slice_mode,
"gridX": options.grid_x,
"gridY": options.grid_y,
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
@@ -3106,8 +3118,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
Vec::new()
};
let warning = platform_art_generation_warning(generated);
let spritesheet_slice_layout = if is_canonical_art_spritesheet {
json_string_field(generated, "sliceLayout")
let spritesheet_slice_mode = if is_canonical_art_spritesheet {
json_string_field(generated, "sliceMode")
} else {
None
};
@@ -3168,7 +3180,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
warning,
slice_warning,
slices,
spritesheet_slice_layout,
spritesheet_slice_mode,
generation_route,
generation_kind,
reference_resource_ids,
@@ -6508,7 +6520,7 @@ fn validate_strict_platform_art_spritesheet_contract(
task_id: Option<&str>,
generation_route: &str,
generation_kind: &str,
spritesheet_slice_layout: Option<&str>,
spritesheet_slice_mode: Option<&str>,
reference_resource_ids: &[String],
has_transparent_pixels: bool,
has_visible_pixels: bool,
@@ -6545,7 +6557,7 @@ fn validate_strict_platform_art_spritesheet_contract(
{
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
}
let _requested_slice_layout = spritesheet_slice_layout;
let _requested_slice_mode = spritesheet_slice_mode;
if reference_resource_ids.len() != 1
|| reference_resource_ids[0].trim().is_empty()
|| reference_resource_ids[0].trim() == resource_id
@@ -7307,7 +7319,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
warning,
mut slice_warning,
slices,
spritesheet_slice_layout,
spritesheet_slice_mode,
generation_route,
generation_kind,
reference_resource_ids,
@@ -7326,7 +7338,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
task_id.as_deref(),
&generation_route,
&generation_kind,
spritesheet_slice_layout.as_deref(),
spritesheet_slice_mode.as_deref(),
&reference_resource_ids,
spritesheet_has_transparent_pixels,
spritesheet_has_visible_pixels,
@@ -7901,8 +7913,8 @@ mod canvas_generation_tests {
let body = serde_json::json!({
"error": {
"code": "invalid-request",
"field": "sliceLayout",
"message": "只支持 grid-2x2operationId=private-operation-idapi_key=private-key",
"field": "sliceMode",
"message": "只支持 gridoperationId=private-operation-idapi_key=private-key",
},
"details": {
"path": "C:\\Users\\private\\secret.json",
@@ -7911,8 +7923,8 @@ mod canvas_generation_tests {
.to_string();
let summary = summarize_external_http_error_body(&body).expect("summary");
assert!(summary.contains("code=invalid-request"), "{summary}");
assert!(summary.contains("field=sliceLayout"), "{summary}");
assert!(summary.contains("只支持 grid-2x2"), "{summary}");
assert!(summary.contains("field=sliceMode"), "{summary}");
assert!(summary.contains("只支持 grid"), "{summary}");
assert!(!summary.contains("private-operation-id"), "{summary}");
assert!(!summary.contains("private-key"), "{summary}");
assert!(!summary.contains("C:\\Users\\private"), "{summary}");
@@ -8312,6 +8324,9 @@ mod canvas_generation_tests {
asset_label: "手工背景".to_string(),
replace_existing: true,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let ordinary =
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
@@ -9826,7 +9841,7 @@ mod canvas_generation_tests {
Some("spritesheet-task"),
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
Some("grid-2x2"),
Some("grid"),
&["art-spec-resource".to_string()],
true,
true,
@@ -10325,6 +10340,9 @@ mod canvas_generation_tests {
asset_label: "整包规范图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = "生成同一套整包美术";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11220,6 +11238,9 @@ mod canvas_generation_tests {
asset_label: "整包背景图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = "保持同一个生成提示词";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11681,6 +11702,9 @@ mod canvas_generation_tests {
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -12287,6 +12311,9 @@ mod canvas_generation_tests {
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: true,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
}
}
@@ -12317,7 +12344,7 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices: Vec::new(),
spritesheet_slice_layout: Some("grid-2x2".to_string()),
spritesheet_slice_mode: Some("grid".to_string()),
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
@@ -12636,7 +12663,7 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices,
spritesheet_slice_layout: Some("grid-2x2".to_string()),
spritesheet_slice_mode: Some("grid".to_string()),
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
@@ -555,6 +555,17 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.or_else(|| input.get("slice_count"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as usize);
let slice_mode = agent_runtime_tool_input_text(input, &["sliceMode", "slice_mode"]);
let grid_x = input
.get("gridX")
.or_else(|| input.get("grid_x"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as u32);
let grid_y = input
.get("gridY")
.or_else(|| input.get("grid_y"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as u32);
let mut requested_options = PlatformArtAssetGenerationOptions {
output_path: (!output_path.trim().is_empty()).then_some(output_path),
aspect_ratio,
@@ -563,6 +574,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
asset_label,
replace_existing,
slice_count,
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
grid_x,
grid_y,
};
if let Some(pending) = pending_action {
match recover_persisted_visual_generation_options(
@@ -609,6 +623,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
},
replace_existing,
slice_count,
slice_mode: requested_options
.slice_mode
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
grid_x,
grid_y,
}
};
options.replace_existing = replace_existing;
@@ -654,6 +673,28 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
detail: None,
};
}
if options
.slice_mode
.as_deref()
.is_some_and(|slice_mode| !matches!(slice_mode, "connected-components" | "grid"))
{
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: "图片生成 sliceMode 不受支持".to_string(),
detail: None,
};
}
if options.slice_mode.as_deref() == Some("grid")
&& (options.grid_x.is_none() || options.grid_y.is_none())
{
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: "grid 模式必须同时提供 gridX 与 gridY".to_string(),
detail: None,
};
}
if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
@@ -4594,6 +4594,9 @@ pub(crate) fn prepare_local_project_asset_generation(
.unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
},
})
}
@@ -3192,7 +3192,9 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
"spritesheetImageSrc": "/generated/canvas/spritesheet.png",
"spritesheetWidth": 2,
"spritesheetHeight": 1,
"sliceLayout": "grid-2x2",
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"iconImageSrcs": icon_image_srcs,
"sliceWarning": null,
"prompt": "原创游戏素材图集",
@@ -1076,6 +1076,9 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
},
)
.await;
@@ -5493,6 +5496,9 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",