DEV Community

Cover image for README Demo GIFs: I Compared FFmpeg, gifski and MP4 on Four Clips
Sheen
Sheen

Posted on

README Demo GIFs: I Compared FFmpeg, gifski and MP4 on Four Clips

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
Enter fullscreen mode Exit fullscreen mode

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 -
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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", "Settings"]):
        y = 100 + i * 52
        if s == "Invoices":
            d.rounded_rectangle([14, y - 8, 226, y + 34], 8, fill=(52, 60, 82))
        d.text((34, y), s, font=font(20), fill=(210, 216, 230))
    return img


def make_header():
    img = Image.new("RGB", (W - 240, 84))
    d = ImageDraw.Draw(img)
    for x in range(W - 240):  # horizontal gradient: the part dithering exists for
        d.line([(x, 0), (x, 84)], fill=mix((79, 70, 229), (168, 85, 247), x / (W - 240)))
    d.text((50, 22), "Invoices", font=font(34, True), fill=(255, 255, 255))
    return img


STATIC, HEADER = make_static(), make_header()
ROWS = [make_row(*t) for t in INVOICES]
SCROLL_ROWS = [make_row(f"{INVOICES[i % 7][0].rsplit(' #', 1)[0]} #{1042 + i}", *INVOICES[i % 7][1:]) for i in range(40)]


