simplex-web 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. simplex/README.md +32 -0
  2. simplex/cli/README.md +13 -0
  3. simplex/cli/__init__.py +5 -0
  4. simplex/cli/commands.py +384 -0
  5. simplex/deck/README.md +19 -0
  6. simplex/deck/__init__.py +7 -0
  7. simplex/deck/_template/assets/.gitkeep +0 -0
  8. simplex/deck/_template/assets/code/.gitkeep +0 -0
  9. simplex/deck/_template/assets/figures/.gitkeep +0 -0
  10. simplex/deck/_template/deck.toml +11 -0
  11. simplex/deck/_template/manim.cfg +3 -0
  12. simplex/deck/_template/notes.md +27 -0
  13. simplex/deck/_template/refs.bib +12 -0
  14. simplex/deck/_template/slides/__init__.py +7 -0
  15. simplex/deck/_template/slides/intro.py +21 -0
  16. simplex/deck/config.py +207 -0
  17. simplex/deck/registry.py +110 -0
  18. simplex/deck/scaffold.py +86 -0
  19. simplex/deck/section.py +40 -0
  20. simplex/engine/README.md +9 -0
  21. simplex/render/README.md +46 -0
  22. simplex/render/__init__.py +1 -0
  23. simplex/render/html.py +132 -0
  24. simplex/render/pdf.py +32 -0
  25. simplex/render/pptx.py +32 -0
  26. simplex/render/reconcile.py +350 -0
  27. simplex/render/runner.py +116 -0
  28. simplex/render/thumbnail.py +374 -0
  29. simplex/slides/README.md +9 -0
  30. simplex/slides/components/README.md +9 -0
  31. simplex/theme/README.md +9 -0
  32. simplex/web/README.md +33 -0
  33. simplex/web/__init__.py +1 -0
  34. simplex/web/bibliography.py +248 -0
  35. simplex/web/bibtex.py +129 -0
  36. simplex/web/builder.py +321 -0
  37. simplex/web/callouts.py +134 -0
  38. simplex/web/citations.py +118 -0
  39. simplex/web/equations.py +79 -0
  40. simplex/web/notes.py +135 -0
  41. simplex/web/refs.py +60 -0
  42. simplex/web/sidenotes.py +76 -0
  43. simplex/web/site_config.py +71 -0
  44. simplex/web/slide_ref.py +54 -0
  45. simplex/web/static/.gitkeep +0 -0
  46. simplex/web/static/README.md +23 -0
  47. simplex/web/static/fonts/lato/lato-latin-400-italic.woff2 +0 -0
  48. simplex/web/static/fonts/lato/lato-latin-400-normal.woff2 +0 -0
  49. simplex/web/static/fonts/lato/lato-latin-700-italic.woff2 +0 -0
  50. simplex/web/static/fonts/lato/lato-latin-700-normal.woff2 +0 -0
  51. simplex/web/static/fonts/lato/lato-latin-900-normal.woff2 +0 -0
  52. simplex/web/static/fonts/merriweather/merriweather-latin-400-italic.woff2 +0 -0
  53. simplex/web/static/fonts/merriweather/merriweather-latin-400-normal.woff2 +0 -0
  54. simplex/web/static/fonts/merriweather/merriweather-latin-700-italic.woff2 +0 -0
  55. simplex/web/static/fonts/merriweather/merriweather-latin-700-normal.woff2 +0 -0
  56. simplex/web/static/fonts/merriweather/merriweather-latin-900-normal.woff2 +0 -0
  57. simplex/web/static/htmx.min.js +1 -0
  58. simplex/web/static/katex/auto-render.min.js +1 -0
  59. simplex/web/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  60. simplex/web/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  61. simplex/web/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  62. simplex/web/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  63. simplex/web/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  64. simplex/web/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  65. simplex/web/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  66. simplex/web/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  67. simplex/web/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  68. simplex/web/static/katex/katex.min.css +1 -0
  69. simplex/web/static/katex/katex.min.js +1 -0
  70. simplex/web/static/lucide/README.md +7 -0
  71. simplex/web/static/lucide/lucide.min.js +12 -0
  72. simplex/web/static/notes.js +68 -0
  73. simplex/web/static/reveal.js/reset.css +30 -0
  74. simplex/web/static/reveal.js/reveal.css +8 -0
  75. simplex/web/static/reveal.js/reveal.js +9 -0
  76. simplex/web/static/simplex.css +1870 -0
  77. simplex/web/static/tailwind.js +64 -0
  78. simplex/web/static/viewer.js +428 -0
  79. simplex/web/templates/README.md +19 -0
  80. simplex/web/templates/_carousel.html +117 -0
  81. simplex/web/templates/base.html +110 -0
  82. simplex/web/templates/deck.html +149 -0
  83. simplex/web/templates/index.html +20 -0
  84. simplex/web/templates/revealjs.html.j2 +374 -0
  85. simplex/web/templates/section.html +74 -0
  86. simplex/web/vendor.py +148 -0
  87. simplex_web-0.2.0.dist-info/METADATA +166 -0
  88. simplex_web-0.2.0.dist-info/RECORD +91 -0
  89. simplex_web-0.2.0.dist-info/WHEEL +4 -0
  90. simplex_web-0.2.0.dist-info/entry_points.txt +2 -0
  91. simplex_web-0.2.0.dist-info/licenses/LICENSE +21 -0
