Files

2822 lines
126 KiB
Python
Raw Permalink 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 base64
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
import urllib.request
from pathlib import Path
from . import check, config
from .i18n import T, detect
VERSION = "0.6.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 = "Documents/GaussianXR" # Gaussian XR's personal scenes: Steam updates leave them alone
# The gallery that comes with Gaussian XR: its examples, and scenes copied by Splat Workshop 0.2.3 and older
FRAME_STEAM = ".local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes"
FRAME_PLACES = {"own": FRAME_SCENES, "steam": FRAME_STEAM}
FRAME_BACKUP = "Documents/GaussianSplats" # copies made by versions up to 0.2.3 – removed along with a scene
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 # file being fetched: {"name", "total", "done", "error", "finished"}
download_queue = [] # files waiting, fetched one after the other: [{"name", "size"}]
download_batch = {} # since the queue was last empty: {"files", "bytes", "done_files", "done_bytes", "failed"}
download_proc = None # ssh of the file being fetched (to cancel it)
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 ""
notes = [] # notify-send processes still running (the tests wait for them)
def notify(title: str, body: str) -> None:
if shutil.which("notify-send"):
notes[:] = [p for p in notes if p.poll() is None]
notes.append(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 any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh", "mesh.sh", "import.sh"))
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.
An expired listing is renewed in the background and the last one answered meanwhile: asking a Frame
that is switched off takes seconds, and the scene list (which asks too) would hang that long."""
if not FRAME:
return None
if not force and time.time() - frame_cache["t"] < 20:
return frame_cache["data"]
if not force and frame_cache["t"]:
if not frame_cache.get("renewing"):
frame_cache["renewing"] = True
threading.Thread(target=renew_frame_listing, daemon=True).start()
return frame_cache["data"]
return read_frame_listing()
def renew_frame_listing():
try:
read_frame_listing()
finally:
frame_cache["renewing"] = False
def read_frame_listing():
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}; ls {FRAME_STEAM}) 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 download_busy() -> bool:
return bool(download_queue) or bool(download and not download.get("finished"))
def start_download(files: list):
"""Fetch recordings from the Frame, one after the other ([{"name", "size"}]). Files already on this PC are
skipped (a single one is refused). Adds to the queue when a download is already running."""
with lock:
waiting = {f["name"] for f in download_queue} | ({download["name"]} if download_busy() and download else set())
new = [f for f in files if not (REC / f["name"]).exists() and f["name"] not in waiting]
if not new:
return err("err.name_taken") if len(files) == 1 and (REC / files[0]["name"]).exists() else (
err("err.download_busy") if files else err("err.not_on_frame"))
idle = not download_busy()
if idle:
download_batch.clear()
download_batch.update(files=0, bytes=0, done_files=0, done_bytes=0, failed=[])
download_batch["files"] += len(new)
download_batch["bytes"] += sum(f["size"] for f in new)
download_queue.extend(new)
if idle:
threading.Thread(target=download_worker, daemon=True).start()
return None
def download_worker():
global download
while True:
with lock:
if not download_queue:
break
f = download_queue.pop(0)
download = {"name": f["name"], "total": f["size"], "done": 0, "error": None, "finished": False}
fetch(f["name"], f["size"])
with lock:
download_batch["done_files"] += 1
download_batch["done_bytes"] += f["size"]
if download["error"]:
download_batch["failed"].append(f["name"])
download["finished"] = True
b = download_batch
if b.get("done_files") and not b.get("cancelled"):
ok = b["done_files"] - len(b["failed"])
if b["failed"]:
notify(t("notify.download_failed.title"), t("notify.download_failed", n=len(b["failed"]), ok=ok))
else:
notify(t("notify.download.title"), t("notify.download", n=ok))
def fetch(name: str, size: int):
"""One recording from the Frame, via a .part file (never a half file under the real name)."""
global download_proc
REC.mkdir(parents=True, exist_ok=True)
part = REC / (name + ".part")
try:
with open(part, "wb") as fh:
p = download_proc = 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 download.get("cancelled"):
raise RuntimeError("cancelled")
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)
finally:
download_proc = None
def cancel_download():
"""Empty the queue and stop the file being fetched (its half is removed)."""
with lock:
if not download_busy():
return err("err.no_download")
download_queue.clear()
download_batch["cancelled"] = True
if download and not download.get("finished"):
download["cancelled"] = True
if download_proc and download_proc.poll() is None:
download_proc.kill()
return None
def download_status():
return {**(download or {}), "queue": [f["name"] for f in download_queue], "batch": dict(download_batch),
"busy": download_busy()} if download or download_queue else 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}
RX_VSTEP = re.compile(r"^@@ vstep (\d+) (\d+) (\S+)", re.M)
def parse_versions(text: str) -> dict:
"""Progress of versions.sh: one phase per version."""
steps = RX_VSTEP.findall(text)
i, n, key = steps[-1]
info = {"version": key, "versions": int(n)}
if RX_DONE.search(text):
info["done"] = True
return {"phase": int(i) - 1, "pct": round(100 * (int(i) - 1) / int(n)), "eta": None, "info": info}
def parse_output(text: str) -> dict:
text = text.replace("\r", "\n")
if "@@ cphase" in text:
return parse_combine(text)
if "@@ vstep" in text:
return parse_versions(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]
for needle, hint in KNOWN_FAILURES:
if needle in text:
info["hint"] = hint
return {"phase": phase, "pct": pct, "eta": eta, "info": info}
# Failures with a known cause: a line of the pipeline's output -> a hint in the interface (web/i18n.js)
KNOWN_FAILURES = (("unexpected keyword argument 'metadata_errors'", "hint.pyav"),)
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, part: str = "", chain: dict = None):
"""A conversion; `part` (e.g. "120.0-245.5") converts only that stretch of the recording."""
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 or part:
env["NO_UPLOAD"] = "1" # a part alone is no scene for the Frame
if part:
env["PART"] = part
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},
**({"part": part} if part else {}), **({"chain": chain} if chain else {})}
save_state(st)
RUNS.mkdir(parents=True, exist_ok=True)
write_meta(slug, name=name, recording=Path(path).name, quality=env["QUALITY"], part=part or None,
**({} if "--resume" in args else {"cleaned": [], "recording_renamed": False}),
**({"previous": previous} if previous is not None else {}))
return None
# ---------------------------------------------------------------- a long recording in parts
# The Arcturus pipeline takes at most 500 image pairs per recording. web/index.html suggests as many parts
# as a recording needs for the full rate of its quality preset, all equally long (no short rest).
PARTS_MAX = 10
PART_OVERLAP = 4.0 # seconds both neighbouring parts get, so they surely meet
def part_ranges(duration: float, n: int) -> list:
step = duration / n
return [(max(0.0, i * step - (PART_OVERLAP / 2 if i else 0)),
min(duration, (i + 1) * step + (PART_OVERLAP / 2 if i < n - 1 else 0))) for i in range(n)]
def start_parts(p: dict):
"""Convert a long recording in n parts one after the other, then make them one room and put it
together (each part gets up to 500 image pairs)."""
try:
n = int(p.get("parts"))
except (TypeError, ValueError):
return err("err.bad_value", key="parts")
if not 2 <= n <= PARTS_MAX:
return err("err.range", key="parts", lo=2, hi=PARTS_MAX)
path, room = p.get("path", ""), clean_name(p.get("name") or "", file=False)
if local_path(Path(path).name) != path:
return err("err.unknown_recording")
duration = probe(path).get("duration") or 0
if duration / n < 20:
return err("err.parts_too_short", secs=20)
room_slug = config.slugify(room)
if not room_slug or room_slug.startswith("."):
return err("err.bad_name")
names = [t("part.name", name=room, i=i + 1, n=n) for i in range(n)]
slugs = [config.slugify(x) for x in names]
taken = lambda x: (RUNS / x).exists() or meta_file(x).exists() or (RUNS / f"{x}.log").exists()
if any(taken(x) for x in [room_slug, *slugs]) or len(set(slugs)) < n or room_slug in slugs:
return err("err.scene_name_taken")
ranges = part_ranges(duration, n)
chain = {"room": room, "path": path, "quality": p.get("quality", "high"), "i": 0, "n": n,
"parts": [{"name": x, "slug": s_, "part": f"{a:.2f}-{b:.2f}"} for x, s_, (a, b) in zip(names, slugs, ranges)],
"settings": {k: p.get(k) for k in ("frames", "scale", "maxg", "iter") if p.get(k) not in (None, "")},
"upload": bool(p.get("upload", True)) and bool(FRAME)}
first = chain["parts"][0]
return start_job({**chain["settings"], "path": path, "name": first["name"], "quality": chain["quality"],
"upload": False}, part=first["part"], chain={"i": 0, "n": n, "room": room, "parts": chain["parts"],
"settings": chain["settings"], "upload": chain["upload"]})
def advance_parts():
"""After a part: the next one; after the last: the parts become one room, which is put together."""
js = job_status(tail=False)
ch = js and js.get("chain")
if not ch or js["running"] or ch.get("done"):
return
st = load_state()
st["job"]["chain"]["done"] = True # only once, also after a restart of the server
save_state(st)
room = ch["room"]
if js["result"] != "ok":
if js["result"] == "failed":
notify(t("notify.parts_failed.title"), t("notify.parts_failed", name=room, i=ch["i"] + 1, n=ch["n"]))
return
i = ch["i"] + 1
if i < ch["n"]:
nxt = ch["parts"][i]
e = start_job({**ch["settings"], "path": js_path(js), "name": nxt["name"], "quality": js["quality"], "upload": False},
part=nxt["part"], chain={**ch, "i": i})
if e:
notify(t("notify.parts_failed.title"), t("notify.parts_failed", name=room, i=i + 1, n=ch["n"]))
return
slug = save_group("", room, ",".join(x["slug"] for x in ch["parts"]), room=True)
if isinstance(slug, dict):
notify(t("notify.parts_failed.title"), t("notify.parts_failed", name=room, i=ch["n"], n=ch["n"]))
return
e = start_combine({"slug": slug, "upload": ch["upload"], "full": True})
notify(t("notify.parts_done.title"), t("notify.parts_done", name=room, n=ch["n"]) if not e else
t("notify.parts_done_alone", name=room, n=ch["n"]))
def js_path(js: dict) -> str:
return local_path(js["recording"]) or ""
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
st["standby"] = {} # someone is there: no standby afterwards
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")}
b = download_batch
pct = (round(100 * (b["done_bytes"] + (download["done"] if download and not download.get("finished") else 0))
/ max(b["bytes"], 1)) if download_busy() else None)
return {"job": job, "download": {"pct": pct} if pct is not None else None,
"checking": check_busy(), "uploading": any(u["proc"].poll() is None for u in uploads.values())}
# ---------------------------------------------------------------- standby when everything is done
STANDBY_GRACE = 60 # seconds between "all done" and the standby (time to change one's mind)
AWAKE_AFTER = 15 # seconds the PC is still kept awake after the last work (gaps between jobs)
awake = {"proc": None, "idle_since": None}
def hold_awake(busy: bool) -> None:
"""While anything runs, and a little longer: one sleep inhibitor of this server for all of it. The
jobs have their own, but between two of them (the parts of a long recording, then putting them
together) there are a few seconds without – enough for the desktop to suspend a PC idle for hours."""
p = awake["proc"]
if busy:
awake["idle_since"] = None
if (p is None or p.poll() is not None) and can_inhibit():
awake["proc"] = subprocess.Popen(keep_awake(["sleep", "infinity"], t("inhibit.busy")),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
return
awake["idle_since"] = awake["idle_since"] or time.time()
if p is not None and time.time() - awake["idle_since"] > AWAKE_AFTER:
release_awake()
def release_awake() -> None:
p, awake["proc"] = awake["proc"], None
if p is not None and p.poll() is None:
try:
os.killpg(p.pid, signal.SIGTERM)
except ProcessLookupError:
pass
p.wait()
def standby_status() -> dict:
sb = load_state().get("standby") or {}
return {"on": bool(sb.get("on")), "at": sb.get("at"), "error": sb.get("error")}
def set_standby(on: bool) -> dict:
"""Switch "standby when everything is done" on or off (also stops a countdown)."""
with lock:
st = load_state()
st["standby"] = {"on": True, "since": time.time()} if on else {}
save_state(st)
return standby_status()
def standby_tick(busy: bool) -> None:
"""Once nothing runs any more: a minute's notice, then standby. (Cancelling a job switches it off:
someone is there.)"""
st = load_state()
sb = st.get("standby") or {}
if not sb.get("on"):
return
if busy:
if sb.get("at"):
sb.pop("at")
save_state(st)
return
if not sb.get("at"):
sb["at"] = time.time() + STANDBY_GRACE
save_state(st)
notify(t("notify.standby.title"), t("notify.standby", s=STANDBY_GRACE))
return
if time.time() < sb["at"]:
return
st["standby"] = {}
save_state(st)
release_awake() # our own lock must not keep the PC up
r = subprocess.run(["systemctl", "suspend"], capture_output=True, text=True)
if r.returncode:
st = load_state()
st["standby"] = {"error": (r.stderr or r.stdout).strip()[-300:]}
save_state(st)
notify(t("notify.standby_failed.title"), t("notify.standby_failed", why=(r.stderr or r.stdout).strip()[-200:]))
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.get("chain"):
pass # advance_parts() reports at the end
elif 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 js.get("kind") == "import" and js["result"] != "ok" and not (RUNS / js["slug"]).exists():
meta_file(js["slug"]).unlink(missing_ok=True) # nothing came of it: the name is free again
if not running:
restore_previous()
advance_parts()
js = job_status(tail=False) # the next part (or putting them together) may run now
running = bool(js and js["running"])
for slug in list(uploads):
upload_status(slug) # notify even without an open page
return running
def watcher():
"""Every few seconds: notifications and tidying up; stop the server after a longer idle time."""
was_running = None
while True:
time.sleep(3)
was_running = running = watch_once(was_running)
busy = (running or download_busy() or check_busy()
or any(u["proc"].poll() is None for u in uploads.values()))
hold_awake(busy)
standby_tick(busy)
if idle_exit and not busy and time.time() - last_request > CFG.idle_minutes * 60:
os._exit(0)
# ---------------------------------------------------------------- copy to the Frame
# ---------------------------------------------------------------- scale (models, e.g. a model railway)
SCALE_MAX = 1000 # 1:1000 – further than any model railway or building model
def world_scale_env(meta: dict) -> dict:
"""WORLD_SCALE for to-frame.sh/combine.sh: show a model in Gaussian XR in the size of the original."""
scale = meta.get("scale") or 1
return {"WORLD_SCALE": f"{scale:g}"} if meta.get("scale_xr") and scale != 1 else {}
def set_scale(slug: str, ratio, xr: bool, members: bool, update_frame: bool):
"""Scale 1:ratio of a scene or group (1 = original size). For a group optionally also for its scenes."""
try:
ratio = float(ratio)
except (TypeError, ValueError):
return err("err.bad_value", key="scale")
if not 1 <= ratio <= SCALE_MAX or ratio != ratio:
return err("err.range", key="scale", lo=1, hi=SCALE_MAX)
meta = read_meta(slug) if slug and Path(slug).name == slug and not slug.startswith(".") else {}
group = is_group(meta)
if not group and not (scene_dir(slug) and (RUNS / slug / "scene.sog").exists()):
return err("err.no_scene")
inner_rooms = [m for m in meta.get("members", []) if is_room(read_meta(m))] if group and members else []
targets = [slug] + inner_rooms + ([m for m, _ in group_scenes(slug, meta)] if group and members else [])
if any(scene_busy(t_) for t_ in targets):
return err("err.scene_busy")
xr = bool(xr) and ratio != 1
for t_ in targets:
write_meta(t_, scale=ratio if ratio != 1 else None, scale_xr=xr)
if update_frame and FRAME and (RUNS / slug / "scene.sog").exists():
return start_upload(slug) # the Frame gets the new worldScale
return None
# ---------------------------------------------------------------- versions (scripts/versions.sh)
VERSION_LIMITS = (10_000, 50_000_000) # splats
def read_version(vdir: Path) -> dict:
try:
return json.loads((vdir / "version.json").read_text())
except (OSError, ValueError):
return {}
CLEANUPS = ("ceiling", "floor", "haze", "outside") # scripts/clean.py, each 0 (off) to 1
def version_key(splats, scale: float, clean: dict = None) -> str:
"""Folder name of a version: 1m, 0.75m, all; with a scale e.g. 1m-1x87; cleaned up e.g. all-c50h30."""
key = "all" if splats == "all" else f"{splats / 1e6:g}m"
key += f"-1x{scale:g}" if scale != 1 else ""
return key + ("-" + "".join(f"{k[0]}{round(v * 100)}" for k, v in clean.items()) if clean else "")
def parse_cleanup(raw: str):
""""ceiling=0.5,haze=0.3" -> {"ceiling": 0.5, "haze": 0.3}, the ones switched on (0 < S <= 1); or an error."""
if not raw:
return {}
pairs = dict(kv.partition("=")[::2] for kv in raw.split(","))
if set(pairs) - set(CLEANUPS):
return err("err.bad_value", key="clean")
out = {}
for k in CLEANUPS:
try:
v = float(pairs.get(k) or 0)
except ValueError:
return err("err.bad_value", key=k)
if v != v:
return err("err.bad_value", key=k)
if not 0 <= v <= 1:
return err("err.range", key=k, lo=0, hi=1)
if v:
out[k] = round(v, 2)
return out
def versions_of(slug: str) -> list:
d = scene_dir(slug)
if not d or not (d / "versions").is_dir():
return []
meta = read_meta(slug)
source = d / "spirula" / "scene.ply"
built = source.stat().st_mtime if source.exists() else 0
fr = frame_listing()
folders = meta.get("version_folders") or {}
out = []
for vdir in sorted((d / "versions").iterdir()):
info = read_version(vdir)
if not info or not (vdir / "scene.sog").exists():
continue
key = vdir.name
on_frame = None
if fr is not None:
folder = folders.get(key)
on_frame = (folder in fr["scenes"]) if folder else any(
re.fullmatch(r"\d\d_" + re.escape(f"{slug}-{key}"), x) for x in fr["scenes"])
clean = info.get("clean") or {}
target = info.get("target", "")
out.append({"key": key, "label": info.get("label"), "splats": info.get("splats"), "scale": info.get("scale", 1),
"clean": clean, "purpose": "pc" if target == "all" else "frame",
# Spirula opens a ply: a cleaned PC version keeps its own, a plain one is the scene's own
"ply": (vdir / "spirula" / "scene.ply").exists() or (target == "all" and not clean and source.exists()),
"size": (vdir / "scene.sog").stat().st_size, "created": int(info.get("created", 0)),
"stale": info.get("created", 0) < built, "on_frame": on_frame,
"upload": upload_status(f"{slug}#{key}")})
return sorted(out, key=lambda v: (v["scale"], v["splats"] or 0))
def start_versions(p: dict):
"""New versions of a scene or tour: sizes (splats, or "all") times scales."""
global proc
slug = p.get("slug", "")
d = scene_dir(slug) if slug and not slug.startswith(".") else None
if not d or not (d / "spirula" / "scene.ply").exists() or not (d / "scene.sog").exists():
return err("err.no_scene")
sizes, scales = [], []
for x in (p.get("sizes") or "").split(","):
x = x.strip()
if not x:
continue
if x == "all":
sizes.append("all")
continue
try:
n = int(float(x))
except ValueError:
return err("err.bad_value", key="sizes")
if not VERSION_LIMITS[0] <= n <= VERSION_LIMITS[1]:
return err("err.range", key="sizes", lo=VERSION_LIMITS[0], hi=VERSION_LIMITS[1])
sizes.append(n)
for x in (p.get("scales") or "1").split(","):
try:
sc = float(x)
except ValueError:
return err("err.bad_value", key="scales")
if not 1 <= sc <= SCALE_MAX or sc != sc:
return err("err.range", key="scales", lo=1, hi=SCALE_MAX)
scales.append(sc)
if not sizes:
return err("err.no_versions")
clean = parse_cleanup(p.get("clean"))
if "code" in clean:
return clean
meta = read_meta(slug)
name = meta.get("name") or slug
specs = []
# with a clean-up, optionally the same without, to compare side by side
variants = [clean, {}] if clean and p.get("also_plain") else [clean]
for sc in dict.fromkeys(scales):
for n in dict.fromkeys(sizes):
for cl in variants:
size = t("ver.all") if n == "all" else t("ver.size", n=f"{n / 1e6:g}".replace(".", "," if t.lang == "de" else "."))
plate = (f"{name} · {size}" + (f" · 1:{sc:g}" if sc != 1 else "")
+ "".join(f" · {t('ver.clean.' + k)} {round(v * 100)} %" for k, v in cl.items()))
specs.append(f"{version_key(n, sc, cl)}|{n}|{sc:g}|{plate}"
+ ("|" + ",".join(f"{k}={v:g}" for k, v in cl.items()) if cl else ""))
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
return err("err.job_busy")
if scene_busy(slug):
return err("err.scene_busy")
JOBS.mkdir(parents=True, exist_ok=True)
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_versions_{slug}.out"
with open(out, "wb") as fh:
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "versions.sh"), str(d), *specs],
t("inhibit.versions", name=name)), cwd=ROOT, stdout=fh,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
st = load_state()
st["job"] = {"kind": "versions", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
"steps": [s.split("|")[3].removeprefix(name + " · ") for s in specs]}
save_state(st)
return None
GALLERY_TTL = 20 # seconds the list of the Frame's gallery is reused
gallery_cache = {"t": 0, "data": None}
RX_FOLDER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._ -]*")
def frame_gallery(force=False):
"""Every scene in Gaussian XR's gallery on the Frame (a folder with a preview image), with what it is:
a scene, tour or version made here, or something else (e.g. Gaussian XR's examples). None = no Frame."""
if not FRAME:
return None
if not force and time.time() - gallery_cache["t"] < GALLERY_TTL:
return gallery_cache["data"]
cmd = "".join(f'(cd {path} 2>/dev/null && for d in */; do d=${{d%/}}; '
f'g=$(ls "$d"/*.gallery.png 2>/dev/null | head -1); [ -n "$g" ] || continue; '
f'echo "{place}|$d|$(du -sb "$d" | cut -f1)|${{g##*/}}"; done); ' for place, path in FRAME_PLACES.items())
cmd += "true"
try:
r = subprocess.run(SSH + [cmd], capture_output=True, text=True, timeout=20)
except subprocess.TimeoutExpired:
r = None
if r is None or r.returncode == 255:
gallery_cache.update(t=time.time(), data=None)
return None
owners = {} # folder -> (slug, version, kind, name)
for f in RUNS.glob("*.ui.json"):
slug = f.name.removesuffix(".ui.json")
meta = read_meta(slug)
kind, name = ("tour" if is_group(meta) else "scene"), meta.get("name") or slug
if meta.get("frame_folder"):
owners[meta["frame_folder"]] = (slug, "", kind, name)
for key, folder in (meta.get("version_folders") or {}).items():
label = read_version(RUNS / slug / "versions" / key).get("label") or f"{name} · {key}"
owners[folder] = (slug, key, "version", label)
owners.setdefault(("re", slug), (slug, "", kind, name))
out = []
for line in r.stdout.splitlines():
place, _, line = line.partition("|")
folder, _, rest = line.partition("|")
size, _, preview = rest.partition("|")
if place not in FRAME_PLACES or not RX_FOLDER.fullmatch(folder):
continue
owner = owners.get(folder)
if not owner and (m := re.fullmatch(r"\d\d_(.+)", folder)):
rest_ = m.group(1)
owner = owners.get(("re", rest_))
if not owner and "-" in rest_: # <scene>-<version>
slug, _, key = rest_.rpartition("-")
if (RUNS / slug / "versions" / key / "version.json").exists():
owner = (slug, key, "version", read_version(RUNS / slug / "versions" / key).get("label") or rest_)
slug, version, kind, name = owner or ("", "", "other", re.sub(r"^\d\d_", "", folder))
out.append({"folder": folder, "place": place, "size": int(size or 0), "preview": preview, "slug": slug,
"version": version, "kind": kind, "name": name})
gallery_cache.update(t=time.time(), data=out)
return out
def gallery_entry(gallery, folder: str, place: str = ""):
"""A scene in the Frame's gallery by its folder (and place, when the same name is in both)."""
return next((x for x in gallery or [] if x["folder"] == folder and place in ("", x["place"])), None)
def frame_preview(folder: str, place: str = ""):
"""The gallery image of a scene on the Frame (cached on the PC)."""
g = gallery_entry(frame_gallery(), folder, place)
if not g:
return None
CACHE.mkdir(parents=True, exist_ok=True)
cached = CACHE / f"frame-{hashlib.sha1(f'{g['place']}|{folder}|{g['size']}'.encode()).hexdigest()[:16]}.png"
if cached.exists():
return cached.read_bytes()
try:
r = subprocess.run(SSH + [f"cat {FRAME_PLACES[g['place']]}/{shq(folder)}/{shq(g['preview'])}"],
capture_output=True, timeout=20)
except subprocess.TimeoutExpired:
return None
if r.returncode != 0 or not r.stdout:
return None
cached.write_bytes(r.stdout)
return r.stdout
def remove_frame_folder(folder: str, place: str = ""):
"""Take a scene off the Frame's gallery – any scene in it (also Gaussian XR's examples; Steam brings
those back with "verify files"). The PC is not touched."""
if not FRAME:
return err("err.no_frame")
gallery = frame_gallery(force=True)
if gallery is None:
return err("err.frame_offline")
g = gallery_entry(gallery, folder, place)
if not g or not RX_FOLDER.fullmatch(folder):
return err("err.not_on_frame_version")
key = f"{g['slug']}#{g['version']}" if g["version"] else g["slug"]
if g["slug"] and (u := uploads.get(key)) and u["proc"].poll() is None:
return err("err.upload_busy")
try:
r = subprocess.run(SSH + [f"rm -rf -- {FRAME_PLACES[g['place']]}/{shq(folder)} {FRAME_BACKUP}/{shq(folder)}"],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
return err("err.frame_offline")
if r.returncode != 0:
return err("err.frame_failed", detail=(r.stderr.strip().splitlines() or [""])[-1])
if g["slug"]: # forget the folder, so copying again makes a new one
meta = read_meta(g["slug"])
if g["version"]:
folders = meta.get("version_folders") or {}
folders.pop(g["version"], None)
write_meta(g["slug"], version_folders=folders)
elif meta.get("frame_folder") == folder:
write_meta(g["slug"], frame_folder=None)
uploads.pop(key, None)
gallery_cache["t"] = frame_cache["t"] = 0
return None
def remove_from_frame(slug: str, version: str):
"""Take one version of a scene off the Frame."""
if not FRAME:
return err("err.no_frame")
if not slug or slug.startswith(".") or not version or version.startswith(".") or Path(version).name != version:
return err("err.no_version")
gallery = frame_gallery(force=True)
if gallery is None:
return err("err.frame_offline")
g = next((x for x in gallery if x["slug"] == slug and x["version"] == version), None)
if not g:
return err("err.not_on_frame_version")
return remove_frame_folder(g["folder"], g["place"])
def move_to_own_folder():
"""Move the scenes made here out of Gaussian XR's own gallery folder (where 0.2.3 and older copied them,
and where a Steam update may remove them) into the folder for personal scenes. Gaussian XR's examples stay."""
if not FRAME:
return err("err.no_frame")
gallery = frame_gallery(force=True)
if gallery is None:
return err("err.frame_offline")
if any(u["proc"].poll() is None for u in uploads.values()): # to-frame.sh may be choosing or moving a folder
return err("err.upload_busy")
moving = [g for g in gallery if g["place"] == "steam" and g["kind"] != "other"]
if not moving:
return None
# A folder of the same name in the personal folder is the newer copy: the old one goes
cmd = f"mkdir -p {FRAME_SCENES} && " + " && ".join(
f"{{ [ -e {FRAME_SCENES}/{shq(g['folder'])} ] && rm -rf -- {FRAME_STEAM}/{shq(g['folder'])} || "
f"mv -- {FRAME_STEAM}/{shq(g['folder'])} {FRAME_SCENES}/; }}" for g in moving)
try:
r = subprocess.run(SSH + [cmd], capture_output=True, text=True, timeout=60)
except subprocess.TimeoutExpired:
return err("err.frame_offline")
gallery_cache["t"] = frame_cache["t"] = 0
if r.returncode != 0:
return err("err.frame_failed", detail=(r.stderr.strip().splitlines() or [""])[-1])
return None
def delete_version(slug: str, key: str):
d = scene_dir(slug) if slug and not slug.startswith(".") else None
vdir = d / "versions" / Path(key).name if d and key and not key.startswith(".") else None
if not vdir or not vdir.is_dir():
return err("err.no_version")
if scene_busy(slug) or ((u := uploads.get(f"{slug}#{key}")) and u["proc"].poll() is None):
return err("err.scene_busy")
try:
r = subprocess.run(["gio", "trash", "--", str(vdir)], 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)
return None
# ---------------------------------------------------------------- your own splats (scripts/import.sh, 0.6)
SPLAT_EXTS = (".ply", ".sog", ".spz", ".splat", ".ksplat")
IMPORT_UP = ("y-down", "y-up", "z-up")
def start_import(p: dict):
"""A splat file from this PC (picked in the file dialog) as a new scene."""
global proc
src = Path(p.get("path") or "")
if not src.is_absolute() or not src.is_file() or src.suffix.lower() not in SPLAT_EXTS:
return err("err.import_file")
name = (p.get("name") or "").strip() or src.stem
slug = config.slugify(name)
if not slug or slug.startswith("."):
return err("err.bad_name")
if (RUNS / slug).exists() or meta_file(slug).exists():
return err("err.scene_name_taken")
up = p.get("up") or "y-down"
if up not in IMPORT_UP:
return err("err.bad_value", key="up")
try:
scale = float(p.get("scale") or 1)
except (TypeError, ValueError):
return err("err.bad_value", key="scale")
if not 0.0001 <= scale <= 10000 or scale != scale:
return err("err.range", key="scale", lo=0.0001, hi=10000)
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
return err("err.job_busy")
RUNS.mkdir(parents=True, exist_ok=True)
out = RUNS / f"{slug}.log" # also the scene's log (to-frame.sh looks for it)
write_meta(slug, name=name, imported=src.name)
with open(out, "wb") as fh:
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "import.sh"), str(src), str(RUNS / slug), name, up, f"{scale:g}"],
t("inhibit.import", name=name)), cwd=ROOT, stdout=fh,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
st = load_state()
st["job"] = {"kind": "import", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
"steps": [t("import.step.convert"), t("import.step.preview")]}
save_state(st)
return None
def import_preview(p: dict) -> dict:
"""Small pictures of the picked file, one per choice of "up", and a guess which one stands upright
(scripts/import_preview.py) – kept per file, so picking it again is instant."""
src = Path(p.get("path") or "")
if not src.is_absolute() or not src.is_file() or src.suffix.lower() not in SPLAT_EXTS:
return {"error": err("err.import_file")}
st = src.stat()
key = hashlib.sha1(f"{src}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()[:16]
out = CACHE / "import" / key
if not (out / "guess.json").exists():
tmp = out.with_name(key + ".tmp")
shutil.rmtree(tmp, ignore_errors=True)
try:
r = subprocess.run(["uv", "run", "-q", str(SCRIPTS / "import_preview.py"), str(src), str(tmp),
str(CFG.splat_transform or "")], capture_output=True, text=True, timeout=300)
except (OSError, subprocess.TimeoutExpired):
r = None
if r is None or r.returncode:
shutil.rmtree(tmp, ignore_errors=True)
return {"error": err("err.import_preview", detail=((r.stderr if r else "").strip().splitlines() or [""])[-1])}
(tmp / "guess.json").write_text(r.stdout.strip().splitlines()[-1])
shutil.rmtree(out, ignore_errors=True)
tmp.rename(out)
guess = json.loads((out / "guess.json").read_text()).get("guess")
return {"guess": guess if guess in IMPORT_UP else "y-down",
"images": {u: "data:image/png;base64," + base64.b64encode((out / f"{u}.png").read_bytes()).decode()
for u in IMPORT_UP}}
# ---------------------------------------------------------------- is there a newer version?
REPO = "thunderheronight/splat-workshop"
UPDATE_FILE = STATE_DIR / "update.json" # {"checked": time, "tag": "v0.5.1", "url": release page}
UPDATE_EVERY = 24 * 3600
update_lock = threading.Lock()
def version_tuple(v: str) -> tuple:
""""v0.5.1" / "0.5.1-experimental" -> (0, 5, 1); unparsable -> ()."""
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", v or "")
return tuple(int(x) for x in m.groups()) if m else ()
def fetch_latest_release() -> dict | None:
"""The newest release on GitHub: one plain request, nothing about the user is sent."""
req = urllib.request.Request(f"https://api.github.com/repos/{REPO}/releases/latest",
headers={"Accept": "application/vnd.github+json", "User-Agent": f"SplatWorkshop/{VERSION}"})
try:
with urllib.request.urlopen(req, timeout=8) as r:
data = json.loads(r.read())
except (OSError, ValueError):
return None
tag, url = data.get("tag_name"), data.get("html_url")
if not isinstance(tag, str) or not version_tuple(tag):
return None
if not (isinstance(url, str) and url.startswith(f"https://github.com/{REPO}/")):
url = f"https://github.com/{REPO}/releases/latest"
return {"tag": tag, "url": url}
def ask_for_update():
"""At most once a day, in the background (switched off with check_updates = false)."""
if not CFG.check_updates or not update_lock.acquire(blocking=False):
return
try:
try:
last = json.loads(UPDATE_FILE.read_text())
except (OSError, ValueError):
last = {}
if time.time() - last.get("checked", 0) < UPDATE_EVERY:
update_lock.release()
return
except Exception:
update_lock.release()
raise
def run():
try:
found = fetch_latest_release()
write_json(UPDATE_FILE, {"checked": time.time(), **(found or {})})
finally:
update_lock.release()
threading.Thread(target=run, daemon=True).start()
def check_update_now() -> dict:
"""Asked for by hand (tray menu, page): right away, also when the daily check is switched off."""
with update_lock:
found = fetch_latest_release()
if found:
write_json(UPDATE_FILE, {"checked": time.time(), **found})
return {"reached": found is not None, "current": VERSION, "update": update_info(force=True)}
def update_info(force=False) -> dict | None:
"""A newer release than this one, or None."""
if not CFG.check_updates and not force:
return None
try:
last = json.loads(UPDATE_FILE.read_text())
except (OSError, ValueError):
return None
latest = version_tuple(last.get("tag", ""))
if latest and latest > version_tuple(VERSION):
return {"latest": last["tag"].lstrip("v"), "url": last.get("url"), "current": VERSION}
return None
# ---------------------------------------------------------------- meshes (scripts/mesh.sh, 0.5)
MESH_FORMATS = ("glb", "ply", "obj", "stl")
MESH_TYPES = {".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".obj": "model/obj", ".mtl": "text/plain",
".ply": "application/octet-stream", ".stl": "model/stl", ".png": "image/png", ".jpg": "image/jpeg"}
@functools.cache
def blender_cmd() -> list | None:
"""How to start Blender here: installed as a program, or as the Flatpak from Flathub; None = not there."""
if shutil.which("blender"):
return ["blender"]
if shutil.which("flatpak"):
try:
if subprocess.run(["flatpak", "info", "org.blender.Blender"], capture_output=True, timeout=10).returncode == 0:
return ["flatpak", "run", "org.blender.Blender"]
except (OSError, subprocess.TimeoutExpired):
pass
return None
def mesh_file(slug: str, name: str) -> Path | None:
"""One file of a scene's mesh, by its plain name (nothing outside the mesh folder)."""
d = scene_dir(slug) if slug and not slug.startswith(".") else None
if not d or not name or Path(name).name != name or name.startswith(".") or name == "mesh.json":
return None
f = d / "mesh" / name
return f if f.is_file() and f.suffix.lower() in MESH_TYPES else None
def open_in_blender(slug: str):
"""Blender with the mesh imported – GLB if there is one, else OBJ, PLY or STL."""
cmd = blender_cmd()
if not cmd:
return err("err.no_blender")
d = scene_dir(slug) if slug and not slug.startswith(".") else None
m = d / "mesh" if d else None
files = {f.suffix.lower(): f for f in m.iterdir()} if m and m.is_dir() else {}
importers = {".glb": "bpy.ops.import_scene.gltf", ".obj": "bpy.ops.wm.obj_import",
".ply": "bpy.ops.wm.ply_import", ".stl": "bpy.ops.wm.stl_import"}
ext = next((e for e in importers if e in files), None)
if not ext:
return err("err.no_mesh")
# Right at start-up Blender's window is not ready ("context is incorrect"): import a moment later,
# into the 3D view, without the default cube, and show all of it
code = f"""import bpy
def _splat_import():
win = bpy.context.window_manager.windows[0]
area = next((a for a in win.screen.areas if a.type == "VIEW_3D"), None)
over = {{"window": win}}
if area:
over.update(area=area, region=next(r for r in area.regions if r.type == "WINDOW"))
with bpy.context.temp_override(**over):
for o in list(bpy.data.objects):
bpy.data.objects.remove(o)
{importers[ext]}(filepath={str(files[ext])!r})
if area:
bpy.ops.view3d.view_all()
bpy.app.timers.register(_splat_import, first_interval=0.5)
"""
subprocess.Popen([*cmd, "--python-expr", code], start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
return None
def mesh_of(slug: str) -> dict:
"""The mesh of a scene or tour: its files, and whether a texture is possible (training images still there)."""
d = scene_dir(slug) if slug and not slug.startswith(".") else None
if not d:
return {}
texture = (d / "dataset" / "images").is_dir() and (d / "dataset" / "sparse").is_dir()
m = d / "mesh"
info = {"texture_possible": texture, "files": [], "made": None, "blender": blender_cmd() is not None}
if (m / "mesh.json").exists():
try:
made = json.loads((m / "mesh.json").read_text())
except (OSError, ValueError):
made = {}
src = d / "spirula" / "scene.ply"
info["made"] = {**made, "stale": src.exists() and made.get("created", 0) < src.stat().st_mtime}
info["files"] = [{"name": f.name, "size": f.stat().st_size} for f in sorted(m.iterdir())
if f.is_file() and f.name != "mesh.json"]
return info
def start_mesh(p: dict):
"""A mesh of a scene or tour in the chosen formats (glb, ply, obj, stl), optionally with a texture."""
global proc
slug = p.get("slug", "")
d = scene_dir(slug) if slug and not slug.startswith(".") else None
if not d or not (d / "spirula" / "scene.ply").exists():
return err("err.no_scene")
formats = [f for f in dict.fromkeys((p.get("formats") or "").split(",")) if f]
if not formats or any(f not in MESH_FORMATS for f in formats):
return err("err.bad_value", key="formats")
texture = bool(p.get("texture")) and mesh_of(slug)["texture_possible"]
name = read_meta(slug).get("name") or slug
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
return err("err.job_busy")
if scene_busy(slug):
return err("err.scene_busy")
JOBS.mkdir(parents=True, exist_ok=True)
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_mesh_{slug}.out"
with open(out, "wb") as fh:
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "mesh.sh"), str(d), ",".join(formats), "1" if texture else "0"],
t("inhibit.mesh", name=name)), cwd=ROOT, stdout=fh,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
st = load_state()
st["job"] = {"kind": "mesh", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
"steps": [t("mesh.step.mesh"), t("mesh.step.upright")]}
save_state(st)
return None
def delete_mesh(slug: str):
d = scene_dir(slug) if slug and not slug.startswith(".") else None
if not d or not (d / "mesh").is_dir():
return err("err.no_mesh")
if scene_busy(slug):
return err("err.scene_busy")
try:
r = subprocess.run(["gio", "trash", "--", str(d / "mesh")], 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)
return None
def start_upload(slug: str, version: str = ""):
"""Copy a scene – or one of its versions – to the Frame. Every version gets its own scene folder there."""
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")
vdir = None
if version:
vdir = d / "versions" / Path(version).name
if version.startswith(".") or not (vdir / "scene.sog").exists():
return err("err.no_version")
key = f"{slug}#{version}" if version else slug
with lock:
u = uploads.get(key)
if u and u["proc"].poll() is None:
return err("err.upload_busy")
meta = read_meta(slug)
env = {**os.environ}
name = meta.get("name") or slug
if vdir:
info = read_version(vdir)
name = info.get("label") or name
env.update(STEM=f"{slug}-{version}".lower(), SLUG=f"{slug}-{version}",
LOG=str(RUNS / Path(meta.get("log") or f"{slug}.log").name))
if folder := (meta.get("version_folders") or {}).get(version):
env["TARGET"] = folder
else:
for k, var in (("frame_folder", "TARGET"), ("stem", "STEM")):
if meta.get(k):
env[var] = meta[k]
env.update(world_scale_env(meta))
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}{'-' + version if version else ''}.out"
with open(out, "wb") as fh:
p = subprocess.Popen(keep_awake([str(SCRIPTS / "to-frame.sh"), str(vdir or d), name],
t("inhibit.upload", name=name)), cwd=ROOT, env=env,
stdout=fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
start_new_session=True)
uploads[key] = {"proc": p, "out": out, "started": time.time(), "notified": False}
return None
RX_UPBYTES = re.compile(r"^@@ upbytes (\d+) (\d+)$", re.M)
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 None and (m := RX_UPBYTES.findall(text)): # how far the copy is
done, total = (int(x) for x in m[-1])
if total > 0:
res.update(pct=min(99, round(100 * done / total)), done=done, total=total)
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]:
scene, _, version = slug.partition("#")
if version:
folders = read_meta(scene).get("version_folders") or {}
write_meta(scene, version_folders={**folders, version: d[2]})
else:
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,
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr")),
"versions": len(list(d.glob("versions/*/version.json"))), "mesh": (d / "mesh" / "mesh.json").exists()})
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, group: str = ""):
"""(PNG bytes, info dict with pixels_per_metre) of one or more scenes (cached), or an error dict.
The plan of a group is drawn in the group's scale, if it has one; otherwise all scenes need the same."""
if style not in ("pretty", "classic"):
style = "pretty"
gmeta = read_meta(group) if group and Path(group).name == group and not group.startswith(".") else {}
if gmeta and not is_group(gmeta):
return err("err.bad_request")
room_of = dict(group_scenes(group, gmeta)) if gmeta else {} # one label per room
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, room_of.get(slug) or read_meta(slug).get("name") or slug, ply.stat().st_mtime))
if not runs or len(runs) > 12:
return err("err.bad_request")
if (gmeta.get("scale") or 1) != 1:
ratio = gmeta["scale"]
else:
scales = {read_meta(slug).get("scale") or 1 for slug in dict.fromkeys(slugs)}
if len(scales) > 1:
return err("err.mixed_scale")
ratio = scales.pop()
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, ratio]).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 ratio != 1:
cmd += ["--ratio", str(ratio)]
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}, part=meta.get("part") or "")
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)
# a part's piece cut out of its recording (kept for resuming): not needed any more, and cut again
# from the recording any time – deleted, not into the trash (it is as big as that stretch of video)
for cut in (RUNS / ".parts" / f"{slug}.mp4", RUNS / ".parts" / f"{slug}.mp4.tmp.mp4"):
cut.unlink(missing_ok=True)
try:
(RUNS / ".parts").rmdir()
except OSError:
pass
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 group_scenes(slug, meta)]
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)
renames = [(nd, old_stem, new_stem), (nd / "full", f"{old_stem}-full", f"{new_stem}-full")]
renames += [(v, f"{slug}-{v.name}".lower(), f"{new}-{v.name}".lower()) for v in (nd / "versions").glob("*/")]
for folder, old, new_ in renames:
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
def recording_in_use(name: str):
"""Why a recording can't be deleted right now (converted, checked or fetched), or None."""
job = load_state().get("job") or {}
if job.get("recording") == name and pid_alive(job.get("pid", 0)):
return err("err.recording_busy")
if checks.get(name, {}).get("state") in ("queued", "running"):
return err("err.recording_busy")
if download_busy() and (name in [f["name"] for f in download_queue] or (download and download["name"] == name)):
return err("err.recording_fetching")
return None
def delete_recording(name: str, force: bool = False):
"""A recording on this PC into the desktop trash (recoverable). Only from the recordings folder.
If parts were cut from it, only when asked for once more (`force`): it is the original for cutting again."""
src = REC / Path(name).name
if not name or not src.is_file() or src.parent.resolve() != REC.resolve():
return err("err.not_local")
if e := recording_in_use(src.name):
return e
unfinished = [m.name.removesuffix(".ui.json") for m in RUNS.glob("*.ui.json")
if read_meta(m.name.removesuffix(".ui.json")).get("recording") == src.name]
unfinished = [s for s in unfinished if (RUNS / s).is_dir() and not (RUNS / s / "scene.sog").exists()]
if unfinished:
return err("err.recording_needed", name=read_meta(unfinished[0]).get("name") or unfinished[0])
# parts are cut from the original again when needed (start over, resume): ask once more
parts = [m.name.removesuffix(".ui.json") for m in RUNS.glob("*.ui.json")
if (pm := read_meta(m.name.removesuffix(".ui.json"))).get("recording") == src.name and pm.get("part")]
if parts and not force:
return err("err.recording_has_parts", name=read_meta(parts[0]).get("name") or parts[0], n=len(parts))
try:
r = subprocess.run(["gio", "trash", "--", str(src)], 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])
with lock:
meta = rec_meta()
if meta.pop(src.name, None) is not None:
write_json(RECMETA, meta, indent=1)
checks.pop(src.name, None)
frame_cache["t"] = 0
return None
def delete_frame_recording(name: str):
"""A recording on the Frame, for good (the Frame has no trash). Only from the Frame's recordings."""
if not FRAME:
return err("err.no_frame")
fr = frame_listing(force=True)
if fr is None:
return err("err.frame_offline")
if not any(f["name"] == name for f in fr["files"]) or Path(name).name != name:
return err("err.not_on_frame")
if e := recording_in_use(name):
return e
try:
r = subprocess.run(SSH + [f"rm -f -- {FRAME_VIDEOS}/{shq(name)}"], capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
return err("err.frame_offline")
if r.returncode != 0:
return err("err.frame_failed", detail=(r.stderr.strip().splitlines() or [""])[-1])
frame_cache["t"] = 0
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).
# A group marked "room" is one room made of several recordings (only scenes). Any other group may hold
# scenes and such rooms; putting it together always starts from the recordings, each in its room.
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 is_room(meta: dict) -> bool:
return is_group(meta) and bool(meta.get("room"))
def group_scenes(slug: str, meta: dict = None) -> list:
"""The scenes of a group, each with the name of its room: [(scene slug, room name)]. A scene on its own
is a room of its own; the scenes of a room (also of a room inside the group) all share its name."""
meta = meta if meta is not None else read_meta(slug)
if not is_group(meta):
return []
scene = lambda m, mm: scene_dir(m) and not is_group(mm)
if is_room(meta):
return [(m, meta.get("name") or slug) for m in meta.get("members", []) if scene(m, read_meta(m))]
out, names = [], {}
for m in meta.get("members", []):
mm = read_meta(m)
if is_room(mm):
found = [s_ for s_ in mm.get("members", []) if scene(s_, read_meta(s_))]
elif scene(m, mm):
found = [m]
else:
continue
room = base = mm.get("name") or m
k = 1
while room in names and names[room] != m: # two rooms of the same name stay two rooms
k += 1
room = f"{base} ({k})"
names[room] = m
out += [(s_, room) for s_ in found]
return out
def parent_group(slug: str):
return next((g for g, gm in all_groups().items() if slug in gm.get("members", [])), None)
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 = []
every = all_groups()
parents = {m: g for g, gm in every.items() for m in gm.get("members", [])}
for slug, meta in sorted(every.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
or (not is_room(meta) and m in every and is_room(every[m]))]
inner = [(m, r) for m, r in group_scenes(slug, meta) 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(m for m, _ in inner)
or any((RUNS / m / "scene.sog").stat().st_mtime > built for m, _ in inner),
"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,
"room": is_room(meta), "parent": parents.get(slug), "scenes": [m for m, _ in inner],
"rooms": len({r for _, r in inner}),
"busy": scene_busy(slug), "options": {k: meta.get(k) for k in ("align", "partition", "frame_splats", "full")},
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr")),
"versions": len(list(d.glob("versions/*/version.json"))), "mesh": (d / "mesh" / "mesh.json").exists(),
"splats": sum(scene_list[m].get("gaussians") or 0 for m, _ in inner)})
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, room: bool = False):
"""Create (no slug) or change a group. Returns its slug, or an error. A room (room=True) holds scenes
only; any other group scenes and rooms."""
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()}
existing = all_groups()
rooms = {g for g, gm in existing.items() if is_room(gm) and g != slug}
allowed = scene_slugs if room else scene_slugs | rooms
if not members or any(m not in allowed for m in members):
return err("err.bad_request")
if slug and not room and parent_group(slug): # inside another group it has to stay a room
return err("err.room_in_group", group=existing[parent_group(slug)].get("name") or parent_group(slug))
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, "room": room})
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")
for g, gm in all_groups().items(): # a room inside another group
if slug in gm.get("members", []):
write_meta(g, members=[new if m == slug else m for m in gm["members"]])
slug = new
write_meta(slug, name=name, members=members, room=room)
d = RUNS / slug
if renamed and (d / "scene.sog").exists(): # new name plates on the tour
stem = slug.lower()
cmds = []
runs = [m for m in members if (RUNS / m / "spirula" / "scene.ply").exists()]
if room and (d / "plan.png").exists() and runs:
# a room's floor plan carries its name: drawn again, and the preview images from it
plan = d / "plan.new.png"
cmds.append((["uv", "run", "-q", str(SCRIPTS / "floorplan.py"), str(plan),
*[f"{RUNS / m}={name}" for m in runs], *(["--comma"] if t.lang == "de" else [])], {}))
cmds.append((["mv", str(plan), str(d / "plan.png")], {}))
cmds.append((["uv", "run", "-q", str(SCRIPTS / "make_gallery.py"), "--plan", str(d / "plan.png"),
str(d), name, stem], {}))
cmds.append(([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 1_000_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()}
inner = [(m, r) for m, r in group_scenes(slug, meta) if m in scene_list
and (RUNS / m / "spirula" / "scene.ply").exists() and (RUNS / m / "dataset/sparse/0/images.bin").exists()]
members = [m for m, _ in inner]
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"]
env.update(world_scale_env(meta))
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]
if len({r for _, r in inner}) < len(inner): # a room made of several recordings
env["ROOMS"] = json.dumps([r for _, r in inner], ensure_ascii=False)
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({r for _, r in inner}), "recordings": len(inner), "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(kind: str = "recording"):
if kind == "splat":
start, title = str(Path.home()), t("pick.splat")
kde, gtk = "Splats (" + " ".join(f"*{e}" for e in SPLAT_EXTS) + ")", " ".join(f"*{e}" for e in SPLAT_EXTS)
else:
start = str(REC if REC.exists() else Path.home())
title, kde, gtk = t("pick.recording"), "Videos (*.mp4)", "*.mp4"
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, kde],
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", f"--file-filter={gtk}"]):
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()
ask_for_update()
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, "update": update_info()})
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_status(), "uploads": ups,
"standby": standby_status()})
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/frame/gallery":
g = frame_gallery(force=bool(q.get("refresh")))
return self.send(200, {"online": g is not None, "configured": bool(FRAME), "scenes": g or []})
if u.path == "/api/frame/preview":
data = frame_preview(q.get("folder", ""), q.get("place", ""))
return self.send(200, data, "image/png", cache=True) if data else self.send(404, b"", "image/png")
if u.path == "/api/versions":
return self.send(200, {"versions": versions_of(q.get("slug", ""))})
if u.path == "/api/mesh":
return self.send(200, mesh_of(q.get("slug", "")))
if u.path == "/api/mesh/file":
f = mesh_file(q.get("slug", ""), q.get("name", ""))
if not f:
return self.send(404, {"error": err("err.no_mesh")})
self.send_response(200)
self.send_header("Content-Type", MESH_TYPES[f.suffix.lower()])
self.send_header("Content-Length", str(f.stat().st_size))
self.send_header("Content-Disposition", "attachment; filename*=UTF-8''" + urllib.parse.quote(f.name))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
with open(f, "rb") as fh:
shutil.copyfileobj(fh, self.wfile, 1 << 20)
return None
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", ""), q.get("group", ""))
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", "members", "key", "version", "sizes", "scales", "folder",
"names", "place", "where",
"align", "partition", "clean", "formats")):
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", "/api/import", "/api/import/preview") \
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/parts":
e = start_parts(p)
if e == "exists":
e = err("err.scene_name_taken")
elif path == "/api/cancel":
e = cancel_job()
elif path == "/api/download":
fr = frame_listing()
names = p["names"].split("\n") if p.get("names") else [p.get("name")] # several: one per line
on_frame = {f["name"]: f for f in (fr or {}).get("files", [])}
files = [on_frame[n] for n in names if isinstance(n, str) and n in on_frame]
e = start_download(files) if files else err("err.not_on_frame")
elif path == "/api/download/cancel":
e = cancel_download()
elif path == "/api/upload":
e = start_upload(p.get("slug", ""), p.get("version", ""))
elif path == "/api/versions":
e = start_versions(p)
elif path == "/api/frame/remove":
e = (remove_frame_folder(p["folder"], p.get("place", "")) if p.get("folder") else
remove_from_frame(p.get("slug", ""), p.get("version", "")))
elif path == "/api/frame/move":
e = move_to_own_folder()
elif path == "/api/import":
e = start_import(p)
elif path == "/api/import/preview":
r = import_preview(p)
return self.send(400 if "error" in r else 200, r)
elif path == "/api/update/check":
return self.send(200, check_update_now())
elif path == "/api/mesh":
e = start_mesh(p)
elif path == "/api/mesh/delete":
e = delete_mesh(p.get("slug", ""))
elif path == "/api/version/delete":
e = delete_version(p.get("slug", ""), p.get("version", ""))
elif path == "/api/pick":
chosen = pick_file(p.get("kind") or "recording")
if p.get("kind") == "splat":
return self.send(200, {"path": chosen})
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/recording/delete":
e = delete_frame_recording(p.get("name", "")) if p.get("where") == "frame" else (
delete_recording(p.get("name", ""), bool(p.get("force"))) if p.get("where") == "pc" else err("err.bad_request"))
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", ""), bool(p.get("room")))
if isinstance(e, str):
return self.send(200, {"ok": True, "slug": e})
elif path == "/api/scale":
e = set_scale(p.get("slug", ""), p.get("scale"), bool(p.get("xr")), bool(p.get("with_scenes")),
bool(p.get("update_frame")))
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/standby":
return self.send(200, set_standby(bool(p.get("on"))))
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") == "blender":
e = open_in_blender(p.get("slug", ""))
elif p.get("what") == "spirula":
ply = d / "spirula" / "scene.ply"
if p.get("version"): # a version: its own ply, or the scene's for a plain "all"
v = {x["key"]: x for x in versions_of(p.get("slug", ""))}.get(p["version"])
own = d / "versions" / p["version"] / "spirula" / "scene.ply"
ply = own if v and own.exists() else ply if v and v["ply"] else None
if ply is None:
e = err("err.no_version")
else:
subprocess.Popen([str(CFG.spirula), str(ply)], cwd=ply.parent.parent,
start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
folder = d / "mesh" if p.get("what") == "mesh" and (d / "mesh").is_dir() else d
subprocess.Popen(["xdg-open", str(folder)], 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()