leonx.ai Don't trust, verify
← 回首页

English · 中文

视频每隔 7 秒闪一下,我以为是掉帧——查到底是并行 worker 的首帧

2026-07-15 · 一台讲解视频引擎的 build log

还是上一篇 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 毫秒,肉眼抓不住细节,只留个「闪了一下」的印象——可一摆到一块,它明明白白被倒带了一帧:

第 423、424、425 帧并排:两侧四块,中间塌回一块
#423 / #425 都是铺满底面的四块;中间 #424 塌回一块,连读数都回到「体积 = 1 块」——整段动画被倒带了正好一帧,下一帧又恢复。

严格 212 帧一次,这不是巧合

三处暴跌帧的间隔,严格是 212 帧——7.07 秒,一分不差。这一点最关键:要是段落切换出的岔子,间隔会随旁白长短忽长忽短;这么精确的等距,只可能来自机器的周期性行为。让它翻渲染器源码,数字对上了:片子按 ceil(1269 / 6) = 212 帧一段,切给 6 个 worker 并行渲。

worker帧段首帧
00–211#0 · 正好是真起点
1212–423#212 · 开场段本就 1 块,看不出
2424–635#424 · 闪!
3636–847#636 · 闪!
4848–1059#848 · 闪!
51060–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 报的「已修复」也得拿同一把尺子回量。同一条逐帧亮度曲线,修完前后摆一块:

逐帧平均亮度 YAVG 修复前后对比:上图三根等距尖刺,下图平顺无尖刺
上:修复前,三根扎向底部的尖刺严格落在 #424 / #636 / #848。下:修复后,同一条亮度包络,尖刺全部消失——边界帧亮度都跟邻居齐平了。

再目视一眼那个出事的 #424,它不再是塌回一块的初始态:

第 424 帧修复前后对比:修复前塌成一块,修复后正确显示四块
同一帧,一行 onUpdate → modifiers 之隔:从「体积 = 1 块」回到正确的「4 块」,连相机角度都对上了。

因为改在那段公共代码上,以后所有片子——不管 3D 还是本来就没事的 2D——都自动免疫了这个坑。

最后

技术上带走一句:当「闪 / 卡」出现严格等距的周期,先别急着怪素材——去数一数渲染是不是切给了并行 worker,周期往往就是「总帧 ÷ worker 数」。还有一条更通用:任何「每帧重绘」的逻辑,都不该挂在能被 suppressEvents 抑制的回调上;要挂在离屏渲染一定会执行的属性计算阶段。

但这趟我更想留下的,是「人和 agent 怎么配」这一层。昨天我只给了「在闪、卡顿」这种模糊症状,Claude Code 再能跑也只是原地打转——它不缺力气,缺方向。今天我丢过去一个其实是错的猜测「掉帧」,它反倒破了案:一个字面错、方向对的假设,把搜索空间从整台引擎收窄到了帧这一层。而它一旦有了方向,ffprobe、抽帧、数周期、翻源码、改代码这些力气活,跑得比我快得多、也全得多。分工其实很清楚——方向和把关归我,力气活归它

说句私货:这台讲解视频引擎是给 leonclass.com 做的——帮理科老师把审好的题变成能讲的片子。这趟排查跟教育没半点关系,纯是工程,但那句老话又验了一遍:直觉说掉帧,ffprobe 一查就否;它说修好了,曲线拉平了才算。Don't trust, verify——不管是我的直觉,还是 agent 的结论,都别停在「看着像」。

你要也在琢磨验证 / 教育 / agent,欢迎来聊。