Compare commits

..
10 Commits
Author SHA1 Message Date
thunderheronightandClaude Opus 5.5 2ce2c5696b 0.3.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:04:24 +02:00
thunderheronightandClaude Opus 5.5 fccaf4afc6 Folder names without a trailing dot (NTFS refuses them)
Found moving the runs to an NTFS disk: the tour "Rundgang 12.10." (folder Rundgang-12.10.) was not copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:02:35 +02:00
thunderheronightandClaude Opus 5.5 3cf0e81aef Fixes from the review; release 0.3.0
- The watcher reports the next part (or putting the parts together) as running in the tick that
  starts it: no false standby notice between parts.
- Deleting an unfinished part deletes its piece of video in runs/.parts too.
- combine.sh keeps the floor plan's labels (plan.json).
- Tests: the chain through the watcher's tick (twice), one standby notice per chain, the own lock
  released before suspending, rooms of the same name, view-dependent colour matched, the preview plan
  of a home named by rooms; notifications are awaited instead of checked too early.
- 0.3.0: version, changelog, READMEs without the preview notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:41:49 +02:00
thunderheronightandClaude Opus 5.5 ae9655bd3d Standby when done; kept awake between jobs; renamed rooms get a new floor plan
- A checkbox on the running job: when everything is finished (parts, putting them together, copying),
  a minute's notice, then systemctl suspend; cancelling a job switches it off; a refused standby is
  reported. Our own lock is released first (it kept the PC up in a first try by hand).
