Merge pull request #12 from outis1one/claude/seasonal-sunrise-montage-nn268

Claude/seasonal sunrise montage nn268
This commit is contained in:
Outis
2026-04-19 20:32:15 -04:00
committed by GitHub
5 changed files with 120 additions and 64 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ source "$SCRIPT_DIR/sky-cam.conf"
export TIMEZONE # make it visible to season_info.py subprocess
# Camera name: first argument overrides conf (systemd passes it via ExecStart).
CAM_NAME="${1:-$CAM_NAME}"
CAM_NAME="${1:-$SUNRISE_CAM}"
# ── Configuration ──────────────────────────────────────────────────────────────
base_dir="$BASE_DIR"
+52 -27
View File
@@ -48,6 +48,19 @@ 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"
fi
# ── Collect images in window ──────────────────────────────────────────────────
if [ ! -d "$image_dir" ]; then
"$SCRIPT_DIR/notify.sh" "FAILED: sunrise $current_date" \
@@ -126,21 +139,20 @@ 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)"
# ── Build overlay filter ──────────────────────────────────────────────────────
# Time displayed as stacked characters down the right side (e.g. 0/7/:/2/3),
# semi-transparent with a soft drop shadow so it reads on any background.
# ── Build overlay filter (skipped if SUNRISE_OVERLAY_ENABLED=false) ──────────
SR_TIME=$(echo "$sunrise_time_local" | cut -d'-' -f1,2 | tr '-' ':')
# Build "H\nH\n:\nM\nM" for vertical stacking in drawtext
SR_VERT=$(echo "$SR_TIME" | awk 'BEGIN{FS=""}{for(i=1;i<=NF;i++){printf "%s",$i; if(i<NF)printf "\\n"}}')
DT="drawtext"
[ -n "$FONT" ] && DT="${DT}=fontfile='${FONT}'" || DT="${DT}"
DT="${DT}:text='${SR_VERT}'"
DT="${DT}:fontcolor=white@${SUNRISE_OVERLAY_OPACITY}"
DT="${DT}:fontsize=h/22"
DT="${DT}:line_spacing=4"
DT="${DT}:x=w-tw-18:y=(h-th)/2"
DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1"
DT=""
if [ "${SUNRISE_OVERLAY_ENABLED:-true}" = "true" ]; then
SR_VERT=$(echo "$SR_TIME" | awk 'BEGIN{FS=""}{for(i=1;i<=NF;i++){printf "%s",$i; if(i<NF)printf "\\n"}}')
DT="drawtext"
[ -n "$FONT" ] && DT="${DT}=fontfile='${FONT}'" || DT="${DT}"
DT="${DT}:text='${SR_VERT}'"
DT="${DT}:fontcolor=white@${SUNRISE_OVERLAY_OPACITY}"
DT="${DT}:fontsize=h/22"
DT="${DT}:line_spacing=4"
DT="${DT}:x=w-tw-18:y=(h-th)/2"
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
@@ -161,10 +173,9 @@ audio_offset="0"
cam_audio="$image_dir/sunrise-audio.m4a"
if [ "${AUDIO_ENABLED:-false}" = "true" ]; then
if [ -f "$cam_audio" ]; then
buffer_sec=$(( ${AUDIO_PRE_BUFFER_MIN:-2} * 60 ))
audio_offset=$(echo "scale=1; $SUNRISE_PRE_MIN * 60 + $buffer_sec - $SUNRISE_TARGET_SECS / 2" | bc)
# Already exactly SUNRISE_TARGET_SECS, centred on sunrise — no offset needed
audio_src="$cam_audio"
echo "Audio: camera recording offset=${audio_offset}s"
echo "Audio: camera recording (centred on sunrise)"
else
library_dir="$SCRIPT_DIR/sunrise-sounds"
if [ -d "$library_dir" ]; then
@@ -203,23 +214,20 @@ if [ -n "$audio_src" ]; then
fi
fi
# ── Step 3b: burn overlay ─────────────────────────────────────────────────────
echo "Step 3b/3: overlay → $final_video"
# ── 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
if ffmpeg -loglevel warning \
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 -crf "$CRF_SUNRISE" \
"${audio_out_flags[@]}" \
-y "$final_video"; 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})"
rm -f "$sped_video" "$temp_audio"; 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
else
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"
@@ -228,4 +236,21 @@ else
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
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, sunrise at ${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
fi
+1 -1
View File
@@ -11,7 +11,7 @@ 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:-$CAM_NAME}"
CAM_NAME="${1:-$SUNRISE_CAM}"
# ── Configuration ──────────────────────────────────────────────────────────────
base_dir="$BASE_DIR"
+42 -16
View File
@@ -147,12 +147,17 @@
# =============================================================================
# ── Install location ──────────────────────────────────────────────────────────
# Full path to the directory containing this file and all the scripts.
SCRIPT_DIR=/home/motion/drives/local-2tb/sunrise-scripts
# Auto-detected from where this file lives — override only if you move the
# scripts to a different location after install.
SCRIPT_DIR="${SCRIPT_DIR:-$(dirname "$(realpath "${BASH_SOURCE[0]}")")}"
# ── Storage ───────────────────────────────────────────────────────────────────
BASE_DIR=/home/motion/drives/local-2tb
MOVIES_DIR=$BASE_DIR/movies # override if videos live on a different drive
# BASE_DIR: root folder where camera images are stored.
# Structure created automatically: BASE_DIR/<cam>/YYYY-MM-DD/HH-MM-SS.jpg
# Defaults to a 'data' subfolder next to the scripts; override to point at
# an external drive or network mount (recommended for production).
BASE_DIR="${BASE_DIR:-$SCRIPT_DIR/data}"
MOVIES_DIR="${MOVIES_DIR:-$BASE_DIR/movies}" # override if videos live on a different drive
MUSIC_DIR=/home/motion/drives/local-2tb/music/4 Seasons
# ── Location & timezone ───────────────────────────────────────────────────────
@@ -179,14 +184,28 @@ CAMERAS=(sunrise) # e.g. CAMERAS=(sunrise north west)
SUNRISE_CAM=sunrise # which camera faces east (gets the sunrise video job)
# ── Schedules ─────────────────────────────────────────────────────────────────
# SCHEDULE_SUNRISE when to run daily_sunrise_video.sh (SUNRISE_CAM only)
# SCHEDULE_SEASONS_<cam> when to run 4-seasons.sh for each camera
# SCHEDULE_FULLDAY_<cam> when to run fullday-video.sh for each camera
#
# Times are HH:MM:SS (24-hour). Space cameras at least 30 min apart.
# All times are HH:MM:SS (24-hour local time).
# install.sh reads these and generates one systemd timer per camera per job.
#
SCHEDULE_SUNRISE=09:00:00
# SCHEDULE_SUNRISE
# When to START the sunrise video job. The script wakes up, calculates
# today's sunrise time, then WAITS internally until the capture window is
# fully finished before touching any images. Set this early enough to
# cover your earliest possible sunrise minus SUNRISE_PRE_MIN — 03:00 works
# year-round for most locations. The video is ready a few minutes after the
# capture window closes, not at this start time.
#
# SCHEDULE_SEASONS_<cam> (one per camera in CAMERAS)
# When to run the Four Seasons daily clip job. Processes YESTERDAY'S images
# (not today's) — always runs after midnight. On the last day of a season
# movement this automatically triggers the movement montage build.
# Space multiple cameras at least 30 min apart to avoid disk contention.
#
# SCHEDULE_FULLDAY_<cam> (one per camera in CAMERAS)
# When to run the full-day timelapse job. Also processes YESTERDAY'S images.
# Space after SCHEDULE_SEASONS to avoid overlap.
#
SCHEDULE_SUNRISE=03:00:00
SCHEDULE_SEASONS_sunrise=01:00:00
SCHEDULE_FULLDAY_sunrise=02:00:00
@@ -199,12 +218,18 @@ SCHEDULE_FULLDAY_sunrise=02:00:00
#SCHEDULE_FULLDAY_west=03:00:00
# ── Sunrise video tuning ──────────────────────────────────────────────────────
# How many minutes before/after sunrise to include in the daily clip.
SUNRISE_PRE_MIN=70
SUNRISE_POST_MIN=10
# Target duration (seconds) for the speed-adjusted sunrise video.
# Capture window around sunrise:
# Images from (sunrise - SUNRISE_PRE_MIN) to (sunrise + SUNRISE_POST_MIN)
# are included in the clip. Larger windows give more context; they are all
# speed-adjusted to fit SUNRISE_TARGET_SECS regardless of window size.
SUNRISE_PRE_MIN=70 # minutes before sunrise to start capturing
SUNRISE_POST_MIN=10 # minutes after sunrise to stop capturing
# Target length of the final sunrise video (seconds).
SUNRISE_TARGET_SECS=10
# Opacity of the sunrise-time overlay (0.0 = invisible, 1.0 = fully opaque).
# Burn the local sunrise time vertically on the right side of the video.
# Set false to skip the overlay entirely (cleaner look, faster encoding).
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 ─────────────────────────────────────────────────
@@ -258,8 +283,9 @@ CAPTURE_INTERVAL=10
# The audio file is deleted automatically after it is mixed into the video.
#
AUDIO_ENABLED=false # set true if your camera has a working mic
AUDIO_PRE_BUFFER_MIN=2 # extra minutes to start before the capture window
CAPTURE_AUDIO_BITRATE=96k # bitrate for the sunrise audio recording
# Audio is recorded for exactly SUNRISE_TARGET_SECS, centred on actual sunrise:
# floor(TARGET/2) seconds before, ceil(TARGET/2) after (odd second → post-sunrise).
# ── Mattermost — daily sunrise upload ─────────────────────────────────────────
# mattermost_url, access_token, channel_id go in .env (see bottom of this file).
+16 -11
View File
@@ -1,13 +1,15 @@
#!/bin/bash
# sunrise-audio-capture.sh — record the camera's RTSP audio stream during the
# sunrise window so daily_sunrise_video.sh can mix in natural ambient sound
# (birds, rain, wind) at real speed while the video plays as a timelapse.
# sunrise-audio-capture.sh — record exactly SUNRISE_TARGET_SECS of audio
# centred on the actual sunrise moment, so daily_sunrise_video.sh can mix in
# real ambient sound (birds, rain, wind) at natural speed.
#
# Triggered daily at 03:00 by sky-cam-audio-capture.timer.
# Waits internally until (sunrise SUNRISE_PRE_MIN AUDIO_PRE_BUFFER_MIN),
# then records for the full window. Exits immediately if AUDIO_ENABLED≠true.
# Triggered at 03:00 by sky-cam-audio-capture.timer; waits internally until
# the right moment before recording. Exits immediately if AUDIO_ENABLED≠true.
#
# Output: BASE_DIR/SUNRISE_CAM/YYYY-MM-DD/sunrise-audio.m4a
# Recording window: floor(TARGET/2) seconds before sunrise,
# ceil(TARGET/2) seconds after (odd second goes to post).
#
# Output: BASE_DIR/<cam>/YYYY-MM-DD/sunrise-audio.m4a
# Deleted automatically by daily_sunrise_video.sh after mixing.
set -euo pipefail
@@ -40,13 +42,16 @@ fi
sunrise_time_local=$(TZ="$TIMEZONE" date -d "$sunrise_time" +"%H:%M:%S")
echo "Sunrise (local): $sunrise_time_local"
# ── Calculate wait and record duration ───────────────────────────────────────
# ── Calculate recording window centred on sunrise ─────────────────────────────
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")
buffer_sec=$(( ${AUDIO_PRE_BUFFER_MIN:-2} * 60 ))
record_start_sec=$(( sunrise_sec - SUNRISE_PRE_MIN * 60 - buffer_sec ))
record_dur_sec=$(( (SUNRISE_PRE_MIN + SUNRISE_POST_MIN) * 60 + buffer_sec ))
half_pre=$(( SUNRISE_TARGET_SECS / 2 )) # floor — pre-sunrise portion
half_post=$(( SUNRISE_TARGET_SECS - half_pre )) # ceil — post-sunrise gets odd second
record_start_sec=$(( sunrise_sec - half_pre ))
record_dur_sec=$SUNRISE_TARGET_SECS
echo "Audio window: ${half_pre}s before → ${half_post}s after sunrise (${record_dur_sec}s total)"
midnight=$(date -d "today 00:00:00" +%s)
now_day_sec=$(( $(date +%s) - midnight ))