Checked November 10, 2026 (15:00 CST, UTC+8) against the live pages linked below. I build bamabound.app, an independent studio that turns one photo of one person into a vertical clip of them holding a red I'M BAMA BOUND banner. The script here was tested on synthetic clips (a drawn red box on a green field), not on real people.
People ask some version of this a lot: "Can I make my Bama Bound video into a GIF?" They want it for a group-chat reaction, a Discord emoji-sized loop or a forum signature. The answer is yes. The catch is that the one thing that makes the meme recognisable is a flat, saturated red banner with white capitals, and GIF is a format that only has 256 colours per frame. Get the palette wrong and the banner turns brick-orange or picks up crawling dots.
This post turns the clip into a GIF or an animated WebP and then measures what that cost: file size, frame timing, and colour error on the banner (CIE76 ΔE, roughly "how many just-noticeable steps away from the original colour"). One Python script does both.
Short answer
| Output (3 s, 320 px wide, 10 fps, test clip with light grain) | Size | Banner ΔE | Whole-frame ΔE |
|---|---|---|---|
GIF, ffmpeg defaults (ffmpeg -i clip.mp4 out.gif) | 463 KB | 26.9 | 21.6 |
GIF, own palette (palettegen + paletteuse, sierra2_4a dither) | 2,395 KB | 1.1 | 1.3 |
| GIF, own palette, no dither | 2,263 KB | 1.1 | 1.3 |
| GIF, own palette, denoised first | 1,219 KB | 1.1 | 1.2 |
| Animated WebP, quality 70 | 64 KB | 3.6 | 2.1 |
- ffmpeg's default GIF is small because it maps every frame to a fixed, generic palette. That is exactly what turns the banner red into the wrong red: ΔE 27 is a different colour, not a subtle shift.
- Build a palette from the clip and the banner is back within about 1 ΔE (invisible). The file is five times bigger.
- Grain is what makes GIFs huge. AI video has fine noise that changes every frame, and GIF has to store each change. Denoising first halved the size here.
- If the place you're posting accepts animated WebP, use it. It was 37× smaller than the best GIF on this clip, with a colour error you'd need a side-by-side to see.
Why GIF struggles with this meme specifically
A GIF frame is an indexed image: up to 256 colours, and each pixel stores an index into that table. Two things follow.
- Which 256 colours matter. Without a custom palette, ffmpeg falls back to a general-purpose palette spread over the whole colour cube. The banner's crimson isn't in it, so every banner pixel snaps to the nearest entry, which is visibly orange-brown. Running
palettegenover the actual frames puts real banner reds into the table. - Dithering vs. flat areas. Error-diffusion dithering (sierra, Floyd–Steinberg) hides banding in gradients like the sky, but on a flat red area it can add a faint moving texture. Bayer (ordered) dithering adds a fixed cross-hatch. On the banner itself, "no dither" was the most accurate here. The sky looked a bit more banded, which matters less.
GIF also stores timing in hundredths of a second. 10, 20, 25 and 50 fps are exact. 12 fps isn't: ffmpeg alternates 80 ms and 90 ms delays to average out, which you can see in the script's delays_ms output. It's harmless for a reaction loop, but it's why I default to 10 fps.
The script
clip_to_gif.py encodes, then decodes the result with Pillow and compares it against the source decoded at the same times and size. Pillow handles the GIF disposal rules and animated WebP. ffmpeg 7.1's WebP decoder can't read animated WebP at all, which is a fun thing to discover at 1 a.m.
#!/usr/bin/env python3 """Turn a vertical banner clip into a GIF or animated WebP, then measure what it cost. python3 clip_to_gif.py in.mp4 out.gif [--start 2 --duration 3 --fps 10 --width 320] [--method palette|naive] [--dither sierra2_4a|bayer|none] [--max-kb 4000] python3 clip_to_gif.py in.mp4 out.webp [--quality 70] add --denoise to smooth AI-video grain first (much smaller GIFs) After encoding it decodes the result and the source at the same times and size and reports file size, frame count, frame delays, and colour error (CIE76 delta E) on the red banner and on the whole frame. Exit 1 if --max-kb is set and not met. Needs ffmpeg/ffprobe on PATH, NumPy and Pillow (Pillow decodes the GIF/WebP, because ffmpeg's WebP decoder cannot read animated WebP). """ import argparse, json, os, subprocess, sys import numpy as np from PIL import Image, ImageSequence def run(cmd): subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) def probe_size(path): out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", path], check=True, capture_output=True, text=True).stdout s = json.loads(out)["streams"][0] return s["width"], s["height"] def frames(path, vf, w, h, n=None): """Decode to RGB24 frames through an optional filter chain.""" cmd = ["ffmpeg", "-v", "error", "-i", path] if vf: cmd += ["-vf", vf] cmd += ["-f", "rawvideo", "-pix_fmt", "rgb24", "-"] raw = subprocess.run(cmd, check=True, capture_output=True).stdout a = np.frombuffer(raw, np.uint8).reshape(-1, h, w, 3) return a if n is None else a[:n] def to_lab(rgb): c = rgb.astype(np.float64) / 255.0 c = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) m = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]) xyz = c @ m.T / np.array([0.95047, 1.0, 1.08883]) f = np.where(xyz > 216 / 24389, np.cbrt(xyz), (24389 / 27 * xyz + 16) / 116) return np.stack([116 * f[..., 1] - 16, 500 * (f[..., 0] - f[..., 1]), 200 * (f[..., 1] - f[..., 2])], -1) def red_mask(rgb): """Saturated, bright, red-ish pixels: the banner, not the field or the sky.""" c = rgb.astype(np.int16); r, g, b = c[..., 0], c[..., 1], c[..., 2] return (r > 120) & (r - g > 70) & (r - b > 50) def encode(a, w_out, chain): if a.out.endswith(".webp"): run(["ffmpeg", "-v", "error", "-y", "-ss", str(a.start), "-t", str(a.duration), "-i", a.src, "-vf", chain, "-an", "-c:v", "libwebp_anim", "-lossless", "0", "-q:v", str(a.quality), "-loop", "0", a.out]) elif a.method == "naive": run(["ffmpeg", "-v", "error", "-y", "-ss", str(a.start), "-t", str(a.duration), "-i", a.src, "-vf", chain, "-an", "-loop", "0", a.out]) else: d = {"bayer": "bayer:bayer_scale=3", "none": "none"}.get(a.dither, a.dither) fc = (f"[0:v]{chain},split[x][y];[x]palettegen=max_colors=256:stats_mode=diff[p];" f"[y][p]paletteuse=dither={d}:diff_mode=rectangle") run(["ffmpeg", "-v", "error", "-y", "-ss", str(a.start), "-t", str(a.duration), "-i", a.src, "-filter_complex", fc, "-an", "-loop", "0", a.out]) def decode_anim(path): """Frames (RGB, after GIF disposal/WebP blending) and per-frame delays in ms.""" im = Image.open(path); fr, delays = [], [] for f in ImageSequence.Iterator(im): fr.append(np.asarray(f.convert("RGB"))); delays.append(f.info.get("duration", 0)) return np.stack(fr), delays def measure(a, w_out, h_out, chain): out_f, delays = decode_anim(a.out) src_f = frames(a.src, f"trim=start={a.start}:duration={a.duration},setpts=PTS-STARTPTS,{chain}", w_out, h_out, len(out_f)) n = min(len(out_f), len(src_f)) out_f, src_f = out_f[:n], src_f[:n] de = np.linalg.norm(to_lab(out_f) - to_lab(src_f), axis=-1) m = red_mask(src_f) return {"file": a.out, "kb": round(os.path.getsize(a.out) / 1024, 1), "width": w_out, "height": h_out, "fps": a.fps, "frames": len(delays), "delays_ms": sorted(set(delays)), "duration_s": round(sum(delays) / 1000, 3), "banner_share": round(float(m.mean()), 3), "banner_dE_mean": round(float(de[m].mean()), 2) if m.any() else None, "frame_dE_mean": round(float(de.mean()), 2)} def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("src"); p.add_argument("out") p.add_argument("--start", type=float, default=0.0); p.add_argument("--duration", type=float, default=3.0) p.add_argument("--fps", type=int, default=10); p.add_argument("--width", type=int, default=320) p.add_argument("--method", choices=["palette", "naive"], default="palette") p.add_argument("--dither", default="sierra2_4a"); p.add_argument("--quality", type=int, default=70) p.add_argument("--max-kb", type=float, default=0) p.add_argument("--denoise", action="store_true") a = p.parse_args() if a.out.endswith(".gif") and 100 % a.fps: print(f"warning: GIF delays are whole 1/100 s; {a.fps} fps cannot be timed exactly " f"(use 10, 20, 25 or 50)", file=sys.stderr) while True: w0, h0 = probe_size(a.src) w_out = a.width - a.width % 2 h_out = round(h0 * w_out / w0 / 2) * 2 chain = f"fps={a.fps},scale={w_out}:{h_out}:flags=lanczos" if a.denoise: chain = "hqdn3d=8:6:12:12," + chain encode(a, w_out, chain) kb = os.path.getsize(a.out) / 1024 if not a.max_kb or kb <= a.max_kb or a.width * 0.85 < 160: break print(f"{kb:.0f} KB at {w_out} px wide is over {a.max_kb:.0f} KB; shrinking", file=sys.stderr) a.width = int(a.width * 0.85) # too big: shrink 15% and try again r = measure(a, w_out, h_out, chain) r["ok"] = not a.max_kb or r["kb"] <= a.max_kb print(json.dumps(r)) return 0 if r["ok"] else 1 if __name__ == "__main__": sys.exit(main()) The banner mask is deliberately simple: pixels where red clearly dominates green and blue. For your own clip, check banner_share. It should roughly match how much of the frame the banner fills (about 12% in my test clip). If it's near 0, the mask missed and the banner ΔE means nothing.
Test it on a clip where you know the answer
Don't tune a converter on the clip you care about. This generator draws a sky gradient, a grainy green field, and a red banner that sways left and right, with a beep track (no people, no real footage). NOISE controls how much grain the field has.
#!/usr/bin/env bash # Synthetic stand-in for a vertical banner clip (no people, no real footage): # gradient sky, noisy green field, a red banner with white text that sways # left and right, and a soundtrack of short 880 Hz beeps at irregular times # over a quiet 220 Hz hum. # ./make_field_test_clip.sh out.mp4 [WxH] [fps] [seconds] # env NOISE=0..40 sets the field grain (default 18) set -euo pipefail out=${1:-field_test.mp4}; size=${2:-480x854}; fps=${3:-30}; dur=${4:-8} W=${size%x*}; H=${size#*x} FONT=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf BW=$((W*3/4)); BH=$((H/6)); BY=$((H*2/5)) BX="(${W}-${BW})/2+${W}/10*sin(2*PI*t/3.1)*cos(2*PI*t/7.3)" SKY=$((H*2/5)) ffmpeg -v error -y \ -f lavfi -i "gradients=s=${W}x${SKY}:c0=0x2F7FD0:c1=0xBFE3FF:x0=0:y0=0:x1=0:y1=${SKY}:r=${fps}:d=${dur}:speed=0" \ -f lavfi -i "color=c=0x3A8A3A:s=${W}x${H}:r=${fps}:d=${dur}" \ -f lavfi -i "color=c=0xC8102E:s=${BW}x${BH}:r=${fps}:d=${dur}" \ -f lavfi -i "aevalsrc='0.08*sin(2*PI*220*t)+0.6*sin(2*PI*880*t)*(between(t,0.7,0.82)+between(t,2.1,2.22)+between(t,2.6,2.72)+between(t,4.9,5.02)+between(t,6.3,6.42))':s=48000:d=${dur}" \ -filter_complex "[1:v]noise=alls=${NOISE:-18}:allf=t,format=yuv420p[field];\ [field][0:v]overlay=0:0[bg];\ [2:v]drawtext=fontfile=${FONT}:text='TEST BANNER':fontcolor=white:fontsize=$((BH/4)):x=(w-text_w)/2:y=(h-text_h)/2[ban];\ [bg][ban]overlay=x='${BX}':y=${BY},format=yuv420p[v]" \ -map "[v]" -map 3:a -c:v libx264 -preset veryfast -crf 20 -r "$fps" -c:a aac -b:a 128k -t "$dur" "$out" echo "wrote $out (${W}x${H} @ ${fps} fps, ${dur} s)" Results on my box (ffmpeg 7.1, NumPy 2.5, Pillow 13), 3 seconds from 2.0 s:
$ NOISE=6 ./make_field_test_clip.sh lo.mp4 $ python3 clip_to_gif.py lo.mp4 n.gif --method naive --start 2 --duration 3 {"kb": 463.1, "frames": 30, "delays_ms": [100], "banner_dE_mean": 26.89, "frame_dE_mean": 21.59, ...} $ python3 clip_to_gif.py lo.mp4 p.gif --start 2 --duration 3 {"kb": 2394.5, "banner_dE_mean": 1.12, "frame_dE_mean": 1.28, ...} $ python3 clip_to_gif.py lo.mp4 pd.gif --denoise --start 2 --duration 3 {"kb": 1219.4, "banner_dE_mean": 1.08, "frame_dE_mean": 1.17, ...} $ python3 clip_to_gif.py lo.mp4 w.webp --start 2 --duration 3 {"kb": 64.0, "banner_dE_mean": 3.59, "frame_dE_mean": 2.07, ...} $ python3 clip_to_gif.py lo.mp4 w12.gif --fps 12 --start 2 --duration 3 warning: GIF delays are whole 1/100 s; 12 fps cannot be timed exactly (use 10, 20, 25 or 50) {"frames": 36, "delays_ms": [80, 90], "duration_s": 3.0, ...} With the default heavier grain (NOISE=18), the palette GIF was 3,159 KB, and denoising barely helped (3,098 KB). Past a certain amount of grain, the light hqdn3d pass can't clean it up, and you need a smaller size or a shorter loop instead.
Hitting a size limit
Many places cap GIF uploads. --max-kb re-encodes with the width shrunk by 15% each time until the file fits, then measures the final one:
$ python3 clip_to_gif.py lo.mp4 cap.gif --width 480 --fps 20 --denoise --max-kb 1500 --start 2 --duration 3 2876 KB at 480 px wide is over 1500 KB; shrinking 3637 KB at 408 px wide is over 1500 KB; shrinking 2463 KB at 346 px wide is over 1500 KB; shrinking 1757 KB at 294 px wide is over 1500 KB; shrinking {"kb": 1313.0, "width": 248, "height": 442, "fps": 20, "banner_dE_mean": 1.27, "ok": true} Notice that 408 px was bigger than 480 px. At 480 px the source wasn't being scaled at all. At 408 px, the Lanczos resample added fine ringing that the palette then had to dither. File size versus width isn't monotonic for GIF, so the loop checks the actual size rather than predicting it.
Choosing settings for a Bama Bound loop
- Pick the 2–3 seconds that sell the joke, usually the banner held up and steady. The whole ~8-second clip (FAQ) is too long for a GIF.
- Start at 320 px wide and 10 fps. Go to 20 fps only if the motion looks choppy.
- Use your own palette, always. Try
--dither noneif the banner shows a crawling texture. - Use
--denoisefor grainy clips. Check the banner ΔE afterwards. It should stay under about 2. - Prefer animated WebP or a short MP4 wherever they're accepted. GIF is for places that accept nothing else.
- Sound is gone in both formats. If the soundtrack is part of the joke for your audience, post the video.
Does a higher resolution help?
Not for a GIF. You're going to scale down to 320–480 px anyway, so 480p is plent...