Source code for hypertools.plot.hyper_animation

"""HyperAnimation: the object an animated ``hyp.plot(...)`` returns.

A thin wrapper around the matplotlib ``Figure`` and ``FuncAnimation`` produced
by an animated plot. It exists so that, in a notebook, the animation plays
inline automatically (via ``_repr_html_``), and so the caller can export it
directly::

    anim = hyp.plot(data, animate='spin')   # auto-plays inline in Jupyter
    anim.to_html5_video()                    # HTML5 <video> string
    anim.save('trajectory.gif')              # write a file
    fig = anim.figure                        # the matplotlib Figure

Before QC 2026-07 an animated ``plot`` returned a bare ``(figure, animation)``
tuple, so the natural ``anim = hyp.plot(...); anim.to_html5_video()`` failed
(``'tuple'`` has no ``to_html5_video``). ``HyperAnimation`` subclasses ``tuple``
and IS ``(figure, animation)``, so every existing pattern keeps working --
``fig, anim = hyp.plot(...)``, ``out[0]``, ``isinstance(out, tuple)``,
``len(out) == 2`` -- while the export/display methods are now available on the
returned object itself.
"""


def mark_draw_started(animation):
    """Silence matplotlib's 'Animation was deleted without rendering anything'
    UserWarning for ``animation`` (release-1.0 audit, X4-warnings-012).

    The warning fires from ``Animation.__del__`` when the private
    ``_draw_was_started`` flag is still False; matplotlib only reads the flag
    there (and itself force-sets it True in ``Animation.save``), so marking it
    True has no effect on rendering -- saving/display still work exactly as
    before. Used by ``HyperAnimation.__del__`` when a wrapper is discarded,
    and by ``plot.py`` when handing the RAW ``FuncAnimation`` back in a
    ``return_model=True`` bundle (that path never constructs a
    ``HyperAnimation``, so without this the animation -- kept in a reference
    cycle by its own canvas callbacks -- warned at the next cyclic-gc pass,
    misattributed to whatever code ran later)."""
    try:
        if getattr(animation, '_draw_was_started', None) is False:
            animation._draw_was_started = True
    except Exception:
        # never let cleanup raise during interpreter shutdown/GC
        pass


[docs] class HyperAnimation(tuple): """A ``(figure, animation)`` tuple with animation export/display helpers. Indexing/unpacking behave exactly like the legacy 2-tuple (``fig, anim = result``; ``result[0]`` is the figure, ``result[1]`` the animation), so this is a drop-in replacement for the old return value. """ def __new__(cls, figure, animation): self = super().__new__(cls, (figure, animation)) return self @property def figure(self): """The matplotlib ``Figure`` the animation draws on.""" return self[0] @property def animation(self): """The underlying ``matplotlib.animation.Animation`` (usually a ``FuncAnimation``). Keeping a reference to it keeps the animation alive.""" return self[1] # --- export / display -------------------------------------------------- def to_html5_video(self, *args, **kwargs): """Return an HTML5 ``<video>`` string for the animation (needs FFmpeg; delegates to ``matplotlib.animation.Animation.to_html5_video``).""" return self.animation.to_html5_video(*args, **kwargs) def to_jshtml(self, *args, **kwargs): """Return an interactive JavaScript HTML animation (no FFmpeg needed; delegates to ``matplotlib.animation.Animation.to_jshtml``).""" return self.animation.to_jshtml(*args, **kwargs) def save(self, filename, *args, **kwargs): """Save the animation to a file. The writer is chosen by file extension -- .gif and .png/.apng (animated PNG) via Pillow, .svg as a frame-capped vector animation, .mp4/.mov/.avi/.m4v/.mkv via ffmpeg (only the video formats need ffmpeg) -- matching what ``hyp.plot(..., save_path=...)`` supports; any other extension raises ``ValueError`` naming the supported formats. ``filename`` may be a str or any path-like (e.g. ``pathlib.Path``). Passing an explicit ``writer`` (or positional args) delegates straight to ``matplotlib.animation.Animation.save`` instead. QC 2026-07: ``.save('x.svg')`` / ``.save('x.png')`` used to crash (raw ``Animation.save`` tried to pipe h264 into an svg/png), even though the same extensions work via ``save_path=``. """ if args or 'writer' in kwargs: return self.animation.save(filename, *args, **kwargs) from .animate import _save_animation fps = kwargs.pop('fps', None) or self._fps() return _save_animation(self.animation, str(filename), fps) def _fps(self): """Frames per second from the animation's frame interval (default 30).""" interval = getattr(self.animation, '_interval', None) if interval: return max(1, round(1000.0 / interval)) return 30 def _repr_html_(self): """Rich display in Jupyter/Colab: play the animation inline. Prefer an HTML5 video (needs FFmpeg); fall back to the JS-HTML animation, which needs no external tools.""" try: return self.animation.to_html5_video() except Exception: try: return self.animation.to_jshtml() except Exception: return None def __repr__(self): return (f"HyperAnimation(figure={self.figure!r}, " f"animation={type(self.animation).__name__})") def __del__(self): """Silence matplotlib's 'Animation was deleted without rendering anything' UserWarning when a HyperAnimation is discarded/rebound without ever being saved or displayed (release-1.0 audit, X4-warnings-012: a common exploratory pattern -- ``hyp.plot(..., animate=True)`` in a loop, or an unbound call -- scolded users at garbage collection for hypertools' own object lifecycle). The warning fires from ``Animation.__del__`` when its private ``_draw_was_started`` flag is still False; marking the flag here, as this wrapper is collected, keeps matplotlib quiet without affecting rendering (nothing is drawn or closed -- saving/display still work exactly as before if the inner animation object outlives the wrapper). Delegates to ``mark_draw_started`` (also used by the ``return_model=True`` bundle path in ``plot.py``).""" try: mark_draw_started(self[1]) except Exception: # never let cleanup raise during interpreter shutdown/GC pass