Skip to content

feat(framework): Provider 抽象 + 主体抠图 + 视频下载重试(#152 的前置 · Refs #171) - #179

Merged
xiaocheny214 merged 12 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/provider-interfaces-and-matte
Aug 11, 2026
Merged

feat(framework): Provider 抽象 + 主体抠图 + 视频下载重试(#152 的前置 · Refs #171)#179
xiaocheny214 merged 12 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/provider-interfaces-and-matte

Conversation

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor

变更内容

windup_framework/providers 的抽象与实现:

  • interfaces.pyImageProvider / VideoProvider / MatteProvider 三个 Protocol,零依赖,供上层按能力而非按厂商声明依赖
  • matte.pyOnnxU2NetMatteProvider 主体抠图。不用 rembg:其依赖链 pymatting → numba 0.53 / llvmlite 0.36 在 Python 3.12 无轮子(实测装不上);而 rembg 内核就是 u2netp 过 onnxruntime,默认 alpha_matting=False 时根本不碰 pymatting。直调 onnxruntime 甩掉整条死重依赖,同模型同质量
  • sufy.py — 图生视频 provider,含按现行 FAL 队列接口的实现

为什么先提这个

这是 #152 的前置。 #152strategy/concrete.py:17 import 了 MatteProvider,而主线 providers/ 下没有 matte.py,合入即 import 失败。已在 #152 下附可复核位置。

三处实测挣得的修复

1. 视频成品下载无重试导致整单作废(#129

取视频那一步是单次读取、不校验长度,而它发生在提交任务、轮询、等待全部成功之后——钱已经付了、视频已经生成好,只差把数据取回来,此时连接断一次整单就废。实测同一角色连续两单死在这里各烧一次费用,第三单才成功。加三次退避重试与长度校验;四条回归测试拿修复前的旧实现做过对照,确认其中三条在修复前确实会失败。

2. onnxruntime 缺失时不再静默降级

旧行为是回落到「取四角主色做 chroma-key」。两个问题:猜背景色——白底母版四角就是白色,浅色角色(骨白 / 银甲)与背景撞色会被抠穿;静默——开发机上看着能跑、输出其实是坏的,要到产物验收才发现。改为抛 RuntimeError

3. 抠图对闭合区域天然失灵,补窄阈值底色清理

u2netp 是显著性模型,四足角色腿间那块被主体围住的背景空隙被判成主体内部,整块底色留在产物里;轮廓上还带一圈底色描边。母版底色是刻意生成的纯色、均匀度极高(实测四角标准差 1.0–1.2),拿它做一次窄阈值清理正好补上这个洞。三个角色残留 2.54% / 0.44% / 1.25% → 0.17% / 0.21% / 0.26%

阈值必须窄。实测一个铁锈橙毛 (222,130,70) 的角色配玫红底 (222,41,124):两者红通道完全相同、欧氏距离仅 104。先后试过两版宽阈值 chroma,都把橙毛判成半透明并去「反解」,越解越坏(先成橄榄绿、再成亮绿)。取 38 时橙毛 d≈117 完全不受影响,闭合空隙 d≈0 干净移除。

与「按颜色抠是死路」那条既有结论的边界:那条说的是拿颜色当主体判据(白底浅色角色会被抠穿)。这里主体判据仍是 u2netp,颜色只用来做减法,绝不新增主体像素;底色不够均匀时(四角标准差 > 8)整体跳过。

采样要跳过最外圈。 视频帧最外一两行/列常是编码器边缘伪影而非底色:实测 9 段真 i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)被判「底不均匀」而跳过清理——修复在真实路径上等于从不生效。逐一查证全部由最外圈造成(某视频最右一列整列纯黑 std 50.4)。往里让 2px 后 144 帧零误跳,三张静态母版取样中位色一字节未变。

依赖声明

  • onnxruntime>=1.17,<1.24 — 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac 装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致
  • qiniu>=7.14 — 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/uploadModuleNotFoundError

(这两条主线现已具备,本 PR 不重复添加。)

关联与依赖

Refs #171 · Refs #129 · Refs #20 · 是 #152 的前置

stack 声明:本分支 stack 在 feat/character-domain-models#172)之上——sufy.py 的类型标注用到 windup_common.models#172 合并后本分支 rebase,届时 diff 只剩 providers 这一层。

本地验证

uv run ruff check .   All checks passed!
uv run lint-imports   Contracts: 2 kept, 0 broken.
uv run pytest -q      全绿

待对齐

FAL 队列 provider 从未真实调用过。 认证方式(Key 而非 Bearer)、/v1 前缀剥离、请求体形状、轮询地址(六个 kling 模型共用 /queue/fal-ai/kling-video/requests/{id},模型段与 {mode} 都消失)、结果回退、下载,全部只对着 OpenAPI 规范与 mock 验证(37 个用例、变异测试 11/11 被捕获)。首次真跑要花钱,可能暴露不一致——这条如实写在这里,不当作已验证。

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
windup Ignored Ignored Preview Aug 11, 2026 10:08am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Found three high-confidence issues affecting credential safety, fresh installs, and locked test environments.

Verification

  • git diff --check passed.
  • python3 -m compileall -q backend/packages/framework/src backend/tests passed.
  • PR tests could not run because uv is unavailable in this runner.

View job run

Comment thread backend/packages/framework/src/windup_framework/providers/sufy.py Outdated
Comment thread backend/packages/framework/pyproject.toml
Comment thread backend/uv.lock
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
机器审 PR 1024XEngineer#179 P1。成品 URL 是网关响应里的绝对地址(正常指向 CDN,异常可以是
网关返回的任意地址),原实现复用带 Authorization 的网关 client 直接 GET。httpx
只在跨源**重定向**时才自动摘 Authorization,对一开始就跨源的直连请求会原样带上
client 级 headers —— API key 因此发给了那个域名。

改法:
- 按目标地址判定后显式摘凭证,不是一律摘。网关也可能签发自己域名下的下载链接,
  那条路径摘了头就是 401,所以同源保留、跨源摘掉 Authorization 与 Cookie。
- Proxy-Authorization 不动:它是给代理的,与目标是否同源无关。
- 同源判据对齐 httpx 自己的 `_redirect_headers`(scheme + host + 端口),
  未 import 其私有函数,免得被上游改名。
- 请求改为进重试循环之前构造,非 http(s) 地址在发出任何一次请求之前就炸。
- 2026-08-05 实测挣来的三次退避重试与 Content-Length 校验原样保留(视频已生成、
  费用已产生,断一次不能整单作废),FAL 面调用处那句"用同一个 client 带鉴权头取"
  的注释同步更正 —— 它正是这个泄漏的出处。

变异验证 13 个:12 被杀。唯一存活的是单独拆掉"默认端口补齐" —— httpx 0.28 已把
:443/:80 归一化成 port=None,该行与 scheme 比较互为冗余,两条同时拆即被杀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
机器审 PR 1024XEngineer#179 P1。成品 URL 是网关响应里的绝对地址(正常指向 CDN,异常可以是
网关返回的任意地址),原实现复用带 Authorization 的网关 client 直接 GET。httpx
只在跨源**重定向**时才自动摘 Authorization,对一开始就跨源的直连请求会原样带上
client 级 headers —— API key 因此发给了那个域名。

