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

1"""Render a Plotly scanpath animation to a shareable GIF or MP4 clip. 

2 

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. 

10 

11How it stays faithful to what the user sees on screen: 

12 

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. 

23 

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""" 

30 

31from __future__ import annotations 

32 

33import io 

34import threading 

35from collections.abc import Callable, Iterable 

36from time import perf_counter 

37 

38import numpy as np 

39import plotly.graph_objects as go 

40 

41from .export_status import ExportStage, StatusCallback, emit_status 

42 

43# The interactive formats live elsewhere; these are the rasterized clip formats. 

44VIDEO_FORMATS: tuple[str, ...] = ("gif", "mp4") 

45 

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 

60 

61_MIME = {"gif": "image/gif", "mp4": "video/mp4"} 

62 

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" 

72 

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 

85 

86ProgressCallback = Callable[[int, int], None] 

87 

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() 

97 

98 

99class AnimationExportError(RuntimeError): 

100 """Frame rendering or encoding failed. 

101 

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 """ 

105 

106 

107class AnimationBudgetError(AnimationExportError): 

108 """The requested GIF is over this server's pixel budget (SEC1 / BUG-74). 

109 

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 """ 

115 

116 

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) 

127 

128 

129def chromium_browser_path() -> str | None: 

130 """Return the browser path Kaleido would use, without launching it. 

131 

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. 

137 

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 

154 

155 

156def chrome_available() -> bool: 

157 """Whether Kaleido can find a compatible Chromium-family browser.""" 

158 return chromium_browser_path() is not None 

159 

160 

161def mime_for(fmt: str) -> str: 

162 return _MIME[fmt.lower()] 

163 

164 

165def _elapsed_labels(fig: go.Figure, n_frames: int) -> list[str]: 

166 """Per-frame "elapsed reading time" labels, lifted from the slider steps. 

167 

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 

179 

180 

181def _static_base(fig: go.Figure) -> go.Figure: 

182 """A frameless deep copy of ``fig`` with interactive controls removed. 

183 

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 

205 

206 

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 

212 

213 top = int(fig.layout.margin.t or 0) 

214 return max(min(top, _CONTROLS_MARGIN_PX) - _STATIC_TOP_MARGIN_PX, 0) 

215 

216 

217def _static_height(fig: go.Figure) -> int: 

218 """The rasterized clip's height: the figure's, less the reclaimed control band. 

219 

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) 

225 

226 

227def _add_elapsed_annotation(base: go.Figure) -> int: 

228 """Append the clip's own "Elapsed" readout to ``base``; return its index. 

229 

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 

248 

249 

250def _served_to_other_machines() -> bool: 

251 """Whether this export runs inside a Streamlit server others can reach. 

252 

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 

265 

266 return not server_bound_to_loopback() 

267 

268 

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). 

273 

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 ) 

298 

299 

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. 

304 

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 

315 

316 

317def _select_frames(n: int, max_frames: int | None) -> list[int]: 

318 """Indices of frames to render, evenly downsampled to ``max_frames``. 

319 

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)}) 

328 

329 

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. 

339 

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. 

343 

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 

354 

355 frames = list(fig.frames or ()) 

356 if not frames: 

357 raise AnimationExportError("This animation has no frames to export.") 

358 

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 

365 

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 

379 

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 

421 

422 return pngs, (width, height) 

423 

424 

425def _load_rgb_frames(pngs: list[bytes]) -> list[np.ndarray]: 

426 from PIL import Image 

427 

428 return [np.asarray(Image.open(io.BytesIO(b)).convert("RGB")) for b in pngs] 

429 

430 

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. 

435 

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. 

447 

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 

453 

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 

460 

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))) 

473 

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() 

494 

495 

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. 

498 

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 

511 

512 import imageio 

513 

514 if not pngs: 

515 raise AnimationExportError("No frames to encode.") 

516 

517 dt_ms = 1000.0 / _MP4_FPS 

518 

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 

567 

568 

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. 

581 

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. 

596 

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 ) 

610 

611 if frame_duration_ms is None: 

612 from .plots import animation_clip_frame_ms 

613 

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 ) 

620 

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) 

629 

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 ) 

648 

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 ) 

660 

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