Coverage for scanpath_studio/animation_export.py: 93%
241 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""Render a Plotly scanpath animation to a shareable GIF or MP4 clip.
3The **Animate** toggle (in the Scanpath Visualization control rail) builds a Plotly
4``go.Figure`` with one frame per fixation onset (see
5:func:`scanpath_studio.plots.make_scanpath_animation`). The
6interactive **HTML** export keeps that figure verbatim — play button, slider and
7all. This module is the non-interactive counterpart: it rasterizes the very same
8frames and encodes them into a GIF or MP4 you can drop into a slide deck, a paper,
9or a chat without needing a browser to replay it.
11How it stays faithful to what the user sees on screen:
13* **Same frames.** Each ``go.Frame`` is applied onto a frameless copy of the base
14 figure and rendered to PNG, so word boxes, true-to-scale labels, saccades,
15 order numbers and the orange current-fixation highlight all match the live view.
16* **Same clock.** The on-screen replay takes ``reading span / playback speed``
17 (BUG-93's wall-clock player); the clip spreads that over its frames, so its
18 runtime equals the playback time quoted on screen (``animation_playback_ms``).
19 A replay faster than the format can show drops frames to stay on time, as the
20 player does between display ticks (:func:`clip_frame_count`).
21* **Same readout.** The slider's "Elapsed: X.Xs" value is re-drawn as a static
22 annotation per frame, since the interactive slider can't survive rasterization.
24Rendering goes through Kaleido (headless Chrome), the same engine the PNG/SVG/PDF
25exports use. A *single* browser is kept warm across all frames
26(``start_sync_server`` → ``calc_fig_sync`` → ``stop_sync_server``): the per-call
27``fig.to_image`` cold-starts Chrome every time (~10 s/frame), whereas a warm
28browser renders each frame in a fraction of a second.
29"""
31from __future__ import annotations
33import io
34import threading
35from collections.abc import Callable, Iterable
36from time import perf_counter
38import numpy as np
39import plotly.graph_objects as go
41from .export_status import ExportStage, StatusCallback, emit_status
43# The interactive formats live elsewhere; these are the rasterized clip formats.
44VIDEO_FORMATS: tuple[str, ...] = ("gif", "mp4")
46# SEC1 (BUG-74): a GIF's cost grows with its raster pixels — every frame is
47# decoded, palette-quantized and written whole (``disposal=2`` stores full frames,
48# not deltas), and the finished file is held in memory for the download. The
49# encoder streams since BUG-74, so a frame no longer costs its decoded size for the
50# whole encode, but the rendered PNGs and the output still scale with
51# frames × width × height × scale². So a GIF over this budget is refused before
52# Kaleido starts. A server other machines can reach gets the lower figure: it is
53# shared, and one visitor's 2000-frame, 2× clip is everyone's outage. Locally the
54# budget only catches the absurd — the rail's own ceiling (2000 frames at 2× of a
55# display-capped figure, ~6 Gpx) fits under it. MP4 is not budgeted: its frames go
56# straight through ffmpeg and H.264 keeps the file small, which is also why the
57# refusal points at it.
58GIF_PIXEL_BUDGET_LOCAL = 8_000_000_000
59GIF_PIXEL_BUDGET_HOSTED = 800_000_000
61_MIME = {"gif": "image/gif", "mp4": "video/mp4"}
63# make_scanpath_animation reserves `plots._CONTROLS_MARGIN_PX` of top margin for
64# the play/slider controls. With the controls stripped we reclaim that band down
65# to a slim one that still fits the "Elapsed" annotation. Only that band goes: a
66# title above it, a caption or horizontal colour bar below the plot, a vertical
67# colour bar on the right and the coordinate-grid ticks keep their own margins.
68_STATIC_TOP_MARGIN_PX = 28
69# The one annotation the raster loop owns — found by name so a frame updates its
70# text and nothing else (the figure's captions, keys and disclosures stay).
71_ELAPSED_ANNOTATION_NAME = "elapsed_readout"
73# Floor on a GIF frame delay: the format stores delays in centiseconds and many
74# viewers silently promote sub-20 ms delays to ~100 ms, so clamp here to keep
75# fast playback honest. MP4 has no such quirk. A replay whose frames are shorter
76# than this renders fewer of them instead (`clip_frame_count`).
77_GIF_MIN_FRAME_MS = 20
78# MP4 plays at one constant rate, but animation frames last anything from a few
79# ms (fast playback on a fine grid) to several hundred (×0.25, or a downsampled
80# long trial). We encode at a fixed, universally-playable rate and hold each
81# animation frame for the right number of video frames (repeats compress to
82# ~nothing in H.264); frames shorter than one video frame are dropped first
83# (`clip_frame_count`), so the clip's runtime tracks the replay across the range.
84_MP4_FPS = 60.0
86ProgressCallback = Callable[[int, int], None]
88# Kaleido's warm server (`start_sync_server` → `calc_fig_sync` → `stop_sync_server`)
89# is one process-wide singleton whose task and result queues are unlocked: two
90# renders overlapping on it can each collect the other's bytes, and one's stop can
91# strand the other. Every warm-server span in the app holds this lock for its whole
92# start → render → stop, so overlapping exports queue instead. Two sessions on one
93# server could always overlap; since UX-150 one session can too, because the
94# current-figure download renders on a worker thread while the script thread is
95# free to start a bundle. Re-entrant in case a span ever opens inside another.
96KALEIDO_LOCK = threading.RLock()
99class AnimationExportError(RuntimeError):
100 """Frame rendering or encoding failed.
102 The most common cause is a missing Chrome/Chromium for Kaleido; the message
103 is surfaced to the user with a hint to fall back to the HTML export.
104 """
107class AnimationBudgetError(AnimationExportError):
108 """The requested GIF is over this server's pixel budget (SEC1 / BUG-74).
110 Raised before anything is rendered. A subclass so a caller that already
111 handles :class:`AnimationExportError` keeps working, and one that wants to can
112 tell "too big" apart from "Chrome is missing" — the message says what to change,
113 and the browser-install hint would be the wrong advice.
114 """
117# Actionable remediation when Kaleido can't find a Chrome/Chromium binary — the
118# usual cause of a failed GIF/MP4 (or static PNG/SVG/PDF) export (ENG-10).
119# BUG-85: worded for both installs. The frozen desktop bundle has no
120# `kaleido_get_chrome` / `plotly_get_chrome` and no Python prompt, but
121# `chromium_browser_path` finds an installed Chrome, Chromium or Edge there too.
122CHROME_INSTALL_HINT = (
123 "Image export needs Chrome, Chromium or Edge, and none was found. Install one "
124 "of them and try again — or, with a pip install, run `plotly_get_chrome -y`. "
125 "The **HTML** export needs no browser."
126)
129def chromium_browser_path() -> str | None:
130 """Return the browser path Kaleido would use, without launching it.
132 ``Chromium.find_browser`` is Choreographer's complete discovery path: it
133 honors ``BROWSER_PATH``, searches ``PATH``, checks platform-specific
134 locations for Chrome, Chromium, Edge, Brave, and Vivaldi, and can fall back
135 to Choreographer's managed Chrome download. We prefer a system browser: a
136 stale managed download must not shadow a working installed Edge/Chrome.
138 Passing the browser-info mapping to ``get_browser_path`` only searches for
139 the mapping's *keys* (``chrome``, ``edge``, …), which misses both managed
140 downloads and standard macOS app locations.
141 """
142 try:
143 from choreographer.browsers.chromium import Chromium
144 except Exception:
145 return None
146 for skip_local in (True, False):
147 try:
148 path = Chromium.find_browser(skip_local=skip_local)
149 except Exception:
150 continue
151 if path:
152 return str(path)
153 return None
156def chrome_available() -> bool:
157 """Whether Kaleido can find a compatible Chromium-family browser."""
158 return chromium_browser_path() is not None
161def mime_for(fmt: str) -> str:
162 return _MIME[fmt.lower()]
165def _elapsed_labels(fig: go.Figure, n_frames: int) -> list[str]:
166 """Per-frame "elapsed reading time" labels, lifted from the slider steps.
168 ``_animation_time_slider`` already computes one ``"X.Xs"`` label per frame, so
169 we reuse them verbatim rather than recomputing onsets. Falls back to blanks if
170 the figure has no slider (e.g. an empty animation).
171 """
172 sliders = fig.layout.sliders
173 if sliders and sliders[0].steps:
174 labels = [step.label or "" for step in sliders[0].steps]
175 if len(labels) >= n_frames:
176 return list(labels[:n_frames])
177 return list(labels) + [""] * (n_frames - len(labels))
178 return [""] * n_frames
181def _static_base(fig: go.Figure) -> go.Figure:
182 """A frameless deep copy of ``fig`` with interactive controls removed.
184 The play/pause/restart buttons and the slider are meaningless in a rasterized
185 clip — and worse, they'd be burnt into every frame. ``update_layout`` can't
186 clear array layout properties (passing ``None`` is a no-op and ``[]`` doesn't
187 truncate the existing entries), so we assign the attributes directly. The
188 reserved control band is then reclaimed so the clip isn't topped by an empty
189 strip; a slim margin remains for the "Elapsed" annotation. Only the control
190 band is reclaimed: the other margins hold a title, a caption, a color bar or
191 the coordinate-grid ticks, and are kept as they are. The replay's clock
192 on ``layout.meta`` (BUG-93) goes too — only the live player reads it, and it
193 would otherwise ride into every frame Kaleido renders.
194 """
195 base = go.Figure(fig)
196 base.frames = ()
197 base.layout.updatemenus = []
198 base.layout.sliders = []
199 base.layout.meta = None
200 base.update_layout(
201 margin=dict(t=int(fig.layout.margin.t or 0) - _control_band_trim(fig)),
202 height=_static_height(fig),
203 )
204 return base
207def _control_band_trim(fig: go.Figure) -> int:
208 """How many px of top margin the stripped transport controls free up."""
209 if not (fig.layout.sliders or fig.layout.updatemenus):
210 return 0
211 from .plots import _CONTROLS_MARGIN_PX
213 top = int(fig.layout.margin.t or 0)
214 return max(min(top, _CONTROLS_MARGIN_PX) - _STATIC_TOP_MARGIN_PX, 0)
217def _static_height(fig: go.Figure) -> int:
218 """The rasterized clip's height: the figure's, less the reclaimed control band.
220 Its own function so the pixel budget can size a clip without deep-copying
221 the figure (and every one of its frames) the way :func:`_static_base` must.
222 """
223 height = int(fig.layout.height or 600)
224 return max(height - _control_band_trim(fig), _STATIC_TOP_MARGIN_PX + 1)
227def _add_elapsed_annotation(base: go.Figure) -> int:
228 """Append the clip's own "Elapsed" readout to ``base``; return its index.
230 A separate entry, so each frame rewrites only its text. Passing
231 ``annotations=[...]`` to ``update_layout`` instead *merges* into the existing
232 array and stamps the readout over every caption, duration key and
233 Illustration disclosure the figure already carries.
234 """
235 base.add_annotation(
236 text="",
237 name=_ELAPSED_ANNOTATION_NAME,
238 x=0.99,
239 y=1.0,
240 xref="paper",
241 yref="paper",
242 xanchor="right",
243 yanchor="bottom",
244 showarrow=False,
245 font=dict(size=14, color="#444"),
246 )
247 return len(base.layout.annotations) - 1
250def _served_to_other_machines() -> bool:
251 """Whether this export runs inside a Streamlit server others can reach.
253 Outside a Streamlit runtime (a script, the CLI) the caller is on their own
254 machine. Inside one, the answer is the server's own bind address — never the
255 URL the browser reports, which the browser controls (see
256 ``persistence.server_bound_to_loopback``).
257 """
258 try:
259 from streamlit import runtime
260 except Exception: # pragma: no cover - streamlit is a hard dependency
261 return False
262 if not runtime.exists():
263 return False
264 from .persistence import server_bound_to_loopback
266 return not server_bound_to_loopback()
269def check_gif_budget(
270 n_frames: int, width: int, height: int, scale: float, *, budget: int | None = None
271) -> None:
272 """Refuse a GIF whose frames would exceed the pixel budget (SEC1 / BUG-74).
274 ``width``/``height`` are the clip's pixels at 1×; Kaleido multiplies both by
275 ``scale``. ``budget`` defaults to :data:`GIF_PIXEL_BUDGET_HOSTED` inside a
276 server other machines can reach and :data:`GIF_PIXEL_BUDGET_LOCAL` anywhere
277 else. Raises :class:`AnimationBudgetError` naming the ways back under — MP4,
278 fewer frames, a lower resolution — with the frame count that would fit at
279 this scale, so the message is something to act on.
280 """
281 shared = budget is None and _served_to_other_machines()
282 if budget is None:
283 budget = GIF_PIXEL_BUDGET_HOSTED if shared else GIF_PIXEL_BUDGET_LOCAL
284 per_frame = max(round(width * scale), 1) * max(round(height * scale), 1)
285 total = int(n_frames) * per_frame
286 if total <= budget:
287 return
288 fits = int(budget) // per_frame
289 shorter = f"cap it at {fits} frames or fewer, or " if fits >= 1 else ""
290 raise AnimationBudgetError(
291 f"a {n_frames}-frame GIF at {width}×{height} px and {scale:g}× comes to "
292 f"{total / 1e6:,.0f} megapixels of frames, over the "
293 f"{budget / 1e6:,.0f}-megapixel limit for one GIF"
294 f"{' on this shared server' if shared else ''}. Export **MP4** instead — "
295 f"its frames stream straight to the encoder and the file stays small — or "
296 f"{shorter}lower the resolution."
297 )
300def clip_frame_count(
301 n_frames: int, frame_duration_ms: float, fmt: str, max_frames: int | None = None
302) -> int:
303 """How many of a replay's ``n_frames`` a ``fmt`` clip renders.
305 A frame shorter than the format can hold — one video frame at ``_MP4_FPS``,
306 or ``_GIF_MIN_FRAME_MS`` for a GIF — would be held that long anyway and
307 stretch the clip, so a fast replay keeps only as many frames as fit its
308 runtime, each held a little longer (BUG-93). ``max_frames`` caps it further.
309 Never fewer than two, so the clip still ends on the whole scanpath.
310 """
311 shortest = _GIF_MIN_FRAME_MS if fmt.lower() == "gif" else 1000.0 / _MP4_FPS
312 fits = int(n_frames * frame_duration_ms / shortest + 1e-9)
313 count = min(n_frames, max(2, fits))
314 return min(count, max_frames) if max_frames else count
317def _select_frames(n: int, max_frames: int | None) -> list[int]:
318 """Indices of frames to render, evenly downsampled to ``max_frames``.
320 Returns ``range(n)`` unchanged when no cap applies. Downsampling keeps the
321 first and last frames (so the clip still starts empty and ends on the full
322 scanpath) and spreads the rest evenly; callers scale the frame duration by
323 ``n / len(selected)`` to preserve the overall runtime.
324 """
325 if max_frames is None or max_frames <= 0 or n <= max_frames:
326 return list(range(n))
327 return sorted({round(i) for i in np.linspace(0, n - 1, max_frames)})
330def render_png_frames(
331 fig: go.Figure,
332 *,
333 scale: float = 1.0,
334 show_elapsed: bool = True,
335 frame_indices: list[int] | None = None,
336 progress_callback: ProgressCallback | None = None,
337) -> tuple[list[bytes], tuple[int, int]]:
338 """Rasterize the animation's frames to PNG bytes via one persistent Kaleido browser.
340 Returns ``(png_bytes_per_frame, (width, height))``. ``frame_indices`` selects a
341 subset (for downsampling); defaults to every frame. ``progress_callback`` is
342 called ``(done, total)`` after each frame so the UI can drive a progress bar.
344 Raises :class:`AnimationExportError` if the figure has no frames, Kaleido is
345 missing, the browser won't start, or a frame fails to render (e.g. no Chrome).
346 """
347 try:
348 import kaleido
349 except Exception as exc: # pragma: no cover - import guard
350 raise AnimationExportError(
351 "Kaleido is not installed, so the animation can't be rasterized to "
352 "GIF/MP4. Use the HTML export instead, or `pip install kaleido`."
353 ) from exc
355 frames = list(fig.frames or ())
356 if not frames:
357 raise AnimationExportError("This animation has no frames to export.")
359 indices = frame_indices if frame_indices is not None else list(range(len(frames)))
360 base = _static_base(fig)
361 width = int(fig.layout.width or 900)
362 height = int(base.layout.height)
363 elapsed = _elapsed_labels(fig, len(frames)) if show_elapsed else None
364 elapsed_index = _add_elapsed_annotation(base) if elapsed is not None else None
366 # A single warm browser renders every frame fast; if it won't start, fall back
367 # to per-frame cold `to_image` (slow, ~10 s/frame) ONLY when Chrome is actually
368 # present (a transient/port/profile issue), mirroring export._figure_renderer.
369 # When Chrome is genuinely missing, abort early with the actionable hint.
370 cold_fallback = False
371 browser_path = chromium_browser_path()
372 if browser_path is None:
373 raise AnimationExportError(CHROME_INSTALL_HINT)
374 with KALEIDO_LOCK:
375 try:
376 kaleido.start_sync_server(path=browser_path, silence_warnings=True)
377 except Exception:
378 cold_fallback = True
380 pngs: list[bytes] = []
381 try:
382 for done, k in enumerate(indices, start=1):
383 frame = frames[k]
384 for data_obj, trace_idx in zip(frame.data, frame.traces):
385 base.data[trace_idx].update(data_obj)
386 if elapsed_index is not None:
387 base.layout.annotations[
388 elapsed_index
389 ].text = f"Elapsed: {elapsed[k]}"
390 try:
391 if cold_fallback:
392 png = base.to_image(
393 format="png", width=width, height=height, scale=scale
394 )
395 else:
396 png = kaleido.calc_fig_sync(
397 base,
398 opts={
399 "format": "png",
400 "width": width,
401 "height": height,
402 "scale": scale,
403 },
404 )
405 except Exception as exc:
406 raise AnimationExportError(
407 CHROME_INSTALL_HINT
408 if not chrome_available()
409 else f"Frame {k + 1} of {len(frames)} couldn't be drawn "
410 f"({exc}). Try again, or export the HTML replay."
411 ) from exc
412 pngs.append(bytes(png))
413 if progress_callback is not None:
414 progress_callback(done, len(indices))
415 finally:
416 if not cold_fallback:
417 try:
418 kaleido.stop_sync_server(silence_warnings=True)
419 except Exception: # pragma: no cover - best-effort teardown
420 pass
422 return pngs, (width, height)
425def _load_rgb_frames(pngs: list[bytes]) -> list[np.ndarray]:
426 from PIL import Image
428 return [np.asarray(Image.open(io.BytesIO(b)).convert("RGB")) for b in pngs]
431def encode_gif(
432 pngs: Iterable[bytes], frame_duration_ms: float, *, loop: int = 0
433) -> bytes:
434 """Encode PNG frames into an animated GIF with a uniform per-frame delay.
436 **Streams** (SEC1 / BUG-74): each PNG is decoded, quantized to a 256-color
437 palette and written before the next is read, so memory holds one decoded frame
438 and one palette frame, not the whole clip. Pillow's ``save(save_all=True)``
439 cannot do that — it keeps every normalized frame until the end to diff them —
440 and handing it the decoded list on top cost ~10 MB per frame at 2× (measured
441 ~4.6 GB for the default 250-frame cap at a large figure). The bytes written
442 are Pillow's own multi-frame layout for this input: the global header comes
443 from frame one, every later frame carries its own palette, each is stored
444 whole (``disposal=2`` with no transparency never crops to a delta), and a frame
445 identical to the one before it is folded into it with the durations summed —
446 which is why this holds one frame back before writing it.
448 GIF stores delays in whole centiseconds and Pillow truncates the rest, so each
449 frame's delay is rounded against the running total instead: the clip keeps
450 its length rather than losing up to 10 ms a frame (BUG-93).
451 """
452 from PIL import GifImagePlugin, Image, ImageChops
454 duration = max(frame_duration_ms, _GIF_MIN_FRAME_MS)
455 buf = io.BytesIO()
456 pending: Image.Image | None = None
457 pending_ms = 0
458 wrote_header = False
459 written_cs = 0
461 def _flush() -> None:
462 nonlocal wrote_header
463 info = {"duration": pending_ms, "disposal": 2, "loop": loop, "optimize": True}
464 if not wrote_header:
465 # `getheader` also normalizes the palette in place, so the frame and
466 # the global colour table it writes agree.
467 header, _used = GifImagePlugin.getheader(pending, info=dict(info))
468 buf.write(b"".join(header))
469 wrote_header = True
470 else:
471 info["include_color_table"] = True
472 buf.write(b"".join(GifImagePlugin.getdata(pending, (0, 0), **info)))
474 for i, png in enumerate(pngs):
475 total_cs = round((i + 1) * duration / 10)
476 delay_ms = 10 * (total_cs - written_cs)
477 written_cs = total_cs
478 with Image.open(io.BytesIO(png)) as decoded:
479 frame = decoded.convert("RGB").convert("P", palette=Image.Palette.ADAPTIVE)
480 if pending is not None:
481 same = pending.getpalette() == frame.getpalette() and (
482 ImageChops.subtract_modulo(frame, pending).getbbox() is None
483 )
484 if same:
485 pending_ms += delay_ms
486 continue
487 _flush()
488 pending, pending_ms = frame, delay_ms
489 if pending is None:
490 raise AnimationExportError("No frames to encode.")
491 _flush()
492 buf.write(b";")
493 return buf.getvalue()
496def encode_mp4(pngs: list[bytes], frame_duration_ms: float) -> bytes:
497 """Encode PNG frames into an H.264 MP4 whose runtime matches the on-screen replay.
499 Every frame is held for ``frame_duration_ms``. An MP4 plays at one constant
500 rate, so we encode at a fixed 60 fps and hold each animation frame for
501 ``round(frame_duration_ms / (1000/60))`` video frames (at least one — a caller
502 with shorter frames renders fewer of them, see :func:`clip_frame_count`). That
503 reproduces durations from ~16 ms to several hundred ms accurately — the repeated
504 frames are identical, so H.264 compresses them to near-nothing. Frames stream
505 through the writer one at a time (repeats reuse the same array), so memory stays
506 flat regardless of clip length. H.264 ``yuv420p`` needs even dimensions, so each
507 frame is edge-padded to even width/height.
508 """
509 import os
510 import tempfile
512 import imageio
514 if not pngs:
515 raise AnimationExportError("No frames to encode.")
517 dt_ms = 1000.0 / _MP4_FPS
519 # Deliberately not a context manager: `delete=False` plus an immediate
520 # close is how you reserve a path for ffmpeg to write. A `with` block
521 # would delete the file before the encoder ever opened it.
522 tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
523 tmp.close()
524 try:
525 writer = imageio.get_writer(
526 tmp.name,
527 format="FFMPEG",
528 mode="I",
529 fps=_MP4_FPS,
530 codec="libx264",
531 pixelformat="yuv420p",
532 macro_block_size=1,
533 )
534 try:
535 pad = None
536 # Error-diffuse the per-frame repeat count against the cumulative
537 # target time so rounding never accumulates into runtime drift: the
538 # clip lands on round(n * frame_duration / dt) video frames exactly.
539 emitted = 0
540 for i, b in enumerate(pngs):
541 arr = _load_rgb_frames([b])[0]
542 if pad is None:
543 h, w = arr.shape[:2]
544 pad = (h % 2, w % 2)
545 if pad[0] or pad[1]:
546 arr = np.pad(arr, ((0, pad[0]), (0, pad[1]), (0, 0)), mode="edge")
547 target_total = round((i + 1) * frame_duration_ms / dt_ms)
548 reps = max(1, target_total - emitted)
549 emitted += reps
550 for _ in range(reps):
551 writer.append_data(arr)
552 finally:
553 writer.close()
554 with open(tmp.name, "rb") as fh:
555 return fh.read()
556 except AnimationExportError:
557 raise
558 except Exception as exc:
559 raise AnimationExportError(
560 f"The MP4 couldn't be made ({exc}). Try GIF, or the HTML replay."
561 ) from exc
562 finally:
563 try:
564 os.unlink(tmp.name)
565 except OSError: # pragma: no cover - best-effort cleanup
566 pass
569def export_animation(
570 fig: go.Figure,
571 *,
572 fmt: str,
573 frame_duration_ms: float | None = None,
574 scale: float = 1.0,
575 show_elapsed: bool = True,
576 max_frames: int | None = None,
577 progress_callback: ProgressCallback | None = None,
578 status_callback: StatusCallback | None = None,
579) -> bytes:
580 """Render a scanpath-animation figure to GIF or MP4 bytes.
582 Args:
583 fig: a replay from ``scanpath_studio.animate_scanpath`` (it must have frames).
584 fmt: ``"gif"`` or ``"mp4"``.
585 frame_duration_ms: uniform per-frame duration. By default the replay's
586 own (:func:`plots.animation_clip_frame_ms`), so the clip lasts what
587 the on-screen replay does — ``reading span / playback speed``.
588 Required for a figure ``animate_scanpath`` didn't build.
589 scale: Kaleido render scale (1.0 = on-screen px; <1 is faster/smaller,
590 >1 is crisper/larger).
591 show_elapsed: draw the "Elapsed: X.Xs" readout in the top margin.
592 max_frames: cap the number of rendered frames by even downsampling; the
593 frame duration is scaled up to keep the total runtime unchanged. The
594 UI uses this to bound render time on very long trials.
595 progress_callback: ``(done, total)`` after each rendered frame.
597 Raises:
598 ValueError: unknown ``fmt``, or no ``frame_duration_ms`` for a figure that
599 carries no replay clock.
600 AnimationBudgetError: a GIF over :func:`check_gif_budget`'s pixel budget,
601 raised before any frame is rendered.
602 AnimationExportError: rendering or encoding failed.
603 """
604 started = perf_counter()
605 fmt = fmt.lower()
606 if fmt not in VIDEO_FORMATS:
607 raise ValueError(
608 f"Unsupported format {fmt!r}; expected one of {VIDEO_FORMATS}."
609 )
611 if frame_duration_ms is None:
612 from .plots import animation_clip_frame_ms
614 frame_duration_ms = animation_clip_frame_ms(fig)
615 if frame_duration_ms is None:
616 raise ValueError(
617 "This figure carries no replay clock (it wasn't built by "
618 "make_scanpath_animation); pass frame_duration_ms."
619 )
621 n_total = len(fig.frames or ())
622 indices = _select_frames(
623 n_total, clip_frame_count(n_total, frame_duration_ms, fmt, max_frames)
624 )
625 # Preserve total runtime when downsampling: fewer frames, each held longer.
626 effective_duration = frame_duration_ms
627 if indices and len(indices) < n_total:
628 effective_duration = frame_duration_ms * n_total / len(indices)
630 emit_status(
631 status_callback,
632 ExportStage.PREPARING,
633 f"Preparing {len(indices)} animation frames…",
634 started_at=started,
635 )
636 try:
637 if fmt == "gif" and indices:
638 # SEC1: refuse before Chrome starts, not after the frames are made.
639 check_gif_budget(
640 len(indices), int(fig.layout.width or 900), _static_height(fig), scale
641 )
642 emit_status(
643 status_callback,
644 ExportStage.STARTING_RENDERER,
645 "Starting one shared Chrome/Kaleido renderer…",
646 started_at=started,
647 )
649 def _on_frame(done: int, total: int) -> None:
650 if progress_callback is not None:
651 progress_callback(done, total)
652 emit_status(
653 status_callback,
654 ExportStage.RASTERIZING,
655 f"Rendered frame {done}/{total}…",
656 started_at=started,
657 completed=done,
658 total=total,
659 )
661 pngs, _size = render_png_frames(
662 fig,
663 scale=scale,
664 show_elapsed=show_elapsed,
665 frame_indices=indices,
666 progress_callback=_on_frame,
667 )
668 emit_status(
669 status_callback,
670 ExportStage.ENCODING_WRITING,
671 f"Encoding {fmt.upper()}…",
672 started_at=started,
673 )
674 result = (
675 encode_gif(pngs, effective_duration)
676 if fmt == "gif"
677 else encode_mp4(pngs, effective_duration)
678 )
679 emit_status(
680 status_callback,
681 ExportStage.FINALIZING,
682 "Finishing the animation…",
683 started_at=started,
684 )
685 result = bytes(result)
686 emit_status(
687 status_callback,
688 ExportStage.READY,
689 "Animation is ready to download.",
690 started_at=started,
691 completed=len(indices),
692 total=len(indices),
693 )
694 return result
695 except Exception as exc:
696 emit_status(
697 status_callback,
698 ExportStage.ERROR,
699 "Animation export failed.",
700 started_at=started,
701 error=str(exc),
702 )
703 raise