改法:
- 按目标地址判定后显式摘凭证,不是一律摘。网关也可能签发自己域名下的下载链接,
  那条路径摘了头就是 401,所以同源保留、跨源摘掉 Authorization 与 Cookie。
- Proxy-Authorization 不动:它是给代理的,与目标是否同源无关。
- 同源判据对齐 httpx 自己的 `_redirect_headers`(scheme + host + 端口),
  未 import 其私有函数,免得被上游改名。
- 请求改为进重试循环之前构造,非 http(s) 地址在发出任何一次请求之前就炸。
- 2026-08-05 实测挣来的三次退避重试与 Content-Length 校验原样保留(视频已生成、
  费用已产生,断一次不能整单作废),FAL 面调用处那句"用同一个 client 带鉴权头取"
  的注释同步更正 —— 它正是这个泄漏的出处。

变异验证 13 个:12 被杀。唯一存活的是单独拆掉"默认端口补齐" —— httpx 0.28 已把
:443/:80 归一化成 port=None,该行与 scheme 比较互为冗余,两条同时拆即被杀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/provider-interfaces-and-matte branch from 413dcf1 to 6c944be Compare August 10, 2026 10:24
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

三条 P1 已处理,6c944be。

① 下载复用带鉴权的 client — 已修。泄漏点实际有两处SufyVideoProvider.i2v(OpenAI 面)与 FalQueueVideoProvider.i2v(FAL 面)都把带 Authorization 的 client 传进 _download,后者还有一句注释在为它辩护("用同一个 client 带鉴权头取"),那句注释就是泄漏的来源。

取了 review 给的第二个方案(校验目标后剥离),没取第一个(一律用不带认证的 client)。理由:网关有可能在自家域名上签下载链接,那条路径缺 header 会 401;而不真跑一次付费调用无法区分两者。所以按目标地址判定 —— 新增 _download_request()client.build_request 造请求,目标与 client.base_url 不同源时 pop 掉 AuthorizationCookie,然后 client.send()。已验证 httpx 0.28.1 的 send() 不会重新合并 client 级 headers(build_request 已合并过),且 _build_request_auth 只在传 auth= 时触发,头不会回来。

Proxy-Authorization 刻意不动 —— 它属于代理不属于目标,pop 掉会打断走代理的下载。请求在进重试循环之前构造,地址不合法立刻失败而不是重试三次之后(新增 UnsafeDownloadUrlError)。2026-08-05 那套重试 + Content-Length 校验逐字保留(它治的是"视频已生成、费用已产生,下载断一次整单作废",实测烧过两次钱)。

② / ③ 依赖与 lock — 在 3bda9fd 已修(今天 15:37 取主线 pyproject.toml 并重锁)。按"只信 live 数据"复核过,不是读文件确认的:rm -rf .venv && uv sync --frozen 全新安装成功,装上 bcrypt 5.0.0 / passlib 1.7.4 / redis 8.1.0 / resend 2.35.0 / pytest_cov 7.1.0 / coverage 7.15.4;该 frozen venv 上 uv run pytest -q → 130 passed 且有 coverage 输出。两条描述的失败场景都不成立。uv lock --check 通过,registry = "https://pypi.org" 计数为 0(88 处阿里云镜像源完好)。

