Source code for ultraplot.animation

#!/usr/bin/env python3
"""
Fast drop-in replacements for the `matplotlib.animation` classes.

The classes here subclass their Matplotlib counterparts, so the constructor
signatures, the attributes, and the notebook representations are unchanged.
What differs is how frames are rendered:

* `~ultraplot.animation.FuncAnimation.save` bypasses the per-frame
  `~matplotlib.figure.Figure.savefig` call used by Matplotlib's writers and
  instead renders straight into the Agg buffer, piping raw ``RGBA`` bytes to
  the encoder. No PNG round-trip, no ``print_figure`` machinery.
* The expensive UltraPlot tight-layout pass runs once, for the first frame,
  rather than on every frame.
* Blitting is used while saving, not just interactively, so only the artists
  the update function returns are redrawn per frame.

Everything falls back to Matplotlib's own implementation when the fast path
cannot be used (custom writer instances, ``bbox_inches``, vector output, and
so on), so the output is never silently wrong.
"""

from __future__ import annotations

import itertools
import os
import subprocess
from contextlib import ExitStack, contextmanager, suppress
from tempfile import TemporaryFile
from pathlib import Path

import matplotlib as mpl
import matplotlib.animation as manimation
import numpy as np
from matplotlib import cbook

__all__ = [
    "FuncAnimation",
    "ArtistAnimation",
]

#: Containers written frame-by-frame by piping raw RGBA to ``ffmpeg``.
_FFMPEG_SUFFIXES = frozenset(
    (".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi", ".ogv", ".ogg")
    + (".gif", ".webp", ".apng", ".avif")
)

#: Containers assembled in memory by Pillow.
_PILLOW_SUFFIXES = frozenset((".gif", ".webp", ".apng"))

#: Containers whose codec is implied by the extension, as in Matplotlib's
#: `~matplotlib.animation.FFMpegWriter.output_args`.
_SUFFIX_CODECS = frozenset((".gif", ".webp", ".apng", ".avif"))

#: Writer names whose behavior the fast path reproduces exactly. Anything else,
#: ``imagemagick`` included, is handed back to Matplotlib.
_FAST_WRITERS = frozenset(("ffmpeg", "pillow"))


def _suffix(filename):
    """
    Return the lowercase suffix of a path-like filename.
    """
    return Path(os.fspath(filename)).suffix.lower()


class _RawWriter:
    """
    Base class for writers that consume raw ``RGBA`` frames.

    Subclasses implement `write`, `_close`, and `_discard`. The output file is
    deleted unless `finish` completed, so an animation that fails halfway
    through never leaves a truncated movie that looks like a whole one.
    """

    def __init__(self, filename, fps):
        self.filename = os.fspath(filename)
        self.fps = fps
        self.width = self.height = None
        self._started = False
        self._finished = False

    def setup(self, width, height):
        self.width, self.height = width, height
        self._started = True

    def write(self, buffer):  # pragma: no cover - abstract
        raise NotImplementedError

    def _close(self):  # pragma: no cover - abstract
        raise NotImplementedError

    def _discard(self):
        pass

    def finish(self):
        """
        Complete the file. Anything short of this counts as a failed save.
        """
        self._close()
        self._finished = True

    def cleanup(self):
        """
        Release resources, and remove the output of an unfinished save.
        """
        self._discard()
        if self._started and not self._finished:
            with suppress(OSError):
                os.remove(self.filename)