def draw_chips(img, active):
    d, x, pos = ImageDraw.Draw(img), LIST_X, {}
    for c in CHIPS:
        w = int(d.textlength(c, font=font(20, True))) + 44
        on = c == active
        d.rounded_rectangle([x, 104, x + w, 148], 22, fill=(79, 70, 229) if on else (255, 255, 255),
                            outline=(79, 70, 229) if on else (200, 205, 218), width=2)
        d.text((x + 22, 114), c, font=font(20, True), fill=(255, 255, 255) if on else (70, 78, 98))
        pos[c] = (x + w // 2, 126)
        x += w + 14
    return pos


def cursor(img, x, y):
    pts = [(0, 0), (0, 24), (6, 19), (11, 29), (15, 27), (10, 18), (18, 18)]
    ImageDraw.Draw(img).polygon([(x + a, y + b) for a, b in pts], fill=(255, 255, 255), outline=(0, 0, 0))


def frame_click(t):  # mostly static: move, click a chip, list re-flows, hold
    img = STATIC.copy()
    img.paste(HEADER, (240, 0))
    clicked = t >= 2.5
    tx, ty = draw_chips(img, "Unpaid" if clicked else "All")["Unpaid"]
    tr, n = (ease((t - 2.5) / 0.7) if clicked else 0.0), 0
    for i, row in enumerate(INVOICES):
        y0 = LIST_Y + i * (ROW_H + 10)
        if row[3]:
            y, alpha, n = lerp(y0, LIST_Y + n * (ROW_H + 10), tr), 255, n + 1
        else:
            y, alpha = y0, int(255 * (1 - tr))
        if alpha:
            img.paste(ROWS[i], (LIST_X, int(y)), Image.new("L", ROWS[i].size, alpha))
    if t < 1.2:
        cx, cy = 1000, 560
    elif t < 2.4:
        k = ease((t - 1.2) / 1.2)
        cx, cy = lerp(1000, tx, k), lerp(560, ty, k)
    elif t < 3.4:
        cx, cy = tx, ty
    else:
        k = ease((t - 3.4) / 0.6)
        cx, cy = lerp(tx, 800, k), lerp(ty, 420, k)
    cursor(img, int(cx), int(cy))
    return img


def frame_scroll(t):  # continuous motion under a gradient header
    img = STATIC.copy()
    for i, r in enumerate(SCROLL_ROWS):
        y = 100 + i * (ROW_H + 10) - int(t * 140)
        if -ROW_H < y < H:
            img.paste(r, (LIST_X, y))
    img.paste(HEADER, (240, 0))
    return img


GH = Image.linear_gradient("L").transpose(Image.ROTATE_90).resize((W + 600, H), Image.BICUBIC)
GV = Image.linear_gradient("L").resize((W, H + 300), Image.BICUBIC)
GR = Image.radial_gradient("L").resize((W + 500, H + 300), Image.BICUBIC)
CARD = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ImageDraw.Draw(CARD).rounded_rectangle([120, 190, 860, 530], 28, fill=(255, 255, 255, 46))


def frame_gradient(t):  # smooth colour field drifting behind a card: the case dithering is for
    a, b, c = int(t * 70), int(t * 35), int(t * 45)
    r = GH.crop((a, 0, a + W, H)).point(lambda p: 40 + p * 0.6)
    g = GV.crop((0, b, W, b + H)).point(lambda p: 20 + p * 0.55)
    bl = GR.crop((c, int(t * 25), c + W, int(t * 25) + H)).point(lambda p: 90 + p * 0.5)
    img = Image.alpha_composite(Image.merge("RGB", (r, g, bl)).convert("RGBA"), CARD).convert("RGB")
    d = ImageDraw.Draw(img)
    d.text((160, 220), "Release dashboard", font=font(54, True), fill=(255, 255, 255))
    for i, line in enumerate(["Builds passed: 4,812", "Deploys this week: 31", "Median build time: 1m 48s"]):
        d.text((160, 320 + i * 52), line, font=font(28), fill=(240, 242, 255))
    d.text((160, 484), "Updated just now", font=font(16), fill=(225, 228, 245))
    return img


TERM = [  # (start s, prompt, text, colour, typed?)
    (0.4, "$ ", "npm run build", (235, 235, 235), True),
    (1.6, "", "vite v5.4 building for production...", (150, 156, 172), False),
    (2.1, "", "42 modules transformed.", (150, 156, 172), False),
    (2.6, "", "dist/index.html        0.46 kB", (110, 190, 255), False),
    (2.8, "", "dist/assets/app.js    148.20 kB", (110, 190, 255), False),
    (3.0, "", "built in 1.84s", (90, 205, 130), False),
    (4.2, "$ ", "npm test", (235, 235, 235), True),
    (5.2, "", "PASS  src/parse.test.ts", (90, 205, 130), False),
    (5.5, "", "PASS  src/render.test.ts", (90, 205, 130), False),
    (5.8, "", "Tests: 12 passed, 12 total", (235, 235, 235), False),
]


def frame_term(t):  # dark background, monospace text appearing line by line
    img = Image.new("RGB", (W, H), (24, 26, 33))
    d = ImageDraw.Draw(img)
    d.rectangle([0, 0, W, 44], fill=(40, 43, 54))
    for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
        d.ellipse([20 + i * 28, 14, 36 + i * 28, 30], fill=c)
    f, y, end = font(28, mono=True), 80, (40, 80)
    for start, prompt, text, col, typed in TERM:
        if t < start:
            break
        shown = text[:int((t - start) * 16) + 1] if typed else text
        d.text((40, y), prompt, font=f, fill=(90, 205, 130))
        x = 40 + d.textlength(prompt, font=f)
        d.text((x, y), shown, font=f, fill=col)
        end = (x + d.textlength(shown, font=f), y)
        y += 44
    if int(t * 2) % 2 == 0:
        d.rectangle([end[0] + 4, end[1] + 2, end[0] + 20, end[1] + 32], fill=(235, 235, 235))
    return img


def make_clip(path, fn, seconds):
    p = subprocess.Popen(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
                          "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", "-c:v", "libx264", "-crf", "12",
                          "-pix_fmt", "yuv420p", path], stdin=subprocess.PIPE)
    for n in range(int(seconds * FPS)):
        p.stdin.write(fn(n / FPS).tobytes())
    p.stdin.close()
    if p.wait():
        raise RuntimeError("could not encode the synthetic clip (is libx264 available?)")


# ------------------------------------------------------------------ converters
BASE = "bayer:bayer_scale=3"


def gif(src, out, w, fps, seconds, palette=True, dither=BASE, colors=256, diff=False):
    base = f"fps={fps},scale={w}:-2:flags=lanczos"
    if not palette:  # FFmpeg's default palette
        return ff(["-t", str(seconds), "-i", src, "-vf", base, "-loop", "0", out])
    pal = out + ".palette.png"
    gen = f"palettegen=max_colors={colors}" + (":stats_mode=diff" if diff else "")
    ff(["-t", str(seconds), "-i", src, "-vf", f"{base},{gen}", "-frames:v", "1", "-update", "1", pal])
    use = f"paletteuse=dither={dither}" + (":diff_mode=rectangle" if diff else "")
    ff(["-t", str(seconds), "-i", src, "-i", pal, "-filter_complex", f"[0:v]{base}[v];[v][1:v]{use}",
        "-an", "-loop", "0", out])
    os.remove(pal)


