This page is available in English only; the English version prevails.

All posts

Turning a MediaRecorder WebM into a GIF in the browser

By Tiger Liu · · 4 min read

ReelCap records a browser tab to video, and since September 1 it can also turn a recording into a GIF. The whole conversion runs on the preview page in the browser: no upload, no server, no WebAssembly build of ffmpeg. The approach is simple, but two details — a missing duration and a frozen tab — took longer than the encoder itself.

The approach

There is no native GIF encoder in the browser, so the pipeline is:

  1. Load the recording into a hidden <video> element.
  2. Seek to a timestamp, draw the frame onto a <canvas>.
  3. Read the pixels, reduce them to a 256-color palette.
  4. Append the frame to a GIF with gifenc, a small MIT-licensed encoder with no dependencies.
  5. Repeat for every sampled frame.

Problem one: video.duration is Infinity

Seeking to i / fps seconds requires knowing how long the video is. For WebM files produced by MediaRecorder, Chrome reports video.duration as Infinity. The recorder writes the file as a stream while recording and never goes back to fill in the duration in the header, so the browser has nothing to read.

The widely used workaround is to seek somewhere impossible. The browser clamps the playhead to the real end of the media and, in doing so, discovers and updates the duration:

async function ensureDuration(video: HTMLVideoElement): Promise<number> {
	if (Number.isFinite(video.duration) && video.duration > 0) return video.duration * 1000
	await new Promise<void>((resolve) => {
		const done = () => {
			video.removeEventListener("timeupdate", done)
			resolve()
		}
		video.addEventListener("timeupdate", done)
		video.currentTime = 1e101
		setTimeout(done, 3000) // some containers never fire timeupdate
	})
	video.currentTime = 0
	return Number.isFinite(video.duration) ? video.duration * 1000 : 0
}

Two safety nets matter here. The timeout keeps the export from hanging on a file where timeupdate never arrives, and the caller falls back to the duration measured while recording if the value is still not finite. The same timeout idea applies to every individual seek: near the end of a file, seeked sometimes does not fire, and repeating one frame is a much better outcome than a progress bar stuck at 97%.

Problem two: size, which decides the defaults

GIF has no inter-frame compression. File size grows linearly with width × height × number of frames, so a long, full-resolution recording produces a file nobody can attach anywhere. Instead of letting people discover that after a two-minute wait, the defaults cap it up front:

  • Width at most 720 px, scaled proportionally. Narrower recordings are never upscaled — that only adds bytes.
  • Frame rate 12 fps.
  • Length at most 30 seconds; anything longer is cut, and the interface says so.

These live in a pure function that takes the duration and video size and returns the output plan, which makes the trade-off testable without a browser.

Problem three: color and a frozen tab

Each frame is quantized to a 256-color palette. gifenc's default rgb444 format is fast but leaves visible noise on photographic content like screenshots of real web pages; rgb565 looked noticeably cleaner for a small cost in quantization time:

const { data } = ctx.getImageData(0, 0, plan.width, plan.height)
const palette = quantize(data, 256, { format: "rgb565" })
const index = applyPalette(data, palette, "rgb565")
gif.writeFrame(index, plan.width, plan.height, { palette, delay: plan.delayMs })

The canvas is created with { willReadFrequently: true }. Reading pixels back every frame is exactly what that flag is for; without it Chrome keeps the canvas on the GPU and each getImageData call takes a slow read-back path.

The last issue was responsiveness. A 30-second GIF at 12 fps is 360 frames of seek, draw, quantize and encode. Run in one uninterrupted loop, that blocks the main thread long enough for the preview page to go blank. Yielding to the event loop every four frames keeps the progress bar moving and the page usable:

if (i % 4 === 3) await new Promise((r) => setTimeout(r, 0))

A Web Worker would be the more thorough answer, but a <video> element is not available inside a worker, so the frame extraction would still have to happen on the main thread. For this workload, yielding was enough.

What I would tell someone building the same thing

  • Never trust duration on a MediaRecorder WebM. Force it, and keep a fallback measured during recording.
  • Put timeouts on media events. seeked and timeupdate are not guaranteed to fire.
  • Choose output limits before writing the encoder. For GIF, size is the product decision; quality settings come second.
  • Yield from long canvas loops, or the user will think the page crashed.

The GIF export is part of ReelCap, and you can follow later changes to it in the changelog.

Mentioned in this post

每月一封,幕后实录

旗下每个产品上线了什么、哪些做法有效、哪些翻了车。每月一封,仅此而已。

点一下确认链接即可订阅,之后每月一封,随时可退订。