Rendering MP4 video entirely in the browser

September 24, 2026 · 5 min read · by Nobody

The usual way to make a video on the web is to send everything to a server that runs a headless browser and ffmpeg. It works, but it means render queues, servers to keep busy and a bill that grows with every export.

Modern browsers can now encode video themselves. Here's how Vectorcut, the video tool I'm building, produces a full MP4 without any render server, and the parts that were harder than expected.

SceneSVG + tweens Frame at tpure function SVG to imagedrawn on canvas VideoEncoderH.264 Voice tracksone per scene MixWeb Audio, offline AudioEncoderAAC or Opus MP4 muxerfast start Download
The export pipeline. Video frames and the voice track are encoded separately, then packaged into one MP4.

Step 1: frames must be a pure function of time

A browser animation normally runs on a clock: CSS animations and requestAnimationFrame move things forward as real time passes. An exporter can't work that way. It must be able to ask for frame 1,237 and get exactly the same picture every time, at any speed.

So each scene is a static SVG plus a list of tweens: "fade #title from 0 to 1 between 200 ms and 600 ms". The picture at any moment is computed from the time alone:

export function tweenValueAt(tween: Tween, timeMs: number): number {
  if (timeMs <= tween.startMs) return tween.from;
  if (timeMs >= tween.endMs) return tween.to;
  const progress = (timeMs - tween.startMs) / (tween.endMs - tween.startMs);
  return tween.from + (tween.to - tween.from) * EASING[tween.easing](progress);
}

To draw a frame, the engine turns every element's state into a CSS rule and injects one <style> block into the SVG:

// renderFrame(svg, tweens, 400) adds, for example:
// <style>#title{opacity:0.5!important;transform:translate(0px,12px)!important}</style>

Because the same function powers the live preview and the export, what you preview is exactly what you export.

Step 2: SVG to pixels

An SVG string becomes an image through a data URL, and the image is drawn onto a canvas:

const image = new Image();
image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(frameSvg);
await image.decode();
context.drawImage(image, 0, 0, width, height);

Two browser rules shape everything here, and both turn out to be features:

  1. An SVG loaded as an image can't run scripts. That makes it safe to render SVG drawn by an AI model, as long as it's only ever shown as an image.
  2. An SVG loaded as an image can't load external files. Illustrations inside a scene must be embedded as data URLs before drawing, so the renderer swaps each stored image reference for its data first.

Step 3: encoding with WebCodecs

The WebCodecs API exposes the browser's own (often hardware-accelerated) video and audio encoders. Writing MP4 files also needs a muxer to package the encoded streams; I use the open-source library Mediabunny, which wraps both:

const output = new Output({ format: new Mp4OutputFormat({ fastStart: "in-memory" }), target: new BufferTarget() });
const video = new CanvasSource(canvas, { codec: "avc", quality: QUALITY_HIGH });
output.addVideoTrack(video, { frameRate: 30 });
await output.start();

for (let frame = 0; frame < frameCount; frame++) {
  await drawFrame(context, frameAt(frame / 30));
  await video.add(frame / 30, 1 / 30); // timestamp and duration in seconds
}
await output.finalize();

fastStart puts the MP4 index at the front of the file, so the result starts playing immediately when uploaded anywhere.

Not every browser can encode every codec, so the exporter asks before choosing: it tries H.264 first, then VP9, then AV1.

Step 4: the audio track

Each scene has its own voice clip. For export they need to become one continuous track, with each clip placed at its scene's start time. The Web Audio API's OfflineAudioContext does this faster than real time:

const offline = new OfflineAudioContext(2, totalSeconds * 48_000, 48_000);
for (const scene of voicedScenes) {
  const source = offline.createBufferSource();
  source.buffer = await decode(scene.audio);
  source.connect(offline.destination);
  source.start(scene.startSeconds);
}
const voiceTrack = await offline.startRendering();

AAC isn't available in every browser's encoder, so the exporter falls back to Opus when needed. Both play fine in modern players.

What it costs, and what it doesn't

Server rendering Browser rendering
Render servers Needed None
Queue wait Yes, under load No
Scales with users Costs grow Each user brings their own computer
Speed Consistent Depends on the user's device
Tamper-proof Yes No: the user controls the machine

The honest trade-offs:

For short, animated, vector-based videos, those trade-offs are easy to accept. No render farm, no queue, and preview and export can never disagree.

Earlier in this series: how a video editor actually works. Next: why easing makes animation feel alive.

Nobody