统一图片抠图到BGFilter模式

为标准纯色背景请求显式传递background_mode=flat。

将手动去背景迁移到background_mode=complex、seg_model=birefnet、cross_check=off。

删除独立BiRefNet服务地址与超时配置并保留token兼容别名。

同步更新前端provider、定向测试、架构文档与项目决策记录。
This commit is contained in:
2026-07-14 07:22:43 +00:00
parent 5299f4bc67
commit 43471ed9b4
12 changed files with 86 additions and 148 deletions
@@ -16,6 +16,14 @@
---
## 2026-07-14 手动去背景迁移到 BgFilter complex 模式
- 背景:图片画布手动“去除背景”此前单独代理 BiRefNet 服务;BgFilter 已增加 `background_mode=complex`,可直接处理非纯色背景,继续保留独立服务会形成重复的上游、配置和错误处理链路。
- 决策:`POST /api/editor/images/background-removals` 保持前端与 BFF 契约不变,worker 改用现有 BgFilter 地址、token、超时和共享 HTTP client。multipart 提交图片文件、`background_mode=complex``seg_model=birefnet``cross_check=off`,不提交 `screen_color`。标准纯色背景的角色形象、图标 spritesheet、UI 素材提取和角色动作逐帧抠图继续使用 `background_mode=flat`。删除独立 BiRefNet base URL / timeout 配置;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 仅作为 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 的兼容回退别名。
- 影响范围:图片画布手动去背景 worker、BgFilter HTTP 协议、api-server 配置、资源元数据、前端 provider 展示和相关文档。
- 验证方式:运行 api-server BGFilter / 手动去背景定向测试、前端 editorProjectClient / 画布 workflow 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-07-13 外部生成任务持久化真实执行阶段
- 背景:图片画布任务列表此前把所有 `running` 任务固定映射为“正在生成”,角色生图、图标/UI spritesheet、角色动作和手动去背景进入抠图后仍无法展示“正在处理”;前端按耗时推断阶段会产生新的非正式业务真相。
File diff suppressed because one or more lines are too long
@@ -231,6 +231,7 @@ npm run check:server-rs-ddd
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible``GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1``GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY``APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models``/v1/chat/completions``/v1/responses` 可用性。
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event``event_key = external_generation_run`metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations``/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required``request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt``max_attempts``retry_delay_ms``reference_image_bytes_total``request_params`,不要把 `SendRequest` 当成上游业务错误。
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作抽帧后的纯色背景透明化统一走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>``seg_model=<segModel>``cross_check=<on|off>`;角色形象生成和角色动作逐帧去背固定传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取固定传 `cross_check=off`,不依赖服务端默认值。前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务;`cross_check` 同样只属于后端内部供应商策略,不进入前端或外部 OpenAPI。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,四条路线均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM`gpt-5-mini`Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet``cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED``GENARRATIVE_ALIYUN_MATTING_ENDPOINT``GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID``GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET``GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。
- 2026-07-14 更新:上句关于“手动去背景继续代理独立 BiRefNet”的口径已废止。手动 `POST /api/editor/images/background-removals` 现与标准纯色背景链路共用 BgFilter 配置和 HTTP client,固定传 `background_mode=complex``seg_model=birefnet``cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat` 并继续沿用各自的 `screen_color``seg_model``cross_check` 策略。独立 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS` 已删除,旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名。
- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。单次 BgFilter 调用失败后立即重试 `1` 次,第二次仍失败才进入既有“阿里云通用抠图 → 本地键色”降级链,每次已发出的失败调用都单独写审计。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
@@ -65,7 +65,8 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg
lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。
图片画布角色图、图标素材UI 素材提取在绿色 / 蓝色幕布去背景时优先调用 BgFilter;默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看 `editor_bgfilter_request_start``editor_bgfilter_fallback_to_aliyun_matting``editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。
图片画布角色图、图标素材UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看`background_mode=flat` `editor_bgfilter_request_start``editor_bgfilter_fallback_to_aliyun_matting``editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。
手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex``seg_model=birefnet``cross_check=off`,不传背景色;它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
@@ -61,7 +61,7 @@
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
```
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter,固定传 `background_mode=flat`按默认 `segModel=birefnet` 透明化,然后复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。
## 验收点
@@ -58,7 +58,7 @@
## 去背与保存
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor``segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;BgFilter multipart 固定传 `background_mode=flat`请求字段同时包含 `screenColor``segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。
- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。
@@ -65,7 +65,7 @@
角色设定:<用户输入的角色设定>
```
- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file``screen_color=<screenColor>``seg_model=<segModel>`,用户路径默认并只提交 `seg_model=birefnet`。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。
- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file``screen_color=<screenColor>``seg_model=<segModel>``background_mode=flat`,用户路径默认并只提交 `seg_model=birefnet``flat` 明确表示沿用单一纯色背景抠图模式。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。
-`assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`
## 生成规范参考图
+1 -73
View File
@@ -17,8 +17,6 @@ const DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS: u64 = 600;
const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900;
const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800;
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
const DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL: &str = "http://58.87.105.82";
const DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS: u64 = 120_000;
const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter";
const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 180_000;
const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3;
@@ -64,9 +62,6 @@ pub struct AppConfig {
pub wallet_refund_outbox_flush_interval: Duration,
pub wallet_refund_outbox_max_bytes: u64,
pub editor_generation_pricing_override_path: PathBuf,
pub editor_background_removal_base_url: String,
pub editor_background_removal_token: Option<String>,
pub editor_background_removal_request_timeout_ms: u64,
pub editor_bgfilter_base_url: String,
pub editor_bgfilter_token: Option<String>,
pub editor_bgfilter_request_timeout_ms: u64,
@@ -309,11 +304,6 @@ impl Default for AppConfig {
wallet_refund_outbox_max_bytes: 64 * 1024 * 1024,
editor_generation_pricing_override_path:
crate::editor_generation_config::default_editor_generation_pricing_override_path(),
editor_background_removal_base_url: DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL
.to_string(),
editor_background_removal_token: None,
editor_background_removal_request_timeout_ms:
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS,
editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(),
editor_bgfilter_token: None,
editor_bgfilter_request_timeout_ms: DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
@@ -507,18 +497,6 @@ impl AppConfig {
{
config.editor_generation_pricing_override_path = PathBuf::from(pricing_override_path);
}
if let Some(base_url) =
read_first_non_empty_env(&["GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL"])
{
config.editor_background_removal_base_url = base_url;
}
config.editor_background_removal_token =
read_first_non_empty_env(&["GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN"]);
if let Some(timeout_ms) = read_first_positive_u64_env(&[
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS",
]) {
config.editor_background_removal_request_timeout_ms = timeout_ms;
}
if let Some(base_url) = read_first_non_empty_env(&["GENARRATIVE_EDITOR_BGFILTER_BASE_URL"])
{
config.editor_bgfilter_base_url = base_url;
@@ -1591,8 +1569,7 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::{
AppConfig, DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL,
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS, DEFAULT_EDITOR_BGFILTER_BASE_URL,
AppConfig, DEFAULT_EDITOR_BGFILTER_BASE_URL,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD,
DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
@@ -1617,15 +1594,6 @@ mod tests {
assert!(config.llm_base_url.is_empty());
// assert!(config.apimart_base_url.is_empty());
assert!(config.vector_engine_base_url.is_empty());
assert_eq!(
config.editor_background_removal_base_url,
DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL
);
assert_eq!(
config.editor_background_removal_request_timeout_ms,
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS
);
assert!(config.editor_background_removal_token.is_none());
assert_eq!(
config.editor_bgfilter_base_url,
DEFAULT_EDITOR_BGFILTER_BASE_URL
@@ -2338,46 +2306,6 @@ mod tests {
}
}
#[test]
fn from_env_reads_editor_background_removal_settings() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS");
std::env::set_var(
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL",
"http://10.0.0.12:8090",
);
std::env::set_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN", "token-1");
std::env::set_var(
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS",
"90000",
);
}
let config = AppConfig::from_env();
assert_eq!(
config.editor_background_removal_base_url,
"http://10.0.0.12:8090"
);
assert_eq!(
config.editor_background_removal_token.as_deref(),
Some("token-1")
);
assert_eq!(config.editor_background_removal_request_timeout_ms, 90_000);
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS");
}
}
#[test]
fn from_env_reads_editor_bgfilter_settings_and_reuses_background_token() {
let _guard = ENV_LOCK
@@ -115,6 +115,8 @@ const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"
const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source";
pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet";
const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
const EDITOR_BGFILTER_BACKGROUND_MODE_FLAT: &str = "flat";
const EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX: &str = "complex";
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true;
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false;
const EDITOR_BGFILTER_RETRY_COUNT: usize = 1;
@@ -2363,7 +2365,7 @@ pub(crate) async fn remove_editor_image_background_for_owner(
"background-removal",
"removed-background",
"result",
"birefnet",
"bgfilter-complex",
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
@@ -2382,8 +2384,8 @@ pub(crate) async fn remove_editor_image_background_for_owner(
height: removed.height,
prompt: "remove background".to_string(),
actual_prompt: None,
model: "BiRefNet".to_string(),
provider: "BiRefNet".to_string(),
model: "BgFilter complex".to_string(),
provider: "BgFilter".to_string(),
task_id: task_id.clone(),
source_resource_id: payload.source_resource_id,
asset_kind: payload.asset_kind,
@@ -2424,7 +2426,7 @@ pub(crate) async fn remove_editor_image_background_for_owner(
source_type: "generated",
task_id,
elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
provider: "BiRefNet",
provider: "BgFilter",
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
@@ -2649,6 +2651,7 @@ async fn request_editor_generated_screen_background_with_bgfilter(
mime_type = %source_mime_type,
screen_color = screen_color.hex,
seg_model,
background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_FLAT,
cross_check,
input_bytes,
timeout_ms,
@@ -2668,6 +2671,7 @@ async fn request_editor_generated_screen_background_with_bgfilter(
.part("file", file_part)
.text("screen_color", screen_color.hex.to_string())
.text("seg_model", seg_model.to_string())
.text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_FLAT)
.text(
"cross_check",
editor_bgfilter_cross_check_form_value(cross_check),
@@ -2791,13 +2795,10 @@ async fn request_editor_background_removal_image(
state: &AppState,
source_image: OpenAiReferenceImage,
) -> Result<EditorBackgroundRemovalImage, AppError> {
let url = editor_background_removal_endpoint(state)?;
let url = editor_bgfilter_endpoint(state)?;
let call_id = format!("background-removal-call-{}", current_utc_micros());
let request_started_at = Instant::now();
let timeout_ms = state
.config
.editor_background_removal_request_timeout_ms
.max(1);
let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1);
let input_bytes = source_image.bytes.len();
let source_file_name = source_image.file_name.clone();
let source_mime_type = source_image.mime_type.clone();
@@ -2806,34 +2807,34 @@ async fn request_editor_background_removal_image(
upstream_url = %url,
file_name = %source_file_name,
mime_type = %source_mime_type,
background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX,
cross_check = EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
input_bytes,
timeout_ms,
"editor_background_removal_request_start"
);
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "birefnet",
"message": format!("创建抠图 HTTP 客户端失败:{error}"),
}))
})?;
let http_client = state.editor_bgfilter_http_client();
let file_part = reqwest::multipart::Part::bytes(source_image.bytes)
.file_name(source_image.file_name)
.mime_str(source_image.mime_type.as_str())
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "birefnet",
"provider": "bgfilter",
"message": format!("图片 MIME 类型无效:{error}"),
}))
})?;
let mut request = http_client
.post(url.as_str())
.multipart(reqwest::multipart::Form::new().part("file", file_part));
let form = reqwest::multipart::Form::new()
.part("file", file_part)
.text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX)
.text("seg_model", EDITOR_BGFILTER_DEFAULT_SEG_MODEL)
.text(
"cross_check",
editor_bgfilter_cross_check_form_value(EDITOR_BGFILTER_CROSS_CHECK_DISABLED),
);
let mut request = http_client.post(url.as_str()).multipart(form);
if let Some(token) = state
.config
.editor_background_removal_token
.editor_bgfilter_token
.as_deref()
.map(str::trim)
.filter(|token| !token.is_empty())
@@ -2848,7 +2849,10 @@ async fn request_editor_background_removal_image(
error = %error,
"editor_background_removal_request_failed"
);
map_editor_background_removal_error(error)
map_editor_bgfilter_error(
error,
request_started_at.elapsed().as_millis() as u64,
)
})?;
let status = response.status();
if !status.is_success() {
@@ -2865,8 +2869,8 @@ async fn request_editor_background_removal_image(
);
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "birefnet",
"message": "抠图服务返回非成功状态",
"provider": "bgfilter",
"message": "BgFilter 服务返回非成功状态",
"upstreamStatus": status.as_u16(),
"upstreamMessage": message.chars().take(500).collect::<String>(),
})),
@@ -2881,15 +2885,15 @@ async fn request_editor_background_removal_image(
.to_string();
let upstream_elapsed_ms = response
.headers()
.get("x-birefnet-elapsed-ms")
.get("x-bgfilter-elapsed-ms")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok());
let bytes = read_editor_background_removal_bytes(response, request_started_at).await?;
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "birefnet",
"message": "抠图服务未返回图片",
"provider": "bgfilter",
"message": "BgFilter 服务未返回图片",
})),
);
}
@@ -2918,22 +2922,6 @@ async fn request_editor_background_removal_image(
})
}
fn editor_background_removal_endpoint(state: &AppState) -> Result<String, AppError> {
let base_url = state.config.editor_background_removal_base_url.trim();
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "birefnet",
"message": "抠图服务地址未配置",
})),
);
}
Ok(format!(
"{}/remove-background",
base_url.trim_end_matches('/')
))
}
fn editor_bgfilter_endpoint(state: &AppState) -> Result<String, AppError> {
let base_url = state.config.editor_bgfilter_base_url.trim();
if base_url.is_empty() {
@@ -2974,19 +2962,6 @@ fn editor_bgfilter_cross_check_form_value(enabled: bool) -> &'static str {
if enabled { "on" } else { "off" }
}
fn map_editor_background_removal_error(error: reqwest::Error) -> AppError {
let status = if error.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "birefnet",
"message": format!("请求抠图服务失败:{error}"),
"timeout": error.is_timeout(),
}))
}
fn map_editor_bgfilter_error(error: reqwest::Error, latency_ms: u64) -> AppError {
let status = if error.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
@@ -3006,7 +2981,7 @@ async fn read_editor_background_removal_bytes(
response: reqwest::Response,
request_started_at: Instant,
) -> Result<Vec<u8>, AppError> {
read_editor_image_removal_response_bytes(response, "birefnet", "抠图服务", request_started_at)
read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter", request_started_at)
.await
}
@@ -3079,7 +3054,7 @@ fn editor_image_removal_body_read_error(
}
fn decode_editor_background_removal_image(bytes: &[u8]) -> Result<image::DynamicImage, AppError> {
decode_editor_removed_background_image(bytes, "birefnet")
decode_editor_removed_background_image(bytes, "bgfilter")
}
fn decode_editor_removed_background_image(
@@ -7041,8 +7016,8 @@ mod tests {
source_type: "generated".to_string(),
prompt: Some("remove background".to_string()),
actual_prompt: None,
model: Some("BiRefNet".to_string()),
provider: Some("BiRefNet".to_string()),
model: Some("BgFilter complex".to_string()),
provider: Some("BgFilter".to_string()),
task_id: Some("extgen-cutout".to_string()),
source_resource_id: Some("resource-source".to_string()),
asset_kind: Some("character".to_string()),
@@ -7849,6 +7824,12 @@ mod tests {
assert_eq!(editor_bgfilter_cross_check_form_value(false), "off");
}
#[test]
fn editor_bgfilter_background_mode_matches_service_contract() {
assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_FLAT, "flat");
assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, "complex");
}
#[test]
fn editor_bgfilter_circuit_opens_after_consecutive_failures_and_resets_on_success() {
reset_editor_bgfilter_circuit_for_tests();
@@ -8026,6 +8007,8 @@ mod tests {
"\"screen_color\"",
"\"seg_model\"",
"seg_model.to_string()",
"\"background_mode\"",
"EDITOR_BGFILTER_BACKGROUND_MODE_FLAT",
"\"cross_check\"",
"editor_bgfilter_cross_check_form_value(cross_check)",
],
@@ -8039,12 +8022,27 @@ mod tests {
assert_function_contains(
source,
"async fn request_editor_background_removal_image",
"fn editor_background_removal_endpoint",
"fn editor_bgfilter_endpoint",
&[
"editor_background_removal_endpoint",
"\"provider\": \"birefnet\"",
"editor_bgfilter_endpoint",
"editor_bgfilter_request_timeout_ms.max(1)",
"state.editor_bgfilter_http_client()",
"editor_bgfilter_token",
"\"background_mode\"",
"EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX",
"\"seg_model\"",
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
"\"cross_check\"",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"\"provider\": \"bgfilter\"",
],
);
assert_function_not_contains(
source,
"async fn request_editor_background_removal_image",
"fn editor_bgfilter_endpoint",
&["\"screen_color\"", "reqwest::Client::builder"],
);
assert_function_not_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
@@ -1785,7 +1785,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
height: 768,
taskId: 'background-removal-task',
elapsedMs: 1234,
provider: 'BiRefNet',
provider: 'BgFilter',
});
render(
<GenerationWorkflowHarness
@@ -1874,7 +1874,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
height: 768,
taskId: 'background-removal-project-task',
elapsedMs: 1234,
provider: 'BiRefNet',
provider: 'BgFilter',
project: null,
});
});
@@ -1929,7 +1929,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
height: 768,
taskId: 'background-removal-project-task',
elapsedMs: 1234,
provider: 'BiRefNet',
provider: 'BgFilter',
project: {
projectId: 'project-1',
title: '队列项目',
@@ -1635,7 +1635,7 @@ export function useImageCanvasGenerationWorkflow({
originalWidth: result.width,
originalHeight: result.height,
sourceType: result.sourceType ?? 'generated',
provider: result.provider ?? 'BiRefNet',
provider: result.provider ?? 'BgFilter',
taskId: result.taskId ?? null,
objectKey: result.resource?.objectKey ?? result.objectKey ?? null,
assetObjectId:
@@ -1539,7 +1539,7 @@ describe('editorProjectClient', () => {
assetObjectId: 'asset-object-cutout',
taskId: 'background-removal-1',
elapsedMs: 1200,
provider: 'BiRefNet',
provider: 'BgFilter',
project: null,
});