| name | animate |
| description | Generate animations from a time sequence of PNG files using Python (for frame generation) and ffmpeg (for video encoding to .mp4/.ogg). |
| user-invocable | true |
Help the user create animations from a sequence of plots/images.
The workflow has two stages: (1) generate PNG frames with Python/matplotlib, then (2) encode with ffmpeg.
Installation
conda install -c conda-forge ffmpeg matplotlib xarray cartopy
Stage 1 — Generate PNG Frames with Python
Basic matplotlib loop
import matplotlib.pyplot as plt
from pathlib import Path
out_dir = Path("frames")
out_dir.mkdir(exist_ok=True)
for i, t in enumerate(time_steps):
fig, ax = plt.subplots()
ax.set_title(str(t))
fig.savefig(out_dir / f"frame_{i:04d}.png", dpi=150, bbox_inches="tight")
plt.close(fig)
Variable frame rate (obs vs forecast, or slow/fast segments)
Write a frames.txt concat list so each frame can have its own duration:
with open("frames.txt", "w") as f:
for p in obs_frames:
f.write(f"file '{p}'\n")
f.write(f"duration {1/12:.6f}\n")
for p in fcst_frames:
f.write(f"file '{p}'\n")
f.write(f"duration {1/2:.6f}\n")
f.write(f"file '{fcst_frames[-1]}'\n")
Parsing timestamps from filenames
import re, datetime as dt
pattern = re.compile(r".*_(\d{10})\.png")
def parse_time(p):
m = pattern.match(p.name)
return dt.datetime.strptime(m.group(1), "%Y%m%d%H") if m else None
frames = sorted([p for p in Path("img_dir").glob("*.png") if parse_time(p)])
Stage 2 — Encode with ffmpeg
From a numbered sequence (img0000.png, img0001.png, …)
ffmpeg -y -f image2 -r 12 -i frames/frame_%04d.png \
-vcodec libx264 -crf 25 -pix_fmt yuv420p \
output.mp4
ffmpeg -y -f image2 -r 12 -i frames/frame_%04d.png \
-vcodec libtheora -b:v 4096k \
output.ogg
From a frames.txt concat list (variable frame rate)
ffmpeg -y -f concat -safe 0 -i frames.txt \
-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
-vsync vfr -pix_fmt yuv420p -c:v libx264 \
output.mp4
With explicit scaling
ffmpeg -y -f image2 -r 12 -i frames/frame_%04d.png \
-vf scale=1920:1080 \
-vcodec libx264 -crf 25 -pix_fmt yuv420p \
output.mp4
Key ffmpeg flags
| Flag | Meaning |
|---|
-y | Overwrite output without prompting |
-f image2 | Input is an image sequence |
-f concat -safe 0 -i frames.txt | Input is a concat list (variable duration) |
-r N | Frame rate (fps) |
-c:v libx264 | H.264 codec (MP4) |
-c:v libtheora | Theora codec (OGG) |
-crf 25 | H.264 quality (0=lossless, 51=worst; 18–28 typical) |
-b:v 4096k | Bitrate (alternative to -crf) |
-pix_fmt yuv420p | Pixel format for broad compatibility |
-vf scale=W:H | Resize output |
-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" | Ensure even dimensions (required by libx264) |
-vsync vfr | Variable frame rate (use with concat list) |
Full Python + ffmpeg Script Template
"""Generate frames and encode animation."""
import subprocess
import datetime as dt
from pathlib import Path
import matplotlib.pyplot as plt
FRAME_DIR = Path("frames")
FRAME_DIR.mkdir(exist_ok=True)
FPS = 12
for i, t in enumerate(time_steps):
fig, ax = plt.subplots(figsize=(10, 6))
fig.savefig(FRAME_DIR / f"frame_{i:04d}.png", dpi=150, bbox_inches="tight")
plt.close(fig)
subprocess.run([
"ffmpeg", "-y",
"-f", "image2", "-r", str(FPS),
"-i", str(FRAME_DIR / "frame_%04d.png"),
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v", "libx264", "-crf", "25", "-pix_fmt", "yuv420p",
"output.mp4",
], check=True)
Variable-Rate Template (obs + forecast)
"""Variable frame rate animation: fast obs, slow forecast."""
import subprocess, datetime as dt
from pathlib import Path
obs_frames = sorted(Path("obs_imgs").glob("*.png"))
fcst_frames = sorted(Path("fcst_imgs").glob("*.png"))
with open("frames.txt", "w") as f:
for p in obs_frames:
f.write(f"file '{p.resolve()}'\nduration {1/12:.6f}\n")
for p in fcst_frames:
f.write(f"file '{p.resolve()}'\nduration {1/2:.6f}\n")
f.write(f"file '{fcst_frames[-1].resolve()}'\n")
subprocess.run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", "frames.txt",
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-vsync", "vfr", "-pix_fmt", "yuv420p", "-c:v", "libx264",
"output.mp4",
], check=True)