def mp4(src, out, w, fps, seconds, crf=28):
    ff(["-t", str(seconds), "-i", src, "-vf", f"fps={fps},scale={w}:-2:flags=lanczos", "-c:v", "libx264",
        "-preset", "slow", "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", out])


def gifski(src, out, w, fps, seconds, quality=90):
    feed = subprocess.Popen(["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-t", str(seconds), "-i", src,
                             "-vf", f"fps={fps},scale={w}:-2:flags=lanczos", "-f", "yuv4mpegpipe", "-"],
                            stdout=subprocess.PIPE)
    r = subprocess.run(["gifski", "-Q", str(quality), "-o", out, "-"], stdin=feed.stdout, capture_output=True)
    feed.stdout.close()
    feed.wait()
    if r.returncode:
        raise RuntimeError("gifski failed:\n" + r.stderr.decode()[-500:])


def gifsicle(src_gif, out):
    r = subprocess.run(["gifsicle", "-O3", src_gif, "-o", out], capture_output=True)
    if r.returncode:
        raise RuntimeError("gifsicle failed:\n" + r.stderr.decode()[-500:])


RECIPES = [  # label, kind, width (0 = full width), fps, options
    ("A. default palette, full width, 30 fps", "gif", 0, 30, {"palette": False}),
    ("B. default palette, 600 px, 10 fps", "gif", 600, 10, {"palette": False}),
    ("C. base recipe: palette + bayer_scale=3, 600 px, 10 fps", "gif", 600, 10, {}),
    ("D. dither=none", "gif", 600, 10, {"dither": "none"}),
    ("E. dither=sierra2_4a", "gif", 600, 10, {"dither": "sierra2_4a"}),
    ("F. base recipe at 8 fps", "gif", 600, 8, {}),
    ("G. base recipe at 15 fps", "gif", 600, 15, {}),
    ("H. base recipe at 20 fps", "gif", 600, 20, {}),
    ("I. base recipe at 25 fps", "gif", 600, 25, {}),
    ("J. base recipe at 800 px", "gif", 800, 10, {}),
    ("K. base recipe with 64 colors", "gif", 600, 10, {"colors": 64}),
    ("L. base recipe + diff_mode=rectangle", "gif", 600, 10, {"diff": True}),
    ("M. dither=none with 64 colors", "gif", 600, 10, {"dither": "none", "colors": 64}),
    ("N. MP4 (H.264 crf 28), full width, 30 fps", "mp4", 0, 30, {}),
    ("O. MP4 (H.264 crf 28), 600 px, 30 fps", "mp4", 600, 30, {}),
]
EXTRAS = [  # skipped by --quick; P-R need gifski / gifsicle on PATH
    ("P. gifski -Q 90, 600 px, 10 fps", "gifski", 600, 10, {"quality": 90}),
    ("Q. gifski -Q 70, 600 px, 10 fps", "gifski", 600, 10, {"quality": 70}),
    ("R. base recipe + gifsicle -O3", "gifsicle", 600, 10, {}),
    ("S. bayer_scale=1", "gif", 600, 10, {"dither": "bayer:bayer_scale=1"}),
    ("T. bayer_scale=2", "gif", 600, 10, {"dither": "bayer:bayer_scale=2"}),
    ("U. bayer_scale=4", "gif", 600, 10, {"dither": "bayer:bayer_scale=4"}),
    ("V. bayer_scale=5", "gif", 600, 10, {"dither": "bayer:bayer_scale=5"}),
]


