Skip to content

Animation Module — Save / Embed Helpers for Any FuncAnimation#

The cleopatra.glyphs.base.animation module exposes cleopatra's animation save / inline-embed machinery as glyph-independent helpers. They operate on any matplotlib.animation.FuncAnimation — a sine wave, stock prices, or a map — not only on a Glyph's internal self.anim:

  • save_animation(anim, path, fps=2, ...) writes an animation to a file, choosing the writer from the extension: gif and webp via Pillow, mov/avi/mp4 via FFmpeg. The extension is matched case-insensitively. Quality controls are keyword-only: crf / bitrate (mutually exclusive), codec, preset, pix_fmt, and dpi for the FFmpeg formats; optimize and loop for the Pillow (GIF/WebP) formats; plus extra_args passed straight through to the writer.
  • to_bytes(anim, fmt="gif", fps=2, ...) renders to in-memory bytes in any supported format (temp file cleaned up afterwards); to_gif(...) and to_mp4(...) are thin wrappers for the two common formats.
  • embed_gif(anim, fps=2) returns an IPython.display.Image for inline notebook display. IPython is imported lazily (and is bundled with Jupyter, so any notebook already has it); if it is absent, embed_gif raises a clear ModuleNotFoundError with a pip install ipython hint — or use to_gif for raw bytes with no IPython dependency.
  • gif_from_video(src, path, fps=12, width=None, max_colors=254, ...) derives a GIF from a video already on disk, without re-rendering. Drawing is usually far more expensive than encoding, so a long clip is best rendered once to MP4 and every other format derived from that file.

The GIF palette#

Both GIF paths — save_animation and gif_from_video — quantise through one palette shared by every frame, built by build_clip_palette from the colours the whole clip contains and applied by quantize_to_palette. Both are public, so a downstream package writing its own frames can reuse the same table rather than re-deriving one. Per-frame palettes would make constant regions shimmer and let a colour drift between frames; two of the 256 entries are pinned to pure black and white so single-colour overlays stay crisp.

The palette is chosen for colour coverage, not pixel population, over the set of colours the clip contains. The distinction matters on exactly the clips this package produces: with a population-weighted split (median cut) a large textured background claims nearly every palette slot, and small saturated marks — overlay glyphs, thin paths, labels — collapse to the nearest muddy neighbour. On the texture-heavy clip in tests/test_animation.py those marks landed 100–180 away (in RGB distance) from the colours they were drawn in; selecting for coverage reproduces them exactly, and TestClipPaletteQuality asserts it — so the claim is checked, not remembered.

Because coverage is computed over distinct colours rather than pixels, a mark survives no matter how small it is: a one-pixel orbit path is kept as faithfully as a large glyph. Sampling the frames spatially to build a cheaper palette source would undo that — an interpolating resize blends a one-pixel mark into its background before the quantiser ever sees it.

The trade is a marginally coarser background, because palette entries now go to colours the clip contains rather than to the colours it contains most of, and file size moves either way depending on the clip. Both were measured while developing this and neither is asserted by a test, so treat the direction as reliable and the magnitude as indicative.

quantize_method is the opt-out, on both save_animation and gif_from_video. It takes a key of QUANTIZE_METHODS"coverage" (the default), "median", or "octree". Reach for "median" on a smooth photographic clip with no small marks at stake: it splits the colour cube by how densely the clip populates it, so the crowded regions a photographic background occupies win the table. Note none of these see pixel counts — the palette is built from each colour once, so they weight by distinct colours, not by area:

save_animation(anim, "clip.gif", fps=12, quantize_method="median")

Render the intermediate with pix_fmt="yuv444p" if a GIF will be derived from it

save_animation writes yuv420p by default — the right choice for playback compatibility, but it stores colour at half resolution in each direction. That loss happens before the GIF palette ever runs, and no quantiser can undo it: on the same test clip a yuv420p intermediate caps the derived GIF at ~50 RGB distance, against ~5 from a yuv444p one. gif_from_video emits a UserWarning when it is handed a subsampled source.

Memory

gif_from_video decodes the source twice rather than holding it, so the decoded RGB frames are never all resident. The quantised frames still are — Pillow's GIF encoder accumulates every frame before writing its first byte. Expect roughly width × height × frames bytes at peak: a third of what the RGB frames would cost, but still proportional to the clip's length. Use width to bring a long master down.

mp4 = save_animation(anim, "master.mp4", fps=12, crf=0, pix_fmt="yuv444p")
gif_from_video(mp4, "web.gif", fps=12, width=720)

SUPPORTED_VIDEO_FORMAT is ["gif", "mov", "avi", "mp4", "webp"]. Glyph.save_animation delegates to save_animation, so the writer/format logic has a single source of truth. Downstream packages that build their own FuncAnimation can reuse these helpers instead of re-rolling temp-file + writer + IPython.display glue.

Usage#

import matplotlib
matplotlib.use("Agg")  # any backend; Agg shown for headless rendering
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

from cleopatra.glyphs.base.animation import embed_gif, save_animation, to_gif

# Build any FuncAnimation — no Glyph required.
fig, ax = plt.subplots()
(line,) = ax.plot([0, 1], [0, 0])


def update(i):
    line.set_ydata([0, i])
    return (line,)


anim = FuncAnimation(fig, update, frames=3, blit=True)

save_animation(anim, "wave.gif", fps=3)   # write to a file (gif/webp/mov/avi/mp4)
save_animation(anim, "wave.mp4", fps=3, crf=18)  # quality-controlled MP4
gif_bytes = to_gif(anim, fps=3)           # in-memory GIF bytes
embed_gif(anim, fps=3)                     # inline in a notebook cell

Note

