From e433a99ceddf7168d23377ffc8d585fc80ba8fb2 Mon Sep 17 00:00:00 2001 From: St33v Date: Wed, 10 Jun 2026 19:10:38 +1000 Subject: 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/__/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 --- synopticMorph.sh | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 synopticMorph.sh (limited to 'synopticMorph.sh') 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/__/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)" -- cgit v1.3