mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 10:01:29 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a9b5a0f1 | ||
|
|
a1f9df0d1f | ||
|
|
bc3345a5aa | ||
|
|
daf6a17241 | ||
|
|
e69961ad3e | ||
|
|
dfaa6a4a1c | ||
|
|
7e64f039d6 | ||
|
|
fb1c0ff515 |
No files matched your search
@@ -2,6 +2,33 @@
|
||||
|
||||
All notable changes to Splat Workshop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.2.3] – 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Scale for models**, e.g. a model railway: set Z, N, TT, H0, S, 0, G or your own scale for a scene or a whole group (optionally passed on to its scenes). Floor plan, room sizes, the scale bar and the measuring tool then show the size of the original – a 10 cm H0 model reads as 8.7 m. The floor plan also looks for walls and floors in the model's scale, so a model is drawn like a real building.
|
||||
- **Walk through a model in its original size** in Gaussian XR (option): Splat Workshop sets Gaussian XR's `worldScale` when copying a scene or tour to the Frame – the scene files on the PC stay unchanged. *Not tested on the Frame yet.*
|
||||
|
||||
- **Versions** of a scene or tour, several side by side: fewer splats for the Frame (0.75 / 1 / 1.5 / 2 M), all or a limit for the PC (3 / 5 / 8 M), or an own size – each also in the original's size for models. Every version gets its own place in the Frame's gallery (e.g. "Flat · 1 M" next to "Flat · 2 M"), so you can find out yourself what runs smoothly. Frame rates measured on a Steam Frame are shown for information.
|
||||
- **Fetch several recordings from the Frame at once**: *Fetch all* (everything not on this PC yet) or tick recordings and *Fetch selection*. They come one after the other, with one progress bar for all and *Cancel*; recordings ticked while fetching join the queue. A notification at the end says when the Frame can be switched off; one that fails does not stop the others.
|
||||
- **Delete a recording**, one at a time: on the Frame (for good – with a clear warning if it is not on this PC yet) or on this PC (to the trash). A recording that is being converted, checked or fetched, or that an unfinished scene still needs, stays.
|
||||
- **"On the Frame" tab** under the finished scenes: everything in Gaussian XR's gallery on the Frame with its preview image – scenes, tours, versions and Gaussian XR's own examples – and *Off the Frame* to delete any of them there (the PC is not touched).
|
||||
|
||||
### Changed
|
||||
|
||||
- **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).
|
||||
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
@@ -65,6 +92,7 @@ First release.
|
||||
- **Floor plans** (experimental) in two styles with a measuring tool.
|
||||
- Configurable video player; English and German interface.
|
||||
|
||||
[0.2.3]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.1.0...v0.2.0
|
||||
|
||||
@@ -45,6 +45,9 @@ Alle Änderungen: [CHANGELOG.md](CHANGELOG.md) (englisch)
|
||||
- **Gruppen und Rundgänge**: Szenen gruppieren (z. B. alle Räume der Wohnung) und zu einer Szene zusammensetzen – eine volle Fassung für Gaussian XR am PC und eine kleinere für die Frame. Aufnahmen aus einem Durchgang teilen das Tracking-Koordinatensystem der Frame; die Werkstatt richtet die Räume aneinander aus (Korrekturen A–D zur Wahl), legt die Grenzen zwischen den Aufnahmen in die Türen und zeigt, wie gut die Räume aneinanderpassen. Vor dem Start schätzt sie den Speicherbedarf.
|
||||
<br><img src="docs/combine.png" width="360" alt="Dialog „Zusammensetzen“: Korrekturen A bis D, Grenzen zwischen den Aufnahmen, Größe der Frame-Fassung, volle Fassung, Speicherschätzung">
|
||||
<br><sub>Beispieldaten (englische Oberfläche) – Raumnamen und Vorschaubilder sind Platzhalter.</sub>
|
||||
- **Fassungen** einer Szene oder eines Rundgangs nebeneinander: weniger Splats für die Frame (0,75–2 Mio.), alle oder eine Obergrenze für den PC oder eine eigene Größe – jede mit eigenem Platz in der Galerie der Frame, zum Vergleichen, was flüssig läuft.
|
||||
- Reiter **Auf der Frame**: alles, was in der Galerie von Gaussian XR auf der Frame liegt, mit Vorschaubildern – und von dort aufräumen.
|
||||
- **Maßstab für Modelle** wie eine Modelleisenbahn (Z, N, TT, H0, S, 0, G oder eigener): Grundriss, Größen und Messwerkzeug zeigen die Größe des Originals, und auf Wunsch zeigt Gaussian XR das Modell begehbar in Originalgröße *(an der Frame noch nicht getestet)*.
|
||||
- **Grundrisse** *(experimentell)* einzelner Szenen oder einer ganzen Gruppe – Aufnahmen aus einem Durchgang fügen sich von selbst zum Grundriss der ganzen Wohnung. Zwei Stile: *Hübsch* (Draufsicht in Farbe, Wände, gelaufener Weg) und *Klassisch* (leuchtende Punktdichte), dazu ein einfaches Messwerkzeug (Punkte anklicken, Abstände ablesen – etwa ±5 cm genau).
|
||||
- **Fertige Szenen**: später auf die Frame übertragen, erneut übertragen, umbenennen (Ordner und Namensschild ziehen mit), löschen (in den Papierkorb), Ordner oder Spirula Studio öffnen.
|
||||
- **Abgebrochene Läufe** werden mit ihrem Stand angezeigt: fortsetzen, neu starten oder löschen.
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +47,10 @@ All changes: [CHANGELOG.md](CHANGELOG.md)
|
||||
- **Groups and tours**: group scenes (e.g. all rooms of your home) and put them together into one scene – a full version for Gaussian XR on the PC and a smaller one for the Frame. Recordings from one session share the Frame's tracking frame; Splat Workshop fits the rooms to each other (corrections A–D to choose), lets the borders between recordings run through doors and shows how well the rooms meet. Before starting it estimates the memory needed.
|
||||
<br><img src="docs/combine.png" width="360" alt="Dialog “Put together”: corrections A to D, borders between the recordings, size of the Frame version, full version, memory estimate">
|
||||
<br><sub>Example data – room names and preview images are placeholders.</sub>
|
||||
- **Versions** of a scene or tour side by side: fewer splats for the Frame (0.75–2 M), all or a limit for the PC, or your own size – each with its own place in the Frame's gallery, so you can compare what runs smoothly.
|
||||
- **Fetch all** recordings from the Frame, or a selection, one after the other – with a notification when the Frame can be switched off.
|
||||
- **On the Frame** tab: everything in Gaussian XR's gallery on the Frame with preview images, and taking things off it.
|
||||
- **Scale for models** like a model railway (Z, N, TT, H0, S, 0, G or your own): floor plan, sizes and the measuring tool show the original's size, and optionally Gaussian XR shows the model in its original size to walk through *(not tested on the Frame yet)*.
|
||||
- **Floor plans** *(experimental)* of single scenes or a whole group – recordings from one session line up by themselves into a plan of your whole home. Two styles: *Pretty* (colour top view, walls, the path you walked) and *Classic* (glowing point density), plus a simple measuring tool (click points, get distances – about ±5 cm).
|
||||
<br><img src="docs/floorplan.png" width="640" alt="Combined floor plan of an example home in the classic style, with the measuring tool">
|
||||
<br><sub>Example home – synthetic data, drawn with the same renderer. Combining rooms relies on the Frame keeping its tracking frame between recordings; how well that holds for a whole home is still being tested.</sub>
|
||||
@@ -114,7 +118,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 – Splat Workshop also keeps a copy in `~/Documents/GaussianSplats` on the Frame, and *Copy again* restores a scene.
|
||||
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
|
||||
|
||||
|
||||
+17
-2
@@ -7,11 +7,12 @@
|
||||
# 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
|
||||
# NO_UPLOAD=1 only compute, don't copy to the Frame
|
||||
# WORLD_SCALE=87 show a model in Gaussian XR in the size of the original (1:87)
|
||||
# A previous result of the same name stays until the new one is done.
|
||||
#
|
||||
# Lines starting with "@@" are machine-readable progress markers for the web interface.
|
||||
@@ -36,7 +37,20 @@ 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)
|
||||
world_scale() {
|
||||
[ -n "${WORLD_SCALE:-}" ] || return 0
|
||||
python3 - "$1" "$WORLD_SCALE" <<'PY'
|
||||
import json, sys
|
||||
path, scale = sys.argv[1], float(sys.argv[2])
|
||||
data = json.load(open(path))
|
||||
data["worldScale"] = scale
|
||||
json.dump(data, open(path, "w"), indent=2)
|
||||
PY
|
||||
}
|
||||
|
||||
main() {
|
||||
rm -rf "$tmp"
|
||||
@@ -74,6 +88,7 @@ main() {
|
||||
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp/full" "${FULL_NAME:-$name (full)}" "$stem-full"
|
||||
cp "$tmp/full/scene.sog" "$tmp/full/$stem-full.sog"
|
||||
cp "$tmp/full/scene.xrSceneTransform.json" "$tmp/full/$stem-full.xrSceneTransform.json"
|
||||
world_scale "$tmp/full/$stem-full.xrSceneTransform.json"
|
||||
fi
|
||||
|
||||
# Replace the previous result only now
|
||||
|
||||
+38
-20
@@ -101,9 +101,11 @@ def heights(y):
|
||||
return np.percentile(y, 97), np.percentile(y, 3)
|
||||
|
||||
|
||||
def wall_angle(points_xz):
|
||||
def wall_angle(points_xz, bin_size=0.03):
|
||||
"""Yaw that makes the walls axis-aligned: maximise the peakiness of the x/z histograms."""
|
||||
best, best_score = 0.0, -1
|
||||
if len(points_xz) < 50: # no walls found: leave it as it is
|
||||
return best
|
||||
for deg in np.arange(0, 90, 0.5):
|
||||
a = np.radians(deg)
|
||||
c, s = np.cos(a), np.sin(a)
|
||||
@@ -111,7 +113,7 @@ def wall_angle(points_xz):
|
||||
v = points_xz[:, 0] * s + points_xz[:, 1] * c
|
||||
score = 0
|
||||
for w in (u, v):
|
||||
h, _ = np.histogram(w, bins=max(10, int((w.max() - w.min()) / 0.03)))
|
||||
h, _ = np.histogram(w, bins=max(10, int((w.max() - w.min()) / bin_size)))
|
||||
score += float(np.sum(h.astype(np.float64) ** 2)) / len(w) ** 2
|
||||
if score > best_score:
|
||||
best, best_score = a, score
|
||||
@@ -142,11 +144,16 @@ def main():
|
||||
ap.add_argument("runs", nargs="+", help="run folder, optionally =label")
|
||||
ap.add_argument("--title", default="")
|
||||
ap.add_argument("--comma", action="store_true", help="decimal comma in sizes")
|
||||
ap.add_argument("--ratio", type=float, default=1.0,
|
||||
help="model scale, e.g. 87 for H0 (1:87): sizes and the scale bar show the original's size")
|
||||
ap.add_argument("--style", choices=("pretty", "classic"), default="pretty",
|
||||
help="pretty: colour top view, glowing walls, camera path; classic: glowing point density only")
|
||||
args = ap.parse_args()
|
||||
out = Path(args.out)
|
||||
rng = np.random.default_rng(1)
|
||||
# All lengths below are in metres of the original: a model (--ratio 87) is measured in its own scale,
|
||||
# so its walls are looked for 1 to 2 cm above its floor instead of 0.9 to 1.9 m.
|
||||
m = 1 / args.ratio
|
||||
scenes = []
|
||||
for arg in args.runs:
|
||||
run, _, label = arg.partition("=")
|
||||
@@ -155,23 +162,25 @@ def main():
|
||||
floor, ceil = heights(xyz[:, 1])
|
||||
# keep the room: below the ceiling, not below the floor, and drop far-away floaters
|
||||
lo, hi = np.percentile(xyz[:, [0, 2]], 0.5, axis=0), np.percentile(xyz[:, [0, 2]], 99.5, axis=0)
|
||||
inside = ((xyz[:, 1] > ceil + 0.25) & (xyz[:, 1] < floor + 0.15)
|
||||
& np.all(xyz[:, [0, 2]] >= lo - 0.3, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3, axis=1))
|
||||
up = floor - xyz[:, 1] # height above the floor in metres
|
||||
walls = inside & (up > 0.9) & (up < 1.9)
|
||||
inside = ((xyz[:, 1] > ceil + 0.25 * m) & (xyz[:, 1] < floor + 0.15 * m)
|
||||
& np.all(xyz[:, [0, 2]] >= lo - 0.3 * m, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3 * m, axis=1))
|
||||
if inside.sum() < 100: # e.g. a model drawn without its scale: keep all
|
||||
inside = np.ones(len(xyz), bool)
|
||||
up = floor - xyz[:, 1] # height above the floor
|
||||
walls = inside & (up > 0.9 * m) & (up < 1.9 * m)
|
||||
scenes.append({"label": label or run.name, "xyz": xyz[inside], "rgb": rgb[inside], "up": up[inside],
|
||||
"walls": xyz[walls][:, [0, 2]], "cams": load_cameras(run), "floor": floor})
|
||||
|
||||
all_walls = np.vstack([s["walls"] for s in scenes])
|
||||
sub = all_walls[rng.choice(len(all_walls), min(len(all_walls), 150_000), replace=False)]
|
||||
angle = wall_angle(sub)
|
||||
angle = wall_angle(sub, 0.03 * m)
|
||||
for s in scenes:
|
||||
s["p"] = rotate(s["xyz"][:, [0, 2]], angle)
|
||||
s["w"] = rotate(s["walls"], angle)
|
||||
s["c"] = rotate(s["cams"][:, [0, 2]], angle) if s["cams"] is not None else None
|
||||
s["box"] = room_box(s["w"])
|
||||
s["box"] = room_box(s["w"], 0.05 * m)
|
||||
if s["box"] is not None: # keep only this room (plus a margin)
|
||||
b0, b1 = s["box"][0] - 0.35, s["box"][1] + 0.35
|
||||
b0, b1 = s["box"][0] - 0.35 * m, s["box"][1] + 0.35 * m
|
||||
ok = np.all((s["p"] >= b0) & (s["p"] <= b1), axis=1)
|
||||
s["p"], s["rgb"], s["up"] = s["p"][ok], s["rgb"][ok], s["up"][ok]
|
||||
okw = np.all((s["w"] >= b0) & (s["w"] <= b1), axis=1)
|
||||
@@ -179,11 +188,11 @@ def main():
|
||||
|
||||
boxes = [s["box"] for s in scenes if s["box"] is not None]
|
||||
if boxes:
|
||||
lo = np.min([b[0] for b in boxes], axis=0) - 0.8
|
||||
hi = np.max([b[1] for b in boxes], axis=0) + 0.8
|
||||
lo = np.min([b[0] for b in boxes], axis=0) - 0.8 * m
|
||||
hi = np.max([b[1] for b in boxes], axis=0) + 0.8 * m
|
||||
else:
|
||||
allp = np.vstack([s["p"] for s in scenes])
|
||||
lo, hi = np.percentile(allp, 1, axis=0) - 0.5, np.percentile(allp, 99, axis=0) + 0.5
|
||||
lo, hi = np.percentile(allp, 1, axis=0) - 0.5 * m, np.percentile(allp, 99, axis=0) + 0.5 * m
|
||||
span = hi - lo
|
||||
ppm = (SIZE - 160) / span.max() # pixels per metre
|
||||
W, H = int(span[0] * ppm) + 160, int(span[1] * ppm) + 200
|
||||
@@ -212,23 +221,32 @@ def main():
|
||||
if s["box"] is not None:
|
||||
(bx0, bz0), (bx1, bz1) = s["box"]
|
||||
cx, cz = px(np.array([[(bx0 + bx1) / 2, (bz0 + bz1) / 2]]))
|
||||
size = f"~ {bx1 - bx0:.1f} × {bz1 - bz0:.1f} m".replace(".", "," if args.comma else ".")
|
||||
size = f"~ {(bx1 - bx0) * args.ratio:.1f} × {(bz1 - bz0) * args.ratio:.1f} m".replace(".", "," if args.comma else ".")
|
||||
for txt, fnt, dy in ((s["label"], f_label, -16), (size, f_small, 16)):
|
||||
tw = d.textlength(txt, font=fnt)
|
||||
d.text((cx[0] - tw / 2 + 1, cz[0] + dy - 13 + 1), txt, font=fnt, fill=(0, 0, 0))
|
||||
d.text((cx[0] - tw / 2, cz[0] + dy - 13), txt, font=fnt, fill=(255, 255, 255) if dy < 0 else (210, 214, 224))
|
||||
|
||||
# scale bar (1 m) and title
|
||||
d.line((W - 60 - ppm, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
|
||||
for xx in (W - 60 - ppm, W - 60):
|
||||
# scale bar: a round length of the original (for a model: of the real thing), about 150 px long
|
||||
real_ppm = ppm / args.ratio # pixels per metre of the original
|
||||
length = min((0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000), key=lambda l: abs(l * real_ppm - 150))
|
||||
bar = length * real_ppm
|
||||
text = (f"{length * 100:g} cm" if length < 1 else f"{length:g} m")
|
||||
d.line((W - 60 - bar, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
|
||||
for xx in (W - 60 - bar, W - 60):
|
||||
d.line((xx, H - 47, xx, H - 33), fill=(230, 230, 230), width=3)
|
||||
d.text((W - 60 - ppm / 2 - 12, H - 72), "1 m", font=f_small, fill=(230, 230, 230))
|
||||
tw = d.textlength(text, font=f_small)
|
||||
d.text((W - 60 - bar / 2 - tw / 2, H - 72), text, font=f_small, fill=(230, 230, 230))
|
||||
if args.ratio != 1:
|
||||
ratio = f"1:{args.ratio:g}".replace(".", "," if args.comma else ".")
|
||||
d.text((W - 60 - bar - 20 - d.textlength(ratio, font=f_small), H - 50), ratio, font=f_small, fill=(230, 230, 230))
|
||||
if args.title:
|
||||
d.text((40, 32), args.title, font=f_title, fill=(235, 238, 245))
|
||||
out_img.save(out)
|
||||
info = [{"label": s["label"], "size": [round(float(v), 2) for v in (s["box"][1] - s["box"][0])] if s["box"] is not None else None}
|
||||
for s in scenes]
|
||||
print(json.dumps({"angle": round(float(np.degrees(angle)), 1), "scenes": info, "pixels_per_metre": round(ppm, 1)}))
|
||||
info = [{"label": s["label"], "size": [round(float(v) * args.ratio, 2) for v in (s["box"][1] - s["box"][0])]
|
||||
if s["box"] is not None else None} for s in scenes]
|
||||
print(json.dumps({"angle": round(float(np.degrees(angle)), 1), "scenes": info, "ratio": args.ratio,
|
||||
"pixels_per_metre": round(real_ppm, 4)}))
|
||||
|
||||
|
||||
def classic(scenes, px, W, H):
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
Usage: uv run make_gallery.py <run folder> <pipeline log> <display name> <file stem>
|
||||
uv run make_gallery.py --plan <floor plan.png> <run folder> <display name> <file stem>
|
||||
uv run make_gallery.py --label <name plate.png> <text>
|
||||
Reads the initial view from the log ("Gaussian XR initial view: cam0/... Display rotation X°")
|
||||
and writes <file stem>.gallery.png and <file stem>.sceneinfo.png into the run folder.
|
||||
The look matches the example scenes that ship with Gaussian XR.
|
||||
@@ -93,6 +94,9 @@ def from_plan(plan: Path, out: Path) -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) == 4 and sys.argv[1] == "--label":
|
||||
label(sys.argv[3], Path(sys.argv[2]))
|
||||
return
|
||||
if len(sys.argv) == 6 and sys.argv[1] == "--plan":
|
||||
plan, run, name, stem = Path(sys.argv[2]), Path(sys.argv[3]), sys.argv[4], sys.argv[5]
|
||||
from_plan(plan, run / f"{stem}.gallery.png")
|
||||
|
||||
+38
-14
@@ -5,8 +5,10 @@
|
||||
# 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)
|
||||
set -euo pipefail
|
||||
# Everything in one block: bash reads it completely before it starts, so updating this file
|
||||
@@ -16,23 +18,39 @@ 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
|
||||
FRAME_BACKUP=Documents/GaussianSplats
|
||||
# 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}
|
||||
[ -d "$run" ] || run=$RUNS_DIR/$run
|
||||
out=$(realpath "$run")
|
||||
slug=$(basename "$out")
|
||||
slug=${SLUG:-$(basename "$out")}
|
||||
[ -f "$out/scene.sog" ] || { echo "No scene.sog in $out" >&2; exit 1; }
|
||||
log=${LOG:-$RUNS_DIR/$slug.log}
|
||||
[ -f "$log" ] || log=$(grep -ls "Gaussian XR initial view" "$RUNS_DIR"/*.log 2>/dev/null | xargs -r grep -l "$out/" | head -1)
|
||||
[ -f "$log" ] || { echo "No pipeline log found for $slug (set LOG=...)" >&2; exit 1; }
|
||||
|
||||
# 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)
|
||||
world_scale() {
|
||||
[ -n "${WORLD_SCALE:-}" ] || return 0
|
||||
python3 - "$1" "$WORLD_SCALE" <<'PY'
|
||||
import json, sys
|
||||
path, scale = sys.argv[1], float(sys.argv[2])
|
||||
data = json.load(open(path))
|
||||
data["worldScale"] = scale
|
||||
json.dump(data, open(path, "w"), indent=2)
|
||||
PY
|
||||
}
|
||||
|
||||
# Gallery files: <stem>.sog / .xrSceneTransform.json / .gallery.png / .sceneinfo.png
|
||||
stem=${STEM:-$(printf '%s' "$slug" | tr 'A-Z' 'a-z')}
|
||||
cp "$out/scene.sog" "$out/$stem.sog"
|
||||
cp "$out/scene.xrSceneTransform.json" "$out/$stem.xrSceneTransform.json"
|
||||
world_scale "$out/$stem.xrSceneTransform.json"
|
||||
uv run -q "$HERE/make_gallery.py" "$out" "$log" "$name" "$stem"
|
||||
|
||||
[ "${GALLERY_ONLY:-0}" = 1 ] && exit 0
|
||||
@@ -41,20 +59,26 @@ 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")}
|
||||
ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_BACKUP/$folder $FRAME_SCENES/$folder"
|
||||
scp -q "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} "$FRAME_HOST:$FRAME_BACKUP/$folder/"
|
||||
# Files of this scene under an earlier name (renamed in Splat Workshop) go first, so the folder holds one scene
|
||||
ssh "$FRAME_HOST" "for d in $FRAME_SCENES/$folder $FRAME_BACKUP/$folder; do
|
||||
for f in \$d/*.sog \$d/*.xrSceneTransform.json \$d/*.gallery.png \$d/*.sceneinfo.png; do
|
||||
case \${f##*/} in $stem.*) ;; *) rm -f -- \"\$f\" ;; esac
|
||||
done
|
||||
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.
|
||||
# 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"
|
||||
ssh "$FRAME_HOST" "cp $FRAME_BACKUP/$folder/$stem.* $FRAME_SCENES/$folder/ && chmod 755 $FRAME_SCENES/$folder/*"
|
||||
scp -q "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} "$FRAME_HOST:$FRAME_SCENES/$folder/"
|
||||
ssh "$FRAME_HOST" "chmod 755 $FRAME_SCENES/$folder/*"
|
||||
echo "@@ done frame $folder"
|
||||
echo
|
||||
echo "Done: \"$name\" is in the Gaussian XR gallery ($folder)."
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# Versions of a finished scene or tour: fewer splats for the Frame, all (or a limit) for the PC,
|
||||
# optionally shown in the original's size (models). Each version is a complete scene in
|
||||
# <run folder>/versions/<key>/ and can be copied to the Frame next to the others.
|
||||
#
|
||||
# Usage: scripts/versions.sh <run folder> "<key>|<splats>|<scale>|<name plate>" ...
|
||||
# <key> folder name of the version, e.g. 1m, 0.75m, all, 1m-1x87
|
||||
# <splats> number of splats, or "all"
|
||||
# <scale> 1, or e.g. 87: Gaussian XR shows it in the original's size (worldScale 87)
|
||||
# <name plate> text on the name plate in the gallery, e.g. "Flat · 1 M"
|
||||
# The splats come from <run folder>/spirula/scene.ply. Lines starting with "@@" are progress markers.
|
||||
set -euo pipefail
|
||||
# Everything in one block: bash reads it completely before it starts, so updating this file
|
||||
# (git pull, editing) does not affect a run that is already going.
|
||||
{
|
||||
|
||||
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
ROOT=$(dirname "$HERE")
|
||||
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
|
||||
|
||||
out=$(realpath "${1:?Give a run folder}")
|
||||
shift
|
||||
[ $# -ge 1 ] || { echo "Give at least one version" >&2; exit 1; }
|
||||
src=$out/spirula/scene.ply
|
||||
[ -f "$src" ] || { echo "No spirula/scene.ply in $out" >&2; exit 1; }
|
||||
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found" >&2; exit 1; }
|
||||
slug=$(basename "$out")
|
||||
preview=$(ls "$out"/*.gallery.png 2>/dev/null | head -1 || true)
|
||||
total=$(python3 - "$src" <<'PY'
|
||||
import sys
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
for line in f:
|
||||
if line.startswith(b"element vertex"):
|
||||
print(int(line.split()[-1])); break
|
||||
PY
|
||||
)
|
||||
echo "Making $# version(s) of $slug ($total splats)"
|
||||
|
||||
i=0
|
||||
for spec in "$@"; do
|
||||
i=$((i + 1))
|
||||
IFS='|' read -r key splats scale plate <<< "$spec"
|
||||
case "$key" in ''|.*|*/*) echo "Unusable version name: $key" >&2; exit 1 ;; esac
|
||||
echo "@@ vstep $i $# $key"
|
||||
dir=$out/versions/$key
|
||||
tmp=$dir.tmp
|
||||
rm -rf "$tmp"
|
||||
mkdir -p "$tmp"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
if [ "$splats" = all ] || [ "$splats" -ge "$total" ]; then
|
||||
echo "$key: all $total splats"
|
||||
"$SPLAT_TRANSFORM" -w "$src" "$tmp/scene.sog"
|
||||
made=$total
|
||||
else
|
||||
echo "$key: reducing $total to $splats splats"
|
||||
"$SPLAT_TRANSFORM" -w "$src" --decimate "$splats" "$tmp/reduced.ply"
|
||||
"$SPLAT_TRANSFORM" -w "$tmp/reduced.ply" "$tmp/scene.sog"
|
||||
rm -f "$tmp/reduced.ply"
|
||||
made=$splats
|
||||
fi
|
||||
stem=$(printf '%s-%s' "$slug" "$key" | tr 'A-Z' 'a-z')
|
||||
python3 - "$out/scene.xrSceneTransform.json" "$tmp/scene.xrSceneTransform.json" "$scale" <<'PY'
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
data["worldScale"] = float(sys.argv[3]) # 1 = original size of the scene
|
||||
json.dump(data, open(sys.argv[2], "w"), indent=2)
|
||||
PY
|
||||
if [ -n "$preview" ]; then
|
||||
cp "$preview" "$tmp/$stem.gallery.png"
|
||||
fi
|
||||
uv run -q "$HERE/make_gallery.py" --label "$tmp/$stem.sceneinfo.png" "$plate"
|
||||
python3 - "$tmp/version.json" "$key" "$made" "$splats" "$scale" "$plate" <<'PY'
|
||||
import json, sys, time
|
||||
path, key, made, target, scale, plate = sys.argv[1:]
|
||||
json.dump({"key": key, "splats": int(made), "target": target, "scale": float(scale), "label": plate,
|
||||
"created": time.time()}, open(path, "w"), indent=1)
|
||||
PY
|
||||
rm -rf "$dir"
|
||||
mv "$tmp" "$dir"
|
||||
trap - EXIT
|
||||
done
|
||||
echo "@@ done local"
|
||||
echo "Done: $out/versions"
|
||||
exit
|
||||
}
|
||||
@@ -13,8 +13,13 @@ TEXTS = {
|
||||
"inhibit.download": "Downloading a recording from the Frame",
|
||||
"inhibit.combine": "Putting “{name}” together",
|
||||
"tour.full_name": "{name} (full)",
|
||||
"inhibit.versions": "Making versions of “{name}”", "ver.all": "all splats", "ver.size": "{n} M",
|
||||
"notify.failed.title": "Conversion failed",
|
||||
"notify.failed": "“{name}” – details in Splat Workshop",
|
||||
"notify.download.title": "Fetched from the Frame",
|
||||
"notify.download": "{n} recording(s) are on this PC – you can switch the Frame off",
|
||||
"notify.download_failed.title": "Fetching failed",
|
||||
"notify.download_failed": "{n} recording(s) could not be fetched ({ok} did) – is the Frame switched on?",
|
||||
"notify.upload.title": "Copied to the Frame",
|
||||
"notify.upload": "“{name}” is in the Gaussian XR gallery",
|
||||
"notify.upload_failed.title": "Copying failed",
|
||||
@@ -62,8 +67,13 @@ TEXTS = {
|
||||
"inhibit.download": "Lädt eine Aufnahme von der Frame",
|
||||
"inhibit.combine": "Setzt „{name}“ zusammen",
|
||||
"tour.full_name": "{name} (voll)",
|
||||
"inhibit.versions": "Erstellt Fassungen von „{name}“", "ver.all": "alle Splats", "ver.size": "{n} Mio.",
|
||||
"notify.failed.title": "Umwandlung fehlgeschlagen",
|
||||
"notify.failed": "„{name}“ – Details in der Splat-Werkstatt",
|
||||
"notify.download.title": "Von der Frame geholt",
|
||||
"notify.download": "{n} Aufnahme(n) liegen auf dem PC – du kannst die Frame abschalten",
|
||||
"notify.download_failed.title": "Holen fehlgeschlagen",
|
||||
"notify.download_failed": "{n} Aufnahme(n) konnten nicht geholt werden ({ok} schon) – ist die Frame an?",
|
||||
"notify.upload.title": "Auf die Frame übertragen",
|
||||
"notify.upload": "„{name}“ liegt in der Gaussian-XR-Galerie",
|
||||
"notify.upload_failed.title": "Übertragung fehlgeschlagen",
|
||||
|
||||
+589
-61
@@ -29,7 +29,7 @@ from pathlib import Path
|
||||
from . import check, config
|
||||
from .i18n import T, detect
|
||||
|
||||
VERSION = "0.2.2"
|
||||
VERSION = "0.2.3"
|
||||
CFG = config.load()
|
||||
ROOT = config.ROOT
|
||||
WEB = ROOT / "web"
|
||||
@@ -43,7 +43,11 @@ 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()
|
||||
# Names the server may be reached under (protects against DNS rebinding); IP addresses always work
|
||||
@@ -57,7 +61,10 @@ plan_slots = threading.BoundedSemaphore(2) # floor plans drawn at the same tim
|
||||
last_request = time.time()
|
||||
idle_exit = True
|
||||
proc = None # Popen of the running splat.sh (if started by this server)
|
||||
download = None # {"name", "total", "done", "error", "finished"}
|
||||
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 = {}
|
||||
@@ -141,7 +148,7 @@ def pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
|
||||
if cmd:
|
||||
return "splat.sh" in cmd or "combine.sh" in cmd
|
||||
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh"))
|
||||
except OSError:
|
||||
pass
|
||||
if not pid:
|
||||
@@ -171,7 +178,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:
|
||||
@@ -394,39 +401,101 @@ def check_worker():
|
||||
|
||||
# ---------------------------------------------------------------- download from the Frame
|
||||
|
||||
def start_download(name: str, size: int):
|
||||
global download
|
||||
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:
|
||||
if download and not download.get("finished"):
|
||||
return err("err.download_busy")
|
||||
if (REC / name).exists():
|
||||
return err("err.name_taken")
|
||||
download = {"name": name, "total": size, "done": 0, "error": None, "finished": False}
|
||||
|
||||
def run():
|
||||
REC.mkdir(parents=True, exist_ok=True)
|
||||
part = REC / (name + ".part")
|
||||
try:
|
||||
with open(part, "wb") as fh:
|
||||
p = subprocess.Popen(keep_awake(SSH + [f"cat {FRAME_VIDEOS}/{shq(name)}"], t("inhibit.download")),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
while chunk := p.stdout.read(1 << 20):
|
||||
fh.write(chunk)
|
||||
download["done"] += len(chunk)
|
||||
rc = p.wait()
|
||||
if rc != 0 or download["done"] != size:
|
||||
raise RuntimeError("transfer interrupted")
|
||||
part.rename(REC / name)
|
||||
frame_cache["t"] = 0
|
||||
except Exception as e: # noqa: BLE001 – shown in the interface
|
||||
download["error"] = str(e)
|
||||
part.unlink(missing_ok=True)
|
||||
download["finished"] = True
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
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+)%")
|
||||
@@ -466,10 +535,25 @@ def parse_combine(text: str) -> dict:
|
||||
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
|
||||
@@ -620,8 +704,10 @@ def activity() -> dict:
|
||||
job = None
|
||||
if js and js["running"]:
|
||||
job = {k: js.get(k) for k in ("name", "phase", "pct", "eta", "upload")}
|
||||
dl = download if download and not download.get("finished") else None
|
||||
return {"job": job, "download": {"pct": round(100 * dl["done"] / max(dl["total"], 1))} if dl else None,
|
||||
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())}
|
||||
|
||||
|
||||
@@ -657,7 +743,7 @@ def watcher():
|
||||
while True:
|
||||
time.sleep(3)
|
||||
was_running = running = watch_once(was_running)
|
||||
busy = (running or (download and not download.get("finished")) or check_busy()
|
||||
busy = (running or download_busy() or check_busy()
|
||||
or any(u["proc"].poll() is None for u in uploads.values()))
|
||||
if idle_exit and not busy and time.time() - last_request > CFG.idle_minutes * 60:
|
||||
os._exit(0)
|
||||
@@ -665,31 +751,360 @@ def watcher():
|
||||
|
||||
# ---------------------------------------------------------------- copy to the Frame
|
||||
|
||||
def start_upload(slug: str):
|
||||
# ---------------------------------------------------------------- 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")
|
||||
targets = [slug] + ([m for m in meta.get("members", []) if scene_dir(m)] 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 {}
|
||||
|
||||
|
||||
def version_key(splats, scale: float) -> str:
|
||||
"""Folder name of a version: 1m, 0.75m, all; with a scale e.g. 1m-1x87."""
|
||||
key = "all" if splats == "all" else f"{splats / 1e6:g}m"
|
||||
return key + (f"-1x{scale:g}" if scale != 1 else "")
|
||||
|
||||
|
||||
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"])
|
||||
out.append({"key": key, "label": info.get("label"), "splats": info.get("splats"), "scale": info.get("scale", 1),
|
||||
"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")
|
||||
meta = read_meta(slug)
|
||||
name = meta.get("name") or slug
|
||||
specs = []
|
||||
for sc in dict.fromkeys(scales):
|
||||
for n in dict.fromkeys(sizes):
|
||||
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 "")
|
||||
specs.append(f"{version_key(n, sc)}|{n}|{sc:g}|{plate}")
|
||||
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
|
||||
|
||||
|
||||
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(slug)
|
||||
u = uploads.get(key)
|
||||
if u and u["proc"].poll() is None:
|
||||
return err("err.upload_busy")
|
||||
meta = read_meta(slug)
|
||||
env = {**os.environ}
|
||||
for key, var in (("frame_folder", "TARGET"), ("stem", "STEM")):
|
||||
if meta.get(key):
|
||||
env[var] = meta[key]
|
||||
if meta.get("log"):
|
||||
env["LOG"] = str(RUNS / Path(meta["log"]).name)
|
||||
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}.out"
|
||||
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(d), meta.get("name") or slug],
|
||||
t("inhibit.upload", name=meta.get("name") or slug)), cwd=ROOT, env=env,
|
||||
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[slug] = {"proc": p, "out": out, "started": time.time(), "notified": False}
|
||||
uploads[key] = {"proc": p, "out": out, "started": time.time(), "notified": False}
|
||||
return None
|
||||
|
||||
|
||||
@@ -705,7 +1120,12 @@ def upload_status(slug: str):
|
||||
name = read_meta(slug).get("name") or slug
|
||||
if rc == 0:
|
||||
if (d := RX_DONE.search(text)) and d[2]:
|
||||
write_meta(slug, frame_folder=d[2])
|
||||
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:
|
||||
@@ -798,7 +1218,9 @@ def scenes():
|
||||
"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})
|
||||
"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")))})
|
||||
return out
|
||||
|
||||
|
||||
@@ -807,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 = []
|
||||
@@ -820,9 +1243,19 @@ 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")
|
||||
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]).encode()).hexdigest()[:16]
|
||||
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:
|
||||
@@ -836,6 +1269,8 @@ def floorplan(slugs: list[str], style: str, lang: str, title: str):
|
||||
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:
|
||||
@@ -1035,7 +1470,9 @@ def move_scene(slug: str, new: str) -> None:
|
||||
meta["frame_folder"] = next((s for s in fr["scenes"] if re.fullmatch(r"\d\d_" + re.escape(slug), s)), None)
|
||||
old_stem, new_stem = meta.get("stem") or slug.lower(), new.lower()
|
||||
d.rename(nd)
|
||||
for folder, old, new_ in ((nd, old_stem, new_stem), (nd / "full", f"{old_stem}-full", f"{new_stem}-full")):
|
||||
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):]))
|
||||
@@ -1154,6 +1591,66 @@ def rename_recording(name: str, new: str):
|
||||
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):
|
||||
"""A recording on this PC into the desktop trash (recoverable). Only from the recordings folder."""
|
||||
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])
|
||||
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
|
||||
@@ -1221,6 +1718,8 @@ def groups():
|
||||
"on_frame": on_frame, "upload": upload_status(slug), "disk": disk_usage(d)}
|
||||
out.append({"slug": slug, "name": meta.get("name") or slug, "members": members, "result": res,
|
||||
"busy": scene_busy(slug), "options": {k: meta.get(k) for k in ("align", "partition", "frame_splats", "full")},
|
||||
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr")),
|
||||
"versions": len(list(d.glob("versions/*/version.json"))),
|
||||
"splats": sum(scene_list[m].get("gaussians") or 0 for m in members)})
|
||||
return {"groups": out, "suggestion": suggestion(scene_list, out), "mem_available_gb": mem_available()}
|
||||
|
||||
@@ -1309,7 +1808,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:
|
||||
@@ -1332,6 +1831,7 @@ def start_combine(p: dict):
|
||||
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"
|
||||
@@ -1472,16 +1972,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, check_status(Path(q.get("name", "")).name))
|
||||
if u.path == "/api/status":
|
||||
ups = {s: upload_status(s) for s in list(uploads)}
|
||||
return self.send(200, {"job": job_status(), "download": download, "uploads": ups})
|
||||
return self.send(200, {"job": job_status(), "download": download_status(), "uploads": ups})
|
||||
if u.path == "/api/scenes":
|
||||
return self.send(200, scenes())
|
||||
if u.path == "/api/unfinished":
|
||||
return self.send(200, unfinished_runs())
|
||||
if u.path == "/api/groups":
|
||||
return self.send(200, groups())
|
||||
if u.path == "/api/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/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
|
||||
@@ -1519,7 +2027,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
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",
|
||||
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")):
|
||||
return self.send(400, {"error": err("err.bad_request")})
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
@@ -1536,10 +2045,23 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
e = cancel_job()
|
||||
elif path == "/api/download":
|
||||
fr = frame_listing()
|
||||
f = next((f for f in (fr or {}).get("files", []) if f["name"] == p.get("name")), None)
|
||||
e = start_download(f["name"], f["size"]) if f else err("err.not_on_frame")
|
||||
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", ""))
|
||||
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/version/delete":
|
||||
e = delete_version(p.get("slug", ""), p.get("version", ""))
|
||||
elif path == "/api/pick":
|
||||
chosen = pick_file()
|
||||
if chosen:
|
||||
@@ -1547,6 +2069,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
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", "")) 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")))
|
||||
@@ -1563,6 +2088,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
e = save_group(p.get("slug", ""), p.get("name", ""), p.get("members", ""))
|
||||
if isinstance(e, str):
|
||||
return self.send(200, {"ok": True, "slug": e})
|
||||
elif path == "/api/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:]
|
||||
|
||||
+22
-2
@@ -135,6 +135,16 @@ 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
|
||||
for i in range(0, len(out), 1000):
|
||||
sys.stdout.buffer.write(out[i:i + 1000]); sys.stdout.buffer.flush(); time.sleep(0.1)
|
||||
sys.exit(0)
|
||||
sys.exit(subprocess.run(["bash", "-c", cmd or "true"], cwd="{FRAME_HOME}", env=env).returncode)
|
||||
''')
|
||||
script(BIN / "scp", f'''#!{sys.executable}
|
||||
@@ -148,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)
|
||||
''')
|
||||
@@ -185,7 +198,14 @@ def clean():
|
||||
d.mkdir()
|
||||
for cache in (server.usage_cache, server.probe_cache, server.uploads, server.checks):
|
||||
cache.clear()
|
||||
for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FRAME_HOST"):
|
||||
if server.download_busy():
|
||||
server.cancel_download()
|
||||
end = time.time() + 10
|
||||
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", "FAKE_FRAME_SHORT", "FAKE_SCP_SLOW",
|
||||
"FRAME_HOST"):
|
||||
os.environ.pop(k, None)
|
||||
os.environ["FRAME_HOST"] = ""
|
||||
TOOL_LOG.unlink(missing_ok=True)
|
||||
@@ -213,7 +233,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
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The "On the Frame" tab: everything in Gaussian XR's gallery, what it is, and taking it off the Frame."""
|
||||
import json
|
||||
import time
|
||||
|
||||
from conftest import RUNS, server
|
||||
import synth
|
||||
|
||||
|
||||
def upload(api, slug, version=""):
|
||||
body = {"slug": slug, **({"version": version} if version else {})}
|
||||
assert api.post("/api/upload", body)[0] == 200
|
||||
key = f"{slug}#{version}" if version else slug
|
||||
end = time.time() + 60
|
||||
while (u := server.upload_status(key)) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status(key)["ok"]
|
||||
|
||||
|
||||
def example(frame, name="04_BurtLakeHouse"):
|
||||
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)
|
||||
return d
|
||||
|
||||
|
||||
def test_the_gallery_on_the_frame(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
upload(api, "Kitchen")
|
||||
assert server.start_versions({"slug": "Kitchen", "sizes": "10000"}) is None
|
||||
from conftest import wait_job
|
||||
wait_job()
|
||||
upload(api, "Kitchen", "0.01m")
|
||||
example(frame)
|
||||
(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"]}
|
||||
assert set(by) == {"04_BurtLakeHouse", "20_Kitchen", "21_Kitchen-0.01m"}
|
||||
assert (by["20_Kitchen"]["kind"], by["20_Kitchen"]["name"], by["20_Kitchen"]["slug"]) == ("scene", "Kitchen", "Kitchen")
|
||||
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
|
||||
|
||||
|
||||
def test_taking_a_scene_off_the_frame_and_copying_it_again(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
upload(api, "Kitchen")
|
||||
assert api.post("/api/frame/remove", {"folder": "20_Kitchen"})[0] == 200
|
||||
assert not (frame / server.FRAME_SCENES / "20_Kitchen").exists()
|
||||
assert (RUNS / "Kitchen" / "scene.sog").exists() # the PC is not touched
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text()).get("frame_folder") is None
|
||||
upload(api, "Kitchen") # copying again works
|
||||
assert (frame / server.FRAME_SCENES / "20_Kitchen" / "kitchen.sog").exists()
|
||||
|
||||
|
||||
def test_an_example_scene_can_be_deleted_too(api, frame):
|
||||
d = example(frame)
|
||||
assert api.post("/api/frame/remove", {"folder": "04_BurtLakeHouse"})[0] == 200
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
def test_only_scenes_of_the_gallery_can_be_removed(api, frame):
|
||||
(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_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
|
||||
@@ -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")
|
||||
|
||||
+163
-2
@@ -3,7 +3,9 @@ import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from conftest import REC, RUNS, recording, server, wait_for_tool
|
||||
import pytest
|
||||
|
||||
from conftest import REC, RUNS, TRASH, recording, server, tools_called, wait_for_tool
|
||||
import synth
|
||||
|
||||
VIDEOS = server.FRAME_VIDEOS
|
||||
@@ -115,7 +117,7 @@ def test_copying_a_scene_to_the_frame(api, frame):
|
||||
folder = frame / SCENES / "20_Kitchen"
|
||||
assert sorted(p.name for p in folder.iterdir()) == [
|
||||
"kitchen.gallery.png", "kitchen.sceneinfo.png", "kitchen.sog", "kitchen.xrSceneTransform.json"]
|
||||
assert (frame / "Documents" / "GaussianSplats" / "20_Kitchen" / "kitchen.sog").exists() # backup copy
|
||||
assert not (frame / "Documents" / "GaussianSplats").exists() # no second copy on the Frame
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "20_Kitchen"
|
||||
assert wait_for_tool("notify-send") # sent in the background
|
||||
assert {s["slug"]: s["on_frame"] for s in server.scenes()}["Kitchen"] is True
|
||||
@@ -158,3 +160,162 @@ def test_a_scene_renamed_before_its_frame_folder_was_remembered(api, frame):
|
||||
api.post("/api/upload", {"slug": "Kueche"})
|
||||
assert wait_upload("Kueche")["ok"]
|
||||
assert sorted(p.name for p in (frame / SCENES).iterdir()) == ["20_Kitchen"]
|
||||
|
||||
|
||||
def wait_fetched(timeout=30):
|
||||
end = time.time() + timeout
|
||||
while server.download_busy() and time.time() < end:
|
||||
time.sleep(0.05)
|
||||
assert not server.download_busy()
|
||||
return server.download_status()
|
||||
|
||||
|
||||
def fetch_notes(wait=0):
|
||||
"""Notifications about fetching (others of an earlier test may still arrive in the background)."""
|
||||
end = time.time() + wait
|
||||
while True:
|
||||
notes = [c for c in tools_called() if c[0] == "notify-send" and "recording(s)" in c[-1]]
|
||||
if notes or time.time() >= end:
|
||||
return notes
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
NAMES = [f"2026-10-14_1{i}-00-00_stereo.mp4" for i in range(4)]
|
||||
|
||||
|
||||
def test_fetching_several_recordings_one_after_the_other(api, frame):
|
||||
for i, n in enumerate(NAMES):
|
||||
put_on_frame(frame, n, bytes([65 + i]) * 3000)
|
||||
(REC / NAMES[0]).write_bytes(b"already here") # skipped, never overwritten
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_SLOW"] = "1"
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:3])})[0] == 200
|
||||
st = api.get("/api/status")[1]["download"]
|
||||
assert st["busy"] and st["batch"]["files"] == 2
|
||||
assert [st["name"], *st["queue"]] == NAMES[1:3] or st["queue"] == NAMES[1:3]
|
||||
assert api.post("/api/download", {"names": f"{NAMES[3]}\n{NAMES[2]}"})[0] == 200 # added while running
|
||||
assert api.post("/api/download", {"names": NAMES[2]})[1]["error"]["code"] == "err.download_busy"
|
||||
st = wait_fetched()
|
||||
assert st["batch"]["files"] == st["batch"]["done_files"] == 3 and st["batch"]["failed"] == []
|
||||
assert (REC / NAMES[0]).read_bytes() == b"already here"
|
||||
for i, n in enumerate(NAMES[1:], 1):
|
||||
assert (REC / n).read_bytes() == bytes([65 + i]) * 3000
|
||||
assert not list(REC.glob("*.part"))
|
||||
note = fetch_notes(wait=5)
|
||||
assert len(note) == 1 and "3 recording(s)" in note[0][-1] # one notification at the end
|
||||
|
||||
|
||||
def test_a_failed_one_does_not_stop_the_others(api, frame):
|
||||
for n in NAMES[:2]:
|
||||
put_on_frame(frame, n)
|
||||
server.frame_listing(force=True)
|
||||
(frame / VIDEOS / NAMES[0]).unlink() # gone from the Frame in the meantime
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:2])})[0] == 200
|
||||
st = wait_fetched()
|
||||
assert st["batch"]["failed"] == [NAMES[0]] and (REC / NAMES[1]).exists() and not (REC / NAMES[0]).exists()
|
||||
assert not list(REC.glob("*.part"))
|
||||
assert [n[-1] for n in fetch_notes(wait=5)] == ["1 recording(s) could not be fetched (1 did) – is the Frame switched on?"]
|
||||
|
||||
|
||||
def test_cancelling_empties_the_queue(api, frame):
|
||||
for n in NAMES[:3]:
|
||||
put_on_frame(frame, n, b"x" * 20000)
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_SLOW"] = "1"
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:3])})[0] == 200
|
||||
time.sleep(0.3)
|
||||
assert api.post("/api/download/cancel", {})[0] == 200
|
||||
st = wait_fetched()
|
||||
assert st["queue"] == [] and st["batch"]["cancelled"]
|
||||
assert not list(REC.iterdir()) # no file and no half of one
|
||||
assert not fetch_notes(wait=0.5)
|
||||
assert api.post("/api/download/cancel", {})[1]["error"]["code"] == "err.no_download"
|
||||
|
||||
|
||||
def test_fetching_only_what_is_on_the_frame(api, frame):
|
||||
put_on_frame(frame, NAMES[0])
|
||||
server.frame_listing(force=True)
|
||||
for body in ({"names": "../../etc/passwd"}, {"names": "x\n.."}, {"names": "\n"}):
|
||||
status, r = api.post("/api/download", body)
|
||||
assert status == 400 and r["error"]["code"] == "err.not_on_frame", body
|
||||
assert api.post("/api/download", {"names": [NAMES[0]]})[1]["error"]["code"] == "err.bad_request"
|
||||
|
||||
|
||||
def test_deleting_a_recording_on_this_pc_moves_it_to_the_trash(api):
|
||||
rec = recording()
|
||||
assert api.post("/api/rename", {"name": rec.name, "new": "Kitchen"})[0] == 200
|
||||
assert api.post("/api/recording/delete", {"name": "Kitchen.mp4", "where": "pc"})[0] == 200
|
||||
assert not (REC / "Kitchen.mp4").exists() and (TRASH / "Kitchen.mp4").exists()
|
||||
assert "Kitchen.mp4" not in json.loads((server.RECMETA).read_text()) # its old name is forgotten
|
||||
assert api.get("/api/recordings")[1]["local"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "..", "../state/state.json", "nope.mp4"])
|
||||
def test_deleting_only_recordings_of_the_recordings_folder(api, name):
|
||||
recording()
|
||||
status, r = api.post("/api/recording/delete", {"name": name, "where": "pc"})
|
||||
assert status == 400 and r["error"]["code"] == "err.not_local"
|
||||
assert len(list(REC.iterdir())) == 1 and not list(TRASH.iterdir())
|
||||
assert api.post("/api/recording/delete", {"name": "x", "where": "elsewhere"})[0] == 400
|
||||
|
||||
|
||||
def test_a_recording_an_unfinished_scene_needs_stays(api):
|
||||
rec = recording()
|
||||
synth.make_room(RUNS, "Kitchen", "A", recording=rec.name)
|
||||
(RUNS / "Kitchen" / "scene.sog").unlink() # not finished yet
|
||||
status, r = api.post("/api/recording/delete", {"name": rec.name, "where": "pc"})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_needed"
|
||||
assert rec.exists()
|
||||
|
||||
|
||||
def test_deleting_a_recording_on_the_frame(api, frame):
|
||||
name, _ = put_on_frame(frame)
|
||||
other, _ = put_on_frame(frame, NAMES[1])
|
||||
(REC / name).write_bytes(b"the copy on this PC")
|
||||
assert api.post("/api/recording/delete", {"name": name, "where": "frame"})[0] == 200
|
||||
assert not (frame / VIDEOS / name).exists() and (frame / VIDEOS / other).exists()
|
||||
assert (REC / name).read_bytes() == b"the copy on this PC" # the PC is not touched
|
||||
assert [f["name"] for f in api.get("/api/recordings")[1]["frame"]] == [other]
|
||||
for bad in (name, "../" + other, "thumbnails", ""):
|
||||
status, r = api.post("/api/recording/delete", {"name": bad, "where": "frame"})
|
||||
assert status == 400 and r["error"]["code"] == "err.not_on_frame", bad
|
||||
assert (frame / VIDEOS / other).exists()
|
||||
|
||||
|
||||
def test_a_recording_being_fetched_is_not_deleted(api, frame):
|
||||
for n in NAMES[:2]:
|
||||
put_on_frame(frame, n, b"x" * 20000)
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_SLOW"] = "1"
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:2])})[0] == 200
|
||||
for n in NAMES[:2]: # fetching right now, and waiting
|
||||
status, r = api.post("/api/recording/delete", {"name": n, "where": "frame"})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_fetching"
|
||||
assert all((frame / VIDEOS / n).exists() for n in NAMES[:2])
|
||||
|
||||
|
||||
def test_deleting_on_a_frame_that_is_off(api, frame):
|
||||
name, _ = put_on_frame(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()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Scale for models (e.g. H0 = 1:87): floor plan and measurements in the original's size, and the
|
||||
original's size in Gaussian XR (worldScale).
|
||||
|
||||
The model is the synthetic 4 x 3 m room built 87 times smaller, so the expected numbers are simply
|
||||
those of the full-size room."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import BASE, ROOT, RUNS, server
|
||||
import synth
|
||||
|
||||
H0 = 87
|
||||
|
||||
|
||||
def plan(*runs, ratio=None):
|
||||
out = runs[0].parent / "plan.png"
|
||||
cmd = [sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(out), *[f"{r}={r.name}" for r in runs]]
|
||||
if ratio:
|
||||
cmd += ["--ratio", str(ratio)]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return json.loads(r.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def test_a_model_has_the_floor_plan_of_the_original():
|
||||
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
|
||||
model = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0), ratio=H0)
|
||||
assert model["scenes"][0]["size"] == pytest.approx(full["scenes"][0]["size"], abs=0.05) # 4 x 3 m
|
||||
assert model["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01) # measuring in metres
|
||||
assert model["ratio"] == H0
|
||||
|
||||
|
||||
def test_a_model_without_its_scale_still_draws():
|
||||
info = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0))
|
||||
assert info["ratio"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", ["0.5", "2000", "abc", "nan"])
|
||||
def test_scales_out_of_range_are_refused(api, scale):
|
||||
synth.make_room(RUNS, "Layout", "A")
|
||||
status, r = api.post("/api/scale", {"slug": "Layout", "scale": scale})
|
||||
assert status == 400 and r["error"]["code"] in ("err.range", "err.bad_value")
|
||||
|
||||
|
||||
def test_setting_and_clearing_the_scale_of_a_scene(api):
|
||||
synth.make_room(RUNS, "Layout", "A")
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "87", "xr": True})[0] == 200
|
||||
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
|
||||
assert (s["scale"], s["scale_xr"]) == (87, True)
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "1", "xr": True})[0] == 200
|
||||
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
|
||||
assert (s["scale"], s["scale_xr"]) == (1, False) # original size: nothing to enlarge in XR
|
||||
assert api.post("/api/scale", {"slug": "Nope", "scale": "87"})[0] == 400
|
||||
|
||||
|
||||
def test_a_group_passes_its_scale_on(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")
|
||||
synth.make_room(RUNS, "Other", "A", recording="2026-10-03_19-00-00_stereo.mp4")
|
||||
server.save_group("", "Layout", "Station,Yard")
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "160", "with_scenes": True})[0] == 200
|
||||
scales = {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}
|
||||
assert scales == {"Station": 160, "Yard": 160, "Other": 1}
|
||||
assert api.get("/api/groups")[1]["groups"][0]["scale"] == 160
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": "87"}) # without its scenes this time
|
||||
assert {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}["Station"] == 160
|
||||
|
||||
|
||||
def test_the_floor_plan_endpoint_measures_in_the_originals_metres(api):
|
||||
synth.make_room(RUNS, "Layout", "A", error=(0.0, (0, 0, 0)), scale=1 / H0)
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": str(H0)})
|
||||
status, png = api.get("/api/floorplan?slugs=Layout&style=classic")
|
||||
assert status == 200 and png[:4] == b"\x89PNG"
|
||||
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
|
||||
info = server.floorplan(["Layout"], "classic", "en", "")[1]
|
||||
assert info["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01)
|
||||
|
||||
|
||||
def wait_upload(slug):
|
||||
end = time.time() + 120
|
||||
while time.time() < end:
|
||||
u = server.upload_status(slug)
|
||||
if u and not u["running"]:
|
||||
return u
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xr,expected", [(True, H0), (False, 1.0)])
|
||||
def test_world_scale_on_the_frame(api, frame, xr, expected):
|
||||
run = synth.make_room(RUNS, "Layout", "A")
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": str(H0), "xr": xr})
|
||||
api.post("/api/upload", {"slug": "Layout"})
|
||||
assert wait_upload("Layout")["ok"]
|
||||
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
|
||||
assert sent["worldScale"] == expected
|
||||
assert sent["transform"] == json.loads((run / "scene.xrSceneTransform.json").read_text())["transform"]
|
||||
assert json.loads((run / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0 # the original stays
|
||||
|
||||
|
||||
def test_changing_the_scale_updates_the_frame(api, frame):
|
||||
synth.make_room(RUNS, "Layout", "A")
|
||||
api.post("/api/upload", {"slug": "Layout"})
|
||||
wait_upload("Layout")
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "45", "xr": True, "update_frame": True})[0] == 200
|
||||
assert wait_upload("Layout")["ok"]
|
||||
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
|
||||
assert sent["worldScale"] == 45
|
||||
|
||||
|
||||
def test_a_tour_of_a_model_is_walkable_in_both_versions():
|
||||
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")
|
||||
server.set_scale("Layout", H0, xr=True, members=True, update_frame=False)
|
||||
assert server.start_combine({"slug": "Layout", "full": True}) is None
|
||||
from conftest import wait_job
|
||||
assert wait_job()["result"] == "ok"
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Versions of a scene or tour (scripts/versions.sh): several sizes and scales side by side, each its own
|
||||
scene – also on the Frame, where every version gets its own folder in the gallery."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import RUNS, TRASH, recording, server, tools_called, wait_job
|
||||
import synth
|
||||
|
||||
|
||||
def room(slug="Room", **kw):
|
||||
run = synth.make_room(RUNS, slug, "A", name=slug, **kw)
|
||||
with open(run / "spirula" / "scene.ply", "rb") as f:
|
||||
total = next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex"))
|
||||
return run, total
|
||||
|
||||
|
||||
def make(slug, sizes, scales="1"):
|
||||
assert server.start_versions({"slug": slug, "sizes": sizes, "scales": scales}) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
return {v["key"]: v for v in server.versions_of(slug)}
|
||||
|
||||
|
||||
def test_versions_in_several_sizes():
|
||||
run, total = room()
|
||||
versions = make("Room", "10000,20000,all")
|
||||
assert set(versions) == {"0.01m", "0.02m", "all"}
|
||||
assert [versions[k]["splats"] for k in ("0.01m", "0.02m", "all")] == [10000, 20000, total]
|
||||
assert versions["0.01m"]["label"] == "Room · 0.01 M" and versions["all"]["label"] == "Room · all splats"
|
||||
reduced = [c for c in tools_called() if c[0] == "splat-transform" and "--decimate" in c]
|
||||
assert sorted(c[c.index("--decimate") + 1] for c in reduced) == ["10000", "20000"] # "all" is not reduced
|
||||
for key in versions:
|
||||
v = run / "versions" / key
|
||||
assert (v / "scene.sog").exists() and (v / f"room-{key}.sceneinfo.png").exists()
|
||||
assert (v / f"room-{key}.gallery.png").exists() # the scene's preview
|
||||
assert json.loads((v / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0
|
||||
assert server.scenes()[0]["versions"] == 3
|
||||
|
||||
|
||||
def test_a_size_above_the_scene_is_all_of_it():
|
||||
run, total = room()
|
||||
versions = make("Room", str(total * 2))
|
||||
assert list(versions.values())[0]["splats"] == total
|
||||
|
||||
|
||||
def test_versions_in_the_originals_size(api):
|
||||
run, _ = room()
|
||||
api.post("/api/scale", {"slug": "Room", "scale": "87"})
|
||||
versions = make("Room", "10000", "1,87")
|
||||
assert set(versions) == {"0.01m", "0.01m-1x87"}
|
||||
assert versions["0.01m-1x87"]["label"] == "Room · 0.01 M · 1:87"
|
||||
worlds = {k: json.loads((run / "versions" / k / "scene.xrSceneTransform.json").read_text())["worldScale"]
|
||||
for k in versions}
|
||||
assert worlds == {"0.01m": 1.0, "0.01m-1x87": 87.0}
|
||||
|
||||
|
||||
def test_versions_side_by_side_on_the_frame(api, frame):
|
||||
room()
|
||||
make("Room", "10000,20000")
|
||||
for key in ("0.01m", "0.02m"):
|
||||
assert api.post("/api/upload", {"slug": "Room", "version": key})[0] == 200
|
||||
end = time.time() + 60
|
||||
while (u := server.upload_status(f"Room#{key}")) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status(f"Room#{key}")["ok"]
|
||||
scenes = frame / server.FRAME_SCENES
|
||||
assert sorted(p.name for p in scenes.iterdir()) == ["20_Room-0.01m", "21_Room-0.02m"]
|
||||
assert sorted(p.name for p in (scenes / "21_Room-0.02m").iterdir()) == [
|
||||
"room-0.02m.gallery.png", "room-0.02m.sceneinfo.png", "room-0.02m.sog", "room-0.02m.xrSceneTransform.json"]
|
||||
folders = json.loads((RUNS / "Room.ui.json").read_text())["version_folders"]
|
||||
assert folders == {"0.01m": "20_Room-0.01m", "0.02m": "21_Room-0.02m"}
|
||||
versions = {v["key"]: v for v in server.versions_of("Room")}
|
||||
assert versions["0.01m"]["on_frame"] and versions["0.02m"]["on_frame"]
|
||||
assert api.post("/api/upload", {"slug": "Room", "version": "9m"})[1]["error"]["code"] == "err.no_version"
|
||||
|
||||
|
||||
def test_deleting_a_version(api):
|
||||
run, _ = room()
|
||||
make("Room", "10000,20000")
|
||||
assert api.post("/api/version/delete", {"slug": "Room", "version": "0.01m"})[0] == 200
|
||||
assert set(v["key"] for v in server.versions_of("Room")) == {"0.02m"}
|
||||
assert (TRASH / "0.01m").exists() and (run / "spirula" / "scene.ply").exists()
|
||||
assert api.post("/api/version/delete", {"slug": "Room", "version": "../spirula"})[0] == 400
|
||||
|
||||
|
||||
def test_versions_follow_a_renamed_scene(api):
|
||||
room("Kitchen")
|
||||
make("Kitchen", "10000")
|
||||
assert api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})[0] == 200
|
||||
v = RUNS / "Kueche" / "versions" / "0.01m"
|
||||
assert (v / "kueche-0.01m.sceneinfo.png").exists() and not (v / "kitchen-0.01m.sceneinfo.png").exists()
|
||||
assert [x["key"] for x in server.versions_of("Kueche")] == ["0.01m"]
|
||||
|
||||
|
||||
def test_a_version_older_than_its_scene():
|
||||
run, _ = room()
|
||||
make("Room", "10000")
|
||||
assert not server.versions_of("Room")[0]["stale"]
|
||||
time.sleep(0.05)
|
||||
os.utime(run / "spirula" / "scene.ply") # the scene was computed again
|
||||
assert server.versions_of("Room")[0]["stale"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [
|
||||
({"sizes": "5"}, "err.range"), ({"sizes": "abc"}, "err.bad_value"), ({"sizes": ""}, "err.no_versions"),
|
||||
({"sizes": "10000", "scales": "0.5"}, "err.range"), ({"slug": "Nope", "sizes": "10000"}, "err.no_scene"),
|
||||
])
|
||||
def test_invalid_versions_are_refused(body, code):
|
||||
room()
|
||||
assert server.start_versions({"slug": "Room", **body})["code"] == code
|
||||
|
||||
|
||||
def test_versions_of_a_tour():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Hallway", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4")
|
||||
server.save_group("", "Flat", "Kitchen,Hallway")
|
||||
assert server.start_combine({"slug": "Flat", "full": False}) is None
|
||||
assert wait_job()["result"] == "ok"
|
||||
versions = make("Flat", "10000,all")
|
||||
assert set(versions) == {"0.01m", "all"}
|
||||
assert versions["all"]["splats"] == server.groups()["groups"][0]["result"]["splats"]
|
||||
assert server.groups()["groups"][0]["versions"] == 2
|
||||
|
||||
|
||||
def test_one_job_at_a_time():
|
||||
room()
|
||||
os.environ["FAKE_PIPELINE"] = "slow"
|
||||
rec = recording()
|
||||
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
|
||||
(backup / "20_Room-0.01m").mkdir(parents=True) # a copy left by version 0.2.3 or older
|
||||
(backup / "21_Room-0.02m").mkdir()
|
||||
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()
|
||||
|
||||
|
||||
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"]
|
||||
+98
-2
@@ -34,6 +34,12 @@ const I18N = {
|
||||
"confirm.cancel": "Really cancel the conversion? With “Resume” you can continue later from the last saved state.",
|
||||
// step 1
|
||||
"s1.title": "Choose a recording", "tab.frame": "On the Frame", "tab.local": "On this PC",
|
||||
"dl.all": "Fetch all ({n} · {size})", "dl.all.title": "Fetch every recording that is not on this PC yet, one after the other",
|
||||
"dl.sel": "Fetch selection ({n} · {size})", "dl.sel.title": "Fetch the ticked recordings, one after the other",
|
||||
"dl.pick": "Fetch this one too", "dl.queued": "waiting", "dl.cancel": "Cancel", "dl.cancelled": "Fetching cancelled",
|
||||
"dl.progress": "Fetching {i} of {n} from the Frame · {done} of {total}",
|
||||
"dl.done": "{n} recording(s) fetched – you can switch the Frame off", "dl.failed": "{n} recording(s) could not be fetched",
|
||||
"err.no_download": "Nothing is being fetched",
|
||||
"checkAll": "Check all", "checkAll.title": "Check all recordings on this PC that have not been checked yet",
|
||||
"pick": "Choose file …", "refresh.title": "Reload list", "loading": "Loading …",
|
||||
"frame.unreachable": "The Frame is not reachable – is it on and in the same network?",
|
||||
@@ -122,6 +128,14 @@ const I18N = {
|
||||
"sc.spirula.starting": "Starting Spirula …", "sc.uploadStarted": "Copying to the Frame …",
|
||||
"sc.splats": "{n} M splats",
|
||||
"sc.plan": "Floor plan",
|
||||
"rdel.frame": "🗑 Delete from the Frame", "rdel.pc": "🗑 Delete from this PC",
|
||||
"rdel.confirm.frame": "Delete “{name}” ({size}) from the Frame? This cannot be undone there – the copy on this PC stays.",
|
||||
"rdel.confirm.frame.only": "“{name}” ({size}) is NOT on this PC yet! Delete it from the Frame anyway? It is then gone for good.",
|
||||
"rdel.confirm.pc": "Move “{name}” ({size}) on this PC to the trash? Finished scenes stay.",
|
||||
"rdel.confirm.pc.only": "Move “{name}” ({size}) to the trash? It is no longer on the Frame – the trash then holds the only copy.",
|
||||
"rdel.done.frame": "“{name}” deleted from the Frame", "rdel.done.pc": "“{name}” moved to the trash",
|
||||
"err.recording_fetching": "The recording is being fetched from the Frame right now",
|
||||
"err.recording_needed": "“{name}” from this recording is not finished yet and still needs it",
|
||||
"ren.button": "✎ Rename", "ren.ok": "Rename", "ren.cancel": "Cancel", "ren.done": "Renamed",
|
||||
"ren.rec.title": "Rename recording",
|
||||
"ren.rec.hint": "Renames the file on this PC. The recording date is kept; the copy on the Frame keeps its name.",
|
||||
@@ -184,7 +198,41 @@ const I18N = {
|
||||
"job.sub.combine": "{n} scenes · correction {align} · {target} · for {time}",
|
||||
"err.in_other_group": "“{group}” already contains one of these scenes",
|
||||
"err.combine_too_few": "Putting together needs at least two finished scenes",
|
||||
"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",
|
||||
"scale.button.title": "Scale – for models, e.g. a model railway", "scale.title": "Scale of “{name}”",
|
||||
"scale.hint": "For models: floor plan, sizes and measurements then show the size of the original (H0: 10 cm in the model = 8.7 m).",
|
||||
"scale.original": "Original size", "scale.custom": "Own scale",
|
||||
"scale.xr": "Walk through it in Gaussian XR in the original's size (not tested on the Frame yet)",
|
||||
"scale.members": "Also for all scenes in this group", "scale.frame": "Update it on the Frame too",
|
||||
"scale.line": "Scale {ratio}", "scale.line.xr": "original size in XR", "scale.done": "Scale saved",
|
||||
"ver.button": "Versions ({n})", "ver.button.title": "Versions with fewer splats for the Frame or all splats for the PC – several side by side",
|
||||
"ver.title": "Versions of “{name}” ({n} splats)",
|
||||
"ver.hint": "Every version is its own scene and gets its own place in the Frame's gallery – e.g. 0.75 M and 2 M side by side to compare.",
|
||||
"ver.new": "New versions", "ver.frame": "For the Frame", "ver.pc": "For the PC", "ver.custom": "Own size", "ver.million": "million splats",
|
||||
"ver.scales": "Scale", "ver.walk": "walk through it ({ratio})", "ver.all": "all splats",
|
||||
"ver.measured": "Measured on a Steam Frame (Gaussian XR, 10/2026): 0.57 M ≈ 36 fps, 2 M ≈ 23 fps, 5 M ≈ 14 fps.",
|
||||
"ver.make": "Make versions", "ver.pick": "Pick at least one size", "ver.started": "Making versions …",
|
||||
"ver.none": "No versions yet.", "ver.stale": "older than the scene", "ver.delete": "Delete version",
|
||||
"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",
|
||||
"stab.pc": "On this PC", "stab.frame": "On the Frame",
|
||||
"fg.hint": "Everything in Gaussian XR's gallery on the Frame. Taking something off the Frame does not touch this PC.",
|
||||
"fg.loading": "Asking the Frame …", "fg.empty": "The gallery on the Frame is empty.",
|
||||
"fg.kind.scene": "Scene", "fg.kind.tour": "Tour", "fg.kind.version": "Version", "fg.kind.other": "Not from Splat Workshop",
|
||||
"fg.remove": "Off the Frame", "fg.remove.title": "Delete this scene from the Frame's gallery",
|
||||
"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.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",
|
||||
@@ -231,6 +279,12 @@ const I18N = {
|
||||
"res.failed": "Fehlgeschlagen – die letzten Zeilen im Protokoll zeigen warum.",
|
||||
"confirm.cancel": "Umwandlung wirklich abbrechen? Mit „Fortsetzen“ geht es später ab dem letzten Zwischenstand weiter.",
|
||||
"s1.title": "Aufnahme wählen", "tab.frame": "Auf der Frame", "tab.local": "Auf dem PC",
|
||||
"dl.all": "Alle holen ({n} · {size})", "dl.all.title": "Alle Aufnahmen, die noch nicht auf dem PC sind, nacheinander holen",
|
||||
"dl.sel": "Auswahl holen ({n} · {size})", "dl.sel.title": "Die angehakten Aufnahmen nacheinander holen",
|
||||
"dl.pick": "Diese auch holen", "dl.queued": "wartet", "dl.cancel": "Abbrechen", "dl.cancelled": "Holen abgebrochen",
|
||||
"dl.progress": "Hole {i} von {n} von der Frame · {done} von {total}",
|
||||
"dl.done": "{n} Aufnahme(n) geholt – du kannst die Frame abschalten", "dl.failed": "{n} Aufnahme(n) konnten nicht geholt werden",
|
||||
"err.no_download": "Es wird nichts geholt",
|
||||
"checkAll": "Alle prüfen", "checkAll.title": "Alle Aufnahmen auf dem PC prüfen, die noch nicht geprüft sind",
|
||||
"pick": "Datei wählen …", "refresh.title": "Liste neu laden", "loading": "Lade …",
|
||||
"frame.unreachable": "Die Frame ist nicht erreichbar – ist sie an und im selben Netz?",
|
||||
@@ -315,6 +369,14 @@ const I18N = {
|
||||
"sc.spirula.starting": "Spirula startet …", "sc.uploadStarted": "Übertrage auf die Frame …",
|
||||
"sc.splats": "{n} Mio. Splats",
|
||||
"sc.plan": "Grundriss",
|
||||
"rdel.frame": "🗑 Von der Frame löschen", "rdel.pc": "🗑 Vom PC löschen",
|
||||
"rdel.confirm.frame": "„{name}“ ({size}) von der Frame löschen? Dort lässt sich das nicht rückgängig machen – die Kopie auf dem PC bleibt.",
|
||||
"rdel.confirm.frame.only": "„{name}“ ({size}) liegt noch NICHT auf dem PC! Trotzdem von der Frame löschen? Dann ist sie endgültig weg.",
|
||||
"rdel.confirm.pc": "„{name}“ ({size}) auf dem PC in den Papierkorb verschieben? Fertige Szenen bleiben.",
|
||||
"rdel.confirm.pc.only": "„{name}“ ({size}) in den Papierkorb verschieben? Auf der Frame liegt sie nicht mehr – der Papierkorb hat dann die einzige Kopie.",
|
||||
"rdel.done.frame": "„{name}“ von der Frame gelöscht", "rdel.done.pc": "„{name}“ in den Papierkorb verschoben",
|
||||
"err.recording_fetching": "Die Aufnahme wird gerade von der Frame geholt",
|
||||
"err.recording_needed": "„{name}“ aus dieser Aufnahme ist noch nicht fertig und braucht sie noch",
|
||||
"ren.button": "✎ Umbenennen", "ren.ok": "Umbenennen", "ren.cancel": "Abbrechen", "ren.done": "Umbenannt",
|
||||
"ren.rec.title": "Aufnahme umbenennen",
|
||||
"ren.rec.hint": "Benennt die Datei auf dem PC um. Das Aufnahmedatum bleibt erhalten; die Kopie auf der Frame behält ihren Namen.",
|
||||
@@ -377,7 +439,41 @@ const I18N = {
|
||||
"job.sub.combine": "{n} Szenen · Korrektur {align} · {target} · seit {time}",
|
||||
"err.in_other_group": "„{group}“ enthält schon eine dieser Szenen",
|
||||
"err.combine_too_few": "Zum Zusammensetzen braucht es mindestens zwei fertige Szenen",
|
||||
"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",
|
||||
"scale.button.title": "Maßstab – für Modelle, z. B. eine Modelleisenbahn", "scale.title": "Maßstab von „{name}“",
|
||||
"scale.hint": "Für Modelle: Grundriss, Größen und Messwerte zeigen dann die Größe des Originals (H0: 10 cm im Modell = 8,7 m).",
|
||||
"scale.original": "Originalgröße", "scale.custom": "Eigener Maßstab",
|
||||
"scale.xr": "In Gaussian XR in Originalgröße begehbar (an der Frame noch nicht getestet)",
|
||||
"scale.members": "Auch für alle Szenen dieser Gruppe", "scale.frame": "Auch auf der Frame aktualisieren",
|
||||
"scale.line": "Maßstab {ratio}", "scale.line.xr": "in XR in Originalgröße", "scale.done": "Maßstab gespeichert",
|
||||
"ver.button": "Fassungen ({n})", "ver.button.title": "Fassungen mit weniger Splats für die Frame oder allen Splats für den PC – mehrere nebeneinander",
|
||||
"ver.title": "Fassungen von „{name}“ ({n} Splats)",
|
||||
"ver.hint": "Jede Fassung ist eine eigene Szene und bekommt einen eigenen Platz in der Galerie der Frame – z. B. 0,75 Mio. und 2 Mio. nebeneinander zum Vergleichen.",
|
||||
"ver.new": "Neue Fassungen", "ver.frame": "Für die Frame", "ver.pc": "Für den PC", "ver.custom": "Eigene Größe", "ver.million": "Mio. Splats",
|
||||
"ver.scales": "Maßstab", "ver.walk": "begehbar ({ratio})", "ver.all": "alle Splats",
|
||||
"ver.measured": "Gemessen auf einer Steam Frame (Gaussian XR, 10/2026): 0,57 Mio. ≈ 36 fps, 2 Mio. ≈ 23 fps, 5 Mio. ≈ 14 fps.",
|
||||
"ver.make": "Fassungen erstellen", "ver.pick": "Mindestens eine Größe wählen", "ver.started": "Erstelle Fassungen …",
|
||||
"ver.none": "Noch keine Fassungen.", "ver.stale": "älter als die Szene", "ver.delete": "Fassung löschen",
|
||||
"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",
|
||||
"stab.pc": "Auf dem PC", "stab.frame": "Auf der Frame",
|
||||
"fg.hint": "Alles, was in der Galerie von Gaussian XR auf der Frame liegt. Von der Frame nehmen ändert nichts auf dem PC.",
|
||||
"fg.loading": "Frage die Frame …", "fg.empty": "Die Galerie auf der Frame ist leer.",
|
||||
"fg.kind.scene": "Szene", "fg.kind.tour": "Rundgang", "fg.kind.version": "Fassung", "fg.kind.other": "Nicht aus der Splat-Werkstatt",
|
||||
"fg.remove": "Von der Frame nehmen", "fg.remove.title": "Diese Szene aus der Galerie der Frame löschen",
|
||||
"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.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",
|
||||
|
||||
+325
-26
@@ -67,6 +67,8 @@ label.field small { font-size: 12px; color: var(--faint); line-height: 1.4; }
|
||||
.play:hover { transform: scale(1.08); }
|
||||
.cbadge { position: absolute; left: 8px; top: 8px; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center;
|
||||
font-size: 13px; font-weight: 700; color: #0b0d12; }
|
||||
.dlpick { position: absolute; right: 6px; top: 6px; padding: 4px; border-radius: 8px; background: rgba(11,13,18,.6); line-height: 0; cursor: pointer; }
|
||||
.dlpick input { width: 18px; height: 18px; margin: 0; cursor: pointer; accent-color: var(--accent); }
|
||||
.cbadge.ok { background: var(--ok); } .cbadge.warn { background: var(--warn); } .cbadge.bad { background: var(--bad); }
|
||||
|
||||
/* recording check */
|
||||
@@ -155,6 +157,7 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
|
||||
padding: 10px 16px; border-radius: 10px; font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 10; }
|
||||
.toast.show { opacity: 1; }
|
||||
.hidden { display: none !important; }
|
||||
.invisible { visibility: hidden; }
|
||||
.group { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 12px; }
|
||||
.ghead { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.gthumb { width: 132px; height: 132px; flex: none; border-radius: 10px; background: #0b0d12 center/cover no-repeat; display: grid; place-items: center; font-size: 44px; }
|
||||
@@ -166,6 +169,8 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
|
||||
.suggest { border: 1px dashed var(--accent); border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-size: 13.5px; }
|
||||
.suggest .row { margin-top: 8px; }
|
||||
dialog.wide { max-width: 560px; }
|
||||
.vgrid { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; align-items: center; font-size: 13.5px; }
|
||||
.vgrid label.chk { display: inline-flex; gap: 5px; align-items: center; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px; }
|
||||
dialog select { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||
dialog h3 { font-size: 13.5px; margin: 16px 0 8px; }
|
||||
.opts { display: grid; gap: 6px; }
|
||||
@@ -258,10 +263,15 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<button class="tab active" data-tab="frame" data-i18n="tab.frame"></button>
|
||||
<button class="tab" data-tab="local" data-i18n="tab.local"></button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="small hidden" id="dlSelBtn" data-i18n-title="dl.sel.title"></button>
|
||||
<button class="small hidden" id="dlAllBtn" data-i18n-title="dl.all.title"></button>
|
||||
<button class="small" id="checkAllBtn" data-i18n="checkAll" data-i18n-title="checkAll.title"></button>
|
||||
<button class="small pc-only" id="pickBtn" data-i18n="pick"></button>
|
||||
<button class="small" id="refreshBtn" data-i18n-title="refresh.title">↻</button>
|
||||
</div>
|
||||
<div id="dlBar" class="hidden" style="display:flex;gap:10px;align-items:center;margin:0 0 12px">
|
||||
<div style="flex:1"><span class="muted" id="dlText"></span><div class="bar"><div id="dlFill" style="width:0"></div></div></div>
|
||||
<button class="small" id="dlCancel" data-i18n="dl.cancel"></button></div>
|
||||
<div id="recGrid" class="grid"></div>
|
||||
<div id="checkBox" class="hidden"></div>
|
||||
<div id="checkRes" class="hidden"></div>
|
||||
@@ -320,9 +330,22 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<h2 data-i18n="scenes.title" style="margin:0"></h2>
|
||||
<div class="row"><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
|
||||
</div>
|
||||
<div id="groupSuggest" class="suggest hidden"></div>
|
||||
<div id="groupList"></div>
|
||||
<div id="sceneGrid" class="grid"></div>
|
||||
<div class="tabs" id="sceneTabs">
|
||||
<button class="tab active" data-stab="pc" data-i18n="stab.pc"></button>
|
||||
<button class="tab" data-stab="frame" data-i18n="stab.frame"></button>
|
||||
<button class="small hidden" id="frameRefresh" title="↻" aria-label="↻">↻</button>
|
||||
</div>
|
||||
<div id="pcView">
|
||||
<div id="groupSuggest" class="suggest hidden"></div>
|
||||
<div id="groupList"></div>
|
||||
<div id="sceneGrid" class="grid"></div>
|
||||
</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>
|
||||
|
||||
<!-- cancelled / failed runs -->
|
||||
@@ -361,7 +384,9 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<h3 data-i18n="cmb.part"></h3>
|
||||
<div class="opts" id="cmbPart"></div>
|
||||
<h3 data-i18n="cmb.size"></h3>
|
||||
<select id="cmbSize"><option value="2000000" data-i18n="cmb.size.2"></option><option value="3500000" data-i18n="cmb.size.35"></option><option value="5000000" data-i18n="cmb.size.5"></option></select>
|
||||
<div class="row"><select id="cmbSize"></select>
|
||||
<input id="cmbSizeCustom" class="hidden" type="number" min="0.5" max="10" step="0.05" style="width:90px"> <span class="hidden" id="cmbSizeUnit" data-i18n="ver.million"></span></div>
|
||||
<p class="faint" style="font-size:12px;margin:6px 0 0" data-i18n="ver.measured"></p>
|
||||
<label class="row" style="margin-top:14px;gap:8px;font-size:13.5px"><input type="checkbox" id="cmbFull"> <span data-i18n="cmb.full"></span></label>
|
||||
<p class="faint" id="cmbMem" style="font-size:12.5px;margin:6px 0 0"></p>
|
||||
<div id="cmbMemLow" class="msg warn hidden" data-i18n="cmb.mem.low"></div>
|
||||
@@ -372,6 +397,38 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="verDlg" class="wide">
|
||||
<h2 style="margin-top:0" id="verTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="ver.hint"></p>
|
||||
<div id="verList" class="unfin"></div>
|
||||
<h3 data-i18n="ver.new"></h3>
|
||||
<div class="vgrid">
|
||||
<b data-i18n="ver.frame"></b><div class="row" id="verFrame"></div>
|
||||
<b data-i18n="ver.pc"></b><div class="row" id="verPc"></div>
|
||||
<b data-i18n="ver.custom"></b><label class="row" style="gap:6px"><input id="verCustom" type="number" min="0.1" max="50" step="0.05" style="width:90px"> <span data-i18n="ver.million"></span></label>
|
||||
<b class="hidden" id="verScaleHead" data-i18n="ver.scales"></b><div class="row hidden" id="verScales"></div>
|
||||
</div>
|
||||
<p class="faint" style="font-size:12px;margin:12px 0 0" data-i18n="ver.measured"></p>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="verClose" data-i18n="clean.close"></button>
|
||||
<button class="primary" id="verMake" data-i18n="ver.make"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="scaleDlg">
|
||||
<h2 style="margin-top:0" id="scaleTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="scale.hint"></p>
|
||||
<div class="opts" id="scaleOpts"></div>
|
||||
<label class="row" style="margin-top:10px;gap:8px;font-size:13.5px">1 : <input id="scaleCustom" type="number" min="1" max="1000" step="any" style="width:110px"></label>
|
||||
<label class="row" id="scaleXrRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:12px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleXr" style="margin-top:3px"> <span data-i18n="scale.xr"></span></label>
|
||||
<label class="row hidden" id="scaleMembersRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleMembers" checked> <span data-i18n="scale.members"></span></label>
|
||||
<label class="row hidden" id="scaleFrameRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleFrame" checked> <span data-i18n="scale.frame"></span></label>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="scaleCancel" data-i18n="grp.cancel"></button>
|
||||
<button class="primary" id="scaleOk" data-i18n="grp.save"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="renDlg">
|
||||
<h2 style="margin-top:0" id="renTitle"></h2>
|
||||
<p class="muted" id="renHint" style="font-size:13px"></p>
|
||||
@@ -424,7 +481,7 @@ const PRESETS = {
|
||||
high: d => ({frames: clamp(Math.round(d * 5), 100, 500), scale: 1, maxg: 2, iter: 30000}),
|
||||
max: d => ({frames: clamp(Math.round(d * 8), 100, 500), scale: 1, maxg: 3, iter: 50000}),
|
||||
};
|
||||
const S = { tab: "frame", recs: null, sel: null, probe: null, quality: "high", job: null, dl: null, dismissed: null,
|
||||
const S = { tab: "frame", recs: null, picked: new Set(), sel: null, probe: null, quality: "high", job: null, dl: null, dismissed: null,
|
||||
frameOnline: null, frameConfigured: true };
|
||||
|
||||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||||
@@ -516,15 +573,17 @@ function renderRecs() {
|
||||
const sel = S.sel && S.sel.name === f.name ? " sel" : "";
|
||||
const local = S.tab === "frame" && f.local ? `<span class="badge">${tr("onPc")}</span>` : "";
|
||||
const onPc = S.tab === "local" || f.local;
|
||||
const pick = S.tab === "frame" && !f.local && !queued(f.name)
|
||||
? `<label class="dlpick" title="${tr("dl.pick")}"><input type="checkbox" data-pick="${esc(f.name)}"${S.picked.has(f.name) ? " checked" : ""}></label>` : "";
|
||||
const play = onPc ? `<button class="play pc-only" data-play="${esc(f.name)}" title="${tr("play.title")}" aria-label="${tr("play.title")}">${PLAY}</button>` : "";
|
||||
return `<div class="rec${sel}" data-i="${i}" role="button" tabindex="0">
|
||||
<div class="thumb" data-src="/api/thumb?source=${onPc ? "local" : "frame"}&name=${encodeURIComponent(f.name)}">${badge(f)}${play}</div>
|
||||
<div class="meta"><b>${esc(f.label || fmtDate(f.date) || f.name)}</b><span>${f.label && f.date ? esc(fmtDate(f.date)) + " · " : ""}${mb(f.size)} ${local}</span></div></div>`;
|
||||
<div class="thumb" data-src="/api/thumb?source=${onPc ? "local" : "frame"}&name=${encodeURIComponent(f.name)}">${badge(f)}${play}${pick}</div>
|
||||
<div class="meta"><b>${esc(f.label || fmtDate(f.date) || f.name)}</b><span>${f.label && f.date ? esc(fmtDate(f.date)) + " · " : ""}${mb(f.size)} ${local}${queued(f.name) ? `<span class="badge">${tr("dl.queued")}</span>` : ""}</span></div></div>`;
|
||||
}).join("");
|
||||
g.querySelectorAll(".thumb").forEach(t => {
|
||||
const url = t.dataset.src, img = new Image();
|
||||
img.onload = () => { t.style.backgroundImage = `url(${JSON.stringify(url)})`; };
|
||||
img.onerror = () => { if (!t.querySelector(".play")) t.textContent = "◌"; };
|
||||
img.onerror = () => { if (!t.querySelector(".play")) t.insertAdjacentHTML("afterbegin", "<span>◌</span>"); };
|
||||
img.src = url;
|
||||
});
|
||||
g.querySelectorAll(".rec").forEach(b => {
|
||||
@@ -532,6 +591,47 @@ function renderRecs() {
|
||||
b.onkeydown = e => { if ((e.key === "Enter" || e.key === " ") && e.target === b) { e.preventDefault(); b.click(); } };
|
||||
});
|
||||
g.querySelectorAll("[data-play]").forEach(b => b.onclick = e => { e.stopPropagation(); play(b.dataset.play); });
|
||||
g.querySelectorAll(".dlpick").forEach(b => { b.onclick = e => e.stopPropagation(); b.onkeydown = e => e.stopPropagation(); });
|
||||
g.querySelectorAll("[data-pick]").forEach(c => c.onchange = () => {
|
||||
c.checked ? S.picked.add(c.dataset.pick) : S.picked.delete(c.dataset.pick); renderDlButtons();
|
||||
});
|
||||
renderDlButtons();
|
||||
}
|
||||
|
||||
/* ---------- fetching several recordings from the Frame, one after the other ---------- */
|
||||
function queued(name) { const d = S.dl; return !!(d && d.busy && ((d.name === name && !d.finished) || (d.queue || []).includes(name))); }
|
||||
function fetchable() { return S.recs && S.recs.frame_online ? (S.recs.frame || []).filter(f => !f.local && !queued(f.name)) : []; }
|
||||
function renderDlButtons() {
|
||||
const onFrame = S.tab === "frame", all = fetchable();
|
||||
for (const n of [...S.picked]) if (!all.some(f => f.name === n)) S.picked.delete(n);
|
||||
const picked = all.filter(f => S.picked.has(f.name));
|
||||
const size = list => mb(list.reduce((a, f) => a + f.size, 0));
|
||||
$("#dlAllBtn").classList.toggle("hidden", !onFrame || !all.length);
|
||||
$("#dlAllBtn").textContent = "⬇ " + tr("dl.all", {n: all.length, size: size(all)});
|
||||
$("#dlSelBtn").classList.toggle("hidden", !onFrame || !picked.length);
|
||||
$("#dlSelBtn").textContent = "⬇ " + tr("dl.sel", {n: picked.length, size: size(picked)});
|
||||
}
|
||||
async function fetchMany(list) {
|
||||
if (!list.length) return;
|
||||
const r = await api("/api/download", {names: list.map(f => f.name).join("\n")});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
S.picked.clear();
|
||||
S.dl = {...(S.dl || {}), busy: true, queue: [...((S.dl && S.dl.queue) || []), ...list.map(f => f.name)]};
|
||||
renderRecs(); renderDlBar();
|
||||
}
|
||||
$("#dlAllBtn").onclick = () => fetchMany(fetchable());
|
||||
$("#dlSelBtn").onclick = () => fetchMany(fetchable().filter(f => S.picked.has(f.name)));
|
||||
$("#dlCancel").onclick = async () => {
|
||||
const r = await api("/api/download/cancel", {});
|
||||
if (!r.ok) toast(trErr(r.error));
|
||||
};
|
||||
function renderDlBar() {
|
||||
const d = S.dl, b = d && d.batch, on = !!(d && d.busy && b && b.files);
|
||||
$("#dlBar").classList.toggle("hidden", !on);
|
||||
if (!on) return;
|
||||
const cur = d.finished ? 0 : d.done || 0, done = (b.done_bytes || 0) + cur;
|
||||
$("#dlText").textContent = tr("dl.progress", {i: Math.min(b.done_files + 1, b.files), n: b.files, done: mb(done), total: mb(b.bytes)});
|
||||
$("#dlFill").style.width = (done / Math.max(b.bytes, 1) * 100).toFixed(1) + "%";
|
||||
}
|
||||
function badge(f) {
|
||||
const lv = f.check || (S.recs?.local.find(x => x.name === f.name) || {}).check;
|
||||
@@ -573,8 +673,9 @@ function renderCheck() {
|
||||
const dl = S.dl && S.dl.name === f.name && !S.dl.finished ? S.dl : null;
|
||||
box.innerHTML = dl
|
||||
? `<div style="flex:1"><span class="muted">${tr("probe.fetching", {done: mb(dl.done), total: mb(dl.total)})}</span><div class="bar"><div style="width:${(dl.done / dl.total * 100).toFixed(1)}%"></div></div></div>`
|
||||
: `<span class="muted">${tr("probe.onFrame", {size: mb(f.size)})}</span><button class="small" id="fetchBtn">${esc(tr("probe.fetch"))}</button>`;
|
||||
: `<span class="muted">${tr("probe.onFrame", {size: mb(f.size)})}</span><button class="small" id="fetchBtn">${esc(tr("probe.fetch"))}</button>${delButton(f)}`;
|
||||
const b = $("#fetchBtn"); if (b) b.onclick = () => fetchSel();
|
||||
if ($("#delSel")) $("#delSel").onclick = () => deleteRecording(f);
|
||||
return;
|
||||
}
|
||||
const p = S.probe;
|
||||
@@ -588,13 +689,37 @@ function renderCheck() {
|
||||
box.insertAdjacentHTML("afterbegin", `<div class="row" style="order:9;margin-left:auto">
|
||||
<button class="small" id="checkSel" title="${tr("check.title")}">${tr("check.run")}</button>
|
||||
<button class="small pc-only" id="playSel">${tr("play.button")}</button>
|
||||
${S.recs?.local.find(x => x.name === f.name)?.renamable ? `<button class="small" id="renSel">${tr("ren.button")}</button>` : ""}</div>`);
|
||||
${S.recs?.local.find(x => x.name === f.name)?.renamable ? `<button class="small" id="renSel">${tr("ren.button")}</button>` : ""}${delButton(f)}</div>`);
|
||||
if ($("#delSel")) $("#delSel").onclick = () => deleteRecording(f);
|
||||
$("#playSel").onclick = () => play(f.name);
|
||||
if ($("#renSel")) $("#renSel").onclick = () => renameRecording(f);
|
||||
$("#checkSel").onclick = () => runCheck(f.name);
|
||||
loadCheck(f.name);
|
||||
}
|
||||
|
||||
/* ---------- deleting one recording: on the Frame (for good) or on this PC (trash) ---------- */
|
||||
function delWhere(f) {
|
||||
if (f.source === "frame") return S.recs && S.recs.frame_online && !queued(f.name) ? "frame" : null;
|
||||
return S.recs?.local.find(x => x.name === f.name)?.renamable && !queued(f.name) ? "pc" : null;
|
||||
}
|
||||
function delButton(f) {
|
||||
const where = delWhere(f);
|
||||
return where ? `<button class="small" id="delSel" title="${tr("rdel." + where)}">${tr("rdel." + where)}</button>` : "";
|
||||
}
|
||||
async function deleteRecording(f) {
|
||||
const where = delWhere(f);
|
||||
if (!where) return;
|
||||
const loc = S.recs.local.find(x => x.name === f.name), orig = loc ? loc.orig : f.name;
|
||||
const other = where === "frame" ? !!(f.local || loc) : !!(S.recs.frame || []).find(x => x.name === orig);
|
||||
const name = f.label || fmtDate(f.date) || f.name;
|
||||
if (!confirm(tr(`rdel.confirm.${where}${other ? "" : ".only"}`, {name, size: mb(f.size)}))) return;
|
||||
const r = await api("/api/recording/delete", {name: f.name, where});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
toast(tr("rdel.done." + where, {name}));
|
||||
S.sel = null; S.probe = null; $("#checkRes").className = "hidden";
|
||||
renderCheck(); updateStart(); loadRecs(true);
|
||||
}
|
||||
|
||||
/* ---------- recording check ---------- */
|
||||
const TL_COLOR = {ok: "var(--ok)", flat: "var(--warn)", dark: "#6b5cff", fast: "#ff8fd0", eye: "var(--bad)", over: "#f4f1c9"};
|
||||
function mmss(s) { return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }
|
||||
@@ -658,7 +783,8 @@ async function checkAll() {
|
||||
async function fetchSel() {
|
||||
const r = await api("/api/download", {name: S.sel.name});
|
||||
if (!r.ok) { toast(trErr(r.error)); return false; }
|
||||
S.dl = {name: S.sel.name, done: 0, total: S.sel.size, finished: false};
|
||||
if (!S.dl || !S.dl.busy) S.dl = {name: S.sel.name, done: 0, total: S.sel.size, finished: false, busy: true, queue: []};
|
||||
else S.dl.queue = [...(S.dl.queue || []), S.sel.name];
|
||||
renderCheck(); updateStart();
|
||||
return new Promise(res => { S.dlWait = res; });
|
||||
}
|
||||
@@ -687,7 +813,7 @@ function updateStart() {
|
||||
const total = Math.round(train + s.frames * 2.5 / 60 + 1);
|
||||
const busy = S.job && S.job.running;
|
||||
const ready = S.sel && (!S.probe || S.probe.ok) && $("#name").value.trim();
|
||||
$("#startBtn").disabled = !ready || busy || (S.dl && !S.dl.finished);
|
||||
$("#startBtn").disabled = !ready || busy || !!(S.sel && queued(S.sel.name));
|
||||
$("#estimate").innerHTML = busy ? tr("est.busy") : !S.sel ? tr("est.none") :
|
||||
tr("est", {frames: s.frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total});
|
||||
}
|
||||
@@ -697,8 +823,8 @@ document.querySelectorAll("#quality button").forEach(b => b.onclick = () => {
|
||||
document.querySelectorAll("input[name=target]").forEach(r => r.onchange = () => { S.targetTouched = true; targetChanged(); });
|
||||
["frames", "scale", "maxg", "iter"].forEach(id => $("#" + id).oninput = updateStart);
|
||||
$("#name").oninput = () => { $("#name").dataset.auto = "0"; updateStart(); };
|
||||
function setTab(tab) { S.tab = tab; document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
|
||||
document.querySelectorAll(".tab").forEach(t => t.onclick = () => { setTab(t.dataset.tab); renderRecs(); });
|
||||
function setTab(tab) { S.tab = tab; document.querySelectorAll(".tab[data-tab]").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
|
||||
document.querySelectorAll(".tab[data-tab]").forEach(t => t.onclick = () => { setTab(t.dataset.tab); renderRecs(); });
|
||||
$("#refreshBtn").onclick = () => loadRecs(true);
|
||||
$("#pickBtn").onclick = async () => {
|
||||
const r = await api("/api/pick", {});
|
||||
@@ -750,11 +876,14 @@ function renderJob() {
|
||||
$("#jobName").textContent = j.name;
|
||||
const qk = QUALITY_ALIAS[j.quality] || j.quality, combine = j.kind === "combine";
|
||||
const target = tr(j.upload ? "job.toFrame" : "job.pcOnly"), time = mins(Date.now() / 1000 - j.started);
|
||||
$("#jobSub").textContent = combine ? tr("job.sub.combine", {n: j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
|
||||
const versions = j.kind === "versions";
|
||||
$("#jobSub").textContent = versions ? tr("job.sub.versions", {n: (j.steps || []).length, time})
|
||||
: combine ? tr("job.sub.combine", {n: j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
|
||||
: tr("job.sub", {rec: j.recording, q: tr("q." + qk), target, time});
|
||||
$("#cancelBtn").classList.toggle("hidden", !j.running);
|
||||
$("#dismissBtn").classList.toggle("hidden", j.running);
|
||||
const phases = [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr((combine && i < 4 ? "cphase." : "phase.") + i));
|
||||
const phases = versions ? (j.steps || [])
|
||||
: [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr((combine && i < 4 ? "cphase." : "phase.") + i));
|
||||
const finished = !j.running && j.result === "ok";
|
||||
$("#steps").style.gridTemplateColumns = `repeat(${phases.length}, 1fr)`;
|
||||
$("#steps").innerHTML = phases.map((p, i) => {
|
||||
@@ -821,8 +950,9 @@ function sceneCard(s) {
|
||||
<div class="meta"><b>${esc(s.name)}</b>
|
||||
<span>${new Date(s.mtime * 1000).toLocaleDateString(LANG)} · ${s.gaussians ? tr("sc.splats", {n: num(s.gaussians / 1e6, 2)}) + " · " : ""}${mb(s.size)}</span>
|
||||
${s.disk ? `<span class="faint">${tr("sc.disk", {size: mb(s.disk.total)})}</span>` : ""}
|
||||
${scaleLine(s)}
|
||||
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
|
||||
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
|
||||
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small" data-versions="${esc(s.slug)}" title="${tr("ver.button.title")}">${tr("ver.button", {n: s.versions || 0})}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
|
||||
</div></div>`;
|
||||
}
|
||||
const ALIGN_LETTER = {frame: "A", images: "B", shift: "C", rigid: "D"};
|
||||
@@ -854,6 +984,7 @@ function groupBlock(g, bySlug) {
|
||||
<div class="gthumb" ${thumb}>${r && r.preview ? "" : "📁"}</div>
|
||||
<div class="gmeta"><b>📁 ${esc(g.name)}</b>
|
||||
<span>${tr("grp.rooms", {n: rooms.length})} · ${tr("sc.disk", {size: mb(disk)})}</span>
|
||||
${scaleLine(g)}
|
||||
${tour}${busy}
|
||||
<div class="acts">
|
||||
<button class="small" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : "grp.show", {n: rooms.length})}</button>
|
||||
@@ -863,6 +994,8 @@ function groupBlock(g, bySlug) {
|
||||
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
|
||||
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}
|
||||
<button class="small" data-gedit="${esc(g.slug)}">${tr("grp.edit")}</button>
|
||||
<button class="small" data-scale="${esc(g.slug)}" title="${tr("scale.button.title")}" ${g.busy ? "disabled" : ""}>📏${g.scale !== 1 ? " " + ratioText(g.scale) : ""}</button>
|
||||
${r && r.ply ? `<button class="small" data-versions="${esc(g.slug)}" title="${tr("ver.button.title")}">${tr("ver.button", {n: g.versions || 0})}</button>` : ""}
|
||||
<button class="small" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
|
||||
<button class="small" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
|
||||
</div>
|
||||
@@ -894,12 +1027,14 @@ async function loadScenes() {
|
||||
$("#sceneGrid").innerHTML = loose.length ? loose.map(sceneCard).join("")
|
||||
: S.groups.length ? "" : `<div class="empty" style="grid-column:1/-1">${tr("scenes.none")}</div>`;
|
||||
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); loadScenes(); });
|
||||
document.querySelectorAll("[data-scale]").forEach(b => b.onclick = () => openScale(b.dataset.scale));
|
||||
document.querySelectorAll("[data-versions]").forEach(b => b.onclick = () => openVersions(b.dataset.versions));
|
||||
document.querySelectorAll("[data-gedit]").forEach(b => b.onclick = () => openGroupDlg(S.groups.find(g => g.slug === b.dataset.gedit)));
|
||||
document.querySelectorAll("[data-gdelete]").forEach(b => b.onclick = () => deleteGroup(S.groups.find(g => g.slug === b.dataset.gdelete)));
|
||||
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});
|
||||
@@ -989,7 +1124,13 @@ function openCombine(g) {
|
||||
radios("#cmbAlign", "cmb.align", ["frame", "images", "shift", "rigid"], o.align || "images");
|
||||
radios("#cmbPart", "cmb.part", ["view", "nearest"], o.partition || "view");
|
||||
$("#cmbTitle").textContent = tr("cmb.title", {name: g.name});
|
||||
$("#cmbSize").value = String(o.frame_splats || 3500000);
|
||||
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)}${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;
|
||||
const showCustom = () => ["#cmbSizeCustom", "#cmbSizeUnit"].forEach(id => $(id).classList.toggle("hidden", $("#cmbSize").value !== "custom"));
|
||||
$("#cmbSize").onchange = showCustom; showCustom();
|
||||
// memory, measured on six rooms: reduce ~0.45 GB per million splats, full SOG ~0.65 GB per million
|
||||
const total = (g.splats || 0) * 0.85 / 1e6, needFrame = Math.max(1.5, 0.45 * total), needFull = Math.max(needFrame, 0.65 * total);
|
||||
const avail = S.memGB || 0, lowFull = avail && needFull > avail;
|
||||
@@ -1003,7 +1144,7 @@ function openCombine(g) {
|
||||
$("#cmbCancel").onclick = () => $("#combineDlg").close();
|
||||
$("#cmbStart").onclick = async () => {
|
||||
const r = await api("/api/combine", {slug: g.slug, align: $("input[name='cmb.align']:checked").value,
|
||||
partition: $("input[name='cmb.part']:checked").value, frame_splats: $("#cmbSize").value,
|
||||
partition: $("input[name='cmb.part']:checked").value, frame_splats: $("#cmbSize").value === "custom" ? String(Math.round(parseFloat($("#cmbSizeCustom").value) * 1e6)) : $("#cmbSize").value,
|
||||
full: $("#cmbFull").checked, upload: S.frameConfigured && $("#cmbUpload").checked});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#combineDlg").close(); toast(tr("cmb.started")); await poll(); loadScenes();
|
||||
@@ -1011,6 +1152,152 @@ function openCombine(g) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- what is on the Frame ---------- */
|
||||
function setSceneTab(tab) {
|
||||
S.stab = tab;
|
||||
document.querySelectorAll(".tab[data-stab]").forEach(x => x.classList.toggle("active", x.dataset.stab === tab));
|
||||
$("#pcView").classList.toggle("hidden", tab !== "pc");
|
||||
$("#frameView").classList.toggle("hidden", tab !== "frame");
|
||||
$("#frameRefresh").classList.toggle("hidden", tab !== "frame");
|
||||
$("#groupNewBtn").classList.toggle("invisible", tab !== "pc");
|
||||
if (tab === "frame") loadFrameGallery();
|
||||
}
|
||||
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>`;
|
||||
const r = await api("/api/frame/gallery" + (refresh ? "?refresh=1" : ""));
|
||||
if (!r.configured) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("frame.none")}</div>`; return; }
|
||||
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")}&place=${x.place}&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" 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.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, place: x.place});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
toast(tr("fg.removed", {name: x.name})); loadFrameGallery(true); loadScenes();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 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, 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);
|
||||
$("#verScales").classList.toggle("hidden", scale === 1);
|
||||
$("#verScales").innerHTML = scale === 1 ? "" : `<label class="chk"><input type="checkbox" value="1" checked> ${tr("scale.original")}</label>`
|
||||
+ `<label class="chk"><input type="checkbox" value="${scale}"> ${tr("ver.walk", {ratio: ratioText(scale)})}</label>`;
|
||||
await drawVersions(slug);
|
||||
$("#verDlg").showModal();
|
||||
$("#verClose").onclick = () => $("#verDlg").close();
|
||||
$("#verMake").onclick = async () => {
|
||||
const sizes = [...document.querySelectorAll("#verFrame input:checked, #verPc input:checked")]
|
||||
.map(i => i.value === "all" ? "all" : String(Math.round(parseFloat(i.value) * 1e6)));
|
||||
const custom = parseFloat($("#verCustom").value);
|
||||
if (custom > 0) sizes.push(String(Math.round(custom * 1e6)));
|
||||
const scales = scale === 1 ? ["1"] : [...document.querySelectorAll("#verScales input:checked")].map(i => i.value);
|
||||
if (!sizes.length || !scales.length) { toast(tr("ver.pick")); return; }
|
||||
const r = await api("/api/versions", {slug, sizes: sizes.join(","), scales: scales.join(",")});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#verDlg").close(); toast(tr("ver.started")); await poll();
|
||||
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
};
|
||||
}
|
||||
async function drawVersions(slug) {
|
||||
const r = await api("/api/versions?slug=" + encodeURIComponent(slug));
|
||||
const list = r.versions || [];
|
||||
$("#verList").innerHTML = list.length ? list.map(v => `<div class="row">
|
||||
<b>${esc(v.label)}</b>
|
||||
<span class="muted" style="font-size:12.5px">${tr("million", {n: num(v.splats / 1e6, 2)})} · ${mb(v.size)}${v.stale ? " · " + tr("ver.stale") : ""}</span>
|
||||
<span class="sp"></span>${frameState({upload: v.upload, on_frame: v.on_frame})}
|
||||
${S.frameConfigured ? `<button class="small" data-vup="${esc(v.key)}" ${v.upload && v.upload.running ? "disabled" : ""}>${tr(v.on_frame ? "sc.reupload" : "sc.toFrame")}</button>` : ""}
|
||||
${v.on_frame ? `<button class="small" data-vunframe="${esc(v.key)}" title="${tr("ver.unframe.title")}">${tr("ver.unframe")}</button>` : ""}
|
||||
<button class="small" data-vdel="${esc(v.key)}" title="${tr("ver.delete")}" aria-label="${tr("ver.delete")}">${tr("sc.delete")}</button>
|
||||
</div>`).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
|
||||
$("#verList").querySelectorAll("[data-vup]").forEach(b => b.onclick = async () => {
|
||||
const res = await api("/api/upload", {slug, version: b.dataset.vup});
|
||||
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;
|
||||
const res = await api("/api/version/delete", {slug, version: v.key});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
drawVersions(slug); loadScenes();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- scale (models) ---------- */
|
||||
const GAUGES = [[1, "scale.original"], [220, "Z"], [160, "N"], [120, "TT"], [87, "H0"], [64, "S"], [45, "0"], [22.5, "G"]];
|
||||
function ratioText(r) { return "1:" + num(r, r % 1 ? 1 : 0); }
|
||||
function scaleLine(x) {
|
||||
if (!x.scale || x.scale === 1) return "";
|
||||
return `<span class="faint">${tr("scale.line", {ratio: ratioText(x.scale)})}${x.scale_xr ? " · " + tr("scale.line.xr") : ""}</span>`;
|
||||
}
|
||||
function openScale(slug) {
|
||||
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
|
||||
if (!x) return;
|
||||
const cur = x.scale || 1, known = GAUGES.some(([r]) => r === cur);
|
||||
$("#scaleTitle").textContent = tr("scale.title", {name: x.name});
|
||||
$("#scaleOpts").innerHTML = GAUGES.map(([r, label]) => `<label class="opt${r === cur ? " active" : ""}">
|
||||
<input type="radio" name="scaleR" value="${r}" ${r === cur ? "checked" : ""}><div><b>${r === 1 ? tr(label) : label} · ${ratioText(r)}</b></div></label>`).join("")
|
||||
+ `<label class="opt${known ? "" : " active"}"><input type="radio" name="scaleR" value="custom" ${known ? "" : "checked"}><div><b>${tr("scale.custom")}</b></div></label>`;
|
||||
$("#scaleCustom").value = known ? "" : cur;
|
||||
const value = () => { const v = $("input[name=scaleR]:checked").value; return v === "custom" ? parseFloat($("#scaleCustom").value) : parseFloat(v); };
|
||||
const sync = () => { $("#scaleOpts").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked));
|
||||
const one = value() === 1; $("#scaleXr").disabled = one; if (one) $("#scaleXr").checked = false; };
|
||||
$("#scaleOpts").querySelectorAll("input").forEach(i => i.onchange = sync);
|
||||
$("#scaleCustom").oninput = () => { $("input[name=scaleR][value=custom]").checked = true; sync(); };
|
||||
$("#scaleXr").checked = !!x.scale_xr;
|
||||
const onFrame = g ? g.result && g.result.on_frame : x.on_frame;
|
||||
$("#scaleMembersRow").classList.toggle("hidden", !g);
|
||||
$("#scaleFrameRow").classList.toggle("hidden", !(S.frameConfigured && onFrame));
|
||||
sync();
|
||||
$("#scaleDlg").showModal();
|
||||
$("#scaleCancel").onclick = () => $("#scaleDlg").close();
|
||||
$("#scaleOk").onclick = async () => {
|
||||
const r = await api("/api/scale", {slug, scale: String(value()), xr: $("#scaleXr").checked, with_scenes: !!g && $("#scaleMembers").checked,
|
||||
update_frame: !$("#scaleFrameRow").classList.contains("hidden") && $("#scaleFrame").checked});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#scaleDlg").close(); toast(tr("scale.done")); loadScenes();
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- renaming ---------- */
|
||||
function askName(title, hint, value, frameOption) {
|
||||
$("#renTitle").textContent = title; $("#renHint").textContent = hint;
|
||||
@@ -1084,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();
|
||||
@@ -1182,12 +1470,23 @@ async function poll() {
|
||||
$("#offline").classList.toggle("hidden", !offline);
|
||||
document.body.classList.toggle("offline", offline);
|
||||
if (offline) return;
|
||||
const wasFetching = !!(S.dl && S.dl.busy), doneFiles = S.dl && S.dl.batch ? S.dl.batch.done_files : 0;
|
||||
S.job = r.job; S.dl = r.download;
|
||||
if (S.dl && S.dl.finished && S.dlWait && S.sel && S.dl.name === S.sel.name) {
|
||||
renderDlBar();
|
||||
const nowFetching = !!(S.dl && S.dl.busy);
|
||||
if (wasFetching && (!nowFetching || (S.dl.batch && S.dl.batch.done_files !== doneFiles))) {
|
||||
await loadRecs();
|
||||
if (!nowFetching && S.dl && S.dl.batch) {
|
||||
const b = S.dl.batch, failed = (b.failed || []).length;
|
||||
toast(b.cancelled ? tr("dl.cancelled") : failed ? tr("dl.failed", {n: failed}) : tr("dl.done", {n: b.done_files}));
|
||||
}
|
||||
}
|
||||
// The chosen recording was fetched (also as part of several – the next one may already be on its way)
|
||||
if (S.dlWait && S.sel && !queued(S.sel.name)) {
|
||||
const res = S.dlWait; S.dlWait = null;
|
||||
if (S.dl.error) { toast(tr("err.generic") + ": " + S.dl.error); renderCheck(); res(false); }
|
||||
else { await loadRecs(); const f = S.recs.local.find(x => x.name === S.sel.name);
|
||||
if (f) { S.sel.path = f.path; await runProbe(f.path); } res(true); }
|
||||
await loadRecs(); const f = S.recs.local.find(x => x.name === S.sel.name);
|
||||
if (f) { S.sel.path = f.path; await runProbe(f.path); res(true); }
|
||||
else { toast(tr("err.generic") + ": " + ((S.dl && S.dl.name === S.sel.name && S.dl.error) || tr("dl.failed", {n: 1}))); renderCheck(); res(false); }
|
||||
} else if (S.dl && !S.dl.finished && S.sel && S.dl.name === S.sel.name) renderCheck();
|
||||
renderJob(); updateStart();
|
||||
const running = !!(S.job && S.job.running);
|
||||
|
||||
Reference in new issue
Block a user