mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 12:01:54 +02:00
Compare commits
No files matched your search
@@ -2,6 +2,79 @@
|
||||
|
||||
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.4.0] – 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Clean up a version** (experimental): in the versions dialog, four sliders from *off* to 100 % – the scene itself stays as it is, only the new version is cleaned up, so it can be compared side by side with one without (e.g. *all splats* for the PC).
|
||||
- **Ceiling**: a white ceiling gives the reconstruction nothing to hold on to – it stays thin, with a dark veil under it. Takes out the veil (deeper with more strength; dark things within 50 cm of a wall, like a cupboard top or a bird cage, stay) and what lies above the ceiling, and puts in a closed ceiling in the colour of what there is of it. The ceiling's height is found where walls and ceiling meet. In a real living room: the veil of about 210,000 splats gone, the ceiling closed with 72,000.
|
||||
- **Floor**: fills holes in the floor in the colour of the floor around them, and takes out what lies under it.
|
||||
- **Haze**: takes out big, nearly transparent blobs floating in the room – measured by their *smallest* axis, so long thin things (the bars of a bird cage, cables) are no haze and stay.
|
||||
- **Edges**: takes out what lies well outside the room – beyond the walls, above the ceiling, under the floor.
|
||||
- Also on the command line: `scripts/clean.py`.
|
||||
- **The versions dialog, reworked** – by purpose instead of by size: *for the Frame* (one size, each with its frame rate as measured: 0.75 M ≈ 36 fps, 1 M ≈ 35 fps and clearly sharper, 1.5 M ≈ 29 fps, 2 M ≈ 24 fps) or *for the PC* (all splats). One version per click, optionally the same *without cleaning up* next to it to compare. Before making, the dialog shows what it will make – and when that replaces an existing version.
|
||||
- Versions are named by what was cleaned up and how strongly (*all splats · ceiling 50 % · haze 30 %*), so two tries never look alike.
|
||||
- **As template** takes a version's settings into the form: change one slider and make the next.
|
||||
- A cleaned version for the PC keeps its own file for **Spirula Studio** and opens there with one click.
|
||||
- **Versions unfold under their scene or tour** (*▸ 3 versions*), with Spirula, copying to the Frame (with a warning above 2.5 M splats), template and delete.
|
||||
- **Tidier action bars** on scenes and tours: what you do (put together, copy to the Frame) – what you look at (Spirula, floor plan, folder) – what you change (rename, scale, clean up, delete, as icons with tooltips); the unfolding lists (*▸ 3 recordings*, *▸ 1 version* or *+ Versions …*) on a line of their own. One versions button instead of two.
|
||||
- The PC sizes 3, 5 and 8 M are gone – on the PC all splats is what counts; any other size goes through *own size*.
|
||||
- `scripts/freespace.py` (experimental, command line only): takes out splats in space the cameras looked through, from the recordings' sparse models. On a real living room it found only 0.4 % – white walls and ceilings have too few points to tell – but looked a little better; kept for trying.
|
||||
|
||||
## [0.3.1] – 2026-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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
|
||||
|
||||
- **Scale for models**, e.g. a model railway: set Z, N, TT, H0, S, 0, G or your own scale for a scene or a whole group (optionally passed on to its scenes). Floor plan, room sizes, the scale bar and the measuring tool then show the size of the original – a 10 cm H0 model reads as 8.7 m. The floor plan also looks for walls and floors in the model's scale, so a model is drawn like a real building.
|
||||
- **Walk through a model in its original size** in Gaussian XR (option): Splat Workshop sets Gaussian XR's `worldScale` when copying a scene or tour to the Frame – the scene files on the PC stay unchanged. *Not tested on the Frame yet.*
|
||||
|
||||
- **Versions** of a scene or tour, several side by side: fewer splats for the Frame (0.75 / 1 / 1.5 / 2 M), all or a limit for the PC (3 / 5 / 8 M), or an own size – each also in the original's size for models. Every version gets its own place in the Frame's gallery (e.g. "Flat · 1 M" next to "Flat · 2 M"), so you can find out yourself what runs smoothly. Frame rates measured on a Steam Frame are shown for information.
|
||||
- **Fetch several recordings from the Frame at once**: *Fetch all* (everything not on this PC yet) or tick recordings and *Fetch selection*. They come one after the other, with one progress bar for all and *Cancel*; recordings ticked while fetching join the queue. A notification at the end says when the Frame can be switched off; one that fails does not stop the others.
|
||||
- **Delete a recording**, one at a time: on the Frame (for good – with a clear warning if it is not on this PC yet) or on this PC (to the trash). A recording that is being converted, checked or fetched, or that an unfinished scene still needs, stays.
|
||||
- **"On the Frame" tab** under the finished scenes: everything in Gaussian XR's gallery on the Frame with its preview image – scenes, tours, versions and Gaussian XR's own examples – and *Off the Frame* to delete any of them there (the PC is not touched).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Scenes go to Gaussian XR's folder for personal scenes** (`Documents/GaussianXR` on the Frame) instead of the app's own `Scenes` folder, which a Steam update or "verify files" can reset. They appear in the gallery after Gaussian XR's examples. Scenes copied by older versions move over when copied again, or all at once with *Move to own scenes* in the *On the Frame* tab; numbers of new folders count both places.
|
||||
- **No backup copies on the Frame any more**: everything is on the PC, and *Copy again* restores a scene. Copies left in `Documents/GaussianSplats` by older versions are removed together with their scene.
|
||||
- **1 M splats is the recommended size for the Frame** (preselected for versions and tours, also the default of `combine.sh`, was 3.5 M). Measured on a Steam Frame with one tour: 0.75 M 36 fps, 1 M 35 fps, 1.5 M 29 fps, 2 M 24 fps, 5 M 14 fps – 1 M looked clearly sharper than 0.75 M, 1.5 M a little sharper but already slightly unsteady. All sizes stay selectable.
|
||||
- Putting a tour together offers 0.75 to 2 M splats or an own size for its Frame version (was 2 / 3.5 / 5 M).
|
||||
|
||||
- Tests extended after an independent review with mutation testing (499 including the type checks of every request field): scale, versions, fetching several recordings, deleting recordings, the *On the Frame* tab and the new folder, and what is locked while a scene is computed, checked, fetched or copied.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The floor plan no longer fails for scenes in which it finds no walls (e.g. a model without its scale).
|
||||
- The floor plan of a group uses the group's scale, also when it was not passed on to its scenes. Scenes in different scales without one for the group give a clear message instead of a plan in the wrong size.
|
||||
- *Move to own scenes* waits until no scene is being copied to the Frame.
|
||||
|
||||
## [0.2.2] – 2026-10-14
|
||||
|
||||
### Changed
|
||||
@@ -65,6 +138,10 @@ First release.
|
||||
- **Floor plans** (experimental) in two styles with a measuring tool.
|
||||
- Configurable video player; English and German interface.
|
||||
|
||||
[0.4.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.1...v0.4.0
|
||||
[0.3.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.3...v0.3.0
|
||||
[0.2.3]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.1.0...v0.2.0
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Notes for working on Splat Workshop
|
||||
|
||||
## Releasing – every release, also a patch
|
||||
|
||||
1. Only with the maintainer's explicit OK for each step: push, tag, GitHub release, editing release notes.
|
||||
2. `VERSION` in `splat_workshop/server.py`, a `## [x.y.z] – date` section and the compare link in `CHANGELOG.md`.
|
||||
3. Tests green locally and in GitHub Actions before tagging.
|
||||
4. **Release notes always have this shape** (a patch release too – people who only read the latest
|
||||
release must still see what the series brought):
|
||||
- one bold line: what the series is about, plus what this release adds;
|
||||
- the screenshot (`docs/groups.png`, example data only);
|
||||
- `## Highlights of x.y` – the highlights of the whole minor series, copied from the `x.y.0` release;
|
||||
- a link to the `x.y.0` release for all details;
|
||||
- `## New in x.y.z` – what this release adds or fixes (for `x.y.0` itself: `## New in x.y`);
|
||||
- `**Full changelog:**` compare link to the previous tag.
|
||||
Check the finished notes against this list before publishing.
|
||||
|
||||
## Tests
|
||||
|
||||
`uv run --no-project --with pytest --with numpy --with scipy --with pillow pytest`
|
||||
|
||||
Expectations come from how the synthetic data is built (`tests/synth.py`), not from the code. Tests must
|
||||
never touch real data, the Frame or this PC's power state (`systemctl`, `ssh`, `notify-send` are faked).
|
||||
|
||||
## Running scripts
|
||||
|
||||
`splat.sh`, `to-frame.sh`, `combine.sh`, `versions.sh` run as one `{ … exit; }` block; still, never edit
|
||||
one in place while a job uses it – write a new file and `mv` it over.
|
||||
|
||||
## File systems
|
||||
|
||||
Runs may live on NTFS: no folder or file name may end with a dot or contain `:`; git there needs
|
||||
`core.fileMode false`.
|
||||
+32
-5
@@ -19,8 +19,27 @@ 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.4
|
||||
|
||||
- 🧽 **Fassungen bereinigen** *(experimentell)* – vier Schieberegler im Fassungen-Dialog: **Decke** (eine weiße Decke wird zum dunklen Schleier – der Schleier kommt weg, die Decke wird in ihrer eigenen Farbe geschlossen), **Boden** (Löcher gefüllt), **Dunst** (große durchsichtige Wolken; dünne Gitter und Kabel bleiben), **Rand** (was außerhalb des Raums liegt). Die Szene selbst bleibt; die bereinigte Fassung neben einer ohne vergleichen.
|
||||
- 🎚️ **Fassungen nach Zweck** – *für die Frame* (eine Größe, mit den auf einer Steam Frame gemessenen fps) oder *für den PC* (alle Splats); auf Wunsch dieselbe ohne Bereinigung daneben; vorher eine Vorschau, was entsteht. Fassungen heißen nach ihren Bereinigungsstärken, *Als Vorlage* übernimmt die Einstellungen für den nächsten Versuch, eine PC-Fassung öffnet sich mit einem Klick in **Spirula Studio**, und Fassungen **klappen unter ihrer Szene oder ihrem Rundgang auf**.
|
||||
- 🧭 **Aufgeräumte Knopfleisten** – was du tust, was du anschaust, was du änderst; das Aufklappen in einer eigenen Zeile.
|
||||
|
||||
## 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,10 +61,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>
|
||||
- **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).
|
||||
- **Fassungen** einer Szene oder eines Rundgangs nebeneinander: weniger Splats für die Frame (0,75–2 Mio.), alle oder eine Obergrenze für den PC oder eine eigene Größe – jede mit eigenem Platz in der Galerie der Frame, zum Vergleichen, was flüssig läuft. Eine Fassung lässt sich auch **bereinigen** *(0.4, experimentell)*: Decke, Boden, Dunst und Rand, jeweils mit eigener Stärke.
|
||||
- **Alle holen**: Aufnahmen von der Frame alle oder als Auswahl nacheinander holen – mit einer Benachrichtigung, sobald die Frame aus darf.
|
||||
- **Aufnahmen löschen**, einzeln: auf der Frame (mit deutlicher Warnung, wenn sie noch nicht auf dem PC liegt) oder auf dem PC (in den Papierkorb).
|
||||
- Reiter **Auf der Frame**: alles, was in der Galerie von Gaussian XR auf der Frame liegt, mit Vorschaubildern – und von dort aufräumen.
|
||||
- **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; 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.
|
||||
@@ -74,14 +99,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,8 +21,23 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
|
||||
1. **Pick a recording** directly from the Frame (over SSH) or from your PC, with thumbnails.
|
||||
2. **Check it** before spending an hour on it: brightness, structure, fast pans and covered lenses, second by second.
|
||||
3. **Convert it** with a quality preset (Fast / High / Maximum) or fine-tuned values, with live progress, ETA and a PSNR curve.
|
||||
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's `Scenes` folder on the Frame.
|
||||
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.
|
||||
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's folder for personal scenes on the Frame (`Documents/GaussianXR`).
|
||||
5. **Put your home together**: group the rooms – a room may be several recordings – and turn them into one tour you walk through, full size for the PC, smaller for the Frame.
|
||||
|
||||
## New in 0.4
|
||||
|
||||
- 🧽 **Clean up a version** *(experimental)* – four sliders in the versions dialog: **Ceiling** (a white ceiling turns into a dark veil – the veil goes, the ceiling is closed in its own colour), **Floor** (holes filled), **Haze** (big transparent blobs; thin bars and cables stay), **Edges** (what lies outside the room). The scene itself stays; compare the cleaned version with one without.
|
||||
- 🎚️ **Versions by purpose** – *for the Frame* (one size, with the frame rate measured on a Steam Frame) or *for the PC* (all splats); optionally the same without cleaning up next to it; a preview of what will be made. Versions are named by their clean-up strengths, *As template* takes a version's settings for the next try, a PC version opens in **Spirula Studio** with one click, and versions **unfold under their scene or tour**.
|
||||
- 🧭 **Tidier action bars** – what you do, what you look at, what you change; the unfolding lists on a line of their own.
|
||||
|
||||
## 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,10 +59,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>
|
||||
- **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).
|
||||
- **Versions** of a scene or tour side by side: fewer splats for the Frame (0.75–2 M), all or a limit for the PC, or your own size – each with its own place in the Frame's gallery, so you can compare what runs smoothly. A version can also be **cleaned up** *(0.4, experimental)*: ceiling, floor, haze and edges, each with its own strength.
|
||||
- **Fetch all** recordings from the Frame, or a selection, one after the other – with a notification when the Frame can be switched off.
|
||||
- **Delete a recording**, one at a time: on the Frame (with a clear warning if it is not on the PC yet) or on the PC (to the trash).
|
||||
- **On the Frame** tab: everything in Gaussian XR's gallery on the Frame with preview images, and taking things off it.
|
||||
- **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; 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.
|
||||
@@ -110,11 +131,11 @@ 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. A Steam “verify files” or app update can reset Gaussian XR's `Scenes` folder – Splat Workshop also keeps a copy in `~/Documents/GaussianSplats` on the Frame, and *Copy again* restores a scene.
|
||||
After copying, **restart Gaussian XR** on the Frame so the new scene appears in the gallery. Scenes go to `Documents/GaussianXR`, which Steam updates leave alone (Splat Workshop 0.2.3 and older used Gaussian XR's own `Scenes` folder – *Move to own scenes* in the *On the Frame* tab moves them over). Everything also stays on the PC, and *Copy again* restores a scene. For the Frame, about **1 M splats** runs smoothly and still looks sharp. The *On the Frame* tab under the finished scenes shows what is in the gallery on the Frame and takes things off it.
|
||||
|
||||
### Access from other devices
|
||||
|
||||
@@ -125,8 +146,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
|
||||
@@ -154,6 +177,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
|
||||
|
||||
@@ -161,7 +185,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)
|
||||
```
|
||||
|
||||
@@ -175,7 +199,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
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["numpy", "scipy", "pillow"]
|
||||
# ///
|
||||
"""Clean up a finished scene or room: the things a reconstruction of a real room tends to get wrong.
|
||||
|
||||
Usage: uv run clean.py <in.ply> <out.ply> [--ceiling S] [--floor S] [--haze S] [--outside S]
|
||||
|
||||
Every strength S runs from 0 (off) to 1 (strongest). The room is found as for the floor plan (walls 0.9 to
|
||||
1.9 m above the floor, turned to them; Y points down).
|
||||
--ceiling A white ceiling has no texture: the reconstruction finds nothing to hold on to, leaves the
|
||||
ceiling thin and hangs a dark veil under it. Takes out the veil (dark or thin splats from
|
||||
10 cm to 60 cm under the ceiling – how deep grows with S; dark but solid things like a cupboard
|
||||
top or a bird cage stay if they are within 50 cm of a wall) and everything above the ceiling
|
||||
(found where walls and ceiling meet), and puts in a closed ceiling in the colour of what there
|
||||
is of it nearby.
|
||||
--floor Fills holes in the floor (where the floor itself has too little: more with S) in the colour of
|
||||
the floor nearby, and takes out what lies under it.
|
||||
--haze Takes out big, nearly transparent blobs floating in the room. Measured by their *smallest*
|
||||
axis: long thin splats (wires, bars of a cage, cables) are no haze. More with S.
|
||||
--outside Takes out what lies well outside the room (beyond the walls, above the ceiling, under the
|
||||
floor): from 1 m beyond it (S near 0) to 30 cm (S = 1).
|
||||
Prints what it did; the input is never changed.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import floorplan as F # noqa: E402 (room box, wall angle)
|
||||
|
||||
C0 = 0.28209479177387814
|
||||
STEP = 0.02 # grid of a filled ceiling or floor (m)
|
||||
|
||||
|
||||
def read_ply(path):
|
||||
with open(path, "rb") as f:
|
||||
header = b""
|
||||
while not header.endswith(b"end_header\n"):
|
||||
line = f.readline()
|
||||
if not line:
|
||||
sys.exit(f"Not a PLY file: {path}")
|
||||
header += line
|
||||
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
|
||||
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
|
||||
v = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
|
||||
return header, v
|
||||
|
||||
|
||||
def write_ply(path, header, v):
|
||||
lines = [b"element vertex %d\n" % len(v) if l.startswith(b"element vertex") else l
|
||||
for l in header.splitlines(keepends=True)]
|
||||
tmp = Path(str(path) + ".tmp")
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(b"".join(lines))
|
||||
v.tofile(f)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
class Room:
|
||||
"""Where floor, ceiling and walls are, from the solid splats."""
|
||||
def __init__(self, xyz, alpha):
|
||||
solid = xyz[alpha > 0.5]
|
||||
if len(solid) < 1000:
|
||||
sys.exit("Too few solid splats to find the room")
|
||||
self.floor, _ = F.heights(solid[:, 1])
|
||||
up = self.floor - solid[:, 1]
|
||||
walls = solid[(up > 0.9) & (up < 1.9)][:, [0, 2]]
|
||||
sub = walls[np.random.default_rng(0).choice(len(walls), min(len(walls), 150_000), replace=False)]
|
||||
self.angle = F.wall_angle(sub, 0.03)
|
||||
box = F.room_box(F.rotate(walls, self.angle), 0.05)
|
||||
if box is None:
|
||||
sys.exit("No walls found")
|
||||
self.lo, self.hi = box
|
||||
self.up = self.floor - xyz[:, 1] # height above the floor of every splat
|
||||
self.p = F.rotate(xyz[:, [0, 2]], self.angle) # floor plan position, walls axis-aligned
|
||||
|
||||
def within(self, margin):
|
||||
"""Splats inside the walls, `margin` metres away from them (negative: beyond them)."""
|
||||
return np.all((self.p > self.lo + margin) & (self.p < self.hi - margin), axis=1)
|
||||
|
||||
def grid(self):
|
||||
g0, g1 = np.meshgrid(np.arange(self.lo[0], self.hi[0], STEP), np.arange(self.lo[1], self.hi[1], STEP),
|
||||
indexing="ij")
|
||||
return np.stack([g0.ravel(), g1.ravel()], 1)
|
||||
|
||||
|
||||
def layer(room, v, at, cells, ref, rgb, alpha):
|
||||
"""Flat splats (3 cm wide, 2 mm thick) at height `at` above the floor in the floor plan cells, in the
|
||||
colour of the reference splats `ref` nearby (half of it the overall colour: calm, no blotches)."""
|
||||
overall = np.average(rgb[ref], weights=alpha[ref], axis=0) if ref.any() else np.array([0.8, 0.8, 0.8])
|
||||
col = np.tile(overall, (len(cells), 1))
|
||||
if ref.sum() >= 10:
|
||||
d, j = cKDTree(room.p[ref]).query(cells, k=32, distance_upper_bound=0.5, workers=-1)
|
||||
ok = np.isfinite(d)
|
||||
j = np.where(ok, j, 0)
|
||||
w = np.where(ok, alpha[ref][j] / (0.05 + np.where(ok, d, 1)), 0)
|
||||
near = (w[:, :, None] * rgb[ref][j]).sum(1) / np.maximum(w.sum(1, keepdims=True), 1e-9)
|
||||
col = np.where(w.sum(1, keepdims=True) > 0, 0.5 * near + 0.5 * overall, overall)
|
||||
new = np.zeros(len(cells), dtype=v.dtype)
|
||||
xz = F.rotate(cells, -room.angle)
|
||||
new["x"], new["z"], new["y"] = xz[:, 0], xz[:, 1], room.floor - at
|
||||
for k in range(3):
|
||||
new[f"f_dc_{k}"] = (np.clip(col[:, k], 0, 1) - 0.5) / C0
|
||||
new["opacity"] = np.log(0.98 / 0.02)
|
||||
new["scale_0"] = new["scale_2"] = np.log(0.015)
|
||||
new["scale_1"] = np.log(0.002) # flat: y is up/down
|
||||
new["rot_0"] = 1.0
|
||||
return new
|
||||
|
||||
|
||||
def ceiling(room, v, rgb, lum, alpha, s):
|
||||
"""Height of the ceiling, what to take out (veil, above it), the new ceiling."""
|
||||
inside = room.within(0)
|
||||
# where walls and ceiling meet, solid splats pile up: the strongest line near the walls above 2 m
|
||||
# (the ceiling itself, white and thin, is no good to find it)
|
||||
corner = inside & ~room.within(0.3) & (alpha > 0.5) & (room.up > 2.0) & (room.up < 3.5)
|
||||
if corner.sum() < 100:
|
||||
print("Ceiling: not found, left as it is")
|
||||
return np.zeros(len(v), bool), v[:0]
|
||||
h, edges = np.histogram(room.up[corner], bins=np.arange(2.0, 3.5, 0.02))
|
||||
h = np.convolve(h, [1, 2, 1], "same")
|
||||
# the highest strong line: a cupboard or shelf top by the wall can be strong, too, but lies lower
|
||||
H = float(edges[np.flatnonzero(h >= 0.5 * h.max())[-1]] + 0.01)
|
||||
bright = inside & (lum > 0.3) & (room.up > 2.0)
|
||||
depth = 0.1 + 0.5 * s
|
||||
under = (room.up > H - depth) & (room.up < H - 0.03)
|
||||
veil = under & ((room.within(0.15) & (alpha < 0.3)) | (room.within(0.5) & (lum < 0.3)))
|
||||
above = inside & (room.up > H + 0.05)
|
||||
new = layer(room, v, H, room.grid(), bright & (np.abs(room.up - H) < 0.08), rgb, alpha)
|
||||
print(f"Ceiling {H:.2f} m above the floor: veil ({depth:.2f} m deep) {veil.sum():,} splats and "
|
||||
f"{above.sum():,} above it out, closed with {len(new):,}")
|
||||
return veil | above, new
|
||||
|
||||
|
||||
def floor(room, v, rgb, alpha, s):
|
||||
"""Holes in the floor filled, what lies under it out."""
|
||||
inside = room.within(0)
|
||||
below = inside & (room.up < -0.05)
|
||||
near = inside & (np.abs(room.up) < 0.04)
|
||||
cells = room.grid()
|
||||
n = (len(np.arange(room.lo[0], room.hi[0], STEP)), len(np.arange(room.lo[1], room.hi[1], STEP)))
|
||||
idx = np.clip(np.floor((room.p[near] - room.lo) / STEP).astype(np.int64), 0, np.array(n) - 1)
|
||||
n0 = n[1]
|
||||
cover = np.bincount(idx[:, 0] * n0 + idx[:, 1], alpha[near], len(cells))
|
||||
# a cell and its neighbours: a floor a centimetre off still counts
|
||||
c2 = cover.reshape(-1, n0)
|
||||
pad = np.pad(c2, 1)
|
||||
around = sum(pad[1 + dx:1 + dx + c2.shape[0], 1 + dy:1 + dy + c2.shape[1]]
|
||||
for dx in (-1, 0, 1) for dy in (-1, 0, 1)).ravel()
|
||||
hole = around < 1.5 * s
|
||||
new = layer(room, v, 0.0, cells[hole], near & (alpha > 0.3), rgb, alpha)
|
||||
print(f"Floor: {below.sum():,} splats under it out, {hole.sum():,} holes ({100 * hole.mean():.0f} %) filled")
|
||||
return below, new
|
||||
|
||||
|
||||
def haze(v, alpha, s):
|
||||
smallest = np.exp(np.stack([v["scale_0"], v["scale_1"], v["scale_2"]], 1).astype(np.float64)).min(1)
|
||||
size, opacity = 0.04 - 0.025 * s, 0.05 + 0.15 * s
|
||||
out = (smallest > size) & (alpha < opacity)
|
||||
print(f"Haze: {out.sum():,} splats out (all axes over {size * 100:.1f} cm, under {opacity * 100:.0f} % opaque)")
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("src")
|
||||
ap.add_argument("dst")
|
||||
for k in ("ceiling", "floor", "haze", "outside"):
|
||||
ap.add_argument(f"--{k}", type=float, default=0.0)
|
||||
args = ap.parse_args()
|
||||
for k in ("ceiling", "floor", "haze", "outside"):
|
||||
if not 0 <= getattr(args, k) <= 1:
|
||||
sys.exit(f"--{k} must be between 0 and 1")
|
||||
header, v = read_ply(args.src)
|
||||
xyz = np.stack([v["x"], v["y"], v["z"]], 1).astype(np.float64)
|
||||
alpha = 1 / (1 + np.exp(-v["opacity"].astype(np.float64)))
|
||||
rgb = 0.5 + C0 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1).astype(np.float64)
|
||||
lum = rgb @ [0.299, 0.587, 0.114]
|
||||
room = Room(xyz, alpha)
|
||||
print(f"Room {room.hi[0] - room.lo[0]:.1f} × {room.hi[1] - room.lo[1]:.1f} m, {len(v):,} splats")
|
||||
drop, added = np.zeros(len(v), bool), []
|
||||
if args.ceiling:
|
||||
d, new = ceiling(room, v, rgb, lum, alpha, args.ceiling)
|
||||
drop |= d
|
||||
added.append(new)
|
||||
if args.floor:
|
||||
d, new = floor(room, v, rgb, alpha, args.floor)
|
||||
drop |= d
|
||||
added.append(new)
|
||||
if args.haze:
|
||||
drop |= haze(v, alpha, args.haze)
|
||||
if args.outside:
|
||||
m = 1.0 - 0.7 * args.outside
|
||||
solid_in = room.up[room.within(0) & (alpha > 0.5)]
|
||||
top = max(np.percentile(solid_in, 99.5), 2.0) if len(solid_in) else 2.5
|
||||
out = ~room.within(-m) | (room.up < -m) | (room.up > top + m)
|
||||
print(f"Outside: {out.sum():,} splats more than {m:.2f} m outside the room out")
|
||||
drop |= out
|
||||
result = np.concatenate([v[~drop], *added])
|
||||
write_ply(args.dst, header, result)
|
||||
print(f"{len(result):,} splats ({drop.sum():,} out, {sum(len(a) for a in added):,} in)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+154
-19
@@ -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))
|
||||
|
||||
+31
-5
@@ -7,11 +7,15 @@
|
||||
# Environment:
|
||||
# ALIGN=frame|images|shift|rigid how the rooms are fitted together (default: images, see combine.py)
|
||||
# PARTITION=view|nearest where the borders between the recordings run (default: view)
|
||||
# FRAME_SPLATS=3500000 splats in the Frame version (the full version keeps all)
|
||||
# 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)")
|
||||
# PLAN_COMMA=1 decimal comma on the floor plan used as preview
|
||||
# NO_UPLOAD=1 only compute, don't copy to the Frame
|
||||
# WORLD_SCALE=87 show a model in Gaussian XR in the size of the original (1:87)
|
||||
# A previous result of the same name stays until the new one is done.
|
||||
#
|
||||
# Lines starting with "@@" are machine-readable progress markers for the web interface.
|
||||
@@ -36,7 +40,20 @@ log=$RUNS_DIR/$slug.log
|
||||
work=$RUNS_DIR/.building
|
||||
tmp=$work/$slug
|
||||
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
|
||||
frame_splats=${FRAME_SPLATS:-3500000}
|
||||
frame_splats=${FRAME_SPLATS:-1000000}
|
||||
|
||||
# Gaussian XR's worldScale in a scene's transform file, when WORLD_SCALE is set (a model shown in the
|
||||
# size of the original, e.g. 87 for H0)
|
||||
world_scale() {
|
||||
[ -n "${WORLD_SCALE:-}" ] || return 0
|
||||
python3 - "$1" "$WORLD_SCALE" <<'PY'
|
||||
import json, sys
|
||||
path, scale = sys.argv[1], float(sys.argv[2])
|
||||
data = json.load(open(path))
|
||||
data["worldScale"] = scale
|
||||
json.dump(data, open(path, "w"), indent=2)
|
||||
PY
|
||||
}
|
||||
|
||||
main() {
|
||||
rm -rf "$tmp"
|
||||
@@ -45,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
|
||||
@@ -67,13 +85,21 @@ 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"
|
||||
cp "$tmp/full/scene.sog" "$tmp/full/$stem-full.sog"
|
||||
cp "$tmp/full/scene.xrSceneTransform.json" "$tmp/full/$stem-full.xrSceneTransform.json"
|
||||
world_scale "$tmp/full/$stem-full.xrSceneTransform.json"
|
||||
fi
|
||||
|
||||
# Replace the previous result only now
|
||||
|
||||
+54
-25
@@ -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.
|
||||
@@ -101,9 +102,11 @@ def heights(y):
|
||||
return np.percentile(y, 97), np.percentile(y, 3)
|
||||
|
||||
|
||||
def wall_angle(points_xz):
|
||||
def wall_angle(points_xz, bin_size=0.03):
|
||||
"""Yaw that makes the walls axis-aligned: maximise the peakiness of the x/z histograms."""
|
||||
best, best_score = 0.0, -1
|
||||
if len(points_xz) < 50: # no walls found: leave it as it is
|
||||
return best
|
||||
for deg in np.arange(0, 90, 0.5):
|
||||
a = np.radians(deg)
|
||||
c, s = np.cos(a), np.sin(a)
|
||||
@@ -111,7 +114,7 @@ def wall_angle(points_xz):
|
||||
v = points_xz[:, 0] * s + points_xz[:, 1] * c
|
||||
score = 0
|
||||
for w in (u, v):
|
||||
h, _ = np.histogram(w, bins=max(10, int((w.max() - w.min()) / 0.03)))
|
||||
h, _ = np.histogram(w, bins=max(10, int((w.max() - w.min()) / bin_size)))
|
||||
score += float(np.sum(h.astype(np.float64) ** 2)) / len(w) ** 2
|
||||
if score > best_score:
|
||||
best, best_score = a, score
|
||||
@@ -142,11 +145,16 @@ def main():
|
||||
ap.add_argument("runs", nargs="+", help="run folder, optionally =label")
|
||||
ap.add_argument("--title", default="")
|
||||
ap.add_argument("--comma", action="store_true", help="decimal comma in sizes")
|
||||
ap.add_argument("--ratio", type=float, default=1.0,
|
||||
help="model scale, e.g. 87 for H0 (1:87): sizes and the scale bar show the original's size")
|
||||
ap.add_argument("--style", choices=("pretty", "classic"), default="pretty",
|
||||
help="pretty: colour top view, glowing walls, camera path; classic: glowing point density only")
|
||||
args = ap.parse_args()
|
||||
out = Path(args.out)
|
||||
rng = np.random.default_rng(1)
|
||||
# All lengths below are in metres of the original: a model (--ratio 87) is measured in its own scale,
|
||||
# so its walls are looked for 1 to 2 cm above its floor instead of 0.9 to 1.9 m.
|
||||
m = 1 / args.ratio
|
||||
scenes = []
|
||||
for arg in args.runs:
|
||||
run, _, label = arg.partition("=")
|
||||
@@ -155,23 +163,33 @@ def main():
|
||||
floor, ceil = heights(xyz[:, 1])
|
||||
# keep the room: below the ceiling, not below the floor, and drop far-away floaters
|
||||
lo, hi = np.percentile(xyz[:, [0, 2]], 0.5, axis=0), np.percentile(xyz[:, [0, 2]], 99.5, axis=0)
|
||||
inside = ((xyz[:, 1] > ceil + 0.25) & (xyz[:, 1] < floor + 0.15)
|
||||
& np.all(xyz[:, [0, 2]] >= lo - 0.3, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3, axis=1))
|
||||
up = floor - xyz[:, 1] # height above the floor in metres
|
||||
walls = inside & (up > 0.9) & (up < 1.9)
|
||||
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})
|
||||
inside = ((xyz[:, 1] > ceil + 0.25 * m) & (xyz[:, 1] < floor + 0.15 * m)
|
||||
& np.all(xyz[:, [0, 2]] >= lo - 0.3 * m, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3 * m, axis=1))
|
||||
if inside.sum() < 100: # e.g. a model drawn without its scale: keep all
|
||||
inside = np.ones(len(xyz), bool)
|
||||
up = floor - xyz[:, 1] # height above the floor
|
||||
walls = inside & (up > 0.9 * m) & (up < 1.9 * m)
|
||||
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)]
|
||||
angle = wall_angle(sub)
|
||||
angle = wall_angle(sub, 0.03 * m)
|
||||
for s in scenes:
|
||||
s["p"] = rotate(s["xyz"][:, [0, 2]], angle)
|
||||
s["w"] = rotate(s["walls"], angle)
|
||||
s["c"] = rotate(s["cams"][:, [0, 2]], angle) if s["cams"] is not None else None
|
||||
s["box"] = room_box(s["w"])
|
||||
s["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, s["box"][1] + 0.35
|
||||
b0, b1 = s["box"][0] - 0.35 * m, s["box"][1] + 0.35 * m
|
||||
ok = np.all((s["p"] >= b0) & (s["p"] <= b1), axis=1)
|
||||
s["p"], s["rgb"], s["up"] = s["p"][ok], s["rgb"][ok], s["up"][ok]
|
||||
okw = np.all((s["w"] >= b0) & (s["w"] <= b1), axis=1)
|
||||
@@ -179,11 +197,11 @@ def main():
|
||||
|
||||
boxes = [s["box"] for s in scenes if s["box"] is not None]
|
||||
if boxes:
|
||||
lo = np.min([b[0] for b in boxes], axis=0) - 0.8
|
||||
hi = np.max([b[1] for b in boxes], axis=0) + 0.8
|
||||
lo = np.min([b[0] for b in boxes], axis=0) - 0.8 * m
|
||||
hi = np.max([b[1] for b in boxes], axis=0) + 0.8 * m
|
||||
else:
|
||||
allp = np.vstack([s["p"] for s in scenes])
|
||||
lo, hi = np.percentile(allp, 1, axis=0) - 0.5, np.percentile(allp, 99, axis=0) + 0.5
|
||||
lo, hi = np.percentile(allp, 1, axis=0) - 0.5 * m, np.percentile(allp, 99, axis=0) + 0.5 * m
|
||||
span = hi - lo
|
||||
ppm = (SIZE - 160) / span.max() # pixels per metre
|
||||
W, H = int(span[0] * ppm) + 160, int(span[1] * ppm) + 200
|
||||
@@ -203,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)
|
||||
@@ -212,23 +232,32 @@ def main():
|
||||
if s["box"] is not None:
|
||||
(bx0, bz0), (bx1, bz1) = s["box"]
|
||||
cx, cz = px(np.array([[(bx0 + bx1) / 2, (bz0 + bz1) / 2]]))
|
||||
size = f"~ {bx1 - bx0:.1f} × {bz1 - bz0:.1f} m".replace(".", "," if args.comma else ".")
|
||||
size = f"~ {(bx1 - bx0) * args.ratio:.1f} × {(bz1 - bz0) * args.ratio:.1f} m".replace(".", "," if args.comma else ".")
|
||||
for txt, fnt, dy in ((s["label"], f_label, -16), (size, f_small, 16)):
|
||||
tw = d.textlength(txt, font=fnt)
|
||||
d.text((cx[0] - tw / 2 + 1, cz[0] + dy - 13 + 1), txt, font=fnt, fill=(0, 0, 0))
|
||||
d.text((cx[0] - tw / 2, cz[0] + dy - 13), txt, font=fnt, fill=(255, 255, 255) if dy < 0 else (210, 214, 224))
|
||||
|
||||
# scale bar (1 m) and title
|
||||
d.line((W - 60 - ppm, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
|
||||
for xx in (W - 60 - ppm, W - 60):
|
||||
# scale bar: a round length of the original (for a model: of the real thing), about 150 px long
|
||||
real_ppm = ppm / args.ratio # pixels per metre of the original
|
||||
length = min((0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000), key=lambda l: abs(l * real_ppm - 150))
|
||||
bar = length * real_ppm
|
||||
text = (f"{length * 100:g} cm" if length < 1 else f"{length:g} m")
|
||||
d.line((W - 60 - bar, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
|
||||
for xx in (W - 60 - bar, W - 60):
|
||||
d.line((xx, H - 47, xx, H - 33), fill=(230, 230, 230), width=3)
|
||||
d.text((W - 60 - ppm / 2 - 12, H - 72), "1 m", font=f_small, fill=(230, 230, 230))
|
||||
tw = d.textlength(text, font=f_small)
|
||||
d.text((W - 60 - bar / 2 - tw / 2, H - 72), text, font=f_small, fill=(230, 230, 230))
|
||||
if args.ratio != 1:
|
||||
ratio = f"1:{args.ratio:g}".replace(".", "," if args.comma else ".")
|
||||
d.text((W - 60 - bar - 20 - d.textlength(ratio, font=f_small), H - 50), ratio, font=f_small, fill=(230, 230, 230))
|
||||
if args.title:
|
||||
d.text((40, 32), args.title, font=f_title, fill=(235, 238, 245))
|
||||
out_img.save(out)
|
||||
info = [{"label": s["label"], "size": [round(float(v), 2) for v in (s["box"][1] - s["box"][0])] if s["box"] is not None else None}
|
||||
for s in scenes]
|
||||
print(json.dumps({"angle": round(float(np.degrees(angle)), 1), "scenes": info, "pixels_per_metre": round(ppm, 1)}))
|
||||
info = [{"label": s["label"], "size": [round(float(v) * args.ratio, 2) for v in (s["box"][1] - s["box"][0])]
|
||||
if s["box"] is not None else None} for s in scenes]
|
||||
print(json.dumps({"angle": round(float(np.degrees(angle)), 1), "scenes": info, "ratio": args.ratio,
|
||||
"pixels_per_metre": round(real_ppm, 4)}))
|
||||
|
||||
|
||||
def classic(scenes, px, W, H):
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["numpy", "scipy"]
|
||||
# ///
|
||||
"""EXPERIMENTAL, not wired into the interface yet (0.4): splats in space the cameras looked through.
|
||||
|
||||
For every (sampled) image: the SfM points it saw give a coarse depth map (per cell the nearest seen
|
||||
surface). A splat that projects into such a cell clearly in front of that surface sat in the line of sight
|
||||
of a camera that saw something behind it: free space. Removed when enough images say so.
|
||||
Usage: freespace.py <scene.ply> <out.ply> <run>[,<run>...] [--every N] [--strength S] [--solid A]
|
||||
<run> the recordings the scene was made of (their sparse model and the Frame's tracking); the
|
||||
scene must be in their fitted frame, as combine.py makes it (alignment "images")
|
||||
--solid A where the SfM points say nothing (white walls and ceilings), use the splats more opaque than A
|
||||
|
||||
First try on a real living room (15.10.2026): only 0.4 % of the splats found, mostly faint ones. The SfM
|
||||
points – the only reliable "something is here" – are missing exactly on white surfaces, and the dark veil
|
||||
under a white ceiling hangs closer to it (10–35 cm) than this can tell apart; scripts/clean.py --ceiling
|
||||
is the tool for that.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import combine as C # noqa: E402
|
||||
|
||||
NP = {0: 3, 1: 4, 2: 4, 3: 5, 4: 8, 5: 8, 6: 12, 7: 5, 8: 4, 9: 5, 10: 12}
|
||||
GRID = 48 # depth cells per image side
|
||||
|
||||
|
||||
def read_cams(path):
|
||||
out = {}
|
||||
with open(path, "rb") as f:
|
||||
n, = struct.unpack("<Q", f.read(8))
|
||||
for _ in range(n):
|
||||
cid, model, w, h = struct.unpack("<iiQQ", f.read(24))
|
||||
out[cid] = (model, w, h, np.array(struct.unpack("<%dd" % NP[model], f.read(8 * NP[model]))))
|
||||
return out
|
||||
|
||||
|
||||
def read_images(path):
|
||||
out = []
|
||||
with open(path, "rb") as f:
|
||||
n, = struct.unpack("<Q", f.read(8))
|
||||
for _ in range(n):
|
||||
_, qw, qx, qy, qz, tx, ty, tz, cid = struct.unpack("<i7di", f.read(64))
|
||||
name = b""
|
||||
while (ch := f.read(1)) != b"\0":
|
||||
name += ch
|
||||
k, = struct.unpack("<Q", f.read(8))
|
||||
obs = np.frombuffer(f.read(24 * k), dtype=np.dtype([("x", "<f8"), ("y", "<f8"), ("id", "<i8")]))
|
||||
R = Rotation.from_quat([qx, qy, qz, qw]).as_matrix()
|
||||
out.append((name.decode(), R, np.array([tx, ty, tz]), cid, obs[obs["id"] >= 0].copy()))
|
||||
return out
|
||||
|
||||
|
||||
def read_points3d(path):
|
||||
ids, xyz = [], []
|
||||
with open(path, "rb") as f:
|
||||
n, = struct.unpack("<Q", f.read(8))
|
||||
for _ in range(n):
|
||||
pid, x, y, z, _, _, _, e, k = struct.unpack("<Q3d3BdQ", f.read(51))
|
||||
f.seek(8 * k, 1)
|
||||
ids.append(pid)
|
||||
xyz.append((x, y, z))
|
||||
ids = np.array(ids)
|
||||
table = np.full(ids.max() + 1, -1)
|
||||
table[ids] = np.arange(len(ids))
|
||||
return table, np.array(xyz)
|
||||
|
||||
|
||||
def project(cam, pc):
|
||||
"""Camera coordinates -> pixels (OPENCV_FISHEYE or pinhole); valid only for z > 0."""
|
||||
model, w, h, p = cam
|
||||
x, y = pc[:, 0] / pc[:, 2], pc[:, 1] / pc[:, 2]
|
||||
if model == 5:
|
||||
fx, fy, cx, cy, k1, k2, k3, k4 = p
|
||||
r = np.sqrt(x * x + y * y)
|
||||
th = np.arctan(r)
|
||||
thd = th * (1 + k1 * th**2 + k2 * th**4 + k3 * th**6 + k4 * th**8)
|
||||
s = np.where(r > 1e-9, thd / np.maximum(r, 1e-9), 1.0)
|
||||
return fx * x * s + cx, fy * y * s + cy
|
||||
fx, fy, cx, cy = (p[0], p[0], p[1], p[2]) if model in (0, 2, 3) else p[:4]
|
||||
return fx * x + cx, fy * y + cy
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("src")
|
||||
ap.add_argument("dst")
|
||||
ap.add_argument("runs")
|
||||
ap.add_argument("--every", type=int, default=4)
|
||||
ap.add_argument("--strength", type=float, default=0.5)
|
||||
ap.add_argument("--solid", type=float, default=0.0)
|
||||
args = ap.parse_args()
|
||||
header, v = C.read_ply(Path(args.src))
|
||||
P = np.stack([v["x"], v["y"], v["z"]], 1).astype(np.float64)
|
||||
A = 1 / (1 + np.exp(-v["opacity"].astype(np.float64)))
|
||||
free = np.zeros(len(v), np.int32)
|
||||
seen = np.zeros(len(v), np.int32)
|
||||
for run in args.runs.split(","):
|
||||
run = Path(run)
|
||||
sp = run / "dataset/sparse/0"
|
||||
cams, imgs = read_cams(sp / "cameras.bin"), read_images(sp / "images.bin")
|
||||
table, X = read_points3d(sp / "points3D.bin")
|
||||
# the room's frame: fitted to the Frame's tracking, as combine.py does (alignment "images")
|
||||
ai = json.loads((run / "dataset/main.aiscene").read_text())
|
||||
rec = {f["path"].removeprefix("images/"): np.array(f["worldFromCam"])[:3, 3] for f in ai["frames"]}
|
||||
names = [i[0].replace("\\", "/") for i in imgs]
|
||||
centres = np.array([-i[1].T @ i[2] for i in imgs])
|
||||
both = [k for k, nm in enumerate(names) if nm in rec]
|
||||
fit = C.umeyama(centres[both], np.array([rec[names[k]] for k in both]))
|
||||
# splats into this recording's own frame (inverse of the fit), cameras stay as they are
|
||||
Q = (fit.R.T @ ((P - fit.t) / fit.s).T).T
|
||||
for name, R, t, cid, obs in imgs[::args.every]:
|
||||
cam = cams[cid]
|
||||
_, w, h, _ = cam
|
||||
pts = X[table[obs["id"]]]
|
||||
pc = (R @ pts.T).T + t
|
||||
ok = pc[:, 2] > 0.05
|
||||
cx = np.clip((obs["x"][ok] / w * GRID).astype(int), 0, GRID - 1)
|
||||
cy = np.clip((obs["y"][ok] / h * GRID).astype(int), 0, GRID - 1)
|
||||
depth = np.full(GRID * GRID, np.inf)
|
||||
np.minimum.at(depth, cy * GRID + cx, pc[ok, 2])
|
||||
sc = (R @ Q.T).T + t
|
||||
front = sc[:, 2] > 0.1
|
||||
if args.solid: # where SfM saw nothing (a white ceiling): the solid splats
|
||||
so = front & (A > args.solid)
|
||||
us, vs = project(cam, sc[so])
|
||||
ins = (us >= 0) & (us < w) & (vs >= 0) & (vs < h)
|
||||
cs = (np.clip((vs[ins] / h * GRID).astype(int), 0, GRID - 1) * GRID
|
||||
+ np.clip((us[ins] / w * GRID).astype(int), 0, GRID - 1))
|
||||
sd = np.full(GRID * GRID, np.inf)
|
||||
np.minimum.at(sd, cs, sc[so][ins, 2])
|
||||
depth = np.where(np.isfinite(depth), depth, sd)
|
||||
u, vv = project(cam, sc[front])
|
||||
inside = (u >= 0) & (u < w) & (vv >= 0) & (vv < h)
|
||||
idx = np.flatnonzero(front)[inside]
|
||||
cell = (np.clip((vv[inside] / h * GRID).astype(int), 0, GRID - 1) * GRID
|
||||
+ np.clip((u[inside] / w * GRID).astype(int), 0, GRID - 1))
|
||||
d = depth[cell]
|
||||
known = np.isfinite(d)
|
||||
z = sc[idx, 2]
|
||||
seen[idx[known]] += 1
|
||||
margin = 0.15 + 0.1 * d[known]
|
||||
free[idx[known][z[known] < d[known] - margin]] += 1
|
||||
print(run.name, "images used:", len(imgs[::args.every]), flush=True)
|
||||
s = args.strength
|
||||
need = int(round(6 - 4 * s)) # images that looked through it
|
||||
frac = 0.7 - 0.3 * s # of those that saw the place at all
|
||||
drop = (free >= need) & (free >= frac * np.maximum(seen, 1)) & (A < 0.9)
|
||||
print(f"free-space splats: {drop.sum():,} of {len(v):,} ({100 * drop.mean():.1f} %), "
|
||||
f"opacity median {np.median(A[drop]) if drop.any() else 0:.2f}")
|
||||
lines = [b"element vertex %d\n" % (~drop).sum() if l.startswith(b"element vertex") else l
|
||||
for l in header.splitlines(keepends=True)]
|
||||
with open(args.dst, "wb") as f:
|
||||
f.write(b"".join(lines))
|
||||
v[~drop].tofile(f)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
Usage: uv run make_gallery.py <run folder> <pipeline log> <display name> <file stem>
|
||||
uv run make_gallery.py --plan <floor plan.png> <run folder> <display name> <file stem>
|
||||
uv run make_gallery.py --label <name plate.png> <text>
|
||||
Reads the initial view from the log ("Gaussian XR initial view: cam0/... Display rotation X°")
|
||||
and writes <file stem>.gallery.png and <file stem>.sceneinfo.png into the run folder.
|
||||
The look matches the example scenes that ship with Gaussian XR.
|
||||
@@ -93,6 +94,9 @@ def from_plan(plan: Path, out: Path) -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) == 4 and sys.argv[1] == "--label":
|
||||
label(sys.argv[3], Path(sys.argv[2]))
|
||||
return
|
||||
if len(sys.argv) == 6 and sys.argv[1] == "--plan":
|
||||
plan, run, name, stem = Path(sys.argv[2]), Path(sys.argv[3]), sys.argv[4], sys.argv[5]
|
||||
from_plan(plan, run / f"{stem}.gallery.png")
|
||||
|
||||
+28
-2
@@ -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
|
||||
|
||||
|
||||
+38
-14
@@ -5,8 +5,10 @@
|
||||
# Usage: scripts/to-frame.sh <run folder> "<display name>"
|
||||
# <run folder> runs/<name> or just <name>
|
||||
# GALLERY_ONLY=1 only create the gallery files (preview image, name plate) in the run folder
|
||||
# TARGET=NN_name use this scene folder on the Frame (default: same name, or next free number from 20)
|
||||
# TARGET=NN_name use this scene folder on the Frame (default: same name, or next free number from 20;
|
||||
# a folder of that name in Gaussian XR's own gallery folder is moved over)
|
||||
# STEM=name file stem (default: folder name in lower case)
|
||||
# SLUG=name name for a new scene folder on the Frame (default: the run folder's name)
|
||||
# LOG=file pipeline log with the initial view (default: runs/<folder>.log)
|
||||
set -euo pipefail
|
||||
# Everything in one block: bash reads it completely before it starts, so updating this file
|
||||
@@ -16,23 +18,39 @@ set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
ROOT=$(dirname "$HERE")
|
||||
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
|
||||
FRAME_SCENES=.local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes
|
||||
FRAME_BACKUP=Documents/GaussianSplats
|
||||
# Gaussian XR's folder for personal scenes – Steam updates leave it alone. Its own gallery folder
|
||||
# (FRAME_STEAM) holds the examples, and scenes copied by Splat Workshop 0.2.3 and older.
|
||||
FRAME_SCENES=Documents/GaussianXR
|
||||
FRAME_STEAM=.local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes
|
||||
|
||||
run=${1:?Give a run folder}
|
||||
name=${2:?Give a display name}
|
||||
[ -d "$run" ] || run=$RUNS_DIR/$run
|
||||
out=$(realpath "$run")
|
||||
slug=$(basename "$out")
|
||||
slug=${SLUG:-$(basename "$out")}
|
||||
[ -f "$out/scene.sog" ] || { echo "No scene.sog in $out" >&2; exit 1; }
|
||||
log=${LOG:-$RUNS_DIR/$slug.log}
|
||||
[ -f "$log" ] || log=$(grep -ls "Gaussian XR initial view" "$RUNS_DIR"/*.log 2>/dev/null | xargs -r grep -l "$out/" | head -1)
|
||||
[ -f "$log" ] || { echo "No pipeline log found for $slug (set LOG=...)" >&2; exit 1; }
|
||||
|
||||
# Gaussian XR's worldScale in a scene's transform file, when WORLD_SCALE is set (a model shown in the
|
||||
# size of the original, e.g. 87 for H0)
|
||||
world_scale() {
|
||||
[ -n "${WORLD_SCALE:-}" ] || return 0
|
||||
python3 - "$1" "$WORLD_SCALE" <<'PY'
|
||||
import json, sys
|
||||
path, scale = sys.argv[1], float(sys.argv[2])
|
||||
data = json.load(open(path))
|
||||
data["worldScale"] = scale
|
||||
json.dump(data, open(path, "w"), indent=2)
|
||||
PY
|
||||
}
|
||||
|
||||
# Gallery files: <stem>.sog / .xrSceneTransform.json / .gallery.png / .sceneinfo.png
|
||||
stem=${STEM:-$(printf '%s' "$slug" | tr 'A-Z' 'a-z')}
|
||||
cp "$out/scene.sog" "$out/$stem.sog"
|
||||
cp "$out/scene.xrSceneTransform.json" "$out/$stem.xrSceneTransform.json"
|
||||
world_scale "$out/$stem.xrSceneTransform.json"
|
||||
uv run -q "$HERE/make_gallery.py" "$out" "$log" "$name" "$stem"
|
||||
|
||||
[ "${GALLERY_ONLY:-0}" = 1 ] && exit 0
|
||||
@@ -41,20 +59,26 @@ uv run -q "$HERE/make_gallery.py" "$out" "$log" "$name" "$stem"
|
||||
echo "@@ phase upload"
|
||||
echo "Copying to the Frame ..."
|
||||
# Reuse an existing folder of the same name, otherwise the next free number from 20
|
||||
# (numbers are counted in both folders, so a scene never has the same number as another in the gallery)
|
||||
folder=${TARGET:-$(ssh -o ConnectTimeout=8 "$FRAME_HOST" "
|
||||
existing=\$(ls -d $FRAME_SCENES/[0-9][0-9]_$slug 2>/dev/null | head -1)
|
||||
found() { ls -d $FRAME_SCENES/\$1 $FRAME_STEAM/\$1 2>/dev/null | head -1; }
|
||||
existing=\$(found '[0-9][0-9]_$slug')
|
||||
if [ -n \"\$existing\" ]; then basename \"\$existing\"; exit; fi
|
||||
n=20; while ls -d $FRAME_SCENES/\${n}_* >/dev/null 2>&1; do n=\$((n+1)); done
|
||||
n=20; while [ -n \"\$(found \${n}_\*)\" ]; do n=\$((n+1)); done
|
||||
echo \${n}_$slug")}
|
||||
ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_BACKUP/$folder $FRAME_SCENES/$folder"
|
||||
scp -q "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} "$FRAME_HOST:$FRAME_BACKUP/$folder/"
|
||||
# Files of this scene under an earlier name (renamed in Splat Workshop) go first, so the folder holds one scene
|
||||
ssh "$FRAME_HOST" "for d in $FRAME_SCENES/$folder $FRAME_BACKUP/$folder; do
|
||||
for f in \$d/*.sog \$d/*.xrSceneTransform.json \$d/*.gallery.png \$d/*.sceneinfo.png; do
|
||||
case \${f##*/} in $stem.*) ;; *) rm -f -- \"\$f\" ;; esac
|
||||
done
|
||||
case "$folder" in [0-9][0-9]_?*) ;; *) echo "Unusable folder on the Frame: '$folder'" >&2; exit 1 ;; esac
|
||||
case "$folder" in */*|*..*) echo "Unusable folder on the Frame: '$folder'" >&2; exit 1 ;; esac
|
||||
# Straight into the gallery – everything is on the PC, copying again restores a scene.
|
||||
# Files of this scene under an earlier name (renamed in Splat Workshop) go first, so the folder holds one scene.
|
||||
# A copy made by 0.2.3 or older in Gaussian XR's own gallery folder moves to the personal one.
|
||||
ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_SCENES && \
|
||||
if [ -d $FRAME_STEAM/$folder ] && [ ! -e $FRAME_SCENES/$folder ]; then mv $FRAME_STEAM/$folder $FRAME_SCENES/; fi && \
|
||||
rm -rf $FRAME_STEAM/$folder && mkdir -p $FRAME_SCENES/$folder && cd $FRAME_SCENES/$folder && \
|
||||
for f in *.sog *.xrSceneTransform.json *.gallery.png *.sceneinfo.png; do
|
||||
case \$f in $stem.*) ;; *) rm -f -- \"\$f\" ;; esac
|
||||
done"
|
||||
ssh "$FRAME_HOST" "cp $FRAME_BACKUP/$folder/$stem.* $FRAME_SCENES/$folder/ && chmod 755 $FRAME_SCENES/$folder/*"
|
||||
scp -q "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} "$FRAME_HOST:$FRAME_SCENES/$folder/"
|
||||
ssh "$FRAME_HOST" "chmod 755 $FRAME_SCENES/$folder/*"
|
||||
echo "@@ done frame $folder"
|
||||
echo
|
||||
echo "Done: \"$name\" is in the Gaussian XR gallery ($folder)."
|
||||
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# Versions of a finished scene or tour: fewer splats for the Frame, all (or a limit) for the PC,
|
||||
# optionally shown in the original's size (models). Each version is a complete scene in
|
||||
# <run folder>/versions/<key>/ and can be copied to the Frame next to the others.
|
||||
#
|
||||
# Usage: scripts/versions.sh <run folder> "<key>|<splats>|<scale>|<name plate>[|<clean-up>]" ...
|
||||
# <key> folder name of the version, e.g. 1m, 0.75m, all, 1m-1x87
|
||||
# <splats> number of splats, or "all"
|
||||
# <scale> 1, or e.g. 87: Gaussian XR shows it in the original's size (worldScale 87)
|
||||
# <name plate> text on the name plate in the gallery, e.g. "Flat · 1 M"
|
||||
# <clean-up> optional, e.g. "ceiling=0.5,haze=0.3": cleaned up first (scripts/clean.py; ceiling,
|
||||
# floor, haze, outside, each 0 to 1), then reduced
|
||||
# The splats come from <run folder>/spirula/scene.ply. Lines starting with "@@" are progress markers.
|
||||
set -euo pipefail
|
||||
# Everything in one block: bash reads it completely before it starts, so updating this file
|
||||
# (git pull, editing) does not affect a run that is already going.
|
||||
{
|
||||
|
||||
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
ROOT=$(dirname "$HERE")
|
||||
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
|
||||
|
||||
out=$(realpath "${1:?Give a run folder}")
|
||||
shift
|
||||
[ $# -ge 1 ] || { echo "Give at least one version" >&2; exit 1; }
|
||||
src=$out/spirula/scene.ply
|
||||
[ -f "$src" ] || { echo "No spirula/scene.ply in $out" >&2; exit 1; }
|
||||
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found" >&2; exit 1; }
|
||||
slug=$(basename "$out")
|
||||
preview=$(ls "$out"/*.gallery.png 2>/dev/null | head -1 || true)
|
||||
total=$(python3 - "$src" <<'PY'
|
||||
import sys
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
for line in f:
|
||||
if line.startswith(b"element vertex"):
|
||||
print(int(line.split()[-1])); break
|
||||
PY
|
||||
)
|
||||
echo "Making $# version(s) of $slug ($total splats)"
|
||||
|
||||
i=0
|
||||
for spec in "$@"; do
|
||||
i=$((i + 1))
|
||||
IFS='|' read -r key splats scale plate clean <<< "$spec"
|
||||
case "$key" in ''|.*|*/*) echo "Unusable version name: $key" >&2; exit 1 ;; esac
|
||||
echo "@@ vstep $i $# $key"
|
||||
dir=$out/versions/$key
|
||||
tmp=$dir.tmp
|
||||
rm -rf "$tmp"
|
||||
mkdir -p "$tmp"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
from=$src
|
||||
have=$total
|
||||
if [ -n "${clean:-}" ]; then
|
||||
args=()
|
||||
IFS=',' read -ra parts <<< "$clean"
|
||||
for kv in "${parts[@]}"; do
|
||||
k=${kv%%=*} v=${kv#*=}
|
||||
case "$k" in ceiling|floor|haze|outside) ;; *) echo "Unknown clean-up: $k" >&2; exit 1 ;; esac
|
||||
[[ "$v" =~ ^(0(\.[0-9]+)?|1(\.0+)?)$ ]] || { echo "Clean-up strength must be 0 to 1: $kv" >&2; exit 1; }
|
||||
args+=("--$k" "$v")
|
||||
done
|
||||
echo "$key: cleaning up ($clean)"
|
||||
uv run -q "$HERE/clean.py" "$src" "$tmp/clean.ply" "${args[@]}"
|
||||
from=$tmp/clean.ply
|
||||
have=$(python3 -c 'import sys
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
print(next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex")))' "$from")
|
||||
fi
|
||||
if [ "$splats" = all ] || [ "$splats" -ge "$have" ]; then
|
||||
echo "$key: all $have splats"
|
||||
"$SPLAT_TRANSFORM" -w "$from" "$tmp/scene.sog"
|
||||
made=$have
|
||||
else
|
||||
echo "$key: reducing $have to $splats splats"
|
||||
"$SPLAT_TRANSFORM" -w "$from" --decimate "$splats" "$tmp/reduced.ply"
|
||||
"$SPLAT_TRANSFORM" -w "$tmp/reduced.ply" "$tmp/scene.sog"
|
||||
rm -f "$tmp/reduced.ply"
|
||||
made=$splats
|
||||
fi
|
||||
if [ -f "$tmp/clean.ply" ] && [ "$splats" = all ]; then
|
||||
mkdir -p "$tmp/spirula" # for the PC: Spirula Studio opens the ply
|
||||
mv "$tmp/clean.ply" "$tmp/spirula/scene.ply"
|
||||
fi
|
||||
rm -f "$tmp/clean.ply"
|
||||
stem=$(printf '%s-%s' "$slug" "$key" | tr 'A-Z' 'a-z')
|
||||
python3 - "$out/scene.xrSceneTransform.json" "$tmp/scene.xrSceneTransform.json" "$scale" <<'PY'
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
data["worldScale"] = float(sys.argv[3]) # 1 = original size of the scene
|
||||
json.dump(data, open(sys.argv[2], "w"), indent=2)
|
||||
PY
|
||||
if [ -n "$preview" ]; then
|
||||
cp "$preview" "$tmp/$stem.gallery.png"
|
||||
fi
|
||||
uv run -q "$HERE/make_gallery.py" --label "$tmp/$stem.sceneinfo.png" "$plate"
|
||||
python3 - "$tmp/version.json" "$key" "$made" "$splats" "$scale" "$plate" "${clean:-}" <<'PY'
|
||||
import json, sys, time
|
||||
path, key, made, target, scale, plate, clean = sys.argv[1:]
|
||||
info = {"key": key, "splats": int(made), "target": target, "scale": float(scale), "label": plate,
|
||||
"created": time.time()}
|
||||
if clean:
|
||||
info["clean"] = {k: float(v) for k, v in (kv.split("=") for kv in clean.split(","))}
|
||||
json.dump(info, open(path, "w"), indent=1)
|
||||
PY
|
||||
rm -rf "$dir"
|
||||
mv "$tmp" "$dir"
|
||||
trap - EXIT
|
||||
done
|
||||
echo "@@ done local"
|
||||
echo "Done: $out/versions"
|
||||
exit
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -13,8 +13,23 @@ 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",
|
||||
"ver.clean.ceiling": "ceiling", "ver.clean.floor": "floor", "ver.clean.haze": "haze", "ver.clean.outside": "edges",
|
||||
"notify.failed.title": "Conversion failed",
|
||||
"notify.failed": "“{name}” – details in Splat Workshop",
|
||||
"notify.download.title": "Fetched from the Frame",
|
||||
"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",
|
||||
@@ -62,8 +77,23 @@ 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.",
|
||||
"ver.clean.ceiling": "Decke", "ver.clean.floor": "Boden", "ver.clean.haze": "Dunst", "ver.clean.outside": "Rand",
|
||||
"notify.failed.title": "Umwandlung fehlgeschlagen",
|
||||
"notify.failed": "„{name}“ – Details in der Splat-Werkstatt",
|
||||
"notify.download.title": "Von der Frame geholt",
|
||||
"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",
|
||||
|
||||
+930
-90
File diff suppressed because it is too large.
Load diff
+62
-3
@@ -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}
|
||||
@@ -135,6 +172,16 @@ if os.environ.get("FAKE_FRAME_CUT") and cmd.startswith("cat "): # the connecti
|
||||
out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout
|
||||
sys.stdout.buffer.write(out[:len(out) // 2])
|
||||
sys.exit(255)
|
||||
if os.environ.get("FAKE_FRAME_SHORT") and cmd.startswith("cat "): # ends early, but without an error
|
||||
out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout
|
||||
sys.stdout.buffer.write(out[:len(out) // 2])
|
||||
sys.exit(0)
|
||||
if os.environ.get("FAKE_FRAME_SLOW") and cmd.startswith("cat "): # a slow connection: 1 kB every 0.1 s
|
||||
import time
|
||||
out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout
|
||||
for i in range(0, len(out), 1000):
|
||||
sys.stdout.buffer.write(out[i:i + 1000]); sys.stdout.buffer.flush(); time.sleep(0.1)
|
||||
sys.exit(0)
|
||||
sys.exit(subprocess.run(["bash", "-c", cmd or "true"], cwd="{FRAME_HOME}", env=env).returncode)
|
||||
''')
|
||||
script(BIN / "scp", f'''#!{sys.executable}
|
||||
@@ -148,6 +195,9 @@ def local(a):
|
||||
return Path("{FRAME_HOME}") / a.split(":", 1)[1]
|
||||
return Path(a)
|
||||
*src, dst = map(local, args)
|
||||
if os.environ.get("FAKE_SCP_SLOW"): # a slow connection: copying takes 3 s
|
||||
import time
|
||||
time.sleep(3)
|
||||
for s in src:
|
||||
shutil.copy(s, dst / s.name if dst.is_dir() else dst)
|
||||
''')
|
||||
@@ -176,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()
|
||||
@@ -185,7 +237,14 @@ def clean():
|
||||
d.mkdir()
|
||||
for cache in (server.usage_cache, server.probe_cache, server.uploads, server.checks):
|
||||
cache.clear()
|
||||
for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FRAME_HOST"):
|
||||
if server.download_busy():
|
||||
server.cancel_download()
|
||||
end = time.time() + 10
|
||||
while server.download_busy() and time.time() < end:
|
||||
time.sleep(0.05)
|
||||
server.download, server.download_queue[:], server.download_batch = None, [], {}
|
||||
for k in ("FAKE_PIPELINE", "FAKE_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"] = ""
|
||||
TOOL_LOG.unlink(missing_ok=True)
|
||||
@@ -213,7 +272,7 @@ def frame(monkeypatch):
|
||||
monkeypatch.setattr(server, "FRAME", host)
|
||||
monkeypatch.setattr(server, "SSH", [*server.SSH[:-1], host])
|
||||
os.environ["FRAME_HOST"] = host # for the scripts (splat.sh, to-frame.sh)
|
||||
for d in (server.FRAME_VIDEOS + "/thumbnails", server.FRAME_SCENES):
|
||||
for d in (server.FRAME_VIDEOS + "/thumbnails", server.FRAME_SCENES, server.FRAME_STEAM):
|
||||
(FRAME_HOME / d).mkdir(parents=True, exist_ok=True)
|
||||
return FRAME_HOME
|
||||
|
||||
|
||||
+11
-2
@@ -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)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Cleaning up a scene (0.4, scripts/clean.py): ceiling, floor, haze, outside – and as part of its versions.
|
||||
|
||||
The synthetic room is built so every kind of splat can be told apart afterwards by its colour: a white
|
||||
ceiling that the reconstruction left thin, a dark veil under it, a dark cupboard top by the wall, a bird
|
||||
cage of long thin bars, fat transparent blobs (haze), a floor with a hole, and things beyond the walls.
|
||||
The expectations come from how it is built, not from the code."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from conftest import ROOT, RUNS, server, wait_job
|
||||
import synth
|
||||
|
||||
CLEAN = ROOT / "scripts" / "clean.py"
|
||||
FLOOR, CEILING = 1.6, -0.9 # y points down: the room is 2.5 m high
|
||||
W, D = 4.0, 5.0 # x and z extent of the room
|
||||
WALL, FLOORC, CEILC = (0.6, 0.6, 0.6), (0.45, 0.3, 0.2), (0.85, 0.85, 0.82)
|
||||
VEIL, CUPBOARD, CAGE, HAZE, OUT = (0.12, 0.12, 0.13), (0.2, 0.1, 0.05), (0.3, 0.3, 0.35), (0.5, 0.5, 0.9), (0.9, 0.1, 0.9)
|
||||
LOGIT = lambda a: float(np.log(a / (1 - a)))
|
||||
|
||||
|
||||
def splats(pts, colour, opacity=0.95, scale=(0.02, 0.02, 0.02)):
|
||||
v = np.zeros(len(pts), dtype=np.dtype([(p, "<f4") for p in synth.PROPS]))
|
||||
v["x"], v["y"], v["z"] = np.asarray(pts).T
|
||||
for k in range(3):
|
||||
v[f"f_dc_{k}"] = (colour[k] - 0.5) / 0.28209479177387814
|
||||
v[f"scale_{k}"] = np.log(scale[k])
|
||||
v["opacity"] = LOGIT(opacity)
|
||||
v["rot_0"] = 1.0
|
||||
return v
|
||||
|
||||
|
||||
def grid(a0, a1, b0, b1, step):
|
||||
a, b = np.meshgrid(np.arange(a0, a1 + 1e-9, step), np.arange(b0, b1 + 1e-9, step), indexing="ij")
|
||||
return a.ravel(), b.ravel()
|
||||
|
||||
|
||||
def room(path):
|
||||
"""The living room as a reconstruction of a white ceiling tends to come out."""
|
||||
rng = np.random.default_rng(1)
|
||||
parts = []
|
||||
ys = np.arange(CEILING, FLOOR + 1e-9, 0.04)
|
||||
for x in (0.0, W): # walls
|
||||
z, y = grid(0, D, CEILING, FLOOR, 0.04)
|
||||
parts.append(splats(np.stack([np.full_like(z, x), y, z], 1), WALL))
|
||||
for z in (0.0, D):
|
||||
x, y = grid(0, W, CEILING, FLOOR, 0.04)
|
||||
parts.append(splats(np.stack([x, y, np.full_like(x, z)], 1), WALL))
|
||||
x, z = grid(0, W, 0, D, 0.03) # floor with a hole of 1 x 1 m
|
||||
hole = (x > 1.5) & (x < 2.5) & (z > 2.0) & (z < 3.0)
|
||||
parts.append(splats(np.stack([x[~hole], np.full((~hole).sum(), FLOOR), z[~hole]], 1), FLOORC))
|
||||
x, z = grid(0, W, 0, D, 0.12) # thin ceiling: little, half transparent ...
|
||||
parts.append(splats(np.stack([x, np.full_like(x, CEILING), z], 1), CEILC, opacity=0.4))
|
||||
x, z = grid(0, W, 0, D, 0.03) # ... but solid where it meets the walls
|
||||
edge = (x < 0.25) | (x > W - 0.25) | (z < 0.25) | (z > D - 0.25)
|
||||
parts.append(splats(np.stack([x[edge], np.full(edge.sum(), CEILING), z[edge]], 1), CEILC))
|
||||
n = 4000 # the veil: dark, thin, 10–30 cm under it
|
||||
veil = np.stack([rng.uniform(0.8, W - 0.8, n), CEILING + rng.uniform(0.1, 0.3, n), rng.uniform(0.8, D - 0.8, n)], 1)
|
||||
parts.append(splats(veil, VEIL, opacity=0.2, scale=(0.03, 0.03, 0.03)))
|
||||
x, z = grid(0.05, 0.4, 1.0, 2.0, 0.03) # a dark cupboard top at the wall, 2.2 m high
|
||||
parts.append(splats(np.stack([x, np.full_like(x, FLOOR - 2.2), z], 1), CUPBOARD))
|
||||
bars = [] # a bird cage: long thin bars, faint
|
||||
for bx in np.arange(1.8, 2.2, 0.02):
|
||||
for by in np.arange(FLOOR - 2.0, FLOOR - 1.2, 0.08):
|
||||
bars.append((bx, by, 2.5))
|
||||
parts.append(splats(bars, CAGE, opacity=0.15, scale=(0.001, 0.06, 0.001)))
|
||||
n = 300 # haze: fat, transparent blobs in the room
|
||||
blobs = np.stack([rng.uniform(1, 3, n), rng.uniform(FLOOR - 1.6, FLOOR - 0.6, n), rng.uniform(1, 4, n)], 1)
|
||||
parts.append(splats(blobs, HAZE, opacity=0.06, scale=(0.06, 0.06, 0.06)))
|
||||
n = 500 # beyond the walls, seen through the window
|
||||
out = np.stack([rng.uniform(-3, -1.5, n), rng.uniform(FLOOR - 2, FLOOR, n), rng.uniform(0, D, n)], 1)
|
||||
parts.append(splats(out, OUT))
|
||||
v = np.concatenate(parts)
|
||||
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
|
||||
len(v), "".join(f"property float {p}\n" for p in synth.PROPS))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(header.encode())
|
||||
v.tofile(f)
|
||||
return v
|
||||
|
||||
|
||||
def read(path):
|
||||
with open(path, "rb") as f:
|
||||
header = b""
|
||||
while not header.endswith(b"end_header\n"):
|
||||
header += f.readline()
|
||||
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
|
||||
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
|
||||
return np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
|
||||
|
||||
|
||||
def colour_of(v):
|
||||
return 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
|
||||
|
||||
|
||||
def count(v, rgb):
|
||||
return int(np.all(np.abs(colour_of(v) - rgb) < 0.01, axis=1).sum())
|
||||
|
||||
|
||||
def clean(tmp_path, *args):
|
||||
src, dst = tmp_path / "in.ply", tmp_path / "out.ply"
|
||||
before = room(src)
|
||||
r = subprocess.run([sys.executable, str(CLEAN), str(src), str(dst), *args], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return before, read(dst), r.stdout
|
||||
|
||||
|
||||
def test_ceiling_veil_out_ceiling_closed(tmp_path):
|
||||
before, after, out = clean(tmp_path, "--ceiling", "0.5")
|
||||
assert "Ceiling 2.5" in out # found where walls and ceiling meet
|
||||
assert count(after, VEIL) == 0 # the veil is gone
|
||||
assert count(after, CUPBOARD) == count(before, CUPBOARD) # the dark cupboard top by the wall stays
|
||||
assert count(after, CAGE) == count(before, CAGE) # and the cage, far below the ceiling
|
||||
flat = after[np.isclose(after["y"], CEILING, atol=0.03) & (after["opacity"] > 3)]
|
||||
# closed: a solid layer over the whole room, in the ceiling's own colour
|
||||
assert len(flat) > (W / 0.02) * (D / 0.02) * 0.9
|
||||
assert np.allclose(np.median(colour_of(flat), 0), CEILC, atol=0.05)
|
||||
for k in ("x", "y", "z", "f_dc_0", "opacity", "scale_0"):
|
||||
assert np.isfinite(after[k]).all()
|
||||
|
||||
|
||||
def test_floor_holes_filled_in_the_floors_colour(tmp_path):
|
||||
before, after, out = clean(tmp_path, "--floor", "0.5")
|
||||
added = after[len(before):]
|
||||
inside = (added["x"] > 1.6) & (added["x"] < 2.4) & (added["z"] > 2.1) & (added["z"] < 2.9)
|
||||
assert inside.sum() > 0.8 * (0.8 / 0.02) ** 2 # the hole is closed ...
|
||||
assert len(added) < 1.3 * (1 / 0.02) ** 2 # ... and little more than the hole
|
||||
assert np.allclose(np.median(colour_of(added), 0), FLOORC, atol=0.05)
|
||||
assert np.allclose(added["y"], FLOOR, atol=0.01)
|
||||
|
||||
|
||||
def test_haze_goes_but_not_thin_bars(tmp_path):
|
||||
before, after, _ = clean(tmp_path, "--haze", "1")
|
||||
assert count(after, HAZE) == 0
|
||||
assert count(after, CAGE) == count(before, CAGE) # long and thin is no haze
|
||||
assert count(after, VEIL) == 0 # at full strength the veil is haze, too
|
||||
assert count(after, WALL) == count(before, WALL) and count(after, CUPBOARD) == count(before, CUPBOARD)
|
||||
|
||||
|
||||
def test_outside_goes(tmp_path):
|
||||
before, after, _ = clean(tmp_path, "--outside", "1")
|
||||
assert count(after, OUT) == 0 and count(after, WALL) == count(before, WALL)
|
||||
|
||||
|
||||
def test_nothing_switched_on_changes_nothing(tmp_path):
|
||||
before, after, _ = clean(tmp_path)
|
||||
assert len(after) == len(before) and np.array_equal(after["x"], before["x"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [["--haze", "2"], ["--ceiling", "-0.1"]])
|
||||
def test_strengths_from_0_to_1(tmp_path, args):
|
||||
src = tmp_path / "in.ply"
|
||||
room(src)
|
||||
r = subprocess.run([sys.executable, str(CLEAN), str(src), str(tmp_path / "o.ply"), *args], capture_output=True, text=True)
|
||||
assert r.returncode != 0 and "between 0 and 1" in r.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- as part of the versions
|
||||
|
||||
def scene(slug="Living"):
|
||||
run = RUNS / slug
|
||||
(run / "spirula").mkdir(parents=True, exist_ok=True)
|
||||
room(run / "spirula" / "scene.ply")
|
||||
(run / "scene.sog").write_bytes(b"sog")
|
||||
(run / "scene.xrSceneTransform.json").write_text(json.dumps({"worldScale": 1.0}))
|
||||
server.write_meta(slug, name="Living room")
|
||||
return run
|
||||
|
||||
|
||||
def test_a_cleaned_version(api):
|
||||
run = scene()
|
||||
total = len(read(run / "spirula" / "scene.ply"))
|
||||
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "ceiling=0.5,haze=0.3"})
|
||||
assert status == 200, r
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
v = {x["key"]: x for x in server.versions_of("Living")}
|
||||
assert list(v) == ["all-c50h30"]
|
||||
x = v["all-c50h30"]
|
||||
assert x["clean"] == {"ceiling": 0.5, "haze": 0.3} and x["label"] == "Living room · all splats · ceiling 50 % · haze 30 %"
|
||||
assert x["purpose"] == "pc" and x["ply"] # Spirula Studio can open it
|
||||
info = json.loads((run / "versions" / "all-c50h30" / "version.json").read_text())
|
||||
assert info["splats"] != total # counted after cleaning
|
||||
assert not (run / "versions" / "all-c50h30" / "clean.ply").exists()
|
||||
assert len(read(run / "versions" / "all-c50h30" / "spirula" / "scene.ply")) == info["splats"]
|
||||
assert len(read(run / "spirula" / "scene.ply")) == total # the scene itself is untouched
|
||||
|
||||
|
||||
def test_a_cleaned_and_a_plain_version_side_by_side(api):
|
||||
scene()
|
||||
api.post("/api/versions", {"slug": "Living", "sizes": "all"})
|
||||
wait_job()
|
||||
api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "floor=1"})
|
||||
wait_job()
|
||||
assert sorted(x["key"] for x in server.versions_of("Living")) == ["all", "all-f100"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("clean,code", [("roof=0.5", "err.bad_value"), ("haze=2", "err.range"), ("haze=x", "err.bad_value"),
|
||||
("haze=nan", "err.bad_value"), ({"haze": 1}, "err.bad_request")])
|
||||
def test_odd_clean_ups_are_refused(api, clean, code):
|
||||
scene()
|
||||
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": clean})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
|
||||
|
||||
def test_cleaned_and_plain_in_one_go(api):
|
||||
"""For the Frame, with and without cleaning up, to compare: two versions, named by their strengths."""
|
||||
run = scene()
|
||||
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "20000", "clean": "haze=0.4", "also_plain": True})
|
||||
assert status == 200, r
|
||||
assert wait_job()["result"] == "ok"
|
||||
v = {x["key"]: x for x in server.versions_of("Living")}
|
||||
assert set(v) == {"0.02m", "0.02m-h40"}
|
||||
assert v["0.02m-h40"]["label"] == "Living room · 0.02 M · haze 40 %" and v["0.02m"]["label"] == "Living room · 0.02 M"
|
||||
assert all(x["purpose"] == "frame" and not x["ply"] for x in v.values()) # Frame versions keep no ply
|
||||
assert not (run / "versions" / "0.02m-h40" / "spirula").exists()
|
||||
|
||||
|
||||
def test_a_version_opens_in_spirula(api, monkeypatch):
|
||||
run = scene()
|
||||
api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "ceiling=0.5"})
|
||||
wait_job()
|
||||
api.post("/api/versions", {"slug": "Living", "sizes": "20000"})
|
||||
wait_job()
|
||||
opened = []
|
||||
monkeypatch.setattr(server.subprocess, "Popen", lambda cmd, **kw: opened.append(cmd))
|
||||
assert api.post("/api/open", {"slug": "Living", "version": "all-c50", "what": "spirula"})[0] == 200
|
||||
assert opened[-1][1] == str(run / "versions" / "all-c50" / "spirula" / "scene.ply")
|
||||
status, r = api.post("/api/open", {"slug": "Living", "version": "0.02m", "what": "spirula"}) # no ply
|
||||
assert status == 400 and r["error"]["code"] == "err.no_version"
|
||||
status, r = api.post("/api/open", {"slug": "Living", "version": "../x", "what": "spirula"})
|
||||
assert status == 400 and len(opened) == 1
|
||||
@@ -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 "
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The "On the Frame" tab: everything in Gaussian XR's gallery, what it is, and taking it off the Frame."""
|
||||
import json
|
||||
import time
|
||||
|
||||
from conftest import RUNS, server
|
||||
import synth
|
||||
|
||||
|
||||
def upload(api, slug, version=""):
|
||||
body = {"slug": slug, **({"version": version} if version else {})}
|
||||
assert api.post("/api/upload", body)[0] == 200
|
||||
key = f"{slug}#{version}" if version else slug
|
||||
end = time.time() + 60
|
||||
while (u := server.upload_status(key)) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status(key)["ok"]
|
||||
|
||||
|
||||
def example(frame, name="04_BurtLakeHouse"):
|
||||
d = frame / server.FRAME_STEAM / name # Gaussian XR's own gallery folder
|
||||
d.mkdir()
|
||||
(d / "trained-572k.htgs.gallery.png").write_bytes(b"example preview")
|
||||
(d / "trained-572k.htgs.ply").write_bytes(b"x" * 5000)
|
||||
return d
|
||||
|
||||
|
||||
def test_the_gallery_on_the_frame(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
upload(api, "Kitchen")
|
||||
assert server.start_versions({"slug": "Kitchen", "sizes": "10000"}) is None
|
||||
from conftest import wait_job
|
||||
wait_job()
|
||||
upload(api, "Kitchen", "0.01m")
|
||||
example(frame)
|
||||
(frame / server.FRAME_STEAM / "common_audio").mkdir() # not a scene: no preview image
|
||||
status, r = api.get("/api/frame/gallery?refresh=1")
|
||||
assert status == 200 and r["online"]
|
||||
by = {x["folder"]: x for x in r["scenes"]}
|
||||
assert set(by) == {"04_BurtLakeHouse", "20_Kitchen", "21_Kitchen-0.01m"}
|
||||
assert (by["20_Kitchen"]["kind"], by["20_Kitchen"]["name"], by["20_Kitchen"]["slug"]) == ("scene", "Kitchen", "Kitchen")
|
||||
assert (by["21_Kitchen-0.01m"]["kind"], by["21_Kitchen-0.01m"]["version"]) == ("version", "0.01m")
|
||||
assert (by["04_BurtLakeHouse"]["kind"], by["04_BurtLakeHouse"]["name"]) == ("other", "BurtLakeHouse")
|
||||
assert by["04_BurtLakeHouse"]["size"] >= 5000
|
||||
assert (by["04_BurtLakeHouse"]["place"], by["20_Kitchen"]["place"]) == ("steam", "own")
|
||||
status, png = api.get("/api/frame/preview?folder=04_BurtLakeHouse")
|
||||
assert status == 200 and png == b"example preview"
|
||||
assert api.get("/api/frame/preview?folder=../../.ssh")[0] == 404
|
||||
|
||||
|
||||
def test_taking_a_scene_off_the_frame_and_copying_it_again(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
upload(api, "Kitchen")
|
||||
assert api.post("/api/frame/remove", {"folder": "20_Kitchen"})[0] == 200
|
||||
assert not (frame / server.FRAME_SCENES / "20_Kitchen").exists()
|
||||
assert (RUNS / "Kitchen" / "scene.sog").exists() # the PC is not touched
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text()).get("frame_folder") is None
|
||||
upload(api, "Kitchen") # copying again works
|
||||
assert (frame / server.FRAME_SCENES / "20_Kitchen" / "kitchen.sog").exists()
|
||||
|
||||
|
||||
def test_an_example_scene_can_be_deleted_too(api, frame):
|
||||
d = example(frame)
|
||||
assert api.post("/api/frame/remove", {"folder": "04_BurtLakeHouse"})[0] == 200
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
def test_only_scenes_of_the_gallery_can_be_removed(api, frame):
|
||||
(frame / server.FRAME_STEAM / "common_audio").mkdir()
|
||||
(frame / "Videos" / "keep.txt").write_text("x")
|
||||
for folder in ("common_audio", "..", "../..", "../../Videos", "nope"):
|
||||
status, r = api.post("/api/frame/remove", {"folder": folder})
|
||||
assert status == 400, folder
|
||||
assert (frame / server.FRAME_STEAM / "common_audio").exists() and (frame / "Videos" / "keep.txt").exists()
|
||||
|
||||
|
||||
def old_copy(frame, folder, stem, content=b"old"):
|
||||
"""A scene copied by Splat Workshop 0.2.3 or older: in Gaussian XR's own gallery folder."""
|
||||
d = frame / server.FRAME_STEAM / folder
|
||||
d.mkdir()
|
||||
for ext in ("sog", "xrSceneTransform.json", "sceneinfo.png"):
|
||||
(d / f"{stem}.{ext}").write_bytes(content)
|
||||
(d / f"{stem}.gallery.png").write_bytes(b"preview")
|
||||
return d
|
||||
|
||||
|
||||
def test_scenes_go_to_the_personal_folder_and_numbers_count_both(api, frame):
|
||||
old_copy(frame, "20_Other", "other") # e.g. a scene of someone else
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
upload(api, "Kitchen")
|
||||
assert sorted(p.name for p in (frame / server.FRAME_SCENES).iterdir()) == ["21_Kitchen"]
|
||||
assert (frame / server.FRAME_STEAM / "20_Other").exists()
|
||||
|
||||
|
||||
def test_copying_again_moves_an_old_copy_over(api, frame):
|
||||
old = old_copy(frame, "22_Kitchen", "kitchen")
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
upload(api, "Kitchen")
|
||||
assert not old.exists()
|
||||
new = frame / server.FRAME_SCENES / "22_Kitchen"
|
||||
assert (new / "kitchen.sog").read_bytes() != b"old"
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "22_Kitchen"
|
||||
|
||||
|
||||
def test_moving_the_old_copies_to_the_personal_folder(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
synth.make_room(RUNS, "Hallway", "B", name="Hallway")
|
||||
old_copy(frame, "20_Kitchen", "kitchen")
|
||||
old_copy(frame, "21_Hallway", "hallway")
|
||||
example(frame)
|
||||
newer = frame / server.FRAME_SCENES / "21_Hallway" # already copied again: the newer one stays
|
||||
newer.mkdir(parents=True)
|
||||
(newer / "hallway.sog").write_bytes(b"new")
|
||||
(newer / "hallway.gallery.png").write_bytes(b"preview")
|
||||
assert api.post("/api/frame/move", {})[0] == 200
|
||||
assert sorted(p.name for p in (frame / server.FRAME_STEAM).iterdir()) == ["04_BurtLakeHouse"] # the example stays
|
||||
assert sorted(p.name for p in (frame / server.FRAME_SCENES).iterdir()) == ["20_Kitchen", "21_Hallway"]
|
||||
assert (frame / server.FRAME_SCENES / "20_Kitchen" / "kitchen.sog").read_bytes() == b"old"
|
||||
assert (newer / "hallway.sog").read_bytes() == b"new"
|
||||
kinds = {(x["folder"], x["place"]) for x in api.get("/api/frame/gallery?refresh=1")[1]["scenes"]}
|
||||
assert kinds == {("04_BurtLakeHouse", "steam"), ("20_Kitchen", "own"), ("21_Hallway", "own")}
|
||||
assert {s["slug"]: s["on_frame"] for s in server.scenes()} == {"Kitchen": True, "Hallway": True}
|
||||
|
||||
|
||||
def test_the_same_folder_in_both_places(api, frame):
|
||||
old_copy(frame, "20_Kitchen", "kitchen")
|
||||
d = frame / server.FRAME_SCENES / "20_Kitchen"
|
||||
d.mkdir()
|
||||
(d / "kitchen.gallery.png").write_bytes(b"own preview")
|
||||
assert api.get("/api/frame/preview?folder=20_Kitchen&place=own") == (200, b"own preview")
|
||||
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "steam"})[0] == 200
|
||||
assert d.exists() and not (frame / server.FRAME_STEAM / "20_Kitchen").exists()
|
||||
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "nowhere"})[0] == 400
|
||||
@@ -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"
|
||||
@@ -154,6 +154,21 @@ def test_a_scene_being_computed_cannot_be_changed(api):
|
||||
("/api/clean", {"slug": "Kitchen", "what": "temp"})):
|
||||
status, r = api.post(path, body)
|
||||
assert status == 400 and r["error"]["code"] == "err.scene_busy", path
|
||||
# the new actions of 0.2.3 leave it alone, too
|
||||
v = RUNS / "Kitchen" / "versions" / "0.01m"
|
||||
v.mkdir(parents=True)
|
||||
(v / "scene.sog").write_bytes(b"x")
|
||||
(v / "version.json").write_text(json.dumps({"key": "0.01m", "splats": 10000, "scale": 1, "label": "Kitchen · 0.01 M"}))
|
||||
for path, body, code in (("/api/scale", {"slug": "Kitchen", "scale": "87"}, "err.scene_busy"),
|
||||
("/api/version/delete", {"slug": "Kitchen", "version": "0.01m"}, "err.scene_busy"),
|
||||
("/api/versions", {"slug": "Kitchen", "sizes": "10000"}, "err.job_busy")):
|
||||
status, r = api.post(path, body)
|
||||
assert status == 400 and r["error"]["code"] == code, path
|
||||
assert v.is_dir() and not (server.read_meta("Kitchen").get("scale") or 1) - 1
|
||||
# and its recording stays
|
||||
rec = server.load_state()["job"]["recording"]
|
||||
status, r = api.post("/api/recording/delete", {"name": rec, "where": "pc"})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_busy" and (server.REC / rec).exists()
|
||||
# and no tour is put together meanwhile (one job at a time)
|
||||
synth.make_room(RUNS, "Office", "A")
|
||||
synth.make_room(RUNS, "Hallway", "B")
|
||||
|
||||
@@ -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()
|
||||
+163
-2
@@ -3,7 +3,9 @@ import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from conftest import REC, RUNS, recording, server, wait_for_tool
|
||||
import pytest
|
||||
|
||||
from conftest import REC, RUNS, TRASH, recording, server, tools_called, wait_for_tool
|
||||
import synth
|
||||
|
||||
VIDEOS = server.FRAME_VIDEOS
|
||||
@@ -115,7 +117,7 @@ def test_copying_a_scene_to_the_frame(api, frame):
|
||||
folder = frame / SCENES / "20_Kitchen"
|
||||
assert sorted(p.name for p in folder.iterdir()) == [
|
||||
"kitchen.gallery.png", "kitchen.sceneinfo.png", "kitchen.sog", "kitchen.xrSceneTransform.json"]
|
||||
assert (frame / "Documents" / "GaussianSplats" / "20_Kitchen" / "kitchen.sog").exists() # backup copy
|
||||
assert not (frame / "Documents" / "GaussianSplats").exists() # no second copy on the Frame
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "20_Kitchen"
|
||||
assert wait_for_tool("notify-send") # sent in the background
|
||||
assert {s["slug"]: s["on_frame"] for s in server.scenes()}["Kitchen"] is True
|
||||
@@ -158,3 +160,162 @@ def test_a_scene_renamed_before_its_frame_folder_was_remembered(api, frame):
|
||||
api.post("/api/upload", {"slug": "Kueche"})
|
||||
assert wait_upload("Kueche")["ok"]
|
||||
assert sorted(p.name for p in (frame / SCENES).iterdir()) == ["20_Kitchen"]
|
||||
|
||||
|
||||
def wait_fetched(timeout=30):
|
||||
end = time.time() + timeout
|
||||
while server.download_busy() and time.time() < end:
|
||||
time.sleep(0.05)
|
||||
assert not server.download_busy()
|
||||
return server.download_status()
|
||||
|
||||
|
||||
def fetch_notes(wait=0):
|
||||
"""Notifications about fetching (others of an earlier test may still arrive in the background)."""
|
||||
end = time.time() + wait
|
||||
while True:
|
||||
notes = [c for c in tools_called() if c[0] == "notify-send" and "recording(s)" in c[-1]]
|
||||
if notes or time.time() >= end:
|
||||
return notes
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
NAMES = [f"2026-10-14_1{i}-00-00_stereo.mp4" for i in range(4)]
|
||||
|
||||
|
||||
def test_fetching_several_recordings_one_after_the_other(api, frame):
|
||||
for i, n in enumerate(NAMES):
|
||||
put_on_frame(frame, n, bytes([65 + i]) * 3000)
|
||||
(REC / NAMES[0]).write_bytes(b"already here") # skipped, never overwritten
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_SLOW"] = "1"
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:3])})[0] == 200
|
||||
st = api.get("/api/status")[1]["download"]
|
||||
assert st["busy"] and st["batch"]["files"] == 2
|
||||
assert [st["name"], *st["queue"]] == NAMES[1:3] or st["queue"] == NAMES[1:3]
|
||||
assert api.post("/api/download", {"names": f"{NAMES[3]}\n{NAMES[2]}"})[0] == 200 # added while running
|
||||
assert api.post("/api/download", {"names": NAMES[2]})[1]["error"]["code"] == "err.download_busy"
|
||||
st = wait_fetched()
|
||||
assert st["batch"]["files"] == st["batch"]["done_files"] == 3 and st["batch"]["failed"] == []
|
||||
assert (REC / NAMES[0]).read_bytes() == b"already here"
|
||||
for i, n in enumerate(NAMES[1:], 1):
|
||||
assert (REC / n).read_bytes() == bytes([65 + i]) * 3000
|
||||
assert not list(REC.glob("*.part"))
|
||||
note = fetch_notes(wait=5)
|
||||
assert len(note) == 1 and "3 recording(s)" in note[0][-1] # one notification at the end
|
||||
|
||||
|
||||
def test_a_failed_one_does_not_stop_the_others(api, frame):
|
||||
for n in NAMES[:2]:
|
||||
put_on_frame(frame, n)
|
||||
server.frame_listing(force=True)
|
||||
(frame / VIDEOS / NAMES[0]).unlink() # gone from the Frame in the meantime
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:2])})[0] == 200
|
||||
st = wait_fetched()
|
||||
assert st["batch"]["failed"] == [NAMES[0]] and (REC / NAMES[1]).exists() and not (REC / NAMES[0]).exists()
|
||||
assert not list(REC.glob("*.part"))
|
||||
assert [n[-1] for n in fetch_notes(wait=5)] == ["1 recording(s) could not be fetched (1 did) – is the Frame switched on?"]
|
||||
|
||||
|
||||
def test_cancelling_empties_the_queue(api, frame):
|
||||
for n in NAMES[:3]:
|
||||
put_on_frame(frame, n, b"x" * 20000)
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_SLOW"] = "1"
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:3])})[0] == 200
|
||||
time.sleep(0.3)
|
||||
assert api.post("/api/download/cancel", {})[0] == 200
|
||||
st = wait_fetched()
|
||||
assert st["queue"] == [] and st["batch"]["cancelled"]
|
||||
assert not list(REC.iterdir()) # no file and no half of one
|
||||
assert not fetch_notes(wait=0.5)
|
||||
assert api.post("/api/download/cancel", {})[1]["error"]["code"] == "err.no_download"
|
||||
|
||||
|
||||
def test_fetching_only_what_is_on_the_frame(api, frame):
|
||||
put_on_frame(frame, NAMES[0])
|
||||
server.frame_listing(force=True)
|
||||
for body in ({"names": "../../etc/passwd"}, {"names": "x\n.."}, {"names": "\n"}):
|
||||
status, r = api.post("/api/download", body)
|
||||
assert status == 400 and r["error"]["code"] == "err.not_on_frame", body
|
||||
assert api.post("/api/download", {"names": [NAMES[0]]})[1]["error"]["code"] == "err.bad_request"
|
||||
|
||||
|
||||
def test_deleting_a_recording_on_this_pc_moves_it_to_the_trash(api):
|
||||
rec = recording()
|
||||
assert api.post("/api/rename", {"name": rec.name, "new": "Kitchen"})[0] == 200
|
||||
assert api.post("/api/recording/delete", {"name": "Kitchen.mp4", "where": "pc"})[0] == 200
|
||||
assert not (REC / "Kitchen.mp4").exists() and (TRASH / "Kitchen.mp4").exists()
|
||||
assert "Kitchen.mp4" not in json.loads((server.RECMETA).read_text()) # its old name is forgotten
|
||||
assert api.get("/api/recordings")[1]["local"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "..", "../state/state.json", "nope.mp4"])
|
||||
def test_deleting_only_recordings_of_the_recordings_folder(api, name):
|
||||
recording()
|
||||
status, r = api.post("/api/recording/delete", {"name": name, "where": "pc"})
|
||||
assert status == 400 and r["error"]["code"] == "err.not_local"
|
||||
assert len(list(REC.iterdir())) == 1 and not list(TRASH.iterdir())
|
||||
assert api.post("/api/recording/delete", {"name": "x", "where": "elsewhere"})[0] == 400
|
||||
|
||||
|
||||
def test_a_recording_an_unfinished_scene_needs_stays(api):
|
||||
rec = recording()
|
||||
synth.make_room(RUNS, "Kitchen", "A", recording=rec.name)
|
||||
(RUNS / "Kitchen" / "scene.sog").unlink() # not finished yet
|
||||
status, r = api.post("/api/recording/delete", {"name": rec.name, "where": "pc"})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_needed"
|
||||
assert rec.exists()
|
||||
|
||||
|
||||
def test_deleting_a_recording_on_the_frame(api, frame):
|
||||
name, _ = put_on_frame(frame)
|
||||
other, _ = put_on_frame(frame, NAMES[1])
|
||||
(REC / name).write_bytes(b"the copy on this PC")
|
||||
assert api.post("/api/recording/delete", {"name": name, "where": "frame"})[0] == 200
|
||||
assert not (frame / VIDEOS / name).exists() and (frame / VIDEOS / other).exists()
|
||||
assert (REC / name).read_bytes() == b"the copy on this PC" # the PC is not touched
|
||||
assert [f["name"] for f in api.get("/api/recordings")[1]["frame"]] == [other]
|
||||
for bad in (name, "../" + other, "thumbnails", ""):
|
||||
status, r = api.post("/api/recording/delete", {"name": bad, "where": "frame"})
|
||||
assert status == 400 and r["error"]["code"] == "err.not_on_frame", bad
|
||||
assert (frame / VIDEOS / other).exists()
|
||||
|
||||
|
||||
def test_a_recording_being_fetched_is_not_deleted(api, frame):
|
||||
for n in NAMES[:2]:
|
||||
put_on_frame(frame, n, b"x" * 20000)
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_SLOW"] = "1"
|
||||
assert api.post("/api/download", {"names": "\n".join(NAMES[:2])})[0] == 200
|
||||
for n in NAMES[:2]: # fetching right now, and waiting
|
||||
status, r = api.post("/api/recording/delete", {"name": n, "where": "frame"})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_fetching"
|
||||
assert all((frame / VIDEOS / n).exists() for n in NAMES[:2])
|
||||
|
||||
|
||||
def test_deleting_on_a_frame_that_is_off(api, frame):
|
||||
name, _ = put_on_frame(frame)
|
||||
os.environ["FAKE_FRAME_OFF"] = "1"
|
||||
status, r = api.post("/api/recording/delete", {"name": name, "where": "frame"})
|
||||
assert status == 400 and r["error"]["code"] == "err.frame_offline"
|
||||
|
||||
|
||||
def test_a_recording_being_checked_is_not_deleted(api, frame):
|
||||
rec = recording()
|
||||
put_on_frame(frame, rec.name)
|
||||
server.checks[rec.name] = {"state": "running"}
|
||||
for where in ("pc", "frame"):
|
||||
status, r = api.post("/api/recording/delete", {"name": rec.name, "where": where})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_busy", where
|
||||
assert rec.exists() and (frame / VIDEOS / rec.name).exists()
|
||||
|
||||
|
||||
def test_a_download_that_ends_too_early_leaves_nothing_behind(api, frame):
|
||||
name, _ = put_on_frame(frame)
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_SHORT"] = "1" # half the file, but no error from ssh
|
||||
assert api.post("/api/download", {"name": name})[0] == 200
|
||||
st = wait_fetched()
|
||||
assert st["error"] and st["batch"]["failed"] == [name]
|
||||
assert not (REC / name).exists() and not (REC / (name + ".part")).exists()
|
||||
@@ -0,0 +1,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"]
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Scale for models (e.g. H0 = 1:87): floor plan and measurements in the original's size, and the
|
||||
original's size in Gaussian XR (worldScale).
|
||||
|
||||
The model is the synthetic 4 x 3 m room built 87 times smaller, so the expected numbers are simply
|
||||
those of the full-size room."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import BASE, ROOT, RUNS, server
|
||||
import synth
|
||||
|
||||
H0 = 87
|
||||
|
||||
|
||||
def plan(*runs, ratio=None):
|
||||
out = runs[0].parent / "plan.png"
|
||||
cmd = [sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(out), *[f"{r}={r.name}" for r in runs]]
|
||||
if ratio:
|
||||
cmd += ["--ratio", str(ratio)]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return json.loads(r.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def test_a_model_has_the_floor_plan_of_the_original():
|
||||
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
|
||||
model = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0), ratio=H0)
|
||||
assert model["scenes"][0]["size"] == pytest.approx(full["scenes"][0]["size"], abs=0.05) # 4 x 3 m
|
||||
assert model["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01) # measuring in metres
|
||||
assert model["ratio"] == H0
|
||||
|
||||
|
||||
def test_a_model_without_its_scale_still_draws():
|
||||
info = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0))
|
||||
assert info["ratio"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", ["0.5", "2000", "abc", "nan"])
|
||||
def test_scales_out_of_range_are_refused(api, scale):
|
||||
synth.make_room(RUNS, "Layout", "A")
|
||||
status, r = api.post("/api/scale", {"slug": "Layout", "scale": scale})
|
||||
assert status == 400 and r["error"]["code"] in ("err.range", "err.bad_value")
|
||||
|
||||
|
||||
def test_setting_and_clearing_the_scale_of_a_scene(api):
|
||||
synth.make_room(RUNS, "Layout", "A")
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "87", "xr": True})[0] == 200
|
||||
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
|
||||
assert (s["scale"], s["scale_xr"]) == (87, True)
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "1", "xr": True})[0] == 200
|
||||
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
|
||||
assert (s["scale"], s["scale_xr"]) == (1, False) # original size: nothing to enlarge in XR
|
||||
assert api.post("/api/scale", {"slug": "Nope", "scale": "87"})[0] == 400
|
||||
|
||||
|
||||
def test_a_group_passes_its_scale_on(api):
|
||||
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Other", "A", recording="2026-10-03_19-00-00_stereo.mp4")
|
||||
server.save_group("", "Layout", "Station,Yard")
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "160", "with_scenes": True})[0] == 200
|
||||
scales = {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}
|
||||
assert scales == {"Station": 160, "Yard": 160, "Other": 1}
|
||||
assert api.get("/api/groups")[1]["groups"][0]["scale"] == 160
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": "87"}) # without its scenes this time
|
||||
assert {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}["Station"] == 160
|
||||
|
||||
|
||||
def test_the_floor_plan_endpoint_measures_in_the_originals_metres(api):
|
||||
synth.make_room(RUNS, "Layout", "A", error=(0.0, (0, 0, 0)), scale=1 / H0)
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": str(H0)})
|
||||
status, png = api.get("/api/floorplan?slugs=Layout&style=classic")
|
||||
assert status == 200 and png[:4] == b"\x89PNG"
|
||||
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
|
||||
info = server.floorplan(["Layout"], "classic", "en", "")[1]
|
||||
assert info["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01)
|
||||
|
||||
|
||||
def wait_upload(slug):
|
||||
end = time.time() + 120
|
||||
while time.time() < end:
|
||||
u = server.upload_status(slug)
|
||||
if u and not u["running"]:
|
||||
return u
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xr,expected", [(True, H0), (False, 1.0)])
|
||||
def test_world_scale_on_the_frame(api, frame, xr, expected):
|
||||
run = synth.make_room(RUNS, "Layout", "A")
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": str(H0), "xr": xr})
|
||||
api.post("/api/upload", {"slug": "Layout"})
|
||||
assert wait_upload("Layout")["ok"]
|
||||
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
|
||||
assert sent["worldScale"] == expected
|
||||
assert sent["transform"] == json.loads((run / "scene.xrSceneTransform.json").read_text())["transform"]
|
||||
assert json.loads((run / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0 # the original stays
|
||||
|
||||
|
||||
def test_changing_the_scale_updates_the_frame(api, frame):
|
||||
synth.make_room(RUNS, "Layout", "A")
|
||||
api.post("/api/upload", {"slug": "Layout"})
|
||||
wait_upload("Layout")
|
||||
assert api.post("/api/scale", {"slug": "Layout", "scale": "45", "xr": True, "update_frame": True})[0] == 200
|
||||
assert wait_upload("Layout")["ok"]
|
||||
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
|
||||
assert sent["worldScale"] == 45
|
||||
|
||||
|
||||
def test_a_tour_of_a_model_is_walkable_in_both_versions():
|
||||
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
|
||||
server.save_group("", "Layout", "Station,Yard")
|
||||
server.set_scale("Layout", H0, xr=True, members=True, update_frame=False)
|
||||
assert server.start_combine({"slug": "Layout", "full": True}) is None
|
||||
from conftest import wait_job
|
||||
assert wait_job()["result"] == "ok"
|
||||
tour = RUNS / "Layout"
|
||||
assert json.loads((tour / "layout.xrSceneTransform.json").read_text())["worldScale"] == H0
|
||||
assert json.loads((tour / "full" / "layout-full.xrSceneTransform.json").read_text())["worldScale"] == H0
|
||||
|
||||
|
||||
def test_the_floor_plan_of_a_group_in_the_groups_scale(api):
|
||||
synth.make_room(RUNS, "Station", "A", error=(0.0, (0, 0, 0)), scale=1 / H0)
|
||||
server.save_group("", "Layout", "Station")
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": str(H0)}) # only on the group, not passed on
|
||||
assert {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}["Station"] == 1
|
||||
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
|
||||
info = server.floorplan(["Station"], "classic", "en", "", "Layout")[1]
|
||||
assert info["ratio"] == H0
|
||||
assert info["scenes"][0]["size"] == pytest.approx(full["scenes"][0]["size"], abs=0.05) # 4 x 3 m
|
||||
status, png = api.get("/api/floorplan?slugs=Station&style=classic&group=Layout")
|
||||
assert status == 200 and png[:4] == b"\x89PNG"
|
||||
assert server.floorplan(["Station"], "classic", "en", "", "Station")["code"] == "err.bad_request" # not a group
|
||||
|
||||
|
||||
def test_a_floor_plan_of_scenes_in_different_scales_is_refused(api):
|
||||
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
|
||||
server.save_group("", "Layout", "Station,Yard")
|
||||
api.post("/api/scale", {"slug": "Station", "scale": "87"})
|
||||
api.post("/api/scale", {"slug": "Yard", "scale": "160"})
|
||||
status, r = api.get("/api/floorplan?slugs=Station,Yard&group=Layout")
|
||||
assert status == 400 and r["error"]["code"] == "err.mixed_scale"
|
||||
api.post("/api/scale", {"slug": "Layout", "scale": "87"}) # one scale for the group decides
|
||||
assert api.get("/api/floorplan?slugs=Station,Yard&style=classic&group=Layout")[0] == 200
|
||||
@@ -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):
|
||||
|
||||
@@ -126,11 +126,15 @@ def test_from_this_pc_means_loopback():
|
||||
|
||||
|
||||
ENDPOINTS = ["/api/start", "/api/rename", "/api/clean", "/api/delete", "/api/upload", "/api/download", "/api/check",
|
||||
"/api/group", "/api/group/dismiss", "/api/combine", "/api/resume"]
|
||||
"/api/group", "/api/group/dismiss", "/api/combine", "/api/resume", "/api/versions", "/api/scale",
|
||||
"/api/frame/remove", "/api/frame/move", "/api/recording/delete", "/api/version/delete",
|
||||
"/api/download/cancel"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ENDPOINTS)
|
||||
@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality"])
|
||||
@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality",
|
||||
"existing", "partition", "version", "sizes", "scales", "folder", "names", "place",
|
||||
"where"])
|
||||
def test_wrongly_typed_fields_get_400_everywhere(api, path, field):
|
||||
for bad in (5, ["a"], True):
|
||||
assert api.post(path, {field: bad})[0] == 400, (path, field, bad)
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Versions of a scene or tour (scripts/versions.sh): several sizes and scales side by side, each its own
|
||||
scene – also on the Frame, where every version gets its own folder in the gallery."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import RUNS, TRASH, recording, server, tools_called, wait_job
|
||||
import synth
|
||||
|
||||
|
||||
def room(slug="Room", **kw):
|
||||
run = synth.make_room(RUNS, slug, "A", name=slug, **kw)
|
||||
with open(run / "spirula" / "scene.ply", "rb") as f:
|
||||
total = next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex"))
|
||||
return run, total
|
||||
|
||||
|
||||
def make(slug, sizes, scales="1"):
|
||||
assert server.start_versions({"slug": slug, "sizes": sizes, "scales": scales}) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
return {v["key"]: v for v in server.versions_of(slug)}
|
||||
|
||||
|
||||
def test_versions_in_several_sizes():
|
||||
run, total = room()
|
||||
versions = make("Room", "10000,20000,all")
|
||||
assert set(versions) == {"0.01m", "0.02m", "all"}
|
||||
assert [versions[k]["splats"] for k in ("0.01m", "0.02m", "all")] == [10000, 20000, total]
|
||||
assert versions["0.01m"]["label"] == "Room · 0.01 M" and versions["all"]["label"] == "Room · all splats"
|
||||
reduced = [c for c in tools_called() if c[0] == "splat-transform" and "--decimate" in c]
|
||||
assert sorted(c[c.index("--decimate") + 1] for c in reduced) == ["10000", "20000"] # "all" is not reduced
|
||||
for key in versions:
|
||||
v = run / "versions" / key
|
||||
assert (v / "scene.sog").exists() and (v / f"room-{key}.sceneinfo.png").exists()
|
||||
assert (v / f"room-{key}.gallery.png").exists() # the scene's preview
|
||||
assert json.loads((v / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0
|
||||
assert server.scenes()[0]["versions"] == 3
|
||||
|
||||
|
||||
def test_a_size_above_the_scene_is_all_of_it():
|
||||
run, total = room()
|
||||
versions = make("Room", str(total * 2))
|
||||
assert list(versions.values())[0]["splats"] == total
|
||||
|
||||
|
||||
def test_versions_in_the_originals_size(api):
|
||||
run, _ = room()
|
||||
api.post("/api/scale", {"slug": "Room", "scale": "87"})
|
||||
versions = make("Room", "10000", "1,87")
|
||||
assert set(versions) == {"0.01m", "0.01m-1x87"}
|
||||
assert versions["0.01m-1x87"]["label"] == "Room · 0.01 M · 1:87"
|
||||
worlds = {k: json.loads((run / "versions" / k / "scene.xrSceneTransform.json").read_text())["worldScale"]
|
||||
for k in versions}
|
||||
assert worlds == {"0.01m": 1.0, "0.01m-1x87": 87.0}
|
||||
|
||||
|
||||
def test_versions_side_by_side_on_the_frame(api, frame):
|
||||
room()
|
||||
make("Room", "10000,20000")
|
||||
for key in ("0.01m", "0.02m"):
|
||||
assert api.post("/api/upload", {"slug": "Room", "version": key})[0] == 200
|
||||
end = time.time() + 60
|
||||
while (u := server.upload_status(f"Room#{key}")) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status(f"Room#{key}")["ok"]
|
||||
scenes = frame / server.FRAME_SCENES
|
||||
assert sorted(p.name for p in scenes.iterdir()) == ["20_Room-0.01m", "21_Room-0.02m"]
|
||||
assert sorted(p.name for p in (scenes / "21_Room-0.02m").iterdir()) == [
|
||||
"room-0.02m.gallery.png", "room-0.02m.sceneinfo.png", "room-0.02m.sog", "room-0.02m.xrSceneTransform.json"]
|
||||
folders = json.loads((RUNS / "Room.ui.json").read_text())["version_folders"]
|
||||
assert folders == {"0.01m": "20_Room-0.01m", "0.02m": "21_Room-0.02m"}
|
||||
versions = {v["key"]: v for v in server.versions_of("Room")}
|
||||
assert versions["0.01m"]["on_frame"] and versions["0.02m"]["on_frame"]
|
||||
assert api.post("/api/upload", {"slug": "Room", "version": "9m"})[1]["error"]["code"] == "err.no_version"
|
||||
|
||||
|
||||
def test_deleting_a_version(api):
|
||||
run, _ = room()
|
||||
make("Room", "10000,20000")
|
||||
assert api.post("/api/version/delete", {"slug": "Room", "version": "0.01m"})[0] == 200
|
||||
assert set(v["key"] for v in server.versions_of("Room")) == {"0.02m"}
|
||||
assert (TRASH / "0.01m").exists() and (run / "spirula" / "scene.ply").exists()
|
||||
assert api.post("/api/version/delete", {"slug": "Room", "version": "../spirula"})[0] == 400
|
||||
|
||||
|
||||
def test_versions_follow_a_renamed_scene(api):
|
||||
room("Kitchen")
|
||||
make("Kitchen", "10000")
|
||||
assert api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})[0] == 200
|
||||
v = RUNS / "Kueche" / "versions" / "0.01m"
|
||||
assert (v / "kueche-0.01m.sceneinfo.png").exists() and not (v / "kitchen-0.01m.sceneinfo.png").exists()
|
||||
assert [x["key"] for x in server.versions_of("Kueche")] == ["0.01m"]
|
||||
|
||||
|
||||
def test_a_version_older_than_its_scene():
|
||||
run, _ = room()
|
||||
make("Room", "10000")
|
||||
assert not server.versions_of("Room")[0]["stale"]
|
||||
time.sleep(0.05)
|
||||
os.utime(run / "spirula" / "scene.ply") # the scene was computed again
|
||||
assert server.versions_of("Room")[0]["stale"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [
|
||||
({"sizes": "5"}, "err.range"), ({"sizes": "abc"}, "err.bad_value"), ({"sizes": ""}, "err.no_versions"),
|
||||
({"sizes": "10000", "scales": "0.5"}, "err.range"), ({"slug": "Nope", "sizes": "10000"}, "err.no_scene"),
|
||||
])
|
||||
def test_invalid_versions_are_refused(body, code):
|
||||
room()
|
||||
assert server.start_versions({"slug": "Room", **body})["code"] == code
|
||||
|
||||
|
||||
def test_versions_of_a_tour():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Hallway", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4")
|
||||
server.save_group("", "Flat", "Kitchen,Hallway")
|
||||
assert server.start_combine({"slug": "Flat", "full": False}) is None
|
||||
assert wait_job()["result"] == "ok"
|
||||
versions = make("Flat", "10000,all")
|
||||
assert set(versions) == {"0.01m", "all"}
|
||||
assert versions["all"]["splats"] == server.groups()["groups"][0]["result"]["splats"]
|
||||
assert server.groups()["groups"][0]["versions"] == 2
|
||||
|
||||
|
||||
def test_one_job_at_a_time():
|
||||
room()
|
||||
os.environ["FAKE_PIPELINE"] = "slow"
|
||||
rec = recording()
|
||||
assert server.start_job({"path": str(rec), "name": "Other", "quality": "fast", "upload": False}) is None
|
||||
time.sleep(1)
|
||||
assert server.start_versions({"slug": "Room", "sizes": "10000"})["code"] == "err.job_busy"
|
||||
|
||||
|
||||
def upload(api, slug, key):
|
||||
assert api.post("/api/upload", {"slug": slug, "version": key})[0] == 200
|
||||
end = time.time() + 60
|
||||
while (u := server.upload_status(f"{slug}#{key}")) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status(f"{slug}#{key}")["ok"]
|
||||
|
||||
|
||||
def test_taking_a_version_off_the_frame_removes_only_that(api, frame):
|
||||
room()
|
||||
make("Room", "10000,20000")
|
||||
upload(api, "Room", "0.01m")
|
||||
upload(api, "Room", "0.02m")
|
||||
scenes, backup = frame / server.FRAME_SCENES, frame / server.FRAME_BACKUP
|
||||
(scenes / "04_BurtLakeHouse").mkdir() # an example scene of Gaussian XR
|
||||
(scenes / "30_Room-0.01m-old").mkdir() # someone else's folder with a similar name
|
||||
(backup / "20_Room-0.01m").mkdir(parents=True) # a copy left by version 0.2.3 or older
|
||||
(backup / "21_Room-0.02m").mkdir()
|
||||
assert api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"})[0] == 200
|
||||
assert sorted(p.name for p in scenes.iterdir()) == ["04_BurtLakeHouse", "21_Room-0.02m", "30_Room-0.01m-old"]
|
||||
assert not (backup / "20_Room-0.01m").exists() and (backup / "21_Room-0.02m").exists()
|
||||
assert "0.01m" not in json.loads((RUNS / "Room.ui.json").read_text())["version_folders"]
|
||||
v = {x["key"]: x for x in server.versions_of("Room")}
|
||||
assert v["0.01m"]["on_frame"] is False and v["0.02m"]["on_frame"] # still on the PC, off the Frame
|
||||
status, r = api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"})
|
||||
assert status == 400 and r["error"]["code"] == "err.not_on_frame_version"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["", "..", "../0.02m", ".hidden"])
|
||||
def test_taking_off_the_frame_only_with_a_proper_version(api, frame, version):
|
||||
room()
|
||||
assert api.post("/api/frame/remove", {"slug": "Room", "version": version})[0] == 400
|
||||
|
||||
|
||||
def test_taking_off_a_frame_that_is_off(api, frame):
|
||||
room()
|
||||
make("Room", "10000")
|
||||
upload(api, "Room", "0.01m")
|
||||
os.environ["FAKE_FRAME_OFF"] = "1"
|
||||
status, r = api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"})
|
||||
assert status == 400 and r["error"]["code"] == "err.frame_offline"
|
||||
os.environ.pop("FAKE_FRAME_OFF")
|
||||
assert (frame / server.FRAME_SCENES / "20_Room-0.01m").exists()
|
||||
|
||||
|
||||
def test_versions_sh_refuses_odd_version_names():
|
||||
import subprocess
|
||||
from conftest import ROOT
|
||||
run, _ = room()
|
||||
for spec in ("../x|10000|1|x", ".hidden|10000|1|x", "a/b|10000|1|x"):
|
||||
r = subprocess.run([str(ROOT / "scripts" / "versions.sh"), str(run), spec], capture_output=True, text=True)
|
||||
assert r.returncode == 1 and "Unusable version name" in r.stderr, spec
|
||||
assert not (run / "x").exists() and not (run / "versions").exists()
|
||||
|
||||
|
||||
def test_an_old_copy_of_a_version_is_recognised_by_its_folder(api, frame):
|
||||
room()
|
||||
make("Room", "10000") # not copied by this version: no version_folders
|
||||
d = frame / server.FRAME_STEAM / "21_Room-0.01m"
|
||||
d.mkdir()
|
||||
(d / "room-0.01m.gallery.png").write_bytes(b"preview")
|
||||
server.frame_cache["t"] = 0
|
||||
assert server.versions_of("Room")[0]["on_frame"] is True
|
||||
g = {x["folder"]: x for x in api.get("/api/frame/gallery?refresh=1")[1]["scenes"]}["21_Room-0.01m"]
|
||||
assert (g["kind"], g["slug"], g["version"], g["place"]) == ("version", "Room", "0.01m", "steam")
|
||||
|
||||
|
||||
def test_nothing_is_moved_or_removed_on_the_frame_while_copying(api, frame):
|
||||
room()
|
||||
make("Room", "10000")
|
||||
d = frame / server.FRAME_STEAM / "20_Room-0.01m" # an old copy, being copied again right now
|
||||
d.mkdir()
|
||||
(d / "room-0.01m.gallery.png").write_bytes(b"preview")
|
||||
os.environ["FAKE_SCP_SLOW"] = "1"
|
||||
assert api.post("/api/upload", {"slug": "Room", "version": "0.01m"})[0] == 200
|
||||
time.sleep(0.5)
|
||||
for path, body in (("/api/frame/move", {}), ("/api/frame/remove", {"folder": "20_Room-0.01m"}),
|
||||
("/api/version/delete", {"slug": "Room", "version": "0.01m"})):
|
||||
status, r = api.post(path, body)
|
||||
assert status == 400 and r["error"]["code"] in ("err.upload_busy", "err.scene_busy"), path
|
||||
end = time.time() + 30
|
||||
while (u := server.upload_status("Room#0.01m")) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status("Room#0.01m")["ok"]
|
||||
+176
-4
@@ -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",
|
||||
@@ -34,6 +36,12 @@ const I18N = {
|
||||
"confirm.cancel": "Really cancel the conversion? With “Resume” you can continue later from the last saved state.",
|
||||
// step 1
|
||||
"s1.title": "Choose a recording", "tab.frame": "On the Frame", "tab.local": "On this PC",
|
||||
"dl.all": "Fetch all ({n} · {size})", "dl.all.title": "Fetch every recording that is not on this PC yet, one after the other",
|
||||
"dl.sel": "Fetch selection ({n} · {size})", "dl.sel.title": "Fetch the ticked recordings, one after the other",
|
||||
"dl.pick": "Fetch this one too", "dl.queued": "waiting", "dl.cancel": "Cancel", "dl.cancelled": "Fetching cancelled",
|
||||
"dl.progress": "Fetching {i} of {n} from the Frame · {done} of {total}",
|
||||
"dl.done": "{n} recording(s) fetched – you can switch the Frame off", "dl.failed": "{n} recording(s) could not be fetched",
|
||||
"err.no_download": "Nothing is being fetched",
|
||||
"checkAll": "Check all", "checkAll.title": "Check all recordings on this PC that have not been checked yet",
|
||||
"pick": "Choose file …", "refresh.title": "Reload list", "loading": "Loading …",
|
||||
"frame.unreachable": "The Frame is not reachable – is it on and in the same network?",
|
||||
@@ -96,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",
|
||||
@@ -122,6 +143,14 @@ const I18N = {
|
||||
"sc.spirula.starting": "Starting Spirula …", "sc.uploadStarted": "Copying to the Frame …",
|
||||
"sc.splats": "{n} M splats",
|
||||
"sc.plan": "Floor plan",
|
||||
"rdel.frame": "🗑 Delete from the Frame", "rdel.pc": "🗑 Delete from this PC",
|
||||
"rdel.confirm.frame": "Delete “{name}” ({size}) from the Frame? This cannot be undone there – the copy on this PC stays.",
|
||||
"rdel.confirm.frame.only": "“{name}” ({size}) is NOT on this PC yet! Delete it from the Frame anyway? It is then gone for good.",
|
||||
"rdel.confirm.pc": "Move “{name}” ({size}) on this PC to the trash? Finished scenes stay.",
|
||||
"rdel.confirm.pc.only": "Move “{name}” ({size}) to the trash? It is no longer on the Frame – the trash then holds the only copy.",
|
||||
"rdel.done.frame": "“{name}” deleted from the Frame", "rdel.done.pc": "“{name}” moved to the trash",
|
||||
"err.recording_fetching": "The recording is being fetched from the Frame right now",
|
||||
"err.recording_needed": "“{name}” from this recording is not finished yet and still needs it",
|
||||
"ren.button": "✎ Rename", "ren.ok": "Rename", "ren.cancel": "Cancel", "ren.done": "Renamed",
|
||||
"ren.rec.title": "Rename recording",
|
||||
"ren.rec.hint": "Renames the file on this PC. The recording date is kept; the copy on the Frame keeps its name.",
|
||||
@@ -153,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})",
|
||||
@@ -166,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.",
|
||||
@@ -182,9 +218,59 @@ 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",
|
||||
"err.plan_failed": "The floor plan could not be drawn", "sc.replacing": "Being computed again – stays until the new one is done", "err.plan_busy": "Other floor plans are being drawn right now, try again in a moment",
|
||||
"scale.button.title": "Scale – for models, e.g. a model railway", "scale.title": "Scale of “{name}”",
|
||||
"scale.hint": "For models: floor plan, sizes and measurements then show the size of the original (H0: 10 cm in the model = 8.7 m).",
|
||||
"scale.original": "Original size", "scale.custom": "Own scale",
|
||||
"scale.xr": "Walk through it in Gaussian XR in the original's size (not tested on the Frame yet)",
|
||||
"scale.members": "Also for all scenes in this group", "scale.frame": "Update it on the Frame too",
|
||||
"scale.line": "Scale {ratio}", "scale.line.xr": "original size in XR", "scale.done": "Scale saved",
|
||||
"ver.button": "Versions ({n})", "ver.button.title": "Versions with fewer splats for the Frame or all splats for the PC – several side by side",
|
||||
"ver.title": "Versions of “{name}” ({n} splats)",
|
||||
"ver.hint": "Every version is its own scene and gets its own place in the Frame's gallery – e.g. 0.75 M and 2 M side by side to compare.",
|
||||
"ver.new": "New version", "ver.custom": "Own size", "ver.million": "million splats",
|
||||
"ver.scales": "Scale", "ver.walk": "walk through it ({ratio})", "ver.all": "all splats",
|
||||
"ver.measured": "Measured on a Steam Frame (Gaussian XR, 10/2026): 0.75 M ≈ 36 fps, 1 M ≈ 35 fps, 1.5 M ≈ 29 fps, 2 M ≈ 24 fps, 5 M ≈ 14 fps.",
|
||||
"ver.for": "For", "ver.for.frame": "🥽 the Frame (headset)", "ver.for.pc": "🖥️ the PC (all splats, e.g. Spirula Studio)",
|
||||
"ver.size.head": "Size", "ver.fps.0_75": "≈ {fps} fps", "ver.fps.1": "≈ {fps} fps, clearly sharper", "ver.fps.1_5": "≈ {fps} fps, a little unsteady", "ver.fps.2": "≈ {fps} fps",
|
||||
"ver.plain": "Also make it without cleaning up, to compare",
|
||||
"ver.clean.none": "not cleaned up", "ver.slow": "{n} M splats: on the Frame only about 14 fps",
|
||||
"ver.template": "As template", "ver.template.title": "Put this version's settings into the form below – change one slider and make the next one",
|
||||
"ver.template.done": "Settings taken over – change what you want to compare",
|
||||
"ver.replaces": "{what} (replaces the existing one)", "ver.same.all": "All splats without cleaning up is the scene itself – open it in Spirula from the scene, or set a slider.",
|
||||
"ver.makes.1": "Makes:", "ver.makes.n": "Makes {n} versions:", "ver.make.1": "Make version", "ver.make.n": "Make {n} versions",
|
||||
"ver.show": "▸ Show {n} versions", "ver.first": "+ Versions …", "ver.show.1": "▸ Show 1 version", "ver.new.button": "+ New version …", "ver.hide": "▾ Hide versions", "ver.of": "Versions of “{name}”",
|
||||
"ver.pick": "Enter a size (at least 0.01 million)", "ver.started": "Making versions …",
|
||||
"ver.none": "No versions yet.", "ver.stale": "older than the scene", "ver.delete": "Delete version",
|
||||
"ver.confirm": "Move the version “{name}” ({size}) to the trash? A copy on the Frame is not touched.",
|
||||
"job.sub.versions": "{n} versions · for {time}", "err.no_version": "This version does not exist",
|
||||
"err.no_versions": "Pick at least one size",
|
||||
"ver.unframe": "Off the Frame", "ver.unframe.title": "Remove this version from the Frame's gallery (it stays on the PC)",
|
||||
"ver.unframe.confirm": "Remove “{name}” from the Frame? Its folder in the gallery and the backup copy on the Frame are deleted; the version stays on this PC.",
|
||||
"ver.unframed": "“{name}” removed from the Frame", "err.not_on_frame_version": "This version is not on the Frame",
|
||||
"err.frame_offline": "The Frame is not reachable", "err.frame_failed": "The Frame refused",
|
||||
"stab.pc": "On this PC", "stab.frame": "On the Frame",
|
||||
"fg.hint": "Everything in Gaussian XR's gallery on the Frame. Taking something off the Frame does not touch this PC.",
|
||||
"fg.loading": "Asking the Frame …", "fg.empty": "The gallery on the Frame is empty.",
|
||||
"fg.kind.scene": "Scene", "fg.kind.tour": "Tour", "fg.kind.version": "Version", "fg.kind.other": "Not from Splat Workshop",
|
||||
"fg.remove": "Off the Frame", "fg.remove.title": "Delete this scene from the Frame's gallery",
|
||||
"fg.confirm": "Take “{name}” ({size}) off the Frame? It stays on this PC and can be copied again any time.",
|
||||
"fg.confirm.other": "“{name}” ({size}) was not made with Splat Workshop – e.g. one of Gaussian XR's examples. Delete it from the Frame anyway? Steam brings the examples back with “verify integrity of game files”.",
|
||||
"fg.removed": "“{name}” taken off the Frame",
|
||||
"fg.place.own": "Own scenes", "fg.place.own.title": "Documents/GaussianXR on the Frame – Steam updates leave it alone",
|
||||
"fg.place.steam": "Gaussian XR folder", "fg.place.steam.title": "Gaussian XR's own gallery folder – a Steam update or “verify files” can reset it",
|
||||
"fg.move.hint": "{n} scene(s) from Splat Workshop are still in Gaussian XR's own folder, where a Steam update can delete them.",
|
||||
"fg.move": "Move to own scenes", "fg.moved": "Moved to the own scenes on the Frame",
|
||||
"ver.recommended": "(recommended)",
|
||||
"ver.clean": "Clean up", "ver.clean.off": "off",
|
||||
"ver.clean.hint": "Experimental. The scene itself stays as it is; only this version is cleaned up – compare it with one without.",
|
||||
"ver.clean.ceiling": "Ceiling", "ver.clean.ceiling.title": "A white ceiling gives the reconstruction nothing to hold on to: it stays thin, with a dark veil under it. Takes out the veil (deeper with more strength; dark things within 50 cm of a wall stay) and what lies above the ceiling, and puts in a closed ceiling in its own colour.",
|
||||
"ver.clean.floor": "Floor", "ver.clean.floor.title": "Fills holes in the floor in the colour of the floor around them (more holes count with more strength) and takes out what lies under the floor.",
|
||||
"ver.clean.haze": "Haze", "ver.clean.haze.title": "Takes out big, nearly transparent blobs floating in the room. Long thin things (bars, cables, wires) are no haze and stay. At full strength smaller and less transparent blobs go, too.",
|
||||
"ver.clean.outside": "Edges", "ver.clean.outside.title": "Takes out what lies well outside the room: beyond the walls, above the ceiling, under the floor – from 1 m beyond them to 30 cm at full strength. Also the view through a window.",
|
||||
"err.mixed_scale": "These scenes have different scales – set one scale for the group", "err.plan_failed": "The floor plan could not be drawn", "sc.replacing": "Being computed again – stays until the new one is done", "err.plan_busy": "Other floor plans are being drawn right now, try again in a moment",
|
||||
"plan.measure": "📏 Measure", "plan.measure.title": "Measure distances in the floor plan",
|
||||
"plan.measure.hint": "Click to set points · right-click removes the last one · horizontal distances, about ±5 cm",
|
||||
"plan.measure.undo": "Undo", "plan.measure.reset": "Reset",
|
||||
@@ -217,6 +303,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}",
|
||||
@@ -231,6 +319,12 @@ const I18N = {
|
||||
"res.failed": "Fehlgeschlagen – die letzten Zeilen im Protokoll zeigen warum.",
|
||||
"confirm.cancel": "Umwandlung wirklich abbrechen? Mit „Fortsetzen“ geht es später ab dem letzten Zwischenstand weiter.",
|
||||
"s1.title": "Aufnahme wählen", "tab.frame": "Auf der Frame", "tab.local": "Auf dem PC",
|
||||
"dl.all": "Alle holen ({n} · {size})", "dl.all.title": "Alle Aufnahmen, die noch nicht auf dem PC sind, nacheinander holen",
|
||||
"dl.sel": "Auswahl holen ({n} · {size})", "dl.sel.title": "Die angehakten Aufnahmen nacheinander holen",
|
||||
"dl.pick": "Diese auch holen", "dl.queued": "wartet", "dl.cancel": "Abbrechen", "dl.cancelled": "Holen abgebrochen",
|
||||
"dl.progress": "Hole {i} von {n} von der Frame · {done} von {total}",
|
||||
"dl.done": "{n} Aufnahme(n) geholt – du kannst die Frame abschalten", "dl.failed": "{n} Aufnahme(n) konnten nicht geholt werden",
|
||||
"err.no_download": "Es wird nichts geholt",
|
||||
"checkAll": "Alle prüfen", "checkAll.title": "Alle Aufnahmen auf dem PC prüfen, die noch nicht geprüft sind",
|
||||
"pick": "Datei wählen …", "refresh.title": "Liste neu laden", "loading": "Lade …",
|
||||
"frame.unreachable": "Die Frame ist nicht erreichbar – ist sie an und im selben Netz?",
|
||||
@@ -291,6 +385,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",
|
||||
@@ -315,6 +422,14 @@ const I18N = {
|
||||
"sc.spirula.starting": "Spirula startet …", "sc.uploadStarted": "Übertrage auf die Frame …",
|
||||
"sc.splats": "{n} Mio. Splats",
|
||||
"sc.plan": "Grundriss",
|
||||
"rdel.frame": "🗑 Von der Frame löschen", "rdel.pc": "🗑 Vom PC löschen",
|
||||
"rdel.confirm.frame": "„{name}“ ({size}) von der Frame löschen? Dort lässt sich das nicht rückgängig machen – die Kopie auf dem PC bleibt.",
|
||||
"rdel.confirm.frame.only": "„{name}“ ({size}) liegt noch NICHT auf dem PC! Trotzdem von der Frame löschen? Dann ist sie endgültig weg.",
|
||||
"rdel.confirm.pc": "„{name}“ ({size}) auf dem PC in den Papierkorb verschieben? Fertige Szenen bleiben.",
|
||||
"rdel.confirm.pc.only": "„{name}“ ({size}) in den Papierkorb verschieben? Auf der Frame liegt sie nicht mehr – der Papierkorb hat dann die einzige Kopie.",
|
||||
"rdel.done.frame": "„{name}“ von der Frame gelöscht", "rdel.done.pc": "„{name}“ in den Papierkorb verschoben",
|
||||
"err.recording_fetching": "Die Aufnahme wird gerade von der Frame geholt",
|
||||
"err.recording_needed": "„{name}“ aus dieser Aufnahme ist noch nicht fertig und braucht sie noch",
|
||||
"ren.button": "✎ Umbenennen", "ren.ok": "Umbenennen", "ren.cancel": "Abbrechen", "ren.done": "Umbenannt",
|
||||
"ren.rec.title": "Aufnahme umbenennen",
|
||||
"ren.rec.hint": "Benennt die Datei auf dem PC um. Das Aufnahmedatum bleibt erhalten; die Kopie auf der Frame behält ihren Namen.",
|
||||
@@ -346,7 +461,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})",
|
||||
@@ -358,6 +479,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.",
|
||||
@@ -375,9 +497,59 @@ 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",
|
||||
"err.plan_failed": "Der Grundriss konnte nicht gezeichnet werden", "sc.replacing": "Wird neu gerechnet – bleibt, bis die neue fertig ist", "err.plan_busy": "Gerade werden andere Grundrisse gezeichnet, gleich noch einmal versuchen",
|
||||
"scale.button.title": "Maßstab – für Modelle, z. B. eine Modelleisenbahn", "scale.title": "Maßstab von „{name}“",
|
||||
"scale.hint": "Für Modelle: Grundriss, Größen und Messwerte zeigen dann die Größe des Originals (H0: 10 cm im Modell = 8,7 m).",
|
||||
"scale.original": "Originalgröße", "scale.custom": "Eigener Maßstab",
|
||||
"scale.xr": "In Gaussian XR in Originalgröße begehbar (an der Frame noch nicht getestet)",
|
||||
"scale.members": "Auch für alle Szenen dieser Gruppe", "scale.frame": "Auch auf der Frame aktualisieren",
|
||||
"scale.line": "Maßstab {ratio}", "scale.line.xr": "in XR in Originalgröße", "scale.done": "Maßstab gespeichert",
|
||||
"ver.button": "Fassungen ({n})", "ver.button.title": "Fassungen mit weniger Splats für die Frame oder allen Splats für den PC – mehrere nebeneinander",
|
||||
"ver.title": "Fassungen von „{name}“ ({n} Splats)",
|
||||
"ver.hint": "Jede Fassung ist eine eigene Szene und bekommt einen eigenen Platz in der Galerie der Frame – z. B. 0,75 Mio. und 2 Mio. nebeneinander zum Vergleichen.",
|
||||
"ver.new": "Neue Fassung", "ver.custom": "Eigene Größe", "ver.million": "Mio. Splats",
|
||||
"ver.scales": "Maßstab", "ver.walk": "begehbar ({ratio})", "ver.all": "alle Splats",
|
||||
"ver.measured": "Gemessen auf einer Steam Frame (Gaussian XR, 10/2026): 0,75 Mio. ≈ 36 fps, 1 Mio. ≈ 35 fps, 1,5 Mio. ≈ 29 fps, 2 Mio. ≈ 24 fps, 5 Mio. ≈ 14 fps.",
|
||||
"ver.for": "Wofür", "ver.for.frame": "🥽 die Frame (Brille)", "ver.for.pc": "🖥️ den PC (alle Splats, z. B. Spirula Studio)",
|
||||
"ver.size.head": "Größe", "ver.fps.0_75": "≈ {fps} fps", "ver.fps.1": "≈ {fps} fps, deutlich schärfer", "ver.fps.1_5": "≈ {fps} fps, leicht unruhig", "ver.fps.2": "≈ {fps} fps",
|
||||
"ver.plain": "Zusätzlich ohne Bereinigung erstellen, zum Vergleichen",
|
||||
"ver.clean.none": "nicht bereinigt", "ver.slow": "{n} Mio. Splats: auf der Frame nur etwa 14 fps",
|
||||
"ver.template": "Als Vorlage", "ver.template.title": "Die Einstellungen dieser Fassung ins Formular unten übernehmen – einen Regler ändern und die nächste erstellen",
|
||||
"ver.template.done": "Einstellungen übernommen – ändere, was du vergleichen willst",
|
||||
"ver.replaces": "{what} (ersetzt die vorhandene)", "ver.same.all": "Alle Splats ohne Bereinigung ist die Szene selbst – öffne sie bei der Szene in Spirula oder stell einen Regler.",
|
||||
"ver.makes.1": "Erstellt:", "ver.makes.n": "Erstellt {n} Fassungen:", "ver.make.1": "Fassung erstellen", "ver.make.n": "{n} Fassungen erstellen",
|
||||
"ver.show": "▸ {n} Fassungen zeigen", "ver.first": "+ Fassungen …", "ver.show.1": "▸ 1 Fassung zeigen", "ver.new.button": "+ Neue Fassung …", "ver.hide": "▾ Fassungen ausblenden", "ver.of": "Fassungen von „{name}“",
|
||||
"ver.pick": "Eine Größe angeben (mindestens 0,01 Mio.)", "ver.started": "Erstelle Fassungen …",
|
||||
"ver.none": "Noch keine Fassungen.", "ver.stale": "älter als die Szene", "ver.delete": "Fassung löschen",
|
||||
"ver.confirm": "Fassung „{name}“ ({size}) in den Papierkorb verschieben? Eine Kopie auf der Frame bleibt unberührt.",
|
||||
"job.sub.versions": "{n} Fassungen · seit {time}", "err.no_version": "Diese Fassung gibt es nicht",
|
||||
"err.no_versions": "Mindestens eine Größe wählen",
|
||||
"ver.unframe": "Von der Frame nehmen", "ver.unframe.title": "Diese Fassung aus der Galerie der Frame entfernen (auf dem PC bleibt sie)",
|
||||
"ver.unframe.confirm": "„{name}“ von der Frame nehmen? Ihr Ordner in der Galerie und die Sicherungskopie auf der Frame werden gelöscht; auf dem PC bleibt die Fassung.",
|
||||
"ver.unframed": "„{name}“ von der Frame genommen", "err.not_on_frame_version": "Diese Fassung liegt nicht auf der Frame",
|
||||
"err.frame_offline": "Die Frame ist nicht erreichbar", "err.frame_failed": "Die Frame hat abgelehnt",
|
||||
"stab.pc": "Auf dem PC", "stab.frame": "Auf der Frame",
|
||||
"fg.hint": "Alles, was in der Galerie von Gaussian XR auf der Frame liegt. Von der Frame nehmen ändert nichts auf dem PC.",
|
||||
"fg.loading": "Frage die Frame …", "fg.empty": "Die Galerie auf der Frame ist leer.",
|
||||
"fg.kind.scene": "Szene", "fg.kind.tour": "Rundgang", "fg.kind.version": "Fassung", "fg.kind.other": "Nicht aus der Splat-Werkstatt",
|
||||
"fg.remove": "Von der Frame nehmen", "fg.remove.title": "Diese Szene aus der Galerie der Frame löschen",
|
||||
"fg.confirm": "„{name}“ ({size}) von der Frame nehmen? Auf dem PC bleibt sie und lässt sich jederzeit neu übertragen.",
|
||||
"fg.confirm.other": "„{name}“ ({size}) stammt nicht aus der Splat-Werkstatt – z. B. ein Beispiel von Gaussian XR. Trotzdem von der Frame löschen? Steam stellt die Beispiele mit „Dateien auf Fehler überprüfen“ wieder her.",
|
||||
"fg.removed": "„{name}“ von der Frame genommen",
|
||||
"fg.place.own": "Eigene Szenen", "fg.place.own.title": "Documents/GaussianXR auf der Frame – Steam-Updates lassen den Ordner in Ruhe",
|
||||
"fg.place.steam": "Ordner von Gaussian XR", "fg.place.steam.title": "Der Galerie-Ordner von Gaussian XR selbst – ein Steam-Update oder „Dateien überprüfen“ kann ihn zurücksetzen",
|
||||
"fg.move.hint": "{n} Szene(n) aus der Werkstatt liegen noch im Ordner von Gaussian XR, wo ein Steam-Update sie löschen kann.",
|
||||
"fg.move": "Zu den eigenen Szenen verschieben", "fg.moved": "Zu den eigenen Szenen auf der Frame verschoben",
|
||||
"ver.recommended": "(empfohlen)",
|
||||
"ver.clean": "Bereinigen", "ver.clean.off": "aus",
|
||||
"ver.clean.hint": "Experimentell. Die Szene selbst bleibt, wie sie ist; nur diese Fassung wird bereinigt – am besten neben einer ohne vergleichen.",
|
||||
"ver.clean.ceiling": "Decke", "ver.clean.ceiling.title": "Eine weiße Decke gibt der Rekonstruktion keinen Halt: Sie bleibt dünn, darunter hängt ein dunkler Schleier. Nimmt den Schleier heraus (je stärker, desto tiefer; Dunkles bis 50 cm an einer Wand bleibt) und was über der Decke liegt, und setzt eine geschlossene Decke in ihrer eigenen Farbe ein.",
|
||||
"ver.clean.floor": "Boden", "ver.clean.floor.title": "Füllt Löcher im Boden in der Farbe des Bodens drumherum (je stärker, desto mehr gilt als Loch) und nimmt heraus, was unter dem Boden liegt.",
|
||||
"ver.clean.haze": "Dunst", "ver.clean.haze.title": "Nimmt große, fast durchsichtige Wolken heraus, die im Raum schweben. Lange dünne Dinge (Gitter, Kabel, Drähte) sind kein Dunst und bleiben. Je stärker, desto kleinere und weniger durchsichtige Wolken gehen mit.",
|
||||
"ver.clean.outside": "Rand", "ver.clean.outside.title": "Nimmt heraus, was deutlich außerhalb des Raums liegt: hinter den Wänden, über der Decke, unter dem Boden – von 1 m Abstand bis 30 cm bei voller Stärke. Auch der Blick durchs Fenster.",
|
||||
"err.mixed_scale": "Die Szenen haben verschiedene Maßstäbe – einen Maßstab für die Gruppe setzen", "err.plan_failed": "Der Grundriss konnte nicht gezeichnet werden", "sc.replacing": "Wird neu gerechnet – bleibt, bis die neue fertig ist", "err.plan_busy": "Gerade werden andere Grundrisse gezeichnet, gleich noch einmal versuchen",
|
||||
"plan.measure": "📏 Messen", "plan.measure.title": "Abstände im Grundriss messen",
|
||||
"plan.measure.hint": "Klicken setzt Punkte · Rechtsklick nimmt den letzten zurück · waagerechte Abstände, etwa ±5 cm genau",
|
||||
"plan.measure.undo": "Rückgängig", "plan.measure.reset": "Zurücksetzen",
|
||||
|
||||
+575
-54
@@ -67,6 +67,8 @@ label.field small { font-size: 12px; color: var(--faint); line-height: 1.4; }
|
||||
.play:hover { transform: scale(1.08); }
|
||||
.cbadge { position: absolute; left: 8px; top: 8px; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center;
|
||||
font-size: 13px; font-weight: 700; color: #0b0d12; }
|
||||
.dlpick { position: absolute; right: 6px; top: 6px; padding: 4px; border-radius: 8px; background: rgba(11,13,18,.6); line-height: 0; cursor: pointer; }
|
||||
.dlpick input { width: 18px; height: 18px; margin: 0; cursor: pointer; accent-color: var(--accent); }
|
||||
.cbadge.ok { background: var(--ok); } .cbadge.warn { background: var(--warn); } .cbadge.bad { background: var(--bad); }
|
||||
|
||||
/* recording check */
|
||||
@@ -155,6 +157,7 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
|
||||
padding: 10px 16px; border-radius: 10px; font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 10; }
|
||||
.toast.show { opacity: 1; }
|
||||
.hidden { display: none !important; }
|
||||
.invisible { visibility: hidden; }
|
||||
.group { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 12px; }
|
||||
.ghead { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.gthumb { width: 132px; height: 132px; flex: none; border-radius: 10px; background: #0b0d12 center/cover no-repeat; display: grid; place-items: center; font-size: 44px; }
|
||||
@@ -163,9 +166,29 @@ 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; }
|
||||
.vgrid { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; align-items: center; font-size: 13.5px; }
|
||||
.vlist .vrow { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.vlist .vwhat { display: flex; flex-direction: column; gap: 2px; font-size: 13px; min-width: 0; }
|
||||
.vlist .vacts { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.vinline { grid-column: 1 / -1; margin-top: 8px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.vinline .vhead { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.scene .acts.rows { flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
.acts .arow { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.acts .sep { width: 1px; align-self: stretch; margin: 2px 3px; background: var(--line); }
|
||||
.acts button.icon { min-width: 34px; }
|
||||
.toggles { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||
.toggles button.link { background: none; border-color: transparent; color: var(--muted, #9aa3b2); padding-left: 2px; padding-right: 8px; }
|
||||
.toggles button.link:hover { color: inherit; border-color: var(--line); }
|
||||
.vsizes { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; }
|
||||
.vpreview { font-size: 13px; margin: 14px 0 0; min-height: 1.4em; }
|
||||
.cleanup .slide { display: grid; grid-template-columns: 7.5em 1fr 4.5em; gap: 10px; align-items: center; margin: 2px 0; }
|
||||
.cleanup .slide output { text-align: right; color: var(--muted, #9aa3b2); font-variant-numeric: tabular-nums; }
|
||||
.vgrid label.chk { display: inline-flex; gap: 5px; align-items: center; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px; }
|
||||
dialog select { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||
dialog h3 { font-size: 13.5px; margin: 16px 0 8px; }
|
||||
.opts { display: grid; gap: 6px; }
|
||||
@@ -233,6 +256,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>
|
||||
@@ -258,10 +282,15 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<button class="tab active" data-tab="frame" data-i18n="tab.frame"></button>
|
||||
<button class="tab" data-tab="local" data-i18n="tab.local"></button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="small hidden" id="dlSelBtn" data-i18n-title="dl.sel.title"></button>
|
||||
<button class="small hidden" id="dlAllBtn" data-i18n-title="dl.all.title"></button>
|
||||
<button class="small" id="checkAllBtn" data-i18n="checkAll" data-i18n-title="checkAll.title"></button>
|
||||
<button class="small pc-only" id="pickBtn" data-i18n="pick"></button>
|
||||
<button class="small" id="refreshBtn" data-i18n-title="refresh.title">↻</button>
|
||||
</div>
|
||||
<div id="dlBar" class="hidden" style="display:flex;gap:10px;align-items:center;margin:0 0 12px">
|
||||
<div style="flex:1"><span class="muted" id="dlText"></span><div class="bar"><div id="dlFill" style="width:0"></div></div></div>
|
||||
<button class="small" id="dlCancel" data-i18n="dl.cancel"></button></div>
|
||||
<div id="recGrid" class="grid"></div>
|
||||
<div id="checkBox" class="hidden"></div>
|
||||
<div id="checkRes" class="hidden"></div>
|
||||
@@ -306,6 +335,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">
|
||||
@@ -320,9 +359,22 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<h2 data-i18n="scenes.title" style="margin:0"></h2>
|
||||
<div class="row"><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
|
||||
</div>
|
||||
<div id="groupSuggest" class="suggest hidden"></div>
|
||||
<div id="groupList"></div>
|
||||
<div id="sceneGrid" class="grid"></div>
|
||||
<div class="tabs" id="sceneTabs">
|
||||
<button class="tab active" data-stab="pc" data-i18n="stab.pc"></button>
|
||||
<button class="tab" data-stab="frame" data-i18n="stab.frame"></button>
|
||||
<button class="small hidden" id="frameRefresh" title="↻" aria-label="↻">↻</button>
|
||||
</div>
|
||||
<div id="pcView">
|
||||
<div id="groupSuggest" class="suggest hidden"></div>
|
||||
<div id="groupList"></div>
|
||||
<div id="sceneGrid" class="grid"></div>
|
||||
</div>
|
||||
<div id="frameView" class="hidden">
|
||||
<p class="faint" style="font-size:12.5px;margin:0 0 12px" data-i18n="fg.hint"></p>
|
||||
<div id="frameMove" class="hidden" style="font-size:12.5px;margin:0 0 12px"><span id="frameMoveText"></span>
|
||||
<button class="small" id="frameMoveBtn" data-i18n="fg.move"></button></div>
|
||||
<div id="frameGrid" class="grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- cancelled / failed runs -->
|
||||
@@ -345,6 +397,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">
|
||||
@@ -355,13 +410,15 @@ 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>
|
||||
<div class="opts" id="cmbPart"></div>
|
||||
<h3 data-i18n="cmb.size"></h3>
|
||||
<select id="cmbSize"><option value="2000000" data-i18n="cmb.size.2"></option><option value="3500000" data-i18n="cmb.size.35"></option><option value="5000000" data-i18n="cmb.size.5"></option></select>
|
||||
<div class="row"><select id="cmbSize"></select>
|
||||
<input id="cmbSizeCustom" class="hidden" type="number" min="0.5" max="10" step="0.05" style="width:90px"> <span class="hidden" id="cmbSizeUnit" data-i18n="ver.million"></span></div>
|
||||
<p class="faint" style="font-size:12px;margin:6px 0 0" data-i18n="ver.measured"></p>
|
||||
<label class="row" style="margin-top:14px;gap:8px;font-size:13.5px"><input type="checkbox" id="cmbFull"> <span data-i18n="cmb.full"></span></label>
|
||||
<p class="faint" id="cmbMem" style="font-size:12.5px;margin:6px 0 0"></p>
|
||||
<div id="cmbMemLow" class="msg warn hidden" data-i18n="cmb.mem.low"></div>
|
||||
@@ -372,6 +429,44 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="verDlg" class="wide">
|
||||
<h2 style="margin-top:0" id="verTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="ver.hint"></p>
|
||||
<div id="verList" class="vlist"></div>
|
||||
<h3 data-i18n="ver.new"></h3>
|
||||
<div class="vgrid">
|
||||
<b data-i18n="ver.for"></b>
|
||||
<div class="row" id="verFor">
|
||||
<label class="chk"><input type="radio" name="verFor" value="frame" checked> <span data-i18n="ver.for.frame"></span></label>
|
||||
<label class="chk"><input type="radio" name="verFor" value="pc"> <span data-i18n="ver.for.pc"></span></label>
|
||||
</div>
|
||||
<b id="verSizeHead" data-i18n="ver.size.head"></b><div id="verSizes" class="vsizes"></div>
|
||||
<b class="hidden" id="verScaleHead" data-i18n="ver.scales"></b><div class="row hidden" id="verScales"></div>
|
||||
<b data-i18n="ver.clean"></b>
|
||||
<div><div id="verClean" class="cleanup"></div>
|
||||
<label class="chk hidden" id="verPlainBox" style="margin-top:6px"><input type="checkbox" id="verPlain"> <span data-i18n="ver.plain"></span></label></div>
|
||||
</div>
|
||||
<p id="verPreview" class="vpreview"></p>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:12px">
|
||||
<button id="verClose" data-i18n="clean.close"></button>
|
||||
<button class="primary" id="verMake"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="scaleDlg">
|
||||
<h2 style="margin-top:0" id="scaleTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="scale.hint"></p>
|
||||
<div class="opts" id="scaleOpts"></div>
|
||||
<label class="row" style="margin-top:10px;gap:8px;font-size:13.5px">1 : <input id="scaleCustom" type="number" min="1" max="1000" step="any" style="width:110px"></label>
|
||||
<label class="row" id="scaleXrRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:12px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleXr" style="margin-top:3px"> <span data-i18n="scale.xr"></span></label>
|
||||
<label class="row hidden" id="scaleMembersRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleMembers" checked> <span data-i18n="scale.members"></span></label>
|
||||
<label class="row hidden" id="scaleFrameRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleFrame" checked> <span data-i18n="scale.frame"></span></label>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="scaleCancel" data-i18n="grp.cancel"></button>
|
||||
<button class="primary" id="scaleOk" data-i18n="grp.save"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="renDlg">
|
||||
<h2 style="margin-top:0" id="renTitle"></h2>
|
||||
<p class="muted" id="renHint" style="font-size:13px"></p>
|
||||
@@ -424,7 +519,7 @@ const PRESETS = {
|
||||
high: d => ({frames: clamp(Math.round(d * 5), 100, 500), scale: 1, maxg: 2, iter: 30000}),
|
||||
max: d => ({frames: clamp(Math.round(d * 8), 100, 500), scale: 1, maxg: 3, iter: 50000}),
|
||||
};
|
||||
const S = { tab: "frame", recs: null, sel: null, probe: null, quality: "high", job: null, dl: null, dismissed: null,
|
||||
const S = { tab: "frame", recs: null, picked: new Set(), sel: null, probe: null, quality: "high", job: null, dl: null, dismissed: null,
|
||||
frameOnline: null, frameConfigured: true };
|
||||
|
||||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||||
@@ -516,15 +611,17 @@ function renderRecs() {
|
||||
const sel = S.sel && S.sel.name === f.name ? " sel" : "";
|
||||
const local = S.tab === "frame" && f.local ? `<span class="badge">${tr("onPc")}</span>` : "";
|
||||
const onPc = S.tab === "local" || f.local;
|
||||
const pick = S.tab === "frame" && !f.local && !queued(f.name)
|
||||
? `<label class="dlpick" title="${tr("dl.pick")}"><input type="checkbox" data-pick="${esc(f.name)}"${S.picked.has(f.name) ? " checked" : ""}></label>` : "";
|
||||
const play = onPc ? `<button class="play pc-only" data-play="${esc(f.name)}" title="${tr("play.title")}" aria-label="${tr("play.title")}">${PLAY}</button>` : "";
|
||||
return `<div class="rec${sel}" data-i="${i}" role="button" tabindex="0">
|
||||
<div class="thumb" data-src="/api/thumb?source=${onPc ? "local" : "frame"}&name=${encodeURIComponent(f.name)}">${badge(f)}${play}</div>
|
||||
<div class="meta"><b>${esc(f.label || fmtDate(f.date) || f.name)}</b><span>${f.label && f.date ? esc(fmtDate(f.date)) + " · " : ""}${mb(f.size)} ${local}</span></div></div>`;
|
||||
<div class="thumb" data-src="/api/thumb?source=${onPc ? "local" : "frame"}&name=${encodeURIComponent(f.name)}">${badge(f)}${play}${pick}</div>
|
||||
<div class="meta"><b>${esc(f.label || fmtDate(f.date) || f.name)}</b><span>${f.label && f.date ? esc(fmtDate(f.date)) + " · " : ""}${mb(f.size)} ${local}${queued(f.name) ? `<span class="badge">${tr("dl.queued")}</span>` : ""}</span></div></div>`;
|
||||
}).join("");
|
||||
g.querySelectorAll(".thumb").forEach(t => {
|
||||
const url = t.dataset.src, img = new Image();
|
||||
img.onload = () => { t.style.backgroundImage = `url(${JSON.stringify(url)})`; };
|
||||
img.onerror = () => { if (!t.querySelector(".play")) t.textContent = "◌"; };
|
||||
img.onerror = () => { if (!t.querySelector(".play")) t.insertAdjacentHTML("afterbegin", "<span>◌</span>"); };
|
||||
img.src = url;
|
||||
});
|
||||
g.querySelectorAll(".rec").forEach(b => {
|
||||
@@ -532,6 +629,47 @@ function renderRecs() {
|
||||
b.onkeydown = e => { if ((e.key === "Enter" || e.key === " ") && e.target === b) { e.preventDefault(); b.click(); } };
|
||||
});
|
||||
g.querySelectorAll("[data-play]").forEach(b => b.onclick = e => { e.stopPropagation(); play(b.dataset.play); });
|
||||
g.querySelectorAll(".dlpick").forEach(b => { b.onclick = e => e.stopPropagation(); b.onkeydown = e => e.stopPropagation(); });
|
||||
g.querySelectorAll("[data-pick]").forEach(c => c.onchange = () => {
|
||||
c.checked ? S.picked.add(c.dataset.pick) : S.picked.delete(c.dataset.pick); renderDlButtons();
|
||||
});
|
||||
renderDlButtons();
|
||||
}
|
||||
|
||||
/* ---------- fetching several recordings from the Frame, one after the other ---------- */
|
||||
function queued(name) { const d = S.dl; return !!(d && d.busy && ((d.name === name && !d.finished) || (d.queue || []).includes(name))); }
|
||||
function fetchable() { return S.recs && S.recs.frame_online ? (S.recs.frame || []).filter(f => !f.local && !queued(f.name)) : []; }
|
||||
function renderDlButtons() {
|
||||
const onFrame = S.tab === "frame", all = fetchable();
|
||||
for (const n of [...S.picked]) if (!all.some(f => f.name === n)) S.picked.delete(n);
|
||||
const picked = all.filter(f => S.picked.has(f.name));
|
||||
const size = list => mb(list.reduce((a, f) => a + f.size, 0));
|
||||
$("#dlAllBtn").classList.toggle("hidden", !onFrame || !all.length);
|
||||
$("#dlAllBtn").textContent = "⬇ " + tr("dl.all", {n: all.length, size: size(all)});
|
||||
$("#dlSelBtn").classList.toggle("hidden", !onFrame || !picked.length);
|
||||
$("#dlSelBtn").textContent = "⬇ " + tr("dl.sel", {n: picked.length, size: size(picked)});
|
||||
}
|
||||
async function fetchMany(list) {
|
||||
if (!list.length) return;
|
||||
const r = await api("/api/download", {names: list.map(f => f.name).join("\n")});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
S.picked.clear();
|
||||
S.dl = {...(S.dl || {}), busy: true, queue: [...((S.dl && S.dl.queue) || []), ...list.map(f => f.name)]};
|
||||
renderRecs(); renderDlBar();
|
||||
}
|
||||
$("#dlAllBtn").onclick = () => fetchMany(fetchable());
|
||||
$("#dlSelBtn").onclick = () => fetchMany(fetchable().filter(f => S.picked.has(f.name)));
|
||||
$("#dlCancel").onclick = async () => {
|
||||
const r = await api("/api/download/cancel", {});
|
||||
if (!r.ok) toast(trErr(r.error));
|
||||
};
|
||||
function renderDlBar() {
|
||||
const d = S.dl, b = d && d.batch, on = !!(d && d.busy && b && b.files);
|
||||
$("#dlBar").classList.toggle("hidden", !on);
|
||||
if (!on) return;
|
||||
const cur = d.finished ? 0 : d.done || 0, done = (b.done_bytes || 0) + cur;
|
||||
$("#dlText").textContent = tr("dl.progress", {i: Math.min(b.done_files + 1, b.files), n: b.files, done: mb(done), total: mb(b.bytes)});
|
||||
$("#dlFill").style.width = (done / Math.max(b.bytes, 1) * 100).toFixed(1) + "%";
|
||||
}
|
||||
function badge(f) {
|
||||
const lv = f.check || (S.recs?.local.find(x => x.name === f.name) || {}).check;
|
||||
@@ -573,8 +711,9 @@ function renderCheck() {
|
||||
const dl = S.dl && S.dl.name === f.name && !S.dl.finished ? S.dl : null;
|
||||
box.innerHTML = dl
|
||||
? `<div style="flex:1"><span class="muted">${tr("probe.fetching", {done: mb(dl.done), total: mb(dl.total)})}</span><div class="bar"><div style="width:${(dl.done / dl.total * 100).toFixed(1)}%"></div></div></div>`
|
||||
: `<span class="muted">${tr("probe.onFrame", {size: mb(f.size)})}</span><button class="small" id="fetchBtn">${esc(tr("probe.fetch"))}</button>`;
|
||||
: `<span class="muted">${tr("probe.onFrame", {size: mb(f.size)})}</span><button class="small" id="fetchBtn">${esc(tr("probe.fetch"))}</button>${delButton(f)}`;
|
||||
const b = $("#fetchBtn"); if (b) b.onclick = () => fetchSel();
|
||||
if ($("#delSel")) $("#delSel").onclick = () => deleteRecording(f);
|
||||
return;
|
||||
}
|
||||
const p = S.probe;
|
||||
@@ -588,13 +727,41 @@ function renderCheck() {
|
||||
box.insertAdjacentHTML("afterbegin", `<div class="row" style="order:9;margin-left:auto">
|
||||
<button class="small" id="checkSel" title="${tr("check.title")}">${tr("check.run")}</button>
|
||||
<button class="small pc-only" id="playSel">${tr("play.button")}</button>
|
||||
${S.recs?.local.find(x => x.name === f.name)?.renamable ? `<button class="small" id="renSel">${tr("ren.button")}</button>` : ""}</div>`);
|
||||
${S.recs?.local.find(x => x.name === f.name)?.renamable ? `<button class="small" id="renSel">${tr("ren.button")}</button>` : ""}${delButton(f)}</div>`);
|
||||
if ($("#delSel")) $("#delSel").onclick = () => deleteRecording(f);
|
||||
$("#playSel").onclick = () => play(f.name);
|
||||
if ($("#renSel")) $("#renSel").onclick = () => renameRecording(f);
|
||||
$("#checkSel").onclick = () => runCheck(f.name);
|
||||
loadCheck(f.name);
|
||||
}
|
||||
|
||||
/* ---------- deleting one recording: on the Frame (for good) or on this PC (trash) ---------- */
|
||||
function delWhere(f) {
|
||||
if (f.source === "frame") return S.recs && S.recs.frame_online && !queued(f.name) ? "frame" : null;
|
||||
return S.recs?.local.find(x => x.name === f.name)?.renamable && !queued(f.name) ? "pc" : null;
|
||||
}
|
||||
function delButton(f) {
|
||||
const where = delWhere(f);
|
||||
return where ? `<button class="small" id="delSel" title="${tr("rdel." + where)}">${tr("rdel." + where)}</button>` : "";
|
||||
}
|
||||
async function deleteRecording(f) {
|
||||
const where = delWhere(f);
|
||||
if (!where) return;
|
||||
const loc = S.recs.local.find(x => x.name === f.name), orig = loc ? loc.orig : f.name;
|
||||
const other = where === "frame" ? !!(f.local || loc) : !!(S.recs.frame || []).find(x => x.name === orig);
|
||||
const name = f.label || fmtDate(f.date) || f.name;
|
||||
if (!confirm(tr(`rdel.confirm.${where}${other ? "" : ".only"}`, {name, size: mb(f.size)}))) return;
|
||||
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";
|
||||
renderCheck(); updateStart(); loadRecs(true);
|
||||
}
|
||||
|
||||
/* ---------- recording check ---------- */
|
||||
const TL_COLOR = {ok: "var(--ok)", flat: "var(--warn)", dark: "#6b5cff", fast: "#ff8fd0", eye: "var(--bad)", over: "#f4f1c9"};
|
||||
function mmss(s) { return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }
|
||||
@@ -658,7 +825,8 @@ async function checkAll() {
|
||||
async function fetchSel() {
|
||||
const r = await api("/api/download", {name: S.sel.name});
|
||||
if (!r.ok) { toast(trErr(r.error)); return false; }
|
||||
S.dl = {name: S.sel.name, done: 0, total: S.sel.size, finished: false};
|
||||
if (!S.dl || !S.dl.busy) S.dl = {name: S.sel.name, done: 0, total: S.sel.size, finished: false, busy: true, queue: []};
|
||||
else S.dl.queue = [...(S.dl.queue || []), S.sel.name];
|
||||
renderCheck(); updateStart();
|
||||
return new Promise(res => { S.dlWait = res; });
|
||||
}
|
||||
@@ -677,19 +845,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.dl && !S.dl.finished);
|
||||
$("#startBtn").disabled = !ready || busy || !!(S.sel && queued(S.sel.name));
|
||||
$("#estimate").innerHTML = busy ? tr("est.busy") : !S.sel ? tr("est.none") :
|
||||
tr("est", {frames: s.frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total});
|
||||
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();
|
||||
@@ -697,8 +901,8 @@ document.querySelectorAll("#quality button").forEach(b => b.onclick = () => {
|
||||
document.querySelectorAll("input[name=target]").forEach(r => r.onchange = () => { S.targetTouched = true; targetChanged(); });
|
||||
["frames", "scale", "maxg", "iter"].forEach(id => $("#" + id).oninput = updateStart);
|
||||
$("#name").oninput = () => { $("#name").dataset.auto = "0"; updateStart(); };
|
||||
function setTab(tab) { S.tab = tab; document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
|
||||
document.querySelectorAll(".tab").forEach(t => t.onclick = () => { setTab(t.dataset.tab); renderRecs(); });
|
||||
function setTab(tab) { S.tab = tab; document.querySelectorAll(".tab[data-tab]").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
|
||||
document.querySelectorAll(".tab[data-tab]").forEach(t => t.onclick = () => { setTab(t.dataset.tab); renderRecs(); });
|
||||
$("#refreshBtn").onclick = () => loadRecs(true);
|
||||
$("#pickBtn").onclick = async () => {
|
||||
const r = await api("/api/pick", {});
|
||||
@@ -723,6 +927,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});
|
||||
@@ -741,20 +951,33 @@ $("#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);
|
||||
$("#jobSub").textContent = combine ? tr("job.sub.combine", {n: j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
|
||||
const versions = j.kind === "versions";
|
||||
$("#jobSub").textContent = versions ? tr("job.sub.versions", {n: (j.steps || []).length, time})
|
||||
: combine ? tr(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);
|
||||
const phases = [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr((combine && i < 4 ? "cphase." : "phase.") + i));
|
||||
const phases = versions ? (j.steps || [])
|
||||
: [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr((combine && i < 4 ? "cphase." : "phase.") + i));
|
||||
const finished = !j.running && j.result === "ok";
|
||||
$("#steps").style.gridTemplateColumns = `repeat(${phases.length}, 1fr)`;
|
||||
$("#steps").innerHTML = phases.map((p, i) => {
|
||||
@@ -821,9 +1044,16 @@ function sceneCard(s) {
|
||||
<div class="meta"><b>${esc(s.name)}</b>
|
||||
<span>${new Date(s.mtime * 1000).toLocaleDateString(LANG)} · ${s.gaussians ? tr("sc.splats", {n: num(s.gaussians / 1e6, 2)}) + " · " : ""}${mb(s.size)}</span>
|
||||
${s.disk ? `<span class="faint">${tr("sc.disk", {size: mb(s.disk.total)})}</span>` : ""}
|
||||
${scaleLine(s)}
|
||||
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
|
||||
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
|
||||
</div></div>`;
|
||||
<div class="acts rows${s.replacing ? " hidden" : ""}">
|
||||
${S.frameConfigured ? `<div class="arow">${uploadBtn(s)}</div>` : ""}
|
||||
<div class="arow">${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button></div>
|
||||
<div class="arow"><button class="small icon" data-rename="${esc(s.slug)}" title="${tr("ren.button")}" aria-label="${tr("ren.button")}">✎</button><button class="small icon" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small icon" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small icon" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div></div>
|
||||
<div class="toggles${s.replacing ? " hidden" : ""}">${versionsToggle(s.slug, s.versions)}</div>
|
||||
</div></div>
|
||||
${s.versions && versionsOpen(s.slug) ? `<div class="vinline"><div class="vhead"><b>${tr("ver.of", {name: esc(s.name)})}</b><button class="small" data-versions="${esc(s.slug)}">${tr("ver.new.button")}</button></div>
|
||||
<div class="vlist" data-vlist="${esc(s.slug)}"></div></div>` : ""}`;
|
||||
}
|
||||
const ALIGN_LETTER = {frame: "A", images: "B", shift: "C", rigid: "D"};
|
||||
function groupOpen(slug, set) {
|
||||
@@ -833,9 +1063,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);
|
||||
@@ -849,26 +1084,34 @@ 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>
|
||||
${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>` : ""}
|
||||
<button class="small${!r || r.stale ? " primary" : ""}" data-combine="${esc(g.slug)}" ${g.busy || scenes.length < 2 ? "disabled" : ""}>${tr(r ? "grp.recombine" : g.room ? "grp.room.combine" : "grp.combine")}</button>
|
||||
${r ? uploadBtn({slug: g.slug, on_frame: r.on_frame, upload: r.upload}) : ""}<i class="sep"></i>
|
||||
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
|
||||
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}
|
||||
<button class="small" data-gedit="${esc(g.slug)}">${tr("grp.edit")}</button>
|
||||
<button class="small" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
|
||||
<button class="small" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
|
||||
${scenes.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
|
||||
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}<i class="sep"></i>
|
||||
<button class="small icon" data-gedit="${esc(g.slug)}" title="${tr("grp.edit")}" aria-label="${tr("grp.edit")}">✎</button>
|
||||
<button class="small icon" data-scale="${esc(g.slug)}" title="${tr("scale.button.title")}" ${g.busy ? "disabled" : ""}>📏${g.scale !== 1 ? " " + ratioText(g.scale) : ""}</button>
|
||||
<button class="small icon" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
|
||||
<button class="small icon" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
|
||||
</div>
|
||||
<div class="toggles">
|
||||
<button class="small link" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : g.room ? "grp.show.recordings" : inner.length ? "grp.show.rooms" : "grp.show", {n: inner.length + own.length})}</button>
|
||||
${r && r.ply ? versionsToggle(g.slug, g.versions) : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${open ? `<div class="grid ginner">${rooms.map(sceneCard).join("")}</div>` : ""}
|
||||
${g.versions && versionsOpen(g.slug) ? `<div class="vinline"><div class="vhead"><b>${tr("ver.of", {name: esc(g.name)})}</b><button class="small" data-versions="${esc(g.slug)}">${tr("ver.new.button")}</button></div>
|
||||
<div class="vlist" data-vlist="${esc(g.slug)}"></div></div>` : ""}
|
||||
${open ? `<div class="grid ginner">${inner.map(x => groupBlock(x, bySlug, byGroup)).join("")}${own.map(sceneCard).join("")}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
async function loadScenes() {
|
||||
@@ -889,17 +1132,22 @@ 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>`;
|
||||
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); loadScenes(); });
|
||||
document.querySelectorAll("[data-vtoggle]").forEach(b => b.onclick = () => { versionsOpen(b.dataset.vtoggle, !versionsOpen(b.dataset.vtoggle)); loadScenes(); });
|
||||
fillVersionLists();
|
||||
document.querySelectorAll("[data-scale]").forEach(b => b.onclick = () => openScale(b.dataset.scale));
|
||||
document.querySelectorAll("[data-versions]").forEach(b => b.onclick = () => openVersions(b.dataset.versions));
|
||||
document.querySelectorAll("[data-gedit]").forEach(b => b.onclick = () => openGroupDlg(S.groups.find(g => g.slug === b.dataset.gedit)));
|
||||
document.querySelectorAll("[data-gdelete]").forEach(b => b.onclick = () => deleteGroup(S.groups.find(g => g.slug === b.dataset.gdelete)));
|
||||
document.querySelectorAll("[data-combine]").forEach(b => b.onclick = () => openCombine(S.groups.find(g => g.slug === b.dataset.combine)));
|
||||
document.querySelectorAll("[data-gplan]").forEach(b => b.onclick = () => {
|
||||
const g = S.groups.find(x => x.slug === b.dataset.gplan);
|
||||
openPlan(g.members.filter(m => bySlug[m] && bySlug[m].ply), g.name);
|
||||
openPlan((g.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});
|
||||
@@ -960,15 +1208,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();
|
||||
};
|
||||
@@ -989,7 +1251,14 @@ function openCombine(g) {
|
||||
radios("#cmbAlign", "cmb.align", ["frame", "images", "shift", "rigid"], o.align || "images");
|
||||
radios("#cmbPart", "cmb.part", ["view", "nearest"], o.partition || "view");
|
||||
$("#cmbTitle").textContent = tr("cmb.title", {name: g.name});
|
||||
$("#cmbSize").value = String(o.frame_splats || 3500000);
|
||||
$("#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>`;
|
||||
$("#cmbSize").value = known ? String(last) : "custom";
|
||||
$("#cmbSizeCustom").value = known ? "" : last / 1e6;
|
||||
const showCustom = () => ["#cmbSizeCustom", "#cmbSizeUnit"].forEach(id => $(id).classList.toggle("hidden", $("#cmbSize").value !== "custom"));
|
||||
$("#cmbSize").onchange = showCustom; showCustom();
|
||||
// memory, measured on six rooms: reduce ~0.45 GB per million splats, full SOG ~0.65 GB per million
|
||||
const total = (g.splats || 0) * 0.85 / 1e6, needFrame = Math.max(1.5, 0.45 * total), needFull = Math.max(needFrame, 0.65 * total);
|
||||
const avail = S.memGB || 0, lowFull = avail && needFull > avail;
|
||||
@@ -1003,7 +1272,7 @@ function openCombine(g) {
|
||||
$("#cmbCancel").onclick = () => $("#combineDlg").close();
|
||||
$("#cmbStart").onclick = async () => {
|
||||
const r = await api("/api/combine", {slug: g.slug, align: $("input[name='cmb.align']:checked").value,
|
||||
partition: $("input[name='cmb.part']:checked").value, frame_splats: $("#cmbSize").value,
|
||||
partition: $("input[name='cmb.part']:checked").value, frame_splats: $("#cmbSize").value === "custom" ? String(Math.round(parseFloat($("#cmbSizeCustom").value) * 1e6)) : $("#cmbSize").value,
|
||||
full: $("#cmbFull").checked, upload: S.frameConfigured && $("#cmbUpload").checked});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#combineDlg").close(); toast(tr("cmb.started")); await poll(); loadScenes();
|
||||
@@ -1011,6 +1280,246 @@ function openCombine(g) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- what is on the Frame ---------- */
|
||||
function setSceneTab(tab) {
|
||||
S.stab = tab;
|
||||
document.querySelectorAll(".tab[data-stab]").forEach(x => x.classList.toggle("active", x.dataset.stab === tab));
|
||||
$("#pcView").classList.toggle("hidden", tab !== "pc");
|
||||
$("#frameView").classList.toggle("hidden", tab !== "frame");
|
||||
$("#frameRefresh").classList.toggle("hidden", tab !== "frame");
|
||||
$("#groupNewBtn").classList.toggle("invisible", tab !== "pc");
|
||||
if (tab === "frame") loadFrameGallery();
|
||||
}
|
||||
document.querySelectorAll(".tab[data-stab]").forEach(t => t.onclick = () => setSceneTab(t.dataset.stab));
|
||||
$("#frameRefresh").onclick = () => loadFrameGallery(true);
|
||||
$("#frameMoveBtn").onclick = async () => {
|
||||
const res = await api("/api/frame/move", {});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
toast(tr("fg.moved")); loadFrameGallery(true); loadScenes();
|
||||
};
|
||||
async function loadFrameGallery(refresh) {
|
||||
const grid = $("#frameGrid");
|
||||
if (!grid.children.length) grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("fg.loading")}</div>`;
|
||||
const r = await api("/api/frame/gallery" + (refresh ? "?refresh=1" : ""));
|
||||
if (!r.configured) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("frame.none")}</div>`; return; }
|
||||
if (!r.online) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("frame.unreachable")}</div>`; return; }
|
||||
const list = (r.scenes || []).filter(x => x && x.folder);
|
||||
S.frameGallery = list;
|
||||
const old = list.filter(x => x.place === "steam" && x.kind !== "other").length;
|
||||
$("#frameMove").classList.toggle("hidden", !old);
|
||||
$("#frameMoveText").textContent = tr("fg.move.hint", {n: old}) + " ";
|
||||
grid.innerHTML = list.length ? list.map(x => `
|
||||
<div class="rec scene" style="cursor:default">
|
||||
<div class="thumb" style="background-image:url('/api/frame/preview?folder=${encodeURIComponent(x.folder).replace(/'/g, "%27")}&place=${x.place}&s=${x.size}')"></div>
|
||||
<div class="meta"><b>${esc(x.name)}</b>
|
||||
<span>${tr("fg.kind." + x.kind)} · ${mb(x.size)}</span>
|
||||
<span class="faint" style="overflow-wrap:anywhere" title="${tr("fg.place." + x.place + ".title")}">${tr("fg.place." + x.place)} · ${esc(x.folder)}</span>
|
||||
<div class="acts"><button class="small" data-fgdel="${esc(x.place + "/" + x.folder)}" title="${tr("fg.remove.title")}">🗑 ${tr("fg.remove")}</button></div>
|
||||
</div></div>`).join("") : `<div class="empty" style="grid-column:1/-1">${tr("fg.empty")}</div>`;
|
||||
grid.querySelectorAll("[data-fgdel]").forEach(b => b.onclick = async () => {
|
||||
const x = S.frameGallery.find(y => y.place + "/" + y.folder === b.dataset.fgdel);
|
||||
if (!confirm(tr(x.kind === "other" ? "fg.confirm.other" : "fg.confirm", {name: x.name, size: mb(x.size)}))) return;
|
||||
const res = await api("/api/frame/remove", {folder: x.folder, place: x.place});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
toast(tr("fg.removed", {name: x.name})); loadFrameGallery(true); loadScenes();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- versions ---------- */
|
||||
// Frame sizes as measured on a Steam Frame (Gaussian XR, 10/2026): 1 M is nearly as fast as 0.75 M and clearly sharper
|
||||
const FRAME_SIZES = [[0.75, 36], [1, 35], [1.5, 29], [2, 24]], FRAME_RECOMMENDED = 1; // million splats, fps
|
||||
const CLEANUPS = ["ceiling", "floor", "haze", "outside"]; // scripts/clean.py
|
||||
function sizeText(m) { return m === "all" ? tr("ver.all") : tr("million", {n: num(m, Math.round(m * 100) % 10 ? 2 : m % 1 ? 1 : 0)}); }
|
||||
function versionKey(splats, scale, clean) { // as version_key() in server.py
|
||||
let key = splats === "all" ? "all" : `${+(splats / 1e6).toPrecision(6)}m`;
|
||||
if (scale !== 1) key += `-1x${scale}`;
|
||||
const parts = CLEANUPS.filter(k => clean[k]).map(k => k[0] + Math.round(clean[k] * 100));
|
||||
return parts.length ? key + "-" + parts.join("") : key;
|
||||
}
|
||||
function cleanText(clean) {
|
||||
return CLEANUPS.filter(k => clean && clean[k]).map(k => `${tr("ver.clean." + k)} ${Math.round(clean[k] * 100)} %`).join(" · ");
|
||||
}
|
||||
function versionRow(v) {
|
||||
const what = (v.purpose === "pc" ? tr("ver.all") : tr("million", {n: num(v.splats / 1e6, 2)})) + (v.scale !== 1 ? " · " + ratioText(v.scale) : "");
|
||||
const slow = v.purpose === "pc" && v.splats > 2.5e6;
|
||||
return `<div class="vrow">
|
||||
<div class="vwhat"><b>${v.purpose === "pc" ? "🖥️" : "🥽"} ${esc(what)}</b>
|
||||
<span class="muted">${cleanText(v.clean) || tr("ver.clean.none")}</span>
|
||||
<span class="faint">${tr("million", {n: num(v.splats / 1e6, 2)})} · ${mb(v.size)}${v.stale ? " · " + tr("ver.stale") : ""}</span></div>
|
||||
<div class="vacts">${v.on_frame === null && !(v.upload && (v.upload.running || v.upload.error)) ? "" : frameState({upload: v.upload, on_frame: v.on_frame})}
|
||||
${v.ply ? `<button class="small pc-only" data-vspirula="${esc(v.key)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
|
||||
${S.frameConfigured ? `<button class="small" data-vup="${esc(v.key)}" ${v.upload && v.upload.running ? "disabled" : ""} ${slow ? `title="${tr("ver.slow", {n: num(v.splats / 1e6, 1)})}"` : ""}>${tr(v.on_frame ? "sc.reupload" : "sc.toFrame")}${slow ? " ⚠️" : ""}</button>` : ""}
|
||||
${v.on_frame ? `<button class="small" data-vunframe="${esc(v.key)}" title="${tr("ver.unframe.title")}">${tr("ver.unframe")}</button>` : ""}
|
||||
<button class="small" data-vtemplate="${esc(v.key)}" title="${tr("ver.template.title")}">${tr("ver.template")}</button>
|
||||
<button class="small" data-vdel="${esc(v.key)}" title="${tr("ver.delete")}" aria-label="${tr("ver.delete")}">${tr("sc.delete")}</button></div>
|
||||
</div>`;
|
||||
}
|
||||
function bindVersionRows(box, slug, list, redraw) {
|
||||
const find = key => list.find(x => x.key === key);
|
||||
box.querySelectorAll("[data-vspirula]").forEach(b => b.onclick = async () => {
|
||||
const res = await api("/api/open", {slug, version: b.dataset.vspirula, what: "spirula"});
|
||||
toast(res.ok === false ? trErr(res.error) : tr("sc.spirula.starting"));
|
||||
});
|
||||
box.querySelectorAll("[data-vup]").forEach(b => b.onclick = async () => {
|
||||
const res = await api("/api/upload", {slug, version: b.dataset.vup});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
toast(tr("sc.uploadStarted")); setTimeout(redraw, 1500);
|
||||
});
|
||||
box.querySelectorAll("[data-vunframe]").forEach(b => b.onclick = async () => {
|
||||
const v = find(b.dataset.vunframe);
|
||||
if (!confirm(tr("ver.unframe.confirm", {name: v.label}))) return;
|
||||
const res = await api("/api/frame/remove", {slug, version: v.key});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
toast(tr("ver.unframed", {name: v.label})); redraw();
|
||||
});
|
||||
box.querySelectorAll("[data-vtemplate]").forEach(b => b.onclick = async () => {
|
||||
if (!$("#verDlg").open) await openVersions(slug);
|
||||
useTemplate(find(b.dataset.vtemplate));
|
||||
});
|
||||
box.querySelectorAll("[data-vdel]").forEach(b => b.onclick = async () => {
|
||||
const v = find(b.dataset.vdel);
|
||||
if (!confirm(tr("ver.confirm", {name: v.label, size: mb(v.size)}))) return;
|
||||
const res = await api("/api/version/delete", {slug, version: v.key});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
redraw(); loadScenes();
|
||||
});
|
||||
}
|
||||
function verForm() { // what the form would make
|
||||
const pc = document.querySelector('input[name="verFor"]:checked').value === "pc";
|
||||
let size = "all";
|
||||
if (!pc) {
|
||||
const pick = document.querySelector('input[name="verSize"]:checked');
|
||||
size = pick.value === "custom" ? Math.round(parseFloat($("#verCustom").value) * 1e6) || 0 : Math.round(parseFloat(pick.value) * 1e6);
|
||||
}
|
||||
const sc = document.querySelector('input[name="verScale"]:checked');
|
||||
const clean = {};
|
||||
document.querySelectorAll("#verClean input").forEach(i => { if (+i.value) clean[i.dataset.clean] = i.value / 100; });
|
||||
const any = Object.keys(clean).length > 0;
|
||||
return {pc, size, scale: sc ? parseFloat(sc.value) : 1, clean, plain: any && $("#verPlain").checked, any};
|
||||
}
|
||||
function verPreview() {
|
||||
const f = verForm(), V = S.verList || [], made = [];
|
||||
$("#verPlainBox").classList.toggle("hidden", !f.any);
|
||||
$("#verSizes").classList.toggle("hidden", f.pc); $("#verSizeHead").classList.toggle("hidden", f.pc);
|
||||
$("#verSizes").querySelectorAll("label.opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked));
|
||||
if (!f.pc && !(f.size >= 10000)) { $("#verPreview").textContent = tr("ver.pick"); $("#verMake").disabled = true; return; }
|
||||
for (const cl of f.plain ? [f.clean, {}] : [f.clean]) {
|
||||
const key = versionKey(f.size, f.scale, cl), old = V.find(v => v.key === key);
|
||||
const text = (f.pc ? tr("ver.all") : sizeText(f.size / 1e6)) + (f.scale !== 1 ? " · " + ratioText(f.scale) : "") + " · " + (cleanText(cl) || tr("ver.clean.none"));
|
||||
made.push(old ? tr("ver.replaces", {what: text}) : text);
|
||||
}
|
||||
const same = !f.any && f.pc && S.verScene.hasAll; // all splats, not cleaned: the scene itself
|
||||
$("#verPreview").innerHTML = same ? esc(tr("ver.same.all"))
|
||||
: esc(tr(made.length > 1 ? "ver.makes.n" : "ver.makes.1", {n: made.length})) + made.map(x => "<br>• " + esc(x)).join("");
|
||||
$("#verMake").disabled = same;
|
||||
$("#verMake").textContent = tr(made.length > 1 ? "ver.make.n" : "ver.make.1", {n: made.length});
|
||||
}
|
||||
function useTemplate(v) {
|
||||
if (!v) return;
|
||||
document.querySelector(`input[name="verFor"][value="${v.purpose}"]`).checked = true;
|
||||
if (v.purpose === "frame") {
|
||||
const m = v.splats / 1e6, known = FRAME_SIZES.find(([x]) => Math.abs(x - m) < 0.01);
|
||||
document.querySelector(`input[name="verSize"][value="${known ? known[0] : "custom"}"]`).checked = true;
|
||||
if (!known) $("#verCustom").value = num(m, 2).replace(",", ".");
|
||||
}
|
||||
const sc = document.querySelector(`input[name="verScale"][value="${v.scale}"]`);
|
||||
if (sc) sc.checked = true;
|
||||
document.querySelectorAll("#verClean input").forEach(i => { i.value = Math.round((v.clean[i.dataset.clean] || 0) * 100); i.oninput(); });
|
||||
verPreview();
|
||||
toast(tr("ver.template.done"));
|
||||
}
|
||||
async function openVersions(slug) {
|
||||
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
|
||||
if (!x) return;
|
||||
const total = (g ? g.result && g.result.splats : x.gaussians) || 0, scale = x.scale || 1;
|
||||
S.verScene = {slug, hasAll: true};
|
||||
$("#verTitle").textContent = tr("ver.title", {name: x.name, n: tr("million", {n: num(total / 1e6, 2)})});
|
||||
$("#verSizes").innerHTML = FRAME_SIZES.map(([m, fps]) => `<label class="opt${m === FRAME_RECOMMENDED ? " active" : ""}">
|
||||
<input type="radio" name="verSize" value="${m}" ${m === FRAME_RECOMMENDED ? "checked" : ""}>
|
||||
<div><b>${sizeText(m)}${m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""}</b><span>${tr("ver.fps." + String(m).replace(".", "_"), {fps})}</span></div></label>`).join("")
|
||||
+ `<label class="opt"><input type="radio" name="verSize" value="custom"><div><b>${tr("ver.custom")}</b>
|
||||
<span><input id="verCustom" type="number" min="0.1" max="50" step="0.05" style="width:80px"> ${tr("ver.million")}</span></div></label>`;
|
||||
$("#verClean").innerHTML = `<p class="faint" style="font-size:12px;margin:0 0 6px">${tr("ver.clean.hint")}</p>` + CLEANUPS.map(k => `
|
||||
<label class="slide" title="${esc(tr("ver.clean." + k + ".title"))}"><span>${tr("ver.clean." + k)}</span>
|
||||
<input type="range" min="0" max="100" step="10" value="0" data-clean="${k}"><output>${tr("ver.clean.off")}</output></label>`).join("");
|
||||
$("#verClean").querySelectorAll("input").forEach(i => i.oninput = () => {
|
||||
i.nextElementSibling.textContent = +i.value ? i.value + " %" : tr("ver.clean.off"); verPreview(); });
|
||||
$("#verPlain").checked = false;
|
||||
$("#verScaleHead").classList.toggle("hidden", scale === 1);
|
||||
$("#verScales").classList.toggle("hidden", scale === 1);
|
||||
$("#verScales").innerHTML = scale === 1 ? "" : `<label class="chk"><input type="radio" name="verScale" value="1" checked> ${tr("scale.original")}</label>`
|
||||
+ `<label class="chk"><input type="radio" name="verScale" value="${scale}"> ${tr("ver.walk", {ratio: ratioText(scale)})}</label>`;
|
||||
$("#verDlg").querySelectorAll('input[type="radio"], #verPlain, #verCustom').forEach(i => { i.onchange = verPreview; i.oninput = verPreview; });
|
||||
$("#verCustom").onfocus = () => { document.querySelector('input[name="verSize"][value="custom"]').checked = true; verPreview(); };
|
||||
await drawVersions(slug);
|
||||
verPreview();
|
||||
if (!$("#verDlg").open) $("#verDlg").showModal();
|
||||
$("#verClose").onclick = () => $("#verDlg").close();
|
||||
$("#verMake").onclick = async () => {
|
||||
const f = verForm();
|
||||
const clean = Object.entries(f.clean).map(([k, v]) => `${k}=${v}`).join(",");
|
||||
const r = await api("/api/versions", {slug, sizes: String(f.size), scales: String(f.scale), clean, also_plain: f.plain});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#verDlg").close(); toast(tr("ver.started")); await poll();
|
||||
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
};
|
||||
}
|
||||
async function drawVersions(slug) {
|
||||
const r = await api("/api/versions?slug=" + encodeURIComponent(slug));
|
||||
const list = S.verList = r.versions || [];
|
||||
$("#verList").innerHTML = list.length ? list.map(versionRow).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
|
||||
bindVersionRows($("#verList"), slug, list, () => drawVersions(slug).then(verPreview));
|
||||
}
|
||||
async function fillVersionLists() { // the opened lists under scenes and groups
|
||||
for (const box of document.querySelectorAll("[data-vlist]")) {
|
||||
const slug = box.dataset.vlist, r = await api("/api/versions?slug=" + encodeURIComponent(slug)), list = r.versions || [];
|
||||
box.innerHTML = list.map(versionRow).join("") || `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
|
||||
bindVersionRows(box, slug, list, fillVersionLists);
|
||||
}
|
||||
}
|
||||
function versionsOpen(slug, set) { return groupOpen("ver:" + slug, set); }
|
||||
function versionsToggle(slug, n) {
|
||||
if (!n) return `<button class="small link" data-versions="${esc(slug)}" title="${tr("ver.button.title")}">${tr("ver.first")}</button>`;
|
||||
const open = versionsOpen(slug);
|
||||
return `<button class="small link" data-vtoggle="${esc(slug)}" aria-expanded="${open}" title="${tr("ver.button.title")}">${tr(open ? "ver.hide" : n === 1 ? "ver.show.1" : "ver.show", {n})}</button>`;
|
||||
}
|
||||
|
||||
/* ---------- scale (models) ---------- */
|
||||
const GAUGES = [[1, "scale.original"], [220, "Z"], [160, "N"], [120, "TT"], [87, "H0"], [64, "S"], [45, "0"], [22.5, "G"]];
|
||||
function ratioText(r) { return "1:" + num(r, r % 1 ? 1 : 0); }
|
||||
function scaleLine(x) {
|
||||
if (!x.scale || x.scale === 1) return "";
|
||||
return `<span class="faint">${tr("scale.line", {ratio: ratioText(x.scale)})}${x.scale_xr ? " · " + tr("scale.line.xr") : ""}</span>`;
|
||||
}
|
||||
function openScale(slug) {
|
||||
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
|
||||
if (!x) return;
|
||||
const cur = x.scale || 1, known = GAUGES.some(([r]) => r === cur);
|
||||
$("#scaleTitle").textContent = tr("scale.title", {name: x.name});
|
||||
$("#scaleOpts").innerHTML = GAUGES.map(([r, label]) => `<label class="opt${r === cur ? " active" : ""}">
|
||||
<input type="radio" name="scaleR" value="${r}" ${r === cur ? "checked" : ""}><div><b>${r === 1 ? tr(label) : label} · ${ratioText(r)}</b></div></label>`).join("")
|
||||
+ `<label class="opt${known ? "" : " active"}"><input type="radio" name="scaleR" value="custom" ${known ? "" : "checked"}><div><b>${tr("scale.custom")}</b></div></label>`;
|
||||
$("#scaleCustom").value = known ? "" : cur;
|
||||
const value = () => { const v = $("input[name=scaleR]:checked").value; return v === "custom" ? parseFloat($("#scaleCustom").value) : parseFloat(v); };
|
||||
const sync = () => { $("#scaleOpts").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked));
|
||||
const one = value() === 1; $("#scaleXr").disabled = one; if (one) $("#scaleXr").checked = false; };
|
||||
$("#scaleOpts").querySelectorAll("input").forEach(i => i.onchange = sync);
|
||||
$("#scaleCustom").oninput = () => { $("input[name=scaleR][value=custom]").checked = true; sync(); };
|
||||
$("#scaleXr").checked = !!x.scale_xr;
|
||||
const onFrame = g ? g.result && g.result.on_frame : x.on_frame;
|
||||
$("#scaleMembersRow").classList.toggle("hidden", !g);
|
||||
$("#scaleFrameRow").classList.toggle("hidden", !(S.frameConfigured && onFrame));
|
||||
sync();
|
||||
$("#scaleDlg").showModal();
|
||||
$("#scaleCancel").onclick = () => $("#scaleDlg").close();
|
||||
$("#scaleOk").onclick = async () => {
|
||||
const r = await api("/api/scale", {slug, scale: String(value()), xr: $("#scaleXr").checked, with_scenes: !!g && $("#scaleMembers").checked,
|
||||
update_frame: !$("#scaleFrameRow").classList.contains("hidden") && $("#scaleFrame").checked});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#scaleDlg").close(); toast(tr("scale.done")); loadScenes();
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- renaming ---------- */
|
||||
function askName(title, hint, value, frameOption) {
|
||||
$("#renTitle").textContent = title; $("#renHint").textContent = hint;
|
||||
@@ -1052,7 +1561,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};
|
||||
}
|
||||
@@ -1084,11 +1593,12 @@ const P = {slugs: [], name: "", style: "pretty"};
|
||||
function planUrl(download) {
|
||||
const q = new URLSearchParams({slugs: P.slugs.join(","), style: P.style, lang: LANG,
|
||||
title: P.slugs.length > 1 ? P.name : ""});
|
||||
if (P.group) q.set("group", P.group);
|
||||
if (download) { q.set("download", "1"); q.set("name", `${P.name}-${tr("plan.file")}`); }
|
||||
return "/api/floorplan?" + q;
|
||||
}
|
||||
function openPlan(slugs, name) {
|
||||
P.slugs = slugs; P.name = name;
|
||||
function openPlan(slugs, name, group) {
|
||||
P.slugs = slugs; P.name = name; P.group = group || "";
|
||||
$("#planTitle").textContent = tr(slugs.length > 1 ? "plan.title.all" : "plan.title", {name});
|
||||
$("#planStyle").classList.remove("hidden"); $("#planSave").classList.remove("hidden"); $("#planMeasure").classList.remove("hidden");
|
||||
if (!$("#planDlg").open) $("#planDlg").showModal();
|
||||
@@ -1182,12 +1692,23 @@ async function poll() {
|
||||
$("#offline").classList.toggle("hidden", !offline);
|
||||
document.body.classList.toggle("offline", offline);
|
||||
if (offline) return;
|
||||
S.job = r.job; S.dl = r.download;
|
||||
if (S.dl && S.dl.finished && S.dlWait && S.sel && S.dl.name === S.sel.name) {
|
||||
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.standby = r.standby;
|
||||
renderDlBar();
|
||||
const nowFetching = !!(S.dl && S.dl.busy);
|
||||
if (wasFetching && (!nowFetching || (S.dl.batch && S.dl.batch.done_files !== doneFiles))) {
|
||||
await loadRecs();
|
||||
if (!nowFetching && S.dl && S.dl.batch) {
|
||||
const b = S.dl.batch, failed = (b.failed || []).length;
|
||||
toast(b.cancelled ? tr("dl.cancelled") : failed ? tr("dl.failed", {n: failed}) : tr("dl.done", {n: b.done_files}));
|
||||
}
|
||||
}
|
||||
// The chosen recording was fetched (also as part of several – the next one may already be on its way)
|
||||
if (S.dlWait && S.sel && !queued(S.sel.name)) {
|
||||
const res = S.dlWait; S.dlWait = null;
|
||||
if (S.dl.error) { toast(tr("err.generic") + ": " + S.dl.error); renderCheck(); res(false); }
|
||||
else { await loadRecs(); const f = S.recs.local.find(x => x.name === S.sel.name);
|
||||
if (f) { S.sel.path = f.path; await runProbe(f.path); } res(true); }
|
||||
await loadRecs(); const f = S.recs.local.find(x => x.name === S.sel.name);
|
||||
if (f) { S.sel.path = f.path; await runProbe(f.path); res(true); }
|
||||
else { toast(tr("err.generic") + ": " + ((S.dl && S.dl.name === S.sel.name && S.dl.error) || tr("dl.failed", {n: 1}))); renderCheck(); res(false); }
|
||||
} else if (S.dl && !S.dl.finished && S.sel && S.dl.name === S.sel.name) renderCheck();
|
||||
renderJob(); updateStart();
|
||||
const running = !!(S.job && S.job.running);
|
||||
|
||||
Reference in new issue
Block a user