npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@videoflow/renderer-server

npm license

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 VideoJSON payload, 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 to ffmpeg for x264 + AAC encoding.
  • Same pixels as the browser. Both pipelines run inside a real Chromium, so transitions, GLSL effects, fonts, and mix-blend-mode blends look identical to what your users see in @videoflow/renderer-dom.
  • Cancellable + observable. Every render accepts an AbortSignal and an onProgress callback.

Installation

npm install @videoflow/core @videoflow/renderer-server
npx playwright install chromium

Requirements

  • Node.js 18+
  • Chromium (installed by npx playwright install chromium above)
  • 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 ffmpeg

Quick 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.js

Codec 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 static render(...) 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 route API.
  • Fonts. Google Font names referenced via fontFamily are auto-resolved through a bundled registry — no setup needed.

Related packages

Resources

License

Apache-2.0