The output format is taken from the file extension and is matched case-insensitively (out.GIF works). gif / webp are written with Pillow (no FFmpeg needed). Video formats (mov/avi/mp4) use FFmpeg: cleopatra depends on imageio-ffmpeg, which bundles a static FFmpeg binary, so video export works out of the box — a system FFmpeg on the PATH is used in preference when present. A FileNotFoundError (pointing at https://ffmpeg.org/) is raised only if no FFmpeg — bundled or system — is available; an unsupported extension raises a ValueError. embed_gif imports IPython only when called; if IPython is absent it raises a ModuleNotFoundError with a pip install ipython hint (use to_gif to avoid IPython).

Full colour range by default

The FFmpeg export (mov/avi/mp4) is encoded full colour range — the pixel format is swapped for its full-range yuvj* variant (yuv420p becomes yuvj420p) so it maps to the true 0-255 range on every FFmpeg build. The computer-generated, full-range matplotlib figure then keeps its contrast instead of being squeezed into FFmpeg's limited/broadcast default (16-235), which visibly washes it out. Pass extra_args=["-color_range", "tv"] to restore the old limited/broadcast-range behaviour — the most predictable option for the minority of players that ignore the full-range flag.

Module Documentation#

cleopatra.glyphs.base.animation #

Save/embed helpers for matplotlib animations (glyph-independent).

These helpers operate on any matplotlib.animation.FuncAnimation, not only on a Glyph's internal self.anim. Saving or embedding an animation is generic matplotlib machinery — it works on a sine wave, stock prices, or a map — so it lives here alongside the glyph classes that produce animations. Downstream packages that build their own FuncAnimation can reuse cleopatra's writer/format handling instead of re-rolling temp-file + writer + IPython.display glue.

Glyph.save_animation delegates to save_animation below, so the writer/format logic has a single source of truth.

build_clip_palette(frames, colors=CLIP_PALETTE_COLORS, method='coverage') #

Build one colour palette shared by every frame of a clip.

Quantising each frame independently makes constant regions shimmer and lets the same colour drift between frames, so one table is derived from the whole clip and every frame is mapped through it.

The table is chosen for colour coverage (Pillow's MAXCOVERAGE) over the set of colours the clip contains, gathered by _clip_gamut. Median cut, the obvious alternative, splits by pixel population instead: on a clip with a large textured area the background claims nearly every slot and small saturated marks -- overlay glyphs, thin paths, labels -- collapse to the nearest muddy neighbour. Because coverage is computed over distinct colours rather than pixels, a mark survives no matter how few pixels it covers; a single-pixel mark is kept as faithfully as a large one.

Parameters:

Name Type Description Default
frames Iterable[Image]

The clip's frames as RGB PIL.Image.Image objects.

required
colors int

How many palette entries to quantise to. The remaining entries up to 256 are reserved -- pure black and white are pinned so single-colour overlays stay crisp.

CLIP_PALETTE_COLORS
method str

Which strategy picks the entries; a key of QUANTIZE_METHODS. Defaults to "coverage", which spreads them across the clip's colour range. "median" splits the colour cube by how densely the clip populates it, so it spends the table on the crowded regions a photographic background occupies -- worth choosing for a smooth clip with no small marks at stake, where that renders the dominant colours a little more finely. Note it sees each colour once, not once per pixel, so it weights by distinct colours rather than by area.

'coverage'

Returns:

Type Description
Image

PIL.Image.Image: A "P"-mode image carrying the shared palette,

Image

ready to pass to Image.quantize(palette=...).

Raises:

Type Description
ValueError

If frames is empty, if colors is outside 2-254 -- above 254 the reserved black and white would displace chosen entries -- or if method is not a key of QUANTIZE_METHODS.

Examples:

  • Pure black and white are held back at the top of the table, so a single-colour overlay drawn on the clip stays crisp:
    >>> from PIL import Image
    >>> from cleopatra.glyphs.base.animation import build_clip_palette
    >>> frames = [
    ...     Image.new("RGB", (12, 12), (200, 30, 30)),
    ...     Image.new("RGB", (12, 12), (30, 30, 200)),
    ... ]
    >>> entries = build_clip_palette(frames).getpalette()
    >>> entries[254 * 3 : 254 * 3 + 3]
    [0, 0, 0]
    >>> entries[255 * 3 : 255 * 3 + 3]
    [255, 255, 255]
    
  • A smaller budget moves the reserved pair up behind it, so asking for 16 colours still leaves black and white reachable:
    >>> from PIL import Image
    >>> from cleopatra.glyphs.base.animation import build_clip_palette
    >>> frames = [
    ...     Image.new("RGB", (12, 12), (200, 30, 30)),
    ...     Image.new("RGB", (12, 12), (30, 30, 200)),
    ... ]
    >>> entries = build_clip_palette(frames, colors=16).getpalette()
    >>> entries[16 * 3 : 16 * 3 + 3]
    [0, 0, 0]
    
  • The table spans the whole clip, so a colour introduced only in the last frame is still represented:

    >>> from PIL import Image
    >>> from cleopatra.glyphs.base.animation import build_clip_palette
    >>> frames = [Image.new("RGB", (9, 9), (0, 0, 0))] * 4
    >>> frames.append(Image.new("RGB", (9, 9), (255, 0, 255)))
    >>> entries = build_clip_palette(frames).getpalette()
    >>> triples = [tuple(entries[i : i + 3]) for i in range(0, 254 * 3, 3)]
    >>> any(r > 200 and g < 40 and b > 200 for r, g, b in triples)
    True
    

  • The strategy is selectable. On a clip with few enough colours to fit the budget every strategy keeps them all -- the choice only starts to matter once colours must be discarded:

    >>> from PIL import Image
    >>> from cleopatra.glyphs.base.animation import (
    ...     QUANTIZE_METHODS,
    ...     build_clip_palette,
    ... )
    >>> sorted(QUANTIZE_METHODS)
    ['coverage', 'median', 'octree']
    >>> frame = Image.new("RGB", (40, 40), (30, 30, 30))
    >>> frame.putpixel((0, 0), (255, 0, 255))  # one magenta pixel
    >>> table = build_clip_palette([frame], colors=4, method="median").getpalette()
    >>> triples = [tuple(table[i : i + 3]) for i in range(0, 4 * 3, 3)]
    >>> any(r > 200 and g < 40 and b > 200 for r, g, b in triples)
    True
    

See Also

quantize_to_palette: Map the frames onto the palette this returns. gif_from_video: Derives a GIF through this same palette.

Source code in src/cleopatra/glyphs/base/animation.py
def build_clip_palette(
    frames: Iterable[PILImage.Image],
    colors: int = CLIP_PALETTE_COLORS,
    method: str = "coverage",
) -> PILImage.Image:
    """Build one colour palette shared by every frame of a clip.

    Quantising each frame independently makes constant regions shimmer and lets
    the same colour drift between frames, so one table is derived from the whole
    clip and every frame is mapped through it.

    The table is chosen for colour *coverage* (Pillow's `MAXCOVERAGE`) over the
    set of colours the clip contains, gathered by `_clip_gamut`. Median cut, the
    obvious alternative, splits by pixel population instead: on a clip with a
    large textured area the background claims nearly every slot and small
    saturated marks -- overlay glyphs, thin paths, labels -- collapse to the
    nearest muddy neighbour. Because coverage is computed over distinct colours
    rather than pixels, a mark survives no matter how few pixels it covers; a
    single-pixel mark is kept as faithfully as a large one.

    Args:
        frames: The clip's frames as RGB `PIL.Image.Image` objects.
        colors: How many palette entries to quantise to. The remaining entries
            up to 256 are reserved -- pure black and white are pinned so
            single-colour overlays stay crisp.
        method: Which strategy picks the entries; a key of `QUANTIZE_METHODS`.
            Defaults to ``"coverage"``, which spreads them across the clip's
            colour range. ``"median"`` splits the colour cube by how densely
            the clip populates it, so it spends the table on the crowded regions
            a photographic background occupies -- worth choosing for a smooth
            clip with no small marks at stake, where that renders the dominant
            colours a little more finely. Note it sees each colour once, not
            once per pixel, so it weights by distinct colours rather than by
            area.

    Returns:
        PIL.Image.Image: A ``"P"``-mode image carrying the shared palette,
        ready to pass to `Image.quantize(palette=...)`.

    Raises:
        ValueError: If `frames` is empty, if `colors` is outside ``2-254`` --
            above 254 the reserved black and white would displace chosen
            entries -- or if `method` is not a key of `QUANTIZE_METHODS`.

    Examples:
        - Pure black and white are held back at the top of the table, so a
          single-colour overlay drawn on the clip stays crisp:
            ```python
            >>> from PIL import Image
            >>> from cleopatra.glyphs.base.animation import build_clip_palette
            >>> frames = [
            ...     Image.new("RGB", (12, 12), (200, 30, 30)),
            ...     Image.new("RGB", (12, 12), (30, 30, 200)),
            ... ]
            >>> entries = build_clip_palette(frames).getpalette()
            >>> entries[254 * 3 : 254 * 3 + 3]
            [0, 0, 0]
            >>> entries[255 * 3 : 255 * 3 + 3]
            [255, 255, 255]

            ```
        - A smaller budget moves the reserved pair up behind it, so asking for
          16 colours still leaves black and white reachable:
            ```python
            >>> from PIL import Image
            >>> from cleopatra.glyphs.base.animation import build_clip_palette
            >>> frames = [
            ...     Image.new("RGB", (12, 12), (200, 30, 30)),
            ...     Image.new("RGB", (12, 12), (30, 30, 200)),
            ... ]
            >>> entries = build_clip_palette(frames, colors=16).getpalette()
            >>> entries[16 * 3 : 16 * 3 + 3]
            [0, 0, 0]

            ```
        - The table spans the whole clip, so a colour introduced only in the
          last frame is still represented:
            ```python
            >>> from PIL import Image
            >>> from cleopatra.glyphs.base.animation import build_clip_palette
            >>> frames = [Image.new("RGB", (9, 9), (0, 0, 0))] * 4
            >>> frames.append(Image.new("RGB", (9, 9), (255, 0, 255)))
            >>> entries = build_clip_palette(frames).getpalette()
            >>> triples = [tuple(entries[i : i + 3]) for i in range(0, 254 * 3, 3)]
            >>> any(r > 200 and g < 40 and b > 200 for r, g, b in triples)
            True

            ```

        - The strategy is selectable. On a clip with few enough colours to fit
          the budget every strategy keeps them all -- the choice only starts to
          matter once colours must be discarded:
            ```python
            >>> from PIL import Image
            >>> from cleopatra.glyphs.base.animation import (
            ...     QUANTIZE_METHODS,
            ...     build_clip_palette,
            ... )
            >>> sorted(QUANTIZE_METHODS)
            ['coverage', 'median', 'octree']
            >>> frame = Image.new("RGB", (40, 40), (30, 30, 30))
            >>> frame.putpixel((0, 0), (255, 0, 255))  # one magenta pixel
            >>> table = build_clip_palette([frame], colors=4, method="median").getpalette()
            >>> triples = [tuple(table[i : i + 3]) for i in range(0, 4 * 3, 3)]
            >>> any(r > 200 and g < 40 and b > 200 for r, g, b in triples)
            True

            ```

    See Also:
        quantize_to_palette: Map the frames onto the palette this returns.
        gif_from_video: Derives a GIF through this same palette.
    """
    if method not in QUANTIZE_METHODS:
        raise ValueError(
            f"method must be one of {sorted(QUANTIZE_METHODS)}, got {method!r}."
        )
    if not 2 <= colors <= CLIP_PALETTE_COLORS:
        # Above 254 the reserved black/white pair would overwrite chosen entries,
        # and Pillow rejects 256 outright with a bare "invalid palette size".
        raise ValueError(f"colors must be in 2-{CLIP_PALETTE_COLORS}, got {colors!r}.")

    census = _clip_gamut(frames)
    base = census.quantize(colors=colors, method=QUANTIZE_METHODS[method])
    entries = (list(base.getpalette() or []) + [0] * 768)[:768]
    entries[colors * 3 : colors * 3 + 6] = [0, 0, 0, 255, 255, 255]
    palette = PILImage.new("P", (1, 1))
    palette.putpalette(entries)
    return palette

embed_gif(anim, fps=2) #

Return an IPython.display.Image of the animation for inline display.

IPython is imported lazily, so importing cleopatra never requires it. IPython ships with Jupyter, so any notebook already has it; outside a notebook the returned Image is not renderable anyway — use to_gif for raw bytes with no IPython dependency.

Parameters:

Name Type Description Default
anim FuncAnimation

The animation to embed.

required
fps int

Frames per second. Default is 2.

2

Returns:

Type Description
Image

An IPython.display.Image wrapping the rendered GIF, ready to be

Image

returned as the last expression of a notebook cell.

Raises:

Type Description
ModuleNotFoundError

If IPython is not installed, with a hint to pip install ipython (or to use to_gif instead).

Examples:

  • Wrap an animation as an inline image and read back its payload:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import embed_gif
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> img = embed_gif(anim)
    >>> img.format
    'gif'
    >>> img.data[:6] in (b"GIF87a", b"GIF89a")
    True
    >>> plt.close(fig)
    
  • Returning the image as a cell's last expression renders it inline; a custom fps controls playback speed:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import embed_gif
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> img = embed_gif(anim, fps=3)
    >>> len(img.data) > 0
    True
    >>> plt.close(fig)
    
See Also

to_gif: Produce the underlying GIF bytes without IPython. save_animation: Write the animation to a file path instead.

Source code in src/cleopatra/glyphs/base/animation.py
def embed_gif(anim: FuncAnimation, fps: int = 2) -> Image:
    """Return an `IPython.display.Image` of the animation for inline display.

    IPython is imported lazily, so importing cleopatra never requires it.
    IPython ships with Jupyter, so any notebook already has it; outside a
    notebook the returned `Image` is not renderable anyway — use `to_gif`
    for raw bytes with no IPython dependency.

    Args:
        anim: The animation to embed.
        fps: Frames per second. Default is 2.

    Returns:
        An `IPython.display.Image` wrapping the rendered GIF, ready to be
        returned as the last expression of a notebook cell.

    Raises:
        ModuleNotFoundError: If IPython is not installed, with a hint to
            `pip install ipython` (or to use `to_gif` instead).

    Examples:
        - Wrap an animation as an inline image and read back its payload:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import embed_gif
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> img = embed_gif(anim)
            >>> img.format
            'gif'
            >>> img.data[:6] in (b"GIF87a", b"GIF89a")
            True
            >>> plt.close(fig)

            ```
        - Returning the image as a cell's last expression renders it inline;
          a custom `fps` controls playback speed:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import embed_gif
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> img = embed_gif(anim, fps=3)
            >>> len(img.data) > 0
            True
            >>> plt.close(fig)

            ```

    See Also:
        to_gif: Produce the underlying GIF bytes without IPython.
        save_animation: Write the animation to a file path instead.
    """
    try:
        from IPython.display import Image
    except ModuleNotFoundError as e:
        if e.name and e.name.split(".")[0] != "IPython":
            raise
        raise ModuleNotFoundError(
            "embed_gif requires IPython for inline display. Install it with "
            "`pip install ipython` (already present in any Jupyter/IPython "
            "environment). For raw GIF bytes without IPython, use to_gif()."
        ) from e

    return Image(data=to_gif(anim, fps=fps), format="gif")

gif_from_video(src, path, *, fps=12, width=None, max_colors=CLIP_PALETTE_COLORS, loop=0, optimize=True, quantize_method='coverage') #

Derive a GIF from an existing video, without re-rendering the frames.

Drawing is usually far more expensive than encoding -- hours, for a long scientific animation -- so a clip is best rendered once to a video and every other format derived from that file. save_animation needs a live FuncAnimation and would re-render; this reads the frames back off disk instead.

The frames go through exactly the same clip-wide palette as save_animation's GIF path (build_clip_palette), so a GIF derived from a video and one rendered straight from the animation quantise identically.

The source is decoded twice -- once to learn the clip's colours, once to quantise and write -- rather than decoded once into a list. That keeps the decoded RGB frames from ever being held together, which is the larger of the two costs: 150 frames of 720p are 415 MB as RGB against 138 MB as palette indices.

It does not make memory flat in the clip's length. Pillow's GIF encoder accumulates every frame before writing its first byte, so the quantised frames are all resident by the end -- measured at a 154 MB peak for those same 150 frames. A clip whose palette-indexed frames do not fit in memory will still not encode; use width to bring them down.

Parameters:

Name Type Description Default
src str | PathLike

The source video. Any container the bundled FFmpeg can decode.

required
path str | PathLike

Where to write the GIF.

required
fps float

Frames per second to sample the source at. Frames are dropped or duplicated by FFmpeg's fps filter as needed. Defaults to 12.

12
width int | None

Scale the output to this width in pixels, preserving aspect. None (the default) keeps the source's own size.

None
max_colors int

Palette size, in 2-254. The rest of the 256 entries are reserved for pure black and white.

CLIP_PALETTE_COLORS
loop int

How many times the GIF loops; 0 loops forever.

0
optimize bool

Run Pillow's optimisation pass.

True
quantize_method str

Which strategy picks the shared palette; a key of QUANTIZE_METHODS. Defaults to "coverage".

'coverage'

Returns:

Type Description
str

The output path as a str, convenient for chaining.

Raises:

Type Description
FileNotFoundError

If src does not exist, or if neither a system FFmpeg nor imageio-ffmpeg's bundled binary can be found.

ValueError

If max_colors is outside 2-254, if fps is not positive, if width is not positive, if loop is negative, if path does not end in .gif, or if src yields no frames.

Warns:

Type Description
UserWarning

If src is chroma-subsampled (e.g. the yuv420p that save_animation writes by default). Colour resolution is already gone from such a file, which caps how well saturated detail can survive whatever the GIF palette then does -- render the intermediate with pix_fmt="yuv444p" and a low crf when the plan is to derive a GIF from it.

Examples:

  • Render an animation once to MP4, then derive a GIF from that file:
    >>> import os, shutil, tempfile, matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import gif_from_video, save_animation
    >>> tmp = tempfile.mkdtemp()
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=4)
    >>> mp4 = save_animation(anim, os.path.join(tmp, "clip.mp4"), fps=4,
    ...                      pix_fmt="yuv444p")
    >>> gif = gif_from_video(mp4, os.path.join(tmp, "clip.gif"), fps=4)
    >>> from pathlib import Path
    >>> Path(gif).read_bytes()[:6] in (b"GIF87a", b"GIF89a")
    True
    >>> plt.close(fig)
    >>> shutil.rmtree(tmp)
    
  • width scales the output for a web copy, keeping the aspect ratio of the source and leaving the master untouched:
    >>> import os, shutil, tempfile, matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from PIL import Image
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import gif_from_video, save_animation
    >>> tmp = tempfile.mkdtemp()
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=4)
    >>> mp4 = save_animation(anim, os.path.join(tmp, "master.mp4"), fps=4,
    ...                      pix_fmt="yuv444p")
    >>> gif = gif_from_video(mp4, os.path.join(tmp, "web.gif"), fps=4, width=160)
    >>> with Image.open(gif) as web:
    ...     web.size
    (160, 120)
    >>> plt.close(fig)
    >>> shutil.rmtree(tmp)
    
  • A source that does not exist is reported up front, rather than failing later inside the decoder:
    >>> from cleopatra.glyphs.base.animation import gif_from_video
    >>> gif_from_video("no-such-clip.mp4", "out.gif")
    Traceback (most recent call last):
        ...
    FileNotFoundError: The source video 'no-such-clip.mp4' does not exist.
    
