fade_dur = SUNRISE_TARGET_SECS * 0.085 = 0.850 at the default 10s target. bc outputs .850 without a leading zero, which ffmpeg's afade filter rejects with "Unable to parse option value .850 as duration". Pipe bc output through sed to prepend the zero when the value starts with '.'. https://claude.ai/code/session_01DANuzLCLhhQ7Li12RTcswY
578 lines
28 KiB
Bash
Executable File
578 lines
28 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 ────────────────────────────────────────────────────────────
|
||
# Picks a TrueType font file for ffmpeg's drawtext filter. drawtext needs an
|
||
# absolute path to a .ttf/.otf file — it cannot use a font *name* like Arial.
|
||
#
|
||
# To use a different font:
|
||
# 1. Find the .ttf path on this machine, e.g.:
|
||
# fc-match "Liberation Mono" --format='%{file}\n'
|
||
# find /usr/share/fonts -name '*.ttf' | grep -i mono
|
||
# 2. Either (a) hard-code it below by replacing the fc-match line with:
|
||
# FONT="/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf"
|
||
# or (b) change the fc-match query string ("DejaVu Sans:style=Regular")
|
||
# to your preferred face, e.g. "Liberation Mono:style=Bold".
|
||
# 3. Add the chosen path to the fallback list below so the script still works
|
||
# if fc-match is missing.
|
||
# Bold faces (e.g. DejaVuSans-Bold.ttf) read better at small sizes against sky.
|
||
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 ──────────────────────────────────────────────────────
|
||
# Burns the sunrise time onto the video using ffmpeg's drawtext filter.
|
||
# Layout is VERTICAL — each character of "HH:MM" is on its own line, stacked
|
||
# top-to-bottom in the lower-right corner. This is achieved by writing each
|
||
# char to a separate line of $temp_text, then drawtext renders that file
|
||
# verbatim (newlines = line breaks).
|
||
#
|
||
# To switch to a HORIZONTAL "HH:MM" overlay, replace the printf below with:
|
||
# printf '%s' "$SR_TIME" > "$temp_text"
|
||
# and bump fontsize (e.g. h/18) since horizontal text needs less vertical room.
|
||
#
|
||
# ── Quick-edit cheatsheet for every drawtext parameter ──────────────────────
|
||
#
|
||
# FONT FILE (set above in the "Font detection" block, not here).
|
||
# drawtext requires a .ttf path, not a font name.
|
||
#
|
||
# fontcolor Text color. Accepts named colors (yellow, white, red, cyan,
|
||
# orange, lime, magenta, gray) or hex (0xFFCC00, 0xFF8800).
|
||
# The "@N" suffix is opacity 0.0–1.0; comes from
|
||
# SUNRISE_OVERLAY_OPACITY in sky-cam.conf. Examples:
|
||
# fontcolor=white@0.7 # softer white, 70% opaque
|
||
# fontcolor=0xFF8800@0.9 # warm orange, 90% opaque
|
||
#
|
||
# fontsize Pixel height of glyphs. "h/22" = video-height / 22, so it
|
||
# scales with resolution (1080p → ~49px, 720p → ~33px). Use a
|
||
# smaller divisor for BIGGER text:
|
||
# fontsize=h/30 → small fontsize=h/22 → current
|
||
# fontsize=h/18 → medium fontsize=h/14 → large
|
||
# Or set an absolute pixel size: fontsize=48
|
||
#
|
||
# line_spacing Pixels of gap between the stacked characters. Increase for
|
||
# a more spaced look (e.g. 8 or 12); 0 packs them tight.
|
||
#
|
||
# x , y Position of the text box. ffmpeg exposes:
|
||
# w = video width h = video height
|
||
# tw = text width th = text height
|
||
# Current values place the box in the BOTTOM-RIGHT with an
|
||
# 18px right margin and a bottom margin of 18px + 5% of height.
|
||
# Recipes for the other corners (keep ~18px breathing room):
|
||
# Top-left: x=18 y=18
|
||
# Top-right: x=w-tw-18 y=18
|
||
# Bottom-left: x=18 y=h-th-18-h*0.05
|
||
# Bottom-right: x=w-tw-18 y=h-th-18-h*0.15 (current)
|
||
# Centered: x=(w-tw)/2 y=(h-th)/2
|
||
# Centered top: x=(w-tw)/2 y=24
|
||
# To nudge the current spot, change the "18" margins or the
|
||
# "h*0.15" lift (15% above the bottom edge — lower to 0.05 to
|
||
# sit closer to the bottom, raise to 0.20 for more clearance).
|
||
#
|
||
# shadowcolor / shadowx / shadowy
|
||
# A 1-pixel black drop-shadow @55% opacity for legibility against
|
||
# bright sky. Set shadowx=shadowy=0 to disable. For a thicker
|
||
# outline, swap the shadow* lines for:
|
||
# :borderw=2:bordercolor=black@0.7
|
||
# (borderw = outline thickness in pixels.)
|
||
#
|
||
# Other useful drawtext options you can append with ":name=value":
|
||
# box=1:boxcolor=black@0.4:boxborderw=6 # solid background pill
|
||
# alpha='if(lt(t,1),t,1)' # 1-second fade-in
|
||
# enable='between(t,2,8)' # only show 2s–8s
|
||
#
|
||
# Full reference: https://ffmpeg.org/ffmpeg-filters.html#drawtext
|
||
DT=""
|
||
if [ "${SUNRISE_OVERLAY_ENABLED:-true}" = "true" ]; then
|
||
# Vertical layout: write each char of "HH:MM" on its own line.
|
||
# SR_TIME is exactly 5 chars (e.g. "06:42"); indices 0..4 below.
|
||
# Switch to horizontal by replacing this printf with: printf '%s' "$SR_TIME" > "$temp_text"
|
||
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"
|
||
|
||
# Source the glyph file + the text. fontfile is omitted if FONT detection
|
||
# failed above, in which case ffmpeg falls back to its built-in font.
|
||
if [ -n "$FONT" ]; then
|
||
DT="drawtext=fontfile='${FONT}':textfile='${temp_text}'"
|
||
else
|
||
DT="drawtext=textfile='${temp_text}'"
|
||
fi
|
||
|
||
# Color + opacity. Change "yellow" to any color name or 0xRRGGBB.
|
||
# Opacity comes from SUNRISE_OVERLAY_OPACITY in sky-cam.conf.
|
||
DT="${DT}:fontcolor=yellow@${SUNRISE_OVERLAY_OPACITY}"
|
||
|
||
# Size + spacing. fontsize is relative to video height (h/22).
|
||
DT="${DT}:fontsize=h/22:line_spacing=4"
|
||
|
||
# Position — bottom-right corner. See the corner recipes in the comment
|
||
# block above. The "18" values are pixel margins; "h*0.15" lifts the box
|
||
# 15% of the height off the bottom to clear the lower edge.
|
||
DT="${DT}:x=w-tw-18:y=h-th-18-h*0.15"
|
||
|
||
# Drop-shadow for legibility. Set shadowx=shadowy=0 to disable, or swap
|
||
# for :borderw=2:bordercolor=black@0.7 for a thicker outline.
|
||
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)"
|
||
fi
|
||
# 2nd fallback: continuous ambient recording — extract the sunrise window
|
||
# Looks in AUDIO_DIR/<cam>/<date>/ for chunk files named HH-MM-SS[.m4a|-tag.m4a]
|
||
# and extracts SUNRISE_TARGET_SECS centred on sunrise_sec from the right chunk.
|
||
if [ -z "$audio_src" ]; then
|
||
_amb_dir="${AUDIO_DIR:-$BASE_DIR/audio}/$CAM_NAME/$current_date"
|
||
if [ -d "$_amb_dir" ]; then
|
||
_best_chunk=""
|
||
_best_sec=-1
|
||
while IFS= read -r _f; do
|
||
_hms=$(basename "$_f" | cut -c1-8) # first 8 chars = HH-MM-SS
|
||
IFS='-' read -r _h _m _s <<< "$_hms"
|
||
[[ "$_h" =~ ^[0-9]{2}$ ]] || continue
|
||
_cs=$(( 10#$_h * 3600 + 10#$_m * 60 + 10#$_s ))
|
||
if [ "$_cs" -le "$sunrise_sec" ] && [ "$_cs" -gt "$_best_sec" ]; then
|
||
_best_chunk="$_f"; _best_sec=$_cs
|
||
fi
|
||
done < <(find "$_amb_dir" -maxdepth 1 -name "*.m4a" 2>/dev/null | sort)
|
||
if [ -n "$_best_chunk" ]; then
|
||
_offset=$(( sunrise_sec - _best_sec - SUNRISE_TARGET_SECS / 2 ))
|
||
[ "$_offset" -lt 0 ] && _offset=0
|
||
temp_live_audio=$(mktemp --suffix=.m4a)
|
||
echo "Audio: extracting sunrise window from ambient chunk $(basename "$_best_chunk") at +${_offset}s..."
|
||
if ffmpeg -loglevel warning \
|
||
-ss "$_offset" -i "$_best_chunk" \
|
||
-t "$SUNRISE_TARGET_SECS" -vn -c:a copy \
|
||
-y "$temp_live_audio"; then
|
||
audio_src="$temp_live_audio"
|
||
echo "Audio: ambient chunk extracted OK"
|
||
else
|
||
rm -f "$temp_live_audio"; temp_live_audio=""
|
||
echo "Audio: ambient chunk extraction failed"
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
# 3rd fallback: live RTSP — captures current ambient sound right now
|
||
if [ -z "$audio_src" ]; then
|
||
_rtsp_var="CAM_RTSP_${CAM_NAME}"
|
||
_rtsp_url="${!_rtsp_var:-}"
|
||
if [ -n "$_rtsp_url" ]; then
|
||
temp_live_audio=$(mktemp --suffix=.m4a)
|
||
echo "Audio: no saved file — capturing ${SUNRISE_TARGET_SECS}s live from RTSP..."
|
||
if ffmpeg -loglevel warning \
|
||
-rtsp_transport tcp \
|
||
-i "$_rtsp_url" \
|
||
-t "$SUNRISE_TARGET_SECS" \
|
||
-vn -c:a aac -b:a "${CAPTURE_AUDIO_BITRATE:-96k}" \
|
||
-y "$temp_live_audio"; then
|
||
audio_src="$temp_live_audio"
|
||
echo "Audio: live RTSP captured OK"
|
||
else
|
||
rm -f "$temp_live_audio"; temp_live_audio=""
|
||
echo "Audio: live RTSP capture failed — trying yesterday"
|
||
fi
|
||
fi
|
||
fi
|
||
# 4th fallback: yesterday's recording
|
||
if [ -z "$audio_src" ]; then
|
||
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)"
|
||
fi
|
||
fi
|
||
# 5th fallback: library
|
||
if [ -z "$audio_src" ]; then
|
||
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
|
||
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=$(echo "scale=3; $SUNRISE_TARGET_SECS * 0.085" | bc | sed 's/^\./0./')
|
||
fade_out=$(echo "scale=3; $SUNRISE_TARGET_SECS - $fade_dur" | bc | sed 's/^\./0./')
|
||
|
||
# ── 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=""
|
||
# Retry once with a fresh live RTSP capture before giving up on audio
|
||
_mix_ok=false
|
||
if ! $TEST_MODE; then
|
||
_rtsp_var="CAM_RTSP_${CAM_NAME}"
|
||
_rtsp_url="${!_rtsp_var:-}"
|
||
if [ -n "$_rtsp_url" ]; then
|
||
_live_retry=$(mktemp --suffix=.m4a)
|
||
echo "Audio mix failed — retrying with live RTSP capture..."
|
||
if ffmpeg -loglevel warning \
|
||
-rtsp_transport tcp -i "$_rtsp_url" \
|
||
-t "$SUNRISE_TARGET_SECS" -vn \
|
||
-c:a aac -b:a "${CAPTURE_AUDIO_BITRATE:-96k}" \
|
||
-y "$_live_retry"; then
|
||
temp_audio=$(mktemp --suffix=.mp4)
|
||
if ffmpeg -loglevel warning \
|
||
-i "$sped_video" \
|
||
-ss "$audio_offset" -t "$SUNRISE_TARGET_SECS" -i "$_live_retry" \
|
||
-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: live RTSP retry mix OK"
|
||
_mix_ok=true
|
||
else
|
||
rm -f "$temp_audio"; temp_audio=""
|
||
fi
|
||
fi
|
||
rm -f "$_live_retry"
|
||
fi
|
||
fi
|
||
if ! $_mix_ok; then
|
||
echo "Audio mix failed — step 3b will be overlay-only"
|
||
if ! $TEST_MODE; then
|
||
_jlog=$(journalctl --user -u sky-cam-audio-capture.service -n 30 --no-pager 2>/dev/null \
|
||
|| journalctl -u sky-cam-audio-capture.service -n 30 --no-pager 2>/dev/null \
|
||
|| echo "(journal unavailable)")
|
||
"$SCRIPT_DIR/notify.sh" "WARNING: sunrise audio mix failed $current_date" \
|
||
"$(printf 'Audio could not be mixed — continuing with overlay only\n\naudio-capture log:\n%s' "$_jlog")" || true
|
||
[ "$audio_src" = "${cam_audio:-}" ] && rm -f "${cam_audio:-}"
|
||
fi
|
||
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
|