diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | index.html | 9 | ||||
| -rw-r--r-- | morph.html | 53 | ||||
| -rwxr-xr-x | setup.sh | 11 | ||||
| -rw-r--r-- | synopticMorph.sh | 138 | ||||
| -rw-r--r-- | systemd/synoptic-morph.service | 16 | ||||
| -rw-r--r-- | systemd/synoptic.service | 1 |
7 files changed, 226 insertions, 3 deletions
@@ -1,3 +1,4 @@ *.png *.pdf hist* +.claude/ @@ -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> @@ -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 |
