✳ OMNIVIEWER MP4 → GIF MP4 viewer ← back

How we built a fast, private GIF exporter

Every online video-to-GIF converter asks you to upload your footage to a stranger's server. OmniViewer's GIF tab doesn't: it runs a real FFmpeg build, compiled to WebAssembly, inside a Web Worker in your own tab. This is how it works — the two-pass colour recipe that stops GIFs looking muddy, the sandbox that lets a 31 MB wasm blob run without loosening the site's Content Security Policy, the seek trick that makes trimming a 40-minute movie as cheap as trimming a 4-second one, and the two memory ceilings that quietly decide whether your export finishes.

On this page The upload you shouldn't have to make Why GIFs look bad The two-pass recipe Running FFmpeg without weakening the CSP Where the speed comes from The two ceilings Five controls, and what each costs

The upload you shouldn't have to make

Converting a video to a GIF is a pure function. Bytes in, bytes out, no state, no lookup, nothing that needs to know anything about the world. There is no technical reason it should require a round trip to a data centre — and yet the standard flow is: upload the file, wait in a queue, download a result, and hope the copy on their disk really is deleted "within an hour".

That is not a small thing to hand over. The clip is often the most personal file people convert all year: a few seconds of a child, a pet, a private joke from a group chat, a screen recording of an internal tool. It carries an audio track you may have forgotten about, and — as the METADATA tab will happily show you — a ©xyz atom with the GPS coordinates of where it was shot.

The design constraint. The video must never leave the tab. Not to a server we run, not to a CDN, not to an analytics beacon. That rules out the easy implementation and forces the whole encoder into the browser — which turns out to be entirely possible, and the rest of this article is what it costs.

It also removes the limits that come with the server model. There is no queue, no daily conversion cap, no watermark on the free tier, and no account. The only ceiling left is your own machine's memory, and we will get to exactly where that sits.

Why GIFs look bad

GIF is a 1987 format, and its defining limitation is that each frame may reference at most 256 colours, indexed into a colour table stored in the file. Modern video is 8-bit-per-channel RGB — 16.7 million possible colours. Every conversion is therefore a brutal quantisation, and essentially all of the perceived quality of a GIF comes down to which 256 colours you pick.

The lazy answer is to not pick at all. Ask a naive pipeline for a GIF and it falls back to a fixed, generic palette — a uniform cut through RGB space, the descendant of the old "web-safe" 216 colours. That palette is optimised for nothing. A clip of a green bamboo grove gets the same handful of greens as a clip of a sunset, most of its table wasted on colours that never appear in the footage. The result is the banding everyone recognises: skies in visible steps, skin tones going blotchy, gradients turning into contour maps.

The fix is to measure the clip first and build a palette out of the colours it actually contains. FFmpeg has had a filter pair for exactly this for a decade, and it is the single biggest quality decision in the whole exporter.

The two-pass recipe

palettegen consumes a stream of frames and emits a single 16×16 image: the 256 colours best representing everything it saw. paletteuse takes two inputs — the frames and that palette — and maps one onto the other. The subtlety is that the palette isn't known until the last frame has been seen, so a one-pass file-to-file version would need to decode the video twice.

Instead the filtergraph splits the decoded stream in two and feeds both filters from one decode:

scale=480:-2:flags=lanczos,fps=15,split[a][b];
[a]palettegen=max_colors=128:stats_mode=diff[p];
[b][p]paletteuse=dither=sierra2_4a:diff_mode=rectangle

Read left to right, that is: resize to 480 px wide with a Lanczos kernel, resample to 15 fps, then fork. Branch [a] builds the palette; branch [b] waits for it and then does the mapping. Two consumers, one decode.

Three details in there are worth their keep:

Dithering is the last knob and the one with the sharpest trade-off. Error diffusion (sierra2_4a) scatters quantisation error into neighbouring pixels and makes gradients look genuinely smooth — at the cost of destroying the runs of identical pixels that LZW compresses, so files get substantially bigger. Ordered bayer dithering leaves a faint regular cross-hatch but compresses well. none is flattest and smallest. The tab offers all three because the right answer depends entirely on the footage.

Running FFmpeg without weakening the CSP

OmniViewer ships a deliberately tight Content Security Policy. The interesting parts:

default-src 'none';  script-src 'self' …;  worker-src 'self';
object-src 'none';   base-uri 'none';     form-action 'none';
require-trusted-types-for 'script'

No inline scripts beyond two pinned hashes, no eval, no third-party script origins, nowhere to submit a form to, and Trusted Types enforced so no string can reach an HTML sink unsanitised. That policy is a large part of why dropping a hostile file into the page is uninteresting: even if a parser could be tricked, there is nowhere for the payload to go.

An FFmpeg-in-the-browser wrapper fights that policy hard. The usual @ffmpeg/ffmpeg package spins up its own worker from a blob: URL and loads the core from another blob: URL. Both are refused under worker-src 'self', and the standard advice is to add blob: to the policy — which is precisely the loosening we are not willing to do, since blob: script sources are a well-worn XSS escalation path.

