GM! Carl reel motion standard
Locked 2026-09-12. Every reel from here uses this. Source: reel-captions.html
(the TIMING + EASE block is the single source of truth), worked example in reel-count/.
The rules
| Element | Motion | Timing |
|---|---|---|
| Big number | counts up 0 → value, easeOutExpo | starts 0.06s, runs 0.92s |
| Big number | scale 0.88 → 1.0, easeOutBack (slight overshoot) + fade | 0.00s, 0.38s |
| Gold rule | wipes out from centre, 0 → 220px, easeOutCubic | 0.30s, 0.45s |
| Supporting line | fade + rise 26px, easeOutCubic | 0.52s, 0.40s |
| Body lines | fade + rise 30px, staggered 0.09s per line | 0.00s, 0.46s each |
| Source credit | fade in last, always | 0.70s, 0.40s |
Why count-up and not a cut. The whole campaign is called a count. A number that rolls up reads as data being gathered; a number that cuts in reads as a claim. It also buys ~1s of attention on a stat that would otherwise be read and dismissed.
Never animate the source credit's text, only its opacity. A moving citation looks like decoration; a still one looks like evidence.
Render pipeline
Only the animated head (1.25s = 38 frames @30fps) is screenshotted. ffmpeg clones
the last frame for the hold — tpad=stop_mode=clone:stop_duration=N. Rendering full
windows would be ~320 screenshots instead of 114 for identical output.
# 1. frames (Playwright steps window.setFrame(cardId, t) — deterministic, not CSS timing)
# 2. alpha clips
ffmpeg -framerate 30 -i seq/c1-%04d.png -vf "tpad=stop_mode=clone:stop_duration=1.98" -c:v qtrle c1.mov
# 3. composite: offset each card with setpts, gate with enable
[1:v]setpts=PTS+0.10/TB[a];
[0:v][a]overlay=0:0:eof_action=pass:repeatlast=0:enable='between(t,0.10,3.34)'[v1]
Hard-won gotchas
- qtrle (in a
.mov) is the alpha codec that works here. PNG sequences alone can't be offset on a timeline. - Never
apadwith-shortest—-shortestdoes not constrainfilter_complexoutputs, so apad emits infinite audio and the encode never terminates. Bound with-t. -preset slowat 1080x1920 is too heavy on this box.veryfast+crf 20is clean.- Captions need the baked-in scrim. Bright plates swallow cream text.
- Drive animation per frame from JS, never CSS
animation:— a screenshotter has no reliable way to pin CSS animation time, and you get torn frames.
Caption timing comes from the audio, not from taste
Render the VO first, then:
ffmpeg -i vo.wav -af "silencedetect=noise=-40dB:d=0.07" -f null /dev/null
Use the detected gaps as card boundaries. For a sentence with no internal pause, split by syllable weight, not character count — spoken numbers run far longer than they read.
Voice
Qwen3-TTS YOUR_TTS_HOST:5005, voice YOUR_VOICE_ID (Carl's real clone, carl-ref.wav).
Verify against GET /v1/voices every time — that map was inverted once and shipped
months of audio in the wrong voice. Normalise to -16 LUFS for social.