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.
- simscope/__init__.py +6 -0
- simscope/__main__.py +8 -0
- simscope/_assets/simscope-app.css +2 -0
- simscope/_assets/simscope-app.js +4311 -0
- simscope/_assets/simscope-player.js +4325 -0
- simscope/_assets/simscope-web.LICENSES.txt +407 -0
- simscope/_icon.py +22 -0
- simscope/_mjviser.py +203 -0
- simscope/annotations.py +1132 -0
- simscope/cli.py +482 -0
- simscope/core.py +257 -0
- simscope/derived.py +697 -0
- simscope/export.py +799 -0
- simscope/highlights.py +947 -0
- simscope/importers.py +874 -0
- simscope/index.py +579 -0
- simscope/io/__init__.py +45 -0
- simscope/io/blockfile.py +938 -0
- simscope/io/cas.py +294 -0
- simscope/io/codecs.py +566 -0
- simscope/io/errors.py +9 -0
- simscope/io/manifest.py +358 -0
- simscope/io/pack.py +563 -0
- simscope/io/scene.py +239 -0
- simscope/isaaclab.py +1460 -0
- simscope/library.py +705 -0
- simscope/mujoco.py +578 -0
- simscope/py.typed +0 -0
- simscope/recorder.py +784 -0
- simscope/server/__init__.py +9 -0
- simscope/server/app.py +149 -0
- simscope/server/blocks.py +191 -0
- simscope/server/jobs.py +166 -0
- simscope/server/routes.py +707 -0
- simscope/server/security.py +218 -0
- simscope/server/state.py +751 -0
- simscope/server/static.py +84 -0
- simscope/transforms.py +147 -0
- simscope-0.1.1.dist-info/METADATA +132 -0
- simscope-0.1.1.dist-info/RECORD +45 -0
- simscope-0.1.1.dist-info/WHEEL +4 -0
- simscope-0.1.1.dist-info/entry_points.txt +3 -0
- simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
- simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
- 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)
|