puppeteer-capture
v1.49.0
Published
A Puppeteer plugin for capturing page as a video with ultimate quality.
Maintainers
Readme
puppeteer-capture
A Puppeteer plugin for capturing page as a video with ultimate quality.
Standard screencast approaches capture frames in real time, producing inconsistent frame timing and
non-reproducible output. puppeteer-capture uses Chrome's
HeadlessExperimental CDP domain
to capture each frame deterministically. If you need frame-perfect, reproducible video output from Puppeteer,
this is the only option.
Quick Start
npm install puppeteer-capture puppeteerimport { capture, launch } from 'puppeteer-capture'
const browser = await launch()
const page = await browser.newPage()
const recorder = await capture(page)
await page.goto('https://example.com', { waitUntil: 'networkidle0' })
await recorder.start('capture.mp4')
await recorder.waitForTimeout(1000)
await recorder.stop()
await recorder.detach()
await browser.close()Key Features
- Deterministic frame timing — frames are captured on demand via CDP, not in real time
- Frame-perfect reproducible output — the same page produces the same video, every time
- CDP-powered — uses
HeadlessExperimental.beginFramefor precise frame control - Virtual time control —
waitForTimeout()advances the page's own timeline - Works with any Puppeteer workflow — drop-in alongside existing Puppeteer scripts
Comparison
| | puppeteer-capture | puppeteer-screen-recorder | Playwright built-in |
|---|---|---|---|
| Approach | CDP HeadlessExperimental | Screencast | Screencast |
| Frame timing | Deterministic | Real-time | Real-time |
| Reproducibility | Frame-perfect | Varies | Varies |
| Time control | Full (virtual clock) | None | None |
| Platform | Linux, Windows | All | All |
API Reference
launch(options?)
Launches a browser configured for deterministic capture.
import { launch } from 'puppeteer-capture'
const browser = await launch()options— OptionalPuppeteerLaunchOptionsfrompuppeteer-core. Theheadlessoption is overridden to'shell'and the required Chrome arguments are appended toargsautomatically.- Returns
Promise<PuppeteerBrowser>
capture(page, options?)
Creates a PuppeteerCapture instance for the given page.
import { capture } from 'puppeteer-capture'
const recorder = await capture(page)page— APuppeteerPageto capture.options— OptionalPuppeteerCaptureOptionsextended with:attach(boolean, default:true) — Whenfalse, the recorder is created without attaching to the page. Callrecorder.attach(page)later.
- Returns
Promise<PuppeteerCapture>
PuppeteerCaptureOptions
Options passed to capture().
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| fps | number | 60 | Frames per second |
| size | string | — | Output size in ffmpeg notation (e.g. '1280x720') |
| format | (ffmpeg: FfmpegCommand) => Promise<void> | PuppeteerCaptureFormat.MP4() | Output format configurator |
| ffmpeg | string | — | Path to the ffmpeg binary (overrides auto-detection) |
| customFfmpegConfig | (ffmpeg: FfmpegCommand) => Promise<void> | — | Additional ffmpeg configuration callback applied after format |
PuppeteerCaptureStartOptions
Options passed to recorder.start().
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| waitForFirstFrame | boolean | true | Whether start() waits for the first frame before resolving |
| dropCapturedFrames | boolean | false | When true, frames are emitted via events but not written to the output |
PuppeteerCapture
The main interface returned by capture().
Properties
| Property | Type | Description |
|----------|------|-------------|
| page | Page \| null | The attached page, or null if detached |
| isCapturing | boolean | Whether capture is in progress |
| captureTimestamp | number | Current virtual timestamp (ms) since capture start |
| capturedFrames | number | Total frames captured since last start() |
| dropCapturedFrames | boolean | Whether captured frames are dropped (read/write) |
| recordedFrames | number | Total frames written to the output |
Methods
attach(page) — Attaches the recorder to a page. Required only when
capture() was called with { attach: false }.
Throws if already attached.
detach() — Detaches from the current page. Throws if not attached.
start(target, options?) — Starts capturing frames.
target— A file path (string) or aWritablestream.options— OptionalPuppeteerCaptureStartOptions.
When target is a file path, parent directories are created automatically.
stop() — Stops capture and waits for ffmpeg to finalize the output.
waitForTimeout(milliseconds) — Advances the page's virtual timeline by the given number
of milliseconds. Can only be called while capturing. See Time Flow.
on(event, listener) — Subscribes to a capture event.
Events
| Event | Listener Signature | Description |
|-------|--------------------|-------------|
| captureStarted | () => void | Capture was started |
| frameCaptured | (index: number, timestamp: number, data: Buffer) => void | A frame was captured |
| frameCaptureFailed | (reason?: any) => void | Frame capture failed |
| frameRecorded | (index: number, timestamp: number, data: Buffer) => void | A frame was written to the output |
| captureStopped | () => void | Capture was stopped |
PuppeteerCaptureFormat
Format configurators for PuppeteerCaptureOptions.format.
PuppeteerCaptureFormat.MP4(preset?, videoCodec?)
preset— x264 encoding preset (default:'ultrafast'). One of:'ultrafast','superfast','veryfast','faster','fast','medium','slow','slower','veryslow'.videoCodec— Video codec (default:'libx264').- Returns a format configurator function.
import { capture, PuppeteerCaptureFormat } from 'puppeteer-capture'
const recorder = await capture(page, {
format: PuppeteerCaptureFormat.MP4('medium', 'libx264')
})Error Classes
NotChromeHeadlessShell — Thrown when the browser is not chrome-headless-shell.
Use launch() to ensure the correct binary.
MissingRequiredArgs — Thrown when the browser is missing one or more required Chrome
arguments. Use launch() to add them automatically.
Required Chrome Arguments
The following arguments are required and are added automatically by launch():
--deterministic-mode--enable-begin-frame-control--disable-new-content-rendering-timeout--run-all-compositor-stages-before-draw--disable-threaded-animation--disable-threaded-scrolling--disable-checker-imaging--disable-image-animation-resync--enable-surface-synchronization
Platform Risk: HeadlessExperimental Dependency
This library depends entirely on Chrome's
HeadlessExperimental CDP domain
— specifically the
beginFrame
method — for deterministic frame capture. This is the only mechanism in Chrome that provides compositor-level frame
scheduling, which is what enables frame-perfect, reproducible video output.
Current status
beginFrameis not deprecated and remains actively implemented in the Chromium source.enable/disableare marked deprecated in the protocol definition (they are no-ops and have no functional impact).- The domain is labeled Experimental, meaning it can change without notice.
beginFrameis exclusive tochrome-headless-shell(the old headless architecture). It is not available in--headless=new.
Risk assessment
| Timeframe | Risk | Rationale |
|-----------|------|-----------|
| Near-term (0–12 months) | Low | beginFrame is not deprecated; implementation receives maintenance commits; chrome-headless-shell ships with every Chrome release. |
| Medium-term (1–3 years) | Moderate | The "Experimental" label has persisted since inception without graduating to stable. No public commitment to long-term chrome-headless-shell maintenance exists. |
| Long-term (3+ years) | Moderate–High | Chrome's strategic direction favors --headless=new. If chrome-headless-shell is eventually discontinued, beginFrame goes with it. |
Why there is no drop-in alternative
HeadlessExperimental.beginFrame is unique because it controls when the compositor renders each frame. The
alternatives — Page.startScreencast, Page.captureScreenshot, tab capture — all capture frames produced by Chrome's
own compositor timing, which means:
- Frame timing depends on wall-clock time and system load (non-deterministic).
- CSS animations, transitions, and compositor-driven effects cannot be synchronized to a virtual timeline.
- Two runs of the same page may produce different frame counts and visual output.
A partial fallback using Page.captureScreenshot with JavaScript time virtualization can achieve determinism for
JS-driven content but not for CSS animations or compositor-driven effects.
Mitigating factors
- Remotion and other ecosystem projects also depend on
chrome-headless-shellfor deterministic rendering, creating broader pressure to maintain the binary. - Active Chromium issues (e.g., #40550372 — making BeginFrameControl work with Viz) indicate ongoing investment, not abandonment.
- Chromium's deprecation process typically involves long warning periods and migration paths.
Monitoring
To track changes to this dependency:
- Chromium
HeadlessExperimental.pdl— protocol definition (watch for deprecation annotations onbeginFrame) - Chromium
headless_handler.cc— implementation (watch for removal or functional changes) - Chrome DevTools Protocol changelog
- headless-dev mailing list — discussions on headless mode future
Time Flow
The browser runs in deterministic mode, so the time flow is not real time. To wait for a certain amount of time
within the page's timeline, use PuppeteerCapture.waitForTimeout():
await recorder.waitForTimeout(1000)Dependencies
ffmpeg
ffmpeg is resolved in the following order:
FFMPEGenvironment variable pointing to the executable- The executable available via
PATH - Via
ffmpeg-static, if installed as a dependency
Known Issues
See Known Issues for platform constraints and workarounds.
Contributing
See Contributing for development setup and guidelines.
