Compare commits

..
32 Commits
Author SHA1 Message Date
thunderheronightandClaude Opus 5.5 94b4cd431a 0.6.1: pick the upright picture on import; photo scenes from Spirula Studio are Z up
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:34:40 +02:00
thunderheronightandClaude Opus 5.5 bfe3939754 Import: pictures per orientation and a guess which way is up
The dialog shows a small picture per choice of up, as the scene would stand
on the Frame, with the likely one preselected. Labels by symptom. Spirula
Studio's photo-only scenes are Z up, not Y down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:21:28 +02:00
thunderheronightandClaude Opus 5.5 68d88e9a62 0.6.0 – import your own splats; the page updates again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:31:29 +02:00
thunderheronightandClaude Opus 5.5 c6aa71d7ed Page: the footer above the script – below it, setting up its link failed and the status updates never started (since 0.5.1)
A test checks that everything the script sets up on load is already in the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:26:03 +02:00
thunderheronightandClaude Opus 5.5 887406dba4 Import: general wording in the script and tests, too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:20:27 +02:00
thunderheronightandClaude Opus 5.5 5baa4b532f Import: plainer wording, not tied to one example
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:20:19 +02:00
thunderheronightandClaude Opus 5.5 8ebc020e7f Import dialog: the name field in the usual style
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:17:18 +02:00
thunderheronightandClaude Opus 5.5 6ba8f71c5b 0.6 (in progress): import your own splats as scenes (scripts/import.sh, dialog, tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:16:32 +02:00
thunderheronightandClaude Opus 5.5 7f297781c2 0.5.1 – update note and check, version and links at the bottom, copy progress, mesh download and Open in Blender
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:02:25 +02:00
thunderheronightandClaude Opus 5.5 9ddfaf28bf Open in Blender: import once Blender's window is ready (at start-up the import was refused: context is incorrect)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:50:08 +02:00
thunderheronightandClaude Opus 5.5 e43201ab68 0.5.1 (in progress): after a mesh – download its files, open it in Blender, open the folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:44:48 +02:00
thunderheronightandClaude Opus 5.5 a227d6f451 0.5.1 (in progress): note when a newer release is out; how to update in the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:33:59 +02:00
thunderheronightandClaude Opus 5.5 2a9260dd42 CLAUDE.md: new scripts need --chmod=+x on the NTFS checkout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:21:53 +02:00
thunderheronightandClaude Opus 5.5 ec1f119e37 mesh.sh executable in git (the NTFS checkout ignores file modes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:19:26 +02:00
thunderheronightandClaude Opus 5.5 e84f0836b3 0.5.0 – mesh export; README screenshots (example data, rendered in Blender)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:12:35 +02:00
thunderheronightandClaude Opus 5.5 6f46a2b94a Mesh: OBJ with Y up as Blender (and OBJ custom) expects – checked every format by importing it in Blender 5.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:06:34 +02:00
thunderheronightandClaude Opus 5.5 d01571fadd 0.5: mesh dialog polish, changelog and READMEs; "1 version" in the job card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:52:02 +02:00
thunderheronightandClaude Opus 5.5 24513b7483 WIP 0.5: mesh export (scripts/mesh.sh, scripts/upright.py), server job, dialog, tests
Spirula Studio meshes the splats inside the room (clean.py --outside); the result stands upright with the
floor at height 0 – Z up for OBJ/PLY/STL, Y up for GLB. Texture when the training images are still there.
Tested on real scenes (kitchen without images: colours per corner; textured on one with images) and with a
fake spirula mesh in the tests (579 passing). Not done yet: look at the dialog in the browser, changelog,
README, release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:13:17 +02:00
thunderheronightandClaude Opus 5.5 a4ef32f28a README: screenshots of the new versions dialog and the unfolded versions (example data, placeholder art)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:57:22 +02:00
thunderheronightandClaude Opus 5.5 cc6c96b242 0.4.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:43:38 +02:00
thunderheronightandClaude Opus 5.5 08f049c7be Unfold groups and versions at once; Frame listing renewed in the background; version upload off while the Frame is offline
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:42:36 +02:00
thunderheronightandClaude Opus 5.5 376d197f47 Docs: thanks to @andzejsp for spotting the fixed pipeline (#2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:26:02 +02:00
thunderheronightandClaude Opus 5.5 e0f8e92c5d Docs: the pipeline from October 2026 (faad5aac4) fixes the PyAV 19 error; the pin is only for older ones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:23:38 +02:00
thunderheronightandClaude Opus 5.5 20b1262fa9 0.4.1: clearer setup – config typos named, full path for splat-transform, PyAV 19 spotted
From issues #1 and #2:
- Unknown settings in config.toml (e.g. splat-transform instead of splat_transform) show up in the
  setup hints and in install.sh, with the right spelling.
- install.sh writes the full path of splat-transform into a new config or prints the line for an
  existing one: started from the desktop, the app does not see the shell's PATH.
- The setup guide's npm --prefix instructions match the path they tell you to configure.
- A pipeline not pinned below PyAV 19 is spotted before a run; a run failing on it says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:06:01 +02:00
thunderheronightandClaude Opus 5.5 080e9d0b97 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:20:17 +02:00
thunderheronightandClaude Opus 5.5 536340e468 0.4: tidier action bars – do, look, change; unfolding lists on their own line; one versions button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:14:19 +02:00
thunderheronightandClaude Opus 5.5 6a62720554 0.4: versions by purpose, unfolding under their scene, openable in Spirula
- Dialog: for the Frame (one size, with measured fps) or for the PC (all splats); clean-up; optionally
  the same without; a preview of what will be made and what it replaces; one version per click.
- Names carry the clean-up strengths; As template takes a version's settings into the form.
- A cleaned PC version keeps its ply; /api/open opens a version in Spirula Studio.
- Versions unfold under their scene or tour, with Spirula, Frame, template and delete.
- The PC sizes 3/5/8 M are gone; measured frame rates updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:37:36 +02:00
thunderheronightandClaude Opus 5.5 7e1b7fecf8 0.4: changelog and READMEs for cleaning up versions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:21:14 +02:00
thunderheronightandClaude Opus 5.5 addb45e2c2 0.4: free space from the cameras' lines of sight – experimental, not wired in
A splat clearly in front of what an image saw (its SfM points; optionally the solid splats where SfM saw
nothing) sat in the camera's line of sight: free space. On a real living room only 0.4 % were found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:13:17 +02:00
thunderheronightandClaude Opus 5.5 b44e4de901 0.4: clean-up sliders in the versions dialog, tests for clean.py and cleaned versions
- clean.py: the ceiling is the highest strong line where walls meet it (a cupboard top lies lower).
- The API takes the clean-up as a flat string (ceiling=0.5,haze=0.3), like every other field.
- tests/test_clean.py: a synthetic living room with thin ceiling, veil, cupboard top, bird cage,
  haze, a hole in the floor and things beyond the walls – each told apart by its colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:00:23 +02:00
thunderheronightandClaude Opus 5.5 fca8e81422 WIP 0.4: clean up a scene (ceiling, floor, haze, outside) as part of its versions
scripts/clean.py and an optional clean-up field for versions.sh and /api/versions. No interface and no
tests yet; tried on a real living room (the ceiling as liked in the Studio; haze now by the smallest axis,
so the bars of a bird cage are no haze any more – not checked yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:00:23 +02:00
thunderheronightandClaude Opus 5.5 d34c68a553 CLAUDE.md: release checklist (notes keep the series' highlights), tests, scripts, file systems
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:58:03 +02:00
38 changed files with 3071 additions and 135 deletions

No files matched your search

+96
View File
@@ -2,6 +2,95 @@
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.1] – 2026-10-17
### Changed
- **Which way is up – pick the picture**: the import dialog shows a small picture for each choice, the scene as it would stand on the Frame seen from its start, so you simply pick the one standing upright. Our guess is preselected and marked (the vertical is the smaller of the scene's Y and Z extents).
- The choices say what you see: *Upright in Blender (Z up)*, *Like the Workshop's and the Frame's scenes (Y down)*, *Upside down (Y up)*.
- The pictures take a second or two (a few more for `.sog`, `.spz` and the like, which are read through splat-transform) and are kept, so picking the same file again is instant.
### Fixed
- **Scenes made from photos in Spirula Studio lay on their side**: 0.6.0 said Spirula Studio files have Y down and preselected that. That is only true for the Frame's own scenes, which are aligned to its tracking; a scene Spirula Studio makes from photos alone (`spirula sfm auto`) has Z up – checked with a scene made from 70 photos. The dialog now says so, and the guess picks Z up for such files.
## [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
- **Unfolding recordings or versions sometimes did nothing**: every click loaded the whole scene list again, which took about 3 seconds while the Frame was switched off – clicking again in that time folded it back. Now it opens and closes at once, and an expired listing of the Frame is renewed in the background instead of holding up the scene list.
- **"To the Frame" on a version** could be clicked while the Frame was not reachable; now it is greyed out like the one on the scene.
## [0.4.1] – 2026-10-15
Setup made clearer – thanks to the first two issues from outside (#1, #2).
### Fixed
- **A typo in the config is pointed out**: a setting Splat Workshop does not know – such as `splat-transform =` instead of `splat_transform =` – used to be ignored silently, so splat-transform stayed "not found" without a reason. Now the setup hints name it, with the right spelling. `install.sh` does too.
- **splat-transform on the shell's PATH only**: `install.sh` checked the PATH of the shell it runs in, but Splat Workshop, started from the desktop or the tray, does not read `.bashrc`/`.zshrc` – the installer said ✓, the app said "not found". Now `install.sh` writes the full path into a new config, or prints the line to add to an existing one; the hint in the app explains why and how.
- **The npm instructions** in the setup guide installed into a folder whose path did not match the one given for the config. Now: `npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform` (no root needed) and `~/arcturus/node/bin/splat-transform`.
- **The Arcturus pipeline and PyAV 19** ("unexpected keyword argument 'metadata_errors'"): Splat Workshop now spots a pipeline that is not pinned to PyAV below 19 before you start, and names the cause and the fix when a conversion fails because of it – instead of only a traceback. It does not change the pipeline itself.
## [0.4.0] – 2026-10-15
### Added
- **Clean up a version** (experimental): in the versions dialog, four sliders from *off* to 100 % – the scene itself stays as it is, only the new version is cleaned up, so it can be compared side by side with one without (e.g. *all splats* for the PC).
- **Ceiling**: 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, like a cupboard top or a bird cage, stay) and what lies above the ceiling, and puts in a closed ceiling in the colour of what there is of it. The ceiling's height is found where walls and ceiling meet. In a real living room: the veil of about 210,000 splats gone, the ceiling closed with 72,000.
- **Floor**: fills holes in the floor in the colour of the floor around them, and takes out what lies under it.
- **Haze**: takes out big, nearly transparent blobs floating in the room – measured by their *smallest* axis, so long thin things (the bars of a bird cage, cables) are no haze and stay.
- **Edges**: takes out what lies well outside the room – beyond the walls, above the ceiling, under the floor.
- Also on the command line: `scripts/clean.py`.
- **The versions dialog, reworked** – by purpose instead of by size: *for the Frame* (one size, each with its frame rate as measured: 0.75 M ≈ 36 fps, 1 M ≈ 35 fps and clearly sharper, 1.5 M ≈ 29 fps, 2 M ≈ 24 fps) or *for the PC* (all splats). One version per click, optionally the same *without cleaning up* next to it to compare. Before making, the dialog shows what it will make – and when that replaces an existing version.
- Versions are named by what was cleaned up and how strongly (*all splats · ceiling 50 % · haze 30 %*), so two tries never look alike.
- **As template** takes a version's settings into the form: change one slider and make the next.
- A cleaned version for the PC keeps its own file for **Spirula Studio** and opens there with one click.
- **Versions unfold under their scene or tour** (*▸ 3 versions*), with Spirula, copying to the Frame (with a warning above 2.5 M splats), template and delete.
- **Tidier action bars** on scenes and tours: what you do (put together, copy to the Frame) – what you look at (Spirula, floor plan, folder) – what you change (rename, scale, clean up, delete, as icons with tooltips); the unfolding lists (*▸ 3 recordings*, *▸ 1 version* or *+ Versions …*) on a line of their own. One versions button instead of two.
- The PC sizes 3, 5 and 8 M are gone – on the PC all splats is what counts; any other size goes through *own size*.
- `scripts/freespace.py` (experimental, command line only): takes out splats in space the cameras looked through, from the recordings' sparse models. On a real living room it found only 0.4 % – white walls and ceilings have too few points to tell – but looked a little better; kept for trying.
## [0.3.1] – 2026-10-15
### Fixed
@@ -119,6 +208,13 @@ First release.
- **Floor plans** (experimental) in two styles with a measuring tool.
- Configurable video player; English and German interface.
[0.6.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.6.0...v0.6.1
[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
[0.3.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.3...v0.3.0
[0.2.3]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.2...v0.2.3
+34
View File
@@ -0,0 +1,34 @@
# Notes for working on Splat Workshop
## Releasing – every release, also a patch
1. Only with the maintainer's explicit OK for each step: push, tag, GitHub release, editing release notes.
2. `VERSION` in `splat_workshop/server.py`, a `## [x.y.z] – date` section and the compare link in `CHANGELOG.md`.
3. Tests green locally and in GitHub Actions before tagging.
4. **Release notes always have this shape** (a patch release too – people who only read the latest
release must still see what the series brought):
- one bold line: what the series is about, plus what this release adds;
- the screenshot (`docs/groups.png`, example data only);
- `## Highlights of x.y` – the highlights of the whole minor series, copied from the `x.y.0` release;
- a link to the `x.y.0` release for all details;
- `## New in x.y.z` – what this release adds or fixes (for `x.y.0` itself: `## New in x.y`);
- `**Full changelog:**` compare link to the previous tag.
Check the finished notes against this list before publishing.
## Tests
`uv run --no-project --with pytest --with numpy --with scipy --with pillow pytest`
Expectations come from how the synthetic data is built (`tests/synth.py`), not from the code. Tests must
never touch real data, the Frame or this PC's power state (`systemctl`, `ssh`, `notify-send` are faked).
## Running scripts
`splat.sh`, `to-frame.sh`, `combine.sh`, `versions.sh` run as one `{ … exit; }` block; still, never edit
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`. 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.
+26 -1
View File
@@ -22,6 +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 wählst das Bild, in dem sie aufrecht steht – das wahrscheinliche ist vorausgewählt – und bei Bedarf die Größe.
<img src="docs/import.png" width="420" alt="Der Import-Dialog: Datei wählen, benennen, das Bild wählen, in dem die Szene aufrecht steht, 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.
@@ -59,7 +82,7 @@ Alle Änderungen: [CHANGELOG.md](CHANGELOG.md) (englisch)
- **Gruppen und Rundgänge**: Szenen gruppieren (z. B. alle Räume der Wohnung) und zu einer Szene zusammensetzen – eine volle Fassung für Gaussian XR am PC und eine kleinere für die Frame. Aufnahmen aus einem Durchgang teilen das Tracking-Koordinatensystem der Frame; die Werkstatt richtet die Räume aneinander aus (Korrekturen A–D zur Wahl), legt die Grenzen zwischen den Aufnahmen in die Türen und zeigt, wie gut die Räume aneinanderpassen. Vor dem Start schätzt sie den Speicherbedarf.
<br><img src="docs/combine.png" width="360" alt="Dialog „Zusammensetzen“: Korrekturen A bis D, Grenzen zwischen den Aufnahmen, Größe der Frame-Fassung, volle Fassung, Speicherschätzung">
<br><sub>Beispieldaten (englische Oberfläche) – Raumnamen und Vorschaubilder sind Platzhalter.</sub>
- **Fassungen** einer Szene oder eines Rundgangs nebeneinander: weniger Splats für die Frame (0,75–2 Mio.), alle oder eine Obergrenze für den PC oder eine eigene Größe – jede mit eigenem Platz in der Galerie der Frame, zum Vergleichen, was flüssig läuft.
- **Fassungen** einer Szene oder eines Rundgangs nebeneinander: weniger Splats für die Frame (0,75–2 Mio.), alle oder eine Obergrenze für den PC oder eine eigene Größe – jede mit eigenem Platz in der Galerie der Frame, zum Vergleichen, was flüssig läuft. Eine Fassung lässt sich auch **bereinigen** *(0.4, experimentell)*: Decke, Boden, Dunst und Rand, jeweils mit eigener Stärke.
- **Alle holen**: Aufnahmen von der Frame alle oder als Auswahl nacheinander holen – mit einer Benachrichtigung, sobald die Frame aus darf.
- **Aufnahmen löschen**, einzeln: auf der Frame (mit deutlicher Warnung, wenn sie noch nicht auf dem PC liegt) oder auf dem PC (in den Papierkorb).
- Reiter **Auf der Frame**: alles, was in der Galerie von Gaussian XR auf der Frame liegt, mit Vorschaubildern – und von dort aufräumen.
@@ -81,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).
+34 -1
View File
@@ -24,6 +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. Pick the picture in which it stands upright – the likely one is preselected – and, if needed, the size.
<img src="docs/import.png" width="420" alt="The import dialog: choose a file, name it, pick the picture in which the scene stands upright, 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.
@@ -57,7 +79,7 @@ All changes: [CHANGELOG.md](CHANGELOG.md)
- **Groups and tours**: group scenes (e.g. all rooms of your home) and put them together into one scene – a full version for Gaussian XR on the PC and a smaller one for the Frame. Recordings from one session share the Frame's tracking frame; Splat Workshop fits the rooms to each other (corrections A–D to choose), lets the borders between recordings run through doors and shows how well the rooms meet. Before starting it estimates the memory needed.
<br><img src="docs/combine.png" width="360" alt="Dialog “Put together”: corrections A to D, borders between the recordings, size of the Frame version, full version, memory estimate">
<br><sub>Example data – room names and preview images are placeholders.</sub>
- **Versions** of a scene or tour side by side: fewer splats for the Frame (0.75–2 M), all or a limit for the PC, or your own size – each with its own place in the Frame's gallery, so you can compare what runs smoothly.
- **Versions** of a scene or tour side by side: fewer splats for the Frame (0.75–2 M), all or a limit for the PC, or your own size – each with its own place in the Frame's gallery, so you can compare what runs smoothly. A version can also be **cleaned up** *(0.4, experimental)*: ceiling, floor, haze and edges, each with its own strength.
- **Fetch all** recordings from the Frame, or a selection, one after the other – with a notification when the Frame can be switched off.
- **Delete a recording**, one at a time: on the Frame (with a clear warning if it is not on the PC yet) or on the PC (to the trash).
- **On the Frame** tab: everything in Gaussian XR's gallery on the Frame with preview images, and taking things off it.
@@ -115,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.
+3
View File
@@ -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
+10 -2
View File
@@ -88,7 +88,15 @@ Wandelt das Ergebnis in das kompakte SOG-Format, das Gaussian XR lädt:
npm install -g @playcanvas/splat-transform
```
Ohne globale Installation: `npm install --prefix ~/arcturus/node @playcanvas/splat-transform` und in der Config `splat_transform = "~/arcturus/node/bin/splat-transform"` eintragen.
Scheitert `npm install -g` an fehlenden Rechten, ins eigene Home installieren – ohne root:
```bash
npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform
```
und in der Config unter `[paths]` `splat_transform = "~/arcturus/node/bin/splat-transform"` eintragen (mit Unterstrich – `splat-transform =` ist keine Einstellung und wird ignoriert; die Splat-Werkstatt weist darauf hin).
**Immer den vollen Pfad in die Config schreiben**, wenn splat-transform nur im PATH deiner Shell steht (`.bashrc`, `.zshrc`): Vom Desktop oder Tray gestartet liest die Splat-Werkstatt diese Dateien nicht und findet es sonst nicht. `install.sh` trägt die Zeile ein oder gibt sie aus.
## 5. Der Splat-Werkstatt sagen, wo alles liegt
@@ -106,7 +114,7 @@ Splat-Werkstatt neu starten (Tray → Beenden, dann wieder starten). Fehlt noch
| Meldung / Problem | Ursache / Lösung |
|---|---|
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 hat diese Option entfernt. Ältere Version festlegen: im `# /// script`-Kopf oben in `av1_to_3dgs.py` und `av1_colmap.py` `"av>=18.1"` in `"av>=18.1,<19"` ändern. (Mit der Pipeline vom Herbst 2026 nötig; neuere Versionen haben es evtl. behoben.) |
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 hat diese Option entfernt. **Behoben in der Pipeline ab Oktober 2026 (`av1-3dgs-pipeline-faad5aac4.zip`)** – sie kann PyAV 18.1 und 19; einfach die aktuelle herunterladen (danke an [@andzejsp](https://github.com/andzejsp) für den Hinweis, #2). Nur bei einer älteren Pipeline die ältere Version festlegen: im `# /// script`-Kopf oben in `av1_to_3dgs.py` und `av1_colmap.py` `"av>=18.1"` in `"av>=18.1,<19"` ändern. (Mit der Pipeline vom Herbst 2026 nötig; neuere Versionen haben es evtl. behoben.) Die Splat-Werkstatt (ab 0.4.1) weist schon vor dem Start in ihren Einrichtungshinweisen darauf hin und nennt den Grund, falls eine Umwandlung daran scheitert. |
| `Could not select unique evenly spaced capture instants` | Aufnahme zu kurz. Die Pipeline braucht mindestens 100 Aufnahmezeitpunkte – mindestens 10 Sekunden aufnehmen, besser 30+. |
| Spirula findet keine GPU / nutzt llvmpipe | Vulkan-Treiber fehlt, siehe Schritt 1. |
| Die Szene taucht in Gaussian XR nicht auf | Sie muss als echter Ordner (kein Symlink) mit den Galeriebildern in `Scenes/` von Gaussian XR liegen – das macht der Knopf *Auf die Frame*. Ein Steam-Update oder „Dateien überprüfen“ von Gaussian XR kann den Ordner zurücksetzen; dann die Szene einfach neu übertragen. |
+10 -2
View File
@@ -88,7 +88,15 @@ Converts the result into the compact SOG format that Gaussian XR loads:
npm install -g @playcanvas/splat-transform
```
If you can't or don't want to install globally: `npm install --prefix ~/arcturus/node @playcanvas/splat-transform` and set `splat_transform = "~/arcturus/node/bin/splat-transform"` in the config.
If `npm install -g` fails with a permission error, install it into your home instead – no root needed:
```bash
npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform
```
and set `splat_transform = "~/arcturus/node/bin/splat-transform"` under `[paths]` in the config (with an underscore – `splat-transform =` is not a setting and is ignored; Splat Workshop points that out).
**Always set the full path in the config** if splat-transform is only on the PATH of your shell (`.bashrc`, `.zshrc`): started from the desktop or the tray, Splat Workshop does not read those files and would not find it. `install.sh` writes or prints the line for you.
## 5. Tell Splat Workshop where everything is
@@ -106,7 +114,7 @@ Restart Splat Workshop (tray → Quit, then start it again). If something is sti
| Symptom | Cause / fix |
|---|---|
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 removed that option. Pin the older version: in the `# /// script` header at the top of `av1_to_3dgs.py` and `av1_colmap.py`, change `"av>=18.1"` to `"av>=18.1,<19"`. (Was needed with the pipeline from autumn 2026; newer versions may have fixed it.) |
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 removed that option. **Fixed in the pipeline from October 2026 on (`av1-3dgs-pipeline-faad5aac4.zip`)** – it supports PyAV 18.1 and 19; just download the current one (thanks to [@andzejsp](https://github.com/andzejsp) for spotting it, #2). Only with an older pipeline: pin the older version: in the `# /// script` header at the top of `av1_to_3dgs.py` and `av1_colmap.py`, change `"av>=18.1"` to `"av>=18.1,<19"`. (Was needed with the pipeline from autumn 2026; newer versions may have fixed it.) Splat Workshop (0.4.1 and newer) points this out in its setup hints before you start, and names it if a conversion fails because of it. |
| `Could not select unique evenly spaced capture instants` | Recording too short. The pipeline needs at least 100 capture instants – record at least 10 seconds, better 30+. |
| Spirula finds no GPU / uses llvmpipe | Vulkan driver missing, see step 1. |
| The scene does not appear in Gaussian XR | It must be a real folder in Gaussian XR's `Scenes/` (no symlink) with the gallery images – Splat Workshop's *To the Frame* button does this for you. A Steam update or “verify files” of Gaussian XR can reset that folder; just copy the scene again. |
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+24 -3
View File
@@ -26,19 +26,40 @@ for tool in ffmpeg ffprobe uv ssh curl; do
command -v "$tool" >/dev/null && echo " ✓ $tool" || { echo " ✗ $tool"; missing=1; }
done
command -v splat-transform >/dev/null && echo " ✓ splat-transform" ||
echo " ! splat-transform not on PATH (npm install -g @playcanvas/splat-transform, or set it in the config)"
echo " ! splat-transform not on PATH (npm install -g --prefix ~/.local @playcanvas/splat-transform needs no root; or set its path in the config)"
python3 -c 'import PySide6' 2>/dev/null && echo " ✓ PySide6 (tray icon)" ||
echo " · PySide6 not installed – uv will fetch it on first start for the tray icon"
[ $missing = 0 ] || echo "Install the missing tools first (see README)."
mkdir -p "$CONF" "$APPS" "$ICONS"
created=0
if [ ! -f "$CONF/config.toml" ]; then
cp "$ROOT/config.example.toml" "$CONF/config.toml"
echo "Created $CONF/config.toml – set the paths to the Arcturus pipeline and Spirula, and your Frame's address."
created=1
echo "Created $CONF/config.toml from config.example.toml – set the paths to the Arcturus pipeline and Spirula, and your Frame's address."
else
echo "Keeping existing $CONF/config.toml"
echo "Keeping existing $CONF/config.toml (all settings with explanations: $ROOT/config.example.toml)"
fi
# Started from the desktop, Splat Workshop does not see a PATH set in .bashrc/.zshrc: give it the full path
st=$(command -v splat-transform || true)
conf_st=$(python3 -c 'import sys, tomllib
print(tomllib.load(open(sys.argv[1], "rb")).get("paths", {}).get("splat_transform", ""))' "$CONF/config.toml" 2>/dev/null || true)
if [ -n "$st" ] && [ -z "$conf_st" ]; then
if [ $created = 1 ]; then
sed -i "s|^splat_transform = \"\"|splat_transform = \"$st\"|" "$CONF/config.toml"
echo " ✓ splat_transform = \"$st\" written to the config"
else
echo " ! Add this line under [paths] in $CONF/config.toml – started from the desktop,"
echo " Splat Workshop does not see the PATH of your shell:"
echo " splat_transform = \"$st\""
fi
fi
# Settings Splat Workshop does not know (typos such as "splat-transform" instead of "splat_transform")
(cd "$ROOT" && python3 -c 'from splat_workshop import config
for key, hint in config.load().unknown:
print(f" ! Unknown setting \"{key}\" in the config" + (f" – did you mean \"{hint}\"?" if hint else " – it is ignored"))') || true
cp "$ROOT/web/icon.svg" "$ICONS/splat-workshop.svg"
entry() {
cat <<EOF
+209
View File
@@ -0,0 +1,209 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "scipy", "pillow"]
# ///
"""Clean up a finished scene or room: the things a reconstruction of a real room tends to get wrong.
Usage: uv run clean.py <in.ply> <out.ply> [--ceiling S] [--floor S] [--haze S] [--outside S]
Every strength S runs from 0 (off) to 1 (strongest). The room is found as for the floor plan (walls 0.9 to
1.9 m above the floor, turned to them; Y points down).
--ceiling A white ceiling has no texture: the reconstruction finds nothing to hold on to, leaves the
ceiling thin and hangs a dark veil under it. Takes out the veil (dark or thin splats from
10 cm to 60 cm under the ceiling – how deep grows with S; dark but solid things like a cupboard
top or a bird cage stay if they are within 50 cm of a wall) and everything above the ceiling
(found where walls and ceiling meet), and puts in a closed ceiling in the colour of what there
is of it nearby.
--floor Fills holes in the floor (where the floor itself has too little: more with S) in the colour of
the floor nearby, and takes out what lies under it.
--haze Takes out big, nearly transparent blobs floating in the room. Measured by their *smallest*
axis: long thin splats (wires, bars of a cage, cables) are no haze. More with S.
--outside Takes out what lies well outside the room (beyond the walls, above the ceiling, under the
floor): from 1 m beyond it (S near 0) to 30 cm (S = 1).
Prints what it did; the input is never changed.
"""
import argparse
import sys
from pathlib import Path
import numpy as np
from scipy.spatial import cKDTree
sys.path.insert(0, str(Path(__file__).resolve().parent))
import floorplan as F # noqa: E402 (room box, wall angle)
C0 = 0.28209479177387814
STEP = 0.02 # grid of a filled ceiling or floor (m)
def read_ply(path):
with open(path, "rb") as f:
header = b""
while not header.endswith(b"end_header\n"):
line = f.readline()
if not line:
sys.exit(f"Not a PLY file: {path}")
header += line
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
v = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
return header, v
def write_ply(path, header, v):
lines = [b"element vertex %d\n" % len(v) if l.startswith(b"element vertex") else l
for l in header.splitlines(keepends=True)]
tmp = Path(str(path) + ".tmp")
with open(tmp, "wb") as f:
f.write(b"".join(lines))
v.tofile(f)
tmp.replace(path)
class Room:
"""Where floor, ceiling and walls are, from the solid splats."""
def __init__(self, xyz, alpha):
solid = xyz[alpha > 0.5]
if len(solid) < 1000:
sys.exit("Too few solid splats to find the room")
self.floor, _ = F.heights(solid[:, 1])
up = self.floor - solid[:, 1]
walls = solid[(up > 0.9) & (up < 1.9)][:, [0, 2]]
sub = walls[np.random.default_rng(0).choice(len(walls), min(len(walls), 150_000), replace=False)]
self.angle = F.wall_angle(sub, 0.03)
box = F.room_box(F.rotate(walls, self.angle), 0.05)
if box is None:
sys.exit("No walls found")
self.lo, self.hi = box
self.up = self.floor - xyz[:, 1] # height above the floor of every splat
self.p = F.rotate(xyz[:, [0, 2]], self.angle) # floor plan position, walls axis-aligned
def within(self, margin):
"""Splats inside the walls, `margin` metres away from them (negative: beyond them)."""
return np.all((self.p > self.lo + margin) & (self.p < self.hi - margin), axis=1)
def grid(self):
g0, g1 = np.meshgrid(np.arange(self.lo[0], self.hi[0], STEP), np.arange(self.lo[1], self.hi[1], STEP),
indexing="ij")
return np.stack([g0.ravel(), g1.ravel()], 1)
def layer(room, v, at, cells, ref, rgb, alpha):
"""Flat splats (3 cm wide, 2 mm thick) at height `at` above the floor in the floor plan cells, in the
colour of the reference splats `ref` nearby (half of it the overall colour: calm, no blotches)."""
overall = np.average(rgb[ref], weights=alpha[ref], axis=0) if ref.any() else np.array([0.8, 0.8, 0.8])
col = np.tile(overall, (len(cells), 1))
if ref.sum() >= 10:
d, j = cKDTree(room.p[ref]).query(cells, k=32, distance_upper_bound=0.5, workers=-1)
ok = np.isfinite(d)
j = np.where(ok, j, 0)
w = np.where(ok, alpha[ref][j] / (0.05 + np.where(ok, d, 1)), 0)
near = (w[:, :, None] * rgb[ref][j]).sum(1) / np.maximum(w.sum(1, keepdims=True), 1e-9)
col = np.where(w.sum(1, keepdims=True) > 0, 0.5 * near + 0.5 * overall, overall)
new = np.zeros(len(cells), dtype=v.dtype)
xz = F.rotate(cells, -room.angle)
new["x"], new["z"], new["y"] = xz[:, 0], xz[:, 1], room.floor - at
for k in range(3):
new[f"f_dc_{k}"] = (np.clip(col[:, k], 0, 1) - 0.5) / C0
new["opacity"] = np.log(0.98 / 0.02)
new["scale_0"] = new["scale_2"] = np.log(0.015)
new["scale_1"] = np.log(0.002) # flat: y is up/down
new["rot_0"] = 1.0
return new
def ceiling(room, v, rgb, lum, alpha, s):
"""Height of the ceiling, what to take out (veil, above it), the new ceiling."""
inside = room.within(0)
# where walls and ceiling meet, solid splats pile up: the strongest line near the walls above 2 m
# (the ceiling itself, white and thin, is no good to find it)
corner = inside & ~room.within(0.3) & (alpha > 0.5) & (room.up > 2.0) & (room.up < 3.5)
if corner.sum() < 100:
print("Ceiling: not found, left as it is")
return np.zeros(len(v), bool), v[:0]
h, edges = np.histogram(room.up[corner], bins=np.arange(2.0, 3.5, 0.02))
h = np.convolve(h, [1, 2, 1], "same")
# the highest strong line: a cupboard or shelf top by the wall can be strong, too, but lies lower
H = float(edges[np.flatnonzero(h >= 0.5 * h.max())[-1]] + 0.01)
bright = inside & (lum > 0.3) & (room.up > 2.0)
depth = 0.1 + 0.5 * s
under = (room.up > H - depth) & (room.up < H - 0.03)
veil = under & ((room.within(0.15) & (alpha < 0.3)) | (room.within(0.5) & (lum < 0.3)))
above = inside & (room.up > H + 0.05)
new = layer(room, v, H, room.grid(), bright & (np.abs(room.up - H) < 0.08), rgb, alpha)
print(f"Ceiling {H:.2f} m above the floor: veil ({depth:.2f} m deep) {veil.sum():,} splats and "
f"{above.sum():,} above it out, closed with {len(new):,}")
return veil | above, new
def floor(room, v, rgb, alpha, s):
"""Holes in the floor filled, what lies under it out."""
inside = room.within(0)
below = inside & (room.up < -0.05)
near = inside & (np.abs(room.up) < 0.04)
cells = room.grid()
n = (len(np.arange(room.lo[0], room.hi[0], STEP)), len(np.arange(room.lo[1], room.hi[1], STEP)))
idx = np.clip(np.floor((room.p[near] - room.lo) / STEP).astype(np.int64), 0, np.array(n) - 1)
n0 = n[1]
cover = np.bincount(idx[:, 0] * n0 + idx[:, 1], alpha[near], len(cells))
# a cell and its neighbours: a floor a centimetre off still counts
c2 = cover.reshape(-1, n0)
pad = np.pad(c2, 1)
around = sum(pad[1 + dx:1 + dx + c2.shape[0], 1 + dy:1 + dy + c2.shape[1]]
for dx in (-1, 0, 1) for dy in (-1, 0, 1)).ravel()
hole = around < 1.5 * s
new = layer(room, v, 0.0, cells[hole], near & (alpha > 0.3), rgb, alpha)
print(f"Floor: {below.sum():,} splats under it out, {hole.sum():,} holes ({100 * hole.mean():.0f} %) filled")
return below, new
def haze(v, alpha, s):
smallest = np.exp(np.stack([v["scale_0"], v["scale_1"], v["scale_2"]], 1).astype(np.float64)).min(1)
size, opacity = 0.04 - 0.025 * s, 0.05 + 0.15 * s
out = (smallest > size) & (alpha < opacity)
print(f"Haze: {out.sum():,} splats out (all axes over {size * 100:.1f} cm, under {opacity * 100:.0f} % opaque)")
return out
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("src")
ap.add_argument("dst")
for k in ("ceiling", "floor", "haze", "outside"):
ap.add_argument(f"--{k}", type=float, default=0.0)
args = ap.parse_args()
for k in ("ceiling", "floor", "haze", "outside"):
if not 0 <= getattr(args, k) <= 1:
sys.exit(f"--{k} must be between 0 and 1")
header, v = read_ply(args.src)
xyz = np.stack([v["x"], v["y"], v["z"]], 1).astype(np.float64)
alpha = 1 / (1 + np.exp(-v["opacity"].astype(np.float64)))
rgb = 0.5 + C0 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1).astype(np.float64)
lum = rgb @ [0.299, 0.587, 0.114]
room = Room(xyz, alpha)
print(f"Room {room.hi[0] - room.lo[0]:.1f} × {room.hi[1] - room.lo[1]:.1f} m, {len(v):,} splats")
drop, added = np.zeros(len(v), bool), []
if args.ceiling:
d, new = ceiling(room, v, rgb, lum, alpha, args.ceiling)
drop |= d
added.append(new)
if args.floor:
d, new = floor(room, v, rgb, alpha, args.floor)
drop |= d
added.append(new)
if args.haze:
drop |= haze(v, alpha, args.haze)
if args.outside:
m = 1.0 - 0.7 * args.outside
solid_in = room.up[room.within(0) & (alpha > 0.5)]
top = max(np.percentile(solid_in, 99.5), 2.0) if len(solid_in) else 2.5
out = ~room.within(-m) | (room.up < -m) | (room.up > top + m)
print(f"Outside: {out.sum():,} splats more than {m:.2f} m outside the room out")
drop |= out
result = np.concatenate([v[~drop], *added])
write_ply(args.dst, header, result)
print(f"{len(result):,} splats ({drop.sum():,} out, {sum(len(a) for a in added):,} in)")
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "scipy"]
# ///
"""EXPERIMENTAL, not wired into the interface yet (0.4): splats in space the cameras looked through.
For every (sampled) image: the SfM points it saw give a coarse depth map (per cell the nearest seen
surface). A splat that projects into such a cell clearly in front of that surface sat in the line of sight
of a camera that saw something behind it: free space. Removed when enough images say so.
Usage: freespace.py <scene.ply> <out.ply> <run>[,<run>...] [--every N] [--strength S] [--solid A]
<run> the recordings the scene was made of (their sparse model and the Frame's tracking); the
scene must be in their fitted frame, as combine.py makes it (alignment "images")
--solid A where the SfM points say nothing (white walls and ceilings), use the splats more opaque than A
First try on a real living room (15.10.2026): only 0.4 % of the splats found, mostly faint ones. The SfM
points – the only reliable "something is here" – are missing exactly on white surfaces, and the dark veil
under a white ceiling hangs closer to it (10–35 cm) than this can tell apart; scripts/clean.py --ceiling
is the tool for that.
"""
import argparse
import json
import struct
import sys
from pathlib import Path
import numpy as np
from scipy.spatial.transform import Rotation
sys.path.insert(0, str(Path(__file__).resolve().parent))
import combine as C # noqa: E402
NP = {0: 3, 1: 4, 2: 4, 3: 5, 4: 8, 5: 8, 6: 12, 7: 5, 8: 4, 9: 5, 10: 12}
GRID = 48 # depth cells per image side
def read_cams(path):
out = {}
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
cid, model, w, h = struct.unpack("<iiQQ", f.read(24))
out[cid] = (model, w, h, np.array(struct.unpack("<%dd" % NP[model], f.read(8 * NP[model]))))
return out
def read_images(path):
out = []
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
_, qw, qx, qy, qz, tx, ty, tz, cid = struct.unpack("<i7di", f.read(64))
name = b""
while (ch := f.read(1)) != b"\0":
name += ch
k, = struct.unpack("<Q", f.read(8))
obs = np.frombuffer(f.read(24 * k), dtype=np.dtype([("x", "<f8"), ("y", "<f8"), ("id", "<i8")]))
R = Rotation.from_quat([qx, qy, qz, qw]).as_matrix()
out.append((name.decode(), R, np.array([tx, ty, tz]), cid, obs[obs["id"] >= 0].copy()))
return out
def read_points3d(path):
ids, xyz = [], []
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
pid, x, y, z, _, _, _, e, k = struct.unpack("<Q3d3BdQ", f.read(51))
f.seek(8 * k, 1)
ids.append(pid)
xyz.append((x, y, z))
ids = np.array(ids)
table = np.full(ids.max() + 1, -1)
table[ids] = np.arange(len(ids))
return table, np.array(xyz)
def project(cam, pc):
"""Camera coordinates -> pixels (OPENCV_FISHEYE or pinhole); valid only for z > 0."""
model, w, h, p = cam
x, y = pc[:, 0] / pc[:, 2], pc[:, 1] / pc[:, 2]
if model == 5:
fx, fy, cx, cy, k1, k2, k3, k4 = p
r = np.sqrt(x * x + y * y)
th = np.arctan(r)
thd = th * (1 + k1 * th**2 + k2 * th**4 + k3 * th**6 + k4 * th**8)
s = np.where(r > 1e-9, thd / np.maximum(r, 1e-9), 1.0)
return fx * x * s + cx, fy * y * s + cy
fx, fy, cx, cy = (p[0], p[0], p[1], p[2]) if model in (0, 2, 3) else p[:4]
return fx * x + cx, fy * y + cy
def main():
ap = argparse.ArgumentParser()
ap.add_argument("src")
ap.add_argument("dst")
ap.add_argument("runs")
ap.add_argument("--every", type=int, default=4)
ap.add_argument("--strength", type=float, default=0.5)
ap.add_argument("--solid", type=float, default=0.0)
args = ap.parse_args()
header, v = C.read_ply(Path(args.src))
P = np.stack([v["x"], v["y"], v["z"]], 1).astype(np.float64)
A = 1 / (1 + np.exp(-v["opacity"].astype(np.float64)))
free = np.zeros(len(v), np.int32)
seen = np.zeros(len(v), np.int32)
for run in args.runs.split(","):
run = Path(run)
sp = run / "dataset/sparse/0"
cams, imgs = read_cams(sp / "cameras.bin"), read_images(sp / "images.bin")
table, X = read_points3d(sp / "points3D.bin")
# the room's frame: fitted to the Frame's tracking, as combine.py does (alignment "images")
ai = json.loads((run / "dataset/main.aiscene").read_text())
rec = {f["path"].removeprefix("images/"): np.array(f["worldFromCam"])[:3, 3] for f in ai["frames"]}
names = [i[0].replace("\\", "/") for i in imgs]
centres = np.array([-i[1].T @ i[2] for i in imgs])
both = [k for k, nm in enumerate(names) if nm in rec]
fit = C.umeyama(centres[both], np.array([rec[names[k]] for k in both]))
# splats into this recording's own frame (inverse of the fit), cameras stay as they are
Q = (fit.R.T @ ((P - fit.t) / fit.s).T).T
for name, R, t, cid, obs in imgs[::args.every]:
cam = cams[cid]
_, w, h, _ = cam
pts = X[table[obs["id"]]]
pc = (R @ pts.T).T + t
ok = pc[:, 2] > 0.05
cx = np.clip((obs["x"][ok] / w * GRID).astype(int), 0, GRID - 1)
cy = np.clip((obs["y"][ok] / h * GRID).astype(int), 0, GRID - 1)
depth = np.full(GRID * GRID, np.inf)
np.minimum.at(depth, cy * GRID + cx, pc[ok, 2])
sc = (R @ Q.T).T + t
front = sc[:, 2] > 0.1
if args.solid: # where SfM saw nothing (a white ceiling): the solid splats
so = front & (A > args.solid)
us, vs = project(cam, sc[so])
ins = (us >= 0) & (us < w) & (vs >= 0) & (vs < h)
cs = (np.clip((vs[ins] / h * GRID).astype(int), 0, GRID - 1) * GRID
+ np.clip((us[ins] / w * GRID).astype(int), 0, GRID - 1))
sd = np.full(GRID * GRID, np.inf)
np.minimum.at(sd, cs, sc[so][ins, 2])
depth = np.where(np.isfinite(depth), depth, sd)
u, vv = project(cam, sc[front])
inside = (u >= 0) & (u < w) & (vv >= 0) & (vv < h)
idx = np.flatnonzero(front)[inside]
cell = (np.clip((vv[inside] / h * GRID).astype(int), 0, GRID - 1) * GRID
+ np.clip((u[inside] / w * GRID).astype(int), 0, GRID - 1))
d = depth[cell]
known = np.isfinite(d)
z = sc[idx, 2]
seen[idx[known]] += 1
margin = 0.15 + 0.1 * d[known]
free[idx[known][z[known] < d[known] - margin]] += 1
print(run.name, "images used:", len(imgs[::args.every]), flush=True)
s = args.strength
need = int(round(6 - 4 * s)) # images that looked through it
frac = 0.7 - 0.3 * s # of those that saw the place at all
drop = (free >= need) & (free >= frac * np.maximum(seen, 1)) & (A < 0.9)
print(f"free-space splats: {drop.sum():,} of {len(v):,} ({100 * drop.mean():.1f} %), "
f"opacity median {np.median(A[drop]) if drop.any() else 0:.2f}")
lines = [b"element vertex %d\n" % (~drop).sum() if l.startswith(b"element vertex") else l
for l in header.splitlines(keepends=True)]
with open(args.dst, "wb") as f:
f.write(b"".join(lines))
v[~drop].tofile(f)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/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 (COLMAP, the Workshop's own scenes – default), y-up,
# z-up (Blender, Spirula Studio from photos alone)
# 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
}
+151
View File
@@ -0,0 +1,151 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "pillow"]
# ///
"""Small pictures of a splat file for the import dialog (0.6.1): one per choice of "up", as the scene would
stand on the Frame, so the upright one can simply be picked – plus a guess which one it is.
Usage: python3 import_preview.py <splat file> <out dir> [splat-transform]
Writes <out dir>/y-down.png, y-up.png, z-up.png and prints {"guess": "..."} as JSON.
Files other than a plain PLY (.sog .spz .splat .ksplat .compressed.ply) are turned into one first with
splat-transform.
Each view is a small picture as from the Frame at the scene's start, looking level, with Y pointing down as in
our scenes. The turns are the ones scripts/import.sh makes.
The guess (see guess()) only preselects one: the pictures decide.
"""
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
from PIL import Image
TURNS = { # file -> our scene (Y down), as scripts/import.sh
"y-down": np.eye(3),
"y-up": np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]], dtype=np.float64), # 180° about X
"z-up": np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]], dtype=np.float64), # +Z becomes -Y
}
W, H = 320, 200
MAX_POINTS = 2_000_000
FOV = 100 # degrees across, about what the Frame shows
SH_C0 = 0.28209479177387814
BG = (24, 27, 34)
def plain_ply(path: Path) -> bool:
"""A PLY with one float per property and the usual splat columns (what load() reads)."""
if path.suffix.lower() != ".ply":
return False
with open(path, "rb") as f:
head = f.read(64 * 1024)
if b"end_header" not in head:
return False
lines = head.split(b"end_header")[0].decode(errors="replace").splitlines()
props = [ln.split() for ln in lines if ln.startswith("property")]
names = {p[-1] for p in props}
return (sum(ln.startswith("element") for ln in lines) == 1 and all(p[1] == "float" for p in props)
and {"x", "y", "z", "opacity", "f_dc_0", "f_dc_1", "f_dc_2"} <= names
and "format binary_little_endian 1.0" in lines)
def load(path: Path):
"""x, y, z, rgb and a size per splat that is not nearly clear (memory-mapped, at most MAX_POINTS of them)."""
with open(path, "rb") as f:
props, n = [], 0
while (raw := f.readline()) and (line := raw.decode(errors="replace").strip()) != "end_header":
if line.startswith("element vertex"):
n = int(line.split()[-1])
elif line.startswith("property"):
props.append(line.split()[-1])
if not raw or not n:
sys.exit(f"{path.name}: not a splat file")
offset = f.tell()
data = np.memmap(path, dtype=np.dtype([(p, "<f4") for p in props]), mode="r", offset=offset, shape=(n,))
rng = np.random.default_rng(0)
d = data[np.sort(rng.choice(n, MAX_POINTS, replace=False))] if n > MAX_POINTS else data[:]
d = d[1 / (1 + np.exp(-d["opacity"].astype(np.float64))) > 0.1]
xyz = np.stack([d["x"], d["y"], d["z"]], axis=1).astype(np.float64)
rgb = np.clip(0.5 + SH_C0 * np.stack([d["f_dc_0"], d["f_dc_1"], d["f_dc_2"]], axis=1), 0, 1).astype(np.float32)
if {"scale_0", "scale_1", "scale_2"} <= set(props):
sc = np.stack([d["scale_0"], d["scale_1"], d["scale_2"]], axis=1).astype(np.float64)
size = np.exp(np.clip(sc.max(axis=1), -20, 5))
else:
size = np.full(len(d), 0.01)
ok = np.isfinite(xyz).all(axis=1) & np.isfinite(size)
return xyz[ok], rgb[ok], size[ok]
def eye_of(xyz: np.ndarray) -> np.ndarray:
"""Where to look from: the file's origin if it lies in the scene (the Frame's start, the middle of the
cameras for COLMAP-based tools) – otherwise the middle of the splats."""
lo, hi = np.percentile(xyz, 5, axis=0), np.percentile(xyz, 95, axis=0)
return np.zeros(3) if ((lo < 0) & (hi > 0)).all() else np.median(xyz, axis=0)
def guess(xyz: np.ndarray) -> str:
"""The vertical is the smaller of the Y and Z extents (a room is wider than it is high, so is a landscape;
X is never up in the choices). Along Y, there is usually more down by the floor than up by the ceiling."""
lo, hi = np.percentile(xyz, 2, axis=0), np.percentile(xyz, 98, axis=0)
ext = hi - lo
if ext[2] < ext[1]:
return "z-up"
return "y-down" if np.median(xyz[:, 1]) >= (lo[1] + hi[1]) / 2 else "y-up" # Y down: the floor has the larger y
def view(xyz: np.ndarray, rgb: np.ndarray, size: np.ndarray, eye: np.ndarray) -> Image.Image:
"""A small picture as from the Frame at the scene's start: from eye, looking level along +z, x to the
right and y (down) downwards; each splat a dot as big as it looks, the nearest one in each pixel wins."""
p = xyz - eye
z = p[:, 2]
near = 0.04 * float((np.percentile(xyz, 95, axis=0) - np.percentile(xyz, 5, axis=0)).max())
f = W / 2 / np.tan(np.radians(FOV / 2))
m = z > near # what floats right in front of the eye hides all
p, z, c, sz = p[m], z[m], rgb[m], size[m]
u = np.round(W / 2 + f * p[:, 0] / z).astype(int)
v = np.round(H / 2 + f * p[:, 1] / z).astype(int)
r = np.clip(np.round(f * sz / z * 0.5), 0, 2).astype(int)
ok = (u >= -3) & (u < W + 3) & (v >= -3) & (v < H + 3)
u, v, z, c, r = u[ok], v[ok], z[ok], c[ok], r[ok]
pu, pv, pz, pc = [], [], [], []
for rad in range(3): # each dot as a small square of its size
k = r == rad
for dy in range(-rad, rad + 1):
for dx in range(-rad, rad + 1):
pu.append(u[k] + dx); pv.append(v[k] + dy); pz.append(z[k]); pc.append(c[k])
u, v, z, c = np.concatenate(pu), np.concatenate(pv), np.concatenate(pz), np.concatenate(pc)
ok = (u >= 0) & (u < W) & (v >= 0) & (v < H)
u, v, z, c = u[ok], v[ok], z[ok], c[ok]
img = np.zeros((H, W, 3), np.float32)
img[:] = BG
if len(z):
order = np.argsort(-z) # far first: the near ones are written last
c = c / max(float(np.percentile(c.max(axis=1), 95)), 0.05) * 0.9 # rooms come out dim otherwise
img[v[order], u[order]] = np.clip(c[order], 0, 1) * 255
return Image.fromarray(img.astype(np.uint8))
def main():
src, out = Path(sys.argv[1]), Path(sys.argv[2])
out.mkdir(parents=True, exist_ok=True)
ply = src
if not plain_ply(src):
if len(sys.argv) < 4 or not sys.argv[3]:
sys.exit("splat-transform is needed for this file")
ply = out / "as.ply"
if subprocess.run([sys.argv[3], "-w", str(src), str(ply)], stdout=subprocess.DEVNULL).returncode:
sys.exit(f"{src.name}: splat-transform cannot read it")
xyz, rgb, size = load(ply)
if ply != src:
ply.unlink()
if not len(xyz):
sys.exit("no splats in the file")
eye = eye_of(xyz)
for up, r in TURNS.items():
view(xyz @ r.T, rgb, size, eye @ r.T).save(out / f"{up}.png")
print(json.dumps({"guess": guess(xyz)}))
if __name__ == "__main__":
main()
+111
View File
@@ -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
}
+12
View File
@@ -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
+145
View File
@@ -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()
+40 -12
View File
@@ -3,11 +3,13 @@
# optionally shown in the original's size (models). Each version is a complete scene in
# <run folder>/versions/<key>/ and can be copied to the Frame next to the others.
#
# Usage: scripts/versions.sh <run folder> "<key>|<splats>|<scale>|<name plate>" ...
# Usage: scripts/versions.sh <run folder> "<key>|<splats>|<scale>|<name plate>[|<clean-up>]" ...
# <key> folder name of the version, e.g. 1m, 0.75m, all, 1m-1x87
# <splats> number of splats, or "all"
# <scale> 1, or e.g. 87: Gaussian XR shows it in the original's size (worldScale 87)
# <name plate> text on the name plate in the gallery, e.g. "Flat · 1 M"
# <clean-up> optional, e.g. "ceiling=0.5,haze=0.3": cleaned up first (scripts/clean.py; ceiling,
# floor, haze, outside, each 0 to 1), then reduced
# The splats come from <run folder>/spirula/scene.ply. Lines starting with "@@" are progress markers.
set -euo pipefail
# Everything in one block: bash reads it completely before it starts, so updating this file
@@ -39,7 +41,7 @@ echo "Making $# version(s) of $slug ($total splats)"
i=0
for spec in "$@"; do
i=$((i + 1))
IFS='|' read -r key splats scale plate <<< "$spec"
IFS='|' read -r key splats scale plate clean <<< "$spec"
case "$key" in ''|.*|*/*) echo "Unusable version name: $key" >&2; exit 1 ;; esac
echo "@@ vstep $i $# $key"
dir=$out/versions/$key
@@ -47,17 +49,40 @@ for spec in "$@"; do
rm -rf "$tmp"
mkdir -p "$tmp"
trap 'rm -rf "$tmp"' EXIT
if [ "$splats" = all ] || [ "$splats" -ge "$total" ]; then
echo "$key: all $total splats"
"$SPLAT_TRANSFORM" -w "$src" "$tmp/scene.sog"
made=$total
from=$src
have=$total
if [ -n "${clean:-}" ]; then
args=()
IFS=',' read -ra parts <<< "$clean"
for kv in "${parts[@]}"; do
k=${kv%%=*} v=${kv#*=}
case "$k" in ceiling|floor|haze|outside) ;; *) echo "Unknown clean-up: $k" >&2; exit 1 ;; esac
[[ "$v" =~ ^(0(\.[0-9]+)?|1(\.0+)?)$ ]] || { echo "Clean-up strength must be 0 to 1: $kv" >&2; exit 1; }
args+=("--$k" "$v")
done
echo "$key: cleaning up ($clean)"
uv run -q "$HERE/clean.py" "$src" "$tmp/clean.ply" "${args[@]}"
from=$tmp/clean.ply
have=$(python3 -c 'import sys
with open(sys.argv[1], "rb") as f:
print(next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex")))' "$from")
fi
if [ "$splats" = all ] || [ "$splats" -ge "$have" ]; then
echo "$key: all $have splats"
"$SPLAT_TRANSFORM" -w "$from" "$tmp/scene.sog"
made=$have
else
echo "$key: reducing $total to $splats splats"
"$SPLAT_TRANSFORM" -w "$src" --decimate "$splats" "$tmp/reduced.ply"
echo "$key: reducing $have to $splats splats"
"$SPLAT_TRANSFORM" -w "$from" --decimate "$splats" "$tmp/reduced.ply"
"$SPLAT_TRANSFORM" -w "$tmp/reduced.ply" "$tmp/scene.sog"
rm -f "$tmp/reduced.ply"
made=$splats
fi
if [ -f "$tmp/clean.ply" ] && [ "$splats" = all ]; then
mkdir -p "$tmp/spirula" # for the PC: Spirula Studio opens the ply
mv "$tmp/clean.ply" "$tmp/spirula/scene.ply"
fi
rm -f "$tmp/clean.ply"
stem=$(printf '%s-%s' "$slug" "$key" | tr 'A-Z' 'a-z')
python3 - "$out/scene.xrSceneTransform.json" "$tmp/scene.xrSceneTransform.json" "$scale" <<'PY'
import json, sys
@@ -69,11 +94,14 @@ PY
cp "$preview" "$tmp/$stem.gallery.png"
fi
uv run -q "$HERE/make_gallery.py" --label "$tmp/$stem.sceneinfo.png" "$plate"
python3 - "$tmp/version.json" "$key" "$made" "$splats" "$scale" "$plate" <<'PY'
python3 - "$tmp/version.json" "$key" "$made" "$splats" "$scale" "$plate" "${clean:-}" <<'PY'
import json, sys, time
path, key, made, target, scale, plate = sys.argv[1:]
json.dump({"key": key, "splats": int(made), "target": target, "scale": float(scale), "label": plate,
"created": time.time()}, open(path, "w"), indent=1)
path, key, made, target, scale, plate, clean = sys.argv[1:]
info = {"key": key, "splats": int(made), "target": target, "scale": float(scale), "label": plate,
"created": time.time()}
if clean:
info["clean"] = {k: float(v) for k, v in (kv.split("=") for kv in clean.split(","))}
json.dump(info, open(path, "w"), indent=1)
PY
rm -rf "$dir"
mv "$tmp" "$dir"
+53 -7
View File
@@ -5,7 +5,9 @@ Order: built-in defaults < ~/.config/splat-workshop/config.toml (or $SPLAT_WORKS
`python3 -m splat_workshop.config --shell` prints the values as shell assignments for scripts/.
"""
import difflib
import os
import re
import shlex
import shutil
import sys
@@ -28,13 +30,51 @@ 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},
}
def unknown_keys(data: dict) -> list[tuple[str, str]]:
"""Settings in the config file that Splat Workshop does not know (e.g. a typo: "splat-transform"),
as ("section.key", suggestion or "")."""
out = []
for section, values in data.items():
if section not in DEFAULTS:
close = difflib.get_close_matches(section, DEFAULTS, n=1, cutoff=0.6)
out.append((section, close[0] if close else ""))
continue
if not isinstance(values, dict):
continue
for key in values:
if key not in DEFAULTS[section]:
close = difflib.get_close_matches(key.replace("-", "_"), DEFAULTS[section], n=1, cutoff=0.6)
out.append((f"{section}.{key}", f"{section}.{close[0]}" if close else ""))
return out
# PyAV 19 removed av.open(metadata_errors=...), which the Arcturus pipeline (autumn 2026) uses
PYAV_FILES = ("av1_to_3dgs.py", "av1_colmap.py")
RX_AV_DEP = re.compile(r'^#\s*"av(?P<spec>[^"]*)"', re.M)
def pyav_unpinned(pipeline: Path) -> list[str]:
"""Pipeline scripts that use metadata_errors but allow PyAV 19 or newer."""
out = []
for name in PYAV_FILES:
try:
text = (pipeline / name).read_text(errors="replace")
except OSError:
continue
m = RX_AV_DEP.search(text)
if "metadata_errors" in text and m and "<19" not in m["spec"].replace(" ", ""):
out.append(name)
return out
class Config:
def __init__(self, data: dict, source: Path | None):
def __init__(self, data: dict, source: Path | None, unknown=()):
self.source = source
self.unknown = list(unknown)
base = source.parent if source else Path.home()
def path(v: str) -> Path:
@@ -57,18 +97,24 @@ 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")
def problems(self) -> list[tuple[str, dict]]:
"""Missing prerequisites as (message key, params) for the setup hints in the UI."""
out = []
for key, suggestion in self.unknown:
out.append(("setup.unknown_key" if suggestion else "setup.unknown_key.plain",
{"key": key, "suggestion": suggestion, "file": str(self.source)}))
if not (self.pipeline / "av1_to_3dgs.py").is_file():
out.append(("setup.pipeline", {"path": str(self.pipeline)}))
elif bad := pyav_unpinned(self.pipeline):
out.append(("setup.pyav", {"files": ", ".join(bad), "path": str(self.pipeline)}))
if not os.access(self.spirula, os.X_OK):
out.append(("setup.spirula", {"path": str(self.spirula)}))
if not self.splat_transform:
out.append(("setup.splat_transform", {}))
out.append(("setup.splat_transform", {"file": str(self.source or CONFIG_FILE)}))
for tool in ("ffmpeg", "ffprobe", "uv"):
if not shutil.which(tool):
out.append(("setup.tool", {"tool": tool}))
@@ -103,12 +149,12 @@ def _merge(base: dict, over: dict) -> dict:
def load() -> Config:
data, source = DEFAULTS, None
data, source, unknown = DEFAULTS, None, []
if CONFIG_FILE.is_file():
with open(CONFIG_FILE, "rb") as fh:
data = _merge(DEFAULTS, tomllib.load(fh))
source = CONFIG_FILE
return Config(data, source)
own = tomllib.load(fh)
data, source, unknown = _merge(DEFAULTS, own), CONFIG_FILE, unknown_keys(own)
return Config(data, source, unknown)
def shell(cfg: Config) -> str:
+12
View File
@@ -16,7 +16,11 @@ 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",
"notify.failed": "“{name}” – details in Splat Workshop",
"notify.download.title": "Fetched from the Frame",
@@ -47,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}).",
@@ -79,7 +85,11 @@ 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",
"notify.failed": "„{name}“ – Details in der Splat-Werkstatt",
"notify.download.title": "Von der Frame geholt",
@@ -110,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}).",
+431 -23
View File
@@ -9,6 +9,7 @@ Without the tray icon (--no-idle-exit) it stops after idle_minutes with nothing
Start: ./splat-workshop (or python3 -m splat_workshop.server)
"""
import argparse
import base64
import functools
import hashlib
import http.server
@@ -24,12 +25,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.3.1"
VERSION = "0.6.1"
CFG = config.load()
ROOT = config.ROOT
WEB = ROOT / "web"
@@ -152,7 +154,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:
@@ -176,11 +178,29 @@ def err(code: str, **params) -> dict:
# ---------------------------------------------------------------- recordings
def frame_listing(force=False):
"""Recordings on the Frame (cached). None = no Frame configured or not reachable."""
"""Recordings on the Frame (cached). None = no Frame configured or not reachable.
An expired listing is renewed in the background and the last one answered meanwhile: asking a Frame
that is switched off takes seconds, and the scene list (which asks too) would hang that long."""
if not FRAME:
return None
if not force and time.time() - frame_cache["t"] < 20:
return frame_cache["data"]
if not force and frame_cache["t"]:
if not frame_cache.get("renewing"):
frame_cache["renewing"] = True
threading.Thread(target=renew_frame_listing, daemon=True).start()
return frame_cache["data"]
return read_frame_listing()
def renew_frame_listing():
try:
read_frame_listing()
finally:
frame_cache["renewing"] = False
def read_frame_listing():
cmd = (f'(cd {FRAME_VIDEOS} 2>/dev/null && stat -c "%n|%s|%Y" *_stereo.mp4 2>/dev/null); echo ---; '
f'ls {FRAME_VIDEOS}/thumbnails 2>/dev/null; echo ---; (ls {FRAME_SCENES}; ls {FRAME_STEAM}) 2>/dev/null')
try:
@@ -594,9 +614,16 @@ def parse_output(text: str) -> dict:
info["done"] = True
if d[1] == "frame" and d[2]:
info["folder"] = d[2]
for needle, hint in KNOWN_FAILURES:
if needle in text:
info["hint"] = hint
return {"phase": phase, "pct": pct, "eta": eta, "info": info}
# Failures with a known cause: a line of the pipeline's output -> a hint in the interface (web/i18n.js)
KNOWN_FAILURES = (("unexpected keyword argument 'metadata_errors'", "hint.pyav"),)
def job_status(tail=True) -> dict | None:
st = load_state().get("job")
if not st:
@@ -901,6 +928,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()
@@ -974,10 +1003,36 @@ def read_version(vdir: Path) -> dict:
return {}
def version_key(splats, scale: float) -> str:
"""Folder name of a version: 1m, 0.75m, all; with a scale e.g. 1m-1x87."""
CLEANUPS = ("ceiling", "floor", "haze", "outside") # scripts/clean.py, each 0 (off) to 1
def version_key(splats, scale: float, clean: dict = None) -> str:
"""Folder name of a version: 1m, 0.75m, all; with a scale e.g. 1m-1x87; cleaned up e.g. all-c50h30."""
key = "all" if splats == "all" else f"{splats / 1e6:g}m"
return key + (f"-1x{scale:g}" if scale != 1 else "")
key += f"-1x{scale:g}" if scale != 1 else ""
return key + ("-" + "".join(f"{k[0]}{round(v * 100)}" for k, v in clean.items()) if clean else "")
def parse_cleanup(raw: str):
""""ceiling=0.5,haze=0.3" -> {"ceiling": 0.5, "haze": 0.3}, the ones switched on (0 < S <= 1); or an error."""
if not raw:
return {}
pairs = dict(kv.partition("=")[::2] for kv in raw.split(","))
if set(pairs) - set(CLEANUPS):
return err("err.bad_value", key="clean")
out = {}
for k in CLEANUPS:
try:
v = float(pairs.get(k) or 0)
except ValueError:
return err("err.bad_value", key=k)
if v != v:
return err("err.bad_value", key=k)
if not 0 <= v <= 1:
return err("err.range", key=k, lo=0, hi=1)
if v:
out[k] = round(v, 2)
return out
def versions_of(slug: str) -> list:
@@ -1000,7 +1055,12 @@ def versions_of(slug: str) -> list:
folder = folders.get(key)
on_frame = (folder in fr["scenes"]) if folder else any(
re.fullmatch(r"\d\d_" + re.escape(f"{slug}-{key}"), x) for x in fr["scenes"])
clean = info.get("clean") or {}
target = info.get("target", "")
out.append({"key": key, "label": info.get("label"), "splats": info.get("splats"), "scale": info.get("scale", 1),
"clean": clean, "purpose": "pc" if target == "all" else "frame",
# Spirula opens a ply: a cleaned PC version keeps its own, a plain one is the scene's own
"ply": (vdir / "spirula" / "scene.ply").exists() or (target == "all" and not clean and source.exists()),
"size": (vdir / "scene.sog").stat().st_size, "created": int(info.get("created", 0)),
"stale": info.get("created", 0) < built, "on_frame": on_frame,
"upload": upload_status(f"{slug}#{key}")})
@@ -1039,14 +1099,22 @@ def start_versions(p: dict):
scales.append(sc)
if not sizes:
return err("err.no_versions")
clean = parse_cleanup(p.get("clean"))
if "code" in clean:
return clean
meta = read_meta(slug)
name = meta.get("name") or slug
specs = []
# with a clean-up, optionally the same without, to compare side by side
variants = [clean, {}] if clean and p.get("also_plain") else [clean]
for sc in dict.fromkeys(scales):
for n in dict.fromkeys(sizes):
size = t("ver.all") if n == "all" else t("ver.size", n=f"{n / 1e6:g}".replace(".", "," if t.lang == "de" else "."))
plate = f"{name} · {size}" + (f" · 1:{sc:g}" if sc != 1 else "")
specs.append(f"{version_key(n, sc)}|{n}|{sc:g}|{plate}")
for cl in variants:
size = t("ver.all") if n == "all" else t("ver.size", n=f"{n / 1e6:g}".replace(".", "," if t.lang == "de" else "."))
plate = (f"{name} · {size}" + (f" · 1:{sc:g}" if sc != 1 else "")
+ "".join(f" · {t('ver.clean.' + k)} {round(v * 100)} %" for k, v in cl.items()))
specs.append(f"{version_key(n, sc, cl)}|{n}|{sc:g}|{plate}"
+ ("|" + ",".join(f"{k}={v:g}" for k, v in cl.items()) if cl else ""))
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
return err("err.job_busy")
@@ -1240,6 +1308,293 @@ 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
def import_preview(p: dict) -> dict:
"""Small pictures of the picked file, one per choice of "up", and a guess which one stands upright
(scripts/import_preview.py) – kept per file, so picking it again is instant."""
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 {"error": err("err.import_file")}
st = src.stat()
key = hashlib.sha1(f"{src}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()[:16]
out = CACHE / "import" / key
if not (out / "guess.json").exists():
tmp = out.with_name(key + ".tmp")
shutil.rmtree(tmp, ignore_errors=True)
try:
r = subprocess.run(["uv", "run", "-q", str(SCRIPTS / "import_preview.py"), str(src), str(tmp),
str(CFG.splat_transform or "")], capture_output=True, text=True, timeout=300)
except (OSError, subprocess.TimeoutExpired):
r = None
if r is None or r.returncode:
shutil.rmtree(tmp, ignore_errors=True)
return {"error": err("err.import_preview", detail=((r.stderr if r else "").strip().splitlines() or [""])[-1])}
(tmp / "guess.json").write_text(r.stdout.strip().splitlines()[-1])
shutil.rmtree(out, ignore_errors=True)
tmp.rename(out)
guess = json.loads((out / "guess.json").read_text()).get("guess")
return {"guess": guess if guess in IMPORT_UP else "y-down",
"images": {u: "data:image/png;base64," + base64.b64encode((out / f"{u}.png").read_bytes()).decode()
for u in IMPORT_UP}}
# ---------------------------------------------------------------- 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:
@@ -1285,6 +1640,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:
@@ -1292,6 +1650,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
@@ -1397,7 +1759,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
@@ -1956,7 +2318,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()}
@@ -2109,11 +2471,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
@@ -2205,13 +2571,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":
@@ -2246,6 +2613,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", ""))
@@ -2288,11 +2671,12 @@ 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")):
"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", "/api/import/preview") \
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":
@@ -2323,10 +2707,23 @@ 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/import/preview":
r = import_preview(p)
return self.send(400 if "error" in r else 200, r)
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})
@@ -2377,11 +2774,22 @@ 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":
subprocess.Popen([str(CFG.spirula), str(d / "spirula" / "scene.ply")], cwd=d,
start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ply = d / "spirula" / "scene.ply"
if p.get("version"): # a version: its own ply, or the scene's for a plain "all"
v = {x["key"]: x for x in versions_of(p.get("slug", ""))}.get(p["version"])
own = d / "versions" / p["version"] / "spirula" / "scene.ply"
ply = own if v and own.exists() else ply if v and v["ply"] else None
if ply is None:
e = err("err.no_version")
else:
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
View File
@@ -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
View File
@@ -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)
+92
View File
@@ -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)
+236
View File
@@ -0,0 +1,236 @@
"""Cleaning up a scene (0.4, scripts/clean.py): ceiling, floor, haze, outside – and as part of its versions.
The synthetic room is built so every kind of splat can be told apart afterwards by its colour: a white
ceiling that the reconstruction left thin, a dark veil under it, a dark cupboard top by the wall, a bird
cage of long thin bars, fat transparent blobs (haze), a floor with a hole, and things beyond the walls.
The expectations come from how it is built, not from the code."""
import json
import subprocess
import sys
import numpy as np
import pytest
from conftest import ROOT, RUNS, server, wait_job
import synth
CLEAN = ROOT / "scripts" / "clean.py"
FLOOR, CEILING = 1.6, -0.9 # y points down: the room is 2.5 m high
W, D = 4.0, 5.0 # x and z extent of the room
WALL, FLOORC, CEILC = (0.6, 0.6, 0.6), (0.45, 0.3, 0.2), (0.85, 0.85, 0.82)
VEIL, CUPBOARD, CAGE, HAZE, OUT = (0.12, 0.12, 0.13), (0.2, 0.1, 0.05), (0.3, 0.3, 0.35), (0.5, 0.5, 0.9), (0.9, 0.1, 0.9)
LOGIT = lambda a: float(np.log(a / (1 - a)))
def splats(pts, colour, opacity=0.95, scale=(0.02, 0.02, 0.02)):
v = np.zeros(len(pts), dtype=np.dtype([(p, "<f4") for p in synth.PROPS]))
v["x"], v["y"], v["z"] = np.asarray(pts).T
for k in range(3):
v[f"f_dc_{k}"] = (colour[k] - 0.5) / 0.28209479177387814
v[f"scale_{k}"] = np.log(scale[k])
v["opacity"] = LOGIT(opacity)
v["rot_0"] = 1.0
return v
def grid(a0, a1, b0, b1, step):
a, b = np.meshgrid(np.arange(a0, a1 + 1e-9, step), np.arange(b0, b1 + 1e-9, step), indexing="ij")
return a.ravel(), b.ravel()
def room(path):
"""The living room as a reconstruction of a white ceiling tends to come out."""
rng = np.random.default_rng(1)
parts = []
ys = np.arange(CEILING, FLOOR + 1e-9, 0.04)
for x in (0.0, W): # walls
z, y = grid(0, D, CEILING, FLOOR, 0.04)
parts.append(splats(np.stack([np.full_like(z, x), y, z], 1), WALL))
for z in (0.0, D):
x, y = grid(0, W, CEILING, FLOOR, 0.04)
parts.append(splats(np.stack([x, y, np.full_like(x, z)], 1), WALL))
x, z = grid(0, W, 0, D, 0.03) # floor with a hole of 1 x 1 m
hole = (x > 1.5) & (x < 2.5) & (z > 2.0) & (z < 3.0)
parts.append(splats(np.stack([x[~hole], np.full((~hole).sum(), FLOOR), z[~hole]], 1), FLOORC))
x, z = grid(0, W, 0, D, 0.12) # thin ceiling: little, half transparent ...
parts.append(splats(np.stack([x, np.full_like(x, CEILING), z], 1), CEILC, opacity=0.4))
x, z = grid(0, W, 0, D, 0.03) # ... but solid where it meets the walls
edge = (x < 0.25) | (x > W - 0.25) | (z < 0.25) | (z > D - 0.25)
parts.append(splats(np.stack([x[edge], np.full(edge.sum(), CEILING), z[edge]], 1), CEILC))
n = 4000 # the veil: dark, thin, 10–30 cm under it
veil = np.stack([rng.uniform(0.8, W - 0.8, n), CEILING + rng.uniform(0.1, 0.3, n), rng.uniform(0.8, D - 0.8, n)], 1)
parts.append(splats(veil, VEIL, opacity=0.2, scale=(0.03, 0.03, 0.03)))
x, z = grid(0.05, 0.4, 1.0, 2.0, 0.03) # a dark cupboard top at the wall, 2.2 m high
parts.append(splats(np.stack([x, np.full_like(x, FLOOR - 2.2), z], 1), CUPBOARD))
bars = [] # a bird cage: long thin bars, faint
for bx in np.arange(1.8, 2.2, 0.02):
for by in np.arange(FLOOR - 2.0, FLOOR - 1.2, 0.08):
bars.append((bx, by, 2.5))
parts.append(splats(bars, CAGE, opacity=0.15, scale=(0.001, 0.06, 0.001)))
n = 300 # haze: fat, transparent blobs in the room
blobs = np.stack([rng.uniform(1, 3, n), rng.uniform(FLOOR - 1.6, FLOOR - 0.6, n), rng.uniform(1, 4, n)], 1)
parts.append(splats(blobs, HAZE, opacity=0.06, scale=(0.06, 0.06, 0.06)))
n = 500 # beyond the walls, seen through the window
out = np.stack([rng.uniform(-3, -1.5, n), rng.uniform(FLOOR - 2, FLOOR, n), rng.uniform(0, D, n)], 1)
parts.append(splats(out, OUT))
v = np.concatenate(parts)
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
len(v), "".join(f"property float {p}\n" for p in synth.PROPS))
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
f.write(header.encode())
v.tofile(f)
return v
def read(path):
with open(path, "rb") as f:
header = b""
while not header.endswith(b"end_header\n"):
header += f.readline()
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
return np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
def colour_of(v):
return 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
def count(v, rgb):
return int(np.all(np.abs(colour_of(v) - rgb) < 0.01, axis=1).sum())
def clean(tmp_path, *args):
src, dst = tmp_path / "in.ply", tmp_path / "out.ply"
before = room(src)
r = subprocess.run([sys.executable, str(CLEAN), str(src), str(dst), *args], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return before, read(dst), r.stdout
def test_ceiling_veil_out_ceiling_closed(tmp_path):
before, after, out = clean(tmp_path, "--ceiling", "0.5")
assert "Ceiling 2.5" in out # found where walls and ceiling meet
assert count(after, VEIL) == 0 # the veil is gone
assert count(after, CUPBOARD) == count(before, CUPBOARD) # the dark cupboard top by the wall stays
assert count(after, CAGE) == count(before, CAGE) # and the cage, far below the ceiling
flat = after[np.isclose(after["y"], CEILING, atol=0.03) & (after["opacity"] > 3)]
# closed: a solid layer over the whole room, in the ceiling's own colour
assert len(flat) > (W / 0.02) * (D / 0.02) * 0.9
assert np.allclose(np.median(colour_of(flat), 0), CEILC, atol=0.05)
for k in ("x", "y", "z", "f_dc_0", "opacity", "scale_0"):
assert np.isfinite(after[k]).all()
def test_floor_holes_filled_in_the_floors_colour(tmp_path):
before, after, out = clean(tmp_path, "--floor", "0.5")
added = after[len(before):]
inside = (added["x"] > 1.6) & (added["x"] < 2.4) & (added["z"] > 2.1) & (added["z"] < 2.9)
assert inside.sum() > 0.8 * (0.8 / 0.02) ** 2 # the hole is closed ...
assert len(added) < 1.3 * (1 / 0.02) ** 2 # ... and little more than the hole
assert np.allclose(np.median(colour_of(added), 0), FLOORC, atol=0.05)
assert np.allclose(added["y"], FLOOR, atol=0.01)
def test_haze_goes_but_not_thin_bars(tmp_path):
before, after, _ = clean(tmp_path, "--haze", "1")
assert count(after, HAZE) == 0
assert count(after, CAGE) == count(before, CAGE) # long and thin is no haze
assert count(after, VEIL) == 0 # at full strength the veil is haze, too
assert count(after, WALL) == count(before, WALL) and count(after, CUPBOARD) == count(before, CUPBOARD)
def test_outside_goes(tmp_path):
before, after, _ = clean(tmp_path, "--outside", "1")
assert count(after, OUT) == 0 and count(after, WALL) == count(before, WALL)
def test_nothing_switched_on_changes_nothing(tmp_path):
before, after, _ = clean(tmp_path)
assert len(after) == len(before) and np.array_equal(after["x"], before["x"])
@pytest.mark.parametrize("args", [["--haze", "2"], ["--ceiling", "-0.1"]])
def test_strengths_from_0_to_1(tmp_path, args):
src = tmp_path / "in.ply"
room(src)
r = subprocess.run([sys.executable, str(CLEAN), str(src), str(tmp_path / "o.ply"), *args], capture_output=True, text=True)
assert r.returncode != 0 and "between 0 and 1" in r.stderr
# ---------------------------------------------------------------- as part of the versions
def scene(slug="Living"):
run = RUNS / slug
(run / "spirula").mkdir(parents=True, exist_ok=True)
room(run / "spirula" / "scene.ply")
(run / "scene.sog").write_bytes(b"sog")
(run / "scene.xrSceneTransform.json").write_text(json.dumps({"worldScale": 1.0}))
server.write_meta(slug, name="Living room")
return run
def test_a_cleaned_version(api):
run = scene()
total = len(read(run / "spirula" / "scene.ply"))
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "ceiling=0.5,haze=0.3"})
assert status == 200, r
js = wait_job()
assert js["result"] == "ok", js["tail"]
v = {x["key"]: x for x in server.versions_of("Living")}
assert list(v) == ["all-c50h30"]
x = v["all-c50h30"]
assert x["clean"] == {"ceiling": 0.5, "haze": 0.3} and x["label"] == "Living room · all splats · ceiling 50 % · haze 30 %"
assert x["purpose"] == "pc" and x["ply"] # Spirula Studio can open it
info = json.loads((run / "versions" / "all-c50h30" / "version.json").read_text())
assert info["splats"] != total # counted after cleaning
assert not (run / "versions" / "all-c50h30" / "clean.ply").exists()
assert len(read(run / "versions" / "all-c50h30" / "spirula" / "scene.ply")) == info["splats"]
assert len(read(run / "spirula" / "scene.ply")) == total # the scene itself is untouched
def test_a_cleaned_and_a_plain_version_side_by_side(api):
scene()
api.post("/api/versions", {"slug": "Living", "sizes": "all"})
wait_job()
api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "floor=1"})
wait_job()
assert sorted(x["key"] for x in server.versions_of("Living")) == ["all", "all-f100"]
@pytest.mark.parametrize("clean,code", [("roof=0.5", "err.bad_value"), ("haze=2", "err.range"), ("haze=x", "err.bad_value"),
("haze=nan", "err.bad_value"), ({"haze": 1}, "err.bad_request")])
def test_odd_clean_ups_are_refused(api, clean, code):
scene()
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": clean})
assert status == 400 and r["error"]["code"] == code
def test_cleaned_and_plain_in_one_go(api):
"""For the Frame, with and without cleaning up, to compare: two versions, named by their strengths."""
run = scene()
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "20000", "clean": "haze=0.4", "also_plain": True})
assert status == 200, r
assert wait_job()["result"] == "ok"
v = {x["key"]: x for x in server.versions_of("Living")}
assert set(v) == {"0.02m", "0.02m-h40"}
assert v["0.02m-h40"]["label"] == "Living room · 0.02 M · haze 40 %" and v["0.02m"]["label"] == "Living room · 0.02 M"
assert all(x["purpose"] == "frame" and not x["ply"] for x in v.values()) # Frame versions keep no ply
assert not (run / "versions" / "0.02m-h40" / "spirula").exists()
def test_a_version_opens_in_spirula(api, monkeypatch):
run = scene()
api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "ceiling=0.5"})
wait_job()
api.post("/api/versions", {"slug": "Living", "sizes": "20000"})
wait_job()
opened = []
monkeypatch.setattr(server.subprocess, "Popen", lambda cmd, **kw: opened.append(cmd))
assert api.post("/api/open", {"slug": "Living", "version": "all-c50", "what": "spirula"})[0] == 200
assert opened[-1][1] == str(run / "versions" / "all-c50" / "spirula" / "scene.ply")
status, r = api.post("/api/open", {"slug": "Living", "version": "0.02m", "what": "spirula"}) # no ply
assert status == 400 and r["error"]["code"] == "err.no_version"
status, r = api.post("/api/open", {"slug": "Living", "version": "../x", "what": "spirula"})
assert status == 400 and len(opened) == 1
+15
View File
@@ -130,3 +130,18 @@ def test_the_same_folder_in_both_places(api, frame):
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "steam"})[0] == 200
assert d.exists() and not (frame / server.FRAME_STEAM / "20_Kitchen").exists()
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "nowhere"})[0] == 400
def test_an_old_frame_listing_is_answered_at_once_and_renewed_behind(frame):
"""Asking a Frame that is switched off takes seconds; the scene list must not wait for it every time."""
import time as _t
first = server.frame_listing()
assert first is not None
server.frame_cache.update(t=_t.time() - 60, data={"files": [], "thumbs": [], "scenes": ["old"]})
start = _t.time()
assert server.frame_listing()["scenes"] == ["old"] # the last one, without waiting
assert _t.time() - start < 0.5
end = _t.time() + 10
while server.frame_cache.get("renewing") and _t.time() < end:
_t.sleep(0.05)
assert server.frame_listing()["scenes"] != ["old"] # renewed in the background
+150
View File
@@ -0,0 +1,150 @@
"""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 base64
import io
import json
import time
import numpy as np
import pytest
from PIL import Image
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
# ---------------------------------------------------------------- which way is up (0.6.1)
def turned(src, dst, up):
"""The room as another program would have saved it: our scene (Y down) turned back into "up"."""
v = test_clean.read(src).copy()
x, y, z = v["x"].copy(), v["y"].copy(), v["z"].copy()
if up == "y-up":
v["y"], v["z"] = -y, -z # 180° about X
elif up == "z-up":
v["y"], v["z"] = z, -y # down (+Y) becomes -Z
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
len(v), "".join(f"property float {p}\n" for p in v.dtype.names))
dst.write_bytes(header.encode() + v.tobytes())
return dst
def picture(url):
assert url.startswith("data:image/png;base64,")
return Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).convert("RGB")
@pytest.mark.parametrize("up", ["y-down", "y-up", "z-up"])
def test_the_dialog_guesses_which_way_is_up(api, splat, tmp_path, up):
f = splat if up == "y-down" else turned(splat, tmp_path / f"room_{up}.ply", up)
status, r = api.post("/api/import/preview", {"path": str(f)})
assert status == 200, r
assert r["guess"] == up
assert sorted(r["images"]) == ["y-down", "y-up", "z-up"]
for u, url in r["images"].items():
assert picture(url).size == (320, 200), u
def test_the_upright_picture_has_the_floor_below(api, splat, tmp_path):
# the room's floor is brown, its ceiling nearly white: saved with Y up, the right choice shows brown at
# the bottom, our usual Y down at the top (the scene on its head)
f = turned(splat, tmp_path / "room_yup.ply", "y-up")
imgs = {u: np.asarray(picture(url), np.float32) for u, url in api.post("/api/import/preview", {"path": str(f)})[1]["images"].items()}
def brown(img): # floor colour: much more red than blue
return (img[..., 0] - img[..., 2]) > 40
up, head = brown(imgs["y-up"]), brown(imgs["y-down"])
assert up[100:].sum() > 5 * up[:100].sum()
assert head[:100].sum() > 5 * head[100:].sum()
def test_the_pictures_are_kept_per_file(api, splat, monkeypatch):
assert api.post("/api/import/preview", {"path": str(splat)})[0] == 200
calls = []
real = server.subprocess.run
monkeypatch.setattr(server.subprocess, "run", lambda *a, **k: calls.append(a) or real(*a, **k))
assert api.post("/api/import/preview", {"path": str(splat)})[0] == 200
assert calls == [] # the same file: from the cache
def test_a_broken_file_gives_no_pictures(api, tmp_path):
bad = tmp_path / "broken.ply"
bad.write_bytes(b"not a splat")
status, r = api.post("/api/import/preview", {"path": str(bad)})
assert status == 400 and r["error"]["code"] == "err.import_preview"
status, r = api.post("/api/import/preview", {"path": "relative.ply"})
assert status == 400 and r["error"]["code"] == "err.import_file"
+147
View File
@@ -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"
+21
View File
@@ -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)
+18
View File
@@ -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
+1 -1
View File
@@ -100,7 +100,7 @@ def test_no_paths_outside_the_runs_folder(api, leak, slug):
@pytest.mark.parametrize("path,body", [
("/api/open", {"slug": "Room", "what": "folder"}), ("/api/play", {"name": "x.mp4"}),
("/api/pick", {}), ("/api/shutdown", {}),
("/api/pick", {}), ("/api/shutdown", {}), ("/api/import", {"path": "/x.ply"}), ("/api/import/preview", {"path": "/x.ply"}),
])
def test_actions_on_the_screen_only_from_this_pc(api, monkeypatch, path, body):
# video player, file dialog, folder, Spirula and quitting: not from another device in the LAN
+71
View File
@@ -0,0 +1,71 @@
"""Setup hints (0.4.1, from issues #1 and #2): typos in the config, splat-transform only on the shell's PATH,
an Arcturus pipeline that PyAV 19 breaks."""
import os
import subprocess
import pytest
from conftest import ROOT, server
from splat_workshop import config
UNPINNED = '''#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "av>=18.1",
# "numpy",
# ]
# ///
import av
av.open("x", metadata_errors="strict")
'''
def test_a_typo_in_the_config_is_named_with_the_right_spelling():
unknown = config.unknown_keys({"paths": {"splat-transform": "/x", "pipeline": "/p"}, "frmae": {"host": ""}})
assert unknown == [("paths.splat-transform", "paths.splat_transform"), ("frmae", "frame")]
c = config.Config(config._merge(config.DEFAULTS, {}), None, unknown)
keys = [k for k, _ in c.problems()]
assert keys[:2] == ["setup.unknown_key", "setup.unknown_key"]
def test_a_pipeline_that_pyav_19_breaks_is_found(tmp_path):
for name in config.PYAV_FILES:
(tmp_path / name).write_text(UNPINNED)
assert config.pyav_unpinned(tmp_path) == list(config.PYAV_FILES)
(tmp_path / "av1_colmap.py").write_text(UNPINNED.replace('"av>=18.1"', '"av>=18.1,<19"'))
assert config.pyav_unpinned(tmp_path) == ["av1_to_3dgs.py"]
c = config.Config(config._merge(config.DEFAULTS, {"paths": {"pipeline": str(tmp_path)}}), None)
hint = dict(c.problems())["setup.pyav"]
assert hint["files"] == "av1_to_3dgs.py"
def test_a_conversion_failing_on_pyav_says_why():
out = server.parse_output("Traceback ...\nTypeError: open() got an unexpected keyword argument 'metadata_errors'\n")
assert out["info"]["hint"] == "hint.pyav"
assert "hint" not in server.parse_output("Traceback ...\nValueError: something else\n")["info"]
@pytest.mark.parametrize("existing", [False, True])
def test_install_gives_the_full_path_of_splat_transform(tmp_path, existing):
"""install.sh finds splat-transform on the shell's PATH: a new config gets the full path, an existing one
a line to add – the app, started from the desktop, does not see that PATH."""
home, xdg, bin_ = tmp_path / "home", tmp_path / "xdg", tmp_path / "bin"
for d in (home, xdg, bin_):
d.mkdir()
(bin_ / "splat-transform").write_text("#!/bin/sh\n")
(bin_ / "splat-transform").chmod(0o755)
conf = xdg / "splat-workshop" / "config.toml"
if existing:
conf.parent.mkdir()
conf.write_text('[paths]\nsplat-transform = "/typo"\n')
env = {**os.environ, "HOME": str(home), "XDG_CONFIG_HOME": str(xdg), "XDG_DATA_HOME": str(tmp_path / "data"),
"PATH": f"{bin_}:{os.environ['PATH']}"}
env.pop("SPLAT_WORKSHOP_CONFIG", None)
r = subprocess.run([str(ROOT / "install.sh")], env=env, capture_output=True, text=True, timeout=120)
assert r.returncode == 0, r.stderr
line = f'splat_transform = "{bin_ / "splat-transform"}"'
if existing:
assert line in r.stdout and line not in conf.read_text()
assert 'Unknown setting "paths.splat-transform"' in r.stdout and '"paths.splat_transform"' in r.stdout
else:
assert line in conf.read_text() and "written to the config" in r.stdout
+98
View File
@@ -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
+114 -10
View File
@@ -15,7 +15,10 @@ const I18N = {
"setup.hint": "Settings are in {file}. Restart Splat Workshop after changing them.",
"setup.pipeline": "Arcturus pipeline not found in {path} (av1_to_3dgs.py). Download it from arcturus.vision/apps.",
"setup.spirula": "Spirula Studio not found at {path}. Build it as described in the pipeline README.",
"setup.splat_transform": "splat-transform not found – install it with: npm install -g @playcanvas/splat-transform",
"setup.splat_transform": "splat-transform not found. Install it (without root: npm install -g --prefix ~/.local @playcanvas/splat-transform) and put its full path into {file}, e.g. splat_transform = \"/home/you/.local/bin/splat-transform\" – started from the desktop, Splat Workshop does not see a PATH set in .bashrc or .zshrc.",
"setup.unknown_key": "Unknown setting “{key}” in {file} – did you mean “{suggestion}”? (Splat Workshop ignores it.)",
"setup.unknown_key.plain": "Unknown setting “{key}” in {file} – Splat Workshop ignores it.",
"setup.pyav": "The Arcturus pipeline in {path} will fail with “unexpected keyword argument 'metadata_errors'” (PyAV 19). In the # /// script header of {files}, change \"av>=18.1\" to \"av>=18.1,<19\" – see Troubleshooting in docs/arcturus-setup.md.",
"setup.tool": "{tool} is not installed.",
// job card
"job.cancel": "Cancel", "job.dismiss": "Hide", "job.log": "Log",
@@ -33,6 +36,7 @@ const I18N = {
"res.cancelled": "Cancelled. Start again with the same name and choose “Resume” to continue.",
"res.upload_failed": "The scene is finished, but copying to the Frame did not work (Frame off?). Use “To the Frame” in the list of finished scenes once it is on.",
"res.failed": "Failed – the last lines of the log show why.",
"hint.pyav": "Failed: the Arcturus pipeline does not work with PyAV 19 (“unexpected keyword argument 'metadata_errors'”). In the # /// script header of av1_to_3dgs.py and av1_colmap.py, change \"av>=18.1\" to \"av>=18.1,<19\", then resume – see Troubleshooting in docs/arcturus-setup.md.",
"confirm.cancel": "Really cancel the conversion? With “Resume” you can continue later from the last saved state.",
// step 1
"s1.title": "Choose a recording", "tab.frame": "On the Frame", "tab.local": "On this PC",
@@ -230,13 +234,22 @@ const I18N = {
"ver.button": "Versions ({n})", "ver.button.title": "Versions with fewer splats for the Frame or all splats for the PC – several side by side",
"ver.title": "Versions of “{name}” ({n} splats)",
"ver.hint": "Every version is its own scene and gets its own place in the Frame's gallery – e.g. 0.75 M and 2 M side by side to compare.",
"ver.new": "New versions", "ver.frame": "For the Frame", "ver.pc": "For the PC", "ver.custom": "Own size", "ver.million": "million splats",
"ver.new": "New version", "ver.custom": "Own size", "ver.million": "million splats",
"ver.scales": "Scale", "ver.walk": "walk through it ({ratio})", "ver.all": "all splats",
"ver.measured": "Measured on a Steam Frame (Gaussian XR, 10/2026): 0.57 M ≈ 36 fps, 2 M ≈ 23 fps, 5 M ≈ 14 fps.",
"ver.make": "Make versions", "ver.pick": "Pick at least one size", "ver.started": "Making versions …",
"ver.measured": "Measured on a Steam Frame (Gaussian XR, 10/2026): 0.75 M ≈ 36 fps, 1 M ≈ 35 fps, 1.5 M ≈ 29 fps, 2 M ≈ 24 fps, 5 M ≈ 14 fps.",
"ver.for": "For", "ver.for.frame": "🥽 the Frame (headset)", "ver.for.pc": "🖥️ the PC (all splats, e.g. Spirula Studio)",
"ver.size.head": "Size", "ver.fps.0_75": "≈ {fps} fps", "ver.fps.1": "≈ {fps} fps, clearly sharper", "ver.fps.1_5": "≈ {fps} fps, a little unsteady", "ver.fps.2": "≈ {fps} fps",
"ver.plain": "Also make it without cleaning up, to compare",
"ver.clean.none": "not cleaned up", "ver.slow": "{n} M splats: on the Frame only about 14 fps",
"ver.template": "As template", "ver.template.title": "Put this version's settings into the form below – change one slider and make the next one",
"ver.template.done": "Settings taken over – change what you want to compare",
"ver.replaces": "{what} (replaces the existing one)", "ver.same.all": "All splats without cleaning up is the scene itself – open it in Spirula from the scene, or set a slider.",
"ver.makes.1": "Makes:", "ver.makes.n": "Makes {n} versions:", "ver.make.1": "Make version", "ver.make.n": "Make {n} versions",
"ver.show": "▸ Show {n} versions", "ver.first": "+ Versions …", "ver.show.1": "▸ Show 1 version", "ver.new.button": "+ New version …", "ver.hide": "▾ Hide versions", "ver.of": "Versions of “{name}”",
"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.",
@@ -255,6 +268,45 @@ 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 does it stand upright?",
"imp.up.hint": "The pictures show the scene as it would stand on the Frame – pick the upright one. Marked: our guess.",
"imp.up.z-up": "Upright in Blender (Z up)", "imp.up.z-up.text": "e.g. from Blender, or made from photos in Spirula Studio",
"imp.up.y-down": "Like the Workshop's and the Frame's scenes (Y down)", "imp.up.y-down.text": "COLMAP and most tools built on it",
"imp.up.y-up": "Upside down (Y up)", "imp.up.y-up.text": "If it stands on its head with “Y down”",
"imp.guess": "guess", "imp.preview.wait": "Making the pictures …", "imp.preview.none": "No pictures: {detail}",
"err.import_preview": "Could not read the file: {detail}",
"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.",
"ver.clean.floor": "Floor", "ver.clean.floor.title": "Fills holes in the floor in the colour of the floor around them (more holes count with more strength) and takes out what lies under the floor.",
"ver.clean.haze": "Haze", "ver.clean.haze.title": "Takes out big, nearly transparent blobs floating in the room. Long thin things (bars, cables, wires) are no haze and stay. At full strength smaller and less transparent blobs go, too.",
"ver.clean.outside": "Edges", "ver.clean.outside.title": "Takes out what lies well outside the room: beyond the walls, above the ceiling, under the floor – from 1 m beyond them to 30 cm at full strength. Also the view through a window.",
"err.mixed_scale": "These scenes have different scales – set one scale for the group", "err.plan_failed": "The floor plan could not be drawn", "sc.replacing": "Being computed again – stays until the new one is done", "err.plan_busy": "Other floor plans are being drawn right now, try again in a moment",
"plan.measure": "📏 Measure", "plan.measure.title": "Measure distances in the floor plan",
"plan.measure.hint": "Click to set points · right-click removes the last one · horizontal distances, about ±5 cm",
@@ -285,7 +337,10 @@ const I18N = {
"setup.hint": "Die Einstellungen stehen in {file}. Nach Änderungen die Splat-Werkstatt neu starten.",
"setup.pipeline": "Arcturus-Pipeline nicht gefunden in {path} (av1_to_3dgs.py). Download: arcturus.vision/apps.",
"setup.spirula": "Spirula Studio nicht gefunden unter {path}. Bauen wie in der Pipeline-README beschrieben.",
"setup.splat_transform": "splat-transform fehlt – installieren mit: npm install -g @playcanvas/splat-transform",
"setup.splat_transform": "splat-transform nicht gefunden. Installieren (ohne root: npm install -g --prefix ~/.local @playcanvas/splat-transform) und den vollen Pfad in {file} eintragen, z. B. splat_transform = \"/home/du/.local/bin/splat-transform\" – vom Desktop gestartet sieht die Splat-Werkstatt keinen PATH aus .bashrc oder .zshrc.",
"setup.unknown_key": "Unbekannte Einstellung „{key}“ in {file} – meintest du „{suggestion}“? (Die Splat-Werkstatt ignoriert sie.)",
"setup.unknown_key.plain": "Unbekannte Einstellung „{key}“ in {file} – die Splat-Werkstatt ignoriert sie.",
"setup.pyav": "Die Arcturus-Pipeline in {path} wird mit „unexpected keyword argument 'metadata_errors'“ scheitern (PyAV 19). Im # /// script-Kopf von {files} \"av>=18.1\" in \"av>=18.1,<19\" ändern – siehe Troubleshooting in docs/arcturus-setup.md.",
"setup.tool": "{tool} ist nicht installiert.",
"job.cancel": "Abbrechen", "job.dismiss": "Ausblenden", "job.log": "Protokoll",
"job.standby": "💤 Danach in den Standby", "job.standby.in": "💤 Standby in {s} s",
@@ -302,6 +357,7 @@ const I18N = {
"res.cancelled": "Abgebrochen. Mit gleichem Namen erneut starten und „Fortsetzen“ wählen, um weiterzumachen.",
"res.upload_failed": "Die Szene ist fertig gerechnet, aber das Kopieren auf die Frame hat nicht geklappt (Frame aus?). In der Liste der fertigen Szenen „Auf die Frame“ nutzen, sobald sie an ist.",
"res.failed": "Fehlgeschlagen – die letzten Zeilen im Protokoll zeigen warum.",
"hint.pyav": "Fehlgeschlagen: Die Arcturus-Pipeline läuft nicht mit PyAV 19 („unexpected keyword argument 'metadata_errors'“). Im # /// script-Kopf von av1_to_3dgs.py und av1_colmap.py \"av>=18.1\" in \"av>=18.1,<19\" ändern und fortsetzen – siehe Troubleshooting in docs/arcturus-setup.md.",
"confirm.cancel": "Umwandlung wirklich abbrechen? Mit „Fortsetzen“ geht es später ab dem letzten Zwischenstand weiter.",
"s1.title": "Aufnahme wählen", "tab.frame": "Auf der Frame", "tab.local": "Auf dem PC",
"dl.all": "Alle holen ({n} · {size})", "dl.all.title": "Alle Aufnahmen, die noch nicht auf dem PC sind, nacheinander holen",
@@ -494,13 +550,22 @@ const I18N = {
"ver.button": "Fassungen ({n})", "ver.button.title": "Fassungen mit weniger Splats für die Frame oder allen Splats für den PC – mehrere nebeneinander",
"ver.title": "Fassungen von „{name}“ ({n} Splats)",
"ver.hint": "Jede Fassung ist eine eigene Szene und bekommt einen eigenen Platz in der Galerie der Frame – z. B. 0,75 Mio. und 2 Mio. nebeneinander zum Vergleichen.",
"ver.new": "Neue Fassungen", "ver.frame": "Für die Frame", "ver.pc": "Für den PC", "ver.custom": "Eigene Größe", "ver.million": "Mio. Splats",
"ver.new": "Neue Fassung", "ver.custom": "Eigene Größe", "ver.million": "Mio. Splats",
"ver.scales": "Maßstab", "ver.walk": "begehbar ({ratio})", "ver.all": "alle Splats",
"ver.measured": "Gemessen auf einer Steam Frame (Gaussian XR, 10/2026): 0,57 Mio. ≈ 36 fps, 2 Mio. ≈ 23 fps, 5 Mio. ≈ 14 fps.",
"ver.make": "Fassungen erstellen", "ver.pick": "Mindestens eine Größe wählen", "ver.started": "Erstelle Fassungen …",
"ver.measured": "Gemessen auf einer Steam Frame (Gaussian XR, 10/2026): 0,75 Mio. ≈ 36 fps, 1 Mio. ≈ 35 fps, 1,5 Mio. ≈ 29 fps, 2 Mio. ≈ 24 fps, 5 Mio. ≈ 14 fps.",
"ver.for": "Wofür", "ver.for.frame": "🥽 die Frame (Brille)", "ver.for.pc": "🖥️ den PC (alle Splats, z. B. Spirula Studio)",
"ver.size.head": "Größe", "ver.fps.0_75": "≈ {fps} fps", "ver.fps.1": "≈ {fps} fps, deutlich schärfer", "ver.fps.1_5": "≈ {fps} fps, leicht unruhig", "ver.fps.2": "≈ {fps} fps",
"ver.plain": "Zusätzlich ohne Bereinigung erstellen, zum Vergleichen",
"ver.clean.none": "nicht bereinigt", "ver.slow": "{n} Mio. Splats: auf der Frame nur etwa 14 fps",
"ver.template": "Als Vorlage", "ver.template.title": "Die Einstellungen dieser Fassung ins Formular unten übernehmen – einen Regler ändern und die nächste erstellen",
"ver.template.done": "Einstellungen übernommen – ändere, was du vergleichen willst",
"ver.replaces": "{what} (ersetzt die vorhandene)", "ver.same.all": "Alle Splats ohne Bereinigung ist die Szene selbst – öffne sie bei der Szene in Spirula oder stell einen Regler.",
"ver.makes.1": "Erstellt:", "ver.makes.n": "Erstellt {n} Fassungen:", "ver.make.1": "Fassung erstellen", "ver.make.n": "{n} Fassungen erstellen",
"ver.show": "▸ {n} Fassungen zeigen", "ver.first": "+ Fassungen …", "ver.show.1": "▸ 1 Fassung zeigen", "ver.new.button": "+ Neue Fassung …", "ver.hide": "▾ Fassungen ausblenden", "ver.of": "Fassungen von „{name}“",
"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.",
@@ -519,6 +584,45 @@ 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": "Wie steht sie aufrecht?",
"imp.up.hint": "Die Bilder zeigen die Szene so, wie sie auf der Frame stünde – wähle die aufrechte. Markiert: unsere Vermutung.",
"imp.up.z-up": "Steht in Blender richtig (Z oben)", "imp.up.z-up.text": "z. B. aus Blender, oder in Spirula Studio aus Fotos gemacht",
"imp.up.y-down": "Wie die Szenen der Werkstatt / Frame (Y unten)", "imp.up.y-down.text": "COLMAP und die meisten Werkzeuge darauf",
"imp.up.y-up": "Steht auf dem Kopf (Y oben)", "imp.up.y-up.text": "Wenn sie mit „Y unten“ auf dem Kopf steht",
"imp.guess": "vermutet", "imp.preview.wait": "Bilder werden berechnet …", "imp.preview.none": "Keine Bilder: {detail}",
"err.import_preview": "Datei nicht lesbar: {detail}",
"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.",
"ver.clean.floor": "Boden", "ver.clean.floor.title": "Füllt Löcher im Boden in der Farbe des Bodens drumherum (je stärker, desto mehr gilt als Loch) und nimmt heraus, was unter dem Boden liegt.",
"ver.clean.haze": "Dunst", "ver.clean.haze.title": "Nimmt große, fast durchsichtige Wolken heraus, die im Raum schweben. Lange dünne Dinge (Gitter, Kabel, Drähte) sind kein Dunst und bleiben. Je stärker, desto kleinere und weniger durchsichtige Wolken gehen mit.",
"ver.clean.outside": "Rand", "ver.clean.outside.title": "Nimmt heraus, was deutlich außerhalb des Raums liegt: hinter den Wänden, über der Decke, unter dem Boden – von 1 m Abstand bis 30 cm bei voller Stärke. Auch der Blick durchs Fenster.",
"err.mixed_scale": "Die Szenen haben verschiedene Maßstäbe – einen Maßstab für die Gruppe setzen", "err.plan_failed": "Der Grundriss konnte nicht gezeichnet werden", "sc.replacing": "Wird neu gerechnet – bleibt, bis die neue fertig ist", "err.plan_busy": "Gerade werden andere Grundrisse gezeichnet, gleich noch einmal versuchen",
"plan.measure": "📏 Messen", "plan.measure.title": "Abstände im Grundriss messen",
"plan.measure.hint": "Klicken setzt Punkte · Rechtsklick nimmt den letzten zurück · waagerechte Abstände, etwa ±5 cm genau",
+398 -71
View File
@@ -172,10 +172,32 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
.suggest .row { margin-top: 8px; }
dialog.wide { max-width: 560px; }
.vgrid { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; align-items: center; font-size: 13.5px; }
.vlist .vrow { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--line); }
.vlist .vwhat { display: flex; flex-direction: column; gap: 2px; font-size: 13px; min-width: 0; }
.vlist .vacts { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.vinline { grid-column: 1 / -1; margin-top: 8px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; }
.vinline .vhead { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 13px; }
.scene .acts.rows { flex-direction: column; align-items: flex-start; gap: 6px; }
.acts .arow { display: flex; gap: 6px; flex-wrap: wrap; }
.acts .sep { width: 1px; align-self: stretch; margin: 2px 3px; background: var(--line); }
.acts button.icon { min-width: 34px; }
.toggles { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.toggles button.link { background: none; border-color: transparent; color: var(--muted, #9aa3b2); padding-left: 2px; padding-right: 8px; }
.toggles button.link:hover { color: inherit; border-color: var(--line); }
.vsizes { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; }
.vpreview { font-size: 13px; margin: 14px 0 0; min-height: 1.4em; }
.cleanup .slide { display: grid; grid-template-columns: 7.5em 1fr 4.5em; gap: 10px; align-items: center; margin: 2px 0; }
.cleanup .slide output { text-align: right; color: var(--muted, #9aa3b2); font-variant-numeric: tabular-nums; }
.vgrid label.chk { display: inline-flex; gap: 5px; align-items: center; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px; }
dialog select { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font: inherit; }
dialog h3 { font-size: 13.5px; margin: 16px 0 8px; }
.opts { display: grid; gap: 6px; }
.opts.pics { grid-template-columns: repeat(3, 1fr); }
.opts.pics .opt { flex-direction: column; gap: 8px; }
.opts.pics .pic { width: 100%; aspect-ratio: 8 / 5; border-radius: 6px; background: #181b22; object-fit: cover; display: block; }
.opts.pics .pic.wait { display: grid; place-items: center; font-size: 12px; color: var(--muted); text-align: center; padding: 6px; box-sizing: border-box; }
.opts.pics .guess { font-size: 11px; color: var(--accent); border: 1px solid var(--accent); border-radius: 99px; padding: 0 6px; margin-left: 4px; white-space: nowrap; }
@media (max-width: 620px) { .opts.pics { grid-template-columns: 1fr; } }
.unfin { display: grid; gap: 8px; }
.unfin .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; }
.unfin .row b { overflow-wrap: anywhere; } .unfin .row .sp { flex: 1; }
@@ -207,6 +229,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; }
@@ -228,6 +257,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>
@@ -256,6 +293,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>
@@ -341,7 +379,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>
@@ -413,21 +451,54 @@ 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>
<p class="muted" style="font-size:13px;margin-top:-4px" data-i18n="imp.up.hint"></p>
<div class="opts pics" 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>
<div id="verList" class="unfin"></div>
<div id="verList" class="vlist"></div>
<h3 data-i18n="ver.new"></h3>
<div class="vgrid">
<b data-i18n="ver.frame"></b><div class="row" id="verFrame"></div>
<b data-i18n="ver.pc"></b><div class="row" id="verPc"></div>
<b data-i18n="ver.custom"></b><label class="row" style="gap:6px"><input id="verCustom" type="number" min="0.1" max="50" step="0.05" style="width:90px"> <span data-i18n="ver.million"></span></label>
<b data-i18n="ver.for"></b>
<div class="row" id="verFor">
<label class="chk"><input type="radio" name="verFor" value="frame" checked> <span data-i18n="ver.for.frame"></span></label>
<label class="chk"><input type="radio" name="verFor" value="pc"> <span data-i18n="ver.for.pc"></span></label>
</div>
<b id="verSizeHead" data-i18n="ver.size.head"></b><div id="verSizes" class="vsizes"></div>
<b class="hidden" id="verScaleHead" data-i18n="ver.scales"></b><div class="row hidden" id="verScales"></div>
<b data-i18n="ver.clean"></b>
<div><div id="verClean" class="cleanup"></div>
<label class="chk hidden" id="verPlainBox" style="margin-top:6px"><input type="checkbox" id="verPlain"> <span data-i18n="ver.plain"></span></label></div>
</div>
<p class="faint" style="font-size:12px;margin:12px 0 0" data-i18n="ver.measured"></p>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<p id="verPreview" class="vpreview"></p>
<div class="row" style="justify-content:flex-end;margin-top:12px">
<button id="verClose" data-i18n="clean.close"></button>
<button class="primary" id="verMake" data-i18n="ver.make"></button>
<button class="primary" id="verMake"></button>
</div>
</dialog>
@@ -488,6 +559,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);
@@ -541,6 +615,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("");
@@ -548,6 +623,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>`;
@@ -946,8 +1035,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})
@@ -991,10 +1081,17 @@ 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 = tr("res.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;
@@ -1003,7 +1100,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>`;
@@ -1024,8 +1121,14 @@ function sceneCard(s) {
${s.disk ? `<span class="faint">${tr("sc.disk", {size: mb(s.disk.total)})}</span>` : ""}
${scaleLine(s)}
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small" data-versions="${esc(s.slug)}" title="${tr("ver.button.title")}">${tr("ver.button", {n: s.versions || 0})}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
</div></div>`;
<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-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>
${s.versions && versionsOpen(s.slug) ? `<div class="vinline"><div class="vhead"><b>${tr("ver.of", {name: esc(s.name)})}</b><button class="small" data-versions="${esc(s.slug)}">${tr("ver.new.button")}</button></div>
<div class="vlist" data-vlist="${esc(s.slug)}"></div></div>` : ""}`;
}
const ALIGN_LETTER = {frame: "A", images: "B", shift: "C", rigid: "D"};
function groupOpen(slug, set) {
@@ -1065,27 +1168,41 @@ function groupBlock(g, bySlug, byGroup) {
${scaleLine(g)}
${tour}${busy}
<div class="acts">
<button class="small" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : g.room ? "grp.show.recordings" : inner.length ? "grp.show.rooms" : "grp.show", {n: inner.length + own.length})}</button>
<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}) : ""}
${scenes.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</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 ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}
<button class="small" data-gedit="${esc(g.slug)}">${tr("grp.edit")}</button>
<button class="small" data-scale="${esc(g.slug)}" title="${tr("scale.button.title")}" ${g.busy ? "disabled" : ""}>📏${g.scale !== 1 ? " " + ratioText(g.scale) : ""}</button>
${r && r.ply ? `<button class="small" data-versions="${esc(g.slug)}" title="${tr("ver.button.title")}">${tr("ver.button", {n: g.versions || 0})}</button>` : ""}
<button class="small" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
<button class="small" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
${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>
<button class="small icon" data-scale="${esc(g.slug)}" title="${tr("scale.button.title")}" ${g.busy ? "disabled" : ""}>📏${g.scale !== 1 ? " " + ratioText(g.scale) : ""}</button>
<button class="small icon" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
<button class="small icon" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
</div>
<div class="toggles">
<button class="small link" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : g.room ? "grp.show.recordings" : inner.length ? "grp.show.rooms" : "grp.show", {n: inner.length + own.length})}</button>
${r && r.ply ? versionsToggle(g.slug, g.versions) : ""}
</div>
</div>
</div>
${g.versions && versionsOpen(g.slug) ? `<div class="vinline"><div class="vhead"><b>${tr("ver.of", {name: esc(g.name)})}</b><button class="small" data-versions="${esc(g.slug)}">${tr("ver.new.button")}</button></div>
<div class="vlist" data-vlist="${esc(g.slug)}"></div></div>` : ""}
${open ? `<div class="grid ginner">${inner.map(x => groupBlock(x, bySlug, byGroup)).join("")}${own.map(sceneCard).join("")}</div>` : ""}
</div>`;
}
async function loadScenes() {
const seq = S.sceneSeq = (S.sceneSeq || 0) + 1;
const [list, gr] = await Promise.all([api("/api/scenes"), api("/api/groups")]);
const arr = Array.isArray(list) ? list : Object.values(list).filter(x => x && typeof x === "object" && x.slug);
S.scenes = arr; S.groups = gr.groups || []; S.memGB = gr.mem_available_gb;
if (seq !== S.sceneSeq) return; // a newer load is on its way: its answer counts
S.scenes = Array.isArray(list) ? list : Object.values(list).filter(x => x && typeof x === "object" && x.slug);
S.sceneData = gr;
renderScenes();
}
// Draws the list from what was loaded last – opening or closing a group or the versions needs no new
// load (the scene list can take seconds on a slow disk; clicks during it used to toggle back and forth)
function renderScenes() {
const gr = S.sceneData || {}, arr = S.scenes || [];
S.groups = gr.groups || []; S.memGB = gr.mem_available_gb;
const bySlug = Object.fromEntries(arr.map(s => [s.slug, s]));
const inGroup = new Set(S.groups.flatMap(g => g.members));
$("#groupNewBtn").classList.toggle("hidden", !arr.some(s => !inGroup.has(s.slug)));
@@ -1105,9 +1222,12 @@ async function loadScenes() {
const loose = arr.filter(s => !inGroup.has(s.slug));
$("#sceneGrid").innerHTML = loose.length ? loose.map(sceneCard).join("")
: S.groups.length ? "" : `<div class="empty" style="grid-column:1/-1">${tr("scenes.none")}</div>`;
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); loadScenes(); });
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); renderScenes(); });
document.querySelectorAll("[data-vtoggle]").forEach(b => b.onclick = () => { versionsOpen(b.dataset.vtoggle, !versionsOpen(b.dataset.vtoggle)); 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)));
@@ -1291,34 +1411,241 @@ 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;
let shown = 0; // a newer pick wins over an older preview still on its way
const ups = (pics, guess) => {
const keep = document.querySelector('input[name="impUp"]:checked')?.value;
const sel = guess || keep || "y-down";
$("#impUp").innerHTML = ["z-up", "y-down", "y-up"].map(u => `<label class="opt${u === sel ? " active" : ""}">
${pics === null ? "" : pics === "wait" || typeof pics === "string" ? `<div class="pic wait">${esc(pics === "wait" ? tr("imp.preview.wait") : pics)}</div>`
: `<img class="pic" alt="" src="${pics[u]}">`}
<div class="row" style="gap:8px;align-items:flex-start"><input type="radio" name="impUp" value="${u}" ${u === sel ? "checked" : ""}>
<div><b>${tr("imp.up." + u)}</b>${u === guess ? `<span class="guess" style="display:inline">${tr("imp.guess")}</span>` : ""}<span>${tr("imp.up." + u + ".text")}</span></div></div></label>`).join("");
$("#impUp").querySelectorAll("input").forEach(i => i.onchange = () =>
$("#impUp").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked)));
};
$("#impUp").innerHTML = ""; ups(null);
$("#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;
const me = ++shown;
ups("wait");
const p = await api("/api/import/preview", {path: file});
if (me !== shown) return;
if (p.ok) ups(p.images, p.guess);
else ups(tr("imp.preview.none", {detail: p.error ? trErr(p.error) : ""}));
};
$("#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)}&amp;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 ---------- */
const FRAME_SIZES = [0.75, 1, 1.5, 2], PC_SIZES = ["all", 3, 5, 8]; // million splats
const FRAME_RECOMMENDED = 1; // measured on the Frame: as fast as 0.75 M nearly, clearly sharper; 1.5 M already a little unsteady
// 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
const CLEANUPS = ["ceiling", "floor", "haze", "outside"]; // scripts/clean.py
function sizeText(m) { return m === "all" ? tr("ver.all") : tr("million", {n: num(m, Math.round(m * 100) % 10 ? 2 : m % 1 ? 1 : 0)}); }
function versionKey(splats, scale, clean) { // as version_key() in server.py
let key = splats === "all" ? "all" : `${+(splats / 1e6).toPrecision(6)}m`;
if (scale !== 1) key += `-1x${scale}`;
const parts = CLEANUPS.filter(k => clean[k]).map(k => k[0] + Math.round(clean[k] * 100));
return parts.length ? key + "-" + parts.join("") : key;
}
function cleanText(clean) {
return CLEANUPS.filter(k => clean && clean[k]).map(k => `${tr("ver.clean." + k)} ${Math.round(clean[k] * 100)} %`).join(" · ");
}
function versionRow(v) {
const what = (v.purpose === "pc" ? tr("ver.all") : tr("million", {n: num(v.splats / 1e6, 2)})) + (v.scale !== 1 ? " · " + ratioText(v.scale) : "");
const slow = v.purpose === "pc" && v.splats > 2.5e6;
return `<div class="vrow">
<div class="vwhat"><b>${v.purpose === "pc" ? "🖥️" : "🥽"} ${esc(what)}</b>
<span class="muted">${cleanText(v.clean) || tr("ver.clean.none")}</span>
<span class="faint">${tr("million", {n: num(v.splats / 1e6, 2)})} · ${mb(v.size)}${v.stale ? " · " + tr("ver.stale") : ""}</span></div>
<div class="vacts">${v.on_frame === null && !(v.upload && (v.upload.running || v.upload.error)) ? "" : frameState({upload: v.upload, on_frame: v.on_frame})}
${v.ply ? `<button class="small pc-only" data-vspirula="${esc(v.key)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
${S.frameConfigured ? `<button class="small" data-vup="${esc(v.key)}" ${(v.upload && v.upload.running) || v.on_frame === null ? "disabled" : ""} ${slow ? `title="${tr("ver.slow", {n: num(v.splats / 1e6, 1)})}"` : ""}>${tr(v.on_frame ? "sc.reupload" : "sc.toFrame")}${slow ? " ⚠️" : ""}</button>` : ""}
${v.on_frame ? `<button class="small" data-vunframe="${esc(v.key)}" title="${tr("ver.unframe.title")}">${tr("ver.unframe")}</button>` : ""}
<button class="small" data-vtemplate="${esc(v.key)}" title="${tr("ver.template.title")}">${tr("ver.template")}</button>
<button class="small" data-vdel="${esc(v.key)}" title="${tr("ver.delete")}" aria-label="${tr("ver.delete")}">${tr("sc.delete")}</button></div>
</div>`;
}
function bindVersionRows(box, slug, list, redraw) {
const find = key => list.find(x => x.key === key);
box.querySelectorAll("[data-vspirula]").forEach(b => b.onclick = async () => {
const res = await api("/api/open", {slug, version: b.dataset.vspirula, what: "spirula"});
toast(res.ok === false ? trErr(res.error) : tr("sc.spirula.starting"));
});
box.querySelectorAll("[data-vup]").forEach(b => b.onclick = async () => {
const res = await api("/api/upload", {slug, version: b.dataset.vup});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("sc.uploadStarted")); setTimeout(redraw, 1500);
});
box.querySelectorAll("[data-vunframe]").forEach(b => b.onclick = async () => {
const v = find(b.dataset.vunframe);
if (!confirm(tr("ver.unframe.confirm", {name: v.label}))) return;
const res = await api("/api/frame/remove", {slug, version: v.key});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("ver.unframed", {name: v.label})); redraw();
});
box.querySelectorAll("[data-vtemplate]").forEach(b => b.onclick = async () => {
if (!$("#verDlg").open) await openVersions(slug);
useTemplate(find(b.dataset.vtemplate));
});
box.querySelectorAll("[data-vdel]").forEach(b => b.onclick = async () => {
const v = find(b.dataset.vdel);
if (!confirm(tr("ver.confirm", {name: v.label, size: mb(v.size)}))) return;
const res = await api("/api/version/delete", {slug, version: v.key});
if (!res.ok) { toast(trErr(res.error)); return; }
redraw(); loadScenes();
});
}
function verForm() { // what the form would make
const pc = document.querySelector('input[name="verFor"]:checked').value === "pc";
let size = "all";
if (!pc) {
const pick = document.querySelector('input[name="verSize"]:checked');
size = pick.value === "custom" ? Math.round(parseFloat($("#verCustom").value) * 1e6) || 0 : Math.round(parseFloat(pick.value) * 1e6);
}
const sc = document.querySelector('input[name="verScale"]:checked');
const clean = {};
document.querySelectorAll("#verClean input").forEach(i => { if (+i.value) clean[i.dataset.clean] = i.value / 100; });
const any = Object.keys(clean).length > 0;
return {pc, size, scale: sc ? parseFloat(sc.value) : 1, clean, plain: any && $("#verPlain").checked, any};
}
function verPreview() {
const f = verForm(), V = S.verList || [], made = [];
$("#verPlainBox").classList.toggle("hidden", !f.any);
$("#verSizes").classList.toggle("hidden", f.pc); $("#verSizeHead").classList.toggle("hidden", f.pc);
$("#verSizes").querySelectorAll("label.opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked));
if (!f.pc && !(f.size >= 10000)) { $("#verPreview").textContent = tr("ver.pick"); $("#verMake").disabled = true; return; }
for (const cl of f.plain ? [f.clean, {}] : [f.clean]) {
const key = versionKey(f.size, f.scale, cl), old = V.find(v => v.key === key);
const text = (f.pc ? tr("ver.all") : sizeText(f.size / 1e6)) + (f.scale !== 1 ? " · " + ratioText(f.scale) : "") + " · " + (cleanText(cl) || tr("ver.clean.none"));
made.push(old ? tr("ver.replaces", {what: text}) : text);
}
const same = !f.any && f.pc && S.verScene.hasAll; // all splats, not cleaned: the scene itself
$("#verPreview").innerHTML = same ? esc(tr("ver.same.all"))
: esc(tr(made.length > 1 ? "ver.makes.n" : "ver.makes.1", {n: made.length})) + made.map(x => "<br>• " + esc(x)).join("");
$("#verMake").disabled = same;
$("#verMake").textContent = tr(made.length > 1 ? "ver.make.n" : "ver.make.1", {n: made.length});
}
function useTemplate(v) {
if (!v) return;
document.querySelector(`input[name="verFor"][value="${v.purpose}"]`).checked = true;
if (v.purpose === "frame") {
const m = v.splats / 1e6, known = FRAME_SIZES.find(([x]) => Math.abs(x - m) < 0.01);
document.querySelector(`input[name="verSize"][value="${known ? known[0] : "custom"}"]`).checked = true;
if (!known) $("#verCustom").value = num(m, 2).replace(",", ".");
}
const sc = document.querySelector(`input[name="verScale"][value="${v.scale}"]`);
if (sc) sc.checked = true;
document.querySelectorAll("#verClean input").forEach(i => { i.value = Math.round((v.clean[i.dataset.clean] || 0) * 100); i.oninput(); });
verPreview();
toast(tr("ver.template.done"));
}
async function openVersions(slug) {
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
if (!x) return;
const total = (g ? g.result && g.result.splats : x.gaussians) || 0, scale = x.scale || 1;
S.verScene = {slug, hasAll: true};
$("#verTitle").textContent = tr("ver.title", {name: x.name, n: tr("million", {n: num(total / 1e6, 2)})});
const chk = (val, text, on) => `<label class="chk"><input type="checkbox" value="${val}"${on ? " checked" : ""}> ${text}</label>`;
$("#verFrame").innerHTML = FRAME_SIZES.map(m => chk(m, sizeText(m) + (m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""), m === FRAME_RECOMMENDED)).join("");
$("#verPc").innerHTML = PC_SIZES.filter(m => m === "all" || m * 1e6 < total).map(m => chk(m, sizeText(m))).join("");
$("#verCustom").value = "";
$("#verSizes").innerHTML = FRAME_SIZES.map(([m, fps]) => `<label class="opt${m === FRAME_RECOMMENDED ? " active" : ""}">
<input type="radio" name="verSize" value="${m}" ${m === FRAME_RECOMMENDED ? "checked" : ""}>
<div><b>${sizeText(m)}${m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""}</b><span>${tr("ver.fps." + String(m).replace(".", "_"), {fps})}</span></div></label>`).join("")
+ `<label class="opt"><input type="radio" name="verSize" value="custom"><div><b>${tr("ver.custom")}</b>
<span><input id="verCustom" type="number" min="0.1" max="50" step="0.05" style="width:80px"> ${tr("ver.million")}</span></div></label>`;
$("#verClean").innerHTML = `<p class="faint" style="font-size:12px;margin:0 0 6px">${tr("ver.clean.hint")}</p>` + CLEANUPS.map(k => `
<label class="slide" title="${esc(tr("ver.clean." + k + ".title"))}"><span>${tr("ver.clean." + k)}</span>
<input type="range" min="0" max="100" step="10" value="0" data-clean="${k}"><output>${tr("ver.clean.off")}</output></label>`).join("");
$("#verClean").querySelectorAll("input").forEach(i => i.oninput = () => {
i.nextElementSibling.textContent = +i.value ? i.value + " %" : tr("ver.clean.off"); verPreview(); });
$("#verPlain").checked = false;
$("#verScaleHead").classList.toggle("hidden", scale === 1);
$("#verScales").classList.toggle("hidden", scale === 1);
$("#verScales").innerHTML = scale === 1 ? "" : `<label class="chk"><input type="checkbox" value="1" checked> ${tr("scale.original")}</label>`
+ `<label class="chk"><input type="checkbox" value="${scale}"> ${tr("ver.walk", {ratio: ratioText(scale)})}</label>`;
$("#verScales").innerHTML = scale === 1 ? "" : `<label class="chk"><input type="radio" name="verScale" value="1" checked> ${tr("scale.original")}</label>`
+ `<label class="chk"><input type="radio" name="verScale" value="${scale}"> ${tr("ver.walk", {ratio: ratioText(scale)})}</label>`;
$("#verDlg").querySelectorAll('input[type="radio"], #verPlain, #verCustom').forEach(i => { i.onchange = verPreview; i.oninput = verPreview; });
$("#verCustom").onfocus = () => { document.querySelector('input[name="verSize"][value="custom"]').checked = true; verPreview(); };
await drawVersions(slug);
$("#verDlg").showModal();
verPreview();
if (!$("#verDlg").open) $("#verDlg").showModal();
$("#verClose").onclick = () => $("#verDlg").close();
$("#verMake").onclick = async () => {
const sizes = [...document.querySelectorAll("#verFrame input:checked, #verPc input:checked")]
.map(i => i.value === "all" ? "all" : String(Math.round(parseFloat(i.value) * 1e6)));
const custom = parseFloat($("#verCustom").value);
if (custom > 0) sizes.push(String(Math.round(custom * 1e6)));
const scales = scale === 1 ? ["1"] : [...document.querySelectorAll("#verScales input:checked")].map(i => i.value);
if (!sizes.length || !scales.length) { toast(tr("ver.pick")); return; }
const r = await api("/api/versions", {slug, sizes: sizes.join(","), scales: scales.join(",")});
const f = verForm();
const clean = Object.entries(f.clean).map(([k, v]) => `${k}=${v}`).join(",");
const r = await api("/api/versions", {slug, sizes: String(f.size), scales: String(f.scale), clean, also_plain: f.plain});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#verDlg").close(); toast(tr("ver.started")); await poll();
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
@@ -1326,34 +1653,22 @@ async function openVersions(slug) {
}
async function drawVersions(slug) {
const r = await api("/api/versions?slug=" + encodeURIComponent(slug));
const list = r.versions || [];
$("#verList").innerHTML = list.length ? list.map(v => `<div class="row">
<b>${esc(v.label)}</b>
<span class="muted" style="font-size:12.5px">${tr("million", {n: num(v.splats / 1e6, 2)})} · ${mb(v.size)}${v.stale ? " · " + tr("ver.stale") : ""}</span>
<span class="sp"></span>${frameState({upload: v.upload, on_frame: v.on_frame})}
${S.frameConfigured ? `<button class="small" data-vup="${esc(v.key)}" ${v.upload && v.upload.running ? "disabled" : ""}>${tr(v.on_frame ? "sc.reupload" : "sc.toFrame")}</button>` : ""}
${v.on_frame ? `<button class="small" data-vunframe="${esc(v.key)}" title="${tr("ver.unframe.title")}">${tr("ver.unframe")}</button>` : ""}
<button class="small" data-vdel="${esc(v.key)}" title="${tr("ver.delete")}" aria-label="${tr("ver.delete")}">${tr("sc.delete")}</button>
</div>`).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
$("#verList").querySelectorAll("[data-vup]").forEach(b => b.onclick = async () => {
const res = await api("/api/upload", {slug, version: b.dataset.vup});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("sc.uploadStarted")); setTimeout(() => drawVersions(slug), 1500);
});
$("#verList").querySelectorAll("[data-vunframe]").forEach(b => b.onclick = async () => {
const v = list.find(x => x.key === b.dataset.vunframe);
if (!confirm(tr("ver.unframe.confirm", {name: v.label}))) return;
const res = await api("/api/frame/remove", {slug, version: v.key});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("ver.unframed", {name: v.label})); drawVersions(slug);
});
$("#verList").querySelectorAll("[data-vdel]").forEach(b => b.onclick = async () => {
const v = list.find(x => x.key === b.dataset.vdel);
if (!confirm(tr("ver.confirm", {name: v.label, size: mb(v.size)}))) return;
const res = await api("/api/version/delete", {slug, version: v.key});
if (!res.ok) { toast(trErr(res.error)); return; }
drawVersions(slug); loadScenes();
});
const list = S.verList = r.versions || [];
$("#verList").innerHTML = list.length ? list.map(versionRow).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
bindVersionRows($("#verList"), slug, list, () => drawVersions(slug).then(verPreview));
}
async function fillVersionLists() { // the opened lists under scenes and groups
for (const box of document.querySelectorAll("[data-vlist]")) {
const slug = box.dataset.vlist, r = await api("/api/versions?slug=" + encodeURIComponent(slug)), list = r.versions || [];
box.innerHTML = list.map(versionRow).join("") || `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
bindVersionRows(box, slug, list, fillVersionLists);
}
}
function versionsOpen(slug, set) { return groupOpen("ver:" + slug, set); }
function versionsToggle(slug, n) {
if (!n) return `<button class="small link" data-versions="${esc(slug)}" title="${tr("ver.button.title")}">${tr("ver.first")}</button>`;
const open = versionsOpen(slug);
return `<button class="small link" data-vtoggle="${esc(slug)}" aria-expanded="${open}" title="${tr("ver.button.title")}">${tr(open ? "ver.hide" : n === 1 ? "ver.show.1" : "ver.show", {n})}</button>`;
}
/* ---------- scale (models) ---------- */
@@ -1593,10 +1908,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>