GIF frames are often repurposed as reaction images, animation sprites, or training data for ML models. Extracting them cleanly requires handling GIF's unusual compositing model — frames can be partial updates (only the changed region), not full-frame replacements. This guide covers both approaches and explains how to handle the compositing correctly.
TL;DR
- Quick extraction:
ffmpeg -i animation.gif frames/frame_%03d.png - With timing metadata: Pillow (img.info['duration'] per frame in ms)
- Correct compositing: Pillow with explicit frame flattening (see below)
- API: POST GIF with
target=png— returns a zip of all frames
How GIF animation works (and why naive extraction fails)
Animated GIFs use a disposal method for each frame:
- 0 / Do not dispose: Leave the frame as-is under the next frame (transparent areas show previous content)
- 1 / Do not dispose (same as 0): Same effect
- 2 / Restore to background: Clear to background color before drawing next frame
- 3 / Restore to previous: Restore to state before the last frame was drawn
If you extract frames naively with img.seek(i); img.save(), frames with disposal method 0 will look like they're working correctly (they are), but frames that rely on previous state will appear as partial transparent patches. The fix is to composite each frame onto the accumulated canvas.
FFmpeg: fast extraction
FFmpeg handles GIF compositing correctly and is the fastest option for large GIFs.
# Extract all frames as PNG
ffmpeg -i animation.gif frames/frame_%03d.png
# Extract as JPG (smaller, lossy — okay for photos, bad for flat-color GIFs)
ffmpeg -i animation.gif frames/frame_%03d.jpg
# Extract only every other frame (halve frame count)
ffmpeg -i animation.gif -vf select='not(mod(n,2))' -vsync vfr frames/frame_%03d.png
# Get frame count and timing info first:
ffprobe -v quiet -show_streams animation.gif | grep -E 'nb_frames|avg_frame_rate'
Pillow: extract with timing metadata
pip install Pillow
from PIL import Image, ImageSequence
from pathlib import Path
def extract_gif_frames(gif_path: str, out_dir: str, fmt: str = "PNG") -> list[dict]:
"""
Extract all frames from a GIF with per-frame timing.
Returns list of {frame_num, duration_ms, path}.
"""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
gif = Image.open(gif_path)
frames = []
for i, frame in enumerate(ImageSequence.Iterator(gif)):
# Convert to RGBA to handle transparency correctly
frame_rgba = frame.convert("RGBA")
duration_ms = frame.info.get("duration", 100) # default 100ms if missing
ext = fmt.lower()
out_path = out_dir / f"frame_{i:03d}.{ext}"
if fmt == "JPEG":
# JPEG doesn't support alpha — composite onto white
bg = Image.new("RGB", frame_rgba.size, (255, 255, 255))
bg.paste(frame_rgba, mask=frame_rgba.split()[3])
bg.save(out_path, "JPEG", quality=90)
else:
frame_rgba.save(out_path, fmt)
frames.append({"frame": i, "duration_ms": duration_ms, "path": str(out_path)})
return frames
result = extract_gif_frames("animation.gif", "./frames")
print(f"Extracted {len(result)} frames")
for f in result[:3]:
print(f" Frame {f['frame']}: {f['duration_ms']}ms → {f['path']}")
# Frame 0: 100ms → frames/frame_000.png
# Frame 1: 150ms → frames/frame_001.png
# Frame 2: 100ms → frames/frame_002.png
Correct compositing (for GIFs with disposal methods):
def extract_frames_composited(gif_path: str, out_dir: str) -> int:
"""Properly flatten each frame onto accumulated canvas."""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
gif = Image.open(gif_path)
canvas = Image.new("RGBA", gif.size, (0, 0, 0, 0))
count = 0
for i in range(gif.n_frames):
gif.seek(i)
frame = gif.convert("RGBA")
disposal = gif.disposal_method if hasattr(gif, 'disposal_method') else 0
if disposal == 2: # restore to background
canvas = Image.new("RGBA", gif.size, (0, 0, 0, 0))
canvas.paste(frame, (0, 0), frame)
out = out_dir / f"frame_{i:03d}.png"
canvas.copy().save(out)
count += 1
return count
ChangeThisFile API
# GIF → PNG frames via API (returns zip)
curl -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer ctf_sk_your_key_here" \
-F "file=@animation.gif" \
-F "target=png" \
--output frames.zip
unzip frames.zip -d ./frames
import requests
import zipfile
from pathlib import Path
import io
API_KEY = "ctf_sk_your_key_here"
def extract_gif_via_api(gif_path: str, out_dir: str) -> int:
with open(gif_path, "rb") as f:
resp = requests.post(
"https://changethisfile.com/v1/convert",
headers={"Authorization": f"Bearer {API_KEY}"},
files={"file": f},
data={"target": "png"},
timeout=60,
)
resp.raise_for_status()
out_dir = Path(out_dir)
out_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
zf.extractall(out_dir)
frames = list(out_dir.glob("*.png"))
return len(frames)
count = extract_gif_via_api("animation.gif", "./frames")
print(f"Extracted {count} frames")
Edge cases and gotchas
- GIF color palette limit. GIFs are limited to 256 colors per frame. Extracted frames look different from the original source art if the GIF was created from a high-color source. The frames accurately represent what the GIF displays, not the original.
- Large frame counts. A 30fps GIF exported from video can have hundreds of frames. A 10-second GIF at 25fps = 250 frames. Make sure your out_dir has space.
- Looping information. GIF looping count is in the NETSCAPE 2.0 extension block. Pillow exposes it as
gif.info.get('loop', 0)(0 = infinite loop). This affects playback but not frame extraction. - Zero-duration frames. Some GIFs have frames with duration=0 (used as a trigger frame in some tools). These are valid GIF frames — extract them like any other. If you're reconstructing the animation, skip 0-duration frames or default them to 100ms.
Reassemble frames into a new GIF
After extracting and modifying frames (e.g., adding a watermark), reassemble into a new GIF:
from PIL import Image
from pathlib import Path
def frames_to_gif(frames_dir: str, out_gif: str, duration_ms: int = 100, loop: int = 0):
frames = sorted(Path(frames_dir).glob("*.png"))
images = [Image.open(f).convert("RGBA") for f in frames]
images[0].save(
out_gif,
save_all=True,
append_images=images[1:],
duration=duration_ms,
loop=loop, # 0 = infinite
optimize=True,
)
print(f"Wrote {len(images)}-frame GIF to {out_gif}")
frames_to_gif("./frames", "new-animation.gif", duration_ms=80)
For most GIF extraction tasks, FFmpeg is the simplest one-liner. Use Pillow when you need per-frame timing data or want to modify frames before reassembly. API free tier for occasional GIF processing.