summaryrefslogtreecommitdiff
path: root/synopticMorph.sh
blob: 012c10625f87dc15b1d6b79e61c837154dbec021 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
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)"