I needed a short video of my portfolio for LinkedIn: the opening animation, a swordsman who cuts the screen in two, then a fast scroll through every section. Playwright records video in one option, so I started there.
The first video looked fine in stills
recordVideo on a browser context gave me a 25 fps WebM. Every frame I pulled out of it looked right. I had only checked stills. Played back, it stuttered, and the first person who watched it saw it at once.
Measuring it frame by frame told the real story:
- the opening: 65% of frames identical to the one before
- the swordsman: 63%
- the scroll: 79 of 200 transitions were a freeze followed by a jump
That is about 9 real frames per second inside a 25 fps file. The recorder is fed by the browser's screencast, which hands over a frame only when the compositor has one ready. On a heavy page it has fewer, and the gaps are filled with repeats. Nothing fails. The video is just wrong.
Stop recording, start stepping
If the browser cannot keep up in real time, take real time out of the equation. Every CSS animation and transition on a page is a Web Animation, reachable through document.getAnimations(), and a paused animation can be placed at any instant by setting its currentTime. So the capture becomes a loop: advance every animation by exactly 40 ms, take a screenshot, repeat. 25 frames per second by construction, however long each screenshot takes.
// injected with context.addInitScript, before the page loads
const seen = new WeakMap();
let t = 0;
window.__step = (dt) => {
t += dt;
for (const a of document.getAnimations()) {
if (a.timeline && a.timeline !== document.timeline) continue;
let s = seen.get(a);
if (!s) {
s = { start: t - dt, offset: a.currentTime || 0 };
seen.set(a, s);
a.pause();
}
a.currentTime = s.offset + (t - s.start);
}
};
Animations that start later, like the swordsman when his IntersectionObserver fires, are picked up on the next step and join the same clock.
Scrolling gets the same treatment: one scrollTo per frame along an eased curve, instead of a smooth scroll that the recorder samples whenever it can.
for (let i = 1; i <= frames; i++) {
const y = Math.round(target * easeInOut(i / frames));
await page.evaluate((y) => {
window.scrollTo({ top: y, behavior: 'instant' });
window.__step(40);
}, y);
await page.screenshot({ path: `frames/${String(n++).padStart(5, '0')}.jpg`, type: 'jpeg', quality: 95 });
}
The frames go to ffmpeg as an image sequence. The new video has zero frozen frames in the opening, the descent and the scroll.
The traps
A timer that ignores your clock. The opening has a 7-second safety timeout that removes it if its end event never arrives. Each captured frame costs far more than 40 ms of real time, so that timer would have fired long before the virtual clock got there. I neutralised that one timeout in the capture script. Anything driven by setTimeout or requestAnimationFrame runs on wall time, not on yours: the text-scramble effect on my headings plays faster in the video than on the site, and I left it that way.
Scroll-driven animations already have a clock. Animations bound to animation-timeline: view() follow the scroll position, so setting their currentTime makes no sense. The loop skips anything whose timeline is not document.timeline. Driving the scroll drives them.
One frame can lie. A section flashed white for a single frame. In real time nobody would see it. In a 25 fps video it reads as a glitch. A frame-difference pass over the whole video found it, and I cut it.
The upload has opinions too. LinkedIn's composer previewed my WebM, then dropped it as soon as I clicked Next. The same frames encoded as H.264 MP4, in yuv420p with standard colour range, stayed.
What I take from it
A screen recorder answers "what did the screen show?", and on a busy page the honest answer is "less than you asked for". For a demo, the better question is "what should the screen show at 40 ms, at 80, at 120?". The Web Animations API lets you ask it directly.
And watch the playback, not the stills. Every still I checked was perfect.
The 19-second result is in my LinkedIn post, and the site itself is at ayoubhammoudi.com.
I am Ayoub Hammoudi, a full-stack engineer in Paris working on data and AI platforms. More of my work is at github.com/Ayoubhm07.
Top comments (0)