变异测试 13 个,12 被杀。 两个存活分开判断:

  • "不比 scheme" 是真缺口,已补。 https://gwhttp://gw/...(TLS 降级)host 相同、端口都归一化成 None,没有 scheme 检查时 API key 会走明文 HTTP 发出去。补测试后第一轮仍存活(被端口默认值子句掩盖),又加了显式同端口用例(https://gw:8443http://gw:8443)才杀掉。
  • "不填默认端口" 是等价变异,保留并记录。 httpx 0.28 已把 :443/:80 归一化成 port is None,在 scheme 检查存在的前提下该子句改不了任何可达判定。补了一个同时删掉两个子句的变异 → 被杀,证明这对是联合必需的。保留是为了与 httpx 自身实现对齐,分析写进了 _same_origin 的 docstring,免得后来者盲删。

未验证:没做任何付费调用,所以没有 live 证据说明真实网关的结果 URL 是同源还是 CDN —— 这正是修法做成"按地址判定"的原因:跨源则 key 不再泄漏,同源则行为与改前逐字节相同,两个分支都不会回归,但都没在真网关上确认过。另外 SufyVideoProvider.i2v 的提交/轮询路径本来就没有测试(既有缺口,非本次引入),本次未补。

johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
机器审 PR 1024XEngineer#179 P1。成品 URL 是网关响应里的绝对地址(正常指向 CDN,异常可以是
网关返回的任意地址),原实现复用带 Authorization 的网关 client 直接 GET。httpx
只在跨源**重定向**时才自动摘 Authorization,对一开始就跨源的直连请求会原样带上
client 级 headers —— API key 因此发给了那个域名。

改法:
- 按目标地址判定后显式摘凭证,不是一律摘。网关也可能签发自己域名下的下载链接,
  那条路径摘了头就是 401,所以同源保留、跨源摘掉 Authorization 与 Cookie。
- Proxy-Authorization 不动:它是给代理的,与目标是否同源无关。
- 同源判据对齐 httpx 自己的 `_redirect_headers`(scheme + host + 端口),
  未 import 其私有函数,免得被上游改名。
- 请求改为进重试循环之前构造,非 http(s) 地址在发出任何一次请求之前就炸。
- 2026-08-05 实测挣来的三次退避重试与 Content-Length 校验原样保留(视频已生成、
  费用已产生,断一次不能整单作废),FAL 面调用处那句"用同一个 client 带鉴权头取"
  的注释同步更正 —— 它正是这个泄漏的出处。

变异验证 13 个:12 被杀。唯一存活的是单独拆掉"默认端口补齐" —— httpx 0.28 已把
:443/:80 归一化成 port=None,该行与 scheme 比较互为冗余,两条同时拆即被杀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/provider-interfaces-and-matte branch from e051fe2 to 66da7d0 Compare August 11, 2026 02:07
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

已 force-push:把这条依赖链重排成真正的线性 stack。评审锚点会移位,说明原因。

问题:GitHub 的 MERGEABLE 只计算"对当前 main",不计算"前面几个先合进去之后"。本地实测按依赖顺序合并:main → #172#179#180 都干净,#181 时 3~4 个文件冲突matte.py / test_matte_provider.py / test_character_contract.py / quality.py)。

机制#181 / #182 此前是各自独立基于 main、靠"同步提交"携带前置分片内容的副本。合并 #181 时的共同祖先里没有 matte.py(它属于 #179),于是两边各自"新增"同一个文件 = add/add 冲突 —— 即使一边是严格超集,git 也无法自动合并。所以逐文件对齐内容没用,必须让祖先里真有那些文件。

处理:改成 #172#179#180#181#182 的线性 stack,每个分支真正包含前置分支的提交。副作用是那些"同步上游/下游"的提交全部变成冗余,已在重排中丢弃。

内容变化(重排本身不改逻辑,两处例外,均已核对):

验收(重排后逐项跑过):

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

更正一条我此前的判断,并说明随之改变的实现。

我之前把交付帧上一处"背景透出来"读成抠图破洞。复核后这个诊断是错的:那一处是真实的两腿间隙,本来就该透明。交付帧(256²)上封闭透明域最大只有 4px,全是轮廓锯齿。

但在 cutout() 真正跑的地方——全分辨率视频帧——确实存在缺陷,只是成因不同:

  • u2netp 自身在主体内部造的洞很少(8 帧抽样 6 帧为 0),不是主因
  • 真凶是 _flat_bg_penalty 的键控清理:浅肤色 (243,221,200) 到灰底 (219,219,220) 的欧氏距离只有 31.3,窄于 _KEY_KILL=38,于是每帧误杀 820~2346 个 u2netp 已判为主体的像素。这些被误杀的像素被主体围住,表现为封闭空洞。

更要紧的是:只按"不与画面边界连通"判空洞会把两腿之间填实。 迈步相里两只靴子在下方交叠,把腿间空隙彻底封死——它就是一块不与边界连通的背景域。实测 121 帧中 80 帧存在这种封闭空隙、共 25,173 px,朴素版会把它们全部填成主体(最惨单帧 3,172 px,两条腿焊死)。

所以判据是连通性与颜色两条一起:一个透明连通域只要"碰到画幅边界""内部存在任何一个确实是底色的像素",就不是洞。

实测:真空隙 25,173 px → 本实现填 0,朴素版填 25,173;121 帧共填回 51,273 个被误杀的主体像素。alpha 只增不减、只改成 1.0、RGB 不碰。_HOLE_BG_TOL=14 的依据:纯背景色距 p99.9≈6.5、最大 11.1(压缩噪点),而误杀区中位色距 ≥17.1,14 落在这条 1.5 倍间隙里。

