VideoFlowcodeGitHubStudioTry itCoreRenderersReact Video EditorPlaygroundExamplesDocscodeGitHubStudioTry it
Getting started
InstallationQuick startCore conceptsYour first video
Builder
Builder APITime formatsParallel & wait
Layers
TextImageVideoAudioCaptionsShapeGroups
Animation
Animate & keyframesEasing functionsTransitionsEffects
Renderers
Browser rendererServer rendererDOM preview
React Video Editor
QuickstartThemingUploadsCustom panelsHooks & commandsKeyboard shortcuts
API reference
Overview@videoflow/core@videoflow/renderer-browser@videoflow/renderer-server@videoflow/renderer-dom@videoflow/react-video-editor

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

OptionDefaultNotes
signalAbortSignal — abort() cancels cleanly.
onProgress(p: number) => void. p is the fraction of frames done (0..1).
workertrueSet false to run on the main thread (debugging only).
verbosefalseLog frame-level timing to the console.
videoQuality'veryHigh'`'medium'
videoBitrateExplicit 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:

RendererRegister afterRegister before
BrowserRendererconstructionfirst renderFrame / captureFrame / renderAudio / exportVideo
DomRendererconstructionfirst loadVideo
ServerRendererconstructionfirst 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.

VideoFlow

Open-source toolkit for composing videos from code.

Product

CoreRenderersReact Video EditorPlaygroundStudio

Learn

DocsAPI referenceExamplesvs. Remotionvs. FFmpeg

Project

GitHubLicenseContactTermsPrivacy

From the blog

All posts →Building a "Canva for Video" with the @videoflow/react-video-editorAutomating Social Media Captions: A Developer's Guide to Frame-Perfect SubtitlesBeyond Node: How to Render Cinematic Videos from Python and GoBuilding a Video Rendering API with Node.js and VideoFlow (No FFmpeg Required)Mastering Programmatic Video Transitions: A Developer's GuideProgrammatic Video Storage: Why Your MP4s Should Live as Version-Controlled JSONThe Video Markdown Pattern: Turning Static Content into Automated MP4sZero-Cost Video Rendering: How to Export MP4s Directly in the Browser
© 2026 VideoFlow. Apache-2.0 core.