Finds the most recent full moon (within 35 days), prints illumination,
phase angle, alt/az, and parallactic angle, then calls sun_events_in_range()
for the surrounding day. The sun_events call is the direct regression
path for the sunrise_sunset() observer fix (bare topos, not earth+topos).
https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK
skyfield's sunrise_sunset() internally computes ephemeris['earth'] + topos,
so passing the already-combined _observer caused a double-add ValueError.
Store _topos separately and use it only for almanac calls; _observer
(earth + topos) continues to be used for direct .at() observations.
https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK
moon_phase.py: add sun_events_in_range() — returns actual sunset/sunrise
transitions via skyfield almanac, accurate to within a minute
moon_phase_monthly.py:
- _dark_moon_intervals(): replace sun-altitude threshold sampling with
actual sunset/sunrise times; dark window = sunset + DARK_START_MIN to
next sunrise; moon-above-horizon check still sampled every 10 min within
each night; polar fallback if no sun events found
- run_phase(): sort dark-window frames by proximity to exact phase moment
before scanning; first clear detection = the frame temporally closest to
the moon being precisely full/at quarter, not just the first in time order
- Replace DARK_SKY_SUN_ALT_DEG with DARK_START_MIN (default 30 min)
sky-cam.conf: replace MOON_DARK_SKY_SUN_ALT_DEG with MOON_DARK_START_MIN=30;
update comments to explain ±24h window, sunset+30 start, closest-frame logic,
and how the 01:30 and 23:55 edge cases are handled
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
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
Allows processing any past date instead of always yesterday:
./4-seasons.sh east 2026-04-19
Useful for backfilling days with existing images.
Systemd (no date arg) continues to default to yesterday.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
duration=INTERVAL in the concat file made a 0.1fps raw video, which
x264 couldn't speed up 4000x cleanly and produced 20s of the last
frame instead. The original script used plain 'file path' entries
with no duration, letting ffmpeg default to 25fps internally. The
speed_factor calculation against raw_duration compensates correctly
either way, but only the standard-fps raw video survives the extreme
setpts transform. Also removed the coverage_pct check which was
based on images*INTERVAL and became meaningless without duration.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Accessing ${images[-1]} on an empty array under set -u causes a bash
error. Guard with an explicit check so a fully-corrupt image directory
exits with a clear message instead of a cryptic crash.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
fps=25 caused ffmpeg to discard 15 of every 16 source frames, making
the final clip jump by ~2.5 minutes per frame. The original script
had no fps filter, so all source frames were preserved. Without it,
the setpts speed-adjust keeps every frame; the effective framerate in
the output rises proportionally (e.g. ~400fps for a full day at 10s
intervals into an 11s clip), which is fine for H.264 and all
downstream montage steps.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
The SEASONS_GREY_STDDEV_MIN and SEASONS_GREY_DARK_FLOOR settings are
now commented out so the filter is fully inert by default and requires
a deliberate edit to activate.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
A stddev-only filter would wrongly drop near-black night frames.
Now only skips a frame when BOTH stddev < GREY_MIN AND mean brightness
> GREY_DARK_FLOOR (default 30/255). Night frames are dark so they
always pass; bad-signal grey frames are mid-bright so they are caught.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
The previous size check caught empty files but not corrupt JPEGs with
a partial header (common first frame after a camera reconnect).
Replace with ffprobe codec detection — if ffprobe can't identify a
video stream in the file it's skipped with a clear "corrupt" message.
Also adds SEASONS_GREY_STDDEV_MIN (default 0 = off): when enabled,
frames with near-zero pixel standard deviation (uniform grey = bad
RTSP signal) are silently dropped before encoding.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Corrupt or 0-byte JPEGs (e.g. first frame after a camera reconnect)
caused ffmpeg concat to silently truncate the output, missing all
images after the bad file. Now filters those out before building the
concat list and logs a warning + notification when raw video covers
less than 50% of the expected image count.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
read_config / _read_conf now strips trailing inline shell comments
(whitespace + # + anything) before processing the value. Without this,
a line like:
SUNRISE_CAM=east # which camera faces east
produced SUNRISE_CAM = 'east # which camera faces east',
causing sunrise2mm.py to build a path with the comment embedded in it.
The regex \s+#.*$ requires at least one whitespace before # so passwords
or URLs containing # (e.g. %23 URL-encoded) are unaffected.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Audio:
AUDIO_DIR/<cam>/YYYY-MM-DD/<cam>-HH-MM-SS.m4a
Movies — cam name now always leads the filename:
daily_sunrise_video.sh:
east-2026-04-22-daily-sunrise.mp4
east-2026-04-22-daily-sunrise-sped.mp4 (intermediate)
east-2026-04-22-daily-sunrise-test.mp4 (demo)
4-seasons.sh:
east-2026-04-21_Mvt2-Day2of31-final.mp4 (was: 2026-04-21_east_Mvt2-...)
montage-mvt.sh:
east-2026-03-20_Spring_Mvt1-Sped.mp4
east-2026-03-20_Spring_Mvt1-Montage.mp4
year-end-join.sh:
east-2026.mp4 (already had cam at front)
sunrise2mm.py updated to look for the new east-YYYY-MM-DD-daily-sunrise.mp4
filename. Retention find patterns (wildcards) already match both formats.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
New AUDIO_DIR variable (like BASE_DIR/MOVIES_DIR) is the root for all
audio recordings. Set in .env to put audio on a separate drive.
Directory layout:
AUDIO_DIR/<cam>/YYYY-MM-DD/HH-MM-SS.m4a — ambient recordings
AUDIO_DIR/sunrise/<cam>/YYYY-MM-DD/sunrise-audio.m4a — sunrise clips
sky-cam.conf:
- Add AUDIO_DIR="${AUDIO_DIR:-$BASE_DIR/audio}" in storage section
- Remove AMBIENT_DIR (replaced by AUDIO_DIR)
- Add SUNRISE_AUDIO_RETENTION_DAYS=7 (sunrise clips kept separately from ambient)
- Add per-camera ambient retention examples:
AMBIENT_RETENTION_DAYS_south=60 (keep bird recordings longer)
ambient-record.sh:
- Use AUDIO_DIR instead of AMBIENT_DIR
- Per-camera retention: AMBIENT_RETENTION_DAYS_<cam> overrides global default
sunrise-audio-capture.sh:
- Save clips to AUDIO_DIR/sunrise/<cam>/<date>/sunrise-audio.m4a
- Rolling retention of sunrise clips (SUNRISE_AUDIO_RETENTION_DAYS)
daily_sunrise_video.sh:
- Look for cam_audio in new AUDIO_DIR/sunrise path first, fall back to
old BASE_DIR path so existing recordings keep working
install.sh:
- Pre-create AUDIO_DIR/<cam> and AUDIO_DIR/sunrise/<SUNRISE_CAM> dirs
- Add AUDIO_DIR to .env.example storage section
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
daily_sunrise_video.sh:
- Test output now saves to DEMO_DIR/<cam>/ (default: MOVIES_DIR/demoio/<cam>/)
instead of the production sunrise-only directory
- After each test run, deletes test files older than DEMO_RETENTION_DAYS (8)
- Production mode: warns if the last selected frame is >3 intervals before
the window end, which indicates the capture service had a gap near sunrise:
WARNING: last frame is 20min 0s before window end — capture gap near sunrise
Check: systemctl --user status sky-cam-capture-east.service
sky-cam.conf: add DEMO_DIR and DEMO_RETENTION_DAYS settings
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
daily_sunrise_video.sh:
- Fix "Sunrise (UTC)" label — sunrise.py has always returned local time;
now labelled correctly as "Sunrise (local)"
- After image selection, log first and last frame timestamps alongside the
overlay time so the window can be verified at a glance:
Images: 481 (05:22:10 → 06:43:00 overlay: 06:32)
sunrise2mm.py:
- Cleanup now only deletes posts whose message matches "Sunrise YYYY-MM-DD"
(the format this script posts), so other posts with attachments in the
channel are never touched
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
sunrise2mm.py: after a successful upload, scan the channel and delete any
post older than MM_SUNRISE_RETENTION_DAYS that is not pinned and has a file
attachment. Pinned posts are always preserved regardless of age.
sky-cam.conf: add MM_SUNRISE_RETENTION_DAYS=8 (independent of the local
disk SUNRISE_RETENTION_DAYS so the two retentions can differ).
Also modernises sunrise2mm.py: uses the same ${VAR:-default} aware
read_config as sunrise.py, consistent error handling, cleaner structure.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Rule is now consistent throughout:
- *_ENABLED flags are plain true/false in sky-cam.conf — flip them there
- Private values (URLs, credentials, location) go in .env only
sky-cam.conf:
- NTFY_ENABLED, EMAIL_ENABLED, MM_NOTIFY_ENABLED are now plain false (not
using the ${VAR:-} pattern, which implied they could go in .env)
- NTFY_URL, EMAIL_TO, EMAIL_FROM, MM_NOTIFY_CHANNEL_ID use ${VAR:-} pattern
so they are set in .env
- LATITUDE/LONGITUDE comment clarified with example .env lines
install.sh .env.example: updated notification comment to match
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Adds TIMEZONE, BASE_DIR, MOVIES_DIR (commented out) so .env.example
is a complete reference — location, storage paths, RTSP URLs, Mattermost
credentials, and notification endpoints all in one place.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
sky-cam.conf:
- MUSIC_DIR now uses override pattern (MUSIC_DIR="${MUSIC_DIR:-$SCRIPT_DIR/music}")
so the real path can be set in .env without hardcoding it
- LATITUDE/LONGITUDE use override pattern; set real coords in .env to keep
location private and out of git
- NTFY_URL now uses override pattern with empty default; set in .env
- NTFY_ENABLED moved out of comments into active config with false default
sunrise.py:
- Reads .env after sky-cam.conf so .env values override (same source order
as sky-cam.conf itself)
- Handles ${VAR:-default} bash syntax when parsing sky-cam.conf values
All completion notifications now include the full output file path so you
can tell at a glance where the file landed:
- daily_sunrise_video.sh: "filename — Xs, sunrise at HH:MM | /full/path"
- 4-seasons.sh: "filename — Xs | /full/path"
- montage-mvt.sh: "filename | checks | /full/path | verify: ..."
- year-end-join.sh: "filename — Xmin | /full/path" (also adds [cam] to title)
install.sh .env.example now includes LATITUDE, LONGITUDE, MUSIC_DIR, NTFY_URL
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- chmod +x all *.sh and *.py in install.sh so permissions survive git checkouts
- Fix daily_sunrise_video.sh and Python scripts missing executable bit
- Add [CAM_NAME] prefix to all 4-seasons.sh notify.sh calls so multi-camera
setups show which camera each daily clip came from
- Include camera name in output filename: YYYY-MM-DD_<cam>_MvtN-DayXofY-final.mp4
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Per-camera capture interval:
CAPTURE_INTERVAL_<cam> in sky-cam.conf overrides the global value for
a specific camera. capture.sh and 4-seasons.sh both respect it.
Example: south at 30s saves storage; north at 5s gives smoother motion.
4-seasons.sh one-frame bug fix:
Same root cause as the sunrise scripts — concat list had no 'duration'
entries, collapsing all JPEG frames to timestamp 0. Fixed by writing
'duration INTERVAL' per frame. Added fps=25 to the speed-adjust step
so the VFR source resamples to a standard frame rate.
ambient-record.sh (new):
Continuous AAC recorder for natural sounds (birds, rain, wind).
Saves AMBIENT_CHUNK_SECS chunks to AMBIENT_DIR/<cam>/YYYY-MM-DD/HH-MM-SS.m4a.
Reconnects automatically on stream failure. Rolling retention via
AMBIENT_RETENTION_DAYS. Enabled by setting AMBIENT_ENABLED=true and
listing cameras in AMBIENT_CAMS in sky-cam.conf, then re-running install.sh.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Notification config (NTFY_URL, MM_NOTIFY_CHANNEL_ID, etc.) lives in
.env but notify.sh was only sourcing sky-cam.conf, so notifications
were silently skipped even when configured.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
enable --now only starts a service that is not yet running — it leaves
an already-running service untouched, so changes to .env credentials
are never picked up without a manual restart.
Switching to enable + restart means every install.sh run brings capture
services in sync with the current sky-cam.conf and .env: new cameras
start, reconfigured cameras restart, and nothing needs manual follow-up.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
CAM_RTSP_<cam> is set in .env, not sky-cam.conf, but capture.sh was
only sourcing sky-cam.conf — causing the service to fail with "not set"
even after credentials were added to .env.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Root cause: the concat list had no 'duration' entries, so all JPEG
frames collapsed to timestamp 0 and only one frame survived encoding.
Fix: add 'duration CAPTURE_INTERVAL' per entry in both scripts.
Also add fps=25 to the speed-adjust step so the VFR source is resampled
to a standard frame rate — without this, players see ~6fps and may drop
frames to reach the declared rate.
daily_sunrise_video.sh --test [MINUTES]:
Skips sunrise.py lookup and capture-window wait. Uses the last MINUTES
minutes of captured frames (default 2). Overlay shows the mid-point
frame's timestamp. Audio grabbed live from RTSP immediately.
Output: <date>-daily-sunrise-test.mp4 — no notifications or upload.
Usage: ./daily_sunrise_video.sh east --test 2
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- fontcolor: white → yellow (both scripts)
- y position: (h-th)/2 → h-th-18 (vertically centered → lower-right corner)
- test-sunrise.sh: reverted to reading last 60 frames from capture directory
(~10 minutes at 10s interval) instead of live RTSP capture
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Overlay fix (both scripts):
Switch from text='...\n...' to drawtext's textfile= option. Writing
each character on its own line eliminates the \n escape ambiguity in
ffmpeg's filter-string single-quote parser, which was causing 'n' to
appear literally instead of as a line break.
test-sunrise.sh redesign:
Now captures NUM_FRAMES fresh RTSP frames (120s at default interval)
at run time instead of reading pre-existing frames from the capture
directory. The RTSP URL must be set in .env — the script fails early
with a clear message if it is not.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- Overlay text was showing characters run together (e.g. '1n2n') because
inside ffmpeg single-quoted filter text, \n is consumed as just 'n'.
Fix: use \\\\n in bash double-quotes which stores \\n in the variable,
which ffmpeg then parses as \n, which drawtext renders as a newline.
Applied to both test-sunrise.sh (MID_VERT) and daily_sunrise_video.sh
(SR_VERT, replacing the awk one-liner with the same bash approach).
- test-sunrise.sh concat list was missing per-frame duration entries,
causing 12 frames to encode as 0.48s (25fps default) and then slow
down instead of compress. Fixed by writing 'duration INTERVAL' per
frame so 12 frames × 10s = 120s raw → 12× compression to 10s.
- Removed 2>/dev/null from audio ffmpeg call in test-sunrise.sh so
capture failures are visible for diagnosis.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Runs the same 4-step pipeline as daily_sunrise_video.sh but uses the
most recent ~2 minutes of captured frames instead of the sunrise window,
grabs live RTSP audio for the audio mix step, and accepts an arbitrary
output path — so it can be run at any time of day to verify the full
pipeline (speed-adjust, audio mix, vertical time overlay) without
waiting for tomorrow's sunrise.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
capture.sh creates the per-day subdirectory at runtime, but BASE_DIR/<cam>
and MOVIES_DIR/<cam> need to exist first. Create them during install so the
capture service works immediately on first start without needing a separate
manual mkdir step.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
The shell version had to pass the password through a shell variable into
a Python subprocess, which is fragile for special characters.
The Python version uses getpass.getpass() which reads directly from the
terminal at the Python level — no shell variable, no quoting issues, no
risk of special characters being mishandled.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Prompts for a password without echoing, URL-encodes it via Python's
urllib.parse.quote, and prints the result with a ready-to-paste .env
snippet. Password is passed via environment variable to avoid any
shell quoting or injection issues — never written to disk or network.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- Set XDG_RUNTIME_DIR before systemctl --user calls so daemon-reload
and enable --now work when run outside a login session (e.g. via SSH
or sudo)
- Run loginctl enable-linger so user services start at boot without a
login session
- Update .env.example to use single quotes around RTSP URLs and
document URL-encoding for special characters in passwords
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Older sky-cam.conf versions may not define these. Fall back to the
canonical defaults so make-finals.sh works without requiring the user
to update their local conf.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Allows processing a single movement without waiting for the full backup
scan, e.g.:
./make-finals.sh ~/drives/data2-main --cam north --season Autumn --mvt 1
Filters are applied as soon as the relevant variable is known (cam before
calling season_info.py, season/mvt after) so skipped files cost almost
nothing.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
The previous check used || true which masked ffprobe's non-zero exit
code. For files with a missing/partial moov atom ffprobe can print a
duration estimate then exit non-zero — the || true let that through.
Now use || raw_dur="" to honour the exit code, and add a second probe
for an actual video stream codec so files that report a duration but
have no decodable stream are also caught.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Short adagio movements (Summer Mvt 2 at 107s, Winter Mvt 2 at 132s, etc.)
compress each day to only 3-5 seconds of screen time at 1×. Looping the
music N times multiplies the per-day target duration proportionally so the
footage is less aggressively sped up and more watchable.
- sky-cam.conf: add MONTAGE_MUSIC_LOOPS and per-movement overrides
(Summer Mvt2=3×, Winter Mvt2=3×, Autumn Mvt2=2×, Summer Mvt3=2×, Spring Mvt2=2×)
- 4-seasons.sh: multiply target_per_clip by loops
- montage-mvt.sh: compute effective_duration, use -stream_loop for audio,
fix fade_out_start, -t, and post-build duration check
- make-finals.sh: multiply target by loops
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Check ffprobe output before encoding — empty/N/A duration means the
file is corrupt or truncated; skip with a clear message rather than
passing a bad input to ffmpeg.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Scans all *-temp.mp4 files under a backup root regardless of directory
structure, infers camera name from path, uses season_info.py for correct
movement sizing, writes finals to MOVIES_DIR. Handles messy nested paths
(south/south/, movies/south/, etc.) and deduplicates via EXISTS check.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Restructure find loop to iterate known CAMERAS+sunrise in the main shell
so cam_name is visible in the while body. Previously the for loop ran in
a process substitution subshell, making cam_name unavailable and causing
top-level year dirs (e.g. 2026/) to be treated as camera names.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Moves Season/MvtN mp4 files from flat camera dirs into
astro-YEAR/<cam>/Season/MvtN/ using season_info.py for correct
year assignment. Handles cross-year mixing in old backup structure.
Dry-run support; never deletes files.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Re-processes *-temp.mp4 files from a backup location using season_info.py
to compute the correct target duration per clip (music_duration / DAYS_IN_MVT),
producing properly-sized *-final.mp4 files without touching the backup source.
Skips finals that already exist. Fixes drift caused by old pipeline sizing clips
to the wrong movement structure.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- sky-cam.conf: add ENCODE_PRESET=slow (applied to all encodes);
split CRF_MONTAGE (intermediates, H.264) from CRF_MONTAGE_FINAL=22
(archive montage, H.265); add SUNRISE_RETENTION_DAYS=10; improve
CAPTURE_INTERVAL comment with storage/density table
- montage-mvt.sh: steps 1+2 use preset; step 3 final switches to
libx265 + CRF_MONTAGE_FINAL + -tag:v hvc1 for broad compatibility
- 4-seasons.sh: both encode steps use ENCODE_PRESET
- daily_sunrise_video.sh: all encode steps use ENCODE_PRESET; rolling
retention deletes sunrise videos older than SUNRISE_RETENTION_DAYS
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- verify-mvt.sh: remove dead first _info= line (used MVT_START before
defined); fix JPEG size loop to accumulate bytes correctly and display
human-readable total; show size in delete confirmation
- montage-mvt.sh: grep -c uses || echo 0 instead of || true to be
unambiguous about the no-match value under set -e
- capture.sh, capture-watchdog.sh: require camera name arg, remove
silent SUNRISE_CAM fallback
- migrate-seasons.sh: require --cam arg, error if missing
- README.md, sky-cam.conf: remove stale fullday-video.sh references;
README pipeline diagram updated to show verify-mvt.sh
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
No script defaults to SUNRISE_CAM (east) for the seasons pipeline.
Every direction is treated equally — camera name is a required arg,
not an optional override.
- 4-seasons.sh: $1 required, exits with usage if missing
- montage-mvt.sh: arg order changed to <cam> [date]; $1 required
- year-end-join.sh: $2 (cam) required
- verify-mvt.sh: $4 (cam) required
- migrate-seasons.sh: example commands updated to new arg order
- README.md: montage-mvt.sh examples updated
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- sky-cam.conf: CAMERAS now includes east, north, south with staggered
SCHEDULE_SEASONS times (01:00, 01:30, 02:00); fullday schedules and
tuning removed
- install.sh: fullday service/timer generation removed entirely
- fullday-video.sh + systemd units: deleted
- montage-mvt.sh: post-build technical checks (duration drift, video/audio
stream presence) added; notification now includes check results and the
exact verify-mvt.sh command to run
- verify-mvt.sh: new interactive CLI for montage review — shows checks,
plays video, deletes source JPEG folders on approval, or re-runs montage
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
--cam NAME overrides SUNRISE_CAM so the script can be used for any
camera (e.g. north). Summer is now included in the find targets so
any ASTRO_YEAR=2025 Summer clips (Summer 2026) are also migrated.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
The old pattern *Mvt*3* matched .mp3 in every MP3 filename, so sort|head-1
always picked track 01 (Mvt1). Fix by finding all files for the season
then filtering with grep -iE "Mvt[^0-9]*N[^0-9]", which requires the
movement number to be surrounded by non-digit characters (space, dot,
underscore) and cannot match digits embedded in .mp3 or track prefixes.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
All *-final.mp4 clips are valid footage regardless of what speed factor
the old script used — montage-mvt.sh re-adjusts the full concatenated
video to match music duration anyway, so per-clip speed is irrelevant.
Move every ASTRO_YEAR=2025 clip to the correct new movement directory
based on its date. Spring Mvt1 now gets all 30 clips (Mar 21–Apr 19).
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- Filter to ASTRO_YEAR=2025 (Winter 2025-26 onward); skip Autumn 2025 and earlier
- Move files instead of creating symlinks
- Quarantine clips matching Day<N>of92 (Spring clips from broken script with
wrong 92-day movement total) to quarantine-old-scripts/ for review + deletion
- Preserve clips with Dayof92 (no day number, old legacy format) — these are
migrated normally since montage-mvt.sh re-adjusts speed regardless
- Summary shows delete commands for temp files and quarantine folder
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
migrate-seasons.sh uses season_info.py to re-classify every old
*-final.mp4 clip by its YYYY-MM-DD filename prefix, placing a symlink
in the correct new-system directory regardless of how the old system
labelled movements. Handles the boundary mismatches between old and
new movement splits automatically.
Coverage after migration (based on available clips):
Spring 2026 Mvt1 30/31 days (missing Day 1 only — excellent)
Winter Mvt1 ~18/29 days
Winter Mvt2 ~24/29 days
Winter Mvt3 ~20/31 days
Autumn Mvt1 ~24/30 days
Autumn Mvt3 ~18/30 days
Autumn Mvt2 absent — year-end join will not fire for 2024 cycle
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
capture-watchdog.sh: long-running script that checks the newest JPEG mtime
in each camera's output directory every CAPTURE_STALE_SECS/3 seconds (min 5s).
Sends a stall notification via notify.sh if no new frame has arrived within
CAPTURE_STALE_SECS. Sends a recovery notification once new frames resume.
Only alerts if the camera was previously working (avoids false positives on
first start or overnight before the first frame of the day).
sky-cam.conf: CAPTURE_STALE_SECS=30 added next to CAPTURE_INTERVAL with a
note that it should be at least 2x the interval.
install.sh: generates sky-cam-watchdog-<cam>.service for every camera that
has a RTSP URL configured. The watchdog service starts after and alongside
the capture service, restarts on failure, and is enabled/started with the
same loop as the capture service.
README.md: note about watchdog and journalctl command for its logs.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
montage-mvt.sh, year-end-join.sh: CAM_NAME fallback referenced undefined
\$CAM_NAME — crashes under set -euo pipefail on manual runs without args.
Fixed to fall back to \$SUNRISE_CAM (same fix applied earlier to other scripts).
notify.sh: Mattermost notification guard only checked MM_NOTIFY_CHANNEL_ID,
not mattermost_url or access_token. If those are unset, curl would send a
malformed request silently. Added guards for all three required values.
install.sh: seasons/fullday service units had literal \n in After=/Wants=
lines because bash does not interpret \n in heredoc variable expansions.
Changed to \$'...' syntax so actual newlines are written, making valid
systemd unit files. Also: audio capture timer now uses \$SCHEDULE_SUNRISE
instead of hardcoded 03:00, keeping it in sync if the user changes the
schedule.
sunrise-audio-capture.sh: record_start_sec could theoretically go negative
(sunrise very early + large SUNRISE_TARGET_SECS). Added floor-at-zero guard.
daily_sunrise_video.sh: added comment explaining why audio_offset is always 0.
README.md: fix CAM_RTSP_sunrise example to CAM_RTSP_east.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Cameras are now named by direction (east, north, south, west) throughout
config defaults, examples, and documentation. The config key SUNRISE_CAM
is unchanged — it still means 'which camera faces east and gets the sunrise
job'. Only the camera instance name value changes from 'sunrise' to 'east'.
sky-cam.conf: CAMERAS=(east), SUNRISE_CAM=east, SCHEDULE_*_east,
example comments updated to show all four compass points.
README.md: all command examples updated (4-seasons.sh east, journalctl
sky-cam-capture-east, etc.) — phenomenon references ('sunrise window',
'sunrise time') left unchanged.
bootstrap.sh: example updated to show compass directions.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- Step 1 is now dependency installation (apt + pip3) before anything else
- SCRIPT_DIR removed from config table (auto-detected)
- RTSP/Mattermost credentials moved to .env section (step 4)
- install.sh now shown as generating .env.example before credentials are filled in
- Schedule settings explained: SEASONS/FULLDAY process yesterday; SUNRISE waits internally
- How it works: audio capture described as SUNRISE_TARGET_SECS centred on sunrise
- daily_sunrise_video.sh pipeline updated: steps 3a/3b shown separately
- Resilience section replaced with four-tier audio/overlay matrix table
- Camera audio section updated: exact duration, centred split, odd-second rule
- Manual operations: examples use explicit camera name argument
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
4-seasons.sh, fullday-video.sh: CAM_NAME fallback referenced undefined
\$CAM_NAME — under set -euo pipefail this crashes if called manually without
an argument. Changed to fall back to \$SUNRISE_CAM (matches capture.sh).
daily_sunrise_video.sh: add log message when the capture window has already
closed (manual re-run after the fact). Behaviour is unchanged — processing
proceeds immediately — but the operator now gets a clear confirmation rather
than silence.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Previously sunrise-audio-capture.sh recorded the entire capture window
(70+ minutes) and daily_sunrise_video.sh sought to the right position with
a complex offset calculation.
Now: record floor(TARGET/2) seconds before sunrise and ceil(TARGET/2) after —
odd second goes to post-sunrise. The file is exactly SUNRISE_TARGET_SECS long
and already centred, so no offset is needed when mixing.
AUDIO_PRE_BUFFER_MIN removed — it was part of the old whole-window scheme
and is no longer meaningful.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
sky-cam.conf:
- SCRIPT_DIR auto-detects from its own location via BASH_SOURCE — no manual
path entry needed after bootstrap; override still works via environment.
- BASE_DIR defaults to \$SCRIPT_DIR/data; MOVIES_DIR defaults to \$BASE_DIR/movies.
Override either to point at a drive/mount as before.
- SCHEDULE_SUNRISE changed to 03:00 — the script now waits internally until
the capture window closes before touching images, so the schedule just needs
to be early enough, not timed exactly to sunrise.
- Schedule section rewritten to explain what each job does and what day's data
it processes (SEASONS/FULLDAY always process yesterday).
- SUNRISE_PRE_MIN/POST_MIN comments explain the window and its relationship to
SUNRISE_TARGET_SECS.
- SUNRISE_OVERLAY_ENABLED=true added; set false to skip the timestamp entirely.
daily_sunrise_video.sh:
- After calculating the capture window, sleep until end_sec+30s if we fired
before the window finished — guarantees all images are present.
- DT (drawtext filter) is only built when SUNRISE_OVERLAY_ENABLED=true.
- Step 3b skips ffmpeg entirely when overlay is disabled, moving work_video
directly to final_video (faster, no re-encode).
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
daily_sunrise_video.sh: use \$MOVIES_DIR instead of \$BASE_DIR/movies so the
override in sky-cam.conf is respected when videos live on a separate drive.
capture.sh: default camera name falls back to \$SUNRISE_CAM (not the
undefined \$CAM_NAME) when no argument is passed.
sunrise2mm.py: also loads .env after sky-cam.conf so Mattermost credentials
moved to .env are actually visible to the upload script.
sunrise.py: raise a clear error message when LATITUDE/LONGITUDE/TIMEZONE
are missing from sky-cam.conf instead of an opaque KeyError.
4-seasons.sh, montage-mvt.sh: capture season_info.py output before eval so
a Python failure exits cleanly with a diagnostic message rather than
silently continuing with undefined variables.
bootstrap.sh: add system package install step (ffmpeg bc fonts-dejavu) and
show the .env setup step after install.sh generates .env.example.
README.md: correct Python package names and add fonts-dejavu dependency.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
sky-cam.conf now sources .env from the same directory at the bottom of the
file, so RTSP URLs, Mattermost tokens, and notification credentials can live
outside version control. Plaintext credential placeholders removed from conf.
install.sh generates .env.example on every run with the correct CAM_RTSP_*
variable names derived from the CAMERAS list, so users always know exactly
what to put in .env for their specific camera setup.
.gitignore: add .env and sunrise-sounds/ (downloaded audio library).
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
daily_sunrise_video.sh: derive CAM_NAME from $1 (defaulting to SUNRISE_CAM)
instead of an undefined variable; this was causing empty image/output paths.
install.sh: pass $SUNRISE_CAM as argument to daily_sunrise_video.sh so it
matches the pattern used by every other per-camera script.
sunrise2mm.py: use SUNRISE_CAM config key (not the undefined CAM_NAME) to
locate the output directory; fixes silent path mismatch on non-default names.
4-seasons.sh: remove stray `set -x` that was flooding systemd logs with
shell trace output.
README.md: correct Python dependencies from `ephem` to `suntime pytz` to
match what sunrise.py actually imports.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Each stage can now fail independently, giving four upload paths:
audio+overlay, audio-only, overlay-only, or speed-only video.
Step 3a copies the video stream (no re-encode) when mixing audio,
so step 3b failure still leaves a clean audio-mixed file to promote.
Overlay failure notification now reports which quality was uploaded.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