未引入 scipy(numpy 行/列游程传播,34ms/帧,cutout 端到端 0.44s);_spread 与逐像素 BFS 在 300 组随机掩码上逐点等价。

变异测试 9 个变异全部被杀,其中包含"去掉颜色守卫"这一条——它正对应我最初给出的错误设计。

另:本次把 _bg_key() 的取样统一到 _corner_pixels()(跳过最外圈编码器伪影)。键控清理与空洞填充必须按同一个 key 判断,否则一个把某块当背景清掉、另一个又把它当主体填回来。

如实说明未验的部分_HOLE_BG_TOL=14 只在这一个角色、一种灰底上标定过,换成与底色差异更小的角色会漏填(保守方向,不会误填);空洞修复只在 walk 的 121 帧上验过,attack / jump / idle 与其他角色未试。

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

这五个 PR 已达 ready:无待追加改动、CI 通过、AI review 意见全部 resolved。可以开始 review。

依赖顺序(已重排为线性 stack,逐级包含前一片的提交):

#172 共享契约  →  #179 Provider 与抠图  →  #180 出帧工具箱  →  #181 引擎契约与串联  →  #182 任务编排

#180 零依赖于前两片的业务逻辑(纯 PIL / numpy + 真实视频实测),想先看小的可以从它入手。

本地已验的三项(每次推送后重跑):

  • 按依赖顺序合并 main → #172 → #179 → #180 → #181 → #182 五步全干净
  • 逐分支 CI 原样命令全过,测试数 111 → 185 → 266 → 307 → 337 单调递增
  • 全部合入后应用可启动,app.openapi() 口径 29 条路由,与 main 一字不差(零新增零删除)

端到端实证:2026-08-11 用这条链路(不是旁路脚本)从零跑通两个全新角色的走路序列帧——文生图出母版 → i2v → 抽帧 → 选帧 → 抠图 → 像素化 → 对齐 → 打包。


三条已知缺陷,代码在本批 PR 内,已独立立项跟踪,不在本批修复:

三条都不影响流程成功与 CI,属品相问题。选择独立跟踪而不是塞进本批,是为了不让改动范围与 Issue 脱节;其中 #197 的可行方向尚未实现也未验证,如实说明。

@johnnyzhang-eng
johnnyzhang-eng requested a review from nighca August 11, 2026 05:48
Comment thread backend/packages/framework/src/windup_framework/providers/interfaces.py Outdated
Comment thread backend/packages/framework/src/windup_framework/providers/sufy.py
Comment thread backend/packages/framework/src/windup_framework/providers/matte.py
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.68050% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../framework/src/windup_framework/providers/matte.py 93.06% 7 Missing ⚠️
...s/framework/src/windup_framework/providers/sufy.py 99.20% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

第二条(硬编码)已按承诺改完,1fadabb

AIProviderSettings 加了三个字段:video_model / image_model / fal_video_model,默认值即当前实测在用的型号,部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL / AI_FAL_VIDEO_MODEL 覆盖;显式传参仍优先于配置(A/B 对比时不必改环境变量)。

分三个字段而不是复用已有的 model:三条能力同时在用不同模型,共用一个意味着换其中一条把另外两条也换了。

请求形状仍留在代码里,理由写进了配置类的注释,方便后来人看到判据而不是只看到结论。

这批改动逮到一个真 bugFalQueueVideoProvider 的构造期校验发生在型号解析之前,于是 model=None(表示"用配置里的")会被直接拿去查端点表、报"模型 None 不在表里"——也就是走默认路径就构造失败。之前没暴露是因为所有调用点都显式传了型号。已改成先解析型号再校验。

测试 +5,5 条变异全部杀掉:共用一个字段 / 忽略配置写回硬编码 / 显式传参被配置覆盖 / 配置里补上请求形状字段 / 校验挪回解析之前。


