From f748d36d2175a5857bf411b2eb2a7e8934cbad72 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 19:58:12 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E8=A1=A5=E5=85=85AGC=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E9=80=8F=E4=BC=A0=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增主站与BgFilter模式及背景色契约 补充AGC客户端同步任务与兼容性规则 记录联调验收和发布回滚边界 --- docs/README.md | 2 + ...方案】AGC抠图模式与背景色透传-2026-09-16.md | 92 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md diff --git a/docs/README.md b/docs/README.md index 6450b77c5..244df1992 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,8 @@ ## 图片画布与媒体 +- [AGC 抠图模式与背景色透传方案](./technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md):External v1 与 AGC 客户端扩展 `flat`/`complex` 及 BgFilter `auto` 透传。 + - [共享基础组件库与展示页](./technical/【前端架构】共享基础组件库与展示页-2026-08-26.md):网站与客户端复用的无业务 UI chrome、样式边界和 `/components` 展示页。 - [Raw GPT Image 2 图片编辑代理](./technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md):主站客户端调用的同步图片编辑代理、multipart 输入、预检查与计费边界。 - [UI 编辑器自动切分素材工作流](./technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md):UI 设计图素材切分、Raw GPT Image 2 调用与结果持久化边界。 diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md new file mode 100644 index 000000000..754e8b005 --- /dev/null +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -0,0 +1,92 @@ +# AGC 抠图模式与背景色透传方案 + +## 目标 + +主站编辑器保持现有前端行为(继续使用 `complex`),同时扩展 External v1 抠图接口和 AGC 客户端,使客户端可以选择 `complex` / `flat`,并把 `screenColor` 原样交给 BgFilter。`auto` 的背景色识别完全由 BgFilter 负责,主站不读取图片、不调用模型决策颜色、不生成颜色兜底值。 + +## 当前 BgFilter 契约 + +生产服务器 `/root/BGfilter` 的服务契约为 `POST /remove-background` multipart: + +- `background_mode`:可选,`flat` 或 `complex`; +- `screen_color`:可选,支持 `#RRGGBB`、`auto` 或省略;省略/`auto` 时由 BgFilter 从图片边框自动检测; +- `complex` 模式忽略 `screen_color`; +- 自动检测失败由 BgFilter 返回 400。 + +主站向 BgFilter 发送 `#RRGGBB` 时保留 `#`,`auto` 也原样发送,不做所谓的“hex 转换”。 + +## External v1 请求契约 + +接口保持: + +```text +POST /api/external/v1/editor/images/background-removals +``` + +新增可选字段: + +| 字段 | 取值 | 缺省/行为 | +| --- | --- | --- | +| `backgroundMode` | `complex`、`flat` | 不填按 `complex`,保证旧客户端兼容 | +| `screenColor` | `auto` 或 `#RRGGBB` | 不填则不向 BgFilter 发送该字段 | + +组合规则: + +1. 不传新增字段:按 `complex` 执行。 +2. `complex` 不允许传 `screenColor`,返回 400。 +3. `flat` 可以传具体 `#RRGGBB`、`auto`,也可以省略颜色。 +4. 非法模式、非法颜色、空字符串颜色返回 400。 +5. 主站只做格式和组合校验;`auto` 不在主站解析,直接转发给 BgFilter。 + +主站前端继续不传新增字段,因此用户行为不变。 + +## AGC 客户端改动 + +`agc_remove_background` 增加可选参数: + +```json +{ + "sourceLocalAssetId": "...", + "assetName": "...", + "backgroundMode": "flat", + "screenColor": "auto" +} +``` + +客户端保留旧参数调用;新字段不填时不改变旧调用语义。客户端不读取图片、不自动选色、不把 `auto` 改写为具体颜色,使用原有 Bearer 认证、幂等键和队列返回模型。 + +需要同步 `direct_tool_bridge.rs`、工具 manifest/schema、`agc-client-projection` 的 Skill/契约说明及客户端测试。 + +## 实施任务 + +### 任务一:冻结 BgFilter 契约 + +记录服务器已支持的模式、颜色格式、自动检测和错误行为。不得把 SSH 地址、服务器凭据写入客户端或公开契约。 + +### 任务二:更新主站 DTO 与 OpenAPI + +为 External v1 和内部任务 DTO 增加可选字段,更新 `docs/openapi/genarrative-external-v1.openapi.json`,写明默认值、组合约束和 400 响应。 + +### 任务三:更新主站归一化与队列 + +缺省模式归一化为 `complex`;`complex + screenColor` 拒绝;`flat` 允许颜色、省略或 `auto`。队列保存字段,worker 始终发送 `background_mode`,仅在调用方提供颜色时发送 `screen_color`,值原样透传。 + +### 任务四:更新 AGC 客户端 + +增加参数 schema、请求体字段和本地校验,更新 Skill、projection contract 与测试。旧客户端请求必须继续有效。 + +### 任务五:联调与验收 + +覆盖旧请求、`flat + auto`、`flat + #RRGGBB`、`flat` 不传颜色、`complex`、`complex + screenColor` 和非法值;使用真实 BgFilter 验证 multipart 字段及自动检测错误传播。 + +## 依赖、发布与回滚 + +先发布兼容的新主站,再发布支持新参数的 AGC 客户端。主站前端无需发布改动。若联调失败,客户端可回退为只传旧字段,主站仍按 `complex` 处理;主站回滚时不改变旧字段语义。 + +## 验收证据 + +- OpenAPI、Rust DTO、队列任务和客户端请求字段一致; +- 旧客户端不传新增字段仍成功入队并使用 `complex`; +- `auto` 未被主站改写,BgFilter 收到 `auto` 或未收到 `screen_color`; +- 主站前端继续使用 `complex`; +- 定向测试、`npm run check:doc-index`、`npm run check:encoding` 和 `git diff --check` 通过。 From fa64819b6e60ab56a6611d8043f1cb37ec70e586 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 19:59:34 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E5=86=BB=E7=BB=93BgFilter=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE=E6=9C=8D=E5=8A=A1=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录flat与complex模式及auto背景色行为 补充服务器认证头和当前运行版本 --- .../【技术方案】AGC抠图模式与背景色透传-2026-09-16.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md index 754e8b005..9ad7184ee 100644 --- a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -6,15 +6,18 @@ ## 当前 BgFilter 契约 -生产服务器 `/root/BGfilter` 的服务契约为 `POST /remove-background` multipart: +已登录生产服务器核对 `/root/BGfilter`,当前代码版本为 `f1a0833`,运行进程为 `python -m uvicorn app:app --host 0.0.0.0 --port 6006 --workers 1 --no-access-log`。服务契约为 `POST /remove-background` multipart: - `background_mode`:可选,`flat` 或 `complex`; - `screen_color`:可选,支持 `#RRGGBB`、`auto` 或省略;省略/`auto` 时由 BgFilter 从图片边框自动检测; - `complex` 模式忽略 `screen_color`; - 自动检测失败由 BgFilter 返回 400。 +- 若配置 `BGFILTER_AUTH_TOKEN`,必须发送 `X-Genarrative-Image-Token`;缺失或错误返回 401;未配置时该接口不在服务层做 token 校验。 主站向 BgFilter 发送 `#RRGGBB` 时保留 `#`,`auto` 也原样发送,不做所谓的“hex 转换”。 +服务器实现细节:`screen_color` 在 `auto` 或省略时传入内部 pipeline 的 `None`,由边框颜色自动检测;`#RRGGBB` 才会经过颜色解析用于色键。`background_mode` 缺省在 BgFilter 侧为 `flat`,因此主站必须为 External v1 旧请求显式归一化为 `complex`,不能把 BgFilter 的默认值直接当成主站默认值。 + ## External v1 请求契约 接口保持: From d7a0c3cffd4537c819adb08f4f9e8640ff58f65c Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 20:02:23 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E6=89=A9=E5=B1=95=E4=B8=BB=E7=AB=99?= =?UTF-8?q?=E6=8A=A0=E5=9B=BE=E8=AF=B7=E6=B1=82=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加backgroundMode和screenColor可选字段 同步External v1 OpenAPI抠图模式说明 --- docs/openapi/genarrative-external-v1.openapi.json | 11 +++++++++++ server-rs/crates/api-server/src/editor_project.rs | 2 ++ .../crates/api-server/src/external_editor_api.rs | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 0a28b1ab6..a55435e7e 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3211,6 +3211,17 @@ "minLength": 1, "description": "当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID。禁止 Data URL、Blob URL 和临时 signed URL。" }, + "backgroundMode": { + "type": ["string", "null"], + "enum": ["complex", "flat", null], + "default": "complex", + "description": "抠图模式。不填按 complex 处理;flat 使用纯色背景管线。" + }, + "screenColor": { + "type": ["string", "null"], + "pattern": "^(auto|#[0-9A-Fa-f]{6})$", + "description": "仅 flat 模式使用。可传 auto、#RRGGBB 或省略;auto 和省略由 BgFilter 自动检测背景色。complex 搭配此字段返回 400。" + }, "projectId": { "type": ["string", "null"], "description": "可选项目上下文。提供 targetLayerId 时必须同时提供非空 projectId,否则在入队前返回 400。" diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 2c3ccb68e..481188301 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -452,6 +452,8 @@ pub struct EditorImageEditRequest { #[serde(rename_all = "camelCase")] pub struct EditorBackgroundRemovalRequest { pub(crate) source_image_src: String, + pub(crate) background_mode: Option, + pub(crate) screen_color: Option, pub(crate) project_id: Option, pub(crate) target_layer_id: Option, pub(crate) asset_kind: Option, 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 eeddb4371..b730cd5c2 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -169,6 +169,8 @@ pub struct ExternalEditorProjectResourceCreateRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct ExternalEditorBackgroundRemovalRequest { source_image_src: String, + background_mode: Option, + screen_color: Option, project_id: Option, target_layer_id: Option, asset_kind: Option, @@ -183,6 +185,8 @@ impl From for EditorBackgroundRemovalReq fn from(payload: ExternalEditorBackgroundRemovalRequest) -> Self { Self { source_image_src: payload.source_image_src, + background_mode: payload.background_mode, + screen_color: payload.screen_color, project_id: payload.project_id, target_layer_id: payload.target_layer_id, asset_kind: payload.asset_kind, From a17367112f5e01a4be46acba20aba487986287e7 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 20:26:46 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=BB=E7=AB=99?= =?UTF-8?q?=E6=8A=A0=E5=9B=BE=E5=8F=82=E6=95=B0=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺省模式兼容为complex并校验颜色格式 将auto颜色原样保留给BgFilter --- .../crates/api-server/src/editor_project.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 481188301..aebeb5965 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -6292,6 +6292,7 @@ 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( @@ -6403,6 +6404,59 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( } } +fn normalize_editor_background_removal_options( + payload: &mut EditorBackgroundRemovalRequest, +) -> Result<(), AppError> { + let mode = payload + .background_mode + .take() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .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", + })), + ); + } + let color = payload + .screen_color + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + 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", + })), + ); + } + if let Some(color) = color.as_deref() + && color != "auto" + && !(color.len() == 7 + && color.starts_with('#') + && color[1..] + .chars() + .all(|character| character.is_ascii_hexdigit())) + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "screenColor", + "message": "screenColor must be auto or #RRGGBB", + })), + ); + } + payload.background_mode = Some(mode); + payload.screen_color = color; + Ok(()) +} + fn resolve_editor_background_removal_canvas_target( project_id: Option<&str>, target_layer_id: Option<&str>, From ca761e093ca1e0408e228219d7eb3d009c8292b6 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 20:41:48 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E6=8E=A5=E9=80=9A=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=88=B0BgFilter=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 透传backgroundMode和screenColor到内部worker 保留auto及省略颜色的下游识别语义 --- server-rs/crates/api-server/src/editor_project.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index aebeb5965..e1ca4cf81 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -6781,6 +6781,8 @@ pub(crate) async fn remove_editor_image_background_for_owner( let removed = request_editor_background_removal_image_with_bgfilter_worker( state, source.object_key.as_str(), + payload.background_mode.as_deref().unwrap_or("complex"), + payload.screen_color.as_deref(), &matting_audit, ) .await?; @@ -8004,15 +8006,21 @@ fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration { async fn request_editor_background_removal_image_with_bgfilter_worker( state: &AppState, source_object_key: &str, + background_mode: &str, + screen_color: Option<&str>, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { // complex 没有 flat fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的 // 连接失败由 client 内部按预算有界重试,避免 max_attempts=1 的队列任务终态失败。 + let mode = match background_mode { + "flat" => crate::bgfilter_worker::BgfilterBackgroundMode::Flat, + _ => crate::bgfilter_worker::BgfilterBackgroundMode::Complex, + }; let removed = crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry( state, source_object_key, - crate::bgfilter_worker::BgfilterBackgroundMode::Complex, - None, + mode, + screen_color, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_CROSS_CHECK_DISABLED, EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, @@ -22111,6 +22119,8 @@ mod tests { let removed = request_editor_background_removal_image_with_bgfilter_worker( &state, "generated-character-drafts/editor/manual-source.png", + "complex", + None, &audit, ) .await From 70ad794b285bf800391bc5417fb2e862537a3e31 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 21:14:03 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E6=89=A9=E5=B1=95AGC=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持complex与flat模式及auto背景色 保留旧客户端参数调用兼容性 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 32 +++++++++++++- .../src-tauri/src/agent/direct_tools_mcp.rs | 44 ++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) 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 2e3889f46..0be811167 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,7 +1887,15 @@ 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"])?; + bridge_reject_unknown_fields( + arguments, + &[ + "sourceLocalAssetId", + "assetName", + "backgroundMode", + "screenColor", + ], + )?; 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)?; @@ -1896,6 +1904,26 @@ 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 manifest = read_existing_manifest_for_project(&state.root)?; let source_asset = manifest .assets @@ -1935,6 +1963,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val "assetFolderId": context.asset_folder_id, "assetLabel": asset_name, "sourceResourceId": source_resource_id, + "backgroundMode": background_mode, + "screenColor": screen_color, })), ) .send() 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 615fd3ead..482b375e1 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 @@ -449,6 +449,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "minLength": 1, "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS + }, + "backgroundMode": { + "type": "string", + "enum": ["complex", "flat"], + "description": "可选抠图模式;省略时兼容旧行为并使用 complex" + }, + "screenColor": { + "type": "string", + "pattern": "^(auto|#[0-9A-Fa-f]{6})$", + "description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略由 BgFilter 自动检测" } }, "required": ["sourceLocalAssetId", "assetName"], @@ -882,13 +892,45 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin } fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { - validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?; + validate_tool_object_fields( + arguments, + &[ + "sourceLocalAssetId", + "assetName", + "backgroundMode", + "screenColor", + ], + )?; bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; bounded_tool_string( arguments, "assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, )?; + if let Some(mode) = arguments.get("backgroundMode") { + let mode = mode + .as_str() + .ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?; + if mode != "complex" && mode != "flat" { + return Err("backgroundMode 必须是 complex 或 flat".to_string()); + } + } + if let Some(color) = arguments.get("screenColor") { + let color = color + .as_str() + .ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?; + 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 arguments.get("backgroundMode").and_then(Value::as_str) == Some("complex") { + return Err("complex 模式不能传 screenColor".to_string()); + } + } Ok(()) } From e08b031761a77c83741742acdf07519a49ded985 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 21:25:55 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=8F=82=E6=95=B0=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent/direct_tools_mcp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 482b375e1..545ea261d 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 @@ -453,7 +453,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "backgroundMode": { "type": "string", "enum": ["complex", "flat"], - "description": "可选抠图模式;省略时兼容旧行为并使用 complex" + "description": "可选抠图模式;省略时使用 complex" }, "screenColor": { "type": "string", From 13f56e644de26f877e1dbefbde9a2fec86ed6ff2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 21:36:15 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=B0=83=E7=94=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明complex语义分割与flat纯色背景抠图 明确纯色背景优先使用flat并移除内部实现术语 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 4 ++-- docs/openapi/genarrative-external-v1.openapi.json | 6 +++--- .../【技术方案】AGC抠图模式与背景色透传-2026-09-16.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) 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 545ea261d..2563bf913 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 @@ -453,12 +453,12 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "backgroundMode": { "type": "string", "enum": ["complex", "flat"], - "description": "可选抠图模式;省略时使用 complex" + "description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex" }, "screenColor": { "type": "string", "pattern": "^(auto|#[0-9A-Fa-f]{6})$", - "description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略由 BgFilter 自动检测" + "description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测" } }, "required": ["sourceLocalAssetId", "assetName"], diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index a55435e7e..7e24f3460 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1122,7 +1122,7 @@ "tags": ["Editor Images"], "operationId": "removeExternalEditorImageBackground", "summary": "去除编辑器图片背景", - "description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。服务端固定使用 complex 去背景模式,不会在失败时切换到其它 provider。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。", + "description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。complex 使用语义分割识别前景,flat 用于纯色背景抠图;确定背景为纯色时优先使用 flat。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。", "security": [ { "ExternalApiKey": [] @@ -3215,12 +3215,12 @@ "type": ["string", "null"], "enum": ["complex", "flat", null], "default": "complex", - "description": "抠图模式。不填按 complex 处理;flat 使用纯色背景管线。" + "description": "抠图模式。不填按 complex 处理;complex 使用语义分割识别前景,flat 用于纯色背景抠图。确定背景为纯色时优先使用 flat。" }, "screenColor": { "type": ["string", "null"], "pattern": "^(auto|#[0-9A-Fa-f]{6})$", - "description": "仅 flat 模式使用。可传 auto、#RRGGBB 或省略;auto 和省略由 BgFilter 自动检测背景色。complex 搭配此字段返回 400。" + "description": "仅 flat 模式使用。可传 auto、#RRGGBB 或省略;auto 和省略由服务自动检测背景色。complex 搭配此字段返回 400。" }, "projectId": { "type": ["string", "null"], diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md index 9ad7184ee..7cc154a24 100644 --- a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -2,7 +2,7 @@ ## 目标 -主站编辑器保持现有前端行为(继续使用 `complex`),同时扩展 External v1 抠图接口和 AGC 客户端,使客户端可以选择 `complex` / `flat`,并把 `screenColor` 原样交给 BgFilter。`auto` 的背景色识别完全由 BgFilter 负责,主站不读取图片、不调用模型决策颜色、不生成颜色兜底值。 +主站编辑器保持现有前端行为(继续使用 `complex`),同时扩展 External v1 抠图接口和 AGC 客户端,使客户端可以选择 `complex` / `flat`,并把 `screenColor` 原样交给 BgFilter。`complex` 用语义分割识别前景,`flat` 用于纯色背景抠图;确定背景为纯色时优先使用 `flat`。`auto` 的背景色识别完全由下游服务负责,主站不读取图片、不调用模型决策颜色、不生成颜色兜底值。 ## 当前 BgFilter 契约 @@ -16,7 +16,7 @@ 主站向 BgFilter 发送 `#RRGGBB` 时保留 `#`,`auto` 也原样发送,不做所谓的“hex 转换”。 -服务器实现细节:`screen_color` 在 `auto` 或省略时传入内部 pipeline 的 `None`,由边框颜色自动检测;`#RRGGBB` 才会经过颜色解析用于色键。`background_mode` 缺省在 BgFilter 侧为 `flat`,因此主站必须为 External v1 旧请求显式归一化为 `complex`,不能把 BgFilter 的默认值直接当成主站默认值。 +服务器行为:`screen_color` 在 `auto` 或省略时由服务自动检测;`#RRGGBB` 用作指定背景色。`background_mode` 缺省在 BgFilter 侧为 `flat`,因此主站必须为 External v1 旧请求显式归一化为 `complex`,不能把下游服务的默认值直接当成主站默认值。 ## External v1 请求契约 From 16deb0ad610ffa2dbf600ba99dc994c4d3a26946 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 22:06:08 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E5=8F=82=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")); From 9b6ed8a4cfe3d3ac53cc74e64e254409fb75c6b1 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 17 Sep 2026 00:47:23 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E6=95=B4=E7=90=86=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=AF=BC=E5=85=A5=E5=B9=B6=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E5=AF=B9=E5=A4=96=E8=AF=B4=E6=98=8E=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将主站与客户端抠图测试模块的导入统一放到模块顶部 明确 provider 选择与兜底策略仅保留在内部技术方案中 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 3 +- ...方案】AGC抠图模式与背景色透传-2026-09-16.md | 2 ++ .../crates/api-server/src/editor_project.rs | 31 ++++++++++--------- 3 files changed, 20 insertions(+), 16 deletions(-) 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 0ccb5e11f..8b0cd12a0 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 @@ -1889,6 +1889,8 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { #[cfg(test)] mod tests { + use super::*; + #[test] fn remove_background_arguments_enforce_mode_color_contract() { for fields in [ @@ -1933,7 +1935,6 @@ mod tests { ); } } - use super::*; #[cfg(all(windows, feature = "cocos-editor-execute"))] #[test] diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md index c53346eb2..a7e9525fd 100644 --- a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -44,6 +44,8 @@ POST /api/external/v1/editor/images/background-removals 格式或组合错误在入队前返回 400;BgFilter 自动检测失败发生在异步执行阶段,任务通过既有失败状态收口,不把已接受的 202 改成同步 400,不启动其他抠图方式兜底。 +OpenAPI 与客户端工具的对外说明只描述模式用途、参数约束和调用方可见的结果;provider 选择与兜底策略保留在内部技术方案中,不写入对外 description。 + 主站前端继续不传新增字段,因此用户行为不变。 ## AGC 客户端改动 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index abe94fc02..5f92b841a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -13713,6 +13713,22 @@ pub(crate) fn current_utc_micros() -> i64 { #[cfg(test)] mod tests { + use super::*; + use crate::{ + config::AppConfig, + editor_generation_queue::{ + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + EDITOR_VIDEO_GENERATION_JOB_KIND, + }, + editor_green_screen::parse_editor_screen_background_color, + }; + use std::{ + io::{Read as _, Write as _}, + net::{TcpListener, TcpStream}, + sync::mpsc, + thread, + }; + #[test] fn background_removal_options_preserve_queue_parameters_and_legacy_identity() { for (fields, mode, color) in [ @@ -13817,21 +13833,6 @@ mod tests { assert_eq!(output.image.bytes, response_png); } } - use super::*; - use crate::{ - config::AppConfig, - editor_generation_queue::{ - EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, - EDITOR_VIDEO_GENERATION_JOB_KIND, - }, - editor_green_screen::parse_editor_screen_background_color, - }; - use std::{ - io::{Read as _, Write as _}, - net::{TcpListener, TcpStream}, - sync::mpsc, - thread, - }; #[test] fn ordinary_image_prompt_validation_stays_outside_prompt_builder_execution() {