See Also

save_animation: Write a live FuncAnimation straight to a file. build_clip_palette: The shared palette both paths quantise through.

Source code in src/cleopatra/glyphs/base/animation.py
def gif_from_video(
    src: str | os.PathLike,
    path: str | os.PathLike,
    *,
    fps: float = 12,
    width: int | None = None,
    max_colors: int = CLIP_PALETTE_COLORS,
    loop: int = 0,
    optimize: bool = True,
    quantize_method: str = "coverage",
) -> str:
    """Derive a GIF from an existing video, without re-rendering the frames.

    Drawing is usually far more expensive than encoding -- hours, for a long
    scientific animation -- so a clip is best rendered once to a video and every
    other format derived from that file. `save_animation` needs a live
    `FuncAnimation` and would re-render; this reads the frames back off disk
    instead.

    The frames go through exactly the same clip-wide palette as
    `save_animation`'s GIF path (`build_clip_palette`), so a GIF derived from a
    video and one rendered straight from the animation quantise identically.

    The source is decoded twice -- once to learn the clip's colours, once to
    quantise and write -- rather than decoded once into a list. That keeps the
    decoded RGB frames from ever being held together, which is the larger of the
    two costs: 150 frames of 720p are 415 MB as RGB against 138 MB as palette
    indices.

    It does **not** make memory flat in the clip's length. Pillow's GIF encoder
    accumulates every frame before writing its first byte, so the quantised
    frames are all resident by the end -- measured at a 154 MB peak for those
    same 150 frames. A clip whose palette-indexed frames do not fit in memory
    will still not encode; use `width` to bring them down.

    Args:
        src: The source video. Any container the bundled FFmpeg can decode.
        path: Where to write the GIF.
        fps: Frames per second to sample the source at. Frames are dropped or
            duplicated by FFmpeg's `fps` filter as needed. Defaults to `12`.
        width: Scale the output to this width in pixels, preserving aspect.
            `None` (the default) keeps the source's own size.
        max_colors: Palette size, in ``2-254``. The rest of the 256 entries are
            reserved for pure black and white.
        loop: How many times the GIF loops; `0` loops forever.
        optimize: Run Pillow's optimisation pass.
        quantize_method: Which strategy picks the shared palette; a key of
            `QUANTIZE_METHODS`. Defaults to ``"coverage"``.

    Returns:
        The output path as a `str`, convenient for chaining.

    Raises:
        FileNotFoundError: If `src` does not exist, or if neither a system
            FFmpeg nor imageio-ffmpeg's bundled binary can be found.
        ValueError: If `max_colors` is outside ``2-254``, if `fps` is not
            positive, if `width` is not positive, if `loop` is negative, if
            `path` does not end in ``.gif``, or if `src` yields no frames.

    Warns:
        UserWarning: If `src` is chroma-subsampled (e.g. the ``yuv420p`` that
            `save_animation` writes by default). Colour resolution is already
            gone from such a file, which caps how well saturated detail can
            survive whatever the GIF palette then does -- render the
            intermediate with ``pix_fmt="yuv444p"`` and a low `crf` when the
            plan is to derive a GIF from it.

    Examples:
        - Render an animation once to MP4, then derive a GIF from that file:
            ```python
            >>> import os, shutil, tempfile, matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import gif_from_video, save_animation
            >>> tmp = tempfile.mkdtemp()
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=4)
            >>> mp4 = save_animation(anim, os.path.join(tmp, "clip.mp4"), fps=4,
            ...                      pix_fmt="yuv444p")
            >>> gif = gif_from_video(mp4, os.path.join(tmp, "clip.gif"), fps=4)
            >>> from pathlib import Path
            >>> Path(gif).read_bytes()[:6] in (b"GIF87a", b"GIF89a")
            True
            >>> plt.close(fig)
            >>> shutil.rmtree(tmp)

            ```
        - `width` scales the output for a web copy, keeping the aspect ratio of
          the source and leaving the master untouched:
            ```python
            >>> import os, shutil, tempfile, matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from PIL import Image
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import gif_from_video, save_animation
            >>> tmp = tempfile.mkdtemp()
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=4)
            >>> mp4 = save_animation(anim, os.path.join(tmp, "master.mp4"), fps=4,
            ...                      pix_fmt="yuv444p")
            >>> gif = gif_from_video(mp4, os.path.join(tmp, "web.gif"), fps=4, width=160)
            >>> with Image.open(gif) as web:
            ...     web.size
            (160, 120)
            >>> plt.close(fig)
            >>> shutil.rmtree(tmp)

            ```
        - A source that does not exist is reported up front, rather than
          failing later inside the decoder:
            ```python
            >>> from cleopatra.glyphs.base.animation import gif_from_video
            >>> gif_from_video("no-such-clip.mp4", "out.gif")
            Traceback (most recent call last):
                ...
            FileNotFoundError: The source video 'no-such-clip.mp4' does not exist.

            ```

    See Also:
        save_animation: Write a live `FuncAnimation` straight to a file.
        build_clip_palette: The shared palette both paths quantise through.
    """
    src = os.fspath(src)
    path = os.fspath(path)
    if not os.path.isfile(src):
        raise FileNotFoundError(f"The source video {src!r} does not exist.")
    if not 2 <= max_colors <= CLIP_PALETTE_COLORS:
        raise ValueError(
            f"max_colors must be in 2-{CLIP_PALETTE_COLORS}, got {max_colors!r}."
        )
    if width is not None and width <= 0:
        raise ValueError(f"width must be positive, got {width!r}.")
    # Pillow picks its encoder from the extension, so a stray one silently
    # writes a different format -- .png yields an APNG that no caller of a
    # function named gif_from_video is expecting.
    extension = os.path.splitext(path)[1].lstrip(".").lower()
    if extension != "gif":
        raise ValueError(
            f"gif_from_video writes GIFs, but {path!r} implies {extension or 'no'} "
            "format. Use a .gif extension."
        )
    _validate_pillow_options(fps, loop)

    meta = _video_metadata(src)
    if _is_chroma_subsampled(meta.get("pix_fmt")):
        warnings.warn(
            f"{src!r} is {meta.get('pix_fmt')}, which stores colour at reduced "
            "resolution; saturated detail is already degraded before the GIF "
            "palette sees it. Render the intermediate with pix_fmt='yuv444p' "
            "and a low crf when a GIF will be derived from it.",
            UserWarning,
            stacklevel=2,
        )

    # Two passes rather than one buffered one: the palette must see the whole
    # clip before any frame can be quantised. The survey pass keeps nothing, so
    # the decoded RGB frames are never all resident -- the dominant cost. The
    # write pass is still bounded by Pillow, which accumulates the quantised
    # frames before emitting anything; those are a third the size.
    survey = _iter_video_frames(src, fps, width)
    first = next(survey, None)
    if first is None:
        raise ValueError(f"The source video {src!r} yielded no frames.")
    palette = build_clip_palette(
        itertools.chain([first], survey), colors=max_colors, method=quantize_method
    )

    quantised = (
        frame.quantize(palette=palette, dither=PILImage.Dither.FLOYDSTEINBERG)
        for frame in _iter_video_frames(src, fps, width)
    )
    _write_pillow_animation(quantised, path, fps, loop, optimize)
    return path

