posthog/onboarding-videos
Add, replace, and optimize the looping demo videos in the onboarding "welcome" bento grid (packages/ui/src/features/onboarding). Covers the ffmpeg compression workflow (the scripts/optimize-onboarding-videos.mjs wrapper), the faststart + right-size best practices that keep playback smooth, the first-frame poster convention, and how to wire a new clip into WelcomeScreen. Use when onboarding demo clips feel laggy, when a clip is being added/swapped/re-recorded, or when posters and videos drift out of sync.
This is a copy. The original lives at posthog/code-onboarding-videos.
npx skills add https://github.com/PostHog/posthog --skill onboarding-videos
The first onboarding step (WelcomeScreen) shows a bento grid of feature cards.
A card with a media entry plays a short, muted, looping screen recording; cards
without one show a static placeholder. The featured (large, top-left) card is the
only one that plays at a time — hover just moves the highlight.
| Thing | Where |
| --- | --- |
| Clips + posters | packages/ui/src/features/onboarding/assets/<feature>-<light\|dark>.{mp4,jpg} |
| Wiring (MEDIA map, startTime) | packages/ui/src/features/onboarding/components/WelcomeScreen.tsx |
| <video> element + play/seek logic | packages/ui/src/features/onboarding/components/FeatureBentoCard.tsx |
| Optimizer script | scripts/optimize-onboarding-videos.mjs (pnpm optimize:onboarding-videos) |
Raw screen recordings hitch in this UI for two reasons, both fixed by re-encoding:
moov atom at the end of the file. Without faststart the player must readto EOF before it can start, so first-play and every seek stutter. We seek on
mount and on every loop (FeatureBentoCard parks the clip on videoStartTime),
so this bites constantly. Fix: -movflags +faststart.
~500 CSS px (the grid is max-w-[760px]). Even at 2× retina that's ~1000px, but
recordings come in at ~1876px. Decoding huge frames and downscaling them — on
every play and during the framer-motion slot reflow — is wasted work. Fix: cap
width at 1000px (which also matches the poster width exactly).
Bitrate/size are usually already modest; decode cost and startup are the lag,
not bytes. Don't chase file size at the expense of width/faststart.
H.264 · yuv420p · ≤1000px wide (keep aspect, even height) · CRF 23 ·
+faststart · no audio. These live as constants at the top of
scripts/optimize-onboarding-videos.mjs — change them there, not ad-hoc.
pnpm optimize:onboarding-videos # encode any clip not already optimized
pnpm optimize:onboarding-videos --dry-run # show what would change
pnpm optimize:onboarding-videos --force # re-encode all (after changing the target constants)
Requires ffmpeg (brew install ffmpeg). The script tags each output with a
comment marker, so re-runs skip already-optimized files — it's safe to run any
time, including right after dropping in a new clip. It rewrites files in place;
review the git diff --stat and the printed before/after sizes.
1000px but never upscales, so anything narrower ships soft — resolution is the one
thing it can't fix for you. Keep it short (~10–13s); it loops.
assets/<feature>-light.mp4 andassets/<feature>-dark.mp4. Two things bite every time:
(foo.mp4, only foo-dark.mp4 is tagged). Rename it to foo-light.mp4.
~/Downloads) is irrelevant — the<feature> prefix must be the media id you'll use in WelcomeScreen and
follow the existing convention, not whatever the file was called.
No assets.d.ts change needed — *.mp4/*.jpg are wildcard modules.
pnpm optimize:onboarding-videos.and leave startTime at 0, so the poster, the first played frame, and the loop
point are all the same with nothing to keep in sync:
ffmpeg -y -i assets/<feature>-<theme>.mp4 \
-frames:v 1 -vf "scale=1000:-2:flags=lanczos" -q:v 3 \
assets/<feature>-<theme>.jpg
WelcomeScreen.tsx. For a *new* media id, four edits, all keyedby the same slug: import the .mp4 + .jpg, add the id to the MediaId union,
add its entry to the MEDIA map (startTime: 0), and set media: "<id>" on the
target FeatureDef.
Keep the same asset filename and the wiring is untouched — none of step 5
applies. Drop the new file over assets/<feature>-<theme>.mp4, run the optimizer (a
fresh drop carries no skip-marker, so it re-encodes without --force), and
regenerate that poster (step 4). If you replace only one theme, re-check that
light and dark still share an aspect ratio — a clip with stray padding frames
differently from its sibling, and the gap shows when the user toggles theme.
FeatureBentoCard shows the poster while a card rests, then seeks the <video> to
MEDIA[id].startTime on mount and loops back there (not necessarily to 0). The
simple, default contract: **the poster is the clip's first frame and startTime is
0**, so the still, the first played frame, and the loop point are all identical —
nothing to keep in sync. Keep the poster the same pixel width as the clip (1000px)
so the poster→video swap is seamless.
startTime can start/loop mid-clip if you ever need it (the code-review clip uses
3s) — but then the poster MUST be that exact frame (ffmpeg -ss <startTime> -i …),
or the still jumps the instant playback starts. Prefer the first-frame default
unless you have a specific reason.
# clips: moov should print BEFORE mdat, width ≤ 1000:
ffprobe -v trace <clip>.mp4 2>&1 | grep -o -m2 -E "type:'(moov|mdat)'"
ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 <clip>.mp4
# wiring: a grown MediaId union may need a formatter reflow, so let biome fix it:
npx biome check --write packages/ui/src/features/onboarding/components/WelcomeScreen.tsx
pnpm --filter @posthog/ui typecheck
To confirm playback feels smooth in the real app, use the test-electron-app
skill to drive the running onboarding flow.
Take posthog/onboarding-videos from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx, brew.
Without those the skill loads but fails at the first command.