Demos that film themselves
I wanted a short video for the top of kolu.dev — thirty seconds of the app doing something real, on a loop, the kind every dev tool has now. The obvious way is to open a screen recorder, do the thing by hand, and trim it. I didn’t, and why is the whole post.
A recorded demo is a photograph of your product on one afternoon. Then you move a button, rename a panel, flip a default, and the video on your front page is quietly lying. Nobody catches it, because nobody re-watches their own marketing. The first to notice is a visitor holding it against the app they just opened.
kolu already has a Cucumber + Playwright harness that drives the real app to prove it works — 880-odd scenarios, a fat step library. It opens terminals, clones repos, launches agents, clicks the dock. Which is exactly what a demo does. If the thing that proves your app works can also film it, the demo stops being a photograph and becomes a build artifact — one that breaks when the UI moves, the way a test does.
Here’s what came out — the actual home-page clip, regenerated by just record hero-demo:
It shipped in #1213 . Getting there took two dead ends, and then the actual work — which had nothing to do with recording.
Recorded demos rot
A hand-recorded clip is faster to make, sure. And you could re-shoot it every release to keep it current. My guess is you won’t — I never have. The cost was never the shoot; it’s the discipline, and discipline is exactly the thing that quietly fails. A clip wired to your tests asks for none: it re-renders on the next run, so it can’t fall behind the app without the run falling over first.
You can’t film it from inside the browser
The first instinct is wrong: just record the terminal. asciinema, vhs, terminalizer — they tap the PTY, capture the byte stream, replay it into an emulator. Tiny and reproducible, and none of them can see kolu. kolu’s terminals are xterm.js painted to a browser canvas — raster pixels, not a replayable ANSI stream. So it has to be pixel capture: drive the real browser, grab what it paints.
The browser will hand you frames, so ask it. Playwright has recordVideo. Dead end one: VP8 at a fixed one megabit, 25 fps, no quality knob. At 720p of dense terminal text it shimmers, and no ffmpeg pass recovers detail the VP8 stage already threw away. A crisp mp4 from a blurry source just faithfully preserves the blur.
So go over its head, to Chrome’s DevTools protocol, and ask for 2× screenshots on a tick. Dead end two, more interesting — every in-Chrome path hits the same ceiling:
Page.startScreencastignores device scale — capped at CSS pixels, so no true 2×.Page.captureScreenshotcan do 2×, but each 2560×1440 grab is compositor-bound at ~260 ms. Three frames a second. A slideshow.
And it opened on a flash of nothing — the capture loop started before the app loaded, so frame zero strobed on every loop. Sitting in front of a demo that was both blank and stuttering, I typed:
The white box was a quick fix. The 3 fps was the real lesson, and it’s structural. From inside Chrome, capture is either smooth and low-res or sharp and too slow to be smooth. The compositor is the ceiling, and you’re standing under it.
Move the camera outside Chrome
The fix: stop asking the browser for frames and film the screen it draws on. Run a headful Chrome at 2× inside an Xvfb virtual display and point ffmpeg’s x11grab device at the framebuffer. ffmpeg samples on its own clock, in physical pixels, with no idea Chrome exists — smooth because the grabber’s clock isn’t Chrome’s, crisp because it’s reading actual 2× pixels. Both at once, the thing you couldn’t buy from inside.
Two gotchas, each one cost a cycle:
- The ffmpeg in nixpkgs is built
--disable-xlib, so it has no x11grab device — you needffmpeg-full, and the error if you forget is a baffling complaint about an unrecognizeddraw_mouseoption. - x11grab films the whole window, which the first time meant Chrome’s tab strip and a
localhost:38425address bar in the shot — reads as “a browser tab,” not “an app.” Fix: app-mode,--app=<url>, the chromeless window a PWA gets. After that the frame is just kolu.
This part you can lift wholesale — it names nothing about kolu. “Drive a headful browser at 2× under Xvfb, grab the framebuffer, transcode” would screencast any web app, so it lives in its own folder with the dependency arrow pointing out, ready to become its own package the day a second thing wants it.
The hard part was taste, not capture
I expected capture to be the hard part. Capture took a day. The taste took the next — a second day of nothing but watching the clip and fixing what was wrong with it.
Once the pipeline worked, every flaw was a one-line change and a re-run, not a re-shoot. And a demo has a lot of flaws, each obvious only once you watch it. The terminal opened under the dock, unreadable — nudge it 200px right. Most were like that. A few I remember by the exact note I typed into the agent building it.
The status flipped to done and the loop ended before your eye could land on it:
So hold a beat. The clicks needed pointing at, and my first idea — a glowing ring — read as part of the app:
Swapped for a coral arrow that’s plainly an annotation. The two terminals’ themes were too close to tell the repos apart:
They do. The clip ended on one dark terminal and one light, so the two repos read as two things at a glance. The climax exists to show the comments feature — select a line, leave a comment, hit its copy button, paste the clipboard into the agent as-is — so faking it with a hand-typed prompt was off the table:
Type the prompt by hand and you’ve skipped the very thing the shot is there to demonstrate.
None of these are clever, and that’s the point. A reproducible demo spends your attention on the hundred small true things instead of on the machinery — each fix is a line and a re-run, cheap enough to actually make. A hand-recorded demo dies after the second re-shoot. This one absorbed every correction without complaint.
A demo built as a test goes flaky
Build the demo as a test and you inherit a test’s problems. That climax is a real edit on a real checkout — Claude Code actually changes the file on camera. So the second run, the file is already edited from the first, and Claude correctly says there’s nothing to do. A flaky test, for the most ordinary reason: leftover state. The fix is the one you’d write for any test — git checkout -- . before each run, so the edit is always a fresh, visible change. I’d never had to think about test isolation for a marketing video, and having to is the strongest evidence the framing is right.
There’s one honest seam. The clip is captured on my own machine, not the clean CI box the rest of the harness runs on, because the finale launches a real authenticated agent and a fresh box has no logged-in CLI. The recipe and recording modules run anywhere; the capture of this clip needs a machine that’s already signed in. Reproducible-from-source, not box-pure. Better to say it than pretend the green checkmark covers it.
Grow the demo, don’t shoot it
The clip became the hero of the home page, then ate the rest of it: the separate /welcome guide folded in once the demo carried the explaining, and the page now shows the whole thing in one scroll. It earned the front by being good, and it got good by being cheap to fix.
That’s the part I’d hand anyone building a dev tool. You already have a harness that drives your real app, sitting in your test directory proving things work. Point a camera at it — not to save the cost of a screen recording, which is nearly free, but to stop it going stale. A demo wired to your tests can’t drift from the app, and it breaks loudly when you change what it shows. And the surprise under that, the reason it’s worth it even if your demo never went stale: you stop shooting a demo and start growing one, a line at a time, the way the rest of your software already gets made.
Your app has a test harness. Why doesn’t your demo run on it?