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
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 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 12, 2026
FAL 队列面已随 1024XEngineer#179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来,
而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
1024XEngineer#179 合入 main 后重排本分支,解冲突时对 7 个文件取了基座版,把本分支自己的实现覆盖了:
character_generator 丢了母版预检与成色量化(退回 123 行前的旧版)、prompt 三个模板丢了
装备参数化、concrete 丢了 canvas 传递、impl/__init__ 整个丢失。

同时把 framework/pyproject.toml 与 uv.lock 取回 main 版再重锁:本分支的旧 lock 少 254 行、
且 pyproject 删掉了 1024XEngineer#179 已入库的 passlib/redis/resend 三条声明,导致 bcrypt 找不到。

现在 lock 相对 main 是纯新增 43 行(imageio + av)。302 passed。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 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 12, 2026
评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 1024XEngineer#179 移除)。
johnnyzhang-eng added a commit that referenced this pull request Aug 12, 2026
* feat(framework): 补 provider 抽象接口与抠图/视频实现

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。

* feat(providers): 按现行 FAL 队列接口重写 i2v,并回退按旧接口形状打的两处补丁

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>

* fix(framework): 依赖声明取主线版本,修 rebase 时 lock 与 pyproject 不一致

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 通过。

* fix(providers): 视频下载不再把 API key 带给成品域名

机器审 PR #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>

* feat(ai_engine): 出帧工具箱——抽帧 / 选帧 / 后处理 / 提示词

视频路线的纯计算层,零 windup 依赖(只用 PIL + numpy),可独立测试。

slicing/  视频 → 帧序列
  extract   解码;loop 循环类动作抽单步态周期;oneshot 一次性动作裁区间;
  quality   帧质量诊断(死帧 / 糊帧判据,只作诊断不进选帧,理由见 loop docstring)

postprocess/  帧 → 交付级序列帧
  pixelate  母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化
  pack      脚线对齐 / sprite sheet / GIF
  rootmotion 逐帧时长(关键帧加长定格,等时长会让动作发飘)

prompt/ + master_prep.py  按动作类型选提示词、按动作预处理母版

两处实测挣得的修复一并带上:

1) 画布横向裁切(postprocess/pack.py)
   align_bottom_center 的三条缩放分支只按高度定标,是"主体是纵向长条"的人形先验。
   横向长条主体按同一系数缩放后宽度超出 cell,被 alpha_composite 以负 dest 静默丢像素,
   PIL 不报错。裁切悬崖 w/h ≈ 1.61;实测狐狸母版 w/h=1.78 丢 27px(鼻尖+尾尖),
   w/h=2.0 只剩 79.9% 内容。加宽度兜底 fill_w=0.96;人形 w/h 0.3–1.1 时该约束恒不生效,
   产物逐像素不变。

2) 步态周期误检(slicing/loop.py)三个坑,四段真 i2v 视频实测
   a. 角色整体平移让 d(p) 单调上升,argmin 滑到搜索窗边界交出假周期。
      实测骷髅走路:不消平移时曲线 40/56 段在上升,只剩 22/42/52 三个浅坑,argmin=22;
      加 _deskew 消平移后整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。
   b. 搜索窗上界 n//2 把真周期挡在窗外(待机真周期 62 > pmax 60)。改为 total*0.6。
   c. 谐波:22 接近真周期的一半,半周期闭环 = 末帧接回首帧时左右腿瞬间互换。
      改为在基周期整数倍里按归一化接缝复选,优先最小倍数。
   测不到可信凹陷(prominence < 0.25)时判"无周期",退化成全片均匀取、不硬闭环——
   实测骑士待机只有 31 帧,旧算法曲线单调、argmin 落在搜索窗下界 6 交出边界假值。

实测对照(n=16,接缝 = 末→首差 ÷ 组内相邻差均值,越接近 1 越闭合)
  骷髅走路 3.07→1.96 | 骑士走路 1.29→0.87 | 骑士待机 9.31→1.39 | 骑士奔跑 1.77→0.81
  待机那条最直观:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。

消融:改善全部来自 _deskew + 谐波复选。追加的"死帧避让 + 冻结裁剪"两个样本无变化、
两个变差(奔跑接缝 0.81→2.00),已回退,quality 只留作诊断。

* test(ai_engine): 覆盖逐帧时长与母版预处理两条主路径

`frame_durations` 参与每一次出参构造,`prepare_master` 参与每一次 jump / attack 生成,
此前两者均无直接覆盖。21 个用例,锁行为不锁具体数值。

frame_durations
- 动作间必须有区分度(idle > walk > run)——等时长会让动作发飘、没有重量感
- 关键帧定格必须真的比邻帧长,且只定格一帧
- 未知动作要有可用兜底,不能返回 0 或抛错(上游动作类型可能先于本模块扩展)
- 越界 key_frame 不炸(帧数由选帧决定,调用方未必对齐)
- hold_ms 小于基准时长时取基准,定格不能反而变快

prepare_master
- jump / attack 必须补顶部空间,否则腾空 / 过顶挥砍会顶出视频画面上沿被裁
  (实测 attack 15/72 帧触顶)
- 其余动作必须**原样**返回同一对象,无谓重编码会引入压缩损失
- 补的边在顶部、原图贴底(贴反了动作会往下出画)
- ratio 越小顶部留白越多;jump 需要的空间多于 attack
- 非法 ratio 抛 ValueError

已做变异测试,五处故意引入的错误全部被捕获:
  抹掉动作间时长区分度        → 2 failed
  关键帧不定格                → 1 failed
  jump/attack 不补顶部空间     → 3 failed
  补边加在底部而非顶部         → 1 failed
  jump 与 attack 用同一 ratio  → 1 failed
还原后 21 passed。不是写完就绿。

