Skip to content

feat(ai_engine): 出帧工具箱 —— 抽帧 / 选帧 / 后处理 / 提示词(Refs #171) - #180

Open
johnnyzhang-eng wants to merge 20 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/ai-engine-frame-toolkit
Open

feat(ai_engine): 出帧工具箱 —— 抽帧 / 选帧 / 后处理 / 提示词(Refs #171)#180
johnnyzhang-eng wants to merge 20 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/ai-engine-frame-toolkit

Conversation

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor

变更内容

windup_ai_engine 的纯计算层,零 windup 依赖(只用 PIL + numpy),可独立测试:

  • slicing/extract 解码;loop 循环类动作抽单步态周期;oneshot 一次性动作裁区间;quality 帧质量诊断
  • postprocess/pixelate 像素化;pack 脚线对齐 / 图集 / GIF;rootmotion 逐帧时长
  • prompt/ + master_prep — 按动作类型选提示词、按动作预处理母版

周期检测:三个坑与实测

slicing/loop.py 原实现直接取 argmin,三处会交出假周期。

坑 1|平移偏置。 角色在画面里横向位移(实测走位达画宽 18%),d(p) 被「挪了多远」主导、随 p 单调上升,真周期的凹陷被抬平,argmin 滑到搜索窗边界。实测某走路视频(源 121 帧,窗 p ∈ [16,72]):未消平移时曲线 40/56 段在上升,只剩 22 / 42 / 52 三个浅坑,argmin = 22;加 _deskew 消整体平移后,整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。

坑 2|搜索窗过窄。 上界 n//2 把真周期挡在窗外(某待机视频真周期 62 > pmax 60)。改为 total*0.6

坑 3|谐波。 平移偏置让 d(p) 偏爱短 lag,常选中真周期的约 1/2。半周期闭环 = 末帧接回首帧时左右腿瞬间互换 = 肉眼可见的「跳一下」。改为在基周期整数倍里按归一化接缝(末→首帧差 ÷ 组内相邻帧差均值)复选,并优先取最小倍数——倍数越大 = 一个 loop 里塞进越多周期 = 每周期帧数越少 = 动作变糙。

测不到可信凹陷时判「无周期」,退化成全片均匀取、不硬闭环——硬闭环反而制造接缝。实测某待机视频(源 31 帧):未消平移时曲线单调,argmin 落在搜索窗下界 6,交出纯粹的边界假值;消平移后 p=11 虽是局部极小,但其值 14.010 夹在 14.013 与 14.023 之间,凹陷深度 0.006,被门槛挡掉。

四段真视频实测(n=16;归一化接缝越接近 1 越闭合)

视频 旧 · 接缝 旧 · 逐像素重复帧 新 · 接缝 新 · 重复帧
走路 A 3.07 0 1.96 0
走路 B 1.29 0 0.87 0
待机 9.31 16 1.39 0
奔跑 1.77 0 0.81 0

待机那行最直观:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。

消融记录。 改善全部来自 _deskew + 谐波复选。另行追加的「死帧避让 + 冻结裁剪」两条,两个样本无变化、两个变差(奔跑接缝 0.81 → 2.00),已回退quality.py 只作诊断,不进选帧——这条写进了 pick_cycle 的 docstring,免得后来者重犯。

画布横向裁切

align_bottom_center 的三条缩放分支只按高度定标,是「主体是纵向长条」的人形先验。横向长条主体(四足兽 / 坐骑 / 龙)按同一系数缩放后宽度超出 cell,会被 alpha_composite 以负 dest 静默丢像素,PIL 不报错。裁切悬崖在主体 w/h > 1/fill_h ≈ 1.61。

实测:某 w/h=1.78 的四足母版丢 27px(鼻尖 + 尾尖);w/h=2.0 只剩 79.9% 内容。加宽度兜底 fill_w=0.96 后,w/h=1.92 的角色完整落在画布内(修复前主体贴死画布左右边缘 x∈[0,255],修复后 x∈[5,250])。两个人形角色(w/h 0.48 / 0.70)修复前后逐像素相同——该约束在人形区间恒不生效,不误伤既有资产。

主路径测试补齐

frame_durations(每次出参构造都走)与 prepare_master(每次 jump / attack 生成都走)此前零直接覆盖。补 21 个用例,锁行为不锁具体数值:动作间必须有时长区分度(idle > walk > run,等时长会让动作发飘)、关键帧定格必须真的比邻帧长且只定格一帧、未知动作要有兜底不能返回 0、越界 key_frame 不炸、jump/attack 必须补顶部空间(否则腾空/过顶挥砍会顶出视频画面,实测 attack 15/72 帧触顶)、其余动作必须原样返回同一对象(无谓重编码引入压缩损失)。

Holdout 验证

8 段从未参与调参的真视频。pick_oneshot 首次在真视频上验证——它在 jump/attack 主路径上,此前只有合成数据的单元测试:4 段攻击视频无重复帧,选中段的运动强度是全片平均的 1.7–2.3 倍。pick_cycle 4 段中 3 段干净,唯一告警是一段近乎静止的早期废片,算法正确地判「无周期」并退化成不硬闭环。

变异测试

每批改动都做过:故意把实现改错,确认对应用例真的失败,再还原。逐帧时长与母版预处理 5/5 被捕获;周期检测与画布裁切亦同。其中一条最初没被杀——「主体过小」的样本用小方块,而小方块的面积占比也不达标,占比那条判据接住了它,于是删掉最短边检查测试照样绿。换成细长条后两条判据才真正独立。写完就绿的测试等于没写。

关联与依赖

Refs #171 · 是 #152 的前置

stack 声明:本分支 stack 在 #172windup_common/models)与 #179framework/providers)之上。那两个合并后本分支 rebase,届时 diff 只剩 ai_engine 叶子这一层。

本地验证

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

@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.

Found three issues that should be addressed before merging.

Findings without inline locations

  • backend/uv.lock:1565: [P1] Regenerate this lockfile from the current workspace manifests. The windup-framework entry now omits declared runtime dependencies such as langchain-openai, onnxruntime, passlib[bcrypt], qiniu, redis, and resend; the same lock update also drops pytest-cov and the app's pydantic[email] extra. A locked install can therefore fail lock validation or produce an environment missing required runtime packages.

Comment thread backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py
Comment thread backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
机器审在 PR 1024XEngineer#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>
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
机器审在 PR 1024XEngineer#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>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-frame-toolkit branch from 151099a to 250f0f7 Compare August 10, 2026 10:25
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

补一个我上一条漏报的数:流式抽帧的 CPU 代价。

上一条只给了内存收益(488 → 126 MiB),没量时间。补测(同 5 段真实视频,各预热一次后取单次):

视频 帧数 改前 改后 变化
奔跑_专版 121 91 ms 202 ms +122%
奔跑 121 77 ms 190 ms +147%
待机 121 73 ms 179 ms +146%
走路 121 63 ms 192 ms +203%
攻击(短) 31 23 ms 44 ms +92%

慢 2–3 倍,绝对值 +130 ms 左右。原因是 imiter 逐帧走 Python 层,imread 在 C 层批量解码。

判断:这条链路的耗时由 i2v 网络调用(30–60 s)与 16 次本地抠图推理主导,+130 ms 是噪声;而内存是并发的硬约束(4 个并发 worker 按改前要 ~2 GB)。所以换得值,但这个代价此前没写出来,补在这里。

想两头都要的话得让 pyav 按关键帧 seek、只解需要的段,那是另一件事,没做。

顺带更正上一条里一处措辞:那张表里"抽 150 帧降 42%"的场景,说的是 extract_all_frames_bytes(周期检测用),它保留全部 121 帧。省掉的只是那个完整 ndarray。

johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
机器审在 PR 1024XEngineer#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>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-frame-toolkit branch from ee80dbd to 123c420 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 added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
机器审在 PR 1024XEngineer#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>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-frame-toolkit branch from 123c420 to e0f06e4 Compare August 11, 2026 03:37
@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
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
机器审在 PR 1024XEngineer#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>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-frame-toolkit branch from e0f06e4 to 6519cab Compare August 11, 2026 07:59
johnnyzhang-eng and others added 7 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 条用例变红。
johnnyzhang-eng and others added 3 commits August 11, 2026 16:58
对抗复查自己今天这笔实现时发现的两处:

一、路径此前硬编码 "/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 added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
机器审在 PR 1024XEngineer#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>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-frame-toolkit branch from 6519cab to d39166d 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 更自然。真要接
时连同一次真实调用一起加回,归档里有完整的接入记录,重写成本不高。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
机器审在 PR 1024XEngineer#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>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-frame-toolkit branch from d39166d to bd18bb3 Compare August 11, 2026 09:39
johnnyzhang-eng and others added 9 commits August 11, 2026 17:59
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。
视频路线的纯计算层,零 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 只留作诊断。
`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。不是写完就绿。
_gray() 与 _SMALL=48 此前在 slicing/loop.py 与 slicing/quality.py 各有一份完整拷贝。
两处必须在同一尺度上看帧,否则算出的差异量不可比;而分叉不会报错、只在数据上体现
——调一边的降采样尺寸,另一边悄悄保持 48,两个模块的指标从此不再可比。

收成 slicing/_frames.py 唯一定义(SMALL / gray)。行为不变。
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 行(两个新包及其依赖树)。
机器审在 PR 1024XEngineer#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>
机器审 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 里写明了这个坑。
契约断言(DTO 自身)留在 feat/character-domain-models,不 import 上层包;本分片引入
prompt 模块,配套的实现侧断言就该落在这里:类型注解不是运行期约束,把校验写成
`SIDE if facing == Facing.SIDE else FRONT` 的二分时,"sidee" 会静默落到 FRONT 模板——
正面走的提示词配侧面母版,模型靠转身调和矛盾,调用方什么错都收不到。

4 条:四个 build_* 拒绝非法 facing、枚举与合法字符串等价、walk 按 facing 选对模板体、
其余 build_* 同样按 facing 切换。
交付帧一直是 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>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-frame-toolkit branch from bd18bb3 to f513d18 Compare August 11, 2026 10:08
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.

1 participant