DEV Community

Voor AI
Voor AI

Posted on Fully Autonomous

How to Convert MP4 to GIF With FFmpeg

Short answer: the reliable way to convert an MP4 to a GIF with FFmpeg is a two-pass palette: palettegen builds a 256-colour (or smaller) palette from the clip, and paletteuse maps every frame onto it. That fixes the muddy colours and dotted noise of a plain ffmpeg -i in.mp4 out.gif. It does not make the file smaller. File size is controlled by width, frame rate, colour count and duration, so the script below walks a small ladder of those settings until the GIF fits a byte budget, and the tests check that the result really loops, keeps its timing and fits.

Everything here was run with FFmpeg 9.0.1, Python 3 and Pillow on a real 5-second clip.

The fixture

The input is the Rainbow Puddle Sprite example clip from Voor's Seedance 2.5 page: 1280 × 720, 24 fps, 5.06 s, H.264 with an AAC audio track. It is a good GIF stress test because it has soft backlight, translucent wings and a lot of smooth gradients, exactly where GIF's 256-colour limit shows.

Zoomed crop of the same frame: MP4 source, default FFmpeg GIF with visible dot pattern at 2.77 MB, and two-pass palette GIF at 4.71 MB

The one-liner, and why it is not enough

The command most answers give is:

ffmpeg -t 3 -i in.mp4 -vf "fps=15,scale=640:-2" out.gif
Enter fullscreen mode Exit fullscreen mode

On the fixture's first 3 seconds that produced a 2.77 MB GIF, and the zoomed crop above shows the problem: FFmpeg's default GIF encoder uses a generic palette, so the warm backlight turns into a visible dot pattern.

The two-pass palette filter

ffmpeg -t 3 -i in.mp4 -an -filter_complex \
  "[0:v]fps=15,scale=640:-2:flags=lanczos,split[a][b];\
   [a]palettegen=max_colors=256:stats_mode=diff[p];\
   [b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
  -loop 0 out.gif
Enter fullscreen mode Exit fullscreen mode
  • split feeds the same scaled frames to both passes, so you do not need a temporary palette PNG.
  • stats_mode=diff builds the palette from what changes between frames, which suits clips with a mostly static background.
  • dither=bayer:bayer_scale=5 gives an ordered dither that compresses better than error diffusion. diff_mode=rectangle only re-encodes the changed region of each frame.
  • scale=640:-2 keeps the aspect ratio and forces an even height. -loop 0 means loop forever.

Same width, same frame rate, much cleaner colour, and a bigger file: 4.71 MB. That is the part people miss. The palette fixes quality; it does not fix size.

Fitting a byte budget

Most places that accept GIFs have an upload limit, so the useful tool is "best quality that fits N bytes". The script tries four rungs in order and stops at the first one that fits:

"""Convert an MP4 to a looping GIF with FFmpeg's two-pass palette, under a byte budget."""
from __future__ import annotations

import json
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE = shutil.which("ffprobe") or "ffprobe"

# Tried in order until the GIF fits the budget: (width, fps, colors)
LADDER = [(640, 15, 256), (480, 12, 192), (360, 10, 128), (320, 8, 96)]


@dataclass
class GifResult:
    path: Path
    width: int
    fps: int
    colors: int
    bytes: int
    attempts: int


def probe(src: Path) -> dict:
    out = subprocess.run(
        [FFPROBE, "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=width,height,r_frame_rate:format=duration",
         "-of", "json", str(src)],
        check=True, capture_output=True, text=True,
    ).stdout
    data = json.loads(out)
    s = data["streams"][0]
    num, den = (int(x) for x in s["r_frame_rate"].split("/"))
    return {"width": s["width"], "height": s["height"], "fps": num / den,
            "duration": float(data["format"]["duration"])}


def build_filter(width: int, fps: int, colors: int) -> str:
    base = f"fps={fps},scale={width}:-2:flags=lanczos"
    return (f"[0:v]{base},split[a][b];"
            f"[a]palettegen=max_colors={colors}:stats_mode=diff[p];"
            f"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle")


def encode(src: Path, dst: Path, width: int, fps: int, colors: int,
           start: float = 0.0, seconds: float | None = None) -> int:
    cmd = [FFMPEG, "-v", "error", "-y", "-ss", f"{start}"]
    if seconds is not None:
        cmd += ["-t", f"{seconds}"]
    cmd += ["-i", str(src), "-an", "-filter_complex",
            build_filter(width, fps, colors), "-loop", "0", str(dst)]
    subprocess.run(cmd, check=True)
    return dst.stat().st_size


