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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
|
---
title: "Synoptic morph timelapse — PoC to production"
date-created: "2026-06-10"
type: "memoir"
status: "complete"
author:
- "st33v"
- "claude-opus-4-7"
model:
- "claude-opus-4-7"
tldr: "st33v's long-held fantasy of a slowly-morphing synoptic-chart timelapse went from idea to a wired-up production pipeline in one session. Three PoC iterations tuned framerate, dwell, fade, and pause controls; storage reality on cremonde forced a workspace migration to /mnt/enclave; final shape is a rolling 30-day cache of pair morphs at /mnt/enclave/synoptic/pairs/, triggered automatically off synoptic.service via OnSuccess=, published at pestrel.com/morph.html."
chat-url: "https://claude.ai/chat/..."
session-kind: "creative"
side-quests: 1
reader-targets:
- "st33v"
- "claude-code"
- "scrivener"
related:
entities:
- "cremonde"
- "stan"
- "pestrel.com"
concepts:
- "rolling pair-morph cache"
- "fade-in across the loop boundary"
songs: []
tags:
- "bom"
- "synoptic"
- "morph"
- "imagemagick"
- "ffmpeg"
- "systemd"
provenance:
- "doc/radar-four-cities-and-nav.md (earlier today)"
- "doc/bom-radar-rollout.md (previous day)"
---
# Synoptic morph timelapse — PoC to production
## TL;DR
A "just one more thing" at the end of the radar follow-up session turned into the most aesthetic piece of work in this whole arc. st33v has fantasised for years about a slowly-morphing animation of the synoptic charts; ImageMagick's `-morph` plus ffmpeg's `apng`/h264 made it almost trivial once we stopped hitting resource limits and started caching the pair morphs. The output now lives at pestrel.com/morph.html, regenerates automatically after each new chart, and uses ~1 minute of CPU per 6-hour cycle in steady state.
## Context
Followed straight on from the radar-four-cities-and-nav session. st33v asked, almost as an afterthought: are we discarding the synoptic charts after refresh? We weren't — 304 archived PNGs going back 75 days, plus the source PDFs. He surfaced the long-held vision: a beautiful slowly-morphing animation, mostly static (continent outline, lat/lon grid) but with fronts and pressure systems sliding across the chart between 6-hour observations.
## The PoC arc
Three iterations, each driven by st33v's feedback after watching the previous one.
### PoC 1 — proving the technique
8 charts (2 days), `-morph 11`, 12 fps. ImageMagick wrote it cleanly in 79 seconds. 7-second output. Confirmed the aesthetic instinct: continent and grid sit still, isobars slide convincingly, H/L labels smear a bit but in a way that reads as aesthetic rather than broken. st33v's "transformative work" framing dispatched the §10 copyright concern from the original radar spec — derivative enough, less worrying.
### PoC 2 — slowing down
> "it's hard to take it in" — st33v
Halved the framerate to 6 fps and added a 4-frame linger on each source chart so the "real" frames dwell ≈0.8 s before morphing. Confirmed working, length doubled to ~19 s for 2 days. Added an autoplay-muted-loop HTML wrapper at `/morph-poc.html`.
### PoC 3 — three days, smoother, fades
st33v's framing: *"the three-day duration is quite good for putting the current frame into context."*
Bumped to 12 fps with `-morph 23` for smoother motion within the same wall-clock duration. Hit ImageMagick's resource policy: `magick *.png -morph 23` on 12 inputs aborted with SIGABRT. Fix was structural — process pair-by-pair (`magick chartN.png chartN+1.png -morph 23 …` per gap), then concat. Same output, well under per-process limits, and *naturally cacheable* since each pair is independent. That accidental discovery shaped the production design.
Added: 1-second fade-in from black at the start, 2-second fade-out at the end, 3-second dwell on the latest chart. The fade-in deliberately re-applies at the loop boundary so each cycle "resets" rather than jump-cuts.
### PoC controls
- **Pause/resume**: mouse-click on the video works; Shift-key handler is there but appears to be eaten by i3wm or Firefox in st33v's setup. Click is the reliable path; ⏸ overlay confirms the paused state.
- **Cross-page buttons**: Radars (top: 60%) and Static Chart (top: 75%) on the morph page; Radars and Monthly history on the synoptic page. All bottom-left-edge, dark translucent, dashed inner border for visual lightness.
## The storage reckoning
st33v mentioned "I have 34GB free at present" while we discussed PDF retention. `df` told a different story: cremonde root was at **977 MB free** (90% full), `/tmp` was tmpfs at 88% with 1.7 GB of PoC frames. The 34 GB was actually on `/mnt/enclave/` — a separate 65 GB ext4 partition, root-owned (`drwxr-xr-x 6 root root`).
This was a tractable kind of confusion, but a useful one: storage planning for the production morph (≈7 GB of pair cache for 30 days) had to land on enclave, not root. We had st33v sudo-create `/mnt/enclave/synoptic/{pairs,frames,out}` owned by him, then moved the cached pairs out of tmpfs:
```
mv /tmp/morph-poc/pairs/* /mnt/enclave/synoptic/pairs/
rm -rf /tmp/morph-poc
```
`/tmp` recovered to 9 MB used. The bubble was diagnosed as one-off — production writes to `/mnt/enclave/synoptic/` natively, so tmpfs won't get filled again.
## Production shape
Written and shipped as `e433a99`:
- **`synopticMorph.sh`** — content-addressed pair cache at `pairs/<chartA>__<chartB>/p_NNNN.png`. Each run picks the last 120 charts, fills any missing pair (steady-state: one), evicts any pair whose slug isn't in the current window, symlinks frames into a temp `seq/` with linger/dwell, encodes via ffmpeg. `flock` guards against concurrent runs.
- **`systemd/synoptic-morph.service`** — oneshot, `TimeoutStartSec=2h` to cover the ~50-minute bootstrap, `Nice=10` and `IOSchedulingClass=idle` so it doesn't fight the radar timer or anything else for resources.
- **`synoptic.service`** gains `OnSuccess=synoptic-morph.service` — so every successful chart fetch triggers a morph re-render, asynchronously.
- **`morph.html`** at pestrel.com/morph.html → /morph.mp4; **index.html** "Monthly history" button at `top: 75%`, "Radars" at `top: 60%`.
Bootstrap kicked off manually at end of session. ~50 min expected.
## Resolutions
- **Per-pair morph over bulk morph.** Forced by the policy abort, but kept because it naturally enables caching. Decisive structural win.
- **Rolling cache, not delete-after-encode.** Only the oldest pair needs to be evicted each cycle; the rest stays. Trades ~7 GB of disk on enclave for ~24 min/cycle of CPU.
- **OnSuccess= chain, not separate timer.** Morph runs strictly after a successful chart, never on a stale archive.
- **APNG/mp4 split.** Radar loops use APNG (small files, no audio track). Synoptic morph uses mp4/h264 (variable-rate would have ballooned with 4000-frame APNG; h264 keeps it small and works with native `<video>` controls).
- **Click-to-pause as the primary control.** Shift handler retained as best-effort; window manager interception is treated as the user's responsibility, not the page's.
- **`/mnt/enclave/synoptic/`** as the canonical workspace. `/tmp` reverts to tmpfs scratch.
## Cheat sheet — debugging a future morph cycle
```bash
# Bootstrap or refresh manually
sudo systemctl start --no-block synoptic-morph.service
journalctl -u synoptic-morph.service -f
# Inspect cache state
ls /mnt/enclave/synoptic/pairs/ | wc -l # ~119 dirs in steady state
du -sh /mnt/enclave/synoptic/pairs/ # ~7 GB
ls /mnt/enclave/synoptic/pairs/ | sort | tail -3 # newest pairs
# Verify output
ffprobe /srv/www/pestrel/morph.mp4 2>&1 | grep -E "Duration|Stream"
# Tune
sudo systemctl edit synoptic-morph.service # add Environment=FPS=8 etc
```
The script reads `WINDOW_DAYS`, `MORPH_N`, `FPS`, `LINGER`, `DWELL`, `FADE_IN`, `FADE_OUT`, `PUBLISH_PATH` from the environment, so tuning doesn't require editing the script.
## Operational learnings
- **`-morph N` on a sequence has a hidden resource ceiling.** Arch's `/etc/ImageMagick-7/policy.xml` caps in-memory pixel area; pair-by-pair invocation dodges it cleanly. The error mode is SIGABRT with a one-line abort message, not a graceful failure — easy to miss in journal output.
- **The pair-morph idiom is the right primitive for caching.** Once we had it, the rolling-window design followed naturally; eviction is just "remove dirs whose slug isn't in needed_set".
- **`OnSuccess=` for chaining oneshots is the underused systemd primitive.** Synoptic→morph is a perfect fit: morph is *strictly downstream* of synoptic, runs on different timing semantics (CPU-bound rather than network-bound), and should never run on a failed chart fetch.
- **`Nice=10` + `IOSchedulingClass=idle` for low-priority background work.** Doesn't slow the visible work; only uses cycles that would otherwise sit idle.
- **Fade-in re-applies at the loop boundary in browsers.** This is exactly what we want for an autoplay-muted-loop video — gives each cycle a "breath" rather than a jump-cut. Worth designing for explicitly.
- **tmpfs is the right default for `/tmp`.** Anything that wants to persist or grow large should write to a real filesystem; the PoC putting 1.7 GB in tmpfs was diagnosed and fixed by relocating the workspace, not by changing tmpfs.
## Appendix A — side-quest: a "latest transition" mp4
*Surfaced from st33v's "Also can you add the fade-in/fade out to the raw a-b morph" remark, then deferred under "leave the monthly stitch and controls for now".*
The cleanest read: produce a small (~3 s) standalone mp4 of just the most recent 6-hour transition, fades baked in, served alongside `morph.mp4`. Cheap to add — one extra pair-dir → seq → encode pass on the most recent pair only, using the existing cached PNGs.
Useful for:
- a "what just changed in the last 6h" view on the synoptic page, in-line below the static chart;
- embedding in social-media-style cards;
- generating the music-bed cue points later, since each transition has a stable duration.
Don't build unless a use case crystallises. The infrastructure (cached per-pair PNGs) supports it for free.
|