Scenes go to Gaussian XR's personal scenes folder; 1 M splats recommended for the Frame

Documents/GaussianXR is left alone by Steam updates. Old copies in the app's own Scenes folder
move over when copied again, or all at once from the On the Frame tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
thunderheronightandClaude Opus 5.5 committed 2026-10-14 16:17:54 +02:00
1 parent e69961ad3e
commit daf6a17241
9 files changed
+178 -43

No files matched your search

+4 -2
View File
@@ -14,8 +14,10 @@ All notable changes to Splat Workshop. The format follows [Keep a Changelog](htt
### Changed
- **No backup copies on the Frame any more**: scenes are copied straight into the gallery. Everything is on the PC, and *Copy again* restores a scene after a Steam update. Copies left in `Documents/GaussianSplats` by older versions are removed together with their scene.
- 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 – 5 M ran at about 14 fps on the Frame).
- **Scenes go to Gaussian XR's folder for personal scenes** (`Documents/GaussianXR` on the Frame) instead of the app's own `Scenes` folder, which a Steam update or "verify files" can reset. They appear in the gallery after Gaussian XR's examples. Scenes copied by older versions move over when copied again, or all at once with *Move to own scenes* in the *On the Frame* tab; numbers of new folders count both places.
- **No backup copies on the Frame any more**: everything is on the PC, and *Copy again* restores a scene. Copies left in `Documents/GaussianSplats` by older versions are removed together with their scene.
- **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).
### Fixed
+2 -2
View File
@@ -21,7 +21,7 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
1. **Pick a recording** directly from the Frame (over SSH) or from your PC, with thumbnails.
2. **Check it** before spending an hour on it: brightness, structure, fast pans and covered lenses, second by second.
3. **Convert it** with a quality preset (Fast / High / Maximum) or fine-tuned values, with live progress, ETA and a PSNR curve.
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's `Scenes` folder on the Frame.
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's folder for personal scenes on the Frame (`Documents/GaussianXR`).
5. **Put your home together**: group the rooms and turn them into one tour you walk through – full size for the PC, smaller for the Frame.
## New in 0.2
@@ -117,7 +117,7 @@ Start **Splat Workshop** from the menu (or `./splat-workshop`). The tray icon ap
| `scripts/to-frame.sh <run> "<name>"` | (re)copy a finished scene to the Frame |
| `scripts/combine.sh <name> "<display name>" <run>=<label> ...` | put scenes together into a tour (`ALIGN=frame\|images\|shift\|rigid`, `FRAME_SPLATS`, `FULL=0`, `NO_UPLOAD=1`) |
After copying, **restart Gaussian XR** on the Frame so the new scene appears in the gallery. A Steam “verify files” or app update can reset Gaussian XR's `Scenes` folder – everything stays on the PC, and *Copy again* restores a scene. The *On the Frame* tab under the finished scenes shows what is in the gallery on the Frame and takes things off it.
After copying, **restart Gaussian XR** on the Frame so the new scene appears in the gallery. Scenes go to `Documents/GaussianXR`, which Steam updates leave alone (Splat Workshop 0.2.3 and older used Gaussian XR's own `Scenes` folder – *Move to own scenes* in the *On the Frame* tab moves them over). Everything also stays on the PC, and *Copy again* restores a scene. For the Frame, about **1 M splats** runs smoothly and still looks sharp. The *On the Frame* tab under the finished scenes shows what is in the gallery on the Frame and takes things off it.
### Access from other devices
+2 -2
View File
@@ -7,7 +7,7 @@
# Environment:
# ALIGN=frame|images|shift|rigid how the rooms are fitted together (default: images, see combine.py)
# PARTITION=view|nearest where the borders between the recordings run (default: view)
# FRAME_SPLATS=3500000 splats in the Frame version (the full version keeps all)
# FRAME_SPLATS=1000000 splats in the Frame version (the full version keeps all)
# FULL=0 skip the full SOG for the PC (the step that needs the most memory)
# FULL_NAME="..." name plate of the full version (default: "<name> (full)")
# PLAN_COMMA=1 decimal comma on the floor plan used as preview
@@ -37,7 +37,7 @@ log=$RUNS_DIR/$slug.log
work=$RUNS_DIR/.building
tmp=$work/$slug
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
frame_splats=${FRAME_SPLATS:-3500000}
frame_splats=${FRAME_SPLATS:-1000000}
# Gaussian XR's worldScale in a scene's transform file, when WORLD_SCALE is set (a model shown in the
# size of the original, e.g. 87 for H0)
+16 -5
View File
@@ -5,7 +5,8 @@
# Usage: scripts/to-frame.sh <run folder> "<display name>"
# <run folder> runs/<name> or just <name>
# GALLERY_ONLY=1 only create the gallery files (preview image, name plate) in the run folder
# TARGET=NN_name use this scene folder on the Frame (default: same name, or next free number from 20)
# TARGET=NN_name use this scene folder on the Frame (default: same name, or next free number from 20;
# a folder of that name in Gaussian XR's own gallery folder is moved over)
# STEM=name file stem (default: folder name in lower case)
# SLUG=name name for a new scene folder on the Frame (default: the run folder's name)
# LOG=file pipeline log with the initial view (default: runs/<folder>.log)
@@ -17,7 +18,10 @@ set -euo pipefail
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT=$(dirname "$HERE")
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
FRAME_SCENES=.local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes
# Gaussian XR's folder for personal scenes – Steam updates leave it alone. Its own gallery folder
# (FRAME_STEAM) holds the examples, and scenes copied by Splat Workshop 0.2.3 and older.
FRAME_SCENES=Documents/GaussianXR
FRAME_STEAM=.local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes
run=${1:?Give a run folder}
name=${2:?Give a display name}
@@ -55,14 +59,21 @@ uv run -q "$HERE/make_gallery.py" "$out" "$log" "$name" "$stem"
echo "@@ phase upload"
echo "Copying to the Frame ..."
# Reuse an existing folder of the same name, otherwise the next free number from 20
# (numbers are counted in both folders, so a scene never has the same number as another in the gallery)
folder=${TARGET:-$(ssh -o ConnectTimeout=8 "$FRAME_HOST" "
existing=\$(ls -d $FRAME_SCENES/[0-9][0-9]_$slug 2>/dev/null | head -1)
found() { ls -d $FRAME_SCENES/\$1 $FRAME_STEAM/\$1 2>/dev/null | head -1; }
existing=\$(found '[0-9][0-9]_$slug')
if [ -n \"\$existing\" ]; then basename \"\$existing\"; exit; fi
n=20; while ls -d $FRAME_SCENES/\${n}_* >/dev/null 2>&1; do n=\$((n+1)); done
n=20; while [ -n \"\$(found \${n}_\*)\" ]; do n=\$((n+1)); done
echo \${n}_$slug")}
case "$folder" in [0-9][0-9]_?*) ;; *) echo "Unusable folder on the Frame: '$folder'" >&2; exit 1 ;; esac
case "$folder" in */*|*..*) echo "Unusable folder on the Frame: '$folder'" >&2; exit 1 ;; esac
# Straight into the gallery – everything is on the PC, copying again restores a scene.
# Files of this scene under an earlier name (renamed in Splat Workshop) go first, so the folder holds one scene.
ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_SCENES/$folder && cd $FRAME_SCENES/$folder && \
# A copy made by 0.2.3 or older in Gaussian XR's own gallery folder moves to the personal one.
ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_SCENES && \
if [ -d $FRAME_STEAM/$folder ] && [ ! -e $FRAME_SCENES/$folder ]; then mv $FRAME_STEAM/$folder $FRAME_SCENES/; fi && \
rm -rf $FRAME_STEAM/$folder && mkdir -p $FRAME_SCENES/$folder && cd $FRAME_SCENES/$folder && \
for f in *.sog *.xrSceneTransform.json *.gallery.png *.sceneinfo.png; do
case \$f in $stem.*) ;; *) rm -f -- \"\$f\" ;; esac
done"
+60 -19
View File
@@ -43,7 +43,10 @@ STATE = STATE_DIR / "state.json"
FRAME = CFG.frame_host
FRAME_VIDEOS = "Videos/ArcturusVisionCamera"
FRAME_SCENES = ".local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes"
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()
@@ -172,7 +175,7 @@ def frame_listing(force=False):
if not force and time.time() - frame_cache["t"] < 20:
return frame_cache["data"]
cmd = (f'(cd {FRAME_VIDEOS} 2>/dev/null && stat -c "%n|%s|%Y" *_stereo.mp4 2>/dev/null); echo ---; '
f'ls {FRAME_VIDEOS}/thumbnails 2>/dev/null; echo ---; ls {FRAME_SCENES} 2>/dev/null')
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:
@@ -831,9 +834,10 @@ def frame_gallery(force=False):
return None
if not force and time.time() - gallery_cache["t"] < GALLERY_TTL:
return gallery_cache["data"]
cmd = (f'cd {FRAME_SCENES} 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 "$d|$(du -sb "$d" | cut -f1)|${{g##*/}}"; done; true')
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:
@@ -854,9 +858,10 @@ def frame_gallery(force=False):
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 not RX_FOLDER.fullmatch(folder):
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)):
@@ -867,23 +872,29 @@ def frame_gallery(force=False):
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, "size": int(size or 0), "preview": preview, "slug": slug, "version": version,
"kind": kind, "name": name})
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 frame_preview(folder: str):
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 = next((x for x in frame_gallery() or [] if x["folder"] == folder), None)
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'{folder}|{g['size']}'.encode()).hexdigest()[:16]}.png"
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_SCENES}/{shq(folder)}/{shq(g['preview'])}"], capture_output=True, timeout=20)
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:
@@ -892,7 +903,7 @@ def frame_preview(folder: str):
return r.stdout
def remove_frame_folder(folder: str):
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:
@@ -900,14 +911,14 @@ def remove_frame_folder(folder: str):
gallery = frame_gallery(force=True)
if gallery is None:
return err("err.frame_offline")
g = next((x for x in gallery if x["folder"] == folder), None)
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_SCENES}/{shq(folder)} {FRAME_BACKUP}/{shq(folder)}"],
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")
@@ -938,7 +949,35 @@ def remove_from_frame(slug: str, version: str):
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"])
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")
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):
return err("err.upload_busy")
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):
@@ -1634,7 +1673,7 @@ def start_combine(p: dict):
if align not in ALIGNS or partition not in PARTITIONS:
return err("err.bad_request")
try:
frame_splats = int(p.get("frame_splats") or 3_500_000)
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:
@@ -1809,7 +1848,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
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", ""))
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", ""))})
@@ -1877,8 +1916,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
elif path == "/api/versions":
e = start_versions(p)
elif path == "/api/frame/remove":
e = (remove_frame_folder(p["folder"]) if p.get("folder") else
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/version/delete":
e = delete_version(p.get("slug", ""), p.get("version", ""))
elif path == "/api/pick":
+1 -1
View File
@@ -213,7 +213,7 @@ def frame(monkeypatch):
monkeypatch.setattr(server, "FRAME", host)
monkeypatch.setattr(server, "SSH", [*server.SSH[:-1], host])
os.environ["FRAME_HOST"] = host # for the scripts (splat.sh, to-frame.sh)
for d in (server.FRAME_VIDEOS + "/thumbnails", server.FRAME_SCENES):
for d in (server.FRAME_VIDEOS + "/thumbnails", server.FRAME_SCENES, server.FRAME_STEAM):
(FRAME_HOME / d).mkdir(parents=True, exist_ok=True)
return FRAME_HOME
+64 -4
View File
@@ -17,7 +17,7 @@ def upload(api, slug, version=""):
def example(frame, name="04_BurtLakeHouse"):
d = frame / server.FRAME_SCENES / name
d = frame / server.FRAME_STEAM / name # Gaussian XR's own gallery folder
d.mkdir()
(d / "trained-572k.htgs.gallery.png").write_bytes(b"example preview")
(d / "trained-572k.htgs.ply").write_bytes(b"x" * 5000)
@@ -32,7 +32,7 @@ def test_the_gallery_on_the_frame(api, frame):
wait_job()
upload(api, "Kitchen", "0.01m")
example(frame)
(frame / server.FRAME_SCENES / "common_audio").mkdir() # not a scene: no preview image
(frame / server.FRAME_STEAM / "common_audio").mkdir() # not a scene: no preview image
status, r = api.get("/api/frame/gallery?refresh=1")
assert status == 200 and r["online"]
by = {x["folder"]: x for x in r["scenes"]}
@@ -41,6 +41,7 @@ def test_the_gallery_on_the_frame(api, frame):
assert (by["21_Kitchen-0.01m"]["kind"], by["21_Kitchen-0.01m"]["version"]) == ("version", "0.01m")
assert (by["04_BurtLakeHouse"]["kind"], by["04_BurtLakeHouse"]["name"]) == ("other", "BurtLakeHouse")
assert by["04_BurtLakeHouse"]["size"] >= 5000
assert (by["04_BurtLakeHouse"]["place"], by["20_Kitchen"]["place"]) == ("steam", "own")
status, png = api.get("/api/frame/preview?folder=04_BurtLakeHouse")
assert status == 200 and png == b"example preview"
assert api.get("/api/frame/preview?folder=../../.ssh")[0] == 404
@@ -64,9 +65,68 @@ def test_an_example_scene_can_be_deleted_too(api, frame):
def test_only_scenes_of_the_gallery_can_be_removed(api, frame):
(frame / server.FRAME_SCENES / "common_audio").mkdir()
(frame / server.FRAME_STEAM / "common_audio").mkdir()
(frame / "Videos" / "keep.txt").write_text("x")
for folder in ("common_audio", "..", "../..", "../../Videos", "nope"):
status, r = api.post("/api/frame/remove", {"folder": folder})
assert status == 400, folder
assert (frame / server.FRAME_SCENES / "common_audio").exists() and (frame / "Videos" / "keep.txt").exists()
assert (frame / server.FRAME_STEAM / "common_audio").exists() and (frame / "Videos" / "keep.txt").exists()
def old_copy(frame, folder, stem, content=b"old"):
"""A scene copied by Splat Workshop 0.2.3 or older: in Gaussian XR's own gallery folder."""
d = frame / server.FRAME_STEAM / folder
d.mkdir()
for ext in ("sog", "xrSceneTransform.json", "sceneinfo.png"):
(d / f"{stem}.{ext}").write_bytes(content)
(d / f"{stem}.gallery.png").write_bytes(b"preview")
return d
def test_scenes_go_to_the_personal_folder_and_numbers_count_both(api, frame):
old_copy(frame, "20_Other", "other") # e.g. a scene of someone else
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
upload(api, "Kitchen")
assert sorted(p.name for p in (frame / server.FRAME_SCENES).iterdir()) == ["21_Kitchen"]
assert (frame / server.FRAME_STEAM / "20_Other").exists()
def test_copying_again_moves_an_old_copy_over(api, frame):
old = old_copy(frame, "22_Kitchen", "kitchen")
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
upload(api, "Kitchen")
assert not old.exists()
new = frame / server.FRAME_SCENES / "22_Kitchen"
assert (new / "kitchen.sog").read_bytes() != b"old"
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "22_Kitchen"
def test_moving_the_old_copies_to_the_personal_folder(api, frame):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
synth.make_room(RUNS, "Hallway", "B", name="Hallway")
old_copy(frame, "20_Kitchen", "kitchen")
old_copy(frame, "21_Hallway", "hallway")
example(frame)
newer = frame / server.FRAME_SCENES / "21_Hallway" # already copied again: the newer one stays
newer.mkdir(parents=True)
(newer / "hallway.sog").write_bytes(b"new")
(newer / "hallway.gallery.png").write_bytes(b"preview")
assert api.post("/api/frame/move", {})[0] == 200
assert sorted(p.name for p in (frame / server.FRAME_STEAM).iterdir()) == ["04_BurtLakeHouse"] # the example stays
assert sorted(p.name for p in (frame / server.FRAME_SCENES).iterdir()) == ["20_Kitchen", "21_Hallway"]
assert (frame / server.FRAME_SCENES / "20_Kitchen" / "kitchen.sog").read_bytes() == b"old"
assert (newer / "hallway.sog").read_bytes() == b"new"
kinds = {(x["folder"], x["place"]) for x in api.get("/api/frame/gallery?refresh=1")[1]["scenes"]}
assert kinds == {("04_BurtLakeHouse", "steam"), ("20_Kitchen", "own"), ("21_Hallway", "own")}
assert {s["slug"]: s["on_frame"] for s in server.scenes()} == {"Kitchen": True, "Hallway": True}
def test_the_same_folder_in_both_places(api, frame):
old_copy(frame, "20_Kitchen", "kitchen")
d = frame / server.FRAME_SCENES / "20_Kitchen"
d.mkdir()
(d / "kitchen.gallery.png").write_bytes(b"own preview")
assert api.get("/api/frame/preview?folder=20_Kitchen&place=own") == (200, b"own preview")
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "steam"})[0] == 200
assert d.exists() and not (frame / server.FRAME_STEAM / "20_Kitchen").exists()
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "nowhere"})[0] == 400
+10
View File
@@ -213,6 +213,11 @@ const I18N = {
"fg.confirm": "Take “{name}” ({size}) off the Frame? It stays on this PC and can be copied again any time.",
"fg.confirm.other": "“{name}” ({size}) was not made with Splat Workshop – e.g. one of Gaussian XR's examples. Delete it from the Frame anyway? Steam brings the examples back with “verify integrity of game files”.",
"fg.removed": "“{name}” taken off the Frame",
"fg.place.own": "Own scenes", "fg.place.own.title": "Documents/GaussianXR on the Frame – Steam updates leave it alone",
"fg.place.steam": "Gaussian XR folder", "fg.place.steam.title": "Gaussian XR's own gallery folder – a Steam update or “verify files” can reset it",
"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",
"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",
@@ -435,6 +440,11 @@ const I18N = {
"fg.confirm": "„{name}“ ({size}) von der Frame nehmen? Auf dem PC bleibt sie und lässt sich jederzeit neu übertragen.",
"fg.confirm.other": "„{name}“ ({size}) stammt nicht aus der Splat-Werkstatt – z. B. ein Beispiel von Gaussian XR. Trotzdem von der Frame löschen? Steam stellt die Beispiele mit „Dateien auf Fehler überprüfen“ wieder her.",
"fg.removed": "„{name}“ von der Frame genommen",
"fg.place.own": "Eigene Szenen", "fg.place.own.title": "Documents/GaussianXR auf der Frame – Steam-Updates lassen den Ordner in Ruhe",
"fg.place.steam": "Ordner von Gaussian XR", "fg.place.steam.title": "Der Galerie-Ordner von Gaussian XR selbst – ein Steam-Update oder „Dateien überprüfen“ kann ihn zurücksetzen",
"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",
"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",
+19 -8
View File
@@ -335,6 +335,8 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
</div>
<div id="frameView" class="hidden">
<p class="faint" style="font-size:12.5px;margin:0 0 12px" data-i18n="fg.hint"></p>
<div id="frameMove" class="hidden" style="font-size:12.5px;margin:0 0 12px"><span id="frameMoveText"></span>
<button class="small" id="frameMoveBtn" data-i18n="fg.move"></button></div>
<div id="frameGrid" class="grid"></div>
</div>
</section>
@@ -1047,7 +1049,7 @@ function openCombine(g) {
radios("#cmbPart", "cmb.part", ["view", "nearest"], o.partition || "view");
$("#cmbTitle").textContent = tr("cmb.title", {name: g.name});
const last = o.frame_splats || 1000000, known = FRAME_SIZES.some(m => m * 1e6 === last);
$("#cmbSize").innerHTML = FRAME_SIZES.map(m => `<option value="${m * 1e6}">${sizeText(m)}</option>`).join("")
$("#cmbSize").innerHTML = FRAME_SIZES.map(m => `<option value="${m * 1e6}">${sizeText(m)}${m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""}</option>`).join("")
+ `<option value="custom">${tr("ver.custom")}</option>`;
$("#cmbSize").value = known ? String(last) : "custom";
$("#cmbSizeCustom").value = known ? "" : last / 1e6;
@@ -1086,6 +1088,11 @@ function setSceneTab(tab) {
}
document.querySelectorAll(".tab[data-stab]").forEach(t => t.onclick = () => setSceneTab(t.dataset.stab));
$("#frameRefresh").onclick = () => loadFrameGallery(true);
$("#frameMoveBtn").onclick = async () => {
const res = await api("/api/frame/move", {});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("fg.moved")); loadFrameGallery(true); loadScenes();
};
async function loadFrameGallery(refresh) {
const grid = $("#frameGrid");
if (!grid.children.length) grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("fg.loading")}</div>`;
@@ -1094,18 +1101,21 @@ async function loadFrameGallery(refresh) {
if (!r.online) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("frame.unreachable")}</div>`; return; }
const list = (r.scenes || []).filter(x => x && x.folder);
S.frameGallery = list;
const old = list.filter(x => x.place === "steam" && x.kind !== "other").length;
$("#frameMove").classList.toggle("hidden", !old);
$("#frameMoveText").textContent = tr("fg.move.hint", {n: old}) + " ";
grid.innerHTML = list.length ? list.map(x => `
<div class="rec scene" style="cursor:default">
<div class="thumb" style="background-image:url('/api/frame/preview?folder=${encodeURIComponent(x.folder).replace(/'/g, "%27")}&amp;s=${x.size}')"></div>
<div class="thumb" style="background-image:url('/api/frame/preview?folder=${encodeURIComponent(x.folder).replace(/'/g, "%27")}&amp;place=${x.place}&amp;s=${x.size}')"></div>
<div class="meta"><b>${esc(x.name)}</b>
<span>${tr("fg.kind." + x.kind)} · ${mb(x.size)}</span>
<span class="faint" style="overflow-wrap:anywhere">${esc(x.folder)}</span>
<div class="acts"><button class="small" data-fgdel="${esc(x.folder)}" title="${tr("fg.remove.title")}">🗑 ${tr("fg.remove")}</button></div>
<span class="faint" style="overflow-wrap:anywhere" title="${tr("fg.place." + x.place + ".title")}">${tr("fg.place." + x.place)} · ${esc(x.folder)}</span>
<div class="acts"><button class="small" data-fgdel="${esc(x.place + "/" + x.folder)}" title="${tr("fg.remove.title")}">🗑 ${tr("fg.remove")}</button></div>
</div></div>`).join("") : `<div class="empty" style="grid-column:1/-1">${tr("fg.empty")}</div>`;
grid.querySelectorAll("[data-fgdel]").forEach(b => b.onclick = async () => {
const x = S.frameGallery.find(y => y.folder === b.dataset.fgdel);
const x = S.frameGallery.find(y => y.place + "/" + y.folder === b.dataset.fgdel);
if (!confirm(tr(x.kind === "other" ? "fg.confirm.other" : "fg.confirm", {name: x.name, size: mb(x.size)}))) return;
const res = await api("/api/frame/remove", {folder: x.folder});
const res = await api("/api/frame/remove", {folder: x.folder, place: x.place});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("fg.removed", {name: x.name})); loadFrameGallery(true); loadScenes();
});
@@ -1113,14 +1123,15 @@ async function loadFrameGallery(refresh) {
/* ---------- versions ---------- */
const FRAME_SIZES = [0.75, 1, 1.5, 2], PC_SIZES = ["all", 3, 5, 8]; // million splats
const FRAME_RECOMMENDED = 1; // measured on the Frame: as fast as 0.75 M nearly, clearly sharper; 1.5 M already a little unsteady
function sizeText(m) { return m === "all" ? tr("ver.all") : tr("million", {n: num(m, Math.round(m * 100) % 10 ? 2 : m % 1 ? 1 : 0)}); }
async function openVersions(slug) {
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
if (!x) return;
const total = (g ? g.result && g.result.splats : x.gaussians) || 0, scale = x.scale || 1;
$("#verTitle").textContent = tr("ver.title", {name: x.name, n: tr("million", {n: num(total / 1e6, 2)})});
const chk = (val, text) => `<label class="chk"><input type="checkbox" value="${val}"> ${text}</label>`;
$("#verFrame").innerHTML = FRAME_SIZES.map(m => chk(m, sizeText(m))).join("");
const chk = (val, text, on) => `<label class="chk"><input type="checkbox" value="${val}"${on ? " checked" : ""}> ${text}</label>`;
$("#verFrame").innerHTML = FRAME_SIZES.map(m => chk(m, sizeText(m) + (m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""), m === FRAME_RECOMMENDED)).join("");
$("#verPc").innerHTML = PC_SIZES.filter(m => m === "all" || m * 1e6 < total).map(m => chk(m, sizeText(m))).join("");
$("#verCustom").value = "";
$("#verScaleHead").classList.toggle("hidden", scale === 1);