Rendering in the browser
@videoflow/renderer-browser encodes frames with WebCodecs and muxes into MP4 inside a Web Worker. It runs in any recent Chrome, Edge, Firefox, or Safari and returns a Blob you can download, upload, or play back.
Minimal example
import BrowserRenderer from '@videoflow/renderer-browser';
import VideoFlow from '@videoflow/core';
const $ = new VideoFlow({ width: 1920, height: 1080, fps: 30 });
$.addText({ text: 'Exported from the browser' }).fadeIn('500ms');
$.wait('2s');
const json = await $.compile();
const blob = await BrowserRenderer.render(json);
// Download
const url = URL.createObjectURL(blob);
Object.assign(document.createElement('a'), { href: url, download: 'out.mp4' }).click();Options
| Option | Default | Notes |
|---|---|---|
signal | — | AbortSignal — abort() cancels cleanly. |
onProgress | — | (p: number) => void. p is the fraction of frames done (0..1). |
worker | true | Set false to run on the main thread (debugging only). |
verbose | false | Log frame-level timing to the console. |
videoQuality | 'veryHigh' | `'medium' |
videoBitrate | — | Explicit bits per second (e.g. 16_000_000). Overrides videoQuality. |
Video is encoded as H.264 (AVC). Audio prefers AAC and falls back to Opus where the platform ships no AAC encoder — notably Linux, including headless Chrome.
Progress UI
const ctrl = new AbortController();
const blob = await BrowserRenderer.render(json, {
signal: ctrl.signal,
onProgress: (p) => {
// p is 0..1 (fraction of frames done)
progressBar.style.width = (p * 100).toFixed(1) + '%';
},
});
// Cancel from a UI button:
cancelBtn.addEventListener('click', () => ctrl.abort());Memory. A 1080p60 30-second render at AVC ~8 Mbps fits comfortably. For longer clips or 4K, keep an eye on the Worker's memory — prefer chunked muxing (the renderer does this by default) over holding all encoded frames in RAM.
One-shot helper
If you're already in a VideoFlow instance and don't need to hold the JSON, call $.renderVideo() — it compiles and picks a renderer for you.
const blob = await $.renderVideo({ onProgress: (p) => console.log(`${Math.round(p * 100)}%`) });Custom layer types
Every renderer instance owns its own layer-type registry, seeded with the built-in types (addText, addImage, addVideo, addAudio, addCaptions, addShape, group). You can add your own without forking the package — register a runtime class plus its property definitions:
import BrowserRenderer, { RuntimeVisualLayer } from '@videoflow/renderer-browser';
class RuntimeCustomLayer extends RuntimeVisualLayer {
async generateElement() {
if (this.$element) return this.$element;
const el = document.createElement('div');
el.setAttribute('data-element', 'custom');
el.setAttribute('data-id', this.json.id);
this.$element = el;
return el;
}
}
const renderer = new BrowserRenderer(videoJSON);
renderer.registerLayerType('custom', {
runtime: RuntimeCustomLayer,
propertiesDefinition: CustomLayer.propertiesDefinition,
});
const blob = await renderer.exportVideo();Any layer whose type is "custom" now renders through your class, at any depth — groups build their children through the same registry.
Lifecycle. Register after construction and before the first render:
| Renderer | Register after | Register before |
|---|---|---|
BrowserRenderer | construction | first renderFrame / captureFrame / renderAudio / exportVideo |
DomRenderer | construction | first loadVideo |
ServerRenderer | construction | first renderVideo / renderFrame / renderAudio |
Registering a type that already exists replaces it — including a built-in, which is how you override one for a single renderer. Registries belong to instances, so two renderers on the same page never interfere, and an unknown layer type throws an error naming the type and the renderer rather than silently rendering nothing.
Exported for building layer types: RuntimeBaseLayer, RuntimeVisualLayer, RuntimeTextualLayer, RuntimeMediaLayer, plus the LayerTypeDescriptor and RuntimeLayerConstructor types. @videoflow/renderer-dom re-exports them so an editor can build one without adding a dependency.
Video decoding
Video layers decode through WebCodecs, adapting to how they are read rather than seeking per frame. A currentTime seek re-decodes from the preceding keyframe, so stepping through a clip frame by frame costs O(frames × GOP) — 207 ms/frame on a 1080p clip with a stock 250-frame GOP, against 15.6 ms decoding the same frames sequentially. Forward playback decodes each packet once; reverse playback (speed below zero) decodes forward windows and serves them backwards.
There is nothing to configure. Sources whose codec has no WebCodecs decoder on the current platform fall back to a <video> element automatically.