@videoflow/renderer-server
v1.3.4
Published
Server-side video renderering for VideoFlow — renders JSON videos into MP4/WebM videos directly in the browser — Open Source Remotion alternative
Maintainers
Readme
@videoflow/renderer-server
Render VideoFlow videos to MP4 on Node.js. Drives a headless Chromium via Playwright so the server reuses the exact same rendering pipeline as the browser — pixel-for-pixel identical output to @videoflow/renderer-browser and @videoflow/renderer-dom.
Renderers docs: videoflow.dev/renderers · Live playground: videoflow.dev/playground
Why use this package?
- Server-side video generation. Accept a
VideoJSONpayload, return an MP4 — perfect for APIs, batch jobs, and background workers. - WebCodecs-accelerated by default. The headless browser encodes the entire video in-process via
BrowserRenderer.exportVideo(); the finished MP4 is POSTed back to Node — no per-frame screenshot, no JPEG re-encode. Significantly faster than pipelining through ffmpeg. - ffmpeg fallback. When you need ffmpeg-specific flags downstream, switch to the alternative pipeline with
{ ffmpeg: true }— VideoFlow renders frames as JPEG and pipes them toffmpegfor x264 + AAC encoding. - Same pixels as the browser. Both pipelines run inside a real Chromium, so transitions, GLSL effects, fonts, and
mix-blend-modeblends look identical to what your users see in@videoflow/renderer-dom. - Cancellable + observable. Every render accepts an
AbortSignaland anonProgresscallback.
Installation
npm install @videoflow/core @videoflow/renderer-server
npx playwright install chromiumRequirements
- Node.js 18+
- Chromium (installed by
npx playwright install chromiumabove) - ffmpeg 4.4+ — only required if you opt into the ffmpeg pipeline (
{ ffmpeg: true }). The default pipeline does everything inside Chromium.
Installing ffmpeg (optional fallback)
# macOS
brew install ffmpeg
# Linux
sudo apt-get install ffmpeg
# Windows (Chocolatey)
choco install ffmpegQuick Start
import VideoFlow from '@videoflow/core';
const $ = new VideoFlow({ width: 1920, height: 1080, fps: 30 });
const title = $.addText({ text: 'Hello!', fontSize: 6, color: '#fff' });
title.fadeIn('1s');
$.wait('3s');
title.fadeOut('1s');
await $.renderVideo({
outputType: 'file',
output: './output.mp4',
verbose: true,
});$.renderVideo() auto-detects Node.js and dispatches to @videoflow/renderer-server. You can also import the renderer directly:
import VideoRenderer from '@videoflow/renderer-server';
const json = await $.compile();
await VideoRenderer.render(json, {
outputType: 'file',
output: './output.mp4',
});Encoding pipelines
| Mode | When to use | Encoder | Per-frame screenshot? |
| --- | --- | --- | --- |
| ffmpeg: false (default) | The fast path | WebCodecs + MediaBunny inside Chromium | No |
| ffmpeg: true | When you need ffmpeg flags or a non-MP4 container in the same pipeline | ffmpeg (libx264 + AAC) | Yes (JPEG via Playwright) |
The default pipeline is typically several times faster: it skips the per-frame page.screenshot() round-trip and the JPEG → H.264 re-encode. The ffmpeg pipeline remains available for projects that already build on it or that want to apply ffmpeg-specific filters.
// Force the ffmpeg pipeline (e.g. to use a non-default encoder preset downstream)
await VideoRenderer.render(json, {
outputType: 'file',
output: './out.mp4',
ffmpeg: true,
});API
VideoRenderer.render(videoJSON, options?)
One-shot static API — boots a Chromium, runs the render, cleans up.
import VideoRenderer from '@videoflow/renderer-server';
await VideoRenderer.render(videoJSON, {
outputType: 'file', // 'file' | 'buffer' (default 'buffer')
output: './video.mp4', // required when outputType: 'file'
verbose: true, // log progress to stdout
signal: controller.signal, // AbortSignal — cancel mid-render
onProgress: (p) => console.log(p), // 0..1
ffmpeg: false, // default; set true to use the ffmpeg fallback
});Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| outputType | 'file' | 'buffer' | 'buffer' | Where the rendered MP4 ends up |
| output | string | — | File path; required when outputType: 'file' |
| verbose | boolean | false | Print progress / pipeline info to stdout |
| signal | AbortSignal | — | Cancel the in-flight render |
| onProgress | (p: number) => void | — | Called with 0..1 |
| ffmpeg | boolean | false | Pick the ffmpeg fallback instead of the default browser-export path |
Returns: Buffer (when outputType: 'buffer') or the absolute output path (when outputType: 'file').
Instance API
For long-running services or pipelines that re-use the same Chromium across multiple operations, construct a ServerRenderer:
import { ServerRenderer } from '@videoflow/renderer-server';
const json = await $.compile();
const renderer = new ServerRenderer(json);
try {
// Render a single frame to JPEG
const jpeg = await renderer.renderFrame(30);
fs.writeFileSync('frame.jpg', jpeg);
// Render the audio track to a WAV Buffer
const wav = await renderer.renderAudio();
if (wav) fs.writeFileSync('audio.wav', wav);
} finally {
await renderer.cleanup();
}| Method | Returns |
| --- | --- |
| renderer.renderVideo(options) | Buffer or the output path — same options as the static render() |
| renderer.renderFrame(frame) | Buffer (JPEG) |
| renderer.renderAudio() | Buffer \| null (WAV bytes, or null if the project has no audio) |
| renderer.cleanup() | Tears down the Chromium page and any ffmpeg subprocess |
Rendering performance
Two things dominate a server export, and both are handled automatically.
Element capture (HTML in canvas)
When the Chromium being driven implements drawElementImage, the browser-export
pipeline composites each frame with one call instead of rasterizing every
layer through an SVG <foreignObject>. Measured end-to-end on real examples
(wall clock for the whole render, encoding included):
| example | per-layer rasterizer | element capture | |
| --- | --- | --- | --- |
| 01-basic-text | 113.3 ms/frame | 27.1 ms/frame | 4.2× |
| 10-groups | 262.2 ms/frame | 173.3 ms/frame | 1.5× |
| 09-effects | 865.6 ms/frame | 472.1 ms/frame | 1.8× |
Output is visually identical — frame diffs against the rasterizer path show 0.000% of pixels differing by more than 1/16, the remainder being H.264 re-encode noise.
The tradeoff, and how it is handled
Element capture paints the live DOM, so it bypasses LayerRasterizer's
scale/position latch — the mechanism that stops Chrome snapping glyph origins
to the pixel grid during a slow scale ramp. The snap is a quarter of a device
pixel horizontally and a WHOLE one vertically, so a tween slower than that
freezes for several frames and then jumps. Measured on scale: 1 → 1.03 over
4 s, mean frame-to-frame jerk of the sub-pixel side ink edges:
| path | text layer | html component | | --- | --- | --- | | latched rasterizer | 0.013 px (5/119 frozen) | 0.017 px (5/119) | | element capture, 1x | 0.197 px (72/119) | 0.199 px (71/119) | | element capture, 2x | 0.050 px (23/119) | 0.051 px (24/119) |
TEXT and HTML regress by the same amount — this is a property of the paint
path, not of any layer type — and no CSS property avoids it (will-change,
contain: paint, opacity: .999, filter,
text-rendering: geometricPrecision and <svg><text> all measure 0.22 px).
Automatic path selection covers it, and it is the only mechanism on by
default. Leaving elementCapture unset makes the page scan the project and
decline element capture when a DOM layer's animated scale / position moves
slower than the paint grid — a "life push" (1 → 1.03 over ~3 s) is
~0.2 px/frame. Those projects keep the latch and land back at the top row of the
table; everything else keeps the speed. Verbose renders name the layer
responsible:
VideoFlow: Element capture declined — layer "Html" animates scale at 0.24 px/frame
(below the 1.00 px paint grid); using the per-layer rasterizer so the latch keeps that motion smooth.Supersampling is available but off. Capturing at elementCaptureScale
device pixels per project pixel and downsampling divides the quantum by that
factor (1x → 0.208 px jerk, 2x → 0.050, 3x → 0.026, 4x → 0.004), but the cost is
quadratic and at 2x it more than consumed the speedup it was protecting:
| | per frame | | --- | --- | | per-layer rasterizer | 95.9 ms | | element capture 1x | 50.2 ms | | element capture 2x | 113.8 ms | | element capture 3x | 178.7 ms |
It also only halves the vertical snap, so it never was a cure — automatic path
selection is. Raise it per-render when a deliverable's whole point is very slow
type motion and you would rather pay the pixels than fall back. Note it also
moves the decline threshold, which is 1 / scale px per frame.
Force either way when you know better — elementCapture: true takes the speed
and accepts the grid (right for drafts), false turns it off entirely:
await renderer.renderVideo({ output: './out.mp4', outputType: 'file', elementCapture: false });This is entirely automatic: the launch flags are always passed, the page
feature-detects, and anything without the API silently keeps using the
rasterizer. It applies to the default browser-export pipeline only — the legacy
ffmpeg: true path screenshots the live DOM, which element capture would leave
blank, so it is deliberately left alone.
Requires Chrome 149+. The renderer drives your system Chrome
(channel: 'chrome'), so keeping Chrome current is all it takes — verbose
renders report which path was taken, and name the version when it's too old:
VideoFlow: Compositing via element capture (drawElementImage).
VideoFlow: Element capture unavailable — Chrome 135 is too old, 149+ is required; using the per-layer rasterizer.To drive a specific binary instead:
VIDEOFLOW_CHROME_PATH=/path/to/chrome node render.jsCodec availability differs between builds, so check before switching. On Linux neither Chrome nor Chromium ships an AAC encoder (licensing), so audio is encoded as Opus either way — but a build with no H.264 encoder would fail exports outright.
Video decoding
Video layers decode through WebCodecs, adapting to the access pattern rather
than issuing a seek per frame. A seek re-decodes from the preceding keyframe, so
the old path cost O(frames × GOP) — on a 1080p clip with a stock 250-frame
GOP that was 207.7 ms per frame; sequential decoding of the same frames is
15.6 ms. A 10 s 1080p video export went from ~100 s to 21 s.
Sources whose codec has no WebCodecs decoder on the platform fall back to the
previous <video> element path automatically.
External layer types
@videoflow/renderer-browser and @videoflow/renderer-dom let you register a custom layer type by passing the runtime class directly. The server renderer can't do that: the BrowserRenderer lives in a separate headless Chromium realm, and neither page.evaluate arguments nor Playwright's structured serialization can carry functions across it.
So instead you register a module path, and the module gets bundled into the renderer page script:
import { ServerRenderer } from '@videoflow/renderer-server';
const renderer = new ServerRenderer(videoJSON);
renderer.registerLayerType('custom', {
modulePath: '/absolute/path/to/custom-layer-type.js',
exportName: 'default', // optional, defaults to 'default'
});
try {
await renderer.renderVideo({ outputType: 'file', output: './out.mp4' });
} finally {
await renderer.cleanup();
}modulePath must be an absolute local filesystem path (relative paths throw). The module must be browser-compatible — it is bundled with esbuild for the page, not executed in Node — and must export a descriptor:
// /absolute/path/to/custom-layer-type.js
import { RuntimeVisualLayer } from '@videoflow/renderer-browser';
class RuntimeCustomLayer extends RuntimeVisualLayer {
async generateElement() { /* … */ }
}
export default {
runtime: RuntimeCustomLayer,
propertiesDefinition: CustomLayer.propertiesDefinition,
};The descriptors are registered on the in-page BrowserRenderer after construction and before its first frame, so external types work at any group nesting depth — exactly as they do in the browser.
Lifecycle. Register after construction and before renderVideo() / renderFrame() / renderAudio() — those open the headless page and build the bundle. Registering afterwards throws. A duplicate registration replaces the earlier one for that type.
Bundle caching. The renderer-page bundle is cached across renders and keyed on the registered type names, absolute module paths, export names and each module's mtime + size — so two ServerRenderer instances with different registrations never reuse each other's bundle, and editing an external module during development invalidates the cache.
Static shorthand
ServerRenderer.render() accepts a serializable layerTypes array; internally it constructs an instance and calls registerLayerType() for each entry:
await VideoRenderer.render(videoJSON, {
outputType: 'file',
output: './out.mp4',
layerTypes: [
{ type: 'custom', modulePath: '/absolute/path/to/custom-layer-type.js' },
],
});Example: video-generation API
import express from 'express';
import VideoFlow from '@videoflow/core';
const app = express();
app.use(express.json());
app.post('/api/generate-video', async (req, res, next) => {
try {
const { title, subtitle } = req.body;
const $ = new VideoFlow({ width: 1920, height: 1080, fps: 30 });
const t = $.addText({ text: title, fontSize: 6, color: '#fff' });
t.fadeIn('1s'); $.wait('1.5s');
const s = $.addText({ text: subtitle, fontSize: 3, color: '#94a3b8', position: [0.5, 0.6] });
s.fadeIn('500ms'); $.wait('3s');
$.parallel([() => t.fadeOut('500ms'), () => s.fadeOut('500ms')]);
const buffer = await $.renderVideo(); // outputType defaults to 'buffer'
res.set('Content-Type', 'video/mp4').send(buffer);
} catch (err) {
next(err);
}
});
app.listen(3000);Example: batch generation with progress
import VideoFlow from '@videoflow/core';
const items = [
{ text: 'Slide 1', color: '#ef4444' },
{ text: 'Slide 2', color: '#10b981' },
{ text: 'Slide 3', color: '#3b82f6' },
];
for (const [i, item] of items.entries()) {
const $ = new VideoFlow({ width: 1920, height: 1080, fps: 30 });
const t = $.addText({ text: item.text, fontSize: 6, color: item.color });
t.fadeIn('500ms'); $.wait('2s'); t.fadeOut('500ms');
await $.renderVideo({
outputType: 'file',
output: `./output/slide-${i + 1}.mp4`,
onProgress: (p) => process.stdout.write(`\rslide ${i + 1}: ${(p * 100).toFixed(0)}%`),
});
console.log(` ✓ slide ${i + 1}`);
}Example: cancelling a render
import VideoFlow from '@videoflow/core';
const $ = new VideoFlow({ width: 1280, height: 720, fps: 30 });
$.addVideo({}, { source: './long-clip.mp4' }, { waitFor: 'finish' });
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000); // cancel after 5s
try {
await $.renderVideo({
outputType: 'file',
output: './out.mp4',
signal: controller.signal,
});
} catch (err) {
if (err.name === 'AbortError') console.log('cancelled');
else throw err;
}Notes
renderVideo()cleans up after itself. The staticrender(...)API (and$.renderVideo(...)underneath) tears down the Chromium page on completion or abort. Long-running services should use the instance API +cleanup()to share one browser across requests instead of spawning one per call.- Asset URLs. When the project references HTTP(S) URLs, the headless browser fetches them itself, so anything reachable from the server works — including blob URLs you create from in-memory buffers via Playwright's
routeAPI. - Fonts. Google Font names referenced via
fontFamilyare auto-resolved through a bundled registry — no setup needed.
Related packages
@videoflow/core— Define and compose videos programmatically@videoflow/renderer-browser— Render to MP4 in the browser@videoflow/renderer-dom— Live preview / scrubbable playback in the browser
