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/io/pack.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Packs (``*.simscope``): one file holding a library subtree."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
import mmap
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import shutil
|
|
10
|
+
import struct
|
|
11
|
+
import tempfile
|
|
12
|
+
import zlib
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
|
|
17
|
+
from simscope import core
|
|
18
|
+
from simscope.io import blockfile, cas, codecs, errors, manifest, scene
|
|
19
|
+
|
|
20
|
+
MAGIC = b"SSPK"
|
|
21
|
+
MAJOR = 1
|
|
22
|
+
MINOR = 0
|
|
23
|
+
MINOR_DERIVED = 1
|
|
24
|
+
"""Minor version of a pack that carries ``derived/`` entries."""
|
|
25
|
+
DERIVED_PREFIX = "derived/"
|
|
26
|
+
HEADER = struct.Struct("<4sHHIIQII")
|
|
27
|
+
HEADER_SIZE = 32
|
|
28
|
+
ANNOTATIONS_NAME = "annotations.json"
|
|
29
|
+
POSTER_NAME = "poster.png"
|
|
30
|
+
assert HEADER.size == HEADER_SIZE
|
|
31
|
+
|
|
32
|
+
_COPY_CHUNK = 1 << 20
|
|
33
|
+
_MESH_CODEC_OFFSET = 20
|
|
34
|
+
|
|
35
|
+
Source = bytes | pathlib.Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def asset_path(ref: cas.Ref) -> str:
|
|
39
|
+
"""Pack path of an asset blob."""
|
|
40
|
+
return f"assets/{ref.sha256[:2]}/{ref.sha256}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scene_path(ref: cas.Ref) -> str:
|
|
44
|
+
"""Pack path of a scene descriptor."""
|
|
45
|
+
return f"scenes/{ref.sha256[:2]}/{ref.sha256}.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclasses.dataclass
|
|
49
|
+
class _Collector:
|
|
50
|
+
"""Accumulates pack entries and caches per-library transcodes."""
|
|
51
|
+
|
|
52
|
+
store: cas.ContentStore
|
|
53
|
+
transcode: bool
|
|
54
|
+
entries: dict[str, Source] = dataclasses.field(default_factory=dict)
|
|
55
|
+
scene_map: dict[cas.Ref, cas.Ref] = dataclasses.field(default_factory=dict)
|
|
56
|
+
mesh_map: dict[cas.Ref, cas.Ref] = dataclasses.field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
def add_asset(self, ref: cas.Ref) -> None:
|
|
59
|
+
"""Adds an asset blob unchanged."""
|
|
60
|
+
self.entries[asset_path(ref)] = self.store.get(ref)
|
|
61
|
+
|
|
62
|
+
def add_mesh(self, ref: cas.Ref) -> cas.Ref:
|
|
63
|
+
"""Adds a mesh blob, transcoding raw meshes to q16 if requested.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The reference of the blob that was stored in the pack.
|
|
67
|
+
"""
|
|
68
|
+
if ref in self.mesh_map:
|
|
69
|
+
return self.mesh_map[ref]
|
|
70
|
+
blob = self.store.get(ref)
|
|
71
|
+
if self.transcode and blob[_MESH_CODEC_OFFSET] == codecs.MESH_RAW:
|
|
72
|
+
blob = codecs.encode_mesh(codecs.decode_mesh(blob), "q16")
|
|
73
|
+
new = cas.Ref.of(blob)
|
|
74
|
+
self.entries[asset_path(new)] = blob
|
|
75
|
+
self.mesh_map[ref] = new
|
|
76
|
+
return new
|
|
77
|
+
|
|
78
|
+
def add_scene(self, ref: cas.Ref) -> cas.Ref:
|
|
79
|
+
"""Adds a scene descriptor with its meshes and textures.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The reference of the descriptor stored in the pack (new when
|
|
83
|
+
meshes were transcoded).
|
|
84
|
+
"""
|
|
85
|
+
if ref in self.scene_map:
|
|
86
|
+
return self.scene_map[ref]
|
|
87
|
+
data = self.store.get(ref, "scene")
|
|
88
|
+
doc = json.loads(data)
|
|
89
|
+
for tex in doc.get("textures", []):
|
|
90
|
+
self.add_asset(cas.Ref.from_json(tex))
|
|
91
|
+
changed = False
|
|
92
|
+
for mesh in doc.get("meshes", []):
|
|
93
|
+
old = cas.Ref.from_json(mesh)
|
|
94
|
+
new = self.add_mesh(old)
|
|
95
|
+
if new != old:
|
|
96
|
+
mesh.update(new.to_json())
|
|
97
|
+
changed = True
|
|
98
|
+
if changed:
|
|
99
|
+
data = scene.canonical_bytes(doc)
|
|
100
|
+
new_ref = cas.Ref.of(data)
|
|
101
|
+
self.entries[scene_path(new_ref)] = data
|
|
102
|
+
self.scene_map[ref] = new_ref
|
|
103
|
+
return new_ref
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def transcode_stream(
|
|
107
|
+
src: os.PathLike[str] | str,
|
|
108
|
+
dst: os.PathLike[str] | str,
|
|
109
|
+
codec: str = "q16d",
|
|
110
|
+
*,
|
|
111
|
+
kind: core.StreamKind = "pose",
|
|
112
|
+
envs: Sequence[int] | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Re-encodes a block file window by window (bounded memory).
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
src: Source block file.
|
|
118
|
+
dst: Destination block file.
|
|
119
|
+
codec: Target codec.
|
|
120
|
+
kind: Stream kind, which selects quaternion sign handling.
|
|
121
|
+
envs: Keep only these envs, in this order (env ``i`` of the output
|
|
122
|
+
is env ``envs[i]`` of the source). ``None`` keeps all.
|
|
123
|
+
"""
|
|
124
|
+
with blockfile.BlockReader(src, cache_blocks=0) as r:
|
|
125
|
+
n_out = r.n_envs if envs is None else len(envs)
|
|
126
|
+
with blockfile.BlockWriter(
|
|
127
|
+
dst,
|
|
128
|
+
item_shape=r.item_shape,
|
|
129
|
+
n_envs=n_out,
|
|
130
|
+
kind=kind,
|
|
131
|
+
codec=codec,
|
|
132
|
+
block_frames=r.block_frames,
|
|
133
|
+
) as w:
|
|
134
|
+
for t0 in range(0, r.n_frames, r.block_frames):
|
|
135
|
+
w.append(r.read(t0, min(t0 + r.block_frames, r.n_frames), envs))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_envs(envs: Sequence[int], n_envs: int, what: str) -> list[int]:
|
|
139
|
+
"""Validates an env subset against a run.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
envs: Env indices to keep.
|
|
143
|
+
n_envs: Number of envs the run has.
|
|
144
|
+
what: Run name, for the error message.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The indices as a list of ints.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ValueError: If the list is empty, repeats an index, or names an env
|
|
151
|
+
the run does not have.
|
|
152
|
+
"""
|
|
153
|
+
ids = [int(e) for e in envs]
|
|
154
|
+
if not ids:
|
|
155
|
+
raise ValueError("envs must not be empty")
|
|
156
|
+
if len(set(ids)) != len(ids):
|
|
157
|
+
raise ValueError("envs must not repeat an index")
|
|
158
|
+
bad = [e for e in ids if not 0 <= e < n_envs]
|
|
159
|
+
if bad:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"run {what!r} has {n_envs} envs, but envs asks for {bad[:3]}"
|
|
162
|
+
)
|
|
163
|
+
return ids
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def subset_annotations(data: bytes, envs: Sequence[int]) -> bytes:
|
|
167
|
+
"""Restricts an ``annotations.json`` to an env subset.
|
|
168
|
+
|
|
169
|
+
Events and spatial annotations of envs that are kept follow them to
|
|
170
|
+
their new index; those of other envs are dropped. Records without an
|
|
171
|
+
env (``null``) stay. Unknown fields are preserved.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
data: The sidecar bytes.
|
|
175
|
+
envs: Kept envs, in output order.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The sidecar bytes, with only the kept envs.
|
|
179
|
+
"""
|
|
180
|
+
doc = json.loads(data)
|
|
181
|
+
new = {env: i for i, env in enumerate(envs)}
|
|
182
|
+
for key in ("events", "spatial"):
|
|
183
|
+
kept = []
|
|
184
|
+
for rec in doc.get(key, []):
|
|
185
|
+
env = rec.get("env")
|
|
186
|
+
if env is None:
|
|
187
|
+
kept.append(rec)
|
|
188
|
+
elif env in new:
|
|
189
|
+
kept.append({**rec, "env": new[env]})
|
|
190
|
+
if key in doc:
|
|
191
|
+
doc[key] = kept
|
|
192
|
+
text = json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False)
|
|
193
|
+
return (text + "\n").encode("utf-8")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _source_codec(path: pathlib.Path) -> str:
|
|
197
|
+
"""Names the codec to keep when re-encoding a block file unchanged."""
|
|
198
|
+
return "q16d" if _only_q16d(path) else "f32s"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _only_q16d(path: pathlib.Path) -> bool:
|
|
202
|
+
"""Tells whether every block of a finished block file is q16d."""
|
|
203
|
+
with blockfile.BlockReader(path, cache_blocks=0) as r:
|
|
204
|
+
return bool((r.directory["codec"] == codecs.CODEC_Q16D).all())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _write_entries(
|
|
208
|
+
out: BinaryIO, entries: dict[str, Source]
|
|
209
|
+
) -> list[dict[str, str | int]]:
|
|
210
|
+
"""Writes entry bytes (aligned to 8) and returns directory records."""
|
|
211
|
+
records: list[dict[str, str | int]] = []
|
|
212
|
+
for path in sorted(entries):
|
|
213
|
+
src = entries[path]
|
|
214
|
+
out.write(bytes(-out.tell() % 8))
|
|
215
|
+
offset = out.tell()
|
|
216
|
+
if isinstance(src, bytes):
|
|
217
|
+
out.write(src)
|
|
218
|
+
else:
|
|
219
|
+
with open(src, "rb") as f:
|
|
220
|
+
shutil.copyfileobj(f, out, _COPY_CHUNK)
|
|
221
|
+
records.append(
|
|
222
|
+
{"path": path, "offset": offset, "length": out.tell() - offset}
|
|
223
|
+
)
|
|
224
|
+
return records
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def write_pack(
|
|
228
|
+
library_root: os.PathLike[str] | str,
|
|
229
|
+
run_names: list[str],
|
|
230
|
+
out_path: os.PathLike[str] | str,
|
|
231
|
+
*,
|
|
232
|
+
transcode: bool = True,
|
|
233
|
+
annotations: bool = True,
|
|
234
|
+
posters: bool = True,
|
|
235
|
+
envs: Sequence[int] | None = None,
|
|
236
|
+
derived: Mapping[str, Source] | None = None,
|
|
237
|
+
) -> pathlib.Path:
|
|
238
|
+
"""Writes runs and everything they reference into one pack file.
|
|
239
|
+
|
|
240
|
+
With ``transcode=True`` (the export default), pose streams are re-encoded
|
|
241
|
+
with ``q16d``, raw meshes are re-encoded as q16, and scenes are rewritten
|
|
242
|
+
with the new mesh references (so their hashes change). Other streams and
|
|
243
|
+
textures are copied unchanged. Each asset is stored once, however many
|
|
244
|
+
runs share it. The library is not modified.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
library_root: The library directory.
|
|
248
|
+
run_names: Runs to include (each must be complete).
|
|
249
|
+
out_path: The pack file to write (replaced atomically).
|
|
250
|
+
transcode: Whether to shrink the pack as described above.
|
|
251
|
+
annotations: Whether to include each run's ``annotations.json``.
|
|
252
|
+
posters: Whether to include each run's ``poster.png``.
|
|
253
|
+
envs: Keep only these envs of every run, in this order. Streams are
|
|
254
|
+
re-encoded for them (the source codec, or q16d poses with
|
|
255
|
+
``transcode``), ``env_origins`` and ``env_scenes`` are cut to
|
|
256
|
+
them, and the events of other envs leave ``annotations.json``.
|
|
257
|
+
derived: Extra entries by pack path, all under ``derived/``
|
|
258
|
+
(contracts 1). A pack that has any records minor version 1.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The output path.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ValueError: On duplicate run names, a run that is still recording,
|
|
265
|
+
an invalid ``envs`` for a run, or a ``derived`` path outside
|
|
266
|
+
``derived/``.
|
|
267
|
+
FileNotFoundError: If a run or referenced file is missing.
|
|
268
|
+
"""
|
|
269
|
+
root = pathlib.Path(library_root)
|
|
270
|
+
out_path = pathlib.Path(out_path)
|
|
271
|
+
if len(set(run_names)) != len(run_names):
|
|
272
|
+
raise ValueError("duplicate run names")
|
|
273
|
+
for path in derived or {}:
|
|
274
|
+
if not path.startswith(DERIVED_PREFIX) or ".." in path.split("/"):
|
|
275
|
+
raise ValueError(f"derived entries live under derived/: {path!r}")
|
|
276
|
+
col = _Collector(cas.ContentStore(root), transcode)
|
|
277
|
+
with tempfile.TemporaryDirectory(prefix="simscope-pack-") as tmp_name:
|
|
278
|
+
tmp = pathlib.Path(tmp_name)
|
|
279
|
+
for name in run_names:
|
|
280
|
+
_add_run(
|
|
281
|
+
col,
|
|
282
|
+
root,
|
|
283
|
+
manifest.validate_run_name(name),
|
|
284
|
+
tmp,
|
|
285
|
+
sidecars=_sidecars(annotations=annotations, posters=posters),
|
|
286
|
+
envs=envs,
|
|
287
|
+
)
|
|
288
|
+
col.entries.update(derived or {})
|
|
289
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
fd, tmp_out = cas.create_temp(out_path.parent)
|
|
291
|
+
try:
|
|
292
|
+
with os.fdopen(fd, "wb") as out:
|
|
293
|
+
out.write(bytes(HEADER_SIZE))
|
|
294
|
+
records = _write_entries(out, col.entries)
|
|
295
|
+
out.write(bytes(-out.tell() % 8))
|
|
296
|
+
dir_offset = out.tell()
|
|
297
|
+
directory = json.dumps(
|
|
298
|
+
{"entries": records},
|
|
299
|
+
separators=(",", ":"),
|
|
300
|
+
ensure_ascii=False,
|
|
301
|
+
).encode("utf-8")
|
|
302
|
+
out.write(directory)
|
|
303
|
+
out.seek(0)
|
|
304
|
+
out.write(
|
|
305
|
+
HEADER.pack(
|
|
306
|
+
MAGIC,
|
|
307
|
+
MAJOR,
|
|
308
|
+
MINOR_DERIVED if derived else MINOR,
|
|
309
|
+
len(records),
|
|
310
|
+
0,
|
|
311
|
+
dir_offset,
|
|
312
|
+
len(directory),
|
|
313
|
+
zlib.crc32(directory),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
os.replace(tmp_out, out_path)
|
|
317
|
+
except BaseException:
|
|
318
|
+
pathlib.Path(tmp_out).unlink(missing_ok=True)
|
|
319
|
+
raise
|
|
320
|
+
return out_path
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _sidecars(*, annotations: bool, posters: bool) -> tuple[str, ...]:
|
|
324
|
+
"""Names the optional per-run files a pack should carry."""
|
|
325
|
+
names = []
|
|
326
|
+
if annotations:
|
|
327
|
+
names.append(ANNOTATIONS_NAME)
|
|
328
|
+
if posters:
|
|
329
|
+
names.append(POSTER_NAME)
|
|
330
|
+
return tuple(names)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _add_run(
|
|
334
|
+
col: _Collector,
|
|
335
|
+
root: pathlib.Path,
|
|
336
|
+
name: str,
|
|
337
|
+
tmp: pathlib.Path,
|
|
338
|
+
*,
|
|
339
|
+
sidecars: tuple[str, ...],
|
|
340
|
+
envs: Sequence[int] | None = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Collects the entries of one run, plus the named sidecar files."""
|
|
343
|
+
run_dir = root / "runs" / name
|
|
344
|
+
if not (run_dir / manifest.MANIFEST_NAME).exists():
|
|
345
|
+
if (run_dir / manifest.PARTIAL_NAME).exists():
|
|
346
|
+
raise ValueError(f"run {name!r} is still recording")
|
|
347
|
+
raise FileNotFoundError(run_dir / manifest.MANIFEST_NAME)
|
|
348
|
+
man = manifest.read_manifest(run_dir)
|
|
349
|
+
if man.name != name:
|
|
350
|
+
raise errors.FormatError(f"manifest name {man.name!r} != dir {name!r}")
|
|
351
|
+
if envs is not None:
|
|
352
|
+
ids = check_envs(envs, man.n_envs, name)
|
|
353
|
+
man.n_envs = len(ids)
|
|
354
|
+
if man.env_origins is not None:
|
|
355
|
+
man.env_origins = tuple(man.env_origins[i] for i in ids)
|
|
356
|
+
if man.env_scenes is not None:
|
|
357
|
+
man.env_scenes = tuple(man.env_scenes[i] for i in ids)
|
|
358
|
+
else:
|
|
359
|
+
ids = None
|
|
360
|
+
man.scene = col.add_scene(man.scene)
|
|
361
|
+
if man.env_scenes is not None:
|
|
362
|
+
man.env_scenes = tuple(col.add_scene(r) for r in man.env_scenes)
|
|
363
|
+
prefix = f"runs/{name}/"
|
|
364
|
+
col.entries[prefix + manifest.MANIFEST_NAME] = manifest.manifest_bytes(man)
|
|
365
|
+
for stream in man.streams.values():
|
|
366
|
+
src = run_dir / stream.file
|
|
367
|
+
pose = col.transcode and stream.kind == "pose"
|
|
368
|
+
if ids is not None or (pose and not _only_q16d(src)):
|
|
369
|
+
dst = tmp / f"{name}-{stream.file}"
|
|
370
|
+
codec = "q16d" if pose else _source_codec(src)
|
|
371
|
+
transcode_stream(src, dst, codec, kind=stream.kind, envs=ids)
|
|
372
|
+
src = dst
|
|
373
|
+
col.entries[prefix + stream.file] = src
|
|
374
|
+
for sidecar in sidecars:
|
|
375
|
+
path = run_dir / sidecar
|
|
376
|
+
if path.is_file():
|
|
377
|
+
data = path.read_bytes()
|
|
378
|
+
if ids is not None and sidecar == ANNOTATIONS_NAME:
|
|
379
|
+
data = subset_annotations(data, ids)
|
|
380
|
+
col.entries[prefix + sidecar] = data
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class PackReader:
|
|
384
|
+
"""Read access to a pack, from a file (memory-mapped) or from bytes.
|
|
385
|
+
|
|
386
|
+
``read`` returns zero-copy memoryview slices of the pack. Close readers
|
|
387
|
+
opened with :meth:`stream` before closing the pack.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
def __init__(
|
|
391
|
+
self, source: os.PathLike[str] | str | bytes | bytearray | memoryview
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Opens a pack and reads its directory.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
source: A path, or the pack bytes.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
errors.FormatError: On a bad magic, unknown major version, CRC
|
|
400
|
+
mismatch or invalid directory.
|
|
401
|
+
"""
|
|
402
|
+
self._mm: mmap.mmap | None = None
|
|
403
|
+
self._view: memoryview | None = None
|
|
404
|
+
if isinstance(source, str | os.PathLike):
|
|
405
|
+
with open(source, "rb") as f:
|
|
406
|
+
if os.fstat(f.fileno()).st_size < HEADER_SIZE:
|
|
407
|
+
raise errors.FormatError(f"{source}: shorter than header")
|
|
408
|
+
self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
|
|
409
|
+
self._view = memoryview(self._mm)
|
|
410
|
+
else:
|
|
411
|
+
self._view = memoryview(source).cast("B")
|
|
412
|
+
try:
|
|
413
|
+
self._entries = self._parse(self._view)
|
|
414
|
+
except BaseException:
|
|
415
|
+
self.close()
|
|
416
|
+
raise
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _parse(view: memoryview) -> dict[str, tuple[int, int]]:
|
|
420
|
+
"""Validates the header and returns ``path -> (offset, length)``."""
|
|
421
|
+
if len(view) < HEADER_SIZE:
|
|
422
|
+
raise errors.FormatError("pack shorter than its header")
|
|
423
|
+
magic, major, _minor, n, _res, dir_off, dir_len, crc = (
|
|
424
|
+
HEADER.unpack_from(view, 0)
|
|
425
|
+
)
|
|
426
|
+
if magic != MAGIC:
|
|
427
|
+
raise errors.FormatError(f"bad pack magic {magic!r}")
|
|
428
|
+
if major != MAJOR:
|
|
429
|
+
raise errors.FormatError(f"unknown pack major version {major}")
|
|
430
|
+
if dir_off + dir_len > len(view):
|
|
431
|
+
raise errors.FormatError("pack directory is truncated")
|
|
432
|
+
with view[dir_off : dir_off + dir_len] as raw:
|
|
433
|
+
if zlib.crc32(raw) != crc:
|
|
434
|
+
raise errors.FormatError("pack directory CRC mismatch")
|
|
435
|
+
try:
|
|
436
|
+
records = json.loads(bytes(raw))["entries"]
|
|
437
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
438
|
+
raise errors.FormatError(f"bad pack directory: {exc}") from exc
|
|
439
|
+
entries: dict[str, tuple[int, int]] = {}
|
|
440
|
+
for rec in records:
|
|
441
|
+
path, off, length = rec["path"], rec["offset"], rec["length"]
|
|
442
|
+
if (
|
|
443
|
+
path.startswith("/")
|
|
444
|
+
or ".." in path.split("/")
|
|
445
|
+
or off + length > len(view)
|
|
446
|
+
):
|
|
447
|
+
raise errors.FormatError(f"invalid pack entry {path!r}")
|
|
448
|
+
entries[path] = (off, length)
|
|
449
|
+
if len(entries) != n:
|
|
450
|
+
raise errors.FormatError("pack entry count mismatch")
|
|
451
|
+
return entries
|
|
452
|
+
|
|
453
|
+
def paths(self) -> list[str]:
|
|
454
|
+
"""Returns all entry paths, sorted."""
|
|
455
|
+
return sorted(self._entries)
|
|
456
|
+
|
|
457
|
+
def __contains__(self, path: object) -> bool:
|
|
458
|
+
"""Tells whether an entry exists."""
|
|
459
|
+
return path in self._entries
|
|
460
|
+
|
|
461
|
+
def read(self, path: str) -> memoryview:
|
|
462
|
+
"""Returns an entry's bytes as a zero-copy view.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
path: Entry path, for example ``runs/walk/rollout.json``.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
A read-only memoryview into the pack.
|
|
469
|
+
|
|
470
|
+
Raises:
|
|
471
|
+
KeyError: If the entry does not exist.
|
|
472
|
+
ValueError: If the reader is closed.
|
|
473
|
+
"""
|
|
474
|
+
if self._view is None:
|
|
475
|
+
raise ValueError("pack reader is closed")
|
|
476
|
+
off, length = self._entries[path]
|
|
477
|
+
return self._view[off : off + length]
|
|
478
|
+
|
|
479
|
+
def runs(self) -> list[str]:
|
|
480
|
+
"""Returns the names of the runs in the pack, sorted."""
|
|
481
|
+
return sorted(
|
|
482
|
+
p.split("/")[1]
|
|
483
|
+
for p in self._entries
|
|
484
|
+
if p.startswith("runs/")
|
|
485
|
+
and p.endswith("/" + manifest.MANIFEST_NAME)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def manifest(self, run: str) -> manifest.RolloutManifest:
|
|
489
|
+
"""Parses a run's manifest.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
run: Run name.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
The manifest.
|
|
496
|
+
"""
|
|
497
|
+
with self.read(f"runs/{run}/{manifest.MANIFEST_NAME}") as raw:
|
|
498
|
+
return manifest.RolloutManifest.from_json(json.loads(bytes(raw)))
|
|
499
|
+
|
|
500
|
+
def _asset(self, ref: cas.Ref) -> bytes:
|
|
501
|
+
"""Returns a blob's bytes, checking the size."""
|
|
502
|
+
with self.read(asset_path(ref)) as raw:
|
|
503
|
+
if len(raw) != ref.size:
|
|
504
|
+
raise errors.FormatError(f"size mismatch for {ref.sha256}")
|
|
505
|
+
return bytes(raw)
|
|
506
|
+
|
|
507
|
+
def scene(self, ref: cas.Ref) -> core.Scene:
|
|
508
|
+
"""Loads a scene and its assets from the pack.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
ref: Reference from a manifest (``scene`` or ``env_scenes``).
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
The scene. Transcoded meshes have no normals.
|
|
515
|
+
"""
|
|
516
|
+
with self.read(scene_path(ref)) as raw:
|
|
517
|
+
doc = json.loads(bytes(raw))
|
|
518
|
+
return scene.scene_from_json(doc, self._asset)
|
|
519
|
+
|
|
520
|
+
def stream(
|
|
521
|
+
self, run: str, name: str, *, cache_blocks: int = 64
|
|
522
|
+
) -> blockfile.BlockReader:
|
|
523
|
+
"""Opens a stream of a run for random access.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
run: Run name.
|
|
527
|
+
name: Stream name from the run's manifest.
|
|
528
|
+
cache_blocks: Decoded blocks to cache.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
A reader over the block file inside the pack, with the stream
|
|
532
|
+
kind set so q16d poses are renormalized.
|
|
533
|
+
"""
|
|
534
|
+
info = self.manifest(run).streams[name]
|
|
535
|
+
return blockfile.BlockReader(
|
|
536
|
+
self.read(f"runs/{run}/{info.file}"),
|
|
537
|
+
cache_blocks=cache_blocks,
|
|
538
|
+
kind=info.kind,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def close(self) -> None:
|
|
542
|
+
"""Releases the memory map. Safe to call more than once."""
|
|
543
|
+
if self._view is not None:
|
|
544
|
+
self._view.release()
|
|
545
|
+
self._view = None
|
|
546
|
+
if self._mm is not None:
|
|
547
|
+
# Views still exported: the map closes when they are dropped.
|
|
548
|
+
with contextlib.suppress(BufferError):
|
|
549
|
+
self._mm.close()
|
|
550
|
+
self._mm = None
|
|
551
|
+
|
|
552
|
+
def __enter__(self) -> "PackReader":
|
|
553
|
+
"""Returns the reader."""
|
|
554
|
+
return self
|
|
555
|
+
|
|
556
|
+
def __exit__(
|
|
557
|
+
self,
|
|
558
|
+
exc_type: type[BaseException] | None,
|
|
559
|
+
exc: BaseException | None,
|
|
560
|
+
tb: TracebackType | None,
|
|
561
|
+
) -> None:
|
|
562
|
+
"""Closes the reader."""
|
|
563
|
+
self.close()
|