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.
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
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
-
splitfeeds the same scaled frames to both passes, so you do not need a temporary palette PNG. -
stats_mode=diffbuilds the palette from what changes between frames, which suits clips with a mostly static background. -
dither=bayer:bayer_scale=5gives an ordered dither that compresses better than error diffusion.diff_mode=rectangleonly re-encodes the changed region of each frame. -
scale=640:-2keeps the aspect ratio and forces an even height.-loop 0means 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)")
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)
Here is the same moment from each rung:
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()
$ uvx --with pillow pytest -q
.......... [100%]
10 passed
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"];0means 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
-anand trim with-ss/-tbefore-ifor 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)