diff --git a/fullday-video.sh b/fullday-video.sh deleted file mode 100755 index 29e536d..0000000 --- a/fullday-video.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# fullday-video.sh — create a natural-speed timelapse of yesterday's full day of -# images at a fixed frame rate. Files older than RETENTION_DAYS are deleted -# automatically. Sunrise 10-second uploads and montage videos are NOT touched. -# -# Run once per day from cron (e.g. 02:00 daily, after 4-seasons.sh). - -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:-$SUNRISE_CAM}" - -# ── Configuration ────────────────────────────────────────────────────────────── -base_dir="$BASE_DIR" -# FULLDAY_FPS and RETENTION_DAYS come from sky-cam.conf - -# ── Date / paths ────────────────────────────────────────────────────────────── -yesterday=$(date --date="yesterday" +%Y-%m-%d) -image_dir="$base_dir/$CAM_NAME/$yesterday" -output_dir="$MOVIES_DIR/$CAM_NAME/fullday" -mkdir -p "$output_dir" - -# ── Purge old full-day videos ───────────────────────────────────────────────── -echo "Removing full-day videos older than $RETENTION_DAYS days..." -find "$output_dir" -maxdepth 1 -name "*-fullday.mp4" -mtime "+$RETENTION_DAYS" -delete - -# ── Sanity checks ───────────────────────────────────────────────────────────── -if [ ! -d "$image_dir" ]; then - echo "WARNING: Image directory $image_dir not found — skipping." - exit 0 -fi - -num_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l) -if [ "$num_images" -lt 1 ]; then - echo "WARNING: No images in $image_dir — skipping." - exit 0 -fi - -frame_dur=$(echo "scale=6; 1 / $FULLDAY_FPS" | bc) -approx_min=$(echo "scale=1; $num_images / $FULLDAY_FPS / 60" | bc) -echo "Images: $num_images at ${FULLDAY_FPS} fps ≈ ${approx_min} min" - -# ── Build concat list with per-frame duration ───────────────────────────────── -# The concat demuxer needs an explicit duration per image; the last file is -# repeated without a duration to correctly anchor the final frame's pts. -temp_list=$(mktemp --suffix=.txt) -trap 'rm -f "$temp_list"' EXIT - -find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do - printf "file '%s'\nduration %s\n" "$img" "$frame_dur" -done > "$temp_list" - -# Append last image again (required by ffmpeg concat demuxer for images) -last_img=$(find "$image_dir" -type f -name "*.jpg" | sort | tail -n 1) -printf "file '%s'\n" "$last_img" >> "$temp_list" - -# ── Encode — native camera resolution, no audio ─────────────────────────────── -output_file="$output_dir/${yesterday}-fullday.mp4" -echo "Creating: $output_file" - -ffmpeg -loglevel warning \ - -f concat -safe 0 -i "$temp_list" \ - -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") -echo "Done: $output_file (${actual_dur}s / $(echo "scale=1; $actual_dur/60" | bc) min)" diff --git a/install.sh b/install.sh index a6e5823..a4bb3a3 100755 --- a/install.sh +++ b/install.sh @@ -176,12 +176,11 @@ if [ "${AUDIO_ENABLED:-false}" = "true" ]; then timers+=("sky-cam-audio-capture") fi -# ── Per-camera Four Seasons and full-day jobs ───────────────────────────────── -# Reads CAMERAS, SCHEDULE_SEASONS_, SCHEDULE_FULLDAY_ from sky-cam.conf. -# Scripts receive the camera name as $1 so they know which camera to process. +# ── Per-camera Four Seasons jobs ───────────────────────────────────────────── +# Reads CAMERAS and SCHEDULE_SEASONS_ from sky-cam.conf. +# Script receives the camera name as $1 so it knows 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}" \ @@ -194,17 +193,6 @@ for cam in "${CAMERAS[@]}"; do 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 # ── Generate .env.example ──────────────────────────────────────────────────── diff --git a/montage-mvt.sh b/montage-mvt.sh index 9854385..7601c91 100755 --- a/montage-mvt.sh +++ b/montage-mvt.sh @@ -195,16 +195,28 @@ if ffmpeg -loglevel warning \ -t "$music_duration" -y "$output_file"; then rm -f "$sped_file" echo "Done: $output_file" + + # ── Post-build checks ───────────────────────────────────────────────────── + actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$output_file") + dur_delta=$(echo "scale=1; $actual_dur - $music_duration" | bc | sed 's/^-//') + stream_types=$(ffprobe -v error -show_entries stream=codec_type -of csv=p=0 "$output_file" 2>/dev/null) + has_video=$(echo "$stream_types" | grep -c "video" || true) + has_audio=$(echo "$stream_types" | grep -c "audio" || true) + checks="duration=${actual_dur}s (drift ${dur_delta}s)" + [ "$has_video" -gt 0 ] && checks="$checks video=ok" || checks="$checks video=MISSING" + [ "$has_audio" -gt 0 ] && checks="$checks audio=ok" || checks="$checks audio=MISSING" + echo "Checks: $checks" + "$SCRIPT_DIR/notify.sh" \ - "Montage ready: $SEASON Mvt $MVT_NUM ($ASTRO_YEAR)" \ - "$(basename "$output_file") — ${music_duration}s" \ + "Montage ready: $SEASON Mvt $MVT_NUM ($ASTRO_YEAR) [$CAM_NAME]" \ + "$(basename "$output_file") | $checks | verify: ./verify-mvt.sh $ASTRO_YEAR $SEASON $MVT_NUM $CAM_NAME" \ || true else mv "$sped_file" "$output_file" echo "Music+overlay failed — silent video promoted to: $output_file" "$SCRIPT_DIR/notify.sh" \ - "WARNING: montage audio+overlay failed — $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \ - "Silent video saved as $(basename "$output_file") — re-run montage-mvt.sh to retry" || true + "WARNING: montage audio+overlay failed — $SEASON Mvt$MVT_NUM ($ASTRO_YEAR) [$CAM_NAME]" \ + "Silent video saved as $(basename "$output_file") — run: ./verify-mvt.sh $ASTRO_YEAR $SEASON $MVT_NUM $CAM_NAME" || true fi # ── Trigger year-end join on last Autumn movement ──────────────────────────── diff --git a/sky-cam.conf b/sky-cam.conf index 624e6a5..0d006b9 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -48,9 +48,6 @@ # on the last day of a movement, auto-triggers # montage-mvt.sh # -# 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 @@ -104,11 +101,6 @@ # then re-run install.sh # calls → season_info.py, montage-mvt.sh # -# fullday-video.sh -# ← 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 # ← called by 4-seasons.sh on the last day of each movement # → if renamed: update 4-seasons.sh (the exec line near the bottom) @@ -180,7 +172,7 @@ TIMEZONE=America/New_York # 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=(east) # e.g. CAMERAS=(east north south west) +CAMERAS=(east north south) # e.g. CAMERAS=(east north south west) SUNRISE_CAM=east # which camera faces east (gets the sunrise video job) # ── Schedules ───────────────────────────────────────────────────────────────── @@ -207,18 +199,15 @@ SUNRISE_CAM=east # which camera faces east (gets the sunrise v # SCHEDULE_SUNRISE=03:00:00 +# Four Seasons daily clip — one per camera, staggered 30 min apart. +# Processes yesterday's images; on the last day of a movement auto-triggers +# montage-mvt.sh. Space cameras at least 30 min apart to avoid disk contention. SCHEDULE_SEASONS_east=01:00:00 -SCHEDULE_FULLDAY_east=02:00:00 +SCHEDULE_SEASONS_north=01:30:00 +SCHEDULE_SEASONS_south=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_south=02:00:00 -#SCHEDULE_FULLDAY_south=03:00:00 -# +# Add more cameras here: #SCHEDULE_SEASONS_west=02:30:00 -#SCHEDULE_FULLDAY_west=03:30:00 # ── Sunrise video tuning ────────────────────────────────────────────────────── # Capture window around sunrise: @@ -235,10 +224,6 @@ SUNRISE_OVERLAY_ENABLED=true # Opacity of the sunrise-time text (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 @@ -248,7 +233,6 @@ RETENTION_DAYS=10 # delete full-day videos older than this many days # 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 diff --git a/systemd/sky-cam-fullday.service b/systemd/sky-cam-fullday.service deleted file mode 100644 index bef3263..0000000 --- a/systemd/sky-cam-fullday.service +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Sky-Cam full-day timelapse (10-day retention) -OnFailure=sky-cam-notify-failure@%n.service - -[Service] -Type=oneshot -ExecStart=/home/motion/drives/local-2tb/sunrise-scripts/fullday-video.sh -StandardOutput=journal -StandardError=journal diff --git a/systemd/sky-cam-fullday.timer b/systemd/sky-cam-fullday.timer deleted file mode 100644 index e47a900..0000000 --- a/systemd/sky-cam-fullday.timer +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Sky-Cam full-day timelapse — 02:00 - -[Timer] -OnCalendar=*-*-* 02:00:00 -Persistent=true - -[Install] -WantedBy=timers.target diff --git a/verify-mvt.sh b/verify-mvt.sh new file mode 100755 index 0000000..da59351 --- /dev/null +++ b/verify-mvt.sh @@ -0,0 +1,199 @@ +#!/bin/bash +# verify-mvt.sh — review a completed movement montage and manage source JPEGs. +# +# Usage: +# ./verify-mvt.sh [CAM_NAME] +# +# Shows technical checks on the montage, lets you play it, then: +# (p) play — open in ffplay / mpv / vlc (whichever is available) +# (g) good — delete source JPEG folders for all movement days +# (r) redo — re-run music+overlay only (keeps sped file if present) +# (R) redo-all — re-run full montage from daily clips +# (q) quit — exit without changes + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +source "$SCRIPT_DIR/sky-cam.conf" +export TIMEZONE + +# ── Args ────────────────────────────────────────────────────────────────────── +ASTRO_YEAR="${1:-}" +SEASON="${2:-}" +MVT_NUM="${3:-}" +CAM_NAME="${4:-$SUNRISE_CAM}" + +if [ -z "$ASTRO_YEAR" ] || [ -z "$SEASON" ] || [ -z "$MVT_NUM" ]; then + echo "Usage: $0 [CAM_NAME]" + echo " e.g. $0 2025 Spring 1 east" + exit 1 +fi + +# ── Locate montage file ─────────────────────────────────────────────────────── +montage_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt${MVT_NUM}/montage" +montage_file=$(find "$montage_dir" -maxdepth 1 -name "*-Montage.mp4" 2>/dev/null | sort | tail -n 1) + +if [ -z "$montage_file" ] || [ ! -f "$montage_file" ]; then + echo "ERROR: No montage file found in $montage_dir" + echo " Run: ./montage-mvt.sh to build it first." + exit 1 +fi + +# ── Get movement date range from season_info ────────────────────────────────── +_info="$(python3 "$SCRIPT_DIR/season_info.py" "$MVT_START" 2>/dev/null)" 2>/dev/null || true + +# Use the sped file's date prefix to find a representative date in the movement +sped_file=$(find "$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt${MVT_NUM}" \ + -maxdepth 1 -name "*-Sped.mp4" 2>/dev/null | head -n 1 || true) + +# Get MVT_START from the montage filename (format: YYYY-MM-DD_Season_MvtN-Montage.mp4) +mvt_start_date=$(basename "$montage_file" | cut -d_ -f1) +_info="$(python3 "$SCRIPT_DIR/season_info.py" "$mvt_start_date")" +eval "$_info" +# Provides: MVT_START MVT_END SEASON MVT_NUM DAYS_IN_MVT + +# ── Technical checks ────────────────────────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " $SEASON Mvt $MVT_NUM ($ASTRO_YEAR) [$CAM_NAME]" +echo " Movement: $MVT_START → $MVT_END ($DAYS_IN_MVT days)" +echo " File: $(basename "$montage_file")" +echo "══════════════════════════════════════════════════════" + +actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$montage_file" 2>/dev/null || echo "?") +stream_types=$(ffprobe -v error -show_entries stream=codec_type -of csv=p=0 "$montage_file" 2>/dev/null || echo "") +has_video=$(echo "$stream_types" | grep -c "video" 2>/dev/null || echo 0) +has_audio=$(echo "$stream_types" | grep -c "audio" 2>/dev/null || echo 0) +file_size=$(du -sh "$montage_file" | cut -f1) + +# Count finals that went into the montage +finals_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt${MVT_NUM}" +final_count=$(find "$finals_dir" -maxdepth 1 -name "*-final.mp4" 2>/dev/null | wc -l) + +# Check music file +music_base_dir="$MUSIC_DIR" +[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music" +music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*" \ + | grep -iE "Mvt[^0-9]*${MVT_NUM}[^0-9]" | sort | head -n 1) +music_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$music_file" 2>/dev/null || echo "?") +dur_delta="?" +if [ "$actual_dur" != "?" ] && [ "$music_dur" != "?" ]; then + dur_delta=$(echo "scale=2; $actual_dur - $music_dur" | bc | sed 's/^-//') +fi + +echo "" +printf " Duration : %ss (music: %ss drift: %ss)\n" "$actual_dur" "$music_dur" "$dur_delta" +printf " Video stream : %s\n" "$([ "$has_video" -gt 0 ] && echo "ok" || echo "MISSING")" +printf " Audio stream : %s\n" "$([ "$has_audio" -gt 0 ] && echo "ok" || echo "MISSING")" +printf " File size : %s\n" "$file_size" +printf " Daily clips : %d / %d days\n" "$final_count" "$DAYS_IN_MVT" +printf " Music : %s\n" "$(basename "${music_file:-NOT FOUND}")" + +# Count JPEG folders available for deletion +jpeg_count=0 +jpeg_size=0 +d="$MVT_START" +while [[ "$d" < "$MVT_END" || "$d" == "$MVT_END" ]]; do + dir="$BASE_DIR/$CAM_NAME/$d" + if [ -d "$dir" ]; then + jpeg_count=$(( jpeg_count + 1 )) + fi + d=$(date -d "$d + 1 day" +%Y-%m-%d) +done + +if [ "$jpeg_count" -gt 0 ]; then + jpeg_size=$(du -sh "$BASE_DIR/$CAM_NAME" --exclude="*" 2>/dev/null || true) + # Sum only the movement date folders + total_jpeg_bytes=0 + d="$MVT_START" + while [[ "$d" < "$MVT_END" || "$d" == "$MVT_END" ]]; do + dir="$BASE_DIR/$CAM_NAME/$d" + [ -d "$dir" ] && total_jpeg_bytes=$(du -sb "$dir" 2>/dev/null | cut -f1 || echo 0) || true + d=$(date -d "$d + 1 day" +%Y-%m-%d) + done + printf " JPEG folders : %d date folders (%s → %s)\n" \ + "$jpeg_count" "$MVT_START" "$MVT_END" +else + printf " JPEG folders : none found (already deleted)\n" +fi + +echo "" + +# ── Interactive menu ────────────────────────────────────────────────────────── +while true; do + echo " (p) play the montage" + echo " (g) good — delete JPEG folders for this movement" + echo " (r) redo music+overlay (keeps daily clips)" + echo " (R) redo all (re-concat from daily clips)" + echo " (q) quit" + echo "" + read -rp " Choice: " choice + + case "$choice" in + p|P) + if command -v mpv &>/dev/null; then + mpv "$montage_file" & + elif command -v vlc &>/dev/null; then + vlc "$montage_file" & + elif command -v ffplay &>/dev/null; then + ffplay "$montage_file" & + else + echo " No player found (install mpv, vlc, or ffplay)" + fi + ;; + + g|G) + if [ "$jpeg_count" -eq 0 ]; then + echo " No JPEG folders found for this movement — nothing to delete." + continue + fi + echo "" + echo " Will delete $jpeg_count JPEG date folders ($MVT_START → $MVT_END) for camera [$CAM_NAME]." + echo " The montage video and daily clips are kept." + read -rp " Confirm? [yes/no]: " confirm + if [ "$confirm" = "yes" ]; then + deleted=0 + d="$MVT_START" + while [[ "$d" < "$MVT_END" || "$d" == "$MVT_END" ]]; do + dir="$BASE_DIR/$CAM_NAME/$d" + if [ -d "$dir" ]; then + rm -rf "$dir" + echo " deleted $dir" + deleted=$(( deleted + 1 )) + fi + d=$(date -d "$d + 1 day" +%Y-%m-%d) + done + echo " Done — $deleted JPEG folders deleted." + "$SCRIPT_DIR/notify.sh" \ + "JPEGs deleted: $SEASON Mvt$MVT_NUM ($ASTRO_YEAR) [$CAM_NAME]" \ + "$deleted date folders removed after manual verification" || true + else + echo " Cancelled." + fi + ;; + + r) + echo " Re-running music+overlay step..." + "$SCRIPT_DIR/montage-mvt.sh" "$mvt_start_date" "$CAM_NAME" + break + ;; + + R) + echo " Re-running full montage (concat + speed + music + overlay)..." + # Remove sped file so montage-mvt.sh starts from scratch + find "$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt${MVT_NUM}" \ + -maxdepth 1 -name "*-Sped.mp4" -delete 2>/dev/null || true + "$SCRIPT_DIR/montage-mvt.sh" "$mvt_start_date" "$CAM_NAME" + break + ;; + + q|Q) + break + ;; + + *) + echo " Unknown choice — enter p, g, r, R, or q." + ;; + esac + echo "" +done