mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 21:01:41 +02:00
Compare commits
10
Commits
v0.5.0
...
experimental
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d88e9a62 | ||
|
|
c6aa71d7ed | ||
|
|
887406dba4 | ||
|
|
5baa4b532f | ||
|
|
8ebc020e7f | ||
|
|
6ba8f71c5b | ||
|
|
7f297781c2 | ||
|
|
9ddfaf28bf | ||
|
|
e43201ab68 | ||
|
|
a227d6f451 |
No files matched your search
@@ -2,6 +2,31 @@
|
||||
|
||||
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
|
||||
@@ -171,6 +196,8 @@ 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
|
||||
|
||||
+10
-1
@@ -22,9 +22,16 @@ 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. (Angeregt in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
- 🧊 **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>
|
||||
@@ -97,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,9 +24,15 @@ 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. (Suggested in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
- 🧊 **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>
|
||||
@@ -131,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 |
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+10
-2
@@ -16,7 +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}”", "mesh.step.mesh": "Meshing", "mesh.step.upright": "Standing it upright",
|
||||
"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",
|
||||
@@ -49,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}).",
|
||||
@@ -81,7 +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}“", "mesh.step.mesh": "Vernetzen", "mesh.step.upright": "Aufrichten",
|
||||
"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",
|
||||
@@ -114,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}).",
|
||||
|
||||
+234
-11
@@ -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.5.0"
|
||||
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", "mesh.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,11 +1307,197 @@ 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
|
||||
@@ -1316,7 +1505,7 @@ def mesh_of(slug: str) -> dict:
|
||||
return {}
|
||||
texture = (d / "dataset" / "images").is_dir() and (d / "dataset" / "sparse").is_dir()
|
||||
m = d / "mesh"
|
||||
info = {"texture_possible": texture, "files": [], "made": None}
|
||||
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())
|
||||
@@ -1421,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:
|
||||
@@ -1428,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
|
||||
@@ -2245,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
|
||||
@@ -2341,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":
|
||||
@@ -2384,6 +2585,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
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", ""))
|
||||
@@ -2430,7 +2645,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
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":
|
||||
@@ -2461,6 +2676,10 @@ 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":
|
||||
@@ -2468,7 +2687,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
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})
|
||||
@@ -2519,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"
|
||||
|
||||
+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)
|
||||
|
||||
@@ -233,6 +233,7 @@ runs = "{RUNS}"
|
||||
extra_recording_dirs = []
|
||||
[server]
|
||||
language = "en"
|
||||
check_updates = false
|
||||
[frame]
|
||||
host = ""
|
||||
''')
|
||||
@@ -349,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,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
|
||||
+33
-1
@@ -57,7 +57,8 @@ def mesh_calls():
|
||||
|
||||
def test_a_mesh_for_blender_and_3d_printing(api):
|
||||
run = scene()
|
||||
assert server.mesh_of("Living") == {"texture_possible": False, "files": [], "made": None}
|
||||
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()
|
||||
@@ -113,3 +114,34 @@ 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
|
||||
+32
@@ -268,6 +268,20 @@ 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.",
|
||||
@@ -281,6 +295,8 @@ const I18N = {
|
||||
"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.",
|
||||
@@ -565,6 +581,20 @@ 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.",
|
||||
@@ -578,6 +608,8 @@ const I18N = {
|
||||
"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.",
|
||||
|
||||
+116
-9
@@ -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,19 @@ 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>
|
||||
@@ -523,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);
|
||||
@@ -576,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("");
|
||||
@@ -583,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>`;
|
||||
@@ -981,8 +1028,8 @@ 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" || j.kind === "mesh";
|
||||
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {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})
|
||||
@@ -1032,6 +1079,13 @@ function renderJob() {
|
||||
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;
|
||||
}
|
||||
@@ -1039,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>`;
|
||||
@@ -1350,8 +1404,50 @@ 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;
|
||||
@@ -1359,12 +1455,11 @@ async function openMesh(slug) {
|
||||
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>
|
||||
<span class="muted">${m.files.map(f => esc(f.name) + " (" + mb(f.size) + ")").join(" · ")}</span></div>
|
||||
<div class="vacts"><button class="small pc-only" id="meshOpen">${tr("sc.folder")}</button>
|
||||
<button class="small" id="meshDel" title="${tr("mesh.delete")}" aria-label="${tr("mesh.delete")}">${tr("sc.delete")}</button></div></div>` : "";
|
||||
<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) {
|
||||
$("#meshOpen").onclick = () => api("/api/open", {slug, what: "mesh"});
|
||||
bindMeshFiles($("#meshHave"), slug);
|
||||
$("#meshDel").onclick = async () => {
|
||||
if (!confirm(tr("mesh.confirm", {name: x.name}))) return;
|
||||
const r = await api("/api/mesh/delete", {slug});
|
||||
@@ -1791,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