FFmpeg advice for README GIFs tends to be one command and a handful of numbers, with little evidence behind any of them. The failures are familiar: a demo so heavy the page crawls, or one that loads fast but has its text smeared into dots.

I wanted to know which settings actually earn their place, and whether a different tool would do better. So I wrote a script that draws four fake screen recordings and converts each with 17 FFmpeg GIF recipes, then with gifski, gifsicle and MP4, scoring every file for size and fidelity. Then I opened the files, and on one clip the quality score preferred the banded picture to the smooth one.

How I tested

The clips are 8 seconds long, 1280x720 at 30 fps, and drawn with Pillow so anyone can regenerate them exactly:

  • click: an invoices screen. The cursor moves, clicks the Unpaid filter, the list re-flows and the result holds.
  • scroll: a long invoice list scrolling under a gradient header, nonstop.
  • terminal: npm run build and npm test printing line by line in a dark window.
  • gradient: a smooth color field drifting behind a text card, a stand-in for a marketing-style dashboard background.

Each clip went through 22 conversions, labelled A to V. Quality is SSIM, which compares every frame with the source at the same size and frame rate and returns 1.0 for a perfect match. The versions were FFmpeg 6.1.1 (Ubuntu build), gifski 1.34.0, gifsicle 1.94, Pillow 12.1.1 and Python 3.12.

The limits are real. The clips are synthetic, with no photos, no embedded video and no subpixel text, so real recordings will differ. SSIM scores pixel fidelity, not legibility or looks, so I judged looks by zooming in. I didn't test animated WebP or APNG, and I didn't measure how GitHub renders any of it.

Results

Each cell shows file size, then SSIM in parentheses (1 MB is 1024 KB). Compare SSIM only between rows of the same width: A and N are full width, J is 800 px, and the rest are 600 px. Rows C to M use FFmpeg's palette method at 10 fps unless the row says otherwise. Rows P to V come up in the sections below.

Recipe Click Scroll Terminal Gradient
A. Default palette, full width, 30 fps 534 KB (0.689) 10.42 MB (0.763) 92 KB (0.538) 8.40 MB (0.608)
B. Default palette, 600 px, 10 fps 101 KB (0.694) 1.50 MB (0.786) 27 KB (0.550) 1.18 MB (0.618)
C. Base recipe: palette, bayer_scale=3 208 KB (0.969) 2.89 MB (0.972) 46 KB (0.959) 3.00 MB (0.956)
D. dither=none 157 KB (0.998) 2.20 MB (0.996) 41 KB (0.998) 1.86 MB (0.980)
E. dither=sierra2_4a 170 KB (0.997) 2.32 MB (0.996) 45 KB (0.998) 6.28 MB (0.947)
F. Base recipe at 8 fps 170 KB (0.969) 2.41 MB (0.972) 42 KB (0.959) 2.54 MB (0.956)
G. Base recipe at 15 fps 256 KB (0.967) 4.34 MB (0.913) 50 KB (0.958) 3.96 MB (0.955)
H. Base recipe at 20 fps 316 KB (0.968) 5.53 MB (0.977) 55 KB (0.959) 4.78 MB (0.956)
I. Base recipe at 25 fps 364 KB (0.969) 6.74 MB (0.977) 59 KB (0.959) 5.41 MB (0.956)
J. Base recipe at 800 px 316 KB (0.969) 4.48 MB (0.975) 65 KB (0.958) 4.92 MB (0.957)
K. Base recipe with 64 colors 143 KB (0.965) 1.93 MB (0.978) 37 KB (0.957) 1.52 MB (0.934)
L. Base recipe plus diff_mode=rectangle 217 KB (0.972) 2.99 MB (0.974) 44 KB (0.959) 2.97 MB (0.955)
M. dither=none with 64 colors 103 KB (0.995) 1.49 MB (0.993) 34 KB (0.997) 971 KB (0.967)
N. MP4 (H.264, crf 28), full width, 30 fps 67 KB (0.997) 201 KB (0.995) 43 KB (0.999) 266 KB (0.995)
O. MP4 (H.264, crf 28), 600 px, 30 fps 34 KB (0.993) 159 KB (0.985) 19 KB (0.998) 69 KB (0.992)

