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:
- Load the recording into a hidden
<video>element. - Seek to a timestamp, draw the frame onto a
<canvas>. - Read the pixels, reduce them to a 256-color palette.
- Append the frame to a GIF with gifenc, a small MIT-licensed encoder with no dependencies.
- 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
durationon aMediaRecorderWebM. Force it, and keep a fallback measured during recording. - Put timeouts on media events.
seekedandtimeupdateare 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
Behind the scenes, once a month
What we shipped, what worked and what flopped across every product we run. One email a month, nothing else.