From dfaa6a4a1c199b2900fb5ec9df48cd95b3cdc580 Mon Sep 17 00:00:00 2001 From: thunderheronight Date: Wed, 14 Oct 2026 14:32:10 +0200 Subject: [PATCH] Versions: take a version off the Frame (its gallery folder and backup copy, nothing else) Co-Authored-By: Claude Opus 5.5 --- splat_workshop/server.py | 37 ++++++++++++++++++++++++++++++++++ tests/test_versions.py | 43 ++++++++++++++++++++++++++++++++++++++++ web/i18n.js | 8 ++++++++ web/index.html | 8 ++++++++ 4 files changed, 96 insertions(+) diff --git a/splat_workshop/server.py b/splat_workshop/server.py index 37f9f89..b2f2216 100644 --- a/splat_workshop/server.py +++ b/splat_workshop/server.py @@ -44,6 +44,7 @@ STATE = STATE_DIR / "state.json" FRAME = CFG.frame_host FRAME_VIDEOS = "Videos/ArcturusVisionCamera" FRAME_SCENES = ".local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes" +FRAME_BACKUP = "Documents/GaussianSplats" # to-frame.sh keeps a copy of every scene here 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 @@ -818,6 +819,40 @@ def start_versions(p: dict): return None +def remove_from_frame(slug: str, version: str): + """Take a version off the Frame: its gallery folder and the backup copy. Only a folder this version + got from Splat Workshop (remembered, or named exactly _-) – never anything else.""" + if not FRAME: + return err("err.no_frame") + d = scene_dir(slug) if slug and not slug.startswith(".") else None + if not d or not version or version.startswith(".") or Path(version).name != version: + return err("err.no_version") + meta = read_meta(slug) + folders = meta.get("version_folders") or {} + folder = folders.get(version) + fr = frame_listing(force=True) + if fr is None: + return err("err.frame_offline") + if not folder: + folder = next((x for x in fr["scenes"] if re.fullmatch(r"\d\d_" + re.escape(f"{slug}-{version}"), x)), None) + if not folder or not re.fullmatch(r"\d\d_[A-Za-z0-9._-]+", folder) or folder not in fr["scenes"]: + return err("err.not_on_frame_version") + if (u := uploads.get(f"{slug}#{version}")) and u["proc"].poll() is None: + return err("err.upload_busy") + try: + r = subprocess.run(SSH + [f"rm -rf -- {FRAME_SCENES}/{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]) + folders.pop(version, None) + write_meta(slug, version_folders=folders) + uploads.pop(f"{slug}#{version}", None) + frame_cache["t"] = 0 + 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 @@ -1747,6 +1782,8 @@ class Handler(http.server.BaseHTTPRequestHandler): e = start_upload(p.get("slug", ""), p.get("version", "")) elif path == "/api/versions": e = start_versions(p) + elif path == "/api/frame/remove": + e = remove_from_frame(p.get("slug", ""), p.get("version", "")) elif path == "/api/version/delete": e = delete_version(p.get("slug", ""), p.get("version", "")) elif path == "/api/pick": diff --git a/tests/test_versions.py b/tests/test_versions.py index 85c89de..12bdeff 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -132,3 +132,46 @@ def test_one_job_at_a_time(): assert server.start_job({"path": str(rec), "name": "Other", "quality": "fast", "upload": False}) is None time.sleep(1) assert server.start_versions({"slug": "Room", "sizes": "10000"})["code"] == "err.job_busy" + + +def upload(api, slug, key): + assert api.post("/api/upload", {"slug": slug, "version": key})[0] == 200 + end = time.time() + 60 + while (u := server.upload_status(f"{slug}#{key}")) and u["running"] and time.time() < end: + time.sleep(0.2) + assert server.upload_status(f"{slug}#{key}")["ok"] + + +def test_taking_a_version_off_the_frame_removes_only_that(api, frame): + room() + make("Room", "10000,20000") + upload(api, "Room", "0.01m") + upload(api, "Room", "0.02m") + scenes, backup = frame / server.FRAME_SCENES, frame / server.FRAME_BACKUP + (scenes / "04_BurtLakeHouse").mkdir() # an example scene of Gaussian XR + (scenes / "30_Room-0.01m-old").mkdir() # someone else's folder with a similar name + assert api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"})[0] == 200 + assert sorted(p.name for p in scenes.iterdir()) == ["04_BurtLakeHouse", "21_Room-0.02m", "30_Room-0.01m-old"] + assert not (backup / "20_Room-0.01m").exists() and (backup / "21_Room-0.02m").exists() + assert "0.01m" not in json.loads((RUNS / "Room.ui.json").read_text())["version_folders"] + v = {x["key"]: x for x in server.versions_of("Room")} + assert v["0.01m"]["on_frame"] is False and v["0.02m"]["on_frame"] # still on the PC, off the Frame + status, r = api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"}) + assert status == 400 and r["error"]["code"] == "err.not_on_frame_version" + + +@pytest.mark.parametrize("version", ["", "..", "../0.02m", ".hidden"]) +def test_taking_off_the_frame_only_with_a_proper_version(api, frame, version): + room() + assert api.post("/api/frame/remove", {"slug": "Room", "version": version})[0] == 400 + + +def test_taking_off_a_frame_that_is_off(api, frame): + room() + make("Room", "10000") + upload(api, "Room", "0.01m") + os.environ["FAKE_FRAME_OFF"] = "1" + status, r = api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"}) + assert status == 400 and r["error"]["code"] == "err.frame_offline" + os.environ.pop("FAKE_FRAME_OFF") + assert (frame / server.FRAME_SCENES / "20_Room-0.01m").exists() diff --git a/web/i18n.js b/web/i18n.js index 64bb031..f6be6c8 100644 --- a/web/i18n.js +++ b/web/i18n.js @@ -201,6 +201,10 @@ const I18N = { "ver.confirm": "Move the version “{name}” ({size}) to the trash? A copy on the Frame is not touched.", "job.sub.versions": "{n} versions · for {time}", "err.no_version": "This version does not exist", "err.no_versions": "Pick at least one size", + "ver.unframe": "Off the Frame", "ver.unframe.title": "Remove this version from the Frame's gallery (it stays on the PC)", + "ver.unframe.confirm": "Remove “{name}” from the Frame? Its folder in the gallery and the backup copy on the Frame are deleted; the version stays on this PC.", + "ver.unframed": "“{name}” removed from the Frame", "err.not_on_frame_version": "This version is not on the Frame", + "err.frame_offline": "The Frame is not reachable", "err.frame_failed": "The Frame refused", "err.plan_failed": "The floor plan could not be drawn", "sc.replacing": "Being computed again – stays until the new one is done", "err.plan_busy": "Other floor plans are being drawn right now, try again in a moment", "plan.measure": "📏 Measure", "plan.measure.title": "Measure distances in the floor plan", "plan.measure.hint": "Click to set points · right-click removes the last one · horizontal distances, about ±5 cm", @@ -411,6 +415,10 @@ const I18N = { "ver.confirm": "Fassung „{name}“ ({size}) in den Papierkorb verschieben? Eine Kopie auf der Frame bleibt unberührt.", "job.sub.versions": "{n} Fassungen · seit {time}", "err.no_version": "Diese Fassung gibt es nicht", "err.no_versions": "Mindestens eine Größe wählen", + "ver.unframe": "Von der Frame nehmen", "ver.unframe.title": "Diese Fassung aus der Galerie der Frame entfernen (auf dem PC bleibt sie)", + "ver.unframe.confirm": "„{name}“ von der Frame nehmen? Ihr Ordner in der Galerie und die Sicherungskopie auf der Frame werden gelöscht; auf dem PC bleibt die Fassung.", + "ver.unframed": "„{name}“ von der Frame genommen", "err.not_on_frame_version": "Diese Fassung liegt nicht auf der Frame", + "err.frame_offline": "Die Frame ist nicht erreichbar", "err.frame_failed": "Die Frame hat abgelehnt", "err.plan_failed": "Der Grundriss konnte nicht gezeichnet werden", "sc.replacing": "Wird neu gerechnet – bleibt, bis die neue fertig ist", "err.plan_busy": "Gerade werden andere Grundrisse gezeichnet, gleich noch einmal versuchen", "plan.measure": "📏 Messen", "plan.measure.title": "Abstände im Grundriss messen", "plan.measure.hint": "Klicken setzt Punkte · Rechtsklick nimmt den letzten zurück · waagerechte Abstände, etwa ±5 cm genau", diff --git a/web/index.html b/web/index.html index cf07372..f3d855b 100644 --- a/web/index.html +++ b/web/index.html @@ -1102,6 +1102,7 @@ async function drawVersions(slug) { ${tr("million", {n: num(v.splats / 1e6, 2)})} · ${mb(v.size)}${v.stale ? " · " + tr("ver.stale") : ""} ${frameState({upload: v.upload, on_frame: v.on_frame})} ${S.frameConfigured ? `` : ""} + ${v.on_frame ? `` : ""} `).join("") : `

${tr("ver.none")}

`; $("#verList").querySelectorAll("[data-vup]").forEach(b => b.onclick = async () => { @@ -1109,6 +1110,13 @@ async function drawVersions(slug) { if (!res.ok) { toast(trErr(res.error)); return; } toast(tr("sc.uploadStarted")); setTimeout(() => drawVersions(slug), 1500); }); + $("#verList").querySelectorAll("[data-vunframe]").forEach(b => b.onclick = async () => { + const v = list.find(x => x.key === b.dataset.vunframe); + if (!confirm(tr("ver.unframe.confirm", {name: v.label}))) return; + const res = await api("/api/frame/remove", {slug, version: v.key}); + if (!res.ok) { toast(trErr(res.error)); return; } + toast(tr("ver.unframed", {name: v.label})); drawVersions(slug); + }); $("#verList").querySelectorAll("[data-vdel]").forEach(b => b.onclick = async () => { const v = list.find(x => x.key === b.dataset.vdel); if (!confirm(tr("ver.confirm", {name: v.label, size: mb(v.size)}))) return;