Compare commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:26:03 +02:00
thunderheronightandClaude Opus 5.5 887406dba4 Import: general wording in the script and tests, too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:20:27 +02:00
thunderheronightandClaude Opus 5.5 5baa4b532f Import: plainer wording, not tied to one example
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:20:19 +02:00
thunderheronightandClaude Opus 5.5 8ebc020e7f Import dialog: the name field in the usual style
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:17:18 +02:00
thunderheronightandClaude Opus 5.5 6ba8f71c5b 0.6 (in progress): import your own splats as scenes (scripts/import.sh, dialog, tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:16:32 +02:00
11 changed files with 343 additions and 17 deletions

No files matched your search

+15
View File
@@ -2,6 +2,20 @@
All notable changes to Splat Workshop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/).
## [0.6.0] – 2026-10-17
### Added
- **Import your own splats**: *+ Import a splat …* above the finished scenes turns a splat file from another program into a scene like the others – to copy it to the Frame, make versions or a mesh. Reads `.ply`, `.sog`, `.spz`, `.splat` and `.ksplat` (via splat-transform); the file itself stays where it is. (Asked for in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
- **Which way is up**: Y down as Spirula Studio, COLMAP and the Frame's own scenes have it (the usual case), Y up or Z up for files from other tools.
- **Size**: a factor, e.g. 0.01 for a file in centimetres – a reconstruction from photos has no real scale of its own.
- The preview in the Frame's gallery comes from the top view, as for tours.
- Only from this PC (the file dialog opens here). Also on the command line: `scripts/import.sh`.
### Fixed
- **The page did not update any more (0.5.1)**: the new footer stood below the page's script; setting up its *Check for updates* link failed, and the rest of the script – among it the regular status updates – never ran. A finished job stayed on "Converting …" with its progress bar until the page was reloaded. A test now checks that everything the script sets up on load is already in the page.
## [0.5.1] – 2026-10-17
### Added
@@ -182,6 +196,7 @@ First release.
- **Floor plans** (experimental) in two styles with a measuring tool.
- Configurable video player; English and German interface.
[0.6.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.2...v0.5.0
[0.4.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.1...v0.4.2
+7
View File
@@ -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 sagst, wo oben ist, und bei Bedarf die Größe.
<img src="docs/import.png" width="420" alt="Der Import-Dialog: Datei wählen, benennen, sagen, wo oben ist, und die Größe">
<br><sub>Englische Oberfläche.</sub>
## Neu in 0.5
- 🧊 **Mesh-Export** – *Mesh …* macht aus einer Szene oder einem Rundgang eine Oberfläche für Blender und andere 3D-Programme (GLB, OBJ, PLY) oder für den 3D-Druck (STL). Sie steht aufrecht mit dem Boden auf Höhe 0, statt auf der Seite zu liegen – für jedes Format per Import in Blender geprüft; mit Textur, wenn die Trainingsbilder noch da sind. 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).)
+6
View File
@@ -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. You say which way is up and, if needed, the size.
<img src="docs/import.png" width="420" alt="The import dialog: choose a file, name it, say which way is up and the size">
## New in 0.5
- 🧊 **Mesh export** – *Mesh …* turns a scene or tour into a surface for Blender and other 3D programs (GLB, OBJ, PLY) or for 3D printing (STL). It stands upright with the floor at height 0 instead of lying on its side – checked by importing every format in Blender; with a texture when the training images are still there. 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).)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# A splat file from another program as a scene (0.6) – then copied to the Frame, versions, mesh … like any
# other scene.
#
# Usage: scripts/import.sh <splat file> <run folder> "<display name>" [up] [scale]
# <splat file> .ply .compressed.ply .sog .spz .splat .ksplat (whatever splat-transform reads)
# up where "up" is in the file: y-down (Spirula Studio, COLMAP, the Frame – default), y-up, z-up
# scale factor for the size, e.g. 0.01 if the file is in centimetres (default 1)
# Writes <run folder>/ with spirula/scene.ply, scene.sog, scene.xrSceneTransform.json, a preview from the
# top view and a name plate. Lines starting with "@@" are progress markers.
set -euo pipefail
# Everything in one block: bash reads it completely before it starts, so updating this file
# (git pull, editing) does not affect a run that is already going.
{
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT=$(dirname "$HERE")
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
src=$(realpath "${1:?Give a splat file}")
out=${2:?Give the run folder}
name=${3:?Give a name}
up=${4:-y-down}
scale=${5:-1}
[ -f "$src" ] || { echo "No such file: $src" >&2; exit 1; }
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found" >&2; exit 1; }
[ -e "$out" ] && { echo "Already there: $out" >&2; exit 1; }
case "$up" in
y-down) turn=() ;;
y-up) turn=(-r 180,0,0) ;; # +Y becomes -Y (checked: splat-transform -r 180,0,0)
z-up) turn=(-r -90,0,0) ;; # +Z becomes -Y (splat-transform -r 90,0,0 turns +Z into +Y)
*) echo "up must be y-down, y-up or z-up" >&2; exit 1 ;;
esac
[[ "$scale" =~ ^[0-9]+(\.[0-9]+)?$ ]] && awk -v s="$scale" 'BEGIN { exit !(s > 0) }' \
|| { echo "scale must be a number above 0" >&2; exit 1; }
size=()
[ "$scale" != 1 ] && size=(-s "$scale")
slug=$(basename "$out")
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
tmp=$out.tmp
rm -rf "$tmp"
mkdir -p "$tmp/spirula"
trap 'rm -rf "$tmp"' EXIT
echo "@@ vstep 1 2 convert"
echo "Importing $(basename "$src") as \"$name\" (up: $up, scale: $scale) ..."
"$SPLAT_TRANSFORM" -w "$src" "${turn[@]}" "${size[@]}" -N "$tmp/spirula/scene.ply"
"$SPLAT_TRANSFORM" -w "$tmp/spirula/scene.ply" "$tmp/scene.sog"
# (the same line the pipeline writes: the scene list reads the number of splats from it)
python3 -c 'import sys
with open(sys.argv[1], "rb") as f:
n = next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex"))
print(f"spirula: {n:,} Gaussians in {sys.argv[1]}")' "$tmp/spirula/scene.ply"
# Gaussian XR starts at the scene's origin, as it is in the file
cat > "$tmp/scene.xrSceneTransform.json" <<'JSON'
{"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], "worldScale": 1.0, "worldScaleOffset": [0, 0, 0]}
JSON
echo "@@ vstep 2 2 preview"
# Preview for the gallery: the top view as for the floor plan; if that finds no room, plain colour
if uv run -q "$HERE/floorplan.py" "$tmp/plan.png" "$tmp=$name" >/dev/null 2>&1; then
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp" "$name" "$stem"
else
uv run -q --no-project --with pillow python3 -c 'import sys
from PIL import Image
Image.new("RGBA", (512, 512), (40, 46, 60, 255)).save(sys.argv[1])' "$tmp/$stem.gallery.png"
uv run -q "$HERE/make_gallery.py" --label "$tmp/$stem.sceneinfo.png" "$name"
fi
mv "$tmp" "$out"
trap - EXIT
echo "Gaussian XR initial view: (imported scene) Display rotation 0°"
echo "@@ done local"
echo "Done: $out"
exit
}
+6 -2
View File
@@ -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",
+64 -9
View File
@@ -30,7 +30,7 @@ from pathlib import Path
from . import check, config
from .i18n import T, detect
VERSION = "0.5.1"
VERSION = "0.6.0"
CFG = config.load()
ROOT = config.ROOT
WEB = ROOT / "web"
@@ -153,7 +153,7 @@ def pid_alive(pid: int) -> bool:
try:
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
if cmd:
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh", "mesh.sh"))
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh", "mesh.sh", "import.sh"))
except OSError:
pass
if not pid:
@@ -927,6 +927,8 @@ def watch_once(was_running):
t("notify.done.frame" if js["upload"] else "notify.done.local", name=js["name"], mins=mins))
elif js["result"] == "failed":
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
if js.get("kind") == "import" and js["result"] != "ok" and not (RUNS / js["slug"]).exists():
meta_file(js["slug"]).unlink(missing_ok=True) # nothing came of it: the name is free again
if not running:
restore_previous()
advance_parts()
@@ -1305,6 +1307,51 @@ def delete_version(slug: str, key: str):
return None
# ---------------------------------------------------------------- your own splats (scripts/import.sh, 0.6)
SPLAT_EXTS = (".ply", ".sog", ".spz", ".splat", ".ksplat")
IMPORT_UP = ("y-down", "y-up", "z-up")
def start_import(p: dict):
"""A splat file from this PC (picked in the file dialog) as a new scene."""
global proc
src = Path(p.get("path") or "")
if not src.is_absolute() or not src.is_file() or src.suffix.lower() not in SPLAT_EXTS:
return err("err.import_file")
name = (p.get("name") or "").strip() or src.stem
slug = config.slugify(name)
if not slug or slug.startswith("."):
return err("err.bad_name")
if (RUNS / slug).exists() or meta_file(slug).exists():
return err("err.scene_name_taken")
up = p.get("up") or "y-down"
if up not in IMPORT_UP:
return err("err.bad_value", key="up")
try:
scale = float(p.get("scale") or 1)
except (TypeError, ValueError):
return err("err.bad_value", key="scale")
if not 0.0001 <= scale <= 10000 or scale != scale:
return err("err.range", key="scale", lo=0.0001, hi=10000)
with lock:
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
return err("err.job_busy")
RUNS.mkdir(parents=True, exist_ok=True)
out = RUNS / f"{slug}.log" # also the scene's log (to-frame.sh looks for it)
write_meta(slug, name=name, imported=src.name)
with open(out, "wb") as fh:
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "import.sh"), str(src), str(RUNS / slug), name, up, f"{scale:g}"],
t("inhibit.import", name=name)), cwd=ROOT, stdout=fh,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
st = load_state()
st["job"] = {"kind": "import", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
"steps": [t("import.step.convert"), t("import.step.preview")]}
save_state(st)
return None
# ---------------------------------------------------------------- is there a newer version?
REPO = "thunderheronight/splat-workshop"
@@ -2394,11 +2441,15 @@ def start_combine(p: dict):
return None
def pick_file():
start = str(REC if REC.exists() else Path.home())
title = "Aufnahme wählen" if t.lang == "de" else "Choose a recording"
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, "Videos (*.mp4)"],
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", "--file-filter=*.mp4"]):
def pick_file(kind: str = "recording"):
if kind == "splat":
start, title = str(Path.home()), t("pick.splat")
kde, gtk = "Splats (" + " ".join(f"*{e}" for e in SPLAT_EXTS) + ")", " ".join(f"*{e}" for e in SPLAT_EXTS)
else:
start = str(REC if REC.exists() else Path.home())
title, kde, gtk = t("pick.recording"), "Videos (*.mp4)", "*.mp4"
for cmd in (["kdialog", "--title", title, "--getopenfilename", start, kde],
["zenity", "--file-selection", f"--title={title}", f"--filename={start}/", f"--file-filter={gtk}"]):
if shutil.which(cmd[0]):
r = subprocess.run(cmd, capture_output=True, text=True)
return r.stdout.strip() or None
@@ -2594,7 +2645,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(400, {"error": err("err.bad_request")})
path = urllib.parse.urlparse(self.path).path
e = None
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown") and not self._from_this_pc():
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown", "/api/import") and not self._from_this_pc():
# These open windows on this PC's screen or stop the program – only allowed from the PC itself
return self.send(403, {"error": err("err.pc_only")})
if path == "/api/start":
@@ -2625,6 +2676,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
remove_from_frame(p.get("slug", ""), p.get("version", "")))
elif path == "/api/frame/move":
e = move_to_own_folder()
elif path == "/api/import":
e = start_import(p)
elif path == "/api/update/check":
return self.send(200, check_update_now())
elif path == "/api/mesh":
@@ -2634,7 +2687,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
elif path == "/api/version/delete":
e = delete_version(p.get("slug", ""), p.get("version", ""))
elif path == "/api/pick":
chosen = pick_file()
chosen = pick_file(p.get("kind") or "recording")
if p.get("kind") == "splat":
return self.send(200, {"path": chosen})
if chosen:
picked.add(chosen)
return self.send(200, {"path": chosen})
+82
View File
@@ -0,0 +1,82 @@
"""Your own splats (0.6, scripts/import.sh): a splat file from another program becomes a scene like the
others, and goes to the Frame."""
import json
import time
import pytest
from conftest import RUNS, server, tools_called, wait_job
import test_clean
@pytest.fixture
def splat(tmp_path):
f = tmp_path / "garden_dslr.ply"
test_clean.room(f)
return f
def transforms():
return [c for c in tools_called() if c[0] == "splat-transform"]
def test_a_splat_becomes_a_scene(api, splat):
status, r = api.post("/api/import", {"path": str(splat), "name": "Garden"})
assert status == 200, r
js = wait_job()
assert js["result"] == "ok" and js["kind"] == "import", js["tail"]
run = RUNS / "Garden"
for f in ("spirula/scene.ply", "scene.sog", "scene.xrSceneTransform.json", "garden.gallery.png", "garden.sceneinfo.png"):
assert (run / f).exists(), f
assert json.loads((run / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0
s = {x["slug"]: x for x in server.scenes()}["Garden"]
assert s["name"] == "Garden" and s["ply"] and s["gaussians"] == len(test_clean.read(run / "spirula" / "scene.ply"))
assert server.read_meta("Garden")["imported"] == "garden_dslr.ply"
assert splat.exists() and not (RUNS / "Garden.tmp").exists() # the file itself stays
first = transforms()[0]
assert first[first.index("-w") + 1] == str(splat) and "-r" not in first and "-s" not in first
@pytest.mark.parametrize("up,turn", [("y-up", "180,0,0"), ("z-up", "-90,0,0")])
def test_up_and_size_are_set_right(api, splat, up, turn):
assert api.post("/api/import", {"path": str(splat), "name": "Garden", "up": up, "scale": "0.01"})[0] == 200
assert wait_job()["result"] == "ok"
first = transforms()[0]
assert first[first.index("-r") + 1] == turn and first[first.index("-s") + 1] == "0.01"
def test_an_imported_scene_goes_to_the_frame(api, splat, frame):
api.post("/api/import", {"path": str(splat), "name": "Garden"})
wait_job()
assert api.post("/api/upload", {"slug": "Garden"})[0] == 200
end = time.time() + 60
while (u := server.upload_status("Garden")) and u["running"] and time.time() < end:
time.sleep(0.2)
assert server.upload_status("Garden")["ok"], server.upload_status("Garden")
folder = next((frame / server.FRAME_SCENES).iterdir())
assert sorted(p.name for p in folder.iterdir()) == [
"garden.gallery.png", "garden.sceneinfo.png", "garden.sog", "garden.xrSceneTransform.json"]
def test_a_failed_import_frees_the_name(api, tmp_path):
bad = tmp_path / "broken.ply"
bad.write_bytes(b"not a splat")
assert api.post("/api/import", {"path": str(bad), "name": "Broken"})[0] == 200
assert wait_job()["result"] == "failed"
server.watch_once(True)
assert not (RUNS / "Broken").exists() and not server.meta_file("Broken").exists()
@pytest.mark.parametrize("body,code", [
({"path": "relative.ply"}, "err.import_file"), ({"path": "/nope/x.ply"}, "err.import_file"),
({"suffix": ".txt"}, "err.import_file"), ({"up": "x-up"}, "err.bad_value"), ({"scale": "0"}, "err.range"),
({"scale": "abc"}, "err.bad_value"), ({"name": "Taken"}, "err.scene_name_taken")])
def test_odd_imports_are_refused(api, splat, body, code):
(RUNS / "Taken").mkdir(parents=True)
path = splat
if "suffix" in body:
path = splat.with_suffix(body.pop("suffix"))
path.write_bytes(b"x")
status, r = api.post("/api/import", {"path": str(path), "name": "Garden", **body})
assert status == 400 and r["error"]["code"] == code
assert server.load_state().get("job") is None
+21
View File
@@ -0,0 +1,21 @@
"""The page itself (web/index.html): what its script touches the moment it loads must already be there.
In 0.5.1 the footer stood below the script; setting up its link failed, and the rest of the script –
among it the regular status updates – never ran: a finished job stayed "running" until a reload."""
import re
from conftest import ROOT
HTML = (ROOT / "web" / "index.html").read_text()
def test_everything_the_script_sets_up_on_load_is_above_it():
script = HTML.index("<script>")
above = set(re.findall(r'id="([\w-]+)"', HTML[:script]))
code = HTML[script:]
on_load = set(re.findall(r'^\$\("#([\w-]+)"\)\.', code, re.M)) # statements at the top level
missing = sorted(on_load - above)
assert not missing, f"used when the page loads, but not (yet) in the page: {missing}"
def test_the_status_is_polled():
assert re.search(r"^setInterval\(poll, \d+\);", HTML, re.M)
+20
View File
@@ -272,6 +272,16 @@ 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 is up in the file?",
"imp.up.y-down": "Y down (usual)", "imp.up.y-down.text": "Spirula Studio, COLMAP and most tools built on it – like the Frame's own scenes",
"imp.up.y-up": "Y up", "imp.up.y-up.text": "If the scene stands upside down on the Frame",
"imp.up.z-up": "Z up", "imp.up.z-up.text": "If the scene lies on its side on the Frame (e.g. from Blender)",
"imp.scale": "Size × ", "imp.scale.hint": "1 = as in the file; 0.01 if it is in centimetres. Photo reconstructions have no real scale – try it on the Frame.",
"imp.make": "Import", "imp.started": "Importing …", "job.sub.import": "Import · for {time}",
"err.import_file": "Choose a splat file (.ply, .sog, .spz, .splat, .ksplat)",
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "A mesh (surface) for 3D programs and 3D printing",
"mesh.title": "Mesh of “{name}”",
"mesh.hint": "Spirula Studio turns the splats into a surface – for Blender and other 3D programs or for 3D printing. It stands upright (the way Blender & co. expect each format) with the floor at height 0; what lies well outside the room is left out. In metres.",
@@ -575,6 +585,16 @@ 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": "Wo ist oben in der Datei?",
"imp.up.y-down": "Y unten (üblich)", "imp.up.y-down.text": "Spirula Studio, COLMAP und die meisten Werkzeuge darauf – wie die Szenen der Frame selbst",
"imp.up.y-up": "Y oben", "imp.up.y-up.text": "Wenn die Szene auf der Frame auf dem Kopf steht",
"imp.up.z-up": "Z oben", "imp.up.z-up.text": "Wenn die Szene auf der Frame auf der Seite liegt (z. B. aus Blender)",
"imp.scale": "Größe × ", "imp.scale.hint": "1 = wie in der Datei; 0,01, wenn sie in Zentimetern ist. Aus Fotos rekonstruierte Szenen haben keinen echten Maßstab – auf der Frame ausprobieren.",
"imp.make": "Importieren", "imp.started": "Importiere …", "job.sub.import": "Import · seit {time}",
"err.import_file": "Eine Splat-Datei wählen (.ply, .sog, .spz, .splat, .ksplat)",
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "Ein Mesh (Oberfläche) für 3D-Programme und den 3D-Druck",
"mesh.title": "Mesh von „{name}“",
"mesh.hint": "Spirula Studio macht aus den Splats eine Oberfläche – für Blender und andere 3D-Programme oder für den 3D-Druck. Sie steht aufrecht (so, wie Blender & Co. jedes Format erwarten) mit dem Boden auf Höhe 0; was deutlich außerhalb des Raums liegt, bleibt weg. In Metern.",
+47 -6
View File
@@ -373,7 +373,7 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:14px">
<h2 data-i18n="scenes.title" style="margin:0"></h2>
<div class="row"><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
<div class="row"><button class="small pc-only" id="importBtn" data-i18n="imp.button" data-i18n-title="imp.button.title"></button><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
</div>
<div class="tabs" id="sceneTabs">
<button class="tab active" data-stab="pc" data-i18n="stab.pc"></button>
@@ -445,6 +445,19 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
</div>
</dialog>
<dialog id="importDlg" class="wide">
<h2 style="margin-top:0" data-i18n="imp.title"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="imp.hint"></p>
<div class="row" style="gap:8px"><button class="small" id="impPick" data-i18n="imp.pick"></button><span id="impFile" class="muted" style="overflow-wrap:anywhere"></span></div>
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.name"></span><input id="impName" type="text" style="flex:1"></label>
<h3 data-i18n="imp.up"></h3>
<div class="opts" id="impUp"></div>
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.scale"></span><input id="impScale" type="number" min="0.0001" max="10000" step="any" value="1" style="width:110px"><span class="faint" style="font-size:12.5px" data-i18n="imp.scale.hint"></span></label>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="impClose" data-i18n="clean.close"></button>
<button class="primary" id="impMake" data-i18n="imp.make" disabled></button>
</div>
</dialog>
<dialog id="meshDlg" class="wide">
<h2 style="margin-top:0" id="meshTitle"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="mesh.hint"></p>
@@ -539,6 +552,9 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
</dialog>
<div class="toast" id="toast"></div>
<footer class="foot"><span id="footVersion"></span> · <a href="https://github.com/thunderheronight/splat-workshop" target="_blank" rel="noopener">GitHub</a>
· <a href="https://github.com/thunderheronight/splat-workshop/releases" target="_blank" rel="noopener" data-i18n="foot.releases"></a>
· <a href="#" id="footCheck" data-i18n="foot.check"></a></footer>
<script src="/i18n.js"></script>
<script>
const $ = s => document.querySelector(s);
@@ -1012,8 +1028,8 @@ function renderJob() {
$("#jobName").textContent = j.name;
const qk = QUALITY_ALIAS[j.quality] || j.quality, combine = j.kind === "combine";
const target = tr(j.upload ? "job.toFrame" : "job.pcOnly"), time = mins(Date.now() / 1000 - j.started);
const versions = j.kind === "versions" || j.kind === "mesh";
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {time})
const versions = j.kind === "versions" || j.kind === "mesh" || j.kind === "import";
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {time}) : j.kind === "import" ? tr("job.sub.import", {time})
: versions ? tr((j.steps || []).length === 1 ? "job.sub.versions.1" : "job.sub.versions", {n: (j.steps || []).length, time})
: combine ? tr(j.rooms === 1 && j.recordings > 1 ? "job.sub.combine.room" : "job.sub.combine",
{n: j.rooms === 1 && j.recordings > 1 ? j.recordings : j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
@@ -1388,6 +1404,33 @@ async function loadFrameGallery(refresh) {
});
}
/* ---------- your own splats ---------- */
$("#importBtn").onclick = () => {
let file = null;
$("#impFile").textContent = tr("imp.none"); $("#impName").value = ""; $("#impScale").value = "1"; $("#impMake").disabled = true;
$("#impUp").innerHTML = ["y-down", "y-up", "z-up"].map((u, i) => `<label class="opt${i ? "" : " active"}">
<input type="radio" name="impUp" value="${u}" ${i ? "" : "checked"}><div><b>${tr("imp.up." + u)}</b><span>${tr("imp.up." + u + ".text")}</span></div></label>`).join("");
$("#impUp").querySelectorAll("input").forEach(i => i.onchange = () =>
$("#impUp").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked)));
$("#impPick").onclick = async () => {
const r = await api("/api/pick", {kind: "splat"});
if (!r.path) return;
file = r.path;
$("#impFile").textContent = file;
if (!$("#impName").value) $("#impName").value = file.split("/").pop().replace(/(\.compressed)?\.[^.]+$/, "").replace(/[_-]+/g, " ");
$("#impMake").disabled = false;
};
$("#impClose").onclick = () => $("#importDlg").close();
$("#impMake").onclick = async () => {
const r = await api("/api/import", {path: file, name: $("#impName").value.trim(),
up: document.querySelector('input[name="impUp"]:checked').value, scale: $("#impScale").value || "1"});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#importDlg").close(); toast(tr("imp.started")); await poll();
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
};
$("#importDlg").showModal();
};
/* ---------- meshes ---------- */
const MESH_FORMATS = ["glb", "stl", "obj", "ply"];
// A mesh's files to download (also from other devices at home), Blender and the folder (on this PC)
@@ -1859,8 +1902,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>