* refactor(ai_engine): 收拢选帧与帧质量共用的取样原语

_gray() 与 _SMALL=48 此前在 slicing/loop.py 与 slicing/quality.py 各有一份完整拷贝。
两处必须在同一尺度上看帧,否则算出的差异量不可比;而分叉不会报错、只在数据上体现
——调一边的降采样尺寸,另一边悄悄保持 48,两个模块的指标从此不再可比。

收成 slicing/_frames.py 唯一定义(SMALL / gray)。行为不变。

* fix(deps): 重锁 uv.lock 以覆盖视频抽帧依赖

ai_engine 的 pyproject 声明了 imageio / av(抽帧必需),而 rebase 解冲突时 uv.lock
取的是主线版本,两者不一致:uv lock --check 报 lockfile needs to be updated。
本地 venv 里恰好装过这两个包,故本地测试没暴露;CI 用 --frozen 装依赖时会缺。

重锁时带 UV_DEFAULT_INDEX=阿里云镜像 —— 直接 uv lock 会把全仓 90 处包源改写成
pypi.org,产出两千多行与本次改动无关的 diff(主线 Dockerfile 定的就是这个镜像源)。
重锁后:阿里源 90 处、pypi 0 处,只新增 627 行(两个新包及其依赖树)。

* fix(ai_engine): 选帧入参边界——除零 / 静默少给帧一并堵掉

机器审在 PR #180 报的两条 P1,加相邻边界扫描的发现。共同判据:返回长度恒等于 n,
凡是给不出 n 帧的入参一律报错,绝不静默交出一个长度自洽的短序列。

pick_oneshot:
- n=1 撞 /(n-1) 除零(P1)。改为取"关键姿势"单帧:airborne 取脚线最高(顶点),
  swing 取能量峰后一帧(命中瞬间);不取区间首帧(蓄力,和待机一个样)也不取中点
  (动作区间前后不对称,中点落在蓄力段)。
- n<=0 原本静默返回 [](range(n) 为空,连除零都不报)→ 显式拒绝。
- 源帧不足原本原样返回一个短序列 → 报错并报出两个数字。
- 动作区间被裁到不足 n 帧时原本直接返回该区间(14 帧输入请求 12 帧只回 9 帧),
  改为把窗口放宽回来;动作贴视频尾部时缺口退回左边补,保证 n 帧互不重复。
- kind 拼错不再静默按 swing 处理(判据用错会裁出"看起来对"的错区间)。

pick_cycle:
- n<=0 拒绝(P1)。实际机制与机器审所述不同:_offsets(P, 0) 并不除零(range(n) 为空,
  k*P/n 没被求值);真实路径是检出周期时走到 M[idx[-1], idx[0]] 抛 IndexError,而
  测不到周期时**静默返回 []** —— 后者更危险。
- 源帧不足改为报错(原本原样返回)。
- n=1 显式取 medoid:单帧"循环"没有接缝也没有相位,原实现靠 nan 比较的意外结果返回首帧。

测试:两个文件各补边界用例,含 n=1..len(frames) 全量扫长度与不重复性。
变异验证 22 个错法,21 个被杀;唯一存活(区间放宽只往右补)经 1,565,565 组
(total, n, start, end) 穷举确认为等价变异 —— 长度契约完全一致,只有窗口位置不同。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(ai_engine): 抽帧改流式,不再把整段视频读进内存

机器审 P2:iio.imread 会把 (T, H, W, C) 整个 materialize 出来,而我们只要其中 8~16 帧。

实测(14 段真实 i2v 视频,进程 RSS 峰值,非 tracemalloc):
- 121 帧 720p,抽 16 帧:488 MiB → 126 MiB,降 74%
- 同一段抽 8 帧:460 MiB → 98 MiB,降 79%
- 抽 150 帧(周期检测用的 extract_all_frames_bytes):858 MiB → 498 MiB,降 42%

最后一条如实说明降幅边界:它保留全部 121 帧,省掉的只是那个完整 ndarray,保留帧本身
该占的内存还在。并发 worker 叠加时这仍是主要占用项。

正确性:改造前后在 14 段真实视频 × n=8/16/150 共 42 组上抽出的帧**逐像素完全相同**。
n=1 取首帧的既有约定保持不变(关键姿势的选择归 pick_oneshot,不由抽帧层猜)。

帧数来源:先读容器元数据(在 14 段真实视频上与实际帧数全部一致),拿不到正整数就退回
逐帧计数——计数不保留帧、内存不涨。按错的帧数算下标会抽出错位的帧,那是"帧数对、内容
错"的静默失败,多解一遍换一个确定的数划算。

顺带:imageio 分支的 except Exception 加了 warning 日志。它此前把"我们自己算错下标"和
"环境里没装 imageio"混为一谈,两者都表现为悄悄换用 ffmpeg 分支、产出看着正常的帧。

测试 11 条(tests/test_extract_streaming.py),含下标边界、抽到的是首尾与均匀分布那几帧、
要的比有的多时不补帧、元数据报 0 或抛错时退回计数。做过变异测试:改回 imread 让两条变红。
其中一条最初是摆设——炸弹抛 AssertionError 被 except Exception 吞掉、静默走 ffmpeg 后照
样绿,改成 BaseException 子类才真能杀掉变异;docstring 里写明了这个坑。

* test(ai_engine): 提示词构造器的实现侧断言补在引入它的分片

契约断言(DTO 自身)留在 feat/character-domain-models,不 import 上层包;本分片引入
prompt 模块,配套的实现侧断言就该落在这里:类型注解不是运行期约束,把校验写成
`SIDE if facing == Facing.SIDE else FRONT` 的二分时,"sidee" 会静默落到 FRONT 模板——
正面走的提示词配侧面母版,模型靠转身调和矛盾,调用方什么错都收不到。

