Use FFmpeg’s image2pipe input. Capture one complete JPEG or PNG with Puppeteer’s page.screenshot(), write that buffer to FFmpeg’s stdin, wait for the drain event when the pipe is full, then call stdin.end() and wait for FFmpeg to exit. Closing stdin lets FFmpeg write the MP4 trailer; without it, the file is commonly empty or unplayable.
The pipe architecture
Puppeteer does not produce video frames. Its page.screenshot() method returns encoded screenshot data (a Uint8Array, or a base64 string when encoding: 'base64' is requested). FFmpeg can consume those encoded images as a continuous stream through its image2pipe demuxer.
- Launch Chromium and navigate to the page.
- Choose a screenshot codec, normally JPEG for video or PNG when lossless pixels and transparency matter.
- Start FFmpeg with an
image2pipeinput and an input frame rate matching your capture cadence. - Capture one complete image per iteration and write its bytes to
ffmpeg.stdin. - Honor Node.js stream backpressure by waiting for
drainwhenwrite()returnsfalse. - End stdin after the last frame and wait for FFmpeg’s
closeevent before treating the output as complete.
The equivalent shell form documented in FFmpeg’s FAQ is cat *.jpg | ffmpeg -f image2pipe -c:v mjpeg -i - output.mpg. In Node, pipe:0 is FFmpeg’s stdin.
A complete Node.js implementation
This example captures 150 JPEG frames at an intended 30 frames per second and encodes them as H.264 in an MP4 container. It is an implementation pattern, not a guarantee that every host can sustain 30 FPS.
Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minutePC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11#1 Best Overall
import { spawn } from 'node:child_process';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
const ffmpeg = spawn('ffmpeg', [
'-y',
'-f', 'image2pipe',
'-framerate', '30',
'-vcodec', 'mjpeg',
'-i', 'pipe:0',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
'output.mp4',
]);
ffmpeg.stderr.on('data', chunk => process.stderr.write(chunk));
try {
for (let i = 0; i < 150; i++) {
const frame = await page.screenshot({ type: 'jpeg', quality: 85 });
if (!ffmpeg.stdin.write(frame)) {
await new Promise(resolve => ffmpeg.stdin.once('drain', resolve));
}
await new Promise(resolve => setTimeout(resolve, 1000 / 30));
}
ffmpeg.stdin.end();
await new Promise((resolve, reject) => {
ffmpeg.once('close', code => code === 0
? resolve()
: reject(new Error(`ffmpeg exited ${code}`)));
ffmpeg.once('error', reject);
});
} finally {
await browser.close();
}
Install Puppeteer in the project, and make sure an ffmpeg executable is available on PATH. If it is elsewhere, use an absolute executable path in spawn() (for example, spawn('/opt/ffmpeg/bin/ffmpeg', args)).
Why each FFmpeg argument matters
| Argument | Purpose |
|---|---|
-f image2pipe |
Reads a sequence of encoded images from stdin rather than filenames. |
-framerate 30 |
Assigns timestamps to incoming frames. Set it to the cadence your loop is designed to produce. |
-vcodec mjpeg |
Tells FFmpeg that each input image is JPEG. Do not use this declaration for PNG input. |
-i pipe:0 |
Uses file descriptor 0, the child process’s stdin. |
-c:v libx264 |
Encodes the output video as H.264. |
-pix_fmt yuv420p |
Produces a broadly compatible pixel format for MP4 playback. |
PNG, transparency and other output choices
Using PNG frames
Capture with type: 'png' and change the FFmpeg input declaration to a PNG codec:
ffmpeg -f image2pipe -framerate 30 -vcodec png -i pipe:0 -c:v libx264 -pix_fmt yuv420p output.mp4
PNG is lossless but normally larger and slower to encode than JPEG. Video formats such as H.264 do not preserve an alpha channel, so a transparent screenshot will be composited or otherwise converted during encoding.
Changing the container or codec
The output extension and video codec must agree with your playback target. You can write a different container, select another encoder installed in your FFmpeg build, or add filters, but keep the input declaration consistent with the bytes Puppeteer supplies. Never concatenate base64 text to stdin unless you decode it to binary first.
Timing: constant cadence versus real elapsed time
-framerate describes how FFmpeg timestamps the sequence; it does not make Puppeteer capture at that speed. A simple setTimeout(1000 / fps) loop gives an intended cadence, but page rendering and screenshot encoding consume time. For deterministic animation, advance the page state yourself and capture after each state change. For a recording that reflects wall-clock time, measure timestamps and design the capture schedule around elapsed time rather than assuming every iteration takes the same duration.
If your loop falls behind, you must choose between dropping frames, slowing the intended frame rate, or allowing the recording to represent the slower capture rate. Measure on the target browser, viewport, codec and host when performance matters; no authoritative benchmark establishes a universal FPS limit for this pattern.
Backpressure, memory and process lifecycle
Node streams buffer data. Ignoring a false return from stdin.write() can make memory grow while FFmpeg catches up. The example waits for drain before taking another screenshot. For long captures, also monitor process errors and decide how to handle a browser crash or an FFmpeg exit while frames are still being produced.
Always perform these shutdown steps in order:
- Stop capturing new frames.
- Call
ffmpeg.stdin.end(). - Wait for the child’s
closeevent and verify an exit code of zero. - Only then publish, upload or rename the MP4.
FFmpeg cannot finalize many containers until it sees end-of-input. Killing the process or letting Node exit early can leave a file without its trailer.
Rank #3
Built-in Puppeteer recording
Puppeteer documents page.screencast() as an FFmpeg-backed recorder that defaults to WebM/VP9 at 30 FPS and requires FFmpeg installed. Its API reference now labels that method obsolete and points users to page.record(); check the Puppeteer version in your project before choosing either API.
The documented screencast options include ffmpegPath, format, fps, quality, scale, speed, path and overwrite behavior. The returned recording object exposes piping and stopping through the ScreenRecording interface. A built-in recorder is convenient when its supported format and lifecycle are sufficient. Manual piping is preferable when you need each frame, custom timing, JPEG/PNG selection, explicit backpressure handling or a custom FFmpeg filter graph.
Manual piping, page.record() and wrappers compared
| Approach | Best fit | Trade-offs |
|---|---|---|
Manual image2pipe |
Per-frame control, custom filters, exact screenshot options | You own timing, backpressure, shutdown and error handling. |
page.record() (version permitting) |
New code that fits Puppeteer’s supported recorder API | Availability and options depend on the installed Puppeteer version. |
Obsolete page.screencast() |
Existing projects tied to that API | Current documentation marks it obsolete; migrate after checking compatibility. |
ffmpeg-stream |
A stream-oriented Node wrapper | Its documented workflow still requires creating an image2pipe input, writing frames sequentially and ending the input; verify maintenance and licensing. |
puppeteer-stream |
FFmpeg-backed output with format, frame-size and FPS settings | Its documentation requires an FFmpeg binary and recommends Xvfb for its X11 recorder; verify version compatibility. |
Common failures and fixes
MP4 is empty or will not play
Cause: stdin was never ended, or the Node process exited before FFmpeg closed. Call stdin.end(), await close, and check the exit code.
Corrupt or repeated frames
Cause: partial writes, text encoding, or a codec mismatch. Pass the complete Buffer returned by Puppeteer, wait for drain, and use mjpeg for JPEG or png for PNG.
Rank #4
FFmpeg reports “command not found”
Install FFmpeg for the host or provide an explicit executable path. Puppeteer’s recorder also requires FFmpeg; its ffmpegPath option is intended for installations that are not on PATH.
Video speed is wrong
Cause: the declared input frame rate does not match the capture cadence. Set -framerate to the intended rate, or redesign the loop around measured elapsed time when captures cannot keep up.
Memory usage keeps rising
Cause: writing faster than FFmpeg can consume. Check stdin.write() and await drain before capturing the next frame. Also watch for an FFmpeg process that has already exited.
Linux wrapper cannot open a display
X11-based third-party recorders may need a virtual display. The puppeteer-stream documentation recommends Xvfb for that scenario. Manual headless Chromium capture avoids that particular X11 recorder requirement.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Best Value
Or skip the browser setup
If you only need a clean screenshot or PDF rather than a time series of animation frames, ScreenshotNeo is a direct HTTP option. It accepts a URL and returns PNG, JPEG, WebP or PDF. Before capture it accepts cookie/consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups and chat widgets; each cleanup step can be disabled. Bot checks/CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and billing status.
One request is enough:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
See the ScreenshotNeo API documentation for all options. The service provides 63 options, including full-page capture with lazy images loaded, CSS-selector element capture, dark mode, 12 device presets plus arbitrary viewports, retina scale, PDF paper size/margins/landscape/page ranges, HTML/CSS-to-image, custom CSS and JavaScript, pre-capture clicks, hidden selectors, waits for selectors/delays/network idle, ad/tracker/request/resource blocking, custom headers/cookies/user agents/Authorization, timezone and geolocation, transparent backgrounds, resizing, user-selected cache TTLs, signed public-image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API and an OpenAPI specification. Parameter names used by other screenshot APIs also work to ease migration.
Python
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await Bun.write('shot.webp', res);
ScreenshotNeo also exposes an MCP server for AI agents, with take_screenshot, get_page_info and capture_pdf tools that work with Claude, Cursor and other MCP clients. Plans include every feature: Free offers 1,000 shots per month with no card; Starter is $5 for 3,000; Growth $15 for 15,000; Pro $39 for 60,000; Scale $99 for 250,000; and Business $249 for 1,000,000. Yearly billing gives two months free. Create a free ScreenshotNeo account to get the 1,000 monthly shots without adding a card.
Operational checklist
- Pin and record the Puppeteer and FFmpeg versions used in deployment.
- Use a fixed viewport and explicit navigation wait condition.
- Choose JPEG or PNG deliberately and match FFmpeg’s input codec.
- Set the input frame rate from the capture design, not from an assumed maximum.
- Handle
write()backpressure and child-process errors. - End stdin and await FFmpeg completion before consuming the file.
- Test long captures for memory growth, browser crashes and disk capacity.
- Measure performance on the actual host instead of relying on a generic FPS claim.
Frequently Asked Questions
Can I pipe base64 screenshots directly to FFmpeg?
No. Decode the base64 value to binary bytes first, or let Puppeteer return its default byte buffer; FFmpeg’s image2pipe input expects encoded image bytes, not base64 text.
Free tools Windows power users keep installed
One-click scans. No signup required.
Does image2pipe require separate files for each frame?
No. It reads complete encoded images sequentially from stdin, so no temporary frame files are needed.
When should I choose PNG over JPEG?
Choose PNG when lossless pixels are essential. Choose JPEG when smaller frames and faster video encoding are more important; in both cases, declare the matching input codec.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




