<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mu-Tsun Tsai</title>
    <description>The latest articles on DEV Community by Mu-Tsun Tsai (@mutsuntsai).</description>
    <link>https://dev.to/mutsuntsai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1409948%2F9e766a36-7e0a-4dc0-a3ac-476515ef340b.png</url>
      <title>DEV Community: Mu-Tsun Tsai</title>
      <link>https://dev.to/mutsuntsai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mutsuntsai"/>
    <language>en</language>
    <item>
      <title>Replacing Playwright's hardcoded VP8 encoder: a deep dive into page.screencast</title>
      <dc:creator>Mu-Tsun Tsai</dc:creator>
      <pubDate>Tue, 28 Apr 2026 04:19:21 +0000</pubDate>
      <link>https://dev.to/mutsuntsai/replacing-playwrights-hardcoded-vp8-encoder-a-deep-dive-into-pagescreencast-43ee</link>
      <guid>https://dev.to/mutsuntsai/replacing-playwrights-hardcoded-vp8-encoder-a-deep-dive-into-pagescreencast-43ee</guid>
      <description>&lt;p&gt;If you've ever recorded a Playwright session of a text-heavy page — a code editor, a font preview, anything with crisp glyphs — and the output looked like it was filmed through a screen door, you've met Playwright's &lt;code&gt;recordVideo&lt;/code&gt;. The artifacts are not a CDP problem. They're not a frame-rate problem. They are an &lt;strong&gt;encoder choice problem&lt;/strong&gt;, and the encoder is hardcoded.&lt;/p&gt;

&lt;p&gt;This post is about replacing it without patching &lt;code&gt;playwright-core&lt;/code&gt;, using a public API that landed in Playwright 1.59 and that — as far as I can tell — almost nobody is using yet.&lt;/p&gt;

&lt;p&gt;Here's what triggered this whole exercise. I was recording a tutorial video for &lt;a href="https://mutsuntsai.github.io/fontfreeze/" rel="noopener noreferrer"&gt;FontFreeze&lt;/a&gt; — a small web app that bakes OpenType features and variable axes into static font files — using Playwright. The Preview panel of FontFreeze is essentially a wall of glyphs at small point sizes: exactly the workload that VP8 at 1 Mbps falls apart on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; — recorded with Playwright's built-in &lt;code&gt;recordVideo&lt;/code&gt; (VP8 @ 1 Mbps), single frame extracted, 2× crop on the glyph table:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl1jt61lf8cdoxqle76od.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl1jt61lf8cdoxqle76od.png" alt="VP8 1 Mbps — mosquito noise around glyph edges, small punctuation barely legiblen" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; — same page, same single frame, recorded with &lt;code&gt;playwright-recorder-plus&lt;/code&gt; (libx264 CRF 18):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F574iecjq2txll9zlzgmy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F574iecjq2txll9zlzgmy.png" alt="libx264 CRF 18 — clean glyph edges, punctuation crisp" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Crops are pixel-aligned (same source coordinates) and 2× nearest-neighbor upscaled, so what you see is the codec, not the resampler. As an aside: the before crop is &lt;strong&gt;73 KB&lt;/strong&gt; as PNG, the after crop is &lt;strong&gt;33 KB&lt;/strong&gt; — the missing 40 KB is mosquito noise turned into entropy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, in one line of source
&lt;/h2&gt;

&lt;p&gt;So why does the Before image look like that? It's tempting to blame the CDP screencast feed, or the JPEG step, or the resolution. None of those are the culprit. The culprit is a single line buried inside &lt;code&gt;playwright-core&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// playwright-core/lib/server/videoRecorder.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-c:v&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vp8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-b:v&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1M&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-deadline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;realtime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-speed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-threads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VP8, 1 megabit, realtime, single-threaded. There is no option to change any of those. People have asked — repeatedly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/playwright/issues/8683" rel="noopener noreferrer"&gt;#8683&lt;/a&gt; — &lt;em&gt;Tuning video performance&lt;/em&gt; (closed 2021)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/playwright/issues/12056" rel="noopener noreferrer"&gt;#12056&lt;/a&gt; — &lt;em&gt;Configure video quality&lt;/em&gt; (closed 2022)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/playwright/issues/17217" rel="noopener noreferrer"&gt;#17217&lt;/a&gt; — &lt;em&gt;Specify video params (like fps)&lt;/em&gt; (open, no maintainer response)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/playwright/issues/31424" rel="noopener noreferrer"&gt;#31424&lt;/a&gt; — &lt;em&gt;Video recording quality control&lt;/em&gt; (open, no maintainer response)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two were closed as "won't do." The latter two are technically still open, but they've sat without a maintainer reply long enough that the message is the same. This isn't "nobody thought of it" — it's "this is not a direction the project wants to go."&lt;/p&gt;

