#!/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)"