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.
- simplex/README.md +32 -0
- simplex/cli/README.md +13 -0
- simplex/cli/__init__.py +5 -0
- simplex/cli/commands.py +384 -0
- simplex/deck/README.md +19 -0
- simplex/deck/__init__.py +7 -0
- simplex/deck/_template/assets/.gitkeep +0 -0
- simplex/deck/_template/assets/code/.gitkeep +0 -0
- simplex/deck/_template/assets/figures/.gitkeep +0 -0
- simplex/deck/_template/deck.toml +11 -0
- simplex/deck/_template/manim.cfg +3 -0
- simplex/deck/_template/notes.md +27 -0
- simplex/deck/_template/refs.bib +12 -0
- simplex/deck/_template/slides/__init__.py +7 -0
- simplex/deck/_template/slides/intro.py +21 -0
- simplex/deck/config.py +207 -0
- simplex/deck/registry.py +110 -0
- simplex/deck/scaffold.py +86 -0
- simplex/deck/section.py +40 -0
- simplex/engine/README.md +9 -0
- simplex/render/README.md +46 -0
- simplex/render/__init__.py +1 -0
- simplex/render/html.py +132 -0
- simplex/render/pdf.py +32 -0
- simplex/render/pptx.py +32 -0
- simplex/render/reconcile.py +350 -0
- simplex/render/runner.py +116 -0
- simplex/render/thumbnail.py +374 -0
- simplex/slides/README.md +9 -0
- simplex/slides/components/README.md +9 -0
- simplex/theme/README.md +9 -0
- simplex/web/README.md +33 -0
- simplex/web/__init__.py +1 -0
- simplex/web/bibliography.py +248 -0
- simplex/web/bibtex.py +129 -0
- simplex/web/builder.py +321 -0
- simplex/web/callouts.py +134 -0
- simplex/web/citations.py +118 -0
- simplex/web/equations.py +79 -0
- simplex/web/notes.py +135 -0
- simplex/web/refs.py +60 -0
- simplex/web/sidenotes.py +76 -0
- simplex/web/site_config.py +71 -0
- simplex/web/slide_ref.py +54 -0
- simplex/web/static/.gitkeep +0 -0
- simplex/web/static/README.md +23 -0
- simplex/web/static/fonts/lato/lato-latin-400-italic.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-400-normal.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-700-italic.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-700-normal.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-900-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-400-italic.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-400-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-700-italic.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-700-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-900-normal.woff2 +0 -0
- simplex/web/static/htmx.min.js +1 -0
- simplex/web/static/katex/auto-render.min.js +1 -0
- simplex/web/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- simplex/web/static/katex/katex.min.css +1 -0
- simplex/web/static/katex/katex.min.js +1 -0
- simplex/web/static/lucide/README.md +7 -0
- simplex/web/static/lucide/lucide.min.js +12 -0
- simplex/web/static/notes.js +68 -0
- simplex/web/static/reveal.js/reset.css +30 -0
- simplex/web/static/reveal.js/reveal.css +8 -0
- simplex/web/static/reveal.js/reveal.js +9 -0
- simplex/web/static/simplex.css +1870 -0
- simplex/web/static/tailwind.js +64 -0
- simplex/web/static/viewer.js +428 -0
- simplex/web/templates/README.md +19 -0
- simplex/web/templates/_carousel.html +117 -0
- simplex/web/templates/base.html +110 -0
- simplex/web/templates/deck.html +149 -0
- simplex/web/templates/index.html +20 -0
- simplex/web/templates/revealjs.html.j2 +374 -0
- simplex/web/templates/section.html +74 -0
- simplex/web/vendor.py +148 -0
- simplex_web-0.2.0.dist-info/METADATA +166 -0
- simplex_web-0.2.0.dist-info/RECORD +91 -0
- simplex_web-0.2.0.dist-info/WHEEL +4 -0
- simplex_web-0.2.0.dist-info/entry_points.txt +2 -0
- 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))
|
simplex/render/runner.py
ADDED
|
@@ -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)
|