&lt;p&gt;I went looking for a workaround. The first thing I tried was the obvious one: &lt;code&gt;pnpm patch playwright-core&lt;/code&gt;, swap the args. &lt;strong&gt;It doesn't work&lt;/strong&gt; — Playwright ships its own ffmpeg binary, and that binary is built with &lt;code&gt;libvpx&lt;/code&gt; only. You can ask it for libx264 all you want; the ffmpeg in the box doesn't have it.&lt;/p&gt;

&lt;p&gt;I needed a different layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook: &lt;code&gt;page.screencast.start({ onFrame })&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Playwright 1.59 (Nov 2024) added a public API that sits &lt;em&gt;above&lt;/em&gt; the internal video recorder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;screencast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// jpeg is a raw JPEG Buffer, one per frame the page paints&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same CDP screencast stream Playwright's built-in &lt;code&gt;VideoRecorder&lt;/code&gt; consumes — except now you get the JPEGs &lt;em&gt;first&lt;/em&gt;. Spawn your own ffmpeg, pipe them in, and you control the encoder completely. No internals patched, no version coupling beyond 1.59+.&lt;/p&gt;

&lt;p&gt;A naive 30-line prototype looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;spawn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:child_process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;ffmpegPath&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ffmpeg-static&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ffmpegPath&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-f&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image2pipe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-r&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;25&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-i&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-c:v&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;libx264&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-preset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ultrafast&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-crf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;18&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;out.mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;screencast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for about thirty seconds before everything you assumed is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-obvious problem #1: frame numbering can't be left to wall-clock timers
&lt;/h2&gt;

&lt;p&gt;Initial mistake: I drove the writer with &lt;code&gt;setInterval(write, 1000/25)&lt;/code&gt;, grabbing the latest JPEG every tick. Tested it on Windows: I'd record for &lt;strong&gt;87 seconds of wall clock and the output was 65 seconds long&lt;/strong&gt;. A 32% drift. &lt;code&gt;setInterval&lt;/code&gt; on Windows is not a tight-loop CFR clock; it slips, and the slips compound.&lt;/p&gt;

&lt;p&gt;Plotted out, the bug looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fclrjc5cbcxcs8cqhxn61.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fclrjc5cbcxcs8cqhxn61.png" alt="Frames written vs. wall-clock time: setInterval line slopes at ~19 fps and ends at frame 1625 after 87 s of wall clock; the wall-clock-anchored target line slopes at 25 fps and ends at frame 2175." width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The upper line is what &lt;code&gt;-r 25&lt;/code&gt; ffmpeg expects: 25 frames per wall-clock second, ending at frame 2175 after 87 s. The lower line is what &lt;code&gt;setInterval&lt;/code&gt; actually delivers on Windows — about 19 frames per second, ending at frame 1625. ffmpeg labels frame 1625 as "second 65" because it's still doing 25 fps math. The recording is 22 seconds short.&lt;/p&gt;

