- Shorten audio fade in/out to 0.3s so they fit comfortably in the 10s clip. - Lift the sunrise-time overlay by ~5% of frame height to clear the burnt-in timestamp on the source images. - When today's sunrise recording is missing, fall back to yesterday's clip before reaching for the library fallback.
389 lines
17 KiB
Bash
Executable File
389 lines
17 KiB
Bash
Executable File
#!/bin/bash
|
|
# daily_sunrise_video.sh — collect today's sunrise images, speed-adjust to
|
|
# 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.
|
|
#
|
|
# Resilience: the speed-adjusted video is saved permanently before the overlay
|
|
# step. If overlay fails, the sped video survives at:
|
|
# <output_dir>/<date>-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 <date>-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 (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 (local): $sunrise_time"
|
|
sunrise_time_local=$(TZ="$TIMEZONE" date -d "$sunrise_time" +"%H-%M-%S")
|
|
SR_TIME=$(echo "$sunrise_time_local" | cut -d'-' -f1,2 | tr '-' ':')
|
|
fi
|
|
|
|
# ── 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
|
|
$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
|
|
fi
|
|
|
|
temp_list=$(mktemp --suffix=.txt)
|
|
raw_video=""
|
|
temp_audio=""
|
|
temp_text=""
|
|
temp_live_audio=""
|
|
trap 'rm -f "$temp_list" "$raw_video" "$temp_audio" "$temp_text" "$temp_live_audio" 2>/dev/null || true' EXIT
|
|
|
|
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
|
|
|
|
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."
|
|
exit 1
|
|
fi
|
|
first_t=$(basename "${images[0]}" .jpg | tr '-' ':')
|
|
last_t=$(basename "${images[-1]}" .jpg | tr '-' ':')
|
|
echo "Images: ${#images[@]} (${first_t} → ${last_t} overlay: ${SR_TIME})"
|
|
|
|
if ! $TEST_MODE; then
|
|
last_sec=$(time_to_seconds "$(basename "${images[-1]}" .jpg)")
|
|
gap=$(( end_sec - last_sec ))
|
|
if [ "$gap" -gt $(( ${CAPTURE_INTERVAL:-10} * 3 )) ]; then
|
|
echo "WARNING: last frame is $(( gap / 60 ))min ${gap}s before window end — capture gap near sunrise"
|
|
echo " Check: systemctl --user status sky-cam-capture-${CAM_NAME}.service"
|
|
fi
|
|
fi
|
|
|
|
# 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)
|
|
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
|
|
|
|
# ── Step 1: Raw video from images ─────────────────────────────────────────────
|
|
raw_video=$(mktemp --suffix=.mp4)
|
|
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
|
|
$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
|
|
|
|
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 ──────────────────────────────────────────────────────
|
|
# 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/$CAM_NAME-$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},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"
|
|
$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")
|
|
$TEST_MODE || echo "Speed-adjusted: ${sped_dur}s (saved — overlay still pending)"
|
|
$TEST_MODE && echo "Speed-adjusted: ${sped_dur}s"
|
|
|
|
# ── Build overlay filter ──────────────────────────────────────────────────────
|
|
DT=""
|
|
if [ "${SUNRISE_OVERLAY_ENABLED:-true}" = "true" ]; then
|
|
temp_text=$(mktemp --suffix=.txt)
|
|
printf '%s\n%s\n%s\n%s\n%s' \
|
|
"${SR_TIME:0:1}" "${SR_TIME:1:1}" "${SR_TIME:2:1}" "${SR_TIME:3:1}" "${SR_TIME:4:1}" \
|
|
> "$temp_text"
|
|
if [ -n "$FONT" ]; then
|
|
DT="drawtext=fontfile='${FONT}':textfile='${temp_text}'"
|
|
else
|
|
DT="drawtext=textfile='${temp_text}'"
|
|
fi
|
|
DT="${DT}:fontcolor=yellow@${SUNRISE_OVERLAY_OPACITY}"
|
|
DT="${DT}:fontsize=h/22:line_spacing=4"
|
|
DT="${DT}:x=w-tw-18:y=h-th-18-h*0.05"
|
|
DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1"
|
|
fi
|
|
|
|
# ── Pick audio source ─────────────────────────────────────────────────────────
|
|
audio_src=""
|
|
audio_offset="0"
|
|
|
|
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
|
|
echo "Audio: CAM_RTSP_${CAM_NAME} not set — skipping"
|
|
fi
|
|
else
|
|
# New path: AUDIO_DIR/sunrise/<cam>/<date>/sunrise-audio.m4a
|
|
# Fallback to old path (BASE_DIR/<cam>/<date>/sunrise-audio.m4a) for migration
|
|
cam_audio="${AUDIO_DIR:-$BASE_DIR/audio}/sunrise/$CAM_NAME/$current_date/sunrise-audio.m4a"
|
|
[ ! -f "$cam_audio" ] && 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
|
|
# Yesterday fallback before library: keeps the clip on-site
|
|
yesterday_date=$(date -d "yesterday" +%Y-%m-%d)
|
|
yday_audio="${AUDIO_DIR:-$BASE_DIR/audio}/sunrise/$CAM_NAME/$yesterday_date/sunrise-audio.m4a"
|
|
[ ! -f "$yday_audio" ] && yday_audio="$BASE_DIR/$CAM_NAME/$yesterday_date/sunrise-audio.m4a"
|
|
if [ -f "$yday_audio" ]; then
|
|
audio_src="$yday_audio"
|
|
echo "Audio: yesterday's recording ($yesterday_date)"
|
|
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
|
|
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
|
|
demo_dir="${DEMO_DIR:-$MOVIES_DIR/demoio}/$CAM_NAME"
|
|
mkdir -p "$demo_dir"
|
|
final_video="$demo_dir/$CAM_NAME-$current_date-daily-sunrise-test.mp4"
|
|
else
|
|
final_video="$output_dir/$CAM_NAME-$current_date-daily-sunrise.mp4"
|
|
fi
|
|
fade_dur=0.3
|
|
fade_out=$(echo "scale=2; $SUNRISE_TARGET_SECS - $fade_dur" | bc)
|
|
|
|
# ── Step 3a: mix audio (video copied, not re-encoded) ────────────────────────
|
|
work_video="$sped_video"
|
|
has_audio=false
|
|
|
|
if [ -n "$audio_src" ]; then
|
|
temp_audio=$(mktemp --suffix=.mp4)
|
|
echo "Step 3a/3: mixing audio → temp"
|
|
if ffmpeg -loglevel warning \
|
|
-i "$sped_video" \
|
|
-ss "$audio_offset" -t "$SUNRISE_TARGET_SECS" -i "$audio_src" \
|
|
-filter_complex "[1:a]afade=t=in:st=0:d=${fade_dur},afade=t=out:st=${fade_out}:d=${fade_dur}[aout]" \
|
|
-map "0:v" -map "[aout]" \
|
|
-c:v copy -c:a aac -b:a 128k \
|
|
-y "$temp_audio"; then
|
|
work_video="$temp_audio"
|
|
has_audio=true
|
|
echo "Audio: mixed OK"
|
|
else
|
|
rm -f "$temp_audio"; temp_audio=""
|
|
echo "Audio mix failed — step 3b will be overlay-only"
|
|
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
|
|
|
|
# ── Step 3b: burn overlay (or promote work_video directly if disabled) ────────
|
|
if $has_audio; then audio_out_flags=(-c:a copy); else audio_out_flags=(-an); fi
|
|
|
|
step3b_ok=false
|
|
if [ -n "$DT" ]; then
|
|
echo "Step 3b/3: overlay → $final_video"
|
|
if ffmpeg -loglevel warning \
|
|
-i "$work_video" \
|
|
-vf "${DT}" \
|
|
-c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SUNRISE" \
|
|
"${audio_out_flags[@]}" \
|
|
-y "$final_video"; then
|
|
step3b_ok=true
|
|
else
|
|
if $has_audio; then promote_label="audio-mixed"; else promote_label="speed-only"; fi
|
|
mv "$work_video" "$final_video"
|
|
[ "$work_video" != "$sped_video" ] && rm -f "$sped_video"
|
|
temp_audio=""
|
|
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
|
|
echo "Step 3b/3: overlay disabled — promoting as final"
|
|
mv "$work_video" "$final_video"
|
|
[ "$work_video" != "$sped_video" ] && rm -f "$sped_video"
|
|
temp_audio=""
|
|
step3b_ok=true
|
|
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, time: ${SR_TIME})"
|
|
rm -f "$sped_video" "$temp_audio" 2>/dev/null || true; temp_audio=""
|
|
|
|
if $TEST_MODE; then
|
|
# ── Demo retention ────────────────────────────────────────────────────
|
|
demo_retain="${DEMO_RETENTION_DAYS:-8}"
|
|
if [ "$demo_retain" -gt 0 ]; then
|
|
deleted_demo=$(find "${DEMO_DIR:-$MOVIES_DIR/demoio}" \
|
|
-name "*-daily-sunrise-test.mp4" \
|
|
-mtime +"$demo_retain" -print -delete 2>/dev/null | wc -l)
|
|
[ "$deleted_demo" -gt 0 ] && \
|
|
echo "Demo retention: deleted $deleted_demo test file(s) older than ${demo_retain} days" || true
|
|
fi
|
|
else
|
|
[ -f "${cam_audio:-}" ] && rm -f "${cam_audio:-}" || true
|
|
"$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \
|
|
"$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME} | $final_video" || 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" || true
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
exit 0
|