MP4-to-GIF in JavaScript splits by environment. Browser use cases (in-page conversions, no upload) call for ffmpeg.wasm. Server use cases call for fluent-ffmpeg or the API. The actual conversion settings — palette generation, frame rate, scale — are identical across all three.
Method 1: ffmpeg.wasm (browser, no upload)
ffmpeg.wasm is a WebAssembly port of FFmpeg. It runs entirely in the browser — no server, no upload. The library is ~30MB so first load is slow, but conversions stay private.
npm install @ffmpeg/ffmpeg @ffmpeg/util
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
const ffmpeg = new FFmpeg();
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
});
async function mp4ToGif(file, { fps = 15, width = 480, start = 0, duration = 5 } = {}) {
await ffmpeg.writeFile("in.mp4", await fetchFile(file));
// Pass 1: palette
await ffmpeg.exec([
"-ss", String(start), "-t", String(duration),
"-i", "in.mp4",
"-vf", `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`,
"palette.png",
]);
// Pass 2: encode
await ffmpeg.exec([
"-ss", String(start), "-t", String(duration),
"-i", "in.mp4", "-i", "palette.png",
"-lavfi", `fps=${fps},scale=${width}:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=sierra2_4a`,
"out.gif",
]);
const data = await ffmpeg.readFile("out.gif");
return new Blob([data.buffer], { type: "image/gif" });
}
// Wire to a file input:
document.querySelector("input[type=file]").addEventListener("change", async (e) => {
const blob = await mp4ToGif(e.target.files[0]);
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), { href: url, download: "out.gif" });
a.click();
URL.revokeObjectURL(url);
});
Two notes:
- Cross-Origin-Embedder-Policy is required for SharedArrayBuffer (which ffmpeg.wasm uses for performance). Send
Cross-Origin-Embedder-Policy: require-corpandCross-Origin-Opener-Policy: same-originfrom your server. - Performance is ~10-20x slower than native FFmpeg. A 5-second 720p clip takes ~10-30 seconds in ffmpeg.wasm vs. 1-2 seconds with native FFmpeg.
Method 2: fluent-ffmpeg (Node, wraps system FFmpeg)
fluent-ffmpeg is the canonical Node FFmpeg wrapper. It expects FFmpeg to be installed on the system.
npm install fluent-ffmpeg
apt install ffmpeg # or brew install ffmpeg
import ffmpeg from "fluent-ffmpeg";
import { promisify } from "node:util";
import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
async function mp4ToGif(inPath, outPath, { fps = 15, width = 480, start = 0, duration = 5 } = {}) {
const palettePath = path.join(os.tmpdir(), `palette-${Date.now()}.png`);
// Pass 1: palette
await new Promise((resolve, reject) => {
ffmpeg(inPath)
.seekInput(start).duration(duration)
.complexFilter([`fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`])
.save(palettePath)
.on("end", resolve).on("error", reject);
});
// Pass 2: encode
await new Promise((resolve, reject) => {
ffmpeg(inPath)
.seekInput(start).duration(duration)
.input(palettePath)
.complexFilter([`fps=${fps},scale=${width}:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=sierra2_4a`])
.save(outPath)
.on("end", resolve).on("error", reject);
});
await fs.unlink(palettePath);
}
await mp4ToGif("demo.mp4", "demo.gif");
Same two-pass palette workflow as Python. fluent-ffmpeg is just a syntax wrapper — the underlying FFmpeg invocations are identical.
Method 3: ChangeThisFile API (no FFmpeg dependency)
If you don't want to ship FFmpeg or run a 30MB WASM bundle, the API does the conversion server-side. Works in any JS runtime including Cloudflare Workers and Vercel Edge. Free tier gives 100 conversions/month.
const API_KEY = "ctf_sk_your_key_here";
async function mp4ToGif(mp4Buffer, filename = "video.mp4") {
const form = new FormData();
form.append("file", new Blob([mp4Buffer], { type: "video/mp4" }), filename);
form.append("source", "mp4");
form.append("target", "gif");
const response = await fetch("https://changethisfile.com/v1/convert", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return await response.arrayBuffer();
}
const gif = await mp4ToGif(await fs.readFile("demo.mp4"));
await fs.writeFile("demo.gif", Buffer.from(gif));
The API uses the same palette-optimization workflow — quality is equivalent without a local FFmpeg install.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| ffmpeg.wasm | Privacy-first browser tools, no server | ~30MB bundle, ~10x slower than native, COEP/COOP headers required |
| fluent-ffmpeg (Node) | Server apps with FFmpeg already installed | System FFmpeg dep, ~150MB install |
| ChangeThisFile API | Edge runtimes, multi-language teams, no FFmpeg dep | Network call, file size limit (25MB free) |
CLI alternative: shell out to FFmpeg
If your Node app only does the occasional conversion, you can shell out to FFmpeg directly without a wrapper.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
// Pass 1: palette
await run("ffmpeg", ["-y", "-ss", "0", "-t", "5", "-i", "demo.mp4",
"-vf", "fps=15,scale=480:-1:flags=lanczos,palettegen=stats_mode=diff", "palette.png"]);
// Pass 2: encode
await run("ffmpeg", ["-y", "-ss", "0", "-t", "5", "-i", "demo.mp4", "-i", "palette.png",
"-lavfi", "fps=15,scale=480:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=sierra2_4a", "out.gif"]);
Production tips
- Don't run ffmpeg.wasm on the main thread. A 5-second clip can take 10-30s — long enough to freeze the UI. Move it to a Web Worker.
- Cache the FFmpeg WASM blobs. ffmpeg.wasm downloads ~30MB on first load. Use a Service Worker to cache the core files so subsequent loads are instant.
- Set sensible defaults: width=480, fps=15, duration<=10s. 95% of GIF use cases work in this envelope. Wider/longer/faster blows up file size.
- Consider WebP-animated. Same content, 25-50% smaller, supported in every browser. Use target='webp' on the API or change the encoder in FFmpeg.
For privacy-first browser tools, ffmpeg.wasm. For Node servers, fluent-ffmpeg. For edge runtimes or multi-language stacks, the API. Free tier is 100 conversions/month, no card.