统一图片抠图到BGFilter模式
为标准纯色背景请求显式传递background_mode=flat。 将手动去背景迁移到background_mode=complex、seg_model=birefnet、cross_check=off。 删除独立BiRefNet服务地址与超时配置并保留token兼容别名。 同步更新前端provider、定向测试、架构文档与项目决策记录。
This commit is contained in:
@@ -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"`。
|
||||
|
||||
## 生成规范参考图
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user