class _RawFFMpegWriter(_RawWriter):
    """
    Pipe raw ``RGBA`` frames into ``ffmpeg`` with no intermediate encoding.
    """

    def __init__(
        self,
        filename,
        fps,
        *,
        codec=None,
        bitrate=None,
        extra_args=None,
        metadata=None,
    ):
        super().__init__(filename, fps)
        self.metadata = metadata or {}
        self.codec = codec or mpl.rcParams["animation.codec"]
        self.bitrate = mpl.rcParams["animation.bitrate"] if bitrate is None else bitrate
        self.extra_args = extra_args
        self._proc = None
        self._stderr = None

    @staticmethod
    def available():
        """
        Return whether the configured ``ffmpeg`` binary can be executed.
        """
        try:
            manimation.FFMpegWriter.bin_path()
        except Exception:
            return False
        return bool(manimation.FFMpegWriter.isAvailable())

    def _command(self):
        args = [
            manimation.FFMpegWriter.bin_path(),
            "-y",
            "-loglevel",
            "error",
            "-f",
            "rawvideo",
            "-vcodec",
            "rawvideo",
            "-s",
            f"{self.width}x{self.height}",
            "-pix_fmt",
            "rgba",
            "-framerate",
            str(self.fps),
            "-i",
            "pipe:0",
            "-an",
        ]
        extra = self.extra_args
        if extra is None:
            extra = mpl.rcParams["animation.ffmpeg_args"]
        extra = list(extra or ())

        # Codec selection mirrors matplotlib's FFMpegWriter: animated-image
        # containers take the codec implied by their extension, and only h264
        # and gif get the extra treatment they need to play back correctly.
        suffix = _suffix(self.filename)
        codec = suffix[1:] if suffix in _SUFFIX_CODECS else self.codec
        if suffix not in _SUFFIX_CODECS and codec:
            args += ["-vcodec", codec]
        if codec == "h264" and "-pix_fmt" not in extra:
            # Most players need 4:2:0 chroma and even dimensions. The scale is a
            # no-op when the frame is already even-sized.
            args += ["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-pix_fmt", "yuv420p"]
        elif codec == "gif" and "-filter_complex" not in extra:
            args += [
                "-filter_complex",
                "split [a][b];[a] palettegen [p];[b][p] paletteuse",
            ]
        if self.bitrate is not None and self.bitrate > 0:
            args += ["-b:v", f"{int(self.bitrate)}k"]
        for key, value in self.metadata.items():
            args += ["-metadata", f"{key}={value}"]
        args += extra
        args += [self.filename]
        return args

    def setup(self, width, height):
        super().setup(width, height)
        # NOTE: stderr goes to a file, not a pipe. Nothing reads the pipe while
        # frames are being written, so a chatty encoder would fill it and block.
        self._stderr = TemporaryFile()
        self._proc = subprocess.Popen(
            self._command(),
            stdin=subprocess.PIPE,
            stdout=subprocess.DEVNULL,
            stderr=self._stderr,
        )

    def _stderr_text(self):
        self._stderr.seek(0)
        return self._stderr.read().decode("utf-8", "replace")

    def write(self, buffer):
        try:
            self._proc.stdin.write(buffer)
        except BrokenPipeError as err:
            self._proc.wait()
            raise RuntimeError(
                "ffmpeg exited while frames were being written:\n" + self._stderr_text()
            ) from err

    def _close(self):
        self._proc.stdin.close()
        self._proc.wait()
        if self._proc.returncode:
            raise RuntimeError(
                f"ffmpeg exited with code {self._proc.returncode}:\n"
                + self._stderr_text()
            )

    def _discard(self):
        proc = self._proc
        if proc is not None and proc.returncode is None:
            try:
                if proc.stdin is not None and not proc.stdin.closed:
                    proc.stdin.close()
            finally:
                proc.kill()
                proc.wait()
        if self._stderr is not None:
            self._stderr.close()
            self._stderr = None


class _RawPillowWriter(_RawWriter):
    """
    Collect raw ``RGBA`` frames and write an animated image with Pillow.
    """

    def __init__(self, filename, fps):
        super().__init__(filename, fps)
        self._frames = []

    def write(self, buffer):
        from PIL import Image

        # Copy: the canvas hands back the same buffer for every frame.
        self._frames.append(
            Image.frombuffer(
                "RGBA", (self.width, self.height), bytes(buffer), "raw", "RGBA", 0, 1
            )
        )

    def _close(self):
        if not self._frames:
            raise RuntimeError("No frames were rendered for the animation.")
        first, *rest = self._frames
        first.save(
            self.filename,
            save_all=True,
            append_images=rest,
            duration=int(1000 / self.fps),
            loop=0,
        )

    def _discard(self):
        self._frames.clear()


