diff --git a/CHANGELOG.md b/CHANGELOG.md index a2da18f..235988d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,13 @@ All notable changes to Splat Workshop. The format follows [Keep a Changelog](htt - **1 M splats is the recommended size for the Frame** (preselected for versions and tours, also the default of `combine.sh`, was 3.5 M). Measured on a Steam Frame with one tour: 0.75 M 36 fps, 1 M 35 fps, 1.5 M 29 fps, 2 M 24 fps, 5 M 14 fps – 1 M looked clearly sharper than 0.75 M, 1.5 M a little sharper but already slightly unsteady. All sizes stay selectable. - Putting a tour together offers 0.75 to 2 M splats or an own size for its Frame version (was 2 / 3.5 / 5 M). +- Tests extended after an independent review with mutation testing (499 including the type checks of every request field): scale, versions, fetching several recordings, deleting recordings, the *On the Frame* tab and the new folder, and what is locked while a scene is computed, checked, fetched or copied. + ### Fixed - The floor plan no longer fails for scenes in which it finds no walls (e.g. a model without its scale). +- The floor plan of a group uses the group's scale, also when it was not passed on to its scenes. Scenes in different scales without one for the group give a clear message instead of a plan in the wrong size. +- *Move to own scenes* waits until no scene is being copied to the Frame. ## [0.2.2] – 2026-10-14 diff --git a/splat_workshop/server.py b/splat_workshop/server.py index 97888a1..c4056a5 100644 --- a/splat_workshop/server.py +++ b/splat_workshop/server.py @@ -1027,10 +1027,9 @@ def move_to_own_folder(): gallery = frame_gallery(force=True) if gallery is None: return err("err.frame_offline") - moving = [g for g in gallery if g["place"] == "steam" and g["kind"] != "other"] - if any((u := uploads.get(f"{g['slug']}#{g['version']}" if g["version"] else g["slug"])) and u["proc"].poll() is None - for g in moving): + 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 @@ -1230,8 +1229,9 @@ def scenes(): plan_locks = {} # cache key -> Lock, so one plan is drawn only once at a time -def floorplan(slugs: list[str], style: str, lang: str, title: str): - """(PNG bytes, info dict with pixels_per_metre) of one or more scenes (cached), or an error dict.""" +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" runs = [] @@ -1243,8 +1243,16 @@ def floorplan(slugs: list[str], style: str, lang: str, title: str): runs.append((d, read_meta(slug).get("name") or slug, ply.stat().st_mtime)) if not runs or len(runs) > 12: return err("err.bad_request") - scales = {read_meta(slug).get("scale") or 1 for slug in dict.fromkeys(slugs)} - ratio = scales.pop() if len(scales) == 1 else 1 + 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") + 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] @@ -1981,7 +1989,7 @@ class Handler(http.server.BaseHTTPRequestHandler): return self.send(200, {"versions": versions_of(q.get("slug", ""))}) 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("lang", "en"), q.get("title", ""), q.get("group", "")) if isinstance(data, dict): return self.send(400, {"error": data}) data, info = data diff --git a/tests/conftest.py b/tests/conftest.py index 8169d17..951ac40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,6 +135,10 @@ if os.environ.get("FAKE_FRAME_CUT") and cmd.startswith("cat "): # the connecti out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout sys.stdout.buffer.write(out[:len(out) // 2]) sys.exit(255) +if os.environ.get("FAKE_FRAME_SHORT") and cmd.startswith("cat "): # ends early, but without an error + out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout + sys.stdout.buffer.write(out[:len(out) // 2]) + sys.exit(0) if os.environ.get("FAKE_FRAME_SLOW") and cmd.startswith("cat "): # a slow connection: 1 kB every 0.1 s import time out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout @@ -154,6 +158,9 @@ def local(a): return Path("{FRAME_HOME}") / a.split(":", 1)[1] return Path(a) *src, dst = map(local, args) +if os.environ.get("FAKE_SCP_SLOW"): # a slow connection: copying takes 3 s + import time + time.sleep(3) for s in src: shutil.copy(s, dst / s.name if dst.is_dir() else dst) ''') @@ -197,7 +204,8 @@ def clean(): while server.download_busy() and time.time() < end: time.sleep(0.05) server.download, server.download_queue[:], server.download_batch = None, [], {} - for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FAKE_FRAME_SLOW", "FRAME_HOST"): + for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FAKE_FRAME_SLOW", "FAKE_FRAME_SHORT", "FAKE_SCP_SLOW", + "FRAME_HOST"): os.environ.pop(k, None) os.environ["FRAME_HOST"] = "" TOOL_LOG.unlink(missing_ok=True) diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 4e06769..a0b22b2 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -154,6 +154,21 @@ def test_a_scene_being_computed_cannot_be_changed(api): ("/api/clean", {"slug": "Kitchen", "what": "temp"})): status, r = api.post(path, body) assert status == 400 and r["error"]["code"] == "err.scene_busy", path + # the new actions of 0.2.3 leave it alone, too + v = RUNS / "Kitchen" / "versions" / "0.01m" + v.mkdir(parents=True) + (v / "scene.sog").write_bytes(b"x") + (v / "version.json").write_text(json.dumps({"key": "0.01m", "splats": 10000, "scale": 1, "label": "Kitchen · 0.01 M"})) + for path, body, code in (("/api/scale", {"slug": "Kitchen", "scale": "87"}, "err.scene_busy"), + ("/api/version/delete", {"slug": "Kitchen", "version": "0.01m"}, "err.scene_busy"), + ("/api/versions", {"slug": "Kitchen", "sizes": "10000"}, "err.job_busy")): + status, r = api.post(path, body) + assert status == 400 and r["error"]["code"] == code, path + assert v.is_dir() and not (server.read_meta("Kitchen").get("scale") or 1) - 1 + # and its recording stays + rec = server.load_state()["job"]["recording"] + status, r = api.post("/api/recording/delete", {"name": rec, "where": "pc"}) + assert status == 400 and r["error"]["code"] == "err.recording_busy" and (server.REC / rec).exists() # and no tour is put together meanwhile (one job at a time) synth.make_room(RUNS, "Office", "A") synth.make_room(RUNS, "Hallway", "B") diff --git a/tests/test_recordings.py b/tests/test_recordings.py index b258fc4..1ebd0aa 100644 --- a/tests/test_recordings.py +++ b/tests/test_recordings.py @@ -299,3 +299,23 @@ def test_deleting_on_a_frame_that_is_off(api, frame): os.environ["FAKE_FRAME_OFF"] = "1" status, r = api.post("/api/recording/delete", {"name": name, "where": "frame"}) assert status == 400 and r["error"]["code"] == "err.frame_offline" + + +def test_a_recording_being_checked_is_not_deleted(api, frame): + rec = recording() + put_on_frame(frame, rec.name) + server.checks[rec.name] = {"state": "running"} + for where in ("pc", "frame"): + status, r = api.post("/api/recording/delete", {"name": rec.name, "where": where}) + assert status == 400 and r["error"]["code"] == "err.recording_busy", where + assert rec.exists() and (frame / VIDEOS / rec.name).exists() + + +def test_a_download_that_ends_too_early_leaves_nothing_behind(api, frame): + name, _ = put_on_frame(frame) + server.frame_listing(force=True) + os.environ["FAKE_FRAME_SHORT"] = "1" # half the file, but no error from ssh + assert api.post("/api/download", {"name": name})[0] == 200 + st = wait_fetched() + assert st["error"] and st["batch"]["failed"] == [name] + assert not (REC / name).exists() and not (REC / (name + ".part")).exists() diff --git a/tests/test_scale.py b/tests/test_scale.py index 488d321..2d48ab4 100644 --- a/tests/test_scale.py +++ b/tests/test_scale.py @@ -123,3 +123,29 @@ def test_a_tour_of_a_model_is_walkable_in_both_versions(): tour = RUNS / "Layout" assert json.loads((tour / "layout.xrSceneTransform.json").read_text())["worldScale"] == H0 assert json.loads((tour / "full" / "layout-full.xrSceneTransform.json").read_text())["worldScale"] == H0 + + +def test_the_floor_plan_of_a_group_in_the_groups_scale(api): + synth.make_room(RUNS, "Station", "A", error=(0.0, (0, 0, 0)), scale=1 / H0) + server.save_group("", "Layout", "Station") + api.post("/api/scale", {"slug": "Layout", "scale": str(H0)}) # only on the group, not passed on + assert {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}["Station"] == 1 + full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0)))) + info = server.floorplan(["Station"], "classic", "en", "", "Layout")[1] + assert info["ratio"] == H0 + assert info["scenes"][0]["size"] == pytest.approx(full["scenes"][0]["size"], abs=0.05) # 4 x 3 m + status, png = api.get("/api/floorplan?slugs=Station&style=classic&group=Layout") + assert status == 200 and png[:4] == b"\x89PNG" + assert server.floorplan(["Station"], "classic", "en", "", "Station")["code"] == "err.bad_request" # not a group + + +def test_a_floor_plan_of_scenes_in_different_scales_is_refused(api): + synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4") + synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4") + server.save_group("", "Layout", "Station,Yard") + api.post("/api/scale", {"slug": "Station", "scale": "87"}) + api.post("/api/scale", {"slug": "Yard", "scale": "160"}) + status, r = api.get("/api/floorplan?slugs=Station,Yard&group=Layout") + assert status == 400 and r["error"]["code"] == "err.mixed_scale" + api.post("/api/scale", {"slug": "Layout", "scale": "87"}) # one scale for the group decides + assert api.get("/api/floorplan?slugs=Station,Yard&style=classic&group=Layout")[0] == 200 diff --git a/tests/test_security.py b/tests/test_security.py index 75c9d3b..716e9f1 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -126,11 +126,15 @@ def test_from_this_pc_means_loopback(): ENDPOINTS = ["/api/start", "/api/rename", "/api/clean", "/api/delete", "/api/upload", "/api/download", "/api/check", - "/api/group", "/api/group/dismiss", "/api/combine", "/api/resume"] + "/api/group", "/api/group/dismiss", "/api/combine", "/api/resume", "/api/versions", "/api/scale", + "/api/frame/remove", "/api/frame/move", "/api/recording/delete", "/api/version/delete", + "/api/download/cancel"] @pytest.mark.parametrize("path", ENDPOINTS) -@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality"]) +@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality", + "existing", "partition", "version", "sizes", "scales", "folder", "names", "place", + "where"]) def test_wrongly_typed_fields_get_400_everywhere(api, path, field): for bad in (5, ["a"], True): assert api.post(path, {field: bad})[0] == 400, (path, field, bad) diff --git a/tests/test_versions.py b/tests/test_versions.py index 677d43b..5a617aa 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -177,3 +177,44 @@ def test_taking_off_a_frame_that_is_off(api, frame): 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() + + +def test_versions_sh_refuses_odd_version_names(): + import subprocess + from conftest import ROOT + run, _ = room() + for spec in ("../x|10000|1|x", ".hidden|10000|1|x", "a/b|10000|1|x"): + r = subprocess.run([str(ROOT / "scripts" / "versions.sh"), str(run), spec], capture_output=True, text=True) + assert r.returncode == 1 and "Unusable version name" in r.stderr, spec + assert not (run / "x").exists() and not (run / "versions").exists() + + +def test_an_old_copy_of_a_version_is_recognised_by_its_folder(api, frame): + room() + make("Room", "10000") # not copied by this version: no version_folders + d = frame / server.FRAME_STEAM / "21_Room-0.01m" + d.mkdir() + (d / "room-0.01m.gallery.png").write_bytes(b"preview") + server.frame_cache["t"] = 0 + assert server.versions_of("Room")[0]["on_frame"] is True + g = {x["folder"]: x for x in api.get("/api/frame/gallery?refresh=1")[1]["scenes"]}["21_Room-0.01m"] + assert (g["kind"], g["slug"], g["version"], g["place"]) == ("version", "Room", "0.01m", "steam") + + +def test_nothing_is_moved_or_removed_on_the_frame_while_copying(api, frame): + room() + make("Room", "10000") + d = frame / server.FRAME_STEAM / "20_Room-0.01m" # an old copy, being copied again right now + d.mkdir() + (d / "room-0.01m.gallery.png").write_bytes(b"preview") + os.environ["FAKE_SCP_SLOW"] = "1" + assert api.post("/api/upload", {"slug": "Room", "version": "0.01m"})[0] == 200 + time.sleep(0.5) + for path, body in (("/api/frame/move", {}), ("/api/frame/remove", {"folder": "20_Room-0.01m"}), + ("/api/version/delete", {"slug": "Room", "version": "0.01m"})): + status, r = api.post(path, body) + assert status == 400 and r["error"]["code"] in ("err.upload_busy", "err.scene_busy"), path + end = time.time() + 30 + while (u := server.upload_status("Room#0.01m")) and u["running"] and time.time() < end: + time.sleep(0.2) + assert server.upload_status("Room#0.01m")["ok"] diff --git a/web/i18n.js b/web/i18n.js index 001ab09..76ddbed 100644 --- a/web/i18n.js +++ b/web/i18n.js @@ -232,7 +232,7 @@ const I18N = { "fg.move.hint": "{n} scene(s) from Splat Workshop are still in Gaussian XR's own folder, where a Steam update can delete them.", "fg.move": "Move to own scenes", "fg.moved": "Moved to the own scenes on the Frame", "ver.recommended": "(recommended)", - "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", + "err.mixed_scale": "These scenes have different scales – set one scale for the group", "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", "plan.measure.undo": "Undo", "plan.measure.reset": "Reset", @@ -473,7 +473,7 @@ const I18N = { "fg.move.hint": "{n} Szene(n) aus der Werkstatt liegen noch im Ordner von Gaussian XR, wo ein Steam-Update sie löschen kann.", "fg.move": "Zu den eigenen Szenen verschieben", "fg.moved": "Zu den eigenen Szenen auf der Frame verschoben", "ver.recommended": "(empfohlen)", - "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", + "err.mixed_scale": "Die Szenen haben verschiedene Maßstäbe – einen Maßstab für die Gruppe setzen", "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", "plan.measure.undo": "Rückgängig", "plan.measure.reset": "Zurücksetzen", diff --git a/web/index.html b/web/index.html index 4a36316..f0c1a2f 100644 --- a/web/index.html +++ b/web/index.html @@ -1034,7 +1034,7 @@ async function loadScenes() { document.querySelectorAll("[data-combine]").forEach(b => b.onclick = () => openCombine(S.groups.find(g => g.slug === b.dataset.combine))); document.querySelectorAll("[data-gplan]").forEach(b => b.onclick = () => { const g = S.groups.find(x => x.slug === b.dataset.gplan); - openPlan(g.members.filter(m => bySlug[m] && bySlug[m].ply), g.name); + openPlan(g.members.filter(m => bySlug[m] && bySlug[m].ply), g.name, g.slug); }); document.querySelectorAll("[data-upload]").forEach(b => b.onclick = async () => { const r = await api("/api/upload", {slug: b.dataset.upload}); @@ -1371,11 +1371,12 @@ const P = {slugs: [], name: "", style: "pretty"}; function planUrl(download) { const q = new URLSearchParams({slugs: P.slugs.join(","), style: P.style, lang: LANG, title: P.slugs.length > 1 ? P.name : ""}); + if (P.group) q.set("group", P.group); if (download) { q.set("download", "1"); q.set("name", `${P.name}-${tr("plan.file")}`); } return "/api/floorplan?" + q; } -function openPlan(slugs, name) { - P.slugs = slugs; P.name = name; +function openPlan(slugs, name, group) { + P.slugs = slugs; P.name = name; P.group = group || ""; $("#planTitle").textContent = tr(slugs.length > 1 ? "plan.title.all" : "plan.title", {name}); $("#planStyle").classList.remove("hidden"); $("#planSave").classList.remove("hidden"); $("#planMeasure").classList.remove("hidden"); if (!$("#planDlg").open) $("#planDlg").showModal();