4 条:四个 build_* 拒绝非法 facing、枚举与合法字符串等价、walk 按 facing 选对模板体、
其余 build_* 同样按 facing 切换。

* feat(ai_engine): 交付画布支持非方形,引擎可一次出到项目 sprite 尺寸

交付帧一直是 256×256 方形,而项目的 sprite 尺寸是 sprite_width×sprite_height
(API 允许 32~2048,且宽高各自独立、可非方)。上层拿到 256 的帧再缩到项目尺寸,
问题不是"糊一点":那一步用 Image.thumbnail,而 **thumbnail 只缩不放**。
2026-08-11 实测复刻上层这段逻辑,喂一张主体高 157px、脚线 0.92 的 256 交付帧:

    目标 512×512 → 画布 512,主体仍 157px(根本没放大),脚线 0.92 → 0.709
    目标 384×384 → 画布 384,主体仍 157px,                脚线 0.92 → 0.779
    目标 128×128 → 画布 128,主体 78px,                  脚线 0.914(缩小这侧正常)

也就是说放大方向上,align_bottom_center 刚对齐好的脚线被整体挪高,角色不站在地上,
跨动作对齐(ref_height 那套)也一起失效。根治办法是引擎一次就出到目标尺寸。

align_bottom_center 本来就接受 cell,按 cell 出 512 时主体高度实测 154 → 308,
确实翻倍;缺的只是**非方形**能力:cell 只能出方形,非方 sprite 仍得回到上层补边。
本次加 cell_h(None = 方形 cell×cell,默认行为不变),并把体内的几何拆成
cw(宽:水平居中、宽度兜底)与 ch(高:脚线、占高定标),不许串轴。

实测(同一组帧,ref_height=300):
    默认           canvas 256×256  主体高 159  脚线 0.918  水平中心 0.498
    cell=512       canvas 512×512  主体高 317  脚线 0.920  水平中心 0.500
    cell=384,h=512 canvas 384×512  主体高 317  脚线 0.920  水平中心 0.500
    cell=128,h=192 canvas 128×192  主体高 119  脚线 0.917  水平中心 0.496

**默认行为逐像素不变**:default / ref_height / preserve_lift / 宽主体兜底 /
cell=128 / cell=512 六个用例改动前后 sha256 完全一致;另有用例钉死
"不传 cell_h" 与 "cell_h=cell" 两种写法逐像素相同。

几何用比例表达(foot_line / fill_h / fill_w),换画布尺寸不改变构图,所以母版入口
预检与出帧仍共用同一套几何 —— master_check.REJECT_ASPECT = 2*FILL_W/FILL_H 里
本来就没有 cell,与画布像素尺寸无关。用例
test_subject_fill_ratio_is_scale_invariant 在 128/256/512/1024 四档上钉死这条。

顺带:cell/cell_h 非正数改为报错。PIL 允许建 0×0 的图、alpha_composite 也不报错,
静默出一张空图要到落库或前端才暴露。

变异测试(8 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  P1 cell_h 默认写死 256          → doubling_cell_doubles_subject_height 红
  P2 主体占高改按画布宽算          → non_square_applies_each_axis 红
  P3 脚线改按画布宽算              → non_square_applies_each_axis 红
  P4 水平居中改按画布高算          → non_square_applies_each_axis 红
  P5 宽度兜底改按画布高算          → width_fallback_uses_canvas_width 红
  P6 去掉画布尺寸校验              → non_positive_canvas_raises 红
  P7 全透明兜底退回 256 方形       → all_transparent_honour_canvas 红
  P8 出帧画布忽略请求值            → 另外 3 条红(默认档下该变异是恒等,测不到属正常)

本提交只动 ai_engine;把尺寸从 app 传进引擎的接线在上层分支。
CI: ruff / lint-imports(2 contracts kept) / pytest 255 passed 全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tests): 删掉 rebase 带回来的 FAL 测试文件

FAL 队列面已随 #179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来,
而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
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 12, 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 12, 2026
FAL 队列面已随 1024XEngineer#179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来,
而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
1024XEngineer#179 合入 main 后重排本分支,解冲突时对 7 个文件取了基座版,把本分支自己的实现覆盖了:
character_generator 丢了母版预检与成色量化(退回 123 行前的旧版)、prompt 三个模板丢了
装备参数化、concrete 丢了 canvas 传递、impl/__init__ 整个丢失。

同时把 framework/pyproject.toml 与 uv.lock 取回 main 版再重锁:本分支的旧 lock 少 254 行、
且 pyproject 删掉了 1024XEngineer#179 已入库的 passlib/redis/resend 三条声明,导致 bcrypt 找不到。

现在 lock 相对 main 是纯新增 43 行(imageio + av)。302 passed。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 1024XEngineer#179 移除)。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 1024XEngineer#179 移除)。
johnnyzhang-eng added a commit that referenced this pull request Aug 12, 2026
* feat(framework): 补 provider 抽象接口与抠图/视频实现

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。

* feat(providers): 按现行 FAL 队列接口重写 i2v,并回退按旧接口形状打的两处补丁

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>

* fix(providers): 视频下载不再把 API key 带给成品域名

机器审 PR #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>

* feat(ai_engine): 出帧工具箱——抽帧 / 选帧 / 后处理 / 提示词

视频路线的纯计算层,零 windup 依赖(只用 PIL + numpy),可独立测试。

slicing/  视频 → 帧序列
  extract   解码;loop 循环类动作抽单步态周期;oneshot 一次性动作裁区间;
  quality   帧质量诊断(死帧 / 糊帧判据,只作诊断不进选帧,理由见 loop docstring)

postprocess/  帧 → 交付级序列帧
  pixelate  母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化
  pack      脚线对齐 / sprite sheet / GIF
  rootmotion 逐帧时长(关键帧加长定格,等时长会让动作发飘)

