moon_phase.py: add sun_altaz() to compute sun altitude for dark-sky check
moon_phase_monthly.py:
- Replace fixed 22:00-23:00 window with dynamic dark+moon intervals:
sample every 10 min across ±24h of the phase moment, keep blocks where
sun < DARK_SKY_SUN_ALT_DEG and moon > MIN_ALTITUDE — covers the full
night the moon is actually visible in a dark sky regardless of month
- First east frame with quality >= MIN_QUALITY in that window wins
- Illumination% computed at the detection time and shown in the image
caption and Mattermost message
- Remove OBS_TIME_H/M; add DARK_SKY_SUN_ALT_DEG config var
sky-cam.conf: replace MOON_OBS_TIME_LOCAL with MOON_DARK_SKY_SUN_ALT_DEG=-6
and explain the sun-altitude threshold options
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
- moon_composite.py: remove _make_atmosphere_layer() and all atmosphere
parameters from render_phase_closeup(); add when_utc parameter and apply
parallactic angle rotation so the NASA disk is oriented to match east's sky
- moon_phase_monthly.py: change obs time default to 22:00 (aligns with NASA
hourly renders); scan east frames in 22:00-23:00 window; binary go/no-go
(quality >= MIN_QUALITY = post, no clear shot = skip entirely); remove
_atmosphere_opacity_from_quality(), ATMOSPHERE_BLUR, CLOUDY_POST_ENABLED
- sky-cam.conf: update MOON_OBS_TIME_LOCAL to 22:00, remove
MOON_OBS_WINDOW_MIN, remove the atmospheric overlay and cloudy fallback
sections and their config variables
- test_moon_composite.py: single clean test — fetch NASA for 22:00 UTC on
2026-04-29, verify east frame quality, render with parallactic angle; no
procedural fallback
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
Previous approach: scan a 3-day window, find best moon detection,
use that timestamp. Problems: fuzzy composite (multi-day scan could
pick a frame far from actual full moon), no clear tie between the
NASA render and a specific observable moment.
New approach — MOON_OBS_TIME_LOCAL (default 22:30 local):
On the night of the exact phase event, look at east frames in a
±MOON_OBS_WINDOW_MIN (default 30 min) window around the configured
time. The frame closest to that time determines atmosphere opacity;
the same time (rounded to hour) drives the NASA Dial-a-Moon fetch.
This gives one definitive moment per phase per month.
moon_phase_monthly.py:
- PHASE_SPEC stripped to just post_delay + enabled_key (all window/
illumination filtering removed — obs time is the only selector)
- run_phase() replaced with obs-time logic; opacity derived from
detect_moon quality on that single frame
- MOON_FULL_POST_DELAY_DAYS / MOON_QUARTER_POST_DELAY_DAYS default
changed to 1 (post morning after the phase, frames already on disk)
moon_composite.py:
- atmosphere blur default: output_width//10 (192px) → output_width//20
(96px) so cloud shapes survive the blur
sky-cam.conf:
- MOON_OBS_TIME_LOCAL=22:30, MOON_OBS_WINDOW_MIN=30
- Post delay defaults updated to 1
test_moon_composite.py:
- Tries real NASA SVS Dial-a-Moon API first; procedural disc only as
fallback if API unreachable
- Runs detect_moon on the east frame to compute real opacity
- Single output (test_composite_out.jpg)
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
The core mental model was wrong: clouds are between the observer and
the moon, so they occlude the disk — they don't sit behind it.
moon_composite.py:
_make_east_sky_backdrop() → _make_atmosphere_layer()
render_phase_closeup() pipeline is now:
1. Black background
2. NASA moon disk centred (correct phase / libration / shadows)
3. East frame scaled + blurred → composited OVER the disk at
atmosphere_opacity (0.0 = clear, 0.68 = heavy overcast)
Parameters: east_sky_enabled/blur → atmosphere_opacity/blur
moon_phase_monthly.py:
_atmosphere_opacity_from_quality() maps detection quality to opacity:
quality >= 0.85 → 0.00 (clear)
quality 0.70 → 0.20 (light haze)
quality 0.55 → 0.40 (notable cloud, still detected)
quality None → 0.68 (no detection, heavy overcast)
Frame scan now tracks two lists:
qualifying — frames passing quality + illumination (existing)
above_horizon — frames where moon is up but detection failed
When qualifying is empty and MOON_CLOUDY_POST_ENABLED=true, the
above-horizon frame closest to the exact phase moment is used with
opacity 0.45–0.68. The month is always represented — full moon,
first quarter, and third quarter each get a post even in cloudy
months, showing the moon as a faint glow behind cloud.
sky-cam.conf:
MOON_EAST_SKY_ENABLED/BLUR → MOON_ATMOSPHERE_BLUR (opacity is
computed automatically from quality, not configured directly)
New: MOON_CLOUDY_POST_ENABLED=true
test_moon_composite.py:
Generates three outputs at opacity 0.00 / 0.20 / 0.65 so the
full opacity range is visible in one test run.
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
The annular-ring veil approach sampled too small a region (38 px moon
in 3840×2160 gives a tiny annulus) and was invisible in practice.
More fundamentally, using the whole east sky is what "relayed to where
it was taken" actually means.
New approach — _make_east_sky_backdrop():
Scale the full east camera frame to output size, apply GaussianBlur
r = output_width // 6 (≈ 320 px at 1920 wide). The blur erases RTSP
artefacts, OSD overlays, and the wide-angle look while preserving real
sky colour, cloud / haze gradients, and the dark ground silhouette.
The NASA moon disk is pasted sharp on top. The result reads as east
photographed the moon through a telephoto with its actual sky that night.
Clear dark sky → nearly black backdrop (same feel as before).
Thin cloud cover → soft grey-blue haze behind the sharp moon.
Heavy overcast → the moon detection would not qualify, so this case
never reaches rendering.
Config rename: MOON_CLOUD_OVERLAY_* → MOON_EAST_SKY_ENABLED / _BLUR.
moon_phase_monthly.py: CLOUD_OVERLAY_* → EAST_SKY_*.
render_phase_closeup(): cloud_overlay_* params → east_sky_*.
test_moon_composite.py: replace full_moon_closeup.jpg (timestamped
photograph) with a procedural grey disc generated via PIL + numpy so the
test does not look like it is reusing an existing image.
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
The previous commit replaced the caption block in composite_full_moon
but missed render_phase_closeup because that block had no comment to
match the replace_all pattern. Both caption sites now go through
_draw_caption() so the Unicode / font fix applies everywhere.
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
The default PIL bitmap font only covers latin-1, so the em dash (U+2014)
in captions raised UnicodeEncodeError at runtime.
Add _load_font() which tries DejaVu Sans TTF from common system paths
(the fonts-dejavu package is already a listed dependency) and falls back
to the PIL default only when no TTF is found. Add _draw_caption() which
wraps both the font load and the two-pass drop-shadow draw, and sanitises
the string for the bitmap fallback path so it never crashes.
Replace both raw draw.text caption blocks in composite_full_moon() and
render_phase_closeup() with _draw_caption().
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
test_moon_composite.py uses the existing repo sample frames
(21-07-00.jpg east frame with thin cloud cover + full_moon_closeup.jpg
as NASA render stand-in) to produce test_composite_out.jpg without
needing a live NASA API call or moon detection run.
Run: python3 test_moon_composite.py
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
Three changes driven by the same goal: make each monthly moon post
look like east captured it at that exact moment.
Tighter illumination windows (±4% of target phase):
Full moon: 95% → 96–100% (was a 5-point band; now hugs exact full)
Quarters: 40–65% → 46–54% (was a 25-point band; now ±4% of 50%)
This ensures the NASA Dial-a-Moon render timestamp is pulled within
4 percentage points of the true phase, making the fetched image
genuinely represent that night's moon. One fresh fetch per event,
~3/month, cached by hour — never reused across months.
Atmospheric cloud veil from east's surrounding sky:
_extract_cloud_veil() samples the annular sky region just outside
east's moon disk (2×–5× radius), scales it to the output frame,
blurs heavily (GaussianBlur r≈output_width/10) so it reads as haze
rather than an upscaled photo, then blends it over the NASA composite
at an opacity proportional to sky brightness:
sky < 5% mean brightness → no veil (clear dark night)
sky ~10% → ~17% veil (thin haze / airglow)
sky ≥ 20% → 40% veil (max, MOON_CLOUD_OVERLAY_MAX_OPACITY)
After the veil pass the NASA moon disk is re-pasted sharply so the
haze sits naturally behind the crisp lunar surface.
This is the honest answer to "east can't capture high-res clouds":
east's real atmospheric fingerprint becomes the veil texture.
New sky-cam.conf keys:
MOON_CLOUD_OVERLAY_ENABLED=true
MOON_CLOUD_OVERLAY_MAX_OPACITY=0.40
#MOON_CLOUD_OVERLAY_BLUR=0 (0 = auto)
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
The previous east-flavored composite (tight-crop east region + lunar
texture overlay) was honest but visually limited: 38-px source moon, no
real terminator shadows on quarters, and a faint upscaled-halo
background that was less compelling than just a clean lunar render.
New flow:
- East stays the witness (verifies the moon was visible during the
collection window) and supplies the timestamp.
- moon_dialamoon.py fetches the NASA SVS Dial-a-Moon render for that
exact UTC hour. Free, public-domain, real-physics: correct phase,
libration, and crater shadows for any timestamp.
- moon_composite.render_phase_closeup() places that render at ~92% of
frame height on a 1920x1080 black background -- the long-telephoto
look the user asked for.
- One API call per phase event (~36/year), cached forever in
moon-ref/dialamoon/.
Quarter moons now show real 3D crater shadows along the terminator,
which the prior algorithmic phase-shadow couldn't simulate from a
full-moon reference.
Adds MOON_REQUIRE_EAST_VERIFY=true|false toggle so the user can choose
"only post when east saw it" (default, current behavior) vs "post every
cycle regardless of weather over east."
Drops the install-time lunar reference download (no longer needed) and
the now-unused per-phase phase-shadow path stays in moon_composite.py
for reference / future reuse.
https://claude.ai/code/session_015PBVDESC3KLMbq1LpA6qLn
fade_dur = SUNRISE_TARGET_SECS * 0.085 = 0.850 at the default 10s target.
bc outputs .850 without a leading zero, which ffmpeg's afade filter rejects
with "Unable to parse option value .850 as duration". Pipe bc output through
sed to prepend the zero when the value starts with '.'.
https://claude.ai/code/session_01DANuzLCLhhQ7Li12RTcswY
daily_sunrise_video.sh:
- Insert continuous ambient recording extract as 2nd fallback (after today's
scheduled sunrise audio, before live RTSP). Finds the chunk file in
AUDIO_DIR/<cam>/<date>/ whose start time is the latest one at or before
sunrise, then ffmpeg-copies SUNRISE_TARGET_SECS centred on sunrise_sec.
- Renumber remaining fallbacks: live RTSP → 3rd, yesterday → 4th, library → 5th.
sunrise-audio-capture.sh:
- Fix: retention find pipeline could exit non-zero (directory not yet created
or permission error) and with set -euo pipefail cause the service to report
status=1/FAILURE even after a successful capture. Guard with [ -d ] and
add || true so a retention failure never masks a successful recording.
https://claude.ai/code/session_01DANuzLCLhhQ7Li12RTcswY
- daily_sunrise_video.sh: add live RTSP capture as final fallback in production
audio selection (fires when today, yesterday, and library all miss)
- On audio mix failure, retry once with a fresh live RTSP capture before
giving up — handles corrupt/incompatible pre-recorded files
- When mix still fails after retry, append last 30 lines of
sky-cam-audio-capture.service journal to the ntfy notification body
- Move sunrise-time text overlay from 5% to 15% above the bottom edge
https://claude.ai/code/session_01DANuzLCLhhQ7Li12RTcswY
Two new jobs operate on the SUNRISE_CAM, sharing three Python helpers
(moon_phase, moon_detect, moon_composite) and one cached lunar texture:
- moon-track.sh: nightly batch detects the moon in each east frame,
crops a 480x480 box around it, stitches into an mp4 that holds the
moon roughly centred while clouds and stars drift past.
- moon-phase-monthly.sh: daily check that runs whichever phase composite
is due that day. Handles full moon (posts D+3), first quarter (D+2,
best-effort due to daytime-only geometry from east), and third quarter
(D+2). Picks the frame closest in time to the exact phase moment that
meets quality / altitude / illumination thresholds, then composites
the cached lunar texture into it -- sky/halo/parallactic-angle/timing
real from east, surface detail borrowed from the reference image.
Honest-by-design: a 38-px white blob from a wide-field IP camera cannot
be enhanced into crater detail by software. The composite makes the
borrowing explicit and constrains everything else (when, where, sky,
orientation) to match what east actually saw.
install.sh now downloads the skyfield ephemeris (de421.bsp) and the
default lunar reference (Wikipedia CC BY-SA full-moon photo) on first
run. Both can be overridden via .env.
https://claude.ai/code/session_015PBVDESC3KLMbq1LpA6qLn
Drops clear/cloudy/fog/windy and the wind-speed override — those
conditions sound the same on the recordings, so they don't earn a tag.
Also renames "rainshower" → "rainstorm" and rebalances the rain
mapping so 503/504/522 (extreme + heavy showers) get the storm tag.
Final vocabulary: thunderstorm, rain, rainstorm, snow, snowstorm.
Anything else falls through to the un-tagged filename.
Adds weather-tag.sh, a small failure-safe helper that queries OpenWeather's
free "Current weather" endpoint and prints one tag from a fixed vocabulary
(thunderstorm, rain, rainshower, snow, snowstorm, fog, windy, cloudy, clear)
or nothing on any failure.
ambient-record.sh calls it just before each 30-minute chunk so the filename
reflects conditions at the moment of capture, e.g.
east-08-30-00-thunderstorm.m4a
east-12-00-00-clear.m4a
A foggy dawn no longer mislabels the sunny afternoon files.
Off by default. Enable with WEATHER_ENABLED=true and an
OPENWEATHER_API_KEY entry in .env; missing key / API error / disabled
toggle all fall through to the un-tagged filename, never blocking the
recorder.
The retention block ended with `[ test ] && echo`, which under `set -e`
returns exit 1 when `deleted` is 0 — the normal case on most days. As
the script's last command, that bash exit propagated to systemd, which
marked the unit failed and fired the OnFailure ntfy even though the
audio file was written successfully.
Add `|| true` and an explicit `exit 0` so a no-op retention pass is
treated as success.
Adds a quick-edit cheatsheet covering font, color, size, position, and
shadow/border for the sunrise-time overlay so the layout can be tweaked
without grokking ffmpeg's drawtext syntax.
Replaces the fixed 0.3s fade with SUNRISE_TARGET_SECS * 0.085, so a 10s
clip now fades 0.85s in and out. The fade scales automatically if the
target duration is tuned later.
- Shorten audio fade in/out to 0.3s so they fit comfortably in the 10s clip.
- Lift the sunrise-time overlay by ~5% of frame height to clear the
burnt-in timestamp on the source images.
- When today's sunrise recording is missing, fall back to yesterday's
clip before reaching for the library fallback.
- Print "Validating N frames..." before the loop, including active
filter thresholds when grey-checking is on
- Print "X / N frames checked..." every 500 frames so the user can
see the loop is alive during the long ImageMagick pass
- Print "Validation done: N valid frames" after the loop
- Note "(may take several minutes)" on the Step 1 encode echo
- Show the speed factor and target together: "28.8x → 11.08s target"
https://claude.ai/code/session_01U29A8NpgJ41tboiggZNmk8
When SEASONS_GREY_STDDEV_MIN is enabled, ImageMagick's convert outputs
the stddev/mean values without a trailing newline. read exits 1 on EOF-
without-newline even when it successfully populated the variables. With
set -euo pipefail this kills the script silently on the very first image
that enters the grey-filter code path — producing the observed symptom:
script stops after "Images found:" with no error and no video.
Fix: append || true so read's EOF exit code is absorbed. The variables
are still set correctly; only the exit code changes.
https://claude.ai/code/session_01U29A8NpgJ41tboiggZNmk8
When no corrupt or grey frames exist (bad=0, grey=0), the two
conditional-echo lines
[ "$bad" -gt 0 ] && echo "WARNING: ..."
[ "$grey" -gt 0 ] && echo "INFO: ..."
exit with code 1 because [ 0 -gt 0 ] is false and && short-circuits.
With set -euo pipefail the script treats that 1 as an error and exits
immediately with no output — producing exactly the "ran without making
a video, no error, no notification" symptom on any normal day where all
frames are valid.
Fix: append || true to each line so the expression always exits 0.
https://claude.ai/code/session_01U29A8NpgJ41tboiggZNmk8
Without explicit duration in the concat file, ffmpeg assigns timestamps
from EXIF metadata or defaults — producing a variable frame-rate raw
video with uneven frame spacing. Applying setpts without fps= then
preserved that unevenness into the final clip, causing the observed
pause-then-jump-minutes/hours behaviour.
Fix 1: write `duration 0.04` (1/25 s) for every JPEG in the concat
list, making the raw video a true 25 fps CFR stream. The duplicate
final-entry is appended as required by the ffmpeg concat demuxer so the
last content frame keeps its full display duration.
Fix 2: add `fps=25` to the setpts filter in step 2, resampling to
standard 25 fps CFR regardless of the speed factor, which guarantees
smooth, uniform playback in the final clip.
https://claude.ai/code/session_01U29A8NpgJ41tboiggZNmk8
Conditional expressions like `[ "$count" -gt 0 ] && echo ...` return 1
when count is 0 (nothing to delete). Under set -euo pipefail with no
explicit exit 0, that false becomes the script's exit code. systemd sees
failure, OnSuccess= for sunrise2mm.py never fires, and the Mattermost
upload is silently skipped every day.
Fix: add || true to all end-of-script conditional-echo statements and to
the cam_audio cleanup line, then add an explicit exit 0 so the success
path always exits cleanly.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
File size can't reliably reject bad frames (200KB can be good or bad).
Remove SEASONS_MIN_FRAME_BYTES. Instead, the grey/green uniform-colour
filter (SEASONS_GREY_STDDEV_MIN) now skips the expensive ImageMagick
check for files above SEASONS_LARGE_FRAME_BYTES (default 1 MB) since
those are always good. Only sub-1MB files get the content check, making
the filter practical on days with 40k+ images.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
43k images × ffprobe process launch = 30-90 min before encoding starts.
Every corrupt file seen in practice has been 0-byte (caught instantly by
[ ! -s ]). Non-zero corrupt files are rare; if ffmpeg hits one the
existing drift warning flags the short output. Removing ffprobe restores
the fast path: one stat() call per image.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Covers: installing git, first clone, pulling updates, stash vs reset
--hard, checking diffs, switching branches, and a note that .env is
never touched by git pulls.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