(本文英文版同步發表在 dev.to

如果你曾經用 Playwright 錄過一個文字密集的頁面——例如程式編輯器、字型 preview、任何字符銳利的內容——然後輸出看起來像隔著紗窗拍的,你就見識過 Playwright 的 recordVideo 的能耐了。那些人工痕跡不是 CDP 的問題、也不是幀率問題,而是編碼器選擇的問題,而那個編碼器是寫死的。

這篇文章在講怎麼樣在不 patch playwright-core 的情況下解決之,方法是用 Playwright 1.59 加入的一個 public API——而且那個 API 就我所知,幾乎還沒人在用。

整件事的起點是這樣的。我用 Playwright 在錄一段 FontFreeze 的教學影片(可參見之前這篇)。FontFreeze 的預覽面板基本上就是一面小字級的字符牆:剛好就是 VP8 1 Mbps 會出問題的內容。

修改前——用 Playwright 內建的 recordVideo(VP8 @ 1 Mbps)錄、抽出單張 frame、把字符表那塊裁出來放大 2x:

VP8 1 Mbps —— 字符邊緣布滿 mosquito noise(蚊型雜訊),小型標點符號幾乎看不清

修改後——同一個頁面、同一張 frame,改用我寫的 playwright-recorder-plus(libx264 CRF 18)錄:

libx264 CRF 18 —— 字符邊緣乾淨、標點清晰可辨

兩張裁切座標完全對齊(同一組 source coordinates),都用 nearest-neighbor 放大 2x,所以你看到的差異是編碼器造成的,不是放大演算法。順帶一提:before crop 這張 PNG 是 73 KB、after 那張是 33 KB——少掉的那 40 KB 就是 mosquito noise 變成的熵值。

問題的源頭:原始碼裡的一行

那為什麼修改前那張會長那樣?很容易會懷疑是 CDP screencast feed、JPEG 那一步、或者解析度。但這些都不是兇手。兇手是埋在 playwright-core 裡的這一行:

// playwright-core/lib/server/videoRecorder.js
const args = ['-c:v', 'vp8', '-b:v', '1M', '-deadline', 'realtime', '-speed', '8', '-threads', '1'];

VP8、1 megabit、realtime、單執行緒。這幾個參數沒有任何選項可以調。已經有人提問過——而且還不只一次:

  • #8683Tuning video performance(2021 關閉)
  • #12056Configure video quality(2022 關閉)
  • #17217Specify video params (like fps)(open,沒有 maintainer 回應)
  • #31424Video recording quality control(open,沒有 maintainer 回應)

前兩個被以「won’t do」關掉。後兩個技術上來說還開著,但已經放到沒有 maintainer 回應的地步——意思其實一樣。這不是「沒有人想到過」,而是「這個專案不打算往那個方向走」。

我於是去找講法。第一個試的是最直覺的:pnpm patch playwright-core、把 args 換掉。結果不行——Playwright 自己帶一份 ffmpeg 執行檔,那份 binary 只 build 了 libvpx。你叫它用 libx264 它沒有給你;那份附帶的 ffmpeg 裡根本沒有 libx264。

我得換一個層級下手。

突破口:page.screencast.start({ onFrame })

Playwright 1.59(2024 年 11 月)加了一個 public API,它的位置比內部的 video recorder 還上層

await page.screencast.start({
  size: { width: 1280, height: 720 },
  quality: 90,
  onFrame: async (jpeg) => {
    // jpeg 是一個 raw JPEG Buffer,頁面每畫一張就送一張過來
  },
});

這條串流就是 Playwright 內建 VideoRecorder 在用的同一條 CDP screencast——只是這次 JPEG 傳到你手上。你自己 spawn 一個 ffmpeg、把 frame pipe 進去,從此完全掌控編碼器。沒有 patch internals、除了 1.59+ 之外也沒有任何版本耦合。

一個 30 行的粗糙原型長這樣:

import { spawn } from "node:child_process";
import ffmpegPath from "ffmpeg-static";

const ff = spawn(ffmpegPath!, [
  "-f", "image2pipe",
  "-r", "25",
  "-i", "-",
  "-c:v", "libx264",
  "-preset", "ultrafast",
  "-crf", "18",
  "out.mp4",
]);

await page.screencast.start({
  size: { width: 1280, height: 720 },
  quality: 90,
  onFrame: async (jpeg) => { ff.stdin.write(jpeg); },
});

只不過這樣寫的話,沒兩三下你就會發現,之前很多的假設都有問題。

不顯然的問題之一:frame 編號不能交給 wall-clock timer

第一次踩雷:我用 setInterval(write, 1000/25) 驅動寫入、每個 tick 抓最新的 JPEG 餵進去。在 Windows 上測試:我錄了 87 秒的 wall clock,輸出卻只有 65 秒長。32% 的 drift(漂移)。setInterval 在 Windows 上不是一個緊迫的 CFR clock;它會 slip(滑掉),而且滑掉的量會累積。

把這個 bug 畫出來大概是這樣:

xychart-beta
    title "Frames written vs. wall-clock time"
    x-axis "Wall-clock seconds elapsed" 0 --> 87
    y-axis "Frames written" 0 --> 2200
    line "setInterval (actual)" [0, 475, 950, 1425, 1625]
    line "wall-clock anchored (target)" [0, 550, 1100, 1650, 2175]

上面那條線是 -r 25 ffmpeg 期待看到的:每秒 wall clock 25 張、87 秒後到 frame 2175。下面那條是 setInterval 在 Windows 上實際給出的——大概每秒 19 張,最後停在 frame 1625。ffmpeg 把 frame 1625 標成「第 65 秒」是因為它仍在做 25 fps 的算術。錄影短了 22 秒。

修法就藏在 Playwright 自家的 videoRecorder.js 裡,明明白白:

// 每張 JPEG 進來時:
const frameNumber = Math.floor((nowMs - startMs) * fps / 1000);
const gap = frameNumber - lastFrameNumber;
for (let i = 1; i < gap; i++) ff.stdin.write(lastJpeg); // 用上一張填滿空 slot
ff.stdin.write(jpeg);
lastFrameNumber = frameNumber;
lastJpeg = jpeg;

沒有 timer、沒有 setInterval。每張 JPEG 進來時用 wall clock 算 frame 編號,中間的空隙用重複前一張 JPEG 補起來。CDP 是 variable-rate(頁面沒重繪就不送 frame),所以複製不是浪費——它就是「頁面這 200 ms 沒變」在 CFR 檔裡應該長的樣子。

額外的一個小措施:餵 ffmpeg 的時候加 -fps_mode passthrough(取代已 deprecated 的 -vsync 0)。沒加的話,libvpx-vp9——還有某些 libx264 build——會把「重複的」frame 當成優化目標靜默丟掉,把你的補幀作廢。

不顯然的問題之二:t=0 不能設在第一個 CDP frame

這個我在實際頁面上踩過。FontFreeze 在可互動之前要載入 Pyodide 跟字型檔——recorder.start() 之後大約有 7 秒視覺上完全靜止。CDP 是 variable-rate,這 7 秒它一張 frame 都不送。頁面沒重繪、沒東西可送。

如果你把 t=0 anchor 到「第一張收到的 JPEG」,那 7 秒會從輸出中蒸發。影片會變短。任何後製想跟它對齊的東西——旁白、Playwright trace overlay、click sound——都會差 7 秒、整個沒用。

修法:

// recorder.start() 時:
this._startWallMs = performance.now();
this._lastFrameNumber = -1;

// 第一個 onFrame 時:
if (this._lastFrameNumber === -1) {
  // 用這張 JPEG 回填 slot 0 .. frameNumber-1。
  // 推論:CDP 沒送 frame ⇒ 頁面沒變化。
  // 所以這段時間頁面就是「長現在這樣」。
  for (let i = 0; i < frameNumber; i++) ff.stdin.write(jpeg);
}

第一張 frame 不只代表「現在」,它代表「start() 之後到現在的整段時間」。這對「靜態暖機」是正確的近似。(如果是「有動畫但 CDP 剛好都沒抓到」的情境就不對了——但實務上我還沒遇過。)

同樣這個 wall-clock anchor 對音訊也很重要。如果你用「第 42 frame / 25 fps」去排程一個 click sound,你已經內建了最多 1/fps 秒對真實時間的誤差,因為 frameCount 在兩次 onFrame 之間落後 wall clock。改用 performance.now() - startWallMs,click 就會落在使用者真的點擊的位置。

不顯然的問題之三:encoder 的速度不是品質措施——是正確性措施

問題一跟問題二修好之後,下一個 failure mode 自動送上門。試試 VP9 CRF 24。漂亮的檔案、相同視覺品質下大小是 H.264 的一半。錄一段 90 秒的影片。

它會 drift。不是 32% 那麼誇張——可能 90 秒內差個 2 秒,但會隨長度累積。

原因在這。ff.stdin.write(jpeg) 會在 kernel pipe buffer 滿、ffmpeg 跟不上排空時回 false。Node 會 honour backpressure(背壓):下一次 write 會 await 'drain' 事件。但這個 backpressure 會反向沿著你的 onFrame queue 倒回來。CDP 持續產生 frame;它們在 Node 的 microtask queue 裡堆積等 onFrame return;你在 onFrame 裡讀的 timestamp(performance.now())比真實時間晚;你那個 wall-clock anchored 的 frameNumber 跟著長得比 wall time 慢;影片又變短了。

這個你從 Node 端修不了。ffmpeg process 平均必須跑得比 realtime 快、而且要有 headroom 吸收尖峰。libx264 -preset ultrafast 在現代筆電 CPU 上輕鬆跑 5–10x realtime。libvpx-vp9 沒辦法,特別是文字內容在 realtime 限制下壓不出小檔。libsvtav1 在任何能產出小檔的 preset 下也都不行。

這聽起來像「品質 vs 速度」的取捨;非也,因為有一條乾淨的解法:兩次 pass

flowchart TD
    A["第一次 PASS — 錄影時即時執行<br/>━━━━━━━━━━━━━━━━━━━━━━━<br/>onFrame → libx264 ultrafast CRF 18 → intermediate.mp4<br/>(固定;使用者無法變更)"]
    B["第二次 PASS — 停止後在背景中執行<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>intermediate.mp4 → 任意你想要的東西<br/>如果真的想要也可以用 libsvtav1 preset 0<br/>音訊 mux 也在這邊發生"]
    A -->|"recorder.stop() 在這裡返回"| B
    style A fill:#1e3a5f,stroke:#4ec9b0,color:#fff
    style B fill:#2d4f3a,stroke:#4ec9b0,color:#fff

第一次 pass 存在的目的就是永遠不要 block CDP。它在位元率上極度浪費,所以 backpressure 不可能發生——檔案大是因為 encoder 快。第二次 pass 讀的是檔案、不是 live stream,backpressure 不再是問題:encoder 慢只代表你 await 最終 mp4 等久一點,它沒辦法回到過去把錄影縮短。

這個劃分也讓 API 可以「該強硬的地方強硬(live encoder)、可以彈性的地方有彈性(最終 encoder)」。playwright-recorder-plus 把 second pass 暴露成 preset: "youtube" | "web"、或者你想完全自己控制就用 ffmpegArgs: string[]第一次 pass 是鎖死的。

我也有試過不要這樣做:試過讓使用者自己設定 live encoder。但光想像「我的影片比 wall clock 短」這類 issue 會湧進來的量,我就確定那會是這個套件最常被回報的 bug。

不顯然的問題之四:JPEG size 被「誰先問就誰決定」鎖住

這是一個很微妙的問題,先簡短講。Playwright 的 screencast server 會在第一個呼叫 addClient 的 client 啟動,第一個 client 要求的 size 會在整個 session 裡被鎖死。後續的 client 會靜默拿到那個鎖死的 size。

最常踩到的情境:context.tracing.start({ screenshots: true })attachRecorder() 之前跑,因為 tracing 也是一個 screencast client。Tracing 的 fallback size(通常 800×450)勝出,於是 recorder 不知不覺地產出 800×450 的影片,儘管它要的是 1280×720。

修法:解析第一張 frame 的 JPEG SOF marker、跟要求的 size 比對、不一致就立刻 throw。大概 30 行、每段錄影只跑一次、亞微秒級。比交付一個 480p 檔案兩天後才發現好。

最後 API 長什麼樣

import { attachRecorder } from "playwright-recorder-plus";

const recorder = await attachRecorder(page, {
  path: "out.mp4",
  size: { width: 1280, height: 720 },
  fps: 25,
  preset: "youtube",          // 或者:ffmpegArgs: ["-c:v", "libsvtav1", ...]
  // autoStart: true(預設)—— attachRecorder 內部就會呼叫 start()
});

// ... 操作頁面 ...

await recorder.stop();
await recorder.finalized;       // 等 second-pass mp4 寫好

Pause/resume——錄教學影片時想跳過冗長 setup 階段就需要:

await recorder.pause();
await page.evaluate(() => waitForPyodide());
await recorder.resume();

Click sound(或任何音效),按 wall clock 排程、在 second pass 一起 mux 進去:

await page.locator(".save-button").click();
recorder.audio("./assets/click.wav", { offset: 0.05 });  // 從現在算起 50 ms 後

對於 multi-page context(彈出視窗、target=_blank 流程),有 attachRecorderForContext(context) 會自動為每個新開的 page 掛上 recorder。

我沒有要解決的事

  • 頁面音訊擷取:CDP 沒有對應的 API。本套件提供 recorder.audio(path, opts?) 來把預先錄好的音訊(TTS 旁白、click 音效)混進去,但沒辦法擷取頁面自己播的聲音。如果你真的需要,README 有指向 getDisplayMedia + MediaRecorder injection 的方向。
  • 一個整合的影片處理 pipeline:v0.1.0 是「比 recordVideo 好一點」,不是 framework。要轉檔請自己用 ffmpeg。

試試看

pnpm add playwright-recorder-plus

Repo: github.com/MuTsunTsai/playwright-recorder-plus
npm: playwright-recorder-plus

如果你曾經在 Playwright #8683、#12056、#17217 或 #31424 留過言或按過讚——這個就是為你而做的。

我的文章對您有幫助嗎?請我喝杯咖啡吧:

分享此頁至:
最後修改日期: 2026/04/28

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。