summaryrefslogtreecommitdiff
path: root/synopticMorph.sh
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 /synopticMorph.sh
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>
Diffstat (limited to 'synopticMorph.sh')
-rw-r--r--synopticMorph.sh138
1 files changed, 138 insertions, 0 deletions
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)"