#!/bin/bash # 4-seasons.sh — create one daily clip from yesterday's images, sized to its # share of the corresponding Vivaldi movement's music duration. # # On the last day of a movement period, automatically runs montage-mvt.sh to # compile the full movement montage. # # Run once per day from cron (e.g. 01:00 daily). set -euo pipefail SCRIPT_DIR="$(dirname "$(realpath "$0")")" source "$SCRIPT_DIR/sky-cam.conf" export TIMEZONE # make it visible to season_info.py subprocess # Camera name: required first argument (systemd passes it via ExecStart). # Date: optional second argument (YYYY-MM-DD); defaults to yesterday. CAM_NAME="${1:-}" DATE_ARG="${2:-}" if [ -z "$CAM_NAME" ]; then echo "Usage: $0 [YYYY-MM-DD]" echo " e.g. $0 east" echo " $0 east 2026-04-20" exit 1 fi # ── Configuration ────────────────────────────────────────────────────────────── base_dir="$BASE_DIR" music_base_dir="$MUSIC_DIR" [ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music" # ── Date setup ──────────────────────────────────────────────────────────────── if [ -n "$DATE_ARG" ]; then yesterday=$(date --date="$DATE_ARG" +%Y-%m-%d) else yesterday=$(date --date="yesterday" +%Y-%m-%d) fi echo "Processing: $yesterday" # ── Season / movement info from astronomical calculation ────────────────────── _season_info="$(python3 "$SCRIPT_DIR/season_info.py" "$yesterday")" || { echo "Error: season_info.py failed — check Python dependencies (pytz)" exit 1 } eval "$_season_info" # Provides: SEASON MVT_NUM DAY_OF_MVT DAYS_IN_MVT MVT_START MVT_END # IS_LAST_DAY ASTRO_YEAR echo "Season=$SEASON Mvt=$MVT_NUM Year=$ASTRO_YEAR Day=$DAY_OF_MVT/$DAYS_IN_MVT LastDay=$IS_LAST_DAY" # ── Locate music file and get its duration ──────────────────────────────────── music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*" \ | grep -iE "Mvt[^0-9]*${MVT_NUM}[^0-9]" | sort | head -n 1) if [ -z "$music_file" ] || [ ! -f "$music_file" ]; then echo "ERROR: Music file not found for $SEASON Mvt $MVT_NUM in $music_base_dir" exit 1 fi music_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$music_file") echo "Music: $(basename "$music_file") ($music_duration s)" # Each daily clip covers (music_duration * loops) / DAYS_IN_MVT seconds. # Short movements use loops > 1 so each day gets more screen time. loops_var="MONTAGE_MUSIC_LOOPS_${SEASON}_${MVT_NUM}" loops="${!loops_var:-${MONTAGE_MUSIC_LOOPS:-1}}" target_per_clip=$(echo "scale=6; $music_duration * $loops / $DAYS_IN_MVT" | bc) echo "Target clip duration: $target_per_clip s ($DAYS_IN_MVT days in movement, ${loops}x music)" # ── Locate yesterday's images ───────────────────────────────────────────────── image_dir="$base_dir/$CAM_NAME/$yesterday" if [ ! -d "$image_dir" ]; then echo "WARNING: Image directory $image_dir not found — skipping $yesterday." exit 0 fi total_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l) if [ "$total_images" -lt 1 ]; then echo "WARNING: No images in $image_dir — skipping $yesterday." exit 0 fi echo "Images found: $total_images" # ── Per-camera capture interval ─────────────────────────────────────────────── interval_var="CAPTURE_INTERVAL_${CAM_NAME}" INTERVAL="${!interval_var:-${CAPTURE_INTERVAL:-10}}" # ── Prepare output directory ────────────────────────────────────────────────── output_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM" mkdir -p "$output_dir" # ── Build sorted image list ─────────────────────────────────────────────────── temp_file=$(mktemp --suffix=.txt) temp_video="$output_dir/${yesterday}_Mvt${MVT_NUM}-temp.mp4" cleanup() { rm -f "$temp_file" "$temp_video" 2>/dev/null || true; } trap cleanup EXIT mapfile -t all_images < <(find "$image_dir" -type f -name "*.jpg" | sort) # Validate each frame: skip empty files and optionally uniform-colour bad-signal frames. # SEASONS_GREY_STDDEV_MIN=0 (default) disables the content check entirely. # When enabled, large files (> SEASONS_LARGE_FRAME_BYTES, default 1 MB) are trusted # as good without running the expensive ImageMagick check — only sub-1MB frames # are inspected. This keeps the filter fast even on days with 40k+ images. GREY_MIN="${SEASONS_GREY_STDDEV_MIN:-0}" GREY_DARK_FLOOR="${SEASONS_GREY_DARK_FLOOR:-30}" LARGE_BYTES="${SEASONS_LARGE_FRAME_BYTES:-1048576}" # files above this skip content check total_frames=${#all_images[@]} images=(); bad=0; grey=0; checked=0 if [ "$GREY_MIN" != "0" ]; then echo "Validating $total_frames frames (grey-filter active: stddev<${GREY_MIN}, mean>${GREY_DARK_FLOOR})..." else echo "Validating $total_frames frames..." fi for img in "${all_images[@]}"; do ((checked++)) || true if (( checked % 500 == 0 || checked == total_frames )); then echo " $checked / $total_frames frames checked..." fi # Skip empty or unreadable files (0-byte writes from camera reconnects) if [ ! -r "$img" ] || [ ! -s "$img" ]; then echo "WARNING: skipping empty/missing frame: $(basename "$img")" ((bad++)) || true; continue fi # Optional: skip uniform-colour bad-signal frames (green, grey, etc.). # Only runs ImageMagick on files below SEASONS_LARGE_FRAME_BYTES — large files # are always good and skip the check entirely. if [ "$GREY_MIN" != "0" ] && command -v convert &>/dev/null; then fsize=$(stat -c%s "$img" 2>/dev/null || echo 0) if [ "$fsize" -lt "$LARGE_BYTES" ]; then read -r stddev mean < <(convert "$img" -colorspace Gray \ -format "%[fx:int(standard_deviation*255)] %[fx:int(mean*255)]" \ info: 2>/dev/null || echo "255 128") || true if [ "${stddev:-255}" -lt "$GREY_MIN" ] && [ "${mean:-0}" -gt "$GREY_DARK_FLOOR" ]; then ((grey++)) || true; continue fi fi fi images+=("$img") done [ "$bad" -gt 0 ] && echo "WARNING: skipped $bad corrupt/empty frame(s) of ${#all_images[@]}" || true [ "$grey" -gt 0 ] && echo "INFO: skipped $grey grey/uniform frame(s) (SEASONS_GREY_STDDEV_MIN=${GREY_MIN})" || true if [ "${#images[@]}" -eq 0 ]; then echo "ERROR: no valid images remain after filtering — skipping $yesterday." exit 0 fi echo "Validation done: ${#images[@]} valid frames" for img in "${images[@]}"; do printf "file '%s'\nduration 0.04\n" "$img" done > "$temp_file" # ffmpeg concat demuxer requires a final entry with no duration for image files printf "file '%s'\n" "${images[-1]}" >> "$temp_file" # ── Step 1: Build raw video ─────────────────────────────────────────────────── echo "Step 1/2: encoding raw video from ${#images[@]} frames (may take several minutes)..." ffmpeg -loglevel warning \ -f concat -safe 0 -i "$temp_file" \ -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -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") echo "Raw duration: $raw_duration s" # ── Step 2: Speed-adjust to target clip duration ────────────────────────────── speed_factor=$(echo "scale=6; $raw_duration / $target_per_clip" | bc) echo "Step 2/2: speed factor ${speed_factor}x → ${target_per_clip}s target..." final_file="$output_dir/${CAM_NAME}-${yesterday}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_MVT}-final.mp4" if ! ffmpeg -loglevel warning \ -i "$temp_video" \ -vf "setpts=PTS/$speed_factor,fps=25" \ -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SEASONS_FINAL" \ -t "$target_per_clip" -an \ -y "$final_file"; then rm -f "$final_file" "$SCRIPT_DIR/notify.sh" "FAILED: [$CAM_NAME] $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: [$CAM_NAME] $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" "[$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT/$DAYS_IN_MVT saved ($yesterday)" \ "$(basename "$final_file") — ${adjusted}s | $final_file" || 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" "$CAM_NAME" "$yesterday" fi