quantize_to_palette(frames, palette) #

Map every frame onto a shared palette, dithering the residual error.

Parameters:

Name Type Description Default
frames Iterable[Image]

The clip's frames as RGB PIL.Image.Image objects.

required
palette Image

A "P"-mode image carrying the palette, from build_clip_palette.

required

Returns:

Name Type Description
list list[Image]

The frames as "P"-mode images sharing palette.

Examples:

  • Every frame comes back palette-mode, carrying the same table -- which is what keeps a constant region byte-stable from frame to frame:
    >>> from PIL import Image
    >>> from cleopatra.glyphs.base.animation import (
    ...     build_clip_palette,
    ...     quantize_to_palette,
    ... )
    >>> frames = [
    ...     Image.new("RGB", (8, 8), (255, 0, 0)),
    ...     Image.new("RGB", (8, 8), (0, 0, 255)),
    ... ]
    >>> quantised = quantize_to_palette(frames, build_clip_palette(frames))
    >>> len(quantised)
    2
    >>> quantised[0].mode
    'P'
    >>> quantised[0].getpalette() == quantised[1].getpalette()
    True
    
  • A colour the palette holds exactly survives the round trip unchanged:
    >>> from PIL import Image
    >>> from cleopatra.glyphs.base.animation import (
    ...     build_clip_palette,
    ...     quantize_to_palette,
    ... )
    >>> frames = [Image.new("RGB", (8, 8), (255, 0, 0))]
    >>> quantised = quantize_to_palette(frames, build_clip_palette(frames))
    >>> quantised[0].convert("RGB").getpixel((0, 0))
    (255, 0, 0)
    
