simview 6.2.3__tar.gz → 6.3.1__tar.gz
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.
- {simview-6.2.3/simview.egg-info → simview-6.3.1}/PKG-INFO +1 -1
- {simview-6.2.3 → simview-6.3.1}/pyproject.toml +1 -1
- {simview-6.2.3 → simview-6.3.1}/simview/__main__.py +23 -3
- {simview-6.2.3 → simview-6.3.1}/simview/info.py +11 -5
- simview-6.3.1/simview/preload.py +69 -0
- {simview-6.2.3 → simview-6.3.1}/simview/server.py +195 -15
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/SimView.js +71 -27
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/BatchManager.js +9 -1
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/Scene.js +55 -1
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/StateStore.js +23 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/Terrain.js +46 -17
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/TerrainProfile.js +13 -3
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/viewState.js +21 -0
- {simview-6.2.3 → simview-6.3.1}/simview/templates/index.html +11 -0
- {simview-6.2.3 → simview-6.3.1}/simview/view.py +7 -5
- {simview-6.2.3 → simview-6.3.1/simview.egg-info}/PKG-INFO +1 -1
- {simview-6.2.3 → simview-6.3.1}/simview.egg-info/SOURCES.txt +2 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_cli.py +4 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_live.py +61 -1
- simview-6.3.1/tests/test_preload.py +25 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_server.py +43 -1
- {simview-6.2.3 → simview-6.3.1}/tests/test_show.py +20 -0
- {simview-6.2.3 → simview-6.3.1}/LICENSE +0 -0
- {simview-6.2.3 → simview-6.3.1}/MANIFEST.in +0 -0
- {simview-6.2.3 → simview-6.3.1}/README.md +0 -0
- {simview-6.2.3 → simview-6.3.1}/setup.cfg +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/__init__.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/columnar.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/diff.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/launcher.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/live.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/merge.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/model.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/py.typed +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/remote.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/render.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/scene.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/state.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/css/controls.css +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/AnimationController.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/InteractionController.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/WindowedField.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/config.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/main.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/Body.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/Polyline.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/StaticObject.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/colormap.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/similarity.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/utils.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/AnalysisPanel.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/BatchLegend.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/BodyStateWindow.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/Controls.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/ErrorMetrics.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/Legend.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/PlaybackControls.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/ScalarPlotter.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/chartControls.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/batchPresets.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/batchVisibility.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/blobCodec.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/blobWindow.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/bodyTransforms.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/cameraRange.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/csv.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/episodes.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/errorMath.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/html.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/interpolate.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/keyboard.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/liveFollow.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/scalarSeries.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/terrainSample.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/uplot.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/viewerDefaults.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/visibleRanges.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/js/vendor/js-colormaps.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/three.core.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/three.module.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/lib/uPlot-1.6.32/uPlot.esm.js +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/lib/uPlot-1.6.32/uPlot.min.css +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/textures/contacts/red-cross0.png +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/static/textures/points/ball1.png +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/terrain.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview/utils.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview.egg-info/dependency_links.txt +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview.egg-info/entry_points.txt +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview.egg-info/requires.txt +0 -0
- {simview-6.2.3 → simview-6.3.1}/simview.egg-info/top_level.txt +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_batch_selection.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_columnar_ondisk.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_columnar_states.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_contacts.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_diff.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_embed_defaults.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_episodes.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_info.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_launcher.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_merge.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_pointcloud_features.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_remote.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_roundtrip.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_scene.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_terrain.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_terrain_query.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_trajectory.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_utils.py +0 -0
- {simview-6.2.3 → simview-6.3.1}/tests/test_view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simview
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.1
|
|
4
4
|
Summary: THREE.js-based simulation visualization tool
|
|
5
5
|
Author-email: Jan Vlk <vlkjan6@fel.cvut.cz>, Ales Kucera <kuceral4@fel.cvut.cz>, David Korcak <david.korcak@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -82,8 +82,14 @@ def export_static_viewer(directory: Path) -> None:
|
|
|
82
82
|
|
|
83
83
|
package = files("simview")
|
|
84
84
|
directory.mkdir(parents=True, exist_ok=True)
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
from simview.preload import render_index
|
|
86
|
+
|
|
87
|
+
index = package / "templates" / "index.html"
|
|
88
|
+
static = package / "static"
|
|
89
|
+
(directory / "index.html").write_text(
|
|
90
|
+
render_index(str(index), str(package)), encoding="utf-8"
|
|
91
|
+
)
|
|
92
|
+
shutil.copytree(str(static), directory / "static", dirs_exist_ok=True)
|
|
87
93
|
logger.info("Static viewer written to %s", directory)
|
|
88
94
|
|
|
89
95
|
|
|
@@ -297,9 +303,18 @@ def save_merged(
|
|
|
297
303
|
logger.error("Error: --save-merged requires at least 2 input files to merge.")
|
|
298
304
|
sys.exit(1)
|
|
299
305
|
|
|
306
|
+
from simview.columnar import columnarize_states, inline_blob
|
|
300
307
|
from simview.merge import merge_simulation_files
|
|
301
308
|
|
|
302
309
|
merged = merge_simulation_files(paths, selections)
|
|
310
|
+
# Pack the merged states the same way SimulationScene.save does: merge
|
|
311
|
+
# decodes every blob to plain lists, so dumping it directly would write
|
|
312
|
+
# the fat legacy per-frame layout. Falls back silently when numpy is
|
|
313
|
+
# unavailable or the states are too irregular (columnarize_states
|
|
314
|
+
# returns None).
|
|
315
|
+
columnar_states = columnarize_states(merged["states"], merged["model"], inline_blob)
|
|
316
|
+
if columnar_states is not None:
|
|
317
|
+
merged["states"] = columnar_states
|
|
303
318
|
payload = json.dumps(merged).encode("utf-8")
|
|
304
319
|
if out_path.suffix == ".gz":
|
|
305
320
|
payload = gzip.compress(payload, compresslevel=1)
|
|
@@ -372,7 +387,12 @@ def _add_host_port_flags(sp: argparse.ArgumentParser) -> None:
|
|
|
372
387
|
"--host",
|
|
373
388
|
type=str,
|
|
374
389
|
default="127.0.0.1",
|
|
375
|
-
help=
|
|
390
|
+
help=(
|
|
391
|
+
"Host/interface for the server to bind to (default: 127.0.0.1). "
|
|
392
|
+
"Note: the server has no authentication, so binding to a "
|
|
393
|
+
"non-loopback address exposes the scene data to everyone who can "
|
|
394
|
+
"reach it."
|
|
395
|
+
),
|
|
376
396
|
)
|
|
377
397
|
sp.add_argument(
|
|
378
398
|
"--port",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"""Structural summary of a scene JSON file, for `simview info` and for coding
|
|
2
2
|
agents inspecting simview-generated data files.
|
|
3
3
|
|
|
4
|
-
Deliberately dependency-free (stdlib only: json, gzip
|
|
5
|
-
`simview.utils.read_maybe_gzipped_bytes`, base64) so it works on a base
|
|
4
|
+
Deliberately dependency-free (stdlib only: json, gzip, base64) so it works on a base
|
|
6
5
|
install without the `authoring` extra (torch/numpy) -- see "Lazy imports" in
|
|
7
6
|
docs/dev/architecture.md. Does not import `simview.model`/`simview.server`:
|
|
8
7
|
the former pulls in torch at module scope, the latter fastapi/uvicorn --
|
|
@@ -11,6 +10,7 @@ both wrong layering for a lightweight inspection tool. Shares its blob-decoding/
|
|
|
11
10
|
which are stdlib-only on this read path.
|
|
12
11
|
"""
|
|
13
12
|
|
|
13
|
+
import gzip
|
|
14
14
|
import json
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any
|
|
@@ -23,7 +23,7 @@ from simview.columnar import (
|
|
|
23
23
|
is_blob,
|
|
24
24
|
is_columnar,
|
|
25
25
|
)
|
|
26
|
-
from simview.utils import body_label, cap, human_bytes
|
|
26
|
+
from simview.utils import body_label, cap, human_bytes
|
|
27
27
|
|
|
28
28
|
_MAX_DETAIL_ITEMS = 20
|
|
29
29
|
_LARGE_FILE_BYTES = 100_000_000
|
|
@@ -218,9 +218,12 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
|
|
|
218
218
|
|
|
219
219
|
scalars_summary = {}
|
|
220
220
|
for name in scalar_names:
|
|
221
|
-
|
|
221
|
+
# One pass per scalar: collect missing-frame indices and check
|
|
222
|
+
# lengths in the same walk.
|
|
223
|
+
missing = []
|
|
222
224
|
for i, s in enumerate(states):
|
|
223
225
|
if name not in s:
|
|
226
|
+
missing.append(i)
|
|
224
227
|
continue
|
|
225
228
|
value = s[name]
|
|
226
229
|
length = len(value) if isinstance(value, list) else 1
|
|
@@ -395,7 +398,10 @@ def summarize_scene(path: str | Path) -> dict:
|
|
|
395
398
|
path = Path(path)
|
|
396
399
|
raw = path.read_bytes()
|
|
397
400
|
gzipped = raw[:2] == b"\x1f\x8b"
|
|
398
|
-
|
|
401
|
+
# Decompress from the bytes already in hand rather than calling
|
|
402
|
+
# read_maybe_gzipped_bytes(path), which would read the whole file a
|
|
403
|
+
# second time -- scene files are often hundreds of MB.
|
|
404
|
+
data = json.loads(gzip.decompress(raw) if gzipped else raw)
|
|
399
405
|
if not isinstance(data, dict):
|
|
400
406
|
raise ValueError(
|
|
401
407
|
"scene file must contain a JSON object with 'model'/'states' keys"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""`<link rel="modulepreload">` tags for the viewer page.
|
|
2
|
+
|
|
3
|
+
The browser only discovers an ES module's imports after it has fetched and
|
|
4
|
+
parsed the importer, so the ~50-file module graph downloads one dependency
|
|
5
|
+
level at a time. Listing every module in the page head lets them all start at
|
|
6
|
+
once (a ~50 ms round trip each otherwise adds up level by level).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from functools import cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# `import ... from "x"`, `export ... from "x"` and side-effect `import "x"`.
|
|
15
|
+
# Dynamic import() is skipped: those modules are lazy on purpose.
|
|
16
|
+
_STATIC_IMPORT = re.compile(
|
|
17
|
+
r"""(?:^|[;\n])\s*(?:import|export)\b[^;'"]*?(?:\bfrom\s*)?["']([^"']+)["']\s*;?""",
|
|
18
|
+
)
|
|
19
|
+
_IMPORTMAP = re.compile(r'<script type="importmap">(.*?)</script>', re.S)
|
|
20
|
+
PLACEHOLDER = "<!-- @modulepreload -->"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve(
|
|
24
|
+
spec: str, importer: Path, root: Path, imports: dict[str, str]
|
|
25
|
+
) -> Path | None:
|
|
26
|
+
"""Filesystem path `spec` points at, or None for anything outside `root`."""
|
|
27
|
+
if spec.startswith("."):
|
|
28
|
+
target = importer.parent / spec
|
|
29
|
+
else:
|
|
30
|
+
# Bare specifier -> import map; "prefix/" entries map whole directories.
|
|
31
|
+
for key, value in imports.items():
|
|
32
|
+
if spec == key or (key.endswith("/") and spec.startswith(key)):
|
|
33
|
+
target = root / (value + spec[len(key) :])
|
|
34
|
+
break
|
|
35
|
+
else:
|
|
36
|
+
return None
|
|
37
|
+
target = target.resolve()
|
|
38
|
+
return target if target.is_file() and root in target.parents else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def module_graph(html: str, root: Path, entry: str = "static/js/main.js") -> list[str]:
|
|
42
|
+
"""Page-relative URLs (`./static/...`) of every module reachable from `entry`."""
|
|
43
|
+
match = _IMPORTMAP.search(html)
|
|
44
|
+
imports = json.loads(match.group(1))["imports"] if match else {}
|
|
45
|
+
imports = {k: v.removeprefix("./") for k, v in imports.items()}
|
|
46
|
+
root = root.resolve()
|
|
47
|
+
seen: dict[Path, None] = {}
|
|
48
|
+
todo = [(root / entry).resolve()]
|
|
49
|
+
while todo:
|
|
50
|
+
path = todo.pop()
|
|
51
|
+
if path in seen:
|
|
52
|
+
continue
|
|
53
|
+
seen[path] = None
|
|
54
|
+
for spec in _STATIC_IMPORT.findall(path.read_text(encoding="utf-8")):
|
|
55
|
+
dep = _resolve(spec, path, root, imports)
|
|
56
|
+
if dep is not None:
|
|
57
|
+
todo.append(dep)
|
|
58
|
+
return ["./" + p.relative_to(root).as_posix() for p in seen]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cache
|
|
62
|
+
def render_index(index_path: str, root: str) -> str:
|
|
63
|
+
"""index.html with its modulepreload placeholder filled in."""
|
|
64
|
+
html = Path(index_path).read_text(encoding="utf-8")
|
|
65
|
+
links = "\n ".join(
|
|
66
|
+
f'<link rel="modulepreload" href="{url}" />'
|
|
67
|
+
for url in module_graph(html, Path(root))
|
|
68
|
+
)
|
|
69
|
+
return html.replace(PLACEHOLDER, links)
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import secrets
|
|
11
12
|
import threading
|
|
12
13
|
import time
|
|
@@ -20,12 +21,15 @@ import orjson
|
|
|
20
21
|
import uvicorn
|
|
21
22
|
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
|
22
23
|
from fastapi.middleware.cors import CORSMiddleware
|
|
23
|
-
from fastapi.responses import
|
|
24
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
24
25
|
from fastapi.staticfiles import StaticFiles
|
|
25
26
|
from pydantic import BaseModel
|
|
27
|
+
from starlette.datastructures import Headers
|
|
28
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
26
29
|
from starlette.types import Scope
|
|
27
30
|
|
|
28
31
|
from simview.columnar import columnarize_states, is_columnar, rewrite_blobs
|
|
32
|
+
from simview.preload import render_index
|
|
29
33
|
from simview.utils import dialable_host, find_free_port, read_maybe_gzipped_bytes
|
|
30
34
|
|
|
31
35
|
logger = logging.getLogger("simview.server")
|
|
@@ -36,7 +40,31 @@ STATIC = str(files("simview").joinpath("static"))
|
|
|
36
40
|
# Local-only viewer: CORS is restricted to localhost/127.0.0.1 on any port so a
|
|
37
41
|
# browser tab open on another local dev server can't be silently allowed, while
|
|
38
42
|
# still letting the bundled UI (served from the same host) talk to the API.
|
|
39
|
-
|
|
43
|
+
# The host list is shared with the /ws/states origin check (see
|
|
44
|
+
# _LOCAL_ORIGIN_RE below), so the two can't drift apart.
|
|
45
|
+
_LOCAL_HOSTS = r"(localhost|127\.0\.0\.1|\[::1\])"
|
|
46
|
+
_ALLOWED_ORIGIN_REGEX = rf"^https?://{_LOCAL_HOSTS}(:\d+)?$"
|
|
47
|
+
|
|
48
|
+
# The same host list, compiled, for the /ws/states origin check (see
|
|
49
|
+
# _ws_origin_allowed): CORS does not apply to WebSockets, so the endpoint has
|
|
50
|
+
# to check the browser's Origin header itself.
|
|
51
|
+
_LOCAL_ORIGIN_RE = re.compile(rf"^{_LOCAL_HOSTS}(:\d+)?$")
|
|
52
|
+
# A bare address (IPv4 or IPv6 literal, optionally bracketed) -- what a Host
|
|
53
|
+
# header holds when the browser dialed the server by address rather than by a
|
|
54
|
+
# (rebindable) name. Also used by that check.
|
|
55
|
+
_IP_LITERAL_RE = re.compile(r"^\[?[0-9A-Fa-f:.]+\]?$")
|
|
56
|
+
|
|
57
|
+
# Hosts run()/ViewerHandle bind to without a warning (see _warn_public_bind).
|
|
58
|
+
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
59
|
+
|
|
60
|
+
# Hard cap on the declared size of any request body. /batch-names is the only
|
|
61
|
+
# endpoint that accepts one, and a scene's batch names are a handful of short
|
|
62
|
+
# strings, so 1 MiB is orders of magnitude above legitimate use while keeping a
|
|
63
|
+
# rogue POST (or a runaway client) from ballooning memory or the on-disk
|
|
64
|
+
# sidecar.
|
|
65
|
+
_MAX_REQUEST_BODY_BYTES = 1 << 20
|
|
66
|
+
# Per-name cap for POST /batch-names, same reasoning per string.
|
|
67
|
+
_MAX_BATCH_NAME_CHARS = 256
|
|
40
68
|
|
|
41
69
|
# Subdirectories of simview/static that hold vendored, version-pinned third-party
|
|
42
70
|
# libraries. These never change for a given release, so they get a long-lived,
|
|
@@ -101,6 +129,99 @@ def _not_found(what: str) -> JSONResponse:
|
|
|
101
129
|
return JSONResponse({"message": f"{what} data not available"}, status_code=404)
|
|
102
130
|
|
|
103
131
|
|
|
132
|
+
def _warn_public_bind(host: str) -> None:
|
|
133
|
+
"""Warn when the server is bound where other machines can reach it.
|
|
134
|
+
|
|
135
|
+
The server has no authentication: anyone who can connect can read the
|
|
136
|
+
scene data and persist batch names to disk next to the scene file. That is
|
|
137
|
+
fine on loopback (the default, and all a personal viewer needs) and a
|
|
138
|
+
deliberate choice on e.g. an HPC login node -- hence a warning, not an
|
|
139
|
+
error.
|
|
140
|
+
"""
|
|
141
|
+
if host not in _LOOPBACK_HOSTS:
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Serving on %s with NO authentication: anyone who can reach this "
|
|
144
|
+
"address can read the scene data and write batch names. Only bind "
|
|
145
|
+
"to a shared interface on networks you trust.",
|
|
146
|
+
host,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _host_of(authority: str) -> str:
|
|
151
|
+
"""The host part of a Host-header-style `host[:port]` authority, keeping
|
|
152
|
+
IPv6 brackets intact ('[::1]:5420' -> '[::1]')."""
|
|
153
|
+
if authority.startswith("["):
|
|
154
|
+
return authority.partition("]")[0] + "]"
|
|
155
|
+
return authority.partition(":")[0]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _ws_origin_allowed(websocket: WebSocket) -> bool:
|
|
159
|
+
"""Whether /ws/states should serve this connection.
|
|
160
|
+
|
|
161
|
+
Browsers always send an Origin header on WebSocket connections, and CORS
|
|
162
|
+
does not apply to them, so without this check any site open in the user's
|
|
163
|
+
browser could connect to a running live viewer and read every pushed
|
|
164
|
+
frame. Two origins are allowed:
|
|
165
|
+
|
|
166
|
+
* a localhost one (the viewer itself, another local dev server, the
|
|
167
|
+
Jupyter iframe), matching the CORS list above;
|
|
168
|
+
* a same-origin one -- the Origin's host:port equals the Host header, i.e.
|
|
169
|
+
a page this very server served, connecting back to the address it was
|
|
170
|
+
loaded from. The Host must additionally name an address or 'localhost':
|
|
171
|
+
a hostname that Origin and Host agree on is the DNS-rebinding signature,
|
|
172
|
+
while an address the browser actually dialed is by construction this
|
|
173
|
+
machine. (The trade-off: a live viewer reached by hostname over the LAN
|
|
174
|
+
is refused -- reach it by address, e.g. http://10.0.0.5:5420.)
|
|
175
|
+
|
|
176
|
+
A missing Origin (tests, non-browser tooling) is allowed; browsers never
|
|
177
|
+
omit it.
|
|
178
|
+
"""
|
|
179
|
+
origin = websocket.headers.get("origin")
|
|
180
|
+
if origin is None:
|
|
181
|
+
return True
|
|
182
|
+
match = re.match(r"^https?://([^/?#]+)", origin)
|
|
183
|
+
if not match:
|
|
184
|
+
return False
|
|
185
|
+
if _LOCAL_ORIGIN_RE.match(match.group(1)):
|
|
186
|
+
return True
|
|
187
|
+
host_header = websocket.headers.get("host", "")
|
|
188
|
+
if match.group(1) != host_header:
|
|
189
|
+
return False
|
|
190
|
+
host = _host_of(host_header)
|
|
191
|
+
return host == "localhost" or _IP_LITERAL_RE.match(host) is not None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _BodySizeLimitMiddleware:
|
|
195
|
+
"""Rejects requests whose declared body exceeds _MAX_REQUEST_BODY_BYTES.
|
|
196
|
+
|
|
197
|
+
Checks Content-Length and answers 413 before FastAPI/uvicorn buffers and
|
|
198
|
+
parses anything. A chunked request with no Content-Length slips past this
|
|
199
|
+
-- acceptable for a local single-user tool, where the point is bounding
|
|
200
|
+
what a casual or runaway client can push, not defeating a deliberate
|
|
201
|
+
attacker with full protocol control.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, app):
|
|
205
|
+
self.app = app
|
|
206
|
+
|
|
207
|
+
async def __call__(self, scope: Scope, receive, send):
|
|
208
|
+
if scope["type"] == "http":
|
|
209
|
+
length = Headers(scope=scope).get("content-length")
|
|
210
|
+
if length is not None and length.isdigit():
|
|
211
|
+
if int(length) > _MAX_REQUEST_BODY_BYTES:
|
|
212
|
+
response = JSONResponse(
|
|
213
|
+
{
|
|
214
|
+
"message": (
|
|
215
|
+
f"Request body exceeds {_MAX_REQUEST_BODY_BYTES} bytes"
|
|
216
|
+
)
|
|
217
|
+
},
|
|
218
|
+
status_code=413,
|
|
219
|
+
)
|
|
220
|
+
await response(scope, receive, send)
|
|
221
|
+
return
|
|
222
|
+
await self.app(scope, receive, send)
|
|
223
|
+
|
|
224
|
+
|
|
104
225
|
class BatchNamesRequest(BaseModel):
|
|
105
226
|
names: list[str]
|
|
106
227
|
|
|
@@ -168,10 +289,15 @@ class SimViewServer:
|
|
|
168
289
|
# SimViewServer doesn't need a reference to it. Bounded: on a run
|
|
169
290
|
# longer than the cap the oldest frames are forgotten, so a late viewer
|
|
170
291
|
# replays the most recent window instead of the entire history.
|
|
171
|
-
|
|
292
|
+
# Each entry is (absolute frame index, frame): the index rides with
|
|
293
|
+
# the frame so a catch-up never has to reconstruct it from
|
|
294
|
+
# `frames_pushed - len(buffer)`, whose two halves are updated by
|
|
295
|
+
# different threads and could be read mid-update.
|
|
296
|
+
self.frame_buffer: deque[tuple[int, dict]] = deque(maxlen=frame_buffer_size)
|
|
172
297
|
# Absolute index (in the producer's scene.states) of the next frame to
|
|
173
|
-
# enter frame_buffer
|
|
174
|
-
#
|
|
298
|
+
# enter frame_buffer. Maintained by push_frame on the producer's thread
|
|
299
|
+
# and read there only (live.py); catch-up reads the indices stored in
|
|
300
|
+
# the buffer instead.
|
|
175
301
|
self.frames_pushed = 0
|
|
176
302
|
self._batch_selections = (
|
|
177
303
|
None if batch_selections is None else [str(s) for s in batch_selections]
|
|
@@ -213,6 +339,10 @@ class SimViewServer:
|
|
|
213
339
|
allow_methods=["GET", "POST"],
|
|
214
340
|
allow_headers=["*"],
|
|
215
341
|
)
|
|
342
|
+
# Body-size cap for the one endpoint with a request body. Added after
|
|
343
|
+
# CORS above, so it wraps it and rejects oversized payloads before any
|
|
344
|
+
# middleware or handler runs.
|
|
345
|
+
self.app.add_middleware(_BodySizeLimitMiddleware)
|
|
216
346
|
|
|
217
347
|
# Mount static files. StaticFiles adds ETag/Last-Modified headers so
|
|
218
348
|
# unchanged assets (vendored libs, textures, our own JS) are served
|
|
@@ -220,8 +350,17 @@ class SimViewServer:
|
|
|
220
350
|
# libs as immutable and everything else short-lived (max-age=60), so
|
|
221
351
|
# index.html's fixed ./static/... paths never need a cache-busting
|
|
222
352
|
# query param.
|
|
353
|
+
# Gzipped on the fly (three.js alone is ~1.1 MB raw, ~230 KB gzipped);
|
|
354
|
+
# applied to /static only -- /blob is float data that barely
|
|
355
|
+
# compresses and is served with Range support.
|
|
223
356
|
self.app.mount(
|
|
224
|
-
"/static",
|
|
357
|
+
"/static",
|
|
358
|
+
GZipMiddleware(
|
|
359
|
+
CacheControlStaticFiles(directory=STATIC),
|
|
360
|
+
minimum_size=1024,
|
|
361
|
+
compresslevel=6,
|
|
362
|
+
),
|
|
363
|
+
name="static",
|
|
225
364
|
)
|
|
226
365
|
|
|
227
366
|
# Pre-serialized, gzipped payloads for HTTP serving. The parsed dicts are
|
|
@@ -286,9 +425,13 @@ class SimViewServer:
|
|
|
286
425
|
# write dead "/blob/..." URLs, and a second show() would serve stale
|
|
287
426
|
# blob references). Deep-copying just the model is cheap: the big
|
|
288
427
|
# payloads are immutable blob strings, which deepcopy shares rather
|
|
289
|
-
# than duplicating. `states`
|
|
428
|
+
# than duplicating. A columnar `states` doc gets the same treatment
|
|
429
|
+
# below (rewrite_blobs rewrites its inline blobs in place too); the
|
|
430
|
+
# legacy per-frame list is only ever read.
|
|
290
431
|
if preloaded and model_data is not None:
|
|
291
432
|
model_data = copy.deepcopy(model_data)
|
|
433
|
+
if preloaded and is_columnar(states_data):
|
|
434
|
+
states_data = copy.deepcopy(states_data)
|
|
292
435
|
|
|
293
436
|
# Taken once, at load: the sidecar must describe the batches that were
|
|
294
437
|
# actually loaded, not whatever the file is by the time of a rename.
|
|
@@ -323,8 +466,11 @@ class SimViewServer:
|
|
|
323
466
|
# Random per-load token folded into every blob URL so it's safe to cache
|
|
324
467
|
# them forever: a later server restart serving a different scene on the
|
|
325
468
|
# same port gets a different token, so it can never collide with a
|
|
326
|
-
# stale cached response for blob id N from a previous load.
|
|
327
|
-
|
|
469
|
+
# stale cached response for blob id N from a previous load. It doubles
|
|
470
|
+
# as the only thing gating /blob responses (the /model payload that
|
|
471
|
+
# lists the URLs is CORS-restricted), so it is 128-bit rather than the
|
|
472
|
+
# few bits a pure cache-buster would need.
|
|
473
|
+
self._blob_token = secrets.token_hex(16)
|
|
328
474
|
|
|
329
475
|
def register_blob(raw: bytes) -> str:
|
|
330
476
|
blob_id = len(self.blobs)
|
|
@@ -386,7 +532,7 @@ class SimViewServer:
|
|
|
386
532
|
def setup_routes(self):
|
|
387
533
|
@self.app.get("/")
|
|
388
534
|
async def index():
|
|
389
|
-
return
|
|
535
|
+
return HTMLResponse(render_index(INDEX_HTML, str(Path(STATIC).parent)))
|
|
390
536
|
|
|
391
537
|
_gzip_headers = {"Content-Encoding": "gzip"}
|
|
392
538
|
|
|
@@ -461,6 +607,19 @@ class SimViewServer:
|
|
|
461
607
|
media_type="application/json",
|
|
462
608
|
status_code=400,
|
|
463
609
|
)
|
|
610
|
+
if any(len(name) > _MAX_BATCH_NAME_CHARS for name in names):
|
|
611
|
+
return Response(
|
|
612
|
+
content=json.dumps(
|
|
613
|
+
{
|
|
614
|
+
"message": (
|
|
615
|
+
"Batch names must each be at most "
|
|
616
|
+
f"{_MAX_BATCH_NAME_CHARS} characters"
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
),
|
|
620
|
+
media_type="application/json",
|
|
621
|
+
status_code=400,
|
|
622
|
+
)
|
|
464
623
|
|
|
465
624
|
self.model_data["batchNames"] = names
|
|
466
625
|
self._pack_model()
|
|
@@ -490,6 +649,23 @@ class SimViewServer:
|
|
|
490
649
|
# so a viewer opened mid-run still sees the recent timeline.
|
|
491
650
|
@self.app.websocket("/ws/states")
|
|
492
651
|
async def ws_states(websocket: WebSocket):
|
|
652
|
+
if not _ws_origin_allowed(websocket):
|
|
653
|
+
# CORS doesn't apply to WebSockets, so the endpoint checks
|
|
654
|
+
# the browser's Origin itself (see _ws_origin_allowed).
|
|
655
|
+
# Rejected during the handshake, before any frame is sent.
|
|
656
|
+
# A warning, not info: the hostname case is a formerly
|
|
657
|
+
# working setup now refused, and this line is the only
|
|
658
|
+
# server-side clue.
|
|
659
|
+
logger.warning(
|
|
660
|
+
"Rejected /ws/states connection with Origin %r: only "
|
|
661
|
+
"localhost and same-origin pages may connect to the "
|
|
662
|
+
"live stream. A viewer opened by hostname must be "
|
|
663
|
+
"opened by its address instead (e.g. "
|
|
664
|
+
"http://10.0.0.5:5420).",
|
|
665
|
+
websocket.headers.get("origin"),
|
|
666
|
+
)
|
|
667
|
+
await websocket.close(code=1008)
|
|
668
|
+
return
|
|
493
669
|
await websocket.accept()
|
|
494
670
|
try:
|
|
495
671
|
await self._send_catchup(websocket)
|
|
@@ -524,20 +700,22 @@ class SimViewServer:
|
|
|
524
700
|
if not pending:
|
|
525
701
|
self.ws_clients.add(websocket)
|
|
526
702
|
return
|
|
527
|
-
# Absolute index of buffered[0]: everything pushed minus what the
|
|
528
|
-
# (bounded) buffer still holds.
|
|
529
|
-
offset = self.frames_pushed - len(buffered) + replayed
|
|
530
703
|
for start in range(0, len(pending), _CATCHUP_CHUNK_SIZE):
|
|
531
704
|
chunk = pending[start : start + _CATCHUP_CHUNK_SIZE]
|
|
532
705
|
await websocket.send_text(
|
|
533
|
-
json.dumps(
|
|
706
|
+
json.dumps(
|
|
707
|
+
{
|
|
708
|
+
"states": [frame for _, frame in chunk],
|
|
709
|
+
"frameOffset": pending[start][0],
|
|
710
|
+
}
|
|
711
|
+
)
|
|
534
712
|
)
|
|
535
713
|
replayed += len(pending)
|
|
536
714
|
|
|
537
715
|
def push_frame(self, frame: dict) -> None:
|
|
538
716
|
"""Record one new frame for catch-up (live mode). The caller
|
|
539
717
|
broadcasts it separately via `broadcast_frame`."""
|
|
540
|
-
self.frame_buffer.append(frame)
|
|
718
|
+
self.frame_buffer.append((self.frames_pushed, frame))
|
|
541
719
|
self.frames_pushed += 1
|
|
542
720
|
|
|
543
721
|
def set_episodes(self, episodes: list[dict]) -> None:
|
|
@@ -599,6 +777,7 @@ class SimViewServer:
|
|
|
599
777
|
open_browser: bool = False,
|
|
600
778
|
):
|
|
601
779
|
"""Serve on `host:port`, blocking until the server stops."""
|
|
780
|
+
_warn_public_bind(host)
|
|
602
781
|
logger.info("SimView server running on http://%s:%s", host, port)
|
|
603
782
|
if open_browser:
|
|
604
783
|
import webbrowser
|
|
@@ -693,6 +872,7 @@ class ViewerHandle:
|
|
|
693
872
|
self.port = find_free_port(host, preferred_port)
|
|
694
873
|
self.view = view
|
|
695
874
|
self.iframe_height = iframe_height
|
|
875
|
+
_warn_public_bind(host)
|
|
696
876
|
|
|
697
877
|
# Prefer uvicorn's modern sansio websocket implementation when the
|
|
698
878
|
# `websockets` package is available -- the default "auto" still selects
|