Does the palette step matter?

Yes. Generating a palette from the clip first is the biggest effect in the table. Row B, FFmpeg's built-in palette with no palettegen pass, looks like a bargain: 101 KB, 1.50 MB, 27 KB and 1.18 MB. Its SSIM is 0.55 to 0.79, and the dotted noise is obvious against the dark terminal background. Building a palette from the clip first costs 1.7 to 2.5 times the bytes and fixes the picture. I treat B's small size as a defect.

Which dither setting should I use?

Use dither=none for flat UI and terminal footage, and bayer_scale=3 or gifski for gradients. The reasoning matters, because the quality score pointed the other way and I zoomed in to check. FFmpeg's bayer dither takes a bayer_scale from 0 to 5, and on every clip a higher scale gave a smaller file and a higher SSIM:

Dither Click Scroll Terminal Gradient
bayer_scale=1 235 KB (0.758) 3.30 MB (0.794) 50 KB (0.682) 3.52 MB (0.755)
bayer_scale=2 214 KB (0.900) 2.97 MB (0.920) 46 KB (0.890) 3.24 MB (0.903)
bayer_scale=3 (base recipe, C) 208 KB (0.969) 2.89 MB (0.972) 46 KB (0.959) 3.00 MB (0.956)
bayer_scale=4 187 KB (0.992) 2.61 MB (0.992) 43 KB (0.978) 2.59 MB (0.972)
bayer_scale=5 177 KB (0.996) 2.49 MB (0.995) 42 KB (0.997) 2.43 MB (0.978)
dither=none (D) 157 KB (0.998) 2.20 MB (0.996) 41 KB (0.998) 1.86 MB (0.980)
dither=sierra2_4a (E) 170 KB (0.997) 2.32 MB (0.996) 45 KB (0.998) 6.28 MB (0.947)

If dithering is new to you, two short Computerphile videos cover the two families. Ordered dithering is what bayer does. Error diffusion is the family sierra2_4a belongs to, and the second video covers Floyd-Steinberg, a close relative.

Zoomed in 3x on a text-free patch of the gradient clip, the pictures run the other way. Scale 2 is a coarse checkerboard. Scale 3 is smooth with a fine pattern. Scales 4 and 5 bring back the flat patches of none, because the pattern that hid them is gone. sierra2_4a, the default dither in paletteuse, is smooth with the finest texture. SSIM scored them 0.980 for the banded none, 0.978 for scale 5, 0.956 for scale 3 and 0.947 for sierra2_4a. Dither adds noise on purpose, and SSIM counts noise as error, so it rewards the dither that hides the least.

sierra2_4a also weighed 6.28 MB on that clip, against 3.00 MB for scale 3. I can't say why. My guess is that error diffusion changes the noise pattern from frame to frame, which leaves GIF's frame-to-frame savings with less to work with.

On the flat clips the trade reverses. dither=none (D) was 25%, 24% and 10% smaller than scale 3 on click, scroll and terminal, and scale 3 left a faint dotted texture on flat areas where none stayed clean.

What frame rate should a README GIF use?

10 fps, and the reason is arithmetic. GIF stores each frame's delay in hundredths of a second, and 10 fps is exactly 100 ms. Not every rate fits. The script's delay column shows 15 fps alternating between 60 and 70 ms, 8 fps between 120 and 130 ms and 30 fps between 30 and 40 ms. The average is right, but individual frames last different lengths.

20 fps and 25 fps give exact delays of 50 and 40 ms, but they cost 21% to 92% and 30% to 134% more bytes than 10 fps. Going down to 8 fps saved 8% to 18%, and 15 fps cost 10% to 50% more. On the scroll clip, the 15 fps file scored 0.913 against 0.972 at 10 fps, while 20 and 25 fps scored 0.977. 8 fps is uneven too and showed no drop, so I can't say the timing is the cause.

How much do width and color count matter?

Width and color count both trade bytes for legibility or smoothness. Going from 600 to 800 px (J against C) added 42% to 64% depending on the clip, so crop tighter before you widen. Small text goes first. In the click clip, the 14 px secondary labels shrink to about 7 px at 600 px wide and turned to smudges in every recipe I zoomed into, while the 24 px titles stayed readable. Judge a crop at the final width, not at full size.