另外两条的处理:

  • 第一条(FirstFrameUploader 已在对应行内回复:它是 framework 层的 Protocol,而 media 上传是 server 层的实现,import-linter 的分层禁止 framework 反向依赖 app.server,所以只能声明一个洞、由 bootstrap 注入。不是第二套上传逻辑。
  • 第三条(抠图分层) 已在对应行内回复:换抠图实现不需要重构(MatteProvider 已是 Protocol、消费方按 Protocol 注入,现有 205 行一行不动);放 framework 是因为它要 import onnxruntime + 下载权重,属外部资源适配。但你戳到的第三点我认——这个文件里确实混了"模型适配"(约 40 行)与"图像后处理"(约 120 行),后者与用哪个抠图模型无关、换实现时会跟着丢掉。这条我建议单独开 Issue 跟踪,因为它要动 ai_engine.postprocess 的接口,而那层在 feat(ai_engine): 出帧工具箱 —— 抽帧 / 选帧 / 后处理 / 提示词(Refs #171) #180 / feat(ai_engine): 对外契约 + 动作分流 + 入口预检与出参成色(Refs #171 #53) #181 正在评审,现在动会让三个 PR 互相牵扯。

本 PR 与其上三片已同步 rebase 并全部重跑:逐分支 CI 全过(111 → 190 → 271 → 312 → 351),顺序合并 main → #172 → #179 → #180 → #181 → #182 五步干净。

johnnyzhang-eng and others added 10 commits August 11, 2026 16:58
providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。
2026-08-07 用一张全新角色母版跑 kling-v3-omni 端到端时实测发现,费用已产生。

现象:提交成功、status=completed、16 帧齐、逐帧时长齐、下游抽帧/选帧/抠图/脚线对齐
全部正常工作,最终产出一组构图完整的序列帧。但画面里是一个**与母版毫无关系的写实路人**
——母版是插画风、赭黄长外套、背铜管乐器的乐手,产出是深绿外套的写实人物,且只有下半身
(提示词里 "the legs clearly visible" 被当成了取景指令)。

根因:首帧字段按**模型**选,不是按"本地图/公网 URL"选。厂商文档写明 Kling 用
image_list、Sora 用 input_reference,而本仓只把 kling-video-o1 列进了 image_list 名单。
kling-v3-omni 收到 input_reference 后既不报错也不采纳,退化成纯文生视频。

危险在于失败形态:老模型(v2 系列)塞错字段会 failed,还能发现;kling-v3-omni 是
**成功返回一个错误结果**,整条管线无一处能察觉。这与本批 PR 已修的"未实现路线返回空帧"
属同一类问题,只是发生在更外层——空帧至少还能靠"帧是空的"判出来,这个连帧都是好的。

两处修复:

1) _needs_image_list 显式归类 + kling-v3 前缀兜底。仅对已确认的型号切换字段:
   v2-5-turbo / v2-1 已实测可吃 input_reference(2026-07-27 端到端到 completed),
   不动既有通路,避免为修一个模型而破坏三个。

2) _assert_reference_registered 在**下载视频之前**拦截。网关在
   billing_type_description 里明写计费口径,送了首帧却拿到"无参考视频"即为铁证。
   提交后与轮询到 completed 时各查一次。字段缺失时不拦——不同网关字段不一定存在,
   宁可漏判也不误伤。

四条回归测试,变异测试确认有效:把 v3-omni 退回 input_reference(复现原 bug)、
去掉计费口径检查,各有 1 条用例失败;还原后 8 passed。
2026-08-07 拉网关 OpenAPI spec 逐个核对:平台现有 69 个 POST 视频端点,其中 22 个
图生视频**全部**在 FAL 队列面 /queue/... 下,首帧一律是 URL 形态字段(image_url /
start_image_url),同日实测送 base64 dataURI 无一能用。原 SufyVideoProvider 建在
OpenAI 风格 /v1/videos + input_reference dataURI 上,是过时的接口形状——在它上面打的
两处补丁方向错了,一并回退:

- _needs_image_list / _IMAGE_LIST_MODELS 里新增的 kling-v3-omni / kling-v3
- _assert_reference_registered / ReferenceIgnoredError 及其 3 条测试

新增 FalQueueVideoProvider 与旧实现并存(没有实测证据说 /v1/videos 已坏,sora 系可能
仍只在那一面)。要点:

1) 模型 → 端点的显式硬表 FAL_I2V_ENDPOINTS,不拼路径。每家有三样东西不同且都猜不出
   来:提交路径的型号段;首帧字段名(同是 kling,o3 / v2.5-turbo 叫 image_url,
   v3 / v2.6 / o1 叫 start_image_url);轮询前缀(**不是**提交路径 + /requests,
   kling 六个型号共用 /queue/fal-ai/kling-video/requests/{id})。未登记的模型抛
   UnknownVideoModelError,不做前缀匹配、不做兜底——猜出一条"存在但语义不同"的路径
   (如把 image-to-video 猜成 reference-to-video)会正常出片、正常计费。

2) i2v 契约冲突:Protocol 收 bytes,FAL 面只吃公网 URL。选择"provider 自己适配",
   Protocol 签名不动——新增 FirstFrameUploader port,provider 构造时必传,内部把补边
   后的首帧换成 URL。调用方零改动;母版已在公网时用 PreUploadedFirstFrame 复用该
   URL、不重传。

3) 失败一律显式抛错,不静默降级:spec 明写「任务失败时后端也返回 COMPLETED,通过
   detail 区分」,故 COMPLETED 还要查 detail;认不出的 status 当失败(继续轮询会把
   "协议变了"伪装成"生成太慢");超时抛 VideoJobTimeoutError;参数校验在上传首帧之前
   完成;下载复用既有 _download(重试 + 长度校验,治"视频已生成、费用已产生,下载断
   一次整单作废")。

FAL 面鉴权是 Authorization: Key(不是 Bearer),base_url 需从 /v1 退回网关根
(/queue 与 /v1 平级)。两处都有 spec 依据,已写进注释与测试。

37 条新测试全程 mock 不联网;11 个变异(错端点 / 错字段名 / 错轮询前缀 / 去掉各处抛错
/ 去掉下载重试 / 参数校验挪到上传后)逐个确认能被测到,全部 KILLED。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
两处,都是 2026-08-07 用三个全新角色母版实测出来的。

1) u2netp 对闭合区域天然失灵
   四足角色腿间的背景是一块被主体围住的空隙,显著性模型把它当成主体内部,整块底色
   留在产物里;轮廓上还带一圈底色描边。母版底色是刻意生成的纯色、均匀度极高(实测
   四角标准差 1.0–1.2),拿它做一次窄阈值清理正好补上这个洞。

   阈值必须窄。实测一个铁锈橙毛 (222,130,70) 的角色配玫红底 (222,41,124):两者红通道
   完全相同、欧氏距离仅 104。先后试过两版宽阈值 chroma,都把橙毛判成半透明并去"反解",
   越解越坏(先成橄榄绿、再成亮绿)。取 38 时橙毛 d≈117 完全不受影响,而闭合空隙里的
   背景 d≈0 干净移除。三个角色残留 2.54%/0.44%/1.25% → 0.17%/0.21%/0.26%。

   与"按颜色抠是死路"那条规则的边界:那条说的是拿颜色当**主体判据**(白底浅色角色会
   被抠穿)。这里主体判据仍是 u2netp,颜色只用来**做减法**,绝不新增主体像素;底色不够
   均匀时(四角 std > 8)直接跳过,等于不清理。

