DEV Community

Said MOUNAIM
Said MOUNAIM

Posted on

I built a private video-to-GIF converter that runs entirely in the browser

Most online GIF makers upload your video to a server, add a watermark, and bury the download button under ads. I wanted the opposite, so I built Snipgif: a free, open-source video-to-GIF converter where your file never leaves your device.

What it does

  • Convert MP4, MOV, WebM, or AVI to GIF
  • Trim on a timeline (up to 30 seconds of GIF)
  • Presets (Small for chat, Balanced, High quality) or custom FPS, width, and quality
  • Crop to 1:1, 16:9, or 9:16, change speed, reverse, or boomerang
  • Meme-style captions
  • A standalone GIF compressor (fewer colors, skipped frames, smaller width)
  • Installable as an app, and it works offline

How it works

The whole conversion runs in the browser with ffmpeg.wasm. The site itself is a static Astro build. Only the converter and compressor are React islands, so the other pages ship almost no JavaScript.

A few decisions that mattered:

1. Load the 30 MB engine only when needed. The ffmpeg core is big. It isn't part of the page load. It downloads from jsDelivr the moment you pick a file, so by the time you hit Convert it's usually ready.

2. Single-threaded core on purpose. The multi-threaded ffmpeg.wasm core needs SharedArrayBuffer, which means COOP/COEP headers. Those headers block third-party resources that don't opt in. The single-threaded core is slower, but it keeps hosting simple.

3. Offline with a hand-written service worker. At build time, a small Astro integration writes the full list of built files into sw.js, so every page is cached on install. The ffmpeg core is cached the first time you use it.

One bug worth sharing: my first version did await cache.put(...) before returning the response. If the browser refused to store the 30 MB file (a phone low on storage, for example), a successful download turned into an error. The fix was to make caching best-effort: return the response right away and do the cache write in event.waitUntil(...), swallowing errors. A cache should make things faster, never break them.

4. Privacy without a cookie banner. Analytics are optional (GA4). Google Consent Mode defaults to denied for visitors in the EEA, the UK, and Switzerland, so they get no analytics cookies and no banner is needed.

Stack

Astro, React, TypeScript, Tailwind CSS, shadcn/ui, ffmpeg.wasm, deployed on Vercel.

Try it or contribute

Issues and pull requests are welcome. I'd love feedback, especially on performance with large videos on mobile.

Top comments (3)

Collapse
 
ivannovazzi profile image
Ivan Annovazzi •

That cache.put bug is nasty, a quota refusal on some random phone is the kind of thing you'd chase for hours. Curious how you fixed it, skip the cache when the write fails and eat the 30MB download again next run?

Collapse
 
demovexa profile image
DemoVexa •

On large files on mobile, the first thing I'd check is how the video gets into ffmpeg.wasm. Writing it with writeFile copies the whole file into wasm memory, so a long phone recording can use up the tab before decoding even starts. Version 0.12 can mount the File through WORKERFS and read it in place, which helps a lot on low memory phones. I'd also put -ss and -t before the input so only the trimmed range gets decoded, then scale down before palettegen rather than after. Loading the 30 MB core only when someone picks a file is a nice touch. And making the cache write best effort was the right fix, because a failed cache shouldn't turn a good download into an error.

Collapse
 
komopdf profile image
KomoPDF •

Handling the single-threaded build to bypass COOP/COEP is a smart trade-off for static hosting. SharedArrayBuffer requirements create endless headaches with third-party CDNs and embedded previews.

Two browser memory quirks we ran into with client-side media pipelines might be relevant as you test larger files on mobile:

  1. Emscripten heap fragmentation: If users convert a second or third video in the same tab, dynamic WASM heap growth rarely shrinks back down. Explicitly unlinking intermediate files in the virtual filesystem and reusing a fixed canvas buffer keeps peak RAM from triggering mobile Safari's aggressive tab reload limit.
  2. Canvas frame extraction: Drawing video frames directly into an OffscreenCanvas worker rather than passing raw blobs back to the main thread avoids double-buffering the frame array in DOM memory.

The event.waitUntil fallback for the 30 MB core cache write is a great catch. Service workers silently rejecting full quotas on budget Android phones is a notorious edge case that rarely shows up in desktop dev tools.