simscope 0.1.1__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 (45) hide show
  1. simscope/__init__.py +6 -0
  2. simscope/__main__.py +8 -0
  3. simscope/_assets/simscope-app.css +2 -0
  4. simscope/_assets/simscope-app.js +4311 -0
  5. simscope/_assets/simscope-player.js +4325 -0
  6. simscope/_assets/simscope-web.LICENSES.txt +407 -0
  7. simscope/_icon.py +22 -0
  8. simscope/_mjviser.py +203 -0
  9. simscope/annotations.py +1132 -0
  10. simscope/cli.py +482 -0
  11. simscope/core.py +257 -0
  12. simscope/derived.py +697 -0
  13. simscope/export.py +799 -0
  14. simscope/highlights.py +947 -0
  15. simscope/importers.py +874 -0
  16. simscope/index.py +579 -0
  17. simscope/io/__init__.py +45 -0
  18. simscope/io/blockfile.py +938 -0
  19. simscope/io/cas.py +294 -0
  20. simscope/io/codecs.py +566 -0
  21. simscope/io/errors.py +9 -0
  22. simscope/io/manifest.py +358 -0
  23. simscope/io/pack.py +563 -0
  24. simscope/io/scene.py +239 -0
  25. simscope/isaaclab.py +1460 -0
  26. simscope/library.py +705 -0
  27. simscope/mujoco.py +578 -0
  28. simscope/py.typed +0 -0
  29. simscope/recorder.py +784 -0
  30. simscope/server/__init__.py +9 -0
  31. simscope/server/app.py +149 -0
  32. simscope/server/blocks.py +191 -0
  33. simscope/server/jobs.py +166 -0
  34. simscope/server/routes.py +707 -0
  35. simscope/server/security.py +218 -0
  36. simscope/server/state.py +751 -0
  37. simscope/server/static.py +84 -0
  38. simscope/transforms.py +147 -0
  39. simscope-0.1.1.dist-info/METADATA +132 -0
  40. simscope-0.1.1.dist-info/RECORD +45 -0
  41. simscope-0.1.1.dist-info/WHEEL +4 -0
  42. simscope-0.1.1.dist-info/entry_points.txt +3 -0
  43. simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
  44. simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
  45. simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