2) 去掉 onnxruntime 缺失时的静默兜底
   旧行为是回落到"取四角主色做 chroma-key"。两个问题:猜背景色——白底母版四角就是白色,
   浅色角色与背景撞色会被抠穿;静默——开发机上看着能跑、输出其实是坏的,要到产物验收
   才发现。改为抛 RuntimeError。

五条回归测试,变异测试确认有效:阈值放宽到 120(误伤橙毛)、去掉均匀性守卫、清理系数
允许 >1(凭空造主体)、恢复静默兜底,各有用例失败;还原后 7 passed。
rebase 到 main 时解冲突取了主线的 uv.lock,但 framework/pyproject.toml 取了本分支的,
后者少了主线用户模块加的 passlib[bcrypt] / redis / resend —— CI 装依赖时按 pyproject
解析,于是 conftest.py 导入 bcrypt 失败(ModuleNotFoundError,本地因 venv 里已装而没暴露)。

主线的 pyproject 已含本分支需要的全部依赖(onnxruntime<1.24 / qiniu / pillow / numpy,
连注释都是从这条线过去的),故直接取主线版本,两边并集自然成立。uv lock --check 通过。
机器审 PR 1024XEngineer#179 P1。成品 URL 是网关响应里的绝对地址(正常指向 CDN,异常可以是
网关返回的任意地址),原实现复用带 Authorization 的网关 client 直接 GET。httpx
只在跨源**重定向**时才自动摘 Authorization,对一开始就跨源的直连请求会原样带上
client 级 headers —— API key 因此发给了那个域名。

改法:
- 按目标地址判定后显式摘凭证,不是一律摘。网关也可能签发自己域名下的下载链接,
  那条路径摘了头就是 401,所以同源保留、跨源摘掉 Authorization 与 Cookie。
- Proxy-Authorization 不动:它是给代理的,与目标是否同源无关。
- 同源判据对齐 httpx 自己的 `_redirect_headers`(scheme + host + 端口),
  未 import 其私有函数,免得被上游改名。
- 请求改为进重试循环之前构造,非 http(s) 地址在发出任何一次请求之前就炸。
- 2026-08-05 实测挣来的三次退避重试与 Content-Length 校验原样保留(视频已生成、
  费用已产生,断一次不能整单作废),FAL 面调用处那句"用同一个 client 带鉴权头取"
  的注释同步更正 —— 它正是这个泄漏的出处。

变异验证 13 个:12 被杀。唯一存活的是单独拆掉"默认端口补齐" —— httpx 0.28 已把
:443/:80 归一化成 port=None,该行与 scheme 比较互为冗余,两条同时拆即被杀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /generation/image 是可达端点,ImageTaskExecutor 默认实例化 SufyImageProvider,
而该类的 gen_image 直接抛 NotImplementedError —— 每个图像任务都稳定走到 FAILED。
端点看着可用、实际必失败,是本仓最忌讳的形态(机器审逮到)。

实现要点:
- 走 OpenAI 兼容的 /chat/completions 面,参考图以 data URI 塞进 content 数组。
  与 i2v 的提交-轮询-下载三段式是完全不同的调用形状,不复用 VideoProvider 通路。
- 对整个响应 JSON 正则取 data URI,不猜 message.content 的层级:不同网关包裹层级
  不一致,猜错的代价是"调用成功、费用已产生、但我们报没图"。
- 空图重试 3 次。模型偶发返回一条不含图的正常响应;这与 _download 的网络重试是两
  码事,后者治连接断。
- 校验 base64 解出的字节数下限 5000。响应里可能带几十字节的占位串,当图存下去就是
  一个打不开的文件。
- 取不到有效图抛 RuntimeError,不返回空 bytes:上游会把返回值直接上传对象存储并写
  进任务结果,0 字节的"成功"就是用户看到的裂图。

通路取自已跑通的实现(同日用它出过三张角色母版),非新写。

测试 5 条,全 mock 无付费调用,逐条做过变异测试:
把重试改成 1 次 / 去掉字节下限校验 / 丢掉参考图 / 拿不到图返回空 bytes / 成功后不
早退,五个变异各让 1~3 条用例变红。
对抗复查自己今天这笔实现时发现的两处:

一、路径此前硬编码 "/chat/completions",而 AIProviderSettings.chat_completions_path
   本来就在配置里、零消费方 —— 正是本轮在删的那类字段。改成读配置。

二、更要紧:同一把 key 下不同网关的模型目录**不一样**。实测 GET /v1/models:
   一个网关 73 个模型、一个图像模型都没有;另一个 134 个、含本模块的默认模型
   (2026-08-10 实测)。配错 AI_BASE_URL 时原始报错只是一条裸 404,读的人无从判断
   该改配置还是改模型名。现在 400/404 一律翻译成指向 GET {base}/models 的错误。