See Also

build_clip_palette: Builds the shared palette these frames map onto.

Source code in src/cleopatra/glyphs/base/animation.py
def quantize_to_palette(
    frames: Iterable[PILImage.Image], palette: PILImage.Image
) -> list[PILImage.Image]:
    """Map every frame onto a shared palette, dithering the residual error.

    Args:
        frames: The clip's frames as RGB `PIL.Image.Image` objects.
        palette: A ``"P"``-mode image carrying the palette, from
            `build_clip_palette`.

    Returns:
        list: The frames as ``"P"``-mode images sharing `palette`.

    Examples:
        - Every frame comes back palette-mode, carrying the same table -- which
          is what keeps a constant region byte-stable from frame to frame:
            ```python
            >>> from PIL import Image
            >>> from cleopatra.glyphs.base.animation import (
            ...     build_clip_palette,
            ...     quantize_to_palette,
            ... )
            >>> frames = [
            ...     Image.new("RGB", (8, 8), (255, 0, 0)),
            ...     Image.new("RGB", (8, 8), (0, 0, 255)),
            ... ]
            >>> quantised = quantize_to_palette(frames, build_clip_palette(frames))
            >>> len(quantised)
            2
            >>> quantised[0].mode
            'P'
            >>> quantised[0].getpalette() == quantised[1].getpalette()
            True

            ```
        - A colour the palette holds exactly survives the round trip unchanged:
            ```python
            >>> from PIL import Image
            >>> from cleopatra.glyphs.base.animation import (
            ...     build_clip_palette,
            ...     quantize_to_palette,
            ... )
            >>> frames = [Image.new("RGB", (8, 8), (255, 0, 0))]
            >>> quantised = quantize_to_palette(frames, build_clip_palette(frames))
            >>> quantised[0].convert("RGB").getpixel((0, 0))
            (255, 0, 0)

            ```

    See Also:
        build_clip_palette: Builds the shared palette these frames map onto.
    """
    return [
        frame.quantize(palette=palette, dither=PILImage.Dither.FLOYDSTEINBERG)
        for frame in frames
    ]

save_animation(anim, path, fps=2, *, crf=None, bitrate=None, codec=None, preset=None, pix_fmt='yuv420p', dpi=None, optimize=True, loop=0, quantize_method='coverage', extra_args=None) #

Save any FuncAnimation to a file.

The output format is determined by the file extension. GIF and animated WebP use an optimising Pillow writer; mov/avi/mp4 use FFmpeg. FFmpeg is located on PATH when present and otherwise falls back to the binary bundled with imageio-ffmpeg, so video export works with no separate install. WebP is typically 3-5x smaller than GIF for photographic frames.

Note: when no system FFmpeg is found, the first video export sets matplotlib's global rcParams["animation.ffmpeg_path"] to the bundled binary — a process-wide side effect that then applies to any later matplotlib animation in the same process.

For the FFmpeg formats the frame is automatically padded up to an even width/height (libx264 rejects odd dimensions) and encoded full colour range: the pix_fmt is swapped for its full-range yuvj* variant (so yuv420p becomes yuvj420p), which carries the range in the format itself and so maps to full 0-255 on every ffmpeg build. matplotlib figures are computer-generated and full-range (0-255) by construction, so this keeps their full contrast instead of squeezing it into ffmpeg's limited/broadcast default (16-235), which visibly washes the video out; pass extra_args=["-color_range", "tv"] for the old limited-range behaviour, which the few players that ignore the full-range flag render more predictably. By default no fixed bitrate is requested (unlike older versions, which forced 1800 kbit/s), so libx264 uses its constant-quality default of roughly CRF 23 — pass crf or bitrate to trade size against quality. GIF output is written with Pillow's optimize pass enabled; both GIF and WebP loop forever by default.

Parameters:

Name Type Description Default
anim FuncAnimation

The animation to save.

required
path str | PathLike