Cutting colors saves about as much as narrowing the frame. A 64-color palette (K) was 19% to 49% smaller than the base recipe, and with none (M) it was 18% to 49% smaller than D. Both cost smoothness. On the gradient clip, K gets a coarser pattern and M turns into blotches, and M put wide vertical stripes in the click clip's gradient header. On the terminal clip, which has no gradients, M looked fine.

Not every setting moved the needle. diff_mode=rectangle (L) stayed within 5% of C on every clip, so it isn't in my recipe.

What makes a GIF big?

How much of the frame changes, more than any setting. With one recipe (C), the terminal clip came to 46 KB and the click clip to 208 KB, while the scroll and gradient clips came to 2.89 MB and 3.00 MB. The gradient clip doesn't scroll, yet it weighs about as much as the one that does, because its colors drift and almost every pixel differs from the previous frame. Before asking how long a demo runs, I ask how much of it changes.

Do gifski and gifsicle beat FFmpeg?

gifski often does, and gifsicle barely does. Rows P to R, against the base recipe:

Tool Click Scroll Terminal Gradient
FFmpeg base recipe (C) 208 KB (0.969) 2.89 MB (0.972) 46 KB (0.959) 3.00 MB (0.956)
Base recipe plus gifsicle -O3 (R) 201 KB (0.969) 2.75 MB (0.972) 43 KB (0.959) 3.00 MB (0.956)
gifski -Q 90 (P) 139 KB (0.992) 1.68 MB (0.984) 57 KB (1.000) 1.92 MB (0.966)
gifski -Q 70 (Q) 73 KB (0.978) 1.15 MB (0.927) 24 KB (0.999) 664 KB (0.940)

gifsicle's -O3 is lossless and trimmed 0% to 5% off the base recipe, which is worth one command but not a workflow.

gifski at -Q 90 was 33%, 42% and 36% smaller than the base recipe on click, scroll and gradient, and 25% larger on the terminal. On the gradient clip it stayed smooth where none bands, with a fine speckle instead. It stores a repeated frame once with a longer delay, which is why its click GIF has 33 frames to FFmpeg's 80, with holds of up to 3.7 seconds. At -Q 70 the files shrank by another 32% to 66%, and the flat white areas of the click and scroll clips showed visible speckle and streaks. At -Q 90 I saw only faint speckle there.

So gifski earns the install for UI and gradient footage. On the terminal clip FFmpeg beat -Q 90 (41 KB against 57 KB) and needs nothing extra, though -Q 70 was smaller still at 24 KB and looked clean on that dark background.

GIF or MP4 in a GitHub README?

MP4 is far smaller on anything with motion, and about the same size on terminal footage. At 600 px the MP4 (O) was about 6x smaller than the base GIF on the click clip, 19x on scroll, 2x on the terminal and 45x on the gradient. Against gifski -Q 90 the gaps narrow to 4x, 11x, 3x and 29x. The terminal GIF made with dither=none was already 41 KB, so the gap barely matters there.

GitHub's limits shape the rest. Attachments cap images and GIFs at 10 MB, and videos at 10 MB on free plans or 100 MB on paid ones. The naive full-size scroll GIF (A) came to 10.42 MB, over the cap. Video upload works anywhere you write Markdown, READMEs included, with one catch several projects have hit: only videos uploaded through GitHub's own uploader, which produces a user-attachments/assets/... URL, get an inline player. An MP4 committed to the repo and linked by path doesn't (one example), and a <video src> tag pointing elsewhere is stripped (another). Test it on your own repo before you rely on it.

Where the line falls between GIF and video depends on length as well as size, and the next section explains why five seconds matters.

Are README GIFs accessible?

Only partly, and the author controls the part that matters. GitHub respects an OS-level preference for reduced motion, and readers can switch animated images off. Those readers see a still image with a play/pause button. My terminal clip fails that test: its first frame is an empty window with a cursor, which tells a reader nothing. Opening on a title frame fixes that.