def mp4_to_gif(src: Path, dst: Path, max_bytes: int,
               start: float = 0.0, seconds: float | None = None) -> GifResult:
    info = probe(src)
    for attempt, (width, fps, colors) in enumerate(LADDER, start=1):
        width = min(width, info["width"])
        fps = min(fps, round(info["fps"]))
        size = encode(src, dst, width, fps, colors, start, seconds)
        if size <= max_bytes:
            return GifResult(dst, width, fps, colors, size, attempt)
    dst.unlink(missing_ok=True)
    raise ValueError(f"{src.name}: no ladder step fits {max_bytes} bytes "
                     f"(last try {size} bytes); shorten the clip with --seconds")


if __name__ == "__main__":
    src, dst, budget = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3])
    secs = float(sys.argv[4]) if len(sys.argv) > 4 else None
    r = mp4_to_gif(src, dst, budget, seconds=secs)
    print(f"{r.path} {r.width}px {r.fps}fps {r.colors} colors "
          f"{r.bytes} bytes after {r.attempts} attempt(s)")
Enter fullscreen mode Exit fullscreen mode

Running it on the same 3-second slice:

$ python3 mp4_to_gif.py rainbow-puddle-sprite.mp4 out.gif 3000000 3
out.gif 480px 12fps 192 colors 2096976 bytes after 2 attempt(s)

$ python3 mp4_to_gif.py rainbow-puddle-sprite.mp4 out.gif 1500000 3
out.gif 360px 10fps 128 colors 932816 bytes after 3 attempt(s)

$ python3 mp4_to_gif.py rainbow-puddle-sprite.mp4 out.gif 900000 3
out.gif 320px 8fps 96 colors 565291 bytes after 4 attempt(s)
Enter fullscreen mode Exit fullscreen mode

Here is the same moment from each rung:

The same frame from four GIF ladder rungs: 640px 15fps 256 colours 4.71 MB, 480px 12fps 192 colours 2.10 MB, 360px 10fps 128 colours 0.93 MB, 320px 8fps 96 colours 0.57 MB

On this clip, the first two steps down the ladder each cut the file by more than half. Width does most of the work; dropping from 15 to 8 fps makes the wing flap visibly choppier, so if motion matters more than size, trim the clip instead of cutting the frame rate.

Tests that catch broken GIFs

A GIF that plays once, drifts in speed or silently blows the budget still "converts" without errors. These checks use Pillow to read the GIF back (the full file has 10 tests, including probe and aspect-ratio checks):

def test_loops_forever(gif):
    _, _, loop, _ = frames_and_durations(gif.path)
    assert loop == 0


def test_frame_timing_matches_fps(gif):
    _, durations, _, _ = frames_and_durations(gif.path)
    expected = 1000 / gif.fps
    # GIF delays are stored in centiseconds, so allow one 10 ms tick
    assert all(abs(d - expected) <= 10 for d in durations[:-1])


def test_ladder_steps_down_when_budget_is_tight(tmp_path):
    r = mp4_to_gif(CLIP, tmp_path / "small.gif", max_bytes=900_000, seconds=3)
    assert r.attempts > 1 and r.bytes <= 900_000


def test_impossible_budget_raises_and_leaves_no_file(tmp_path):
    out = tmp_path / "never.gif"
    with pytest.raises(ValueError, match="no ladder step fits"):
        mp4_to_gif(CLIP, out, max_bytes=10_000, seconds=3)
    assert not out.exists()
Enter fullscreen mode Exit fullscreen mode
$ uvx --with pillow pytest -q
..........                                                               [100%]
10 passed
Enter fullscreen mode Exit fullscreen mode

Two details worth knowing:

  • GIF frame delays are stored in centiseconds, so 12 fps becomes 80 or 90 ms per frame, never exactly 83.3 ms. The timing test allows one 10 ms tick.
  • Pillow exposes the NETSCAPE loop extension as im.info["loop"]; 0 means infinite. If it is missing, many viewers play the GIF once.

Troubleshooting

  • "height not divisible by 2" or odd sizes: use scale=W:-2, not -1.
  • Colours still band in gradients: try dither=sierra2_4a (bigger files) or keep 256 colours and cut duration instead.
  • The GIF has audio-sync or length issues: GIF has no audio; pass -an and trim with -ss/-t before -i for fast, frame-accurate-enough cuts.
  • Still too big at 320 px: GIF is the wrong format. Most platforms accept MP4 or WebP animations, which are usually far smaller at better quality.

Getting a test clip

If you want to run the ladder on a clip of your own, the Seedance 2.5 video workspace is where the fixture came from. When I checked on October 27, 2026, its default settings (16:9, 480p, 5 seconds, audio on) showed a quote of 314 credits, and new creations are Public with a watermark by default. Whatever you generate, measure the downloaded file with probe() rather than assuming its frame rate.

Wrap-up

Use palettegen + paletteuse for quality, a width/fps/colour ladder for size, and Pillow assertions so a GIF that does not loop or does not fit fails the build. If you need a short, gradient-heavy clip to test on, make one in the same video workspace and run the script on the download.

Top comments (0)