summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSt33v <github@f3rr3t.com>2026-06-10 19:10:38 +1000
committerSt33v <github@f3rr3t.com>2026-06-10 19:10:38 +1000
commite433a99ceddf7168d23377ffc8d585fc80ba8fb2 (patch)
tree32f7ff056e404ae1d4563125c169bd4c535992aa
parent59e7c485be1e9be62f0b4cdb7d1130701c1e3c46 (diff)
Add synoptic-morph: 30-day rolling morph timelapse
synopticMorph.sh: rolling-cache implementation. On each run: 1. Pick the last WINDOW_DAYS*4 charts from the archive. 2. For each adjacent pair, fill any missing pair-morph (cached at /mnt/enclave/synoptic/pairs/<chartA>__<chartB>/p_NNNN.png). 3. Evict pair dirs whose slug isn't in the current window. 4. Symlink frames into a temp seq dir with LINGER on each source chart and DWELL on the latest, fade-in 1s and fade-out 2s. 5. Encode to /srv/www/pestrel/morph.mp4 with ffmpeg/h264. Bootstrap: ~50 min CPU on first run (119 pair morphs at ~25s each). Steady state: ~1 min/cycle (1 new pair + concat + encode). synoptic-morph.service: oneshot, TimeoutStartSec=2h to cover the bootstrap, Nice=10 + IOSchedulingClass=idle so it doesn't fight the system for CPU/disk. synoptic.service gains OnSuccess=synoptic-morph.service so the chain fires automatically after each successful chart fetch. morph.html: points at /morph.mp4 now. index.html: "Three-day history" button renamed to "Monthly history", URL /morph.html. setup.sh: installs the new unit + script, provisions /mnt/enclave/synoptic/{pairs,out} when the enclave mount is present. .gitignore: drop .claude/ (transient harness state). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
-rw-r--r--.gitignore1
-rw-r--r--index.html9
-rw-r--r--morph.html53
-rwxr-xr-xsetup.sh11
-rw-r--r--synopticMorph.sh138
-rw-r--r--systemd/synoptic-morph.service16
-rw-r--r--systemd/synoptic.service1
7 files changed, 226 insertions, 3 deletions
diff --git a/.gitignore b/.gitignore
index 7b07d45..feddf82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
*.png
*.pdf
hist*
+.claude/
diff --git a/index.html b/index.html
index 219680a..6a9fc9f 100644
--- a/index.html
+++ b/index.html
@@ -8,18 +8,21 @@
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
img { max-width: 100%; max-height: 100vh; }
a.nav-button {
- position: fixed; left: 0; top: 16.6%;
+ position: fixed; left: 0;
color: #aaa; background: rgba(0,0,0,0.7);
padding: 0.45em 0.9em;
border: 1px solid #444; border-left: none;
border-radius: 0 0.3em 0.3em 0;
text-decoration: none; font-family: sans-serif; font-size: 0.95em;
}
- a.nav-button:hover { color: #fff; border-color: #888; }
+ a.nav-button.radars { top: 60%; }
+ a.nav-button.history { top: 75%; }
+ a.nav-button:hover { color: #fff; border-color: #888; }
</style>
</head>
<body>
<img src="/synopticLatest.png" alt="Latest synoptic">
- <a class="nav-button" href="https://radar.pestrel.com/">Radars</a>
+ <a class="nav-button radars" href="https://radar.pestrel.com/">Radars</a>
+ <a class="nav-button history" href="/morph.html">Monthly history</a>
</body>
</html>
diff --git a/morph.html b/morph.html
new file mode 100644
index 0000000..b600234
--- /dev/null
+++ b/morph.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Synoptic — 30-day morph</title>
+<style>
+ body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
+ video { max-width: 100%; max-height: 100vh; cursor: pointer; }
+ a.nav-button {
+ position: fixed; left: 0;
+ color: #aaa; background: rgba(0,0,0,0.7);
+ padding: 0.45em 0.9em;
+ border: 1px solid #444; border-left: none;
+ border-radius: 0 0.3em 0.3em 0;
+ text-decoration: none; font-family: sans-serif; font-size: 0.95em;
+ }
+ a.nav-button.radars { top: 60%; }
+ a.nav-button.static { top: 75%; }
+ a.nav-button:hover { color: #fff; border-color: #888; }
+ .hint {
+ position: fixed; right: 0.6em; bottom: 0.5em;
+ color: #555; font-family: sans-serif; font-size: 0.75em;
+ pointer-events: none;
+ }
+ #paused-indicator {
+ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
+ color: #fff; font-family: sans-serif; font-size: 4em;
+ background: rgba(0,0,0,0.5); padding: 0.2em 0.6em; border-radius: 0.2em;
+ display: none; pointer-events: none;
+ }
+ body.paused #paused-indicator { display: block; }
+</style>
+</head>
+<body>
+ <video id="vid" src="/morph.mp4" autoplay muted loop playsinline></video>
+ <a class="nav-button radars" href="https://radar.pestrel.com/">Radars</a>
+ <a class="nav-button static" href="/">Static Chart</a>
+ <div class="hint">click or Shift: pause / resume</div>
+ <div id="paused-indicator">⏸</div>
+ <script>
+ const v = document.getElementById('vid');
+ function toggle() {
+ if (v.paused) { v.play(); document.body.classList.remove('paused'); }
+ else { v.pause(); document.body.classList.add('paused'); }
+ }
+ v.addEventListener('click', toggle);
+ window.addEventListener('keydown', (e) => {
+ if (e.key === 'Shift' && !e.repeat) { e.preventDefault(); toggle(); }
+ });
+ </script>
+</body>
+</html>
diff --git a/setup.sh b/setup.sh
index a9b2fec..9da9e70 100755
--- a/setup.sh
+++ b/setup.sh
@@ -63,6 +63,12 @@ chown "$OWNER:$OWNER" /srv/www/pestrel
install -d -o "$OWNER" -g "$OWNER" /opt/radar
install -d -o "$OWNER" -g "$OWNER" /var/lib/radar
install -d -o "$OWNER" -g "$OWNER" /srv/www/radar
+
+if [ -d /mnt/enclave ]; then
+ install -d -o "$OWNER" -g "$OWNER" /mnt/enclave/synoptic /mnt/enclave/synoptic/pairs /mnt/enclave/synoptic/out
+else
+ echo "WARNING: /mnt/enclave does not exist; synoptic-morph will need its workspace path configured."
+fi
install -d -o "$OWNER" -g "$OWNER" /srv/www/radar/sydney
install -d -o "$OWNER" -g "$OWNER" /srv/www/radar/brisbane
install -d -o "$OWNER" -g "$OWNER" /srv/www/radar/canberra
@@ -82,11 +88,16 @@ install -o "$OWNER" -g "$OWNER" -m 644 "$SCRIPT_DIR/radar.cairns.html" /srv/ww
echo "==> Installing radarFetch.sh..."
install -o "$OWNER" -g "$OWNER" -m 755 "$SCRIPT_DIR/radarFetch.sh" /opt/radar/radarFetch.sh
+echo "==> Installing synopticMorph.sh and morph.html..."
+install -o "$OWNER" -g "$OWNER" -m 755 "$SCRIPT_DIR/synopticMorph.sh" /opt/synoptic/synopticMorph.sh
+install -o "$OWNER" -g "$OWNER" -m 644 "$SCRIPT_DIR/morph.html" /srv/www/pestrel/morph.html
+
# ---------------------------------------------------------------------------
# Systemd units
# ---------------------------------------------------------------------------
echo "==> Installing systemd unit files..."
for unit in synoptic.service synoptic.timer synoptic-retry.service synoptic-retry.timer \
+ synoptic-morph.service \
radar.service radar.timer radar-retry.service radar-retry.timer; do
install -m 644 "$SCRIPT_DIR/systemd/${unit}" "/etc/systemd/system/${unit}"
echo " installed ${unit}"
diff --git a/synopticMorph.sh b/synopticMorph.sh
new file mode 100644
index 0000000..012c106
--- /dev/null
+++ b/synopticMorph.sh
@@ -0,0 +1,138 @@
+#!/bin/bash
+set -euo pipefail
+shopt -s nullglob
+
+# SJP 2026-06-10
+# Build a 30-day morph timelapse of BOM synoptic charts.
+# Cache structure: /mnt/enclave/synoptic/pairs/<chartA>__<chartB>/p_NNNN.png
+# On each call: identify rolling window, fill any missing pairs, evict
+# out-of-window pairs, stitch with linger/dwell, encode mp4.
+# Triggered via OnSuccess= on synoptic.service.
+
+ARCHIVE_DIR="${ARCHIVE_DIR:-/var/lib/synoptic/archive}"
+WORK_ROOT="${WORK_ROOT:-/mnt/enclave/synoptic}"
+PAIRS_DIR="$WORK_ROOT/pairs"
+OUT_DIR="$WORK_ROOT/out"
+PUBLISH_PATH="${PUBLISH_PATH:-/srv/www/pestrel/morph.mp4}"
+
+WINDOW_DAYS="${WINDOW_DAYS:-30}"
+WINDOW_CHARTS=$((WINDOW_DAYS * 4))
+MORPH_N="${MORPH_N:-23}"
+FPS="${FPS:-12}"
+LINGER="${LINGER:-8}"
+DWELL="${DWELL:-36}"
+FADE_IN="${FADE_IN:-1}"
+FADE_OUT="${FADE_OUT:-2}"
+
+require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || { echo "Required: $1" >&2; exit 127; }
+}
+require_cmd magick
+require_cmd ffmpeg
+require_cmd flock
+
+mkdir -p "$PAIRS_DIR" "$OUT_DIR"
+
+exec 9>"$WORK_ROOT/.lock"
+flock -n 9 || { echo "Morph already in progress; skipping."; exit 0; }
+
+# --- 1. Rolling window --------------------------------------------------
+mapfile -t window < <(ls -1 "$ARCHIVE_DIR"/[0-9]*.png 2>/dev/null | tail -n "$WINDOW_CHARTS")
+if [ "${#window[@]}" -lt 2 ]; then
+ echo "Need at least 2 charts in window (have ${#window[@]})." >&2
+ exit 1
+fi
+first=$(basename "${window[0]}" .png)
+last=$(basename "${window[-1]}" .png)
+echo "Window: ${#window[@]} charts ($first → $last)"
+
+# --- 2. Identify needed pair slugs --------------------------------------
+needed=()
+for i in $(seq 0 $((${#window[@]} - 2))); do
+ a=$(basename "${window[$i]}" .png)
+ b=$(basename "${window[$((i+1))]}" .png)
+ needed+=("${a}__${b}")
+done
+
+# --- 3. Fill any missing pairs ------------------------------------------
+SLOT=$((MORPH_N + 1))
+new_pairs=0
+for slug in "${needed[@]}"; do
+ dir="$PAIRS_DIR/$slug"
+ sentinel="$dir/p_$(printf '%04d' $SLOT).png"
+ if [ ! -f "$sentinel" ]; then
+ a="${slug%__*}"; b="${slug##*__}"
+ echo " computing pair $a → $b"
+ mkdir -p "$dir"
+ magick "$ARCHIVE_DIR/${a}.png" "$ARCHIVE_DIR/${b}.png" \
+ -morph "$MORPH_N" "$dir/p_%04d.png"
+ new_pairs=$((new_pairs + 1))
+ fi
+done
+echo "Computed $new_pairs new pair(s)"
+
+# --- 4. Evict pairs outside the window ----------------------------------
+declare -A needed_set
+for slug in "${needed[@]}"; do needed_set[$slug]=1; done
+evicted=0
+for dir in "$PAIRS_DIR"/*/; do
+ slug=$(basename "$dir")
+ if [ -z "${needed_set[$slug]:-}" ]; then
+ rm -rf "$dir"
+ evicted=$((evicted + 1))
+ fi
+done
+echo "Evicted $evicted out-of-window pair(s)"
+
+# --- 5. Assemble sequence with linger/dwell -----------------------------
+SEQ_DIR=$(mktemp -d "$WORK_ROOT/seq.XXXXXX")
+trap 'rm -rf "$SEQ_DIR"' EXIT
+
+last_pair_idx=$(( ${#needed[@]} - 1 ))
+out=0
+
+# Per pair: take frames 0..MORPH_N (24 frames), dropping the joint with next.
+# For the very last pair only, also take frame SLOT (the final chartB).
+# Insert LINGER after every j==0 (each source chart appears once, as next
+# pair's j==0). Insert DWELL after the final j==SLOT (the latest chart).
+for i in "${!needed[@]}"; do
+ dir="$PAIRS_DIR/${needed[$i]}"
+ last_frame=$MORPH_N
+ if [ "$i" -eq "$last_pair_idx" ]; then last_frame=$SLOT; fi
+ for j in $(seq 0 $last_frame); do
+ src="$dir/p_$(printf '%04d' $j).png"
+ if [ ! -f "$src" ]; then
+ echo "Missing pair frame: $src" >&2
+ exit 2
+ fi
+ ln -sf "$src" "$SEQ_DIR/seq_$(printf '%06d' $out).png"
+ out=$((out + 1))
+ if [ "$j" -eq 0 ]; then
+ for k in $(seq 1 $LINGER); do
+ ln -sf "$src" "$SEQ_DIR/seq_$(printf '%06d' $out).png"
+ out=$((out + 1))
+ done
+ elif [ "$i" -eq "$last_pair_idx" ] && [ "$j" -eq "$SLOT" ]; then
+ for k in $(seq 1 $DWELL); do
+ ln -sf "$src" "$SEQ_DIR/seq_$(printf '%06d' $out).png"
+ out=$((out + 1))
+ done
+ fi
+ done
+done
+
+dur=$(awk "BEGIN{printf \"%.2f\", $out/$FPS}")
+fout=$(awk "BEGIN{printf \"%.3f\", $out/$FPS - $FADE_OUT}")
+echo "Sequence: $out frames = ${dur}s at ${FPS} fps; fade-out ${FADE_OUT}s from ${fout}s"
+
+# --- 6. Encode ----------------------------------------------------------
+TMP_MP4=$(mktemp "$OUT_DIR/morph.XXXXXX.mp4")
+ffmpeg -y -hide_banner -loglevel error -framerate "$FPS" \
+ -i "$SEQ_DIR/seq_%06d.png" \
+ -vf "fade=t=in:st=0:d=$FADE_IN,fade=t=out:st=$fout:d=$FADE_OUT" \
+ -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$TMP_MP4"
+
+mkdir -p "$(dirname "$PUBLISH_PATH")"
+install -m 644 "$TMP_MP4" "$PUBLISH_PATH"
+rm -f "$TMP_MP4"
+echo "Published $PUBLISH_PATH ($(stat -c %s "$PUBLISH_PATH") bytes)"
diff --git a/systemd/synoptic-morph.service b/systemd/synoptic-morph.service
new file mode 100644
index 0000000..042b697
--- /dev/null
+++ b/systemd/synoptic-morph.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Render rolling 30-day synoptic-chart morph timelapse
+
+[Service]
+Type=oneshot
+User=st33v
+WorkingDirectory=/mnt/enclave/synoptic
+ExecStart=/opt/synoptic/synopticMorph.sh
+# Bootstrap can take ~50 min (119 pair morphs); steady-state is ~1 min.
+TimeoutStartSec=2h
+Nice=10
+IOSchedulingClass=idle
+ExecStopPost=/bin/sh -c 'STATUS=SUCCESS; [ "$$EXIT_STATUS" != "0" ] && STATUS=FAILURE; logger -t synoptic-morph -p user.err "synoptic-morph $$STATUS exit=$$EXIT_STATUS"'
+SyslogIdentifier=synoptic-morph
+StandardOutput=journal
+StandardError=journal
diff --git a/systemd/synoptic.service b/systemd/synoptic.service
index 33e7237..c094ee9 100644
--- a/systemd/synoptic.service
+++ b/systemd/synoptic.service
@@ -1,6 +1,7 @@
[Unit]
Description=Download and render BOM synoptic chart
OnFailure=synoptic-retry.timer
+OnSuccess=synoptic-morph.service
[Service]
Type=oneshot