prompt/ + master_prep.py  按动作类型选提示词、按动作预处理母版

两处实测挣得的修复一并带上:

1) 画布横向裁切(postprocess/pack.py)
   align_bottom_center 的三条缩放分支只按高度定标,是"主体是纵向长条"的人形先验。
   横向长条主体按同一系数缩放后宽度超出 cell,被 alpha_composite 以负 dest 静默丢像素,
   PIL 不报错。裁切悬崖 w/h ≈ 1.61;实测狐狸母版 w/h=1.78 丢 27px(鼻尖+尾尖),
   w/h=2.0 只剩 79.9% 内容。加宽度兜底 fill_w=0.96;人形 w/h 0.3–1.1 时该约束恒不生效,
   产物逐像素不变。

2) 步态周期误检(slicing/loop.py)三个坑,四段真 i2v 视频实测
   a. 角色整体平移让 d(p) 单调上升,argmin 滑到搜索窗边界交出假周期。
      实测骷髅走路:不消平移时曲线 40/56 段在上升,只剩 22/42/52 三个浅坑,argmin=22;
      加 _deskew 消平移后整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。
   b. 搜索窗上界 n//2 把真周期挡在窗外(待机真周期 62 > pmax 60)。改为 total*0.6。
   c. 谐波:22 接近真周期的一半,半周期闭环 = 末帧接回首帧时左右腿瞬间互换。
      改为在基周期整数倍里按归一化接缝复选,优先最小倍数。
   测不到可信凹陷(prominence < 0.25)时判"无周期",退化成全片均匀取、不硬闭环——
   实测骑士待机只有 31 帧,旧算法曲线单调、argmin 落在搜索窗下界 6 交出边界假值。

实测对照(n=16,接缝 = 末→首差 ÷ 组内相邻差均值,越接近 1 越闭合)
  骷髅走路 3.07→1.96 | 骑士走路 1.29→0.87 | 骑士待机 9.31→1.39 | 骑士奔跑 1.77→0.81
  待机那条最直观:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。

消融:改善全部来自 _deskew + 谐波复选。追加的"死帧避让 + 冻结裁剪"两个样本无变化、
两个变差(奔跑接缝 0.81→2.00),已回退,quality 只留作诊断。

* chore(tests): 删掉 rebase 带回来的 FAL 测试文件

FAL 队列面已随 #179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来,
而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。

* feat(framework): 补 provider 抽象接口与抠图/视频实现

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。

* refactor(ai_engine): 收拢 PNG bytes ↔ PIL 的转换,并重新 stack 到三个前置分支

管线内部按 PIL.Image 处理,跨模块边界(strategy → generator → ports 出参)按 PNG bytes
传递。这对转换此前在 strategy/concrete.py 与 impl/character_generator.py 各写了一份完整
拷贝,收成 _imgio.py 唯一定义(to_png / from_png)。编码参数一旦分叉,会在"某些帧丢了
alpha"这类只在画面上体现、不报错的地方出问题。

同步 stack:本分支重新对齐到 feat/character-domain-models、feat/provider-interfaces-and-matte、
feat/ai-engine-frame-toolkit 的当前终态,同名文件与三者逐字节一致。

* feat(ai_engine): 母版入口预检 + 出参成色信号 + 抠图跳过编码器边缘伪影

两头各加一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入;
出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。

此前 ports 与 impl 里所有 raise 都在输出侧,对 master 不做任何前置判定。
2026-08-07 实测:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错,
16 帧构图完整的错角色出完、钱花完。

check_master 判三类**本地零成本可判**的形态问题,不通过抛 MasterRejected:
- UNDECODABLE 不是图 / 截断
- NO_SUBJECT 全透明或全同色,没有可动的东西
- SUBJECT_TOO_SMALL 包围盒最短边 < 8px(放大 20 倍是色块不是角色),
  或主体占比 < 0.1%(对角散落两粒噪点会把包围盒撑到整幅,边长检查全过)
- ASPECT_TOO_WIDE 主体 w/h 超阈值,方形画布只能把角色硬缩成一条

REJECT_ASPECT 由交付画布几何推出(2*FILL_W/FILL_H)而非拍脑袋,并有测试锁住
这个推导关系——改了 pack.py 的填充比而这里不动,预检会放行一批下游装不下的母版。

MasterRejected 带机器可读的 code:server 据此选文案、判 4xx-不重试,与
NotImplementedError / 其他 ValueError(引擎侧问题,5xx,要人介入)分工明确。
判不了的(画的是不是角色、朝向对不对)不在此列,模块 docstring 写清"本层不判什么"。

GeneratedAction 此前只能表达"生成完了",不能表达"生成得怎么样":一段每帧都一样的
walk 与一段步态干净的 walk,帧数 / 时长 / fps 完全相同,调用方分辨不出。

三个字段各自不可由其他两个推导:
- motion_scale 相邻帧差的**绝对**尺度。必须单独给:dead_frame_mask 两条判据都是
  相对的,整段冻结时 d 全为 0、两条不等式变成 0<0,一帧死帧都报不出(实测 12 帧
  全同报 0 死帧)——相对判据天生看不见"整体没动"。
- dead_frames 死帧下标(不是 numpy 掩码:跨出 ai_engine 的契约要"哪几帧")
- loop_seam 末帧接回首帧的跳幅 ÷ 相邻帧平均步长。在**对齐之后**量,量的是用户真正
  看到的那组帧;分母为 0 返回 None 而不是 0.0——0.0 会被读成"完美闭环"。
  一次性动作(jump/attack)不给:首尾姿态本就不同,给个必然难看的数会诱导错误决定。

刻意没有糊帧率:2026-08-05 实测 6 段真 i2v 没有一帧糊帧,加进来是恒等于 1 的常数。

