Files
thunderheronight--splat-wor…/scripts/splat.sh
T
thunderheronightandClaude Opus 5.5 de6c632267 Split a long video into parts that become one room
Each part gets up to 500 image pairs; parts are cut losslessly with the original time stamps, converted
one after the other and put together as a room. The original stays (asked once more before deleting).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:02:44 +02:00

174 lines
8.0 KiB
Bash
Executable File

#!/bin/bash
# Arcturus Vision recording -> Gaussian splat -> scene in the Gaussian XR gallery on the Steam Frame.
#
# Usage: scripts/splat.sh <recording> "<display name>" [extra av1_to_3dgs.py options]
# <recording> a local .mp4, or part of a file name on the Frame
# (e.g. "19-00" or "2026-10-03_19-00-53"); it is downloaded first
# <display name> shown on the name plate in the gallery, e.g. "Living room"
#
# Environment:
# QUALITY=fast|high|max preset (default: high)
# fast 100 image pairs, half resolution, up to 1.5 M splats (~10 min, for trying things out)
# high ~5 image pairs per second of video (100-500), full resolution, up to 2 M splats
# 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.
# Paths and the Frame address come from ~/.config/splat-workshop/config.toml
# (or PIPELINE_DIR, SPIRULA_BIN, SPLAT_TRANSFORM, RECORDINGS_DIR, RUNS_DIR, FRAME_HOST).
#
# Lines starting with "@@" are machine-readable progress markers for the web interface.
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)"
FRAME_VIDEOS=Videos/ArcturusVisionCamera
src=${1:?Give a recording (.mp4 or part of a file name on the Frame)}
name=${2:?Give a display name, e.g. "Living room"}
shift 2
# Folder name from the display name: transliterate, keep only safe characters
slug=$(cd "$ROOT" && python3 -m splat_workshop.config --slug "$name")
case "$slug" in ''|.*) echo "The display name does not give a usable folder name" >&2; exit 1 ;; esac
[ -f "$PIPELINE_DIR/av1_to_3dgs.py" ] || { echo "Arcturus pipeline not found in $PIPELINE_DIR (see README)" >&2; exit 1; }
[ -x "$SPIRULA_BIN" ] || { echo "Spirula Studio not found at $SPIRULA_BIN (see README)" >&2; exit 1; }
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found (npm install -g @playcanvas/splat-transform)" >&2; exit 1; }
# 1. Recording
if [ -f "$src" ]; then
in=$(realpath "$src")
else
[ -n "$FRAME_HOST" ] || { echo "No such file, and no Frame configured: $src" >&2; exit 1; }
remote=$(ssh -o ConnectTimeout=8 "$FRAME_HOST" "ls $FRAME_VIDEOS/*${src}*_stereo.mp4 2>/dev/null" || true)
count=$(printf '%s\n' "$remote" | grep -c . || true)
[ "$count" = 1 ] || { echo "$count recordings on the Frame match \"$src\":" >&2; printf '%s\n' "$remote" >&2; exit 1; }
mkdir -p "$RECORDINGS_DIR"
in=$RECORDINGS_DIR/$(basename "$remote")
if [ ! -f "$in" ]; then
echo "Downloading $(basename "$remote") from the Frame ..."
scp -q "$FRAME_HOST:$remote" "$in.part" && mv "$in.part" "$in"
fi
fi
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() {
[ -d "$prev/run" ] && [ ! -f "$out/scene.sog" ] || return 0
if [ -e "$out" ]; then mv "$out" "$prev/failed"; fi
mv "$prev/run" "$out"
if [ -f "$prev/log" ]; then mv "$prev/log" "$log"; fi
# The web interface keeps the settings of the previous run in "previous"
python3 - "$RUNS_DIR/$slug.ui.json" <<'PY' || true
import json, sys
try:
meta = json.load(open(sys.argv[1]))
except (OSError, ValueError):
sys.exit()
if isinstance(meta.get("previous"), dict):
json.dump(meta["previous"], open(sys.argv[1], "w"), ensure_ascii=False)
PY
echo "The previous result of \"$name\" was kept."
rm -rf "$prev"
rmdir "$RUNS_DIR/.previous" 2>/dev/null || true
}
if [ "${FRESH:-0}" = 1 ]; then
restore_previous # left over from an earlier run that never finished (e.g. power cut)
rm -rf "$prev"
if [ -f "$out/scene.sog" ]; then
mkdir -p "$prev"
mv "$out" "$prev/run"
if [ -f "$log" ]; then mv "$log" "$prev/log"; fi
trap restore_previous EXIT
trap 'exit 143' TERM; trap 'exit 130' INT; trap 'exit 129' HUP
else
rm -rf "$out" "$log"
fi
fi
# The pipeline resumes finished stages only: an interrupted training run blocks --resume.
# Then start the training over; images and camera positions are reused.
manifest=$out/spirula/run.json
if [[ " $* " == *" --resume "* ]] && [ -f "$manifest" ] && ! grep -q '"status": *"complete"' "$manifest"; then
ckpt=$(ls -d "$out"/spirula/training/step-*.ckpt 2>/dev/null | tail -1)
echo "Training was interrupted${ckpt:+ (last checkpoint: ${ckpt##*/step-})}; the pipeline cannot continue it." | tee -a "$log"
echo "Restarting the training, images and camera positions are reused." | tee -a "$log"
rm -rf "$out/spirula"
fi
# Image pairs from the duration: <per second>, clamped to the pipeline limits 100..500
pairs() {
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 }'
}
case "${QUALITY:-high}" in
fast) frames=100; scale=0.5; maxg=1500000; iter=30000 ;;
high) frames=$(pairs 5); scale=1; maxg=2000000; iter=30000 ;;
max) frames=$(pairs 8); scale=1; maxg=3000000; iter=50000 ;;
*) echo "QUALITY must be fast, high or max" >&2; exit 1 ;;
esac
quality=(--frames "${FRAMES:-$frames}" --training-image-scale "${SCALE:-$scale}"
--max-gaussians "${MAXG:-$maxg}" --iterations "${ITER:-$iter}")
echo "Quality ${QUALITY:-high}: ${quality[*]}" | tee -a "$log"
# 2. Compute the Gaussian splat. Spirula in English, so the progress lines can be parsed.
cd "$PIPELINE_DIR"
SS_LANG=en uv run av1_to_3dgs.py "$in" "$out" \
--backend spirula --sfm-backend spirula --no-undistort \
--spirula "$SPIRULA_BIN" \
--splat-transform "$SPLAT_TRANSFORM" \
"${quality[@]}" "$@" 2>&1 | tee -a "$log"
[ -f "$out/scene.sog" ] || { echo "No scene.sog was produced, see $log" >&2; exit 1; }
if [ -n "${PART:-}" ]; then
rm -f "$cut" # cut again from the recording when needed
rmdir "$RUNS_DIR/.parts" 2>/dev/null || true
fi
rm -rf "$prev"
rmdir "$RUNS_DIR/.previous" 2>/dev/null || true
# 3. Gallery files, then copy to the Frame (to-frame.sh also works on its own)
if [ "${NO_UPLOAD:-0}" = 1 ] || [ -z "$FRAME_HOST" ]; then
GALLERY_ONLY=1 LOG="$log" "$HERE/to-frame.sh" "$out" "$name"
echo "@@ done local"
echo "Done (not copied to the Frame): $out"
exit 0
fi
LOG="$log" "$HERE/to-frame.sh" "$out" "$name"
exit
}