@videoflow/renderer-browser
v1.2.1
Published
Browser-side video renderer for VideoFlow
Maintainers
Readme
@videoflow/renderer-browser
Render VideoFlow videos to MP4 entirely in the browser — no server, no upload, no backend. Built on WebCodecs, MediaBunny, and a per-layer rasterization pipeline that pushes encoding to a Web Worker so the page stays responsive.
Live demo: videoflow.dev/playground · Renderers docs: videoflow.dev/renderers
Why use this package?
- Real MP4 files generated client-side — H.264 video + AAC (or Opus) audio, muxed by MediaBunny.
- Zero server cost. The user's device does the work; you keep their footage on-device.
- Worker-based encoding. Frame rasterization stays on the main thread (SVG
<foreignObject>decode requires DOM), but encoding/muxing run in a dedicated Worker — UI stays smooth. - Tier-based per-layer rasterization. Static / simple-transform layers paint with a direct
drawImagefast path; complex layers go through a cached SVG rasterizer. - WebGL effect compositor. Layers with
effectsare piped through a ping-pong shader pipeline. - Audio sub-mixes for groups. A
$.group(...)whose children produce audio is rendered as its own buffer first, then placed on the parent timeline — group-levelvolume/pan/pitch/mute/ fade transitions apply to the sub-mix as a whole.
This is the export renderer. For interactive playback / scrubbing in the same browser, pair it with @videoflow/renderer-dom — they share the same transition + effect registries, so live preview and exported MP4 always agree.
Installation
npm install @videoflow/core @videoflow/renderer-browserQuick Start
import VideoFlow from '@videoflow/core';
import VideoRenderer from '@videoflow/renderer-browser';
// 1. Define a video
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');
// 2. Compile to JSON, render to an MP4 Blob
const json = await $.compile();
const blob = await VideoRenderer.render(json);
// 3. Download
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'hello.mp4';
a.click();
URL.revokeObjectURL(a.href);You can also bypass compile() and let VideoFlow auto-detect the environment:
const blob = await $.renderVideo(); // → Blob in the browserAPI
VideoRenderer.render(videoJSON, options?)
Static one-shot — creates a renderer, exports an MP4, and tears everything down.
const blob = await VideoRenderer.render(videoJSON, {
signal: controller.signal, // AbortSignal — cancel mid-encode
onProgress: (p) => console.log((p * 100).toFixed(1) + '%'),
worker: true, // default; pass false to encode on the main thread
});Options
| Option | Type | Description |
| --- | --- | --- |
| signal | AbortSignal | Cancel the encode mid-flight |
| onProgress | (p: number) => void | Called with 0..1 during encode |
| worker | boolean | Encode in a dedicated Worker (default true). Set false for environments without Worker support |
Returns: Blob (video/mp4).
VideoRenderer.renderFrame(videoJSON, frame)
const canvas = await VideoRenderer.renderFrame(videoJSON, 30); // OffscreenCanvasVideoRenderer.renderAudio(videoJSON)
const audioBuffer = await VideoRenderer.renderAudio(videoJSON); // AudioBuffer | nullReturns null when the project has no audio layers.
Instance API
For long-lived previews (re-rendering many frames, sharing one rasterizer cache, …) use the constructor + captureFrame() / exportVideo():
import BrowserRenderer from '@videoflow/renderer-browser';
const renderer = new BrowserRenderer(videoJSON);
try {
for (let f = 0; f < total; f++) {
const offscreen = await renderer.captureFrame(f);
// …consume the OffscreenCanvas…
}
} finally {
renderer.destroy();
}How it works
- Layer mounting. Each layer becomes a DOM element in an off-screen
[data-renderer]container. CSS handles transforms, filters, blend modes, fonts, andmix-blend-modeblending. - Per-frame property pass. Every layer's interpolated properties at the current frame are written as inline CSS / custom properties.
- Tier-based rasterization (
LayerRasterizer):- Tier 1 — simple transform + no filters/borders/shadows → straight
drawImagefrom the layer's source bitmap onto the destination canvas. - Tier 3 — anything else (rotation, 3D, filters, text, shapes, effects-bearing layers) → rasterized through an SVG
<foreignObject>, cached per layer until the resolved props change.
- Tier 1 — simple transform + no filters/borders/shadows → straight
- Effect pipeline. Layers with
effectsare piped through aWebGLEffectCompositor(ping-pong FBOs) before composite. - Composite onto the final canvas. Layers paint in sorted track order with their
blendModeapplied viaglobalCompositeOperation. Groups composite their children onto a private project-sized surface first, then drop that surface onto the parent. - Audio mix. An
OfflineAudioContextmixes every audio-bearing layer (recursing through groups).volume/pankeyframes drive AudioParam automation;pitchis decoupled fromspeedvia an offline granular pitch shifter;muteshort-circuits the source. - Encode + mux. Frames and audio are fed into a Worker-resident MediaBunny pipeline (WebCodecs
VideoEncoder/AudioEncoder), which produces the final MP4 buffer.
Transitions
Built-in transition presets (the same library used by @videoflow/renderer-dom) auto-register on import. See the core README → Transitions for the full categorised table and the signed-p contract.
Custom transitions
BrowserRenderer.registerTransition() writes to a registry shared with DomRenderer — register once, works in both export and live preview.
import BrowserRenderer from '@videoflow/renderer-browser';
BrowserRenderer.registerTransition('spinIn', (p, properties, params, ctx) => {
const t = 1 - Math.abs(p); // 0 at edges, 1 at rest
properties.rotation = (properties.rotation ?? 0) + (1 - t) * (params.angle ?? 360);
properties.opacity = (properties.opacity ?? 1) * t;
return properties;
}, {
defaultEasing: 'easeOut',
layerCategory: 'visual', // 'all' | 'visual' | 'audio' | 'textual'
});The function receives:
p— signed progress in[-1, +1], already eased per the layer'seasing.-1is the start oftransitionIn,0is rest,+1is the end oftransitionOut.properties— the layer's resolved properties at this frame. Mutate in place or return a new object.params— values from the layer'stransitionIn.params/transitionOut.params.ctx—{ seed, frame, fps, projectWidth, projectHeight }for deterministic per-layer randomness and aspect-aware geometry.
Set injectsEffects: true if your preset pushes synthetic effects onto properties.__effects — the renderer keeps the effect overlay mounted across the layer's lifetime so the WebGL pipeline always engages.
GLSL effects
Built-in effects (chromaticAberration, pixelate, vignette, rgbSplit, invert, bloom, colorCorrection, frostedGlass, lightSweep, gaussianBlur, motionBlur, noiseDissolve, …) are auto-registered on import. Reference them by name from a layer's effects property; animate any param via a dot-path key.
Custom effects
import BrowserRenderer from '@videoflow/renderer-browser';
BrowserRenderer.registerEffect(
'glitchShift',
`
vec4 effect(sampler2D tex, vec2 uv, vec2 resolution) {
vec2 shifted = uv + vec2(u_amount * sin(uv.y * 40.0), 0.0);
return texture2D(tex, shifted);
}`,
{
amount: { type: 'float', default: 0.02, min: 0, max: 0.1, animatable: true },
},
);The GLSL snippet defines a single vec4 effect(sampler2D tex, vec2 uv, vec2 resolution). Each declared param becomes a u_<name> uniform. The compositor wraps the snippet with the precision/uniform/varying boilerplate at registration time.
Param types: 'float', 'int', 'bool', 'vec2', 'vec3', 'vec4', 'color' (CSS colour string → vec4).
End-to-end example: an export button
<button id="exportBtn">Export Video</button>
<progress id="prog" max="1" value="0"></progress>
<script type="module">
import VideoFlow from '@videoflow/core';
import VideoRenderer from '@videoflow/renderer-browser';
document.getElementById('exportBtn').addEventListener('click', async () => {
const $ = new VideoFlow({ width: 1280, height: 720, fps: 30 });
$.addImage(
{
fit: 'cover',
effects: [
{ effect: 'vignette', params: { strength: 0.6 } },
{ effect: 'chromaticAberration', params: { amount: 0.003 } },
],
},
{ source: './photo.jpg', sourceDuration: '5s' },
);
const title = $.addText(
{ text: 'Made with VideoFlow', fontSize: 5, fontWeight: 800 },
{
startTime: '0.5s',
sourceDuration: '4s',
transitionIn: { transition: 'slideUp', duration: '600ms' },
transitionOut: { transition: 'fade', duration: '500ms' },
},
);
$.wait('5s');
const json = await $.compile();
const blob = await VideoRenderer.render(json, {
onProgress: (p) => { document.getElementById('prog').value = p; },
});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'export.mp4';
a.click();
URL.revokeObjectURL(a.href);
});
</script>Notes & requirements
- WebCodecs. Required for video encoding. Available in Chrome / Edge / recent Firefox / Safari 17+. The library probes for AAC support and falls back to Opus when AAC isn't available (notably on Linux Chrome). If neither is available the audio track is dropped and a warning is emitted — the video still encodes.
- Cross-origin sources. Video / image / audio sources must be CORS-readable for
decode()/decodeAudioData()to succeed. Same-origin or blob-URL sources always work. - Bundlers. The encoder Worker is bundled inline as a Blob URL, so no special bundler config is needed (esbuild / Vite / webpack all just work).
Related packages
@videoflow/core— Define and compose videos programmatically@videoflow/renderer-dom— Live preview / scrubbable playback in the browser@videoflow/renderer-server— Render to MP4 on Node.js
