diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/4-seasons.sh b/4-seasons.sh index 8502960..213361c 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" @@ -59,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 ─────────────────────────────────────────────────── @@ -76,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") @@ -87,20 +90,36 @@ 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 26 \ + -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..." # 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/daily_sunrise_video.sh b/daily_sunrise_video.sh index 04082bd..97e0869 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -1,152 +1,161 @@ #!/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. +# +# 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 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="$BASE_DIR/movies/$CAM_NAME/$output_date/sunrise-only" - -# Make sure the output directory exists 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 + "$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 +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 + "$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 -# Create a temporary file to store the list of image files -temp_file=$(mktemp) +temp_list=$(mktemp --suffix=.txt) +raw_video="" +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 + "$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 +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/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"; 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 -# 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 28 -vsync 2 -y "$first_movie" +# ── 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 -# Check the duration of the created video -video_duration=$(ffmpeg -i "$first_movie" 2>&1 | grep "Duration" | cut -d ' ' -f 4 | sed s/,//) -echo "Initial video duration: $video_duration" +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)" -# 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 +# ── 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 '-' ':') +# 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 "Adjusted video duration: $adjusted_duration" - -# Check for ffmpeg success -if [ $? -ne 0 ]; then - echo "Error: ffmpeg failed to create the final video." - exit 1 +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"; then + "$SCRIPT_DIR/notify.sh" "FAILED: sunrise overlay $current_date" \ + "Overlay failed — speed-only video saved: $(basename "$sped_video")" || true + exit 1 fi -# Inform user the video has been created -echo "Video created at $final_video" +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})" -# 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 +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/fullday-video.sh b/fullday-video.sh index e3bb633..c2df80a 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 @@ -17,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 ───────────────────────────────────────────────── @@ -60,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/install.sh b/install.sh index 0dfb66d..2166c34 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" < "$UNIT_DIR/sky-cam-sunrise.service" < "$UNIT_DIR/sky-cam-sunrise-upload.service" <, 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 +156,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..5929ce2 100755 --- a/montage-mvt.sh +++ b/montage-mvt.sh @@ -22,14 +22,20 @@ 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" [ ! -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:-}" @@ -39,12 +45,14 @@ 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" 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 @@ -54,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]}") @@ -96,21 +121,28 @@ 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 ────────────────────── +# ── 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 26 -an \ - -t "$music_duration" -y "$temp_sped" + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_MONTAGE" -an \ + -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 @@ -146,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 192k \ - -t "$music_duration" -y "$output_file" -rm "$temp_sped" + -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a "$AUDIO_BITRATE" \ + -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 ──────────────────────────────────────────────────────────────────── @@ -167,5 +204,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..311db94 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,55 +150,116 @@ # 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 +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 ─────────────────────────────────────────────────────── # 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 +# +# 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 +# ── 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 SUNRISE_POST_MIN=10 # Target duration (seconds) for the speed-adjusted sunrise video. SUNRISE_TARGET_SECS=10 +# Opacity of the sunrise-time overlay (0.0 = invisible, 1.0 = fully opaque). +SUNRISE_OVERLAY_OPACITY=0.45 # ── Full-day timelapse tuning ───────────────────────────────────────────────── 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 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/sunrise2mm.py b/sunrise2mm.py old mode 100644 new mode 100755 index 6b3c0d0..403111e --- a/sunrise2mm.py +++ b/sunrise2mm.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import requests import os from datetime import datetime @@ -45,7 +46,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 6817063..178fb59 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 @@ -17,11 +17,13 @@ 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" +year_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR" echo "Assembling Four Seasons $ASTRO_YEAR from $year_dir ..." @@ -30,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" @@ -39,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)" @@ -61,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" \