From 3ffd37236ff2f77b836b8bfc775421cfb376bbcd Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 22:06:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=8A=A0=E5=9B=BE=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E9=80=8F=E4=BC=A0=E4=B8=8E=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复flat自动识别门禁并严格校验模式和颜色 保持旧请求幂等指纹并区分客户端抠图意图 同步工具说明和Skill契约并补充定向测试 记录真实BgFilter验证及本地数据库阻塞的待验收项 --- .../agc-skills/agc-client-projection/SKILL.md | 2 +- .../references/projection-contract.md | 2 +- .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 101 ++++++++------ .../src-tauri/src/agent/direct_tools_mcp.rs | 50 ++++++- .../genarrative-external-v1.openapi.json | 12 +- ...【实施计划】抠图模式联调验收-2026-09-16.md | 14 ++ .../【里程碑】抠图模式联调验收-2026-09-16.md | 14 ++ .../shared-memory/decision-log.md | 7 + ...架构】BgFilter受限资源调度方案-2026-07-21.md | 2 +- ...方案】AGC抠图模式与背景色透传-2026-09-16.md | 21 ++- .../crates/api-server/src/bgfilter_worker.rs | 97 ++++++++++++- .../api-server/src/editor_generation_queue.rs | 46 +++++++ .../crates/api-server/src/editor_project.rs | 130 ++++++++++++++++-- .../api-server/src/external_editor_api.rs | 41 +++++- 15 files changed, 464 insertions(+), 79 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】抠图模式联调验收-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】抠图模式联调验收-2026-09-16.md diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 07cbe9c1b..4d4862554 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. -6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. +6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 15006410b..87cba6b1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. -`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. +`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. 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 f2270d7b8..c57ea3638 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.16", + "version": "2026-08-26.17", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6" + "sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1" } ] } 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 0be811167..705da3ecb 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 @@ -1887,15 +1887,7 @@ async fn bridge_create_or_derive_resource( async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { let result = async { - bridge_reject_unknown_fields( - arguments, - &[ - "sourceLocalAssetId", - "assetName", - "backgroundMode", - "screenColor", - ], - )?; + super::direct_tools_mcp::validate_remove_background_arguments(arguments)?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; enforce_project_permission_policy(&state.root, "asset.register")?; let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?; @@ -1904,26 +1896,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val "assetName", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, )?; - let background_mode = bridge_optional_bounded_string(arguments, "backgroundMode", 16)?; - let screen_color = bridge_optional_bounded_string(arguments, "screenColor", 16)?; - if let Some(mode) = background_mode.as_deref() { - if mode != "complex" && mode != "flat" { - return Err("backgroundMode 必须是 complex 或 flat".to_string()); - } - } - if let Some(color) = screen_color.as_deref() { - let valid_hex = color.len() == 7 - && color.starts_with('#') - && color[1..] - .chars() - .all(|character| character.is_ascii_hexdigit()); - if color != "auto" && !valid_hex { - return Err("screenColor 必须是 auto 或 #RRGGBB".to_string()); - } - if background_mode.as_deref() == Some("complex") { - return Err("complex 模式不能传 screenColor".to_string()); - } - } + let background_mode = arguments.get("backgroundMode").and_then(Value::as_str); + let screen_color = arguments.get("screenColor").and_then(Value::as_str); let manifest = read_existing_manifest_for_project(&state.root)?; let source_asset = manifest .assets @@ -1948,24 +1922,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .map_err(|_| "创建抠图服务连接失败".to_string())?; let context = prepare_external_canvas_generation_context(&state.root, &client, &access).await?; - let fingerprint = format!("{}\0{}", source_asset_id, asset_name); + let fingerprint = background_removal_request_fingerprint( + &source_asset_id, + &asset_name, + background_mode, + screen_color, + ); let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; let route = "/api/external/v1/editor/images/background-removals"; + let mut request_body = json!({ + "sourceImageSrc": source_resource_id, + "projectId": manifest.project_id, + "assetKind": source_asset.kind, + "assetFolderId": context.asset_folder_id, + "assetLabel": asset_name, + "sourceResourceId": source_resource_id, + }); + if background_mode == Some("flat") { + request_body["backgroundMode"] = json!("flat"); + } + if let Some(color) = screen_color { + request_body["screenColor"] = json!(color); + } let response = crate::http_client::with_agc_main_site_marker( client .post(format!("{}{}", api_base_url, route)) .bearer_auth(api_key) .header("Idempotency-Key", idempotency_key) - .json(&json!({ - "sourceImageSrc": source_resource_id, - "projectId": manifest.project_id, - "assetKind": source_asset.kind, - "assetFolderId": context.asset_folder_id, - "assetLabel": asset_name, - "sourceResourceId": source_resource_id, - "backgroundMode": background_mode, - "screenColor": screen_color, - })), + .json(&request_body), ) .send() .await @@ -2002,6 +1986,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val } } +fn background_removal_request_fingerprint( + source: &str, + name: &str, + mode: Option<&str>, + color: Option<&str>, +) -> String { + let mode = mode.unwrap_or("complex"); + if mode == "complex" && color.is_none() { + format!("{source}\0{name}") + } else { + format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or("")) + } +} + fn bridge_safe_queue_state(value: Value) -> Value { let object = value.as_object(); json!({ @@ -2733,6 +2731,29 @@ pub(crate) async fn start_direct_tool_bridge( #[cfg(test)] mod tests { + #[test] + fn remove_background_identity_preserves_default_and_distinguishes_options() { + let legacy = "asset-1\0透明图"; + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", None, None), + legacy + ); + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None), + legacy + ); + let mut identities = std::collections::HashSet::new(); + identities.insert(legacy.to_string()); + for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] { + let id = + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color); + assert_eq!( + id, + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color) + ); + assert!(identities.insert(id)); + } + } use super::*; use std::io::{Cursor, Read, Write}; 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 2563bf913..0ccb5e11f 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 @@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }), json!({ "name": "agc_remove_background", - "description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。", + "description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。", "inputSchema": { "type": "object", "properties": { @@ -891,7 +891,7 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin } } -fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { +pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { validate_tool_object_fields( arguments, &[ @@ -927,7 +927,7 @@ fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> if color != "auto" && !valid_hex { return Err("screenColor 必须是 auto 或 #RRGGBB".to_string()); } - if arguments.get("backgroundMode").and_then(Value::as_str) == Some("complex") { + if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") { return Err("complex 模式不能传 screenColor".to_string()); } } @@ -1889,6 +1889,50 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { #[cfg(test)] mod tests { + #[test] + fn remove_background_arguments_enforce_mode_color_contract() { + for fields in [ + json!({}), + json!({"backgroundMode":"complex"}), + json!({"backgroundMode":"flat"}), + json!({"backgroundMode":"flat","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_ok(), + "{fields}" + ); + } + for fields in [ + json!({"screenColor":"auto"}), + json!({"backgroundMode":"complex","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":""}), + json!({"backgroundMode":"flat","screenColor":" auto "}), + json!({"backgroundMode":"flat","screenColor":"AUTO"}), + json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}), + json!({"backgroundMode":"flat","screenColor":null}), + json!({"backgroundMode":"flat","screenColor":12}), + json!({"backgroundMode":""}), + json!({"backgroundMode":"FLAT"}), + json!({"backgroundMode":" flat "}), + json!({"backgroundMode":null}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_err(), + "{fields}" + ); + } + } use super::*; #[cfg(all(windows, feature = "cocos-editor-execute"))] diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 7e24f3460..a70761fa3 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3215,12 +3215,12 @@ "type": ["string", "null"], "enum": ["complex", "flat", null], "default": "complex", - "description": "抠图模式。不填按 complex 处理;complex 使用语义分割识别前景,flat 用于纯色背景抠图。确定背景为纯色时优先使用 flat。" + "description": "抠图模式。省略或 null 按 complex 处理;complex 使用语义分割识别前景,flat 用于纯色背景抠图。确定背景为纯色时优先使用 flat。" }, "screenColor": { "type": ["string", "null"], "pattern": "^(auto|#[0-9A-Fa-f]{6})$", - "description": "仅 flat 模式使用。可传 auto、#RRGGBB 或省略;auto 和省略由服务自动检测背景色。complex 搭配此字段返回 400。" + "description": "仅 flat 模式使用。可传 auto、#RRGGBB 或省略;null 等同省略。auto 和省略由服务自动检测背景色。模式省略或 complex 时提供非 null 颜色返回 400;空字符串或非法颜色返回 400。" }, "projectId": { "type": ["string", "null"], @@ -3260,6 +3260,14 @@ "description": "画布生成占位完成指令。提供时优先按生成完成链路写入结果,targetLayerId 不参与原位替换。" } }, + "if": { + "required": ["screenColor"], + "properties": { "screenColor": { "type": "string" } } + }, + "then": { + "required": ["backgroundMode"], + "properties": { "backgroundMode": { "const": "flat" } } + }, "additionalProperties": false }, "EditorImageGenerationResponse": { diff --git a/docs/project-memory/plans/【实施计划】抠图模式联调验收-2026-09-16.md b/docs/project-memory/plans/【实施计划】抠图模式联调验收-2026-09-16.md new file mode 100644 index 000000000..6d6094211 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】抠图模式联调验收-2026-09-16.md @@ -0,0 +1,14 @@ +# 抠图模式联调验收实施计划 + +依据:[里程碑](./【里程碑】抠图模式联调验收-2026-09-16.md)。 + +1. 主站修复 worker 对 auto/省略的门禁、空值契约和旧指纹;补接口、队列与 worker 定向测试。 +2. 客户端独立核对 schema、参数校验、幂等意图、Skill 与契约说明。 +3. 串行请求真实 BgFilter,凭据仅在进程内读取,不输出或落库。 +4. 运行 cargo test 的 background_removal、bgfilter、OpenAPI 定向过滤,客户端定向测试;运行 doc-index、encoding、diff 检查。 +5. 尝试 npm run dev:api-server 与 healthz smoke;记录真实登录/全链路未验证项。 +6. 收敛证据到主规范,删除临时计划。 + +检查点:先通过契约测试,再开展运行时核验;不因环境缺失修改生产配置。回滚仅限本次局部补丁。 + +当前收口:主站及客户端修复和定向检查已执行,真实 BgFilter 四组成功及自动检测失败分支已验证;本地 API 启动被现有数据库连接配置阻塞。仅完整登录/队列/资源回写运行时证据尚待补齐,测试凭据和临时日志不得提交。 diff --git a/docs/project-memory/plans/【里程碑】抠图模式联调验收-2026-09-16.md b/docs/project-memory/plans/【里程碑】抠图模式联调验收-2026-09-16.md new file mode 100644 index 000000000..e5f065108 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】抠图模式联调验收-2026-09-16.md @@ -0,0 +1,14 @@ +# 抠图模式联调验收 + +Version: 1 +Status: 自动化与真实上游验收已执行;完整主站链路待本地数据库恢复 +Date: 2026-09-16 +Parent Spec: ../../technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md + +交付:证明客户端、External v1、队列和 BgFilter 的模式/颜色契约一致;修正范围内缺漏。 +不做:401 专题、部署、主站前端变化、生成角色与图集自动选色行为变化、数据库 schema 修改。 + +验收:旧请求 complex;flat 三种颜色输入贯通;非法组合入队前 400;幂等包含新意图并保留旧请求指纹;真实服务返回可解码透明 PNG;实际证据与未验证环境分开记录。 +依赖:既有方案及前五步代码。 + +剩余门禁:当前本地 SpacetimeDB 连接拒绝,导致 api-server 启动恢复未就绪。环境恢复后,用 npm run dev:api-server 验证 healthz,再以真实登录客户端提交三种 flat 请求及旧 complex 请求,确认队列完成和资源回写。不得用直连 BgFilter 测试替代此门禁;完成后将结论回写主规范并删除本计划和实施计划。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a12a05be6..a4ca70a6f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,13 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-16 抠图模式与背景色契约 + +- External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 +- flat 的颜色允许 `auto`、`#RRGGBB` 或省略,自动识别完全由 BgFilter 负责。主站只校验、透传,不调用视觉模型选色;complex 携带颜色、非法值和空字符串在入队前拒绝。 +- 来源、名称、模式与颜色共同区分客户端请求意图;旧参数调用及旧 External 请求的幂等指纹须保持稳定。 +- 权威合同:[AGC 抠图模式与背景色透传](../../technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md)。 + ## 2026-09-16 策划 Agent 工具执行退出项目级写锁并自动接续中断批次 - 背景:策划 Agent 每个 `read_file` / `write_file` / `patch_file` 工具都在执行前竞争全局项目写锁,但同一会话已由 `.agent/design-agent/active.lock` 串行化,工具目标又限定在 `design_artifacts`;项目锁既不覆盖「工具 + 会话 checkpoint」事务,还把进程中断时的 `executing=true` 不确定窗口扩大到等锁与工具执行全程。真机项目出现 `pendingBatch.executing=true`、`function_call` 无配对 output、UI 只显示工作中且无错误的状态。 diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index 5fd4e58f3..233455db6 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -151,7 +151,7 @@ Authorization: Bearer - 当前部署只有一个配置内私有 OSS bucket,因此请求只传 `sourceObjectKey`,子 worker 从自身 OSS 配置取 bucket 并生成短期签名 URL。 - 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。 - `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。 -- `screenColor` 只对 flat 必填;complex 不得误接 flat 参数,两种模式的熔断状态必须隔离。 +- `screenColor` 在 flat 下可省略,也可传 `auto` 或 `#RRGGBB`;省略或 `auto` 由 BgFilter 自动识别。complex 不得携带背景色,两种模式的熔断状态必须隔离。生成角色、图集等既有链路继续传已确定的背景色。 - `maxQueueWaitMs` 与 `callBudgetMs` 都是相对预算,不是跨机器绝对时间。前者从 admission 起约束排队阶段(worker 还会用 §5.2 的动态估计对其取 min);后者从取得 provider permit 起计时,覆盖签名、两次 attempt、结果校验和响应构造。`callBudgetMs` 是父侧按 `N / est` 公式算出的“配置指纹”,仅作核对:worker 始终以自己按同一公式派生的值执行,不一致时不拒绝请求,而是记录 warn 日志并递增漂移指标。发布调优 N / est 时新旧进程共存的瞬态漂移因此不会误伤在途任务;持久性漂移的硬拦截由部署脚本的共享 env 对齐校验承担。 - JSON body 设置很小的固定上限;源图字节不进入该 JSON。 diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md index 7cc154a24..c53346eb2 100644 --- a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -38,8 +38,11 @@ POST /api/external/v1/editor/images/background-removals 1. 不传新增字段:按 `complex` 执行。 2. `complex` 不允许传 `screenColor`,返回 400。 3. `flat` 可以传具体 `#RRGGBB`、`auto`,也可以省略颜色。 -4. 非法模式、非法颜色、空字符串颜色返回 400。 +4. 模式和颜色严格按原值校验;非法值、空字符串、前后空格和大写 `AUTO` / `FLAT` 返回 400。十六进制颜色的字母允许大小写。 5. 主站只做格式和组合校验;`auto` 不在主站解析,直接转发给 BgFilter。 +6. HTTP 请求中的 `null` 视同省略;模式省略时提供非 null 颜色同样违反 complex 约束。客户端 MCP 可选参数应省略,不传 null。 + +格式或组合错误在入队前返回 400;BgFilter 自动检测失败发生在异步执行阶段,任务通过既有失败状态收口,不把已接受的 202 改成同步 400,不启动其他抠图方式兜底。 主站前端继续不传新增字段,因此用户行为不变。 @@ -58,7 +61,7 @@ POST /api/external/v1/editor/images/background-removals 客户端保留旧参数调用;新字段不填时不改变旧调用语义。客户端不读取图片、不自动选色、不把 `auto` 改写为具体颜色,使用原有 Bearer 认证、幂等键和队列返回模型。 -需要同步 `direct_tool_bridge.rs`、工具 manifest/schema、`agc-client-projection` 的 Skill/契约说明及客户端测试。 +工具 schema、桥接参数校验和随包 `agc-client-projection` Skill/契约说明必须保持一致。模式与颜色属于请求意图,必须参与客户端幂等指纹;同一图片与名称的不同模式不能复用同一次请求。缺省 complex 且没有颜色时保留既有指纹。主站在默认值归一化之前计算 External 请求指纹,缺失的新字段不序列化,避免旧请求重放发生冲突。 ## 实施任务 @@ -88,8 +91,12 @@ POST /api/external/v1/editor/images/background-removals ## 验收证据 -- OpenAPI、Rust DTO、队列任务和客户端请求字段一致; -- 旧客户端不传新增字段仍成功入队并使用 `complex`; -- `auto` 未被主站改写,BgFilter 收到 `auto` 或未收到 `screen_color`; -- 主站前端继续使用 `complex`; -- 定向测试、`npm run check:doc-index`、`npm run check:encoding` 和 `git diff --check` 通过。 +2026-09-16 实测: + +- 主站 `cargo test -p api-server background_removal`:36 项通过,覆盖非法请求入队前拒绝、缺省 complex、队列参数保留、旧请求指纹、父侧内部 RPC 和 provider multipart。 +- `cargo test -p api-server bgfilter`:52 项通过,包括 flat 的 auto/省略/具体颜色以及既有生成链路。 +- `exported_openapi_json_contains_external_editor_routes_and_security` 契约测试通过。 +- 客户端 `agent::direct_tools_mcp::tests` 18 项、`agent::skill_pack::tests` 4 项与抠图幂等指纹测试通过;Skill manifest 内容指纹已同步。主站与客户端 rustfmt、文档索引、编码及 diff 检查通过。 +- 真实 BgFilter(版本 `f1a0833`):使用进程内凭据串行请求 `flat + auto`、flat 省略颜色、`flat + #CFEFFF`、complex;四组均返回 200、512×512 RGBA PNG,alpha 范围均为 0–255。 +- 无纯色背景的随机噪声图片使用 flat + auto 返回 400,确认自动识别失败要求调用方提供颜色。测试没有修改服务器代码或配置。 +- 本地 `npm run dev:api-server` 已尝试,但当前配置指向的 SpacetimeDB 不可连接,服务停留在启动恢复重试,`/healthz` 未通过;已结束本次启动。完整登录客户端 → 主站持久化队列 → 结果回写的运行时验收尚未完成,不能用真实 BgFilter 的独立测试代替。没有部署本次主站或客户端代码。 diff --git a/server-rs/crates/api-server/src/bgfilter_worker.rs b/server-rs/crates/api-server/src/bgfilter_worker.rs index 666228ba0..595f39548 100644 --- a/server-rs/crates/api-server/src/bgfilter_worker.rs +++ b/server-rs/crates/api-server/src/bgfilter_worker.rs @@ -849,13 +849,13 @@ fn validate_internal_request( } match request.background_mode { BgfilterBackgroundMode::Flat => { - let screen_color = request.screen_color.as_deref().ok_or_else(|| { - WorkerFailure::new("invalid_request", "flat 请求缺少 screenColor", false) - })?; - if !valid_screen_color(screen_color) { + if let Some(screen_color) = request.screen_color.as_deref() + && screen_color != "auto" + && !valid_screen_color(screen_color) + { return Err(WorkerFailure::new( "invalid_request", - "screenColor 必须是 #RRGGBB", + "screenColor 必须是 auto 或 #RRGGBB", false, )); } @@ -2811,7 +2811,13 @@ mod tests { call_budget_ms: 321_000, audit_context: None, }; - assert!(validate_internal_request(&request, &admission).is_err()); + assert!(validate_internal_request(&request, &admission).is_ok()); + request.screen_color = Some("auto".to_string()); + assert!(validate_internal_request(&request, &admission).is_ok()); + for invalid in ["", "AUTO", "#12", "#GGGGGG"] { + request.screen_color = Some(invalid.to_string()); + assert!(validate_internal_request(&request, &admission).is_err()); + } request.screen_color = Some("#CFEFFF".to_string()); assert!(validate_internal_request(&request, &admission).is_ok()); // callBudget 只是父侧配置指纹:与 worker 公式值不一致不得拒绝(发布重启窗口 @@ -2854,6 +2860,85 @@ mod tests { assert!(validate_internal_request(&request, &admission).is_err()); } + #[tokio::test] + async fn background_removal_provider_multipart_preserves_mode_and_color() { + for (mode, color) in [ + (BgfilterBackgroundMode::Complex, None), + (BgfilterBackgroundMode::Flat, None), + (BgfilterBackgroundMode::Flat, Some("auto")), + (BgfilterBackgroundMode::Flat, Some("#Ab12EF")), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, mut receiver) = tokio::sync::mpsc::channel(1); + let router = Router::new().route( + "/remove-background", + post(move |headers: HeaderMap, body: axum::body::Bytes| { + let sender = sender.clone(); + async move { + sender.send((headers, body)).await.unwrap(); + ( + [(axum::http::header::CONTENT_TYPE, "image/png")], + encoded_png(2, 3), + ) + } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let state = AppState::new(AppConfig { + editor_bgfilter_base_url: format!("http://{address}"), + editor_bgfilter_token: Some("provider-test-token".to_string()), + ..AppConfig::default() + }) + .unwrap(); + let request = BgfilterInternalRequest { + request_id: "multipart-test".to_string(), + source_object_key: "editor-upload/source.png".to_string(), + background_mode: mode, + screen_color: color.map(str::to_string), + seg_model: "birefnet".to_string(), + cross_check: false, + max_queue_wait_ms: 1000, + call_budget_ms: 321000, + audit_context: None, + }; + let result = request_provider_once( + &state, + &request, + "https://example.invalid/source.png", + ProviderAttemptBudget { + timeout: Duration::from_secs(5), + budget_limited: false, + }, + 1, + Instant::now() + Duration::from_secs(5), + &BgfilterTaskTracker::new(), + ) + .await; + server.abort(); + let result = result.unwrap(); + assert_eq!((result.width, result.height), (2, 3)); + let (headers, body) = receiver.recv().await.unwrap(); + assert_eq!( + headers[BGFILTER_PROVIDER_TOKEN_HEADER], + "provider-test-token" + ); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains(&format!( + "name=\"background_mode\"\r\n\r\n{}\r\n", + mode.as_str() + ))); + assert!(body.contains("name=\"image_url\"")); + assert!(!body.contains("name=\"file\"")); + match color { + Some(color) => { + assert!(body.contains(&format!("name=\"screen_color\"\r\n\r\n{color}\r\n"))) + } + None => assert!(!body.contains("name=\"screen_color\"")), + } + } + } + #[test] fn provider_timeout_distinguishes_full_attempt_from_budget_truncation() { let now = Instant::now(); diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 0fdb965f9..b615a870b 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -1019,6 +1019,52 @@ mod tests { ); } + #[test] + fn background_removal_identity_preserves_old_dto_and_distinguishes_options() { + let legacy = json!({ + "sourceImageSrc": "resource-source", "projectId": null, + "targetLayerId": null, "assetKind": null, "generationInputs": null, + "assetFolderId": null, "assetLabel": null, "sourceResourceId": null, + "taskId": null, "canvasCompletion": null, + }); + let old = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &legacy, + "stable-key", + ) + .unwrap(); + let restored: crate::editor_project::EditorBackgroundRemovalRequest = + serde_json::from_value(legacy.clone()).unwrap(); + let current = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &restored, + "stable-key", + ) + .unwrap(); + assert_eq!(old.request_fingerprint, current.request_fingerprint); + assert_eq!(old.job_id, current.job_id); + let mut fingerprints = std::collections::HashSet::new(); + fingerprints.insert(current.request_fingerprint); + for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] { + let mut body = legacy.clone(); + body["backgroundMode"] = json!("flat"); + if let Some(color) = color { + body["screenColor"] = json!(color); + } + let changed = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &body, + "stable-key", + ) + .unwrap(); + assert_eq!(old.job_id, changed.job_id); + assert!(fingerprints.insert(changed.request_fingerprint)); + } + } + #[test] fn external_api_dedupe_key_preserves_legacy_hash_bytes() { let dedupe_key = build_editor_generation_dedupe_key( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e1ca4cf81..abe94fc02 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -452,7 +452,9 @@ pub struct EditorImageEditRequest { #[serde(rename_all = "camelCase")] pub struct EditorBackgroundRemovalRequest { pub(crate) source_image_src: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) background_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) screen_color: Option, pub(crate) project_id: Option, pub(crate) target_layer_id: Option, @@ -6292,7 +6294,6 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( mut payload: EditorBackgroundRemovalRequest, external_idempotency_key: Option<&str>, ) -> Result { - normalize_editor_background_removal_options(&mut payload)?; let external_request_identity = external_idempotency_key .map(|idempotency_key| { external_api_editor_generation_request_identity( @@ -6303,6 +6304,7 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( ) }) .transpose()?; + normalize_editor_background_removal_options(&mut payload)?; ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-background-removal", @@ -6409,34 +6411,28 @@ fn normalize_editor_background_removal_options( ) -> Result<(), AppError> { let mode = payload .background_mode - .take() - .map(|value| value.trim().to_ascii_lowercase()) - .filter(|value| !value.is_empty()) + .clone() .unwrap_or_else(|| "complex".to_string()); if mode != "complex" && mode != "flat" { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-background-removal", "field": "backgroundMode", - "message": "backgroundMode must be complex or flat", + "message": "backgroundMode 必须是 complex 或 flat", })), ); } - let color = payload - .screen_color - .take() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); + let color = payload.screen_color.as_deref(); if mode == "complex" && color.is_some() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-background-removal", "field": "screenColor", - "message": "screenColor is only supported when backgroundMode is flat", + "message": "只有 flat 模式可以提供 screenColor", })), ); } - if let Some(color) = color.as_deref() + if let Some(color) = color && color != "auto" && !(color.len() == 7 && color.starts_with('#') @@ -6448,12 +6444,11 @@ fn normalize_editor_background_removal_options( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-background-removal", "field": "screenColor", - "message": "screenColor must be auto or #RRGGBB", + "message": "screenColor 必须是 auto 或 #RRGGBB", })), ); } payload.background_mode = Some(mode); - payload.screen_color = color; Ok(()) } @@ -6706,6 +6701,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( caller: EditorGenerationCaller, mut payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { + normalize_editor_background_removal_options(&mut payload)?; payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); let started_at = Instant::now(); @@ -8010,7 +8006,7 @@ async fn request_editor_background_removal_image_with_bgfilter_worker( screen_color: Option<&str>, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { - // complex 没有 flat fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的 + // 独立抠图两种模式都不进入生成链路的 fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的 // 连接失败由 client 内部按预算有界重试,避免 max_attempts=1 的队列任务终态失败。 let mode = match background_mode { "flat" => crate::bgfilter_worker::BgfilterBackgroundMode::Flat, @@ -13717,6 +13713,110 @@ pub(crate) fn current_utc_micros() -> i64 { #[cfg(test)] mod tests { + #[test] + fn background_removal_options_preserve_queue_parameters_and_legacy_identity() { + for (fields, mode, color) in [ + (json!({}), "complex", None), + (json!({"backgroundMode": "complex"}), "complex", None), + (json!({"backgroundMode": "flat"}), "flat", None), + ( + json!({"backgroundMode": "flat", "screenColor": "auto"}), + "flat", + Some("auto"), + ), + ( + json!({"backgroundMode": "flat", "screenColor": "#Ab12EF"}), + "flat", + Some("#Ab12EF"), + ), + ] { + let mut input = json!({"sourceImageSrc": "resource-source"}); + input + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + let mut payload: EditorBackgroundRemovalRequest = + serde_json::from_value(input).unwrap(); + if payload.background_mode.is_none() { + let raw = serde_json::to_value(&payload).unwrap(); + assert!(raw.get("backgroundMode").is_none()); + assert!(raw.get("screenColor").is_none()); + } + normalize_editor_background_removal_options(&mut payload).unwrap(); + let restored: EditorBackgroundRemovalRequest = + serde_json::from_str(&serde_json::to_string(&payload).unwrap()).unwrap(); + assert_eq!(restored.background_mode.as_deref(), Some(mode)); + assert_eq!(restored.screen_color.as_deref(), color); + } + } + + #[test] + fn background_removal_options_reject_invalid_values_and_color_without_flat() { + for fields in [ + json!({"backgroundMode": ""}), + json!({"backgroundMode": "FLAT"}), + json!({"backgroundMode": " flat "}), + json!({"backgroundMode": "other"}), + json!({"screenColor": "auto"}), + json!({"backgroundMode": "complex", "screenColor": "auto"}), + json!({"backgroundMode": "complex", "screenColor": ""}), + json!({"backgroundMode": "flat", "screenColor": ""}), + json!({"backgroundMode": "flat", "screenColor": "AUTO"}), + json!({"backgroundMode": "flat", "screenColor": " auto "}), + json!({"backgroundMode": "flat", "screenColor": "#GGGGGG"}), + json!({"backgroundMode": "flat", "screenColor": "CFEFFF"}), + ] { + let mut input = json!({"sourceImageSrc": "resource-source"}); + input + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + let mut payload: EditorBackgroundRemovalRequest = + serde_json::from_value(input).unwrap(); + assert_eq!( + normalize_editor_background_removal_options(&mut payload) + .unwrap_err() + .status_code(), + StatusCode::BAD_REQUEST, + "{fields}" + ); + } + } + + #[tokio::test] + async fn background_removal_flat_options_reach_internal_worker_unchanged() { + for color in [None, Some("auto"), Some("#Ab12EF")] { + let response_png = encode_test_png(3, 2); + let (base_url, receiver, server) = spawn_bgfilter_worker_png_mock(response_png.clone()); + let state = AppState::new(AppConfig { + bgfilter_worker_base_url: base_url, + bgfilter_internal_token: Some("flat-test-token".to_string()), + ..AppConfig::default() + }) + .unwrap(); + let audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: None, + profile_id: None, + request_id: None, + external_call_deadline: None, + }; + let output = request_editor_background_removal_image_with_bgfilter_worker( + &state, + "generated-character-drafts/editor/source.png", + "flat", + color, + &audit, + ) + .await + .unwrap(); + let request = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + server.join().unwrap(); + let payload = parse_mock_http_json_body(&request); + assert_eq!(payload["backgroundMode"], json!("flat")); + assert_eq!(payload["screenColor"], json!(color)); + assert_eq!(output.image.bytes, response_png); + } + } use super::*; use crate::{ config::AppConfig, 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 b730cd5c2..99923530f 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -1868,7 +1868,7 @@ mod tests { } #[tokio::test] - async fn external_background_removal_rejects_undocumented_fields_before_queueing() { + async fn external_background_removal_rejects_invalid_parameters_before_queueing() { let state = AppState::new(crate::config::AppConfig::default()) .expect("external background removal test state should build"); state.fail_test_editor_generation_enqueue(); @@ -1890,6 +1890,26 @@ mod tests { json!({"taskId": "caller-controlled-task"}), ), ("unknown field", json!({"unexpected": true})), + ("invalid mode", json!({"backgroundMode": "unknown"})), + ("empty mode", json!({"backgroundMode": ""})), + ("uppercase mode", json!({"backgroundMode": "FLAT"})), + ("color without flat", json!({"screenColor": "auto"})), + ( + "complex with color", + json!({"backgroundMode": "complex", "screenColor": "#123456"}), + ), + ( + "empty color", + json!({"backgroundMode": "flat", "screenColor": ""}), + ), + ( + "invalid color", + json!({"backgroundMode": "flat", "screenColor": "red"}), + ), + ( + "non-string color", + json!({"backgroundMode": "flat", "screenColor": 123}), + ), ] { let mut request_body = json!({"sourceImageSrc": "editor-upload/source.png"}); request_body @@ -2848,6 +2868,25 @@ mod tests { ["EditorBackgroundRemovalRequest"]["properties"]["targetLayerId"]["description"] .as_str() .expect("background removal targetLayerId should document placement semantics"); + let background_properties = + &parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["properties"]; + assert_eq!( + background_properties["backgroundMode"]["enum"], + json!(["complex", "flat", null]) + ); + assert_eq!( + background_properties["backgroundMode"]["default"], + "complex" + ); + assert_eq!( + background_properties["screenColor"]["pattern"], + "^(auto|#[0-9A-Fa-f]{6})$" + ); + assert_eq!( + parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["then"]["properties"] + ["backgroundMode"]["const"], + "flat" + ); assert!(background_target_description.contains("projectId")); assert!(background_target_description.contains("canvasCompletion")); assert!(background_target_description.contains("assetObjectId"));