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