Rendering on a server
@videoflow/renderer-server runs in Node. It launches headless Chromium via Playwright, loads the same browser renderer the browser package uses, and exports an MP4 — by default entirely inside the browser (WebCodecs + MediaBunny mux), with no ffmpeg dependency. An alternative ffmpeg pipeline is also available for hosts that need custom encoder flags.
Install
npm install @videoflow/core @videoflow/renderer-server
npx playwright install chromiumNode 18+ is required. ffmpeg is optional — only install it system-wide if you plan to pass { ffmpeg: true }.
To a file
import ServerRenderer from '@videoflow/renderer-server';
import VideoFlow from '@videoflow/core';
const $ = new VideoFlow({ width: 1920, height: 1080, fps: 30 });
// ... build flow ...
const json = await $.compile();
await ServerRenderer.render(json, {
outputType: 'file',
output: './out.mp4',
onProgress: (p) => console.log(`${Math.round(p * 100)}%`),
});To a buffer
const buffer = await ServerRenderer.render(json, { outputType: 'buffer' });
// e.g. upload to S3 straight from memory
await s3.send(new PutObjectCommand({ Bucket, Key, Body: buffer, ContentType: 'video/mp4' }));Options
| Option | Default | Notes |
|---|---|---|
ffmpeg | false | When true, switches to the alternative per-frame screenshot → ffmpeg pipeline. Requires ffmpeg 4.4+ on PATH. |
outputType | 'buffer' | `'file' |
output | — | Path when outputType: 'file'. |
signal | — | AbortSignal for cancellation. |
onProgress | — | (p: 0..1) => void. |
verbose | false | Stream stage logs to the console. |
videoQuality | 'veryHigh' | `'medium' |
videoBitrate | — | Explicit bits per second (e.g. 16_000_000). Overrides videoQuality. |
elementCapture | auto | Leave unset to let the renderer decide. false forces the SVG rasterizer, true forces capture on. See Rendering performance. |
elementCaptureScale | 1 | Supersample factor for element capture, max 4. Costs scale quadratically. |
layerTypes | — | External layer types to register — see Custom layer types. |
Rendering performance
Two things dominate a render, and both are handled for you.
Frames are composited with Chromium's "HTML in canvas" (drawElementImage) when the browser supports it, instead of serializing every layer into an SVG <foreignObject>. That removes the font re-embedding, canvas re-encoding and XML serialization the SVG path needs. Measured end-to-end on the bundled examples:
| example | SVG rasterizer | element capture |
|---|---|---|
| effects showcase | 970 ms/frame | 480 ms/frame |
| nested groups | 218 ms/frame | 140 ms/frame |
It needs Chrome 149+, and the renderer drives your system Chrome — so keeping Chrome current is all it takes. Verbose renders say which path was taken, and name the version when it is too old. Set VIDEOFLOW_CHROME_PATH to point at a specific binary.
Where a project animates scale or position more slowly than the browser's paint grid, the renderer automatically falls back to the SVG rasterizer for that project, because its scale latch keeps such motion smooth. You do not normally need to touch any of this.
Video layers decode through WebCodecs, adapting to the access pattern rather than seeking per frame. A seek re-decodes from the preceding keyframe, so seeking frame by frame costs O(frames × GOP) — on a 1080p clip with a stock 250-frame GOP that was 207 ms per frame, against 15.6 ms decoding sequentially. Sources whose codec has no WebCodecs decoder fall back to the previous <video> path automatically.
Custom layer types
The page runs in a separate Chromium realm, so a runtime layer class cannot be handed to it. Register an absolute module path instead and the module is bundled into the renderer page:
import { ServerRenderer } from '@videoflow/renderer-server';
const renderer = new ServerRenderer(json);
renderer.registerLayerType('custom', {
modulePath: '/absolute/path/to/custom-layer-type.js',
exportName: 'default', // optional
});
await renderer.renderVideo({ outputType: 'file', output: './out.mp4' });
await renderer.cleanup();The module must be browser-compatible and export { runtime, propertiesDefinition }. Register after construction and before the first render — that is when the headless page and its bundle are built. See the browser renderer guide for how to write the runtime class.
Pipelines
| Browser export (default) | ffmpeg pipeline | |
|---|---|---|
| Encoder | WebCodecs (H.264) + MediaBunny mux | JPEG screenshots → libx264 via ffmpeg |
| Audio | Encoded inside the browser, muxed with video | Rendered to WAV, muxed by ffmpeg |
| System deps | Playwright Chromium only | Playwright + ffmpeg 4.4+ |
| Speed | Several × faster — no per-frame round-trip | Slower; useful when you need ffmpeg flags |
| Output | MP4 (H.264 + AAC) | MP4 (configurable via ffmpeg) |
Single frame and audio
Need a thumbnail or a music-only export? The renderer exposes per-frame and audio-only methods on the instance:
const renderer = new ServerRenderer(json);
const jpeg = await renderer.renderFrame(120); // Buffer (JPEG)
const wav = await renderer.renderAudio(); // Buffer (WAV) | null
await renderer.cleanup();Inside an HTTP handler
// Express / Node / Bun
app.post('/render', async (req, res) => {
const json = req.body;
try {
const buffer = await ServerRenderer.render(json, { outputType: 'buffer' });
res.setHeader('Content-Type', 'video/mp4');
res.setHeader('Content-Disposition', 'attachment; filename="video.mp4"');
res.end(buffer);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});Scaling. Render jobs are CPU-heavy. In production, push them into a queue (BullMQ, SQS, Temporal) and cap concurrency to your CPU count. Call closeSharedBrowser() on shutdown — the package keeps a single Chromium instance alive across renders.