Files
thunderheronight--splat-wor…/splat_workshop/server.py
T
thunderheronightandClaude Opus 5.5 a8a6aa5188 0.2.1: automated tests and GitHub Actions; a cancelled job only ends with its last process
57 tests without GPU, recordings or Frame: fake pipeline, fake splat-transform and trash, and
synthetic rooms with a known error for putting rooms together. Found on the way: after cancelling,
the job counted as finished when systemd-inhibit ended, while splat.sh was still putting the
previous scene back – pid_alive now looks at the whole process group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:22:30 +02:00

1615 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Splat Workshop: local web interface for scripts/splat.sh.
Starts splat.sh as its own process group (it keeps running when the browser – or this server –
is closed), follows its output and shows phase, progress and result. Python standard library only.
Listens on 127.0.0.1, or in the home network when [server] lan = true (only private addresses).
Without the tray icon (--no-idle-exit) it stops after idle_minutes with nothing running and no page open.
Start: ./splat-workshop (or python3 -m splat_workshop.server)
"""
import argparse
import functools
import hashlib
import http.server
import ipaddress
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import threading
import time
import traceback
import urllib.parse
from pathlib import Path
from . import check, config
from .i18n import T, detect
VERSION = "0.2.1"
CFG = config.load()
ROOT = config.ROOT
WEB = ROOT / "web"
SCRIPTS = ROOT / "scripts"
REC = CFG.recordings
RUNS = CFG.runs
STATE_DIR = CFG.state_dir
JOBS = STATE_DIR / "jobs"
CACHE = STATE_DIR / "cache"
STATE = STATE_DIR / "state.json"
FRAME = CFG.frame_host
FRAME_VIDEOS = "Videos/ArcturusVisionCamera"
FRAME_SCENES = ".local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes"
SSH = ["ssh", "-o", "ConnectTimeout=4", "-o", "BatchMode=yes", FRAME]
HOSTNAME = socket.gethostname().lower()
# Names the server may be reached under (protects against DNS rebinding); IP addresses always work
HOST_NAMES = {"localhost", HOSTNAME} | {f"{HOSTNAME}.{d}" for d in ("local", "lan", "home", "fritz.box")}
QUALITIES = ("fast", "high", "max")
t = T(detect(CFG.language))
lock = threading.Lock()
meta_lock = threading.Lock() # read-modify-write of the *.ui.json files
plan_slots = threading.BoundedSemaphore(2) # floor plans drawn at the same time
last_request = time.time()
idle_exit = True
proc = None # Popen of the running splat.sh (if started by this server)
download = None # {"name", "total", "done", "error", "finished"}
uploads = {} # slug -> {"proc", "out", "started", "notified"}
frame_cache = {"t": 0, "data": None}
probe_cache = {}
picked = set() # recordings chosen in the file dialog outside the usual folders
# ---------------------------------------------------------------- helpers
def lan_ip():
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("192.0.2.1", 9)) # routing lookup only, nothing is sent
return s.getsockname()[0]
except OSError:
return None
def host_allowed(host: str) -> bool:
host = host.strip("[]").lower()
try:
ip = ipaddress.ip_address(host)
return ip.is_loopback or (CFG.lan and ip.is_private)
except ValueError:
return host == "localhost" or (CFG.lan and host in HOST_NAMES)
def pretty_date(stem: str) -> str:
"""ISO date and time from a recording name (the browser formats it)."""
m = re.match(r"(\d{4})-(\d\d)-(\d\d)_(\d\d)-(\d\d)", stem)
return f"{m[1]}-{m[2]}-{m[3]}T{m[4]}:{m[5]}" if m else ""
def notify(title: str, body: str) -> None:
if shutil.which("notify-send"):
subprocess.Popen(["notify-send", "-a", t("app"), "-i", "applications-graphics", title, body],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@functools.cache
def can_inhibit() -> bool:
"""systemd-inhibit exists and logind accepts the lock (not the case in some containers)."""
try:
return subprocess.run(["systemd-inhibit", "--what=sleep", "--why=test", "true"], timeout=5,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def keep_awake(cmd: list, why: str) -> list:
"""Run cmd with a sleep inhibitor, so the PC does not suspend in the middle of it.
The lock belongs to the child process: it lasts exactly as long as the job, even if this server stops."""
if not can_inhibit():
return cmd
return ["systemd-inhibit", "--what=sleep", f"--who={t('app')}", f"--why={why}", "--mode=block", *cmd]
def load_state() -> dict:
try:
return json.loads(STATE.read_text())
except (OSError, ValueError):
return {}
def write_json(path: Path, data, indent=None) -> None:
"""Write via a temporary file, so a reader never sees half a file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.{threading.get_ident()}.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=indent))
tmp.replace(path)
def save_state(st: dict) -> None:
write_json(STATE, st, indent=1)
def pid_alive(pid: int) -> bool:
"""Is the job started as `pid` still running? The job is its whole process group: the first process
(systemd-inhibit, if the PC is kept awake) can end before splat.sh has finished cleaning up."""
if proc is not None and proc.pid == pid:
proc.poll() # reap it, so a finished first process does not count
try:
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
if cmd:
return "splat.sh" in cmd or "combine.sh" in cmd
except OSError:
pass
if not pid:
return False
try:
os.killpg(pid, 0) # the first process is gone, are others of the job left?
return True
except (ProcessLookupError, PermissionError):
return False
def shq(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def err(code: str, **params) -> dict:
"""Error for the web interface: a key from web/i18n.js plus parameters."""
return {"code": code, "params": params}
# ---------------------------------------------------------------- recordings
def frame_listing(force=False):
"""Recordings on the Frame (cached). None = no Frame configured or not reachable."""
if not FRAME:
return None
if not force and time.time() - frame_cache["t"] < 20:
return frame_cache["data"]
cmd = (f'(cd {FRAME_VIDEOS} 2>/dev/null && stat -c "%n|%s|%Y" *_stereo.mp4 2>/dev/null); echo ---; '
f'ls {FRAME_VIDEOS}/thumbnails 2>/dev/null; echo ---; ls {FRAME_SCENES} 2>/dev/null')
try:
r = subprocess.run(SSH + [cmd], capture_output=True, text=True, timeout=10)
except subprocess.TimeoutExpired:
r = None
if r is None or r.returncode == 255:
data = None
else:
files, _, rest = r.stdout.partition("---\n")
thumbs, _, scenes_ = rest.partition("---\n")
data = {"files": [], "thumbs": thumbs.split(), "scenes": scenes_.split()}
for line in files.splitlines():
parts = line.rsplit("|", 2)
if len(parts) == 3:
data["files"].append({"name": parts[0], "size": int(parts[1]), "mtime": int(parts[2])})
frame_cache.update(t=time.time(), data=data)
return data
RECMETA = STATE_DIR / "recordings.json" # renamed recordings: {current name: {"orig": name on the Frame}}
def rec_meta() -> dict:
try:
return json.loads(RECMETA.read_text())
except (OSError, ValueError):
return {}
def local_files():
seen, out = set(), []
renamed = rec_meta()
cands = list(REC.glob("*.mp4")) if REC.is_dir() else []
for d in CFG.extra_recording_dirs:
if d.is_dir():
cands += d.glob("*_stereo.mp4")
for p in sorted(cands, key=lambda p: p.stat().st_mtime, reverse=True):
if p.name in seen:
continue
seen.add(p.name)
st = p.stat()
cr = check_cached(p)
orig = renamed.get(p.name, {}).get("orig", p.name)
out.append({"name": p.name, "path": str(p), "size": st.st_size, "mtime": int(st.st_mtime),
"date": pretty_date(Path(orig).stem), "orig": orig,
"label": p.name.removesuffix(".mp4") if orig != p.name else None,
"renamable": p.parent.resolve() == REC.resolve(), "check": cr["level"] if cr else None})
return out
def local_path(name: str):
"""Path of a recording on this PC – only from the known list, never taken from the browser."""
return next((f["path"] for f in local_files() if f["name"] == name),
next((p for p in picked if Path(p).name == name and Path(p).is_file()), None))
def recordings():
fr = frame_listing()
local = local_files()
local_names = {f["orig"]: f["path"] for f in local}
frame = None
if fr is not None:
frame = [{**f, "date": pretty_date(f["name"]), "local": local_names.get(f["name"])}
for f in sorted(fr["files"], key=lambda f: f["name"], reverse=True)]
return {"frame_configured": bool(FRAME), "frame_online": fr is not None, "frame": frame, "local": local}
def probe(path: str) -> dict:
p = Path(path)
if not p.is_file():
return {"ok": False, "errors": [err("probe.missing")], "warnings": []}
key = (path, p.stat().st_mtime)
if key in probe_cache:
return probe_cache[key]
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
"stream=codec_type,codec_tag_string,width,height,avg_frame_rate,color_transfer:format=duration",
"-of", "json", path], capture_output=True, text=True)
try:
info = json.loads(r.stdout)
except ValueError:
return {"ok": False, "errors": [err("probe.unreadable")], "warnings": []}
streams = info.get("streams", [])
videos = [s for s in streams if s.get("codec_type") == "video"]
gpmd = any(s.get("codec_tag_string") == "gpmd" for s in streams)
dur = float(info.get("format", {}).get("duration", 0) or 0)
fps = 0.0
if videos:
n, _, d = videos[0].get("avg_frame_rate", "0/1").partition("/")
fps = float(n) / float(d or 1) if float(d or 1) else 0.0
hdr = bool(videos) and videos[0].get("color_transfer") in ("arib-std-b67", "smpte2084")
errors, warnings = [], []
if len(videos) < 2:
errors.append(err("probe.not_stereo"))
if not gpmd:
errors.append(err("probe.no_gpmd"))
if dur and dur < 8:
errors.append(err("probe.too_short", secs=round(dur)))
elif dur and dur < 20:
warnings.append(err("probe.short", secs=round(dur)))
res = {"ok": not errors, "errors": errors, "warnings": warnings, "duration": dur,
"fps": round(fps, 1), "hdr": hdr,
"size": f"{videos[0]['width']}×{videos[0]['height']}" if videos else ""}
probe_cache[key] = res
return res
def thumbnail(source: str, name: str):
"""Preview image as PNG bytes (cached) or None."""
CACHE.mkdir(parents=True, exist_ok=True)
stem = Path(name).stem
cached = CACHE / f"{stem}.thumb"
if cached.exists() and cached.stat().st_size:
return cached.read_bytes()
local = local_path(name)
if local:
dur = probe(local).get("duration") or 6
try:
r = subprocess.run(["ffmpeg", "-v", "error", "-ss", f"{dur / 3:.2f}", "-i", local, "-map", "0:v:0",
"-frames:v", "1", "-vf", "scale=360:-1,eq=gamma=1.3,format=rgb24",
"-f", "image2pipe", "-c:v", "png", "-"], capture_output=True, timeout=30)
except subprocess.TimeoutExpired:
return None
if r.stdout:
cached.write_bytes(r.stdout) # don't cache failures
return r.stdout or None
if source != "frame":
return None
# No preview on the Frame: remember the empty result for 10 min only, then try again
if cached.exists() and time.time() - cached.stat().st_mtime < 600:
return None
fr = frame_listing()
if fr is None or not any(f["name"] == name for f in fr["files"]):
return None
data = b""
match = next((x for x in fr["thumbs"] if stem in x or stem.removesuffix("_stereo") in x), None)
if match:
try:
r = subprocess.run(SSH + [f"cat {FRAME_VIDEOS}/thumbnails/{shq(match)}"], capture_output=True, timeout=20)
except subprocess.TimeoutExpired:
return None # try again next time
data = r.stdout if r.returncode == 0 else b""
cached.write_bytes(data)
return data or None
# ---------------------------------------------------------------- recording check (check.py)
checks = {} # name -> {"state": queued|running|done|error, "done", "total", "error"}
check_queue = []
CHECK_VERSION = 1 # bump when the result format changes; older results are measured again
def check_file(path: Path) -> Path:
st = path.stat()
return CACHE / f"{path.stem}.{st.st_size}-{int(st.st_mtime)}.v{CHECK_VERSION}.check.json"
def check_cached(path: Path):
f = check_file(path)
try:
return json.loads(f.read_text()) if f.exists() else None
except ValueError:
return None
def check_status(name: str) -> dict:
local = local_path(name)
if not local:
return {"state": "missing"}
res = check_cached(Path(local))
if res:
return {"state": "done", "result": res}
with lock:
c = checks.get(name)
return dict(c) if c else {"state": "none"}
def check_busy() -> bool:
return bool(check_queue) or any(c["state"] == "running" for c in checks.values())
def start_check(name: str):
local = local_path(name)
if not local:
return err("err.not_local")
with lock:
if checks.get(name, {}).get("state") in ("queued", "running"):
return None
dur = probe(local).get("duration") or 0
checks[name] = {"state": "queued", "done": 0, "total": round(dur), "error": None}
check_queue.append((name, local, dur))
if len(check_queue) == 1 and not any(c["state"] == "running" for c in checks.values()):
threading.Thread(target=check_worker, daemon=True).start()
return None
def check_worker():
while True:
with lock:
if not check_queue:
return
name, local, dur = check_queue.pop(0)
checks[name]["state"] = "running"
def progress(i, name=name):
checks[name]["done"] = i
try:
rows = check.measure(local, dur, progress)
res = check.evaluate(rows, dur)
res["measured"] = time.time()
CACHE.mkdir(parents=True, exist_ok=True)
for old in CACHE.glob(f"{Path(local).stem}.*.v{CHECK_VERSION}.check.json"):
old.unlink()
check_file(Path(local)).write_text(json.dumps(res, ensure_ascii=False))
checks[name]["state"] = "done"
except Exception as e: # noqa: BLE001 – shown in the interface
checks[name].update(state="error", error=str(e))
# ---------------------------------------------------------------- download from the Frame
def start_download(name: str, size: int):
global download
with lock:
if download and not download.get("finished"):
return err("err.download_busy")
if (REC / name).exists():
return err("err.name_taken")
download = {"name": name, "total": size, "done": 0, "error": None, "finished": False}
def run():
REC.mkdir(parents=True, exist_ok=True)
part = REC / (name + ".part")
try:
with open(part, "wb") as fh:
p = subprocess.Popen(keep_awake(SSH + [f"cat {FRAME_VIDEOS}/{shq(name)}"], t("inhibit.download")),
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
while chunk := p.stdout.read(1 << 20):
fh.write(chunk)
download["done"] += len(chunk)
rc = p.wait()
if rc != 0 or download["done"] != size:
raise RuntimeError("transfer interrupted")
part.rename(REC / name)
frame_cache["t"] = 0
except Exception as e: # noqa: BLE001 – shown in the interface
download["error"] = str(e)
part.unlink(missing_ok=True)
download["finished"] = True
threading.Thread(target=run, daemon=True).start()
return None
# ---------------------------------------------------------------- conversion
RX_EXTRACT = re.compile(r"Extraction progress: decoded (\d+)%")
RX_STEP = re.compile(r"(?:step|Schritt)\s+(\d+)/(\d+)\s+\(\s*(\d+)%\)\s+(?:splats|Splats)\s+(\d+)\s+"
r"\[(?:elapsed|Laufzeit) ([\d:.]+) \| (?:ETA|Restzeit) ([\d:.]+)\].*?psnr=([\d.]+)")
RX_ALIGN = re.compile(r"Aligned (\d+) cameras .*?RMS ([\d.]+) m")
RX_TOTAL = re.compile(r"Extracting (\d+) images")
RX_GAUSS = re.compile(r"spirula: ([\d,]+) Gaussians")
RX_DONE = re.compile(r"^@@ done (frame|local)(?: (\S+))?", re.M)
def hms(s: str) -> float:
h, m, sec = s.split(":")
return int(h) * 3600 + int(m) * 60 + float(sec)
RX_CPHASE = re.compile(r"^@@ cphase (\d)", re.M)
RX_CPCT = re.compile(r"^@@ pct (\d+)", re.M)
RX_CTOTAL = re.compile(r"^@@ total (\d+)", re.M)
def parse_combine(text: str) -> dict:
"""Progress of combine.sh: phases 0 align, 1 assemble, 2 reduce, 3 convert, 4 copy to the Frame."""
last = list(RX_CPHASE.finditer(text))[-1]
phase, rest = int(last[1]), text[last.end():]
pcts = RX_CPCT.findall(rest)
pct = int(pcts[-1]) if pcts else None
info = {}
if tot := RX_CTOTAL.findall(text):
info["splats"] = int(tot[-1])
if "@@ phase upload" in rest:
phase, pct = 4, None
if d := RX_DONE.search(text):
info["done"] = True
if d[1] == "frame" and d[2]:
info["folder"] = d[2]
return {"phase": phase, "pct": pct, "eta": None, "info": info}
def parse_output(text: str) -> dict:
text = text.replace("\r", "\n")
if "@@ cphase" in text:
return parse_combine(text)
phase, pct, eta = 0, None, None
info = {}
m = None
for m in RX_EXTRACT.finditer(text):
pass
if m:
pct = int(m[1])
if "Running native Spirula SfM" in text:
phase, pct = 1, None
if a := RX_ALIGN.search(text):
info["cameras"] = int(a[1])
info["rms_cm"] = round(float(a[2]) * 100, 1)
if tot := RX_TOTAL.search(text):
info["images"] = int(tot[1])
steps = RX_STEP.findall(text)
if steps:
phase = 2
last = steps[-1]
pct = int(last[2])
eta = hms(last[5])
info.update(step=int(last[0]), steps=int(last[1]), splats=int(last[3]), elapsed=hms(last[4]))
psnr = [float(s[6]) for s in steps]
k = max(1, len(psnr) // 120) # smoothed curve, at most 120 points
info["psnr_curve"] = [round(sum(psnr[i:i + k]) / len(psnr[i:i + k]), 2) for i in range(0, len(psnr), k)]
tail = psnr[-10:]
info["psnr"] = round(sum(tail) / len(tail), 1)
if g := RX_GAUSS.search(text):
info["gaussians"] = int(g[1].replace(",", ""))
if "splat-transform" in text:
phase, pct, eta = 3, None, None
if "@@ phase upload" in text:
phase, pct = 4, None
if d := RX_DONE.search(text):
info["done"] = True
if d[1] == "frame" and d[2]:
info["folder"] = d[2]
return {"phase": phase, "pct": pct, "eta": eta, "info": info}
def job_status(tail=True) -> dict | None:
st = load_state().get("job")
if not st:
return None
out = Path(st["out"])
text = out.read_text(errors="replace") if out.exists() else ""
running = pid_alive(st["pid"])
parsed = parse_output(text)
res = {**st, **parsed, "running": running}
if tail:
res["tail"] = "\n".join(text.replace("\r", "\n").splitlines()[-200:])
if not running:
if parsed["info"].get("done"):
res["result"] = "ok"
elif st.get("cancelled"):
res["result"] = "cancelled"
else:
res["result"] = "failed"
return res
def start_job(p: dict):
global proc
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
return err("err.job_busy")
path, name = p.get("path", ""), (p.get("name") or "").strip()
if local_path(Path(path).name) != path:
return err("err.unknown_recording")
if not (pr := probe(path))["ok"]:
return pr["errors"][0]
slug = config.slugify(name)
if not slug or slug.startswith("."):
return err("err.bad_name")
env = {**os.environ, "QUALITY": p.get("quality", "high")}
if env["QUALITY"] not in QUALITIES:
return err("err.bad_quality")
for key, lo, hi, typ in (("FRAMES", 100, 500, int), ("SCALE", 0.25, 1, float),
("MAXG", 200_000, 6_000_000, int), ("ITER", 1000, 100_000, int)):
v = p.get(key.lower())
if v not in (None, ""):
try:
v = typ(v)
except ValueError:
return err("err.bad_value", key=key)
if not lo <= v <= hi:
return err("err.range", key=key, lo=lo, hi=hi)
env[key] = str(v)
if not p.get("upload", True) or not FRAME:
env["NO_UPLOAD"] = "1"
args = []
previous = None
if (RUNS / slug).exists():
if p.get("existing") == "resume" and no_resume(slug):
return "exists" # training images were deleted: only a fresh start works
if p.get("existing") == "resume":
args.append("--resume")
elif p.get("existing") == "fresh":
env["FRESH"] = "1"
# splat.sh keeps the old result until the new one is done and puts it back otherwise,
# including these settings
if (RUNS / slug / "scene.sog").exists():
previous = {k: v for k, v in read_meta(slug).items() if k != "previous"}
else:
return "exists"
JOBS.mkdir(parents=True, exist_ok=True)
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_{slug}.out"
with open(out, "wb") as fh:
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "splat.sh"), path, name, *args],
t("inhibit.job", name=name)), cwd=ROOT, env=env, stdout=fh,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
st = load_state()
st["job"] = {"pid": proc.pid, "out": str(out), "name": name, "slug": slug, "recording": Path(path).name,
"quality": env["QUALITY"], "upload": "NO_UPLOAD" not in env, "started": time.time(),
"overrides": {k: env[k] for k in ("FRAMES", "SCALE", "MAXG", "ITER") if k in env}}
save_state(st)
RUNS.mkdir(parents=True, exist_ok=True)
write_meta(slug, name=name, recording=Path(path).name, quality=env["QUALITY"],
**({} if "--resume" in args else {"cleaned": [], "recording_renamed": False}),
**({"previous": previous} if previous is not None else {}))
return None
def cancel_job():
st = load_state()
job = st.get("job")
if not job or not pid_alive(job["pid"]):
return err("err.no_job")
job["cancelled"] = True
save_state(st)
try:
os.killpg(job["pid"], signal.SIGTERM)
except ProcessLookupError:
pass
def kill_later(pid):
time.sleep(8)
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
threading.Thread(target=kill_later, args=(job["pid"],), daemon=True).start()
return None
def activity() -> dict:
"""Compact summary for the tray icon."""
js = job_status(tail=False)
job = None
if js and js["running"]:
job = {k: js.get(k) for k in ("name", "phase", "pct", "eta", "upload")}
dl = download if download and not download.get("finished") else None
return {"job": job, "download": {"pct": round(100 * dl["done"] / max(dl["total"], 1))} if dl else None,
"checking": check_busy(), "uploading": any(u["proc"].poll() is None for u in uploads.values())}
def watcher():
"""Notification when a run ends; 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
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:
os._exit(0)
# ---------------------------------------------------------------- copy to the Frame
def start_upload(slug: str):
if not FRAME:
return err("err.no_frame")
d = scene_dir(slug)
if not d or not (d / "scene.sog").exists():
return err("err.no_scene")
with lock:
u = uploads.get(slug)
if u and u["proc"].poll() is None:
return err("err.upload_busy")
meta = read_meta(slug)
env = {**os.environ}
for key, var in (("frame_folder", "TARGET"), ("stem", "STEM")):
if meta.get(key):
env[var] = meta[key]
if meta.get("log"):
env["LOG"] = str(RUNS / Path(meta["log"]).name)
JOBS.mkdir(parents=True, exist_ok=True)
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_to-frame_{slug}.out"
with open(out, "wb") as fh:
p = subprocess.Popen(keep_awake([str(SCRIPTS / "to-frame.sh"), str(d), meta.get("name") or slug],
t("inhibit.upload", name=meta.get("name") or slug)), cwd=ROOT, env=env,
stdout=fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
start_new_session=True)
uploads[slug] = {"proc": p, "out": out, "started": time.time(), "notified": False}
return None
def upload_status(slug: str):
u = uploads.get(slug)
if not u:
return None
rc = u["proc"].poll()
text = u["out"].read_text(errors="replace") if u["out"].exists() else ""
res = {"running": rc is None, "ok": rc == 0}
if rc is not None and not u["notified"]:
u["notified"] = True
name = read_meta(slug).get("name") or slug
if rc == 0:
if (d := RX_DONE.search(text)) and d[2]:
write_meta(slug, frame_folder=d[2])
frame_cache["t"] = 0
notify(t("notify.upload.title"), t("notify.upload", name=name))
else:
notify(t("notify.upload_failed.title"), t("notify.upload_failed", name=name))
if rc not in (None, 0):
res["error"] = "\n".join(text.strip().splitlines()[-3:]) or "error"
return res
# ---------------------------------------------------------------- scenes
def meta_file(slug: str) -> Path:
return RUNS / f"{slug}.ui.json"
def read_meta(slug: str) -> dict:
try:
return json.loads(meta_file(slug).read_text())
except (OSError, ValueError):
return {}
def write_meta(slug: str, **kw) -> None:
with meta_lock:
meta = read_meta(slug)
meta.update(kw)
write_json(meta_file(slug), meta)
PREVIOUS = RUNS / ".previous" # splat.sh keeps a scene here while it is computed again (FRESH)
def restore_previous():
"""splat.sh puts a replaced scene back itself when the new run fails. If it could not
(killed hard after cancelling, power cut), do it here once no run of that scene is going."""
if not PREVIOUS.is_dir():
return
with lock: # start_job holds it until the new job is recorded
job = load_state().get("job") or {}
busy = job.get("slug") if pid_alive(job.get("pid", 0)) else None
for prev in PREVIOUS.iterdir():
slug, run, out = prev.name, prev / "run", RUNS / prev.name
if slug == busy:
continue
if run.is_dir() and not (out / "scene.sog").exists():
if out.exists():
shutil.rmtree(prev / "failed", ignore_errors=True)
out.rename(prev / "failed")
run.rename(out)
if (prev / "log").exists():
(prev / "log").replace(RUNS / f"{slug}.log")
with meta_lock:
meta = read_meta(slug)
if isinstance(meta.get("previous"), dict):
write_json(meta_file(slug), meta["previous"])
shutil.rmtree(prev, ignore_errors=True)
try:
PREVIOUS.rmdir()
except OSError:
pass
def scenes():
fr = frame_listing()
out = []
if not RUNS.is_dir():
return out
found = [(d, False) for d in RUNS.glob("*/") if (d / "scene.sog").exists() and not is_group(read_meta(d.name))]
# A scene that is being computed again stays in the list (without actions) until it is replaced
found += [(d, True) for d in PREVIOUS.glob("*/run/")
if (d / "scene.sog").exists() and not (RUNS / d.parent.name / "scene.sog").exists()]
for d, replacing in sorted(found, key=lambda x: x[0].stat().st_mtime, reverse=True):
sog = d / "scene.sog"
slug = d.parent.name if replacing else d.name
meta = read_meta(slug)
if replacing and isinstance(meta.get("previous"), dict):
meta = meta["previous"]
on_frame = None
if fr is not None:
folder = meta.get("frame_folder")
on_frame = (folder in fr["scenes"]) if folder else any(
re.fullmatch(r"\d\d_" + re.escape(slug), s) for s in fr["scenes"])
gallery = next(d.glob("*.gallery.png"), None)
gauss = None
for log in (RUNS / f"{slug}.log", RUNS / f"{slug}.out"):
if log.exists() and (g := RX_GAUSS.search(log.read_text(errors="replace"))):
gauss = int(g[1].replace(",", ""))
break
out.append({"slug": slug, "name": meta.get("name") or slug, "quality": meta.get("quality"),
"recording": meta.get("recording"), "size": sog.stat().st_size,
"mtime": int(sog.stat().st_mtime), "gaussians": gauss, "preview": bool(gallery),
"ply": (d / "spirula" / "scene.ply").exists(), "on_frame": on_frame, "disk": disk_usage(d),
"upload": upload_status(slug), "replacing": replacing})
return out
# ---------------------------------------------------------------- floor plans (scripts/floorplan.py)
plan_locks = {} # cache key -> Lock, so one plan is drawn only once at a time
def floorplan(slugs: list[str], style: str, lang: str, title: str):
"""(PNG bytes, info dict with pixels_per_metre) of one or more scenes (cached), or an error dict."""
if style not in ("pretty", "classic"):
style = "pretty"
runs = []
for slug in dict.fromkeys(slugs):
d = scene_dir(slug)
ply = d / "spirula" / "scene.ply" if d else None
if not ply or not ply.exists():
return err("err.no_scene")
runs.append((d, read_meta(slug).get("name") or slug, ply.stat().st_mtime))
if not runs or len(runs) > 12:
return err("err.bad_request")
title = title.strip()[:60]
script = (SCRIPTS / "floorplan.py").stat().st_mtime # a new version of the script draws anew
key = hashlib.sha1(json.dumps([[str(r[0]), r[1], r[2]] for r in runs] + [style, lang, title, script]).encode()).hexdigest()[:16]
out = CACHE / f"plan-{key}.png"
meta = out.with_suffix(".json")
with lock:
plan_lock = plan_locks.setdefault(key, threading.Lock())
with plan_lock:
if not out.exists() or not meta.exists():
CACHE.mkdir(parents=True, exist_ok=True)
cmd = ["uv", "run", "-q", str(SCRIPTS / "floorplan.py"), str(out.with_suffix(".tmp.png")),
*[f"{d}={name}" for d, name, _ in runs], "--style", style]
if title:
cmd += ["--title", title]
if lang == "de":
cmd.append("--comma")
if not plan_slots.acquire(timeout=1):
return err("err.plan_busy")
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
except subprocess.TimeoutExpired:
return err("err.plan_failed", detail="timeout")
finally:
plan_slots.release()
if r.returncode != 0 or not out.with_suffix(".tmp.png").exists():
return err("err.plan_failed", detail=(r.stderr or r.stdout).strip().splitlines()[-1:] or "")
meta.write_text(r.stdout.strip().splitlines()[-1])
out.with_suffix(".tmp.png").replace(out)
try:
info = json.loads(meta.read_text())
except ValueError:
info = {}
return out.read_bytes(), info
def scene_dir(slug: str) -> Path | None:
d = (RUNS / slug).resolve()
return d if d.parent == RUNS.resolve() and d.is_dir() else None
# ---------------------------------------------------------------- renaming and cleaning up
# What a finished scene can do without: intermediate state (lossless) and the training images (a new
# training then has to start from the recording again). Scene, floor plan and Spirula viewer keep working.
CLEANUP = {"temp": ("spirula/training", "dataset/.spirula-sfm"), "images": ("dataset/images",)}
usage_cache = {} # run folder -> (time, sizes)
def dir_size(p: Path) -> int:
total = 0
try:
for e in os.scandir(p):
if e.is_dir(follow_symlinks=False):
total += dir_size(Path(e.path))
elif e.is_file(follow_symlinks=False):
total += e.stat(follow_symlinks=False).st_blocks * 512
except OSError:
pass
return total
def disk_usage(d: Path) -> dict:
hit = usage_cache.get(d)
if hit and time.time() - hit[0] < 60:
return hit[1]
sizes = {k: sum(dir_size(d / rel) for rel in rels) for k, rels in CLEANUP.items()}
sizes["total"] = dir_size(d)
usage_cache[d] = (time.time(), sizes)
return sizes
def no_resume(slug: str):
"""Why an existing run can only be started over: its training images were deleted, or its recording
was renamed (the pipeline remembers the recording's path and refuses to resume with another one)."""
meta = read_meta(slug)
if "images" in (meta.get("cleaned") or []):
return "images"
return "renamed" if meta.get("recording_renamed") or meta.get("folder_renamed") else None
def scene_busy(slug: str) -> bool:
job = load_state().get("job") or {}
u = uploads.get(slug)
return (job.get("slug") == slug and pid_alive(job.get("pid", 0))) or bool(u and u["proc"].poll() is None)
def clean_scene(slug: str, what: str):
d = scene_dir(slug)
if not d or not (d / "scene.sog").exists():
return err("err.no_scene")
if what not in CLEANUP:
return err("err.bad_request")
if scene_busy(slug):
return err("err.scene_busy")
for rel in CLEANUP[what]:
shutil.rmtree(d / rel, ignore_errors=True)
meta = read_meta(slug)
write_meta(slug, cleaned=sorted(set(meta.get("cleaned") or []) | {what}))
usage_cache.pop(d, None)
return None
QUALITY_ALIASES = {"schnell": "fast", "hoch": "high", "maximal": "max"} # runs from the first version
def unfinished_runs():
"""Runs without a finished scene (cancelled, failed, interrupted), except the one computing now."""
if not RUNS.is_dir():
return []
job = load_state().get("job") or {}
busy = job.get("slug") if pid_alive(job.get("pid", 0)) else None
metas = {f.name.removesuffix(".ui.json"): read_meta(f.name.removesuffix(".ui.json")) for f in RUNS.glob("*.ui.json")}
used_logs = {Path(m["log"]).name for m in metas.values() if m.get("log")}
slugs = {d.name for d in RUNS.iterdir() if d.is_dir()} | set(metas)
slugs |= {f.name.removesuffix(".log") for f in RUNS.glob("*.log") if f.name not in used_logs}
out = []
for slug in sorted(slugs):
d, log = RUNS / slug, RUNS / f"{slug}.log"
meta = metas.get(slug, {})
if slug.startswith(".") or slug == busy or (d / "scene.sog").exists() or (PREVIOUS / slug).exists() \
or is_group(meta):
continue
parsed = parse_output(log.read_text(errors="replace")) if log.exists() and d.is_dir() else None
parts = [p for p in (d, log, RUNS / f"{slug}.out", meta_file(slug)) if p.exists()]
size = sum(dir_size(p) if p.is_dir() else p.stat().st_size for p in parts)
rec = meta.get("recording")
startable = bool(meta.get("name") and rec and local_path(rec) and config.slugify(meta["name"]) == slug)
out.append({"slug": slug, "name": meta.get("name") or slug, "recording": rec,
"mtime": int(max(p.stat().st_mtime for p in parts)), "size": size,
"phase": parsed["phase"] if parsed else None, "pct": parsed["pct"] if parsed else None,
"resumable": startable and d.is_dir() and resumable_run(slug), "restartable": startable,
"folder": d.is_dir()})
return sorted(out, key=lambda r: r["mtime"], reverse=True)
def resumable_run(slug: str) -> bool:
"""The pipeline continues only after the camera positions are complete (an interrupted training
is restarted by splat.sh with them); an earlier interruption has to start over."""
try:
done = json.loads((RUNS / slug / "dataset" / "spirula-dataset.json").read_text()).get("status") == "complete"
except (OSError, ValueError):
return False
return done and not no_resume(slug)
def resume_run(slug: str, fresh: bool):
meta = read_meta(slug)
rec = local_path(meta.get("recording") or "")
if not rec or not meta.get("name") or config.slugify(meta["name"]) != slug:
return err("err.not_resumable")
if not fresh and not resumable_run(slug):
return err("err.not_resumable")
quality = QUALITY_ALIASES.get(meta.get("quality"), meta.get("quality") or "high")
return start_job({"path": rec, "name": meta["name"], "quality": quality, "existing": "fresh" if fresh else "resume",
"upload": bool(FRAME) and frame_listing() is not None})
def delete_scene(slug: str):
"""Scene or unfinished run: folder, log and settings into the desktop trash (recoverable);
a copy on the Frame stays."""
if not slug or Path(slug).name != slug or slug.startswith("."):
return err("err.no_scene")
if scene_busy(slug):
return err("err.scene_busy")
d = scene_dir(slug)
files = ([d] if d else []) + [p for p in (RUNS / f"{slug}.log", RUNS / f"{slug}.out", meta_file(slug)) if p.exists()]
if not files:
return err("err.no_scene")
try:
r = subprocess.run(["gio", "trash", "--", *map(str, files)], capture_output=True, text=True, timeout=120)
except (OSError, subprocess.TimeoutExpired) as e:
return err("err.trash_failed", detail=str(e))
if r.returncode != 0:
return err("err.trash_failed", detail=(r.stderr.strip().splitlines() or [""])[-1])
usage_cache.pop(d, None)
uploads.pop(slug, None)
for g, gm in all_groups().items():
if slug in gm.get("members", []):
write_meta(g, members=[m for m in gm["members"] if m != slug])
return None
def clean_group(slug: str, what: str):
"""Clean up the same kind of data in every scene of a group (the tour itself needs none of it)."""
meta = read_meta(slug) if slug and Path(slug).name == slug else {}
if not is_group(meta):
return err("err.no_scene")
if what not in CLEANUP:
return err("err.bad_request")
members = [m for m in meta.get("members", []) if scene_dir(m)]
if scene_busy(slug) or any(scene_busy(m) for m in members):
return err("err.scene_busy")
for m in members:
if e := clean_scene(m, what):
return e
return None
def clean_name(name: str, file: bool = True) -> str:
"""A name from user input without control characters; as a file name also without path separators."""
name = re.sub(r"\s+", " ", re.sub(r"[\x00-\x1f]", "", name or "")).strip()
if file:
name = re.sub(r"\s*[/\\]\s*", " - ", name).strip(" .-")
return name[:80]
def move_scene(slug: str, new: str) -> None:
"""Scene folder, its log/settings/output and the gallery files inside get the new folder name.
The folder on the Frame stays (remembered in frame_folder), so no second copy appears there."""
d, nd = RUNS / slug, RUNS / new
meta = read_meta(slug)
if not meta.get("frame_folder") and (fr := frame_listing()) is not None:
meta["frame_folder"] = next((s for s in fr["scenes"] if re.fullmatch(r"\d\d_" + re.escape(slug), s)), None)
old_stem, new_stem = meta.get("stem") or slug.lower(), new.lower()
d.rename(nd)
for folder, old, new_ in ((nd, old_stem, new_stem), (nd / "full", f"{old_stem}-full", f"{new_stem}-full")):
for f in folder.glob(f"{glob_escape(old)}.*"): # full/: the full version of a tour
if f.name[len(old):] in (".sog", ".xrSceneTransform.json", ".gallery.png", ".sceneinfo.png"):
f.rename(folder / (new_ + f.name[len(old):]))
log = RUNS / Path(meta.get("log") or f"{slug}.log").name
if log.exists():
log.rename(RUNS / f"{new}.log")
if (RUNS / f"{slug}.out").exists():
(RUNS / f"{slug}.out").rename(RUNS / f"{new}.out")
for k in ("stem", "log"):
meta.pop(k, None)
if not meta.get("frame_folder"):
meta.pop("frame_folder", None)
meta["folder_renamed"] = True # the pipeline's own files still name the old folder
with meta_lock:
write_json(meta_file(new), meta)
meta_file(slug).unlink(missing_ok=True)
usage_cache.pop(d, None)
uploads.pop(slug, None)
with lock:
st = load_state()
if (st.get("job") or {}).get("slug") == slug:
st["job"]["slug"] = new
save_state(st)
for g, gm in all_groups().items():
if slug in gm.get("members", []):
write_meta(g, members=[new if m == slug else m for m in gm["members"]])
def glob_escape(s: str) -> str:
return re.sub(r"([*?\[])", r"[\1]", s)
def rename_scene(slug: str, name: str, update_frame: bool):
d = scene_dir(slug)
name = clean_name(name, file=False)
if not d or not (d / "scene.sog").exists():
return err("err.no_scene")
if not name:
return err("err.bad_name")
if scene_busy(slug):
return err("err.scene_busy")
new = config.slugify(name)
if not new or new.startswith("."):
return err("err.bad_name")
if new != slug:
if (RUNS / new).exists() or meta_file(new).exists() or (RUNS / f"{new}.log").exists():
return err("err.scene_name_taken")
move_scene(slug, new)
slug, d = new, RUNS / new
meta = read_meta(slug)
write_meta(slug, name=name)
# New name plate for the gallery; the preview image stays if the training images are gone
env = {**os.environ, "GALLERY_ONLY": "1"}
if meta.get("stem"):
env["STEM"] = meta["stem"]
if meta.get("log"):
env["LOG"] = str(RUNS / Path(meta["log"]).name)
try:
subprocess.run([str(SCRIPTS / "to-frame.sh"), str(d), name], cwd=ROOT, env=env, timeout=120,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
pass # the name is saved; the plate is redone on the next upload
return start_upload(slug) if update_frame else None
def rename_recording(name: str, new: str):
src = REC / Path(name).name
new = clean_name(new).removesuffix(".mp4")
if not src.is_file() or src.parent.resolve() != REC.resolve():
return err("err.not_local")
if not new:
return err("err.bad_name")
dst = REC / f"{new}.mp4"
if dst.exists():
return err("err.name_taken")
job = load_state().get("job") or {}
if job.get("recording") == src.name and pid_alive(job.get("pid", 0)):
return err("err.recording_busy")
if checks.get(src.name, {}).get("state") in ("queued", "running"):
return err("err.recording_busy")
users = []
for m in RUNS.glob("*.ui.json"):
try:
if json.loads(m.read_text()).get("recording") == src.name:
users.append(m.name.removesuffix(".ui.json"))
except (OSError, ValueError):
pass
unfinished = [s for s in users if (RUNS / s).is_dir() and not (RUNS / s / "scene.sog").exists()]
if unfinished:
# The pipeline would refuse to resume it with a renamed recording
return err("err.recording_unfinished", name=read_meta(unfinished[0]).get("name") or unfinished[0])
with lock:
os.rename(src, dst) # keeps size and mtime, so cached results stay valid
meta = rec_meta()
orig = meta.pop(src.name, {}).get("orig", src.name)
if orig != dst.name:
meta[dst.name] = {"orig": orig}
write_json(RECMETA, meta, indent=1)
# Cached thumbnail and check result belong to the file name
old_stem, new_stem = src.stem, dst.stem
if (CACHE / f"{old_stem}.thumb").exists():
os.replace(CACHE / f"{old_stem}.thumb", CACHE / f"{new_stem}.thumb")
for f in CACHE.glob("*.check.json"):
if f.name.startswith(old_stem + ".") and re.fullmatch(r"\.\d+-\d+\.v\d+\.check\.json",
f.name[len(old_stem):]):
os.replace(f, CACHE / (new_stem + f.name[len(old_stem):]))
if src.name in checks:
checks[dst.name] = checks.pop(src.name)
for m in RUNS.glob("*.ui.json"):
if read_meta(slug := m.name.removesuffix(".ui.json")).get("recording") == src.name:
write_meta(slug, recording=dst.name, recording_renamed=True)
st = load_state()
if (st.get("job") or {}).get("recording") == src.name:
st["job"]["recording"] = dst.name
save_state(st)
return None
# ---------------------------------------------------------------- groups and tours (scripts/combine.sh)
# A group is a <slug>.ui.json with kind "group" and its member scenes; once put together, the tour
# lives in <slug>/ like a scene (scene.sog = Frame version, full/ = all splats, spirula/scene.ply).
ALIGNS = ("frame", "images", "shift", "rigid")
PARTITIONS = ("view", "nearest")
SESSION_GAP = 20 * 60 # recordings closer than this belong to one session (suggestion)
def is_group(meta: dict) -> bool:
return meta.get("kind") == "group"
def all_groups() -> dict:
out = {}
for f in RUNS.glob("*.ui.json"):
slug = f.name.removesuffix(".ui.json")
if is_group(meta := read_meta(slug)):
out[slug] = meta
return out
def recorded_at(meta: dict):
"""When the scene's recording was made (from the Frame's file name), or None."""
rec = meta.get("recording") or ""
orig = rec_meta().get(rec, {}).get("orig", rec)
m = re.match(r"(\d{4})-(\d\d)-(\d\d)_(\d\d)-(\d\d)-(\d\d)", orig)
return time.mktime(time.strptime("-".join(m.groups()), "%Y-%m-%d-%H-%M-%S")) if m else None
def mem_available() -> float:
try:
kb = next(int(l.split()[1]) for l in Path("/proc/meminfo").read_text().splitlines() if l.startswith("MemAvailable"))
return round(kb / 1048576, 1)
except (OSError, StopIteration, ValueError):
return 0.0
def groups():
scene_list = {s["slug"]: s for s in scenes()}
fr = frame_listing()
out = []
for slug, meta in sorted(all_groups().items(), key=lambda kv: (kv[1].get("name") or kv[0]).lower()):
members = [m for m in meta.get("members", []) if m in scene_list]
d, res = RUNS / slug, None
if (d / "scene.sog").exists():
try:
info = json.loads((d / "combine.json").read_text())
except (OSError, ValueError):
info = {}
built = (d / "scene.sog").stat().st_mtime
used = sorted(Path(r["run"]).name for r in info.get("rooms", {}).values())
on_frame = None
if fr is not None:
folder = meta.get("frame_folder")
on_frame = (folder in fr["scenes"]) if folder else any(
re.fullmatch(r"\d\d_" + re.escape(slug), x) for x in fr["scenes"])
full = d / "full" / "scene.sog"
res = {"size": (d / "scene.sog").stat().st_size, "full_size": full.stat().st_size if full.exists() else None,
"splats": info.get("splats"), "frame_splats": meta.get("frame_splats"),
"align": info.get("align"), "partition": info.get("partition"), "hub": info.get("hub"),
"seams": info.get("seams", {}), "mtime": int(built),
"stale": used != sorted(members) or any((RUNS / m / "scene.sog").stat().st_mtime > built for m in members),
"preview": bool(next(d.glob("*.gallery.png"), None)), "ply": (d / "spirula" / "scene.ply").exists(),
"on_frame": on_frame, "upload": upload_status(slug), "disk": disk_usage(d)}
out.append({"slug": slug, "name": meta.get("name") or slug, "members": members, "result": res,
"busy": scene_busy(slug), "options": {k: meta.get(k) for k in ("align", "partition", "frame_splats", "full")},
"splats": sum(scene_list[m].get("gaussians") or 0 for m in members)})
return {"groups": out, "suggestion": suggestion(scene_list, out), "mem_available_gb": mem_available()}
def suggestion(scene_list: dict, group_list: list):
"""Scenes recorded right after each other and not in a group yet: offer them as one group."""
grouped = {m for g in group_list for m in g["members"]}
timed = sorted((t, slug) for slug in scene_list if slug not in grouped
and (t := recorded_at(read_meta(slug))) is not None)
runs, cur = [], []
for t, slug in timed:
if cur and t - cur[-1][0] > SESSION_GAP:
runs.append(cur)
cur = []
cur.append((t, slug))
runs.append(cur)
dismissed = set(load_state().get("dismissed_groups", []))
for r in sorted(runs, key=len, reverse=True):
key = ",".join(sorted(s for _, s in r))
if len(r) >= 2 and key not in dismissed:
return {"members": [s for _, s in r], "key": key, "date": time.strftime("%Y-%m-%d", time.localtime(r[0][0]))}
return None
def save_group(slug: str, name: str, members: str):
"""Create (no slug) or change a group. Returns its slug, or an error."""
name = clean_name(name, file=False)
if not name:
return err("err.bad_name")
members = [m for m in dict.fromkeys(members.split(",")) if m]
scene_slugs = {s["slug"] for s in scenes()}
if not members or any(m not in scene_slugs for m in members):
return err("err.bad_request")
existing = all_groups()
for g, gm in existing.items():
if g != slug and (both := set(gm.get("members", [])) & set(members)):
return err("err.in_other_group", group=gm.get("name") or g)
new = config.slugify(name)
if not new or new.startswith("."):
return err("err.bad_name")
taken = lambda x: (RUNS / x).exists() or meta_file(x).exists() or (RUNS / f"{x}.log").exists()
if not slug:
if taken(new):
return err("err.scene_name_taken")
write_json(meta_file(new), {"kind": "group", "name": name, "members": members})
return new
if slug not in existing:
return err("err.no_scene")
if scene_busy(slug):
return err("err.scene_busy")
renamed = name != existing[slug].get("name")
if new != slug:
if taken(new):
return err("err.scene_name_taken")
if (RUNS / slug).exists():
move_scene(slug, new)
else:
meta_file(slug).rename(meta_file(new))
if (RUNS / f"{slug}.log").exists():
(RUNS / f"{slug}.log").rename(RUNS / f"{new}.log")
slug = new
write_meta(slug, name=name, members=members)
d = RUNS / slug
if renamed and (d / "scene.sog").exists(): # new name plates on the tour
stem = slug.lower()
cmds = [([str(SCRIPTS / "to-frame.sh"), str(d), name], {"GALLERY_ONLY": "1", "LOG": str(RUNS / f"{slug}.log")})]
if (d / "full" / "scene.sog").exists() and (d / "plan.png").exists():
cmds.append((["uv", "run", "-q", str(SCRIPTS / "make_gallery.py"), "--plan", str(d / "plan.png"),
str(d / "full"), t("tour.full_name", name=name), f"{stem}-full"], {}))
for cmd, extra in cmds:
try:
subprocess.run(cmd, cwd=ROOT, timeout=120, env={**os.environ, **extra},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
pass
return slug
def start_combine(p: dict):
global proc
slug = p.get("slug", "")
meta = read_meta(slug) if slug and Path(slug).name == slug else {}
if not is_group(meta):
return err("err.no_scene")
align, partition = p.get("align") or "images", p.get("partition") or "view"
if align not in ALIGNS or partition not in PARTITIONS:
return err("err.bad_request")
try:
frame_splats = int(p.get("frame_splats") or 3_500_000)
except (TypeError, ValueError):
return err("err.bad_value", key="frame_splats")
if not 500_000 <= frame_splats <= 10_000_000:
return err("err.range", key="frame_splats", lo=500_000, hi=10_000_000)
scene_list = {s["slug"]: s for s in scenes()}
members = [m for m in meta.get("members", []) if m in scene_list
and (RUNS / m / "spirula" / "scene.ply").exists() and (RUNS / m / "dataset/sparse/0/images.bin").exists()]
if len(members) < 2:
return err("err.combine_too_few")
full = bool(p.get("full", True))
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
return err("err.job_busy")
if any(scene_busy(m) for m in members) or scene_busy(slug):
return err("err.scene_busy")
name = meta.get("name") or slug
env = {**os.environ, "ALIGN": align, "PARTITION": partition, "FRAME_SPLATS": str(frame_splats),
"FULL": "1" if full else "0", "FULL_NAME": t("tour.full_name", name=name)}
if t.lang == "de":
env["PLAN_COMMA"] = "1"
if meta.get("frame_folder"):
env["TARGET"] = meta["frame_folder"]
upload = bool(p.get("upload")) and bool(FRAME)
if not upload:
env["NO_UPLOAD"] = "1"
pairs = [f"{RUNS / m}={scene_list[m]['name']}" for m in members]
JOBS.mkdir(parents=True, exist_ok=True)
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_{slug}.out"
with open(out, "wb") as fh:
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "combine.sh"), slug, name, *pairs],
t("inhibit.combine", name=name)), cwd=ROOT, env=env, stdout=fh,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
st = load_state()
st["job"] = {"kind": "combine", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
"recording": "", "rooms": len(members), "quality": align, "partition": partition,
"upload": upload, "full": full, "started": time.time(), "overrides": {}}
save_state(st)
write_meta(slug, align=align, partition=partition, frame_splats=frame_splats, full=full)
return None
def pick_file():
start = str(REC if REC.exists() else Path.home())
title = "Aufnahme wählen" if t.lang == "de" else "Choose a recording"
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, "Videos (*.mp4)"],
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", "--file-filter=*.mp4"]):
if shutil.which(cmd[0]):
r = subprocess.run(cmd, capture_output=True, text=True)
return r.stdout.strip() or None
return None
def shutdown(cancel: bool):
if cancel:
cancel_job()
# answer the request first, then exit
threading.Timer(0.3, lambda: os._exit(0)).start()
# ---------------------------------------------------------------- HTTP
STATIC = {"/": ("index.html", "text/html; charset=utf-8"), "/index.html": ("index.html", "text/html; charset=utf-8"),
"/i18n.js": ("i18n.js", "text/javascript; charset=utf-8"), "/icon.svg": ("icon.svg", "image/svg+xml")}
class Handler(http.server.BaseHTTPRequestHandler):
server_version = f"SplatWorkshop/{VERSION}"
def log_message(self, *a):
pass
def _host_ok(self):
"""Host header (DNS rebinding), the client's real address, and no requests from other websites."""
client = ipaddress.ip_address(self.client_address[0])
if not (client.is_loopback or (CFG.lan and client.is_private)):
return False
# Browsers name the origin of every request; another site (even another port on localhost) is refused.
# Only opening the page itself via a link is fine.
site = self.headers.get("Sec-Fetch-Site", "same-origin")
page = self.command == "GET" and urllib.parse.urlparse(self.path).path in STATIC \
and self.headers.get("Sec-Fetch-Mode") == "navigate"
if site not in ("same-origin", "none") and not page:
return False
return host_allowed((self.headers.get("Host") or "").rsplit(":", 1)[0])
def _same_origin(self):
"""POST only from our own page (Origin must match Host) – protects against other websites."""
origin = self.headers.get("Origin", "")
return not origin or urllib.parse.urlparse(origin).netloc == self.headers.get("Host", "")
def _from_this_pc(self):
return ipaddress.ip_address(self.client_address[0]).is_loopback
def send(self, code, body, ctype="application/json; charset=utf-8", cache=False):
if isinstance(body, (dict, list)):
body = json.dumps(body, ensure_ascii=False).encode()
elif isinstance(body, str):
body = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "max-age=86400" if cache else "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(body)
def _safely(self, handler):
try:
handler()
except (BrokenPipeError, ConnectionResetError):
pass
except Exception: # noqa: BLE001 – answer instead of dropping the connection
traceback.print_exc()
try:
self.send(500, {"error": err("err.bad_request")})
except OSError:
pass
def do_GET(self):
self._safely(self._get)
def do_POST(self):
self._safely(self._post)
def _get(self):
global last_request
if not self._host_ok():
return self.send(403, {"error": err("err.forbidden")})
u = urllib.parse.urlparse(self.path)
q = dict(urllib.parse.parse_qsl(u.query))
if u.path != "/api/activity": # the tray polls this; it does not count as an open page
last_request = time.time()
if u.path in STATIC:
f, ctype = STATIC[u.path]
return self.send(200, (WEB / f).read_bytes(), ctype)
if u.path == "/api/ping":
here = self._from_this_pc()
ip = lan_ip() if CFG.lan else None
return self.send(200, {
"ok": True, "app": "splat-workshop", "version": VERSION, "local": here,
"lan_url": f"http://{ip}:{CFG.port}/" if ip and here else None,
"language": CFG.language, "frame_configured": bool(FRAME),
"problems": [err(k, **p) for k, p in CFG.problems()],
"config": str(CFG.source or config.CONFIG_FILE) if here else None})
if u.path == "/api/activity":
return self.send(200, activity())
if u.path == "/api/recordings":
if q.get("refresh"):
frame_cache["t"] = 0
return self.send(200, recordings())
if u.path == "/api/probe":
path = q.get("path", "")
if local_path(Path(path).name) != path:
return self.send(200, {"ok": False, "errors": [err("err.unknown_recording")], "warnings": []})
return self.send(200, probe(path))
if u.path == "/api/thumb":
data = thumbnail(q.get("source", ""), Path(q.get("name", "")).name)
return self.send(200, data, "image/png", cache=True) if data else self.send(404, b"", "image/png")
if u.path == "/api/check":
return self.send(200, check_status(Path(q.get("name", "")).name))
if u.path == "/api/status":
ups = {s: upload_status(s) for s in list(uploads)}
return self.send(200, {"job": job_status(), "download": download, "uploads": ups})
if u.path == "/api/scenes":
return self.send(200, scenes())
if u.path == "/api/unfinished":
return self.send(200, unfinished_runs())
if u.path == "/api/groups":
return self.send(200, groups())
if u.path == "/api/floorplan":
data = floorplan([x for x in q.get("slugs", "").split(",") if x], q.get("style", "pretty"),
q.get("lang", "en"), q.get("title", ""))
if isinstance(data, dict):
return self.send(400, {"error": data})
data, info = data
self.send_response(200)
self.send_header("X-Pixels-Per-Metre", str(info.get("pixels_per_metre", "")))
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
if q.get("download"):
name = re.sub(r"[^A-Za-z0-9._-]+", "-", q.get("name") or "floorplan").strip("-") or "floorplan"
self.send_header("Content-Disposition", f'attachment; filename="{name}.png"')
self.end_headers()
self.wfile.write(data)
return
if u.path == "/api/preview":
d = scene_dir(q.get("slug", ""))
img = next(d.glob("*.gallery.png"), None) if d else None
prev = PREVIOUS / Path(q.get("slug", "")).name / "run"
if not img and q.get("slug", "").strip(".") and prev.is_dir():
img = next(prev.glob("*.gallery.png"), None)
return self.send(200, img.read_bytes(), "image/png") if img else self.send(404, b"", "image/png")
return self.send(404, {"error": err("err.not_found")})
def _post(self):
global last_request
if not self._host_ok() or not self._same_origin():
return self.send(403, {"error": err("err.forbidden")})
last_request = time.time()
try:
n = int(self.headers.get("Content-Length") or 0)
if not 0 <= n <= 1 << 20:
raise ValueError
p = json.loads(self.rfile.read(n) or b"{}")
except ValueError:
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")):
return self.send(400, {"error": err("err.bad_request")})
path = urllib.parse.urlparse(self.path).path
e = None
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown") and not self._from_this_pc():
# These open windows on this PC's screen or stop the program – only allowed from the PC itself
return self.send(403, {"error": err("err.pc_only")})
if path == "/api/start":
e = start_job(p)
if e == "exists":
slug = config.slugify((p.get("name") or "").strip())
return self.send(409, {"error": err("exists"), "no_resume": no_resume(slug)})
elif path == "/api/cancel":
e = cancel_job()
elif path == "/api/download":
fr = frame_listing()
f = next((f for f in (fr or {}).get("files", []) if f["name"] == p.get("name")), None)
e = start_download(f["name"], f["size"]) if f else err("err.not_on_frame")
elif path == "/api/upload":
e = start_upload(p.get("slug", ""))
elif path == "/api/pick":
chosen = pick_file()
if chosen:
picked.add(chosen)
return self.send(200, {"path": chosen})
elif path == "/api/check":
e = start_check(Path(p.get("name", "")).name)
elif path == "/api/rename":
if p.get("slug"):
e = rename_scene(p["slug"], p.get("new", ""), bool(p.get("update_frame")))
else:
e = rename_recording(p.get("name", ""), p.get("new", ""))
if not e:
return self.send(200, {"ok": True, "name": clean_name(p.get("new", "")).removesuffix(".mp4") + ".mp4"})
elif path == "/api/clean":
slug = p.get("slug", "")
e = (clean_group if slug and is_group(read_meta(slug)) else clean_scene)(slug, p.get("what", ""))
elif path == "/api/delete":
e = delete_scene(p.get("slug", ""))
elif path == "/api/group":
e = save_group(p.get("slug", ""), p.get("name", ""), p.get("members", ""))
if isinstance(e, str):
return self.send(200, {"ok": True, "slug": e})
elif path == "/api/group/dismiss":
st = load_state()
st["dismissed_groups"] = sorted(set(st.get("dismissed_groups", [])) | {p.get("key", "")})[-50:]
save_state(st)
elif path == "/api/combine":
e = start_combine(p)
elif path == "/api/resume":
e = resume_run(p.get("slug", ""), bool(p.get("fresh")))
if e == "exists": # e.g. the training images were cleaned up in the meantime
e = err("err.not_resumable")
elif path == "/api/play":
local = local_path(Path(p.get("name", "")).name)
if not local:
e = err("err.not_local")
else:
subprocess.Popen([*CFG.video_player, local], start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
elif path == "/api/open":
d = scene_dir(p.get("slug", ""))
if not d:
e = err("err.no_scene")
elif p.get("what") == "spirula":
subprocess.Popen([str(CFG.spirula), str(d / "spirula" / "scene.ply")], cwd=d,
start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
subprocess.Popen(["xdg-open", str(d)], start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
elif path == "/api/shutdown":
self.send(200, {"ok": True})
return shutdown(bool(p.get("cancel_job")))
else:
return self.send(404, {"error": err("err.not_found")})
return self.send(400 if e else 200, {"error": e} if e else {"ok": True})
def main():
global idle_exit
ap = argparse.ArgumentParser(description="Splat Workshop web server")
ap.add_argument("--no-idle-exit", action="store_true", help="keep running when idle (used by the tray icon)")
args = ap.parse_args()
idle_exit = not args.no_idle_exit
threading.Thread(target=watcher, daemon=True).start()
srv = http.server.ThreadingHTTPServer(("0.0.0.0" if CFG.lan else "127.0.0.1", CFG.port), Handler)
srv.daemon_threads = True
msg = f"Splat Workshop {VERSION} on http://127.0.0.1:{CFG.port}/"
if CFG.lan:
msg += f" and in your network on http://{lan_ip()}:{CFG.port}/"
print(msg, flush=True)
for key, params in CFG.problems():
print(f" setup: {key} {params}", flush=True)
srv.serve_forever()
if __name__ == "__main__":
main()