&lt;p&gt;The fix is in Playwright's own &lt;code&gt;videoRecorder.js&lt;/code&gt;, hidden in plain sight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// per JPEG frame:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frameNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;nowMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;fps&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frameNumber&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastFrameNumber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;ff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastJpeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// duplicate to fill&lt;/span&gt;
&lt;span class="nx"&gt;ff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;lastFrameNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frameNumber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;lastJpeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No timer. No &lt;code&gt;setInterval&lt;/code&gt;. The frame number is computed from wall-clock time on every JPEG arrival, and any gap is back-filled by &lt;strong&gt;duplicating the last JPEG&lt;/strong&gt;. CDP is variable-rate (it skips frames when the page doesn't repaint), so the duplication isn't waste — it's exactly what "page didn't change for 200 ms" should look like in a CFR file.&lt;/p&gt;

&lt;p&gt;One extra knob: feed ffmpeg with &lt;code&gt;-fps_mode passthrough&lt;/code&gt; (the modern replacement for &lt;code&gt;-vsync 0&lt;/code&gt;). Without it, libvpx-vp9 — and some libx264 builds — will silently drop "duplicate" frames as an optimization, undoing your padding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-obvious problem #2: t=0 cannot be the first CDP frame
&lt;/h2&gt;

&lt;p&gt;This one bit me on a real page. FontFreeze loads Pyodide and a font file before it's interactive — about &lt;strong&gt;7 seconds of nothing happening visually after &lt;code&gt;recorder.start()&lt;/code&gt;&lt;/strong&gt;. CDP, being variable-rate, sends &lt;em&gt;zero frames&lt;/em&gt; during those 7 seconds. The page hasn't repainted, so there's nothing to send.&lt;/p&gt;

&lt;p&gt;If you anchor &lt;code&gt;t=0&lt;/code&gt; to the first JPEG you receive, those 7 seconds vanish from the output. The video is too short. Anything you try to align to it later — narration, a Playwright trace overlay, click sounds — is off by 7 seconds and useless.&lt;/p&gt;

&lt;p&gt;Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// at recorder.start():&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_startWallMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_lastFrameNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// at first onFrame:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_lastFrameNumber&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Backfill slot 0 .. frameNumber-1 with this JPEG.&lt;/span&gt;
  &lt;span class="c1"&gt;// Reasoning: CDP not sending frames means the page didn't change.&lt;/span&gt;
  &lt;span class="c1"&gt;// So the page looked like *this* the whole time.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;frameNumber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;ff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first frame represents not just "now" but "everything since &lt;code&gt;start()&lt;/code&gt;." That's the right approximation for a static warm-up. (For an animation that CDP somehow misses, it isn't. I haven't seen this case in practice.)&lt;/p&gt;

&lt;p&gt;The same wall-clock anchor matters for audio. If you schedule a click sound at "frame 42 / 25 fps," you've baked in up-to-1/fps of error against real time, because &lt;code&gt;frameCount&lt;/code&gt; lags wall-clock between onFrame calls. Use &lt;code&gt;performance.now() - startWallMs&lt;/code&gt; and the click lands where the user clicked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-obvious problem #3: encoder speed isn't a quality knob — it's a correctness knob
&lt;/h2&gt;

&lt;p&gt;Once #1 and #2 are fixed, you get the next failure mode for free. Try VP9 CRF 24. Beautiful files, half the size of H.264 at the same visual quality. Run a 90-second recording.&lt;/p&gt;

&lt;p&gt;It drifts. Not 32% — small, maybe 2 seconds over 90, but it grows with length.&lt;/p&gt;

&lt;p&gt;Here's why. &lt;code&gt;ff.stdin.write(jpeg)&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; when the kernel pipe buffer is full and ffmpeg can't drain it fast enough. Node honours backpressure: the next write awaits &lt;code&gt;'drain'&lt;/code&gt;. &lt;strong&gt;But that backpressure travels backwards through your &lt;code&gt;onFrame&lt;/code&gt; queue.&lt;/strong&gt; CDP keeps producing frames; they pile up in Node's microtask queue waiting for &lt;code&gt;onFrame&lt;/code&gt; to return; the timestamp you read inside &lt;code&gt;onFrame&lt;/code&gt; (&lt;code&gt;performance.now()&lt;/code&gt;) drifts later than reality; your wall-clock-anchored &lt;code&gt;frameNumber&lt;/code&gt; grows slower than wall time should make it grow; the video is short again.&lt;/p&gt;

&lt;p&gt;You can't fix this from the Node side. The ffmpeg process must, on average, &lt;strong&gt;encode faster than realtime&lt;/strong&gt;, with enough headroom to absorb spikes. &lt;code&gt;libx264 -preset ultrafast&lt;/code&gt; does 5–10× realtime on a modern laptop CPU. &lt;code&gt;libvpx-vp9&lt;/code&gt; does not, especially on text content that compresses poorly under realtime constraints. Neither does &lt;code&gt;libsvtav1&lt;/code&gt; at any preset that produces a small file.&lt;/p&gt;

&lt;p&gt;This sounds like a quality-vs-speed tradeoff. It isn't, because there's a clean way out: &lt;strong&gt;two passes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsetosy11gq5nm7imtjty.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsetosy11gq5nm7imtjty.png" alt="Two-pass pipeline: a blue first-pass box (live, libx264 ultrafast CRF 18, fixed) flows into a green second-pass box (background, intermediate.mp4 to whatever the user wants, audio mux). The arrow between them is labelled " width="618" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first pass exists to &lt;strong&gt;never block CDP&lt;/strong&gt;. It's so wasteful, in bitrate terms, that backpressure is impossible — the file is large because the encoder is fast. The second pass reads a file, not a live stream, so backpressure is a non-issue: a slow encoder just means you wait longer for the final mp4. It can't reach back through time and shorten the recording.&lt;/p&gt;

&lt;p&gt;This split also means the API can be opinionated where it has to be (the live encoder) and flexible where it can be (the final encoder). &lt;code&gt;playwright-recorder-plus&lt;/code&gt; exposes the second pass as &lt;code&gt;preset: "youtube" | "web"&lt;/code&gt; or, if you want full control, &lt;code&gt;ffmpegArgs: string[]&lt;/code&gt;. The first pass is locked.&lt;/p&gt;

&lt;p&gt;I tried not doing it this way. I tried letting users set the live encoder. The amount of "my video is shorter than the wall clock" issues that would generate makes me certain it'd be the most-reported bug in the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-obvious problem #4: the JPEG size is locked by whoever asked first
&lt;/h2&gt;

&lt;p&gt;A subtle one, mentioned briefly. Playwright's screencast server starts on the first client that calls &lt;code&gt;addClient&lt;/code&gt;, and the size that &lt;em&gt;first&lt;/em&gt; client requests is locked for the lifetime of the session. Subsequent clients silently get the locked size.&lt;/p&gt;

&lt;p&gt;The most common way to hit this: &lt;code&gt;context.tracing.start({ screenshots: true })&lt;/code&gt; runs &lt;em&gt;before&lt;/em&gt; &lt;code&gt;attachRecorder()&lt;/code&gt;, because tracing is also a screencast client. Tracing's fallback size (often 800×450) wins, and &lt;code&gt;recorder&lt;/code&gt; quietly produces 800×450 video despite asking for 1280×720.&lt;/p&gt;

&lt;p&gt;Fix: parse the JPEG SOF marker from the first frame, compare against the requested size, throw immediately on mismatch. ~30 lines, runs once per recording, sub-microsecond. Better than shipping a 480p file and finding out two days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API ended up looking like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;attachRecorder&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playwright-recorder-plus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;attachRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;out.mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;youtube&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// or: ffmpegArgs: ["-c:v", "libsvtav1", ...]&lt;/span&gt;
  &lt;span class="c1"&gt;// autoStart: true (default) — start() called inside attachRecorder&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// ... drive the page ...&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalized&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// wait for second-pass mp4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pause/resume — needed for tutorial recording where you want to skip a long setup phase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;waitForPyodide&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click sounds (or any audio cue), scheduled against wall clock, mixed in during the second pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.save-button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./assets/click.wav&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// 50 ms after now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For multi-page contexts (popups, target=_blank flows), there's &lt;code&gt;attachRecorderForContext(context)&lt;/code&gt; that auto-attaches a recorder to every page that opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not solving
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page audio capture.&lt;/strong&gt; CDP has no API for it. The package provides &lt;code&gt;recorder.audio(path, opts?)&lt;/code&gt; for mixing in pre-recorded audio (TTS narration, click SFX), but it cannot capture what the page itself plays. The README points at &lt;code&gt;getDisplayMedia&lt;/code&gt; + &lt;code&gt;MediaRecorder&lt;/code&gt; injection if you really need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A unified video processing pipeline.&lt;/strong&gt; v0.1.0 is "a better &lt;code&gt;recordVideo&lt;/code&gt;," not a framework. If you need to transcode, you have ffmpeg.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add playwright-recorder-plus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/MuTsunTsai/playwright-recorder-plus" rel="noopener noreferrer"&gt;github.com/MuTsunTsai/playwright-recorder-plus&lt;/a&gt;&lt;br&gt;
npm: &lt;a href="https://www.npmjs.com/package/playwright-recorder-plus" rel="noopener noreferrer"&gt;playwright-recorder-plus&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've ever filed or thumbed-up Playwright #8683, #12056, #17217, or #31424 — this is built for you.&lt;/p&gt;

</description>
      <category>playwright</category>
    </item>
  </channel>
</rss>