def bench(name, src, seconds, work, have_x264, quick=False):
    src_w = int(probe(src, "width")[0])
    seconds = round(min(seconds, duration(src) or seconds), 2)
    print(f"\n### {name} (source {os.path.getsize(src) / 1024:.0f} KB, {src_w} px wide, {seconds:g} s)\n")
    print("| Recipe | Size | Frames | Delay (ms) | SSIM |\n|---|---:|---:|---:|---:|")
    for label, kind, w, fps, opts in RECIPES + ([] if quick else EXTRAS):
        if kind == "mp4" and not have_x264:
            print(f"| {label} | skipped (no libx264) | | | |")
            continue
        tool = {"gifski": "gifski", "gifsicle": "gifsicle"}.get(kind)
        if tool and not shutil.which(tool):
            print(f"| {label} | skipped ({tool} not on PATH) | | | |")
            continue
        w = min(w or src_w, src_w)
        if kind == "mp4":
            w -= w % 2  # H.264 4:2:0 needs even dimensions
        out = os.path.join(work, f"{name}-{label[0]}." + ("mp4" if kind == "mp4" else "gif"))
        if kind == "gifsicle":
            gifsicle(os.path.join(work, f"{name}-C.gif"), out)
        else:
            {"gif": gif, "mp4": mp4, "gifski": gifski}[kind](src, out, w, fps, seconds, **opts)
        size = os.path.getsize(out)
        s = ssim(out, src, w, fps, seconds)
        human = f"{size / 1048576:.2f} MB" if size > 1048576 else f"{size / 1024:.0f} KB"
        print(f"| {label} | {human} | {probe(out, 'nb_read_frames')[0]} | "
              f"{gif_delays(out) if kind == 'gif' else '-'} | {f'{s:.4f}' if s else '-'} |")


def main():
    ap = argparse.ArgumentParser(description="Benchmark README demo-GIF settings against MP4.")
    ap.add_argument("--src", help="benchmark your own recording instead of the synthetic clips")
    ap.add_argument("--seconds", type=float, default=8.0, help="seconds to convert (default 8)")
    ap.add_argument("--keep", help="keep generated files in this directory")
    ap.add_argument("--quick", action="store_true", help="core rows A-O only")
    a = ap.parse_args()
    for tool in ("ffmpeg", "ffprobe"):
        if not shutil.which(tool):
            sys.exit(f"{tool} not found on PATH")
    if a.src and not os.path.isfile(a.src):
        sys.exit(f"file not found: {a.src}")
    enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True).stdout
    have_x264 = "libx264" in enc
    ver = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True).stdout.splitlines()[0]
    print(f"{ver}\nPillow {getattr(Image, '__version__', '?')}, Python {platform.python_version()}, "
          f"{platform.system()}")
    work = a.keep or tempfile.mkdtemp(prefix="gifbench-")
    os.makedirs(work, exist_ok=True)
    try:
        if a.src:
            bench("your-recording", a.src, a.seconds, work, have_x264, a.quick)
        else:
            if not have_x264:
                sys.exit("the synthetic clips need ffmpeg with libx264; pass --src with your own video instead")
            for name, fn in (("click", frame_click), ("scroll", frame_scroll), ("terminal", frame_term),
                             ("gradient", frame_gradient)):
                src = os.path.join(work, f"{name}-source.mp4")
                make_clip(src, fn, a.seconds)
                bench(name, src, a.seconds, work, have_x264, a.quick)
    except RuntimeError as e:
        sys.exit(str(e))
    finally:
        if a.keep:
            print(f"\nFiles kept in {work}")
        else:
            shutil.rmtree(work, ignore_errors=True)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

What I'd test next

Three things I didn't measure, in the order I'd like to see numbers for them.

The first is real footage. My clips are tidy, while screen recordings carry video content, subpixel text and compression noise, and I don't know where the line between none, bayer and gifski falls for them. The second is animated WebP and APNG, which FFmpeg can encode, but I haven't checked what GitHub does with them. The third is a guard in CI: a small check that fails a pull request when a README image passes a size budget would catch the next oversized demo before it merges. I haven't built that yet.

If you run the script on your own recording, I'd like to see your table. Paste it in the comments, especially if it ranks the settings differently.

Top comments (1)

Collapse
 
demovexa profile image
DemoVexa •

How much of the frame changes is the variable I'd plan the recording around, because it's easier to remove change before capture than after. Turn off the blinking cursor, hide the menu bar clock, replace spinners with a static state, and jump to positions instead of smooth scrolling. Each of those repaints pixels in frames where nothing useful happens. For the pauses that are left, FFmpeg's mpdecimate filter with -fps_mode vfr drops near duplicate frames, so a held result is stored once with a longer delay, a bit like what gifski does with repeated frames. Did you try it on the click clip? I'd expect it to help most there, where the screen sits still between clicks.