The rule behind the five-second figure is WCAG's Pause, Stop, Hide: anything that starts moving by itself, keeps going longer than five seconds and sits beside other content needs a way to stop it. WCAG's own technique for animated GIFs, G152, is to make them stop within five seconds, and -loop -1 in FFmpeg does that by encoding a single play. My clips run 8 seconds, so as encoded they would need a pause control, and the only one a reader gets is GitHub's autoplay setting. That is why I keep demos to five seconds or less and give longer ones a video with controls.

The recipe I'd use

Footage Tool and setting Why
Terminal output, flat UI FFmpeg with dither=none Small and clean on both. gifski -Q 70 beat it on the terminal clip but speckled white UI
Gradients, soft backgrounds gifski -Q 90, or FFmpeg with bayer_scale=3 Both stay smooth, and gifski gave the smaller file
Continuous motion, or longer than about five seconds MP4 uploaded through GitHub Far smaller, with playback controls

The FFmpeg command builds the palette and applies it in one filter graph:

ffmpeg -i capture.mp4 -filter_complex "[0:v]fps=10,scale=600:-2:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse=dither=none" -an -loop 0 readme-demo.gif 

It produced byte-identical files to the two-pass version the benchmark used, on all four clips. -an drops audio, -loop 0 loops forever (use -loop -1 to play once), and scale=600:-2 fixes the width and lets FFmpeg pick an even height. Add -ss and -t before -i to trim. For gradients, replace none with bayer:bayer_scale=3.

The catch is memory. The palette needs every frame before the first one can be encoded, so FFmpeg holds the clip in RAM. Peak memory was 139 MB for the 8-second scroll clip and 599 MB for a 64-second loop of it (-stream_loop 7 -c copy), against about 70 MB when I ran palettegen and the encode as separate passes, which I measured with /usr/bin/time. For a long recording, write the palette to a PNG first and pass it to paletteuse as a second input.

gifski reads its frames from FFmpeg over a pipe:

ffmpeg -i capture.mp4 -vf "fps=10,scale=600:-2:flags=lanczos" -f yuv4mpegpipe - | gifski -Q 90 -o readme-demo.gif - 

That command matched the benchmark's gifski files byte for byte. The recording, trimming and embedding steps are covered in more detail in this walkthrough on the CoAnimator blog.

Run it on your own footage

Save the script below as readme_gif_bench.py, then:

pip install pillow python3 readme_gif_bench.py # the four synthetic clips python3 readme_gif_bench.py --src demo.mp4 # your own recording python3 readme_gif_bench.py --keep out # keep the files so you can zoom in python3 readme_gif_bench.py --quick # rows A to O only 

It needs FFmpeg and ffprobe on your PATH, with libx264 for the MP4 rows. Rows P to R run only if gifski and gifsicle are installed. Use --keep. SSIM can't see banding, and opening the files is how I caught it.

