Compare commits

...
2 Commits
Author SHA1 Message Date
thunderheronightandClaude Opus 5.5 fd8cd8dbb3 Tests, second review round: no desktop actions or silent exits from broken code; last gaps
- Fakes for kdialog, zenity and xdg-open; every test catches shutdown/os._exit
- Broken download, frame folder found on rename, nested values in non-text fields,
  Start over from the cancelled runs, the watcher's notification and tidying up
  (watcher split into watch_once for that)
- Fake pipeline refuses to continue an unfinished extraction, like the real one
213 tests, every mutation of both review rounds is caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:22:52 +02:00
thunderheronightandClaude Opus 5.5 da00372c5a 0.2.2: tests hardened after an independent review; strict types for all text fields
206 tests instead of 57; every surviving mutation of the review is caught now. New fakes for
ffprobe, ssh/scp (the Frame as a folder) and notify-send; synthetic rooms with tracking noise,
scale error, turned splats, tracking turn and an adjustable walk-in. Fixed: wrongly typed
members/key/align/partition gave a 500 or were stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:52:56 +02:00
12 changed files with 807 additions and 111 deletions

No files matched your search

+12
View File
@@ -2,6 +2,17 @@
All notable changes to Splat Workshop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/).
## [0.2.2] – 2026-10-14
### Changed
- **Tests hardened after an independent review in two rounds** (213 instead of 57): every deliberately broken piece of code from the review is now caught (mutation testing). New: the recording check, renaming recordings, the Frame's list, downloads (also a broken one) and copying scenes to the Frame (through a fake `ssh`), recovery after a power cut, what happens after a job (notification, tidying up), *Start over* from the list of cancelled runs, scenes being left alone while they are computed, checks before a conversion starts (fake `ffprobe` instead of switching the check off), splat orientation and size when putting rooms together, correction D against C on a turned tracking, the reduced Frame version. Synthetic rooms now have tracking noise, scale errors and turned splats. Even with broken code the tests can no longer open anything on the desktop or end the test run unnoticed.
- Known limit, now written down as a test: walking into a room is only recognised up to 0.4 m from another recording's path.
### Fixed
- Wrongly typed fields (e.g. `members: 5` when saving a group) were answered with an internal error or stored – now they get a clean 400.
## [0.2.1] – 2026-10-14
### Added
@@ -54,6 +65,7 @@ First release.
- **Floor plans** (experimental) in two styles with a measuring tool.
- Configurable video player; English and German interface.
[0.2.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/thunderheronight/splat-workshop/releases/tag/v0.1.0
+3 -1
View File
@@ -173,7 +173,9 @@ Automated tests run on every push ([GitHub Actions](https://github.com/thunderhe
uv run --no-project --with pytest --with numpy --with scipy --with pillow pytest
```
They need no GPU, no recordings and no Steam Frame: the Arcturus pipeline, Spirula, splat-transform and the desktop trash are replaced by small fakes, and putting rooms together is tested on synthetic rooms with a known error (`tests/synth.py`). Covered: who may talk to the server (other websites, LAN mode, malformed requests, paths), renaming, clean-up, deleting, cancelled runs, groups, conversions with cancel/start over/resume (the real `splat.sh`), tours (the real `combine.sh`), the four corrections, borders through doors, and that the floor plan is not mirrored. Not covered: the real pipeline, Spirula and the Frame – those are tested by hand.
They need no GPU, no recordings and no Steam Frame: the Arcturus pipeline, Spirula, splat-transform, ffprobe, the desktop trash and notifications are replaced by small fakes, the Frame is a folder reached through a fake `ssh`/`scp`, and putting rooms together is tested on synthetic rooms with a known error – turned, shifted and scaled reconstructions, tracking noise, drift and turn (`tests/synth.py`).
Covered: who may talk to the server (other websites, LAN mode, malformed or wrongly typed requests, paths outside the runs folder), the recording check, renaming recordings and scenes, clean-up, deleting, cancelled runs, a scene being left alone while it is computed, recovery after a power cut, groups, conversions with cancel/start over/resume (the real `splat.sh`), the Frame's list, downloads and copying scenes over (the real `to-frame.sh`), notifications and tidying up after a job, tours (the real `combine.sh`), the four corrections, splat orientation and size, borders through doors, and that the floor plan is not mirrored. The tests were reviewed independently with mutation testing (deliberately broken code must make them fail). Not covered: the real pipeline, Spirula, the real Frame, the tray icon, the browser interface and the server stopping itself when idle – those are tested by hand.
## Uninstall
+31 -25
View File
@@ -29,7 +29,7 @@ from pathlib import Path
from . import check, config
from .i18n import T, detect
VERSION = "0.2.1"
VERSION = "0.2.2"
CFG = config.load()
ROOT = config.ROOT
WEB = ROOT / "web"
@@ -625,33 +625,38 @@ def activity() -> dict:
"checking": check_busy(), "uploading": any(u["proc"].poll() is None for u in uploads.values())}
def watch_once(was_running):
"""One look at the jobs: notification and tidying up when a run has ended. Returns whether one runs."""
if proc is not None:
proc.poll() # reap the zombie
js = job_status(tail=False)
running = bool(js and js["running"])
if was_running and not running and js:
mins = round((time.time() - js["started"]) / 60)
if js["result"] == "ok":
if js["info"].get("folder"):
write_meta(js["slug"], frame_folder=js["info"]["folder"])
with meta_lock: # the replaced scene's settings are not needed any more
meta = read_meta(js["slug"])
if meta.pop("previous", None) is not None:
write_json(meta_file(js["slug"]), meta)
notify(t("notify.done.title"),
t("notify.done.frame" if js["upload"] else "notify.done.local", name=js["name"], mins=mins))
elif js["result"] == "failed":
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
if not running:
restore_previous()
for slug in list(uploads):
upload_status(slug) # notify even without an open page
return running
def watcher():
"""Notification when a run ends; stop the server after a longer idle time."""
"""Every few seconds: notifications and tidying up; stop the server after a longer idle time."""
was_running = None
while True:
time.sleep(3)
if proc is not None:
proc.poll() # reap the zombie
js = job_status(tail=False)
running = bool(js and js["running"])
if was_running and not running and js:
mins = round((time.time() - js["started"]) / 60)
if js["result"] == "ok":
if js["info"].get("folder"):
write_meta(js["slug"], frame_folder=js["info"]["folder"])
with meta_lock: # the replaced scene's settings are not needed any more
meta = read_meta(js["slug"])
if meta.pop("previous", None) is not None:
write_json(meta_file(js["slug"]), meta)
notify(t("notify.done.title"),
t("notify.done.frame" if js["upload"] else "notify.done.local", name=js["name"], mins=mins))
elif js["result"] == "failed":
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
was_running = running
if not running:
restore_previous()
for slug in list(uploads):
upload_status(slug) # notify even without an open page
was_running = running = watch_once(was_running)
busy = (running or (download and not download.get("finished")) or check_busy()
or any(u["proc"].poll() is None for u in uploads.values()))
if idle_exit and not busy and time.time() - last_request > CFG.idle_minutes * 60:
@@ -1514,7 +1519,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
p = None
# Only flat objects of simple values; text fields are text
if not isinstance(p, dict) or any(not isinstance(v, (str, int, float, bool, type(None))) for v in p.values()) \
or any(not isinstance(p.get(k, ""), str) for k in ("name", "new", "slug", "path", "what", "quality", "existing")):
or any(not isinstance(p.get(k, ""), str) for k in ("name", "new", "slug", "path", "what", "quality", "existing", "members", "key",
"align", "partition")):
return self.send(400, {"error": err("err.bad_request")})
path = urllib.parse.urlparse(self.path).path
e = None
+122 -8
View File
@@ -20,9 +20,11 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path[:0] = [str(ROOT), str(Path(__file__).parent)]
BASE = Path(tempfile.mkdtemp(prefix="splat-workshop-tests-"))
RUNS, REC, STATE, BIN, TRASH, PIPE = (BASE / n for n in ("runs", "recordings", "state", "bin", "trash", "pipeline"))
for d in (RUNS, REC, STATE, BIN, TRASH, PIPE):
RUNS, REC, STATE, BIN, TRASH, PIPE, FRAME_HOME = (BASE / n for n in (
"runs", "recordings", "state", "bin", "trash", "pipeline", "frame"))
for d in (RUNS, REC, STATE, BIN, TRASH, PIPE, FRAME_HOME):
d.mkdir()
TOOL_LOG = BASE / "tools.log" # what the fake tools were called with, one JSON line each
def script(path: Path, text: str):
@@ -37,6 +39,18 @@ import base64, json, os, sys, time
from pathlib import Path
out = Path(sys.argv[2])
mode = os.environ.get("FAKE_PIPELINE", "ok")
# like the real pipeline: an unfinished training blocks --resume ("Incomplete training retained")
run_json = out / "spirula" / "run.json"
if "--resume" in sys.argv and run_json.exists() and json.loads(run_json.read_text()).get("status") != "complete":
sys.exit("error: Incomplete training retained; use a new output directory to restart")
# like the real one: an existing output folder is only continued with --resume, and only once the camera
# positions are complete ("Incomplete native SfM retained" / "already exists without a matching manifest")
dataset = out / "dataset" / "spirula-dataset.json"
if out.exists() and any(out.iterdir()):
if "--resume" not in sys.argv:
sys.exit("error: Spirula SfM output already exists without a matching resume manifest")
if not dataset.exists() or json.loads(dataset.read_text()).get("status") != "complete":
sys.exit("error: Incomplete native SfM retained for diagnosis; use a fresh output directory")
(out / "dataset").mkdir(parents=True, exist_ok=True)
print("Extracting 20 images from 2 video streams", flush=True)
(out / "dataset" / "spirula-dataset.json").write_text(json.dumps({"status": "complete"}))
@@ -50,6 +64,7 @@ for step in (1000, 2000, 3000):
time.sleep(20)
(out / "spirula").mkdir(exist_ok=True)
(out / "spirula" / "scene.ply").write_bytes(b"ply\n")
run_json.write_text(json.dumps({"status": "complete"}))
print("spirula: 1,000 Gaussians in " + str(out / "spirula" / "scene.ply"), flush=True)
(out / "scene.sog").write_bytes(b"fake sog")
(out / "scene.xrSceneTransform.json").write_text(json.dumps({"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}))
@@ -60,7 +75,9 @@ print("Gaussian XR scene transform: " + str(out / "scene.xrSceneTransform.json")
script(BIN / "spirula", "#!/bin/sh\nexit 0\n")
# splat-transform: copies the input (a .ply stays a .ply), writes a small file for .sog
script(BIN / "splat-transform", f'''#!{sys.executable}
import shutil, sys
import json, shutil, sys
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["splat-transform", *sys.argv[1:]]) + "\\n")
args = [a for a in sys.argv[1:] if a not in ("-w", "--overwrite")]
if "--decimate" in args:
i = args.index("--decimate"); del args[i:i + 2]
@@ -78,6 +95,63 @@ shift; [ "$1" = -- ] && shift
exec mv --backup=numbered -t "{TRASH}" -- "$@"
''')
# Nothing may open on the desktop, even if the code under test is broken: file dialogs, xdg-open (folder,
# video player) only leave a note in the tool log
for tool in ("kdialog", "zenity", "xdg-open"):
script(BIN / tool, f'''#!{sys.executable}
import json, sys
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["{tool}", *sys.argv[1:]]) + "\\n")
''')
# notify-send: no desktop notifications from the tests, only a note in the tool log
script(BIN / "notify-send", f'''#!{sys.executable}
import json, sys
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["notify-send", *sys.argv[1:]]) + "\\n")
''')
# ffprobe: a good Arcturus recording (two video streams, gpmd, 60 s) unless the file name says otherwise:
# "mono" one video stream, "nogpmd" no metadata stream, "short" 5 s, "brief" 15 s
script(BIN / "ffprobe", f'''#!{sys.executable}
import json, sys
name = sys.argv[-1]
video = {{"codec_type": "video", "codec_tag_string": "hvc1", "width": 2464, "height": 2464, "avg_frame_rate": "30/1"}}
streams = [video] if "mono" in name else [video, dict(video)]
if "nogpmd" not in name:
streams.append({{"codec_type": "data", "codec_tag_string": "gpmd"}})
dur = 5 if "short" in name else 15 if "brief" in name else 60
print(json.dumps({{"streams": streams, "format": {{"duration": str(dur)}}}}))
''')
# ssh / scp: the Frame is a folder (its home directory); FAKE_FRAME_OFF=1 makes it unreachable
script(BIN / "ssh", f'''#!{sys.executable}
import os, subprocess, sys
if os.environ.get("FAKE_FRAME_OFF"):
sys.exit(255)
args = sys.argv[1:]
while args and args[0].startswith("-"):
args = args[2:] if args[0] in ("-o", "-p", "-i") else args[1:]
cmd = " ".join(args[1:])
env = {{**os.environ, "HOME": "{FRAME_HOME}"}}
if os.environ.get("FAKE_FRAME_CUT") and cmd.startswith("cat "): # the connection breaks halfway
out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout
sys.stdout.buffer.write(out[:len(out) // 2])
sys.exit(255)
sys.exit(subprocess.run(["bash", "-c", cmd or "true"], cwd="{FRAME_HOME}", env=env).returncode)
''')
script(BIN / "scp", f'''#!{sys.executable}
import os, shutil, sys
from pathlib import Path
if os.environ.get("FAKE_FRAME_OFF"):
sys.exit(255)
args = [a for a in sys.argv[1:] if a not in ("-q",)]
def local(a):
if ":" in a.split("/", 1)[0]:
return Path("{FRAME_HOME}") / a.split(":", 1)[1]
return Path(a)
*src, dst = map(local, args)
for s in src:
shutil.copy(s, dst / s.name if dst.is_dir() else dst)
''')
(BASE / "config.toml").write_text(f'''
[paths]
pipeline = "{PIPE}"
@@ -106,18 +180,58 @@ def clean():
if server.pid_alive(st.get("pid", 0)):
server.cancel_job()
wait_job(timeout=20)
for d in (RUNS, REC, STATE, TRASH):
for d in (RUNS, REC, STATE, TRASH, FRAME_HOME):
shutil.rmtree(d, ignore_errors=True)
d.mkdir()
for cache in (server.usage_cache, server.probe_cache, server.uploads, server.checks):
cache.clear()
os.environ.pop("FAKE_PIPELINE", None)
for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FRAME_HOST"):
os.environ.pop(k, None)
os.environ["FRAME_HOST"] = ""
TOOL_LOG.unlink(missing_ok=True)
server.frame_cache.update(t=0, data=None)
SHUTDOWNS = [] # calls of server.shutdown during the current test
@pytest.fixture(autouse=True)
def never_exit(monkeypatch):
"""Quitting must never end the test run – a broken guard would otherwise make it stop silently (green)."""
SHUTDOWNS.clear()
monkeypatch.setattr(server, "shutdown", lambda cancel: SHUTDOWNS.append(cancel))
def no_exit(code=0):
raise RuntimeError(f"os._exit({code}) called during a test")
monkeypatch.setattr(server.os, "_exit", no_exit)
@pytest.fixture
def ok_probe(monkeypatch):
"""Recordings are fake files: let the recording check (ffprobe) accept them."""
monkeypatch.setattr(server, "probe", lambda path: {"ok": True, "errors": [], "warnings": [], "duration": 60})
def frame(monkeypatch):
"""A Steam Frame (the fake one: a folder) is configured and reachable. Returns its home directory."""
host = "steamos@frame.test"
monkeypatch.setattr(server, "FRAME", host)
monkeypatch.setattr(server, "SSH", [*server.SSH[:-1], host])
os.environ["FRAME_HOST"] = host # for the scripts (splat.sh, to-frame.sh)
for d in (server.FRAME_VIDEOS + "/thumbnails", server.FRAME_SCENES):
(FRAME_HOME / d).mkdir(parents=True, exist_ok=True)
return FRAME_HOME
def tools_called():
"""Calls of the fake tools so far, e.g. [["splat-transform", "-w", ...], ...]."""
return [json.loads(line) for line in TOOL_LOG.read_text().splitlines()] if TOOL_LOG.exists() else []
def wait_for_tool(name, timeout=5):
"""Calls of one fake tool, waiting a little for ones started in the background (notifications)."""
end = time.time() + timeout
while time.time() < end:
calls = [c for c in tools_called() if c[0] == name]
if calls:
return calls
time.sleep(0.1)
return []
def recording(name="2026-10-12_16-40-00_stereo.mp4"):
+49 -25
View File
@@ -19,8 +19,10 @@ Every recording walks around its room; room B starts in room A next to the door
The reconstruction is off by a known small rotation and shift (`error`), as a real one is before
it is fitted to the Frame's tracking.
"""
import base64
import json
import struct
import zlib
from pathlib import Path
import numpy as np
@@ -28,6 +30,7 @@ from scipy.spatial.transform import Rotation
FLOOR, CEILING = 1.6, -0.9 # y (down): floor below the cameras, ceiling above
DOOR = (1.2, 2.0) # door between the rooms, z range
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
PROPS = (["x", "y", "z", "nx", "ny", "nz", "f_dc_0", "f_dc_1", "f_dc_2"] + [f"f_rest_{i}" for i in range(45)]
+ ["opacity", "scale_0", "scale_1", "scale_2", "rot_0", "rot_1", "rot_2", "rot_3"])
@@ -50,17 +53,17 @@ def walls(x0, x1, step=0.04):
return np.array(pts)
def path(room):
def path(room, walk_in_offset=0.3):
"""Camera positions over time (y = 0, head height) and the times in seconds."""
t = np.linspace(0, 2 * np.pi, 140)
if room == "A":
# starts at the far wall, passes the door in the middle of the round
loop = np.stack([2 + 1.3 * np.cos(t + np.pi), np.zeros_like(t), 1.5 + 0.9 * np.sin(t + np.pi)], 1)
return loop, np.linspace(0, 40, len(loop))
# starts on room A's round (like a recording that starts in the hallway), then through the door
# starts in room A next to room A's round (like a recording that starts in the hallway), then through
# the door. walk_in_offset: how far from room A's own path (towards room A's wall) it starts.
tt = np.linspace(-1.0, 0, 18)
# 30 cm closer to room A's wall than room A's own recording went
along_a = np.stack([2 + 1.3 * np.cos(tt), np.zeros_like(tt), 1.5 + 0.9 * np.sin(tt) - 0.3], 1)
along_a = np.stack([2 + 1.3 * np.cos(tt), np.zeros_like(tt), 1.5 + 0.9 * np.sin(tt) - walk_in_offset], 1)
walk_in = np.vstack([along_a, np.stack([np.linspace(3.35, 4.6, 12), np.zeros(12), np.full(12, 1.6)], 1)])
loop = np.stack([6 + 1.3 * np.cos(t + np.pi), np.zeros_like(t), 1.5 + 0.9 * np.sin(t + np.pi)], 1)
pos = np.vstack([walk_in, loop])
@@ -95,15 +98,17 @@ def write_points(path_, pts):
f.write(struct.pack("<Q3d3BdQ", i + 1, *p, 128, 128, 128, 0.5, 0))
def write_ply(path_, pts, colours):
def write_ply(path_, pts, colours, rotation=(1.0, 0.0, 0.0, 0.0), scale=0.02):
"""One splat per point; all with the same rotation (w, x, y, z) and size (metres)."""
data = np.zeros(len(pts), dtype=np.dtype([(p, "<f4") for p in PROPS]))
data["x"], data["y"], data["z"] = pts.T
for k in range(3):
data[f"f_dc_{k}"] = (colours[:, k] - 0.5) / 0.28209479177387814
data["opacity"] = 3.0
for k in range(3):
data[f"scale_{k}"] = np.log(0.02)
data["rot_0"] = 1.0
data[f"scale_{k}"] = np.log(scale)
for k in range(4):
data[f"rot_{k}"] = rotation[k]
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
len(pts), "".join(f"property float {p}\n" for p in PROPS))
with open(path_, "wb") as f:
@@ -111,51 +116,70 @@ def write_ply(path_, pts, colours):
data.tofile(f)
def make_room(runs: Path, slug: str, room: str, error=(1.0, (0.03, 0.0, -0.02)), drift=(0.0, 0.0, 0.0),
name=None, recording=None, markers=False, colour=None):
"""Write one synthetic room into runs/<slug>. error = (yaw in degrees, shift in m) of the reconstruction;
drift = constant offset of the Frame's tracking for this recording. Returns the run folder."""
MARKERS = (((1, 0, 0), (0.0, 0.5, 0.0)), ((0, 1, 0), (2.0, 0.5, 0.0)), ((0, 0, 1), (0.0, 0.5, 2.0)))
SPLAT_TURN = Rotation.from_euler("x", 20, degrees=True) # how every splat is turned in the real world
SPLAT_SIZE = 0.02 # metres
def make_room(runs: Path, slug: str, room: str, error=(1.0, (0.03, 0.0, -0.02)), scale=1.0,
drift=(0.0, 0.0, 0.0), turn=0.0, noise=0.005, walk_in_offset=0.3,
name=None, recording=None, markers=(), colour=None):
"""Write one synthetic room into runs/<slug>. Returns the run folder.
The reconstruction differs from the real world by `error` = (yaw in degrees, shift in m) and `scale`.
The Frame's tracking of this recording is off by `drift` (m) and turned by `turn` (degrees, about the
room's middle), plus `noise` (m) per pose. markers: True for red/green/blue at the corner, along +x and
along +z, or a list of ((r, g, b), (x, y, z)) – blobs of 400 splats at real-world positions.
runs/<slug>/synth.json records how many splats are the room's own (of these, how many on the wall it
shares with the other room – zero thickness, so it belongs to both) and how many were seen through the door."""
rng = np.random.default_rng(zlib.crc32(slug.encode()))
run = runs / slug
sparse = run / "dataset" / "sparse" / "0"
sparse.mkdir(parents=True, exist_ok=True)
(run / "spirula").mkdir(exist_ok=True)
x0, x1 = (0.0, 4.0) if room == "A" else (4.0, 8.0)
pts = walls(x0, x1)
# every recording its own colour, so the origin of a splat can be seen after putting rooms together
colours = np.tile(colour or ((0.8, 0.3, 0.3) if room == "A" else (0.3, 0.3, 0.8)), (len(pts), 1))
for c, at in (MARKERS if markers is True else markers):
blob = np.array(at) + rng.normal(0, 0.05, (400, 3))
pts, colours = np.vstack([pts, blob]), np.vstack([colours, np.tile(c, (400, 1))])
own = len(pts)
shared = int((np.abs(pts[:, 0] - 4) < 1e-9).sum()) # the wall between the rooms belongs to both
# seen through the door: a little of the neighbouring room close to the door
other = walls(4.0, 8.0) if room == "A" else walls(0.0, 4.0)
near_door = other[(np.abs(other[:, 0] - 4) < 1.0) & (other[:, 2] > 0.6) & (other[:, 2] < 2.6)]
pts = np.vstack([pts, near_door])
# every recording its own colour, so the origin of a splat can be seen after putting rooms together
colours = np.tile(colour or ((0.8, 0.3, 0.3) if room == "A" else (0.3, 0.3, 0.8)), (len(pts), 1))
if markers: # red at the origin corner, green along +x, blue along +z
for c, at in (((1, 0, 0), (0.0, 0.5, 0.0)), ((0, 1, 0), (2.0, 0.5, 0.0)), ((0, 0, 1), (0.0, 0.5, 2.0))):
blob = np.array(at) + np.random.default_rng(1).normal(0, 0.05, (400, 3))
pts = np.vstack([pts, blob])
colours = np.vstack([colours, np.tile(c, (400, 1))])
colours = np.vstack([colours, np.tile(colours[0], (len(near_door), 1))])
pos, times = path(room)
pos, times = path(room, walk_in_offset)
centre = np.array([(x0 + x1) / 2, 0.0, 1.5])
yaw, shift = error
E = Rotation.from_euler("y", yaw, degrees=True).as_matrix() # world -> reconstruction
to_sfm = lambda p: (E @ p.T).T + np.array(shift)
E = Rotation.from_euler("y", yaw, degrees=True) # world -> reconstruction
to_sfm = lambda p: scale * E.apply(p) + np.array(shift)
T = Rotation.from_euler("y", turn, degrees=True) # the tracking's own turn
names, Rs, Cs, frames = [], [], [], []
for k, (c, t) in enumerate(zip(pos, times)):
R_world = look_at(c.copy(), centre)
image = f"cam0/{k:04d}-{t:.6f}.jpg"
names.append(image)
Rs.append(R_world @ E.T)
Rs.append(R_world @ E.as_matrix().T)
Cs.append(to_sfm(c))
world_from_cam = np.eye(4)
world_from_cam[:3, :3] = R_world.T
world_from_cam[:3, 3] = c + np.array(drift)
world_from_cam[:3, :3] = T.as_matrix() @ R_world.T
world_from_cam[:3, 3] = T.apply(c - centre) + centre + np.array(drift) + rng.normal(0, noise, 3)
frames.append({"cam": 0, "path": "images/" + image, "worldFromCam": world_from_cam.tolist(),
"playbackTimestampSec": float(t)})
write_images(sparse / "images.bin", names, Rs, Cs)
write_points(sparse / "points3D.bin", to_sfm(pts))
write_ply(run / "spirula" / "scene.ply", to_sfm(pts), colours)
q = (E * SPLAT_TURN).as_quat() # (x, y, z, w) in the reconstruction
write_ply(run / "spirula" / "scene.ply", to_sfm(pts), colours, (q[3], q[0], q[1], q[2]), SPLAT_SIZE * scale)
(run / "dataset" / "main.aiscene").write_text(json.dumps({"format": "synthetic", "cameras": [], "frames": frames}))
(run / "scene.sog").write_bytes(b"synthetic")
(run / "scene.xrSceneTransform.json").write_text(json.dumps({"transform": np.eye(4).tolist(), "worldScale": 1.0}))
(run / f"{slug.lower()}.gallery.png").write_bytes(PNG)
(run / "synth.json").write_text(json.dumps({"own": own, "shared_wall": shared, "seen_through_door": len(near_door),
"splats": len(pts)}))
(runs / f"{slug}.log").write_text(f"spirula: {len(pts):,} Gaussians in {run}/spirula/scene.ply\n")
meta = {"name": name or slug, "recording": recording or f"2026-10-12_16-{40 + (room == 'B') * 3:02d}-00_stereo.mp4",
"quality": "high"}
+103
View File
@@ -0,0 +1,103 @@
"""The recording check's rating (splat_workshop/check.py): per-second measurements in, findings out.
Rows are made by hand with values clearly on one side of each limit, so the tests say what a user
gets for a recording that is, say, 50 % dark – not what the code happens to compute."""
import pytest
from splat_workshop import check
GOOD = {"bright": 120, "detail": 7.0, "motion": 10.0, "over": 0.0, "cast": 5, "rgb": (120, 120, 120),
"right": {"bright": 120, "detail": 7.0}}
def rows(n=60, **changes_by_second):
"""n good seconds; changes_by_second: {"dark": range(...), ...} turns those seconds bad in one way."""
out = [dict(GOOD, right=dict(GOOD["right"])) for _ in range(n)]
for kind, secs in changes_by_second.items():
for i in secs:
r = out[i]
if kind == "dark":
r["bright"] = r["right"]["bright"] = 20
elif kind == "flat":
r["detail"] = r["right"]["detail"] = 1.0
elif kind == "fast":
r["motion"] = 80.0
elif kind == "over":
r["over"] = 0.2
elif kind == "eye": # one lens covered
r["right"] = {"bright": 30, "detail": 0.5}
return out
def findings(res):
return {f["code"]: f["level"] for f in res["findings"]}
def test_a_good_recording():
res = check.evaluate(rows(), 60)
assert res["level"] == "ok" and res["findings"] == [] and set(res["timeline"]) == {"ok"}
assert res["summary"] == {"bright": 120, "detail": 7.0, "seconds": 60}
def test_nothing_measured():
assert check.evaluate([], 0)["level"] == "bad"
def test_too_dark_overall():
dark = [dict(r, bright=20, right={"bright": 20, "detail": 7.0}) for r in rows()] # both lenses
assert findings(check.evaluate(dark, 60)) == {"too_dark": "bad"}
@pytest.mark.parametrize("secs,level", [(range(0, 30), "bad"), # half the recording dark
(range(0, 24), "warn"), # 40 %: not more than 40 %
(range(0, 3), "warn")])
def test_dark_stretches(secs, level):
res = check.evaluate(rows(dark=secs), 60)
assert findings(res)["dark"] == level
f = next(f for f in res["findings"] if f["code"] == "dark")
assert f["secs"] == len(secs) and f["spans"] == [(secs[0], secs[-1])]
assert res["timeline"][secs[0]] == "dark"
@pytest.mark.parametrize("secs,level", [(range(10, 40), "bad"), (range(10, 15), "warn"), (range(10, 13), "info")])
def test_plain_surfaces(secs, level):
# 30 s = 50 %: bad; 5 s = 8 %: warn; 3 s = 5 %: only a note
assert findings(check.evaluate(rows(flat=secs), 60))["flat"] == level
def test_a_single_plain_second_is_not_worth_a_finding():
assert "flat" not in findings(check.evaluate(rows(flat=[10]), 60))
@pytest.mark.parametrize("secs,level", [(range(0, 12), "warn"), (range(0, 5), "info")])
def test_fast_pans(secs, level):
assert findings(check.evaluate(rows(fast=secs), 60))["fast"] == level
def test_a_covered_lens_needs_three_seconds():
assert findings(check.evaluate(rows(eye=range(5, 8)), 60))["cameras_differ"] == "warn"
assert "cameras_differ" not in findings(check.evaluate(rows(eye=range(5, 7)), 60))
def test_overexposure_and_colour_cast_are_notes_only():
tinted = [dict(r, cast=30, rgb=(160, 140, 80)) for r in rows(over=range(0, 5))]
res = check.evaluate(tinted, 60)
assert findings(res) == {"overexposed": "info", "color_cast": "info"}
assert res["level"] == "ok"
assert next(f for f in res["findings"] if f["code"] == "color_cast")["tone"] == "yellow"
@pytest.mark.parametrize("duration,code,level", [(15, "short", "warn"), (200, "long", "info")])
def test_length(duration, code, level):
assert findings(check.evaluate(rows(), duration))[code] == level
def test_the_worst_finding_decides():
res = check.evaluate(rows(dark=range(0, 30), fast=range(40, 45)), 60)
assert res["level"] == "bad"
assert res["timeline"][0] == "dark" and res["timeline"][42] == "fast" and res["timeline"][50] == "ok"
def test_spans():
assert check._spans([False, True, True, False, True]) == [(1, 2), (4, 4)]
assert check._spans([True, False, True, True], min_len=2) == [(2, 3)]
+128 -40
View File
@@ -1,5 +1,10 @@
"""Putting rooms together (scripts/combine.py) on synthetic rooms with a known error."""
"""Putting rooms together (scripts/combine.py, scripts/combine.sh) on synthetic rooms with a known error.
The expectations come from how the synthetic rooms were built (tests/synth.py), not from the code:
each reconstruction is turned, shifted and scaled by a known amount, the Frame's tracking has 5 mm
noise and optionally a drift or a turn of its own."""
import json
import os
import subprocess
import sys
@@ -7,25 +12,27 @@ import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from conftest import ROOT, RUNS, BASE
from conftest import BASE, ROOT, RUNS, tools_called
import synth
COMBINE = ROOT / "scripts" / "combine.py"
YAW_A, YAW_B = 1.0, -0.8 # degrees: how far each reconstruction is turned
DRIFT_B = 0.03 # metres: the Frame's tracking of room B is off by this much
def two_rooms(drift_b=DRIFT_B, b_far=False):
a = synth.make_room(RUNS, "A", "A", error=(YAW_A, (0.03, 0.0, -0.02)))
b = synth.make_room(RUNS, "B", "B", error=(YAW_B, (-0.02, 0.01, 0.03)),
drift=(20.0 if b_far else drift_b, 0.0, 0.0))
def two_rooms(drift_b=DRIFT_B, turn_b=0.0, b_far=False, walk_in_offset=0.3, scale_a=1.0, markers_b=()):
a = synth.make_room(RUNS, "A", "A", error=(1.0, (0.03, 0.0, -0.02)), scale=scale_a,
markers=[((0, 1, 0), (1.0, 0.5, 0.3)), ((0, 1, 1), (1.0, 0.5, 2.7))])
b = synth.make_room(RUNS, "B", "B", error=(-0.8, (-0.02, 0.01, 0.03)), turn=turn_b,
drift=(20.0 if b_far else drift_b, 0.0, 0.0), walk_in_offset=walk_in_offset,
markers=markers_b)
return a, b
def combine(*args, align="images", partition="view"):
def combine(*runs, align="images", partition="view"):
out = BASE / f"out-{align}-{partition}"
r = subprocess.run([sys.executable, str(COMBINE), str(out), *[f"{p}={p.name}" for p in args],
r = subprocess.run([sys.executable, str(COMBINE), str(out), *[f"{p}={p.name}" for p in runs],
"--align", align, "--partition", partition], capture_output=True, text=True)
assert r.returncode == 0 or "@@ error" in r.stdout, r.stderr
return out, r
@@ -43,59 +50,140 @@ def read_ply(path):
return np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
def blob(v, rgb):
"""Centre of the marker splats of one colour (markers are pure colours, rooms are not)."""
c = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
m = np.all(np.abs(c - np.array(rgb)) < 0.02, axis=1)
assert m.sum() >= 300, f"marker {rgb} not found"
return np.stack([v["x"], v["y"], v["z"]], 1)[m].mean(0)
@pytest.mark.parametrize("align,lo,hi", [
("frame", 5.0, 12.0), # as delivered: both reconstructions' errors add up
("images", 2.0, 4.0), # rooms fitted, but room B's tracking drift (3 cm) is left
("shift", 0.0, 0.5), # shifting the rooms over the door removes the drift
("rigid", 0.0, 0.5),
("shift", 0.0, 1.0), # shifting the rooms over the door removes the drift
("rigid", 0.0, 1.0),
])
def test_corrections_take_out_the_known_error(align, lo, hi):
out, r = combine(*two_rooms(), align=align)
assert r.returncode == 0, r.stderr
seam = result(out)["seams"]["A|B"]["cm"]
assert lo <= seam <= hi, f"{align}: overlap off by {seam} cm"
def test_every_room_is_fitted_to_the_frames_tracking():
out, r = combine(*two_rooms(drift_b=0.0))
rooms = result(out)["rooms"]
for room in rooms.values():
assert room["tracking_cm"] > 3 # off before: turned and shifted
assert room["fitted_cm"] < 0.1 # fitted over all images: exact
# the splats are turned back as well (they start without rotation in the synthetic rooms)
out, r = combine(*two_rooms(drift_b=0.0, scale_a=1.03))
for room in result(out)["rooms"].values():
assert room["tracking_cm"] > 3 # off before: turned, shifted (and room A 3 % too big)
assert room["fitted_cm"] < 1.5 # fitted over all images: down to the 5 mm tracking noise
def test_splats_get_the_rotation_and_size_they_have_in_reality():
"""Room A's reconstruction is 3 % too big and turned by 1°; afterwards every splat must have its
real-world orientation (20° about x) and size (2 cm) again – with the right sign."""
out, r = combine(*two_rooms(drift_b=0.0, scale_a=1.03))
v = read_ply(out / "spirula" / "scene.ply")
q = np.stack([v["rot_1"], v["rot_2"], v["rot_3"], v["rot_0"]], 1)
yaws = np.abs(Rotation.from_quat(q).as_euler("yxz", degrees=True)[:, 0])
assert np.isclose(sorted(set(np.round(yaws, 1))), [abs(YAW_B), YAW_A], atol=0.05).all()
q = np.stack([v["rot_1"], v["rot_2"], v["rot_3"], v["rot_0"]], 1).astype(np.float64)
off = (Rotation.from_quat(q) * synth.SPLAT_TURN.inv()).magnitude()
assert np.degrees(off).max() < 0.3, "splats are turned the wrong way"
size = np.exp(np.stack([v["scale_0"], v["scale_1"], v["scale_2"]], 1).astype(np.float64))
assert np.allclose(size, synth.SPLAT_SIZE, rtol=0.01), "splats have the wrong size"
# and the room itself has its real size: the two markers in room A are 2.4 m apart
assert abs(np.linalg.norm(blob(v, (0, 1, 1)) - blob(v, (0, 1, 0))) - 2.4) < 0.02
def test_all_splats_are_written_once():
out, r = combine(*two_rooms())
info = result(out)
v = read_ply(out / "spirula" / "scene.ply")
assert len(v) == info["splats"] == sum(room["kept"] for room in info["rooms"].values())
assert (out / "scene.xrSceneTransform.json").exists()
def test_shift_and_turn_also_takes_out_a_turned_tracking():
"""Room B's tracking is turned by 2° about the room's middle: shifting (C) cannot repair that,
shifting and turning (D) can reduce it. Measured independently of the alignment: the distances from
the markers in room A to a marker in the far corner of room B (rigid motions keep distances).
Known limit: the turn can only be read from the overlap at the door, so D does not take it out
completely (here about 2 cm are left of about 6 cm)."""
far_b = ((1, 0, 1), (7.5, 0.5, 2.7))
truth = [np.linalg.norm(np.array(far_b[1]) - np.array(p)) for p in ((1.0, 0.5, 0.3), (1.0, 0.5, 2.7))]
def error(align):
out, r = combine(*two_rooms(drift_b=0.0, turn_b=2.0, markers_b=[far_b]), align=align)
v = read_ply(out / "spirula" / "scene.ply")
got = [np.linalg.norm(blob(v, far_b[0]) - blob(v, c)) for c in ((0, 1, 0), (0, 1, 1))]
return max(abs(g - t) for g, t in zip(got, truth)) * 100
shift, rigid = error("shift"), error("rigid")
assert shift > 3, f"the test needs a turn that shifting cannot repair ({shift:.1f} cm)"
assert rigid < shift / 2, f"shift {shift:.1f} cm, shift and turn {rigid:.1f} cm"
def test_borders_run_through_the_door():
def test_every_splat_is_written_once():
a, b = two_rooms()
out, r = combine(a, b)
info, v = result(out), read_ply(out / "spirula" / "scene.ply")
assert len(v) == info["splats"]
for run in (a, b):
made = json.loads((run / "synth.json").read_text())
room = info["rooms"][run.name]
assert room["splats"] == made["splats"] # read completely
# a room keeps its own splats (the shared wall may go either way); of what it saw of the neighbour
# through the door it keeps at most the part right behind the door, where its own cameras were closer
assert 0.95 * (made["own"] - made["shared_wall"]) <= room["kept"] <= made["own"] + made["seen_through_door"]
# each place once: together the rooms keep no more than they are made of
total_own = sum(json.loads((run / "synth.json").read_text())["own"] for run in (a, b))
assert len(v) <= total_own
@pytest.mark.parametrize("offset", [0.1, 0.3])
def test_borders_run_through_the_door(offset):
"""Room B starts its recording in room A (walking in). With borders 'through doors' that stretch
claims nothing, so room A comes completely from room A's own recording."""
def colours_in_room_a(partition):
out, r = combine(*two_rooms(drift_b=0.0), partition=partition)
assert r.returncode == 0, r.stderr
claims nothing, so room A comes from room A's own recording; 'nearest camera' cuts into room A."""
def from_b_in_room_a(partition):
out, r = combine(*two_rooms(drift_b=0.0, walk_in_offset=offset), partition=partition)
v = read_ply(out / "spirula" / "scene.ply")
inside_a = (v["x"] > 0.2) & (v["x"] < 3.6)
blue = v["f_dc_2"] > v["f_dc_0"] # room B's splats are blue, room A's red
return int((inside_a & blue).sum()), result(out)["rooms"]["B"]["walk_in_out_cameras"]
from_b_view, walk_in = colours_in_room_a("view")
from_b_nearest, _ = colours_in_room_a("nearest")
assert walk_in > 10 # recognised as walking in
assert from_b_view < 50 # practically nothing of room B inside room A
# nearest camera: room B's walk-in cuts into room A's wall
assert from_b_nearest > 50 and from_b_nearest > 10 * max(from_b_view, 1)
c = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
from_b = np.all(np.abs(c - (0.3, 0.3, 0.8)) < 0.02, axis=1) # room B's own colour
return int((inside_a & from_b).sum()), result(out)["rooms"]["B"]["walk_in_out_cameras"]
view, walk_in = from_b_in_room_a("view")
assert walk_in > 10 # recognised as walking in
assert view < 50 # practically nothing of room B inside room A
if offset >= 0.3: # close to the round, room A's cameras win anyway
nearest, _ = from_b_in_room_a("nearest")
assert nearest > 10 * max(view, 5)
@pytest.mark.xfail(strict=True, reason="known limit: walking in is only recognised up to 0.4 m from the other "
"recording's round; further away the walk-in stretch claims room A's wall")
def test_borders_with_a_walk_in_far_from_the_other_round():
out, r = combine(*two_rooms(drift_b=0.0, walk_in_offset=0.6))
assert result(out)["rooms"]["B"]["walk_in_out_cameras"] > 10
def test_rooms_from_different_sessions_are_refused():
out, r = combine(*two_rooms(b_far=True))
assert r.returncode != 0
assert "@@ error not_connected" in r.stdout
def run_combine_sh(env, *runs):
return subprocess.run([str(ROOT / "scripts" / "combine.sh"), "Tour", "Tour", *[f"{p}={p.name}" for p in runs]],
env={**os.environ, "NO_UPLOAD": "1", **env}, capture_output=True, text=True)
def test_combine_sh_reduces_for_the_frame_and_can_skip_the_full_version():
a, b = two_rooms()
r = run_combine_sh({"FRAME_SPLATS": "10000", "FULL": "0"}, a, b)
assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
calls = [c[1:] for c in tools_called() if c[0] == "splat-transform"]
tmp = str(RUNS / ".building" / "Tour")
assert calls[0] == ["-w", f"{tmp}/spirula/scene.ply", "--decimate", "10000", f"{tmp}/frame.ply"]
assert calls[1] == ["-w", f"{tmp}/frame.ply", f"{tmp}/scene.sog"]
assert len(calls) == 2 # no full version
tour = RUNS / "Tour"
assert (tour / "scene.sog").exists() and not (tour / "full").exists() and not (tour / "frame.ply").exists()
assert not (RUNS / ".building").exists()
def test_combine_sh_full_version_without_reducing_a_small_tour():
a, b = two_rooms()
r = run_combine_sh({"FRAME_SPLATS": "3500000"}, a, b)
assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
calls = [c[1:] for c in tools_called() if c[0] == "splat-transform"]
assert not any("--decimate" in c for c in calls) # fewer splats than the Frame version allows
assert (RUNS / "Tour" / "full" / "tour-full.sog").exists()
assert (RUNS / "Tour" / "full" / "tour-full.sceneinfo.png").exists()
+2 -1
View File
@@ -41,6 +41,7 @@ def test_floor_plan_is_not_mirrored():
assert cross < 0, "the floor plan is mirrored"
def test_classic_style_draws():
def test_classic_style_smoke():
"""Smoke test: the classic style draws something (its colours are per room, not checkable here)."""
img = draw("classic")
assert img.shape[0] > 500 and img.std() > 5 # not an empty picture
+2
View File
@@ -13,6 +13,8 @@ def make_flat():
def test_rooms_recorded_one_after_another_are_suggested(api):
make_flat()
# 25 minutes after the hallway: more than the 20 minutes that still count as one session
synth.make_room(RUNS, "Garden", "A", name="Garden", recording="2026-10-12_17-08-00_stereo.mp4")
status, r = api.get("/api/groups")
assert status == 200 and r["groups"] == []
assert sorted(r["suggestion"]["members"]) == ["Hallway", "Kitchen"] # not the statue from another day
+133 -3
View File
@@ -5,11 +5,9 @@ import time
import pytest
from conftest import RUNS, recording, server, wait_job
from conftest import RUNS, recording, server, wait_for_tool, wait_job
import synth
pytestmark = pytest.mark.usefixtures("ok_probe")
def convert(name="Kitchen", existing=None, quality="fast", rec=None):
rec = rec or recording()
@@ -77,7 +75,11 @@ def test_resume_after_an_interrupted_training():
assert server.resume_run("Kitchen", fresh=False) is None
js = wait_job()
assert js["result"] == "ok", js["tail"]
# the (fake) pipeline refuses --resume with an unfinished training, like the real one: it only
# worked because splat.sh started the training over
assert "Training was interrupted (last checkpoint: 18000.ckpt)" in js["tail"]
assert "Incomplete training retained" not in js["tail"]
assert not (RUNS / "Kitchen" / "spirula" / "training").exists()
def test_putting_a_tour_together():
@@ -106,3 +108,131 @@ def test_a_tour_needs_two_scenes_and_no_other_job():
server.save_group("", "Lonely", "Kitchen")
assert server.start_combine({"slug": "Lonely"})["code"] == "err.combine_too_few"
assert server.start_combine({"slug": "Lonely", "align": "magic"})["code"] == "err.bad_request"
# ---------------------------------------------------------------- checks before a conversion starts
@pytest.mark.parametrize("file,code", [
("2026-10-12_16-40-00_mono.mp4", "probe.not_stereo"),
("2026-10-12_16-40-00_nogpmd.mp4", "probe.no_gpmd"),
("2026-10-12_16-40-00_short.mp4", "probe.too_short"),
])
def test_unsuitable_recordings_are_refused(api, file, code):
rec = recording(file)
status, r = api.post("/api/start", {"path": str(rec), "name": "Kitchen", "quality": "fast", "upload": False})
assert status == 400 and r["error"]["code"] == code
assert not (RUNS / "Kitchen").exists()
def test_a_short_recording_only_warns():
p = server.probe(str(recording("2026-10-12_16-40-00_brief.mp4")))
assert p["ok"] and p["warnings"][0]["code"] == "probe.short"
@pytest.mark.parametrize("body,code", [
({"path": "/etc/passwd"}, "err.unknown_recording"), # only recordings from the known folders
({"quality": "bogus"}, "err.bad_quality"),
({"frames": "9999"}, "err.range"),
({"scale": "abc"}, "err.bad_value"),
({"name": "///"}, "err.bad_name"),
])
def test_invalid_conversions_are_refused(body, code):
full = {"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False, **body}
assert server.start_job(full)["code"] == code
assert server.load_state().get("job") is None
# ---------------------------------------------------------------- a scene that is being computed is left alone
def test_a_scene_being_computed_cannot_be_changed(api):
convert() # a finished scene ...
os.environ["FAKE_PIPELINE"] = "slow" # ... computed again (resume keeps scene.sog meanwhile)
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
"existing": "resume"}) is None
time.sleep(2)
for path, body in (("/api/delete", {"slug": "Kitchen"}), ("/api/rename", {"slug": "Kitchen", "new": "Other"}),
("/api/clean", {"slug": "Kitchen", "what": "temp"})):
status, r = api.post(path, body)
assert status == 400 and r["error"]["code"] == "err.scene_busy", path
# and no tour is put together meanwhile (one job at a time)
synth.make_room(RUNS, "Office", "A")
synth.make_room(RUNS, "Hallway", "B")
assert server.save_group("", "Home", "Office,Hallway") == "Home"
assert server.start_combine({"slug": "Home"})["code"] == "err.job_busy"
assert (RUNS / "Kitchen" / "scene.sog").exists() and (RUNS / "Kitchen.log").exists()
server.cancel_job()
wait_job(timeout=30)
# ---------------------------------------------------------------- after a power cut
def leftover_from_a_power_cut():
"""A start over that never finished: the old scene sits in .previous, a half new run in its place."""
convert(quality="fast")
old = (RUNS / "Kitchen" / "scene.sog").read_bytes()
prev = RUNS / ".previous" / "Kitchen"
prev.mkdir(parents=True)
(RUNS / "Kitchen").rename(prev / "run")
(RUNS / "Kitchen.log").rename(prev / "log")
(RUNS / "Kitchen").mkdir()
(RUNS / "Kitchen" / "half.txt").write_text("half")
meta = json.loads((RUNS / "Kitchen.ui.json").read_text())
(RUNS / "Kitchen.ui.json").write_text(json.dumps({**meta, "quality": "high", "previous": meta}))
return old
def test_the_server_puts_back_a_scene_left_after_a_power_cut():
old = leftover_from_a_power_cut()
server.restore_previous()
assert (RUNS / "Kitchen" / "scene.sog").read_bytes() == old
assert not (RUNS / "Kitchen" / "half.txt").exists()
assert (RUNS / "Kitchen.log").exists()
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["quality"] == "fast"
assert not (RUNS / ".previous").exists()
def test_start_over_after_a_power_cut_keeps_the_old_scene_if_it_fails():
old = leftover_from_a_power_cut()
os.environ["FAKE_PIPELINE"] = "fail"
js = convert(existing="fresh")
assert js["result"] == "failed"
assert (RUNS / "Kitchen" / "scene.sog").read_bytes() == old # splat.sh first put the left-over back
def test_start_over_from_the_list_of_cancelled_runs():
rec = recording()
# stopped while extracting images: what the pipeline leaves behind (state "running", half the images)
(RUNS / "Kitchen" / "dataset" / "images" / "cam0").mkdir(parents=True)
(RUNS / "Kitchen" / "dataset" / "images" / "cam0" / "0000.jpg").write_bytes(b"x")
(RUNS / "Kitchen" / "dataset" / "spirula-dataset.json").write_text('{"status": "running"}')
(RUNS / "Kitchen.log").write_text("Extraction progress: decoded 4% / saved 44 of 1000\n")
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name, "quality": "schnell"}))
assert server.resume_run("Kitchen", fresh=False)["code"] == "err.not_resumable"
assert server.resume_run("Kitchen", fresh=True) is None
js = wait_job()
assert js["result"] == "ok" and js["quality"] == "fast" # settings of the first version understood
assert "Kitchen" in {s["slug"] for s in server.scenes()}
assert "Kitchen" not in {u["slug"] for u in server.unfinished_runs()}
# ---------------------------------------------------------------- after a job: the watcher
def test_after_a_job_the_watcher_notifies_and_tidies_up():
convert()
os.environ["FAKE_PIPELINE"] = "ok"
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
"existing": "fresh"}) is None
assert server.watch_once(None) is True # running
wait_job()
assert "previous" in json.loads((RUNS / "Kitchen.ui.json").read_text())
assert server.watch_once(True) is False # it has just ended
assert "previous" not in json.loads((RUNS / "Kitchen.ui.json").read_text())
notes = wait_for_tool("notify-send")
assert notes and "Kitchen" in notes[-1][-1]
def test_after_a_failed_job_the_watcher_says_so():
os.environ["FAKE_PIPELINE"] = "fail"
convert()
server.watch_once(True)
assert wait_for_tool("notify-send")
+160
View File
@@ -0,0 +1,160 @@
"""Recordings on the PC and on the Frame: renaming, the Frame's list, download, copying scenes over."""
import json
import os
import time
from conftest import REC, RUNS, recording, server, wait_for_tool
import synth
VIDEOS = server.FRAME_VIDEOS
SCENES = server.FRAME_SCENES
# ---------------------------------------------------------------- renaming a recording on the PC
def test_renaming_a_recording_keeps_date_check_result_and_thumbnail(api):
rec = recording("2026-10-12_16-40-00_stereo.mp4")
server.CACHE.mkdir(parents=True, exist_ok=True)
check = server.check_file(rec)
check.write_text(json.dumps({"level": "warn"}))
(server.CACHE / "2026-10-12_16-40-00_stereo.thumb").write_bytes(b"thumb")
status, r = api.post("/api/rename", {"name": rec.name, "new": "Kitchen / evening"})
assert status == 200 and r["name"] == "Kitchen - evening.mp4"
new = REC / "Kitchen - evening.mp4"
assert new.exists() and not rec.exists()
f = next(x for x in server.local_files() if x["name"] == new.name)
assert (f["date"], f["label"], f["orig"]) == ("2026-10-12T16:40", "Kitchen - evening", rec.name)
assert f["check"] == "warn" # the check result moved along
assert (server.CACHE / "Kitchen - evening.thumb").read_bytes() == b"thumb"
def test_runs_follow_a_renamed_recording(api):
rec = recording()
synth.make_room(RUNS, "Kitchen", "A", recording=rec.name) # a finished scene made from it
api.post("/api/rename", {"name": rec.name, "new": "Kitchen take 1"})
meta = json.loads((RUNS / "Kitchen.ui.json").read_text())
assert meta["recording"] == "Kitchen take 1.mp4" and meta["recording_renamed"] is True
assert server.no_resume("Kitchen") == "renamed"
def test_a_recording_with_an_unfinished_run_cannot_be_renamed(api):
rec = recording()
(RUNS / "Kitchen").mkdir() # interrupted, no scene.sog
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name}))
status, r = api.post("/api/rename", {"name": rec.name, "new": "Other"})
assert status == 400 and r["error"]["code"] == "err.recording_unfinished" # the pipeline could not resume it
assert rec.exists()
def test_renaming_a_recording_to_a_taken_name_is_refused(api):
a, b = recording("2026-10-12_16-40-00_stereo.mp4"), recording("2026-10-12_16-43-00_stereo.mp4")
status, r = api.post("/api/rename", {"name": a.name, "new": b.stem})
assert status == 400 and r["error"]["code"] == "err.name_taken"
assert api.post("/api/rename", {"name": "../../etc/passwd", "new": "x"})[0] == 400
# ---------------------------------------------------------------- the Frame
def put_on_frame(home, name="2026-10-14_10-00-00_stereo.mp4", data=b"recording" * 1000):
(home / VIDEOS / name).write_bytes(data)
(home / VIDEOS / "thumbnails" / name.replace("_stereo.mp4", ".jpg")).write_bytes(b"jpegdata")
return name, data
def test_the_frames_recordings_are_listed(api, frame):
name, data = put_on_frame(frame)
(frame / SCENES / "20_Kitchen").mkdir()
status, r = api.get("/api/recordings?refresh=1")
assert status == 200 and r["frame_online"]
f = r["frame"][0]
assert (f["name"], f["size"], f["date"], f["local"]) == (name, len(data), "2026-10-14T10:00", None)
assert server.frame_listing()["scenes"] == ["20_Kitchen"]
def test_a_frame_that_is_off(api, frame):
os.environ["FAKE_FRAME_OFF"] = "1"
r = api.get("/api/recordings?refresh=1")[1]
assert r["frame_configured"] and r["frame_online"] is False and r["frame"] is None
def test_the_frames_preview_image(api, frame):
name, _ = put_on_frame(frame)
status, body = api.get(f"/api/thumb?source=frame&name={name}")
assert status == 200 and body == b"jpegdata"
def test_downloading_a_recording(api, frame):
name, data = put_on_frame(frame)
server.frame_listing(force=True)
assert api.post("/api/download", {"name": name})[0] == 200
end = time.time() + 20
while not (server.download or {}).get("finished") and time.time() < end:
time.sleep(0.1)
assert server.download["error"] is None
assert (REC / name).read_bytes() == data and not (REC / (name + ".part")).exists()
status, r = api.post("/api/download", {"name": name}) # never overwrites
assert status == 400 and r["error"]["code"] == "err.name_taken"
assert api.post("/api/download", {"name": "not-there.mp4"})[1]["error"]["code"] == "err.not_on_frame"
def wait_upload(slug):
end = time.time() + 120
while time.time() < end:
u = server.upload_status(slug)
if u and not u["running"]:
return u
time.sleep(0.2)
raise TimeoutError
def test_copying_a_scene_to_the_frame(api, frame):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
assert api.post("/api/upload", {"slug": "Kitchen"})[0] == 200
u = wait_upload("Kitchen")
assert u["ok"], u.get("error")
folder = frame / SCENES / "20_Kitchen"
assert sorted(p.name for p in folder.iterdir()) == [
"kitchen.gallery.png", "kitchen.sceneinfo.png", "kitchen.sog", "kitchen.xrSceneTransform.json"]
assert (frame / "Documents" / "GaussianSplats" / "20_Kitchen" / "kitchen.sog").exists() # backup copy
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "20_Kitchen"
assert wait_for_tool("notify-send") # sent in the background
assert {s["slug"]: s["on_frame"] for s in server.scenes()}["Kitchen"] is True
def test_a_renamed_scene_stays_in_its_frame_folder(api, frame):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
api.post("/api/upload", {"slug": "Kitchen"})
wait_upload("Kitchen")
api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})
assert api.post("/api/upload", {"slug": "Kueche"})[0] == 200
assert wait_upload("Kueche")["ok"]
scenes = sorted(p.name for p in (frame / SCENES).iterdir())
assert scenes == ["20_Kitchen"] # no second copy
assert sorted(p.name for p in (frame / SCENES / "20_Kitchen").iterdir()) == [
"kueche.gallery.png", "kueche.sceneinfo.png", "kueche.sog", "kueche.xrSceneTransform.json"]
def test_a_broken_download_leaves_nothing_behind(api, frame):
name, data = put_on_frame(frame)
server.frame_listing(force=True)
os.environ["FAKE_FRAME_CUT"] = "1" # the connection breaks halfway
assert api.post("/api/download", {"name": name})[0] == 200
end = time.time() + 20
while not (server.download or {}).get("finished") and time.time() < end:
time.sleep(0.1)
assert server.download["error"]
assert not (REC / name).exists() and not (REC / (name + ".part")).exists()
def test_a_scene_renamed_before_its_frame_folder_was_remembered(api, frame):
"""Copied to the Frame by an older version (no frame_folder in its settings): renaming finds the
folder on the Frame, so copying again does not create a second one."""
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
(frame / SCENES / "20_Kitchen").mkdir()
(frame / SCENES / "20_Kitchen" / "kitchen.sog").write_bytes(b"old")
server.frame_listing(force=True)
assert api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})[0] == 200
assert json.loads((RUNS / "Kueche.ui.json").read_text())["frame_folder"] == "20_Kitchen"
api.post("/api/upload", {"slug": "Kueche"})
assert wait_upload("Kueche")["ok"]
assert sorted(p.name for p in (frame / SCENES).iterdir()) == ["20_Kitchen"]
+62 -8
View File
@@ -1,7 +1,7 @@
"""Who may talk to the server: only this PC (or the home network in LAN mode), never other websites."""
import pytest
from conftest import RUNS, server
from conftest import RUNS, SHUTDOWNS, server
import synth
@@ -62,23 +62,59 @@ def test_lan_mode_checks_the_real_client_address(monkeypatch, lan, client, ok):
(b"{not json", {}),
(b"", {"Content-Length": "-5"}),
(b"", {"Content-Length": str(10 ** 9)}),
(b'{"slug": "x", "full": {"a": 1}}', {}), # nested value in a field that is not text
])
def test_invalid_requests_get_400_not_a_crash(api, body, headers):
assert api.post("/api/rename", body, headers)[0] == 400
assert api.get("/api/ping")[0] == 200 # still alive
@pytest.mark.parametrize("slug", ["../runs", "..", "/etc", ".previous", "a/../../b"])
def test_no_paths_outside_the_runs_folder(api, slug):
@pytest.fixture
def leak():
"""Something that looks like a scene one level above the runs folder – it must never be reachable."""
above = RUNS.parent
(above / "spirula").mkdir(exist_ok=True)
(above / "spirula" / "scene.ply").write_bytes(b"ply\n")
(above / "leak.gallery.png").write_bytes(b"secret")
(above / "scene.sog").write_bytes(b"secret")
yield
for f in ("leak.gallery.png", "scene.sog"):
(above / f).unlink(missing_ok=True)
(above / "spirula" / "scene.ply").unlink(missing_ok=True)
(above / "spirula").rmdir()
@pytest.mark.parametrize("slug", ["..", "../runs", "/etc", ".previous", "a/../../b", "."])
def test_no_paths_outside_the_runs_folder(api, leak, slug):
synth.make_room(RUNS, "Room", "A")
assert api.get(f"/api/preview?slug={slug}")[0] == 404
assert api.post("/api/delete", {"slug": slug})[0] == 400
assert api.post("/api/clean", {"slug": slug, "what": "temp"})[0] == 400
assert (RUNS / "Room" / "scene.sog").exists()
assert server.scene_dir(slug) is None
status, body = api.get(f"/api/preview?slug={slug}")
assert status == 404 and body != b"secret"
assert api.get(f"/api/floorplan?slugs={slug}")[0] == 400
for path, body in (("/api/delete", {"slug": slug}), ("/api/clean", {"slug": slug, "what": "temp"}),
("/api/rename", {"slug": slug, "new": "x"}), ("/api/upload", {"slug": slug}),
("/api/combine", {"slug": slug})):
assert api.post(path, body)[0] == 400, path
assert (RUNS / "Room" / "scene.sog").exists() and (RUNS.parent / "leak.gallery.png").exists()
def test_actions_on_the_screen_only_from_this_pc():
@pytest.mark.parametrize("path,body", [
("/api/open", {"slug": "Room", "what": "folder"}), ("/api/play", {"name": "x.mp4"}),
("/api/pick", {}), ("/api/shutdown", {}),
])
def test_actions_on_the_screen_only_from_this_pc(api, monkeypatch, path, body):
# video player, file dialog, folder, Spirula and quitting: not from another device in the LAN
monkeypatch.setattr(server.Handler, "_from_this_pc", lambda self: False)
started = []
monkeypatch.setattr(server.subprocess, "Popen", lambda *a, **k: started.append(a))
monkeypatch.setattr(server, "pick_file", lambda: started.append("file dialog"))
synth.make_room(RUNS, "Room", "A")
status, r = api.post(path, body)
assert status == 403 and r["error"]["code"] == "err.pc_only"
assert started == [] and SHUTDOWNS == [] # and nothing happened
def test_from_this_pc_means_loopback():
class Remote(server.Handler):
def __init__(self):
self.client_address = ("192.168.1.20", 50000)
@@ -89,7 +125,25 @@ def test_actions_on_the_screen_only_from_this_pc():
assert not Remote()._from_this_pc() and Local()._from_this_pc()
ENDPOINTS = ["/api/start", "/api/rename", "/api/clean", "/api/delete", "/api/upload", "/api/download", "/api/check",
"/api/group", "/api/group/dismiss", "/api/combine", "/api/resume"]
@pytest.mark.parametrize("path", ENDPOINTS)
@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality"])
def test_wrongly_typed_fields_get_400_everywhere(api, path, field):
for bad in (5, ["a"], True):
assert api.post(path, {field: bad})[0] == 400, (path, field, bad)
def test_unknown_thumbnail_names_leave_no_cache_files(api):
status, _ = api.get("/api/thumb?source=frame&name=whatever.mp4")
assert status == 404
assert not list(server.CACHE.glob("whatever*"))
def test_nested_values_are_refused_in_any_field(api):
# a request that would work, apart from one nested value in a field that is not text
synth.make_room(RUNS, "Room", "A")
assert api.post("/api/rename", {"slug": "Room", "new": "Other", "update_frame": {"a": 1}})[0] == 400
assert (RUNS / "Room").is_dir() and not (RUNS / "Other").exists()