引擎只如实报数、不代替上层判决:交付 / 重试 / 换母版是产品决策,阈值该由 server 按
场景定;且到这一步钱已花完,引擎单方面丢弃产物只是把损失变成两份。

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

17 条新用例。变异测试 6/6 全部被捕获:阈值改成硬编码、去掉占比检查、去掉最短边检查、
motion_scale 恒返回 1、loop_seam 分母为 0 时返回 0.0、贴边采样。

其中"去掉最短边检查"最初**没被杀**——样本用的小方块占比也不达标,占比那条接住了它。
换成细长条(占比 1.3% 远超下限,只有边长这条能拦)后才真正独立。写完就绿的测试等于没写。

* fix(ai_engine): 出参只留一个播放时序真相源,并同步上游两处修复

机器审在本 PR 报的三条 P2,两条同源:契约里存在"能填/能读、但与另一处矛盾或不生效"的
字段。

一、GeneratedAction.fps 删除。它抄自入参,而 durations 按动作查表得来,两者描述同一段
   素材的不同播放速度:fps=20 宣称 50ms/帧,walk 实际给 125ms/帧,取哪个看消费方心情。
   逐帧 ms 严格更能表达(关键帧定格),所以保 durations、删 fps;真要单一帧率由消费方算。
   连带删除 ActionSpec.fps(在 feat/character-domain-models 里,本分支同步)。

二、删掉一条为缺陷背书的测试。此处曾有 test_loop_mode_currently_changes_nothing,把
   "传 pingpong / none 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒
   删注释"。那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个
   行为背书。现改为断言字段确实不存在——ActionSpec.loop 与 LoopMode 都已移除。
   同理,test_generate_walk_is_wired_end_to_end 里的 `assert out.fps == action.fps`
   换成断言时长确实来自动作查表(walk = 125ms/帧)。

三、抽帧改流式(改动本体在 feat/ai-engine-frame-toolkit,本分支同步)。121 帧 720p 真实
   视频抽 16 帧,进程 RSS 峰值 488 → 126 MiB。

变异测试:把 GeneratedAction.fps 加回去 1 条红;把 durations 改成固定 50ms 不查表 1 条红。
CI:ruff / import-linter 2 contracts / pytest 276 passed。

* fix(ai_engine): 母版预检的比例上限跟着交付画布走,非方画布不再判宽了

上一个提交让交付画布可以非方,这条紧接着补上被它架空的东西:master_check 的
REJECT_ASPECT 推导默认画布是方形 —— FILL_W 与 FILL_H 是**同一条边长**的两个比例。
画布能非方之后前提不成立了,同一条推导做下来是

    R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch)

不跟着收的后果正是这条阈值最怕的那件事:**预检按方形判、出帧按非方出**。
2026-08-11 实测,一个刚好过检(w/h=3.0968)的主体在各档画布上的交付占高:

    256×256   0.3086      512×512  0.3105      1024×1024  0.3105
    384×512   0.2324  ← 阈值本意保证的下限是 FILL_H/2 = 0.31,被架空

新增 reject_aspect_for(canvas) 算实际上限,check_master 收可选 canvas,
CharacterGenerator 把**出帧用的同一个 canvas** 传给预检。
canvas=None 或方形画布时与本提交之前完全一致(用例钉死 128/256/512/1024 四档
以及 None 都等于原 REJECT_ASPECT)。

修好之后的不变式实测(源画幅放大到 3000×600 杜绝主体被源边界裁掉;处在各自比例
上限的主体,交付占高应恒等于 FILL_H/2 = 0.31):

    256×256 上限 3.0968 → 0.3086      512×512 上限 3.0968 → 0.3105
    1024×1024 上限 3.0968 → 0.3105    384×512 上限 2.3226 → 0.3105
    512×384 上限 4.1290 → 0.3099      128×192 上限 2.0645 → 0.3125
    640×480 上限 4.1290 → 0.3104      2048×2048 上限 3.0968 → 0.3101
    与 FILL_H/2 的最大偏差 0.0025(取整噪声量级)

即窄高画布收紧、宽扁画布放宽,两侧都回到同一条几何。

