mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 11:01:32 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b4cd431a | ||
|
|
bfe3939754 | ||
|
|
68d88e9a62 | ||
|
|
c6aa71d7ed | ||
|
|
887406dba4 | ||
|
|
5baa4b532f | ||
|
|
8ebc020e7f | ||
|
|
6ba8f71c5b |
No files matched your search
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes to Splat Workshop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.6.1] – 2026-10-17
|
||||
|
||||
### Changed
|
||||
|
||||
- **Which way is up – pick the picture**: the import dialog shows a small picture for each choice, the scene as it would stand on the Frame seen from its start, so you simply pick the one standing upright. Our guess is preselected and marked (the vertical is the smaller of the scene's Y and Z extents).
|
||||
- The choices say what you see: *Upright in Blender (Z up)*, *Like the Workshop's and the Frame's scenes (Y down)*, *Upside down (Y up)*.
|
||||
- The pictures take a second or two (a few more for `.sog`, `.spz` and the like, which are read through splat-transform) and are kept, so picking the same file again is instant.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scenes made from photos in Spirula Studio lay on their side**: 0.6.0 said Spirula Studio files have Y down and preselected that. That is only true for the Frame's own scenes, which are aligned to its tracking; a scene Spirula Studio makes from photos alone (`spirula sfm auto`) has Z up – checked with a scene made from 70 photos. The dialog now says so, and the guess picks Z up for such files.
|
||||
|
||||
## [0.6.0] – 2026-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Import your own splats**: *+ Import a splat …* above the finished scenes turns a splat file from another program into a scene like the others – to copy it to the Frame, make versions or a mesh. Reads `.ply`, `.sog`, `.spz`, `.splat` and `.ksplat` (via splat-transform); the file itself stays where it is. (Asked for in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
- **Which way is up**: Y down as Spirula Studio, COLMAP and the Frame's own scenes have it (the usual case), Y up or Z up for files from other tools.
|
||||
- **Size**: a factor, e.g. 0.01 for a file in centimetres – a reconstruction from photos has no real scale of its own.
|
||||
- The preview in the Frame's gallery comes from the top view, as for tours.
|
||||
- Only from this PC (the file dialog opens here). Also on the command line: `scripts/import.sh`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The page did not update any more (0.5.1)**: the new footer stood below the page's script; setting up its *Check for updates* link failed, and the rest of the script – among it the regular status updates – never ran. A finished job stayed on "Converting …" with its progress bar until the page was reloaded. A test now checks that everything the script sets up on load is already in the page.
|
||||
|
||||
## [0.5.1] – 2026-10-17
|
||||
|
||||
### Added
|
||||
@@ -182,6 +208,8 @@ First release.
|
||||
- **Floor plans** (experimental) in two styles with a measuring tool.
|
||||
- Configurable video player; English and German interface.
|
||||
|
||||
[0.6.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.1...v0.6.0
|
||||
[0.5.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.2...v0.5.0
|
||||
[0.4.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.1...v0.4.2
|
||||
|
||||
@@ -22,6 +22,13 @@ Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork
|
||||
4. **In die Galerie**: Vorschaubild und Namensschild im Stil der Beispielszenen, kopiert in den Ordner für eigene Szenen von Gaussian XR auf der Frame (`Documents/GaussianXR`).
|
||||
5. **Die Wohnung zusammensetzen**: Räume gruppieren – ein Raum darf aus mehreren Aufnahmen bestehen – und zu einem Rundgang machen, durch den du gehst, voll für den PC, kleiner für die Frame.
|
||||
|
||||
## Neu in 0.6
|
||||
|
||||
- 📥 **Eigene Splats importieren** – eine Splat-Datei aus einem anderen Programm (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) wird zu einer Szene wie die anderen: auf die Frame bringen, Fassungen oder ein Mesh machen. Du wählst das Bild, in dem sie aufrecht steht – das wahrscheinliche ist vorausgewählt – und bei Bedarf die Größe.
|
||||
|
||||
<img src="docs/import.png" width="420" alt="Der Import-Dialog: Datei wählen, benennen, das Bild wählen, in dem die Szene aufrecht steht, und die Größe">
|
||||
<br><sub>Englische Oberfläche.</sub>
|
||||
|
||||
## Neu in 0.5
|
||||
|
||||
- 🧊 **Mesh-Export** – *Mesh …* macht aus einer Szene oder einem Rundgang eine Oberfläche für Blender und andere 3D-Programme (GLB, OBJ, PLY) oder für den 3D-Druck (STL). Sie steht aufrecht mit dem Boden auf Höhe 0, statt auf der Seite zu liegen – für jedes Format per Import in Blender geprüft; mit Textur, wenn die Trainingsbilder noch da sind. Die Dateien lassen sich herunterladen – auch von anderen Geräten im Heimnetz – oder mit einem Klick in Blender öffnen. (Angeregt in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
|
||||
@@ -24,6 +24,12 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
|
||||
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's folder for personal scenes on the Frame (`Documents/GaussianXR`).
|
||||
5. **Put your home together**: group the rooms – a room may be several recordings – and turn them into one tour you walk through, full size for the PC, smaller for the Frame.
|
||||
|
||||
## New in 0.6
|
||||
|
||||
- 📥 **Import your own splats** – a splat file from another program (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) becomes a scene like the others: copy it to the Frame, make versions or a mesh. Pick the picture in which it stands upright – the likely one is preselected – and, if needed, the size.
|
||||
|
||||
<img src="docs/import.png" width="420" alt="The import dialog: choose a file, name it, pick the picture in which the scene stands upright, and the size">
|
||||
|
||||
## New in 0.5
|
||||
|
||||
- 🧊 **Mesh export** – *Mesh …* turns a scene or tour into a surface for Blender and other 3D programs (GLB, OBJ, PLY) or for 3D printing (STL). It stands upright with the floor at height 0 instead of lying on its side – checked by importing every format in Blender; with a texture when the training images are still there. Download the files – also from other devices at home – or open the mesh in Blender with one click. (Suggested in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# A splat file from another program as a scene (0.6) – then copied to the Frame, versions, mesh … like any
|
||||
# other scene.
|
||||
#
|
||||
# Usage: scripts/import.sh <splat file> <run folder> "<display name>" [up] [scale]
|
||||
# <splat file> .ply .compressed.ply .sog .spz .splat .ksplat (whatever splat-transform reads)
|
||||
# up where "up" is in the file: y-down (COLMAP, the Workshop's own scenes – default), y-up,
|
||||
# z-up (Blender, Spirula Studio from photos alone)
|
||||
# scale factor for the size, e.g. 0.01 if the file is in centimetres (default 1)
|
||||
# Writes <run folder>/ with spirula/scene.ply, scene.sog, scene.xrSceneTransform.json, a preview from the
|
||||
# top view and a name plate. Lines starting with "@@" are progress markers.
|
||||
set -euo pipefail
|
||||
# Everything in one block: bash reads it completely before it starts, so updating this file
|
||||
# (git pull, editing) does not affect a run that is already going.
|
||||
{
|
||||
|
||||
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
ROOT=$(dirname "$HERE")
|
||||
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
|
||||
|
||||
src=$(realpath "${1:?Give a splat file}")
|
||||
out=${2:?Give the run folder}
|
||||
name=${3:?Give a name}
|
||||
up=${4:-y-down}
|
||||
scale=${5:-1}
|
||||
[ -f "$src" ] || { echo "No such file: $src" >&2; exit 1; }
|
||||
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found" >&2; exit 1; }
|
||||
[ -e "$out" ] && { echo "Already there: $out" >&2; exit 1; }
|
||||
case "$up" in
|
||||
y-down) turn=() ;;
|
||||
y-up) turn=(-r 180,0,0) ;; # +Y becomes -Y (checked: splat-transform -r 180,0,0)
|
||||
z-up) turn=(-r -90,0,0) ;; # +Z becomes -Y (splat-transform -r 90,0,0 turns +Z into +Y)
|
||||
*) echo "up must be y-down, y-up or z-up" >&2; exit 1 ;;
|
||||
esac
|
||||
[[ "$scale" =~ ^[0-9]+(\.[0-9]+)?$ ]] && awk -v s="$scale" 'BEGIN { exit !(s > 0) }' \
|
||||
|| { echo "scale must be a number above 0" >&2; exit 1; }
|
||||
size=()
|
||||
[ "$scale" != 1 ] && size=(-s "$scale")
|
||||
slug=$(basename "$out")
|
||||
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
|
||||
|
||||
tmp=$out.tmp
|
||||
rm -rf "$tmp"
|
||||
mkdir -p "$tmp/spirula"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
echo "@@ vstep 1 2 convert"
|
||||
echo "Importing $(basename "$src") as \"$name\" (up: $up, scale: $scale) ..."
|
||||
"$SPLAT_TRANSFORM" -w "$src" "${turn[@]}" "${size[@]}" -N "$tmp/spirula/scene.ply"
|
||||
"$SPLAT_TRANSFORM" -w "$tmp/spirula/scene.ply" "$tmp/scene.sog"
|
||||
# (the same line the pipeline writes: the scene list reads the number of splats from it)
|
||||
python3 -c 'import sys
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
n = next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex"))
|
||||
print(f"spirula: {n:,} Gaussians in {sys.argv[1]}")' "$tmp/spirula/scene.ply"
|
||||
# Gaussian XR starts at the scene's origin, as it is in the file
|
||||
cat > "$tmp/scene.xrSceneTransform.json" <<'JSON'
|
||||
{"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], "worldScale": 1.0, "worldScaleOffset": [0, 0, 0]}
|
||||
JSON
|
||||
|
||||
echo "@@ vstep 2 2 preview"
|
||||
# Preview for the gallery: the top view as for the floor plan; if that finds no room, plain colour
|
||||
if uv run -q "$HERE/floorplan.py" "$tmp/plan.png" "$tmp=$name" >/dev/null 2>&1; then
|
||||
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp" "$name" "$stem"
|
||||
else
|
||||
uv run -q --no-project --with pillow python3 -c 'import sys
|
||||
from PIL import Image
|
||||
Image.new("RGBA", (512, 512), (40, 46, 60, 255)).save(sys.argv[1])' "$tmp/$stem.gallery.png"
|
||||
uv run -q "$HERE/make_gallery.py" --label "$tmp/$stem.sceneinfo.png" "$name"
|
||||
fi
|
||||
mv "$tmp" "$out"
|
||||
trap - EXIT
|
||||
echo "Gaussian XR initial view: (imported scene) Display rotation 0°"
|
||||
echo "@@ done local"
|
||||
echo "Done: $out"
|
||||
exit
|
||||
}
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["numpy", "pillow"]
|
||||
# ///
|
||||
"""Small pictures of a splat file for the import dialog (0.6.1): one per choice of "up", as the scene would
|
||||
stand on the Frame, so the upright one can simply be picked – plus a guess which one it is.
|
||||
|
||||
Usage: python3 import_preview.py <splat file> <out dir> [splat-transform]
|
||||
Writes <out dir>/y-down.png, y-up.png, z-up.png and prints {"guess": "..."} as JSON.
|
||||
Files other than a plain PLY (.sog .spz .splat .ksplat .compressed.ply) are turned into one first with
|
||||
splat-transform.
|
||||
|
||||
Each view is a small picture as from the Frame at the scene's start, looking level, with Y pointing down as in
|
||||
our scenes. The turns are the ones scripts/import.sh makes.
|
||||
The guess (see guess()) only preselects one: the pictures decide.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
TURNS = { # file -> our scene (Y down), as scripts/import.sh
|
||||
"y-down": np.eye(3),
|
||||
"y-up": np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]], dtype=np.float64), # 180° about X
|
||||
"z-up": np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]], dtype=np.float64), # +Z becomes -Y
|
||||
}
|
||||
W, H = 320, 200
|
||||
MAX_POINTS = 2_000_000
|
||||
FOV = 100 # degrees across, about what the Frame shows
|
||||
SH_C0 = 0.28209479177387814
|
||||
BG = (24, 27, 34)
|
||||
|
||||
|
||||
def plain_ply(path: Path) -> bool:
|
||||
"""A PLY with one float per property and the usual splat columns (what load() reads)."""
|
||||
if path.suffix.lower() != ".ply":
|
||||
return False
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(64 * 1024)
|
||||
if b"end_header" not in head:
|
||||
return False
|
||||
lines = head.split(b"end_header")[0].decode(errors="replace").splitlines()
|
||||
props = [ln.split() for ln in lines if ln.startswith("property")]
|
||||
names = {p[-1] for p in props}
|
||||
return (sum(ln.startswith("element") for ln in lines) == 1 and all(p[1] == "float" for p in props)
|
||||
and {"x", "y", "z", "opacity", "f_dc_0", "f_dc_1", "f_dc_2"} <= names
|
||||
and "format binary_little_endian 1.0" in lines)
|
||||
|
||||
|
||||
def load(path: Path):
|
||||
"""x, y, z, rgb and a size per splat that is not nearly clear (memory-mapped, at most MAX_POINTS of them)."""
|
||||
with open(path, "rb") as f:
|
||||
props, n = [], 0
|
||||
while (raw := f.readline()) and (line := raw.decode(errors="replace").strip()) != "end_header":
|
||||
if line.startswith("element vertex"):
|
||||
n = int(line.split()[-1])
|
||||
elif line.startswith("property"):
|
||||
props.append(line.split()[-1])
|
||||
if not raw or not n:
|
||||
sys.exit(f"{path.name}: not a splat file")
|
||||
offset = f.tell()
|
||||
data = np.memmap(path, dtype=np.dtype([(p, "<f4") for p in props]), mode="r", offset=offset, shape=(n,))
|
||||
rng = np.random.default_rng(0)
|
||||
d = data[np.sort(rng.choice(n, MAX_POINTS, replace=False))] if n > MAX_POINTS else data[:]
|
||||
d = d[1 / (1 + np.exp(-d["opacity"].astype(np.float64))) > 0.1]
|
||||
xyz = np.stack([d["x"], d["y"], d["z"]], axis=1).astype(np.float64)
|
||||
rgb = np.clip(0.5 + SH_C0 * np.stack([d["f_dc_0"], d["f_dc_1"], d["f_dc_2"]], axis=1), 0, 1).astype(np.float32)
|
||||
if {"scale_0", "scale_1", "scale_2"} <= set(props):
|
||||
sc = np.stack([d["scale_0"], d["scale_1"], d["scale_2"]], axis=1).astype(np.float64)
|
||||
size = np.exp(np.clip(sc.max(axis=1), -20, 5))
|
||||
else:
|
||||
size = np.full(len(d), 0.01)
|
||||
ok = np.isfinite(xyz).all(axis=1) & np.isfinite(size)
|
||||
return xyz[ok], rgb[ok], size[ok]
|
||||
|
||||
|
||||
def eye_of(xyz: np.ndarray) -> np.ndarray:
|
||||
"""Where to look from: the file's origin if it lies in the scene (the Frame's start, the middle of the
|
||||
cameras for COLMAP-based tools) – otherwise the middle of the splats."""
|
||||
lo, hi = np.percentile(xyz, 5, axis=0), np.percentile(xyz, 95, axis=0)
|
||||
return np.zeros(3) if ((lo < 0) & (hi > 0)).all() else np.median(xyz, axis=0)
|
||||
|
||||
|
||||
def guess(xyz: np.ndarray) -> str:
|
||||
"""The vertical is the smaller of the Y and Z extents (a room is wider than it is high, so is a landscape;
|
||||
X is never up in the choices). Along Y, there is usually more down by the floor than up by the ceiling."""
|
||||
lo, hi = np.percentile(xyz, 2, axis=0), np.percentile(xyz, 98, axis=0)
|
||||
ext = hi - lo
|
||||
if ext[2] < ext[1]:
|
||||
return "z-up"
|
||||
return "y-down" if np.median(xyz[:, 1]) >= (lo[1] + hi[1]) / 2 else "y-up" # Y down: the floor has the larger y
|
||||
|
||||
|
||||
def view(xyz: np.ndarray, rgb: np.ndarray, size: np.ndarray, eye: np.ndarray) -> Image.Image:
|
||||
"""A small picture as from the Frame at the scene's start: from eye, looking level along +z, x to the
|
||||
right and y (down) downwards; each splat a dot as big as it looks, the nearest one in each pixel wins."""
|
||||
p = xyz - eye
|
||||
z = p[:, 2]
|
||||
near = 0.04 * float((np.percentile(xyz, 95, axis=0) - np.percentile(xyz, 5, axis=0)).max())
|
||||
f = W / 2 / np.tan(np.radians(FOV / 2))
|
||||
m = z > near # what floats right in front of the eye hides all
|
||||
p, z, c, sz = p[m], z[m], rgb[m], size[m]
|
||||
u = np.round(W / 2 + f * p[:, 0] / z).astype(int)
|
||||
v = np.round(H / 2 + f * p[:, 1] / z).astype(int)
|
||||
r = np.clip(np.round(f * sz / z * 0.5), 0, 2).astype(int)
|
||||
ok = (u >= -3) & (u < W + 3) & (v >= -3) & (v < H + 3)
|
||||
u, v, z, c, r = u[ok], v[ok], z[ok], c[ok], r[ok]
|
||||
pu, pv, pz, pc = [], [], [], []
|
||||
for rad in range(3): # each dot as a small square of its size
|
||||
k = r == rad
|
||||
for dy in range(-rad, rad + 1):
|
||||
for dx in range(-rad, rad + 1):
|
||||
pu.append(u[k] + dx); pv.append(v[k] + dy); pz.append(z[k]); pc.append(c[k])
|
||||
u, v, z, c = np.concatenate(pu), np.concatenate(pv), np.concatenate(pz), np.concatenate(pc)
|
||||
ok = (u >= 0) & (u < W) & (v >= 0) & (v < H)
|
||||
u, v, z, c = u[ok], v[ok], z[ok], c[ok]
|
||||
img = np.zeros((H, W, 3), np.float32)
|
||||
img[:] = BG
|
||||
if len(z):
|
||||
order = np.argsort(-z) # far first: the near ones are written last
|
||||
c = c / max(float(np.percentile(c.max(axis=1), 95)), 0.05) * 0.9 # rooms come out dim otherwise
|
||||
img[v[order], u[order]] = np.clip(c[order], 0, 1) * 255
|
||||
return Image.fromarray(img.astype(np.uint8))
|
||||
|
||||
|
||||
def main():
|
||||
src, out = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
ply = src
|
||||
if not plain_ply(src):
|
||||
if len(sys.argv) < 4 or not sys.argv[3]:
|
||||
sys.exit("splat-transform is needed for this file")
|
||||
ply = out / "as.ply"
|
||||
if subprocess.run([sys.argv[3], "-w", str(src), str(ply)], stdout=subprocess.DEVNULL).returncode:
|
||||
sys.exit(f"{src.name}: splat-transform cannot read it")
|
||||
xyz, rgb, size = load(ply)
|
||||
if ply != src:
|
||||
ply.unlink()
|
||||
if not len(xyz):
|
||||
sys.exit("no splats in the file")
|
||||
eye = eye_of(xyz)
|
||||
for up, r in TURNS.items():
|
||||
view(xyz @ r.T, rgb, size, eye @ r.T).save(out / f"{up}.png")
|
||||
print(json.dumps({"guess": guess(xyz)}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,7 +16,9 @@ TEXTS = {
|
||||
"inhibit.busy": "Busy (converting, putting together, copying)",
|
||||
"notify.standby.title": "Everything done", "notify.standby": "The PC goes to standby in {s} seconds – to stop it, untick “Standby when done” in Splat Workshop.",
|
||||
"notify.standby_failed.title": "No standby", "notify.standby_failed": "The PC could not go to standby: {why}",
|
||||
"inhibit.mesh": "Making a mesh of “{name}”", "mesh.step.mesh": "Meshing", "mesh.step.upright": "Standing it upright",
|
||||
"inhibit.mesh": "Making a mesh of “{name}”", "inhibit.import": "Importing “{name}”",
|
||||
"import.step.convert": "Converting", "import.step.preview": "Preview", "pick.splat": "Choose a splat file",
|
||||
"pick.recording": "Choose a recording", "mesh.step.mesh": "Meshing", "mesh.step.upright": "Standing it upright",
|
||||
"inhibit.versions": "Making versions of “{name}”", "ver.all": "all splats", "ver.size": "{n} M",
|
||||
"ver.clean.ceiling": "ceiling", "ver.clean.floor": "floor", "ver.clean.haze": "haze", "ver.clean.outside": "edges",
|
||||
"notify.failed.title": "Conversion failed",
|
||||
@@ -83,7 +85,9 @@ TEXTS = {
|
||||
"inhibit.busy": "Beschäftigt (Umwandeln, Zusammensetzen, Kopieren)",
|
||||
"notify.standby.title": "Alles fertig", "notify.standby": "Der PC geht in {s} Sekunden in den Standby – zum Abbrechen in der Splat-Werkstatt den Haken bei „Danach in den Standby“ entfernen.",
|
||||
"notify.standby_failed.title": "Kein Standby", "notify.standby_failed": "Der PC konnte nicht in den Standby: {why}",
|
||||
"inhibit.mesh": "Erstellt ein Mesh von „{name}“", "mesh.step.mesh": "Vernetzen", "mesh.step.upright": "Aufrichten",
|
||||
"inhibit.mesh": "Erstellt ein Mesh von „{name}“", "inhibit.import": "Importiert „{name}“",
|
||||
"import.step.convert": "Umwandeln", "import.step.preview": "Vorschau", "pick.splat": "Splat-Datei wählen",
|
||||
"pick.recording": "Aufnahme wählen", "mesh.step.mesh": "Vernetzen", "mesh.step.upright": "Aufrichten",
|
||||
"inhibit.versions": "Erstellt Fassungen von „{name}“", "ver.all": "alle Splats", "ver.size": "{n} Mio.",
|
||||
"ver.clean.ceiling": "Decke", "ver.clean.floor": "Boden", "ver.clean.haze": "Dunst", "ver.clean.outside": "Rand",
|
||||
"notify.failed.title": "Umwandlung fehlgeschlagen",
|
||||
|
||||
@@ -9,6 +9,7 @@ Without the tray icon (--no-idle-exit) it stops after idle_minutes with nothing
|
||||
Start: ./splat-workshop (or python3 -m splat_workshop.server)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import http.server
|
||||
@@ -30,7 +31,7 @@ from pathlib import Path
|
||||
from . import check, config
|
||||
from .i18n import T, detect
|
||||
|
||||
VERSION = "0.5.1"
|
||||
VERSION = "0.6.1"
|
||||
CFG = config.load()
|
||||
ROOT = config.ROOT
|
||||
WEB = ROOT / "web"
|
||||
@@ -153,7 +154,7 @@ def pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
|
||||
if cmd:
|
||||
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh", "mesh.sh"))
|
||||
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh", "mesh.sh", "import.sh"))
|
||||
except OSError:
|
||||
pass
|
||||
if not pid:
|
||||
@@ -927,6 +928,8 @@ def watch_once(was_running):
|
||||
t("notify.done.frame" if js["upload"] else "notify.done.local", name=js["name"], mins=mins))
|
||||
elif js["result"] == "failed":
|
||||
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
|
||||
if js.get("kind") == "import" and js["result"] != "ok" and not (RUNS / js["slug"]).exists():
|
||||
meta_file(js["slug"]).unlink(missing_ok=True) # nothing came of it: the name is free again
|
||||
if not running:
|
||||
restore_previous()
|
||||
advance_parts()
|
||||
@@ -1305,6 +1308,80 @@ def delete_version(slug: str, key: str):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- your own splats (scripts/import.sh, 0.6)
|
||||
|
||||
SPLAT_EXTS = (".ply", ".sog", ".spz", ".splat", ".ksplat")
|
||||
IMPORT_UP = ("y-down", "y-up", "z-up")
|
||||
|
||||
|
||||
def start_import(p: dict):
|
||||
"""A splat file from this PC (picked in the file dialog) as a new scene."""
|
||||
global proc
|
||||
src = Path(p.get("path") or "")
|
||||
if not src.is_absolute() or not src.is_file() or src.suffix.lower() not in SPLAT_EXTS:
|
||||
return err("err.import_file")
|
||||
name = (p.get("name") or "").strip() or src.stem
|
||||
slug = config.slugify(name)
|
||||
if not slug or slug.startswith("."):
|
||||
return err("err.bad_name")
|
||||
if (RUNS / slug).exists() or meta_file(slug).exists():
|
||||
return err("err.scene_name_taken")
|
||||
up = p.get("up") or "y-down"
|
||||
if up not in IMPORT_UP:
|
||||
return err("err.bad_value", key="up")
|
||||
try:
|
||||
scale = float(p.get("scale") or 1)
|
||||
except (TypeError, ValueError):
|
||||
return err("err.bad_value", key="scale")
|
||||
if not 0.0001 <= scale <= 10000 or scale != scale:
|
||||
return err("err.range", key="scale", lo=0.0001, hi=10000)
|
||||
with lock:
|
||||
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
|
||||
return err("err.job_busy")
|
||||
RUNS.mkdir(parents=True, exist_ok=True)
|
||||
out = RUNS / f"{slug}.log" # also the scene's log (to-frame.sh looks for it)
|
||||
write_meta(slug, name=name, imported=src.name)
|
||||
with open(out, "wb") as fh:
|
||||
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "import.sh"), str(src), str(RUNS / slug), name, up, f"{scale:g}"],
|
||||
t("inhibit.import", name=name)), cwd=ROOT, stdout=fh,
|
||||
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
||||
st = load_state()
|
||||
st["job"] = {"kind": "import", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
|
||||
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
|
||||
"steps": [t("import.step.convert"), t("import.step.preview")]}
|
||||
save_state(st)
|
||||
return None
|
||||
|
||||
|
||||
def import_preview(p: dict) -> dict:
|
||||
"""Small pictures of the picked file, one per choice of "up", and a guess which one stands upright
|
||||
(scripts/import_preview.py) – kept per file, so picking it again is instant."""
|
||||
src = Path(p.get("path") or "")
|
||||
if not src.is_absolute() or not src.is_file() or src.suffix.lower() not in SPLAT_EXTS:
|
||||
return {"error": err("err.import_file")}
|
||||
st = src.stat()
|
||||
key = hashlib.sha1(f"{src}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()[:16]
|
||||
out = CACHE / "import" / key
|
||||
if not (out / "guess.json").exists():
|
||||
tmp = out.with_name(key + ".tmp")
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
try:
|
||||
r = subprocess.run(["uv", "run", "-q", str(SCRIPTS / "import_preview.py"), str(src), str(tmp),
|
||||
str(CFG.splat_transform or "")], capture_output=True, text=True, timeout=300)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
r = None
|
||||
if r is None or r.returncode:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return {"error": err("err.import_preview", detail=((r.stderr if r else "").strip().splitlines() or [""])[-1])}
|
||||
(tmp / "guess.json").write_text(r.stdout.strip().splitlines()[-1])
|
||||
shutil.rmtree(out, ignore_errors=True)
|
||||
tmp.rename(out)
|
||||
guess = json.loads((out / "guess.json").read_text()).get("guess")
|
||||
return {"guess": guess if guess in IMPORT_UP else "y-down",
|
||||
"images": {u: "data:image/png;base64," + base64.b64encode((out / f"{u}.png").read_bytes()).decode()
|
||||
for u in IMPORT_UP}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- is there a newer version?
|
||||
|
||||
REPO = "thunderheronight/splat-workshop"
|
||||
@@ -2394,11 +2471,15 @@ def start_combine(p: dict):
|
||||
return None
|
||||
|
||||
|
||||
def pick_file():
|
||||
start = str(REC if REC.exists() else Path.home())
|
||||
title = "Aufnahme wählen" if t.lang == "de" else "Choose a recording"
|
||||
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, "Videos (*.mp4)"],
|
||||
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", "--file-filter=*.mp4"]):
|
||||
def pick_file(kind: str = "recording"):
|
||||
if kind == "splat":
|
||||
start, title = str(Path.home()), t("pick.splat")
|
||||
kde, gtk = "Splats (" + " ".join(f"*{e}" for e in SPLAT_EXTS) + ")", " ".join(f"*{e}" for e in SPLAT_EXTS)
|
||||
else:
|
||||
start = str(REC if REC.exists() else Path.home())
|
||||
title, kde, gtk = t("pick.recording"), "Videos (*.mp4)", "*.mp4"
|
||||
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, kde],
|
||||
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", f"--file-filter={gtk}"]):
|
||||
if shutil.which(cmd[0]):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return r.stdout.strip() or None
|
||||
@@ -2594,7 +2675,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(400, {"error": err("err.bad_request")})
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
e = None
|
||||
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown") and not self._from_this_pc():
|
||||
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown", "/api/import", "/api/import/preview") \
|
||||
and not self._from_this_pc():
|
||||
# These open windows on this PC's screen or stop the program – only allowed from the PC itself
|
||||
return self.send(403, {"error": err("err.pc_only")})
|
||||
if path == "/api/start":
|
||||
@@ -2625,6 +2707,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
remove_from_frame(p.get("slug", ""), p.get("version", "")))
|
||||
elif path == "/api/frame/move":
|
||||
e = move_to_own_folder()
|
||||
elif path == "/api/import":
|
||||
e = start_import(p)
|
||||
elif path == "/api/import/preview":
|
||||
r = import_preview(p)
|
||||
return self.send(400 if "error" in r else 200, r)
|
||||
elif path == "/api/update/check":
|
||||
return self.send(200, check_update_now())
|
||||
elif path == "/api/mesh":
|
||||
@@ -2634,7 +2721,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
elif path == "/api/version/delete":
|
||||
e = delete_version(p.get("slug", ""), p.get("version", ""))
|
||||
elif path == "/api/pick":
|
||||
chosen = pick_file()
|
||||
chosen = pick_file(p.get("kind") or "recording")
|
||||
if p.get("kind") == "splat":
|
||||
return self.send(200, {"path": chosen})
|
||||
if chosen:
|
||||
picked.add(chosen)
|
||||
return self.send(200, {"path": chosen})
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Your own splats (0.6, scripts/import.sh): a splat file from another program becomes a scene like the
|
||||
others, and goes to the Frame."""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from conftest import RUNS, server, tools_called, wait_job
|
||||
import test_clean
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def splat(tmp_path):
|
||||
f = tmp_path / "garden_dslr.ply"
|
||||
test_clean.room(f)
|
||||
return f
|
||||
|
||||
|
||||
def transforms():
|
||||
return [c for c in tools_called() if c[0] == "splat-transform"]
|
||||
|
||||
|
||||
def test_a_splat_becomes_a_scene(api, splat):
|
||||
status, r = api.post("/api/import", {"path": str(splat), "name": "Garden"})
|
||||
assert status == 200, r
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok" and js["kind"] == "import", js["tail"]
|
||||
run = RUNS / "Garden"
|
||||
for f in ("spirula/scene.ply", "scene.sog", "scene.xrSceneTransform.json", "garden.gallery.png", "garden.sceneinfo.png"):
|
||||
assert (run / f).exists(), f
|
||||
assert json.loads((run / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0
|
||||
s = {x["slug"]: x for x in server.scenes()}["Garden"]
|
||||
assert s["name"] == "Garden" and s["ply"] and s["gaussians"] == len(test_clean.read(run / "spirula" / "scene.ply"))
|
||||
assert server.read_meta("Garden")["imported"] == "garden_dslr.ply"
|
||||
assert splat.exists() and not (RUNS / "Garden.tmp").exists() # the file itself stays
|
||||
first = transforms()[0]
|
||||
assert first[first.index("-w") + 1] == str(splat) and "-r" not in first and "-s" not in first
|
||||
|
||||
|
||||
@pytest.mark.parametrize("up,turn", [("y-up", "180,0,0"), ("z-up", "-90,0,0")])
|
||||
def test_up_and_size_are_set_right(api, splat, up, turn):
|
||||
assert api.post("/api/import", {"path": str(splat), "name": "Garden", "up": up, "scale": "0.01"})[0] == 200
|
||||
assert wait_job()["result"] == "ok"
|
||||
first = transforms()[0]
|
||||
assert first[first.index("-r") + 1] == turn and first[first.index("-s") + 1] == "0.01"
|
||||
|
||||
|
||||
def test_an_imported_scene_goes_to_the_frame(api, splat, frame):
|
||||
api.post("/api/import", {"path": str(splat), "name": "Garden"})
|
||||
wait_job()
|
||||
assert api.post("/api/upload", {"slug": "Garden"})[0] == 200
|
||||
end = time.time() + 60
|
||||
while (u := server.upload_status("Garden")) and u["running"] and time.time() < end:
|
||||
time.sleep(0.2)
|
||||
assert server.upload_status("Garden")["ok"], server.upload_status("Garden")
|
||||
folder = next((frame / server.FRAME_SCENES).iterdir())
|
||||
assert sorted(p.name for p in folder.iterdir()) == [
|
||||
"garden.gallery.png", "garden.sceneinfo.png", "garden.sog", "garden.xrSceneTransform.json"]
|
||||
|
||||
|
||||
def test_a_failed_import_frees_the_name(api, tmp_path):
|
||||
bad = tmp_path / "broken.ply"
|
||||
bad.write_bytes(b"not a splat")
|
||||
assert api.post("/api/import", {"path": str(bad), "name": "Broken"})[0] == 200
|
||||
assert wait_job()["result"] == "failed"
|
||||
server.watch_once(True)
|
||||
assert not (RUNS / "Broken").exists() and not server.meta_file("Broken").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [
|
||||
({"path": "relative.ply"}, "err.import_file"), ({"path": "/nope/x.ply"}, "err.import_file"),
|
||||
({"suffix": ".txt"}, "err.import_file"), ({"up": "x-up"}, "err.bad_value"), ({"scale": "0"}, "err.range"),
|
||||
({"scale": "abc"}, "err.bad_value"), ({"name": "Taken"}, "err.scene_name_taken")])
|
||||
def test_odd_imports_are_refused(api, splat, body, code):
|
||||
(RUNS / "Taken").mkdir(parents=True)
|
||||
path = splat
|
||||
if "suffix" in body:
|
||||
path = splat.with_suffix(body.pop("suffix"))
|
||||
path.write_bytes(b"x")
|
||||
status, r = api.post("/api/import", {"path": str(path), "name": "Garden", **body})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
assert server.load_state().get("job") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- which way is up (0.6.1)
|
||||
|
||||
def turned(src, dst, up):
|
||||
"""The room as another program would have saved it: our scene (Y down) turned back into "up"."""
|
||||
v = test_clean.read(src).copy()
|
||||
x, y, z = v["x"].copy(), v["y"].copy(), v["z"].copy()
|
||||
if up == "y-up":
|
||||
v["y"], v["z"] = -y, -z # 180° about X
|
||||
elif up == "z-up":
|
||||
v["y"], v["z"] = z, -y # down (+Y) becomes -Z
|
||||
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
|
||||
len(v), "".join(f"property float {p}\n" for p in v.dtype.names))
|
||||
dst.write_bytes(header.encode() + v.tobytes())
|
||||
return dst
|
||||
|
||||
|
||||
def picture(url):
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
return Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).convert("RGB")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("up", ["y-down", "y-up", "z-up"])
|
||||
def test_the_dialog_guesses_which_way_is_up(api, splat, tmp_path, up):
|
||||
f = splat if up == "y-down" else turned(splat, tmp_path / f"room_{up}.ply", up)
|
||||
status, r = api.post("/api/import/preview", {"path": str(f)})
|
||||
assert status == 200, r
|
||||
assert r["guess"] == up
|
||||
assert sorted(r["images"]) == ["y-down", "y-up", "z-up"]
|
||||
for u, url in r["images"].items():
|
||||
assert picture(url).size == (320, 200), u
|
||||
|
||||
|
||||
def test_the_upright_picture_has_the_floor_below(api, splat, tmp_path):
|
||||
# the room's floor is brown, its ceiling nearly white: saved with Y up, the right choice shows brown at
|
||||
# the bottom, our usual Y down at the top (the scene on its head)
|
||||
f = turned(splat, tmp_path / "room_yup.ply", "y-up")
|
||||
imgs = {u: np.asarray(picture(url), np.float32) for u, url in api.post("/api/import/preview", {"path": str(f)})[1]["images"].items()}
|
||||
|
||||
def brown(img): # floor colour: much more red than blue
|
||||
return (img[..., 0] - img[..., 2]) > 40
|
||||
|
||||
up, head = brown(imgs["y-up"]), brown(imgs["y-down"])
|
||||
assert up[100:].sum() > 5 * up[:100].sum()
|
||||
assert head[:100].sum() > 5 * head[100:].sum()
|
||||
|
||||
|
||||
def test_the_pictures_are_kept_per_file(api, splat, monkeypatch):
|
||||
assert api.post("/api/import/preview", {"path": str(splat)})[0] == 200
|
||||
calls = []
|
||||
real = server.subprocess.run
|
||||
monkeypatch.setattr(server.subprocess, "run", lambda *a, **k: calls.append(a) or real(*a, **k))
|
||||
assert api.post("/api/import/preview", {"path": str(splat)})[0] == 200
|
||||
assert calls == [] # the same file: from the cache
|
||||
|
||||
|
||||
def test_a_broken_file_gives_no_pictures(api, tmp_path):
|
||||
bad = tmp_path / "broken.ply"
|
||||
bad.write_bytes(b"not a splat")
|
||||
status, r = api.post("/api/import/preview", {"path": str(bad)})
|
||||
assert status == 400 and r["error"]["code"] == "err.import_preview"
|
||||
status, r = api.post("/api/import/preview", {"path": "relative.ply"})
|
||||
assert status == 400 and r["error"]["code"] == "err.import_file"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""The page itself (web/index.html): what its script touches the moment it loads must already be there.
|
||||
In 0.5.1 the footer stood below the script; setting up its link failed, and the rest of the script –
|
||||
among it the regular status updates – never ran: a finished job stayed "running" until a reload."""
|
||||
import re
|
||||
|
||||
from conftest import ROOT
|
||||
|
||||
HTML = (ROOT / "web" / "index.html").read_text()
|
||||
|
||||
|
||||
def test_everything_the_script_sets_up_on_load_is_above_it():
|
||||
script = HTML.index("<script>")
|
||||
above = set(re.findall(r'id="([\w-]+)"', HTML[:script]))
|
||||
code = HTML[script:]
|
||||
on_load = set(re.findall(r'^\$\("#([\w-]+)"\)\.', code, re.M)) # statements at the top level
|
||||
missing = sorted(on_load - above)
|
||||
assert not missing, f"used when the page loads, but not (yet) in the page: {missing}"
|
||||
|
||||
|
||||
def test_the_status_is_polled():
|
||||
assert re.search(r"^setInterval\(poll, \d+\);", HTML, re.M)
|
||||
@@ -100,7 +100,7 @@ def test_no_paths_outside_the_runs_folder(api, leak, slug):
|
||||
|
||||
@pytest.mark.parametrize("path,body", [
|
||||
("/api/open", {"slug": "Room", "what": "folder"}), ("/api/play", {"name": "x.mp4"}),
|
||||
("/api/pick", {}), ("/api/shutdown", {}),
|
||||
("/api/pick", {}), ("/api/shutdown", {}), ("/api/import", {"path": "/x.ply"}), ("/api/import/preview", {"path": "/x.ply"}),
|
||||
])
|
||||
def test_actions_on_the_screen_only_from_this_pc(api, monkeypatch, path, body):
|
||||
# video player, file dialog, folder, Spirula and quitting: not from another device in the LAN
|
||||
|
||||
+26
@@ -272,6 +272,19 @@ const I18N = {
|
||||
"update.later": "Later", "update.text.plain": "Splat Workshop {latest} is out", "update.none": "You have the newest version ({current})",
|
||||
"update.offline": "GitHub could not be reached – try again later", "foot.version": "Splat Workshop {v}", "foot.releases": "Releases",
|
||||
"foot.check": "Check for updates", "sc.uploading.pct": "Copying to the Frame … {pct} % ({done} of {total})", "update.how": "To update: run {cmd} in the Splat Workshop folder, then quit Splat Workshop (tray icon → Quit) and start it again. Your config and scenes stay.",
|
||||
"imp.button": "+ Import a splat …", "imp.button.title": "A splat file from another program as a scene",
|
||||
"imp.title": "Import a splat", "imp.hint": "A splat file from another program becomes a scene like the others. The file itself is not changed.",
|
||||
"imp.pick": "Choose file …", "imp.none": "No file chosen (.ply, .sog, .spz, .splat, .ksplat)", "imp.name": "Name",
|
||||
"imp.up": "Which way does it stand upright?",
|
||||
"imp.up.hint": "The pictures show the scene as it would stand on the Frame – pick the upright one. Marked: our guess.",
|
||||
"imp.up.z-up": "Upright in Blender (Z up)", "imp.up.z-up.text": "e.g. from Blender, or made from photos in Spirula Studio",
|
||||
"imp.up.y-down": "Like the Workshop's and the Frame's scenes (Y down)", "imp.up.y-down.text": "COLMAP and most tools built on it",
|
||||
"imp.up.y-up": "Upside down (Y up)", "imp.up.y-up.text": "If it stands on its head with “Y down”",
|
||||
"imp.guess": "guess", "imp.preview.wait": "Making the pictures …", "imp.preview.none": "No pictures: {detail}",
|
||||
"err.import_preview": "Could not read the file: {detail}",
|
||||
"imp.scale": "Size × ", "imp.scale.hint": "1 = as in the file; 0.01 if it is in centimetres. Photo reconstructions have no real scale – try it on the Frame.",
|
||||
"imp.make": "Import", "imp.started": "Importing …", "job.sub.import": "Import · for {time}",
|
||||
"err.import_file": "Choose a splat file (.ply, .sog, .spz, .splat, .ksplat)",
|
||||
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "A mesh (surface) for 3D programs and 3D printing",
|
||||
"mesh.title": "Mesh of “{name}”",
|
||||
"mesh.hint": "Spirula Studio turns the splats into a surface – for Blender and other 3D programs or for 3D printing. It stands upright (the way Blender & co. expect each format) with the floor at height 0; what lies well outside the room is left out. In metres.",
|
||||
@@ -575,6 +588,19 @@ const I18N = {
|
||||
"update.later": "Später", "update.text.plain": "Die Splat-Werkstatt {latest} ist da", "update.none": "Du hast die neueste Version ({current})",
|
||||
"update.offline": "GitHub war nicht erreichbar – später noch einmal versuchen", "foot.version": "Splat-Werkstatt {v}", "foot.releases": "Versionen",
|
||||
"foot.check": "Nach Updates suchen", "sc.uploading.pct": "Wird auf die Frame übertragen … {pct} % ({done} von {total})", "update.how": "Zum Aktualisieren im Ordner der Splat-Werkstatt {cmd} ausführen, dann die Splat-Werkstatt beenden (Tray-Symbol → Beenden) und neu starten. Config und Szenen bleiben erhalten.",
|
||||
"imp.button": "+ Splat importieren …", "imp.button.title": "Eine Splat-Datei aus einem anderen Programm als Szene aufnehmen",
|
||||
"imp.title": "Splat importieren", "imp.hint": "Eine Splat-Datei aus einem anderen Programm wird zu einer Szene wie die anderen. Die Datei selbst bleibt unverändert.",
|
||||
"imp.pick": "Datei wählen …", "imp.none": "Keine Datei gewählt (.ply, .sog, .spz, .splat, .ksplat)", "imp.name": "Name",
|
||||
"imp.up": "Wie steht sie aufrecht?",
|
||||
"imp.up.hint": "Die Bilder zeigen die Szene so, wie sie auf der Frame stünde – wähle die aufrechte. Markiert: unsere Vermutung.",
|
||||
"imp.up.z-up": "Steht in Blender richtig (Z oben)", "imp.up.z-up.text": "z. B. aus Blender, oder in Spirula Studio aus Fotos gemacht",
|
||||
"imp.up.y-down": "Wie die Szenen der Werkstatt / Frame (Y unten)", "imp.up.y-down.text": "COLMAP und die meisten Werkzeuge darauf",
|
||||
"imp.up.y-up": "Steht auf dem Kopf (Y oben)", "imp.up.y-up.text": "Wenn sie mit „Y unten“ auf dem Kopf steht",
|
||||
"imp.guess": "vermutet", "imp.preview.wait": "Bilder werden berechnet …", "imp.preview.none": "Keine Bilder: {detail}",
|
||||
"err.import_preview": "Datei nicht lesbar: {detail}",
|
||||
"imp.scale": "Größe × ", "imp.scale.hint": "1 = wie in der Datei; 0,01, wenn sie in Zentimetern ist. Aus Fotos rekonstruierte Szenen haben keinen echten Maßstab – auf der Frame ausprobieren.",
|
||||
"imp.make": "Importieren", "imp.started": "Importiere …", "job.sub.import": "Import · seit {time}",
|
||||
"err.import_file": "Eine Splat-Datei wählen (.ply, .sog, .spz, .splat, .ksplat)",
|
||||
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "Ein Mesh (Oberfläche) für 3D-Programme und den 3D-Druck",
|
||||
"mesh.title": "Mesh von „{name}“",
|
||||
"mesh.hint": "Spirula Studio macht aus den Splats eine Oberfläche – für Blender und andere 3D-Programme oder für den 3D-Druck. Sie steht aufrecht (so, wie Blender & Co. jedes Format erwarten) mit dem Boden auf Höhe 0; was deutlich außerhalb des Raums liegt, bleibt weg. In Metern.",
|
||||
|
||||
+69
-6
@@ -192,6 +192,12 @@ dialog.wide { max-width: 560px; }
|
||||
dialog select { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||
dialog h3 { font-size: 13.5px; margin: 16px 0 8px; }
|
||||
.opts { display: grid; gap: 6px; }
|
||||
.opts.pics { grid-template-columns: repeat(3, 1fr); }
|
||||
.opts.pics .opt { flex-direction: column; gap: 8px; }
|
||||
.opts.pics .pic { width: 100%; aspect-ratio: 8 / 5; border-radius: 6px; background: #181b22; object-fit: cover; display: block; }
|
||||
.opts.pics .pic.wait { display: grid; place-items: center; font-size: 12px; color: var(--muted); text-align: center; padding: 6px; box-sizing: border-box; }
|
||||
.opts.pics .guess { font-size: 11px; color: var(--accent); border: 1px solid var(--accent); border-radius: 99px; padding: 0 6px; margin-left: 4px; white-space: nowrap; }
|
||||
@media (max-width: 620px) { .opts.pics { grid-template-columns: 1fr; } }
|
||||
.unfin { display: grid; gap: 8px; }
|
||||
.unfin .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.unfin .row b { overflow-wrap: anywhere; } .unfin .row .sp { flex: 1; }
|
||||
@@ -373,7 +379,7 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between;margin-bottom:14px">
|
||||
<h2 data-i18n="scenes.title" style="margin:0"></h2>
|
||||
<div class="row"><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
|
||||
<div class="row"><button class="small pc-only" id="importBtn" data-i18n="imp.button" data-i18n-title="imp.button.title"></button><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
|
||||
</div>
|
||||
<div class="tabs" id="sceneTabs">
|
||||
<button class="tab active" data-stab="pc" data-i18n="stab.pc"></button>
|
||||
@@ -445,6 +451,20 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="importDlg" class="wide">
|
||||
<h2 style="margin-top:0" data-i18n="imp.title"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="imp.hint"></p>
|
||||
<div class="row" style="gap:8px"><button class="small" id="impPick" data-i18n="imp.pick"></button><span id="impFile" class="muted" style="overflow-wrap:anywhere"></span></div>
|
||||
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.name"></span><input id="impName" type="text" style="flex:1"></label>
|
||||
<h3 data-i18n="imp.up"></h3>
|
||||
<p class="muted" style="font-size:13px;margin-top:-4px" data-i18n="imp.up.hint"></p>
|
||||
<div class="opts pics" id="impUp"></div>
|
||||
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.scale"></span><input id="impScale" type="number" min="0.0001" max="10000" step="any" value="1" style="width:110px"><span class="faint" style="font-size:12.5px" data-i18n="imp.scale.hint"></span></label>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="impClose" data-i18n="clean.close"></button>
|
||||
<button class="primary" id="impMake" data-i18n="imp.make" disabled></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="meshDlg" class="wide">
|
||||
<h2 style="margin-top:0" id="meshTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="mesh.hint"></p>
|
||||
@@ -539,6 +559,9 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<footer class="foot"><span id="footVersion"></span> · <a href="https://github.com/thunderheronight/splat-workshop" target="_blank" rel="noopener">GitHub</a>
|
||||
· <a href="https://github.com/thunderheronight/splat-workshop/releases" target="_blank" rel="noopener" data-i18n="foot.releases"></a>
|
||||
· <a href="#" id="footCheck" data-i18n="foot.check"></a></footer>
|
||||
<script src="/i18n.js"></script>
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
@@ -1012,8 +1035,8 @@ function renderJob() {
|
||||
$("#jobName").textContent = j.name;
|
||||
const qk = QUALITY_ALIAS[j.quality] || j.quality, combine = j.kind === "combine";
|
||||
const target = tr(j.upload ? "job.toFrame" : "job.pcOnly"), time = mins(Date.now() / 1000 - j.started);
|
||||
const versions = j.kind === "versions" || j.kind === "mesh";
|
||||
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {time})
|
||||
const versions = j.kind === "versions" || j.kind === "mesh" || j.kind === "import";
|
||||
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {time}) : j.kind === "import" ? tr("job.sub.import", {time})
|
||||
: versions ? tr((j.steps || []).length === 1 ? "job.sub.versions.1" : "job.sub.versions", {n: (j.steps || []).length, time})
|
||||
: combine ? tr(j.rooms === 1 && j.recordings > 1 ? "job.sub.combine.room" : "job.sub.combine",
|
||||
{n: j.rooms === 1 && j.recordings > 1 ? j.recordings : j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
|
||||
@@ -1388,6 +1411,48 @@ async function loadFrameGallery(refresh) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- your own splats ---------- */
|
||||
$("#importBtn").onclick = () => {
|
||||
let file = null;
|
||||
$("#impFile").textContent = tr("imp.none"); $("#impName").value = ""; $("#impScale").value = "1"; $("#impMake").disabled = true;
|
||||
let shown = 0; // a newer pick wins over an older preview still on its way
|
||||
const ups = (pics, guess) => {
|
||||
const keep = document.querySelector('input[name="impUp"]:checked')?.value;
|
||||
const sel = guess || keep || "y-down";
|
||||
$("#impUp").innerHTML = ["z-up", "y-down", "y-up"].map(u => `<label class="opt${u === sel ? " active" : ""}">
|
||||
${pics === null ? "" : pics === "wait" || typeof pics === "string" ? `<div class="pic wait">${esc(pics === "wait" ? tr("imp.preview.wait") : pics)}</div>`
|
||||
: `<img class="pic" alt="" src="${pics[u]}">`}
|
||||
<div class="row" style="gap:8px;align-items:flex-start"><input type="radio" name="impUp" value="${u}" ${u === sel ? "checked" : ""}>
|
||||
<div><b>${tr("imp.up." + u)}</b>${u === guess ? `<span class="guess" style="display:inline">${tr("imp.guess")}</span>` : ""}<span>${tr("imp.up." + u + ".text")}</span></div></div></label>`).join("");
|
||||
$("#impUp").querySelectorAll("input").forEach(i => i.onchange = () =>
|
||||
$("#impUp").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked)));
|
||||
};
|
||||
$("#impUp").innerHTML = ""; ups(null);
|
||||
$("#impPick").onclick = async () => {
|
||||
const r = await api("/api/pick", {kind: "splat"});
|
||||
if (!r.path) return;
|
||||
file = r.path;
|
||||
$("#impFile").textContent = file;
|
||||
if (!$("#impName").value) $("#impName").value = file.split("/").pop().replace(/(\.compressed)?\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||
$("#impMake").disabled = false;
|
||||
const me = ++shown;
|
||||
ups("wait");
|
||||
const p = await api("/api/import/preview", {path: file});
|
||||
if (me !== shown) return;
|
||||
if (p.ok) ups(p.images, p.guess);
|
||||
else ups(tr("imp.preview.none", {detail: p.error ? trErr(p.error) : ""}));
|
||||
};
|
||||
$("#impClose").onclick = () => $("#importDlg").close();
|
||||
$("#impMake").onclick = async () => {
|
||||
const r = await api("/api/import", {path: file, name: $("#impName").value.trim(),
|
||||
up: document.querySelector('input[name="impUp"]:checked').value, scale: $("#impScale").value || "1"});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#importDlg").close(); toast(tr("imp.started")); await poll();
|
||||
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
};
|
||||
$("#importDlg").showModal();
|
||||
};
|
||||
|
||||
/* ---------- meshes ---------- */
|
||||
const MESH_FORMATS = ["glb", "stl", "obj", "ply"];
|
||||
// A mesh's files to download (also from other devices at home), Blender and the folder (on this PC)
|
||||
@@ -1859,8 +1924,6 @@ setInterval(repingForUpdate, 3 * 3600 * 1000);
|
||||
setInterval(poll, 2000);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
|
||||
</script>
|
||||
<footer class="foot"><span id="footVersion"></span> · <a href="https://github.com/thunderheronight/splat-workshop" target="_blank" rel="noopener">GitHub</a>
|
||||
· <a href="https://github.com/thunderheronight/splat-workshop/releases" target="_blank" rel="noopener" data-i18n="foot.releases"></a>
|
||||
· <a href="#" id="footCheck" data-i18n="foot.check"></a></footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user