class _FastSaveMixin:
    """
    The fast `save` path, shared by the animation classes.

    Subclasses supply the three frame hooks below; everything else here is the
    machinery that renders those frames into a movie file.
    """

    # Frame hooks

    def _fast_frame_seq(self):  # pragma: no cover - abstract
        raise NotImplementedError

    def _fast_frame_artists(self, frame):  # pragma: no cover - abstract
        raise NotImplementedError

    def _fast_init_artists(self, frame):  # pragma: no cover - abstract
        raise NotImplementedError

    @contextmanager
    def _frozen_layout(self):
        """
        Run the UltraPlot layout solver once instead of once per frame.

        UltraPlot recomputes tight layout whenever the figure is marked dirty.
        During an animation the geometry must stay fixed anyway, or frames
        would jitter, so the solver is switched off after the first draw. Yields
        a function that marks the current layout as final.
        """
        fig = self._fig
        state = (
            getattr(fig, "_layout_initialized", False),
            getattr(fig, "_layout_dirty", False),
            getattr(fig, "_skip_autolayout", False),
        )

        def freeze():
            fig._layout_initialized = True
            fig._layout_dirty = False
            fig._skip_autolayout = True

        try:
            yield freeze
        finally:
            fig._layout_initialized, fig._layout_dirty, fig._skip_autolayout = state

    @contextmanager
    def _animated_artists(self, artists=()):
        """
        Temporarily mark artists as animated so full draws skip them.

        Yields a function that marks further artists, for update functions that
        return a different set of artists as the animation goes on.
        """
        previous = {}

        def mark(artists):
            for artist in artists:
                previous.setdefault(artist, artist.get_animated())
                artist.set_animated(True)

        mark(artists)
        try:
            yield mark
        finally:
            for artist, animated in previous.items():
                artist.set_animated(animated)

    @contextmanager
    def _suspended_event_source(self):
        """
        Keep the interactive timer from starting on the frames drawn here.

        `matplotlib.animation.Animation` starts itself from the figure's first
        ``draw_event``. The draws below are for the movie file, not the screen.
        """
        cid = getattr(self, "_first_draw_id", None)
        canvas = self._fig.canvas
        if cid is not None:
            canvas.mpl_disconnect(cid)
        try:
            yield
        finally:
            if cid is not None:
                self._first_draw_id = self._fig.canvas.mpl_connect(
                    "draw_event", self._start
                )

    @contextmanager
    def _suspended_figure_blitting(self):
        """
        Stand down the figure's own retained-draw machinery while saving.

        `~ultraplot.figure.Figure.savefig` does the same before printing. A live
        `~ultraplot._animation._BlitManager` keeps its artists flagged animated
        and repaints them from a ``draw_event`` handler, which would fight the
        frames drawn here.
        """
        fig = self._fig
        with ExitStack() as stack:
            selective = getattr(fig, "_selective_draw_manager", None)
            if selective is not None:
                stack.enter_context(selective.save_context())
            for manager in tuple(getattr(fig, "_blit_managers", ())):
                stack.enter_context(manager._save_context())
            yield

    @contextmanager
    def _agg_canvas(self):
        """
        Ensure the figure has a canvas that can blit and expose an RGBA buffer.
        """
        fig = self._fig
        canvas = fig.canvas
        if all(
            callable(getattr(canvas, name, None))
            for name in ("buffer_rgba", "copy_from_bbox", "restore_region")
        ):
            yield canvas
            return
        from matplotlib.backends.backend_agg import FigureCanvasAgg

        original = canvas
        try:
            yield FigureCanvasAgg(fig)
        finally:
            fig.set_canvas(original)

    def _resolve_writer(self, filename, writer, savefig_kwargs, extra_anim):
        """
        Return the name of the fast writer to use, or ``None`` for none.

        The fast path must pick the same writer Matplotlib would, or the same
        call would produce a differently encoded file than before.
        """
        if extra_anim:
            return None
        if writer is not None and not isinstance(writer, str):
            return None  # a configured writer instance must be honored
        if savefig_kwargs:
            # Any styling override (facecolor, transparency, bbox_inches, ...)
            # belongs to the savefig pipeline the fast path skips.
            return None
        # Matplotlib falls back to the rc setting when no writer is named.
        writer = writer or mpl.rcParams["animation.writer"]
        if writer not in _FAST_WRITERS:
            return None
        suffix = _suffix(filename)
        if writer == "ffmpeg":
            if suffix in _FFMPEG_SUFFIXES and _RawFFMpegWriter.available():
                return "ffmpeg"
            return None
        if suffix not in _PILLOW_SUFFIXES:
            return None
        try:
            import PIL  # noqa: F401
        except ImportError:
            return None
        return "pillow"

    def _make_raw_writer(
        self, filename, writer, fps, codec, bitrate, extra_args, metadata
    ):
        """
        Return the raw-frame writer for the resolved writer name.
        """
        if writer == "ffmpeg":
            return _RawFFMpegWriter(
                filename,
                fps,
                codec=codec,
                bitrate=bitrate,
                extra_args=extra_args,
                metadata=metadata,
            )
        return _RawPillowWriter(filename, fps)

    def _fast_save(
        self,
        filename,
        writer,
        fps,
        dpi,
        codec,
        bitrate,
        extra_args,
        metadata,
        progress_callback,
        blit,
    ):
        """
        Render every frame straight into the Agg buffer and pipe it out.
        """
        fig = self._fig
        self._draw_was_started = True
        raw = self._make_raw_writer(
            filename, writer, fps, codec, bitrate, extra_args, metadata
        )
        total = getattr(self, "_save_count", None)

        with ExitStack() as stack:
            # NOTE: `cleanup` is the only unconditional callback. `finish` runs
            # after the loop instead, so a failing update function does not
            # leave a truncated movie behind looking like a complete one.
            stack.callback(raw.cleanup)
            # The event source is suspended before the canvas is swapped, so
            # that its reconnect on the way out lands on the original canvas.
            stack.enter_context(self._suspended_event_source())
            canvas = stack.enter_context(self._agg_canvas())
            # NOTE: Matplotlib's own save sets canvas._is_saving, but that makes
            # `Axes.draw` include animated artists so that savefig captures
            # them. That would bake the first frame into the blitting
            # background. Suppress the timer-starting draw callback instead.
            stack.enter_context(cbook._setattr_cm(canvas, manager=None))
            stack.enter_context(self._suspended_figure_blitting())
            if dpi is not None and dpi != fig.dpi:
                stack.callback(fig.set_dpi, fig.dpi)
                fig.set_dpi(dpi)

            frames = self._fast_frame_seq()
            try:
                first = next(frames)
            except StopIteration:
                raise ValueError("The animation has no frames to save.") from None
            frames = itertools.chain((first,), frames)
            init_artists = list(self._fast_init_artists(first) or ())
            if blit and not init_artists:
                blit = False  # nothing was returned, so nothing can be blitted
            mark_animated = None
            if blit:
                mark_animated = stack.enter_context(
                    self._animated_artists(init_artists)
                )

            # The first draw runs tight layout; every later one reuses it.
            freeze_layout = stack.enter_context(self._frozen_layout())
            canvas.draw()
            freeze_layout()
            background = canvas.copy_from_bbox(fig.bbox) if blit else None

            height, width = np.asarray(canvas.buffer_rgba()).shape[:2]
            raw.setup(width, height)

            for count, frame in enumerate(frames):
                artists = self._fast_frame_artists(frame)
                if blit:
                    artists = sorted(
                        artists or init_artists, key=lambda a: a.get_zorder()
                    )
                    mark_animated(artists)
                    canvas.restore_region(background)
                    for artist in artists:
                        fig.draw_artist(artist)
                else:
                    freeze_layout()
                    canvas.draw()
                raw.write(memoryview(canvas.buffer_rgba()))
                if progress_callback is not None:
                    progress_callback(count, total)

            raw.finish()

    def save(
        self,
        filename,
        writer=None,
        fps=None,
        dpi=None,
        codec=None,
        bitrate=None,
        extra_args=None,
        metadata=None,
        extra_anim=None,
        savefig_kwargs=None,
        *,
        progress_callback=None,
        fast=None,
        blit=None,
    ):
        """
        Save the animation to a movie file.

        Parameters
        ----------
        filename : path-like
            The output file, e.g. ``'movie.mp4'`` or ``'movie.gif'``.
        writer : str or `~matplotlib.animation.AbstractMovieWriter`, optional
            Same meaning as in `matplotlib.animation.Animation.save`. Passing a
            writer *instance*, or a writer the fast path does not implement,
            transparently falls back to Matplotlib's implementation.
        fps : int, optional
            Frames per second. Defaults to the animation interval.
        dpi : float, optional
            Resolution of the saved frames. Unlike Matplotlib, which uses
            :rc:`savefig.dpi`, this defaults to the figure's own dpi. UltraPlot
            sets ``savefig.dpi`` to 1000 for publication-quality stills, which
            for a movie means hundredfold larger frames and a hundredfold
            slower encode.
        codec, bitrate, extra_args, metadata : optional
            Passed to the encoder, as in Matplotlib.
        extra_anim : list, optional
            Additional animations to composite. Forces the Matplotlib path.
        savefig_kwargs : dict, optional
            Extra `~matplotlib.figure.Figure.savefig` arguments. Any value here
            forces the Matplotlib path, since the fast path skips ``savefig``.
        progress_callback : callable, optional
            Called as ``progress_callback(current_frame, total_frames)``.
        fast : bool, optional
            Whether to use the fast renderer. The default, ``None``, uses it
            whenever it can reproduce the requested output exactly. Passing
            ``True`` raises if the fast path is unavailable.
        blit : bool, optional
            Whether to blit while saving. Defaults to the animation's own
            ``blit`` setting. Blitting only redraws the artists returned by the
            update function, so anything else changed per frame (titles, ticks,
            axes limits) will not appear. Pass ``False`` to redraw everything.

        Other Parameters
        ----------------
        See `matplotlib.animation.Animation.save`.

        See also
        --------
        matplotlib.animation.Animation.save
        """
        savefig_kwargs = savefig_kwargs or {}
        resolved = self._resolve_writer(filename, writer, savefig_kwargs, extra_anim)
        if fast and resolved is None:
            raise ValueError(
                f"Cannot use the fast animation writer for {filename!r} with "
                f"writer={writer!r}. Pass fast=False to use matplotlib instead."
            )
        if resolved is None or fast is False:
            return super().save(
                filename,
                writer=writer,
                fps=fps,
                dpi=dpi,
                codec=codec,
                bitrate=bitrate,
                extra_args=extra_args,
                metadata=metadata,
                extra_anim=extra_anim,
                savefig_kwargs=savefig_kwargs or None,
                progress_callback=progress_callback,
            )

        if fps is None:
            fps = 1000.0 / getattr(self, "_interval", 200)
        # NOTE: Matplotlib defaults to rc['savefig.dpi'], but UltraPlot raises
        # that to 1000 for publication-quality stills. On a movie that means a
        # hundredfold more pixels in every frame, so default to the figure's
        # own dpi and let the caller ask for more.
        if dpi is None or dpi == "figure":
            dpi = self._fig.dpi
        if blit is None:
            blit = bool(getattr(self, "_blit", False))
        return self._fast_save(
            filename,
            resolved,
            fps,
            dpi,
            codec,
            bitrate,
            extra_args,
            metadata,
            progress_callback,
            blit,
        )