变异测试(5 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  M1 非方画布不收紧阈值            → narrow_canvas_tightens 等 3 条红
  M2 宽高比取倒数(方向反了)      → narrow_canvas_tightens 等 3 条红
  M3 方形画布也被改动              → square_canvas_is_unchanged 红
  M4 判定仍用写死的 REJECT_ASPECT  → check_master_uses_the_canvas 红
  M5 预检不吃 canvas               → precheck_and_output_share_geometry 红

M5 一开始杀不掉(没有任何用例覆盖"预检与出帧用了不同 canvas"),补
test_precheck_and_output_share_the_same_canvas_geometry 之后才杀掉 —— 取一个夹在
方形阈值与 384×512 阈值之间的母版,方形放行、窄高必拒。

**依赖上游分支**:同 013520f,需要 feat/ai-engine-frame-toolkit 的 5da358e。
在工作区打上该提交的 pack.py 后跑,CI 全绿:ruff / lint-imports(2 contracts kept)
/ pytest 288 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: rebase 到新 main 后取回被基座版覆盖的 ai_engine 实现

#179 合入 main 后重排本分支,解冲突时对 7 个文件取了基座版,把本分支自己的实现覆盖了:
character_generator 丢了母版预检与成色量化(退回 123 行前的旧版)、prompt 三个模板丢了
装备参数化、concrete 丢了 canvas 传递、impl/__init__ 整个丢失。

同时把 framework/pyproject.toml 与 uv.lock 取回 main 版再重锁:本分支的旧 lock 少 254 行、
且 pyproject 删掉了 #179 已入库的 passlib/redis/resend 三条声明,导致 bcrypt 找不到。

现在 lock 相对 main 是纯新增 43 行(imageio + av)。302 passed。

* fix(ai_engine): 进度上报统一到一个刻度,不再倒退

评审实跑逮到的:generator 按 i/4 报,中间夹着的 strategy.derive 按 i/3 报到**同一个**
ProgressPort 上。消费方按 i/total 画条会看到倒退两次 —— route 25.0% → derive 0.0%、
derive 66.7% → lastmile 50.0%,totals 同时出现 3 和 4。一个量两个真相源,取哪个看
消费方心情,与本分片删掉 fps / loop / palette 是同一条理由。

改法取评审给的第一条(generator 传偏移):generator 独占全局刻度 total=10,strategy 的
子进度由 _BandProgress 线性映射进 derive 区间 [2,7]。

- strategy 侧零改动。适配器只读它每次调用时自报的 total,不要求它声明自己有几步——
  声明值与实际值又是一对可以对不上的真相源。也不让它知道外层有几步:它是可插拔件,
  各路线步数本就不同。
- 刻度取 10 不取 5,是为了给 derive 段留出中间刻度;否则子进度全落同一格,虽不倒退
  但最慢的那段整段不动。
- 修后序列:0 → 10 → 20 → 30 → 50 → 80 → 90%,单一 total,零倒退。

测试 +3:同一次生成只允许一个 total、进度非递减、子进度必须落在 derive 区间内且
区间内确实动过(只断言"不倒退"的话,把适配器换成"永远报区间起点"也能过)。这三条
打在修复前的代码上全部 FAIL,报的正是评审给的那两处倒退。

一处如实说明:ProgressPort 的 docstring 写的是"server 转 SSE / 轮询状态",但 #182 目前
唯一的实现是 executor.py:137 的 logger.info,SSE payload 里没有进度字段。所以今天这个
倒退只落在日志里,还没被用户看到 —— 也正因为没有活消费方依赖 total==4,才能直接改刻度
而不必兼容旧值。

Refs #171 #53

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
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 12, 2026
评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 1024XEngineer#179 移除)。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 1024XEngineer#179 移除)。
huyanxius pushed a commit that referenced this pull request Aug 12, 2026
* feat(framework): 补 provider 抽象接口与抠图/视频实现

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。

* feat(framework): 补 provider 抽象接口与抠图/视频实现

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。

* feat(ai_engine): 对外契约 ports + 动作分流 strategy + 串联 CharacterGenerator

server 与生成引擎之间的唯一边界,以及"哪个动作走哪条生成路线"这个架构决策。

ports/  server 只 import 这里,由 CI 的 import-linter 分层门禁强制。
  CharacterGeneratorPort.generate(card, action, master, progress) -> GeneratedAction
  边界:ai_engine 只产出帧 bytes + 逐帧时长,不碰存储 / 数据库 / 任务状态。母版由
  server 从 Character.reference_image_url 取好以 bytes 传入;产出的帧由 server 上传
  对象存储、写 character_data。依据是"谁掌握租户与配额上下文"——bucket、路径规则、
  归属项目、配额全在 server;ai_engine 自持存储等于把租户概念下沉到一个只做图像计算
  的层。代价是帧 bytes 在内存过一次(16 帧 512×512 RGBA ≈ 16MB,可接受)。

strategy/  ROUTE_MATRIX 是实测挣得的架构契约,改它 = 改产线。
  walk / run / jump / attack / idle -> VIDEO_I2V;hit -> PER_FRAME
  依据:逐帧独立生成锁不住"哪条腿在前"(踢踏舞),视频天生连贯、腿自然交替;
  hit 这类离散姿势单帧可编辑价值高、无连续步态。Refs #35 #53。

impl/CharacterGenerator  选路线 -> strategy.derive 出帧 -> 脚线对齐 -> GeneratedAction。

与 #53 原设计的两处差异:

1) idle 从 PROC_IDLE 改走 VIDEO_I2V,GenRoute.PROC_IDLE 与 ProcIdleStrategy 一并移除。
   #53 原设计 idle 走 ¥0 的程序化局部呼吸(Idle-B),实测做不出可用效果,放弃,认这份
   i2v 的钱。不留没有实现的枚举值。

2) 未实现的路线抛错,不返回空帧。
   旧桩实现 return [b""] * n_frames,调用方拿到的 GeneratedAction 帧数对、时长对、
   无异常——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进
   character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。
   现在 PerFrameStrategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出
   已装配了哪些;strategy 吐出空帧时抛 ValueError。四条回归测试拿旧实现对照过,
   确认在修复前全部失败。

另记录 ROUTE_MATRIX 形状的已知边界:它是「动作类型 → 路线」一对一映射,隐含前提是
"路线由动作的物理性质唯一决定"。该前提对逐帧 / 视频成立,但对渲染出帧路线不成立——
同一个 walk 走 i2v 还是走渲染,取决于该角色有没有 3D 模型,那是 server 才知道的事。
接入第三条路线前须先定「路线选择由谁决定」。

本分支 stack 在 feat/character-domain-models、feat/provider-interfaces-and-matte、
feat/ai-engine-frame-toolkit 之上,那三个合并后 rebase。

* fix(generation): 修机器审报的五处 P1

`_get_generator()` 里还留着 `GenRoute.PROC_IDLE: ProcIdleStrategy(...)`,而这两个都已
随「程序化待机放弃」删除。注入 generator 的测试走不到这条装配路径,所以测试全绿而真实
调用全崩。改为只装当前 GenRoute 真有的路线,并加一条漏装断言——将来新增枚举成员时会
在装配处立刻暴露,而不是等某个动作第一次被请求。

`_download_master` / `_download` 直接 `httpx.get(input.reference_image_urls[0])`,
而那个 URL 来自已认证请求的请求体。等于把服务器当跳板:打 loopback 绕过鉴权中间件、
读云实例元数据服务的临时凭证、探测私网拓扑;重定向还能把合法域名换成上述任意一种。

