The suntime library uses a simplified approximation that degrades at high
latitudes as sunrise approaches very early hours near the summer solstice.
The computed sunrise drifted progressively earlier than the actual event,
shrinking the capture window until today it ended before the sun rose.
Switch to skyfield's sunrise_sunset almanac (DE421 ephemeris), which is
accurate to within a second at any latitude — the same library already
used by moon_phase.py for sun events. The search window is anchored to
local midnight→midnight UTC so the correct calendar-day sunrise is
always returned regardless of timezone offset.
Also promote skyfield to the core pip install in bootstrap.sh (dropping
suntime, which is no longer needed) and tidy the stale "suntime" hints
in 4-seasons.sh and montage-mvt.sh error messages.
https://claude.ai/code/session_01S4oUbU9xNj91cbV5bj5NRn
- 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
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
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
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
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
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
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
- 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
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
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
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
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
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 pipeline now saves intermediate files permanently before the step most
likely to fail, so a partial failure leaves a recoverable artifact:
- daily_sunrise_video.sh: speed-adjusted video (*-sped.mp4) survives if the
drawtext overlay step fails; deleted automatically on success
- montage-mvt.sh: speed-adjusted video (*-Sped.mp4) survives if the
music-mux + attribution overlay step fails; deleted on success
- 4-seasons.sh / montage-mvt.sh / year-end-join.sh: explicit ffmpeg error
handling with targeted notify.sh calls naming the failed step and the
surviving artifact path
- montage-mvt.sh / 4-seasons.sh / year-end-join.sh: clip-duration-sum vs
music-duration drift check warns when cumulative float error exceeds 2 s
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
Adds six CRF vars, AUDIO_BITRATE, and three MONTAGE_* overlay durations.
Values match previous hardcoded defaults so behavior is unchanged.
The same CRF appeared in several scripts but with intentionally different
values per purpose (28 for draft intermediates, 26 for polished output).
Each script's CRF is now its own variable in conf, so changing one never
silently changes another.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
sky-cam.conf:
- CAMERAS array replaces single CAM_NAME; add a name, add its schedules,
re-run install.sh — no script editing needed
- SCHEDULE_SUNRISE, SCHEDULE_SEASONS_<cam>, SCHEDULE_FULLDAY_<cam> replace
hardcoded times in install.sh
- Notification options commented out (uncomment to enable)
- Lat/lon set to mid-Atlantic (25.0, -38.0) as neutral placeholder
install.sh:
- Loops over CAMERAS to generate per-camera sky-cam-seasons-<cam> and
sky-cam-fullday-<cam> timers with schedules from conf
- Sunrise timer remains a single sky-cam-sunrise unit (SUNRISE_CAM only)
fullday-video.sh, 4-seasons.sh, montage-mvt.sh, year-end-join.sh:
- Each accepts camera name as an argument (systemd passes it via ExecStart)
and falls back to conf default when run manually without an arg
- Camera name propagates through the full call chain:
4-seasons → montage-mvt → year-end-join
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
- sunrise_video.10s.sh: use $TIMEZONE (was hardcoded America/New_York),
$SUNRISE_PRE_MIN/$SUNRISE_POST_MIN for capture window, $SUNRISE_TARGET_SECS
for output duration (was wrong value 8 in a script named 10s)
- fullday-video.sh: remove hardcoded FULLDAY_FPS/RETENTION_DAYS; both now
come from sky-cam.conf
- 4-seasons.sh, montage-mvt.sh: export TIMEZONE after sourcing conf so
season_info.py subprocess picks it up via env rather than falling back
to its hardcoded America/New_York default
- sky-cam.conf: add SUNRISE_PRE_MIN, SUNRISE_POST_MIN, SUNRISE_TARGET_SECS,
FULLDAY_FPS, RETENTION_DAYS
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
sky-cam.conf
- Add SCRIPT_DIR (install location) and CAM_NAME (camera/folder name)
- These are the only values that change when deploying to a new machine
install.sh (new)
- Sources sky-cam.conf, generates all systemd .service and .timer units
with correct ExecStart paths, then enables and starts the timers
- Run once after editing sky-cam.conf; run again if paths change
- Supports --system flag for system-wide install
All .sh scripts + sunrise2mm.py
- No hardcoded paths remain
- script_name=$(basename ...) derivation replaced by $CAM_NAME from conf
- sunrise_video.10s.sh now sources sky-cam.conf for BASE_DIR, CAM_NAME,
and SCRIPT_DIR (replaces all /home/motion/... references)
- sunrise2mm.py builds video path from BASE_DIR + CAM_NAME in conf
systemd/ template files kept as reference but no longer need editing
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
sky-cam.conf (new)
- Single file replaces mattermost_config.txt reference, notify_config.txt,
and the hardcoded BASE_DIR/MUSIC_DIR/TIMEZONE scattered across scripts
- Bash-sourceable and Python key=value readable (same format)
All .sh scripts
- source sky-cam.conf at startup; BASE_DIR / MUSIC_DIR from config
sunrise2mm.py
- Reads sky-cam.conf relative to script location; no hardcoded path
notify.sh
- Sources sky-cam.conf; Mattermost credentials come from there directly,
no separate mattermost_config.txt lookup needed
systemd/sky-cam-notify-failure@.service (new)
- Template unit called by OnFailure= in each service
- Sends notification via notify.sh when any sky-cam job fails
- All three .service units now have OnFailure=sky-cam-notify-failure@%n.service
Removed: notify_config.txt, sky-cam.crontab (superseded)
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
season_info.py
- Add ASTRO_YEAR: year of the Winter solstice that started the current cycle
(Spring/Summer/Autumn 2026 → 2025; Winter Dec-2026 → 2026)
4-seasons.sh
- Daily clip output path now includes ASTRO_YEAR:
movies/$name/$ASTRO_YEAR/$SEASON/Mvt$N/
montage-mvt.sh (full rewrite)
- Attribution is now a drawtext overlay (top 6 s of the video, fade in/out)
instead of an appended black card — no frame-size matching needed, and
year-end join requires no special handling
- Output path mirrors the new year-based folder structure
- Sends notification via notify.sh when done
- Automatically triggers year-end-join.sh after Autumn Mvt 3
year-end-join.sh (new)
- Concatenates all 12 movement montages for a given ASTRO_YEAR
- Output: movies/$name/$ASTRO_YEAR/$name-$ASTRO_YEAR.mp4
- Sends notification when done
notify.sh + notify_config.txt (new)
- Generic best-effort notification helper: ntfy, email, Mattermost text post
- All methods disabled by default; user fills in notify_config.txt
sky-cam.crontab + systemd/ (new)
- Crontab file: sunrise at 09:00, 4-seasons at 01:00, fullday at 02:00
- systemd .service + .timer units for all three daily jobs
- montage/year-end triggered internally, not by cron
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
season_info.py (new):
- Computes actual equinox/solstice dates via Jean Meeus approximation
- Converts to LOCAL timezone (pytz / zoneinfo) so the calendar date reflects
what the camera sees on the ground, including DST transitions
- Timezone defaults to America/New_York; override via $TIMEZONE env or 2nd arg
- Divides each season into 3 equal movements; handles Winter's year boundary
- Outputs IS_LAST_DAY=true on the final day of each movement
4-seasons.sh:
- Uses season_info.py for all date/season logic (no hardcoded day-of-year)
- Passes $yesterday to season_info.py so astronomical dates are correct
- Removed forced 1280x720 scale; clips stay at native camera resolution
- Auto-triggers montage-mvt.sh "$yesterday" on last day of movement so the
correct movement (not the next day's) is compiled
montage-mvt.sh:
- Accepts optional YYYY-MM-DD argument (passed by 4-seasons.sh) so it
resolves the correct season/movement when run the morning after last day
- Detects source resolution from first daily clip via ffprobe; attribution
card is generated at that exact resolution — no resizing of main video
- Font sizes proportional to frame height so text looks right at any res
- License corrected to CC BY-SA 3.0 (was CC BY-NC-SA 4.0)
fullday-video.sh:
- Removed 30-minute target; video length is natural (num_images / FPS)
- Default FULLDAY_FPS=5 (configurable at top of script)
- Retains RETENTION_DAYS=10 auto-delete for full-day videos only
- Sunrise 10s uploads and montage videos are never auto-deleted
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
4-seasons.sh:
- Fix per-video target duration: divide by days_in_movement (~30) not
days_in_season (~90), so concatenated clips sum to the movement's
music duration
- Force consistent 1280x720 output (scale+pad) to prevent resolution
mismatches when montage-mvt.sh concatenates across days
- Use ffprobe (not ffmpeg stderr parsing) for reliable duration reading
- Add music-dir fallback to repo-local music/ folder
- Remove the 2× music-doubling workaround (root cause fixed above)
montage-mvt.sh — complete rewrite:
- Proper season/movement detection matching 4-seasons.sh logic
- Step 1: concat all *-final.mp4 clips, normalise to 1280x720
- Step 2: speed-adjust concatenated video to exactly match music duration
- Step 3: merge with Vivaldi movement audio + 2 s video/audio fade in/out
- Step 4: generate 7-second black attribution card (drawtext) crediting
John Harrison / Wichita State University Chamber Players / FMA /
CC BY-NC-SA 4.0
- Step 5: concat montage + attribution into final file
- Temp files cleaned up via EXIT trap
fullday-video.sh — new script:
- Full-day timelapse from yesterday's images targeting ~30 minutes
- Per-frame duration = 1800 / num_images (clamped 0.017–10 s/frame)
- 1280x720 output, video-only (no audio)
- Auto-deletes fullday videos older than 10 days
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