Full script: readme_gif_bench.py
#!/usr/bin/env python3 """readme_gif_bench.py: measure what README demo-GIF settings actually cost. Builds four synthetic 1280x720, 30 fps clips (or uses your own with --src), converts each with a set of FFmpeg GIF recipes plus H.264 MP4, and prints file size, frame count, GIF frame delays and SSIM (pixel fidelity against the source at the same size and fps; 1.0 = identical). Rows P and Q (gifski) and R (gifsicle) run only if those tools are on your PATH. python3 readme_gif_bench.py # synthetic clips: click, scroll, terminal, gradient python3 readme_gif_bench.py --src demo.mp4 # your own recording python3 readme_gif_bench.py --keep out # keep the generated files so you can look at them python3 readme_gif_bench.py --quick # core rows A-O only (skip the extras P-V) Needs python3, Pillow (pip install pillow), and ffmpeg + ffprobe on PATH (libx264 for the MP4 rows). SSIM measures fidelity, not legibility: always look at the files. Numbers depend on your footage and FFmpeg build, so re-run before you quote them. """ import argparse import os import platform import re import shutil import subprocess import sys import tempfile from PIL import Image, ImageChops, ImageDraw, ImageFont W, H, FPS = 1280, 720, 30 # ------------------------------------------------------------------ small helpers def font(size, bold=False, mono=False): names = (["DejaVuSansMono.ttf", "Menlo.ttc", "Consolas.ttf", "cour.ttf"] if mono else ["DejaVuSans-Bold.ttf", "Arial Bold.ttf", "arialbd.ttf"] if bold else ["DejaVuSans.ttf", "Arial.ttf", "arial.ttf"]) for n in names: try: return ImageFont.truetype(n, size) except OSError: pass try: return ImageFont.load_default(size) except TypeError: # Pillow < 10.1  return ImageFont.load_default() def lerp(a, b, t): return a + (b - a) * t def ease(t): t = max(0.0, min(1.0, t)) return t * t * (3 - 2 * t) def mix(c1, c2, t): return tuple(int(lerp(a, b, t)) for a, b in zip(c1, c2)) def ff(args): r = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-y", *args], capture_output=True, text=True) if r.returncode: raise RuntimeError("ffmpeg failed:\n" + r.stderr[-800:]) def probe(path, entries): r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", f"stream={entries}", "-of", "csv=p=0", path], capture_output=True, text=True) return r.stdout.strip().split(",") def duration(path): r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path], capture_output=True, text=True) try: return float(r.stdout.strip()) except ValueError: return None def ssim(test, src, w, fps, seconds): r = subprocess.run( ["ffmpeg", "-hide_banner", "-nostdin", "-t", str(seconds), "-i", test, "-t", str(seconds), "-i", src, "-filter_complex", f"[0:v]format=yuv420p[t];[1:v]fps={fps},scale={w}:-2:flags=lanczos,format=yuv420p[r];" "[t][r]ssim", "-f", "null", "-"], capture_output=True, text=True) m = re.search(r"All:([0-9.]+)", r.stderr) return float(m.group(1)) if m else None def gif_delays(path): im, seen = Image.open(path), set() for i in range(getattr(im, "n_frames", 1)): im.seek(i) seen.add(im.info.get("duration", 0)) return "/".join(str(d) for d in sorted(seen)) if len(seen) <= 3 else "mixed" # ------------------------------------------------------------------ synthetic clips INVOICES = [("Northwind Traders #1042", "Priya", "Unpaid", True), ("Fabrikam Studio #1043", "Marco", "Paid", False), ("Contoso Retail #1044", "Priya", "Unpaid", True), ("Adventure Works #1045", "Lena", "Draft", False), ("Tailspin Toys #1046", "Marco", "Unpaid", True), ("Wingtip Supply #1047", "Lena", "Paid", False), ("Litware Group #1048", "Priya", "Draft", False)] CHIPS = ["All", "Draft", "Unpaid", "Paid"] ROW_W, ROW_H, LIST_X, LIST_Y = 950, 64, 290, 176 def make_row(title, who, due, overdue): img = Image.new("RGB", (ROW_W, ROW_H), (255, 255, 255)) d = ImageDraw.Draw(img) d.rectangle([0, 0, ROW_W - 1, ROW_H - 1], outline=(222, 226, 234)) d.rounded_rectangle([20, 20, 44, 44], 5, outline=(160, 168, 184), width=2) d.text((68, 16), title, font=font(24), fill=(28, 32, 44)) d.text((68, 42), who, font=font(14), fill=(120, 128, 146)) bg, fg = ((255, 240, 214), (150, 85, 0)) if overdue else ((233, 238, 250), (60, 80, 150)) d.rounded_rectangle([ROW_W - 190, 18, ROW_W - 24, 46], 14, fill=bg) d.text((ROW_W - 168, 22), due, font=font(18, True), fill=fg) return img def make_static(): img = Image.new("RGB", (W, H), (245, 246, 250)) d = ImageDraw.Draw(img) d.rectangle([0, 0, 240, H], fill=(31, 36, 48)) d.text((28, 28), "Ledgerly", font=font(28, True), fill=(255, 255, 255)) for i, s in enumerate(["Overview", "Invoices", "Clients", "Reports",Source:https://dev.to/sheen_417f0f3a7f4/readme-demo-gifs-i-compared-ffmpeg-gifski-and-mp4-on-four-clips-32o9

Related posts