summaryrefslogtreecommitdiff
path: root/synopticMorph.sh
diff options
context:
space:
mode:
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)"