这条修的是"错误信息不可操作",不是"配置错误本身"——后者要在部署侧确认网关目录里
确实有所用模型,代码管不了。

测试 +3(路径来自配置、400/404 给出目录提示)。变异测试:路径写死 1 条红、去掉错误
翻译 2 条红。
放大看交付帧,主体内部有透明洞,背景直接透出来。2026-08-11 在归档角色
「林间斥候」走路的 121 帧真实视频帧(1280×720)上把成因拆开量了一遍:

- u2netp 自己在主体内部造的洞:8 帧抽样里 6 帧为 0 —— 不是主要成因;
- 真正的成因是键控误杀:_flat_bg_penalty 每帧杀掉 820~2346 个 u2netp 判为
  主体的像素。角色浅肤色 (243,221,200) 到母版灰底 (219,219,220) 的欧氏距离
  只有 31.3,窄于 _KEY_KILL=38,于是大腿、小臂这些浅色皮肤被当底色抠掉。
  这些被误杀的像素被主体围住,就是「封闭空洞」,填回去即修复。

**只按「不与画面边界连通」判定会把两腿之间填实。** 直觉上腿间空隙从下方通到
画幅底边所以天然安全,实测不成立:迈步相里两只靴子在下方交叠,把空隙彻底封死。
121 帧里 80 帧存在这种封闭的底色空隙,共 25173 像素;只判连通性的朴素版把这
25173 像素**全部**填成主体(最惨单帧 src_024 填掉 3172 像素,两条腿焊在一起,
截图见验证记录)。归档的 04_走路_原画帧/frame_03 同样有 129 像素的封闭腿间空隙。

所以判据是连通性 + 颜色两条一起:一个透明连通域只要「碰到画幅边界」或者
「里面存在任何一个确实是底色的像素」,就不是洞。两条否决合成一次扩散,种子 =
边界上的透明像素 ∪ 底色像素。实测结果:

- 25173 个真空隙像素,守卫版填掉 0 个,朴素版填掉 25173 个;
- 121 帧合计填回 51273 个被误杀的主体像素(朴素版 82118,多出来的就是空隙);
- alpha 只增不减,改动值只能是 1.0,RGB 通道不碰 —— 没有洞的帧逐像素不变。

_HOLE_BG_TOL=14 的取值有实测依据:视频帧里纯背景区域的色距 p99.9≈6.5、
最大 11.1(压缩噪点),而被误杀的浅肤色连通域中位色距 ≥17.1,14 落在这条间隙里。

扩散不用逐像素 BFS:1280×720 约 92 万像素,纯 Python BFS 要几十秒,抠图是逐帧
调用的扛不住。改成按行/列游程传播,一个 pass 推过整条游程。实测填洞单独耗时
34ms,cutout 端到端 0.44s/帧。scipy 不在依赖里,没有为此新增依赖。

顺手把四角估底色抽成 _bg_key(),让「底色是什么」只有一个真相源 —— 键控清理和
填洞必须按同一个 key 判,否则一个把某块当背景清掉、另一个又把它当主体填回来。

变异测试(9 个变异逐个改坏实现 → 确认对应用例变红 → 还原,全部被杀):
  M1 去掉颜色守卫(种子只剩边界,即朴素设计)→ closed_leg_gap 红
  M2 去掉边界种子                              → border_touching 红
  M3/M4 _spread 只做行传播 / 只做列传播        → spread_is_four_connected 红
  M5 去掉「底不是纯色就停手」的早退            → non_flat_background 红
  M6 填成 0.5 而不是 1.0                       → enclosed_hole_is_filled 红
  M7 _HOLE_BG_TOL 放大到 200                   → enclosed_hole_is_filled 红
  M8 _HOLE_BG_TOL 归零                         → closed_leg_gap 红
  M9 丢掉「封闭」条件                          → closed_leg_gap 等 4 条红
另外 _spread 与逐像素 BFS 在 300 组随机掩码 + 螺旋形上逐点等价(用例里留了 25 组)。

CI: ruff / lint-imports(2 contracts kept) / pytest 185 passed 全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
人工评审指出 providers 层硬编码过多。拆开看是三类,处理方式不同:

**改进配置**(本次做的):三条能力各自的模型型号。
`AIProviderSettings` 加 `video_model` / `image_model` / `fal_video_model`,默认值即当前
实测在用的型号,部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL / AI_FAL_VIDEO_MODEL 覆盖。
分成三个字段而不是共用已有的 `model`:三条能力同时在用不同模型,共用一个意味着换其中
一条把另外两条也换了。显式传参仍优先于配置,方便 A/B 对比时不必改环境变量。

**留在代码里**(本次不做,理由写进配置类的注释):哪个模型吃 image_list、哪个吃
input_reference、FAL 队列路径长什么样。这些不是运行参数,是该模型的 API 形状事实,改变
的是请求怎么构造。放进配置会把"填错了会怎样"从部署期推到运行期 —— 字段塞错不会立刻
报错,任务照常 queued,直到生成阶段才 failed,而费用可能已经产生(2026-07-29 实测)。

**暂不处理**:重试次数与字节下限。可配置化,但现在提出去只增加配置面,等真要调再说。

