feat(framework): Provider 抽象 + 主体抠图 + 视频下载重试(#152 的前置 · Refs #171) - #179
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
There was a problem hiding this comment.
Review summary
Found three high-confidence issues affecting credential safety, fresh installs, and locked test environments.
Verification
git diff --checkpassed.python3 -m compileall -q backend/packages/framework/src backend/testspassed.- PR tests could not run because
uvis unavailable in this runner.
机器审 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>
机器审 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>
413dcf1 to
6c944be
Compare
|
三条 P1 已处理,6c944be。 ① 下载复用带鉴权的 client — 已修。泄漏点实际有两处: 取了 review 给的第二个方案(校验目标后剥离),没取第一个(一律用不带认证的 client)。理由:网关有可能在自家域名上签下载链接,那条路径缺 header 会 401;而不真跑一次付费调用无法区分两者。所以按目标地址判定 —— 新增
② / ③ 依赖与 lock — 在 变异测试 13 个,12 被杀。 两个存活分开判断:
未验证:没做任何付费调用,所以没有 live 证据说明真实网关的结果 URL 是同源还是 CDN —— 这正是修法做成"按地址判定"的原因:跨源则 key 不再泄漏,同源则行为与改前逐字节相同,两个分支都不会回归,但都没在真网关上确认过。另外 |
机器审 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>
e051fe2 to
66da7d0
Compare
|
已 force-push:把这条依赖链重排成真正的线性 stack。评审锚点会移位,说明原因。 问题:GitHub 的 机制:#181 / #182 此前是各自独立基于 main、靠"同步提交"携带前置分片内容的副本。合并 #181 时的共同祖先里没有 处理:改成 #172 → #179 → #180 → #181 → #182 的线性 stack,每个分支真正包含前置分支的提交。副作用是那些"同步上游/下游"的提交全部变成冗余,已在重排中丢弃。 内容变化(重排本身不改逻辑,两处例外,均已核对):
验收(重排后逐项跑过):
|
|
更正一条我此前的判断,并说明随之改变的实现。 我之前把交付帧上一处"背景透出来"读成抠图破洞。复核后这个诊断是错的:那一处是真实的两腿间隙,本来就该透明。交付帧(256²)上封闭透明域最大只有 4px,全是轮廓锯齿。 但在
更要紧的是:只按"不与画面边界连通"判空洞会把两腿之间填实。 迈步相里两只靴子在下方交叠,把腿间空隙彻底封死——它就是一块不与边界连通的背景域。实测 121 帧中 80 帧存在这种封闭空隙、共 25,173 px,朴素版会把它们全部填成主体(最惨单帧 3,172 px,两条腿焊死)。 所以判据是连通性与颜色两条一起:一个透明连通域只要"碰到画幅边界"或"内部存在任何一个确实是底色的像素",就不是洞。 实测:真空隙 25,173 px → 本实现填 0,朴素版填 25,173;121 帧共填回 51,273 个被误杀的主体像素。alpha 只增不减、只改成 1.0、RGB 不碰。 未引入 scipy(numpy 行/列游程传播,34ms/帧, 变异测试 9 个变异全部被杀,其中包含"去掉颜色守卫"这一条——它正对应我最初给出的错误设计。 另:本次把 如实说明未验的部分: |
|
这五个 PR 已达 ready:无待追加改动、CI 通过、AI review 意见全部 resolved。可以开始 review。 依赖顺序(已重排为线性 stack,逐级包含前一片的提交): #180 零依赖于前两片的业务逻辑(纯 PIL / numpy + 真实视频实测),想先看小的可以从它入手。 本地已验的三项(每次推送后重跑):
端到端实证:2026-08-11 用这条链路(不是旁路脚本)从零跑通两个全新角色的走路序列帧——文生图出母版 → i2v → 抽帧 → 选帧 → 抠图 → 像素化 → 对齐 → 打包。 三条已知缺陷,代码在本批 PR 内,已独立立项跟踪,不在本批修复:
三条都不影响流程成功与 CI,属品相问题。选择独立跟踪而不是塞进本批,是为了不让改动范围与 Issue 脱节;其中 #197 的可行方向尚未实现也未验证,如实说明。 |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
第二条(硬编码)已按承诺改完,
分三个字段而不是复用已有的 请求形状仍留在代码里,理由写进了配置类的注释,方便后来人看到判据而不是只看到结论。 这批改动逮到一个真 bug: 测试 +5,5 条变异全部杀掉:共用一个字段 / 忽略配置写回硬编码 / 显式传参被配置覆盖 / 配置里补上请求形状字段 / 校验挪回解析之前。 另外两条的处理:
本 PR 与其上三片已同步 rebase 并全部重跑:逐分支 CI 全过(111 → 190 → 271 → 312 → 351),顺序合并 |
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 条变异全部杀掉(共用一个字段 / 忽略配置写回硬编码 / 显式传参被配置覆盖 / 配置里补上请求形状字段 / 校验挪回解析之前)。
1fadabb to
7245818
Compare
评审质疑「为什么还需要一层不该理解业务的东西」(指 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。
机器审 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>
FAL 队列面已随 1024XEngineer#179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来, 而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。
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。
机器审 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>
评审报的是实情:这三个端点在分支上还是 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 移除)。
* 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>
机器审 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>
FAL 队列面已随 1024XEngineer#179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来, 而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。
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。
评审报的是实情:这三个端点在分支上还是 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 移除)。
评审报的是实情:这三个端点在分支上还是 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 移除)。
* 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>
评审报的是实情:这三个端点在分支上还是 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 移除)。
评审报的是实情:这三个端点在分支上还是 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 移除)。
* 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>
变更内容
windup_framework/providers的抽象与实现:interfaces.py—ImageProvider/VideoProvider/MatteProvider三个 Protocol,零依赖,供上层按能力而非按厂商声明依赖matte.py—OnnxU2NetMatteProvider主体抠图。不用 rembg:其依赖链 pymatting → numba 0.53 / llvmlite 0.36 在 Python 3.12 无轮子(实测装不上);而 rembg 内核就是 u2netp 过 onnxruntime,默认alpha_matting=False时根本不碰 pymatting。直调 onnxruntime 甩掉整条死重依赖,同模型同质量sufy.py— 图生视频 provider,含按现行 FAL 队列接口的实现为什么先提这个
这是 #152 的前置。 #152 的
strategy/concrete.py:17import 了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/upload才ModuleNotFoundError(这两条主线现已具备,本 PR 不重复添加。)
关联与依赖
Refs #171 · Refs #129 · Refs #20 · 是 #152 的前置
stack 声明:本分支 stack 在
feat/character-domain-models(#172)之上——sufy.py的类型标注用到windup_common.models。#172 合并后本分支 rebase,届时 diff 只剩 providers 这一层。本地验证
待对齐
FAL 队列 provider 从未真实调用过。 认证方式(
Key而非Bearer)、/v1前缀剥离、请求体形状、轮询地址(六个 kling 模型共用/queue/fal-ai/kling-video/requests/{id},模型段与{mode}都消失)、结果回退、下载,全部只对着 OpenAPI 规范与 mock 验证(37 个用例、变异测试 11/11 被捕获)。首次真跑要花钱,可能暴露不一致——这条如实写在这里,不当作已验证。