TL;DR
- A tiny server finds the MP4 behind a tweet; the browser downloads it straight from X.
- ffmpeg.wasm turns it into a GIF on the visitor's device, so nothing is uploaded.
- The tricks that mattered: load ffmpeg only on click, build a custom 256-color palette, and watch memory on phones.
I often want to turn a short clip from X (Twitter) into a GIF — for a reply, a doc, or a chat. Most online converters make you sign up or upload the video to their server, then hand back a blurry file.
So I built one that does the whole job inside the browser tab. Here is how it works, and the things that tripped me up, in case you want to do something similar.
The plan in one sentence
Find the video file behind the tweet, download it straight into the browser, and let ffmpeg (compiled to WebAssembly) turn it into a GIF on the visitor's own device.
That gives three nice properties:
- Nothing is uploaded. The video goes from X to the visitor's tab, not to my server.
- The server stays tiny. It only answers one question: "which video file belongs to this tweet?"
- No account needed. There is nothing to store, so there is nothing to log in to.
Step 1: Find the video file behind a tweet
A tweet link like x.com/user/status/123… doesn't point at a video file. I run a small Cloudflare Worker that takes the tweet ID and returns the list of MP4 files X has for it (X keeps several sizes of each video).
The browser then downloads the MP4 directly from video.twimg.com. That works because X's video server allows other websites to fetch its files (it sends CORS headers), so the video never passes through my server.
One easy win: download the smallest version that is still wide enough for the GIF. If the GIF will be 480px wide, there is no point fetching the 1280px file — it's much bigger and only makes people wait.
// Smallest video version that is at least as wide as the GIF function pickVariant(variants, gifWidth) { const byWidth = [...variants].sort((a, b) => a.width - b.width); return byWidth.find((v) => v.width >= gifWidth) ?? byWidth.at(-1); } Step 2: Load ffmpeg.wasm only when someone clicks "Convert"
The ffmpeg engine is big — about 32 MB before compression. Loading it with the page would make the site slow for everyone, including people who only came to read.
So the engine loads only after the first click on "Convert", with a progress bar while it downloads. After that it stays in memory, so the second GIF starts instantly.
Two gotchas here:
- ffmpeg runs in a background thread (a Web Worker), and that thread refuses to load code from another domain. The fix: download the core files from the CDN yourself, turn them into local
blob:URLs, and hand those to ffmpeg. I also keep a second CDN as a backup in case the first one is blocked. - The faster multi-threaded build needs two extra security headers on your page (COOP and COEP). On my site those headers broke the Cloudflare Turnstile anti-bot check, so I use the single-thread build. It's slower but works everywhere. If your page has no third-party embeds, try multi-thread first and fall back when it fails.
import { FFmpeg } from '@ffmpeg/ffmpeg'; import { toBlobURL } from '@ffmpeg/util'; const base = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm'; const ffmpeg = new FFmpeg(); await ffmpeg.load({ coreURL: await toBlobURL(`${base}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await toBlobURL(`${base}/ffmpeg-core.wasm`, 'application/wasm'), }); Step 3: The ffmpeg command that makes GIFs look good
A GIF can only use 256 colors. If you let ffmpeg pick a generic palette, the result looks grainy. The well-known fix is to build a palette from the clip itself, then use it — and you can do both in one command:
-ss 3 -t 5 -i in.mp4 -filter_complex "fps=15,scale=480:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" -loop 0 out.gif What each part does, in plain words:
-
-ss 3 -t 5before-i: jump straight to second 3 and read only 5 seconds, instead of decoding the whole video. -
fps=15thenscale=480:-1: drop frames first, then resize — so we don't waste time resizing frames we throw away.-1keeps the aspect ratio, andflags=lanczosis a sharper way to shrink images. -
split→palettegen→paletteuse: one copy of the frames is used to pick the best 256 colors, the other copy is drawn with them. -
dither=bayer: a fixed dot pattern instead of the default "noisy" dithering. The noise changes every frame, which makes GIFs much bigger. Bayer stays still, so files shrink a lot. -
diff_mode=rectangle: only redraw the part of each frame that changed. -
-loop 0: loop forever, like people expect from a GIF.
Step 4: Don't crash the tab
Everything runs in the visitor's memory, and phones don't have much. Three things helped:
- Limit the source size. ffmpeg.wasm keeps the whole video in memory, so I stop the download once a file passes 450 MB. Above that, phone tabs started crashing.
- Switch to two passes for long or large clips. The one-command version keeps every frame in memory until the palette is ready. When the raw frames would pass ~256 MB, I first save the palette to a PNG, then run a second pass that uses it. Slower, but memory stays flat.
- Give ffmpeg a copy of the file.
ffmpeg.writeFile()moves the buffer into the worker, and your original becomes empty. If the user tweaks the settings and converts again, you need the original — so passdata.slice().
Step 5: Tell people what's happening
A blank spinner for 20 seconds feels broken. Two small touches made it feel fast:
- Real progress. ffmpeg prints lines like
time=00:00:02.40while it works. Divide that by the clip length and you have a progress bar. - A size estimate before converting. I converted a bunch of real tweet videos and found a 5-second, 480px, 15 fps clip usually lands between 1.3 MB (still shots) and 5.2 MB (shaky phone video), about 2.6 MB in the middle. From that I show a live "about X MB" as people drag the sliders, so nobody waits for a 40 MB file by accident.
What I'd tell myself before starting
- Start with the ffmpeg command on your laptop. Get the GIF looking right in the terminal first; moving it to the browser is the easy part.
- Load the engine lazily. 30+ MB is too much for a page load.
- Plan for memory, not just speed. Crashes on phones were a bigger problem than slow conversions.
- Check which file you download. Picking the right video size saved more time than any ffmpeg flag.
If you want to see it working, the tool is at twittergif.com — paste a post link, trim the clip, and the GIF is made in your tab.
Questions or a better ffmpeg filter? I'd love to hear it in the comments.