From 6d85fa525a05c361784c8bbe3c9632975eab692f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 11:04:51 +0000 Subject: [PATCH 1/7] Multi-camera support; schedules in conf; comment out notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_, SCHEDULE_FULLDAY_ 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- and sky-cam-fullday- 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 --- 4-seasons.sh | 5 +- fullday-video.sh | 3 + install.sh | 64 +++++++++++++------- montage-mvt.sh | 6 +- sky-cam.conf | 149 +++++++++++++++++++++++++++-------------------- year-end-join.sh | 4 +- 6 files changed, 144 insertions(+), 87 deletions(-) diff --git a/4-seasons.sh b/4-seasons.sh index 8502960..ceb6c4e 100755 --- a/4-seasons.sh +++ b/4-seasons.sh @@ -14,6 +14,9 @@ SCRIPT_DIR="$(dirname "$(realpath "$0")")" source "$SCRIPT_DIR/sky-cam.conf" export TIMEZONE # make it visible to season_info.py subprocess +# Camera name: first argument overrides conf (systemd passes it via ExecStart). +CAM_NAME="${1:-$CAM_NAME}" + # ── Configuration ────────────────────────────────────────────────────────────── base_dir="$BASE_DIR" music_base_dir="$MUSIC_DIR" @@ -102,5 +105,5 @@ if [ "$IS_LAST_DAY" = "true" ]; then echo "Last day of $SEASON Mvt $MVT_NUM — triggering montage compilation..." # Pass $yesterday so montage-mvt.sh looks up the correct movement # (running the next morning, "today" would already be the next movement). - "$SCRIPT_DIR/montage-mvt.sh" "$yesterday" + "$SCRIPT_DIR/montage-mvt.sh" "$yesterday" "$CAM_NAME" fi diff --git a/fullday-video.sh b/fullday-video.sh index e3bb633..c5418f0 100755 --- a/fullday-video.sh +++ b/fullday-video.sh @@ -10,6 +10,9 @@ set -euo pipefail SCRIPT_DIR="$(dirname "$(realpath "$0")")" source "$SCRIPT_DIR/sky-cam.conf" +# Camera name: first argument overrides conf (systemd passes it via ExecStart). +CAM_NAME="${1:-$CAM_NAME}" + # ── Configuration ────────────────────────────────────────────────────────────── base_dir="$BASE_DIR" # FULLDAY_FPS and RETENTION_DAYS come from sky-cam.conf diff --git a/install.sh b/install.sh index 0dfb66d..ec7b455 100755 --- a/install.sh +++ b/install.sh @@ -6,7 +6,8 @@ # ./install.sh --system # installs to /etc/systemd/system (needs sudo) # # Run this once after editing sky-cam.conf, and again whenever sky-cam.conf -# changes (e.g. SCRIPT_DIR moves). No other file needs editing. +# changes (e.g. SCRIPT_DIR moves, cameras added, schedules changed). +# No other file needs editing. set -euo pipefail @@ -33,7 +34,7 @@ write_service() { local unit="$1" description="$2" script="$3" extra="${4:-}" cat > "$UNIT_DIR/${unit}.service" < "$UNIT_DIR/${unit}.timer" <, SCHEDULE_FULLDAY_ from sky-cam.conf. +# Scripts receive the camera name as $1 so they know which camera to process. +for cam in "${CAMERAS[@]}"; do + sched_seasons_var="SCHEDULE_SEASONS_${cam}" + sched_fullday_var="SCHEDULE_FULLDAY_${cam}" + + if [ -n "${!sched_seasons_var:-}" ]; then + write_service "sky-cam-seasons-${cam}" \ + "${cam}: Four Seasons daily clip" \ + "4-seasons.sh ${cam}" \ + "After=network-online.target\nWants=network-online.target\n" + write_timer "sky-cam-seasons-${cam}" "${cam}: Four Seasons daily clip" \ + "${!sched_seasons_var}" + timers+=("sky-cam-seasons-${cam}") + else + echo " WARNING: SCHEDULE_SEASONS_${cam} not set — skipping seasons timer for ${cam}" + fi + + if [ -n "${!sched_fullday_var:-}" ]; then + write_service "sky-cam-fullday-${cam}" \ + "${cam}: full-day timelapse" \ + "fullday-video.sh ${cam}" + write_timer "sky-cam-fullday-${cam}" "${cam}: full-day timelapse" \ + "${!sched_fullday_var}" + timers+=("sky-cam-fullday-${cam}") + else + echo " WARNING: SCHEDULE_FULLDAY_${cam} not set — skipping fullday timer for ${cam}" + fi +done # ── Reload and enable ───────────────────────────────────────────────────────── echo "" $SC daemon-reload -for timer in sky-cam-sunrise sky-cam-4seasons sky-cam-fullday; do +for timer in "${timers[@]}"; do $SC enable --now "${timer}.timer" \ && echo " enabled + started ${timer}.timer" \ || echo " WARNING: could not enable ${timer}.timer" @@ -108,8 +128,8 @@ echo "" echo "Done. Check status with:" if $SYSTEM_MODE; then echo " sudo systemctl list-timers 'sky-cam-*'" - echo " sudo journalctl -u sky-cam-4seasons.service -f" + echo " sudo journalctl -u sky-cam-seasons-${CAMERAS[0]}.service -f" else echo " systemctl --user list-timers 'sky-cam-*'" - echo " journalctl --user -u sky-cam-4seasons.service -f" + echo " journalctl --user -u sky-cam-seasons-${CAMERAS[0]}.service -f" fi diff --git a/montage-mvt.sh b/montage-mvt.sh index eac3436..0b9001c 100755 --- a/montage-mvt.sh +++ b/montage-mvt.sh @@ -22,6 +22,10 @@ SCRIPT_DIR="$(dirname "$(realpath "$0")")" source "$SCRIPT_DIR/sky-cam.conf" export TIMEZONE # make it visible to season_info.py subprocess +# Args: [date] [camera-name] +# Camera name: second argument overrides conf (passed through from 4-seasons.sh). +CAM_NAME="${2:-$CAM_NAME}" + # ── Configuration ────────────────────────────────────────────────────────────── base_dir="$BASE_DIR" music_base_dir="$MUSIC_DIR" @@ -167,5 +171,5 @@ echo "Done: $output_file" # ── Trigger year-end join on last Autumn movement ──────────────────────────── if [ "$SEASON" = "Autumn" ] && [ "$MVT_NUM" = "3" ]; then echo "Last movement of astronomical year $ASTRO_YEAR — triggering year-end join..." - "$SCRIPT_DIR/year-end-join.sh" "$ASTRO_YEAR" + "$SCRIPT_DIR/year-end-join.sh" "$ASTRO_YEAR" "$CAM_NAME" fi diff --git a/sky-cam.conf b/sky-cam.conf index 57b1d4a..7feaf7f 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -20,13 +20,14 @@ # Then edit ExecStart= in each .service file to match your SCRIPT_DIR, then: # # systemctl --user daemon-reload -# systemctl --user enable --now sky-cam-sunrise.timer # 09:00 daily -# systemctl --user enable --now sky-cam-4seasons.timer # 01:00 daily -# systemctl --user enable --now sky-cam-fullday.timer # 02:00 daily +# systemctl --user enable --now sky-cam-sunrise.timer +# systemctl --user enable --now sky-cam-seasons-sunrise.timer +# systemctl --user enable --now sky-cam-fullday-sunrise.timer +# # one sky-cam-seasons-.timer and sky-cam-fullday-.timer per camera # # Check status: # systemctl --user list-timers 'sky-cam-*' -# journalctl --user -u sky-cam-4seasons.service -f +# journalctl --user -u sky-cam-seasons-sunrise.service -f # # System-wide (replace --user with no flag, prepend sudo): # sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/ @@ -37,44 +38,44 @@ # ----------------------------------------------------------------------------- # # You run / schedule: -# daily_sunrise_video.sh — daily at 09:00 (via systemd) -# grabs today's sunrise images, makes a 10-second -# video, uploads it to Mattermost +# daily_sunrise_video.sh — daily at SCHEDULE_SUNRISE (via systemd, SUNRISE_CAM only) +# grabs images around sunrise, makes a short video, +# uploads it to Mattermost # -# 4-seasons.sh — daily at 01:00 (via systemd) -# makes yesterday's daily clip sized to its share -# of the matching Vivaldi movement's music duration; -# on the last day of a movement, auto-triggers -# montage-mvt.sh +# 4-seasons.sh — daily at SCHEDULE_SEASONS_ (via systemd, per camera) +# makes yesterday's daily clip sized to its share +# of the matching Vivaldi movement's music duration; +# on the last day of a movement, auto-triggers +# montage-mvt.sh # -# fullday-video.sh — daily at 02:00 (via systemd) -# full-day timelapse at fixed fps; deletes files -# older than 10 days automatically +# fullday-video.sh — daily at SCHEDULE_FULLDAY_ (via systemd, per camera) +# full-day timelapse at fixed fps; deletes videos +# older than RETENTION_DAYS automatically # -# install.sh — run once at setup, and again if this file changes -# generates and installs systemd units from conf +# install.sh — run once at setup, and again if this file changes +# generates and installs systemd units from conf # -# stitch-cameras.py — run manually when needed -# stitches north + sunrise into a panorama -# usage: python3 stitch-cameras.py north.jpg sunrise.jpg prefix [focal] +# stitch-cameras.py — run manually when needed +# stitches north + sunrise into a panorama +# usage: python3 stitch-cameras.py north.jpg sunrise.jpg prefix [focal] # # Auto-triggered (do not run directly): -# montage-mvt.sh — called by 4-seasons.sh on last day of each movement -# concatenates all daily clips for the movement, -# speed-adjusts to match music, overlays attribution, -# sends completion notification; on last Autumn -# movement also triggers year-end-join.sh +# montage-mvt.sh — called by 4-seasons.sh on last day of each movement +# concatenates all daily clips for the movement, +# speed-adjusts to match music, overlays attribution, +# sends completion notification; on last Autumn +# movement also triggers year-end-join.sh # -# year-end-join.sh — called by montage-mvt.sh at end of Autumn Mvt 3 -# concatenates all 12 movement montages into one -# Four Seasons year video, sends notification +# year-end-join.sh — called by montage-mvt.sh at end of Autumn Mvt 3 +# concatenates all 12 movement montages into one +# Four Seasons year video, sends notification # # Helpers (called by the scripts above, not run directly): -# notify.sh — sends notifications via ntfy / email / Mattermost -# season_info.py — outputs astronomical season/movement/year for a date -# sunrise.py — outputs today's sunrise time (used by daily_sunrise_video) -# sunrise2mm.py — uploads the sunrise video to Mattermost -# sunrise_overlay.py — burns a timestamp overlay onto the sunrise video +# notify.sh — sends notifications via ntfy / email / Mattermost +# season_info.py — outputs astronomical season/movement/year for a date +# sunrise.py — outputs today's sunrise time (used by daily_sunrise_video) +# sunrise2mm.py — uploads the sunrise video to Mattermost +# sunrise_overlay.py — burns a timestamp overlay onto the sunrise video # # ----------------------------------------------------------------------------- # FILE DEPENDENCIES — who calls whom, and what to update if you rename a file @@ -92,20 +93,20 @@ # and sky-cam-notify-failure@.service # # daily_sunrise_video.sh -# ← called by systemd sky-cam-sunrise.timer (09:00) -# → if renamed: update install.sh (write_service call, line ~80) +# ← called by systemd sky-cam-sunrise.timer (SCHEDULE_SUNRISE) +# → if renamed: update install.sh (write_service call ~line 70) # then re-run install.sh to regenerate the unit # calls → sunrise.py, sunrise_overlay.py, sunrise2mm.py # # 4-seasons.sh -# ← called by systemd sky-cam-4seasons.timer (01:00) -# → if renamed: update install.sh (write_service call, line ~86) +# ← called by systemd sky-cam-seasons-.timer (SCHEDULE_SEASONS_) +# → if renamed: update install.sh (write_service call ~line 85) # then re-run install.sh # calls → season_info.py, montage-mvt.sh # # fullday-video.sh -# ← called by systemd sky-cam-fullday.timer (02:00) -# → if renamed: update install.sh (write_service call, line ~93) +# ← called by systemd sky-cam-fullday-.timer (SCHEDULE_FULLDAY_) +# → if renamed: update install.sh (write_service call ~line 93) # then re-run install.sh # # montage-mvt.sh @@ -149,10 +150,6 @@ # Full path to the directory containing this file and all the scripts. SCRIPT_DIR=/home/motion/drives/local-2tb/sunrise-scripts -# Camera name — subfolder under BASE_DIR (images) and BASE_DIR/movies (videos). -# E.g. "sunrise" → images: $BASE_DIR/sunrise/YYYY-MM-DD/ videos: $BASE_DIR/movies/sunrise/ -CAM_NAME=sunrise - # ── Storage ─────────────────────────────────────────────────────────────────── BASE_DIR=/home/motion/drives/local-2tb MUSIC_DIR=/home/motion/drives/local-2tb/music/4 Seasons @@ -160,10 +157,38 @@ MUSIC_DIR=/home/motion/drives/local-2tb/music/4 Seasons # ── Location & timezone ─────────────────────────────────────────────────────── # Used by sunrise.py to calculate sunrise time each day. # TIMEZONE must be a valid IANA name: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones -LATITUDE=38.123456 -LONGITUDE=-97.654321 +LATITUDE=25.0000 +LONGITUDE=-38.0000 TIMEZONE=America/New_York +# ── Cameras ─────────────────────────────────────────────────────────────────── +# List every camera name here (space-separated inside the parentheses). +# Each name becomes a subfolder under BASE_DIR (images) and BASE_DIR/movies. +# To add a camera: add its name here, add its schedules below, re-run install.sh. +# +CAMERAS=(sunrise) # e.g. CAMERAS=(sunrise north west) +SUNRISE_CAM=sunrise # which camera faces east (gets the sunrise video job) + +# ── Schedules ───────────────────────────────────────────────────────────────── +# SCHEDULE_SUNRISE when to run daily_sunrise_video.sh (SUNRISE_CAM only) +# SCHEDULE_SEASONS_ when to run 4-seasons.sh for each camera +# SCHEDULE_FULLDAY_ when to run fullday-video.sh for each camera +# +# Times are HH:MM:SS (24-hour). Space cameras at least 30 min apart. +# install.sh reads these and generates one systemd timer per camera per job. +# +SCHEDULE_SUNRISE=09:00:00 + +SCHEDULE_SEASONS_sunrise=01:00:00 +SCHEDULE_FULLDAY_sunrise=02:00:00 + +# Uncomment and fill in for each camera you add to CAMERAS above: +#SCHEDULE_SEASONS_north=01:30:00 +#SCHEDULE_FULLDAY_north=02:30:00 +# +#SCHEDULE_SEASONS_west=02:00:00 +#SCHEDULE_FULLDAY_west=03:00:00 + # ── Sunrise video tuning ────────────────────────────────────────────────────── # How many minutes before/after sunrise to include in the daily clip. SUNRISE_PRE_MIN=70 @@ -181,23 +206,23 @@ access_token=your-access-token-here channel_id=your-daily-upload-channel-id-here # ── Notifications — montage / year-end complete + any job failure ───────────── -# Notifications fire when a movement montage or the year video finishes. -# They also fire automatically via systemd OnFailure= if any daily job fails. -# Enable one or more methods below. +# Notifications fire when a movement montage or the year video finishes, +# and via systemd OnFailure= if any daily job fails. +# Uncomment and configure one or more methods to enable. -# ntfy — push notifications, zero signup required for self-hosted: -# https://docs.ntfy.sh/install/ -# or use the free cloud tier at ntfy.sh (pick any topic name) -NTFY_ENABLED=false -NTFY_URL=https://ntfy.sh/your-topic-here +# ntfy — push notifications, zero signup for self-hosted or free cloud tier: +# Self-hosted: https://docs.ntfy.sh/install/ +# Cloud: pick any topic name at ntfy.sh (no account needed) +#NTFY_ENABLED=true +#NTFY_URL=https://ntfy.sh/your-topic-here -# Email — requires the 'mail' command on the system (package: mailutils or s-nail). -# For outbound SMTP configure /etc/ssmtp/ssmtp.conf or msmtp. -EMAIL_ENABLED=false -EMAIL_TO=you@example.com -EMAIL_FROM=skycam@localhost +# Email — requires the 'mail' command (package: mailutils or s-nail). +# Configure outbound SMTP via /etc/ssmtp/ssmtp.conf or msmtp. +#EMAIL_ENABLED=true +#EMAIL_TO=you@example.com +#EMAIL_FROM=skycam@localhost -# Mattermost text post — reuses the URL and token above, but posts to this -# channel (can be the same daily channel or a separate private/admin channel). -MM_NOTIFY_ENABLED=false -MM_NOTIFY_CHANNEL_ID=your-notify-channel-id-here +# Mattermost text post — reuses mattermost_url and access_token above. +# Can be the same daily channel or a separate admin/private channel. +#MM_NOTIFY_ENABLED=true +#MM_NOTIFY_CHANNEL_ID=your-notify-channel-id-here diff --git a/year-end-join.sh b/year-end-join.sh index 6817063..4fda07a 100755 --- a/year-end-join.sh +++ b/year-end-join.sh @@ -17,9 +17,11 @@ source "$SCRIPT_DIR/sky-cam.conf" ASTRO_YEAR="${1:-}" if [ -z "$ASTRO_YEAR" ]; then - echo "Usage: $0 (e.g. $0 2025)" + echo "Usage: $0 [CAM_NAME] (e.g. $0 2025 north)" exit 1 fi +# Camera name: second argument overrides conf (passed through from montage-mvt.sh). +CAM_NAME="${2:-$CAM_NAME}" year_dir="$BASE_DIR/movies/$CAM_NAME/$ASTRO_YEAR" From 46b3a36d0a603ee63feb98d2129eb884709e807b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 11:08:19 +0000 Subject: [PATCH 2/7] Add lat/lon source instructions to conf comment https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- sky-cam.conf | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sky-cam.conf b/sky-cam.conf index 7feaf7f..c386dd6 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -156,7 +156,15 @@ MUSIC_DIR=/home/motion/drives/local-2tb/music/4 Seasons # ── Location & timezone ─────────────────────────────────────────────────────── # Used by sunrise.py to calculate sunrise time each day. -# TIMEZONE must be a valid IANA name: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# +# To find your coordinates: +# Google Maps — right-click your location → the first item shown is "lat, lon" +# Or: maps.google.com, drop a pin, coordinates appear in the URL and sidebar +# +# TIMEZONE must be a valid IANA name. Look yours up at: +# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# (use the value in the "TZ identifier" column, e.g. America/Chicago) +# LATITUDE=25.0000 LONGITUDE=-38.0000 TIMEZONE=America/New_York From fffb19fd38f8ecaa4ca44f2da9044701dcf89db6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:01:27 +0000 Subject: [PATCH 3/7] Add MOVIES_DIR to conf; remove hardcoded 'movies' subdir All scripts now use $MOVIES_DIR instead of $BASE_DIR/movies. Defaults to BASE_DIR/movies but can point to a separate drive. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- 4-seasons.sh | 2 +- daily_sunrise_video.sh | 2 +- fullday-video.sh | 2 +- montage-mvt.sh | 2 +- sky-cam.conf | 1 + sunrise2mm.py | 2 +- year-end-join.sh | 4 ++-- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/4-seasons.sh b/4-seasons.sh index ceb6c4e..b53df3b 100755 --- a/4-seasons.sh +++ b/4-seasons.sh @@ -62,7 +62,7 @@ fi echo "Images found: $total_images" # ── Prepare output directory ────────────────────────────────────────────────── -output_dir="$base_dir/movies/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM" +output_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM" mkdir -p "$output_dir" # ── Build sorted image list ─────────────────────────────────────────────────── diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index 04082bd..da5bf51 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -11,7 +11,7 @@ output_date=$(date +%Y-%m) image_dir="$BASE_DIR/$CAM_NAME/$current_date" # Set output directory -output_dir="$BASE_DIR/movies/$CAM_NAME/$output_date/sunrise-only" +output_dir="$MOVIES_DIR/$CAM_NAME/$output_date/sunrise-only" # Make sure the output directory exists mkdir -p "$output_dir" diff --git a/fullday-video.sh b/fullday-video.sh index c5418f0..a627b5e 100755 --- a/fullday-video.sh +++ b/fullday-video.sh @@ -20,7 +20,7 @@ base_dir="$BASE_DIR" # ── Date / paths ────────────────────────────────────────────────────────────── yesterday=$(date --date="yesterday" +%Y-%m-%d) image_dir="$base_dir/$CAM_NAME/$yesterday" -output_dir="$base_dir/movies/$CAM_NAME/fullday" +output_dir="$MOVIES_DIR/$CAM_NAME/fullday" mkdir -p "$output_dir" # ── Purge old full-day videos ───────────────────────────────────────────────── diff --git a/montage-mvt.sh b/montage-mvt.sh index 0b9001c..dfcd52a 100755 --- a/montage-mvt.sh +++ b/montage-mvt.sh @@ -43,7 +43,7 @@ eval "$(python3 "$SCRIPT_DIR/season_info.py" ${DATE_ARG:+"$DATE_ARG"})" echo "Season=$SEASON Mvt=$MVT_NUM Year=$ASTRO_YEAR (ref: ${DATE_ARG:-today})" # ── Locate files ────────────────────────────────────────────────────────────── -video_dir="$base_dir/movies/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM" +video_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM" output_dir="$video_dir/montage" mkdir -p "$output_dir" diff --git a/sky-cam.conf b/sky-cam.conf index c386dd6..2046e92 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -152,6 +152,7 @@ SCRIPT_DIR=/home/motion/drives/local-2tb/sunrise-scripts # ── Storage ─────────────────────────────────────────────────────────────────── BASE_DIR=/home/motion/drives/local-2tb +MOVIES_DIR=$BASE_DIR/movies # override if videos live on a different drive MUSIC_DIR=/home/motion/drives/local-2tb/music/4 Seasons # ── Location & timezone ─────────────────────────────────────────────────────── diff --git a/sunrise2mm.py b/sunrise2mm.py index 6b3c0d0..d4cafe2 100644 --- a/sunrise2mm.py +++ b/sunrise2mm.py @@ -45,7 +45,7 @@ today_date_str = today.strftime("%Y-%m-%d") # e.g., "2024-11-05" today_month_str = today.strftime("%Y-%m") # e.g., "2024-11" # Set the full folder path for today's video -base_video_folder = os.path.join(config.get('BASE_DIR', ''), 'movies', config.get('CAM_NAME', 'sunrise')) +base_video_folder = os.path.join(config.get('MOVIES_DIR', ''), config.get('CAM_NAME', 'sunrise')) output_dir = os.path.join(base_video_folder, today_month_str, "sunrise-only") # Check if the directory exists diff --git a/year-end-join.sh b/year-end-join.sh index 4fda07a..4da4746 100755 --- a/year-end-join.sh +++ b/year-end-join.sh @@ -8,7 +8,7 @@ # The 12 individual movement videos already carry their own attribution overlay # (first 6 seconds of each), so no extra card is appended here. # -# Output: $BASE_DIR/movies/$CAM_NAME/$ASTRO_YEAR/$CAM_NAME-$ASTRO_YEAR.mp4 +# Output: $MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$CAM_NAME-$ASTRO_YEAR.mp4 set -euo pipefail @@ -23,7 +23,7 @@ fi # Camera name: second argument overrides conf (passed through from montage-mvt.sh). CAM_NAME="${2:-$CAM_NAME}" -year_dir="$BASE_DIR/movies/$CAM_NAME/$ASTRO_YEAR" +year_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR" echo "Assembling Four Seasons $ASTRO_YEAR from $year_dir ..." From 6d68010691b8c9a3b15949dda931e953bdd1b428 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:08:14 +0000 Subject: [PATCH 4/7] Expose encoding quality and overlay durations in sky-cam.conf 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 --- 4-seasons.sh | 4 ++-- daily_sunrise_video.sh | 2 +- fullday-video.sh | 2 +- montage-mvt.sh | 14 ++++++++------ sky-cam.conf | 26 ++++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/4-seasons.sh b/4-seasons.sh index b53df3b..58595a0 100755 --- a/4-seasons.sh +++ b/4-seasons.sh @@ -79,7 +79,7 @@ done > "$temp_file" echo "Step 1/2: encoding raw video..." ffmpeg -loglevel warning \ -f concat -safe 0 -i "$temp_file" \ - -c:v libx264 -pix_fmt yuv420p -crf 28 -vsync 2 -an \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SEASONS_RAW" -vsync 2 -an \ -y "$temp_video" raw_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$temp_video") @@ -93,7 +93,7 @@ final_file="$output_dir/${yesterday}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_M ffmpeg -loglevel warning \ -i "$temp_video" \ -vf "setpts=PTS/$speed_factor" \ - -c:v libx264 -pix_fmt yuv420p -crf 26 \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SEASONS_FINAL" \ -t "$target_per_clip" -an \ -y "$final_file" diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index da5bf51..8289109 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -100,7 +100,7 @@ first_movie="$output_dir/$current_date-sunrise-day.mp4" # Create the first movie at the default frame rate (no speed adjustments) echo "Creating video at default frame rate..." -ffmpeg -f concat -safe 0 -i "$temp_file" -c:v libx264 -pix_fmt yuv420p -crf 28 -vsync 2 -y "$first_movie" +ffmpeg -f concat -safe 0 -i "$temp_file" -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -vsync 2 -y "$first_movie" # Check the duration of the created video video_duration=$(ffmpeg -i "$first_movie" 2>&1 | grep "Duration" | cut -d ' ' -f 4 | sed s/,//) diff --git a/fullday-video.sh b/fullday-video.sh index a627b5e..c2df80a 100755 --- a/fullday-video.sh +++ b/fullday-video.sh @@ -63,7 +63,7 @@ echo "Creating: $output_file" ffmpeg -loglevel warning \ -f concat -safe 0 -i "$temp_list" \ - -c:v libx264 -pix_fmt yuv420p -crf 28 \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_FULLDAY" \ -an -y "$output_file" actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$output_file") diff --git a/montage-mvt.sh b/montage-mvt.sh index dfcd52a..c22d6dc 100755 --- a/montage-mvt.sh +++ b/montage-mvt.sh @@ -31,9 +31,11 @@ base_dir="$BASE_DIR" music_base_dir="$MUSIC_DIR" [ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music" -FADE_DUR=2.0 # video + audio fade in/out (seconds) -ATTR_DUR=6 # attribution overlay duration (seconds from start) -ATTR_FADE=1 # attribution fade in / fade out duration +# Fade/overlay durations come from sky-cam.conf (MONTAGE_FADE_DUR, +# MONTAGE_ATTR_DUR, MONTAGE_ATTR_FADE). Alias for shorter local use. +FADE_DUR="$MONTAGE_FADE_DUR" +ATTR_DUR="$MONTAGE_ATTR_DUR" +ATTR_FADE="$MONTAGE_ATTR_FADE" # ── Season / movement info ──────────────────────────────────────────────────── DATE_ARG="${1:-}" @@ -100,7 +102,7 @@ ffmpeg -loglevel warning \ -f concat -safe 0 -i "$concat_list" \ -vf "scale=${VIDEO_W}:${VIDEO_H}:force_original_aspect_ratio=decrease,\ pad=${VIDEO_W}:${VIDEO_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" \ - -c:v libx264 -pix_fmt yuv420p -crf 26 -an \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_MONTAGE" -an \ -y "$temp_concat" # ── Step 2: Speed-adjust to match music duration exactly ────────────────────── @@ -112,7 +114,7 @@ temp_sped=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_sped") ffmpeg -loglevel warning \ -i "$temp_concat" \ -vf "setpts=PTS/$speed_factor" \ - -c:v libx264 -pix_fmt yuv420p -crf 26 -an \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_MONTAGE" -an \ -t "$music_duration" -y "$temp_sped" rm "$temp_concat" @@ -156,7 +158,7 @@ ffmpeg -loglevel warning \ "[0:v]fade=t=in:st=0:d=${FADE_DUR},fade=t=out:st=${fade_out_start}:d=${FADE_DUR},${DT}[vout];\ [1:a]afade=t=in:st=0:d=${FADE_DUR},afade=t=out:st=${fade_out_start}:d=${FADE_DUR}[aout]" \ -map "[vout]" -map "[aout]" \ - -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k \ + -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a "$AUDIO_BITRATE" \ -t "$music_duration" -y "$output_file" rm "$temp_sped" diff --git a/sky-cam.conf b/sky-cam.conf index 2046e92..9d0f3e2 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -209,6 +209,32 @@ SUNRISE_TARGET_SECS=10 FULLDAY_FPS=5 # frame rate of the timelapse video RETENTION_DAYS=10 # delete full-day videos older than this many days +# ── Video encoding quality ──────────────────────────────────────────────────── +# FFmpeg CRF: lower = higher quality / larger files. Typical range 18–30. +# 18 ≈ visually lossless 23 = ffmpeg default 28 = smaller/lower +# Different scripts use different defaults by design — intermediate/draft +# encodings use a higher CRF (smaller files, quality less critical), while +# final "polished" outputs use a lower CRF for better quality. +# Each is independent — changing one does NOT change the others. +# +CRF_SUNRISE=28 # daily_sunrise_video.sh — daily Mattermost upload +CRF_FULLDAY=28 # fullday-video.sh — full-day timelapse +CRF_SEASONS_RAW=28 # 4-seasons.sh — raw concat before speed-adjust +CRF_SEASONS_FINAL=26 # 4-seasons.sh — final daily clip (goes into montage) +CRF_MONTAGE=26 # montage-mvt.sh — concat and speed-adjust and final +AUDIO_BITRATE=192k # montage-mvt.sh — music track on movement montages + +# ── Montage attribution overlay ─────────────────────────────────────────────── +# A translucent bar across the top of each movement montage, naming the +# music source ("The Four Seasons — Antonio Vivaldi ..."). +# Appears for MONTAGE_ATTR_DUR seconds from the start, with ATTR_FADE-second +# fade in and fade out. FADE_DUR controls the video/audio fade in/out at +# the very start and very end of the montage. +# +MONTAGE_FADE_DUR=2.0 # video + audio fade in/out (seconds) +MONTAGE_ATTR_DUR=6 # attribution overlay total duration (seconds) +MONTAGE_ATTR_FADE=1 # attribution fade-in and fade-out length (seconds) + # ── Mattermost — daily sunrise upload ───────────────────────────────────────── mattermost_url=https://your-mattermost-server.example.com access_token=your-access-token-here From 7486916f9f9c55677fdd93d96bc68eb7fd3abcb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:35:33 +0000 Subject: [PATCH 5/7] Sunrise time overlay; systemd upload separation; bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daily_sunrise_video.sh: - Fixed: was using undefined \$MOVIES_DIR (now \$BASE_DIR/movies) - Fixed: sunrise_overlay.py was called but never existed — would fail daily - Fixed: speed-adjust step had no explicit codec (-c:v libx264 missing) - New: sunrise time overlaid as stacked vertical characters on the right side using ffmpeg drawtext — no Python/Pillow dependency - New: upload removed from script; now triggered by systemd OnSuccess= - Combines speed-adjust and overlay into one ffmpeg pass (was two) - SUNRISE_OVERLAY_OPACITY from conf controls transparency install.sh: - sky-cam-sunrise.service gains OnSuccess=sky-cam-sunrise-upload.service so upload only runs when video creation succeeds, with separate log - New sky-cam-sunrise-upload.service runs sunrise2mm.py sunrise2mm.py: add shebang + chmod +x so systemd ExecStart= calls it directly sky-cam.conf: add SUNRISE_OVERLAY_OPACITY=0.45 https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- daily_sunrise_video.sh | 203 +++++++++++++++++------------------------ install.sh | 36 +++++++- sky-cam.conf | 2 + sunrise2mm.py | 1 + 4 files changed, 119 insertions(+), 123 deletions(-) mode change 100644 => 100755 sunrise2mm.py diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index 8289109..11a2a20 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -1,152 +1,117 @@ #!/bin/bash +# daily_sunrise_video.sh — collect today's sunrise images, speed-adjust to +# SUNRISE_TARGET_SECS, burn the local sunrise time vertically on the right +# side, and write the final video. Upload is handled by a separate systemd +# service (sky-cam-sunrise-upload) triggered via OnSuccess= so the two jobs +# have independent log entries and failure states. + +set -euo pipefail SCRIPT_DIR="$(dirname "$(realpath "$0")")" source "$SCRIPT_DIR/sky-cam.conf" +export TZ="$TIMEZONE" -# Get today's date in the format YYYY-MM-DD +# ── Date / paths ────────────────────────────────────────────────────────────── current_date=$(date +%Y-%m-%d) output_date=$(date +%Y-%m) -# Define the directory where images are stored (using today's date) image_dir="$BASE_DIR/$CAM_NAME/$current_date" - -# Set output directory -output_dir="$MOVIES_DIR/$CAM_NAME/$output_date/sunrise-only" - -# Make sure the output directory exists +output_dir="$BASE_DIR/movies/$CAM_NAME/$output_date/sunrise-only" mkdir -p "$output_dir" -# Get the sunrise time for today using the sunrise.py script +# ── Sunrise time ────────────────────────────────────────────────────────────── sunrise_time=$(python3 "$SCRIPT_DIR/sunrise.py") - -# Check if the sunrise time was fetched successfully if [[ -z "$sunrise_time" ]]; then - echo "Error: Failed to retrieve sunrise time." - exit 1 + echo "Error: Failed to retrieve sunrise time." + exit 1 fi +echo "Sunrise (UTC): $sunrise_time" -# Output the sunrise time in UTC -echo "Sunrise time (UTC): $sunrise_time" +sunrise_time_local=$(TZ="$TIMEZONE" date -d "$sunrise_time" +"%H-%M-%S") +echo "Sunrise (local): $sunrise_time_local" -# Export timezone so date and subprocesses use the configured local time. -export TZ="$TIMEZONE" +# ── Capture window ──────────────────────────────────────────────────────────── +time_to_seconds() { IFS='-' read -r h m s <<< "$1"; echo $((10#$h * 3600 + 10#$m * 60 + 10#$s)); } -# Convert sunrise time from UTC to local time. -sunrise_time_et=$(TZ="$TIMEZONE" date -d "$sunrise_time" +"%H-%M-%S") +sunrise_sec=$(time_to_seconds "$sunrise_time_local") +start_sec=$((sunrise_sec - SUNRISE_PRE_MIN * 60)) +end_sec=$((sunrise_sec + SUNRISE_POST_MIN * 60)) +echo "Window: $((start_sec/3600)):$(printf '%02d' $(((start_sec%3600)/60))) → $((end_sec/3600)):$(printf '%02d' $(((end_sec%3600)/60)))" -# Output the converted sunrise time in Eastern Time -echo "Sunrise time (Eastern Time): $sunrise_time_et" - -# Function to convert time to seconds since midnight -time_to_seconds() { - IFS='-' read -r h m s <<< "$1" - echo $((10#$h * 3600 + 10#$m * 60 + 10#$s)) -} - -# Convert sunrise time (in Eastern Time) to seconds since midnight -sunrise_seconds=$(time_to_seconds "$sunrise_time_et") - -# Calculate the capture window around sunrise. -start_seconds=$((sunrise_seconds - SUNRISE_PRE_MIN * 60)) -end_seconds=$((sunrise_seconds + SUNRISE_POST_MIN * 60)) - -# Output the start and end times in seconds for debugging -echo "Start seconds: $start_seconds" -echo "End seconds: $end_seconds" -echo "Sunrise seconds: $sunrise_seconds" - -# Check if the image directory exists +# ── Collect images in window ────────────────────────────────────────────────── if [ ! -d "$image_dir" ]; then - echo "Error: The directory for images does not exist: $image_dir" - exit 1 + echo "Error: image directory not found: $image_dir" + exit 1 fi -# Create a temporary file to store the list of image files -temp_file=$(mktemp) +temp_list=$(mktemp --suffix=.txt) +trap 'rm -f "$temp_list" "$raw_video" 2>/dev/null || true' EXIT -# Find, sort, and process images in the specified directory -# Sort files by filename, which will order them correctly by MM-DD-SS -find "$image_dir" -type f -name "*.jpg" | sort -n | while read -r image; do - # Extract the time portion of the filename (e.g., 09-16-00 from 09-16-00.jpg) - image_time=$(basename "$image" .jpg) # MM-DD-SS +find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do + img_sec=$(time_to_seconds "$(basename "$img" .jpg)") + if [[ $img_sec -ge $start_sec && $img_sec -le $end_sec ]]; then + echo "file '$img'" + fi +done > "$temp_list" - # Convert image time to seconds since midnight - image_seconds=$(time_to_seconds "$image_time") +num_images=$(wc -l < "$temp_list") +if [[ $num_images -lt 1 ]]; then + echo "No images found in sunrise window." + exit 1 +fi +echo "Images in window: $num_images" - # Debug: Output the image time and its seconds since midnight - echo "Image: $image, Time: $image_time, Seconds: $image_seconds" - - # Check if the image time falls within the specified range - if [[ $image_seconds -ge $start_seconds && $image_seconds -le $end_seconds ]]; then - echo "Image $image_time ($image_seconds) is within the range [$start_seconds, $end_seconds]" - - # Add the image to the temporary file - echo "file '$image'" >> "$temp_file" - fi -done - -# Check if any images were found in the time range -if [ ! -s "$temp_file" ]; then - echo "No images found within the specified time range." - exit 1 +# ── Font detection (same fallback chain as montage-mvt.sh) ─────────────────── +FONT="" +if command -v fc-match &>/dev/null; then + FONT=$(fc-match "DejaVu Sans:style=Regular" --format="%{file}" 2>/dev/null || true) +fi +if [ -z "$FONT" ]; then + for f in \ + /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf \ + /usr/share/fonts/dejavu/DejaVuSans.ttf \ + /usr/share/fonts/TTF/DejaVuSans.ttf \ + /usr/share/fonts/truetype/freefont/FreeSans.ttf; do + [ -f "$f" ] && { FONT="$f"; break; } + done fi -# Calculate the number of images -num_images=$(wc -l < "$temp_file") -echo "Total images to be included in the video: $num_images" +# ── Step 1: Raw video from images ───────────────────────────────────────────── +raw_video=$(mktemp --suffix=.mp4) +echo "Step 1/2: encoding raw video..." +ffmpeg -loglevel warning \ + -f concat -safe 0 -i "$temp_list" \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -vsync 2 -an \ + -y "$raw_video" -# Output video filename for the first movie (no speed adjustments) -first_movie="$output_dir/$current_date-sunrise-day.mp4" +raw_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$raw_video") +speed_factor=$(echo "scale=3; $raw_dur / $SUNRISE_TARGET_SECS" | bc) +echo "Raw: ${raw_dur}s speed factor: ${speed_factor}x" -# Create the first movie at the default frame rate (no speed adjustments) -echo "Creating video at default frame rate..." -ffmpeg -f concat -safe 0 -i "$temp_file" -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -vsync 2 -y "$first_movie" +# ── Step 2: Speed-adjust + sunrise time overlay ─────────────────────────────── +# Time displayed as stacked characters down the right side (e.g. 0/7/:/2/3), +# semi-transparent with a soft drop shadow so it reads on any background. +SR_TIME=$(echo "$sunrise_time_local" | cut -d'-' -f1,2 | tr '-' ':') +# Build "H\nH\n:\nM\nM" for vertical stacking in drawtext +SR_VERT=$(echo "$SR_TIME" | awk 'BEGIN{FS=""}{for(i=1;i<=NF;i++){printf "%s",$i; if(i&1 | grep "Duration" | cut -d ' ' -f 4 | sed s/,//) -echo "Initial video duration: $video_duration" +DT="drawtext" +[ -n "$FONT" ] && DT="${DT}=fontfile='${FONT}'" || DT="${DT}" +DT="${DT}:text='${SR_VERT}'" +DT="${DT}:fontcolor=white@${SUNRISE_OVERLAY_OPACITY}" +DT="${DT}:fontsize=h/22" +DT="${DT}:line_spacing=4" +DT="${DT}:x=w-tw-18:y=(h-th)/2" +DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1" -# Get the video duration in seconds (from HH:MM:SS format) -IFS=':' read -r hours minutes seconds <<< $(echo $video_duration | cut -d '.' -f1) # Extract time -total_seconds=$(($hours * 3600 + $minutes * 60 + $seconds)) # Convert to total seconds - -target_duration=$SUNRISE_TARGET_SECS - -speed_up_factor=$(echo "scale=3; $total_seconds / $target_duration" | bc) -echo "Calculated speed-up factor: $speed_up_factor" - -# Output filename for the second movie (speed-adjusted) final_video="$output_dir/$current_date-daily-sunrise.mp4" +echo "Step 2/2: speed-adjust + overlay → $final_video" -# Apply the speed adjustment using the setpts filter -echo "Adjusting video speed to ${SUNRISE_TARGET_SECS}s..." -ffmpeg -i "$first_movie" -filter:v "setpts=PTS/$speed_up_factor" -y "$final_video" +ffmpeg -loglevel warning \ + -i "$raw_video" \ + -vf "setpts=PTS/${speed_factor},${DT}" \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \ + -y "$final_video" -# Check the duration of the adjusted video -adjusted_duration=$(ffmpeg -i "$final_video" 2>&1 | grep "Duration" | cut -d ' ' -f 4 | sed s/,//) -echo "Adjusted video duration: $adjusted_duration" - -# Check for ffmpeg success -if [ $? -ne 0 ]; then - echo "Error: ffmpeg failed to create the final video." - exit 1 -fi - -# Inform user the video has been created -echo "Video created at $final_video" - -# Clean up temporary files -rm "$temp_file" - -# Run the sunrise_overlay.py script to add the sunrise overlay -python3 "$SCRIPT_DIR/sunrise_overlay.py" "$final_video" - -# Run the sunrise2mm.py script to upload the video -python3 "$SCRIPT_DIR/sunrise2mm.py" - -# Check if the Python script was successful -if [ $? -eq 0 ]; then - echo "Successfully uploaded the video to Mattermost." -else - echo "There was an issue uploading the video to Mattermost." -fi +actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_video") +echo "Done: $final_video (${actual_dur}s, sunrise at ${SR_TIME})" diff --git a/install.sh b/install.sh index ec7b455..2166c34 100755 --- a/install.sh +++ b/install.sh @@ -75,10 +75,38 @@ EOF echo " wrote sky-cam-notify-failure@.service" # ── Sunrise video — SUNRISE_CAM only ───────────────────────────────────────── -write_service "sky-cam-sunrise" \ - "$SUNRISE_CAM: daily sunrise video → Mattermost" \ - "daily_sunrise_video.sh" \ - "After=network-online.target\nWants=network-online.target\n" +# OnSuccess= triggers the upload service only when video creation succeeds. +cat > "$UNIT_DIR/sky-cam-sunrise.service" < "$UNIT_DIR/sky-cam-sunrise-upload.service" < Date: Sun, 19 Apr 2026 16:48:18 +0000 Subject: [PATCH 6/7] Add .gitignore to exclude __pycache__ https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ From 9c929f28d8c72e50032547a597ce531f29f09a3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:35:26 +0000 Subject: [PATCH 7/7] Add per-step resilience and notifications across all video pipeline scripts 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 --- 4-seasons.sh | 20 +++++++++++-- daily_sunrise_video.sh | 64 +++++++++++++++++++++++++++++++++++------- montage-mvt.sh | 47 +++++++++++++++++++++++++------ year-end-join.sh | 22 +++++++++++++-- 4 files changed, 130 insertions(+), 23 deletions(-) diff --git a/4-seasons.sh b/4-seasons.sh index 58595a0..213361c 100755 --- a/4-seasons.sh +++ b/4-seasons.sh @@ -90,16 +90,32 @@ speed_factor=$(echo "scale=6; $raw_duration / $target_per_clip" | bc) echo "Step 2/2: speed factor $speed_factor..." final_file="$output_dir/${yesterday}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_MVT}-final.mp4" -ffmpeg -loglevel warning \ +if ! ffmpeg -loglevel warning \ -i "$temp_video" \ -vf "setpts=PTS/$speed_factor" \ -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SEASONS_FINAL" \ -t "$target_per_clip" -an \ - -y "$final_file" + -y "$final_file"; then + rm -f "$final_file" + "$SCRIPT_DIR/notify.sh" "FAILED: $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT speed-adjust ($yesterday)" \ + "ffmpeg speed-adjust failed — re-run 4-seasons.sh $CAM_NAME while JPGs exist" || true + exit 1 +fi adjusted=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_file") echo "Daily clip: $final_file ($adjusted s)" +# Warn if clip duration deviates more than 0.5 s from target (float drift check) +drift=$(echo "scale=3; $adjusted - $target_per_clip" | bc | sed 's/^-//') +if awk "BEGIN{exit !($drift > 0.5)}"; then + echo "WARNING: clip duration ${adjusted}s differs from target ${target_per_clip}s by ${drift}s" + "$SCRIPT_DIR/notify.sh" "WARNING: $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT duration drift ($yesterday)" \ + "Clip ${adjusted}s vs target ${target_per_clip}s (drift ${drift}s)" || true +fi + +"$SCRIPT_DIR/notify.sh" "$SEASON Mvt$MVT_NUM Day$DAY_OF_MVT/$DAYS_IN_MVT saved ($yesterday)" \ + "$(basename "$final_file") — ${adjusted}s" || true + # ── Auto-trigger montage on last day of movement ────────────────────────────── if [ "$IS_LAST_DAY" = "true" ]; then echo "Last day of $SEASON Mvt $MVT_NUM — triggering montage compilation..." diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index 11a2a20..97e0869 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -4,6 +4,12 @@ # side, and write the final video. Upload is handled by a separate systemd # service (sky-cam-sunrise-upload) triggered via OnSuccess= so the two jobs # have independent log entries and failure states. +# +# Resilience: the speed-adjusted video is saved permanently before the overlay +# step. If overlay fails, the sped video survives at: +# /-daily-sunrise-sped.mp4 +# Re-run this script once the issue is resolved; the sped file is deleted +# automatically when the overlay step succeeds. set -euo pipefail @@ -22,6 +28,8 @@ mkdir -p "$output_dir" # ── Sunrise time ────────────────────────────────────────────────────────────── sunrise_time=$(python3 "$SCRIPT_DIR/sunrise.py") if [[ -z "$sunrise_time" ]]; then + "$SCRIPT_DIR/notify.sh" "FAILED: sunrise $current_date" \ + "Could not retrieve sunrise time from sunrise.py" || true echo "Error: Failed to retrieve sunrise time." exit 1 fi @@ -40,11 +48,14 @@ echo "Window: $((start_sec/3600)):$(printf '%02d' $(((start_sec%3600)/60))) → # ── Collect images in window ────────────────────────────────────────────────── if [ ! -d "$image_dir" ]; then + "$SCRIPT_DIR/notify.sh" "FAILED: sunrise $current_date" \ + "Image directory not found: $image_dir" || true echo "Error: image directory not found: $image_dir" exit 1 fi temp_list=$(mktemp --suffix=.txt) +raw_video="" trap 'rm -f "$temp_list" "$raw_video" 2>/dev/null || true' EXIT find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do @@ -56,6 +67,8 @@ done > "$temp_list" num_images=$(wc -l < "$temp_list") if [[ $num_images -lt 1 ]]; then + "$SCRIPT_DIR/notify.sh" "FAILED: sunrise $current_date" \ + "No images found in sunrise window" || true echo "No images found in sunrise window." exit 1 fi @@ -78,17 +91,39 @@ fi # ── Step 1: Raw video from images ───────────────────────────────────────────── raw_video=$(mktemp --suffix=.mp4) -echo "Step 1/2: encoding raw video..." -ffmpeg -loglevel warning \ +echo "Step 1/3: encoding raw video..." +if ! ffmpeg -loglevel warning \ -f concat -safe 0 -i "$temp_list" \ -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -vsync 2 -an \ - -y "$raw_video" + -y "$raw_video"; then + "$SCRIPT_DIR/notify.sh" "FAILED: sunrise step 1/3 (encode) $current_date" \ + "ffmpeg raw encode from images failed — check: journalctl -u sky-cam-sunrise.service" || true + exit 1 +fi raw_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$raw_video") speed_factor=$(echo "scale=3; $raw_dur / $SUNRISE_TARGET_SECS" | bc) echo "Raw: ${raw_dur}s speed factor: ${speed_factor}x" -# ── Step 2: Speed-adjust + sunrise time overlay ─────────────────────────────── +# ── Step 2: Speed-adjust — saved permanently so overlay failure is recoverable ─ +# Deleted automatically if step 3 succeeds. +sped_video="$output_dir/$current_date-daily-sunrise-sped.mp4" +echo "Step 2/3: speed-adjust → $sped_video" +if ! ffmpeg -loglevel warning \ + -i "$raw_video" \ + -vf "setpts=PTS/${speed_factor}" \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \ + -y "$sped_video"; then + rm -f "$sped_video" + "$SCRIPT_DIR/notify.sh" "FAILED: sunrise step 2/3 (speed) $current_date" \ + "speed-adjust failed — no video saved" || true + exit 1 +fi + +sped_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$sped_video") +echo "Speed-adjusted: ${sped_dur}s (saved — overlay still pending)" + +# ── Build overlay filter ────────────────────────────────────────────────────── # Time displayed as stacked characters down the right side (e.g. 0/7/:/2/3), # semi-transparent with a soft drop shadow so it reads on any background. SR_TIME=$(echo "$sunrise_time_local" | cut -d'-' -f1,2 | tr '-' ':') @@ -104,14 +139,23 @@ DT="${DT}:line_spacing=4" DT="${DT}:x=w-tw-18:y=(h-th)/2" DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1" +# ── Step 3: Overlay — sunrise time burned in ────────────────────────────────── +# If this fails, $sped_video survives at its permanent path for manual recovery. final_video="$output_dir/$current_date-daily-sunrise.mp4" -echo "Step 2/2: speed-adjust + overlay → $final_video" - -ffmpeg -loglevel warning \ - -i "$raw_video" \ - -vf "setpts=PTS/${speed_factor},${DT}" \ +echo "Step 3/3: overlay → $final_video" +if ! ffmpeg -loglevel warning \ + -i "$sped_video" \ + -vf "${DT}" \ -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \ - -y "$final_video" + -y "$final_video"; then + "$SCRIPT_DIR/notify.sh" "FAILED: sunrise overlay $current_date" \ + "Overlay failed — speed-only video saved: $(basename "$sped_video")" || true + exit 1 +fi actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_video") echo "Done: $final_video (${actual_dur}s, sunrise at ${SR_TIME})" + +rm -f "$sped_video" +"$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \ + "$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true diff --git a/montage-mvt.sh b/montage-mvt.sh index c22d6dc..5929ce2 100755 --- a/montage-mvt.sh +++ b/montage-mvt.sh @@ -51,6 +51,8 @@ mkdir -p "$output_dir" music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*Mvt*${MVT_NUM}*" | sort | head -n 1) if [ -z "$music_file" ]; then + "$SCRIPT_DIR/notify.sh" "FAILED: montage $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \ + "Music file not found in $music_base_dir" || true echo "ERROR: Music file not found for $SEASON Mvt $MVT_NUM in $music_base_dir" exit 1 fi @@ -60,11 +62,28 @@ echo "Music: $(basename "$music_file") (${music_duration}s)" # ── Collect sorted daily clips ──────────────────────────────────────────────── mapfile -d '' daily_clips < <(find "$video_dir" -maxdepth 1 -name "*-final.mp4" -print0 | sort -z) if [ "${#daily_clips[@]}" -eq 0 ]; then + "$SCRIPT_DIR/notify.sh" "FAILED: montage $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \ + "No *-final.mp4 clips found in $video_dir" || true echo "ERROR: No *-final.mp4 clips in $video_dir" exit 1 fi echo "Found ${#daily_clips[@]} daily clips" +# ── Validate clip duration sum against music ────────────────────────────────── +total_clip_dur=0 +for clip in "${daily_clips[@]}"; do + d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$clip") + total_clip_dur=$(echo "scale=3; $total_clip_dur + $d" | bc) +done +clip_drift=$(echo "scale=3; $total_clip_dur - $music_duration" | bc | sed 's/^-//') +echo "Sum of clips: ${total_clip_dur}s Music: ${music_duration}s Drift: ${clip_drift}s" +if awk "BEGIN{exit !($clip_drift > 2.0)}"; then + echo "WARNING: clip total differs from music by ${clip_drift}s — speed pass will compensate" + "$SCRIPT_DIR/notify.sh" \ + "WARNING: $SEASON Mvt$MVT_NUM clip drift ${clip_drift}s ($ASTRO_YEAR)" \ + "Clips sum ${total_clip_dur}s vs music ${music_duration}s" || true +fi + # ── Source resolution ───────────────────────────────────────────────────────── VIDEO_W=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "${daily_clips[0]}") VIDEO_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "${daily_clips[0]}") @@ -105,18 +124,25 @@ pad=${VIDEO_W}:${VIDEO_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" \ -c:v libx264 -pix_fmt yuv420p -crf "$CRF_MONTAGE" -an \ -y "$temp_concat" -# ── Step 2: Speed-adjust to match music duration exactly ────────────────────── +# ── Step 2: Speed-adjust — saved permanently so music/overlay failure is recoverable ─ +# Deleted automatically if step 3 succeeds. concat_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$temp_concat") speed_factor=$(echo "scale=6; $concat_dur / $music_duration" | bc) echo "Step 2/3: speed $speed_factor (raw=${concat_dur}s → music=${music_duration}s)" -temp_sped=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_sped") -ffmpeg -loglevel warning \ +sped_file="$output_dir/${MVT_START}_${SEASON}_Mvt${MVT_NUM}-Sped.mp4" +if ! ffmpeg -loglevel warning \ -i "$temp_concat" \ -vf "setpts=PTS/$speed_factor" \ -c:v libx264 -pix_fmt yuv420p -crf "$CRF_MONTAGE" -an \ - -t "$music_duration" -y "$temp_sped" + -t "$music_duration" -y "$sped_file"; then + rm -f "$sped_file" + "$SCRIPT_DIR/notify.sh" "FAILED: montage step 2/3 (speed) $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \ + "speed-adjust failed — concat temp lost; re-run montage-mvt.sh" || true + exit 1 +fi rm "$temp_concat" +echo "Speed-adjusted: $sped_file (saved — music+overlay still pending)" # ── Step 3: Music + fades + attribution overlay ─────────────────────────────── # Attribution overlay: semi-transparent bar across the top for the first @@ -152,16 +178,21 @@ DT="${DT}:alpha='${ALPHA}':enable='${ENABLE}'" output_file="$output_dir/${MVT_START}_${SEASON}_Mvt${MVT_NUM}-Montage.mp4" -ffmpeg -loglevel warning \ - -i "$temp_sped" -i "$music_file" \ +# If this fails, $sped_file survives for manual recovery (re-run step 3 only). +if ! ffmpeg -loglevel warning \ + -i "$sped_file" -i "$music_file" \ -filter_complex \ "[0:v]fade=t=in:st=0:d=${FADE_DUR},fade=t=out:st=${fade_out_start}:d=${FADE_DUR},${DT}[vout];\ [1:a]afade=t=in:st=0:d=${FADE_DUR},afade=t=out:st=${fade_out_start}:d=${FADE_DUR}[aout]" \ -map "[vout]" -map "[aout]" \ -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a "$AUDIO_BITRATE" \ - -t "$music_duration" -y "$output_file" -rm "$temp_sped" + -t "$music_duration" -y "$output_file"; then + "$SCRIPT_DIR/notify.sh" "FAILED: montage step 3/3 (music+overlay) $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \ + "Music/overlay failed — sped video saved: $(basename "$sped_file")" || true + exit 1 +fi +rm -f "$sped_file" echo "Done: $output_file" # ── Notify ──────────────────────────────────────────────────────────────────── diff --git a/year-end-join.sh b/year-end-join.sh index 4da4746..178fb59 100755 --- a/year-end-join.sh +++ b/year-end-join.sh @@ -32,6 +32,7 @@ concat_list=$(mktemp --suffix=.txt) trap 'rm -f "$concat_list"' EXIT missing=0 +total_expected_dur=0 for season in Winter Spring Summer Autumn; do for mvt in 1 2 3; do mvt_dir="$year_dir/$season/Mvt$mvt/montage" @@ -41,11 +42,14 @@ for season in Winter Spring Summer Autumn; do echo "WARNING: missing montage for $season Mvt$mvt ($mvt_dir)" missing=$((missing + 1)) else - echo " $season Mvt$mvt → $(basename "$f")" + d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f") + total_expected_dur=$(echo "scale=3; $total_expected_dur + $d" | bc) + echo " $season Mvt$mvt → $(basename "$f") (${d}s)" printf "file '%s'\n" "$f" >> "$concat_list" fi done done +echo "Expected total duration: ${total_expected_dur}s ($(echo "scale=1; $total_expected_dur/60" | bc) min)" found=$(wc -l < "$concat_list") echo "$found of 12 movements found ($missing missing)" @@ -63,14 +67,26 @@ fi output_file="$year_dir/${CAM_NAME}-${ASTRO_YEAR}.mp4" echo "Output: $output_file" -ffmpeg -loglevel warning \ +if ! ffmpeg -loglevel warning \ -f concat -safe 0 -i "$concat_list" \ - -c copy -y "$output_file" + -c copy -y "$output_file"; then + "$SCRIPT_DIR/notify.sh" "FAILED: year-end join $CAM_NAME $ASTRO_YEAR" \ + "ffmpeg concat failed — the 12 movement montages are still intact" || true + exit 1 +fi total_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$output_file") total_min=$(echo "scale=1; $total_dur / 60" | bc) echo "Done: $output_file (${total_min} min)" +# Sanity-check actual vs expected duration +dur_drift=$(echo "scale=3; $total_dur - $total_expected_dur" | bc | sed 's/^-//') +if awk "BEGIN{exit !($dur_drift > 2.0)}"; then + echo "WARNING: output ${total_dur}s differs from expected ${total_expected_dur}s by ${dur_drift}s" + "$SCRIPT_DIR/notify.sh" "WARNING: year-end duration drift $CAM_NAME $ASTRO_YEAR" \ + "Output ${total_dur}s vs expected ${total_expected_dur}s (drift ${dur_drift}s)" || true +fi + # ── Notify ──────────────────────────────────────────────────────────────────── "$SCRIPT_DIR/notify.sh" \ "Four Seasons $ASTRO_YEAR complete" \