simplex/render/pptx.py ADDED
@@ -0,0 +1,32 @@
1
+ """Convert a rendered deck to PowerPoint via ``manim_slides.convert.PowerPoint``.
2
+
3
+ Free path through manim-slides' in-process converter; same pattern as
4
+ ``render/pdf.py``. Users who need a corporate-PowerPoint format get it
5
+ without extra dependencies.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from simplex.deck.config import DeckConfig
11
+
12
+
13
+ def export(deck: DeckConfig, *, output_dir: Path) -> Path:
14
+ """Write ``<output_dir>/<slug>.pptx`` from manim-slides' rendered scenes."""
15
+ from manim_slides.convert import PowerPoint
16
+ from manim_slides.present import get_scenes_presentation_config
17
+
18
+ output_dir.mkdir(parents=True, exist_ok=True)
19
+ media_dir = output_dir.resolve()
20
+ pptx_path = media_dir / f"{deck.slug}.pptx"
21
+ scenes = deck.scene_class_names
22
+ if not scenes:
23
+ raise ValueError(f"deck {deck.slug!r} has no scenes/entrypoints configured")
24
+
25
+ presentation_configs = get_scenes_presentation_config(
26
+ list(scenes),
27
+ media_dir / "slides",
28
+ )
29
+ PowerPoint(presentation_configs=presentation_configs).convert_to( # pyright: ignore[reportCallIssue]
30
+ pptx_path
31
+ )
32
+ return pptx_path
@@ -0,0 +1,350 @@
1
+ """Build a main/sub-slide manifest from manim's native sections JSON.
2
+
3
+ Two JSON sources are read per scene:
4
+
5
+ - ``<media>/videos/<src_stem>/<quality>/sections/<Scene>.json`` -- written
6
+ by manim's ``SceneFileWriter.combine_to_section_videos`` when
7
+ ``save_sections=True`` is set (the Simplex plugin always sets it). One
8
+ entry per ``Scene.next_section(name=..., section_type=...)`` call. Carries
9
+ ``name``, ``type``, ``video``, plus ffprobe metadata.
10
+ - ``<media>/slides/<Scene>.json`` -- written by manim-slides
11
+ (``PresentationConfig``). Carries the per-slide media paths used by the
12
+ RevealJS converter.
13
+
14
+ The reconciler walks each scene's sections in order and groups consecutive
15
+ SUB rows under their preceding MAIN, producing a ``DeckManifest`` of
16
+ ``MainSlide`` records. The schema (``DeckManifest``, ``MainSlide``,
17
+ ``Subsection``) is owned by the ``manim-simplex`` plugin and imported
18
+ from :mod:`simplex.manifest` -- the two repos share a single Pydantic
19
+ definition rather than maintaining parallel copies.
20
+ """
21
+
22
+ import contextlib
23
+ import json
24
+ import shutil
25
+ import subprocess
26
+ from pathlib import Path
27
+
28
+ from simplex.deck.config import DeckConfig
29
+ from simplex.manifest import DeckManifest, MainSlide, Subsection
30
+ from simplex.section import SimplexSectionType
31
+
32
+ # Section types we recognise as a MAIN boundary. Anything not on this list
33
+ # (and not the auto-created first ``default.normal``) is attached as a sub.
34
+ _MAIN_PREFIX = "simplex.main"
35
+ _DEFAULT_NORMAL = "default.normal"
36
+
37
+
38
+ def _coerce_section_type(raw: str, *, as_main: bool) -> SimplexSectionType:
39
+ """Map a raw Manim sections-JSON ``type`` string to a ``SimplexSectionType``.
40
+
41
+ Strings that already match a Simplex value (``simplex.main``,
42
+ ``simplex.sub.loop``, ...) round-trip into the matching enum.
43
+ Anything else (``default.normal`` from Manim's auto-created pre-amble,
44
+ user-written custom types) is bucketed into ``MAIN`` or ``SUB`` based
45
+ on the reconciler's classification at the call site.
46
+ """
47
+ try:
48
+ return SimplexSectionType(raw)
49
+ except ValueError:
50
+ return SimplexSectionType.MAIN if as_main else SimplexSectionType.SUB
51
+
52
+
53
+ def _ffprobe_duration(video: Path) -> float:
54
+ """Return the duration of ``video`` in seconds, or 0.0 if ffprobe is missing."""
55
+ if shutil.which("ffprobe") is None or not video.exists():
56
+ return 0.0
57
+ with contextlib.suppress(subprocess.SubprocessError):
58
+ result = subprocess.run(
59
+ [
60
+ "ffprobe",
61
+ "-v",
62
+ "error",
63
+ "-show_entries",
64
+ "format=duration",
65
+ "-of",
66
+ "default=noprint_wrappers=1:nokey=1",
67
+ str(video),
68
+ ],
69
+ check=True,
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=15,
73
+ )
74
+ try:
75
+ return float(result.stdout.strip() or 0.0)
76
+ except ValueError:
77
+ return 0.0
78
+ return 0.0
79
+
80
+
81
+ def _av_duration(video: Path) -> float:
82
+ """Return the duration of ``video`` via PyAV, or 0.0 if unavailable."""
83
+ if not video.exists():
84
+ return 0.0
85
+ try:
86
+ import av
87
+ from av.error import FFmpegError
88
+ except ImportError:
89
+ return 0.0
90
+ try:
91
+ with av.open(str(video)) as container:
92
+ if container.duration is not None:
93
+ return float(container.duration / 1_000_000)
94
+ stream = container.streams.video[0]
95
+ if stream.duration is not None and stream.time_base is not None:
96
+ return float(stream.duration * stream.time_base)
97
+ except (FFmpegError, IndexError, OSError, ValueError):
98
+ return 0.0
99
+ return 0.0
100
+
101
+
102
+ def _media_duration(video: Path) -> float:
103
+ """Return media duration using the fastest available local decoder."""
104
+ return _ffprobe_duration(video) or _av_duration(video)
105
+
106
+
107
+ def _find_sections_json(media_dir: Path, scene: str) -> Path | None:
108
+ """Find ``<media_dir>/videos/*/*/sections/<scene>.json`` (glob over qualities)."""
109
+ if not media_dir.exists():
110
+ return None
111
+ matches = list((media_dir / "videos").glob(f"*/*/sections/{scene}.json"))
112
+ return matches[0] if matches else None
113
+
114
+
115
+ def _find_presentation_json(media_dir: Path, scene: str) -> Path | None:
116
+ """Find ``<media_dir>/slides/<scene>.json`` written by manim-slides."""
117
+ path = media_dir / "slides" / f"{scene}.json"
118
+ return path if path.exists() else None
119
+
120
+
121
+ def _parse_sections(json_path: Path) -> list[dict[str, object]]:
122
+ """Read manim's sections JSON; return a list of section-dict rows.
123
+
124
+ Schema (subject to drift): each entry has ``name``, ``type``, ``video``,
125
+ plus ffprobe metadata (``width``, ``height``, ``fps``, ``duration``).
126
+ Missing fields are tolerated.
127
+ """
128
+ try:
129
+ data = json.loads(json_path.read_text(encoding="utf-8"))
130
+ except OSError:
131
+ return []
132
+ except json.JSONDecodeError:
133
+ return []
134
+ if not isinstance(data, list):
135
+ return []
136
+ return [row for row in data if isinstance(row, dict)]
137
+
138
+
139
+ def _parse_presentation_slides(json_path: Path) -> list[dict[str, object]]:
140
+ """Read manim-slides PresentationConfig rows from ``slides/<scene>.json``."""
141
+ try:
142
+ data = json.loads(json_path.read_text(encoding="utf-8"))
143
+ except OSError:
144
+ return []
145
+ except json.JSONDecodeError:
146
+ return []
147
+ slides = data.get("slides") if isinstance(data, dict) else None
148
+ if not isinstance(slides, list):
149
+ return []
150
+ return [row for row in slides if isinstance(row, dict)]
151
+
152
+
153
+ def _video_path(row: dict[str, object], sections_dir: Path) -> Path | None:
154
+ """Resolve a section's video file. Manim stores the basename in ``video``."""
155
+ raw = row.get("video")
156
+ if not isinstance(raw, str) or not raw:
157
+ return None
158
+ candidate = Path(raw)
159
+ if not candidate.is_absolute():
160
+ candidate = (sections_dir / candidate).resolve()
161
+ return candidate if candidate.exists() else None
162
+
163
+
164
+ def _presentation_video_path(row: dict[str, object], media_dir: Path) -> Path | None:
165
+ """Resolve a manim-slides media path relative to the deck media directory."""
166
+ raw = row.get("file")
167
+ if not isinstance(raw, str) or not raw:
168
+ return None
169
+ candidate = Path(raw.replace("\\", "/"))
170
+ if not candidate.is_absolute():
171
+ candidate = (media_dir / candidate).resolve()
172
+ return candidate if candidate.exists() else None
173
+
174
+
175
+ def _row_duration(row: dict[str, object], video: Path | None) -> float:
176
+ raw = row.get("duration")
177
+ if isinstance(raw, (int, float)):
178
+ return float(raw)
179
+ if isinstance(raw, str):
180
+ try:
181
+ return float(raw)
182
+ except ValueError:
183
+ pass
184
+ return _media_duration(video) if video is not None else 0.0
185
+
186
+
187
+ def _presentation_subsections(media_dir: Path, scene: str) -> tuple[Subsection, ...]:
188
+ """Fallback sub-stops from manim-slides JSON when Manim sections are absent."""
189
+ json_path = _find_presentation_json(media_dir, scene)
190
+ if json_path is None:
191
+ return ()
192
+ subs: list[Subsection] = []
193
+ for i, row in enumerate(_parse_presentation_slides(json_path), start=1):
194
+ video = _presentation_video_path(row, media_dir)
195
+ if video is None:
196
+ continue
197
+ subs.append(
198
+ Subsection(
199
+ name=f"{scene} {i}",
200
+ section_type=(SimplexSectionType.MAIN if i == 1 else SimplexSectionType.SUB),
201
+ video=video,
202
+ duration_s=_media_duration(video),
203
+ )
204
+ )
205
+ return tuple(subs)
206
+
207
+
208
+ def _is_main_section(type_str: str, *, is_first_in_scene: bool) -> bool:
209
+ """Whether this section starts a new MAIN slide.
210
+
211
+ Manim creates an implicit ``default.normal`` section at the start of
212
+ every scene. When the very next section is an explicit ``simplex.main``,
213
+ the user clearly intends *that* to be the slide's start, so the leading
214
+ ``default.normal`` is absorbed by the caller as a lead-in subsection
215
+ (handled in ``build_manifest``); ``_is_main_section`` only returns True
216
+ for ``default.normal`` when the scene has no explicit main marker at all.
217
+ """
218
+ if type_str.startswith(_MAIN_PREFIX):
219
+ return True
220
+ return is_first_in_scene and type_str == _DEFAULT_NORMAL
221
+
222
+
223
+ def _absorb_leading_default(rows: list[dict[str, object]]) -> int:
224
+ """Count leading ``default.normal`` rows that should fold into the next main.
225
+
226
+ Returns the number of leading rows to attach as lead-in subsections of the
227
+ first ``simplex.main`` row. Returns ``0`` when there's no following
228
+ ``simplex.main`` (so the implicit default stays as its own main).
229
+ """
230
+ count = 0
231
+ while count < len(rows) and str(rows[count].get("type", "")) == _DEFAULT_NORMAL:
232
+ count += 1
233
+ if count == 0 or count >= len(rows):
234
+ return 0
235
+ next_type = str(rows[count].get("type", ""))
236
+ return count if next_type.startswith(_MAIN_PREFIX) else 0
237
+
238
+
239
+ def _humanise(camel: str) -> str:
240
+ if not camel:
241
+ return camel
242
+ out: list[str] = [camel[0]]
243
+ for ch in camel[1:]:
244
+ if ch.isupper() and out[-1] != " ":
245
+ out.append(" ")
246
+ out.append(ch)
247
+ return "".join(out)
248
+
249
+
250
+ def build_manifest(deck: DeckConfig, *, media_dir: Path) -> DeckManifest:
251
+ """Read every scene's sections JSON and return a main/sub tree."""
252
+ main_slides: list[MainSlide] = []
253
+ counter = 1
254
+ for scene in deck.scene_class_names:
255
+ json_path = _find_sections_json(media_dir, scene)
256
+ if json_path is None:
257
+ # Pre-render or test mode: synthesize one empty main per scene.
258
+ subsections = _presentation_subsections(media_dir, scene)
259
+ main_slides.append(
260
+ MainSlide(
261
+ index=counter,
262
+ scene=scene,
263
+ name=_humanise(scene),
264
+ section_type=SimplexSectionType.MAIN,
265
+ subsections=subsections,
266
+ )
267
+ )
268
+ counter += 1
269
+ continue
270
+ sections_dir = json_path.parent
271
+ rows = _parse_sections(json_path)
272
+ if not rows:
273
+ main_slides.append(
274
+ MainSlide(
275
+ index=counter,
276
+ scene=scene,
277
+ name=_humanise(scene),
278
+ section_type=SimplexSectionType.MAIN,
279
+ subsections=(),
280
+ )
281
+ )
282
+ counter += 1
283
+ continue
284
+
285
+ pending_name: str | None = None
286
+ pending_type: SimplexSectionType | None = None
287
+ pending_subs: list[Subsection] = []
288
+
289
+ absorbed = _absorb_leading_default(rows)
290
+ lead_in: list[Subsection] = []
291
+ for absorbed_row in rows[:absorbed]:
292
+ type_str = str(absorbed_row.get("type", _DEFAULT_NORMAL))
293
+ video = _video_path(absorbed_row, sections_dir)
294
+ lead_in.append(
295
+ Subsection(
296
+ name=str(absorbed_row.get("name", "unnamed")),
297
+ section_type=_coerce_section_type(type_str, as_main=False),
298
+ video=video,
299
+ duration_s=_row_duration(absorbed_row, video),
300
+ )
301
+ )
302
+
303
+ for i, row in enumerate(rows[absorbed:], start=absorbed):
304
+ type_str = str(row.get("type", _DEFAULT_NORMAL))
305
+ name = str(row.get("name", "unnamed"))
306
+ video = _video_path(row, sections_dir)
307
+ # ``i == 0`` only matters when nothing was absorbed; an absorbed
308
+ # leading default.normal already handled the "first in scene" case.
309
+ is_main = _is_main_section(type_str, is_first_in_scene=(i == 0))
310
+ sub = Subsection(
311
+ name=name,
312
+ section_type=_coerce_section_type(type_str, as_main=is_main),
313
+ video=video,
314
+ duration_s=_row_duration(row, video),
315
+ )
316
+ if is_main:
317
+ if pending_name is not None and pending_type is not None:
318
+ main_slides.append(
319
+ MainSlide(
320
+ index=counter,
321
+ scene=scene,
322
+ name=pending_name,
323
+ section_type=pending_type,
324
+ subsections=tuple(pending_subs),
325
+ )
326
+ )
327
+ counter += 1
328
+ pending_name = name if type_str != _DEFAULT_NORMAL else _humanise(scene)
329
+ pending_type = (
330
+ _coerce_section_type(type_str, as_main=True)
331
+ if type_str.startswith(_MAIN_PREFIX)
332
+ else SimplexSectionType.MAIN
333
+ )
334
+ pending_subs = [*lead_in, sub]
335
+ lead_in = []
336
+ else:
337
+ pending_subs.append(sub)
338
+ if pending_name is not None and pending_type is not None:
339
+ main_slides.append(
340
+ MainSlide(
341
+ index=counter,
342
+ scene=scene,
343
+ name=pending_name,
344
+ section_type=pending_type,
345
+ subsections=tuple(pending_subs),
346
+ )
347
+ )
348
+ counter += 1
349
+
350
+ return DeckManifest(deck_slug=deck.slug, main_slides=tuple(main_slides))
@@ -0,0 +1,116 @@
1
+ """Invoke ``manim-slides render`` via subprocess.
2
+
3
+ The theme/quality used to flow in via ``SIMPLEX_THEME`` / ``SIMPLEX_QUALITY``
4
+ env vars consumed by a per-scene shim in ``BaseSlide.__init__``. As of
5
+ v0.2.0 each deck declares ``plugins = simplex`` in its ``manim.cfg``; the
6
+ plugin entry-point applies theme defaults and ``save_sections = True`` at
7
+ ``import manim`` time. The runner re-introduces the ``SIMPLEX_THEME`` env
8
+ var purely to *select* which preset the plugin activates -- Python's
9
+ ``ContextVar`` doesn't traverse the ``subprocess`` boundary, so without
10
+ the env var every render falls back to ``DASTIMATOR_DARK`` regardless of
11
+ what the deck's ``deck.toml`` declares.
12
+
13
+ We still spawn a subprocess (not in-process) for three reasons: clean
14
+ SIGINT, OOM isolation, and per-deck ``manim.config`` isolation (different
15
+ decks may use different themes or qualities).
16
+
17
+ We run with ``cwd=output_dir`` so manim-slides writes its per-scene
18
+ ``slides/<Scene>.json`` (PresentationConfig) to the build tree; manim's
19
+ section + video output goes to ``<output_dir>/videos/<src_stem>/<q>/...``
20
+ via ``--media_dir``.
21
+ """
22
+
23
+ import os
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ from simplex.deck.config import DeckConfig
28
+
29
+ _QUALITY_FLAGS: dict[str, str] = {
30
+ "low_quality": "l",
31
+ "medium_quality": "m",
32
+ "high_quality": "h",
33
+ "production_quality": "p",
34
+ "fourk_quality": "k",
35
+ "example_quality": "e",
36
+ }
37
+
38
+
39
+ def _quality_flag(quality_key: str) -> str:
40
+ if quality_key not in _QUALITY_FLAGS:
41
+ known = ", ".join(sorted(_QUALITY_FLAGS))
42
+ raise ValueError(f"unknown quality {quality_key!r}; known: {known}")
43
+ return _QUALITY_FLAGS[quality_key]
44
+
45
+
46
+ def _filter_groups(
47
+ groups: tuple[tuple[Path, tuple[str, ...]], ...],
48
+ scenes: tuple[str, ...],
49
+ ) -> tuple[tuple[Path, tuple[str, ...]], ...]:
50
+ """Keep only entries whose class name is in ``scenes``. Drop empty groups."""
51
+ wanted = set(scenes)
52
+ available = {name for _, names in groups for name in names}
53
+ unknown = wanted - available
54
+ if unknown:
55
+ raise ValueError(
56
+ f"unknown scene name(s): {sorted(unknown)!r}; known: {sorted(available)!r}"
57
+ )
58
+ filtered: list[tuple[Path, tuple[str, ...]]] = []
59
+ for source_file, names in groups:
60
+ kept = tuple(n for n in names if n in wanted)
61
+ if kept:
62
+ filtered.append((source_file, kept))
63
+ return tuple(filtered)
64
+
65
+
66
+ def render(
67
+ deck: DeckConfig,
68
+ *,
69
+ output_dir: Path,
70
+ scenes: tuple[str, ...] = (),
71
+ write_last_frame: bool = False,
72
+ ) -> None:
73
+ """Render every scene in ``deck`` into ``output_dir`` via manim-slides.
74
+
75
+ When ``scenes`` is non-empty, only those class names are rendered.
76
+ When ``write_last_frame=True``, render only the first animation in each
77
+ scene. This still constructs the full scene while keeping smoke checks fast.
78
+ """
79
+ groups = deck.resolve_entrypoints()
80
+ if not groups:
81
+ raise ValueError(f"deck {deck.slug!r} has no scenes/entrypoints configured")
82
+ if scenes:
83
+ groups = _filter_groups(groups, scenes)
84
+ output_dir.mkdir(parents=True, exist_ok=True)
85
+ media_dir = output_dir.resolve()
86
+ quality = _quality_flag(deck.quality)
87
+
88
+ base_args: list[str] = [
89
+ "manim-slides",
90
+ "render",
91
+ "--quality",
92
+ quality,
93
+ "--media_dir",
94
+ str(media_dir),
95
+ "--save_sections",
96
+ ]
97
+ if not deck.caching:
98
+ base_args.append("--disable_caching")
99
+ if write_last_frame:
100
+ # ``--save_last_frame`` conflicts with ``save_sections``: Manim tries
101
+ # to stitch section videos from image-only output. Rendering one
102
+ # animation keeps smoke checks cheap while still exercising the scene.
103
+ base_args.extend(["--from_animation_number", "0,0"])
104
+
105
+ # Carry the deck's theme name across the subprocess via env var; the
106
+ # manim plugin in the child interpreter reads ``SIMPLEX_THEME`` to pick
107
+ # the preset whose background/typography it pushes onto ``manim.config``.
108
+ env = {**os.environ, "SIMPLEX_THEME": deck.theme}
109
+
110
+ for source_file, scene_names in groups:
111
+ args = [
112
+ *base_args,
113
+ str(source_file.resolve()),
114
+ *scene_names,
115
+ ]
116
+ subprocess.run(args, check=True, cwd=media_dir, env=env)