新增 `_fetch.fetch_own_media`:白名单(必须是 `storage_settings.download_base` 前缀)
+ 禁跟随重定向 + 响应体上限 16 MiB(边读边计数,不信 Content-Length)。

取白名单而非黑名单:黑名单要穷举 127/8、10/8、172.16/12、192.168/16、169.254/16、
::1、fc00::/7 以及各种十进制/八进制/IPv6-mapped 写法,漏一条等于没做。而本业务只需拉
自家 bucket 的图(母版与参考图都先经 /media/upload 传上去)。代价是不能再传外部图床
链接——真要支持该走一个显式的「导入外部素材」入口,在那里做完整校验与配额。

`task_repo` 一律发 `task_update`,而 stream 的 `_TERMINAL_EVENTS = {"completed","failed"}`
永不匹配,于是终态 break 走不到。端点带 `retry: 3000`,浏览器原生 EventSource 每 3 秒
重连、每次重收同一条 completed。按状态映射事件名。

`publish` 被后台 daemon thread 调用(executor → task_repo),而队列属于处理 SSE 请求
的那个 loop。`asyncio.Queue` 不是线程安全的。订阅时记下所属 loop,发布时经
`call_soon_threadsafe` 回到那个 loop 再入队;loop 已关闭时静默丢弃(任务状态已落库,
重连后靠 GET /tasks/{id} 取,抛异常会把后台任务整个带崩)。

`num_images` 直通 provider 调用循环,请求模型不设上限——一个已认证请求填个大数就能绕过
按请求计的限流、把成本拉到无上限。加 `ge=1, le=4`;`num_frames` 加 `le=64`;宽高加
`64..2048`。

顺带删掉请求体里的 `user_id: int = Field(gt=0)`:端点已改从 `request.state.current_user`
取归属,这个字段既不被读、又让调用方以为能指定归属者——填别人的 id 不报错也不生效。

20 条回归用例。变异验证中**逮到自己两条摆设测试**并已重写:
- 终态事件名那条原先直接读 `_STATUS_EVENT` 字典,而变异改的是 `_publish_task_update`
  里的用法 → 改为注入假 bus、走真实调用路径断言发出的事件名;
- 跨线程那条证不出 `call_soon_threadsafe` 的必要性(实测裸 `put_nowait` 在单队列场景
  也能被 `get()` 取到,CPython 有元素时走快路径)→ 如实在 docstring 写明本用例强度,
  另补一条「订阅必须记下所属 loop」的结构断言,那条能杀死变异。

漏装断言那条变异存活是**预期**:当前两个路线都装满,`missing` 恒为空集,它是防未来
回归的守卫而非当前行为,测试锁的是「装满」这个事实。

* fix(generation): 请求里的图片尺寸真的生效,并同步上游 provider 两处修复

对抗复查发现:width / height 从请求进到 CharacterImageInput、被 _validate_project_size
校验过,然后被丢掉 —— ImageProvider.gen_image 没有尺寸参数,模型出多大就返多大。调用方
要 512×512、拿到 1024×1024,而请求被接受了。性质与本轮删掉的 ActionSpec.fps / loop 完全
相同,只是这次字段在入口侧。今天还给这两个字段加了 le=2048 上界,等于替它们背书。

模型本身不吃宽高,所以在编排层落实:复用已有的 _fit_to。给它加 smooth 参数——序列帧是
像素画必须 NEAREST(插值会把硬边糊成灰边并引入调色板外的颜色),全彩角色母版反过来,
NEAREST 缩图明显锯齿,用 LANCZOS。

同步上游(改动本体在 feat/provider-interfaces-and-matte):文生图路径改读配置里的
chat_completions_path;400/404 翻译成指向 GET {base}/models 的可操作错误。

测试 +4。变异测试:再把尺寸丢掉 2 条红,smooth 参数不接线 1 条红。其中重采样那条第一版
用纯色图作源是无效仪器(纯色下两种重采样结果完全相同),已换成棋盘格。

* fix(app): 交付尺寸传给引擎出帧,不再拿到帧之后再缩一次

编排层此前对引擎交付的每一帧再做一次 _fit_to(png, sprite_w, sprite_h)。引擎恒出
256,项目要 512 时这就是二次重采样 —— 而实际后果比"糊一次"严重得多:_fit_to 用
Image.thumbnail,**thumbnail 只缩不放**。2026-08-11 复刻这段逻辑实测(喂主体高
157px、脚线 0.92 的 256 交付帧):

    目标 512×512 → 主体仍 157px(根本没放大),脚线 0.92 → 0.709
    目标 384×384 → 主体仍 157px,              脚线 0.92 → 0.779
    目标 128×128 → 主体 78px,                脚线 0.914(缩小方向正常)

即放大方向上主体一点没变大("成品放大看很糊"的直接来源),并且引擎刚用
align_bottom_center 对齐好的脚线被整体挪高,角色不站在地上,ref_height 那套跨动作
本体尺寸一致也一并失效。

改法:把项目 sprite 尺寸作为 canvas 传给 generate,引擎一次出到位,那一步不存在了。

用归档角色「林间斥候」的真实抠图帧(1280×720,主体高中位 619px)端到端实测:

    canvas 不传        交付 256×256   主体高中位 159px
    canvas (256,256)   交付 256×256   主体高中位 159px(与不传**逐字节相同**)
    canvas (512,512)   交付 512×512   主体高中位 318px —— 倍数 2.000
    canvas (384,512)   交付 384×512   主体高与 (512,512) 完全相同(高度几何只看高)
    canvas (1024,1024) 交付 1024×1024 主体高中位 635px