- One sleep lock of the server spans the seconds between two jobs (parts of a long recording).
- A renamed room's floor plan and previews are drawn again with the new name.
- Rooms of the same name: "Kitchen (2)"; the combine dialog and job card speak of a room's recordings.
- README: filming tips for ceilings and light; 0.3 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:54:40 +02:00
thunderheronightandClaude Opus 5.5 58cc77d2f3 Rooms: no dark holes from recordings that had nothing there; one brightness for all recordings
A place only goes to a recording that really has something there (a quarter of the richest one's splat
opacity, over the place and its neighbours). Colours of all recordings are matched per channel from the
places they share (--colour match|keep, COLOUR for combine.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:55:21 +02:00
thunderheronightandClaude Opus 5.5 de6c632267 Split a long video into parts that become one room
Each part gets up to 500 image pairs; parts are cut losslessly with the original time stamps, converted
one after the other and put together as a room. The original stays (asked once more before deleting).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:02:44 +02:00
thunderheronightandClaude Opus 5.5 05fd105480 README (en/de) for the 0.3.0 preview; German README catches up with 0.2.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:46:33 +02:00
thunderheronightandClaude Opus 5.5 5c35fbb15c Mark 0.3 as experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:02:52 +02:00
thunderheronightandClaude Opus 5.5 0e223a18fb Rooms in the web interface: room groups, homes made of rooms, nested display
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:10:56 +02:00
thunderheronightandClaude Opus 5.5 b9b60fa5a0 Rooms made of several recordings: combine, floor plan and groups (core, no UI yet)
A group can be a room (several recordings of one room); any other group may hold scenes and rooms and
is put together from the recordings, each in its room. Recordings of one room no longer count as
walking in to each other, which also hid a real walk-in from another room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:06:23 +02:00
20 changed files with 1476 additions and 120 deletions

No files matched your search

+29
View File
@@ -2,6 +2,33 @@
All notable changes to Splat Workshop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/).
## [0.3.1] – 2026-10-15
### Fixed
- **Folder names without a dot at the end**: a name like "Tour 12.10." gave the folder `Tour-12.10.`, which Windows file systems (NTFS, e.g. a second disk the runs are moved to) refuse – the copy silently lacked that tour. Now it is `Tour-12.10`. Existing folders keep their names.
## [0.3.0] – 2026-10-15
### Added
- **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 it is put together, every part of the room comes from the recording that saw it best. Each recording still gets up to 500 image pairs, so several short recordings give a room far more detail than one long one.
- **Split a long video**: for a recording longer than its quality preset can use (500 image pairs – 100 s at *High*), Splat Workshop offers *Split the video* or *Convert in one piece*, each with its pros and cons. Split, it suggests as few parts as give the full rate of image pairs, all equally long (a 4-minute recording at *High*: 3 parts of 1:24 with 420 pairs each instead of 2 pairs per second), converts them one after the other and puts them together into one room. The parts are cut losslessly with the original time stamps (the pipeline matches images and poses by time); the original recording is never changed and is needed to cut again, so deleting it asks once more.
- **A home made of rooms**: a group may hold such rooms next to single scenes. Putting it together always starts from the original recordings in one go, each in its room: within a room every part from the best recording, between rooms the borders run through the doors as before. Nothing is reduced twice. The floor plan names and measures rooms instead of recordings, and the scale of a home reaches its rooms and their recordings.
- **Standby when done**: a checkbox on the running job. When everything is finished – also the following parts of a long recording, putting them together and copying to the Frame – the PC goes to standby after a minute's notice (a notification says so; untick to stop it). Cancelling a job switches it off: someone is there. If the standby is refused (e.g. another program keeps the PC awake), a notification says why.
- **One brightness for all recordings**: every training settles on its own brightness, so the same wall could come out a third darker in one recording than in the next. Putting them together now brings all recordings to one brightness per colour channel, from the places they share (`COLOUR=keep` for combine.sh leaves them as they are). The factors are in combine.json.
### Fixed
- **Dark holes where a recording had nothing**: a place went to the recording whose cameras looked at it from close by, even when its training had left next to nothing there (seen from behind furniture, too dark …). Now a place only goes to a recording that has at least a quarter of what the richest one has there (compared over the place and its neighbours). In a real living room of three parts, the places left thin dropped from 8.5 % to 1.9 %.
- **Kept awake between jobs**: every job keeps the PC awake while it runs, but between two of them (the parts of a long recording, then putting them together) there were a few seconds without – enough for the desktop to suspend a PC that has been idle for hours. Splat Workshop now holds one lock of its own for all of it, until a little after the last job.
- **A renamed room** gets its floor plan and preview images drawn again with the new name (before: the old name until it was put together again).
- Two rooms of the same name in a home are told apart as "Kitchen" and "Kitchen (2)" (was "Kitchen ·").
- The dialog for putting a room together explains rooms, not tours.
- A part of a long recording that failed or was cancelled keeps its piece of video for resuming; deleting the unfinished run now deletes that piece too (it is as big as that stretch of the recording and was in no list).
- Recordings of the same room no longer count as "walking in" to each other. Before, two overlapping recordings could hide the real walk-in of another room, which then claimed part of the room.
## [0.2.3] – 2026-10-14
### Added
@@ -92,6 +119,8 @@ First release.
- **Floor plans** (experimental) in two styles with a measuring tool.
- Configurable video player; English and German interface.
[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
[0.2.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.0...v0.2.1
+23 -5
View File
@@ -19,8 +19,21 @@ Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork
1. **Aufnahme wählen** – direkt von der Frame (per SSH) oder vom PC, mit Vorschaubildern.
2. **Prüfen**, bevor eine Stunde Rechenzeit draufgeht: Helligkeit, Struktur, schnelle Schwenks, verdeckte Linse – Sekunde für Sekunde.
3. **Umwandeln** mit Qualitätsstufe (Schnell / Hoch / Maximal) oder eigenen Werten, mit Fortschritt, Restzeit und PSNR-Kurve.
4. **In die Galerie**: Vorschaubild und Namensschild im Stil der Beispielszenen, kopiert in den `Scenes`-Ordner von Gaussian XR auf der Frame.
5. **Die Wohnung zusammensetzen**: Räume gruppieren und zu einem Rundgang machen, durch den du gehst – voll für den PC, kleiner für die Frame.
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.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.
- ✂️ **Lange Videos aufteilen** – bei langen Aufnahmen bietet die Werkstatt *Video aufteilen* (volle Detailtiefe, dauert länger) oder *Video nicht aufteilen* (schneller, weniger Bildpaare pro Sekunde) an. Aufgeteilt werden die Teile nacheinander umgewandelt und von selbst zu einem Raum. Die Original-Aufnahme bleibt unverändert.
- 🏠 **Eine Wohnung aus Räumen** – eine Gruppe darf solche Räume neben einzelnen Szenen enthalten. Zusammengesetzt wird in einem Durchgang aus den Original-Aufnahmen: innerhalb eines Raums jeder Teil aus der besten Aufnahme, zwischen den Räumen laufen die Grenzen durch die Türen. Nichts wird doppelt ausgedünnt; der Grundriss benennt und misst Räume statt Aufnahmen.
- 🧩 **Bessere Übergänge zwischen Aufnahmen** – eine Stelle kommt nur noch von einer Aufnahme, die dort wirklich etwas hat (keine dunklen Löcher mehr, wo eine Kamera „von hinter dem Sofa“ hingeschaut hat), und alle Aufnahmen werden auf eine Helligkeit gebracht: Jedes Training landet bei seiner eigenen, im selben Raum bis zu einem Drittel auseinander.
- 💤 **Danach in den Standby** – am laufenden Job anhaken: Wenn alles fertig ist (alle Teile, das Zusammensetzen, das Kopieren auf die Frame), geht der PC nach einer Minute Vorwarnung in den Standby. Bis dahin bleibt er wach, auch in den Sekunden zwischen zwei Jobs.
- 🐛 **Behoben**: Aufnahmen desselben Raums gelten nicht mehr gegenseitig als „Weg hinein“ – vorher konnten sie den echten Weg aus einem anderen Raum verdecken, der sich dann einen Teil des Raums nahm.
## Neu in 0.2.3
- 🚂 **Maßstab für Modelle**, 🎚️ **Fassungen** nebeneinander, ⚡ **1 Mio. Splats als Empfehlung für die Frame**, 📂 Szenen in `Documents/GaussianXR` (Steam-Updates lassen den Ordner in Ruhe), 🥽 Reiter **Auf der Frame**, ⬇️ **Alle Aufnahmen holen**, 🗑️ **Aufnahmen einzeln löschen**. Details: [Release 0.2.3](https://github.com/thunderheronight/splat-workshop/releases/tag/v0.2.3).
## Neu in 0.2
@@ -42,13 +55,16 @@ Alle Änderungen: [CHANGELOG.md](CHANGELOG.md) (englisch)
- **Umwandlungen laufen unabhängig weiter** – Browser zu, Tray beendet, Neustart: Werkstatt wieder öffnen, Fortschritt ist wieder da.
- **Abbrechen & Fortsetzen** ab dem letzten Zwischenstand.
- **Aufnahmeprüfung ohne KI**: reine Messwerte je Sekunde, farbige Zeitleiste und konkrete Hinweise („0:45–0:59 wenig Struktur“).
- **Räume aus mehreren Aufnahmen** *(0.3)*: Ein Raum kann aus mehreren kurzen Aufnahmen bestehen (jede bis zu 500 Bildpaare) und eine Wohnung aus solchen Räumen – jeder Teil kommt aus der Aufnahme, die ihn am besten gesehen hat, die Grenzen zwischen den Räumen laufen durch die Türen. Räume erscheinen in der Liste innerhalb ihrer Wohnung und lassen sich auch einzeln zusammensetzen, ansehen und auf die Frame bringen.
- **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.
- **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.
- **Maßstab für Modelle** wie eine Modelleisenbahn (Z, N, TT, H0, S, 0, G oder eigener): Grundriss, Größen und Messwerkzeug zeigen die Größe des Originals, und auf Wunsch zeigt Gaussian XR das Modell begehbar in Originalgröße *(an der Frame noch nicht getestet)*.
- **Grundrisse** *(experimentell)* einzelner Szenen oder einer ganzen Gruppe – Aufnahmen aus einem Durchgang fügen sich von selbst zum Grundriss der ganzen Wohnung. Zwei Stile: *Hübsch* (Draufsicht in Farbe, Wände, gelaufener Weg) und *Klassisch* (leuchtende Punktdichte), dazu ein einfaches Messwerkzeug (Punkte anklicken, Abstände ablesen – etwa ±5 cm genau).
- **Grundrisse** *(experimentell)* einzelner Szenen oder einer ganzen Gruppe – Aufnahmen aus einem Durchgang fügen sich von selbst zum Grundriss der ganzen Wohnung; ein Raum aus mehreren Aufnahmen erscheint als ein Raum mit allen Wegen. Zwei Stile: *Hübsch* (Draufsicht in Farbe, Wände, gelaufener Weg) und *Klassisch* (leuchtende Punktdichte), dazu ein einfaches Messwerkzeug (Punkte anklicken, Abstände ablesen – etwa ±5 cm genau).
- **Fertige Szenen**: später auf die Frame übertragen, erneut übertragen, umbenennen (Ordner und Namensschild ziehen mit), löschen (in den Papierkorb), Ordner oder Spirula Studio öffnen.
- **Abgebrochene Läufe** werden mit ihrem Stand angezeigt: fortsetzen, neu starten oder löschen.
- **Speicherplatz und Aufräumen**: Jede Szene zeigt, wie viel Platz sie belegt (etwa 4 GB pro Raum). Ein Klick löscht Zwischenstände oder Trainingsbilder – Szene, Grundriss und Viewer funktionieren weiter.
@@ -77,14 +93,16 @@ cd splat-workshop
Danach in `~/.config/splat-workshop/config.toml` die Pfade zur Arcturus-Pipeline ([arcturus.vision/apps](https://arcturus.vision/apps)), zu Spirula Studio und die Adresse der Frame eintragen. Was noch fehlt, zeigt die Werkstatt oben auf der Seite an.
Nach dem Übertragen **Gaussian XR auf der Frame neu starten**, damit die Szene in der Galerie erscheint.
Nach dem Übertragen **Gaussian XR auf der Frame neu starten**, damit die Szene in der Galerie erscheint. Die Szenen liegen in `Documents/GaussianXR`, den Steam-Updates in Ruhe lassen (bis 0.2.3 lagen sie im `Scenes`-Ordner von Gaussian XR – *Zu den eigenen Szenen verschieben* im Reiter *Auf der Frame* holt sie herüber). Alles bleibt außerdem auf dem PC, *Erneut übertragen* stellt eine Szene wieder her. Für die Frame läuft etwa **1 Mio. Splats** flüssig und sieht noch scharf aus.
## Aufnahmetipps
- Viel Licht (Tageslicht plus Raumlicht), **HDR mit 30 fps** in der Kamera-App, nicht „Linear“.
- **Räume**: 30–90 s pro Raum, langsam gehen und langsam drehen, Dinge von mehreren Seiten zeigen.
- **Große Räume**: Die Pipeline nimmt höchstens 500 Bildpaare pro Aufnahme – bei 5 pro Sekunde (*Hoch*) ist das nach 100 s erreicht, eine 4-Minuten-Aufnahme bekommt nur noch 2 pro Sekunde. Bei einer langen Aufnahme beim Start *Video aufteilen* wählen – oder zwei oder drei kürzere Aufnahmen eines großen Raums in einem Durchgang machen und sie als *Ein Raum aus mehreren Aufnahmen* gruppieren.
- **Gegenstände**: langsam im Kreis darum herumgehen (15–40 s), Motiv in der Bildmitte halten.
- Nicht lange auf kahle Wände, Decke oder Fliesen halten.
- Nicht lange auf kahle Wände, Decke oder Fliesen halten. Die **Decke** aber ab und zu mitnehmen, zusammen mit der Wandoberkante oder einer Lampe: Eine weiße Decke, die kaum gefilmt wurde, wird zu einem dunklen Schleier über dem Raum.
- **Gleiches Licht**: Gegen Abend ändert sich das Tageslicht schon innerhalb von Minuten merklich, und Aufnahmen eines Raums bei verschiedenem Licht passen schlecht zusammen.
- Bewegte Personen werden zu Geisterschleiern.
## Transparenzhinweis
+21 -7
View File
@@ -22,7 +22,16 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
2. **Check it** before spending an hour on it: brightness, structure, fast pans and covered lenses, second by second.
3. **Convert it** with a quality preset (Fast / High / Maximum) or fine-tuned values, with live progress, ETA and a PSNR curve.
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's folder for personal scenes on the Frame (`Documents/GaussianXR`).
5. **Put your home together**: group the rooms and turn them into one tour you walk through – full size for the PC, smaller for the Frame.
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.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.
- ✂️ **Split a long video** – for long recordings Splat Workshop offers *Split the video* (full detail, takes longer) or *Convert in one piece* (sooner, fewer image pairs per second). Split, the parts are converted one after the other and become one room by themselves. The original recording stays untouched.
- 🏠 **A home made of rooms** – a group may hold such rooms next to single scenes. It is put together from the original recordings in one go: within a room every part from the best recording, between rooms the borders run through the doors. Nothing is reduced twice; the floor plan names and measures rooms instead of recordings.
- 🧩 **Better seams between recordings** – a place only comes from a recording that really has something there (no more dark holes where a camera “looked at” a spot from behind the sofa), and all recordings are brought to one brightness: every training settles on its own, up to a third apart in the same room.
- 💤 **Standby when done** – tick it on the running job: when everything is finished (all parts, putting them together, copying to the Frame), the PC goes to standby after a minute's notice. In between, the PC is kept awake, also in the seconds between two jobs.
- 🐛 **Fixed**: recordings of the same room no longer count as “walking in” to each other – before, they could hide the real walk-in of another room, which then claimed part of the room.
## New in 0.2
@@ -44,14 +53,16 @@ All changes: [CHANGELOG.md](CHANGELOG.md)
- **Conversions survive everything**: they run as their own process; close the browser, quit the tray, restart – reopen Splat Workshop and you see the progress again.
- **Cancel & resume**: a cancelled conversion continues from its last saved state.
- **Recording check** without AI: plain per-second measurements with a colour-coded timeline and concrete hints (“0:45–0:59 little structure”). See [What the check measures](#what-the-check-measures).
- **Rooms from several recordings** *(0.3)*: a room can be made of several short recordings (each up to 500 image pairs), and a home of such rooms – every part comes from the recording that saw it best, the borders between rooms run through the doors. Rooms appear inside their home in the list and can also be put together, viewed and copied to the Frame on their own.
- **Groups and tours**: group scenes (e.g. all rooms of your home) and put them together into one scene – a full version for Gaussian XR on the PC and a smaller one for the Frame. Recordings from one session share the Frame's tracking frame; Splat Workshop fits the rooms to each other (corrections A–D to choose), lets the borders between recordings run through doors and shows how well the rooms meet. Before starting it estimates the memory needed.
<br><img src="docs/combine.png" width="360" alt="Dialog “Put together”: corrections A to D, borders between the recordings, size of the Frame version, full version, memory estimate">
<br><sub>Example data – room names and preview images are placeholders.</sub>
- **Versions** of a scene or tour side by side: fewer splats for the Frame (0.75–2 M), all or a limit for the PC, or your own size – each with its own place in the Frame's gallery, so you can compare what runs smoothly.
- **Fetch all** recordings from the Frame, or a selection, one after the other – with a notification when the Frame can be switched off.
- **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.
- **Scale for models** like a model railway (Z, N, TT, H0, S, 0, G or your own): floor plan, sizes and the measuring tool show the original's size, and optionally Gaussian XR shows the model in its original size to walk through *(not tested on the Frame yet)*.
- **Floor plans** *(experimental)* of single scenes or a whole group – recordings from one session line up by themselves into a plan of your whole home. Two styles: *Pretty* (colour top view, walls, the path you walked) and *Classic* (glowing point density), plus a simple measuring tool (click points, get distances – about ±5 cm).
- **Floor plans** *(experimental)* of single scenes or a whole group – recordings from one session line up by themselves into a plan of your whole home; a room made of several recordings is drawn as one room with all its paths. Two styles: *Pretty* (colour top view, walls, the path you walked) and *Classic* (glowing point density), plus a simple measuring tool (click points, get distances – about ±5 cm).
<br><img src="docs/floorplan.png" width="640" alt="Combined floor plan of an example home in the classic style, with the measuring tool">
<br><sub>Example home – synthetic data, drawn with the same renderer. Combining rooms relies on the Frame keeping its tracking frame between recordings; how well that holds for a whole home is still being tested.</sub>
- **Finished scenes**: copy to the Frame later, re-copy, rename (folder and name plate follow), delete (to the trash), open the folder or open the scene in Spirula Studio.
@@ -114,9 +125,9 @@ Start **Splat Workshop** from the menu (or `./splat-workshop`). The tray icon ap
| `./splat-workshop --background` | tray icon only (autostart) |
| `./splat-workshop --no-tray` | without tray; the server stops itself after 20 idle minutes |
| `./splat-workshop --server` | server in the foreground |
| `scripts/splat.sh <recording> "<name>"` | convert on the command line (`QUALITY=fast\|high\|max`, `NO_UPLOAD=1`, `--resume`) |
| `scripts/splat.sh <recording> "<name>"` | convert on the command line (`QUALITY=fast\|high\|max`, `NO_UPLOAD=1`, `PART=120-245` for a stretch, `--resume`) |
| `scripts/to-frame.sh <run> "<name>"` | (re)copy a finished scene to the Frame |
| `scripts/combine.sh <name> "<display name>" <run>=<label> ...` | put scenes together into a tour (`ALIGN=frame\|images\|shift\|rigid`, `FRAME_SPLATS`, `FULL=0`, `NO_UPLOAD=1`) |
| `scripts/combine.sh <name> "<display name>" <run>=<label> ...` | put scenes together into a tour (`ALIGN=frame\|images\|shift\|rigid`, `FRAME_SPLATS`, `FULL=0`, `NO_UPLOAD=1`, `ROOMS='["Kitchen","Kitchen","Hallway"]'` for rooms made of several recordings) |
After copying, **restart Gaussian XR** on the Frame so the new scene appears in the gallery. Scenes go to `Documents/GaussianXR`, which Steam updates leave alone (Splat Workshop 0.2.3 and older used Gaussian XR's own `Scenes` folder – *Move to own scenes* in the *On the Frame* tab moves them over). Everything also stays on the PC, and *Copy again* restores a scene. For the Frame, about **1 M splats** runs smoothly and still looks sharp. The *On the Frame* tab under the finished scenes shows what is in the gallery on the Frame and takes things off it.
@@ -129,8 +140,10 @@ Set `lan = true` in `[server]`. The page is then reachable at `http://<pc addres
- **Light**: daylight plus room lights indoors. Dark recordings get noisy and patchy.
- **HDR, 30 fps** in the camera app works best; the pipeline tone-maps HLG automatically. Don't use “Linear”.
- **Rooms**: 30–90 s each, walk slowly, turn slowly, look at things from several sides.
- **Large rooms**: the pipeline uses at most 500 image pairs per recording – at 5 per second (*High*) that is reached after 100 s, a 4-minute recording only gets 2 per second. For a long recording, choose *Split the video* when starting – or make two or three shorter recordings of a large room in one session and group them as *one room made of several recordings*.
- **Objects**: walk around them in a slow circle (15–40 s) and keep them in the middle of the picture.
- Avoid pointing at **bare walls, ceilings or tiles** for long – there is nothing for the software to hold on to.
- Avoid pointing at **bare walls, ceilings or tiles** for long – there is nothing for the software to hold on to. But do include the **ceiling** now and then, together with the top of the walls or a lamp: a white ceiling that was hardly filmed turns into a dark veil hanging over the room.
- **Same light** throughout: daylight changes noticeably within minutes towards the evening, and recordings of one room taken at different light do not match well.
- Moving people or pets become ghostly smears.
## What the check measures
@@ -158,6 +171,7 @@ It is a measurement, not image recognition: it does not detect people or moving
| Maximum | 8 per second (100–500) | full | 3 M | 50k | ~1.5 h+ |
\* RTX 4070 SUPER. Spirula keeps training images on disk, so 12 GB of VRAM are enough for “High”. A run needs a few GB of free disk space.
The 100–500 image pairs per recording are a limit of the Arcturus pipeline. Above 100 s (*High*) or 63 s (*Maximum*) a recording gets fewer pairs per second, and *Maximum* adds no images any more – see *Large rooms* above.
## Files
@@ -165,7 +179,7 @@ It is a measurement, not image recognition: it does not detect people or moving
config: ~/.config/splat-workshop/config.toml
recordings: [paths] recordings (downloaded from the Frame)
scenes: [paths] runs/<name>/ (scene.sog, spirula/scene.ply, gallery files, intermediate data)
tours: [paths] runs/<group>/ (scene.sog = Frame version, full/ = full version, spirula/scene.ply, combine.json)
tours and rooms: [paths] runs/<group>/ (scene.sog = Frame version, full/ = full version, spirula/scene.ply, combine.json)
internal state: next to runs/ in .splat-workshop/ (logs, thumbnails, check results)
```
@@ -179,7 +193,7 @@ uv run --no-project --with pytest --with numpy --with scipy --with pillow pytest
They need no GPU, no recordings and no Steam Frame: the Arcturus pipeline, Spirula, splat-transform, ffprobe, the desktop trash and notifications are replaced by small fakes, the Frame is a folder reached through a fake `ssh`/`scp`, and putting rooms together is tested on synthetic rooms with a known error – turned, shifted and scaled reconstructions, tracking noise, drift and turn (`tests/synth.py`).
Covered: who may talk to the server (other websites, LAN mode, malformed or wrongly typed requests, paths outside the runs folder), the recording check, renaming recordings and scenes, clean-up, deleting, cancelled runs, a scene being left alone while it is computed, recovery after a power cut, groups, conversions with cancel/start over/resume (the real `splat.sh`), the Frame's list, downloads and copying scenes over (the real `to-frame.sh`), notifications and tidying up after a job, tours (the real `combine.sh`), the four corrections, splat orientation and size, borders through doors, and that the floor plan is not mirrored. The tests were reviewed independently with mutation testing (deliberately broken code must make them fail). Not covered: the real pipeline, Spirula, the real Frame, the tray icon, the browser interface and the server stopping itself when idle – those are tested by hand.
Covered: who may talk to the server (other websites, LAN mode, malformed or wrongly typed requests, paths outside the runs folder), the recording check, renaming recordings and scenes, clean-up, deleting, cancelled runs, a scene being left alone while it is computed, recovery after a power cut, groups, conversions with cancel/start over/resume (the real `splat.sh`), the Frame's list, downloads and copying scenes over (the real `to-frame.sh`), notifications and tidying up after a job, tours (the real `combine.sh`), rooms made of several recordings and homes made of rooms, the four corrections, splat orientation and size, borders through doors, and that the floor plan is not mirrored. The tests were reviewed independently with mutation testing (deliberately broken code must make them fail). Not covered: the real pipeline, Spirula, the real Frame, the tray icon, the browser interface and the server stopping itself when idle – those are tested by hand.
## Uninstall
+154 -19
View File
@@ -5,7 +5,11 @@
"""Put several scenes of one recording session together into one big scene.
Usage: uv run combine.py <out folder> <run folder>=<label> [<run folder>=<label> ...]
[--align frame|images|shift|rigid] [--partition view|nearest]
[--align frame|images|shift|rigid] [--partition view|nearest] [--rooms JSON]
[--colour match|keep]
--rooms: the room of every recording, in the same order, e.g. '["Kitchen", "Kitchen", "Hallway"]' – a room
can be made of several recordings (default: every recording is a room of its own).
All recordings of one session share the Steam Frame's tracking frame, so the rooms are already
roughly in place. Steps:
@@ -16,9 +20,16 @@ roughly in place. Steps:
rigid images + a joint shift and slight rotation
2. Who owns which part (--partition):
view per 10 cm cube the recording that saw it from close by and looking at it; the first and
last stretch of a recording along another recording's round (walking in/out) does not
count, so the borders run through doors (default)
last stretch of a recording along the round of another room's recording (walking in/out)
does not count, so the borders between rooms run through doors (default). Recordings of
the same room simply share it: every part from the recording that saw it best.
nearest the recording whose camera was closest
Either way a cube only goes to a recording that really has something there: at least a quarter of
what the richest one has (splat opacity summed per cube). A camera that "looked at" a place from
behind the sofa does not leave a dark hole there.
2b. Colour (--colour): every training settles on its own brightness, so the same wall can come out a
third darker in one recording than in the next. `match` (default) brings all of them to one
brightness per colour channel, from the cubes they share; `keep` leaves them as they are.
3. Every room is transformed, cut to its own part and appended – one room in memory at a time.
Writes <out>/spirula/scene.ply (all splats), <out>/scene.xrSceneTransform.json (start view of the
@@ -39,6 +50,10 @@ from scipy.spatial import cKDTree
from scipy.spatial.transform import Rotation
VOXEL, RADIUS, K = 0.10, 1.5, 64 # ownership: cube size, camera reach, cameras looked at
SHARE = 0.25 # a cube's owner needs at least this share of the richest one's splats
SOLID = 3.0 # summed opacity of a cube something really sits in
GAIN = (0.7, 1.4) # colour matching never changes a recording more than this
C0 = 0.28209479177387814 # spherical harmonics, band 0
BOTH = 1.5 # an overlap: places within this distance of both recordings' cameras
@@ -236,23 +251,116 @@ def joint_rigid(rooms, zones, poses, fixed, centre):
return poses
# ---------------------------------------------------------------- what every recording has where
def cube_codes(points):
"""10 cm cube of every point as one number (±100 km is plenty)."""
k = np.floor(points / VOXEL).astype(np.int64) + (1 << 20)
return (k[:, 0] << 42) | (k[:, 1] << 21) | k[:, 2]
def contents(clouds):
"""clouds: per recording (cube codes, opacity, rgb) of its splats. Per cube: summed opacity and
opacity-weighted colour of every recording – the cube codes (sorted), cover [cubes, n], colour [cubes, n, 3]."""
codes = [c for c, _, _ in clouds]
table = np.unique(np.concatenate(codes))
cover = np.zeros((len(table), len(clouds)))
colour = np.zeros((len(table), len(clouds), 3))
for i, (c, (_, a, rgb)) in enumerate(zip(codes, clouds)):
j = np.searchsorted(table, c)
cover[:, i] = np.bincount(j, a, len(table))
for ch in range(3):
colour[:, i, ch] = np.bincount(j, a * rgb[:, ch], len(table))
colour /= np.maximum(cover, 1e-9)[:, :, None]
return table, cover, colour
def around(table, cover):
"""Summed over each cube and its 26 neighbours: two recordings' copies of one wall, a centimetre apart,
often fall into neighbouring cubes – compared cube by cube, each would seem to have the wall alone."""
out = np.zeros_like(cover)
for dx, dy, dz in itertools.product((-1, 0, 1), repeat=3):
n = table + ((dx << 42) + (dy << 21) + dz)
j = np.clip(np.searchsorted(table, n), 0, len(table) - 1)
hit = table[j] == n
out[hit] += cover[j[hit]]
return out
def colour_gains(cover, colour, min_cubes=200):
"""Per recording and colour channel a factor that brings all recordings to one brightness: from the
cubes two of them both cover well, least squares on the log ratios, all factors together neutral."""
n = cover.shape[1]
gains = np.ones((n, 3))
if n < 2:
return gains
for ch in range(3):
rows, rhs = [], []
for a, b in itertools.combinations(range(n), 2):
m = (cover[:, a] > SOLID) & (cover[:, b] > SOLID) & (colour[:, a, ch] > 0.02) & (colour[:, b, ch] > 0.02)
if m.sum() < min_cubes:
continue
row = np.zeros(n) # log g_a - log g_b = log(c_b / c_a)
row[a], row[b] = 1, -1
w = np.sqrt(m.sum())
rows.append(row * w)
rhs.append(np.median(np.log(colour[m, b, ch] / colour[m, a, ch])) * w)
if not rows:
continue
rows.append(np.ones(n) * 100) # together neutral: the room keeps its overall brightness
rhs.append(0.0)
x, *_ = np.linalg.lstsq(np.array(rows), np.array(rhs), rcond=None)
gains[:, ch] = np.clip(np.exp(x), *GAIN)
return gains
def apply_gain(v, gain):
"""Colour times `gain` (per channel), the view-dependent part included."""
rest = sorted((p for p in v.dtype.names if p.startswith("f_rest_")), key=lambda p: int(p[7:]))
per = len(rest) // 3 # f_rest: all of red, then green, then blue
for ch in range(3):
g = np.float32(gain[ch])
v[f"f_dc_{ch}"] = (g * (0.5 + C0 * v[f"f_dc_{ch}"]) - 0.5) / C0
for p in rest[ch * per:(ch + 1) * per]:
v[p] *= g
# ---------------------------------------------------------------- ownership
def owners(rooms, poses, partition):
"""Function: points -> index of the owning room."""
def owners(rooms, poses, partition, have=None):
"""Function: points -> index of the owning recording."""
labels = list(rooms)
cams = [poses[l](rooms[l]["cams"]) for l in labels]
fwd = [(poses[l].R @ rooms[l]["fwd"].T).T for l in labels]
owner = np.concatenate([np.full(len(c), i) for i, c in enumerate(cams)])
def richest(lab, codes):
"""Only a recording with at least SHARE of the richest one's splats in a cube may own it."""
if have is None:
return lab
table, cover = have
j = np.clip(np.searchsorted(table, codes), 0, len(table) - 1)
c = np.where((table[j] == codes)[:, None], cover[j], 0.0)
able = c >= SHARE * c.max(1, keepdims=True)
thin = ~able[np.arange(len(lab)), lab]
lab = lab.copy()
lab[thin] = c[thin].argmax(1)
return lab
if partition == "nearest":
tree = cKDTree(np.vstack(cams))
return lambda p: owner[tree.query(p, workers=-1)[1]], {}
return lambda p: richest(owner[tree.query(p, workers=-1)[1]], cube_codes(p)), {}
# walking in and out: the first and last stretch of a recording along another recording's path ...
# walking in and out: the first and last stretch of a recording along the path of another room's
# recording ... (recordings of the same room cover the same place on purpose: that is no walking in)
n = len(cams)
room = [rooms[l]["room"] for l in labels]
elsewhere = lambda i, arrays: [a for j, a in enumerate(arrays) if room[j] != room[i]]
def edges(i, others):
inside = cKDTree(others).query(cams[i])[0] < 0.4
if not others:
return np.zeros(len(cams[i]), bool)
inside = cKDTree(np.vstack(others)).query(cams[i])[0] < 0.4
order = np.argsort(rooms[labels[i]]["times"])
flag = np.zeros(len(order), bool)
for seq in (order, order[::-1]):
@@ -261,13 +369,14 @@ def owners(rooms, poses, partition):
break
flag[k] = True
return flag
cand = [edges(i, np.vstack([cams[j] for j in range(n) if j != i])) for i in range(n)]
cand = [edges(i, elsewhere(i, cams)) for i in range(n)]
# ... counts only if another recording covered that place during its own round; otherwise two
# recordings that hand over to each other (one ends where the next starts) would cancel out
transit = []
for i in range(n):
core = np.vstack([cams[j][~cand[j]] for j in range(n) if j != i])
transit.append(cand[i] & (cKDTree(core).query(cams[i])[0] < 0.4))
core = [c for c in elsewhere(i, [cams[j][~cand[j]] for j in range(n)]) if len(c)]
transit.append(cand[i] & (cKDTree(np.vstack(core)).query(cams[i])[0] < 0.4) if core
else np.zeros(len(cams[i]), bool))
transit = np.concatenate(transit)
C, F, O = np.vstack(cams)[~transit], np.vstack(fwd)[~transit], owner[~transit]
tree = cKDTree(C)
@@ -286,11 +395,17 @@ def owners(rooms, poses, partition):
cos = np.einsum("nkd,nkd->nk", to, F[j]) / np.maximum(np.linalg.norm(to, axis=2), 1e-6)
w = np.where(ok & (cos > 0.3), (RADIUS - np.where(ok, d, RADIUS)) * cos, 0.0)
score = np.stack([(w * (O[j] == r)).sum(1) for r in range(n)], axis=1)
codes = cube_codes(c)
if have is not None: # among the recordings that really have something there
table, cover = have
k = np.clip(np.searchsorted(table, codes), 0, len(table) - 1)
cv = np.where((table[k] == codes)[:, None], cover[k], 0.0)
score = np.where(cv >= SHARE * cv.max(1, keepdims=True), score, -1.0)
best = score.argmax(1)
none = score.max(1) == 0
none = score.max(1) <= 0
if none.any():
best[none] = O[tree.query(c[none], workers=-1)[1]]
lab[s:s + 200_000] = best
lab[s:s + 200_000] = richest(best, codes)
return lab[inv.ravel()]
return own, {labels[i]: int(t.sum()) for i, t in enumerate(np.split(transit, np.cumsum([len(c) for c in cams])[:-1]))}
@@ -303,7 +418,13 @@ def main():
ap.add_argument("runs", nargs="+", help="run folder=label")
ap.add_argument("--align", choices=("frame", "images", "shift", "rigid"), default="images")
ap.add_argument("--partition", choices=("view", "nearest"), default="view")
ap.add_argument("--rooms", default="", help="JSON list: the room of every recording (default: one each)")
ap.add_argument("--colour", choices=("match", "keep"), default="match")
args = ap.parse_args()
room_of = json.loads(args.rooms) if args.rooms else []
if room_of and (not isinstance(room_of, list) or len(room_of) != len(args.runs)
or not all(isinstance(r, str) and r for r in room_of)):
sys.exit("--rooms needs one room name per recording")
out = Path(args.out)
(out / "spirula").mkdir(parents=True, exist_ok=True)
started = time.time()
@@ -322,7 +443,7 @@ def main():
both = [k for k, n in enumerate(names) if n in recorded]
if len(both) < 10:
sys.exit(f"{label}: too few cameras with Frame tracking – was it made with the Arcturus pipeline?")
rooms[label] = {"run": run, "cams": centres[both], "fwd": fwd[both],
rooms[label] = {"run": run, "room": room_of[i] if room_of else label, "cams": centres[both], "fwd": fwd[both],
"times": np.array([image_time(names[k]) for k in both]),
"tracked": np.array([recorded[names[k]] for k in both]),
"pts": read_points(run / "dataset/sparse/0/points3D.bin")}
@@ -331,7 +452,7 @@ def main():
# how far each room's reconstruction is from the Frame's tracking, as delivered and fitted
fit = {l: umeyama(r["cams"], r["tracked"]) for l, r in rooms.items()}
rms = lambda p, r: float(np.sqrt((np.linalg.norm(p(r["cams"]) - r["tracked"], axis=1) ** 2).mean()))
room_info = {l: {"run": str(r["run"]), "cameras": len(r["cams"]), "tracking_cm": round(rms(Pose(), r) * 100, 1),
room_info = {l: {"run": str(r["run"]), "room": r["room"], "cameras": len(r["cams"]), "tracking_cm": round(rms(Pose(), r) * 100, 1),
"fitted_cm": round(rms(fit[l], r) * 100, 1)} for l, r in rooms.items()}
# From here on everything is in the fitted frame; `poses` are applied on top of it.
for l, r in rooms.items():
@@ -372,7 +493,20 @@ def main():
progress(100)
print("@@ cphase 1", flush=True)
own, transit = owners(rooms, poses, args.partition)
clouds = [] # what every recording has where (one at a time in memory)
for l, r in rooms.items():
_, v = read_ply(r["run"] / "spirula" / "scene.ply")
xyz = final[l](np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64))
rgb = 0.5 + C0 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], axis=1).astype(np.float64)
clouds.append((cube_codes(xyz), 1 / (1 + np.exp(-v["opacity"].astype(np.float64))), rgb.astype(np.float32)))
del v, xyz, rgb
table, cover, colour = contents(clouds)
del clouds
gains = colour_gains(cover, colour) if args.colour == "match" else np.ones((len(rooms), 3))
for l, g in zip(rooms, gains):
if np.abs(g - 1).max() > 0.005:
print(f"{l}: colour × {g[0]:.2f} {g[1]:.2f} {g[2]:.2f}", flush=True)
own, transit = owners(rooms, poses, args.partition, (table, around(table, cover)))
body = out / "spirula" / "scene.body.tmp"
total, kept, header = 0, {}, None
with open(body, "wb") as fb:
@@ -382,6 +516,7 @@ def main():
keep = own(xyz) == i
v, xyz = v[keep], xyz[keep]
v["x"], v["y"], v["z"] = xyz.T.astype(np.float32)
apply_gain(v, gains[i])
q = Rotation.from_matrix(final[l].R).as_quat() # (x, y, z, w)
rot = quat_mul(np.array([q[3], q[0], q[1], q[2]]),
np.stack([v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"]], axis=1).astype(np.float64))
@@ -404,9 +539,9 @@ def main():
# start view: the one of the room in the middle
shutil.copy(rooms[hub]["run"] / "scene.xrSceneTransform.json", out / "scene.xrSceneTransform.json")
for l in rooms:
room_info[l].update(kept[l], walk_in_out_cameras=transit.get(l, 0))
result = {"align": args.align, "partition": args.partition, "hub": hub, "splats": total,
for l, g in zip(rooms, gains):
room_info[l].update(kept[l], walk_in_out_cameras=transit.get(l, 0), colour=[round(float(x), 3) for x in g])
result = {"align": args.align, "partition": args.partition, "colour": args.colour, "hub": hub, "splats": total,
"rooms": room_info, "seams_before": before, "seams": after,
"seconds": round(time.time() - started)}
(out / "combine.json").write_text(json.dumps(result, ensure_ascii=False, indent=1))
+14 -3
View File
@@ -7,6 +7,9 @@
# Environment:
# ALIGN=frame|images|shift|rigid how the rooms are fitted together (default: images, see combine.py)
# PARTITION=view|nearest where the borders between the recordings run (default: view)
# COLOUR=match|keep bring all recordings to one brightness (default: match)
# ROOMS='["Kitchen","Kitchen","Hallway"]' the room of every recording, in order: a room can be made of
# several recordings (default: every recording is a room of its own)
# FRAME_SPLATS=1000000 splats in the Frame version (the full version keeps all)
# FULL=0 skip the full SOG for the PC (the step that needs the most memory)
# FULL_NAME="..." name plate of the full version (default: "<name> (full)")
@@ -59,7 +62,8 @@ main() {
trap 'exit 143' TERM; trap 'exit 130' INT; trap 'exit 129' HUP
echo "Putting $# scenes together as \"$name\" (alignment ${ALIGN:-images}, borders ${PARTITION:-view})"
uv run -q "$HERE/combine.py" "$tmp" "$@" --align "${ALIGN:-images}" --partition "${PARTITION:-view}"
uv run -q "$HERE/combine.py" "$tmp" "$@" --align "${ALIGN:-images}" --partition "${PARTITION:-view}" --colour "${COLOUR:-match}" \
${ROOMS:+--rooms "$ROOMS"}
total=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["splats"])' "$tmp/combine.json")
# Frame version: fewer splats
@@ -81,8 +85,15 @@ main() {
cp "$tmp/scene.xrSceneTransform.json" "$tmp/full/scene.xrSceneTransform.json"
fi
# Preview for the gallery: the floor plan of all rooms
uv run -q "$HERE/floorplan.py" "$tmp/plan.png" "$@" ${PLAN_COMMA:+--comma} >/dev/null
# Preview for the gallery: the floor plan of all rooms (labelled by room, not by recording)
plan_args=("$@")
if [ -n "${ROOMS:-}" ]; then
mapfile -t plan_args < <(python3 -c '
import json, sys
for arg, room in zip(sys.argv[2:], json.loads(sys.argv[1])):
print(arg.partition("=")[0] + "=" + room)' "$ROOMS" "$@")
fi
uv run -q "$HERE/floorplan.py" "$tmp/plan.png" "${plan_args[@]}" ${PLAN_COMMA:+--comma} > "$tmp/plan.json"
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp" "$name" "$stem"
if [ -d "$tmp/full" ]; then
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp/full" "${FULL_NAME:-$name (full)}" "$stem-full"
+16 -5
View File
@@ -13,6 +13,7 @@ any manual alignment. Per scene the plan shows:
- walls as bright lines (splats in the wall height band, where they pile up)
- the path the camera was carried along (registered camera positions)
- name and approximate size
Several recordings with the same label are one room (made of several recordings): one name, one size.
The plan is rotated so the walls are axis-aligned.
Seen from above, Y down means +x and +z form a mirrored pair on screen (rows count downwards):
x is drawn right to left, otherwise the plan comes out mirror-inverted.
@@ -168,8 +169,16 @@ def main():
inside = np.ones(len(xyz), bool)
up = floor - xyz[:, 1] # height above the floor
walls = inside & (up > 0.9 * m) & (up < 1.9 * m)
scenes.append({"label": label or run.name, "xyz": xyz[inside], "rgb": rgb[inside], "up": up[inside],
"walls": xyz[walls][:, [0, 2]], "cams": load_cameras(run), "floor": floor})
cams = load_cameras(run)
s = {"label": label or run.name, "xyz": xyz[inside], "rgb": rgb[inside], "up": up[inside],
"walls": xyz[walls][:, [0, 2]], "cams": [cams] if cams is not None else [], "floor": floor}
same = next((x for x in scenes if x["label"] == s["label"]), None)
if same: # one more recording of this room
for k in ("xyz", "rgb", "up", "walls"):
same[k] = np.concatenate([same[k], s[k]])
same["cams"] += s["cams"]
else:
scenes.append(s)
all_walls = np.vstack([s["walls"] for s in scenes])
sub = all_walls[rng.choice(len(all_walls), min(len(all_walls), 150_000), replace=False)]
@@ -177,7 +186,7 @@ def main():
for s in scenes:
s["p"] = rotate(s["xyz"][:, [0, 2]], angle)
s["w"] = rotate(s["walls"], angle)
s["c"] = rotate(s["cams"][:, [0, 2]], angle) if s["cams"] is not None else None
s["c"] = [rotate(c[:, [0, 2]], angle) for c in s["cams"]]
s["box"] = room_box(s["w"], 0.05 * m)
if s["box"] is not None: # keep only this room (plus a margin)
b0, b1 = s["box"][0] - 0.35 * m, s["box"][1] + 0.35 * m
@@ -212,8 +221,10 @@ def main():
f_label, f_small, f_title = font(26), font(17), font(30)
for i, s in enumerate(scenes):
col = COLORS[i % len(COLORS)]
if args.style == "pretty" and s["c"] is not None and len(s["c"]) > 1:
x, z = px(s["c"])
for c in (s["c"] if args.style == "pretty" else []): # every recording's path
if len(c) < 2:
continue
x, z = px(c)
pts = list(zip(x.tolist(), z.tolist()))
for k in range(0, len(pts) - 1, 2): # dotted line
d.line([pts[k], pts[k + 1]], fill=col + (255,), width=2)
+28 -2
View File
@@ -13,6 +13,10 @@
# max ~8 image pairs per second of video (100-500), full resolution, up to 3 M splats, 50k steps
# FRAMES, SCALE, MAXG, ITER override single preset values
# NO_UPLOAD=1 only compute, don't copy to the Frame
# PART=120-245 only this stretch of the recording (seconds): cut losslessly with the original
# timestamps (the pipeline matches the images to the poses by time) into
# runs/.parts/<name>.mp4, which goes again once the scene is done. A long recording in
# several parts gets up to 500 image pairs per part.
# FRESH=1 start an existing run of the same name over; its result stays until the new one
# is done and comes back if the new run fails or is cancelled
# Interrupted? Run the same command again with --resume.
@@ -62,6 +66,24 @@ out=$RUNS_DIR/$slug
log=$RUNS_DIR/$slug.log
mkdir -p "$RUNS_DIR"
# A stretch of the recording: cut it once (the same file again for --resume)
secs=
if [ -n "${PART:-}" ]; then
start=${PART%-*}; end=${PART#*-}
[[ "$start" =~ ^[0-9]+(\.[0-9]+)?$ && "$end" =~ ^[0-9]+(\.[0-9]+)?$ ]] \
&& awk -v a="$start" -v b="$end" 'BEGIN { exit !(b > a) }' \
|| { echo "PART must be <start>-<end> in seconds, e.g. 120-245" >&2; exit 1; }
cut=$RUNS_DIR/.parts/$slug.mp4
if [ ! -f "$cut" ]; then
mkdir -p "$RUNS_DIR/.parts"
echo "Cutting $start-$end s out of $(basename "$in") ..."
ffmpeg -v error -y -copyts -ss "$start" -i "$in" -to "$end" -map 0 -c copy -copy_unknown "$cut.tmp.mp4"
mv "$cut.tmp.mp4" "$cut"
fi
in=$cut
secs=$(awk -v a="$start" -v b="$end" 'BEGIN { print b - a }')
fi
# FRESH: set the previous result aside, delete it only once the new one is there
prev=$RUNS_DIR/.previous/$slug
restore_previous() {
@@ -109,8 +131,8 @@ fi
# Image pairs from the duration: <per second>, clamped to the pipeline limits 100..500
pairs() {
local secs
secs=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$in")
local secs=$secs
[ -n "$secs" ] || secs=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$in")
awk -v s="$secs" -v r="$1" 'BEGIN { f = int(s * r + 0.5); if (f < 100) f = 100; if (f > 500) f = 500; print f }'
}
@@ -132,6 +154,10 @@ SS_LANG=en uv run av1_to_3dgs.py "$in" "$out" \
--splat-transform "$SPLAT_TRANSFORM" \
"${quality[@]}" "$@" 2>&1 | tee -a "$log"
[ -f "$out/scene.sog" ] || { echo "No scene.sog was produced, see $log" >&2; exit 1; }
if [ -n "${PART:-}" ]; then
rm -f "$cut" # cut again from the recording when needed
rmdir "$RUNS_DIR/.parts" 2>/dev/null || true
fi
rm -rf "$prev"
rmdir "$RUNS_DIR/.previous" 2>/dev/null || true
+3 -2
View File
@@ -76,7 +76,8 @@ class Config:
def slugify(name: str) -> str:
"""Folder/file name from a display name: ä→ae etc., accents dropped, only [A-Za-z0-9._-]."""
"""Folder/file name from a display name: ä→ae etc., accents dropped, only [A-Za-z0-9._-]; no dot or
dash at the ends ("Tour 12.10." → Tour-12.10: Windows file systems such as NTFS refuse a trailing dot)."""
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("Ä", "Ae"), ("Ö", "Oe"), ("Ü", "Ue"), ("ß", "ss")):
name = name.replace(a, b)
name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
@@ -88,7 +89,7 @@ def slugify(name: str) -> str:
elif not dash:
out.append("-")
dash = True
return "".join(out).strip("-")
return "".join(out).strip("-.")
def _merge(base: dict, over: dict) -> dict:
+18
View File
@@ -13,6 +13,9 @@ TEXTS = {
"inhibit.download": "Downloading a recording from the Frame",
"inhibit.combine": "Putting “{name}” together",
"tour.full_name": "{name} (full)",
"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.versions": "Making versions of “{name}”", "ver.all": "all splats", "ver.size": "{n} M",
"notify.failed.title": "Conversion failed",
"notify.failed": "“{name}” – details in Splat Workshop",
@@ -20,6 +23,12 @@ TEXTS = {
"notify.download": "{n} recording(s) are on this PC – you can switch the Frame off",
"notify.download_failed.title": "Fetching failed",
"notify.download_failed": "{n} recording(s) could not be fetched ({ok} did) – is the Frame switched on?",
"part.name": "{name} · part {i} of {n}",
"notify.parts_done.title": "All parts converted",
"notify.parts_done": "“{name}”: {n} parts are one room now – it is being put together",
"notify.parts_done_alone": "“{name}”: {n} parts are one room now – put it together in Splat Workshop",
"notify.parts_failed.title": "Conversion in parts stopped",
"notify.parts_failed": "“{name}”: part {i} of {n} failed – details in Splat Workshop",
"notify.upload.title": "Copied to the Frame",
"notify.upload": "“{name}” is in the Gaussian XR gallery",
"notify.upload_failed.title": "Copying failed",
@@ -67,6 +76,9 @@ TEXTS = {
"inhibit.download": "Lädt eine Aufnahme von der Frame",
"inhibit.combine": "Setzt „{name}“ zusammen",
"tour.full_name": "{name} (voll)",
"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.versions": "Erstellt Fassungen von „{name}“", "ver.all": "alle Splats", "ver.size": "{n} Mio.",
"notify.failed.title": "Umwandlung fehlgeschlagen",
"notify.failed": "„{name}“ – Details in der Splat-Werkstatt",
@@ -74,6 +86,12 @@ TEXTS = {
"notify.download": "{n} Aufnahme(n) liegen auf dem PC – du kannst die Frame abschalten",
"notify.download_failed.title": "Holen fehlgeschlagen",
"notify.download_failed": "{n} Aufnahme(n) konnten nicht geholt werden ({ok} schon) – ist die Frame an?",
"part.name": "{name} · Teil {i} von {n}",
"notify.parts_done.title": "Alle Teile umgewandelt",
"notify.parts_done": "„{name}“: {n} Teile sind jetzt ein Raum – er wird zusammengesetzt",
"notify.parts_done_alone": "„{name}“: {n} Teile sind jetzt ein Raum – in der Splat-Werkstatt zusammensetzen",
"notify.parts_failed.title": "Umwandlung in Teilen angehalten",
"notify.parts_failed": "„{name}“: Teil {i} von {n} ist fehlgeschlagen – Details in der Splat-Werkstatt",
"notify.upload.title": "Auf die Frame übertragen",
"notify.upload": "„{name}“ liegt in der Gaussian-XR-Galerie",
"notify.upload_failed.title": "Übertragung fehlgeschlagen",
+300 -35
View File
@@ -29,7 +29,7 @@ from pathlib import Path
from . import check, config
from .i18n import T, detect
VERSION = "0.2.3"
VERSION = "0.3.1"
CFG = config.load()
ROOT = config.ROOT
WEB = ROOT / "web"
@@ -97,10 +97,14 @@ def pretty_date(stem: str) -> str:
return f"{m[1]}-{m[2]}-{m[3]}T{m[4]}:{m[5]}" if m else ""
notes = [] # notify-send processes still running (the tests wait for them)
def notify(title: str, body: str) -> None:
if shutil.which("notify-send"):
subprocess.Popen(["notify-send", "-a", t("app"), "-i", "applications-graphics", title, body],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
notes[:] = [p for p in notes if p.poll() is None]
notes.append(subprocess.Popen(["notify-send", "-a", t("app"), "-i", "applications-graphics", title, body],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
@functools.cache
@@ -614,7 +618,8 @@ def job_status(tail=True) -> dict | None:
return res
def start_job(p: dict):
def start_job(p: dict, part: str = "", chain: dict = None):
"""A conversion; `part` (e.g. "120.0-245.5") converts only that stretch of the recording."""
global proc
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
@@ -641,8 +646,10 @@ def start_job(p: dict):
if not lo <= v <= hi:
return err("err.range", key=key, lo=lo, hi=hi)
env[key] = str(v)
if not p.get("upload", True) or not FRAME:
env["NO_UPLOAD"] = "1"
if not p.get("upload", True) or not FRAME or part:
env["NO_UPLOAD"] = "1" # a part alone is no scene for the Frame
if part:
env["PART"] = part
args = []
previous = None
if (RUNS / slug).exists():
@@ -667,21 +674,106 @@ def start_job(p: dict):
st = load_state()
st["job"] = {"pid": proc.pid, "out": str(out), "name": name, "slug": slug, "recording": Path(path).name,
"quality": env["QUALITY"], "upload": "NO_UPLOAD" not in env, "started": time.time(),
"overrides": {k: env[k] for k in ("FRAMES", "SCALE", "MAXG", "ITER") if k in env}}
"overrides": {k: env[k] for k in ("FRAMES", "SCALE", "MAXG", "ITER") if k in env},
**({"part": part} if part else {}), **({"chain": chain} if chain else {})}
save_state(st)
RUNS.mkdir(parents=True, exist_ok=True)
write_meta(slug, name=name, recording=Path(path).name, quality=env["QUALITY"],
write_meta(slug, name=name, recording=Path(path).name, quality=env["QUALITY"], part=part or None,
**({} if "--resume" in args else {"cleaned": [], "recording_renamed": False}),
**({"previous": previous} if previous is not None else {}))
return None
# ---------------------------------------------------------------- a long recording in parts
# The Arcturus pipeline takes at most 500 image pairs per recording. web/index.html suggests as many parts
# as a recording needs for the full rate of its quality preset, all equally long (no short rest).
PARTS_MAX = 10
PART_OVERLAP = 4.0 # seconds both neighbouring parts get, so they surely meet
def part_ranges(duration: float, n: int) -> list:
step = duration / n
return [(max(0.0, i * step - (PART_OVERLAP / 2 if i else 0)),
min(duration, (i + 1) * step + (PART_OVERLAP / 2 if i < n - 1 else 0))) for i in range(n)]
def start_parts(p: dict):
"""Convert a long recording in n parts one after the other, then make them one room and put it
together (each part gets up to 500 image pairs)."""
try:
n = int(p.get("parts"))
except (TypeError, ValueError):
return err("err.bad_value", key="parts")
if not 2 <= n <= PARTS_MAX:
return err("err.range", key="parts", lo=2, hi=PARTS_MAX)
path, room = p.get("path", ""), clean_name(p.get("name") or "", file=False)
if local_path(Path(path).name) != path:
return err("err.unknown_recording")
duration = probe(path).get("duration") or 0
if duration / n < 20:
return err("err.parts_too_short", secs=20)
room_slug = config.slugify(room)
if not room_slug or room_slug.startswith("."):
return err("err.bad_name")
names = [t("part.name", name=room, i=i + 1, n=n) for i in range(n)]
slugs = [config.slugify(x) for x in names]
taken = lambda x: (RUNS / x).exists() or meta_file(x).exists() or (RUNS / f"{x}.log").exists()
if any(taken(x) for x in [room_slug, *slugs]) or len(set(slugs)) < n or room_slug in slugs:
return err("err.scene_name_taken")
ranges = part_ranges(duration, n)
chain = {"room": room, "path": path, "quality": p.get("quality", "high"), "i": 0, "n": n,
"parts": [{"name": x, "slug": s_, "part": f"{a:.2f}-{b:.2f}"} for x, s_, (a, b) in zip(names, slugs, ranges)],
"settings": {k: p.get(k) for k in ("frames", "scale", "maxg", "iter") if p.get(k) not in (None, "")},
"upload": bool(p.get("upload", True)) and bool(FRAME)}
first = chain["parts"][0]
return start_job({**chain["settings"], "path": path, "name": first["name"], "quality": chain["quality"],
"upload": False}, part=first["part"], chain={"i": 0, "n": n, "room": room, "parts": chain["parts"],
"settings": chain["settings"], "upload": chain["upload"]})
def advance_parts():
"""After a part: the next one; after the last: the parts become one room, which is put together."""
js = job_status(tail=False)
ch = js and js.get("chain")
if not ch or js["running"] or ch.get("done"):
return
st = load_state()
st["job"]["chain"]["done"] = True # only once, also after a restart of the server
save_state(st)
room = ch["room"]
if js["result"] != "ok":
if js["result"] == "failed":
notify(t("notify.parts_failed.title"), t("notify.parts_failed", name=room, i=ch["i"] + 1, n=ch["n"]))
return
i = ch["i"] + 1
if i < ch["n"]:
nxt = ch["parts"][i]
e = start_job({**ch["settings"], "path": js_path(js), "name": nxt["name"], "quality": js["quality"], "upload": False},
part=nxt["part"], chain={**ch, "i": i})
if e:
notify(t("notify.parts_failed.title"), t("notify.parts_failed", name=room, i=i + 1, n=ch["n"]))
return
slug = save_group("", room, ",".join(x["slug"] for x in ch["parts"]), room=True)
if isinstance(slug, dict):
notify(t("notify.parts_failed.title"), t("notify.parts_failed", name=room, i=ch["n"], n=ch["n"]))
return
e = start_combine({"slug": slug, "upload": ch["upload"], "full": True})
notify(t("notify.parts_done.title"), t("notify.parts_done", name=room, n=ch["n"]) if not e else
t("notify.parts_done_alone", name=room, n=ch["n"]))
def js_path(js: dict) -> str:
return local_path(js["recording"]) or ""
def cancel_job():
st = load_state()
job = st.get("job")
if not job or not pid_alive(job["pid"]):
return err("err.no_job")
job["cancelled"] = True
st["standby"] = {} # someone is there: no standby afterwards
save_state(st)
try:
os.killpg(job["pid"], signal.SIGTERM)
@@ -711,6 +803,83 @@ def activity() -> dict:
"checking": check_busy(), "uploading": any(u["proc"].poll() is None for u in uploads.values())}
# ---------------------------------------------------------------- standby when everything is done
STANDBY_GRACE = 60 # seconds between "all done" and the standby (time to change one's mind)
AWAKE_AFTER = 15 # seconds the PC is still kept awake after the last work (gaps between jobs)
awake = {"proc": None, "idle_since": None}
def hold_awake(busy: bool) -> None:
"""While anything runs, and a little longer: one sleep inhibitor of this server for all of it. The
jobs have their own, but between two of them (the parts of a long recording, then putting them
together) there are a few seconds without – enough for the desktop to suspend a PC idle for hours."""
p = awake["proc"]
if busy:
awake["idle_since"] = None
if (p is None or p.poll() is not None) and can_inhibit():
awake["proc"] = subprocess.Popen(keep_awake(["sleep", "infinity"], t("inhibit.busy")),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
return
awake["idle_since"] = awake["idle_since"] or time.time()
if p is not None and time.time() - awake["idle_since"] > AWAKE_AFTER:
release_awake()
def release_awake() -> None:
p, awake["proc"] = awake["proc"], None
if p is not None and p.poll() is None:
try:
os.killpg(p.pid, signal.SIGTERM)
except ProcessLookupError:
pass
p.wait()
def standby_status() -> dict:
sb = load_state().get("standby") or {}
return {"on": bool(sb.get("on")), "at": sb.get("at"), "error": sb.get("error")}
def set_standby(on: bool) -> dict:
"""Switch "standby when everything is done" on or off (also stops a countdown)."""
with lock:
st = load_state()
st["standby"] = {"on": True, "since": time.time()} if on else {}
save_state(st)
return standby_status()
def standby_tick(busy: bool) -> None:
"""Once nothing runs any more: a minute's notice, then standby. (Cancelling a job switches it off:
someone is there.)"""
st = load_state()
sb = st.get("standby") or {}
if not sb.get("on"):
return
if busy:
if sb.get("at"):
sb.pop("at")
save_state(st)
return
if not sb.get("at"):
sb["at"] = time.time() + STANDBY_GRACE
save_state(st)
notify(t("notify.standby.title"), t("notify.standby", s=STANDBY_GRACE))
return
if time.time() < sb["at"]:
return
st["standby"] = {}
save_state(st)
release_awake() # our own lock must not keep the PC up
r = subprocess.run(["systemctl", "suspend"], capture_output=True, text=True)
if r.returncode:
st = load_state()
st["standby"] = {"error": (r.stderr or r.stdout).strip()[-300:]}
save_state(st)
notify(t("notify.standby_failed.title"), t("notify.standby_failed", why=(r.stderr or r.stdout).strip()[-200:]))
def watch_once(was_running):
"""One look at the jobs: notification and tidying up when a run has ended. Returns whether one runs."""
if proc is not None:
@@ -719,7 +888,9 @@ def watch_once(was_running):
running = bool(js and js["running"])
if was_running and not running and js:
mins = round((time.time() - js["started"]) / 60)
if js["result"] == "ok":
if js.get("chain"):
pass # advance_parts() reports at the end
elif js["result"] == "ok":
if js["info"].get("folder"):
write_meta(js["slug"], frame_folder=js["info"]["folder"])
with meta_lock: # the replaced scene's settings are not needed any more
@@ -732,6 +903,9 @@ def watch_once(was_running):
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
if not running:
restore_previous()
advance_parts()
js = job_status(tail=False) # the next part (or putting them together) may run now
running = bool(js and js["running"])
for slug in list(uploads):
upload_status(slug) # notify even without an open page
return running
@@ -745,6 +919,8 @@ def watcher():
was_running = running = watch_once(was_running)
busy = (running or download_busy() or check_busy()
or any(u["proc"].poll() is None for u in uploads.values()))
hold_awake(busy)
standby_tick(busy)
if idle_exit and not busy and time.time() - last_request > CFG.idle_minutes * 60:
os._exit(0)
@@ -774,7 +950,8 @@ def set_scale(slug: str, ratio, xr: bool, members: bool, update_frame: bool):
group = is_group(meta)
if not group and not (scene_dir(slug) and (RUNS / slug / "scene.sog").exists()):
return err("err.no_scene")
targets = [slug] + ([m for m in meta.get("members", []) if scene_dir(m)] if group and members else [])
inner_rooms = [m for m in meta.get("members", []) if is_room(read_meta(m))] if group and members else []
targets = [slug] + inner_rooms + ([m for m, _ in group_scenes(slug, meta)] if group and members else [])
if any(scene_busy(t_) for t_ in targets):
return err("err.scene_busy")
xr = bool(xr) and ratio != 1
@@ -1234,18 +1411,19 @@ def floorplan(slugs: list[str], style: str, lang: str, title: str, group: str =
The plan of a group is drawn in the group's scale, if it has one; otherwise all scenes need the same."""
if style not in ("pretty", "classic"):
style = "pretty"
gmeta = read_meta(group) if group and Path(group).name == group and not group.startswith(".") else {}
if gmeta and not is_group(gmeta):
return err("err.bad_request")
room_of = dict(group_scenes(group, gmeta)) if gmeta else {} # one label per room
runs = []
for slug in dict.fromkeys(slugs):
d = scene_dir(slug)
ply = d / "spirula" / "scene.ply" if d else None
if not ply or not ply.exists():
return err("err.no_scene")
runs.append((d, read_meta(slug).get("name") or slug, ply.stat().st_mtime))
runs.append((d, room_of.get(slug) or read_meta(slug).get("name") or slug, ply.stat().st_mtime))
if not runs or len(runs) > 12:
return err("err.bad_request")
gmeta = read_meta(group) if group and Path(group).name == group and not group.startswith(".") else {}
if gmeta and not is_group(gmeta):
return err("err.bad_request")
if (gmeta.get("scale") or 1) != 1:
ratio = gmeta["scale"]
else:
@@ -1409,7 +1587,7 @@ def resume_run(slug: str, fresh: bool):
return err("err.not_resumable")
quality = QUALITY_ALIASES.get(meta.get("quality"), meta.get("quality") or "high")
return start_job({"path": rec, "name": meta["name"], "quality": quality, "existing": "fresh" if fresh else "resume",
"upload": bool(FRAME) and frame_listing() is not None})
"upload": bool(FRAME) and frame_listing() is not None}, part=meta.get("part") or "")
def delete_scene(slug: str):
@@ -1431,6 +1609,14 @@ def delete_scene(slug: str):
return err("err.trash_failed", detail=(r.stderr.strip().splitlines() or [""])[-1])
usage_cache.pop(d, None)
uploads.pop(slug, None)
# a part's piece cut out of its recording (kept for resuming): not needed any more, and cut again
# from the recording any time – deleted, not into the trash (it is as big as that stretch of video)
for cut in (RUNS / ".parts" / f"{slug}.mp4", RUNS / ".parts" / f"{slug}.mp4.tmp.mp4"):
cut.unlink(missing_ok=True)
try:
(RUNS / ".parts").rmdir()
except OSError:
pass
for g, gm in all_groups().items():
if slug in gm.get("members", []):
write_meta(g, members=[m for m in gm["members"] if m != slug])
@@ -1444,7 +1630,7 @@ def clean_group(slug: str, what: str):
return err("err.no_scene")
if what not in CLEANUP:
return err("err.bad_request")
members = [m for m in meta.get("members", []) if scene_dir(m)]
members = [m for m, _ in group_scenes(slug, meta)]
if scene_busy(slug) or any(scene_busy(m) for m in members):
return err("err.scene_busy")
for m in members:
@@ -1603,8 +1789,9 @@ def recording_in_use(name: str):
return None
def delete_recording(name: str):
"""A recording on this PC into the desktop trash (recoverable). Only from the recordings folder."""
def delete_recording(name: str, force: bool = False):
"""A recording on this PC into the desktop trash (recoverable). Only from the recordings folder.
If parts were cut from it, only when asked for once more (`force`): it is the original for cutting again."""
src = REC / Path(name).name
if not name or not src.is_file() or src.parent.resolve() != REC.resolve():
return err("err.not_local")
@@ -1615,6 +1802,11 @@ def delete_recording(name: str):
unfinished = [s for s in unfinished if (RUNS / s).is_dir() and not (RUNS / s / "scene.sog").exists()]
if unfinished:
return err("err.recording_needed", name=read_meta(unfinished[0]).get("name") or unfinished[0])
# parts are cut from the original again when needed (start over, resume): ask once more
parts = [m.name.removesuffix(".ui.json") for m in RUNS.glob("*.ui.json")
if (pm := read_meta(m.name.removesuffix(".ui.json"))).get("recording") == src.name and pm.get("part")]
if parts and not force:
return err("err.recording_has_parts", name=read_meta(parts[0]).get("name") or parts[0], n=len(parts))
try:
r = subprocess.run(["gio", "trash", "--", str(src)], capture_output=True, text=True, timeout=120)
except (OSError, subprocess.TimeoutExpired) as e:
@@ -1655,6 +1847,8 @@ def delete_frame_recording(name: str):
# A group is a <slug>.ui.json with kind "group" and its member scenes; once put together, the tour
# lives in <slug>/ like a scene (scene.sog = Frame version, full/ = all splats, spirula/scene.ply).
# A group marked "room" is one room made of several recordings (only scenes). Any other group may hold
# scenes and such rooms; putting it together always starts from the recordings, each in its room.
ALIGNS = ("frame", "images", "shift", "rigid")
PARTITIONS = ("view", "nearest")
SESSION_GAP = 20 * 60 # recordings closer than this belong to one session (suggestion)
@@ -1664,6 +1858,42 @@ def is_group(meta: dict) -> bool:
return meta.get("kind") == "group"
def is_room(meta: dict) -> bool:
return is_group(meta) and bool(meta.get("room"))
def group_scenes(slug: str, meta: dict = None) -> list:
"""The scenes of a group, each with the name of its room: [(scene slug, room name)]. A scene on its own
is a room of its own; the scenes of a room (also of a room inside the group) all share its name."""
meta = meta if meta is not None else read_meta(slug)
if not is_group(meta):
return []
scene = lambda m, mm: scene_dir(m) and not is_group(mm)
if is_room(meta):
return [(m, meta.get("name") or slug) for m in meta.get("members", []) if scene(m, read_meta(m))]
out, names = [], {}
for m in meta.get("members", []):
mm = read_meta(m)
if is_room(mm):
found = [s_ for s_ in mm.get("members", []) if scene(s_, read_meta(s_))]
elif scene(m, mm):
found = [m]
else:
continue
room = base = mm.get("name") or m
k = 1
while room in names and names[room] != m: # two rooms of the same name stay two rooms
k += 1
room = f"{base} ({k})"
names[room] = m
out += [(s_, room) for s_ in found]
return out
def parent_group(slug: str):
return next((g for g, gm in all_groups().items() if slug in gm.get("members", [])), None)
def all_groups() -> dict:
out = {}
for f in RUNS.glob("*.ui.json"):
@@ -1693,8 +1923,12 @@ def groups():
scene_list = {s["slug"]: s for s in scenes()}
fr = frame_listing()
out = []
for slug, meta in sorted(all_groups().items(), key=lambda kv: (kv[1].get("name") or kv[0]).lower()):
members = [m for m in meta.get("members", []) if m in scene_list]
every = all_groups()
parents = {m: g for g, gm in every.items() for m in gm.get("members", [])}
for slug, meta in sorted(every.items(), key=lambda kv: (kv[1].get("name") or kv[0]).lower()):
members = [m for m in meta.get("members", []) if m in scene_list
or (not is_room(meta) and m in every and is_room(every[m]))]
inner = [(m, r) for m, r in group_scenes(slug, meta) if m in scene_list]
d, res = RUNS / slug, None
if (d / "scene.sog").exists():
try:
@@ -1713,14 +1947,17 @@ def groups():
"splats": info.get("splats"), "frame_splats": meta.get("frame_splats"),
"align": info.get("align"), "partition": info.get("partition"), "hub": info.get("hub"),
"seams": info.get("seams", {}), "mtime": int(built),
"stale": used != sorted(members) or any((RUNS / m / "scene.sog").stat().st_mtime > built for m in members),
"stale": used != sorted(m for m, _ in inner)
or any((RUNS / m / "scene.sog").stat().st_mtime > built for m, _ in inner),
"preview": bool(next(d.glob("*.gallery.png"), None)), "ply": (d / "spirula" / "scene.ply").exists(),
"on_frame": on_frame, "upload": upload_status(slug), "disk": disk_usage(d)}
out.append({"slug": slug, "name": meta.get("name") or slug, "members": members, "result": res,
"room": is_room(meta), "parent": parents.get(slug), "scenes": [m for m, _ in inner],
"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"))),
"splats": sum(scene_list[m].get("gaussians") or 0 for m in members)})
"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()}
@@ -1744,16 +1981,21 @@ def suggestion(scene_list: dict, group_list: list):
return None
def save_group(slug: str, name: str, members: str):
"""Create (no slug) or change a group. Returns its slug, or an error."""
def save_group(slug: str, name: str, members: str, room: bool = False):
"""Create (no slug) or change a group. Returns its slug, or an error. A room (room=True) holds scenes
only; any other group scenes and rooms."""
name = clean_name(name, file=False)
if not name:
return err("err.bad_name")
members = [m for m in dict.fromkeys(members.split(",")) if m]
scene_slugs = {s["slug"] for s in scenes()}
if not members or any(m not in scene_slugs for m in members):
return err("err.bad_request")
existing = all_groups()
rooms = {g for g, gm in existing.items() if is_room(gm) and g != slug}
allowed = scene_slugs if room else scene_slugs | rooms
if not members or any(m not in allowed for m in members):
return err("err.bad_request")
if slug and not room and parent_group(slug): # inside another group it has to stay a room
return err("err.room_in_group", group=existing[parent_group(slug)].get("name") or parent_group(slug))
for g, gm in existing.items():
if g != slug and (both := set(gm.get("members", [])) & set(members)):
return err("err.in_other_group", group=gm.get("name") or g)
@@ -1764,7 +2006,7 @@ def save_group(slug: str, name: str, members: str):
if not slug:
if taken(new):
return err("err.scene_name_taken")
write_json(meta_file(new), {"kind": "group", "name": name, "members": members})
write_json(meta_file(new), {"kind": "group", "name": name, "members": members, "room": room})
return new
if slug not in existing:
return err("err.no_scene")
@@ -1780,12 +2022,25 @@ def save_group(slug: str, name: str, members: str):
meta_file(slug).rename(meta_file(new))
if (RUNS / f"{slug}.log").exists():
(RUNS / f"{slug}.log").rename(RUNS / f"{new}.log")
for g, gm in all_groups().items(): # a room inside another group
if slug in gm.get("members", []):
write_meta(g, members=[new if m == slug else m for m in gm["members"]])
slug = new
write_meta(slug, name=name, members=members)
write_meta(slug, name=name, members=members, room=room)
d = RUNS / slug
if renamed and (d / "scene.sog").exists(): # new name plates on the tour
stem = slug.lower()
cmds = [([str(SCRIPTS / "to-frame.sh"), str(d), name], {"GALLERY_ONLY": "1", "LOG": str(RUNS / f"{slug}.log")})]
cmds = []
runs = [m for m in members if (RUNS / m / "spirula" / "scene.ply").exists()]
if room and (d / "plan.png").exists() and runs:
# a room's floor plan carries its name: drawn again, and the preview images from it
plan = d / "plan.new.png"
cmds.append((["uv", "run", "-q", str(SCRIPTS / "floorplan.py"), str(plan),
*[f"{RUNS / m}={name}" for m in runs], *(["--comma"] if t.lang == "de" else [])], {}))
cmds.append((["mv", str(plan), str(d / "plan.png")], {}))
cmds.append((["uv", "run", "-q", str(SCRIPTS / "make_gallery.py"), "--plan", str(d / "plan.png"),
str(d), name, stem], {}))
cmds.append(([str(SCRIPTS / "to-frame.sh"), str(d), name], {"GALLERY_ONLY": "1", "LOG": str(RUNS / f"{slug}.log")}))
if (d / "full" / "scene.sog").exists() and (d / "plan.png").exists():
cmds.append((["uv", "run", "-q", str(SCRIPTS / "make_gallery.py"), "--plan", str(d / "plan.png"),
str(d / "full"), t("tour.full_name", name=name), f"{stem}-full"], {}))
@@ -1814,8 +2069,9 @@ def start_combine(p: dict):
if not 500_000 <= frame_splats <= 10_000_000:
return err("err.range", key="frame_splats", lo=500_000, hi=10_000_000)
scene_list = {s["slug"]: s for s in scenes()}
members = [m for m in meta.get("members", []) if m in scene_list
and (RUNS / m / "spirula" / "scene.ply").exists() and (RUNS / m / "dataset/sparse/0/images.bin").exists()]
inner = [(m, r) for m, r in group_scenes(slug, meta) if m in scene_list
and (RUNS / m / "spirula" / "scene.ply").exists() and (RUNS / m / "dataset/sparse/0/images.bin").exists()]
members = [m for m, _ in inner]
if len(members) < 2:
return err("err.combine_too_few")
full = bool(p.get("full", True))
@@ -1836,6 +2092,8 @@ def start_combine(p: dict):
if not upload:
env["NO_UPLOAD"] = "1"
pairs = [f"{RUNS / m}={scene_list[m]['name']}" for m in members]
if len({r for _, r in inner}) < len(inner): # a room made of several recordings
env["ROOMS"] = json.dumps([r for _, r in inner], ensure_ascii=False)
JOBS.mkdir(parents=True, exist_ok=True)
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_{slug}.out"
with open(out, "wb") as fh:
@@ -1844,7 +2102,7 @@ def start_combine(p: dict):
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
st = load_state()
st["job"] = {"kind": "combine", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
"recording": "", "rooms": len(members), "quality": align, "partition": partition,
"recording": "", "rooms": len({r for _, r in inner}), "recordings": len(inner), "quality": align, "partition": partition,
"upload": upload, "full": full, "started": time.time(), "overrides": {}}
save_state(st)
write_meta(slug, align=align, partition=partition, frame_splats=frame_splats, full=full)
@@ -1972,7 +2230,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, check_status(Path(q.get("name", "")).name))
if u.path == "/api/status":
ups = {s: upload_status(s) for s in list(uploads)}
return self.send(200, {"job": job_status(), "download": download_status(), "uploads": ups})
return self.send(200, {"job": job_status(), "download": download_status(), "uploads": ups,
"standby": standby_status()})
if u.path == "/api/scenes":
return self.send(200, scenes())
if u.path == "/api/unfinished":
@@ -2041,6 +2300,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
if e == "exists":
slug = config.slugify((p.get("name") or "").strip())
return self.send(409, {"error": err("exists"), "no_resume": no_resume(slug)})
elif path == "/api/parts":
e = start_parts(p)
if e == "exists":
e = err("err.scene_name_taken")
elif path == "/api/cancel":
e = cancel_job()
elif path == "/api/download":
@@ -2071,7 +2334,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
e = start_check(Path(p.get("name", "")).name)
elif path == "/api/recording/delete":
e = delete_frame_recording(p.get("name", "")) if p.get("where") == "frame" else (
delete_recording(p.get("name", "")) if p.get("where") == "pc" else err("err.bad_request"))
delete_recording(p.get("name", ""), bool(p.get("force"))) if p.get("where") == "pc" else err("err.bad_request"))
elif path == "/api/rename":
if p.get("slug"):
e = rename_scene(p["slug"], p.get("new", ""), bool(p.get("update_frame")))
@@ -2085,7 +2348,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
elif path == "/api/delete":
e = delete_scene(p.get("slug", ""))
elif path == "/api/group":
e = save_group(p.get("slug", ""), p.get("name", ""), p.get("members", ""))
e = save_group(p.get("slug", ""), p.get("name", ""), p.get("members", ""), bool(p.get("room")))
if isinstance(e, str):
return self.send(200, {"ok": True, "slug": e})
elif path == "/api/scale":
@@ -2095,6 +2358,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
st = load_state()
st["dismissed_groups"] = sorted(set(st.get("dismissed_groups", [])) | {p.get("key", "")})[-50:]
save_state(st)
elif path == "/api/standby":
return self.send(200, set_standby(bool(p.get("on"))))
elif path == "/api/combine":
e = start_combine(p)
elif path == "/api/resume":
+41 -2
View File
@@ -33,10 +33,13 @@ def script(path: Path, text: str):
# Fake pipeline: behaves like av1_to_3dgs.py as far as Splat Workshop can see (progress lines, result files).
# FAKE_PIPELINE=ok (default) | fail | slow (waits for 60 s, to be cancelled)
# FAKE_PIPELINE=ok (default) | fail | slow (waits for 60 s, to be cancelled) | room (a synthetic recording of
# room A from tests/synth.py that can really be put together: "part-1" in the recording's name walks round
# the left half, other parts the right half)
script(PIPE / "av1_to_3dgs.py", r'''
import base64, json, os, sys, time
from pathlib import Path
TESTS = %r''' % str(Path(__file__).parent) + r'''
out = Path(sys.argv[2])
mode = os.environ.get("FAKE_PIPELINE", "ok")
# like the real pipeline: an unfinished training blocks --resume ("Incomplete training retained")
@@ -64,6 +67,17 @@ for step in (1000, 2000, 3000):
time.sleep(20)
(out / "spirula").mkdir(exist_ok=True)
(out / "spirula" / "scene.ply").write_bytes(b"ply\n")
if mode == "room":
import re, shutil, tempfile
sys.path.insert(0, TESTS)
import synth
first = re.search(r"part-1-of", Path(sys.argv[1]).name) is not None
tmp = Path(tempfile.mkdtemp())
made = synth.make_room(tmp, "x", "A", loop=(1.1, 1.5, 0.6, 0.8) if first else (2.9, 1.5, 0.6, 0.8),
colour=(1, 0, 0) if first else (0, 1, 0))
shutil.copytree(made / "dataset", out / "dataset", dirs_exist_ok=True)
shutil.copy(made / "spirula" / "scene.ply", out / "spirula" / "scene.ply")
shutil.rmtree(tmp)
run_json.write_text(json.dumps({"status": "complete"}))
print("spirula: 1,000 Gaussians in " + str(out / "spirula" / "scene.ply"), flush=True)
(out / "scene.sog").write_bytes(b"fake sog")
@@ -103,12 +117,35 @@ import json, sys
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["{tool}", *sys.argv[1:]]) + "\\n")
''')
# systemctl: the tests must never suspend this PC – a note in the tool log; FAKE_SUSPEND_FAIL=1: refused
script(BIN / "systemctl", f'''#!{sys.executable}
import json, os, sys
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["systemctl", *sys.argv[1:]]) + "\\n")
if os.environ.get("FAKE_SUSPEND_FAIL"):
sys.exit("Operation inhibited by \\"Steam\\" (Steam is downloading)")
''')
# notify-send: no desktop notifications from the tests, only a note in the tool log
script(BIN / "notify-send", f'''#!{sys.executable}
import json, sys
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["notify-send", *sys.argv[1:]]) + "\\n")
''')
# ffmpeg: cutting a part of a recording (-copyts) is only noted in the tool log and gives a small file;
# everything else goes to the real ffmpeg
script(BIN / "ffmpeg", f'''#!{sys.executable}
import json, os, sys
args = sys.argv[1:]
if "-copyts" in args:
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["ffmpeg", *args]) + "\\n")
open(args[-1], "wb").write(b"a part of a recording")
sys.exit(0)
real = {shutil.which("ffmpeg")!r}
if not real:
sys.exit("no ffmpeg")
os.execv(real, [real, *args])
''')
# ffprobe: a good Arcturus recording (two video streams, gpmd, 60 s) unless the file name says otherwise:
# "mono" one video stream, "nogpmd" no metadata stream, "short" 5 s, "brief" 15 s
script(BIN / "ffprobe", f'''#!{sys.executable}
@@ -189,6 +226,8 @@ from splat_workshop import server # noqa: E402 (after the environment is set u
def clean():
"""Every test starts with empty runs, recordings, state and trash."""
yield
for p in server.notes: # notifications still on their way belong to this test
p.wait(10)
st = server.load_state().get("job") or {}
if server.pid_alive(st.get("pid", 0)):
server.cancel_job()
@@ -204,7 +243,7 @@ def clean():
while server.download_busy() and time.time() < end:
time.sleep(0.05)
server.download, server.download_queue[:], server.download_batch = None, [], {}
for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FAKE_FRAME_SLOW", "FAKE_FRAME_SHORT", "FAKE_SCP_SLOW",
for k in ("FAKE_PIPELINE", "FAKE_SUSPEND_FAIL", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FAKE_FRAME_SLOW", "FAKE_FRAME_SHORT", "FAKE_SCP_SLOW",
"FRAME_HOST"):
os.environ.pop(k, None)
os.environ["FRAME_HOST"] = ""
+11 -2
View File
@@ -123,13 +123,15 @@ SPLAT_SIZE = 0.02 # metres
def make_room(runs: Path, slug: str, room: str, error=(1.0, (0.03, 0.0, -0.02)), scale=1.0,
drift=(0.0, 0.0, 0.0), turn=0.0, noise=0.005, walk_in_offset=0.3,
name=None, recording=None, markers=(), colour=None):
name=None, recording=None, markers=(), colour=None, loop=None):
"""Write one synthetic room into runs/<slug>. Returns the run folder.
The reconstruction differs from the real world by `error` = (yaw in degrees, shift in m) and `scale`.
The Frame's tracking of this recording is off by `drift` (m) and turned by `turn` (degrees, about the
room's middle), plus `noise` (m) per pose. markers: True for red/green/blue at the corner, along +x and
along +z, or a list of ((r, g, b), (x, y, z)) – blobs of 400 splats at real-world positions.
loop: (x, z, radius x, radius z) – a round of its own in the room instead of the usual one, looking at the
walls (outwards): several recordings of one room, e.g. one for each half.
runs/<slug>/synth.json records how many splats are the room's own (of these, how many on the wall it
shares with the other room – zero thickness, so it belongs to both) and how many were seen through the door."""
rng = np.random.default_rng(zlib.crc32(slug.encode()))
@@ -154,13 +156,20 @@ def make_room(runs: Path, slug: str, room: str, error=(1.0, (0.03, 0.0, -0.02)),
pos, times = path(room, walk_in_offset)
centre = np.array([(x0 + x1) / 2, 0.0, 1.5])
target = lambda c: centre
if loop:
lx, lz, rx, rz = loop
t = np.linspace(0, 2 * np.pi, 140)
pos = np.stack([lx + rx * np.cos(t), np.zeros_like(t), lz + rz * np.sin(t)], 1)
times = np.linspace(0, 40, len(pos))
target = lambda c: 2 * c - np.array([lx, 0.0, lz]) # outwards, at the walls
yaw, shift = error
E = Rotation.from_euler("y", yaw, degrees=True) # world -> reconstruction
to_sfm = lambda p: scale * E.apply(p) + np.array(shift)
T = Rotation.from_euler("y", turn, degrees=True) # the tracking's own turn
names, Rs, Cs, frames = [], [], [], []
for k, (c, t) in enumerate(zip(pos, times)):
R_world = look_at(c.copy(), centre)
R_world = look_at(c.copy(), target(c))
image = f"cam0/{k:04d}-{t:.6f}.jpg"
names.append(image)
Rs.append(R_world @ E.as_matrix().T)
+7 -6
View File
@@ -28,10 +28,11 @@ def two_rooms(drift_b=DRIFT_B, turn_b=0.0, b_far=False, walk_in_offset=0.3, scal
return a, b
def combine(*runs, align="images", partition="view"):
def combine(*runs, align="images", partition="view", colour="keep"):
"""Colour matching is off by default here: the rooms' and markers' own colours show where a splat came from."""
out = BASE / f"out-{align}-{partition}"
r = subprocess.run([sys.executable, str(COMBINE), str(out), *[f"{p}={p.name}" for p in runs],
"--align", align, "--partition", partition], capture_output=True, text=True)
"--align", align, "--partition", partition, "--colour", colour], capture_output=True, text=True)
assert r.returncode == 0 or "@@ error" in r.stdout, r.stderr
return out, r
@@ -131,7 +132,8 @@ def test_every_splat_is_written_once():
@pytest.mark.parametrize("offset", [0.1, 0.3])
def test_borders_run_through_the_door(offset):
"""Room B starts its recording in room A (walking in). With borders 'through doors' that stretch
claims nothing, so room A comes from room A's own recording; 'nearest camera' cuts into room A."""
claims nothing, so room A comes from room A's own recording; 'nearest camera' cuts into room A (less
since 0.3: a cube only goes to a recording that really has something there)."""
def from_b_in_room_a(partition):
out, r = combine(*two_rooms(drift_b=0.0, walk_in_offset=offset), partition=partition)
v = read_ply(out / "spirula" / "scene.ply")
@@ -142,9 +144,8 @@ def test_borders_run_through_the_door(offset):
view, walk_in = from_b_in_room_a("view")
assert walk_in > 10 # recognised as walking in
assert view < 50 # practically nothing of room B inside room A
if offset >= 0.3: # close to the round, room A's cameras win anyway
nearest, _ = from_b_in_room_a("nearest")
assert nearest > 10 * max(view, 5)
nearest, _ = from_b_in_room_a("nearest")
assert nearest > 2 * max(view, 5)
@pytest.mark.xfail(strict=True, reason="known limit: walking in is only recognised up to 0.4 m from the other "
+16
View File
@@ -1,5 +1,6 @@
"""Groups of scenes: one group per scene, suggestions, renaming, cleaning up together."""
import json
import pytest
from conftest import RUNS, server
import synth
@@ -78,3 +79,18 @@ def test_dissolving_a_group_keeps_its_scenes(api):
assert api.post("/api/delete", {"slug": "Home"})[0] == 200
assert api.get("/api/groups")[1]["groups"] == []
assert {"Kitchen", "Hallway", "Statue"} <= {s["slug"] for s in server.scenes()}
@pytest.mark.parametrize("name,slug", [("Rundgang 12.10.", "Rundgang-12.10"), ("Küche", "Kueche"),
("...", ""), (".hidden", "hidden"), ("Flur 2", "Flur-2")])
def test_folder_names_suit_every_file_system(name, slug):
"""No dot at the end: NTFS (e.g. runs on a Windows disk) refuses it, and the copy loses the tour."""
from splat_workshop import config
assert config.slugify(name) == slug
def test_a_tour_named_with_a_date_gets_a_folder_ntfs_accepts(api):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
synth.make_room(RUNS, "Hall", "B", name="Hall")
status, r = api.post("/api/group", {"name": "Tour 12.10.", "members": "Kitchen,Hall"})
assert status == 200 and r["slug"] == "Tour-12.10"
+161
View File
@@ -0,0 +1,161 @@
"""A long recording in parts (0.3): each part gets up to 500 image pairs, the parts become one room.
The pipeline matches its images to the poses by the time stamps inside the recording, so a part has to be
cut with the original time (ffmpeg -copyts) – checked here on the command splat.sh runs. The fake pipeline
(FAKE_PIPELINE=room) gives every part a synthetic round through one half of room A, so putting the parts
together really runs."""
import json
import os
import subprocess
import pytest
from conftest import REC, ROOT, RUNS, TRASH, recording, server, tools_called, wait_for_tool, wait_job
NAME = "2026-10-14_16-57-54_stereo.mp4" # the fake ffprobe makes it 60 s long
def cuts():
return [c for c in tools_called() if c[0] == "ffmpeg"]
def run_chain(timeout=120):
"""Let the chain run on as the server's watcher does: a tick after every job – and one more, which
must not do anything again."""
was = True
for _ in range(10):
js = wait_job(timeout)
running = server.watch_once(was)
server.watch_once(running)
nxt = server.job_status(tail=False)
if not running and nxt["out"] == js["out"]:
return js
assert running and nxt["running"], "the watcher reports the next job as running"
was = running
raise AssertionError("the chain did not end")
def notes():
for p in server.notes: # notifications are sent in the background
p.wait(10)
return [c for c in tools_called() if c[0] == "notify-send"]
def test_parts_are_equally_long_and_overlap_a_little():
assert server.part_ranges(240, 3) == [(0.0, 82.0), (78.0, 162.0), (158.0, 240)]
ranges = server.part_ranges(100, 2)
assert ranges[0][0] == 0 and ranges[-1][1] == 100 and ranges[0][1] - ranges[1][0] == server.PART_OVERLAP
def test_a_recording_in_parts_becomes_one_room(api):
os.environ["FAKE_PIPELINE"] = "room"
rec = recording(NAME)
status, r = api.post("/api/parts", {"path": str(rec), "name": "Living room", "quality": "high", "parts": 2,
"upload": False})
assert status == 200, r
job = server.job_status(tail=False)
assert (job["chain"]["i"], job["chain"]["n"], job["part"]) == (0, 2, "0.00-32.00")
js = run_chain()
assert js["kind"] == "combine" and js["result"] == "ok", js.get("tail")
# cut losslessly, with the original time, from the original – which stays
assert [c[1:] for c in cuts()] == [
["-v", "error", "-y", "-copyts", "-ss", "0.00", "-i", str(rec), "-to", "32.00", "-map", "0", "-c", "copy",
"-copy_unknown", str(RUNS / ".parts" / "Living-room-part-1-of-2.mp4.tmp.mp4")],
["-v", "error", "-y", "-copyts", "-ss", "28.00", "-i", str(rec), "-to", "60.00", "-map", "0", "-c", "copy",
"-copy_unknown", str(RUNS / ".parts" / "Living-room-part-2-of-2.mp4.tmp.mp4")]]
assert rec.read_bytes() == b"not really a video" and not (RUNS / ".parts").exists()
# the parts: the original is their recording, each knows its stretch
parts = {s: server.read_meta(s) for s in ("Living-room-part-1-of-2", "Living-room-part-2-of-2")}
assert {s: (m["recording"], m["part"]) for s, m in parts.items()} == {
"Living-room-part-1-of-2": (NAME, "0.00-32.00"), "Living-room-part-2-of-2": (NAME, "28.00-60.00")}
room = {g["slug"]: g for g in server.groups()["groups"]}["Living-room"]
assert room["room"] and room["members"] == list(parts) and room["result"] and not room["result"]["stale"]
info = json.loads((RUNS / "Living-room" / "combine.json").read_text())
assert {x["room"] for x in info["rooms"].values()} == {"Living room"}
assert [n[-1] for n in notes() if "part" in n[-1]] == [
"“Living room”: 2 parts are one room now – it is being put together"]
def test_a_failed_part_stops_the_chain():
os.environ["FAKE_PIPELINE"] = "fail"
rec = recording(NAME)
assert server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 3}) is None
js = run_chain()
assert js["result"] == "failed" and js["chain"]["i"] == 0
assert len(cuts()) == 1 and "Living-room" not in {g["slug"] for g in server.groups()["groups"]}
assert any("part 1 of 3 failed" in n[-1] for n in wait_for_tool("notify-send"))
def test_a_cancelled_part_stops_the_chain_quietly():
os.environ["FAKE_PIPELINE"] = "slow"
rec = recording(NAME)
assert server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 2}) is None
import time
time.sleep(1)
server.cancel_job()
js = run_chain(timeout=30)
assert js["result"] == "cancelled" and len(cuts()) == 1
assert not [n for n in notes() if "part" in n[-1]]
@pytest.mark.parametrize("body,code", [
({"parts": 1}, "err.range"), ({"parts": 11}, "err.range"), ({"parts": "x"}, "err.bad_value"),
({"parts": 4}, "err.parts_too_short"), # 15 s each
({"parts": 2, "path": "/etc/passwd"}, "err.unknown_recording"),
({"parts": 2, "name": "Taken"}, "err.scene_name_taken"),
])
def test_parts_that_cannot_work_are_refused(api, body, code):
rec = recording(NAME)
(RUNS / "Taken").mkdir()
full = {"path": str(rec), "name": "Living room", "quality": "high", **body}
status, r = api.post("/api/parts", full)
assert status == 400 and r["error"]["code"] == code
assert server.load_state().get("job") is None
def test_resuming_a_part_cuts_the_same_stretch():
os.environ["FAKE_PIPELINE"] = "fail"
rec = recording(NAME)
server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 2})
run_chain()
os.environ["FAKE_PIPELINE"] = "ok"
assert server.resume_run("Living-room-part-1-of-2", fresh=True) is None
js = wait_job()
assert js["part"] == "0.00-32.00" and js["result"] == "ok", js.get("tail")
def test_the_original_of_parts_is_only_deleted_when_asked_twice(api):
os.environ["FAKE_PIPELINE"] = "room"
rec = recording(NAME)
api.post("/api/parts", {"path": str(rec), "name": "Living room", "quality": "high", "parts": 2, "upload": False})
run_chain()
status, r = api.post("/api/recording/delete", {"name": NAME, "where": "pc"})
assert status == 400 and r["error"]["code"] == "err.recording_has_parts" and r["error"]["params"]["n"] == 2
assert rec.exists()
assert api.post("/api/recording/delete", {"name": NAME, "where": "pc", "force": True})[0] == 200
assert not rec.exists() and (TRASH / NAME).exists()
assert (RUNS / "Living-room-part-1-of-2" / "scene.sog").exists() # the scenes stay
def test_splat_sh_refuses_odd_stretches():
rec = recording(NAME)
for part in ("abc", "30-10", "5", "-3-4", "1-2;rm"):
r = subprocess.run([str(ROOT / "scripts" / "splat.sh"), str(rec), "Odd"], capture_output=True, text=True,
env={**os.environ, "PART": part, "NO_UPLOAD": "1"})
assert r.returncode == 1 and "PART must be" in r.stderr, part
assert not cuts() and not (RUNS / "Odd").exists()
assert list(REC.iterdir()) == [rec]
def test_deleting_a_part_that_did_not_finish_removes_its_cut():
"""A failed or cancelled part keeps its piece of video (for resuming); deleting the run deletes it too
– it is as big as that stretch of the recording, hidden and in no list."""
os.environ["FAKE_PIPELINE"] = "fail"
rec = recording(NAME)
server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 2})
run_chain()
cut = RUNS / ".parts" / "Living-room-part-1-of-2.mp4"
assert cut.exists()
assert server.delete_scene("Living-room-part-1-of-2") is None
assert not cut.exists() and not (RUNS / ".parts").exists() and not (TRASH / cut.name).exists()
assert rec.exists()
+324
View File
@@ -0,0 +1,324 @@
"""Rooms made of several recordings (0.3): putting them together with the rooms of a home.
Several recordings of one room share it: every part comes from the recording that saw it best, and the
recordings of one room are never mistaken for walking in from somewhere else. The expectations come from
how the synthetic recordings were built (tests/synth.py): each has its own colour, so where a splat came
from can be seen in the result – with colour matching off (COLOUR=keep), which would otherwise bring them
closer together."""
import json
import subprocess
import sys
import numpy as np
from conftest import BASE, ROOT, RUNS
import synth
COMBINE = ROOT / "scripts" / "combine.py"
RED, GREEN, HALL = (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.3, 0.3, 0.8)
def combine(runs, rooms=None, name="out", colour="keep"):
out = BASE / name
cmd = [sys.executable, str(COMBINE), str(out), *[f"{p}={p.name}" for p in runs]]
if rooms is not None:
cmd += ["--rooms", json.dumps(rooms)]
cmd += ["--colour", colour]
r = subprocess.run(cmd, capture_output=True, text=True)
return out, r
def splats(out):
with open(out / "spirula" / "scene.ply", "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])
v = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
colour = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
return v, colour
def coloured(colour, rgb):
return np.all(np.abs(colour - np.array(rgb)) < 0.02, axis=1)
def test_a_room_from_two_recordings_takes_each_part_from_the_one_that_saw_it():
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), colour=RED)
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), colour=GREEN)
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"])
assert r.returncode == 0, r.stderr
v, colour = splats(out)
red, green = coloured(colour, RED), coloured(colour, GREEN)
left_wall, right_wall = v["x"] < 0.3, (v["x"] > 3.7) & (v["x"] < 4.3)
assert red[left_wall].mean() > 0.9 and green[right_wall].mean() > 0.9
# each place once: both recordings saw the whole room, the result holds it once
made = json.loads((left / "synth.json").read_text())
assert len(v) <= made["own"] + made["seen_through_door"]
info = json.loads((out / "combine.json").read_text())
assert {l: x["room"] for l, x in info["rooms"].items()} == {"Left": "Kitchen", "Right": "Kitchen"}
assert all(x["walk_in_out_cameras"] == 0 for x in info["rooms"].values())
assert info["seams"]["Left|Right"]["cm"] < 1.0 # the two recordings match
def thin_out(run, keep, gain=1.0):
"""Change a synthetic recording's splats: keep only some (keep(v) -> mask), colours times `gain`."""
path = run / "spirula" / "scene.ply"
raw = path.read_bytes()
end = raw.index(b"end_header\n") + len(b"end_header\n")
props = [l.split()[-1].decode() for l in raw[:end].splitlines() if l.startswith(b"property")]
v = np.frombuffer(raw[end:], dtype=np.dtype([(p, "<f4") for p in props])).copy()
v = v[keep(v)]
for k in range(3):
v[f"f_dc_{k}"] = (gain * (0.5 + 0.28209479177387814 * v[f"f_dc_{k}"]) - 0.5) / 0.28209479177387814
header = raw[:end].replace(b"element vertex %d" % int(raw[:end].split(b"element vertex ")[1].split()[0]),
b"element vertex %d" % len(v))
path.write_bytes(header + v.tobytes())
def test_a_place_goes_to_a_recording_that_really_has_something_there():
"""The right recording looked at the right wall, but its training left nothing there (behind the sofa,
too dark, ...): the wall comes from the left recording instead of being a dark hole."""
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), colour=RED)
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), colour=GREEN)
thin_out(right, lambda v: v["x"] < 3.0)
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"])
assert r.returncode == 0, r.stderr
v, colour = splats(out)
right_wall = (v["x"] > 3.7) & (v["x"] < 4.3)
assert right_wall.sum() > 1000 and coloured(colour, RED)[right_wall].mean() > 0.9
assert coloured(colour, GREEN)[v["x"] < 0.3].mean() < 0.1 # the left wall still from the left
def test_recordings_of_one_room_are_brought_to_one_brightness():
grey = (0.6, 0.6, 0.6)
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), colour=grey)
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), colour=grey)
for run in (left, right): # some view-dependent colour, too
thin_out(run, lambda v: v["x"] > -1)
raw = (run / "spirula" / "scene.ply").read_bytes()
end = raw.index(b"end_header\n") + len(b"end_header\n")
props = [l.split()[-1].decode() for l in raw[:end].splitlines() if l.startswith(b"property")]
v = np.frombuffer(raw[end:], dtype=np.dtype([(p, "<f4") for p in props])).copy()
v["f_rest_0"] = 0.1 # red, first band
(run / "spirula" / "scene.ply").write_bytes(raw[:end] + v.tobytes())
thin_out(right, lambda v: v["x"] > -1, gain=0.7) # its training came out darker
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"], colour="match")
assert r.returncode == 0, r.stderr
v, colour = splats(out)
lw, rw = colour[v["x"] < 0.3, 0], colour[(v["x"] > 3.7) & (v["x"] < 4.3), 0]
assert abs(np.median(lw) - np.median(rw)) < 0.01
assert abs(np.median(lw) - np.sqrt(0.6 * 0.42)) < 0.01 # in between: the room keeps its brightness
info = json.loads((out / "combine.json").read_text())
assert info["colour"] == "match" and info["rooms"]["Right"]["colour"][0] > 1.1
# the view-dependent part is scaled alike: seen from the side the two halves match, too
rest = lambda m: float(np.median(v["f_rest_0"][m]))
gl, gr = info["rooms"]["Left"]["colour"][0], info["rooms"]["Right"]["colour"][0]
assert abs(rest(v["x"] < 0.3) - 0.1 * gl) < 0.002 and abs(rest((v["x"] > 3.7) & (v["x"] < 4.3)) - 0.1 * gr) < 0.002
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"], name="kept", colour="keep")
v, colour = splats(out)
assert abs(np.median(colour[v["x"] < 0.3, 0]) - 0.6) < 0.01
def test_colour_matching_leaves_alone_what_it_cannot_compare():
sys.path.insert(0, str(ROOT / "scripts"))
import combine as C
cover = np.zeros((10, 2)); cover[:, 0] = 5
colour = np.full((10, 2, 3), 0.5)
assert np.all(C.colour_gains(cover, colour) == 1) # nothing in common: unchanged
cover[:] = 5; colour[:, 1] = 0.05 # wildly different: limited
g = C.colour_gains(cover, colour, min_cubes=5)
assert g.max() <= C.GAIN[1] and g.min() >= C.GAIN[0]
def test_recordings_of_one_room_do_not_hide_a_walk_in():
"""Two rounds through the kitchen and the hallway's recording, which starts in the kitchen. Without
rooms the two kitchen rounds look like walking in to each other, and the hallway's real walk-in is not
recognised any more – it then claims part of the kitchen."""
k1 = synth.make_room(RUNS, "K1", "A", colour=RED)
k2 = synth.make_room(RUNS, "K2", "A", loop=(2.0, 1.5, 1.2, 0.8), colour=GREEN)
hall = synth.make_room(RUNS, "Hall", "B")
def hall_in_kitchen(rooms, name):
out, r = combine([k1, k2, hall], rooms=rooms, name=name)
assert r.returncode == 0, r.stderr
v, colour = splats(out)
inside = (v["x"] > 0.2) & (v["x"] < 3.6)
return int((inside & coloured(colour, HALL)).sum()), json.loads((out / "combine.json").read_text())
with_rooms, info = hall_in_kitchen(["Kitchen", "Kitchen", "Hallway"], "rooms")
assert info["rooms"]["Hall"]["walk_in_out_cameras"] > 10
assert with_rooms < 50
assert info["rooms"]["K1"]["walk_in_out_cameras"] == info["rooms"]["K2"]["walk_in_out_cameras"] == 0
without, info = hall_in_kitchen(None, "plain")
# the walk-in is not recognised; the hallway claims part of the kitchen – less than before 0.3, since a
# cube only goes to a recording that really has something there
assert info["rooms"]["Hall"]["walk_in_out_cameras"] == 0 and without > 2 * max(with_rooms, 5)
def test_rooms_need_one_name_per_recording():
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8))
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8))
for rooms in (["Kitchen"], ["Kitchen", ""], "Kitchen", ["Kitchen", 3]):
out, r = combine([left, right], rooms=rooms)
assert r.returncode != 0 and "--rooms" in r.stderr, rooms
def test_the_floor_plan_shows_a_room_of_several_recordings_once():
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), error=(0.0, (0, 0, 0)))
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), error=(0.0, (0, 0, 0)))
hall = synth.make_room(RUNS, "Hall", "B", error=(0.0, (0, 0, 0)))
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(BASE / "plan.png"),
f"{left}=Kitchen", f"{right}=Kitchen", f"{hall}=Hallway"], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
info = json.loads(r.stdout.strip().splitlines()[-1])
sizes = {s["label"]: s["size"] for s in info["scenes"]}
assert set(sizes) == {"Kitchen", "Hallway"}
assert abs(max(sizes["Kitchen"]) - 4) < 0.3 and abs(min(sizes["Kitchen"]) - 3) < 0.3 # the whole 4 x 3 m room
# ---------------------------------------------------------------- rooms in the web interface (server)
from conftest import server, wait_job # noqa: E402
def make_home():
"""The kitchen in two recordings (left and right half), the hallway in one – one session."""
synth.make_room(RUNS, "Left", "A", name="Kitchen left", loop=(1.1, 1.5, 0.6, 0.8), colour=RED,
recording="2026-10-12_16-40-00_stereo.mp4")
synth.make_room(RUNS, "Right", "A", name="Kitchen right", loop=(2.9, 1.5, 0.6, 0.8), colour=GREEN,
recording="2026-10-12_16-41-00_stereo.mp4")
synth.make_room(RUNS, "Hall", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4")
def test_a_room_and_a_home_made_of_it(api):
make_home()
status, r = api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
assert status == 200 and r["slug"] == "Kitchen"
status, r = api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert status == 200
by = {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}
assert (by["Kitchen"]["room"], by["Kitchen"]["parent"], by["Kitchen"]["rooms"]) == (True, "Home", 1)
assert (by["Home"]["room"], by["Home"]["members"], by["Home"]["rooms"]) == (False, ["Kitchen", "Hall"], 2)
assert by["Home"]["scenes"] == ["Left", "Right", "Hall"]
assert api.get("/api/groups")[1]["suggestion"] is None # everything is grouped
def test_what_a_room_and_a_home_may_hold(api):
make_home()
synth.make_room(RUNS, "Other", "A", recording="2026-10-03_19-00-00_stereo.mp4")
assert api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})[0] == 200
assert api.post("/api/group", {"name": "Flat", "members": "Other"})[0] == 200
for body in ({"name": "Room2", "members": "Kitchen,Hall", "room": True}, # a room holds recordings only
{"name": "Home", "members": "Flat,Hall"}, # no group of groups of groups
{"name": "Self", "members": "Self"}):
status, r = api.post("/api/group", body)
assert status == 400 and r["error"]["code"] == "err.bad_request", body
assert api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})[0] == 200
status, r = api.post("/api/group", {"name": "Home 2", "members": "Kitchen"}) # a room in one home only
assert status == 400 and r["error"]["code"] == "err.in_other_group"
status, r = api.post("/api/group", {"slug": "Kitchen", "name": "Kitchen", "members": "Left,Right"})
assert status == 400 and r["error"]["code"] == "err.room_in_group" # inside a home it stays a room
def test_a_renamed_room_stays_in_its_home(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert api.post("/api/group", {"slug": "Kitchen", "name": "Cooking", "members": "Left,Right", "room": True})[0] == 200
by = {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}
assert by["Home"]["members"] == ["Cooking", "Hall"] and by["Cooking"]["parent"] == "Home"
assert api.post("/api/delete", {"slug": "Cooking"})[0] == 200 # dissolving the room
assert {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}["Home"]["members"] == ["Hall"]
def test_putting_a_home_together_from_its_recordings(api, monkeypatch):
monkeypatch.setenv("COLOUR", "keep")
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert server.start_combine({"slug": "Home", "full": False}) is None
js = wait_job()
assert js["result"] == "ok", js["tail"]
info = json.loads((RUNS / "Home" / "combine.json").read_text())
assert {l: x["room"] for l, x in info["rooms"].items()} == {
"Kitchen left": "Kitchen", "Kitchen right": "Kitchen", "Hallway": "Hallway"}
v, colour = splats(RUNS / "Home")
assert coloured(colour, RED)[v["x"] < 0.3].mean() > 0.9 and coloured(colour, GREEN)[(v["x"] > 3.7) & (v["x"] < 3.95)].mean() > 0.9
home = {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}["Home"]
assert home["result"] and not home["result"]["stale"]
# the room alone, too
assert server.start_combine({"slug": "Kitchen", "full": False}) is None
assert wait_job()["result"] == "ok"
info = json.loads((RUNS / "Kitchen" / "combine.json").read_text())
assert {x["room"] for x in info["rooms"].values()} == {"Kitchen"}
def test_a_home_without_rooms_is_put_together_as_before(api, monkeypatch):
make_home()
api.post("/api/group", {"name": "Home", "members": "Right,Hall"}) # (the left half does not reach the door)
seen = {}
real = server.subprocess.Popen
def popen(cmd, *a, **kw):
if any("combine.sh" in str(c) for c in cmd):
seen["rooms"] = kw["env"].get("ROOMS")
return real(cmd, *a, **kw)
monkeypatch.setattr(server.subprocess, "Popen", popen)
assert server.start_combine({"slug": "Home", "full": False}) is None
assert wait_job()["result"] == "ok"
assert seen == {"rooms": None}
def test_the_floor_plan_of_a_home_names_rooms(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
info = server.floorplan(["Left", "Right", "Hall"], "classic", "en", "", "Home")[1]
assert sorted(s["label"] for s in info["scenes"]) == ["Hallway", "Kitchen"]
def test_a_homes_scale_reaches_the_rooms_and_their_recordings(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert api.post("/api/scale", {"slug": "Home", "scale": "87", "with_scenes": True})[0] == 200
scales = {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}
assert scales == {"Left": 87, "Right": 87, "Hall": 87}
assert {g["slug"]: g["scale"] for g in api.get("/api/groups")[1]["groups"]} == {"Home": 87, "Kitchen": 87}
def test_a_renamed_room_gets_a_floor_plan_with_its_new_name(api, monkeypatch):
"""The floor plan (and the preview images made from it) carries the room's name: drawn again."""
monkeypatch.setenv("COLOUR", "keep")
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
assert server.start_combine({"slug": "Kitchen", "full": False}) is None
assert wait_job()["result"] == "ok"
plan = RUNS / "Kitchen" / "plan.png"
before = plan.read_bytes()
status, r = api.post("/api/group", {"slug": "Kitchen", "name": "Big kitchen", "members": "Left,Right", "room": True})
assert status == 200, r
plan = RUNS / r["slug"] / "plan.png"
assert plan.read_bytes() != before and not (plan.parent / "plan.new.png").exists()
assert (plan.parent / f"{r['slug'].lower()}.gallery.png").exists()
def test_two_rooms_of_the_same_name_stay_two_rooms(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
server.write_meta("Hall", name="Kitchen") # e.g. renamed by mistake
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert sorted(server.group_scenes("Home")) == [("Hall", "Kitchen (2)"), ("Left", "Kitchen"), ("Right", "Kitchen")]
def test_the_preview_plan_of_a_home_names_its_rooms(api, monkeypatch):
"""combine.sh gets the rooms (ROOMS) and labels the floor plan of the preview by them."""
monkeypatch.setenv("COLOUR", "keep")
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert server.start_combine({"slug": "Home", "full": False}) is None
assert wait_job()["result"] == "ok"
plan = json.loads((RUNS / "Home" / "plan.json").read_text())
assert sorted(s["label"] for s in plan["scenes"]) == ["Hallway", "Kitchen"]
+5 -5
View File
@@ -23,13 +23,13 @@ def test_renaming_moves_folder_files_and_log(api):
(run / "aufnahme-12.10.-16-40.gallery.png").write_bytes(b"x")
status, r = api.post("/api/rename", {"slug": "Aufnahme-12.10.-16-40", "new": "Büro 12.10."})
assert status == 200, r
new = RUNS / "Buero-12.10."
new = RUNS / "Buero-12.10"
assert new.is_dir() and not run.exists()
assert (new / "buero-12.10..sog").exists() and (new / "buero-12.10..gallery.png").exists()
assert (RUNS / "Buero-12.10..log").exists() and not (RUNS / "Aufnahme-12.10.-16-40.log").exists()
meta = json.loads((RUNS / "Buero-12.10..ui.json").read_text())
assert (new / "buero-12.10.sog").exists() and (new / "buero-12.10.gallery.png").exists()
assert (RUNS / "Buero-12.10.log").exists() and not (RUNS / "Aufnahme-12.10.-16-40.log").exists()
meta = json.loads((RUNS / "Buero-12.10.ui.json").read_text())
assert meta["name"] == "Büro 12.10." and meta["folder_renamed"] is True
assert server.no_resume("Buero-12.10.") == "renamed" # the pipeline remembers the old path
assert server.no_resume("Buero-12.10") == "renamed" # the pipeline remembers the old path
def test_renaming_to_a_taken_name_is_refused(api):
+138
View File
@@ -0,0 +1,138 @@
"""Standby when everything is done (0.3): after the last job – also after the parts of a long recording
and putting them together – a minute's notice, then the PC suspends. The fake systemctl only notes it."""
import os
import time
import pytest
from conftest import recording, server, tools_called, wait_for_tool, wait_job
@pytest.fixture(autouse=True)
def quick(monkeypatch):
monkeypatch.setattr(server, "STANDBY_GRACE", 0)
yield
server.release_awake()
def suspends():
return [c for c in tools_called() if c[0] == "systemctl"]
def test_standby_after_the_job(api):
assert server.start_job({"path": str(recording()), "name": "Room", "quality": "fast", "upload": False}) is None
assert api.post("/api/standby", {"on": True})[1]["on"]
server.standby_tick(busy=True)
assert not suspends() and server.standby_status()["at"] is None # nothing while it runs
wait_job()
server.standby_tick(busy=False) # all done: the notice
assert server.standby_status()["at"] and not suspends()
assert any("standby" in n[-1].lower() for n in wait_for_tool("notify-send"))
server.standby_tick(busy=False) # the minute is up
assert suspends() == [["systemctl", "suspend"]]
assert server.standby_status() == {"on": False, "at": None, "error": None} # once only
server.standby_tick(busy=False)
assert len(suspends()) == 1
def test_work_starting_again_stops_the_countdown():
server.set_standby(True)
server.standby_tick(busy=False)
assert server.standby_status()["at"]
server.standby_tick(busy=True) # e.g. the next part started
assert server.standby_status()["at"] is None and server.standby_status()["on"]
assert not suspends()
def test_switched_off_in_time():
server.set_standby(True)
server.standby_tick(busy=False)
assert server.set_standby(False)["on"] is False
server.standby_tick(busy=False)
assert not suspends()
def test_cancelling_a_job_means_someone_is_there():
os.environ["FAKE_PIPELINE"] = "slow"
server.start_job({"path": str(recording()), "name": "Room", "quality": "fast", "upload": False})
server.set_standby(True)
time.sleep(1)
server.cancel_job()
wait_job(30)
server.standby_tick(busy=False)
server.standby_tick(busy=False)
assert not suspends() and not server.standby_status()["on"]
def test_a_refused_standby_is_reported():
os.environ["FAKE_SUSPEND_FAIL"] = "1"
server.set_standby(True)
server.standby_tick(busy=False)
server.standby_tick(busy=False)
assert "Steam" in server.standby_status()["error"]
end = time.time() + 5 # notifications are sent in the background
while not any("Steam" in c[-1] for c in tools_called() if c[0] == "notify-send") and time.time() < end:
time.sleep(0.1)
assert any("Steam" in c[-1] for c in tools_called() if c[0] == "notify-send")
def test_kept_awake_between_jobs_and_let_go_after(monkeypatch):
"""One lock of the server spans the few seconds between two jobs; it goes a little after the last."""
monkeypatch.setattr(server, "can_inhibit", lambda: True)
monkeypatch.setattr(server, "keep_awake", lambda cmd, why: cmd) # the lock is a process: here `sleep`
monkeypatch.setattr(server, "AWAKE_AFTER", 0.5)
server.hold_awake(True)
p = server.awake["proc"]
assert p and p.poll() is None
server.hold_awake(False) # between two jobs: still held
assert p.poll() is None
server.hold_awake(True)
assert server.awake["proc"] is p # the same lock, not a new one
server.hold_awake(False)
time.sleep(0.6)
server.hold_awake(False)
assert p.poll() is not None and server.awake["proc"] is None
def test_one_notice_after_a_whole_chain_of_parts():
"""Between two parts, and between the last part and putting them together, nothing announces the
standby: the watcher's tick that starts the next part reports it as running."""
os.environ["FAKE_PIPELINE"] = "room"
assert server.start_parts({"path": str(recording("2026-10-14_16-57-54_stereo.mp4")), "name": "Living room",
"quality": "high", "parts": 2, "upload": False}) is None
server.set_standby(True)
was, ticks = True, 0
while ticks < 10:
wait_job()
running = server.watch_once(was)
server.standby_tick(running)
ticks += 1
if not running:
break
assert server.standby_status()["at"] is None
was = running
assert server.job_status(tail=False)["kind"] == "combine"
server.standby_tick(False)
for p in server.notes:
p.wait(10)
notices = [c for c in tools_called() if c[0] == "notify-send" and "standby" in c[-1].lower()]
assert len(notices) == 1 and suspends() == [["systemctl", "suspend"]]
def test_the_own_lock_goes_before_the_standby(monkeypatch):
monkeypatch.setattr(server, "can_inhibit", lambda: True)
monkeypatch.setattr(server, "keep_awake", lambda cmd, why: cmd)
server.hold_awake(True)
lock = server.awake["proc"]
held = []
real = server.subprocess.run
def run(cmd, *a, **kw):
if cmd[:2] == ["systemctl", "suspend"]:
held.append(lock.poll() is None)
return real(cmd, *a, **kw)
monkeypatch.setattr(server.subprocess, "run", run)
server.set_standby(True)
server.standby_tick(False)
server.standby_tick(False)
assert held == [False] and server.awake["proc"] is None
+48 -2
View File
@@ -19,6 +19,8 @@ const I18N = {
"setup.tool": "{tool} is not installed.",
// job card
"job.cancel": "Cancel", "job.dismiss": "Hide", "job.log": "Log",
"job.standby": "💤 Standby when done", "job.standby.in": "💤 Standby in {s} s",
"job.standby.title": "When everything is done (also the following parts, putting them together, copying to the Frame), the PC goes to standby after a minute's notice. Cancelling a job switches this off.",
"job.psnr": "Image quality (PSNR) during training",
"job.sub": "{rec} · {q} · {target} · for {time}", "job.toFrame": "→ Frame", "job.pcOnly": "PC only",
"job.left": " · about {time} left",
@@ -102,6 +104,19 @@ const I18N = {
"t.frame": "Gaussian XR gallery on the Frame", "t.frame.sub": "Copied automatically after computing, with preview image and name plate",
"t.pc": "This PC only", "t.pc.sub": "The result stays in the runs folder – e.g. to look at it in Spirula",
"t.offline": "The Frame is not reachable right now. Computing works anyway – it just has to be on at the end.",
"split": "Long recording",
"split.parts": "Split the video into parts",
"split.parts.pro": "more detail: {rate} instead of {whole} image pairs per second ({pairs} per part of {len})",
"split.parts.con": "takes about {n} times as long; afterwards the parts are put together into one room automatically (small seams are possible where they meet)",
"split.whole": "Convert in one piece",
"split.whole.pro": "done sooner, one conversion, no seams between parts",
"split.whole.con": "only {rate} instead of {full} image pairs per second – the pipeline takes at most 500 per recording, so fine details get lost more easily",
"est.parts": "{n} parts × about {frames} image pairs · up to {maxg} M splats · {iter}k steps · together about {mins} min",
"started.parts": "Started – {n} parts one after the other, then they become one room",
"job.sub.part": "Part {i} of {n} of “{room}” · {q} · {time}",
"err.parts_too_short": "The parts would be shorter than {secs} s – choose fewer parts",
"err.recording_has_parts": "{n} scenes (e.g. “{name}”) were cut from this recording – it is their original: with it, the cuts can be made again (start over, resume).",
"rdel.parts.confirm": "Delete it anyway? The finished scenes stay; without the original their parts can no longer be cut again.",
"start": "Start conversion", "est.none": "Choose a recording first.", "est.busy": "A conversion is already running.",
"est": "{frames} image pairs · {maxg} M splats · {iter}k steps · <b>about {mins} min</b> <span class=\"faint\">(rough estimate)</span>",
"started": "Conversion started", "unusable": "This recording can't be used like this",
@@ -167,7 +182,13 @@ const I18N = {
"grp.new": "+ Group", "grp.new.tip": "Put scenes together in a group, e.g. all rooms of a flat",
"grp.new.title": "New group", "grp.edit.title": "Edit group", "grp.name": "Name", "grp.pick": "Scenes in this group",
"grp.noFree": "Every scene is already in a group – a scene can only be in one.", "grp.save": "Save", "grp.cancel": "Cancel",
"grp.rooms": "{n} scenes", "grp.show": "▸ Show {n} scenes", "grp.hide": "▾ Hide scenes",
"grp.room": "One room made of several recordings",
"grp.room.hint": "e.g. two short rounds through the living room. When put together, every part comes from the recording that saw it best. A room can be part of a home group.",
"grp.room.recordings": "{n} recordings", "grp.home.count": "{rooms} rooms from {n} recordings",
"grp.room.combine": "Put the room together …",
"err.room_in_group": "It is a room of “{group}” – take it out there first",
"grp.rooms": "{n} scenes", "grp.show": "▸ Show {n} scenes", "grp.hide": "▾ Hide",
"grp.show.rooms": "▸ Show {n} rooms", "grp.show.recordings": "▸ Show {n} recordings",
"grp.combine": "Put the tour together …", "grp.recombine": "Put together again …", "grp.busy": "Being put together right now …",
"grp.none": "Not put together yet.",
"grp.tour": "Tour: Frame {frame} ({fsize}){full}", "grp.tour.full": " · full {n} ({size})",
@@ -180,6 +201,7 @@ const I18N = {
"grp.suggest.yes": "Create group …", "grp.suggest.no": "No thanks", "grp.suggest.name": "Tour {date}",
"cmb.title": "Put “{name}” together",
"cmb.hint": "All rooms of one recording session become one scene: a full version for the PC and a smaller one for the Frame. Afterwards the group shows how well the overlaps match – try another correction any time; the previous result stays until the new one is done.",
"cmb.hint.room": "All recordings of this room become one scene: a full version for the PC and a smaller one for the Frame. Every place comes from a recording that really has it, and all recordings are brought to one brightness. Afterwards the room shows how well the recordings match – try another correction any time; the previous result stays until the new one is done.",
"cmb.align": "Correction",
"cmb.align.frame": "A – Frame only", "cmb.align.frame.text": "As the pipeline delivers the rooms (aligned on their first image). For comparison.",
"cmb.align.images": "B – all images (recommended)", "cmb.align.images.text": "Every room fitted to the Frame's movement over all its images. Safe, no surprises.",
@@ -196,6 +218,7 @@ const I18N = {
"cmb.upload": "Copy to the Frame afterwards", "cmb.start": "Put together", "cmb.started": "Putting it together …",
"cphase.0": "Aligning", "cphase.1": "Assembling", "cphase.2": "Reducing", "cphase.3": "Converting",
"job.sub.combine": "{n} scenes · correction {align} · {target} · for {time}",
"job.sub.combine.room": "{n} recordings of one room · correction {align} · {target} · for {time}",
"err.in_other_group": "“{group}” already contains one of these scenes",
"err.combine_too_few": "Putting together needs at least two finished scenes",
"scale.button.title": "Scale – for models, e.g. a model railway", "scale.title": "Scale of “{name}”",
@@ -265,6 +288,8 @@ const I18N = {
"setup.splat_transform": "splat-transform fehlt – installieren mit: npm install -g @playcanvas/splat-transform",
"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",
"job.standby.title": "Wenn alles fertig ist (auch die weiteren Teile, das Zusammensetzen, das Kopieren auf die Frame), geht der PC nach einer Minute Vorwarnung in den Standby. Abbrechen eines Jobs schaltet das aus.",
"job.psnr": "Bildqualität (PSNR) im Training",
"job.sub": "{rec} · {q} · {target} · seit {time}", "job.toFrame": "→ Frame", "job.pcOnly": "nur PC",
"job.left": " · noch ca. {time}",
@@ -345,6 +370,19 @@ const I18N = {
"t.frame": "Gaussian-XR-Galerie auf der Frame", "t.frame.sub": "Wird nach dem Rechnen automatisch kopiert, mit Vorschaubild und Namensschild",
"t.pc": "Nur auf dem PC", "t.pc.sub": "Ergebnis bleibt im runs-Ordner – z. B. zum Ansehen in Spirula",
"t.offline": "Die Frame ist gerade nicht erreichbar. Gerechnet wird trotzdem – sie muss nur am Ende eingeschaltet sein.",
"split": "Lange Aufnahme",
"split.parts": "Video aufteilen in Teile:",
"split.parts.pro": "mehr Detail: {rate} statt {whole} Bildpaare pro Sekunde ({pairs} je Teil à {len})",
"split.parts.con": "dauert etwa {n}-mal so lange; danach werden die Teile automatisch zu einem Raum zusammengesetzt (wo sie sich treffen, sind kleine Nähte möglich)",
"split.whole": "Video nicht aufteilen",
"split.whole.pro": "schneller fertig, eine einzige Umwandlung, keine Nähte zwischen Teilen",
"split.whole.con": "nur {rate} statt {full} Bildpaare pro Sekunde – die Pipeline nimmt höchstens 500 pro Aufnahme, feine Details gehen eher verloren",
"est.parts": "{n} Teile × etwa {frames} Bildpaare · bis {maxg} Mio. Splats · {iter}k Schritte · zusammen etwa {mins} min",
"started.parts": "Gestartet – {n} Teile nacheinander, danach werden sie ein Raum",
"job.sub.part": "Teil {i} von {n} von „{room}“ · {q} · {time}",
"err.parts_too_short": "Die Teile wären kürzer als {secs} s – weniger Teile wählen",
"err.recording_has_parts": "Aus dieser Aufnahme sind {n} Szenen geschnitten (z. B. „{name}“) – sie ist ihr Original: Damit lassen sich die Schnitte wiederholen (neu rechnen, fortsetzen).",
"rdel.parts.confirm": "Trotzdem löschen? Die fertigen Szenen bleiben; ohne das Original lassen sich ihre Teile aber nicht mehr neu schneiden.",
"start": "Umwandlung starten", "est.none": "Erst eine Aufnahme wählen.", "est.busy": "Es läuft schon eine Umwandlung.",
"est": "{frames} Bildpaare · {maxg} Mio. Splats · {iter}k Schritte · <b>ca. {mins} min</b> <span class=\"faint\">(grobe Schätzung)</span>",
"started": "Umwandlung gestartet", "unusable": "Die Aufnahme ist so nicht verwendbar",
@@ -408,7 +446,13 @@ const I18N = {
"grp.new": "+ Gruppe", "grp.new.tip": "Szenen in einer Gruppe zusammenfassen, z. B. alle Räume einer Wohnung",
"grp.new.title": "Neue Gruppe", "grp.edit.title": "Gruppe bearbeiten", "grp.name": "Name", "grp.pick": "Szenen in dieser Gruppe",
"grp.noFree": "Alle Szenen sind schon in einer Gruppe – eine Szene kann nur in einer sein.", "grp.save": "Speichern", "grp.cancel": "Abbrechen",
"grp.rooms": "{n} Szenen", "grp.show": "▸ {n} Szenen zeigen", "grp.hide": "▾ Szenen ausblenden",
"grp.room": "Ein Raum aus mehreren Aufnahmen",
"grp.room.hint": "z. B. zwei kurze Runden durchs Wohnzimmer. Beim Zusammensetzen kommt jeder Teil aus der Aufnahme, die ihn am besten gesehen hat. Ein Raum kann Teil einer Wohnungs-Gruppe sein.",
"grp.room.recordings": "{n} Aufnahmen", "grp.home.count": "{rooms} Räume aus {n} Aufnahmen",
"grp.room.combine": "Raum zusammensetzen …",
"err.room_in_group": "Das ist ein Raum von „{group}“ – erst dort herausnehmen",
"grp.rooms": "{n} Szenen", "grp.show": "▸ {n} Szenen zeigen", "grp.hide": "▾ Ausblenden",
"grp.show.rooms": "▸ {n} Räume zeigen", "grp.show.recordings": "▸ {n} Aufnahmen zeigen",
"grp.combine": "Rundgang zusammensetzen …", "grp.recombine": "Neu zusammensetzen …", "grp.busy": "Wird gerade zusammengesetzt …",
"grp.none": "Noch nicht zusammengesetzt.",
"grp.tour": "Rundgang: Frame {frame} ({fsize}){full}", "grp.tour.full": " · voll {n} ({size})",
@@ -420,6 +464,7 @@ const I18N = {
"grp.suggest": "Diese {n} Szenen vom {date} wurden direkt nacheinander aufgenommen: {names}. Als Gruppe zusammenfassen?",
"grp.suggest.yes": "Gruppe anlegen …", "grp.suggest.no": "Nein danke", "grp.suggest.name": "Rundgang {date}",
"cmb.title": "„{name}“ zusammensetzen",
"cmb.hint.room": "Alle Aufnahmen dieses Raums werden zu einer Szene: eine volle Fassung für den PC und eine kleinere für die Frame. Jede Stelle kommt von einer Aufnahme, die dort wirklich etwas hat, und alle Aufnahmen werden auf eine Helligkeit gebracht. Danach zeigt der Raum, wie gut die Aufnahmen zueinander passen – du kannst jederzeit eine andere Korrektur ausprobieren; das alte Ergebnis bleibt, bis das neue fertig ist.",
"cmb.hint": "Alle Räume eines Aufnahmedurchgangs werden zu einer Szene: eine volle Fassung für den PC und eine kleinere für die Frame. Danach zeigt die Gruppe, wie gut die Übergänge passen – du kannst jederzeit eine andere Korrektur ausprobieren; das alte Ergebnis bleibt, bis das neue fertig ist.",
"cmb.align": "Korrektur",
"cmb.align.frame": "A – nur Frame", "cmb.align.frame.text": "So, wie die Pipeline die Räume liefert (am ersten Bild ausgerichtet). Zum Vergleich.",
@@ -437,6 +482,7 @@ const I18N = {
"cmb.upload": "Danach auf die Frame übertragen", "cmb.start": "Zusammensetzen", "cmb.started": "Wird zusammengesetzt …",
"cphase.0": "Ausrichten", "cphase.1": "Zusammenbauen", "cphase.2": "Verkleinern", "cphase.3": "Umwandeln",
"job.sub.combine": "{n} Szenen · Korrektur {align} · {target} · seit {time}",
"job.sub.combine.room": "{n} Aufnahmen eines Raums · Korrektur {align} · {target} · seit {time}",
"err.in_other_group": "„{group}“ enthält schon eine dieser Szenen",
"err.combine_too_few": "Zum Zusammensetzen braucht es mindestens zwei fertige Szenen",
"scale.button.title": "Maßstab – für Modelle, z. B. eine Modelleisenbahn", "scale.title": "Maßstab von „{name}“",
+119 -25
View File
@@ -166,6 +166,8 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
.gmeta .warn { color: var(--warn); }
.group .acts { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.group .ginner { margin-top: 12px; }
.group .group { background: var(--panel); grid-column: 1 / -1; margin-bottom: 0; }
.group .group .gthumb { width: 96px; height: 96px; font-size: 34px; }
.suggest { border: 1px dashed var(--accent); border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-size: 13.5px; }
.suggest .row { margin-top: 8px; }
dialog.wide { max-width: 560px; }
@@ -238,6 +240,7 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
<span class="title" id="jobName"></span>
<span class="muted" id="jobSub"></span>
<span class="spacer" style="flex:1"></span>
<label class="chk" id="standbyBox" data-i18n-title="job.standby.title"><input type="checkbox" id="standbyChk"> <span id="standbyText" data-i18n="job.standby"></span></label>
<button class="danger small" id="cancelBtn" data-i18n="job.cancel"></button>
<button class="small hidden" id="dismissBtn" data-i18n="job.dismiss"></button>
</div>
@@ -316,6 +319,16 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
<div><b data-i18n="t.pc"></b><span data-i18n="t.pc.sub"></span></div></label>
</div>
<div id="targetWarn" class="msg warn hidden" data-i18n="t.offline"></div>
<div id="splitBox" class="hidden" style="margin-top:16px">
<div class="field" style="margin-bottom:6px" data-i18n="split"></div>
<div class="target">
<label class="opt" id="optSplit"><input type="radio" name="split" value="parts">
<div><b id="splitTitle"></b> <input type="number" id="splitN" min="2" max="10" step="1" style="width:58px;padding:3px 6px;margin:0 4px"><em id="splitRec" class="faint" style="font-size:12.5px;font-style:normal"></em>
<span id="splitPro"></span><span id="splitCon"></span></div></label>
<label class="opt" id="optWhole"><input type="radio" name="split" value="whole">
<div><b data-i18n="split.whole"></b><span id="wholePro"></span><span id="wholeCon"></span></div></label>
</div>
</div>
</div>
</div>
<div class="startrow">
@@ -368,6 +381,9 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
<dialog id="groupDlg">
<h2 style="margin-top:0" id="groupTitle"></h2>
<label class="field"><span data-i18n="grp.name"></span><input id="groupName" type="text" maxlength="80" style="width:100%" autocomplete="off"></label>
<label class="chk" style="display:flex;gap:8px;align-items:flex-start;margin:12px 0 0;font-size:14px">
<input type="checkbox" id="groupRoom" style="margin-top:3px"><span><b data-i18n="grp.room"></b><br>
<span class="faint" style="font-size:12.5px" data-i18n="grp.room.hint"></span></span></label>
<p class="muted" style="font-size:13px;margin:12px 0 6px" data-i18n="grp.pick"></p>
<div id="groupPick" class="pick"></div>
<div class="row" style="justify-content:flex-end;margin-top:16px">
@@ -378,7 +394,7 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
<dialog id="combineDlg" class="wide">
<h2 style="margin-top:0" id="cmbTitle"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="cmb.hint"></p>
<p class="muted" style="font-size:13px;margin-top:0" id="cmbHint"></p>
<h3 data-i18n="cmb.align"></h3>
<div class="opts" id="cmbAlign"></div>
<h3 data-i18n="cmb.part"></h3>
@@ -713,7 +729,11 @@ async function deleteRecording(f) {
const other = where === "frame" ? !!(f.local || loc) : !!(S.recs.frame || []).find(x => x.name === orig);
const name = f.label || fmtDate(f.date) || f.name;
if (!confirm(tr(`rdel.confirm.${where}${other ? "" : ".only"}`, {name, size: mb(f.size)}))) return;
const r = await api("/api/recording/delete", {name: f.name, where});
let r = await api("/api/recording/delete", {name: f.name, where});
if (!r.ok && r.error && r.error.code === "err.recording_has_parts") { // the original of cut parts
if (!confirm(trErr(r.error) + "\n\n" + tr("rdel.parts.confirm"))) return;
r = await api("/api/recording/delete", {name: f.name, where, force: true});
}
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr("rdel.done." + where, {name}));
S.sel = null; S.probe = null; $("#checkRes").className = "hidden";
@@ -803,19 +823,55 @@ function settings() {
maxg: v("maxg") ? +v("maxg") : pre.maxg, iter: v("iter") ? +v("iter") : pre.iter, pre,
};
}
/* ---------- a long recording in parts: each part gets up to 500 image pairs (the pipeline's limit) ---------- */
const PAIR_RATE = {high: 5, max: 8}, PAIRS_MAX = 500;
function splitInfo() {
const d = S.probe && S.probe.ok ? S.probe.duration || 0 : 0, rate = PAIR_RATE[S.quality];
if (!S.sel || !d || !rate || $("#frames").value.trim() || d * rate <= PAIRS_MAX) return null;
const suggested = clamp(Math.ceil(d * rate / PAIRS_MAX), 2, 10);
return {d, rate, suggested, max: Math.max(2, Math.min(10, Math.floor(d / 20)))};
}
function renderSplit() {
const info = splitInfo();
$("#splitBox").classList.toggle("hidden", !info);
if (!info) { S.split = null; return; }
const key = S.sel.name + "|" + S.quality;
if (!S.split || S.split.key !== key) S.split = {key, parts: true, n: Math.min(info.suggested, info.max)};
const n = S.split.n, part = info.d / n, perPart = Math.min(PAIRS_MAX, Math.round(part * info.rate));
const rateParts = num(Math.min(info.rate, PAIRS_MAX / part), 1), rateWhole = num(PAIRS_MAX / info.d, 1);
$("#splitN").value = n; $("#splitN").max = info.max;
$("#splitTitle").textContent = tr("split.parts", {rec: ""}).trim();
$("#splitPro").textContent = "+ " + tr("split.parts.pro", {rate: rateParts, whole: rateWhole, pairs: perPart, len: mmss(Math.round(part))});
$("#splitRec").textContent = n === info.suggested ? tr("ver.recommended") : "";
$("#splitCon").textContent = "- " + tr("split.parts.con", {n});
$("#wholePro").textContent = "+ " + tr("split.whole.pro");
$("#wholeCon").textContent = "- " + tr("split.whole.con", {rate: rateWhole, full: info.rate});
$("#optSplit").classList.toggle("active", S.split.parts); $("#optWhole").classList.toggle("active", !S.split.parts);
document.querySelector(`input[name=split][value=${S.split.parts ? "parts" : "whole"}]`).checked = true;
}
document.querySelectorAll("input[name=split]").forEach(r => r.onchange = () => { S.split.parts = r.value === "parts"; updateStart(); });
$("#splitN").oninput = () => {
const info = splitInfo(), n = parseInt($("#splitN").value, 10);
if (info && n >= 2 && n <= info.max) { S.split.n = n; S.split.parts = true; updateStart(); }
};
$("#splitN").onclick = e => e.preventDefault(); // inside the label: typing, not choosing
function updateStart() {
renderSplit();
const s = settings();
for (const k of ["frames", "scale", "maxg", "iter"]) $("#" + k).placeholder = num(s.pre[k], k === "scale" || k === "maxg" ? 1 : 0);
// rough time estimate (measured on an RTX 4070 SUPER: 30k steps, 2 M splats, full resolution ≈ 30 min;
// preparation ≈ 2.5 s per image pair)
const res = s.scale >= 1 ? 1 : s.scale >= 0.5 ? 0.33 + (s.scale - 0.5) * 1.34 : 0.2;
const train = s.iter / 1000 * res * Math.pow(s.maxg / 2, 0.8);
const total = Math.round(train + s.frames * 2.5 / 60 + 1);
const parts = S.split && S.split.parts ? S.split.n : 1;
const frames = parts > 1 ? Math.min(PAIRS_MAX, Math.round(S.probe.duration / parts * PAIR_RATE[S.quality])) : s.frames;
const total = Math.round((train + frames * 2.5 / 60 + 1) * parts);
const busy = S.job && S.job.running;
const ready = S.sel && (!S.probe || S.probe.ok) && $("#name").value.trim();
$("#startBtn").disabled = !ready || busy || !!(S.sel && queued(S.sel.name));
$("#estimate").innerHTML = busy ? tr("est.busy") : !S.sel ? tr("est.none") :
tr("est", {frames: s.frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total});
parts > 1 ? tr("est.parts", {n: parts, frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total})
: tr("est", {frames: s.frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total});
}
document.querySelectorAll("#quality button").forEach(b => b.onclick = () => {
S.quality = b.dataset.q; document.querySelectorAll("#quality button").forEach(x => x.classList.toggle("active", x === b)); updateStart();
@@ -849,6 +905,12 @@ async function start(existing) {
const v = id => $("#" + id).value.trim();
const body = {path: S.sel.path, name: $("#name").value.trim(), quality: S.quality, upload: target() === "frame", existing,
frames: v("frames") || null, scale: v("scale") || null, maxg: v("maxg") ? Math.round(+v("maxg") * 1e6) : null, iter: v("iter") || null};
if (S.split && S.split.parts && !existing) { // in parts, then one room
const r = await api("/api/parts", {...body, parts: S.split.n});
if (!r.ok) { toast(trErr(r.error)); return; }
S.dismissed = null; toast(tr("started.parts", {n: S.split.n})); poll(); window.scrollTo({top: 0, behavior: "smooth"});
return;
}
const r = await api("/api/start", body);
if (r.status === 409) {
$("#existsText").textContent = tr(r.no_resume ? "exists.noresume." + r.no_resume : "exists.text", {name: body.name});
@@ -867,18 +929,28 @@ $("#cancelBtn").onclick = async () => {
const r = await api("/api/cancel", {}); if (!r.ok) toast(trErr(r.error));
};
$("#dismissBtn").onclick = () => { S.dismissed = S.job?.out; renderJob(); };
$("#standbyChk").onchange = async e => { S.standby = await api("/api/standby", {on: e.target.checked}); renderJob(); };
function renderStandby() {
const sb = S.standby || {}, j = S.job;
$("#standbyChk").checked = !!sb.on;
$("#standbyBox").classList.toggle("hidden", !(j && j.running) && !sb.on);
$("#standbyText").textContent = sb.on && sb.at ? tr("job.standby.in", {s: Math.max(0, Math.round(sb.at - Date.now() / 1000))}) : tr("job.standby");
}
/* ---------- current conversion ---------- */
function renderJob() {
const j = S.job, card = $("#jobCard");
if (!j || (!j.running && S.dismissed === j.out)) { card.classList.add("hidden"); return; }
card.classList.remove("hidden");
renderStandby();
$("#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})
: combine ? tr("job.sub.combine", {n: j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, 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})
: tr("job.sub", {rec: j.recording, q: tr("q." + qk), target, time});
$("#cancelBtn").classList.toggle("hidden", !j.running);
$("#dismissBtn").classList.toggle("hidden", j.running);
@@ -963,9 +1035,14 @@ function groupOpen(slug, set) {
open = open.filter(x => x !== slug); if (set) open.push(slug);
try { localStorage.setItem("openGroups", JSON.stringify(open)); } catch {}
}
function groupBlock(g, bySlug) {
const rooms = g.members.map(m => bySlug[m]).filter(Boolean), r = g.result, open = groupOpen(g.slug);
const disk = rooms.reduce((a, s) => a + (s.disk ? s.disk.total : 0), 0) + (r && r.disk ? r.disk.total : 0);
function groupBlock(g, bySlug, byGroup) {
const scenes = (g.scenes || g.members).map(m => bySlug[m]).filter(Boolean), r = g.result, open = groupOpen(g.slug);
const inner = g.members.map(m => byGroup[m]).filter(Boolean); // rooms inside a home
const own = g.members.map(m => bySlug[m]).filter(Boolean);
const disk = scenes.reduce((a, s) => a + (s.disk ? s.disk.total : 0), 0) + (r && r.disk ? r.disk.total : 0)
+ inner.reduce((a, x) => a + (x.result && x.result.disk ? x.result.disk.total : 0), 0);
const count = g.room ? tr("grp.room.recordings", {n: scenes.length})
: inner.length ? tr("grp.home.count", {rooms: g.rooms, n: scenes.length}) : tr("grp.rooms", {n: scenes.length});
let tour = `<span>${tr("grp.none")}</span>`;
if (r) {
const frameN = Math.min(r.frame_splats || r.splats || 0, r.splats || 0), seams = Object.values(r.seams || {}).map(x => x.cm).sort((a, b) => a - b);
@@ -979,18 +1056,19 @@ function groupBlock(g, bySlug) {
}
const thumb = r && r.preview ? `style="background-image:url('/api/preview?slug=${encodeURIComponent(g.slug)}&t=${r.mtime}')"` : "";
const busy = g.busy ? `<span>${tr("grp.busy")}</span>` : "";
return `<div class="group">
const icon = g.room ? "🚪" : "📁";
return `<div class="group${g.room ? " room" : ""}">
<div class="ghead">
<div class="gthumb" ${thumb}>${r && r.preview ? "" : "📁"}</div>
<div class="gmeta"><b>📁 ${esc(g.name)}</b>
<span>${tr("grp.rooms", {n: rooms.length})} · ${tr("sc.disk", {size: mb(disk)})}</span>
<div class="gthumb" ${thumb}>${r && r.preview ? "" : icon}</div>
<div class="gmeta"><b>${icon} ${esc(g.name)}</b>
<span>${count} · ${tr("sc.disk", {size: mb(disk)})}</span>
${scaleLine(g)}
${tour}${busy}
<div class="acts">
<button class="small" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : "grp.show", {n: rooms.length})}</button>
<button class="small${!r || r.stale ? " primary" : ""}" data-combine="${esc(g.slug)}" ${g.busy || rooms.length < 2 ? "disabled" : ""}>${tr(r ? "grp.recombine" : "grp.combine")}</button>
<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}) : ""}
${rooms.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
${scenes.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
${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>
@@ -1001,7 +1079,7 @@ function groupBlock(g, bySlug) {
</div>
</div>
</div>
${open ? `<div class="grid ginner">${rooms.map(sceneCard).join("")}</div>` : ""}
${open ? `<div class="grid ginner">${inner.map(x => groupBlock(x, bySlug, byGroup)).join("")}${own.map(sceneCard).join("")}</div>` : ""}
</div>`;
}
async function loadScenes() {
@@ -1022,7 +1100,8 @@ async function loadScenes() {
$("#sugYes").onclick = () => openGroupDlg(null, {members: sug.members, name: tr("grp.suggest.name", {date: day})});
$("#sugNo").onclick = async () => { await api("/api/group/dismiss", {key: sug.key}); loadScenes(); };
}
$("#groupList").innerHTML = S.groups.map(g => groupBlock(g, bySlug)).join("");
const byGroup = Object.fromEntries(S.groups.map(g => [g.slug, g]));
$("#groupList").innerHTML = S.groups.filter(g => !g.parent).map(g => groupBlock(g, bySlug, byGroup)).join("");
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>`;
@@ -1034,7 +1113,7 @@ async function loadScenes() {
document.querySelectorAll("[data-combine]").forEach(b => b.onclick = () => openCombine(S.groups.find(g => g.slug === b.dataset.combine)));
document.querySelectorAll("[data-gplan]").forEach(b => b.onclick = () => {
const g = S.groups.find(x => x.slug === b.dataset.gplan);
openPlan(g.members.filter(m => bySlug[m] && bySlug[m].ply), g.name, g.slug);
openPlan((g.scenes || g.members).filter(m => bySlug[m] && bySlug[m].ply), g.name, g.slug);
});
document.querySelectorAll("[data-upload]").forEach(b => b.onclick = async () => {
const r = await api("/api/upload", {slug: b.dataset.upload});
@@ -1095,15 +1174,29 @@ function openGroupDlg(g, preset) {
(S.groups || []).forEach(x => { if (!g || x.slug !== g.slug) x.members.forEach(m => owner[m] = x.name); });
$("#groupTitle").textContent = tr(g ? "grp.edit.title" : "grp.new.title");
$("#groupName").value = g ? g.name : (preset && preset.name) || "";
// One group per scene: scenes of other groups are not offered at all
const free = S.scenes.filter(sc => !owner[sc.slug]);
$("#groupPick").innerHTML = free.length ? free.map(sc => `<label><input type="checkbox" value="${esc(sc.slug)}" ${members.has(sc.slug) ? "checked" : ""}>
${esc(sc.name)}</label>`).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("grp.noFree")}</p>`;
// A room (several recordings of one room) inside a home stays a room; a home with rooms can't become one
$("#groupRoom").checked = !!(g ? g.room : preset && preset.room);
$("#groupRoom").disabled = !!(g && (g.parent || (S.groups || []).some(x => x.room && g.members.includes(x.slug))));
const pick = () => {
const keep = new Set([...document.querySelectorAll("#groupPick input:checked")].map(i => i.value));
if (keep.size) { members.clear(); keep.forEach(m => members.add(m)); }
// One group per scene: scenes of other groups are not offered at all; a home may also hold free rooms
const free = S.scenes.filter(sc => !owner[sc.slug]).map(sc => ({slug: sc.slug, name: sc.name, room: false}));
const rooms = $("#groupRoom").checked ? [] : (S.groups || []).filter(x => x.room && !owner[x.slug] && (!g || x.slug !== g.slug))
.map(x => ({slug: x.slug, name: x.name, room: true, n: x.scenes.length}));
const all = [...rooms, ...free];
$("#groupPick").innerHTML = all.length ? all.map(x => `<label><input type="checkbox" value="${esc(x.slug)}" ${members.has(x.slug) ? "checked" : ""}>
${x.room ? "🚪 " : ""}${esc(x.name)}${x.room ? `<span>${tr("grp.room.recordings", {n: x.n})}</span>` : ""}</label>`).join("")
: `<p class="faint" style="font-size:13px;margin:0">${tr("grp.noFree")}</p>`;
};
$("#groupPick").innerHTML = ""; pick();
$("#groupRoom").onchange = pick;
$("#groupDlg").showModal(); $("#groupName").select();
$("#groupCancel").onclick = () => $("#groupDlg").close();
$("#groupSave").onclick = async () => {
const picked = [...document.querySelectorAll("#groupPick input:checked")].map(i => i.value);
const r = await api("/api/group", {slug: g ? g.slug : "", name: $("#groupName").value.trim(), members: picked.join(",")});
const r = await api("/api/group", {slug: g ? g.slug : "", name: $("#groupName").value.trim(), members: picked.join(","),
room: $("#groupRoom").checked});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#groupDlg").close(); groupOpen(r.slug, true); loadScenes();
};
@@ -1124,6 +1217,7 @@ function openCombine(g) {
radios("#cmbAlign", "cmb.align", ["frame", "images", "shift", "rigid"], o.align || "images");
radios("#cmbPart", "cmb.part", ["view", "nearest"], o.partition || "view");
$("#cmbTitle").textContent = tr("cmb.title", {name: g.name});
$("#cmbHint").textContent = tr(g.room ? "cmb.hint.room" : "cmb.hint");
const last = o.frame_splats || 1000000, known = FRAME_SIZES.some(m => m * 1e6 === last);
$("#cmbSize").innerHTML = FRAME_SIZES.map(m => `<option value="${m * 1e6}">${sizeText(m)}${m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""}</option>`).join("")
+ `<option value="custom">${tr("ver.custom")}</option>`;
@@ -1339,7 +1433,7 @@ function openClean(slug) {
const g = (S.groups || []).find(x => x.slug === slug);
let sc = S.scenes.find(x => x.slug === slug);
if (g) {
const rooms = g.members.map(m => S.scenes.find(x => x.slug === m)).filter(x => x && x.disk);
const rooms = (g.scenes || g.members).map(m => S.scenes.find(x => x.slug === m)).filter(x => x && x.disk);
const sum = k => rooms.reduce((a, x) => a + (x.disk[k] || 0), 0);
sc = {name: g.name, disk: {temp: sum("temp"), images: sum("images"), total: sum("total")}, n: rooms.length};
}
@@ -1471,7 +1565,7 @@ async function poll() {
document.body.classList.toggle("offline", offline);
if (offline) return;
const wasFetching = !!(S.dl && S.dl.busy), doneFiles = S.dl && S.dl.batch ? S.dl.batch.done_files : 0;
S.job = r.job; S.dl = r.download;
S.job = r.job; S.dl = r.download; S.standby = r.standby;
renderDlBar();
const nowFetching = !!(S.dl && S.dl.busy);
if (wasFetching && (!nowFetching || (S.dl.batch && S.dl.batch.done_files !== doneFiles))) {