simscope/export.py ADDED
@@ -0,0 +1,799 @@
1
+ """Exports runs as one offline HTML file or as a ``.simscope`` pack.
2
+
3
+ The HTML file has no external dependencies. It carries:
4
+
5
+ * the player runtime (``simscope-player.js``), gzipped and base64-encoded in a
6
+ ``<script type="text/plain">`` block, plus a tiny classic bootstrap script
7
+ that decodes it (``Uint8Array.fromBase64`` with an ``atob`` fallback),
8
+ gunzips it with ``DecompressionStream`` and starts it from a ``blob:``
9
+ script;
10
+ * one pack holding every run (shared scenes and meshes are stored once),
11
+ base64-encoded in a second ``<script type="text/plain">`` block that all
12
+ players reference with ``src="#simscope-pack"``;
13
+ * the full third-party license text, in an HTML comment at the end.
14
+
15
+ Given the same inputs, the output is byte-identical: entries are sorted and
16
+ gzip uses a fixed level and ``mtime=0``. No timestamps are written.
17
+
18
+ Annotation sidecars (``annotations.json``) are copied verbatim into the pack,
19
+ so notes, ratings and author names travel with it. Pass ``annotations=False``
20
+ to leave them out.
21
+
22
+ ``ui="full"`` swaps the lean ``<simscope-player>`` page for the whole app
23
+ (``simscope-app.js`` and ``.css``) started from a boot block that points at the
24
+ same inline pack (viewer contracts 6). The pack also carries each
25
+ run's derived data under ``derived/<run>/``: the highlights (full exports
26
+ only, because the lean player does not draw them) and, for more than 64 envs,
27
+ the crowd root-pose stream and per-env summaries. ``envs=[...]`` exports only
28
+ those envs.
29
+
30
+ The lean page is deliberately bare (viewer contracts 10): a dark/light
31
+ neutral background and the ``<simscope-player>`` elements, nothing else. The
32
+ runtime lays out a ``compare`` page itself, in the arrangement named by
33
+ ``data-arrange``, and adds the one shared control bar.
34
+ """
35
+
36
+ import base64
37
+ import gzip
38
+ import html
39
+ import importlib
40
+ import importlib.resources
41
+ import json
42
+ import logging
43
+ import os
44
+ import pathlib
45
+ import tempfile
46
+ from collections.abc import Sequence
47
+ from typing import Any, Literal
48
+
49
+ from simscope import _icon, highlights, library
50
+ from simscope.io import cas, manifest, pack
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ Layout = Literal["single", "grid", "compare"]
55
+ Arrange = Literal["side", "stack", "grid"]
56
+ Ui = Literal["lean", "full"]
57
+
58
+ RUNTIME_ASSET = "simscope-player.js"
59
+ APP_ASSET = "simscope-app.js"
60
+ APP_CSS_ASSET = "simscope-app.css"
61
+ LICENSES_ASSET = "simscope-web.LICENSES.txt"
62
+ PACK_ID = "simscope-pack"
63
+ RUNTIME_ID = "simscope-runtime"
64
+ BOOT_ID = "simscope-boot"
65
+ CROWD_ENVS = 64
66
+ """Runs exported with more envs than this carry the crowd-tier files."""
67
+ COMPARE_SYNC = "compare"
68
+ """Name of the shared clock of a compare page."""
69
+ COMPARE_MAX = 4
70
+ """A compare page takes up to this many runs."""
71
+ ARRANGEMENTS = ("side", "stack", "grid")
72
+ """How a compare page lays out its panes: side by side (a horizontal split),
73
+ stacked (a vertical split), or a 2 x 2 grid (one cell empty for three
74
+ runs)."""
75
+ GZIP_LEVEL = 9
76
+ _WRAP = 4096 # base64 line length; whitespace is ignored by both decoders
77
+ _LAYOUTS = ("single", "grid", "compare")
78
+ _UIS = ("lean", "full")
79
+
80
+
81
+ def read_asset(name: str) -> bytes:
82
+ """Returns the bytes of a file shipped in ``simscope/_assets``.
83
+
84
+ Args:
85
+ name: File name, for example ``simscope-player.js``.
86
+
87
+ Returns:
88
+ The file contents.
89
+ """
90
+ resource = importlib.resources.files("simscope") / "_assets" / name
91
+ return resource.read_bytes()
92
+
93
+
94
+ def _check_runs(runs: Sequence[str]) -> list[str]:
95
+ """Validates run names and returns them as a list, in the given order."""
96
+ if isinstance(runs, str):
97
+ raise TypeError("runs must be a sequence of run names, not a string")
98
+ names = [manifest.validate_run_name(name) for name in runs]
99
+ if not names:
100
+ raise ValueError("at least one run is required")
101
+ if len(set(names)) != len(names):
102
+ raise ValueError("duplicate run names")
103
+ return names
104
+
105
+
106
+ def _check_layout(layout: str, names: list[str]) -> list[str]:
107
+ """Checks that a layout can show this many runs.
108
+
109
+ Raises:
110
+ ValueError: For several runs with ``layout="single"``, or more runs
111
+ than there are compare slots with ``layout="compare"``.
112
+ """
113
+ if layout == "single" and len(names) != 1:
114
+ raise ValueError(
115
+ f'layout="single" needs exactly one run, got {len(names)}'
116
+ )
117
+ if layout == "compare" and len(names) > COMPARE_MAX:
118
+ raise ValueError(
119
+ f'layout="compare" takes up to {COMPARE_MAX} runs, got {len(names)}'
120
+ )
121
+ return names
122
+
123
+
124
+ def _arrangement(
125
+ layout: str, arrange: str | None, n_runs: int
126
+ ) -> Arrange | None:
127
+ """Resolves the arrangement of a page.
128
+
129
+ Args:
130
+ layout: The page layout.
131
+ arrange: The requested arrangement, or ``None`` for the default.
132
+ n_runs: How many runs the page shows.
133
+
134
+ Returns:
135
+ ``None`` unless the layout is ``"compare"``; otherwise the
136
+ arrangement, which defaults to ``"side"`` for up to two runs and to
137
+ ``"grid"`` for three or four.
138
+
139
+ Raises:
140
+ ValueError: For an unknown arrangement, or one given to a layout
141
+ other than ``"compare"``.
142
+ """
143
+ if arrange is not None and arrange not in ARRANGEMENTS:
144
+ raise ValueError(
145
+ f"arrange must be one of {ARRANGEMENTS}, got {arrange!r}"
146
+ )
147
+ if layout != "compare":
148
+ if arrange is not None:
149
+ raise ValueError('arrange needs layout="compare"')
150
+ return None
151
+ if arrange is not None:
152
+ return arrange
153
+ return "side" if n_runs <= 2 else "grid"
154
+
155
+
156
+ def _derived_module() -> Any:
157
+ """Imports :mod:`simscope.derived`, or returns ``None`` if absent."""
158
+ try:
159
+ return importlib.import_module("simscope.derived")
160
+ except ImportError:
161
+ return None
162
+
163
+
164
+ def _subset_highlights(
165
+ doc: dict[str, Any], envs: Sequence[int]
166
+ ) -> dict[str, Any]:
167
+ """Restricts a highlights document to an env subset, renumbering envs."""
168
+ new = {env: i for i, env in enumerate(envs)}
169
+ kept: list[dict[str, Any]] = [
170
+ {**h, "env": new[h["env"]]}
171
+ for h in doc["highlights"]
172
+ if h["env"] in new
173
+ ]
174
+ present = {h["kind"] for h in kept} | {
175
+ k for h in kept for k in h.get("also", ())
176
+ }
177
+ return {
178
+ **doc,
179
+ "kinds": [k for k in doc["kinds"] if k["key"] in present],
180
+ "highlights": kept,
181
+ }
182
+
183
+
184
+ def _compact(doc: Any) -> bytes:
185
+ """Serializes JSON without spaces (derived files are read by machines)."""
186
+ return json.dumps(doc, separators=(",", ":"), allow_nan=False).encode()
187
+
188
+
189
+ def _run_highlights(
190
+ run: library.Rollout,
191
+ derived: Any,
192
+ root: pathlib.Path,
193
+ scratch: pathlib.Path,
194
+ ids: Sequence[int] | None,
195
+ ) -> pack.Source:
196
+ """Returns the ``highlights.json`` entry of one run."""
197
+ if derived is not None:
198
+ cache = derived.cache_dir(root, run)
199
+ path = derived.ensure(run, cache, derived.HIGHLIGHTS)
200
+ doc = json.loads(path.read_bytes())
201
+ whole: pack.Source = path
202
+ else:
203
+ doc = highlights.load_or_compute(run, scratch / run.name)
204
+ whole = _compact(doc)
205
+ return whole if ids is None else _compact(_subset_highlights(doc, ids))
206
+
207
+
208
+ def _crowd_entries(
209
+ run: library.Rollout,
210
+ derived: Any,
211
+ root: pathlib.Path,
212
+ scratch: pathlib.Path,
213
+ ids: Sequence[int] | None,
214
+ ) -> dict[str, pack.Source]:
215
+ """Returns the root-pose stream and summaries of a run with many envs."""
216
+ cache = derived.cache_dir(root, run)
217
+ out: dict[str, pack.Source] = {}
218
+ root_pose = derived.ensure(run, cache, derived.ROOT_POSE)
219
+ if root_pose is not None:
220
+ if ids is not None:
221
+ cut = scratch / f"{run.name}-{derived.ROOT_POSE}"
222
+ pack.transcode_stream(root_pose, cut, "q16d", kind="pose", envs=ids)
223
+ root_pose = cut
224
+ out[derived.ROOT_POSE] = root_pose
225
+ summaries = derived.ensure(run, cache, derived.SUMMARIES)
226
+ if summaries is not None:
227
+ if ids is None:
228
+ out[derived.SUMMARIES] = summaries
229
+ else:
230
+ doc = json.loads(summaries.read_bytes())
231
+ doc["values"] = {
232
+ k: [v[i] for i in ids] for k, v in doc["values"].items()
233
+ }
234
+ out[derived.SUMMARIES] = _compact(doc)
235
+ return out
236
+
237
+
238
+ def derived_entries(
239
+ library_root: os.PathLike[str] | str,
240
+ runs: Sequence[str],
241
+ scratch: pathlib.Path,
242
+ *,
243
+ envs: Sequence[int] | None = None,
244
+ with_highlights: bool = True,
245
+ ) -> dict[str, pack.Source]:
246
+ """Collects the ``derived/<run>/`` pack entries of runs.
247
+
248
+ Highlights are included for every run whose highlights can be computed,
249
+ unless ``with_highlights`` is false.
250
+ A run that still has more than 64 envs after ``envs`` also gets its
251
+ crowd-tier ``root_pose.blk`` and ``summaries.json``, when
252
+ :mod:`simscope.derived` is present. Nothing is computed for a run that
253
+ is still recording, and a derived file that fails to compute is left out
254
+ with a warning: derived data is optional in a pack.
255
+
256
+ Args:
257
+ library_root: The library directory.
258
+ runs: Run names (validated by the caller).
259
+ scratch: A folder for files made on the way; it must outlive the
260
+ pack write.
261
+ envs: The env subset of the export, if any.
262
+ with_highlights: Whether to include ``highlights.json``. Lean HTML
263
+ exports leave it out: the player does not draw highlights.
264
+
265
+ Returns:
266
+ Entries by pack path, ready for ``write_pack(derived=...)``.
267
+
268
+ Raises:
269
+ ValueError: If ``envs`` is invalid for a run.
270
+ """
271
+ root = pathlib.Path(library_root)
272
+ derived = _derived_module()
273
+ entries: dict[str, pack.Source] = {}
274
+ for name in runs:
275
+ try:
276
+ run = library.Rollout(root, name)
277
+ except FileNotFoundError:
278
+ continue # write_pack reports it
279
+ with run:
280
+ if run.is_recording:
281
+ continue
282
+ ids = (
283
+ None
284
+ if envs is None
285
+ else pack.check_envs(envs, run.n_envs, name)
286
+ )
287
+ n_out = run.n_envs if ids is None else len(ids)
288
+ prefix = f"{pack.DERIVED_PREFIX}{name}/"
289
+ if with_highlights:
290
+ try:
291
+ entries[prefix + highlights.FILE_NAME] = _run_highlights(
292
+ run, derived, root, scratch, ids
293
+ )
294
+ except Exception: # isolation point: optional data
295
+ logger.warning("no highlights for %s", name, exc_info=True)
296
+ if derived is None or n_out <= CROWD_ENVS:
297
+ continue
298
+ try:
299
+ found = _crowd_entries(run, derived, root, scratch, ids)
300
+ except Exception: # isolation point: optional data must not fail
301
+ logger.warning("no crowd data for %s", name, exc_info=True)
302
+ continue
303
+ entries.update({prefix + k: v for k, v in found.items()})
304
+ return entries
305
+
306
+
307
+ def build_pack(
308
+ library_root: os.PathLike[str] | str,
309
+ runs: Sequence[str],
310
+ *,
311
+ transcode: bool = True,
312
+ annotations: bool = True,
313
+ envs: Sequence[int] | None = None,
314
+ derived: bool = True,
315
+ with_highlights: bool = True,
316
+ ) -> bytes:
317
+ """Builds one pack holding all runs.
318
+
319
+ All runs go through :func:`simscope.io.pack.write_pack` together, so
320
+ shared scenes and meshes are stored once. Each run's ``poster.png`` is
321
+ included when it exists.
322
+
323
+ Args:
324
+ library_root: The library directory.
325
+ runs: Names of the runs to include.
326
+ transcode: Whether to shrink the pack (q16d poses, q16 meshes).
327
+ annotations: Whether to keep each run's ``annotations.json``.
328
+ envs: Keep only these envs of every run (see
329
+ :func:`simscope.io.pack.write_pack`).
330
+ derived: Whether to add the ``derived/<run>/`` entries of
331
+ :func:`derived_entries`. Computing them caches files under the
332
+ library's ``.simscope/derived``.
333
+ with_highlights: Whether those entries include ``highlights.json``
334
+ (``derived`` must be true for it to matter).
335
+
336
+ Returns:
337
+ The pack bytes.
338
+
339
+ Raises:
340
+ ValueError: On an empty or duplicate run list, an invalid run name,
341
+ a run that is still recording, or an invalid ``envs``.
342
+ FileNotFoundError: If a run or a file it references is missing.
343
+ """
344
+ root = pathlib.Path(library_root)
345
+ names = _check_runs(runs)
346
+ with tempfile.TemporaryDirectory(prefix="simscope-export-") as tmp:
347
+ scratch = pathlib.Path(tmp)
348
+ extra = (
349
+ derived_entries(
350
+ root,
351
+ names,
352
+ scratch,
353
+ envs=envs,
354
+ with_highlights=with_highlights,
355
+ )
356
+ if derived
357
+ else {}
358
+ )
359
+ out = scratch / "export.simscope"
360
+ pack.write_pack(
361
+ root,
362
+ names,
363
+ out,
364
+ transcode=transcode,
365
+ annotations=annotations,
366
+ envs=envs,
367
+ derived=extra or None,
368
+ )
369
+ return out.read_bytes()
370
+
371
+
372
+ def _write_atomic(path: pathlib.Path, data: bytes) -> pathlib.Path:
373
+ """Writes ``data`` to ``path`` via a temporary file and a rename."""
374
+ path.parent.mkdir(parents=True, exist_ok=True)
375
+ fd, tmp = cas.create_temp(path.parent)
376
+ try:
377
+ with os.fdopen(fd, "wb") as f:
378
+ f.write(data)
379
+ os.replace(tmp, path)
380
+ except BaseException:
381
+ pathlib.Path(tmp).unlink(missing_ok=True)
382
+ raise
383
+ return path
384
+
385
+
386
+ def export_pack(
387
+ library_root: os.PathLike[str] | str,
388
+ runs: Sequence[str],
389
+ out_path: os.PathLike[str] | str,
390
+ *,
391
+ transcode: bool = True,
392
+ annotations: bool = True,
393
+ envs: Sequence[int] | None = None,
394
+ derived: bool = True,
395
+ ) -> pathlib.Path:
396
+ """Writes runs to a ``.simscope`` pack.
397
+
398
+ Args:
399
+ library_root: The library directory.
400
+ runs: Names of the runs to include.
401
+ out_path: The pack file to write (replaced atomically).
402
+ transcode: Whether to shrink the pack (q16d poses, q16 meshes).
403
+ annotations: Whether to keep each run's ``annotations.json``.
404
+ envs: Keep only these envs of every run.
405
+ derived: Whether to add highlights and crowd data (see
406
+ :func:`build_pack`).
407
+
408
+ Returns:
409
+ The output path.
410
+
411
+ Raises:
412
+ ValueError: On an empty or duplicate run list, an invalid run name,
413
+ a run that is still recording, or an invalid ``envs``.
414
+ FileNotFoundError: If a run or a file it references is missing.
415
+ """
416
+ data = build_pack(
417
+ library_root,
418
+ runs,
419
+ transcode=transcode,
420
+ annotations=annotations,
421
+ envs=envs,
422
+ derived=derived,
423
+ )
424
+ return _write_atomic(pathlib.Path(out_path), data)
425
+
426
+
427
+ def _b64_block(data: bytes) -> str:
428
+ """Base64-encodes bytes as lines of ``_WRAP`` characters."""
429
+ text = base64.b64encode(data).decode("ascii")
430
+ return "\n".join(text[i : i + _WRAP] for i in range(0, len(text), _WRAP))
431
+
432
+
433
+ def gzip_runtime(runtime: bytes) -> bytes:
434
+ """Compresses the player runtime deterministically.
435
+
436
+ Args:
437
+ runtime: The player IIFE.
438
+
439
+ Returns:
440
+ A gzip stream (fixed level, ``mtime=0``, no file name).
441
+ """
442
+ return gzip.compress(runtime, compresslevel=GZIP_LEVEL, mtime=0)
443
+
444
+
445
+ # The whole page style of a lean export (viewer contracts 10): the runtime
446
+ # lays out and styles everything else. The background only stops a flash of
447
+ # the wrong colour before the runtime has started; the greys are the app's
448
+ # oklch(0.97 0 0) and oklch(0.18 0 0).
449
+ _CSS = """\
450
+ :root{color-scheme:light dark}
451
+ html,body{margin:0;height:100%;background:#f5f5f5}
452
+ @media (prefers-color-scheme:dark){html,body{background:#121212}}
453
+ """
454
+ _ERROR_STYLE = (
455
+ "margin:0;padding:16px;font:14px/1.5 system-ui,sans-serif;color:#b3261e"
456
+ )
457
+
458
+ # Classic script: gunzip the runtime and run it from a blob: URL. Nothing
459
+ # here touches the network.
460
+ _BOOTSTRAP = """\
461
+ (function () {
462
+ var $ = function (id) { return document.getElementById(id); };
463
+ function fail(err) {
464
+ var box = $("simscope-error");
465
+ box.textContent = "simscope: could not start the player (" +
466
+ (err && err.message || err) + "). Open this file in a current browser.";
467
+ box.hidden = false;
468
+ }
469
+ async function start() {
470
+ var text = $("%(runtime)s").textContent.trim();
471
+ var bytes = Uint8Array.fromBase64 ? Uint8Array.fromBase64(text) :
472
+ Uint8Array.from(atob(text), function (c) { return c.charCodeAt(0); });
473
+ var stream = new Blob([bytes]).stream()
474
+ .pipeThrough(new DecompressionStream("gzip"));
475
+ var js = await new Response(stream).blob();
476
+ var script = document.createElement("script");
477
+ script.src = URL.createObjectURL(
478
+ new Blob([js], { type: "text/javascript" }));
479
+ script.onload = function () {
480
+ URL.revokeObjectURL(script.src);
481
+ $("%(runtime)s").remove();
482
+ script.remove();
483
+ };
484
+ script.onerror = function () { fail("the runtime did not load"); };
485
+ document.head.appendChild(script);
486
+ }
487
+ start().catch(fail);
488
+ })();
489
+ """.replace("%(runtime)s", RUNTIME_ID)
490
+
491
+ # Starts the shared control of layout="compare" once the runtime has defined
492
+ # the element (the global SimscopePlayer is set when its script ends).
493
+ # attachMaster builds the arrangement, the titles and the one control bar.
494
+ _MASTER = """\
495
+ customElements.whenDefined("simscope-player").then(function () {
496
+ SimscopePlayer.attachMaster(
497
+ document.getElementById("ss-master"), "%(sync)s");
498
+ });
499
+ """.replace("%(sync)s", COMPARE_SYNC)
500
+
501
+
502
+ def _player(run: str, extra: str = "") -> str:
503
+ """Returns a ``<simscope-player>`` element for a run.
504
+
505
+ Args:
506
+ run: Run name.
507
+ extra: Further attributes, such as ``sync="compare"``.
508
+ """
509
+ attrs = f'src="#{PACK_ID}" run="{html.escape(run, quote=True)}"'
510
+ if extra:
511
+ attrs += f" {extra}"
512
+ return f"<simscope-player {attrs}></simscope-player>"
513
+
514
+
515
+ def _figure(run: str, extra: str = "") -> str:
516
+ """Returns a ``<figure>``: the run name as its title, then the player."""
517
+ return (
518
+ f"<figure><figcaption>{html.escape(run)}</figcaption>"
519
+ f"{_player(run, extra)}</figure>"
520
+ )
521
+
522
+
523
+ def _content(
524
+ names: Sequence[str],
525
+ layout: Layout,
526
+ arrange: Arrange | None,
527
+ autoplay: bool,
528
+ loop: bool,
529
+ ) -> str:
530
+ """Returns the body markup of a lean page, below the error boxes.
531
+
532
+ ``single`` is one bare player. ``grid`` is a titled player per run.
533
+ ``compare`` is the box the runtime turns into the shared layout.
534
+ """
535
+ flags = " ".join(["autoplay"] * autoplay + ["loop"] * loop)
536
+ if layout == "single":
537
+ return _player(names[0], flags)
538
+ if layout == "grid":
539
+ return "\n".join(_figure(n, flags) for n in names)
540
+ figures = "\n".join(_figure(n, f'sync="{COMPARE_SYNC}"') for n in names)
541
+ return (
542
+ f'<div id="ss-master" data-arrange="{arrange}" '
543
+ f'data-autoplay="{int(autoplay)}" data-loop="{int(loop)}">\n'
544
+ f"{figures}\n</div>"
545
+ )
546
+
547
+
548
+ def _licences() -> str:
549
+ """Returns the third-party licence text, safe inside an HTML comment."""
550
+ text = read_asset(LICENSES_ASSET).decode("utf-8").strip()
551
+ return text.replace("--", "- -") # keep the comment well-formed
552
+
553
+
554
+ def _boot_block(
555
+ names: Sequence[str], layout: Layout, arrange: Arrange | None = None
556
+ ) -> str:
557
+ """Returns the ``simscope-boot`` JSON script of a full export.
558
+
559
+ ``arrange`` is written (as ``"arrange"``) for compare layouts only.
560
+ """
561
+ boot: dict[str, Any] = {
562
+ "mode": "pack",
563
+ "pack": f"#{PACK_ID}",
564
+ "runs": list(names),
565
+ "layout": layout,
566
+ "writable": False,
567
+ }
568
+ if arrange is not None:
569
+ boot["arrange"] = arrange
570
+ text = json.dumps(boot, sort_keys=True, ensure_ascii=True)
571
+ text = text.replace("<", "\\u003c") # no </script> or <!-- in the block
572
+ return f'<script id="{BOOT_ID}" type="application/json">{text}</script>'
573
+
574
+
575
+ _FULL_CSS = (
576
+ "#simscope-error{font:14px/1.5 system-ui,sans-serif;color:#b3261e;"
577
+ "padding:16px}#simscope-error[hidden]{display:none}"
578
+ )
579
+
580
+
581
+ def _full_page(
582
+ pack_bytes: bytes,
583
+ names: Sequence[str],
584
+ heading: str,
585
+ layout: Layout,
586
+ arrange: Arrange | None,
587
+ ) -> bytes:
588
+ """Builds the page of ``ui="full"``: the app, a boot block, the pack."""
589
+ css = read_asset(APP_CSS_ASSET).decode("utf-8")
590
+ if "</style" in css.lower():
591
+ raise ValueError("the app stylesheet must not contain </style")
592
+ runtime = gzip_runtime(read_asset(APP_ASSET))
593
+ parts = [
594
+ "<!doctype html>",
595
+ '<html lang="en">',
596
+ "<head>",
597
+ '<meta charset="utf-8">',
598
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
599
+ _icon.ICON_LINK, # a data URI: no /favicon.ico request
600
+ f"<title>{html.escape(heading)}</title>",
601
+ f"<style>{_FULL_CSS}</style>",
602
+ f"<style>\n{css.strip()}\n</style>",
603
+ "</head>",
604
+ "<body>",
605
+ '<div id="app"></div>',
606
+ '<p id="simscope-error" role="alert" hidden></p>',
607
+ "<noscript>This page needs JavaScript.</noscript>",
608
+ _boot_block(names, layout, arrange),
609
+ f'<script type="text/plain" id="{RUNTIME_ID}">\n'
610
+ f"{_b64_block(runtime)}\n</script>",
611
+ f'<script type="text/plain" id="{PACK_ID}">\n'
612
+ f"{_b64_block(pack_bytes)}\n</script>",
613
+ f"<script>\n{_BOOTSTRAP}</script>",
614
+ "</body>",
615
+ "</html>",
616
+ f"<!--\n{_licences()}\n-->",
617
+ "",
618
+ ]
619
+ return "\n".join(part for part in parts if part).encode("utf-8")
620
+
621
+
622
+ def build_html(
623
+ pack_bytes: bytes,
624
+ runs: Sequence[str],
625
+ *,
626
+ title: str | None = None,
627
+ layout: Layout = "grid",
628
+ arrange: Arrange | None = None,
629
+ autoplay: bool = True,
630
+ loop: bool = True,
631
+ ui: Ui = "lean",
632
+ ) -> bytes:
633
+ """Builds the HTML page around an existing pack.
634
+
635
+ Args:
636
+ pack_bytes: A pack holding at least the named runs.
637
+ runs: Runs to show, in page order.
638
+ title: Page title; defaults to the run name for a single run and to
639
+ ``"simscope export"`` otherwise.
640
+ layout: ``"single"`` (exactly one run), ``"grid"`` (independent
641
+ players), or ``"compare"`` (panes driven by one shared control).
642
+ arrange: For ``layout="compare"``, how the panes sit: ``"side"``,
643
+ ``"stack"`` or ``"grid"``. ``None`` picks ``"side"`` for one or
644
+ two runs and ``"grid"`` for three or four.
645
+ autoplay: Whether playback starts once the players are ready
646
+ (``ui="lean"`` only; the app has its own controls).
647
+ loop: Whether playback restarts at the end (``ui="lean"`` only).
648
+ ui: ``"lean"`` for ``<simscope-player>`` elements, ``"full"`` for the
649
+ whole app.
650
+
651
+ Returns:
652
+ The UTF-8 page.
653
+
654
+ Raises:
655
+ ValueError: On an unknown layout, ``ui`` or ``arrange``, an
656
+ ``arrange`` without ``layout="compare"``, an empty run list,
657
+ several runs with ``layout="single"``, or more than four with
658
+ ``layout="compare"``.
659
+ """
660
+ if ui not in _UIS:
661
+ raise ValueError(f"ui must be one of {_UIS}, got {ui!r}")
662
+ if layout not in _LAYOUTS:
663
+ raise ValueError(f"layout must be one of {_LAYOUTS}, got {layout!r}")
664
+ names = _check_layout(layout, _check_runs(runs))
665
+ where = _arrangement(layout, arrange, len(names))
666
+ heading = (
667
+ title
668
+ if title is not None
669
+ else (names[0] if layout == "single" else "simscope export")
670
+ )
671
+ if ui == "full":
672
+ return _full_page(pack_bytes, names, heading, layout, where)
673
+ runtime = gzip_runtime(read_asset(RUNTIME_ASSET))
674
+ licenses = _licences()
675
+ parts = [
676
+ "<!doctype html>",
677
+ '<html lang="en">',
678
+ "<head>",
679
+ '<meta charset="utf-8">',
680
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
681
+ _icon.ICON_LINK, # a data URI: no /favicon.ico request
682
+ f"<title>{html.escape(heading)}</title>",
683
+ f"<style>\n{_CSS}</style>",
684
+ "</head>",
685
+ "<body>",
686
+ f'<p id="simscope-error" role="alert" hidden style="{_ERROR_STYLE}">'
687
+ "</p>",
688
+ f'<noscript><p style="{_ERROR_STYLE}">This page needs JavaScript.</p>'
689
+ "</noscript>",
690
+ _content(names, layout, where, autoplay, loop),
691
+ f'<script type="text/plain" id="{RUNTIME_ID}">\n'
692
+ f"{_b64_block(runtime)}\n</script>",
693
+ f'<script type="text/plain" id="{PACK_ID}">\n'
694
+ f"{_b64_block(pack_bytes)}\n</script>",
695
+ f"<script>\n{_BOOTSTRAP}</script>",
696
+ f"<script>\n{_MASTER}</script>" if layout == "compare" else "",
697
+ "</body>",
698
+ "</html>",
699
+ f"<!--\n{licenses}\n-->",
700
+ "",
701
+ ]
702
+ return "\n".join(part for part in parts if part).encode("utf-8")
703
+
704
+
705
+ def export_html(
706
+ library_root: os.PathLike[str] | str,
707
+ runs: Sequence[str],
708
+ out_path: os.PathLike[str] | str,
709
+ *,
710
+ title: str | None = None,
711
+ layout: Layout = "grid",
712
+ arrange: Arrange | None = None,
713
+ transcode: bool = True,
714
+ annotations: bool = True,
715
+ autoplay: bool = True,
716
+ loop: bool = True,
717
+ ui: Ui = "lean",
718
+ envs: Sequence[int] | None = None,
719
+ ) -> pathlib.Path:
720
+ """Exports runs as one self-contained HTML file.
721
+
722
+ The file makes no network requests and works from ``file://``. With
723
+ ``ui="lean"`` runs appear as ``<simscope-player>`` elements in the order
724
+ given; with ``ui="full"`` the page is the whole app, started on the same
725
+ pack. One pack holds all runs, so scenes and meshes shared between runs
726
+ are stored once. A full export's pack also carries each run's
727
+ highlights; for more than 64 envs, either kind carries the crowd root-pose
728
+ stream and summaries. Output is deterministic for equal inputs.
729
+
730
+ A lean page is bare: the players fill the window and the runtime adds one
731
+ control bar (viewer contracts 10). A ``compare`` page looks like the
732
+ app's compare view: the panes fill the page, each with its run name as a
733
+ title and nothing else, and one control bar below them drives every pane.
734
+
735
+ A pack is inlined as base64 in a string, which Chrome caps at 512 MiB, so
736
+ very large runs need ``envs`` to pick a subset.
737
+
738
+ Args:
739
+ library_root: The library directory.
740
+ runs: Names of the runs to export.
741
+ out_path: The HTML file to write (replaced atomically).
742
+ title: Page title; defaults to the run name for a single run and to
743
+ ``"simscope export"`` otherwise.
744
+ layout: ``"single"`` for exactly one run, ``"grid"`` for independent
745
+ players, or ``"compare"`` for up to four players driven in
746
+ lockstep by one shared control bar (play and pause, step, a
747
+ scrubber, the time, loop and speed). Runs of different durations
748
+ stop at their own ends.
749
+ arrange: For ``layout="compare"``, how the panes sit: ``"side"``
750
+ (side by side), ``"stack"`` (one above the other) or ``"grid"``
751
+ (two by two). The default is ``"side"`` for one or two runs and
752
+ ``"grid"`` for three or four. In a full export it is the
753
+ arrangement the app opens with.
754
+ transcode: Whether to shrink the pack (q16d poses, q16 meshes).
755
+ annotations: Whether to include each run's ``annotations.json``, so
756
+ events show as timeline markers.
757
+ autoplay: Whether playback starts once the players are ready
758
+ (``ui="lean"``).
759
+ loop: Whether playback restarts at the end (``ui="lean"``). With
760
+ ``"compare"`` the shared control restarts every run when the
761
+ longest one ends.
762
+ ui: ``"lean"`` (the default) or ``"full"``.
763
+ envs: Export only these envs of every run, in this order; they are
764
+ renumbered from 0. ``None`` exports all.
765
+
766
+ Returns:
767
+ The output path.
768
+
769
+ Raises:
770
+ ValueError: On an unknown layout, ``ui`` or ``arrange``, an
771
+ ``arrange`` without ``layout="compare"``, an empty or duplicate
772
+ run list, an invalid run name, several runs with
773
+ ``layout="single"``, more than four with ``layout="compare"``, a
774
+ run that is still recording, or an invalid ``envs``.
775
+ FileNotFoundError: If a run or a file it references is missing.
776
+ """
777
+ if ui not in _UIS:
778
+ raise ValueError(f"ui must be one of {_UIS}, got {ui!r}")
779
+ names = _check_layout(layout, _check_runs(runs))
780
+ _arrangement(layout, arrange, len(names)) # refuse before packing
781
+ data = build_pack(
782
+ library_root,
783
+ names,
784
+ transcode=transcode,
785
+ annotations=annotations,
786
+ envs=envs,
787
+ with_highlights=ui == "full",
788
+ )
789
+ page = build_html(
790
+ data,
791
+ names,
792
+ title=title,
793
+ layout=layout,
794
+ arrange=arrange,
795
+ autoplay=autoplay,
796
+ loop=loop,
797
+ ui=ui,
798
+ )
799
+ return _write_atomic(pathlib.Path(out_path), page)