顺带修一个这批测试逮到的真 bug:`FalQueueVideoProvider` 的构造期校验发生在型号解析
**之前**,于是 `model=None`(表示"用配置里的")会被直接拿去查端点表,报"模型 None 不在
表里"—— 走默认路径就构造失败。改成先解析型号再校验。

测试 +5,5 条变异全部杀掉(共用一个字段 / 忽略配置写回硬编码 / 显式传参被配置覆盖 /
配置里补上请求形状字段 / 校验挪回解析之前)。
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/provider-interfaces-and-matte branch from 1fadabb to 7245818 Compare August 11, 2026 09:18
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

冲突已解,重排到最新 main。 #172 已合入,依赖链缩短为 #179#180#181#182

冲突根因:#172 合入后本分支还带着它 squash 前的三个提交,与 main 上的 squash 版 add/add 冲突;同时 main 合了 #110 / #194 等 10 个提交,已全部重排吸收。

两种口径都验过:逐个直接对 main 合干净(GitHub 的判据)、按依赖顺序连合也干净。逐分支 CI 全过(212 → 293 → 334 → 374,单调递增)。

评审质疑「为什么还需要一层不该理解业务的东西」(指 FirstFrameUploader)。查证后我认同,
但比他说的更彻底:**整个 FAL 队列面从未被真实调用过** —— app / ai_engine 里零引用,
产品链路走不到它,唯一的"引用"是 interfaces.py 里一句 docstring 指路。

删掉的理由与 GenRoute 只列有实现的路线是同一条,也是我在这批 PR 里反复引用的判据:
没有消费方的代码等于死代码,它让调用方以为该能力已具备。我一边用这条原则删掉
ActionSpec.fps / loop、一边留着 412 行未验证的 provider,是自相矛盾的。

删除:FalQueueVideoProvider / FirstFrameUploader / PreUploadedFirstFrame /
FAL_I2V_ENDPOINTS 与端点映射 / 三个 FAL 专用异常 / config.fal_video_model /
28 条 FAL 测试。sufy.py 从 740 行降到 343 行。

保留一段注释记下两个实测挣来的事实,避免将来重新摸索:FAL 面只吃公网 URL 不吃 base64
(塞 base64 会 queued 之后在生成阶段才 failed,费用可能已产生);鉴权头是
`Authorization: Key`,路径与 /v1 平级。

顺带把 VideoProvider 的 docstring 改成正面依据:**入参恒为 bytes**,因为 ai_engine 必须
持有 bytes —— master_check 预检、master_prep 预处理、像素化锁色板全都读母版像素;改传
URL 的话 ai_engine 还得自己下载回来。某厂商只吃 URL 属该 provider 自己的适配问题,
在 provider 内部转换,不把差异漏给上层。

代价如实说明:veo / seedance 只在 FAL 面,而实测 veo 的走路步态比 kling 更自然。真要接
时连同一次真实调用一起加回,归档里有完整的接入记录,重写成本不高。
CI 的 codecov/patch 报红,查证后是真缺口:`SufyVideoProvider.i2v` —— **产品唯一的付费
路径** —— 一条测试都没有。sufy.py 覆盖率 72%,未覆盖的正是提交/轮询/下载三段式与首帧
处理。matte.py 的 `cutout` 装配顺序同样零覆盖。

补 sufy 7 条(sufy.py 72% → 99%):
- 完整付费路径:提交拿 job id → 轮询到 completed → 下载 mp4
- 首帧必须是 JPEG data URI。PNG base64 会让任务 status=failed(VENDOR_FAILED,
  2026-07-22 实测,33s fail-fast)—— 这条错在提交之后才报,本地看不出来
- 首帧按目标画布**补边不拉伸**:拉伸会改角色比例,而母版比例是角色一致性的一部分
- failed / cancelled 立刻抛,不把剩余轮询预算耗完(钱已经花了,尽快暴露原因更有用)
- 轮询预算用尽抛错而不返回空 bytes(空 bytes 会被当视频送进抽帧,报"无可解码帧",
  真正的原因被埋掉)
- 首帧字段按模型选(塞错字段任务照常 queued,直到生成阶段才 failed,费用可能已产生)

补 matte 3 条(matte.py 71% → 93%):cutout 输出 RGBA、**RGB 通道不被改动**(改了会让
后续像素化锁色板取到被改过的颜色)、清理与填洞的**调用顺序**(反过来会把刚填上的像素
又清掉,且不报错)。真实推理需要 4.7MB onnx 权重,CI 里下不到也不该下,故用假 session
只覆盖装配逻辑。

顺带修一个测试逮到的真 bug:`poll_interval=0` 会在 `max_min * 60 // poll` 处除零,报
ZeroDivisionError,读的人完全看不出是配错了参数。改为构造期拒绝非正数。

9 条变异全部杀掉。其中"补边不拉伸"第一版是摆设 —— 纯色图拉伸后对称两点颜色照样相同,
M3 存活;改成在源图里放一个偏心方块、量它在成品里的宽高比(补边≈1.0,拉伸≈2.67)
才真能杀掉。

另记一个操作教训:变异测试期间用 `git checkout -- <file>` 还原,会把同文件里**尚未提交**
的改动一起丢掉(守卫被静默还原,表现为"还原后测试仍红")。变异 harness 一律用脚本内的
文本备份还原,并在结束时校验 sha256。
@xiaocheny214
xiaocheny214 merged commit 11b96bf into 1024XEngineer:main Aug 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants