The video flickered every 7 seconds — I assumed dropped frames; it was a parallel worker's first frame
Same explainer-video engine as last post, judge-first. This time it wasn't a subjective "is the diagram any good" question — it was a real bug: a 42-second render flickered every few seconds, like a stutter, like dropped frames.
I hit it yesterday and told Claude Code "the picture's flickering and stuttering," and set it loose. It thrashed around for hours — guessing, trying things, no fix. Today I gave it a different framing, one vague hunch: could it be dropped frames? That one line dragged the search from "hunting the whole map" down to the frame level. It followed the thread and turned up a twist: not dropped frames at all — six parallel workers, each cold-starting, their first frame colliding with GSAP's event suppression.
To be clear: every command and output below was run by Claude Code. What I did was give it direction, then check what it found, line by line. Per the last post's rule — don't rush to trust what the agent turns up either.
First, test my own guess: not dropped frames
My steer was "dropped frames" — but a good direction isn't the same as a correct guess. The first thing it did was take that hypothesis to ffprobe — which promptly shot my guess down:
$ ffprobe -show_frames flicker.mp4
total frames 1269 = 42.3s × 30fps, exactly
frame interval 33.3ms ×1268, all even, zero jitter
Not a frame missing at the container level, timestamps perfectly even. So it really isn't dropped frames — but my bad guess wasn't wasted: it swung the aim from "player / encoder" over to "the picture content." Wrong literally, right in direction. So: debug the picture.
Catch it in the act: one frame rewound
Run per-frame brightness again (signalstats YAVG) and three spikes stab downward — a single-frame brightness crash, and only three of them:
$ ffmpeg -i flicker.mp4 -vf signalstats -show_entries frame=lavfi.signalstats.YAVG
frame #423 YAVG 128.4
frame #424 YAVG 96.1 ← single-frame crash
frame #425 YAVG 128.7
...
crash frames: #424 · #636 · #848
Line up frame 424 with its neighbors and the truth is obvious. At 30fps that frame is only 33ms — the eye can't catch the detail, it just registers "a flicker" — but side by side, it's plainly one rewound frame:
Strictly every 212 frames — not a coincidence
The gap between the three crash frames is exactly 212 frames — 7.07 seconds, to the frame. That's the key tell: if it were a segment-boundary glitch, the gap would wobble with narration length; a spacing this precise can only come from periodic machine behavior. Have it open the renderer source and the number matches: the film is split into ceil(1269 / 6) = 212-frame chunks, handed to 6 workers rendering in parallel.
| worker | frame range | first frame |
|---|---|---|
| 0 | 0–211 | #0 · happens to be the true start |
| 1 | 212–423 | #212 · opening is 1 block anyway, invisible |
| 2 | 424–635 | #424 · flicker! |
| 3 | 636–847 | #636 · flicker! |
| 4 | 848–1059 | #848 · flicker! |
| 5 | 1060–1268 | #1060 · ending card, 3D already hidden |
The flickers land on the first frame of every worker. Only three of the six first frames give it away: #0 is the real start anyway, #212 lands in the opening where the block count is still 1, #1060 lands on the ending card where the 3D is already hidden. Case closed — the problem isn't the footage, it's "the first frame each worker renders."
Root cause: the cold-start first frame, painted before the redraw
Each worker cold-starts a fresh page to render its own chunk: the page comes up, the 3D sits at its initial state (that "1 block"), then it seeks to the chunk's start frame and screenshots immediately. The bug is in the timing of that chain — two things collide. The per-frame redraw hangs on an animation callback; and to seek precisely, the seek takes the "write the values, don't fire callbacks" path. So on a worker's first frame: the values are right, but the callback that does the redraw never runs — the picture is frozen on the initial state, and that's the exact moment the shutter fires.
Normal frames survive because a later normal redraw always patches them up; only each worker's first frame — where cold-start and the first seek land in the same beat — gets the screenshot in before the patch.
Stuck for a moment: why does only the 3D flicker, not the 2D text?Because the title and subtitles write their properties directly — after the seek the values are simply right; only the 3D picture's redraw routes through an animation callback, so only it gets silenced along with everything else. That also explains why yesterday's "obvious-looking" fix did nothing — the symptom looked like lazy rendering, but the root was a skipped callback. Wrong medicine.
The fix: put the redraw on the path that always runs
The fix is one line: move the per-frame redraw from "a callback that can be skipped" to "the step that runs even on a seek" — in GSAP terms, from onUpdate to modifiers. Now cold starts and callback-suppressed seeks both redraw, and the worker's first frame lands in sync. It's a change of where one thing hangs — no render logic touched.
Verify: signal flattened
Claude Code made the fix — but I don't get to trust "it's fixed" just because it said so. The change is in the one piece of shared code every render runs through; I have it re-render and run the same detection again. Last post's rule was don't even trust your own validator; here's one more layer — don't trust the agent's "fixed" either, re-measure with the same ruler. The same per-frame brightness curve, before and after, side by side:
And eyeball the culprit #424 — it's no longer the collapsed initial state:
onUpdate → modifiers apart: from "volume = 1 block" back to the correct "4 blocks," camera angle and all. (Labels: 修复前 = before, 修复后 = after.)Because the fix is in that shared code, every video from now on — 3D or the 2D ones that were fine anyway — is immune to this trap for free.
Last
The technical takeaway, one line: when a flicker or stutter shows up on a strictly even period, don't rush to blame the footage — count whether the render was split across parallel workers, because the period is often just "total frames ÷ worker count." And a more general one: any "redraw every frame" logic should never hang on a callback that suppressEvents can silence; hang it in the property-calculation phase that offscreen rendering is guaranteed to run.
But what I most want to keep from this is the human-and-agent layer. Yesterday I only handed over a vague symptom — "flickering, stuttering" — and however hard Claude Code ran, it just spun; it wasn't short on horsepower, it was short on direction. Today I threw it a guess that was actually wrong — "dropped frames" — and that's what cracked it: a hypothesis wrong in the letter but right in aim, narrowing the search from the whole engine down to the frame level. And once it had a direction, the legwork — ffprobe, frame extraction, counting the period, reading source, writing the fix — it did faster and more thoroughly than I would. The split is clean: direction and judgment are mine, the legwork is its.
One aside, since it's ours: this explainer-video engine is for leonclass.com — turning exam questions a teacher has vetted into videos you can actually explain from. This debug had nothing to do with education, pure engineering, but the old line held up again: instinct said dropped frames, ffprobe said no; it said "fixed," only the flattened curve made it so. Don't trust, verify — my hunch or the agent's conclusion, never stop at "looks about right."
If you're also chewing on verification / education / agents, come say hi.