[docs] class FuncAnimation(_FastSaveMixin, manimation.FuncAnimation): """ A faster drop-in replacement for `matplotlib.animation.FuncAnimation`. The signature matches Matplotlib's, with two differences: `blit` defaults to ``True`` instead of ``False``, and `freeze_layout` is added. Saving renders frames directly into the Agg buffer instead of calling `~matplotlib.figure.Figure.savefig` once per frame, which removes the per-frame PNG round-trip and the repeated UltraPlot tight-layout pass. Parameters ---------- fig : `~ultraplot.figure.Figure` The figure to animate. func : callable The update function, called as ``func(frame, *fargs)``. It should return an iterable of the artists it modified. This is required when `blit` is ``True``, and lets the fast path skip untouched artists. frames : int, iterable, generator, or None, optional Source of frame data, as in Matplotlib. init_func : callable, optional Function drawing the clear frame. Should return the animated artists. fargs : tuple, optional Extra positional arguments for `func` and `init_func`. save_count : int, optional Number of frames to cache from a generator. blit : bool, default: True Whether to redraw only the artists returned by `func`. This is the main source of the speedup, but it means changes to artists that are *not* returned, such as titles or tick labels, will not show up. Pass ``False`` to redraw the whole figure each frame, which is still faster than Matplotlib because the layout solver is frozen. cache_frame_data : bool, default: True Whether to cache frame data, as in Matplotlib. **kwargs Passed to `matplotlib.animation.TimedAnimation`, e.g. `interval`, `repeat`, and `repeat_delay`. Examples -------- >>> import ultraplot as uplt >>> import numpy as np >>> fig, ax = uplt.subplots() >>> x = np.linspace(0, 2 * np.pi, 200) >>> (line,) = ax.plot(x, np.sin(x)) >>> def update(frame): ... line.set_ydata(np.sin(x + frame / 10)) ... return (line,) ... >>> ani = uplt.FuncAnimation(fig, update, frames=100) >>> ani.save('waves.mp4') See also -------- matplotlib.animation.FuncAnimation ultraplot.animation.ArtistAnimation """ def __init__( self, fig, func, frames=None, init_func=None, fargs=None, save_count=None, *, blit=True, **kwargs, ): super().__init__( fig, func, frames=frames, init_func=init_func, fargs=fargs, save_count=save_count, blit=blit, **kwargs, ) def _fast_frame_seq(self): return self.new_saved_frame_seq() def _fast_init_artists(self, frame): if self._init_func is None: return self._func(frame, *self._args) return self._init_func() def _fast_frame_artists(self, frame): return self._func(frame, *self._args)
[docs] class ArtistAnimation(_FastSaveMixin, manimation.ArtistAnimation): """ A faster drop-in replacement for `matplotlib.animation.ArtistAnimation`. Frames are lists of artists that are made visible in turn. Saving uses the same direct-to-buffer renderer as `FuncAnimation`. Parameters ---------- fig : `~ultraplot.figure.Figure` The figure to animate. artists : list of list of `~matplotlib.artist.Artist` Each entry is the collection of artists making up one frame. **kwargs Passed to `matplotlib.animation.TimedAnimation`. See also -------- matplotlib.animation.ArtistAnimation ultraplot.animation.FuncAnimation """ def _fast_frame_seq(self): return iter(self._framedata) def _fast_init_artists(self, frame): # Hide every frame, then show the first one, exactly as the parent # class does when it initializes the animation. for other in self._framedata: for artist in other: artist.set_visible(False) return self._fast_frame_artists(frame) def _fast_frame_artists(self, frame): for other in self._framedata: for artist in other: artist.set_visible(False) for artist in frame: artist.set_visible(True) return list(frame)