Saving and embedding animations with save_animation¶
Cleopatra turns any in-memory NumPy time-series into a matplotlib animation, and the helpers in
cleopatra.animation write or embed that animation with real control over
format, quality, and size.
This notebook teaches the animation-export surface refreshed in issue #185. By the end you can:
- save an animation to GIF, MP4, MOV, AVI, or animated WebP from a single call;
- trade file size against quality with
crf/bitrate/preset; - rely on odd figure dimensions and universal playback working automatically;
- export MP4 with no manual ffmpeg install; and
- render an animation straight to bytes or an inline image with
to_gif/to_mp4/to_bytes/embed_gif.
Everything runs on small synthetic arrays generated in the notebook — no data files needed.
Setup¶
Import the pieces we need and define a couple of tiny helpers. save_animation and the to_* helpers all
live in cleopatra.animation; ArrayGlyph builds the animation from an array stack.
import base64
import tempfile
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from IPython.display import HTML, Image
from cleopatra.array_glyph import ArrayGlyph
from cleopatra.animation import (
SUPPORTED_VIDEO_FORMAT,
save_animation,
to_gif,
to_mp4,
to_bytes,
embed_gif,
)
A scratch directory for the files we write, and a helper to show an animated WebP inline (browsers render
it, but IPython.display.Image does not sniff the WebP format, so we embed it as a data URI).
out_dir = Path(tempfile.mkdtemp(prefix='cleopatra_anim_'))
def show_webp(path):
"""Embed an animated .webp inline as a data URI."""
encoded = base64.b64encode(Path(path).read_bytes()).decode()
return HTML(f'<img src="data:image/webp;base64,{encoded}" alt="animated webp">')
def kib(path):
"""File size in kibibytes, rounded for display."""
return round(Path(path).stat().st_size / 1024, 1)
A synthetic time-lapse¶
We animate a Gaussian blob drifting across a 60×60 grid over 12 frames — a stand-in for any scalar field
that evolves in time (rainfall, flow accumulation, temperature). ArrayGlyph accepts a (time, rows, cols)
stack directly.
n_frames, height, width = 12, 60, 60
rows, cols = np.mgrid[0:height, 0:width]
blob_stack = np.zeros((n_frames, height, width))
for t in range(n_frames):
cx = width * (t + 1) / (n_frames + 1)
cy = height * (t + 1) / (n_frames + 1)
blob_stack[t] = np.exp(-((cols - cx) ** 2 + (rows - cy) ** 2) / (2 * (height / 6) ** 2))
frame_labels = [f't{t}' for t in range(n_frames)]
blob_stack.shape
(12, 60, 60)
Quickstart — animate, then save¶
Build the animation with ArrayGlyph.animate, preview it inline, then write it to a GIF with the simplest
possible save_animation(anim, path) call. save_animation returns the path it wrote, so it chains nicely.
glyph = ArrayGlyph(blob_stack, figsize=(4, 4), title='Drifting blob')
anim = glyph.animate(frame_labels, interval=150)
HTML(anim.to_jshtml())
The interactive player above shows exactly what will be saved. Now write it to disk as a GIF:
gif_path = save_animation(anim, str(out_dir / 'blob.gif'), fps=6)
print('wrote', gif_path, '->', kib(gif_path), 'KiB')
Image(gif_path)
wrote /tmp/cleopatra_anim_kxsa8290/blob.gif -> 253.7 KiB
<IPython.core.display.Image object>
Supported formats¶
The output format comes from the file extension. GIF and WebP are written by Pillow (no external tools); MP4/MOV/AVI use ffmpeg.
| extension | writer | needs ffmpeg | notes |
|---|---|---|---|
gif |
Pillow | no | universal, but large for photographic frames |
webp |
Pillow | no | animated; ~3-5× smaller than GIF |
mp4 |
ffmpeg | yes (bundled) | H.264, best for sharing |
mov / avi |
ffmpeg | yes (bundled) | other containers |
sorted(SUPPORTED_VIDEO_FORMAT)
['avi', 'gif', 'mov', 'mp4', 'webp']
Out-of-the-box MP4¶
MP4 export uses ffmpeg. Cleopatra resolves the binary automatically — a system ffmpeg if one is on your
PATH, otherwise the static binary bundled with imageio-ffmpeg — so this call needs no manual ffmpeg
install. The frame is auto-padded to even dimensions and encoded yuv420p for universal playback.
mp4_path = save_animation(anim, str(out_dir / 'blob.mp4'), fps=6)
print('wrote', Path(mp4_path).name, '->', kib(mp4_path), 'KiB')
wrote blob.mp4 -> 25.2 KiB
Quality vs size — crf, bitrate, preset¶
The most useful new knobs. CRF (Constant Rate Factor) is the constant-quality control: lower = higher quality and larger files, higher = smaller. It is usually the best single dial for "smallest file that still looks right".
| argument | meaning | typical value |
|---|---|---|
crf |
constant-quality factor (x264) | 18-28 (23 default) |
bitrate |
target kbit/s (mutually exclusive with crf) |
1500-8000 |
preset |
encoder speed/size trade-off | veryfast … slow |
Let's encode the same clip three ways and compare the resulting file sizes.
variants = {
'default': {},
'crf=28': {'crf': 28},
'crf=36': {'crf': 36},
}
sizes_kib = {}
for name, kwargs in variants.items():
p = save_animation(anim, str(out_dir / f'blob_{name}.mp4'), fps=6, **kwargs)
sizes_kib[name] = kib(p)
sizes_kib
{'default': 25.2, 'crf=28': 15.8, 'crf=36': 9.0}
A higher CRF visibly shrinks the file. Plotting it makes the trade-off obvious:
fig, ax = plt.subplots(figsize=(5, 3))
ax.bar(list(sizes_kib), list(sizes_kib.values()), color='#4C72B0')
ax.set_ylabel('file size (KiB)')
ax.set_title('MP4 size by rate control')
for i, v in enumerate(sizes_kib.values()):
ax.text(i, v, f'{v}', ha='center', va='bottom')
plt.show()
Passing both crf and bitrate is rejected, because they are competing rate-control modes:
try:
save_animation(anim, str(out_dir / 'bad.mp4'), fps=6, crf=20, bitrate=4000)
except ValueError as err:
print('ValueError:', err)
ValueError: Pass either crf or bitrate, not both: they are competing rate-control modes for the encoder.
Odd dimensions just work¶
H.264 requires even width and height, so a figure whose pixel size is odd used to crash the encoder. Cleopatra now pads the frame up to the next even size automatically. Here is a deliberately odd 335×255 figure animated straight through the ffmpeg path.
odd_fig = plt.figure(figsize=(3.35, 2.55), dpi=100) # 335 x 255 px (both odd)
odd_ax = odd_fig.add_subplot(111)
odd_im = odd_ax.imshow(blob_stack[0], cmap='viridis')
odd_ax.axis('off')
odd_anim = FuncAnimation(odd_fig, lambda i: (odd_im.set_data(blob_stack[i]) or odd_im,), frames=n_frames)
px_w = int(round(odd_fig.get_figwidth() * odd_fig.dpi))
px_h = int(round(odd_fig.get_figheight() * odd_fig.dpi))
odd_path = save_animation(odd_anim, str(out_dir / 'odd.mp4'), fps=6, crf=28)
plt.close(odd_fig)
print(f'{px_w}x{px_h} (odd) encoded fine -> {kib(odd_path)} KiB')
335x255 (odd) encoded fine -> 5.7 KiB
Smaller, looping GIF and animated WebP¶
For notebook embeds and the web, GIF is convenient but heavy. save_animation writes optimised GIFs and
lets you control the loop count (loop=0 loops forever). For photographic content, animated WebP is
typically several times smaller than the equivalent GIF.
gif_loop = save_animation(anim, str(out_dir / 'loop3.gif'), fps=6, loop=3)
webp_path = save_animation(anim, str(out_dir / 'blob.webp'), fps=6)
comparison = {'gif': kib(gif_path), 'webp': kib(webp_path)}
print('GIF vs WebP (KiB):', comparison)
GIF vs WebP (KiB): {'gif': 253.7, 'webp': 55.0}
The WebP renders inline just like the GIF, at a fraction of the size:
show_webp(webp_path)
Render straight to bytes — to_gif, to_mp4, to_bytes, embed_gif¶
Sometimes you want the encoded animation in memory — to serve it over HTTP, attach it, or embed it —
without leaving a file on disk. These helpers wrap save_animation and return bytes (or, for embed_gif,
an inline image). They accept the same quality kwargs.
gif_bytes = to_gif(anim, fps=6)
mp4_bytes = to_mp4(anim, fps=6, crf=30)
webp_bytes = to_bytes(anim, fmt='webp', fps=6)
for name, data in [('gif', gif_bytes), ('mp4', mp4_bytes), ('webp', webp_bytes)]:
print(f'{name:4} {len(data):>7,d} bytes magic={data[:4]!r}')
gif 259,774 bytes magic=b'GIF8' mp4 13,701 bytes magic=b'\x00\x00\x00 ' webp 56,342 bytes magic=b'RIFF'
embed_gif returns an IPython.display.Image ready to be the last expression of a cell — the one-liner for
showing an animation inline in a notebook:
embed_gif(anim, fps=6)
<IPython.core.display.Image object>
The same controls from a glyph¶
ArrayGlyph.save_animation (inherited by every glyph) forwards these keyword arguments to
cleopatra.animation.save_animation, so you get the full quality surface without reaching for the free
function.
glyph_out = out_dir / 'from_glyph.mp4'
glyph.save_animation(str(glyph_out), fps=6, crf=32, preset='veryfast')
print('ArrayGlyph.save_animation(crf=32, preset="veryfast") ->', kib(glyph_out), 'KiB')
ArrayGlyph.save_animation(crf=32, preset="veryfast") -> 12.8 KiB
plt.close('all')
Takeaway¶
- One call, many formats:
save_animation(anim, path)picks GIF / WebP / MP4 / MOV / AVI from the extension. crf(orbitrate) +presettrade size against quality;crfis the go-to dial.- Odd figure sizes,
pix_fmt=yuv420p, and ffmpeg resolution are handled for you — MP4 export needs no setup. - WebP is a much smaller alternative to GIF for embeds; GIFs are optimised and loop-controllable.
to_gif/to_mp4/to_bytes/embed_gifgive you the animation in memory or inline.
See the cleopatra.animation API reference for the full signature.