Output file path, as a str or os.PathLike (e.g. a pathlib.Path). Extension determines format. Supported: gif, mov, avi, mp4, webp.

required
fps int

Frames per second. Default is 2.

2
crf int | None

Constant Rate Factor for the ffmpeg formats (lower is higher quality/larger; ~18-28 is typical). Assumes an x264/x265-family codec. Mutually exclusive with bitrate. Ignored for GIF/WebP. None uses the encoder default.

None
bitrate int | None

Target bitrate in kbit/s for the ffmpeg formats. Mutually exclusive with crf. Ignored for GIF/WebP. None lets the encoder choose.

None
codec str | None

ffmpeg codec (e.g. "libx264"). None uses matplotlib's default. Ignored for GIF/WebP.

None
preset str | None

libx264/libx265 speed/size preset (e.g. "slow"); ignored by codecs that don't accept it. Ignored for GIF/WebP.

None
pix_fmt str

Pixel format for the ffmpeg formats. Defaults to "yuv420p" for universal playback; unless a caller -color_range says otherwise it is swapped for its full-range yuvj* variant (see extra_args). Ignored for GIF/WebP.

'yuv420p'
dpi int | None

Resolution in dots per inch. None uses the figure's dpi.

None
optimize bool

GIF only — run Pillow's palette optimisation pass (a no-op for WebP, whose encoder ignores it). Default True.

True
loop int

GIF/WebP only — number of times to loop; 0 loops forever.

0
quantize_method str

GIF only -- which strategy picks the shared palette; a key of QUANTIZE_METHODS. Defaults to "coverage", which keeps small saturated marks. "median" splits the colour cube by how densely the clip populates it, which suits a smooth photographic clip with no small marks at stake. Neither sees pixel counts: the palette is built from each colour once, so they weight by distinct colours rather than by area.

'coverage'
extra_args list[str] | None

Extra ffmpeg flags. A -vf filter here is merged with the automatic even-dimension pad, a -pix_fmt overrides pix_fmt, and a -color_range overrides the full-range default (pass ["-color_range", "tv"] for limited/broadcast range). Note these flags bypass the crf/bitrate exclusivity check, so don't smuggle a conflicting -b:v/-crf through here. Ignored for GIF/WebP.

None

Returns:

Type Description
str

The output path as a str (the os.fspath of path),

str

convenient for chaining. Note a pathlib.Path argument comes

str

back as its string form, not the original object.

Raises:

Type Description
ValueError

If the file format is not supported, if both crf and bitrate are given (competing rate-control modes), or -- for the Pillow formats -- if fps is not positive or loop is negative.

FileNotFoundError

If a video format is requested but neither a system FFmpeg nor imageio-ffmpeg's bundled binary can be found.

Examples:

  • Save a tiny animation to a GIF; the call returns the path it wrote:
    >>> import os, shutil, tempfile, matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from pathlib import Path
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import save_animation
    >>> tmp = tempfile.mkdtemp()
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> path = os.path.join(tmp, "wave.gif")
    >>> save_animation(anim, path) == path
    True
    >>> Path(path).read_bytes()[:6] in (b"GIF87a", b"GIF89a")
    True
    >>> plt.close(fig)
    >>> shutil.rmtree(tmp)
    
  • The extension is matched case-insensitively, so .GIF also works:
    >>> import os, shutil, tempfile, matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import save_animation
    >>> tmp = tempfile.mkdtemp()
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> save_animation(anim, os.path.join(tmp, "WAVE.GIF")).endswith("WAVE.GIF")
    True
    >>> plt.close(fig)
    >>> shutil.rmtree(tmp)
    
  • An unsupported extension raises ValueError before writing (here the animation is rendered once first, so nothing is left dangling):
    >>> import os, shutil, tempfile, matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import save_animation
    >>> tmp = tempfile.mkdtemp()
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> _ = save_animation(anim, os.path.join(tmp, "ok.gif"))
    >>> save_animation(anim, "movie.webm")  # doctest: +ELLIPSIS
    Traceback (most recent call last):
        ...
    ValueError: ...not supported...
    >>> plt.close(fig)
    >>> shutil.rmtree(tmp)
    
See Also

to_gif: Render an animation to in-memory GIF bytes instead of a file. embed_gif: Wrap an animation as an IPython.display.Image.