顺带一条选型信息:源帧主体 619px,512 档交付 318px 仍是**下采样**(不引入插值糊),
1024 档 635px 已经越过源分辨率、是上采样,收益递减。

_fit_to 换成 _require_size:只核对、不补救。尺寸对不上说明生成侧没按 canvas 出帧,
该报错让人看见,而不是缩放补边把问题抹平、交付一批脚线错位的帧。_fit_to 本身保留,
角色母版那条路径(smooth=True)仍在用。

**接口影响**:CharacterGeneratorPort.generate 多了 canvas 入参,实现该 Protocol 的
测试替身必须跟着接。修 _SpyGenerator 时顺带发现一个假绿用例:该 spy 一直在传
GeneratedAction(fps=...),而该字段早已删除,构造直接 TypeError、任务其实被判 FAILED;
当时的用例只断言 seen_facing(在构造之前就赋了值)所以一直绿着。已一并修好,
test_project_perspective_constrains_facing 现在跑的是一次真正成功的任务。

变异测试(4 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  E1 不把项目尺寸传给引擎     → project_sprite_size_is_passed 等 2 条红
  E2 canvas 宽高接反          → non_square_sprite_size_passed 红
  E3 只传宽当成方形           → non_square_sprite_size_passed 红
  E4 尺寸不符时静默放行       → wrong_size_fails_instead_of_rescaled 红

**依赖上游分支**:canvas 入参由 feat/ai-engine-ports-and-strategy 的 013520f 提供,
后者又依赖 feat/ai-engine-frame-toolkit 的 5da358e。本分支尚未同步这两个提交,故
单独跑 pytest 会有 1 条 test_action_task_runs_end_to_end 失败(真实
CharacterGenerator 还不认 canvas)。在工作区先打上那两个提交再跑,CI 全绿:
ruff / lint-imports(2 contracts kept) / pytest 309 passed。同步后即恢复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generation): SSE 订阅补归属校验与终态预检,去掉无消费方的 project_id

人工评审报的三条,逐条修:

一、越权订阅。stream 端点只按 task_id 订阅、不看是谁在订阅,任意已认证用户猜到 id
   就能拿到别人任务的实时进度与最终产物 URL(事件体带 result,即帧的对象存储地址)。
   同文件的 GET /tasks/{task_id} 有校验,stream 漏了,而两者从没被放在一起测过。
   校验放在 subscribe **之前**:放之后的话越权请求仍会在 EventBus 上挂一个订阅者,
   照样收事件、只是响应体被丢弃,订阅表还会因为没人 unsubscribe 而增长。

二、终态预检。原先是一行 TODO,而上一行的 docstring 已经承诺了该行为 —— 读文档的人
   不会发现,实际表现是客户端要先挂满 30 秒心跳超时才拿到终态。

三、project_id 声明为必填但从未被使用,归属判定的依据是任务自己的 user_id,不是调用方
   声称的项目。删掉后 fastapi.Query 也变成未使用 import,反过来印证它确实没有消费方。

顺带:事件 payload 从 task_repo 抽成公开的 task_event_payload()。终态预检是第二个发送
点,在 API 层再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的
同名事件。

测试 +9,5 条变异全部杀掉(去掉归属校验 / 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当成 completed)。

其中"payload 同形状"那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源,
删字段一起变、断言永远成立。改成把 SSE 事件体的键集写死成契约清单,并另加一条直接比
两条真实发送路径产出的用例。

过程中三处是测试自身写错、不是代码错,记下避免再犯:按 HTTP 状态码断言越权(本仓
BizException 统一以 200 + 业务码返回,把"校验生效"误判成"越权",且成功码是 200 不是 0);
create_task 签名靠猜;conftest 的建表清单里没有本 PR 新引入的 generation_task 表。

* fix(generation): SSE 归属补齐「任务属于项目」这一道,并落终态预检

**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 #110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 #110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。

* fix(generation): 三个端点接回服务层,并补上能发现"没接上"的测试

评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 #179 移除)。

* fix(deps): framework 依赖声明与 lock 取回 main 版

rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

#181 昨天修过同一处,当时没顺手检查 #182。

* test(orchestrator): 补齐白名单取图的下载段 —— 三条防线此前一行没测

`codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。#181 合入 main 后
#182 的 patch 只剩自己那 10 个提交,分母变了就压线掉下来。

去看未覆盖的行,最大缺口是 `_fetch.py` 的 46%(26 行里 14 行没跑),而那 14 行正是
**放行之后的三条防线**,一条都没测过:

1. **`follow_redirects=False`** —— 白名单最容易被绕开的方式:URL 本身完全合规,
   坏事发生在重定向之后。自家域名返回 302 指向 169.254.169.254,跟过去就等于白名单
   没写。新测试断言异常之外,还断言**元数据服务那个 URL 从未被请求过**。
2. **声明 Content-Length 超限** → 读 body 之前就拒。
3. **Content-Length 撒谎时边读边计数** —— 声明 1 字节实际吐 100MB,只信 header
   就能吃光 worker 内存。

四条新测试用 httpx.MockTransport,不联网。`_fetch.py` 46% → **100%**。

两处自己踩的坑,记下来:
- 补丁装在 `F.httpx.Client` 上,而工厂内部又调 `httpx.Client` → 无限递归。必须先把
  真的 Client 抓在局部变量里再打补丁。
- 重定向那条初版写的是 `pytest.raises(Exception)`,于是上面那个 RecursionError 也
  算"通过" —— 测试因为错误的原因变绿。已收紧成 `httpx.HTTPStatusError`。

变异测试验过这四条真的能咬:分别关掉重定向防护 / 废掉边读边计数 / 废掉声明超限检查,
三次都被逮到(脚本带 try/finally,结束校验 sha256 一致)。

Refs #171 · Refs #78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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