So we skipped the wrapper. mp4-gif-worker.js is our own module worker, served from our own origin, and it drives the Emscripten core module directly: createFFmpegCore({wasmBinary}), then FS.writeFile, exec(...args), FS.readFile. No blob URLs anywhere, and not one character of the site's CSP had to change to make video encoding work.

The isolation this buys is real, and it runs in three layers. The wasm module can only touch memory the host explicitly gives it — a 32-bit linear address space with no pointer into the page. The worker it runs in has no DOM, no cookies and no access to the main thread's variables; it communicates through structured-cloned messages only. And the whole thing is fetched lazily on an explicit click, so a visitor who never opens the GIF tab never downloads, parses or executes a byte of it. The vendored core is pinned to a specific version in the repository rather than pulled from a CDN at runtime, so what ships is what was reviewed.

One practical wrinkle: a dedicated worker served without its own CSP header does not inherit the page's policy, which is what lets the Emscripten glue do the things Emscripten glue does. That is a deliberate, bounded exception — the worker is a same-origin file we wrote, loading a wasm binary we vendored, with no network access it needs and no DOM to attack.

Where the speed comes from

The naive way to trim a clip is to decode from the beginning and throw frames away until you reach the start point. On a 40-minute movie that means decoding 40 minutes of video to produce three seconds of GIF. Putting -ss before -i instead makes FFmpeg seek in the container's index first and start decoding from the nearest keyframe:

-ss 184.500  -t 3.000  -i input.mp4  -an -sn -dn  -filter_complex …

Now the cost of the export depends on the length of the clip, not the length of the movie. -t bounds the other end, so no frame outside the selection is ever decoded, and -an -sn -dn drop the audio, subtitle and data streams before they cost anything — a GIF has no soundtrack.

The other saving is the one already described: split means the clip is decoded once and consumed twice, rather than run through the file twice as the classic two-command version of this recipe does. And because the input is written into the wasm module's in-memory filesystem rather than a real one, there is no disk I/O in the loop at all.

We also add -map_metadata -1. A GIF has nowhere sensible to put a camera model or a GPS coordinate, and copying source metadata into an output you are about to share is how location data leaks. It is dropped explicitly rather than left to the muxer's discretion.

The two ceilings

Running an encoder in a browser tab means being honest about memory, and the honest answer has two parts. Both are checked before the encode starts, because failing at the finish line after a long decode is the worst possible outcome.

The source file. FFmpeg needs a filesystem, and in wasm that filesystem (MEMFS) lives inside the module's single linear memory — a 32-bit address space browsers can't grow much past 2 GB contiguously. The whole source video has to be written into it. The GIF tab therefore caps the input at about 512 MB and says so up front, suggesting you trim the video first. Every other tab in the toolkit still opens a file of any size: the box walk reads 16 bytes per box and skips mdat entirely, so a 20 GB movie is still fine to inspect, play and fast-start.

The decoded frames. This is the ceiling that actually bites, and it is not obvious. Because palettegen can only emit its palette at end-of-stream, the [b] branch of the split has to buffer every frame of the clip while it waits. That is:

frames × width × height × 4 bytes

 45 frames @ 480×270  →   23 MB   fine
750 frames @ 640×360  →  691 MB   won't finish

So the number that decides whether an export survives is rarely the video's file size — it is duration × frame rate × output area. The tab computes it on every change to any control and disables the button with a specific suggestion ("shorten it, drop the frame rate, or pick a smaller width") rather than letting you find out three minutes into an encode. The same line shows the frame count, the exact output dimensions and a rough size estimate, so the trade-off is visible before you commit rather than after.

Five controls, and what each costs

We looked at what the popular converters actually expose — ezgif, CloudConvert, FreeConvert, Cloudinary — and the same five appear on all of them. That convergence is worth respecting: people arrive already knowing what to reach for. What we added is a statement of what each one costs.

ControlWhat it changesWhat it costs
TrimStart and duration, set on the scrubber or typed exactlyLinear in everything. The single most effective size control there is.
Frame rate5–25 fpsLinear in size and memory. 10–15 fps is the sweet spot for most footage; above 20 the returns fall off sharply.
Width240 / 320 / 480 / 640 px, or the originalQuadratic — halving the width quarters the pixel count. The strongest single lever after trim.
ColoursPalette size, 32–256Sub-linear. Fewer colours means fewer bits per pixel and better LZW runs, but banding returns below about 64 on gradient-heavy footage.
LoopForever, once, or a fixed countFree — two bytes in a Netscape application extension block.

That last one has a trap worth knowing about. The loop count written into a GIF is the number of additional passes after the first, so "play 3 times" is stored as 2, and FFmpeg's own -loop flag has yet another convention where -1 means "don't loop" and 0 means "forever". The tab speaks in what you mean and translates.

Try it on a real file

Open the GIF tab → Drop any MP4, M4V or MOV, scrub to the moment, and convert. No upload, no watermark, no account. Try the sample → A 6-second clip of Kyoto's Arashiyama bamboo grove — dense greens, exactly the footage a generic palette ruins. Inside an MP4 → The companion piece: boxes, the moov index, and the fast-start byte surgery that works on a 20 GB file.