按帧数扩展角色动作抠图超时

按基准超时加每帧两秒计算角色动作批量帧的单请求超时
在共享 BgFilter 客户端上覆盖请求级超时并保持普通路径、重试和降级行为
补充动态超时、请求覆盖和全帧流水线测试
同步后端、运维、画布专题及项目决策文档
This commit is contained in:
2026-07-15 11:48:23 +00:00
parent b148895e29
commit fddf30d7c3
7 changed files with 161 additions and 17 deletions
@@ -16,6 +16,14 @@
---
## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展
- 背景:角色动作全部序列帧会并发进入 BgFilter,而服务端可能在自身进程内排队;固定 `180000ms` 会把排队时间和单帧推理共用同一预算,靠后的请求可能在服务仍正常处理时被 api-server 提前取消。
- 决策:保留 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 作为统一基准值。只有角色动作逐帧 BgFilter 在共享 Client 的 RequestBuilder 上把每一次 HTTP attempt 覆盖为“基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧为 `244000 / 260000 / 276000ms`;角色形象单图、图标、UI 和手动去背景不增加帧预算。该 timeout 覆盖请求发起到响应体读取完成;首次失败后的重试重新获得同样的 request deadline,整批并发策略、失败排空语义和 worker long-job 总预算不变。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构、开发运维和图片画布专题文档。
- 验证方式:运行角色动作超时公式、BgFilter request override 与逐帧流水线定向测试,执行 `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/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-07-15 手动复杂去背景复用 BgFilter 单次重试
- 背景:图片画布手动去背景已经改用 BgFilter `background_mode=complex`,但 worker 仍只发送一次上游请求,短暂网络抖动会直接让任务失败。
File diff suppressed because one or more lines are too long
@@ -241,7 +241,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` 与角色形象生成、图标 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 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `background_mode=complex``seg_model=birefnet``cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `screen_color=<screenColor>``seg_model=<segModel>``cross_check=<on|off>`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `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` 审计。
- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。
- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新获得同样的 deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `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。
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
@@ -67,7 +67,7 @@ HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer
lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。
图片画布角色图、图标素材、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` 日志。
图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`该值是所有路径的基准请求超时;角色动作逐帧请求的每一次 HTTP attempt 额外增加 `2000ms × 本次实际帧数`,默认 `32 / 40 / 48` 帧对应 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景继续使用基准值。该 request timeout 不是整批帧或整项任务超时,首次失败后的重试会重新计时;角色动作整项任务仍受默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800` 预算约束,排查时以 `editor_bgfilter_request_start.timeout_ms` 确认实际值。连续失败达到 `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`,不传背景色;首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `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 暴露到前端。
@@ -162,7 +162,8 @@
- 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32``40``48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再优先调用阿里云通用抠图输出透明背景 PNG;阿里云失败时降级执行本地 `editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。
- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再`BgFilterbackground_mode=flat、seg_model=birefnet、cross_check=on)→ 阿里云通用抠图 → 本地 editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"``assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
@@ -70,7 +70,7 @@ use crate::{
apply_editor_screen_background_decision_to_generation_inputs,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
remove_editor_generated_screen_background_with_bgfilter,
remove_editor_generated_screen_background_with_bgfilter_with_request_timeout,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind,
@@ -116,6 +116,7 @@ const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast";
const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation";
const EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND: &str = "character-animation";
const EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT: &str = "provider_source";
const EDITOR_CHARACTER_ANIMATION_BGFILTER_TIMEOUT_PER_FRAME_MS: u64 = 2_000;
const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video";
const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas";
const EDITOR_VIDEO_SLOT: &str = "video_preview";
@@ -2261,6 +2262,18 @@ async fn create_editor_ark_image_to_video_task(
})
}
fn editor_character_animation_bgfilter_request_timeout_ms(
base_timeout_ms: u64,
frame_count: usize,
) -> u64 {
let frame_timeout_increment_ms = u64::try_from(frame_count)
.unwrap_or(u64::MAX)
.saturating_mul(EDITOR_CHARACTER_ANIMATION_BGFILTER_TIMEOUT_PER_FRAME_MS);
base_timeout_ms
.saturating_add(frame_timeout_increment_ms)
.max(1)
}
async fn extract_and_persist_editor_character_animation_frames(
state: &AppState,
owner_user_id: &str,
@@ -2290,6 +2303,10 @@ async fn extract_and_persist_editor_character_animation_frames(
use futures_util::StreamExt as _;
let frame_count = finalized_frames.len();
let bgfilter_request_timeout_ms = editor_character_animation_bgfilter_request_timeout_ms(
state.config.editor_bgfilter_request_timeout_ms,
frame_count,
);
let frame_results = futures_util::stream::iter(finalized_frames.into_iter().enumerate().map(
|(frame_index, frame)| async move {
process_and_persist_editor_character_animation_frame(
@@ -2302,6 +2319,7 @@ async fn extract_and_persist_editor_character_animation_frames(
request.frame_width,
request.frame_height,
request.screen_color,
bgfilter_request_timeout_ms,
audit,
)
.await
@@ -2365,6 +2383,7 @@ async fn process_and_persist_editor_character_animation_frame(
frame_width: u32,
frame_height: u32,
screen_color: EditorScreenBackgroundColor,
bgfilter_request_timeout_ms: u64,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
@@ -2399,12 +2418,13 @@ async fn process_and_persist_editor_character_animation_frame(
mime_type: frame.mime_type,
extension: frame.extension,
};
let removed = remove_editor_generated_screen_background_with_bgfilter(
let removed = remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state,
&image,
screen_color,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
bgfilter_request_timeout_ms,
audit,
)
.await?;
@@ -6063,6 +6083,34 @@ mod tests {
);
}
#[test]
fn editor_character_animation_bgfilter_timeout_scales_with_frame_count() {
assert_eq!(
editor_character_animation_bgfilter_request_timeout_ms(180_000, 32),
244_000
);
assert_eq!(
editor_character_animation_bgfilter_request_timeout_ms(180_000, 40),
260_000
);
assert_eq!(
editor_character_animation_bgfilter_request_timeout_ms(180_000, 48),
276_000
);
assert_eq!(
editor_character_animation_bgfilter_request_timeout_ms(0, 32),
64_000
);
assert_eq!(
editor_character_animation_bgfilter_request_timeout_ms(0, 0),
1
);
assert_eq!(
editor_character_animation_bgfilter_request_timeout_ms(u64::MAX - 1, 48),
u64::MAX
);
}
#[test]
fn editor_character_animation_frames_use_three_stage_matting_fallback() {
let source = include_str!("character_animation_assets.rs");
@@ -6072,7 +6120,10 @@ mod tests {
"async fn publish_animation_set",
&[
"apply_chroma_key: false",
"editor_character_animation_bgfilter_request_timeout_ms",
"state.config.editor_bgfilter_request_timeout_ms",
"process_and_persist_editor_character_animation_frame",
"bgfilter_request_timeout_ms",
".buffer_unordered(frame_count.max(1))",
".collect::<Vec<_>>()",
"frame_payloads.sort_by_key",
@@ -6086,7 +6137,8 @@ mod tests {
&[
"put_character_animation_object",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"bgfilter_request_timeout_ms",
"finalize_animation_frame_payload",
"put_character_animation_object",
"animation_frame",
@@ -6100,9 +6152,10 @@ mod tests {
"EDITOR_CHARACTER_ANIMATION_ASSET_KIND",
"EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
"bgfilter_request_timeout_ms",
"finalize_animation_frame_payload",
],
);
@@ -2845,6 +2845,27 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(
seg_model: &str,
cross_check: bool,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state,
image,
screen_color,
seg_model,
cross_check,
state.config.editor_bgfilter_request_timeout_ms,
audit,
)
.await
}
pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state: &AppState,
image: &DownloadedOpenAiImage,
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
cross_check: bool,
request_timeout_ms: u64,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) {
// 熔断打开只跳过 BgFilter 调用本身,仍要走阿里云兜底;绝不能在 cooldown 内直接退化到
@@ -2869,6 +2890,7 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(
screen_color,
seg_model,
cross_check,
request_timeout_ms,
)
.await
{
@@ -3045,11 +3067,12 @@ async fn request_editor_generated_screen_background_with_bgfilter(
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
cross_check: bool,
request_timeout_ms: u64,
) -> Result<DownloadedOpenAiImage, AppError> {
let url = editor_bgfilter_endpoint(state)?;
let call_id = format!("bgfilter-call-{}", current_utc_micros());
let request_started_at = Instant::now();
let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1);
let timeout_ms = request_timeout_ms.max(1);
let input_bytes = image.bytes.len();
let source_mime_type = image.mime_type.clone();
let source_file_name = format!(
@@ -3088,7 +3111,10 @@ async fn request_editor_generated_screen_background_with_bgfilter(
"cross_check",
editor_bgfilter_cross_check_form_value(cross_check),
);
let mut request = http_client.post(url.as_str()).multipart(form);
let mut request = http_client
.post(url.as_str())
.timeout(Duration::from_millis(timeout_ms))
.multipart(form);
if let Some(token) = state
.config
.editor_bgfilter_token
@@ -7098,6 +7124,13 @@ mod tests {
fn spawn_bgfilter_png_mock(
response_png: Vec<u8>,
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
spawn_bgfilter_png_mock_with_delay(response_png, Duration::ZERO)
}
fn spawn_bgfilter_png_mock_with_delay(
response_png: Vec<u8>,
response_delay: Duration,
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind");
let address = listener
@@ -7110,6 +7143,7 @@ mod tests {
request_sender
.send(request)
.expect("captured request should be delivered");
thread::sleep(response_delay);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-BgFilter-Elapsed-Ms: 9\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response_png.len()
@@ -9449,6 +9483,44 @@ mod tests {
assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, "complex");
}
#[tokio::test]
async fn editor_generated_bgfilter_request_timeout_overrides_shared_client_default() {
let response_png = encode_test_png(3, 2);
let (base_url, request_receiver, server) =
spawn_bgfilter_png_mock_with_delay(response_png.clone(), Duration::from_millis(100));
let state = AppState::new(AppConfig {
editor_bgfilter_base_url: base_url,
editor_bgfilter_request_timeout_ms: 20,
..AppConfig::default()
})
.expect("state should build");
let source_image = DownloadedOpenAiImage {
bytes: encode_test_png(2, 2),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let removed = request_editor_generated_screen_background_with_bgfilter(
&state,
&source_image,
parse_editor_screen_background_color(Some("#CFEFFF"))
.expect("screen color should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
2_000,
)
.await
.expect("request timeout override should outlive the shared client default");
request_receiver
.recv_timeout(Duration::from_secs(1))
.expect("mock server should capture request");
server.join().expect("mock server should stop cleanly");
assert_eq!(removed.bytes, response_png);
assert_eq!(removed.mime_type, "image/png");
assert_eq!(removed.extension, "png");
}
#[tokio::test]
async fn editor_manual_background_removal_sends_complex_bgfilter_http_contract() {
let response_png = encode_test_png(3, 2);
@@ -9650,9 +9722,18 @@ mod tests {
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
],
);
assert_function_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
&[
"state.config.editor_bgfilter_request_timeout_ms",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
],
);
assert_function_contains_in_order(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter",
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"async fn fallback_editor_screen_background_removal",
&[
// 熔断打开分支必须先委派统一兜底链(阿里云→本地),不能直接退化本地扣色;
@@ -9678,11 +9759,12 @@ mod tests {
assert_function_contains(
source,
"async fn request_editor_generated_screen_background_with_bgfilter",
"async fn request_editor_background_removal_image",
"async fn request_editor_background_removal_image(",
&[
"editor_bgfilter_endpoint",
"editor_bgfilter_request_timeout_ms.max(1)",
"request_timeout_ms.max(1)",
"state.editor_bgfilter_http_client()",
".timeout(Duration::from_millis(timeout_ms))",
"\"screen_color\"",
"\"seg_model\"",
"seg_model.to_string()",
@@ -9695,12 +9777,12 @@ mod tests {
assert_function_not_contains(
source,
"async fn request_editor_generated_screen_background_with_bgfilter",
"async fn request_editor_background_removal_image",
"async fn request_editor_background_removal_image(",
&["reqwest::Client::builder"],
);
assert_function_contains(
source,
"async fn request_editor_background_removal_image",
"async fn request_editor_background_removal_image(",
"fn editor_bgfilter_endpoint",
&[
"editor_bgfilter_endpoint",
@@ -9718,7 +9800,7 @@ mod tests {
);
assert_function_not_contains(
source,
"async fn request_editor_background_removal_image",
"async fn request_editor_background_removal_image(",
"fn editor_bgfilter_endpoint",
&["\"screen_color\"", "reqwest::Client::builder"],
);
@@ -9790,7 +9872,7 @@ mod tests {
assert!(source.contains("const EDITOR_BGFILTER_RETRY_COUNT: usize = 1;"));
assert_function_contains_in_order(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter",
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"async fn fallback_editor_screen_background_removal",
&[
"let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1",