diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md
index e4bbafbc2..93c8592bb 100644
--- a/.codex/skills/genarrative-external-editor-api/SKILL.md
+++ b/.codex/skills/genarrative-external-editor-api/SKILL.md
@@ -32,7 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
-- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output.
+- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
index a744d8589..25c807788 100644
--- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md
+++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
@@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
-`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result.
+`sliceMode` is required and has no default, so every request must state it. Use `"connected-components"` to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each) only when the requirement names equal grid cells or fixed slots; the dimensions must come from that requirement. `connected-components` must not carry `gridX`/`gridY`, and `sliceCount` constrains the connected-component result instead of expressing a grid. Omitting `sliceMode`, or contradicting the declared mode with grid dimensions, returns 400 before pricing, enqueueing, or any provider call.
## Common Values
diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
index af0ee338a..8a48a8347 100644
--- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
+++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
@@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
-3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default).
+3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
-For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `
`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
+For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `
`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py
index c7c0ab33b..bc270fe38 100644
--- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py
+++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py
@@ -617,6 +617,7 @@ class GenarrativeExternalClient:
self,
reference_id: str,
icon_descriptions: list[str],
+ slice_mode: str,
**fields: Any,
) -> Any:
reference_id = normalize_optional_text(reference_id)
@@ -625,6 +626,20 @@ class GenarrativeExternalClient:
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
if not descriptions:
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
+ slice_mode = normalize_optional_text(slice_mode)
+ if slice_mode not in ("connected-components", "grid"):
+ raise GenarrativeApiError(
+ "slice_mode must be declared explicitly as 'connected-components' or 'grid'; the API has no default"
+ )
+ grid_x = fields.get("gridX")
+ grid_y = fields.get("gridY")
+ if slice_mode == "grid":
+ if grid_x is None or grid_y is None:
+ raise GenarrativeApiError("slice_mode='grid' requires both gridX and gridY")
+ elif grid_x is not None or grid_y is not None:
+ raise GenarrativeApiError(
+ "slice_mode='connected-components' must not carry gridX/gridY"
+ )
label = fields.get("assetLabel", "图标图集")
self._apply_canvas_session_fields(fields, label, 1024, 1024)
fields.setdefault("screenColor", "auto")
@@ -635,6 +650,7 @@ class GenarrativeExternalClient:
**fields,
"referenceId": reference_id,
"iconDescriptions": descriptions,
+ "sliceMode": slice_mode,
},
idempotency_key=idempotency_key,
)
@@ -879,9 +895,9 @@ def _self_test() -> None:
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
+ "connected-components",
canvasSession=session,
assetLabel="贪吃蛇透明图集",
- sliceMode="connected-components",
referenceId="must-not-override-explicit-reference",
iconDescriptions=["不得覆盖显式图标描述"],
)
diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
index fed45aa02..c0a748ad3 100644
--- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
+++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
@@ -724,6 +724,7 @@ function canvasAssetCall(agentId) {
assetKind: 'art-spritesheet',
assetLabel: '游戏首版核心美术素材',
replaceExisting: false,
+ sliceMode: 'connected-components',
});
}
@@ -2691,7 +2692,7 @@ function createDeterministicCanvasFixture(apiKey) {
'deterministic spritesheet fixture',
model: 'deterministic-canvas-v1',
provider: 'deterministic-loopback',
- sliceLayout: 'grid-2x2',
+ sliceMode: 'connected-components',
spritesheetResource: {
resourceId,
projectId,
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json
index c57ea3638..02b8fa2d1 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
- "version": "2026-08-26.17",
+ "version": "2026-08-26.19",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -63,7 +63,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
- "sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec"
+ "sha256": "c6329c6a3cbd17a237d042349d7fd8adcf240287ef56d23b49329923e976d534"
},
{
"name": "agc-web-game-development",
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md
index d652b4436..c5c036c2b 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md
@@ -19,9 +19,20 @@ 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
+When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
+required and has no default, so decide it explicitly:
+
+- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
+ or brief actually names equal grid cells, fixed slots, or a concrete
+ column/row count; those dimensions must come from that requirement.
+- Use `sliceMode="connected-components"` for free-form sheets, an open number of
+ subjects, or a request for one sheet; constrain the subject count with
+ `sliceCount` instead of inventing grid dimensions.
+
+Never assume `2x2` or any other grid to express "four kinds of assets", never
+pass `gridX`/`gridY` together with `connected-components`, and never pass
+`sliceMode` for another `kind`. The client rejects a missing, contradictory, or
+misapplied declaration instead of choosing for you. The selected mode is carried
through the client request and returned result; do not infer it from the number
of slices.
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md
index bf0b74481..603b8077b 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md
@@ -15,7 +15,8 @@
- 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.
+- For direct `agc_generate_image` spritesheet requests, `sliceMode` is required and has no default: `connected-components` selects alpha-connectivity detection, while `grid` uses the caller-provided `gridX` and `gridY` (1-32 each) and is only correct when the requirement names equal grid cells, fixed slots, or a concrete column/row count. `connected-components` must not carry `gridX`/`gridY`, and `sliceMode` must not be sent for another `kind`; the client rejects a missing, contradictory, or misapplied declaration instead of choosing a mode. The client preserves the selected mode and grid dimensions in the request identity and result metadata.
+- The client-owned standard art package declares `sliceMode="connected-components"` with `sliceCount=4` because its four canonical slices are mapped to fixed usage paths: the platform must return exactly four slices or fail with an actionable `422` naming the recognized count, and the client refuses to write a usage manifest whose slice count is not exactly four. A `sliceMode` or grid-dimension echo that disagrees with the request also fails closed before local commit.
- 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.
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs
index 619fd4162..945b16c1e 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs
@@ -3382,8 +3382,12 @@ async fn generate_direct_taonier_art_asset_at(
asset_kind: asset_kind.to_string(),
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
- slice_count: None,
- slice_mode: None,
+ // 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量,
+ // 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。
+ slice_count: (asset_kind == "art-spritesheet").then_some(4),
+ // 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在
+ // art-spritesheet 阶段显式声明连通域切分。
+ slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
};
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
index 705da3ecb..08657a534 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
@@ -2158,6 +2158,36 @@ fn bridge_image_generation_kind(arguments: &Value) -> Result {
})
}
+/// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。
+fn validate_generate_image_slice_declaration(
+ kind: &str,
+ slice_mode: Option<&str>,
+ grid_x: Option,
+ grid_y: Option,
+ slice_count: Option,
+) -> Result<(), String> {
+ if kind == "art-spritesheet" {
+ if slice_mode.is_none() {
+ return Err(
+ "kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components"
+ .to_string(),
+ );
+ }
+ if slice_mode == Some("grid") && slice_count.is_some() {
+ return Err(
+ "sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount".to_string(),
+ );
+ }
+ return Ok(());
+ }
+ if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
+ return Err(format!(
+ "工具参数 sliceMode/gridX/gridY 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
+ ));
+ }
+ Ok(())
+}
+
async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
bridge_reject_unknown_fields(
@@ -2251,6 +2281,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
{
return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string());
}
+ validate_generate_image_slice_declaration(
+ kind.as_str(),
+ slice_mode.as_deref(),
+ grid_x,
+ grid_y,
+ None,
+ )?;
let options = PlatformArtAssetGenerationOptions {
output_path,
aspect_ratio,
@@ -2297,6 +2334,14 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
"resources": resources,
"warnings": generated.warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(),
"sliceWarnings": generated.slice_warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(),
+ "sliceMode": generated.slice_mode,
+ "gridX": generated.grid_x,
+ "gridY": generated.grid_y,
+ "slicePaths": generated
+ .slices
+ .iter()
+ .map(|slice| slice.local_path.clone())
+ .collect::>(),
})
.to_string(),
images,
@@ -2731,6 +2776,55 @@ pub(crate) async fn start_direct_tool_bridge(
#[cfg(test)]
mod tests {
+ #[test]
+ fn generate_image_slice_declaration_is_explicit_and_self_consistent() {
+ let missing =
+ validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None)
+ .expect_err("art-spritesheet without sliceMode must fail closed");
+ assert!(missing.contains("没有默认值"), "{missing}");
+ assert!(missing.contains("connected-components"), "{missing}");
+
+ assert!(validate_generate_image_slice_declaration(
+ "art-spritesheet",
+ Some("connected-components"),
+ None,
+ None,
+ Some(4),
+ )
+ .is_ok());
+ assert!(validate_generate_image_slice_declaration(
+ "art-spritesheet",
+ Some("grid"),
+ Some(3),
+ Some(2),
+ None,
+ )
+ .is_ok());
+ let grid_with_count = validate_generate_image_slice_declaration(
+ "art-spritesheet",
+ Some("grid"),
+ Some(2),
+ Some(2),
+ Some(4),
+ )
+ .expect_err("grid mode must not carry sliceCount");
+ assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}");
+
+ let wrong_kind = validate_generate_image_slice_declaration(
+ "image",
+ Some("connected-components"),
+ None,
+ None,
+ None,
+ )
+ .expect_err("slice declaration must stay scoped to art-spritesheet");
+ assert!(
+ wrong_kind.contains("仅对 kind=art-spritesheet 生效"),
+ "{wrong_kind}"
+ );
+ assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
+ }
+
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs
index 8b0cd12a0..1c957ccca 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs
@@ -248,20 +248,19 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"sliceMode": {
"type": "string",
"enum": ["connected-components", "grid"],
- "default": "connected-components",
- "description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分"
+ "description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择"
},
"gridX": {
"type": "integer",
"minimum": 1,
"maximum": 32,
- "description": "grid 模式横向网格数量"
+ "description": "grid 模式横向网格数量,只能与 sliceMode=grid 同时提供"
},
"gridY": {
"type": "integer",
"minimum": 1,
"maximum": 32,
- "description": "grid 模式纵向网格数量"
+ "description": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供"
}
},
"required": ["prompt"],
@@ -2344,6 +2343,20 @@ mod tests {
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
+ assert!(
+ image_tool["inputSchema"]["properties"]["sliceMode"]
+ .get("default")
+ .is_none(),
+ "sliceMode must not advertise a default"
+ );
+ assert!(
+ image_tool["inputSchema"]["properties"]["sliceMode"]["description"]
+ .as_str()
+ .is_some_and(|description| description.contains("没有默认值")
+ && description.contains("gridX")
+ && description.contains("connected-components")),
+ "sliceMode description must carry the explicit decision requirement"
+ );
let edit_tool = specs["tools"]
.as_array()
.expect("tool array")
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
index f545cc279..a3b5be2a0 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
@@ -1564,6 +1564,8 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
slice_warning: Option,
slices: Vec,
spritesheet_slice_mode: Option,
+ spritesheet_grid_x: Option,
+ spritesheet_grid_y: Option,
generation_route: String,
generation_kind: String,
reference_resource_ids: Vec,
@@ -2468,6 +2470,31 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
if require_slices && options.asset_kind != "art-spritesheet" {
return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string());
}
+ // 切分模式没有默认值:图集生成必须在客户端显式声明,缺失或自相矛盾都在付费提交前失败。
+ if options.asset_kind == "art-spritesheet" {
+ let Some(slice_mode) = options.slice_mode.as_deref() else {
+ return Err(
+ "图集生成必须显式声明 sliceMode:等分网格或固定槽位用 grid 并提供 gridX/gridY,自由排布用 connected-components"
+ .to_string(),
+ );
+ };
+ if !matches!(slice_mode, "connected-components" | "grid") {
+ return Err(format!("图集切分模式不受支持:{slice_mode}"));
+ }
+ if slice_mode == "grid" && (options.grid_x.is_none() || options.grid_y.is_none()) {
+ return Err("sliceMode=grid 必须同时提供 gridX 与 gridY".to_string());
+ }
+ if slice_mode == "connected-components"
+ && (options.grid_x.is_some() || options.grid_y.is_some())
+ {
+ return Err(
+ "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明"
+ .to_string(),
+ );
+ }
+ } else if options.slice_mode.is_some() || options.grid_x.is_some() || options.grid_y.is_some() {
+ return Err("sliceMode/gridX/gridY 仅对 art-spritesheet 生效".to_string());
+ }
if super::external_generation_state::is_standalone_platform_art_generation_runtime_context(
runtime_context,
) && game_creator_agent_runtime_external_generation_exists(
@@ -3106,6 +3133,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
} else {
None
};
+ let spritesheet_grid_x = if is_canonical_art_spritesheet {
+ json_u32_field(generated, "gridX")
+ } else {
+ None
+ };
+ let spritesheet_grid_y = if is_canonical_art_spritesheet {
+ json_u32_field(generated, "gridY")
+ } else {
+ None
+ };
let resource_id = json_string_field(resource, "resourceId");
let task_id = if is_canonical_art_spritesheet {
consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])?
@@ -3164,6 +3201,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
slice_warning,
slices,
spritesheet_slice_mode,
+ spritesheet_grid_x,
+ spritesheet_grid_y,
generation_route,
generation_kind,
reference_resource_ids,
@@ -6494,6 +6533,50 @@ impl PlatformArtSliceContractRollback {
}
}
+fn json_u32_field(value: &serde_json::Value, field: &str) -> Option {
+ value
+ .get(field)
+ .and_then(serde_json::Value::as_u64)
+ .and_then(|value| u32::try_from(value).ok())
+}
+
+/// 严格图集必须在请求与响应两端证明同一个切分声明:请求显式声明的模式必须被平台
+/// 原样回显,grid 的行列数也必须一致;否则本地无法判断实际按哪种方式切片。
+fn validate_platform_art_spritesheet_slice_declaration_matches_response(
+ options: &PlatformArtAssetGenerationOptions,
+ response_slice_mode: Option<&str>,
+ response_grid_x: Option,
+ response_grid_y: Option,
+) -> Result<(), String> {
+ let requested = options
+ .slice_mode
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| "图集生成缺少显式 sliceMode 声明,已拒绝提交严格图集".to_string())?;
+ let responded = response_slice_mode
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| {
+ "平台图集响应没有回显 sliceMode,无法证明切分方式与请求一致,已在本地落盘前拒绝提交"
+ .to_string()
+ })?;
+ if responded != requested {
+ return Err(format!(
+ "平台图集响应回显的 sliceMode={responded} 与请求 {requested} 不一致,已拒绝提交"
+ ));
+ }
+ if requested == "grid"
+ && (response_grid_x != options.grid_x || response_grid_y != options.grid_y)
+ {
+ return Err(format!(
+ "平台图集响应回显的 gridX/gridY={:?}/{:?} 与请求 {:?}/{:?} 不一致,已拒绝提交",
+ response_grid_x, response_grid_y, options.grid_x, options.grid_y
+ ));
+ }
+ Ok(())
+}
+
fn validate_strict_platform_art_spritesheet_contract(
slices: &[PreparedPlatformArtAssetSlice],
slice_warning: Option<&str>,
@@ -6504,7 +6587,6 @@ fn validate_strict_platform_art_spritesheet_contract(
task_id: Option<&str>,
generation_route: &str,
generation_kind: &str,
- spritesheet_slice_mode: Option<&str>,
reference_resource_ids: &[String],
has_transparent_pixels: bool,
has_visible_pixels: bool,
@@ -6545,7 +6627,6 @@ fn validate_strict_platform_art_spritesheet_contract(
{
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
}
- 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
@@ -7093,6 +7174,15 @@ fn commit_strict_platform_art_slices_at(
"obstacles-and-scene",
"feedback-effects",
];
+ // 标准图集按用途位置映射到固定路径;数量不一致时必须失败关闭,不能靠 zip 静默截断
+ // 或写入用途错位的切片清单。
+ if slices.len() != usages.len() {
+ return Err(format!(
+ "标准美术图集必须正好包含 {} 张 canonical 切片,平台返回了 {} 张,已拒绝写入以避免用途错位",
+ usages.len(),
+ slices.len()
+ ));
+ }
let mut generated = Vec::with_capacity(slices.len());
let mut registrations = Vec::with_capacity(slices.len());
let mut content_sha256s = Vec::with_capacity(slices.len());
@@ -7308,6 +7398,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
mut slice_warning,
slices,
spritesheet_slice_mode,
+ spritesheet_grid_x,
+ spritesheet_grid_y,
generation_route,
generation_kind,
reference_resource_ids,
@@ -7317,6 +7409,12 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
recover_existing_outputs,
} = prepared;
if require_complete_core_slices {
+ validate_platform_art_spritesheet_slice_declaration_matches_response(
+ options,
+ spritesheet_slice_mode.as_deref(),
+ spritesheet_grid_x,
+ spritesheet_grid_y,
+ )?;
validate_strict_platform_art_spritesheet_contract(
&slices,
slice_warning.as_deref(),
@@ -7327,7 +7425,6 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
task_id.as_deref(),
&generation_route,
&generation_kind,
- spritesheet_slice_mode.as_deref(),
&reference_resource_ids,
spritesheet_has_transparent_pixels,
spritesheet_has_visible_pixels,
@@ -7688,6 +7785,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
})).collect::>(),
"generationRoute": generation_route,
"generationKind": generation_kind,
+ "sliceMode": spritesheet_slice_mode.clone(),
+ "gridX": spritesheet_grid_x,
+ "gridY": spritesheet_grid_y,
"referenceResourceIds": reference_resource_ids,
}),
);
@@ -7697,6 +7797,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
Ok(GeneratedPlatformArtAsset {
asset: registered,
slices: generated_slices,
+ slice_mode: spritesheet_slice_mode.or_else(|| options.slice_mode.clone()),
+ grid_x: spritesheet_grid_x.or(options.grid_x),
+ grid_y: spritesheet_grid_y.or(options.grid_y),
resource_id,
asset_object_id,
task_id,
@@ -9789,7 +9892,6 @@ mod canvas_generation_tests {
Some("spritesheet-task"),
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
- None,
&["art-spec-resource".to_string()],
true,
true,
@@ -9814,7 +9916,6 @@ mod canvas_generation_tests {
None,
"route",
"kind",
- None,
&[],
false,
false,
@@ -9871,7 +9972,6 @@ mod canvas_generation_tests {
Some("spritesheet-task"),
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
- Some("grid"),
&["art-spec-resource".to_string()],
true,
true,
@@ -12347,7 +12447,7 @@ mod canvas_generation_tests {
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: true,
slice_count: None,
- slice_mode: None,
+ slice_mode: Some("connected-components".to_string()),
grid_x: None,
grid_y: None,
}
@@ -12380,7 +12480,9 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices: Vec::new(),
- spritesheet_slice_mode: Some("grid".to_string()),
+ spritesheet_slice_mode: Some("connected-components".to_string()),
+ spritesheet_grid_x: None,
+ spritesheet_grid_y: None,
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()],
@@ -12704,7 +12806,9 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices,
- spritesheet_slice_mode: Some("grid".to_string()),
+ spritesheet_slice_mode: Some("connected-components".to_string()),
+ spritesheet_grid_x: None,
+ spritesheet_grid_y: None,
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()],
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs
index 6f7aa3441..5ba2980c2 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs
@@ -695,6 +695,53 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
detail: None,
};
}
+ // 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。
+ if options.asset_kind == "art-spritesheet" {
+ if options.slice_mode.is_none() {
+ return AgentRuntimeToolObservation {
+ tool: "canvas.asset_generate".to_string(),
+ status: "failed".to_string(),
+ summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components"
+ .to_string(),
+ detail: None,
+ };
+ }
+ if options.slice_mode.as_deref() == Some("connected-components")
+ && (options.grid_x.is_some() || options.grid_y.is_some())
+ {
+ return AgentRuntimeToolObservation {
+ tool: "canvas.asset_generate".to_string(),
+ status: "failed".to_string(),
+ summary:
+ "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明"
+ .to_string(),
+ detail: None,
+ };
+ }
+ if options.slice_mode.as_deref() == Some("grid") && options.slice_count.is_some() {
+ return AgentRuntimeToolObservation {
+ tool: "canvas.asset_generate".to_string(),
+ status: "failed".to_string(),
+ summary: "sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount"
+ .to_string(),
+ detail: None,
+ };
+ }
+ } else if options.slice_mode.is_some()
+ || options.grid_x.is_some()
+ || options.grid_y.is_some()
+ || options.slice_count.is_some()
+ {
+ return AgentRuntimeToolObservation {
+ tool: "canvas.asset_generate".to_string(),
+ status: "failed".to_string(),
+ summary: format!(
+ "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}",
+ options.asset_kind
+ ),
+ detail: None,
+ };
+ }
if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
index 45ba5c505..2240b3341 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
@@ -1036,7 +1036,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate" => {
- "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。"
+ "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。"
}
"ui.workflow.run" => {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
@@ -1311,7 +1311,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
asset_kinds.push(Value::Null);
json!({
"type": "object",
- "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting"],
+ "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting", "sliceMode", "gridX", "gridY", "sliceCount"],
"additionalProperties": false,
"properties": {
"prompt": { "type": "string", "minLength": 1, "maxLength": 4000 },
@@ -1320,7 +1320,11 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] },
"assetKind": { "type": ["string", "null"], "enum": asset_kinds },
"assetLabel": { "type": ["string", "null"], "maxLength": 80 },
- "replaceExisting": { "type": "boolean" }
+ "replaceExisting": { "type": "boolean" },
+ "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" },
+ "gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" },
+ "gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" },
+ "sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" }
}
})
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
index f5724810b..739dc5a3f 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
@@ -4647,7 +4647,10 @@ 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,
+ // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet
+ // 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。
+ slice_mode: (asset_kind == "art-spritesheet")
+ .then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
},
diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs
index 95c7726f6..87608cc7d 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/config.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs
@@ -1198,6 +1198,22 @@ pub(crate) fn prepare_game_creator_project_root_for_read(
{
WindowsAclRepairScope::UserSelected
} else {
+ #[cfg(all(windows, test))]
+ if windows_test_temp_path_needs_owner_initialization(path, is_directory) {
+ // 测试夹具:在提权 shell 里,系统临时目录下新建的目录默认所有者是
+ // Administrators 组而不是当前 TokenUser,测试进程无法提权改所有者。
+ // 该目录由当前测试进程创建,因此按“本调用创建的对象”初始化所有者后
+ // 重试;其它越权所有者、以及临时目录之外的路径仍然失败关闭。
+ if windows_path_is_under_test_temp_dir(path) {
+ secure_windows_game_creator_path_for_current_user_with_owner_policy(
+ path,
+ is_directory,
+ true,
+ true,
+ )?;
+ return Ok(true);
+ }
+ }
return secure_windows_game_creator_path_for_current_user(path, is_directory, true)
.map(|_| true);
};
@@ -1720,7 +1736,7 @@ pub(crate) fn prepare_game_creator_private_path_for_read(
true,
)
} else {
- secure_windows_game_creator_path_for_current_user(path, is_directory, true)
+ verify_game_creator_private_path_or_test_temp_owner(path, is_directory)
};
return result.map(|_| true).map_err(|repair_error| {
if game_creator_private_path_allows_auto_elevation(path) {
@@ -1763,7 +1779,7 @@ pub(crate) fn prepare_game_creator_private_path_for_read(
} else {
// User-selected external files are never silently adopted. Keep the
// strict owner/DACL check, but do not escalate an arbitrary path.
- secure_windows_game_creator_path_for_current_user(path, is_directory, true)?;
+ verify_game_creator_private_path_or_test_temp_owner(path, is_directory)?;
}
Ok(true)
}
@@ -2114,6 +2130,68 @@ pub(crate) fn validate_game_creator_runtime_config_dir_outside_project(
Ok(())
}
+/// 测试夹具专用:判断某个已存在的项目根是否只是“系统临时目录下所有者不是当前用户”。
+///
+/// 部分 Windows 主机(例如以提权 shell 运行测试)在 `%TEMP%` 下新建的目录,默认所有者是
+/// `BUILTIN\Administrators` 组而不是当前 TokenUser;测试进程无法提权改所有者,于是严格
+/// 校验会拒绝一个由测试自己创建、且确实位于系统临时目录的目录。只有测试构建、路径位于
+/// 系统临时目录、并且失败原因确实是所有者不匹配时才返回 true;临时目录之外的越权所有者
+/// 继续失败关闭。
+#[cfg(all(windows, test))]
+fn windows_test_temp_path_needs_owner_initialization(path: &Path, is_directory: bool) -> bool {
+ if !path.is_absolute() {
+ return false;
+ }
+ match secure_windows_game_creator_path_for_current_user(path, is_directory, true) {
+ Ok(()) => false,
+ Err(error) => {
+ error.contains("安全对象不属于当前用户") && windows_path_is_under_test_temp_dir(path)
+ }
+ }
+}
+
+/// 严格校验一个既有私有对象;测试构建下对系统临时目录内的所有者偏差做一次性所有者
+/// 初始化重试,其余情况保持严格失败关闭。
+#[cfg(windows)]
+fn verify_game_creator_private_path_or_test_temp_owner(
+ path: &Path,
+ is_directory: bool,
+) -> Result<(), String> {
+ #[cfg(test)]
+ if windows_test_temp_path_needs_owner_initialization(path, is_directory) {
+ return secure_windows_game_creator_path_for_current_user_with_owner_policy(
+ path,
+ is_directory,
+ true,
+ true,
+ );
+ }
+ secure_windows_game_creator_path_for_current_user(path, is_directory, true)
+}
+
+#[cfg(all(windows, test))]
+fn windows_path_is_under_test_temp_dir(path: &Path) -> bool {
+ let normalize = |value: &Path| {
+ value
+ .to_string_lossy()
+ .replace('/', "\\")
+ .trim_end_matches('\\')
+ .to_ascii_lowercase()
+ };
+ let temp_dir = std::env::temp_dir();
+ let mut roots = vec![normalize(&temp_dir)];
+ if let Ok(canonical) = temp_dir.canonicalize() {
+ let root = normalize(&canonical);
+ if !roots.contains(&root) {
+ roots.push(root);
+ }
+ }
+ let candidate = normalize(path);
+ roots
+ .iter()
+ .any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\")))
+}
+
#[cfg(windows)]
pub(crate) fn secure_windows_game_creator_path_for_current_user(
path: &Path,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs
index fe77f4532..567a0d7de 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/main.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs
@@ -1279,6 +1279,9 @@ struct GeneratedPlatformArtAssetSlice {
struct GeneratedPlatformArtAsset {
asset: UploadLocalAssetResult,
slices: Vec,
+ slice_mode: Option,
+ grid_x: Option,
+ grid_y: Option,
resource_id: Option,
asset_object_id: Option,
task_id: Option,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs
index 7dd5c64a7..89a8d46e4 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs
@@ -1587,6 +1587,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r
"prompt": "生成原创晶体与潮汐构装体图集",
"outputPath": "assets/art-spritesheet.png",
"assetKind": "art-spritesheet",
+ "sliceMode": "connected-components",
"replaceExisting": true
}),
),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
index 2270ca448..5d746c7cc 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
@@ -1233,6 +1233,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m
output_path: Some("assets/art-spritesheet.png".to_string()),
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
+ slice_mode: Some("connected-components".to_string()),
..PlatformArtAssetGenerationOptions::default()
},
))
diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json
index a70761fa3..c120926f0 100644
--- a/docs/openapi/genarrative-external-v1.openapi.json
+++ b/docs/openapi/genarrative-external-v1.openapi.json
@@ -3363,7 +3363,7 @@
},
"EditorIconSpritesheetGenerationRequest": {
"type": "object",
- "required": ["referenceId", "iconDescriptions"],
+ "required": ["referenceId", "iconDescriptions", "sliceMode"],
"properties": {
"referenceId": {
"type": "string",
@@ -3395,26 +3395,25 @@
"connected-components",
"grid"
],
- "default": "connected-components",
- "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。"
+ "description": "必填,没有默认值:必须在引用解析、定价、入队和任何 provider / OSS 副作用之前显式声明切分模式。需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。connected-components 不接受 gridX/gridY,grid 必须同时提供 gridX/gridY(各 1..32)。省略、null 或空字符串返回 400(field=sliceMode),模式与网格参数互相矛盾返回 400(field=gridX/gridY),两者都不会产生计费、入队或 provider 调用。响应中的 sliceMode 回显本次实际采用的模式。"
},
"gridX": {
"type": "integer",
"minimum": 1,
"maximum": 32,
- "description": "grid 模式的横向网格数量。"
+ "description": "grid 模式的横向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。"
},
"gridY": {
"type": "integer",
"minimum": 1,
"maximum": 32,
- "description": "grid 模式的纵向网格数量。"
+ "description": "grid 模式的纵向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。"
},
"sliceCount": {
"type": "integer",
"minimum": 1,
- "maximum": 100,
- "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。"
+ "maximum": 256,
+ "description": "connected-components 模式下可选的目标切片数量(1..256);省略时按图像内容自动识别上限。识别结果与该目标数量不一致、为 0 或超过 256 时返回 422 并给出实际识别数量,不会静默截断。grid 模式的切片数量由 gridX×gridY 决定,不接受该字段。"
},
"screenColor": {
"type": ["string", "null"],
@@ -3649,7 +3648,7 @@
"connected-components",
"grid"
],
- "description": "实际采用的图集切分模式。"
+ "description": "本次实际采用的图集切分模式,与请求显式声明的 sliceMode 一致;图集生成入口不回退到任何默认模式。"
},
"gridX": {
"type": "integer",
@@ -3664,7 +3663,7 @@
"sliceCount": {
"type": "integer",
"minimum": 0,
- "maximum": 100,
+ "maximum": 256,
"description": "实际生成的切片数量。"
},
"sliceWarning": {
diff --git a/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md
new file mode 100644
index 000000000..d365f6539
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md
@@ -0,0 +1,39 @@
+# 【实施计划】图集切片模式显式决策
+
+| 字段 | 值 |
+| --- | --- |
+| Milestone | `docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md` |
+| Status | ready |
+| Owner | Codex |
+
+## 修改边界
+
+- 允许修改:`server-rs/crates/api-server`(图标图集生成入口、错误体、画板 Agent 工具装配、OpenAPI 契约测试)、平台画板前端(`src/services/image-editor`、`src/components/image-editor`)、AGC 客户端(`apps/ai-game-creator-shell/src-tauri` 的 MCP 工具说明、桥接校验、原生工具 schema、图集生成选项与调用方、AGC Skill)、`.codex/skills/genarrative-external-editor-api`、`docs/openapi/genarrative-external-v1.openapi.json`、主规范与共享记忆。
+- 明确不修改 `platform-editor-agent`:画板 Agent 的工具参数不变,其链路在装配层固定显式声明 `connected-components`,画板因此不具备网格生成入口。
+- 明确不修改:拆分 / 去背 / 像素规整算法、切片上限、手动拆分入口行为、SpacetimeDB schema、旧版本客户端兼容分支。
+
+## 实现顺序
+
+1. 平台入口:`sliceMode` 由可选改必填并校验模式自洽性,失败发生在引用解析、定价、入队之前。
+2. 公开契约:OpenAPI 请求体去掉默认值、补必填与失败语义,并补契约测试。
+3. 平台自有调用方显式声明模式:画板 Agent 工具装配(固定连通域)、画板前端提交计划(固定连通域)。
+4. AGC 客户端:MCP 工具说明与桥接校验、原生工具 schema 与观察器、图集生成选项与全部调用方、AGC Skill 与外部 MCP 说明。
+5. 错误可执行性:切片模式按原始字符串接收后逐项校验,统一返回 `field`、允许取值与决策分支;`sliceCount` 契约上限与切片上限对齐。
+6. 反馈闭环:生成结果回显生效声明与切片路径,严格图集在本地提交前校验回显与请求一致。
+7. 标准美术包显式声明 `connected-components` + `sliceCount=4`,用途映射前校验切片数量正好为四。
+8. 测试环境:为提权 Windows 主机上的 `%TEMP%` 所有者偏差补测试构建专用的所有者初始化重试(仅限临时目录内、且失败原因为所有者不匹配)。
+9. 文档与共享记忆同步,最后运行定向验证与编码 / diff 检查。
+
+## 验证命令
+
+1. `cargo test -p api-server editor_icon_spritesheet`(名称按实际测试筛选)
+2. `cargo test -p platform-editor-agent`
+3. `npm run test -- src/services/image-editor/editorProjectClient.test.ts`(按仓库既有前端测试入口)
+4. `cargo test -p ai-game-creator-shell` 定向筛选 `slice_mode` / `generate_image`
+5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
+
+## 风险与回滚点
+
+- 风险 1:已发布的 AGC 客户端与第三方外部 API 调用方在未更新前会因缺失 `sliceMode` 收到 `400`。回滚点为「恢复服务端兜底读取连通域」,但该兜底与本次里程碑目标冲突,需产品确认后再引入过渡期。
+- 风险 2:AGC 原生工具 schema 从“可选”改为“显式声明”,自主运行时可能出现一轮可修复的工具参数失败。回滚点为「保留 schema 字段但收回 description 中的强制措辞」。
+- 风险 3:画板前端显式声明模式后,画板自身不再具备网格生成能力;需要网格时改用外部 API 或后续单独开放画板入口。
diff --git a/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md
new file mode 100644
index 000000000..49925b314
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md
@@ -0,0 +1,49 @@
+# 【里程碑】图集切片模式显式决策
+
+| 字段 | 值 |
+| --- | --- |
+| Version | 1.0 |
+| Status | proposed |
+| Date | 2026-09-17 |
+| Parent Spec | `docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md` |
+
+## 目标
+
+图标图集生成的切分模式不再具备任何隐式默认:平台入口、AGC 客户端自有流程、画板前端和所有 Agent / 工具说明都必须在请求中显式声明 `sliceMode`,并在同一份决策要求下选择 `connected-components` 或 `grid`。
+
+## 范围
+
+- `sliceMode` 在图标图集生成入口成为必填;缺失、`null`、空字符串在副作用之前失败关闭。
+- `grid` 与 `connected-components` 的参数自洽性:`grid` 必须带行列数,连通域不得携带网格尺寸。
+- 决策要求写入主规范、公开契约、MCP / Agent 工具说明、Skill 与客户端自有路径,口径一致。
+- 依赖平台默认值的自有调用方全部改为显式声明,且不新增兜底分支。
+- 失败信息可执行:所有拒绝路径都带字段名与决策要求,`sliceCount` 的目标数量与上限语义在契约中写清。
+- 端到端可证明:生成结果回显生效的切分声明与切片路径,严格图集在本地提交前校验回显与请求一致。
+- 标准美术包显式声明四张 canonical 切片的切分声明,并在用途映射前校验切片数量正好为四。
+
+## 不在范围内
+
+- 不改动图集生成、去背、像素规整、拆分算法本身和切片上限。
+- 不新增切分模式,不恢复已退役的固定网格契约。
+- 不改动手动 `拆分图集` 入口的既有行为。
+- 不为旧版本客户端保留过渡性兜底。
+
+## 依赖与前置条件
+
+- 无外部依赖;`sliceMode`、`gridX`、`gridY` 契约字段已在现行版本存在。
+
+## 验收标准
+
+- [ ] 省略 / `null` / 空字符串 `sliceMode` 的图集生成请求在定价、入队、扣费和 provider 调用之前返回 `400`,错误体含 `field=sliceMode`。
+- [ ] `grid` 缺 `gridX` 或 `gridY`、越界、乘积超限时 `400`;`connected-components` 携带 `gridX`/`gridY` 时 `400`。
+- [ ] 公开契约、MCP / Agent 工具说明、Skill 与画板前端类型都要求显式声明,且不再声明任何默认值。
+- [ ] AGC 客户端与画板前端的所有图集生成路径都显式传入模式,不再依赖平台兜底。
+- [ ] 响应回显的 `sliceMode` 与请求声明一致;`grid` 时同时回显行列数。
+- [ ] 拒绝信息包含字段名、允许取值与决策分支;`sliceCount` 契约上限与切片上限一致。
+- [ ] 标准美术包声明 `sliceCount=4`,数量不符时在写入用途清单前失败关闭。
+
+## 证据要求
+
+- 自动化:平台定向测试(缺失、空串、连通域带网格尺寸、grid 缺维度、正常两种模式)、OpenAPI 契约测试、前端与 AGC 客户端定向测试。
+- 运行时:本地 `api-server` smoke 提交一次缺字段请求,确认返回 `400` 且无扣费 / 入队记录。
+- 边界:确认失败发生在引用解析、定价、入队与 OSS 副作用之前;确认响应字段与请求一致。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index a4ca70a6f..7dc92e160 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -2,6 +2,18 @@
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
+## 2026-09-17 图集切分模式改为显式声明
+
+- 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。
+- 决策要求:只有用户或需求明确要求等分网格、固定槽位或指定行列数时才使用 `grid`,且行列数必须来自该需求;自由排布、数量不定或只要求一张图集时显式传 `connected-components`,需要约束素材张数时用 `sliceCount`,不得用网格参数表达张数,也不得用固定 `2×2` 表达“四类素材”。
+- 影响面:平台两个图集生成入口(`/api/editor/...` 与 `/api/external/v1/editor/...`)、OpenAPI、画板 Agent 工具、画板前端提交计划、AGC 客户端 MCP 工具说明与桥接校验、AGC 原生工具 schema 与观察器、AGC Skill 与外部编辑器 Skill。
+- 迁移影响:省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端和第三方外部 API 调用方)会在图集生成上收到 `400`;本次同时把仓库内自有调用方改为显式声明,不为旧客户端保留兜底分支。
+- 错误可执行性:缺失、空白、未知取值都以 `400` + `field=sliceMode` 返回允许取值和决策分支,`grid` 缺维度提示 `sliceCount` 才是张数约束;`sliceCount` 的公开契约上限与切片上限统一为 `256`(识别数量与目标不一致返回 `422` 并回报实际数量)。
+- 反馈闭环:图集生成结果回显生效的 `sliceMode`/`gridX`/`gridY` 与 `slicePaths`;严格图集提交前必须证明平台回显的模式(`grid` 时含行列数)与请求显式声明一致,缺失或不一致一律失败关闭。
+- 标准美术包:客户端显式声明 `sliceMode=connected-components` + `sliceCount=4`,本地按用途位置写四张 canonical 切片前再次校验数量正好为四,数量不符时失败关闭,禁止截断或补位。
+- 测试环境:在提权 shell 的 Windows 主机上,`%TEMP%` 下新建目录的默认所有者是 `BUILTIN\Administrators` 而不是当前 TokenUser,AGC 的所有者校验会拒绝测试自己创建的项目根;测试构建对该情形(仅限 `%TEMP%` 内、且失败原因为所有者不匹配)先按“本调用创建的对象”初始化所有者后重试,临时目录之外的越权所有者继续失败关闭。
+- 权威合同:[画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。
+
## 2026-09-16 抠图模式与背景色契约
- External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。
diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
index fec3db0e7..b5c244b6e 100644
--- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md
+++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
@@ -27,6 +27,7 @@
- 下面的工具选择口径属于 Agent 规划 prompt / function-calling 约束,不是侧边栏 UI 说明文案;侧边栏面板不展示这些规则解释。
- 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。
- 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`。
+- 画布 Agent 的 `generate-icon-spritesheet` 不暴露切分模式参数,链路固定显式传 `sliceMode=connected-components`;等分网格或固定槽位需求必须由外部 API 调用方显式传 `sliceMode=grid` 与来自需求的 `gridX`/`gridY`,画板工具栏的 `拆分图集` 仍只做连通域拆分。禁止在工具描述、确认卡或回复里承诺按 `2×2` 等网格切分。
- 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。
- function-calling 的 JSON Schema 必须与参数默认值和运行时校验保持一致,不能只在 description 中提示会被运行时拒绝的组合。`generate-ui-design` 固定 `gpt-image-2`,因此 `image_size` 只暴露 `1K / 2K`;其它可切换图片模型的工具通过共享条件 schema 在显式选择 `gpt-image-2` 时同样把 `image_size` 限制为 `1K / 2K`,省略模型时仍按默认 nanobanana2 允许 `0.5K`。`generate-video` 省略 `model` 时按默认 `seedance2.0-fast` 约束 `resolution` 为 `480p / 720p`,显式选择其它模型时仍使用其现有分辨率范围。运行时强类型校验继续作为最终防线。
- `generate-sound-effect` 与站内 / External v1 的 SFX V2 契约一致:Prompt 使用 ECMAScript `String.trim()` 等值 canonicalization 且限制 `1–2048` Unicode code points,model 固定 `eleven_text_to_sound_v2`,`duration` 缺省为手动 `5s`、显式 `null` 为自动时长、数值范围为有限 `0.5–30` 小数,`loop` 缺省 false。显式 `duration:null` 必须绕过通用“顶层 null 当缺省”兼容层,不能在 job payload 中变回 `5s`;确认后的 canonical payload 继续进入现有 `editor_sound_effect_generation` Worker,不新增 Agent 专属音频链路。
diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
index ae769d8b3..b362711d4 100644
--- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
+++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
@@ -2,7 +2,7 @@
日期:`2026-06-15`
-更新时间:`2026-08-10`
+更新时间:`2026-09-17`
## 背景
@@ -40,7 +40,15 @@
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`。
-- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。
+- 图集拆分模式必须由调用方显式声明,任何入口都不得存在隐式默认值:`sliceMode` 是图标图集生成请求的必填字段,`connected-components` 按透明像素连通域切分,`grid` 按调用方提供的 `gridX × gridY` 网格切分。
+- 请求缺失 `sliceMode`、传 `null` 或空字符串时,`POST /api/editor/icon-spritesheets/generations` 与 `POST /api/external/v1/editor/icon-spritesheets/generations` 都必须在引用解析、定价、入队和任何 provider / OSS 副作用之前返回 `400`,错误体带 `field=sliceMode`,message 复述本节的决策要求;服务端不得用兜底模式继续执行,也不得为该字段保留默认值。
+- `grid` 必须同时提供 `gridX` 与 `gridY`(各 `1..32`,乘积不得超过当时生效的图集切片上限);只提供其中一个、越界或乘积超限同样在副作用之前 `400`。
+- `connected-components` 不得同时携带 `gridX` / `gridY`:连通域切分不接受网格尺寸,二者同时出现时按请求自相矛盾在副作用之前返回 `400`(`field=gridX/gridY`),避免调用方以为网格已生效而实际按连通域执行。
+- 决策要求(服务端、客户端、Agent、工具说明和 Skill 必须一致):只有在用户或需求明确要求等分网格、固定槽位或指定行列数时,才使用 `grid`,并把该行列数作为 `gridX` / `gridY` 传入;行列数必须来自用户或需求本身,不得由生成方自行假定,也不得用固定 `2×2` 表达“四类素材”。自由排布、数量不定或只要求“一张图集”时,显式传 `connected-components`;需要约束素材张数时使用 `sliceCount`,不得用网格参数表达张数。调用方、客户端和 Agent 都不得依赖、补齐或推断省略值。
+- 响应继续回显实际采用的 `sliceMode`,`grid` 时同时回显生效的 `gridX` / `gridY`。
+- 错误必须可执行:缺失、空白和未知取值统一返回 `400` 且带 `field=sliceMode`,`grid` 与网格参数的矛盾带 `field=gridX/gridY`,message 说明允许取值、缺参时该走哪条决策分支,以及 `sliceCount` 才是张数约束;不得只回报通用 JSON 解析错误。
+- `sliceCount` 只约束 `connected-components` 的目标张数,取值 `1..256`;识别结果与该目标不一致、为 `0` 或超过上限时返回 `422` 并回报实际识别数量,`grid` 不接受该字段。
+- 客户端的标准美术包(四类 canonical 素材)必须显式声明 `sliceMode=connected-components` 与 `sliceCount=4`:平台要么给出四张切片,要么以可执行的 `422` 说明实际识别数量;本地按用途位置映射前必须再次校验切片数量正好是四张,数量不符时失败关闭,禁止靠截断或补位写出用途错位的切片清单。
- 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。
- 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。
- queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。
@@ -89,7 +97,7 @@
- 透明背景处理正常成功时,父流程把带背景原图和经完整解码 / 尺寸守卫验证的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别 alpha 连通域并执行附加拆分。BgFilter 最终失败或后续 Alpha / 尺寸恢复、原图回读、透明图完整解码失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`、`sliceWarning=null`。该收口不捕获 phase 上报、provider 原图持久化或 `canvasCompletion` 写回错误;provider 原图本身解码失败时在首次持久化前失败,不允许用 `512×512` 伪造元数据。
- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。
- 响应通过 `iconImageSrcs` 返回成功切片素材。图标自动拆分、手动 `拆分图集` 和 UI 提取复用同一个 bounded CPU helper 和 platform 实现:全部原始连通域(包括随后过滤的噪点)最多 `4096` 个,辅助部件通过 `64px` 空间网格只检查最大 `48px` 邻域候选;有效输出按视觉阅读顺序命名为 `素材 N`。
-- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `64` 个输出;输出限制在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。
+- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `256` 个输出;输出上限与 `grid` 的 `gridX × gridY` 上限、`sliceCount` 上限取同一个值,并在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。
## 前端铺放规则
@@ -110,3 +118,4 @@
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。
- 生成图标素材的提交体不包含 `priceMudPoints`;后端必须按归一化后的模型和尺寸计算价格,不信任客户端声明。queue 任务的计费、退款和结果投影使用入队时冻结的同一价格。
+- 图集生成请求省略 `sliceMode`(或显式传 `null` / 空字符串)时返回 `400` 且 `field=sliceMode`,不产生定价、入队、扣费、provider 调用或 OSS 写入;`sliceMode=connected-components` 同时携带 `gridX`/`gridY` 时同样在副作用之前 `400`;`grid` 缺任一维度时 `400`。响应回显的 `sliceMode` 必须与请求声明一致。
diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs
index 0805fb4d4..53d051abc 100644
--- a/server-rs/crates/api-server/src/editor_agent/tool.rs
+++ b/server-rs/crates/api-server/src/editor_agent/tool.rs
@@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
reference_image_srcs: Some(reference_image_srcs),
icon_descriptions: args.icon_descriptions,
slice_count: None,
- slice_mode: None,
+ // 画板 Agent 只生成自由排布的图标表,因此显式声明连通域切分;等分网格或固定
+ // 槽位需求必须由调用方在外部 API 显式传 grid + gridX/gridY,不能依赖任何默认值。
+ slice_mode: Some("connected-components".to_string()),
grid_x: None,
grid_y: None,
style: None,
diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs
index d0e68e432..c109dab63 100644
--- a/server-rs/crates/api-server/src/editor_project_icon.rs
+++ b/server-rs/crates/api-server/src/editor_project_icon.rs
@@ -76,13 +76,13 @@ pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000;
pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048;
-const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256;
+pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256;
pub(crate) const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 =
EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4;
-const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32;
+pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration =
@@ -255,9 +255,11 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
/// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) slice_count: Option,
- /// 图集切分模式;省略时使用连通域切分。
+ /// 图集切分模式;必填且没有默认值,必须在任何副作用之前由调用方显式声明。
+ /// 这里按原始字符串接收,让业务校验能返回带 `field` 和决策要求的 400,
+ /// 而不是只让 serde 抛一个通用的 JSON 解析错误。
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub(crate) slice_mode: Option,
+ pub(crate) slice_mode: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) grid_x: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -283,16 +285,57 @@ pub(crate) enum EditorIconSpritesheetSliceMode {
Grid,
}
-impl Default for EditorIconSpritesheetSliceMode {
- fn default() -> Self {
- Self::ConnectedComponents
+/// `sliceMode` 的显式决策要求:该字段没有默认值,缺失即拒绝。
+pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE: &str = "切分模式没有默认值,必须显式声明:需求明确要求等分网格、固定槽位或指定行列数时传 sliceMode=grid,并用 gridX/gridY 传入该行列数;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components,需要约束素材张数时使用 sliceCount。";
+
+fn editor_icon_spritesheet_slice_mode_error(message: String) -> AppError {
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "field": "sliceMode",
+ "message": message,
+ }))
+}
+
+/// 解析显式的切分模式声明:缺失、空白和未知取值都返回可执行的 400。
+fn parse_editor_icon_spritesheet_slice_mode(
+ slice_mode: Option<&str>,
+) -> Result {
+ let Some(value) = slice_mode.map(str::trim) else {
+ return Err(editor_icon_spritesheet_slice_mode_error(format!(
+ "sliceMode 不能省略:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}"
+ )));
+ };
+ match value {
+ "" => Err(editor_icon_spritesheet_slice_mode_error(format!(
+ "sliceMode 不能为空字符串:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}"
+ ))),
+ "connected-components" => Ok(EditorIconSpritesheetSliceMode::ConnectedComponents),
+ "grid" => Ok(EditorIconSpritesheetSliceMode::Grid),
+ other => Err(editor_icon_spritesheet_slice_mode_error(format!(
+ "sliceMode 不支持 {other},只允许 connected-components 或 grid:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}"
+ ))),
}
}
-fn resolve_editor_icon_spritesheet_slice_mode(
- slice_mode: Option,
-) -> EditorIconSpritesheetSliceMode {
- slice_mode.unwrap_or_default()
+/// 解析并校验图集切分声明:缺失模式、模式与网格参数互相矛盾都在此失败关闭。
+fn resolve_editor_icon_spritesheet_slice_request(
+ slice_mode: Option<&str>,
+ grid_x: Option,
+ grid_y: Option,
+) -> Result<(EditorIconSpritesheetSliceMode, u32, u32), AppError> {
+ let slice_mode = parse_editor_icon_spritesheet_slice_mode(slice_mode)?;
+ if slice_mode == EditorIconSpritesheetSliceMode::ConnectedComponents
+ && (grid_x.is_some() || grid_y.is_some())
+ {
+ return Err(
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "field": "gridX/gridY",
+ "message": "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 sliceMode=grid 同时声明。",
+ })),
+ );
+ }
+ let (grid_x, grid_y) =
+ resolve_editor_icon_spritesheet_grid_dimensions(slice_mode, grid_x, grid_y)?;
+ Ok((slice_mode, grid_x, grid_y))
}
fn resolve_editor_icon_spritesheet_grid_dimensions(
@@ -307,7 +350,10 @@ fn resolve_editor_icon_spritesheet_grid_dimensions(
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "gridX/gridY",
- "message": "grid 模式必须同时提供 gridX 与 gridY。",
+ "message": format!(
+ "sliceMode=grid 必须同时提供 gridX 与 gridY(各 1 到 {}):行列数必须来自需求本身;用网格参数表达素材张数时应改用 sliceMode=connected-components 加 sliceCount。",
+ EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS
+ ),
})),
);
};
@@ -1464,6 +1510,12 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
mut payload: EditorIconSpritesheetGenerationRequest,
external_idempotency_key: Option<&str>,
) -> Result {
+ // 切分模式没有默认值:必须在引用解析、定价和入队之前显式声明。
+ resolve_editor_icon_spritesheet_slice_request(
+ payload.slice_mode.as_deref(),
+ payload.grid_x,
+ payload.grid_y,
+ )?;
payload.generation_inputs =
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
payload.icon_descriptions =
@@ -1545,6 +1597,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
caller: EditorGenerationCaller,
mut payload: EditorIconSpritesheetGenerationRequest,
) -> Result, AppError> {
+ // 切分模式没有默认值:必须在引用解析、定价和任何 provider / OSS 副作用之前显式声明。
+ let (requested_slice_mode, grid_x, grid_y) = resolve_editor_icon_spritesheet_slice_request(
+ payload.slice_mode.as_deref(),
+ payload.grid_x,
+ payload.grid_y,
+ )?;
payload.generation_inputs =
sanitize_editor_client_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_sources_are_stable(
@@ -1647,12 +1705,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
- let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode);
- let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions(
- requested_slice_mode,
- payload.grid_x,
- payload.grid_y,
- )?;
// TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现;
// Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。
// 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义,
@@ -3058,19 +3110,97 @@ mod tests {
}
#[test]
- fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() {
+ fn slice_mode_must_be_declared_and_accepts_explicit_modes() {
+ let missing = resolve_editor_icon_spritesheet_slice_request(None, None, None)
+ .expect_err("omitted sliceMode must fail closed");
+ assert_eq!(missing.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
- resolve_editor_icon_spritesheet_slice_mode(None),
- EditorIconSpritesheetSliceMode::ConnectedComponents
+ missing.details().and_then(|details| details.get("field")),
+ Some(&json!("sliceMode"))
+ );
+ assert!(
+ missing
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(|message| message.contains("没有默认值")
+ && message.contains("grid")
+ && message.contains("connected-components")),
+ "{:?}",
+ missing.details()
+ );
+ let empty = resolve_editor_icon_spritesheet_slice_request(Some(" "), None, None)
+ .expect_err("blank sliceMode must fail closed");
+ assert_eq!(
+ empty.details().and_then(|details| details.get("field")),
+ Some(&json!("sliceMode"))
+ );
+ assert!(
+ empty
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(|message| message.contains("不能为空字符串")
+ && message.contains("connected-components")),
+ "{:?}",
+ empty.details()
+ );
+ let unknown =
+ resolve_editor_icon_spritesheet_slice_request(Some("grid-2x2"), Some(2), Some(2))
+ .expect_err("unknown sliceMode must fail closed with its own message");
+ assert_eq!(
+ unknown.details().and_then(|details| details.get("field")),
+ Some(&json!("sliceMode"))
+ );
+ assert!(
+ unknown
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(
+ |message| message.contains("grid-2x2") && message.contains("没有默认值")
+ ),
+ "{:?}",
+ unknown.details()
);
assert_eq!(
- resolve_editor_icon_spritesheet_grid_dimensions(
- EditorIconSpritesheetSliceMode::Grid,
- Some(3),
- Some(2),
+ resolve_editor_icon_spritesheet_slice_request(
+ Some("connected-components"),
+ None,
+ None,
)
- .expect("grid dimensions should validate"),
- (3, 2)
+ .expect("explicit connected-components mode should validate"),
+ (EditorIconSpritesheetSliceMode::ConnectedComponents, 0, 0)
+ );
+ assert_eq!(
+ resolve_editor_icon_spritesheet_slice_request(Some("grid"), Some(3), Some(2),)
+ .expect("grid dimensions should validate"),
+ (EditorIconSpritesheetSliceMode::Grid, 3, 2)
+ );
+ let contradictory = resolve_editor_icon_spritesheet_slice_request(
+ Some("connected-components"),
+ Some(2),
+ Some(2),
+ )
+ .expect_err("grid dimensions must not accompany connected-components");
+ assert_eq!(contradictory.status_code(), StatusCode::BAD_REQUEST);
+ assert_eq!(
+ contradictory
+ .details()
+ .and_then(|details| details.get("field")),
+ Some(&json!("gridX/gridY"))
+ );
+ let grid_without_dimensions =
+ resolve_editor_icon_spritesheet_slice_request(Some("grid"), None, None)
+ .expect_err("grid without dimensions must fail closed");
+ assert!(
+ grid_without_dimensions
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(|message| message.contains("sliceCount")),
+ "{:?}",
+ grid_without_dimensions.details()
);
let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
"referenceId": "spec",
@@ -3079,8 +3209,22 @@ mod tests {
}))
.expect("explicit connected-components mode should deserialize");
assert_eq!(
- connected.slice_mode,
- Some(EditorIconSpritesheetSliceMode::ConnectedComponents)
+ connected.slice_mode.as_deref(),
+ Some("connected-components")
+ );
+ let omitted: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
+ "referenceId": "spec",
+ "iconDescriptions": ["素材"]
+ }))
+ .expect("omitted sliceMode stays deserializable so the route can return its own 400");
+ assert_eq!(omitted.slice_mode, None);
+ assert!(
+ resolve_editor_icon_spritesheet_slice_request(
+ omitted.slice_mode.as_deref(),
+ omitted.grid_x,
+ omitted.grid_y,
+ )
+ .is_err()
);
let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
"referenceId": "spec",
@@ -3090,6 +3234,7 @@ mod tests {
"gridY": 2
}))
.expect("grid mode should deserialize");
+ assert_eq!(grid.slice_mode.as_deref(), Some("grid"));
assert_eq!(grid.grid_x, Some(3));
assert_eq!(grid.grid_y, Some(2));
}
diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs
index 99923530f..6a53d32d8 100644
--- a/server-rs/crates/api-server/src/external_editor_api.rs
+++ b/server-rs/crates/api-server/src/external_editor_api.rs
@@ -2644,13 +2644,37 @@ mod tests {
icon_spritesheet_request["properties"]["sliceCount"]["minimum"],
json!(1)
);
+ assert_eq!(
+ icon_spritesheet_request["properties"]["sliceCount"]["maximum"],
+ json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_SLICES)
+ );
assert_eq!(
icon_spritesheet_request["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
+ assert!(
+ icon_spritesheet_request["properties"]["sliceMode"]
+ .get("default")
+ .is_none(),
+ "sliceMode must not advertise a default"
+ );
+ assert!(
+ icon_spritesheet_request["required"]
+ .as_array()
+ .is_some_and(|required| required.contains(&json!("sliceMode"))),
+ "sliceMode must be required"
+ );
+ assert!(
+ icon_spritesheet_request["properties"]["sliceMode"]["description"]
+ .as_str()
+ .is_some_and(|description| description.contains("没有默认值")
+ && description.contains("field=sliceMode")
+ && description.contains("gridX/gridY")),
+ "sliceMode description must carry the explicit decision requirement"
+ );
assert_eq!(
icon_spritesheet_request["properties"]["gridX"]["maximum"],
- json!(32)
+ json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS)
);
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
["properties"]["style"];
diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs
index 6b6c469f7..2042fa3d6 100644
--- a/server-rs/crates/api-server/src/external_mcp.rs
+++ b/server-rs/crates/api-server/src/external_mcp.rs
@@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str =
"genarrative://external-editor/skill/references/requests-and-outputs.md";
const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
-const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
+const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
#[derive(Clone, Debug)]
struct McpOperation {
diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
index 004c9d5bd..cdbef119d 100644
--- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
@@ -2749,6 +2749,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
referenceId: 'resource-icon-spec',
iconDescriptions: ['返回按钮\n设置按钮'],
+ sliceMode: 'connected-components',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts
index 7a7ec1dd8..d6e9d77f9 100644
--- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts
+++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts
@@ -1080,6 +1080,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
referenceId: 'resource-icon-spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮\n\n设置按钮'],
+ sliceMode: 'connected-components',
model: 'gpt-image-2',
screenColor: 'auto',
segModel: 'birefnet',
diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts
index 6c14a925d..b11548253 100644
--- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts
+++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts
@@ -1136,6 +1136,9 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
}
: {}),
iconDescriptions,
+ // 切分模式没有默认值:画板链路按自由排布生成图标表,因此显式声明连通域切分;
+ // 等分网格或固定槽位需求必须由调用方显式传 grid + gridX/gridY。
+ sliceMode: 'connected-components',
model: rememberImageModel,
screenColor,
segModel,
diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
index b481fdb2c..e4e4a1159 100644
--- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
+++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
@@ -4249,6 +4249,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'generated-character-drafts/editor/refs/icon-style.png',
],
iconDescriptions: ['返回按钮'],
+ sliceMode: 'connected-components',
}),
);
});
diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts
index d846e24e3..79e965d9f 100644
--- a/src/services/image-editor/editorProjectClient.test.ts
+++ b/src/services/image-editor/editorProjectClient.test.ts
@@ -1112,6 +1112,7 @@ describe('editorProjectClient', () => {
referenceId: 'editor-resource-spec',
referenceImageSrcs: references.slice(0, 5),
iconDescriptions: ['返回按钮'],
+ sliceMode: 'connected-components',
model: 'gpt-image-2',
}),
).rejects.toThrow('图标素材参考图最多允许 4 张');
@@ -1162,6 +1163,29 @@ describe('editorProjectClient', () => {
);
});
+ it('requires an explicit slice declaration and rejects contradictory grid dimensions', async () => {
+ requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } });
+
+ await expect(
+ generateEditorIconSpritesheet({
+ referenceId: 'editor-resource-icon-spec',
+ iconDescriptions: ['返回按钮'],
+ sliceMode: 'grid',
+ }),
+ ).rejects.toThrow('sliceMode=grid 必须同时提供 gridX 与 gridY');
+ await expect(
+ generateEditorIconSpritesheet({
+ referenceId: 'editor-resource-icon-spec',
+ iconDescriptions: ['返回按钮'],
+ sliceMode: 'connected-components',
+ gridX: 2,
+ gridY: 2,
+ }),
+ ).rejects.toThrow('sliceMode=connected-components 不接受 gridX/gridY');
+
+ expect(requestJsonMock).not.toHaveBeenCalled();
+ });
+
it('rejects oversized stable video reference fields before submission', async () => {
await expect(
generateEditorVideo({
@@ -1331,6 +1355,9 @@ describe('editorProjectClient', () => {
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
+ sliceMode: 'grid',
+ gridX: 3,
+ gridY: 2,
assetLabel: '冒险游戏图标',
});
@@ -1348,6 +1375,9 @@ describe('editorProjectClient', () => {
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
+ sliceMode: 'grid',
+ gridX: 3,
+ gridY: 2,
model: 'gemini-3.1-flash-image-preview',
assetLabel: '冒险游戏图标',
}),
@@ -1365,6 +1395,7 @@ describe('editorProjectClient', () => {
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)],
+ sliceMode: 'connected-components',
}),
).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`);
@@ -1377,6 +1408,7 @@ describe('editorProjectClient', () => {
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
],
+ sliceMode: 'connected-components',
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
@@ -1388,6 +1420,7 @@ describe('editorProjectClient', () => {
iconDescriptions: Array.from({ length: 8 }, () =>
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
+ sliceMode: 'connected-components',
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
@@ -1412,6 +1445,7 @@ describe('editorProjectClient', () => {
await generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
+ sliceMode: 'connected-components',
model: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
@@ -1434,6 +1468,7 @@ describe('editorProjectClient', () => {
body: JSON.stringify({
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
+ sliceMode: 'connected-components',
model: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts
index e84825190..55003d328 100644
--- a/src/services/image-editor/editorProjectClient.ts
+++ b/src/services/image-editor/editorProjectClient.ts
@@ -380,7 +380,8 @@ export type EditorIconSpritesheetGenerationInput = {
referenceId: string;
referenceImageSrcs?: string[];
iconDescriptions: string[];
- sliceMode?: 'connected-components' | 'grid';
+ /** 必填且没有默认值:必须显式声明连通域或网格切分。 */
+ sliceMode: 'connected-components' | 'grid';
gridX?: number;
gridY?: number;
model?: string;
@@ -1300,6 +1301,16 @@ export async function generateEditorIconSpritesheet(
input.iconDescriptions,
);
const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2;
+ // 切分模式没有默认值:声明必须自洽,网格尺寸只能与 grid 同时提交。
+ if (input.sliceMode === 'grid') {
+ if (input.gridX === undefined || input.gridY === undefined) {
+ throw new Error('sliceMode=grid 必须同时提供 gridX 与 gridY');
+ }
+ } else if (input.gridX !== undefined || input.gridY !== undefined) {
+ throw new Error(
+ 'sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时提交',
+ );
+ }
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图');
assertEditorReferenceLimit(
input.referenceImageSrcs,
@@ -1317,9 +1328,13 @@ export async function generateEditorIconSpritesheet(
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
iconDescriptions,
- ...(input.sliceMode ? { sliceMode: input.sliceMode } : {}),
- ...(input.gridX !== undefined ? { gridX: input.gridX } : {}),
- ...(input.gridY !== undefined ? { gridY: input.gridY } : {}),
+ sliceMode: input.sliceMode,
+ ...(input.sliceMode === 'grid' && input.gridX !== undefined
+ ? { gridX: input.gridX }
+ : {}),
+ ...(input.sliceMode === 'grid' && input.gridY !== undefined
+ ? { gridY: input.gridY }
+ : {}),
model,
...(input.screenColor ? { screenColor: input.screenColor } : {}),
...(input.segModel ? { segModel: input.segModel } : {}),