视频每隔 7 秒闪一下,我以为是掉帧——查到底是并行 worker 的首帧
还是上一篇 judge-first 里那台讲解视频引擎。这回不是「图好不好」的主观题,是个实打实的 bug:一条 42 秒的成片,画面每隔几秒「闪一下」,像卡顿、像掉帧。
昨天我就撞上了,跟 Claude Code 说「画面在闪、卡顿」,让它查。它来来回回折腾了半天——各种猜、各种试,没修好。今天我换了个说法,把一个模糊的直觉丢给它:会不会是掉帧?就这一句,把排查从「满地图乱找」拽到了帧这一层。它顺着一路查下去,反倒查出个反转:根本不是掉帧,是六个并行 worker 各自的冷启动首帧,撞上了 GSAP 的事件抑制。
说清楚:下面每一步的命令和输出,都是 Claude Code 跑的,我做的是给方向、再逐条核对它查出来的东西。按上一篇立的规矩——连 agent 查出来的,也别急着信。
先验我自己的猜:不是掉帧
我给的方向是「掉帧」,但方向对不等于猜对。它头一件事就是拿 ffprobe 去验这个假设——一验,把我的猜法直接否了:
$ ffprobe -show_frames flicker.mp4
总帧数 1269 = 42.3s × 30fps,不多不少
帧间隔 33.3ms ×1268,全部均匀,零抖动
容器层面一帧不缺、时间戳完全均匀。所以真不是掉帧——可我那句瞎猜也没全白费:它把矛头从「播放 / 编码」拽到了「画面内容」。字面错了,方向对了。那就接着 debug 画面。
抓现行:那一帧被倒带了
再跑一遍逐帧亮度(signalstats 的 YAVG),三根尖刺扎向底部——单帧亮度暴跌,而且只有三处:
$ ffmpeg -i flicker.mp4 -vf signalstats -show_entries frame=lavfi.signalstats.YAVG
帧 #423 YAVG 128.4
帧 #424 YAVG 96.1 ← 单帧暴跌
帧 #425 YAVG 128.7
...
暴跌帧:#424 · #636 · #848
把第 424 帧和它左右的邻居抠出来对齐,真相一目了然。30fps 下这一帧只有 33 毫秒,肉眼抓不住细节,只留个「闪了一下」的印象——可一摆到一块,它明明白白被倒带了一帧:
严格 212 帧一次,这不是巧合
三处暴跌帧的间隔,严格是 212 帧——7.07 秒,一分不差。这一点最关键:要是段落切换出的岔子,间隔会随旁白长短忽长忽短;这么精确的等距,只可能来自机器的周期性行为。让它翻渲染器源码,数字对上了:片子按 ceil(1269 / 6) = 212 帧一段,切给 6 个 worker 并行渲。
| worker | 帧段 | 首帧 |
|---|---|---|
| 0 | 0–211 | #0 · 正好是真起点 |
| 1 | 212–423 | #212 · 开场段本就 1 块,看不出 |
| 2 | 424–635 | #424 · 闪! |
| 3 | 636–847 | #636 · 闪! |
| 4 | 848–1059 | #848 · 闪! |
| 5 | 1060–1268 | #1060 · 结尾卡片段 3D 已隐藏 |
闪帧全落在每个 worker 的第一帧。六个首帧里只有三个露了馅:#0 本来就是起点,#212 落在方块还只有 1 块的开场,#1060 落在 3D 已经隐藏的结尾卡片。谜底收口了——问题不在素材,在「每个 worker 渲的第一帧」。
根因:冷启动的第一帧,画在了重绘之前
每个 worker 都是冷启动一个新页面来渲自己那段:页面刚起来,3D 画面停在初始态(那个「1 块」),接着跳到这段的起始帧、立刻截图。坑就在这条链的时序上——两件事撞在了一起。每帧的重绘,挂在一个动画回调上;而为了精确定位,跳帧走的是「只写属性值、不触发回调」那条路。于是 worker 的第一帧:值算对了,负责重绘的那个回调却没跑,画面定格在初始态,快门恰好在这一刻按下。
普通帧不出事,是因为随后总有一次正常重绘把它补上;唯独每个 worker 的第一帧,冷启动和第一次跳帧挤在同一拍里,截图抢在了补绘之前。
卡了一下:为什么只有 3D 闪,2D 的字不闪?因为标题、字幕那些是直接写属性,跳完帧值就是对的;只有 3D 画面的重绘绕了「动画回调」那一道,才会被一起掐掉。这也解释了昨天瞎试的一个「看着显而易见」的修法为什么没用——症状像是渲染偷懒,根子却是那个回调被跳过了,药没对症。
修法:让重绘走「一定会跑」的那条路
真正的修法就一句:把每帧重绘,从「可能被跳过的回调」挪到「跳帧时也照样执行的那一步」——用 GSAP 的话说,是从 onUpdate 换到 modifiers。这样不管是冷启动,还是被压掉回调的跳帧,重绘都照跑,worker 的第一帧就同步到位了。改的是一处挂载位置,没动任何渲染逻辑。
验证:信号拉平
改是 Claude Code 改的,可我不能它说「修好了」就信。这处改在所有片子都会走的那段公共代码上,让它重渲染、同一套检测再跑一遍——上一篇立的规矩是连自己的验证器都别信,这里再加一层:连 agent 报的「已修复」也得拿同一把尺子回量。同一条逐帧亮度曲线,修完前后摆一块:
再目视一眼那个出事的 #424,它不再是塌回一块的初始态:
onUpdate → modifiers 之隔:从「体积 = 1 块」回到正确的「4 块」,连相机角度都对上了。因为改在那段公共代码上,以后所有片子——不管 3D 还是本来就没事的 2D——都自动免疫了这个坑。
最后
技术上带走一句:当「闪 / 卡」出现严格等距的周期,先别急着怪素材——去数一数渲染是不是切给了并行 worker,周期往往就是「总帧 ÷ worker 数」。还有一条更通用:任何「每帧重绘」的逻辑,都不该挂在能被 suppressEvents 抑制的回调上;要挂在离屏渲染一定会执行的属性计算阶段。
但这趟我更想留下的,是「人和 agent 怎么配」这一层。昨天我只给了「在闪、卡顿」这种模糊症状,Claude Code 再能跑也只是原地打转——它不缺力气,缺方向。今天我丢过去一个其实是错的猜测「掉帧」,它反倒破了案:一个字面错、方向对的假设,把搜索空间从整台引擎收窄到了帧这一层。而它一旦有了方向,ffprobe、抽帧、数周期、翻源码、改代码这些力气活,跑得比我快得多、也全得多。分工其实很清楚——方向和把关归我,力气活归它。
说句私货:这台讲解视频引擎是给 leonclass.com 做的——帮理科老师把审好的题变成能讲的片子。这趟排查跟教育没半点关系,纯是工程,但那句老话又验了一遍:直觉说掉帧,ffprobe 一查就否;它说修好了,曲线拉平了才算。Don't trust, verify——不管是我的直觉,还是 agent 的结论,都别停在「看着像」。
你要也在琢磨验证 / 教育 / agent,欢迎来聊。