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-corp and Cross-Origin-Opener-Policy: same-origin from 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

ApproachBest forTradeoff
ffmpeg.wasmPrivacy-first browser tools, no server~30MB bundle, ~10x slower than native, COEP/COOP headers required
fluent-ffmpeg (Node)Server apps with FFmpeg already installedSystem FFmpeg dep, ~150MB install
ChangeThisFile APIEdge runtimes, multi-language teams, no FFmpeg depNetwork 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.