Source code in src/cleopatra/glyphs/base/animation.py
def save_animation(
    anim: FuncAnimation,
    path: str | os.PathLike,
    fps: int = 2,
    *,
    crf: int | None = None,
    bitrate: int | None = None,
    codec: str | None = None,
    preset: str | None = None,
    pix_fmt: str = "yuv420p",
    dpi: int | None = None,
    optimize: bool = True,
    loop: int = 0,
    quantize_method: str = "coverage",
    extra_args: list[str] | None = None,
) -> str:
    """Save any `FuncAnimation` to a file.

    The output format is determined by the file extension. GIF and animated
    WebP use an optimising Pillow writer; mov/avi/mp4 use FFmpeg. FFmpeg is
    located on `PATH` when present and otherwise falls back to the binary
    bundled with `imageio-ffmpeg`, so video export works with no separate
    install. WebP is typically 3-5x smaller than GIF for photographic frames.

    Note: when no system FFmpeg is found, the first video export sets
    matplotlib's global `rcParams["animation.ffmpeg_path"]` to the bundled
    binary — a process-wide side effect that then applies to any later
    matplotlib animation in the same process.

    For the FFmpeg formats the frame is automatically padded up to an even
    width/height (libx264 rejects odd dimensions) and encoded full colour range:
    the `pix_fmt` is swapped for its full-range `yuvj*` variant (so `yuv420p`
    becomes `yuvj420p`), which carries the range in the format itself and so maps
    to full 0-255 on every ffmpeg build. matplotlib figures are computer-generated
    and full-range (0-255) by construction, so this keeps their full contrast
    instead of squeezing it into ffmpeg's limited/broadcast default (16-235),
    which visibly washes the video out; pass `extra_args=["-color_range", "tv"]`
    for the old limited-range behaviour, which the few players that ignore the
    full-range flag render more predictably. By default no fixed bitrate is
    requested (unlike older versions, which forced 1800 kbit/s), so libx264
    uses its constant-quality default of roughly CRF 23 — pass `crf` or
    `bitrate` to trade size against quality. GIF output is written with
    Pillow's `optimize` pass enabled; both GIF and WebP loop forever by
    default.

    Args:
        anim: The animation to save.
        path: Output file path, as a `str` or `os.PathLike` (e.g. a
            `pathlib.Path`). Extension determines format.
            Supported: gif, mov, avi, mp4, webp.
        fps: Frames per second. Default is 2.
        crf: Constant Rate Factor for the ffmpeg formats (lower is higher
            quality/larger; ~18-28 is typical). Assumes an x264/x265-family
            `codec`. Mutually exclusive with `bitrate`. Ignored for
            GIF/WebP. `None` uses the encoder default.
        bitrate: Target bitrate in kbit/s for the ffmpeg formats. Mutually
            exclusive with `crf`. Ignored for GIF/WebP. `None` lets the
            encoder choose.
        codec: ffmpeg codec (e.g. `"libx264"`). `None` uses matplotlib's
            default. Ignored for GIF/WebP.
        preset: libx264/libx265 speed/size preset (e.g. `"slow"`); ignored by
            codecs that don't accept it. Ignored for GIF/WebP.
        pix_fmt: Pixel format for the ffmpeg formats. Defaults to
            `"yuv420p"` for universal playback; unless a caller `-color_range`
            says otherwise it is swapped for its full-range `yuvj*` variant (see
            `extra_args`). Ignored for GIF/WebP.
        dpi: Resolution in dots per inch. `None` uses the figure's dpi.
        optimize: GIF only — run Pillow's palette optimisation pass (a no-op
            for WebP, whose encoder ignores it). Default `True`.
        loop: GIF/WebP only — number of times to loop; `0` loops forever.
        quantize_method: GIF only -- which strategy picks the shared palette; a
            key of `QUANTIZE_METHODS`. Defaults to ``"coverage"``, which keeps
            small saturated marks. ``"median"`` splits the colour cube by how
            densely the clip populates it, which suits a smooth photographic
            clip with no small marks at stake. Neither sees pixel counts: the
            palette is built from each colour once, so they weight by distinct
            colours rather than by area.
        extra_args: Extra ffmpeg flags. A `-vf` filter here is merged with
            the automatic even-dimension pad, a `-pix_fmt` overrides
            `pix_fmt`, and a `-color_range` overrides the full-range default
            (pass `["-color_range", "tv"]` for limited/broadcast range). Note
            these flags bypass the `crf`/`bitrate` exclusivity check, so don't
            smuggle a conflicting `-b:v`/`-crf` through here. Ignored for
            GIF/WebP.

    Returns:
        The output path as a `str` (the `os.fspath` of `path`),
        convenient for chaining. Note a `pathlib.Path` argument comes
        back as its string form, not the original object.

    Raises:
        ValueError: If the file format is not supported, if both `crf`
            and `bitrate` are given (competing rate-control modes), or -- for
            the Pillow formats -- if `fps` is not positive or `loop` is
            negative.
        FileNotFoundError: If a video format is requested but neither a system
            FFmpeg nor imageio-ffmpeg's bundled binary can be found.

    Examples:
        - Save a tiny animation to a GIF; the call returns the path it wrote:
            ```python
            >>> import os, shutil, tempfile, matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from pathlib import Path
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import save_animation
            >>> tmp = tempfile.mkdtemp()
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> path = os.path.join(tmp, "wave.gif")
            >>> save_animation(anim, path) == path
            True
            >>> Path(path).read_bytes()[:6] in (b"GIF87a", b"GIF89a")
            True
            >>> plt.close(fig)
            >>> shutil.rmtree(tmp)

            ```
        - The extension is matched case-insensitively, so `.GIF` also works:
            ```python
            >>> import os, shutil, tempfile, matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import save_animation
            >>> tmp = tempfile.mkdtemp()
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> save_animation(anim, os.path.join(tmp, "WAVE.GIF")).endswith("WAVE.GIF")
            True
            >>> plt.close(fig)
            >>> shutil.rmtree(tmp)

            ```
        - An unsupported extension raises `ValueError` before writing (here
          the animation is rendered once first, so nothing is left dangling):
            ```python
            >>> import os, shutil, tempfile, matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import save_animation
            >>> tmp = tempfile.mkdtemp()
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> _ = save_animation(anim, os.path.join(tmp, "ok.gif"))
            >>> save_animation(anim, "movie.webm")  # doctest: +ELLIPSIS
            Traceback (most recent call last):
                ...
            ValueError: ...not supported...
            >>> plt.close(fig)
            >>> shutil.rmtree(tmp)

            ```

    See Also:
        to_gif: Render an animation to in-memory GIF bytes instead of a file.
        embed_gif: Wrap an animation as an `IPython.display.Image`.
    """
    path = os.fspath(path)
    video_format = _validated_output_format(
        path, fps, loop, quantize_method, crf, bitrate
    )

    save_kwargs: dict[str, Any] = {} if dpi is None else {"dpi": dpi}

    if video_format in _PILLOW_FORMATS:
        anim.save(
            path,
            writer=_OptimizedPillowWriter(
                fps=fps,
                optimize=optimize,
                loop=loop,
                quantize_method=quantize_method,
            ),
            **save_kwargs,
        )
    else:
        _ensure_ffmpeg_available()
        writer_kwargs: dict[str, Any] = {
            "fps": fps,
            "extra_args": _build_ffmpeg_extra_args(pix_fmt, crf, preset, extra_args),
        }
        if bitrate is not None:
            writer_kwargs["bitrate"] = bitrate
        if codec is not None:
            writer_kwargs["codec"] = codec
        try:
            anim.save(path, writer=FFMpegWriter(**writer_kwargs), **save_kwargs)
        except FileNotFoundError as e:
            raise FileNotFoundError(
                "FFmpeg could not be run. imageio-ffmpeg's bundled binary "
                "normally makes this work out of the box; if you pinned a custom "
                "ffmpeg via matplotlib's animation.ffmpeg_path, make sure it still "
                "exists, or install a system ffmpeg from https://ffmpeg.org/."
            ) from e
    return path

to_bytes(anim, fmt='gif', fps=2, **kwargs) #

Render a FuncAnimation to in-memory bytes in any supported format.

Renders to a temporary file (the writers need a real path) and reads it back, leaving nothing on disk. Handy for embedding in a notebook or serving over HTTP.

Parameters:

Name Type Description Default
anim FuncAnimation

The animation to render.

required
fmt str

Output format — any member of SUPPORTED_VIDEO_FORMAT (e.g. "gif", "mp4", "webp"). A leading dot is tolerated.

'gif'
fps int

Frames per second. Default is 2.

2
**kwargs

Extra keyword arguments forwarded to save_animation (e.g. crf, codec, loop).

{}

Returns:

Type Description
bytes

The encoded bytes of the animation in the requested format.

Raises:

Type Description
ValueError

If fmt is not a supported format.

Examples:

  • Render to GIF bytes and inspect the payload:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import to_bytes
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> data = to_bytes(anim, fmt="gif")
    >>> data[:6] in (b"GIF87a", b"GIF89a")
    True
    >>> plt.close(fig)
    
  • Render to animated WebP and confirm the container magic bytes:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import to_bytes
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> data = to_bytes(anim, fmt="webp")
    >>> data[:4] == b"RIFF" and data[8:12] == b"WEBP"
    True
    >>> plt.close(fig)
    
  • An unsupported format raises ValueError:
    >>> from unittest.mock import MagicMock
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import to_bytes
    >>> to_bytes(MagicMock(spec=FuncAnimation), fmt="webm")  # doctest: +ELLIPSIS
    Traceback (most recent call last):
        ...
    ValueError: ...not supported...
    
See Also

to_gif: Convenience wrapper for GIF bytes. to_mp4: Convenience wrapper for MP4 bytes. save_animation: Write an animation directly to a file path.

