mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 20:01:39 +02:00
Compare commits
No files matched your search
@@ -2,6 +2,46 @@
|
||||
|
||||
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.6.0] – 2026-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Import your own splats**: *+ Import a splat …* above the finished scenes turns a splat file from another program into a scene like the others – to copy it to the Frame, make versions or a mesh. Reads `.ply`, `.sog`, `.spz`, `.splat` and `.ksplat` (via splat-transform); the file itself stays where it is. (Asked for in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
- **Which way is up**: Y down as Spirula Studio, COLMAP and the Frame's own scenes have it (the usual case), Y up or Z up for files from other tools.
|
||||
- **Size**: a factor, e.g. 0.01 for a file in centimetres – a reconstruction from photos has no real scale of its own.
|
||||
- The preview in the Frame's gallery comes from the top view, as for tours.
|
||||
- Only from this PC (the file dialog opens here). Also on the command line: `scripts/import.sh`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The page did not update any more (0.5.1)**: the new footer stood below the page's script; setting up its *Check for updates* link failed, and the rest of the script – among it the regular status updates – never ran. A finished job stayed on "Converting …" with its progress bar until the page was reloaded. A test now checks that everything the script sets up on load is already in the page.
|
||||
|
||||
## [0.5.1] – 2026-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- **A note when a newer version is out**: once a day Splat Workshop asks GitHub for the newest release (one plain request in the background, nothing about you is sent) and shows *Splat Workshop x.y.z is out* at the top, with *What's new* and how to update; *Later* hides it until the next version. `check_updates = false` under `[server]` switches it off. (Asked in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
- **How to update** in the README: `git pull`, restart – config and scenes stay.
|
||||
- **Check for updates** by hand: in the tray icon's menu (the answer comes as a notification; a click on it opens what's new) and at the bottom of the page. (Suggested in #2.)
|
||||
- **Version and links at the bottom of the page**: which Splat Workshop this is, GitHub, the releases. (Suggested in #2.)
|
||||
- **Copying to the Frame shows how far it is**: *Copying to the Frame … 45 % (7 MB of 16 MB)*, measured every 2 seconds on the Frame. Before, it only said that it was copying – a scene of 1 M splats takes a few seconds over Wi-Fi, a big version much longer. (Asked in #2.)
|
||||
- **After a mesh, what to do with it**: the finished job and the mesh dialog show the files to **download** – also from a phone or laptop at home – plus **Open in Blender** (when Blender is installed, as a program or the Flatpak from Flathub: the mesh is imported right away, GLB first) and the folder.
|
||||
|
||||
## [0.5.0] – 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Mesh export**: *Mesh …* on every scene and tour turns the splats into a surface with Spirula Studio (`spirula mesh`) – for Blender and other 3D programs, or for 3D printing. Formats: **GLB** (recommended: colours or texture in one file), **STL** (no colours, for slicers), **OBJ** (with a texture if possible, otherwise without colours) and **PLY** (colours per corner).
|
||||
- **Stands upright, floor at height 0**: our scenes have Y pointing down like the Steam Frame's tracking, so a mesh used to lie on its side in Blender. Now PLY and STL get Z up, GLB and OBJ Y up as their standard and custom want (Blender turns those into Z up itself on import – checked in Blender 5.2 for every format); the floor – found as for the floor plan – is moved to height 0. In metres.
|
||||
- What lies well outside the room is left out before meshing (as the *Edges* clean-up does) – otherwise big blobs beyond the walls become lumps floating around the room.
|
||||
- A **texture** from the training images when they are still there; after *clean up → training images*, colours per corner from the splats.
|
||||
- The mesh lives in the scene's folder (`mesh/`); a new one replaces the old one. *Mesh ✓* shows which scenes have one.
|
||||
- Also on the command line: `scripts/mesh.sh` and `scripts/upright.py`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A versions job with one version said "1 versions".
|
||||
|
||||
## [0.4.2] – 2026-10-15
|
||||
|
||||
### Fixed
|
||||
@@ -156,6 +196,9 @@ First release.
|
||||
- **Floor plans** (experimental) in two styles with a measuring tool.
|
||||
- Configurable video player; English and German interface.
|
||||
|
||||
[0.6.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.1...v0.6.0
|
||||
[0.5.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.2...v0.5.0
|
||||
[0.4.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.1...v0.4.0
|
||||
|
||||
@@ -30,4 +30,5 @@ one in place while a job uses it – write a new file and `mv` it over.
|
||||
## File systems
|
||||
|
||||
Runs may live on NTFS: no folder or file name may end with a dot or contain `:`; git there needs
|
||||
`core.fileMode false`.
|
||||
`core.fileMode false`. With that, git does not see the executable bit: a new shell script needs
|
||||
`git update-index --chmod=+x scripts/<name>.sh`, or it is not executable in CI and fresh clones.
|
||||
@@ -22,12 +22,29 @@ Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork
|
||||
4. **In die Galerie**: Vorschaubild und Namensschild im Stil der Beispielszenen, kopiert in den Ordner für eigene Szenen von Gaussian XR auf der Frame (`Documents/GaussianXR`).
|
||||
5. **Die Wohnung zusammensetzen**: Räume gruppieren – ein Raum darf aus mehreren Aufnahmen bestehen – und zu einem Rundgang machen, durch den du gehst, voll für den PC, kleiner für die Frame.
|
||||
|
||||
## Neu in 0.6
|
||||
|
||||
- 📥 **Eigene Splats importieren** – eine Splat-Datei aus einem anderen Programm (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) wird zu einer Szene wie die anderen: auf die Frame bringen, Fassungen oder ein Mesh machen. Du sagst, wo oben ist, und bei Bedarf die Größe.
|
||||
|
||||
<img src="docs/import.png" width="420" alt="Der Import-Dialog: Datei wählen, benennen, sagen, wo oben ist, und die Größe">
|
||||
<br><sub>Englische Oberfläche.</sub>
|
||||
|
||||
## Neu in 0.5
|
||||
|
||||
- 🧊 **Mesh-Export** – *Mesh …* macht aus einer Szene oder einem Rundgang eine Oberfläche für Blender und andere 3D-Programme (GLB, OBJ, PLY) oder für den 3D-Druck (STL). Sie steht aufrecht mit dem Boden auf Höhe 0, statt auf der Seite zu liegen – für jedes Format per Import in Blender geprüft; mit Textur, wenn die Trainingsbilder noch da sind. Die Dateien lassen sich herunterladen – auch von anderen Geräten im Heimnetz – oder mit einem Klick in Blender öffnen. (Angeregt in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
|
||||
<img src="docs/mesh.png" width="400" alt="Der Mesh-Dialog: GLB und STL angehakt, OBJ und PLY zur Wahl, Textur aus den Trainingsbildern, das vorhandene Mesh mit seinen Dateien"> <img src="docs/mesh-blender.png" width="500" alt="Das Mesh eines Beispielraums in Blender: aufrecht auf dem Boden">
|
||||
<br><sub>Beispieldaten – ein künstlicher Raum, in Blender gerendert (englische Oberfläche).</sub>
|
||||
|
||||
## Neu in 0.4
|
||||
|
||||
- 🧽 **Fassungen bereinigen** *(experimentell)* – vier Schieberegler im Fassungen-Dialog: **Decke** (eine weiße Decke wird zum dunklen Schleier – der Schleier kommt weg, die Decke wird in ihrer eigenen Farbe geschlossen), **Boden** (Löcher gefüllt), **Dunst** (große durchsichtige Wolken; dünne Gitter und Kabel bleiben), **Rand** (was außerhalb des Raums liegt). Die Szene selbst bleibt; die bereinigte Fassung neben einer ohne vergleichen.
|
||||
- 🎚️ **Fassungen nach Zweck** – *für die Frame* (eine Größe, mit den auf einer Steam Frame gemessenen fps) oder *für den PC* (alle Splats); auf Wunsch dieselbe ohne Bereinigung daneben; vorher eine Vorschau, was entsteht. Fassungen heißen nach ihren Bereinigungsstärken, *Als Vorlage* übernimmt die Einstellungen für den nächsten Versuch, eine PC-Fassung öffnet sich mit einem Klick in **Spirula Studio**, und Fassungen **klappen unter ihrer Szene oder ihrem Rundgang auf**.
|
||||
- 🧭 **Aufgeräumte Knopfleisten** – was du tust, was du anschaust, was du änderst; das Aufklappen in einer eigenen Zeile.
|
||||
|
||||
<img src="docs/versions.png" width="420" alt="Der neue Fassungen-Dialog: für die Frame oder den PC, Größen mit gemessenen fps, vier Bereinigungs-Regler, zusätzlich ohne Bereinigung zum Vergleichen und eine Vorschau der zwei Fassungen"> <img src="docs/versions-list.png" width="520" alt="Eine Szene mit aufgeklappten Fassungen: 1 Mio. für die Frame mit und ohne Bereinigung, alle Splats für den PC mit Spirula und Als Vorlage">
|
||||
<br><sub>Beispieldaten – Szene und Vorschaubild sind Platzhalter (englische Oberfläche).</sub>
|
||||
|
||||
## Neu in 0.3
|
||||
|
||||
- 🚪 **Räume aus mehreren Aufnahmen** – eine Gruppe als *Ein Raum aus mehreren Aufnahmen* markieren, z. B. zwei kurze Runden durchs Wohnzimmer. Beim Zusammensetzen kommt jeder Teil des Raums aus der Aufnahme, die ihn am besten gesehen hat. Die Arcturus-Pipeline nimmt höchstens 500 Bildpaare pro Aufnahme – mehrere kurze Aufnahmen geben einem Raum also deutlich mehr Detail als eine lange.
|
||||
@@ -87,6 +104,8 @@ cd splat-workshop
|
||||
./install.sh --desktop # Menüeintrag + Desktop-Verknüpfung; --autostart für das Tray beim Anmelden
|
||||
```
|
||||
|
||||
**Aktualisieren:** im geklonten Ordner `git pull`, dann die Splat-Werkstatt beenden (Tray-Symbol → *Beenden*) und neu starten. Config (`~/.config/splat-workshop/`) und Szenen bleiben, wie sie sind; `./install.sh` braucht es nur, wenn der Menüeintrag fehlt. Meckert `git pull` über lokale Änderungen, hast du selbst eine Datei im Ordner geändert – `git stash` legt die Änderung beiseite, `git stash pop` holt sie zurück. Gibt es ein neueres Release, zeigt die Werkstatt oben einen Hinweis: Einmal am Tag fragt sie GitHub nach dem neuesten Release (eine einfache Anfrage, nichts über dich). `check_updates = false` unter `[server]` in der Config schaltet das ab; *Nach Updates suchen* im Menü des Tray-Symbols oder unten auf der Seite fragt sofort nach.
|
||||
|
||||
**Läuft unter Linux** (getestet mit Nobara/Fedora und einer RTX 4070 SUPER). macOS ist ungetestet, Windows wird von der Arcturus-Pipeline noch nicht unterstützt.
|
||||
|
||||
**Arcturus-Pipeline und Spirula Studio einrichten:** Schritt für Schritt in [docs/arcturus-setup.de.md](docs/arcturus-setup.de.md) (Pakete, Spirula bauen, bekannte Stolperfallen).
|
||||
|
||||
@@ -24,12 +24,28 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
|
||||
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 – a room may be several recordings – and turn them into one tour you walk through, full size for the PC, smaller for the Frame.
|
||||
|
||||
## New in 0.6
|
||||
|
||||
- 📥 **Import your own splats** – a splat file from another program (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) becomes a scene like the others: copy it to the Frame, make versions or a mesh. You say which way is up and, if needed, the size.
|
||||
|
||||
<img src="docs/import.png" width="420" alt="The import dialog: choose a file, name it, say which way is up and the size">
|
||||
|
||||
## New in 0.5
|
||||
|
||||
- 🧊 **Mesh export** – *Mesh …* turns a scene or tour into a surface for Blender and other 3D programs (GLB, OBJ, PLY) or for 3D printing (STL). It stands upright with the floor at height 0 instead of lying on its side – checked by importing every format in Blender; with a texture when the training images are still there. Download the files – also from other devices at home – or open the mesh in Blender with one click. (Suggested in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
|
||||
<img src="docs/mesh.png" width="400" alt="The mesh dialog: GLB and STL ticked, OBJ and PLY to choose, a texture from the training images, the existing mesh with its files"> <img src="docs/mesh-blender.png" width="500" alt="The mesh of an example room in Blender: standing upright on the floor">
|
||||
<br><sub>Example data – a synthetic room, rendered in Blender.</sub>
|
||||
|
||||
## New in 0.4
|
||||
|
||||
- 🧽 **Clean up a version** *(experimental)* – four sliders in the versions dialog: **Ceiling** (a white ceiling turns into a dark veil – the veil goes, the ceiling is closed in its own colour), **Floor** (holes filled), **Haze** (big transparent blobs; thin bars and cables stay), **Edges** (what lies outside the room). The scene itself stays; compare the cleaned version with one without.
|
||||
- 🎚️ **Versions by purpose** – *for the Frame* (one size, with the frame rate measured on a Steam Frame) or *for the PC* (all splats); optionally the same without cleaning up next to it; a preview of what will be made. Versions are named by their clean-up strengths, *As template* takes a version's settings for the next try, a PC version opens in **Spirula Studio** with one click, and versions **unfold under their scene or tour**.
|
||||
- 🧭 **Tidier action bars** – what you do, what you look at, what you change; the unfolding lists on a line of their own.
|
||||
|
||||
<img src="docs/versions.png" width="420" alt="The new version dialog: for the Frame or the PC, sizes with measured frame rates, four clean-up sliders, also without cleaning up to compare, and a preview of the two versions it will make"> <img src="docs/versions-list.png" width="520" alt="A scene with its versions unfolded: 1 M for the Frame with and without cleaning up, all splats for the PC with Spirula and As template">
|
||||
<br><sub>Example data – the scene and its preview are placeholders.</sub>
|
||||
|
||||
## New in 0.3
|
||||
|
||||
- 🚪 **Rooms made of several recordings** – mark a group as *one room made of several recordings*, e.g. two short rounds through the living room. When put together, every part of the room comes from the recording that saw it best. The Arcturus pipeline uses at most 500 image pairs per recording, so several short recordings give a room far more detail than one long one.
|
||||
@@ -121,6 +137,17 @@ Downloading recordings and copying scenes uses SSH with a key:
|
||||
4. On your PC: `ssh-copy-id steamos@<frame address>` (enter the password once), then check that `ssh steamos@<frame address>` logs in without asking.
|
||||
5. Give the Frame a fixed IP address in your router so it does not change, and put `steamos@<that address>` into `[frame] host` in the config.
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
cd splat-workshop # the folder you cloned
|
||||
git pull
|
||||
```
|
||||
|
||||
Then quit Splat Workshop (tray icon → *Quit*) and start it again. Your config (`~/.config/splat-workshop/`) and your scenes stay as they are; `./install.sh` is only needed again if the menu entry is missing. If `git pull` complains about local changes, you edited a file in the folder yourself – `git stash` puts your change aside, `git stash pop` brings it back.
|
||||
|
||||
Splat Workshop shows a note at the top of the page when a newer release is out: once a day it asks GitHub for the newest release (one plain request, nothing about you is sent). `check_updates = false` under `[server]` in the config switches that off; *Check for updates* in the tray icon's menu or at the bottom of the page asks right away.
|
||||
|
||||
## Usage
|
||||
|
||||
Start **Splat Workshop** from the menu (or `./splat-workshop`). The tray icon appears and the page opens in your browser.
|
||||
|
||||
@@ -45,3 +45,6 @@ lan = false
|
||||
idle_minutes = 20
|
||||
# Interface language: "auto" (browser / system language), "en" or "de".
|
||||
language = "auto"
|
||||
# Once a day, ask GitHub for the newest release and show a note when there is a newer one
|
||||
# (one plain request to api.github.com, nothing about you is sent). false: never ask.
|
||||
check_updates = true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 286 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# A splat file from another program as a scene (0.6) – then copied to the Frame, versions, mesh … like any
|
||||
# other scene.
|
||||
#
|
||||
# Usage: scripts/import.sh <splat file> <run folder> "<display name>" [up] [scale]
|
||||
# <splat file> .ply .compressed.ply .sog .spz .splat .ksplat (whatever splat-transform reads)
|
||||
# up where "up" is in the file: y-down (Spirula Studio, COLMAP, the Frame – default), y-up, z-up
|
||||
# scale factor for the size, e.g. 0.01 if the file is in centimetres (default 1)
|
||||
# Writes <run folder>/ with spirula/scene.ply, scene.sog, scene.xrSceneTransform.json, a preview from the
|
||||
# top view and a name plate. 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)"
|
||||
|
||||
src=$(realpath "${1:?Give a splat file}")
|
||||
out=${2:?Give the run folder}
|
||||
name=${3:?Give a name}
|
||||
up=${4:-y-down}
|
||||
scale=${5:-1}
|
||||
[ -f "$src" ] || { echo "No such file: $src" >&2; exit 1; }
|
||||
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found" >&2; exit 1; }
|
||||
[ -e "$out" ] && { echo "Already there: $out" >&2; exit 1; }
|
||||
case "$up" in
|
||||
y-down) turn=() ;;
|
||||
y-up) turn=(-r 180,0,0) ;; # +Y becomes -Y (checked: splat-transform -r 180,0,0)
|
||||
z-up) turn=(-r -90,0,0) ;; # +Z becomes -Y (splat-transform -r 90,0,0 turns +Z into +Y)
|
||||
*) echo "up must be y-down, y-up or z-up" >&2; exit 1 ;;
|
||||
esac
|
||||
[[ "$scale" =~ ^[0-9]+(\.[0-9]+)?$ ]] && awk -v s="$scale" 'BEGIN { exit !(s > 0) }' \
|
||||
|| { echo "scale must be a number above 0" >&2; exit 1; }
|
||||
size=()
|
||||
[ "$scale" != 1 ] && size=(-s "$scale")
|
||||
slug=$(basename "$out")
|
||||
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
|
||||
|
||||
tmp=$out.tmp
|
||||
rm -rf "$tmp"
|
||||
mkdir -p "$tmp/spirula"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
echo "@@ vstep 1 2 convert"
|
||||
echo "Importing $(basename "$src") as \"$name\" (up: $up, scale: $scale) ..."
|
||||
"$SPLAT_TRANSFORM" -w "$src" "${turn[@]}" "${size[@]}" -N "$tmp/spirula/scene.ply"
|
||||
"$SPLAT_TRANSFORM" -w "$tmp/spirula/scene.ply" "$tmp/scene.sog"
|
||||
# (the same line the pipeline writes: the scene list reads the number of splats from it)
|
||||
python3 -c 'import sys
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
n = next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex"))
|
||||
print(f"spirula: {n:,} Gaussians in {sys.argv[1]}")' "$tmp/spirula/scene.ply"
|
||||
# Gaussian XR starts at the scene's origin, as it is in the file
|
||||
cat > "$tmp/scene.xrSceneTransform.json" <<'JSON'
|
||||
{"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], "worldScale": 1.0, "worldScaleOffset": [0, 0, 0]}
|
||||
JSON
|
||||
|
||||
echo "@@ vstep 2 2 preview"
|
||||
# Preview for the gallery: the top view as for the floor plan; if that finds no room, plain colour
|
||||
if uv run -q "$HERE/floorplan.py" "$tmp/plan.png" "$tmp=$name" >/dev/null 2>&1; then
|
||||
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp" "$name" "$stem"
|
||||
else
|
||||
uv run -q --no-project --with pillow python3 -c 'import sys
|
||||
from PIL import Image
|
||||
Image.new("RGBA", (512, 512), (40, 46, 60, 255)).save(sys.argv[1])' "$tmp/$stem.gallery.png"
|
||||
uv run -q "$HERE/make_gallery.py" --label "$tmp/$stem.sceneinfo.png" "$name"
|
||||
fi
|
||||
mv "$tmp" "$out"
|
||||
trap - EXIT
|
||||
echo "Gaussian XR initial view: (imported scene) Display rotation 0°"
|
||||
echo "@@ done local"
|
||||
echo "Done: $out"
|
||||
exit
|
||||
}
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
# A mesh (surface) of a finished scene or tour, for 3D programs and 3D printing – made by Spirula Studio
|
||||
# (`spirula mesh`) from the splats inside the room (scripts/clean.py --outside), then stood upright with
|
||||
# the floor at height 0 (scripts/upright.py).
|
||||
#
|
||||
# Usage: scripts/mesh.sh <run folder> <formats> [texture]
|
||||
# <formats> comma-separated: glb, ply, obj, stl
|
||||
# glb colours (texture or per corner); Y up as glTF wants it – Blender, Windows 3D viewer, web
|
||||
# ply colours per corner; Z up – MeshLab, CloudCompare, Blender
|
||||
# obj with a texture, or without colours; Y up as is custom for OBJ – nearly every 3D program
|
||||
# stl no colours; Z up – slicers for 3D printing
|
||||
# texture 1: a texture from the training images (only if they are still there, see "clean up"),
|
||||
# otherwise colours per corner from the splats
|
||||
# Writes <run folder>/mesh/ (replaced as a whole when done). 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}")
|
||||
formats=${2:?Give the formats, e.g. glb,stl}
|
||||
texture=${3:-0}
|
||||
src=$out/spirula/scene.ply
|
||||
[ -f "$src" ] || { echo "No spirula/scene.ply in $out" >&2; exit 1; }
|
||||
[ -x "$SPIRULA_BIN" ] || { echo "Spirula Studio not found: $SPIRULA_BIN" >&2; exit 1; }
|
||||
IFS=',' read -ra want <<< "$formats"
|
||||
for f in "${want[@]}"; do
|
||||
case "$f" in glb|ply|obj|stl) ;; *) echo "Unknown mesh format: $f (glb, ply, obj, stl)" >&2; exit 1 ;; esac
|
||||
done
|
||||
slug=$(basename "$out")
|
||||
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
|
||||
|
||||
# Colours: a texture needs the cameras and the training images; without them colours per corner
|
||||
data=(--no-data)
|
||||
if [ "$texture" = 1 ]; then
|
||||
if [ -d "$out/dataset/images" ] && [ -d "$out/dataset/sparse" ]; then
|
||||
data=(--data "$out/dataset")
|
||||
else
|
||||
echo "No training images any more – colours per corner instead of a texture"
|
||||
texture=0
|
||||
fi
|
||||
fi
|
||||
colour_of() { # the colour each format gets
|
||||
case "$1" in
|
||||
glb) [ "$texture" = 1 ] && echo texture || echo vertex ;;
|
||||
ply) echo vertex ;;
|
||||
obj) [ "$texture" = 1 ] && echo texture || echo none ;;
|
||||
stl) echo none ;;
|
||||
esac
|
||||
}
|
||||
colours=$(for f in "${want[@]}"; do colour_of "$f"; done | sort -u | paste -sd,)
|
||||
|
||||
tmp=$out/mesh.tmp
|
||||
rm -rf "$tmp"
|
||||
mkdir -p "$tmp"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
echo "@@ vstep 1 2 mesh"
|
||||
# what lies well outside the room (big blobs explaining the view through a window or beyond the walls)
|
||||
# would become lumps floating around the room: out first, as the "Edges" clean-up does
|
||||
from=$src
|
||||
if uv run -q "$HERE/clean.py" "$src" "$tmp/inside.ply" --outside 1 >/dev/null 2>&1; then
|
||||
from=$tmp/inside.ply
|
||||
else
|
||||
echo "No room found (walls) – meshing everything"
|
||||
fi
|
||||
echo "Meshing $slug ($formats; colours: $colours) ..."
|
||||
"$SPIRULA_BIN" mesh "$from" "${data[@]}" --format "$formats" --color "$colours" --output "$tmp/$stem"
|
||||
rm -f "$tmp/inside.ply"
|
||||
|
||||
# Spirula names the files <stem>.<ext>, or <stem>_<colour>.<ext> when it made several colours:
|
||||
# keep the one colour every format was meant to have, under <stem>.<ext>
|
||||
echo "@@ vstep 2 2 upright"
|
||||
keep=()
|
||||
for f in "${want[@]}"; do
|
||||
c=$(colour_of "$f")
|
||||
case "$c" in vertex) tag=vertexcolor ;; none) tag=nocolor ;; texture) tag=texture ;; esac
|
||||
file=""
|
||||
for cand in "$tmp/${stem}_${tag}.$f" "$tmp/${stem}_${tag}"*".$f" "$tmp/$stem.$f"; do
|
||||
[ -f "$cand" ] && { file=$cand; break; }
|
||||
done
|
||||
[ -n "$file" ] || { echo "Spirula made no $f file" >&2; exit 1; }
|
||||
if [ "$c" != texture ] && [ "$file" != "$tmp/$stem.$f" ]; then
|
||||
mv "$file" "$tmp/$stem.$f" # (a texture's file names are referenced inside: they stay)
|
||||
file=$tmp/$stem.$f
|
||||
fi
|
||||
keep+=("$file")
|
||||
done
|
||||
for f in "$tmp"/*; do # the other colour pairs Spirula made along the way
|
||||
case " ${keep[*]} " in *" $f "*) continue ;; esac
|
||||
case "$f" in *.mtl|*.png|*.jpg|*.jpeg) continue ;; esac
|
||||
rm -f "$f"
|
||||
done
|
||||
# upright, the floor (found as for the floor plan) at height 0: the room stands on the ground plane
|
||||
uv run -q --no-project --with numpy --with pillow python3 "$HERE/upright.py" --floor-from "$src" "${keep[@]}"
|
||||
|
||||
python3 - "$tmp/mesh.json" "$formats" "$texture" <<'PY'
|
||||
import json, sys, time
|
||||
path, formats, texture = sys.argv[1:]
|
||||
json.dump({"formats": formats.split(","), "texture": texture == "1", "created": time.time()}, open(path, "w"), indent=1)
|
||||
PY
|
||||
rm -rf "$out/mesh"
|
||||
mv "$tmp" "$out/mesh"
|
||||
trap - EXIT
|
||||
echo "@@ done local"
|
||||
echo "Done: $out/mesh"
|
||||
exit
|
||||
}
|
||||
@@ -77,7 +77,19 @@ ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_SCENES && \
|
||||
for f in *.sog *.xrSceneTransform.json *.gallery.png *.sceneinfo.png; do
|
||||
case \$f in $stem.*) ;; *) rm -f -- \"\$f\" ;; esac
|
||||
done"
|
||||
# How far it is: every 2 s the size of the folder on the Frame against what is being copied ("@@ upbytes")
|
||||
total=$(du -cb "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} | tail -1 | cut -f1)
|
||||
echo "@@ upbytes 0 $total"
|
||||
( while sleep 2; do
|
||||
now=$(ssh -o ConnectTimeout=5 "$FRAME_HOST" "du -cb $FRAME_SCENES/$folder 2>/dev/null | tail -1 | cut -f1" 2>/dev/null) || continue
|
||||
[ -n "$now" ] && echo "@@ upbytes $now $total"
|
||||
done ) &
|
||||
watch=$!
|
||||
trap 'kill $watch 2>/dev/null || true' EXIT
|
||||
scp -q "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} "$FRAME_HOST:$FRAME_SCENES/$folder/"
|
||||
kill $watch 2>/dev/null || true
|
||||
trap - EXIT
|
||||
echo "@@ upbytes $total $total"
|
||||
ssh "$FRAME_HOST" "chmod 755 $FRAME_SCENES/$folder/*"
|
||||
echo "@@ done frame $folder"
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Stand a mesh upright: our scenes have Y pointing down (like the Steam Frame's tracking). 3D programs
|
||||
expect Z up for PLY and STL (Blender, slicers for 3D printing) and Y up for glTF/GLB (by its standard) and
|
||||
OBJ (by custom) – Blender turns those two into Z up itself on import. Without this a room lies on its side
|
||||
or upside down. (Checked by importing each format in Blender 5.2.)
|
||||
|
||||
Usage: python3 upright.py [--floor Y] <file> [<file> ...] (.ply .obj .stl .glb .gltf; changed in place)
|
||||
|
||||
PLY, STL: positions and normals turned so that up (-Y) becomes +Z: (x, y, z) -> (x, z, -y)
|
||||
OBJ: turned 180° about X, up (-Y) becomes +Y: (x, y, z) -> (x, -y, -z)
|
||||
GLB/glTF: the scene's root nodes go under one new node turned 180° about X: (x, y, z) -> (x, -y, -z)
|
||||
– nothing else in the file is touched (textures, materials, accessors stay as they are)
|
||||
--floor Y where the floor is in the scene (Y down): it is moved to height 0, so the room stands on
|
||||
the ground plane (the scene's origin is where the Frame was at the start, at eye height)
|
||||
--floor-from S the same, found in the scene's splats S (spirula/scene.ply) as for the floor plan
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
Z_UP = np.array([[1, 0, 0], [0, 0, 1], [0, -1, 0]], dtype=np.float64) # proper rotation, det +1
|
||||
Y_UP_QUAT = [1.0, 0.0, 0.0, 0.0] # x, y, z, w: 180° about X
|
||||
LIFT = 0.0 # the floor's height before (Y down)
|
||||
|
||||
|
||||
def _replace(path: Path, data: bytes):
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def ply(path: Path):
|
||||
raw = path.read_bytes()
|
||||
end = raw.index(b"end_header\n") + len(b"end_header\n")
|
||||
header = raw[:end].decode("ascii", "replace").splitlines()
|
||||
if "format binary_little_endian 1.0" not in header:
|
||||
sys.exit(f"{path}: only binary little-endian PLY is supported")
|
||||
sizes = {"float": "<f4", "double": "<f8", "uchar": "u1", "char": "i1", "ushort": "<u2", "short": "<i2",
|
||||
"uint": "<u4", "int": "<i4"}
|
||||
props, count, first = [], 0, True
|
||||
for line in header:
|
||||
parts = line.split()
|
||||
if parts[:2] == ["element", "vertex"]:
|
||||
count = int(parts[2])
|
||||
elif parts[:1] == ["element"]:
|
||||
first = False # properties after this belong to faces etc.
|
||||
elif parts[:1] == ["property"] and first and count:
|
||||
if parts[1] == "list":
|
||||
sys.exit(f"{path}: list properties on vertices are not supported")
|
||||
props.append((parts[2], sizes[parts[1]]))
|
||||
dtype = np.dtype(props)
|
||||
v = np.frombuffer(raw, dtype=dtype, count=count, offset=end).copy()
|
||||
for a, b, c in (("x", "y", "z"), ("nx", "ny", "nz")):
|
||||
if a in dtype.names:
|
||||
xyz = np.stack([v[a], v[b], v[c]], 1).astype(np.float64) @ Z_UP.T
|
||||
if a == "x":
|
||||
xyz[:, 2] += LIFT
|
||||
v[a], v[b], v[c] = xyz.T
|
||||
_replace(path, raw[:end] + v.tobytes() + raw[end + v.nbytes:])
|
||||
|
||||
|
||||
def obj(path: Path):
|
||||
out = []
|
||||
with open(path, "rb") as f:
|
||||
for line in f:
|
||||
if line.startswith((b"v ", b"vn ")):
|
||||
tag, rest = line.split(None, 1)
|
||||
nums = rest.split()
|
||||
x, y, z = (float(n) for n in nums[:3])
|
||||
x, y, z = x, -y, -z # Y up (180° about X)
|
||||
y += LIFT if tag == b"v" else 0.0
|
||||
line = b"%s %.7g %.7g %.7g%s\n" % (tag, x, y, z, (b" " + b" ".join(nums[3:])) if nums[3:] else b"")
|
||||
out.append(line)
|
||||
_replace(path, b"".join(out))
|
||||
|
||||
|
||||
def stl(path: Path):
|
||||
raw = path.read_bytes()
|
||||
if raw[:5] == b"solid" and b"facet" in raw[:512]:
|
||||
sys.exit(f"{path}: only binary STL is supported")
|
||||
n, = struct.unpack_from("<I", raw, 80)
|
||||
tri = np.frombuffer(raw, dtype=np.dtype([("n", "<f4", 3), ("v", "<f4", (3, 3)), ("attr", "<u2")]),
|
||||
count=n, offset=84).copy()
|
||||
tri["n"] = tri["n"] @ Z_UP.T
|
||||
tri["v"] = tri["v"] @ Z_UP.T
|
||||
tri["v"][..., 2] += LIFT
|
||||
_replace(path, raw[:84] + tri.tobytes())
|
||||
|
||||
|
||||
def _wrap(gltf: dict):
|
||||
for scene in gltf.get("scenes", []):
|
||||
root = {"name": "upright (Y up)", "rotation": Y_UP_QUAT, "translation": [0.0, LIFT, 0.0],
|
||||
"children": scene.get("nodes", [])}
|
||||
gltf.setdefault("nodes", []).append(root)
|
||||
scene["nodes"] = [len(gltf["nodes"]) - 1]
|
||||
|
||||
|
||||
def glb(path: Path):
|
||||
raw = path.read_bytes()
|
||||
magic, version, _ = struct.unpack_from("<4sII", raw)
|
||||
if magic != b"glTF" or version != 2:
|
||||
sys.exit(f"{path}: not a glTF 2 binary")
|
||||
jlen, jtype = struct.unpack_from("<I4s", raw, 12)
|
||||
if jtype != b"JSON":
|
||||
sys.exit(f"{path}: first chunk is not JSON")
|
||||
gltf = json.loads(raw[20:20 + jlen])
|
||||
_wrap(gltf)
|
||||
js = json.dumps(gltf, separators=(",", ":")).encode()
|
||||
js += b" " * (-len(js) % 4) # chunks are 4-byte aligned, JSON padded with spaces
|
||||
rest = raw[20 + jlen:]
|
||||
total = 12 + 8 + len(js) + len(rest)
|
||||
_replace(path, struct.pack("<4sII", b"glTF", 2, total) + struct.pack("<I4s", len(js), b"JSON") + js + rest)
|
||||
|
||||
|
||||
def gltf(path: Path):
|
||||
data = json.loads(path.read_text())
|
||||
_wrap(data)
|
||||
_replace(path, json.dumps(data).encode())
|
||||
|
||||
|
||||
def main():
|
||||
global LIFT
|
||||
args = sys.argv[1:]
|
||||
if args[:1] == ["--floor"]:
|
||||
LIFT, args = float(args[1]), args[2:]
|
||||
elif args[:1] == ["--floor-from"]:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import floorplan
|
||||
xyz, _ = floorplan.load_ply(Path(args[1]), np.random.default_rng(0))
|
||||
LIFT, args = (float(floorplan.heights(xyz[:, 1])[0]) if len(xyz) else 0.0), args[2:]
|
||||
print(f"floor: {LIFT:.2f}")
|
||||
for arg in args:
|
||||
p = Path(arg)
|
||||
kind = p.suffix.lower().lstrip(".")
|
||||
fn = {"ply": ply, "obj": obj, "stl": stl, "glb": glb, "gltf": gltf}.get(kind)
|
||||
if fn is None:
|
||||
sys.exit(f"{p}: unknown mesh format")
|
||||
fn(p)
|
||||
print(f"upright: {p.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,7 +30,7 @@ DEFAULTS = {
|
||||
"video_player": "",
|
||||
},
|
||||
"frame": {"host": ""},
|
||||
"server": {"port": 8771, "lan": False, "idle_minutes": 20, "language": "auto"},
|
||||
"server": {"port": 8771, "lan": False, "idle_minutes": 20, "language": "auto", "check_updates": True},
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ class Config:
|
||||
self.lan = (lan != "0") if lan is not None else bool(s["lan"])
|
||||
self.idle_minutes = int(s["idle_minutes"])
|
||||
self.language = s.get("language", "auto")
|
||||
self.check_updates = bool(s.get("check_updates", True))
|
||||
# Internal state (jobs, caches) lives next to the runs, so it moves with the data
|
||||
self.state_dir = Path(os.environ.get("SPLAT_WORKSHOP_STATE") or self.runs.parent / ".splat-workshop")
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ TEXTS = {
|
||||
"inhibit.busy": "Busy (converting, putting together, copying)",
|
||||
"notify.standby.title": "Everything done", "notify.standby": "The PC goes to standby in {s} seconds – to stop it, untick “Standby when done” in Splat Workshop.",
|
||||
"notify.standby_failed.title": "No standby", "notify.standby_failed": "The PC could not go to standby: {why}",
|
||||
"inhibit.mesh": "Making a mesh of “{name}”", "inhibit.import": "Importing “{name}”",
|
||||
"import.step.convert": "Converting", "import.step.preview": "Preview", "pick.splat": "Choose a splat file",
|
||||
"pick.recording": "Choose a recording", "mesh.step.mesh": "Meshing", "mesh.step.upright": "Standing it upright",
|
||||
"inhibit.versions": "Making versions of “{name}”", "ver.all": "all splats", "ver.size": "{n} M",
|
||||
"ver.clean.ceiling": "ceiling", "ver.clean.floor": "floor", "ver.clean.haze": "haze", "ver.clean.outside": "edges",
|
||||
"notify.failed.title": "Conversion failed",
|
||||
@@ -48,6 +51,8 @@ TEXTS = {
|
||||
"tray.copied": "Address copied",
|
||||
"tray.cancel": "Cancel conversion …",
|
||||
"tray.quit": "Quit",
|
||||
"tray.update": "Check for updates", "tray.update.new": "Splat Workshop {latest} is out (you have {current}) – click for what's new. To update: git pull in the Splat Workshop folder, then restart.",
|
||||
"tray.update.none": "You have the newest version ({current}).", "tray.update.offline": "GitHub could not be reached – try again later.",
|
||||
"tray.server_down": "Server is not responding",
|
||||
"dlg.cancel.title": "Cancel conversion?",
|
||||
"dlg.cancel.text": "“{name}” is being converted right now ({phase}).",
|
||||
@@ -80,6 +85,9 @@ TEXTS = {
|
||||
"inhibit.busy": "Beschäftigt (Umwandeln, Zusammensetzen, Kopieren)",
|
||||
"notify.standby.title": "Alles fertig", "notify.standby": "Der PC geht in {s} Sekunden in den Standby – zum Abbrechen in der Splat-Werkstatt den Haken bei „Danach in den Standby“ entfernen.",
|
||||
"notify.standby_failed.title": "Kein Standby", "notify.standby_failed": "Der PC konnte nicht in den Standby: {why}",
|
||||
"inhibit.mesh": "Erstellt ein Mesh von „{name}“", "inhibit.import": "Importiert „{name}“",
|
||||
"import.step.convert": "Umwandeln", "import.step.preview": "Vorschau", "pick.splat": "Splat-Datei wählen",
|
||||
"pick.recording": "Aufnahme wählen", "mesh.step.mesh": "Vernetzen", "mesh.step.upright": "Aufrichten",
|
||||
"inhibit.versions": "Erstellt Fassungen von „{name}“", "ver.all": "alle Splats", "ver.size": "{n} Mio.",
|
||||
"ver.clean.ceiling": "Decke", "ver.clean.floor": "Boden", "ver.clean.haze": "Dunst", "ver.clean.outside": "Rand",
|
||||
"notify.failed.title": "Umwandlung fehlgeschlagen",
|
||||
@@ -112,6 +120,8 @@ TEXTS = {
|
||||
"tray.copied": "Adresse kopiert",
|
||||
"tray.cancel": "Umwandlung abbrechen …",
|
||||
"tray.quit": "Beenden",
|
||||
"tray.update": "Nach Updates suchen", "tray.update.new": "Die Splat-Werkstatt {latest} ist da (du hast {current}) – Klick zeigt, was neu ist. Aktualisieren: git pull im Ordner der Splat-Werkstatt, dann neu starten.",
|
||||
"tray.update.none": "Du hast die neueste Version ({current}).", "tray.update.offline": "GitHub war nicht erreichbar – später noch einmal versuchen.",
|
||||
"tray.server_down": "Server antwortet nicht",
|
||||
"dlg.cancel.title": "Umwandlung abbrechen?",
|
||||
"dlg.cancel.text": "Gerade wird „{name}“ umgewandelt ({phase}).",
|
||||
|
||||
+316
-14
@@ -24,12 +24,13 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from . import check, config
|
||||
from .i18n import T, detect
|
||||
|
||||
VERSION = "0.4.2"
|
||||
VERSION = "0.6.0"
|
||||
CFG = config.load()
|
||||
ROOT = config.ROOT
|
||||
WEB = ROOT / "web"
|
||||
@@ -152,7 +153,7 @@ def pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
|
||||
if cmd:
|
||||
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh"))
|
||||
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh", "mesh.sh", "import.sh"))
|
||||
except OSError:
|
||||
pass
|
||||
if not pid:
|
||||
@@ -926,6 +927,8 @@ def watch_once(was_running):
|
||||
t("notify.done.frame" if js["upload"] else "notify.done.local", name=js["name"], mins=mins))
|
||||
elif js["result"] == "failed":
|
||||
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
|
||||
if js.get("kind") == "import" and js["result"] != "ok" and not (RUNS / js["slug"]).exists():
|
||||
meta_file(js["slug"]).unlink(missing_ok=True) # nothing came of it: the name is free again
|
||||
if not running:
|
||||
restore_previous()
|
||||
advance_parts()
|
||||
@@ -1304,6 +1307,264 @@ def delete_version(slug: str, key: str):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- your own splats (scripts/import.sh, 0.6)
|
||||
|
||||
SPLAT_EXTS = (".ply", ".sog", ".spz", ".splat", ".ksplat")
|
||||
IMPORT_UP = ("y-down", "y-up", "z-up")
|
||||
|
||||
|
||||
def start_import(p: dict):
|
||||
"""A splat file from this PC (picked in the file dialog) as a new scene."""
|
||||
global proc
|
||||
src = Path(p.get("path") or "")
|
||||
if not src.is_absolute() or not src.is_file() or src.suffix.lower() not in SPLAT_EXTS:
|
||||
return err("err.import_file")
|
||||
name = (p.get("name") or "").strip() or src.stem
|
||||
slug = config.slugify(name)
|
||||
if not slug or slug.startswith("."):
|
||||
return err("err.bad_name")
|
||||
if (RUNS / slug).exists() or meta_file(slug).exists():
|
||||
return err("err.scene_name_taken")
|
||||
up = p.get("up") or "y-down"
|
||||
if up not in IMPORT_UP:
|
||||
return err("err.bad_value", key="up")
|
||||
try:
|
||||
scale = float(p.get("scale") or 1)
|
||||
except (TypeError, ValueError):
|
||||
return err("err.bad_value", key="scale")
|
||||
if not 0.0001 <= scale <= 10000 or scale != scale:
|
||||
return err("err.range", key="scale", lo=0.0001, hi=10000)
|
||||
with lock:
|
||||
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
|
||||
return err("err.job_busy")
|
||||
RUNS.mkdir(parents=True, exist_ok=True)
|
||||
out = RUNS / f"{slug}.log" # also the scene's log (to-frame.sh looks for it)
|
||||
write_meta(slug, name=name, imported=src.name)
|
||||
with open(out, "wb") as fh:
|
||||
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "import.sh"), str(src), str(RUNS / slug), name, up, f"{scale:g}"],
|
||||
t("inhibit.import", name=name)), cwd=ROOT, stdout=fh,
|
||||
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
||||
st = load_state()
|
||||
st["job"] = {"kind": "import", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
|
||||
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
|
||||
"steps": [t("import.step.convert"), t("import.step.preview")]}
|
||||
save_state(st)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- is there a newer version?
|
||||
|
||||
REPO = "thunderheronight/splat-workshop"
|
||||
UPDATE_FILE = STATE_DIR / "update.json" # {"checked": time, "tag": "v0.5.1", "url": release page}
|
||||
UPDATE_EVERY = 24 * 3600
|
||||
update_lock = threading.Lock()
|
||||
|
||||
|
||||
def version_tuple(v: str) -> tuple:
|
||||
""""v0.5.1" / "0.5.1-experimental" -> (0, 5, 1); unparsable -> ()."""
|
||||
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", v or "")
|
||||
return tuple(int(x) for x in m.groups()) if m else ()
|
||||
|
||||
|
||||
def fetch_latest_release() -> dict | None:
|
||||
"""The newest release on GitHub: one plain request, nothing about the user is sent."""
|
||||
req = urllib.request.Request(f"https://api.github.com/repos/{REPO}/releases/latest",
|
||||
headers={"Accept": "application/vnd.github+json", "User-Agent": f"SplatWorkshop/{VERSION}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=8) as r:
|
||||
data = json.loads(r.read())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
tag, url = data.get("tag_name"), data.get("html_url")
|
||||
if not isinstance(tag, str) or not version_tuple(tag):
|
||||
return None
|
||||
if not (isinstance(url, str) and url.startswith(f"https://github.com/{REPO}/")):
|
||||
url = f"https://github.com/{REPO}/releases/latest"
|
||||
return {"tag": tag, "url": url}
|
||||
|
||||
|
||||
def ask_for_update():
|
||||
"""At most once a day, in the background (switched off with check_updates = false)."""
|
||||
if not CFG.check_updates or not update_lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
try:
|
||||
last = json.loads(UPDATE_FILE.read_text())
|
||||
except (OSError, ValueError):
|
||||
last = {}
|
||||
if time.time() - last.get("checked", 0) < UPDATE_EVERY:
|
||||
update_lock.release()
|
||||
return
|
||||
except Exception:
|
||||
update_lock.release()
|
||||
raise
|
||||
|
||||
def run():
|
||||
try:
|
||||
found = fetch_latest_release()
|
||||
write_json(UPDATE_FILE, {"checked": time.time(), **(found or {})})
|
||||
finally:
|
||||
update_lock.release()
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def check_update_now() -> dict:
|
||||
"""Asked for by hand (tray menu, page): right away, also when the daily check is switched off."""
|
||||
with update_lock:
|
||||
found = fetch_latest_release()
|
||||
if found:
|
||||
write_json(UPDATE_FILE, {"checked": time.time(), **found})
|
||||
return {"reached": found is not None, "current": VERSION, "update": update_info(force=True)}
|
||||
|
||||
|
||||
def update_info(force=False) -> dict | None:
|
||||
"""A newer release than this one, or None."""
|
||||
if not CFG.check_updates and not force:
|
||||
return None
|
||||
try:
|
||||
last = json.loads(UPDATE_FILE.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
latest = version_tuple(last.get("tag", ""))
|
||||
if latest and latest > version_tuple(VERSION):
|
||||
return {"latest": last["tag"].lstrip("v"), "url": last.get("url"), "current": VERSION}
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- meshes (scripts/mesh.sh, 0.5)
|
||||
|
||||
MESH_FORMATS = ("glb", "ply", "obj", "stl")
|
||||
|
||||
|
||||
MESH_TYPES = {".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".obj": "model/obj", ".mtl": "text/plain",
|
||||
".ply": "application/octet-stream", ".stl": "model/stl", ".png": "image/png", ".jpg": "image/jpeg"}
|
||||
|
||||
|
||||
@functools.cache
|
||||
def blender_cmd() -> list | None:
|
||||
"""How to start Blender here: installed as a program, or as the Flatpak from Flathub; None = not there."""
|
||||
if shutil.which("blender"):
|
||||
return ["blender"]
|
||||
if shutil.which("flatpak"):
|
||||
try:
|
||||
if subprocess.run(["flatpak", "info", "org.blender.Blender"], capture_output=True, timeout=10).returncode == 0:
|
||||
return ["flatpak", "run", "org.blender.Blender"]
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def mesh_file(slug: str, name: str) -> Path | None:
|
||||
"""One file of a scene's mesh, by its plain name (nothing outside the mesh folder)."""
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
if not d or not name or Path(name).name != name or name.startswith(".") or name == "mesh.json":
|
||||
return None
|
||||
f = d / "mesh" / name
|
||||
return f if f.is_file() and f.suffix.lower() in MESH_TYPES else None
|
||||
|
||||
|
||||
def open_in_blender(slug: str):
|
||||
"""Blender with the mesh imported – GLB if there is one, else OBJ, PLY or STL."""
|
||||
cmd = blender_cmd()
|
||||
if not cmd:
|
||||
return err("err.no_blender")
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
m = d / "mesh" if d else None
|
||||
files = {f.suffix.lower(): f for f in m.iterdir()} if m and m.is_dir() else {}
|
||||
importers = {".glb": "bpy.ops.import_scene.gltf", ".obj": "bpy.ops.wm.obj_import",
|
||||
".ply": "bpy.ops.wm.ply_import", ".stl": "bpy.ops.wm.stl_import"}
|
||||
ext = next((e for e in importers if e in files), None)
|
||||
if not ext:
|
||||
return err("err.no_mesh")
|
||||
# Right at start-up Blender's window is not ready ("context is incorrect"): import a moment later,
|
||||
# into the 3D view, without the default cube, and show all of it
|
||||
code = f"""import bpy
|
||||
def _splat_import():
|
||||
win = bpy.context.window_manager.windows[0]
|
||||
area = next((a for a in win.screen.areas if a.type == "VIEW_3D"), None)
|
||||
over = {{"window": win}}
|
||||
if area:
|
||||
over.update(area=area, region=next(r for r in area.regions if r.type == "WINDOW"))
|
||||
with bpy.context.temp_override(**over):
|
||||
for o in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(o)
|
||||
{importers[ext]}(filepath={str(files[ext])!r})
|
||||
if area:
|
||||
bpy.ops.view3d.view_all()
|
||||
bpy.app.timers.register(_splat_import, first_interval=0.5)
|
||||
"""
|
||||
subprocess.Popen([*cmd, "--python-expr", code], start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
||||
return None
|
||||
|
||||
|
||||
def mesh_of(slug: str) -> dict:
|
||||
"""The mesh of a scene or tour: its files, and whether a texture is possible (training images still there)."""
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
if not d:
|
||||
return {}
|
||||
texture = (d / "dataset" / "images").is_dir() and (d / "dataset" / "sparse").is_dir()
|
||||
m = d / "mesh"
|
||||
info = {"texture_possible": texture, "files": [], "made": None, "blender": blender_cmd() is not None}
|
||||
if (m / "mesh.json").exists():
|
||||
try:
|
||||
made = json.loads((m / "mesh.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
made = {}
|
||||
src = d / "spirula" / "scene.ply"
|
||||
info["made"] = {**made, "stale": src.exists() and made.get("created", 0) < src.stat().st_mtime}
|
||||
info["files"] = [{"name": f.name, "size": f.stat().st_size} for f in sorted(m.iterdir())
|
||||
if f.is_file() and f.name != "mesh.json"]
|
||||
return info
|
||||
|
||||
|
||||
def start_mesh(p: dict):
|
||||
"""A mesh of a scene or tour in the chosen formats (glb, ply, obj, stl), optionally with a texture."""
|
||||
global proc
|
||||
slug = p.get("slug", "")
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
if not d or not (d / "spirula" / "scene.ply").exists():
|
||||
return err("err.no_scene")
|
||||
formats = [f for f in dict.fromkeys((p.get("formats") or "").split(",")) if f]
|
||||
if not formats or any(f not in MESH_FORMATS for f in formats):
|
||||
return err("err.bad_value", key="formats")
|
||||
texture = bool(p.get("texture")) and mesh_of(slug)["texture_possible"]
|
||||
name = read_meta(slug).get("name") or slug
|
||||
with lock:
|
||||
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
|
||||
return err("err.job_busy")
|
||||
if scene_busy(slug):
|
||||
return err("err.scene_busy")
|
||||
JOBS.mkdir(parents=True, exist_ok=True)
|
||||
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_mesh_{slug}.out"
|
||||
with open(out, "wb") as fh:
|
||||
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "mesh.sh"), str(d), ",".join(formats), "1" if texture else "0"],
|
||||
t("inhibit.mesh", name=name)), cwd=ROOT, stdout=fh,
|
||||
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
||||
st = load_state()
|
||||
st["job"] = {"kind": "mesh", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
|
||||
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
|
||||
"steps": [t("mesh.step.mesh"), t("mesh.step.upright")]}
|
||||
save_state(st)
|
||||
return None
|
||||
|
||||
|
||||
def delete_mesh(slug: str):
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
if not d or not (d / "mesh").is_dir():
|
||||
return err("err.no_mesh")
|
||||
if scene_busy(slug):
|
||||
return err("err.scene_busy")
|
||||
try:
|
||||
r = subprocess.run(["gio", "trash", "--", str(d / "mesh")], capture_output=True, text=True, timeout=120)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return err("err.trash_failed", detail=str(e))
|
||||
if r.returncode != 0:
|
||||
return err("err.trash_failed", detail=(r.stderr.strip().splitlines() or [""])[-1])
|
||||
usage_cache.pop(d, None)
|
||||
return None
|
||||
|
||||
|
||||
def start_upload(slug: str, version: str = ""):
|
||||
"""Copy a scene – or one of its versions – to the Frame. Every version gets its own scene folder there."""
|
||||
if not FRAME:
|
||||
@@ -1349,6 +1610,9 @@ def start_upload(slug: str, version: str = ""):
|
||||
return None
|
||||
|
||||
|
||||
RX_UPBYTES = re.compile(r"^@@ upbytes (\d+) (\d+)$", re.M)
|
||||
|
||||
|
||||
def upload_status(slug: str):
|
||||
u = uploads.get(slug)
|
||||
if not u:
|
||||
@@ -1356,6 +1620,10 @@ def upload_status(slug: str):
|
||||
rc = u["proc"].poll()
|
||||
text = u["out"].read_text(errors="replace") if u["out"].exists() else ""
|
||||
res = {"running": rc is None, "ok": rc == 0}
|
||||
if rc is None and (m := RX_UPBYTES.findall(text)): # how far the copy is
|
||||
done, total = (int(x) for x in m[-1])
|
||||
if total > 0:
|
||||
res.update(pct=min(99, round(100 * done / total)), done=done, total=total)
|
||||
if rc is not None and not u["notified"]:
|
||||
u["notified"] = True
|
||||
name = read_meta(slug).get("name") or slug
|
||||
@@ -1461,7 +1729,7 @@ def scenes():
|
||||
"ply": (d / "spirula" / "scene.ply").exists(), "on_frame": on_frame, "disk": disk_usage(d),
|
||||
"upload": upload_status(slug), "replacing": replacing,
|
||||
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr")),
|
||||
"versions": len(list(d.glob("versions/*/version.json")))})
|
||||
"versions": len(list(d.glob("versions/*/version.json"))), "mesh": (d / "mesh" / "mesh.json").exists()})
|
||||
return out
|
||||
|
||||
|
||||
@@ -2020,7 +2288,7 @@ def groups():
|
||||
"rooms": len({r for _, r in inner}),
|
||||
"busy": scene_busy(slug), "options": {k: meta.get(k) for k in ("align", "partition", "frame_splats", "full")},
|
||||
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr")),
|
||||
"versions": len(list(d.glob("versions/*/version.json"))),
|
||||
"versions": len(list(d.glob("versions/*/version.json"))), "mesh": (d / "mesh" / "mesh.json").exists(),
|
||||
"splats": sum(scene_list[m].get("gaussians") or 0 for m, _ in inner)})
|
||||
return {"groups": out, "suggestion": suggestion(scene_list, out), "mem_available_gb": mem_available()}
|
||||
|
||||
@@ -2173,11 +2441,15 @@ def start_combine(p: dict):
|
||||
return None
|
||||
|
||||
|
||||
def pick_file():
|
||||
start = str(REC if REC.exists() else Path.home())
|
||||
title = "Aufnahme wählen" if t.lang == "de" else "Choose a recording"
|
||||
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, "Videos (*.mp4)"],
|
||||
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", "--file-filter=*.mp4"]):
|
||||
def pick_file(kind: str = "recording"):
|
||||
if kind == "splat":
|
||||
start, title = str(Path.home()), t("pick.splat")
|
||||
kde, gtk = "Splats (" + " ".join(f"*{e}" for e in SPLAT_EXTS) + ")", " ".join(f"*{e}" for e in SPLAT_EXTS)
|
||||
else:
|
||||
start = str(REC if REC.exists() else Path.home())
|
||||
title, kde, gtk = t("pick.recording"), "Videos (*.mp4)", "*.mp4"
|
||||
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, kde],
|
||||
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", f"--file-filter={gtk}"]):
|
||||
if shutil.which(cmd[0]):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return r.stdout.strip() or None
|
||||
@@ -2269,13 +2541,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, (WEB / f).read_bytes(), ctype)
|
||||
if u.path == "/api/ping":
|
||||
here = self._from_this_pc()
|
||||
ask_for_update()
|
||||
ip = lan_ip() if CFG.lan else None
|
||||
return self.send(200, {
|
||||
"ok": True, "app": "splat-workshop", "version": VERSION, "local": here,
|
||||
"lan_url": f"http://{ip}:{CFG.port}/" if ip and here else None,
|
||||
"language": CFG.language, "frame_configured": bool(FRAME),
|
||||
"problems": [err(k, **p) for k, p in CFG.problems()],
|
||||
"config": str(CFG.source or config.CONFIG_FILE) if here else None})
|
||||
"config": str(CFG.source or config.CONFIG_FILE) if here else None, "update": update_info()})
|
||||
if u.path == "/api/activity":
|
||||
return self.send(200, activity())
|
||||
if u.path == "/api/recordings":
|
||||
@@ -2310,6 +2583,22 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, data, "image/png", cache=True) if data else self.send(404, b"", "image/png")
|
||||
if u.path == "/api/versions":
|
||||
return self.send(200, {"versions": versions_of(q.get("slug", ""))})
|
||||
if u.path == "/api/mesh":
|
||||
return self.send(200, mesh_of(q.get("slug", "")))
|
||||
if u.path == "/api/mesh/file":
|
||||
f = mesh_file(q.get("slug", ""), q.get("name", ""))
|
||||
if not f:
|
||||
return self.send(404, {"error": err("err.no_mesh")})
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", MESH_TYPES[f.suffix.lower()])
|
||||
self.send_header("Content-Length", str(f.stat().st_size))
|
||||
self.send_header("Content-Disposition", "attachment; filename*=UTF-8''" + urllib.parse.quote(f.name))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
with open(f, "rb") as fh:
|
||||
shutil.copyfileobj(fh, self.wfile, 1 << 20)
|
||||
return None
|
||||
if u.path == "/api/floorplan":
|
||||
data = floorplan([x for x in q.get("slugs", "").split(",") if x], q.get("style", "pretty"),
|
||||
q.get("lang", "en"), q.get("title", ""), q.get("group", ""))
|
||||
@@ -2352,11 +2641,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if not isinstance(p, dict) or any(not isinstance(v, (str, int, float, bool, type(None))) for v in p.values()) \
|
||||
or any(not isinstance(p.get(k, ""), str) for k in ("name", "new", "slug", "path", "what", "quality", "existing", "members", "key", "version", "sizes", "scales", "folder",
|
||||
"names", "place", "where",
|
||||
"align", "partition", "clean")):
|
||||
"align", "partition", "clean", "formats")):
|
||||
return self.send(400, {"error": err("err.bad_request")})
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
e = None
|
||||
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown") and not self._from_this_pc():
|
||||
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown", "/api/import") and not self._from_this_pc():
|
||||
# These open windows on this PC's screen or stop the program – only allowed from the PC itself
|
||||
return self.send(403, {"error": err("err.pc_only")})
|
||||
if path == "/api/start":
|
||||
@@ -2387,10 +2676,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
remove_from_frame(p.get("slug", ""), p.get("version", "")))
|
||||
elif path == "/api/frame/move":
|
||||
e = move_to_own_folder()
|
||||
elif path == "/api/import":
|
||||
e = start_import(p)
|
||||
elif path == "/api/update/check":
|
||||
return self.send(200, check_update_now())
|
||||
elif path == "/api/mesh":
|
||||
e = start_mesh(p)
|
||||
elif path == "/api/mesh/delete":
|
||||
e = delete_mesh(p.get("slug", ""))
|
||||
elif path == "/api/version/delete":
|
||||
e = delete_version(p.get("slug", ""), p.get("version", ""))
|
||||
elif path == "/api/pick":
|
||||
chosen = pick_file()
|
||||
chosen = pick_file(p.get("kind") or "recording")
|
||||
if p.get("kind") == "splat":
|
||||
return self.send(200, {"path": chosen})
|
||||
if chosen:
|
||||
picked.add(chosen)
|
||||
return self.send(200, {"path": chosen})
|
||||
@@ -2441,6 +2740,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
d = scene_dir(p.get("slug", ""))
|
||||
if not d:
|
||||
e = err("err.no_scene")
|
||||
elif p.get("what") == "blender":
|
||||
e = open_in_blender(p.get("slug", ""))
|
||||
elif p.get("what") == "spirula":
|
||||
ply = d / "spirula" / "scene.ply"
|
||||
if p.get("version"): # a version: its own ply, or the scene's for a plain "all"
|
||||
@@ -2453,7 +2754,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
subprocess.Popen([str(CFG.spirula), str(ply)], cwd=ply.parent.parent,
|
||||
start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", str(d)], start_new_session=True,
|
||||
folder = d / "mesh" if p.get("what") == "mesh" and (d / "mesh").is_dir() else d
|
||||
subprocess.Popen(["xdg-open", str(folder)], start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
elif path == "/api/shutdown":
|
||||
self.send(200, {"ok": True})
|
||||
|
||||
+36
-1
@@ -8,6 +8,7 @@ or be cancelled (it can be resumed later).
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -95,13 +96,17 @@ class Tray:
|
||||
self.a_status = QAction("", self.menu, enabled=False)
|
||||
self.a_lan = QAction("", self.menu, triggered=self.copy_lan, visible=False)
|
||||
self.a_cancel = QAction(t("tray.cancel"), self.menu, triggered=self.cancel, enabled=False)
|
||||
self.a_update = QAction(t("tray.update"), self.menu, triggered=self.check_update)
|
||||
self.update_result = None # from the background check, shown by refresh()
|
||||
self.update_url = None
|
||||
self.a_quit = QAction(t("tray.quit"), self.menu, triggered=self.quit)
|
||||
f = self.a_open.font()
|
||||
f.setBold(True)
|
||||
self.a_open.setFont(f)
|
||||
for a in (self.a_open, None, self.a_status, self.a_lan, None, self.a_cancel, None, self.a_quit):
|
||||
for a in (self.a_open, None, self.a_status, self.a_lan, None, self.a_cancel, None, self.a_update, self.a_quit):
|
||||
self.menu.addSeparator() if a is None else self.menu.addAction(a)
|
||||
self.tray.setContextMenu(self.menu)
|
||||
self.tray.messageClicked.connect(self.open_update)
|
||||
self.lan_url = None
|
||||
|
||||
self.ensure_server()
|
||||
@@ -128,6 +133,8 @@ class Tray:
|
||||
pass
|
||||
|
||||
def refresh(self):
|
||||
if self.update_result is not None:
|
||||
self.show_update()
|
||||
try:
|
||||
self.act = request("/api/activity")
|
||||
self.fails = 0
|
||||
@@ -179,6 +186,34 @@ class Tray:
|
||||
def open(self):
|
||||
webbrowser.open(BASE + "/")
|
||||
|
||||
def check_update(self):
|
||||
"""Ask GitHub now (in the background; the answer comes as a notification)."""
|
||||
self.a_update.setEnabled(False)
|
||||
|
||||
def run():
|
||||
try:
|
||||
self.update_result = request("/api/update/check", {}, timeout=20)
|
||||
except (OSError, ValueError):
|
||||
self.update_result = {"reached": False}
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
def show_update(self):
|
||||
r, self.update_result = self.update_result, None
|
||||
self.a_update.setEnabled(True)
|
||||
u = r.get("update")
|
||||
if u:
|
||||
self.update_url = u.get("url")
|
||||
self.tray.showMessage(t("app"), t("tray.update.new", latest=u["latest"], current=u["current"]),
|
||||
QSystemTrayIcon.Information, 10000)
|
||||
else:
|
||||
self.update_url = None
|
||||
self.tray.showMessage(t("app"), t("tray.update.none", current=r.get("current", "")) if r.get("reached")
|
||||
else t("tray.update.offline"), QSystemTrayIcon.Information, 5000)
|
||||
|
||||
def open_update(self):
|
||||
if self.update_url:
|
||||
webbrowser.open(self.update_url)
|
||||
|
||||
def copy_lan(self):
|
||||
QGuiApplication.clipboard().setText(self.lan_url or "")
|
||||
self.tray.showMessage(t("app"), t("tray.copied"), QSystemTrayIcon.Information, 2000)
|
||||
|
||||
+30
-1
@@ -86,7 +86,28 @@ png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4
|
||||
(out / (out.name.lower() + ".gallery.png")).write_bytes(png)
|
||||
print("Gaussian XR scene transform: " + str(out / "scene.xrSceneTransform.json"), flush=True)
|
||||
''')
|
||||
script(BIN / "spirula", "#!/bin/sh\nexit 0\n")
|
||||
# spirula: `spirula mesh` writes a tiny room in every format/colour pair the real one writes (tests/fake_mesh.py)
|
||||
script(BIN / "spirula", f'''#!{sys.executable}
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, "{Path(__file__).parent}")
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["spirula", *sys.argv[1:]]) + "\\n")
|
||||
args = sys.argv[1:]
|
||||
if args[:1] == ["mesh"]:
|
||||
import fake_mesh
|
||||
opt = lambda k: args[args.index(k) + 1]
|
||||
formats, colours, base = opt("--format").split(","), opt("--color").split(","), Path(opt("--output"))
|
||||
ok = {{("glb", "vertex"), ("glb", "none"), ("glb", "texture"), ("ply", "vertex"), ("ply", "none"),
|
||||
("obj", "none"), ("obj", "texture"), ("stl", "none")}}
|
||||
tag = {{"vertex": "_vertexcolor", "none": "_nocolor", "texture": "_texture"}}
|
||||
for f in formats:
|
||||
for c in colours:
|
||||
if (f, c) in ok:
|
||||
fake_mesh.write(base.with_name(base.name + (tag[c] if len(colours) > 1 else "") + "." + f), f, c)
|
||||
if c == "texture" and f == "obj":
|
||||
base.with_name(base.name + (tag[c] if len(colours) > 1 else "") + ".png").write_bytes(b"png")
|
||||
''')
|
||||
# splat-transform: copies the input (a .ply stays a .ply), writes a small file for .sog
|
||||
script(BIN / "splat-transform", f'''#!{sys.executable}
|
||||
import json, shutil, sys
|
||||
@@ -212,6 +233,7 @@ runs = "{RUNS}"
|
||||
extra_recording_dirs = []
|
||||
[server]
|
||||
language = "en"
|
||||
check_updates = false
|
||||
[frame]
|
||||
host = ""
|
||||
''')
|
||||
@@ -328,6 +350,13 @@ class Client:
|
||||
def get(self, path, headers=None):
|
||||
return self.request("GET", path, headers=headers)
|
||||
|
||||
def raw(self, path):
|
||||
"""GET without decoding: status, body bytes, headers."""
|
||||
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
|
||||
c.request("GET", path)
|
||||
r = c.getresponse()
|
||||
return r.status, r.read(), dict(r.getheaders())
|
||||
|
||||
def post(self, path, body=None, headers=None):
|
||||
return self.request("POST", path, body if body is not None else {}, headers)
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Writers for the fake `spirula mesh` (tests/conftest.py) and readers for the tests: a tiny room as
|
||||
Spirula would mesh it – in the scene's own frame, Y down: the floor at y = +1.6, the ceiling at y = -0.9,
|
||||
both 4 x 4 m around x, z = 0. Each file holds two quads (four triangles)."""
|
||||
import json
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
FLOOR_Y, CEILING_Y = 1.6, -0.9
|
||||
V = np.array([[x, y, z] for y in (FLOOR_Y, CEILING_Y) for x, z in ((-2, -2), (2, -2), (2, 2), (-2, 2))], np.float32)
|
||||
F = np.array([[0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6]], np.int32)
|
||||
N = np.array([[0, -1, 0]] * 4 + [[0, 1, 0]] * 4, np.float32) # floor faces up (-Y), ceiling down
|
||||
|
||||
|
||||
def write(path, fmt, colour):
|
||||
if fmt == "ply":
|
||||
props = "property float x\nproperty float y\nproperty float z\nproperty float nx\nproperty float ny\nproperty float nz\n"
|
||||
props += "property uchar red\nproperty uchar green\nproperty uchar blue\n" if colour == "vertex" else ""
|
||||
head = f"ply\nformat binary_little_endian 1.0\nelement vertex {len(V)}\n{props}element face {len(F)}\nproperty list uchar int vertex_indices\nend_header\n"
|
||||
body = b""
|
||||
for v, n in zip(V, N):
|
||||
body += struct.pack("<6f", *v, *n) + (bytes([200, 150, 100]) if colour == "vertex" else b"")
|
||||
for f in F:
|
||||
body += struct.pack("<B3i", 3, *f)
|
||||
path.write_bytes(head.encode() + body)
|
||||
elif fmt == "obj":
|
||||
lines = ["# fake"] + [f"v {x} {y} {z}" for x, y, z in V] + [f"vn {x} {y} {z}" for x, y, z in N]
|
||||
lines += [f"f {a + 1} {b + 1} {c + 1}" for a, b, c in F]
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
elif fmt == "stl":
|
||||
body = b"".join(struct.pack("<12fH", *N[f[0]], *V[f[0]], *V[f[1]], *V[f[2]], 0) for f in F)
|
||||
path.write_bytes(b"\0" * 80 + struct.pack("<I", len(F)) + body)
|
||||
elif fmt == "glb":
|
||||
buf = V.tobytes() + F.astype(np.uint32).tobytes()
|
||||
gltf = {"asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0, "name": "room"}],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
|
||||
"buffers": [{"byteLength": len(buf)}],
|
||||
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": V.nbytes},
|
||||
{"buffer": 0, "byteOffset": V.nbytes, "byteLength": F.size * 4}],
|
||||
"accessors": [{"bufferView": 0, "componentType": 5126, "count": len(V), "type": "VEC3",
|
||||
"min": V.min(0).tolist(), "max": V.max(0).tolist()},
|
||||
{"bufferView": 1, "componentType": 5125, "count": F.size, "type": "SCALAR"}]}
|
||||
js = json.dumps(gltf).encode()
|
||||
js += b" " * (-len(js) % 4)
|
||||
buf += b"\0" * (-len(buf) % 4)
|
||||
path.write_bytes(struct.pack("<4sII", b"glTF", 2, 12 + 8 + len(js) + 8 + len(buf))
|
||||
+ struct.pack("<I4s", len(js), b"JSON") + js + struct.pack("<I4s", len(buf), b"BIN\0") + buf)
|
||||
|
||||
|
||||
def read_points(path):
|
||||
"""Vertex positions as a 3D program sees them (for GLB: with the node transforms applied)."""
|
||||
fmt = path.suffix[1:]
|
||||
raw = path.read_bytes()
|
||||
if fmt == "ply":
|
||||
end = raw.index(b"end_header\n") + len(b"end_header\n")
|
||||
colour = b"property uchar red" in raw[:end]
|
||||
dt = np.dtype([("p", "<f4", 3), ("n", "<f4", 3)] + ([("c", "u1", 3)] if colour else []))
|
||||
return np.frombuffer(raw, dt, count=len(V), offset=end)["p"].astype(float)
|
||||
if fmt == "obj":
|
||||
return np.array([[float(x) for x in l.split()[1:4]] for l in raw.decode().splitlines() if l.startswith("v ")])
|
||||
if fmt == "stl":
|
||||
n, = struct.unpack_from("<I", raw, 80)
|
||||
t = np.frombuffer(raw, np.dtype([("n", "<f4", 3), ("v", "<f4", (3, 3)), ("a", "<u2")]), count=n, offset=84)
|
||||
return t["v"].reshape(-1, 3).astype(float)
|
||||
if fmt == "glb":
|
||||
jl, = struct.unpack_from("<I", raw, 12)
|
||||
gltf = json.loads(raw[20:20 + jl])
|
||||
bin_ = raw[20 + jl + 8:]
|
||||
pts = np.frombuffer(bin_, np.float32, count=len(V) * 3).reshape(-1, 3).astype(float)
|
||||
|
||||
def transform(i, p):
|
||||
node = gltf["nodes"][i]
|
||||
if "rotation" in node:
|
||||
x, y, z, w = node["rotation"]
|
||||
R = np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])
|
||||
p = p @ R.T
|
||||
return p + np.array(node.get("translation", [0, 0, 0]))
|
||||
out = []
|
||||
for root in gltf["scenes"][gltf.get("scene", 0)]["nodes"]:
|
||||
def walk(i, chain):
|
||||
node = gltf["nodes"][i]
|
||||
if "mesh" in node:
|
||||
p = pts
|
||||
for j in reversed(chain + [i]):
|
||||
p = transform(j, p)
|
||||
out.append(p)
|
||||
for c in node.get("children", []):
|
||||
walk(c, chain + [i])
|
||||
walk(root, [])
|
||||
return np.vstack(out)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Your own splats (0.6, scripts/import.sh): a splat file from another program becomes a scene like the
|
||||
others, and goes to the Frame."""
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import RUNS, server, tools_called, wait_job
|
||||
import test_clean
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def splat(tmp_path):
|
||||
f = tmp_path / "garden_dslr.ply"
|
||||
test_clean.room(f)
|
||||
return f
|
||||
|
||||
|
||||
def transforms():
|
||||
return [c for c in tools_called() if c[0] == "splat-transform"]
|
||||
|
||||
|
||||
def test_a_splat_becomes_a_scene(api, splat):
|
||||
status, r = api.post("/api/import", {"path": str(splat), "name": "Garden"})
|
||||
assert status == 200, r
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok" and js["kind"] == "import", js["tail"]
|
||||
run = RUNS / "Garden"
|
||||
for f in ("spirula/scene.ply", "scene.sog", "scene.xrSceneTransform.json", "garden.gallery.png", "garden.sceneinfo.png"):
|
||||
assert (run / f).exists(), f
|
||||
assert json.loads((run / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0
|
||||
s = {x["slug"]: x for x in server.scenes()}["Garden"]
|
||||
assert s["name"] == "Garden" and s["ply"] and s["gaussians"] == len(test_clean.read(run / "spirula" / "scene.ply"))
|
||||
assert server.read_meta("Garden")["imported"] == "garden_dslr.ply"
|
||||
assert splat.exists() and not (RUNS / "Garden.tmp").exists() # the file itself stays
|
||||
first = transforms()[0]
|
||||
assert first[first.index("-w") + 1] == str(splat) and "-r" not in first and "-s" not in first
|
||||
|
||||
|
||||
@pytest.mark.parametrize("up,turn", [("y-up", "180,0,0"), ("z-up", "-90,0,0")])
|
||||
def test_up_and_size_are_set_right(api, splat, up, turn):
|
||||
assert api.post("/api/import", {"path": str(splat), "name": "Garden", "up": up, "scale": "0.01"})[0] == 200
|
||||
assert wait_job()["result"] == "ok"
|
||||
first = transforms()[0]
|
||||
assert first[first.index("-r") + 1] == turn and first[first.index("-s") + 1] == "0.01"
|
||||
|
||||
|
||||
def test_an_imported_scene_goes_to_the_frame(api, splat, frame):
|
||||
api.post("/api/import", {"path": str(splat), "name": "Garden"})
|
||||
wait_job()
|
||||
assert api.post("/api/upload", {"slug": "Garden"})[0] == 200
|
||||
end = time.time() + 60
|
||||
while (u := server.upload_status("Garden")) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status("Garden")["ok"], server.upload_status("Garden")
|
||||
folder = next((frame / server.FRAME_SCENES).iterdir())
|
||||
assert sorted(p.name for p in folder.iterdir()) == [
|
||||
"garden.gallery.png", "garden.sceneinfo.png", "garden.sog", "garden.xrSceneTransform.json"]
|
||||
|
||||
|
||||
def test_a_failed_import_frees_the_name(api, tmp_path):
|
||||
bad = tmp_path / "broken.ply"
|
||||
bad.write_bytes(b"not a splat")
|
||||
assert api.post("/api/import", {"path": str(bad), "name": "Broken"})[0] == 200
|
||||
assert wait_job()["result"] == "failed"
|
||||
server.watch_once(True)
|
||||
assert not (RUNS / "Broken").exists() and not server.meta_file("Broken").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [
|
||||
({"path": "relative.ply"}, "err.import_file"), ({"path": "/nope/x.ply"}, "err.import_file"),
|
||||
({"suffix": ".txt"}, "err.import_file"), ({"up": "x-up"}, "err.bad_value"), ({"scale": "0"}, "err.range"),
|
||||
({"scale": "abc"}, "err.bad_value"), ({"name": "Taken"}, "err.scene_name_taken")])
|
||||
def test_odd_imports_are_refused(api, splat, body, code):
|
||||
(RUNS / "Taken").mkdir(parents=True)
|
||||
path = splat
|
||||
if "suffix" in body:
|
||||
path = splat.with_suffix(body.pop("suffix"))
|
||||
path.write_bytes(b"x")
|
||||
status, r = api.post("/api/import", {"path": str(path), "name": "Garden", **body})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
assert server.load_state().get("job") is None
|
||||
@@ -0,0 +1,147 @@
|
||||
"""A mesh of a scene (0.5, scripts/mesh.sh + scripts/upright.py): made by Spirula Studio (faked here, see
|
||||
tests/fake_mesh.py), stood upright with the floor at height 0 – Z up for OBJ/PLY/STL, Y up for GLB, as 3D
|
||||
programs expect – instead of lying on its side as the scene does (Y down, like the Frame's tracking)."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from conftest import ROOT, RUNS, TRASH, server, tools_called, wait_job
|
||||
import fake_mesh
|
||||
import test_clean
|
||||
|
||||
UPRIGHT = ROOT / "scripts" / "upright.py"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fmt,colour", [("obj", "none"), ("ply", "vertex"), ("ply", "none"), ("stl", "none"), ("glb", "vertex")])
|
||||
def test_upright_puts_the_floor_at_the_bottom(tmp_path, fmt, colour):
|
||||
f = tmp_path / f"room.{fmt}"
|
||||
fake_mesh.write(f, fmt, colour)
|
||||
r = subprocess.run([sys.executable, str(UPRIGHT), "--floor", str(fake_mesh.FLOOR_Y), str(f)], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
p = fake_mesh.read_points(f)
|
||||
up = 1 if fmt in ("glb", "obj") else 2 # glTF and OBJ: Y up; PLY and STL: Z up (as Blender imports them)
|
||||
k = 6 if fmt == "stl" else 4 # STL lists the corners of each triangle: 2 floor triangles
|
||||
floor, ceiling = p[:k, up], p[k:, up]
|
||||
assert np.allclose(floor, 0, atol=1e-5) # the floor at height 0 ...
|
||||
assert np.allclose(ceiling, fake_mesh.FLOOR_Y - fake_mesh.CEILING_Y, atol=1e-5) # ... the ceiling 2.5 m above
|
||||
if fmt in ("obj", "ply"): # a proper turn, not a mirror: x stays
|
||||
assert np.allclose(p[:, 0], fake_mesh.V[:, 0])
|
||||
if fmt == "ply": # normals turn along: the floor faces up
|
||||
raw = f.read_bytes()
|
||||
end = raw.index(b"end_header\n") + len(b"end_header\n")
|
||||
dt = np.dtype([("p", "<f4", 3), ("n", "<f4", 3)] + ([("c", "u1", 3)] if colour == "vertex" else []))
|
||||
n = np.frombuffer(raw, dt, count=8, offset=end)["n"]
|
||||
assert np.allclose(n[:4], [0, 0, 1]) and np.allclose(n[4:], [0, 0, -1])
|
||||
assert raw.endswith(b"".join(np.array([3], np.uint8).tobytes() + fake_mesh.F[i].astype("<i4").tobytes()
|
||||
for i in range(len(fake_mesh.F)))) # faces untouched
|
||||
|
||||
|
||||
def scene(slug="Living", images=False):
|
||||
run = RUNS / slug
|
||||
test_clean.room(run / "spirula" / "scene.ply")
|
||||
(run / "scene.sog").write_bytes(b"sog")
|
||||
if images:
|
||||
(run / "dataset" / "images").mkdir(parents=True)
|
||||
(run / "dataset" / "sparse").mkdir()
|
||||
server.write_meta(slug, name="Living room")
|
||||
return run
|
||||
|
||||
|
||||
def mesh_calls():
|
||||
return [c for c in tools_called() if c[:2] == ["spirula", "mesh"]]
|
||||
|
||||
|
||||
def test_a_mesh_for_blender_and_3d_printing(api):
|
||||
run = scene()
|
||||
m = server.mesh_of("Living")
|
||||
assert {k: m[k] for k in ("texture_possible", "files", "made")} == {"texture_possible": False, "files": [], "made": None}
|
||||
status, r = api.post("/api/mesh", {"slug": "Living", "formats": "glb,stl", "texture": True})
|
||||
assert status == 200, r
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok" and js["kind"] == "mesh", js["tail"]
|
||||
m = server.mesh_of("Living")
|
||||
assert [f["name"] for f in m["files"]] == ["living.glb", "living.stl"] # one file per format, nothing else
|
||||
assert m["made"]["formats"] == ["glb", "stl"] and m["made"]["texture"] is False and not m["made"]["stale"]
|
||||
call = mesh_calls()[0]
|
||||
assert "--no-data" in call # no training images: no texture
|
||||
assert call[call.index("--color") + 1] == "none,vertex" # GLB with colours, STL without
|
||||
assert call[2].endswith("inside.ply") # meshed from inside the room only
|
||||
assert np.allclose(fake_mesh.read_points(run / "mesh" / "living.stl")[:, 2].min(), 0, atol=0.05)
|
||||
assert np.allclose(fake_mesh.read_points(run / "mesh" / "living.glb")[:, 1].min(), 0, atol=0.05)
|
||||
assert not (run / "mesh.tmp").exists()
|
||||
assert [(s["slug"], s["mesh"]) for s in server.scenes()] == [("Living", True)]
|
||||
|
||||
|
||||
def test_a_texture_when_the_training_images_are_there(api):
|
||||
run = scene(images=True)
|
||||
assert server.mesh_of("Living")["texture_possible"]
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "obj,ply", "texture": True})
|
||||
assert wait_job()["result"] == "ok"
|
||||
call = mesh_calls()[0]
|
||||
assert call[call.index("--data") + 1] == str(run / "dataset")
|
||||
assert call[call.index("--color") + 1] == "texture,vertex"
|
||||
names = [f["name"] for f in server.mesh_of("Living")["files"]]
|
||||
assert names == ["living.ply", "living_texture.obj", "living_texture.png"] # a texture keeps its names
|
||||
|
||||
|
||||
def test_a_new_mesh_replaces_the_old_and_can_be_deleted(api):
|
||||
run = scene()
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "glb,stl"})
|
||||
wait_job()
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "obj"})
|
||||
wait_job()
|
||||
assert [f["name"] for f in server.mesh_of("Living")["files"]] == ["living.obj"]
|
||||
assert api.post("/api/mesh/delete", {"slug": "Living"})[0] == 200
|
||||
assert not (run / "mesh").exists() and (TRASH / "mesh").exists()
|
||||
assert api.post("/api/mesh/delete", {"slug": "Living"})[1]["error"]["code"] == "err.no_mesh"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [({"formats": "fbx"}, "err.bad_value"), ({"formats": ""}, "err.bad_value"),
|
||||
({"slug": "Nope", "formats": "glb"}, "err.no_scene"),
|
||||
({"slug": "../x", "formats": "glb"}, "err.no_scene")])
|
||||
def test_odd_meshes_are_refused(api, body, code):
|
||||
scene()
|
||||
status, r = api.post("/api/mesh", {"slug": "Living", **body})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
assert not mesh_calls()
|
||||
|
||||
|
||||
def test_mesh_sh_refuses_unknown_formats():
|
||||
run = scene()
|
||||
r = subprocess.run([str(ROOT / "scripts" / "mesh.sh"), str(run), "glb,fbx"], capture_output=True, text=True)
|
||||
assert r.returncode == 1 and "Unknown mesh format: fbx" in r.stderr and not mesh_calls()
|
||||
|
||||
|
||||
def test_mesh_files_can_be_downloaded(api):
|
||||
run = scene()
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "glb,stl"})
|
||||
wait_job()
|
||||
import urllib.parse
|
||||
status, body, headers = api.raw("/api/mesh/file?slug=Living&name=living.glb")
|
||||
assert status == 200 and body == (run / "mesh" / "living.glb").read_bytes()
|
||||
assert headers["Content-Type"] == "model/gltf-binary" and "attachment" in headers["Content-Disposition"]
|
||||
for bad in ("../scene.sog", "mesh.json", ".hidden", "living.sog", "", "/etc/passwd"):
|
||||
assert api.raw("/api/mesh/file?slug=Living&name=" + urllib.parse.quote(bad))[0] == 404, bad
|
||||
assert api.raw("/api/mesh/file?slug=..&name=living.glb")[0] == 404
|
||||
|
||||
|
||||
def test_open_in_blender_imports_the_mesh(api, monkeypatch):
|
||||
run = scene()
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "stl,glb"})
|
||||
wait_job()
|
||||
started = []
|
||||
monkeypatch.setattr(server, "blender_cmd", lambda: ["flatpak", "run", "org.blender.Blender"])
|
||||
monkeypatch.setattr(server.subprocess, "Popen", lambda cmd, **kw: started.append(cmd))
|
||||
assert api.post("/api/open", {"slug": "Living", "what": "blender"})[0] == 200
|
||||
cmd = started[-1]
|
||||
assert cmd[:3] == ["flatpak", "run", "org.blender.Blender"] and cmd[3] == "--python-expr"
|
||||
assert "import_scene.gltf" in cmd[4] and repr(str(run / "mesh" / "living.glb")) in cmd[4] # GLB first
|
||||
assert "bpy.app.timers.register" in cmd[4] # not right at start-up: Blender's window isn't ready then
|
||||
compile(cmd[4], "blender", "exec") # valid Python
|
||||
monkeypatch.setattr(server, "blender_cmd", lambda: None)
|
||||
status, r = api.post("/api/open", {"slug": "Living", "what": "blender"})
|
||||
assert status == 400 and r["error"]["code"] == "err.no_blender"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""The page itself (web/index.html): what its script touches the moment it loads must already be there.
|
||||
In 0.5.1 the footer stood below the script; setting up its link failed, and the rest of the script –
|
||||
among it the regular status updates – never ran: a finished job stayed "running" until a reload."""
|
||||
import re
|
||||
|
||||
from conftest import ROOT
|
||||
|
||||
HTML = (ROOT / "web" / "index.html").read_text()
|
||||
|
||||
|
||||
def test_everything_the_script_sets_up_on_load_is_above_it():
|
||||
script = HTML.index("<script>")
|
||||
above = set(re.findall(r'id="([\w-]+)"', HTML[:script]))
|
||||
code = HTML[script:]
|
||||
on_load = set(re.findall(r'^\$\("#([\w-]+)"\)\.', code, re.M)) # statements at the top level
|
||||
missing = sorted(on_load - above)
|
||||
assert not missing, f"used when the page loads, but not (yet) in the page: {missing}"
|
||||
|
||||
|
||||
def test_the_status_is_polled():
|
||||
assert re.search(r"^setInterval\(poll, \d+\);", HTML, re.M)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Finished scenes: renaming, cleaning up, deleting, and runs that did not finish."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from conftest import RUNS, TRASH, server
|
||||
import synth
|
||||
@@ -89,3 +90,20 @@ def test_unfinished_runs_resume_or_start_over(api):
|
||||
assert api.post("/api/resume", {"slug": "Broken"})[1]["error"]["code"] == "err.not_resumable"
|
||||
assert api.post("/api/delete", {"slug": "old-test"})[0] == 200
|
||||
assert not (RUNS / "old-test.log").exists()
|
||||
|
||||
|
||||
def test_copying_to_the_frame_says_how_far_it_is(api, frame):
|
||||
"""to-frame.sh reports every 2 s how much is on the Frame already (\"@@ upbytes\"); the page shows a percentage."""
|
||||
import time
|
||||
synth.make_room(RUNS, "Kitchen", "A")
|
||||
(RUNS / "Kitchen" / "scene.sog").write_bytes(b"s" * 200_000)
|
||||
os.environ["FAKE_SCP_SLOW"] = "1"
|
||||
assert api.post("/api/upload", {"slug": "Kitchen"})[0] == 200
|
||||
seen = []
|
||||
end = time.time() + 30
|
||||
while (u := server.upload_status("Kitchen")) and u["running"] and time.time() < end:
|
||||
if "pct" in u:
|
||||
seen.append(u)
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status("Kitchen")["ok"]
|
||||
assert seen and seen[0]["total"] > 200_000 and 0 <= seen[-1]["pct"] <= 99
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Is there a newer version? (0.5.1, asked in issue #2): once a day the server asks GitHub for the newest
|
||||
release – in the background, switched off with check_updates = false – and the page shows a note."""
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github(monkeypatch):
|
||||
"""A fake api.github.com: answers with `reply`, counts the requests."""
|
||||
seen = {"n": 0, "reply": {"tag_name": "v9.1.0", "html_url": "https://github.com/thunderheronight/splat-workshop/releases/tag/v9.1.0"}}
|
||||
|
||||
def urlopen(req, timeout=None):
|
||||
seen["n"] += 1
|
||||
seen["url"], seen["headers"] = req.full_url, dict(req.header_items())
|
||||
if seen["reply"] is None:
|
||||
raise OSError("offline")
|
||||
return io.BytesIO(json.dumps(seen["reply"]).encode())
|
||||
monkeypatch.setattr(server.urllib.request, "urlopen", urlopen)
|
||||
monkeypatch.setattr(server.CFG, "check_updates", True)
|
||||
server.UPDATE_FILE.unlink(missing_ok=True)
|
||||
yield seen
|
||||
server.UPDATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def ask():
|
||||
server.ask_for_update()
|
||||
end = time.time() + 5
|
||||
while server.update_lock.locked() and time.time() < end:
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def test_a_newer_release_is_shown(github, api):
|
||||
ask()
|
||||
assert github["url"] == "https://api.github.com/repos/thunderheronight/splat-workshop/releases/latest"
|
||||
assert "Cookie" not in github["headers"] and "Authorization" not in github["headers"]
|
||||
info = api.get("/api/ping")[1]["update"]
|
||||
assert info == {"latest": "9.1.0", "current": server.VERSION,
|
||||
"url": "https://github.com/thunderheronight/splat-workshop/releases/tag/v9.1.0"}
|
||||
|
||||
|
||||
def test_asked_at_most_once_a_day(github):
|
||||
ask()
|
||||
ask()
|
||||
assert github["n"] == 1
|
||||
data = json.loads(server.UPDATE_FILE.read_text())
|
||||
data["checked"] -= server.UPDATE_EVERY + 1
|
||||
server.UPDATE_FILE.write_text(json.dumps(data))
|
||||
ask()
|
||||
assert github["n"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", ["v" + server.VERSION, "v0.1.0", "nonsense", None])
|
||||
def test_nothing_shown_for_the_same_an_older_or_an_odd_release(github, tag):
|
||||
github["reply"] = {"tag_name": tag, "html_url": "https://github.com/thunderheronight/splat-workshop/releases"}
|
||||
ask()
|
||||
assert server.update_info() is None
|
||||
|
||||
|
||||
def test_offline_is_no_problem(github):
|
||||
github["reply"] = None
|
||||
ask()
|
||||
assert server.update_info() is None and github["n"] == 1 # and not asked again right away
|
||||
ask()
|
||||
assert github["n"] == 1
|
||||
|
||||
|
||||
def test_a_link_elsewhere_is_not_shown(github):
|
||||
github["reply"] = {"tag_name": "v9.1.0", "html_url": "https://evil.example/download"}
|
||||
ask()
|
||||
assert server.update_info()["url"] == "https://github.com/thunderheronight/splat-workshop/releases/latest"
|
||||
|
||||
|
||||
def test_switched_off_never_asks(github, monkeypatch):
|
||||
monkeypatch.setattr(server.CFG, "check_updates", False)
|
||||
ask()
|
||||
assert github["n"] == 0 and server.update_info() is None
|
||||
|
||||
|
||||
def test_versions_compare_as_numbers():
|
||||
assert server.version_tuple("v0.10.0") > server.version_tuple("0.9.9")
|
||||
assert server.version_tuple("0.5.0-experimental") == (0, 5, 0) and server.version_tuple("x") == ()
|
||||
|
||||
|
||||
def test_checking_by_hand_asks_right_away(github, api, monkeypatch):
|
||||
"""Tray menu or page: asks now, even right after the daily check, and even when that is switched off."""
|
||||
ask()
|
||||
github["reply"]["tag_name"] = "v9.2.0"
|
||||
monkeypatch.setattr(server.CFG, "check_updates", False)
|
||||
status, r = api.post("/api/update/check", {})
|
||||
assert status == 200 and r["reached"] and r["update"]["latest"] == "9.2.0" and github["n"] == 2
|
||||
github["reply"] = None
|
||||
r = api.post("/api/update/check", {})[1]
|
||||
assert r["reached"] is False and r["current"] == server.VERSION
|
||||
+62
-2
@@ -249,7 +249,7 @@ const I18N = {
|
||||
"ver.pick": "Enter a size (at least 0.01 million)", "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",
|
||||
"job.sub.versions": "{n} versions · for {time}", "job.sub.versions.1": "1 version · 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.",
|
||||
@@ -268,6 +268,36 @@ const I18N = {
|
||||
"fg.move.hint": "{n} scene(s) from Splat Workshop are still in Gaussian XR's own folder, where a Steam update can delete them.",
|
||||
"fg.move": "Move to own scenes", "fg.moved": "Moved to the own scenes on the Frame",
|
||||
"ver.recommended": "(recommended)",
|
||||
"update.text": "🎉 Splat Workshop {latest} is out (you have {current}).", "update.notes": "What's new",
|
||||
"update.later": "Later", "update.text.plain": "Splat Workshop {latest} is out", "update.none": "You have the newest version ({current})",
|
||||
"update.offline": "GitHub could not be reached – try again later", "foot.version": "Splat Workshop {v}", "foot.releases": "Releases",
|
||||
"foot.check": "Check for updates", "sc.uploading.pct": "Copying to the Frame … {pct} % ({done} of {total})", "update.how": "To update: run {cmd} in the Splat Workshop folder, then quit Splat Workshop (tray icon → Quit) and start it again. Your config and scenes stay.",
|
||||
"imp.button": "+ Import a splat …", "imp.button.title": "A splat file from another program as a scene",
|
||||
"imp.title": "Import a splat", "imp.hint": "A splat file from another program becomes a scene like the others. The file itself is not changed.",
|
||||
"imp.pick": "Choose file …", "imp.none": "No file chosen (.ply, .sog, .spz, .splat, .ksplat)", "imp.name": "Name",
|
||||
"imp.up": "Which way is up in the file?",
|
||||
"imp.up.y-down": "Y down (usual)", "imp.up.y-down.text": "Spirula Studio, COLMAP and most tools built on it – like the Frame's own scenes",
|
||||
"imp.up.y-up": "Y up", "imp.up.y-up.text": "If the scene stands upside down on the Frame",
|
||||
"imp.up.z-up": "Z up", "imp.up.z-up.text": "If the scene lies on its side on the Frame (e.g. from Blender)",
|
||||
"imp.scale": "Size × ", "imp.scale.hint": "1 = as in the file; 0.01 if it is in centimetres. Photo reconstructions have no real scale – try it on the Frame.",
|
||||
"imp.make": "Import", "imp.started": "Importing …", "job.sub.import": "Import · for {time}",
|
||||
"err.import_file": "Choose a splat file (.ply, .sog, .spz, .splat, .ksplat)",
|
||||
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "A mesh (surface) for 3D programs and 3D printing",
|
||||
"mesh.title": "Mesh of “{name}”",
|
||||
"mesh.hint": "Spirula Studio turns the splats into a surface – for Blender and other 3D programs or for 3D printing. It stands upright (the way Blender & co. expect each format) with the floor at height 0; what lies well outside the room is left out. In metres.",
|
||||
"mesh.new": "New mesh", "mesh.have": "Mesh", "mesh.make": "Make mesh", "mesh.pick": "Pick at least one format",
|
||||
"mesh.started": "Making the mesh …", "mesh.delete": "Delete the mesh",
|
||||
"mesh.confirm": "Move the mesh of “{name}” to the trash?",
|
||||
"mesh.f.glb": "GLB (recommended)", "mesh.f.glb.text": "With colours or texture, one file – Blender, Windows 3D viewer, the web",
|
||||
"mesh.f.stl": "STL", "mesh.f.stl.text": "No colours – slicers for 3D printing",
|
||||
"mesh.f.obj": "OBJ", "mesh.f.obj.text": "With a texture (if possible), otherwise without colours – nearly every 3D program",
|
||||
"mesh.f.ply": "PLY", "mesh.f.ply.text": "Colours per corner – MeshLab, CloudCompare",
|
||||
"mesh.texture": "With a texture from the training images (sharper colours, takes a little longer)",
|
||||
"mesh.texture.none": "No texture possible: the training images were cleaned up – colours per corner from the splats",
|
||||
"mesh.time": "Takes a few minutes and about 4 GB of memory; a new mesh replaces the old one.",
|
||||
"mesh.download.title": "Download this file (also from other devices at home)", "mesh.blender": "Open in Blender",
|
||||
"mesh.blender.starting": "Blender is starting …", "err.no_blender": "Blender is not installed",
|
||||
"job.sub.mesh": "Mesh · for {time}", "res.ok.mesh": "Done – the mesh of “{name}” is in {path}.", "err.no_mesh": "There is no mesh",
|
||||
"ver.clean": "Clean up", "ver.clean.off": "off",
|
||||
"ver.clean.hint": "Experimental. The scene itself stays as it is; only this version is cleaned up – compare it with one without.",
|
||||
"ver.clean.ceiling": "Ceiling", "ver.clean.ceiling.title": "A white ceiling gives the reconstruction nothing to hold on to: it stays thin, with a dark veil under it. Takes out the veil (deeper with more strength; dark things within 50 cm of a wall stay) and what lies above the ceiling, and puts in a closed ceiling in its own colour.",
|
||||
@@ -532,7 +562,7 @@ const I18N = {
|
||||
"ver.pick": "Eine Größe angeben (mindestens 0,01 Mio.)", "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",
|
||||
"job.sub.versions": "{n} Fassungen · seit {time}", "job.sub.versions.1": "1 Fassung · 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.",
|
||||
@@ -551,6 +581,36 @@ const I18N = {
|
||||
"fg.move.hint": "{n} Szene(n) aus der Werkstatt liegen noch im Ordner von Gaussian XR, wo ein Steam-Update sie löschen kann.",
|
||||
"fg.move": "Zu den eigenen Szenen verschieben", "fg.moved": "Zu den eigenen Szenen auf der Frame verschoben",
|
||||
"ver.recommended": "(empfohlen)",
|
||||
"update.text": "🎉 Die Splat-Werkstatt {latest} ist da (du hast {current}).", "update.notes": "Was ist neu",
|
||||
"update.later": "Später", "update.text.plain": "Die Splat-Werkstatt {latest} ist da", "update.none": "Du hast die neueste Version ({current})",
|
||||
"update.offline": "GitHub war nicht erreichbar – später noch einmal versuchen", "foot.version": "Splat-Werkstatt {v}", "foot.releases": "Versionen",
|
||||
"foot.check": "Nach Updates suchen", "sc.uploading.pct": "Wird auf die Frame übertragen … {pct} % ({done} von {total})", "update.how": "Zum Aktualisieren im Ordner der Splat-Werkstatt {cmd} ausführen, dann die Splat-Werkstatt beenden (Tray-Symbol → Beenden) und neu starten. Config und Szenen bleiben erhalten.",
|
||||
"imp.button": "+ Splat importieren …", "imp.button.title": "Eine Splat-Datei aus einem anderen Programm als Szene aufnehmen",
|
||||
"imp.title": "Splat importieren", "imp.hint": "Eine Splat-Datei aus einem anderen Programm wird zu einer Szene wie die anderen. Die Datei selbst bleibt unverändert.",
|
||||
"imp.pick": "Datei wählen …", "imp.none": "Keine Datei gewählt (.ply, .sog, .spz, .splat, .ksplat)", "imp.name": "Name",
|
||||
"imp.up": "Wo ist oben in der Datei?",
|
||||
"imp.up.y-down": "Y unten (üblich)", "imp.up.y-down.text": "Spirula Studio, COLMAP und die meisten Werkzeuge darauf – wie die Szenen der Frame selbst",
|
||||
"imp.up.y-up": "Y oben", "imp.up.y-up.text": "Wenn die Szene auf der Frame auf dem Kopf steht",
|
||||
"imp.up.z-up": "Z oben", "imp.up.z-up.text": "Wenn die Szene auf der Frame auf der Seite liegt (z. B. aus Blender)",
|
||||
"imp.scale": "Größe × ", "imp.scale.hint": "1 = wie in der Datei; 0,01, wenn sie in Zentimetern ist. Aus Fotos rekonstruierte Szenen haben keinen echten Maßstab – auf der Frame ausprobieren.",
|
||||
"imp.make": "Importieren", "imp.started": "Importiere …", "job.sub.import": "Import · seit {time}",
|
||||
"err.import_file": "Eine Splat-Datei wählen (.ply, .sog, .spz, .splat, .ksplat)",
|
||||
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "Ein Mesh (Oberfläche) für 3D-Programme und den 3D-Druck",
|
||||
"mesh.title": "Mesh von „{name}“",
|
||||
"mesh.hint": "Spirula Studio macht aus den Splats eine Oberfläche – für Blender und andere 3D-Programme oder für den 3D-Druck. Sie steht aufrecht (so, wie Blender & Co. jedes Format erwarten) mit dem Boden auf Höhe 0; was deutlich außerhalb des Raums liegt, bleibt weg. In Metern.",
|
||||
"mesh.new": "Neues Mesh", "mesh.have": "Mesh", "mesh.make": "Mesh erstellen", "mesh.pick": "Mindestens ein Format wählen",
|
||||
"mesh.started": "Erstelle das Mesh …", "mesh.delete": "Mesh löschen",
|
||||
"mesh.confirm": "Das Mesh von „{name}“ in den Papierkorb verschieben?",
|
||||
"mesh.f.glb": "GLB (empfohlen)", "mesh.f.glb.text": "Mit Farben oder Textur, eine Datei – Blender, Windows-3D-Viewer, Web",
|
||||
"mesh.f.stl": "STL", "mesh.f.stl.text": "Ohne Farben – Slicer für den 3D-Druck",
|
||||
"mesh.f.obj": "OBJ", "mesh.f.obj.text": "Mit Textur (wenn möglich), sonst ohne Farben – fast jedes 3D-Programm",
|
||||
"mesh.f.ply": "PLY", "mesh.f.ply.text": "Farben pro Ecke – MeshLab, CloudCompare",
|
||||
"mesh.texture": "Mit Textur aus den Trainingsbildern (schärfere Farben, dauert etwas länger)",
|
||||
"mesh.texture.none": "Keine Textur möglich: Die Trainingsbilder wurden aufgeräumt – Farben pro Ecke aus den Splats",
|
||||
"mesh.time": "Dauert ein paar Minuten und braucht etwa 4 GB Arbeitsspeicher; ein neues Mesh ersetzt das alte.",
|
||||
"mesh.download.title": "Diese Datei herunterladen (auch von anderen Geräten im Heimnetz)", "mesh.blender": "In Blender öffnen",
|
||||
"mesh.blender.starting": "Blender startet …", "err.no_blender": "Blender ist nicht installiert",
|
||||
"job.sub.mesh": "Mesh · seit {time}", "res.ok.mesh": "Fertig – das Mesh von „{name}“ liegt unter {path}.", "err.no_mesh": "Es gibt kein Mesh",
|
||||
"ver.clean": "Bereinigen", "ver.clean.off": "aus",
|
||||
"ver.clean.hint": "Experimentell. Die Szene selbst bleibt, wie sie ist; nur diese Fassung wird bereinigt – am besten neben einer ohne vergleichen.",
|
||||
"ver.clean.ceiling": "Decke", "ver.clean.ceiling.title": "Eine weiße Decke gibt der Rekonstruktion keinen Halt: Sie bleibt dünn, darunter hängt ein dunkler Schleier. Nimmt den Schleier heraus (je stärker, desto tiefer; Dunkles bis 50 cm an einer Wand bleibt) und was über der Decke liegt, und setzt eine geschlossene Decke in ihrer eigenen Farbe ein.",
|
||||
|
||||
+174
-6
@@ -223,6 +223,13 @@ dialog.plan { max-width: min(1200px, 94vw); width: 94vw; }
|
||||
.pick label span { color: var(--faint); font-size: 12px; margin-left: auto; }
|
||||
.pick input { accent-color: var(--accent); }
|
||||
#lanPill { cursor: pointer; } #lanPill:hover { color: var(--text); }
|
||||
.meshfiles { margin-top: 10px; }
|
||||
.meshfiles a.btnlink .faint { margin-left: 6px; }
|
||||
.foot { text-align: center; font-size: 12.5px; color: var(--muted, #9aa3b2); padding: 18px 16px 28px; }
|
||||
.foot a { color: inherit; }
|
||||
.update { border-color: color-mix(in srgb, var(--accent, #7c9cff) 45%, var(--line)); padding-top: 12px; padding-bottom: 12px; }
|
||||
.update code { background: var(--bg); padding: 1px 6px; border-radius: 6px; font-size: 13px; }
|
||||
a.btnlink { display: inline-flex; align-items: center; padding: 4px 10px; border: 1px solid var(--line); border-radius: 8px; color: inherit; text-decoration: none; font-size: 13px; }
|
||||
.setup { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
||||
.setup h2 { color: var(--warn); }
|
||||
.setup li { margin: 4px 0; font-size: 14px; }
|
||||
@@ -244,6 +251,14 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<main>
|
||||
<div class="card offline-note hidden" id="offline" role="alert"><b data-i18n="offline.title"></b> <span class="muted" data-i18n="offline.text"></span></div>
|
||||
<!-- missing prerequisites -->
|
||||
<section class="card update hidden" id="updateCard">
|
||||
<div class="row" style="justify-content:space-between;gap:10px">
|
||||
<span id="updateText"></span>
|
||||
<span class="row" style="gap:6px"><a class="small btnlink" id="updateLink" target="_blank" rel="noopener" data-i18n="update.notes"></a>
|
||||
<button class="small" id="updateLater" data-i18n="update.later"></button></span>
|
||||
</div>
|
||||
<p class="faint" style="font-size:13px;margin:6px 0 0" id="updateHow"></p>
|
||||
</section>
|
||||
<section class="card setup hidden" id="setupCard">
|
||||
<h2 data-i18n="setup.title"></h2>
|
||||
<ul id="setupList"></ul>
|
||||
@@ -272,6 +287,7 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<defs><linearGradient id="g" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#7c9cff"/><stop offset="1" stop-color="#7c9cff" stop-opacity="0"/></linearGradient></defs></svg>
|
||||
</div>
|
||||
<div id="result" class="result hidden"></div>
|
||||
<div id="resultExtra" class="hidden"></div>
|
||||
<details style="margin-top:12px"><summary class="muted" style="cursor:pointer;font-size:13.5px" data-i18n="job.log"></summary><pre class="log" id="log"></pre></details>
|
||||
</section>
|
||||
|
||||
@@ -357,7 +373,7 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between;margin-bottom:14px">
|
||||
<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 class="row"><button class="small pc-only" id="importBtn" data-i18n="imp.button" data-i18n-title="imp.button.title"></button><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
|
||||
</div>
|
||||
<div class="tabs" id="sceneTabs">
|
||||
<button class="tab active" data-stab="pc" data-i18n="stab.pc"></button>
|
||||
@@ -429,6 +445,32 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="importDlg" class="wide">
|
||||
<h2 style="margin-top:0" data-i18n="imp.title"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="imp.hint"></p>
|
||||
<div class="row" style="gap:8px"><button class="small" id="impPick" data-i18n="imp.pick"></button><span id="impFile" class="muted" style="overflow-wrap:anywhere"></span></div>
|
||||
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.name"></span><input id="impName" type="text" style="flex:1"></label>
|
||||
<h3 data-i18n="imp.up"></h3>
|
||||
<div class="opts" id="impUp"></div>
|
||||
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.scale"></span><input id="impScale" type="number" min="0.0001" max="10000" step="any" value="1" style="width:110px"><span class="faint" style="font-size:12.5px" data-i18n="imp.scale.hint"></span></label>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="impClose" data-i18n="clean.close"></button>
|
||||
<button class="primary" id="impMake" data-i18n="imp.make" disabled></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="meshDlg" class="wide">
|
||||
<h2 style="margin-top:0" id="meshTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="mesh.hint"></p>
|
||||
<div id="meshHave" class="vlist"></div>
|
||||
<h3 data-i18n="mesh.new"></h3>
|
||||
<div id="meshFormats" class="opts"></div>
|
||||
<label class="chk" id="meshTexBox" style="margin-top:10px"><input type="checkbox" id="meshTex"> <span id="meshTexText"></span></label>
|
||||
<p class="faint" style="font-size:12px;margin:10px 0 0" data-i18n="mesh.time"></p>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="meshClose" data-i18n="clean.close"></button>
|
||||
<button class="primary" id="meshMake" data-i18n="mesh.make"></button>
|
||||
</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>
|
||||
@@ -510,6 +552,9 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<footer class="foot"><span id="footVersion"></span> · <a href="https://github.com/thunderheronight/splat-workshop" target="_blank" rel="noopener">GitHub</a>
|
||||
· <a href="https://github.com/thunderheronight/splat-workshop/releases" target="_blank" rel="noopener" data-i18n="foot.releases"></a>
|
||||
· <a href="#" id="footCheck" data-i18n="foot.check"></a></footer>
|
||||
<script src="/i18n.js"></script>
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
@@ -563,6 +608,7 @@ function renderPing() {
|
||||
pill.classList.remove("hidden");
|
||||
pill.onclick = () => navigator.clipboard?.writeText(r.lan_url).then(() => toast(tr("copied")), () => toast(r.lan_url));
|
||||
}
|
||||
renderUpdate();
|
||||
if (r.problems && r.problems.length) {
|
||||
$("#setupCard").classList.remove("hidden");
|
||||
$("#setupList").innerHTML = r.problems.map(p => `<li>${esc(trErr(p))}</li>`).join("");
|
||||
@@ -570,6 +616,20 @@ function renderPing() {
|
||||
}
|
||||
}
|
||||
|
||||
// A newer release on GitHub (the server asks once a day, see check_updates in the config)
|
||||
function renderUpdate() {
|
||||
const u = (S.ping || {}).update;
|
||||
let later = "";
|
||||
try { later = localStorage.getItem("updateLater") || ""; } catch {}
|
||||
const show = !!u && later !== u.latest && !document.body.classList.contains("remote");
|
||||
$("#updateCard").classList.toggle("hidden", !show);
|
||||
if (!show) return;
|
||||
$("#updateText").innerHTML = tr("update.text", {latest: `<b>${esc(u.latest)}</b>`, current: esc(u.current)});
|
||||
$("#updateLink").href = u.url;
|
||||
$("#updateHow").innerHTML = tr("update.how", {cmd: "<code>git pull</code>"});
|
||||
$("#updateLater").onclick = () => { try { localStorage.setItem("updateLater", u.latest); } catch {} renderUpdate(); };
|
||||
}
|
||||
|
||||
/* ---------- recordings ---------- */
|
||||
async function loadRecs(refresh) {
|
||||
if (refresh) $("#recGrid").innerHTML = `<div class="empty">${tr("loading")}</div>`;
|
||||
@@ -968,8 +1028,9 @@ 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);
|
||||
const versions = j.kind === "versions";
|
||||
$("#jobSub").textContent = versions ? tr("job.sub.versions", {n: (j.steps || []).length, time})
|
||||
const versions = j.kind === "versions" || j.kind === "mesh" || j.kind === "import";
|
||||
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {time}) : j.kind === "import" ? tr("job.sub.import", {time})
|
||||
: versions ? tr((j.steps || []).length === 1 ? "job.sub.versions.1" : "job.sub.versions", {n: (j.steps || []).length, time})
|
||||
: combine ? tr(j.rooms === 1 && j.recordings > 1 ? "job.sub.combine.room" : "job.sub.combine",
|
||||
{n: j.rooms === 1 && j.recordings > 1 ? j.recordings : j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
|
||||
: j.chain ? tr("job.sub.part", {i: j.chain.i + 1, n: j.chain.n, room: j.chain.room, q: tr("q." + qk), time})
|
||||
@@ -1013,11 +1074,18 @@ function renderJob() {
|
||||
if (!j.running) {
|
||||
if (j.result === "ok") { res.className = "result ok";
|
||||
res.textContent = j.upload ? tr("res.ok.frame", {name: j.name, folder: i.folder ? " (" + i.folder + ")" : ""})
|
||||
: tr("res.ok.local", {name: j.name, path: "runs/" + j.slug}); }
|
||||
: tr(j.kind === "mesh" ? "res.ok.mesh" : "res.ok.local", {name: j.name, path: "runs/" + j.slug + (j.kind === "mesh" ? "/mesh" : "")}); }
|
||||
else if (j.result === "cancelled") { res.className = "result warn"; res.textContent = tr("res.cancelled"); }
|
||||
else if (j.phase === 4) { res.className = "result warn"; res.textContent = tr("res.upload_failed"); }
|
||||
else { res.className = "result bad"; res.textContent = i.hint ? tr(i.hint) : tr("res.failed"); }
|
||||
}
|
||||
// after a mesh: its files to download, Blender, the folder
|
||||
const extra = $("#resultExtra"), meshDone = !j.running && j.result === "ok" && j.kind === "mesh";
|
||||
extra.classList.toggle("hidden", !meshDone);
|
||||
if (meshDone && extra.dataset.for !== j.out) {
|
||||
extra.dataset.for = j.out;
|
||||
api("/api/mesh?slug=" + encodeURIComponent(j.slug)).then(m => { extra.innerHTML = meshFiles(j.slug, m); bindMeshFiles(extra, j.slug); });
|
||||
}
|
||||
const log = $("#log"), atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 20;
|
||||
log.textContent = j.tail || ""; if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
@@ -1025,7 +1093,7 @@ function renderJob() {
|
||||
/* ---------- finished scenes ---------- */
|
||||
function frameState(s) {
|
||||
const u = s.upload;
|
||||
if (u && u.running) return `<div class="fstate">${tr("sc.uploading")}</div>`;
|
||||
if (u && u.running) return `<div class="fstate">${u.total ? tr("sc.uploading.pct", {pct: u.pct, done: mb(u.done), total: mb(u.total)}) : tr("sc.uploading")}</div>`;
|
||||
if (u && u.error) return `<div class="fstate err" title="${esc(u.error)}">${tr("sc.uploadFailed")}</div>`;
|
||||
if (s.on_frame === true) return `<div class="fstate yes">${tr("sc.onFrame")}</div>`;
|
||||
if (s.on_frame === false) return `<div class="fstate no">${tr("sc.notOnFrame")}</div>`;
|
||||
@@ -1048,7 +1116,7 @@ function sceneCard(s) {
|
||||
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
|
||||
<div class="acts rows${s.replacing ? " hidden" : ""}">
|
||||
${S.frameConfigured ? `<div class="arow">${uploadBtn(s)}</div>` : ""}
|
||||
<div class="arow">${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}${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></div>
|
||||
<div class="arow">${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}${s.ply ? `<button class="small" data-mesh="${esc(s.slug)}" title="${tr("mesh.button.title")}">${tr(s.mesh ? "mesh.button.has" : "mesh.button")}</button>` : ""}${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></div>
|
||||
<div class="arow"><button class="small icon" data-rename="${esc(s.slug)}" title="${tr("ren.button")}" aria-label="${tr("ren.button")}">✎</button><button class="small icon" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small icon" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small icon" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div></div>
|
||||
<div class="toggles${s.replacing ? " hidden" : ""}">${versionsToggle(s.slug, s.versions)}</div>
|
||||
</div></div>
|
||||
@@ -1096,6 +1164,7 @@ function groupBlock(g, bySlug, byGroup) {
|
||||
<button class="small${!r || r.stale ? " primary" : ""}" data-combine="${esc(g.slug)}" ${g.busy || scenes.length < 2 ? "disabled" : ""}>${tr(r ? "grp.recombine" : g.room ? "grp.room.combine" : "grp.combine")}</button>
|
||||
${r ? uploadBtn({slug: g.slug, on_frame: r.on_frame, upload: r.upload}) : ""}<i class="sep"></i>
|
||||
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
|
||||
${r && r.ply ? `<button class="small" data-mesh="${esc(g.slug)}" title="${tr("mesh.button.title")}">${tr(g.mesh ? "mesh.button.has" : "mesh.button")}</button>` : ""}
|
||||
${scenes.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
|
||||
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}<i class="sep"></i>
|
||||
<button class="small icon" data-gedit="${esc(g.slug)}" title="${tr("grp.edit")}" aria-label="${tr("grp.edit")}">✎</button>
|
||||
@@ -1151,6 +1220,7 @@ function renderScenes() {
|
||||
fillVersionLists();
|
||||
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-mesh]").forEach(b => b.onclick = () => openMesh(b.dataset.mesh));
|
||||
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)));
|
||||
@@ -1334,6 +1404,92 @@ async function loadFrameGallery(refresh) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- your own splats ---------- */
|
||||
$("#importBtn").onclick = () => {
|
||||
let file = null;
|
||||
$("#impFile").textContent = tr("imp.none"); $("#impName").value = ""; $("#impScale").value = "1"; $("#impMake").disabled = true;
|
||||
$("#impUp").innerHTML = ["y-down", "y-up", "z-up"].map((u, i) => `<label class="opt${i ? "" : " active"}">
|
||||
<input type="radio" name="impUp" value="${u}" ${i ? "" : "checked"}><div><b>${tr("imp.up." + u)}</b><span>${tr("imp.up." + u + ".text")}</span></div></label>`).join("");
|
||||
$("#impUp").querySelectorAll("input").forEach(i => i.onchange = () =>
|
||||
$("#impUp").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked)));
|
||||
$("#impPick").onclick = async () => {
|
||||
const r = await api("/api/pick", {kind: "splat"});
|
||||
if (!r.path) return;
|
||||
file = r.path;
|
||||
$("#impFile").textContent = file;
|
||||
if (!$("#impName").value) $("#impName").value = file.split("/").pop().replace(/(\.compressed)?\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||
$("#impMake").disabled = false;
|
||||
};
|
||||
$("#impClose").onclick = () => $("#importDlg").close();
|
||||
$("#impMake").onclick = async () => {
|
||||
const r = await api("/api/import", {path: file, name: $("#impName").value.trim(),
|
||||
up: document.querySelector('input[name="impUp"]:checked').value, scale: $("#impScale").value || "1"});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#importDlg").close(); toast(tr("imp.started")); await poll();
|
||||
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
};
|
||||
$("#importDlg").showModal();
|
||||
};
|
||||
|
||||
/* ---------- meshes ---------- */
|
||||
const MESH_FORMATS = ["glb", "stl", "obj", "ply"];
|
||||
// A mesh's files to download (also from other devices at home), Blender and the folder (on this PC)
|
||||
function meshFiles(slug, m) {
|
||||
if (!m || !m.files || !m.files.length) return "";
|
||||
const link = f => `<a class="btnlink" href="/api/mesh/file?slug=${encodeURIComponent(slug)}&name=${encodeURIComponent(f.name)}" download="${esc(f.name)}" title="${tr("mesh.download.title")}">⬇ ${esc(f.name)} <span class="faint">${mb(f.size)}</span></a>`;
|
||||
return `<div class="meshfiles"><div class="row" style="gap:6px;flex-wrap:wrap">${m.files.map(link).join("")}</div>
|
||||
<div class="row" style="gap:6px;margin-top:8px">${m.blender ? `<button class="small pc-only" data-mblender>${tr("mesh.blender")}</button>` : ""}
|
||||
<button class="small pc-only" data-mfolder>${tr("sc.folder")}</button></div></div>`;
|
||||
}
|
||||
function bindMeshFiles(box, slug) {
|
||||
box.querySelectorAll("[data-mblender]").forEach(b => b.onclick = async () => {
|
||||
const r = await api("/api/open", {slug, what: "blender"});
|
||||
toast(r.ok === false ? trErr(r.error) : tr("mesh.blender.starting"));
|
||||
});
|
||||
box.querySelectorAll("[data-mfolder]").forEach(b => b.onclick = () => api("/api/open", {slug, what: "mesh"}));
|
||||
}
|
||||
async function openMesh(slug) {
|
||||
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
|
||||
if (!x) return;
|
||||
$("#meshTitle").textContent = tr("mesh.title", {name: x.name});
|
||||
const draw = async () => {
|
||||
const m = await api("/api/mesh?slug=" + encodeURIComponent(slug));
|
||||
$("#meshHave").innerHTML = m.made ? `<div class="vrow"><div class="vwhat">
|
||||
<b>${tr("mesh.have")}${m.made.stale ? " · " + tr("ver.stale") : ""}</b></div>
|
||||
<div class="vacts"><button class="small" id="meshDel" title="${tr("mesh.delete")}" aria-label="${tr("mesh.delete")}">${tr("sc.delete")}</button></div></div>
|
||||
${meshFiles(slug, m)}` : "";
|
||||
if (m.made) {
|
||||
bindMeshFiles($("#meshHave"), slug);
|
||||
$("#meshDel").onclick = async () => {
|
||||
if (!confirm(tr("mesh.confirm", {name: x.name}))) return;
|
||||
const r = await api("/api/mesh/delete", {slug});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
draw(); loadScenes();
|
||||
};
|
||||
}
|
||||
$("#meshTex").disabled = !m.texture_possible;
|
||||
$("#meshTex").classList.toggle("hidden", !m.texture_possible); // not possible: only the note
|
||||
$("#meshTex").checked = m.texture_possible;
|
||||
$("#meshTexText").textContent = tr(m.texture_possible ? "mesh.texture" : "mesh.texture.none");
|
||||
};
|
||||
const was = S.meshFormats || ["glb"];
|
||||
$("#meshFormats").innerHTML = MESH_FORMATS.map(f => `<label class="opt${was.includes(f) ? " active" : ""}">
|
||||
<input type="checkbox" value="${f}" ${was.includes(f) ? "checked" : ""}><div><b>${tr("mesh.f." + f)}</b><span>${tr("mesh.f." + f + ".text")}</span></div></label>`).join("");
|
||||
$("#meshFormats").querySelectorAll("input").forEach(i => i.onchange = () => i.closest(".opt").classList.toggle("active", i.checked));
|
||||
await draw();
|
||||
$("#meshDlg").showModal();
|
||||
$("#meshClose").onclick = () => $("#meshDlg").close();
|
||||
$("#meshMake").onclick = async () => {
|
||||
const formats = [...$("#meshFormats").querySelectorAll("input:checked")].map(i => i.value);
|
||||
if (!formats.length) { toast(tr("mesh.pick")); return; }
|
||||
S.meshFormats = formats;
|
||||
const r = await api("/api/mesh", {slug, formats: formats.join(","), texture: $("#meshTex").checked});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#meshDlg").close(); toast(tr("mesh.started")); await poll();
|
||||
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- versions ---------- */
|
||||
// Frame sizes as measured on a Steam Frame (Gaussian XR, 10/2026): 1 M is nearly as fast as 0.75 M and clearly sharper
|
||||
const FRAME_SIZES = [[0.75, 36], [1, 35], [1.5, 29], [2, 24]], FRAME_RECOMMENDED = 1; // million splats, fps
|
||||
@@ -1730,10 +1886,22 @@ api("/api/ping").then(r => {
|
||||
document.body.classList.toggle("remote", r.local === false);
|
||||
S.ping = r; S.frameConfigured = r.frame_configured;
|
||||
setupLang(r.language);
|
||||
$("#footVersion").textContent = tr("foot.version", {v: r.version}); // after the language is known
|
||||
loadRecs(); poll();
|
||||
});
|
||||
$("#footCheck").onclick = async e => {
|
||||
e.preventDefault();
|
||||
const r = await api("/api/update/check", {});
|
||||
if (r.update) { try { localStorage.removeItem("updateLater"); } catch {} S.ping.update = r.update; renderUpdate(); window.scrollTo({top: 0, behavior: "smooth"}); }
|
||||
toast(r.update ? tr("update.text.plain", {latest: r.update.latest}) : r.reached ? tr("update.none", {current: r.current}) : tr("update.offline"));
|
||||
};
|
||||
// the check for a newer version runs in the background: ask again a little later, then every few hours
|
||||
const repingForUpdate = () => api("/api/ping").then(r => { if (r && r.app) { S.ping = r; renderUpdate(); } });
|
||||
setTimeout(repingForUpdate, 15000);
|
||||
setInterval(repingForUpdate, 3 * 3600 * 1000);
|
||||
setInterval(poll, 2000);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user