From 1a1ff1a041472c6bc03759a4c741018f0afa584c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 17:52:29 +0000 Subject: [PATCH] Fix one-frame bug; add --test mode to daily_sunrise_video.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the concat list had no 'duration' entries, so all JPEG frames collapsed to timestamp 0 and only one frame survived encoding. Fix: add 'duration CAPTURE_INTERVAL' per entry in both scripts. Also add fps=25 to the speed-adjust step so the VFR source is resampled to a standard frame rate — without this, players see ~6fps and may drop frames to reach the declared rate. daily_sunrise_video.sh --test [MINUTES]: Skips sunrise.py lookup and capture-window wait. Uses the last MINUTES minutes of captured frames (default 2). Overlay shows the mid-point frame's timestamp. Audio grabbed live from RTSP immediately. Output: -daily-sunrise-test.mp4 — no notifications or upload. Usage: ./daily_sunrise_video.sh east --test 2 https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- daily_sunrise_video.sh | 290 ++++++++++++++++++++++++++--------------- test-sunrise.sh | 2 +- 2 files changed, 186 insertions(+), 106 deletions(-) diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index cbd525d..e398e31 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -1,7 +1,7 @@ #!/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 +# SUNRISE_TARGET_SECS, burn the local sunrise time vertically in the lower-right +# corner, 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. # @@ -10,60 +10,76 @@ # /-daily-sunrise-sped.mp4 # Re-run this script once the issue is resolved; the sped file is deleted # automatically when the overlay step succeeds. +# +# Usage: +# ./daily_sunrise_video.sh [cam_name] +# ./daily_sunrise_video.sh [cam_name] --test [MINUTES] +# +# --test MINUTES skips sunrise.py and the capture-window wait; uses the last +# MINUTES minutes of captured frames (default 2), grabs live +# RTSP audio, writes -daily-sunrise-test.mp4. set -euo pipefail SCRIPT_DIR="$(dirname "$(realpath "$0")")" source "$SCRIPT_DIR/sky-cam.conf" +source "$SCRIPT_DIR/.env" 2>/dev/null || true export TZ="$TIMEZONE" CAM_NAME="${1:-$SUNRISE_CAM}" +TEST_MODE=false +TEST_MINUTES=2 +if [[ "${2:-}" == "--test" ]]; then + TEST_MODE=true + TEST_MINUTES="${3:-2}" +fi + +time_to_seconds() { IFS='-' read -r h m s <<< "$1"; echo $((10#$h * 3600 + 10#$m * 60 + 10#$s)); } # ── Date / paths ────────────────────────────────────────────────────────────── current_date=$(date +%Y-%m-%d) output_date=$(date +%Y-%m) - image_dir="$BASE_DIR/$CAM_NAME/$current_date" output_dir="$MOVIES_DIR/$CAM_NAME/$output_date/sunrise-only" 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 -echo "Sunrise (UTC): $sunrise_time" - -sunrise_time_local=$(TZ="$TIMEZONE" date -d "$sunrise_time" +"%H-%M-%S") -echo "Sunrise (local): $sunrise_time_local" - -# ── Capture window ──────────────────────────────────────────────────────────── -time_to_seconds() { IFS='-' read -r h m s <<< "$1"; echo $((10#$h * 3600 + 10#$m * 60 + 10#$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)))" - -# ── Wait for capture window to finish ──────────────────────────────────────── -# The timer fires early (default 03:00); we sleep internally so images from -# the full window are available before we start encoding. -midnight=$(date -d "today 00:00:00" +%s) -now_day_sec=$(( $(date +%s) - midnight )) -if [ "$now_day_sec" -lt "$end_sec" ]; then - wait_sec=$(( end_sec - now_day_sec + 30 )) - echo "Waiting ${wait_sec}s for capture window to finish (ends $(date -d "@$(( midnight + end_sec ))" '+%H:%M:%S'))..." - sleep "$wait_sec" -else - echo "Capture window already closed at $(date -d "@$(( midnight + end_sec ))" '+%H:%M:%S') — processing available images" +# ── Sunrise time (skipped in test mode) ─────────────────────────────────────── +SR_TIME="" +sunrise_time_local="" +if ! $TEST_MODE; then + 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 + echo "Sunrise (UTC): $sunrise_time" + sunrise_time_local=$(TZ="$TIMEZONE" date -d "$sunrise_time" +"%H-%M-%S") + echo "Sunrise (local): $sunrise_time_local" + SR_TIME=$(echo "$sunrise_time_local" | cut -d'-' -f1,2 | tr '-' ':') fi -# ── Collect images in window ────────────────────────────────────────────────── +# ── Capture window (skipped in test mode) ───────────────────────────────────── +if ! $TEST_MODE; then + 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)))" + midnight=$(date -d "today 00:00:00" +%s) + now_day_sec=$(( $(date +%s) - midnight )) + if [ "$now_day_sec" -lt "$end_sec" ]; then + wait_sec=$(( end_sec - now_day_sec + 30 )) + echo "Waiting ${wait_sec}s for capture window to finish (ends $(date -d "@$(( midnight + end_sec ))" '+%H:%M:%S'))..." + sleep "$wait_sec" + else + echo "Capture window already closed at $(date -d "@$(( midnight + end_sec ))" '+%H:%M:%S') — processing available images" + fi +fi + +# ── Collect images ──────────────────────────────────────────────────────────── if [ ! -d "$image_dir" ]; then - "$SCRIPT_DIR/notify.sh" "FAILED: sunrise $current_date" \ + $TEST_MODE || "$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 @@ -73,25 +89,47 @@ temp_list=$(mktemp --suffix=.txt) raw_video="" temp_audio="" temp_text="" -trap 'rm -f "$temp_list" "$raw_video" "$temp_audio" "$temp_text" 2>/dev/null || true' EXIT +temp_live_audio="" +trap 'rm -f "$temp_list" "$raw_video" "$temp_audio" "$temp_text" "$temp_live_audio" 2>/dev/null || true' EXIT -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" +if $TEST_MODE; then + test_frames=$(( TEST_MINUTES * 60 / ${CAPTURE_INTERVAL:-10} )) + mapfile -t images < <(find "$image_dir" -name "*.jpg" | sort | tail -n "$test_frames") + echo "Test mode: last ${TEST_MINUTES} min (${#images[@]} frames)" +else + mapfile -t images < <( + find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do + img_sec=$(time_to_seconds "$(basename "$img" .jpg)") + [[ $img_sec -ge $start_sec && $img_sec -le $end_sec ]] && echo "$img" + done + ) +fi -num_images=$(wc -l < "$temp_list") -if [[ $num_images -lt 1 ]]; then - "$SCRIPT_DIR/notify.sh" "FAILED: sunrise $current_date" \ +if [ "${#images[@]}" -lt 1 ]; then + $TEST_MODE || "$SCRIPT_DIR/notify.sh" "FAILED: sunrise $current_date" \ "No images found in sunrise window" || true - echo "No images found in sunrise window." + echo "No images found." exit 1 fi -echo "Images in window: $num_images" +echo "Images: ${#images[@]}" -# ── Font detection (same fallback chain as montage-mvt.sh) ─────────────────── +# Concat list with explicit frame duration so each frame represents real elapsed +# time. Without this, all frames collapse to timestamp 0 → one-frame video. +# The final entry needs a duplicate without duration (ffmpeg concat requirement). +for img in "${images[@]}"; do + printf "file '%s'\nduration %s\n" "$img" "${CAPTURE_INTERVAL:-10}" +done > "$temp_list" +printf "file '%s'\n" "${images[-1]}" >> "$temp_list" + +# ── Set overlay time ────────────────────────────────────────────────────────── +if $TEST_MODE; then + mid_idx=$(( ${#images[@]} / 2 )) + mid_hms=$(basename "${images[$mid_idx]}" .jpg) + SR_TIME=$(echo "$mid_hms" | cut -c1-5 | tr '-' ':') + echo "Mid-point frame: $mid_hms → overlay time: $SR_TIME" +fi + +# ── Font detection ──────────────────────────────────────────────────────────── FONT="" if command -v fc-match &>/dev/null; then FONT=$(fc-match "DejaVu Sans:style=Regular" --format="%{file}" 2>/dev/null || true) @@ -108,12 +146,12 @@ fi # ── Step 1: Raw video from images ───────────────────────────────────────────── raw_video=$(mktemp --suffix=.mp4) -echo "Step 1/3: encoding raw video..." +echo "Step 1/3: encoding raw video from ${#images[@]} frames..." if ! ffmpeg -loglevel warning \ -f concat -safe 0 -i "$temp_list" \ -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SUNRISE" -vsync 2 -an \ -y "$raw_video"; then - "$SCRIPT_DIR/notify.sh" "FAILED: sunrise step 1/3 (encode) $current_date" \ + $TEST_MODE || "$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 @@ -122,26 +160,34 @@ 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 — 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" +# ── Step 2: Speed-adjust ────────────────────────────────────────────────────── +# Production: saved permanently so overlay failure is recoverable. +# Test: temporary file, deleted on exit. +# fps=25 forces proper frame rate so all source frames appear in the output. +if $TEST_MODE; then + sped_video=$(mktemp --suffix=-sped.mp4) + echo "Step 2/3: speed-adjust (test)..." +else + sped_video="$output_dir/$current_date-daily-sunrise-sped.mp4" + echo "Step 2/3: speed-adjust → $sped_video" +fi if ! ffmpeg -loglevel warning \ -i "$raw_video" \ - -vf "setpts=PTS/${speed_factor}" \ + -vf "setpts=PTS/${speed_factor},fps=25" \ -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SUNRISE" -an \ + -t "$SUNRISE_TARGET_SECS" \ -y "$sped_video"; then rm -f "$sped_video" - "$SCRIPT_DIR/notify.sh" "FAILED: sunrise step 2/3 (speed) $current_date" \ + $TEST_MODE || "$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)" +$TEST_MODE || echo "Speed-adjusted: ${sped_dur}s (saved — overlay still pending)" +$TEST_MODE && echo "Speed-adjusted: ${sped_dur}s" -# ── Build overlay filter (skipped if SUNRISE_OVERLAY_ENABLED=false) ────────── -SR_TIME=$(echo "$sunrise_time_local" | cut -d'-' -f1,2 | tr '-' ':') +# ── Build overlay filter ────────────────────────────────────────────────────── DT="" if [ "${SUNRISE_OVERLAY_ENABLED:-true}" = "true" ]; then temp_text=$(mktemp --suffix=.txt) @@ -159,40 +205,67 @@ if [ "${SUNRISE_OVERLAY_ENABLED:-true}" = "true" ]; then DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1" fi -# ── Step 3: Audio mix + overlay — each independently optional/fallible ──────── -# Step 3a: mixes audio into a temp copy of the sped video (video stream is -# copied, not re-encoded — fast, and lets step 3b fail independently). -# Step 3b: burns the overlay onto whatever 3a produced. -# Failure matrix → upload always fires: -# audio ✓ overlay ✓ → final has audio + overlay -# audio ✓ overlay ✗ → final has audio, no overlay -# audio ✗ overlay ✓ → final has overlay, no audio -# audio ✗ overlay ✗ → sped video promoted (no audio, no overlay) -final_video="$output_dir/$current_date-daily-sunrise.mp4" -fade_out=$(echo "scale=1; $SUNRISE_TARGET_SECS - 0.5" | bc) - # ── Pick audio source ───────────────────────────────────────────────────────── audio_src="" -audio_offset="0" # cam audio is pre-centred on sunrise; library files are trimmed to fit +audio_offset="0" -cam_audio="$image_dir/sunrise-audio.m4a" -if [ "${AUDIO_ENABLED:-false}" = "true" ]; then - if [ -f "$cam_audio" ]; then - # Already exactly SUNRISE_TARGET_SECS, centred on sunrise — no offset needed - audio_src="$cam_audio" - echo "Audio: camera recording (centred on sunrise)" +if $TEST_MODE; then + # Test: grab live RTSP audio now + rtsp_var="CAM_RTSP_${CAM_NAME}" + RTSP_URL="${!rtsp_var:-}" + if [ -n "$RTSP_URL" ]; then + temp_live_audio=$(mktemp --suffix=.m4a) + echo "Test: recording ${SUNRISE_TARGET_SECS}s of live audio..." + if ffmpeg -loglevel warning \ + -rtsp_transport tcp \ + -i "$RTSP_URL" \ + -t "$SUNRISE_TARGET_SECS" \ + -vn -c:a aac -b:a 64k \ + -y "$temp_live_audio"; then + audio_src="$temp_live_audio" + echo "Audio: live RTSP captured OK" + else + echo "Audio: live RTSP failed — continuing without audio" + rm -f "$temp_live_audio"; temp_live_audio="" + fi else - library_dir="$SCRIPT_DIR/sunrise-sounds" - if [ -d "$library_dir" ]; then - random_file=$(find "$library_dir" -name "*.mp3" | shuf -n 1 2>/dev/null || true) - if [ -n "$random_file" ]; then - audio_src="$random_file" - echo "Audio: library fallback $(basename "$audio_src")" + echo "Audio: CAM_RTSP_${CAM_NAME} not set — skipping" + fi +else + cam_audio="$image_dir/sunrise-audio.m4a" + if [ "${AUDIO_ENABLED:-false}" = "true" ]; then + if [ -f "$cam_audio" ]; then + audio_src="$cam_audio" + echo "Audio: camera recording (centred on sunrise)" + else + library_dir="$SCRIPT_DIR/sunrise-sounds" + if [ -d "$library_dir" ]; then + random_file=$(find "$library_dir" -name "*.mp3" | shuf -n 1 2>/dev/null || true) + if [ -n "$random_file" ]; then + audio_src="$random_file" + echo "Audio: library fallback $(basename "$audio_src")" + fi fi fi fi fi +# ── Step 3: Audio mix + overlay — each independently optional/fallible ──────── +# Step 3a: mixes audio into a temp copy of the sped video (video stream is +# copied, not re-encoded — fast, and lets step 3b fail independently). +# Step 3b: burns the overlay onto whatever 3a produced. +# Failure matrix → upload always fires (production); test just continues: +# audio ✓ overlay ✓ → final has audio + overlay +# audio ✓ overlay ✗ → final has audio, no overlay +# audio ✗ overlay ✓ → final has overlay, no audio +# audio ✗ overlay ✗ → sped video promoted (no audio, no overlay) +if $TEST_MODE; then + final_video="$output_dir/$current_date-daily-sunrise-test.mp4" +else + final_video="$output_dir/$current_date-daily-sunrise.mp4" +fi +fade_out=$(echo "scale=1; $SUNRISE_TARGET_SECS - 0.5" | bc) + # ── Step 3a: mix audio (video copied, not re-encoded) ──────────────────────── work_video="$sped_video" has_audio=false @@ -213,9 +286,11 @@ if [ -n "$audio_src" ]; then else rm -f "$temp_audio"; temp_audio="" echo "Audio mix failed — step 3b will be overlay-only" - "$SCRIPT_DIR/notify.sh" "WARNING: sunrise audio mix failed $current_date" \ - "Audio could not be mixed — continuing with overlay only" || true - [ "$audio_src" = "$cam_audio" ] && rm -f "$cam_audio" + if ! $TEST_MODE; then + "$SCRIPT_DIR/notify.sh" "WARNING: sunrise audio mix failed $current_date" \ + "Audio could not be mixed — continuing with overlay only" || true + [ "$audio_src" = "${cam_audio:-}" ] && rm -f "${cam_audio:-}" + fi fi fi @@ -237,10 +312,12 @@ if [ -n "$DT" ]; then mv "$work_video" "$final_video" [ "$work_video" != "$sped_video" ] && rm -f "$sped_video" temp_audio="" - [ -f "$cam_audio" ] && rm -f "$cam_audio" - echo "Overlay failed — promoting ${promote_label} video as upload target" - "$SCRIPT_DIR/notify.sh" "WARNING: sunrise overlay failed $current_date" \ - "Overlay failed — uploading ${promote_label} video (no timestamp)" || true + if ! $TEST_MODE; then + [ -f "${cam_audio:-}" ] && rm -f "${cam_audio:-}" + echo "Overlay failed — promoting ${promote_label} video as upload target" + "$SCRIPT_DIR/notify.sh" "WARNING: sunrise overlay failed $current_date" \ + "Overlay failed — uploading ${promote_label} video (no timestamp)" || true + fi step3b_ok=true # degraded but recoverable — upload still fires fi else @@ -253,19 +330,22 @@ fi if $step3b_ok; then 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})" + echo "Done: $final_video (${actual_dur}s, time: ${SR_TIME})" rm -f "$sped_video" "$temp_audio" 2>/dev/null || true; temp_audio="" - [ -f "$cam_audio" ] && rm -f "$cam_audio" - "$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \ - "$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true - # ── Rolling retention: delete sunrise videos older than SUNRISE_RETENTION_DAYS ─ - retain="${SUNRISE_RETENTION_DAYS:-10}" - if [ "$retain" -gt 0 ]; then - deleted_old=$(find "$MOVIES_DIR/$CAM_NAME" \ - -path "*/sunrise-only/*-daily-sunrise.mp4" \ - -mtime +"$retain" -print -delete 2>/dev/null | wc -l) - [ "$deleted_old" -gt 0 ] && \ - echo "Retention: deleted $deleted_old sunrise video(s) older than ${retain} days" + if ! $TEST_MODE; then + [ -f "${cam_audio:-}" ] && rm -f "${cam_audio:-}" + "$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \ + "$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true + + # ── Rolling retention ───────────────────────────────────────────────── + retain="${SUNRISE_RETENTION_DAYS:-10}" + if [ "$retain" -gt 0 ]; then + deleted_old=$(find "$MOVIES_DIR/$CAM_NAME" \ + -path "*/sunrise-only/*-daily-sunrise.mp4" \ + -mtime +"$retain" -print -delete 2>/dev/null | wc -l) + [ "$deleted_old" -gt 0 ] && \ + echo "Retention: deleted $deleted_old sunrise video(s) older than ${retain} days" + fi fi fi diff --git a/test-sunrise.sh b/test-sunrise.sh index 1a75c30..f2eac9f 100755 --- a/test-sunrise.sh +++ b/test-sunrise.sh @@ -103,7 +103,7 @@ echo " Raw: ${raw_dur}s speed: ${speed}x" echo "Step 2/4: speed-adjust to ${SUNRISE_TARGET_SECS}s..." ffmpeg -loglevel warning \ -i "$TMP_RAW" \ - -vf "setpts=PTS/$speed" \ + -vf "setpts=PTS/$speed,fps=25" \ -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SUNRISE" -an \ -t "$SUNRISE_TARGET_SECS" \ -y "$TMP_SPED"