Source code in src/cleopatra/glyphs/base/animation.py
def to_bytes(anim: FuncAnimation, fmt: str = "gif", fps: int = 2, **kwargs) -> bytes:
    """Render a `FuncAnimation` to in-memory bytes in any supported format.

    Renders to a temporary file (the writers need a real path) and reads it
    back, leaving nothing on disk. Handy for embedding in a notebook or
    serving over HTTP.

    Args:
        anim: The animation to render.
        fmt: Output format — any member of `SUPPORTED_VIDEO_FORMAT` (e.g.
            `"gif"`, `"mp4"`, `"webp"`). A leading dot is tolerated.
        fps: Frames per second. Default is 2.
        **kwargs: Extra keyword arguments forwarded to `save_animation`
            (e.g. `crf`, `codec`, `loop`).

    Returns:
        The encoded bytes of the animation in the requested format.

    Raises:
        ValueError: If `fmt` is not a supported format.

    Examples:
        - Render to GIF bytes and inspect the payload:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import to_bytes
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> data = to_bytes(anim, fmt="gif")
            >>> data[:6] in (b"GIF87a", b"GIF89a")
            True
            >>> plt.close(fig)

            ```
        - Render to animated WebP and confirm the container magic bytes:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import to_bytes
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> data = to_bytes(anim, fmt="webp")
            >>> data[:4] == b"RIFF" and data[8:12] == b"WEBP"
            True
            >>> plt.close(fig)

            ```
        - An unsupported format raises `ValueError`:
            ```python
            >>> from unittest.mock import MagicMock
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import to_bytes
            >>> to_bytes(MagicMock(spec=FuncAnimation), fmt="webm")  # doctest: +ELLIPSIS
            Traceback (most recent call last):
                ...
            ValueError: ...not supported...

            ```

    See Also:
        to_gif: Convenience wrapper for GIF bytes.
        to_mp4: Convenience wrapper for MP4 bytes.
        save_animation: Write an animation directly to a file path.
    """
    fmt = fmt.lstrip(".").lower()
    if fmt not in SUPPORTED_VIDEO_FORMAT:
        raise ValueError(
            f"The format {fmt!r} is not supported, only "
            f"{SUPPORTED_VIDEO_FORMAT} are supported"
        )
    fd, tmp = tempfile.mkstemp(suffix=f".{fmt}")
    os.close(fd)
    try:
        save_animation(anim, tmp, fps=fps, **kwargs)
        with open(tmp, "rb") as fh:
            return fh.read()
    finally:
        os.remove(tmp)

to_gif(anim, fps=2, **kwargs) #

Render a FuncAnimation to in-memory GIF bytes.

Handy for embedding in a notebook or serving over HTTP without leaving a file on disk. Thin wrapper around to_bytes with fmt="gif".

Parameters:

Name Type Description Default
anim FuncAnimation

The animation to render.

required
fps int

Frames per second. Default is 2.

2
**kwargs

Extra keyword arguments forwarded to save_animation (e.g. optimize, loop).

{}

Returns:

Type Description
bytes

The GIF-encoded bytes of the animation.

Examples:

  • Render an animation to GIF bytes and inspect the payload:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import to_gif
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> data = to_gif(anim)
    >>> data[:6] in (b"GIF87a", b"GIF89a")
    True
    >>> len(data) > 0
    True
    >>> plt.close(fig)
    
  • A higher fps still yields self-contained bytes you can serve over HTTP or write yourself, without leaving a temp file behind:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import to_gif
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=3)
    >>> payload = to_gif(anim, fps=5)
    >>> payload.startswith((b"GIF87a", b"GIF89a"))
    True
    >>> plt.close(fig)
    
See Also

to_bytes: Render to bytes in any supported format. save_animation: Write an animation directly to a file path. embed_gif: Wrap these bytes as an IPython.display.Image.

Source code in src/cleopatra/glyphs/base/animation.py
def to_gif(anim: FuncAnimation, fps: int = 2, **kwargs) -> bytes:
    """Render a `FuncAnimation` to in-memory GIF bytes.

    Handy for embedding in a notebook or serving over HTTP without leaving
    a file on disk. Thin wrapper around `to_bytes` with `fmt="gif"`.

    Args:
        anim: The animation to render.
        fps: Frames per second. Default is 2.
        **kwargs: Extra keyword arguments forwarded to `save_animation`
            (e.g. `optimize`, `loop`).

    Returns:
        The GIF-encoded bytes of the animation.

    Examples:
        - Render an animation to GIF bytes and inspect the payload:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import to_gif
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> data = to_gif(anim)
            >>> data[:6] in (b"GIF87a", b"GIF89a")
            True
            >>> len(data) > 0
            True
            >>> plt.close(fig)

            ```
        - A higher `fps` still yields self-contained bytes you can serve over
          HTTP or write yourself, without leaving a temp file behind:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import to_gif
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=3)
            >>> payload = to_gif(anim, fps=5)
            >>> payload.startswith((b"GIF87a", b"GIF89a"))
            True
            >>> plt.close(fig)

            ```

    See Also:
        to_bytes: Render to bytes in any supported format.
        save_animation: Write an animation directly to a file path.
        embed_gif: Wrap these bytes as an `IPython.display.Image`.
    """
    return to_bytes(anim, fmt="gif", fps=fps, **kwargs)

to_mp4(anim, fps=2, **kwargs) #

Render a FuncAnimation to in-memory MP4 (H.264) bytes.

Handy for embedding a compact, universally-playable clip or serving it over HTTP without leaving a file on disk. Thin wrapper around to_bytes with fmt="mp4"; the frame is auto-padded to even dimensions and encoded yuv420p like every other MP4 export.

Parameters:

Name Type Description Default
anim FuncAnimation

The animation to render.

required
fps int

Frames per second. Default is 2.

2
**kwargs

Extra keyword arguments forwarded to save_animation (e.g. crf, bitrate, codec, preset).

{}

Returns:

Type Description
bytes

The MP4-encoded bytes of the animation.

Raises:

Type Description
FileNotFoundError

If neither a system FFmpeg nor imageio-ffmpeg's bundled binary can be found.

Examples:

  • Render to MP4 bytes and confirm the ISO base-media ftyp box:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import to_mp4
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> data = to_mp4(anim)
    >>> data[4:8] == b"ftyp"
    True
    >>> plt.close(fig)
    
  • Trade size for quality with a CRF and confirm non-empty output:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from matplotlib.animation import FuncAnimation
    >>> from cleopatra.glyphs.base.animation import to_mp4
    >>> fig, ax = plt.subplots()
    >>> (line,) = ax.plot([0, 1], [0, 0])
    >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
    >>> data = to_mp4(anim, crf=30, preset="veryfast")
    >>> len(data) > 0
    True
    >>> plt.close(fig)
    
See Also

to_bytes: Render to bytes in any supported format. to_gif: Render an animation to in-memory GIF bytes. save_animation: Write an animation directly to a file path.

Source code in src/cleopatra/glyphs/base/animation.py
def to_mp4(anim: FuncAnimation, fps: int = 2, **kwargs) -> bytes:
    """Render a `FuncAnimation` to in-memory MP4 (H.264) bytes.

    Handy for embedding a compact, universally-playable clip or serving it
    over HTTP without leaving a file on disk. Thin wrapper around `to_bytes`
    with `fmt="mp4"`; the frame is auto-padded to even dimensions and
    encoded `yuv420p` like every other MP4 export.

    Args:
        anim: The animation to render.
        fps: Frames per second. Default is 2.
        **kwargs: Extra keyword arguments forwarded to `save_animation`
            (e.g. `crf`, `bitrate`, `codec`, `preset`).

    Returns:
        The MP4-encoded bytes of the animation.

    Raises:
        FileNotFoundError: If neither a system FFmpeg nor imageio-ffmpeg's
            bundled binary can be found.

    Examples:
        - Render to MP4 bytes and confirm the ISO base-media `ftyp` box:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import to_mp4
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> data = to_mp4(anim)
            >>> data[4:8] == b"ftyp"
            True
            >>> plt.close(fig)

            ```
        - Trade size for quality with a CRF and confirm non-empty output:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from matplotlib.animation import FuncAnimation
            >>> from cleopatra.glyphs.base.animation import to_mp4
            >>> fig, ax = plt.subplots()
            >>> (line,) = ax.plot([0, 1], [0, 0])
            >>> anim = FuncAnimation(fig, lambda i: (line,), frames=2)
            >>> data = to_mp4(anim, crf=30, preset="veryfast")
            >>> len(data) > 0
            True
            >>> plt.close(fig)

            ```

    See Also:
        to_bytes: Render to bytes in any supported format.
        to_gif: Render an animation to in-memory GIF bytes.
        save_animation: Write an animation directly to a file path.
    """
    return to_bytes(anim, fmt="mp4", fps=fps, **kwargs)