From 6629f9121940cd8cfad7c4fd9b9083b77c7a7709 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:35:07 +0000 Subject: [PATCH 1/5] Add README with quick-start, architecture overview, and operations guide https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- README.md | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed48fd1 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# sky-cam + +Automated sky / timelapse camera scripts that produce: + +- **Daily sunrise clip** — a 10-second speed-adjusted video of the sunrise window, uploaded to Mattermost each morning +- **Four Seasons timelapse** — daily clips sized to each Vivaldi movement's music duration, assembled automatically into per-movement montages (with music + attribution overlay) and a full-year video +- **Full-day timelapse** — a fixed-fps timelapse of every image captured that day, kept for a configurable retention window + +--- + +## Prerequisites + +| Dependency | Notes | +|---|---| +| `ffmpeg` + `ffprobe` | Encoding and duration probing | +| `python3` | `ephem` package for sunrise calculation, `requests` for Mattermost upload | +| `bc` | Shell arithmetic (floating-point speed factors) | +| `fontconfig` (`fc-match`) | Font detection for overlays — optional, falls back to hardcoded paths | +| Camera software | e.g. `motion` — writes JPEG images named `HH-MM-SS.jpg` into `BASE_DIR///` | +| Vivaldi Four Seasons audio | 12 MP3 files named so that `*Spring*Mvt*1*`, `*Summer*Mvt*2*`, etc. match with `find -iname` | + +Install Python dependencies: + +```bash +pip3 install ephem requests +``` + +--- + +## Quick start + +### 1. Download + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/outis1one/sky-cam/main/bootstrap.sh) +cd sky-cam +``` + +Or with a custom install directory: + +```bash +curl -fsSL https://raw.githubusercontent.com/outis1one/sky-cam/main/bootstrap.sh | bash -s -- /opt/sky-cam +cd /opt/sky-cam +``` + +### 2. Configure + +```bash +$EDITOR sky-cam.conf +``` + +Minimum settings to fill in (everything else has sensible defaults): + +| Setting | What it is | +|---|---| +| `SCRIPT_DIR` | Full path to this directory | +| `BASE_DIR` | Root where camera images live (`BASE_DIR///.jpg`) | +| `MOVIES_DIR` | Where finished videos are written (default: `BASE_DIR/movies`) | +| `MUSIC_DIR` | Directory containing the 12 Vivaldi Four Seasons audio files | +| `CAMERAS` | Space-separated list of camera names, e.g. `(sunrise north)` | +| `SUNRISE_CAM` | Which camera faces east and gets the sunrise job | +| `LATITUDE` / `LONGITUDE` / `TIMEZONE` | Your location for sunrise calculation | +| `mattermost_url` / `access_token` / `channel_id` | Mattermost upload credentials | + +### 3. Install systemd timers + +```bash +./install.sh # user-level timers (~/.config/systemd/user), no root needed +# or +./install.sh --system # system-wide (/etc/systemd/system), requires sudo +``` + +Re-run `install.sh` any time `sky-cam.conf` changes. + +### 4. Verify + +```bash +systemctl --user list-timers 'sky-cam-*' +journalctl --user -u sky-cam-sunrise.service -f +``` + +--- + +## How it works + +``` +Camera writes JPEGs + │ + ├─ daily_sunrise_video.sh (runs at SCHEDULE_SUNRISE) + │ Step 1: encode raw video from sunrise-window JPEGs + │ Step 2: speed-adjust to SUNRISE_TARGET_SECS → saved permanently + │ Step 3: burn sunrise time overlay → final video + │ OnSuccess → sunrise2mm.py uploads to Mattermost + │ + ├─ 4-seasons.sh (runs at SCHEDULE_SEASONS_, processes yesterday) + │ Step 1: encode all of yesterday's JPEGs into raw video + │ Step 2: speed-adjust to music_duration / days_in_movement + │ Last day of movement → triggers montage-mvt.sh + │ Step 1: concatenate all daily clips + │ Step 2: speed-adjust to exactly match music → saved permanently + │ Step 3: mix music + fades + attribution overlay → Montage.mp4 + │ Last movement of Autumn → triggers year-end-join.sh + │ + └─ fullday-video.sh (runs at SCHEDULE_FULLDAY_, processes yesterday) + Encode all of yesterday's JPEGs at FULLDAY_FPS + Delete videos older than RETENTION_DAYS +``` + +### Resilience + +Each pipeline saves an intermediate file before the step most likely to fail, so a partial failure leaves a recoverable artifact: + +- **Sunrise**: if the overlay (step 3) fails, the speed-only video is promoted to the upload target — the upload still happens and you get a notification of the overlay failure +- **Montage**: if music + overlay (step 3) fails, the speed-adjusted silent video is promoted to `*-Montage.mp4` — `year-end-join.sh` still includes the movement and you get a warning notification + +--- + +## Notifications + +`notify.sh` sends alerts through any combination of: + +| Channel | Config key(s) | +|---|---| +| [ntfy](https://ntfy.sh) | `NTFY_ENABLED=true`, `NTFY_URL=https://ntfy.sh/your-topic` | +| Email | `EMAIL_ENABLED=true`, `EMAIL_TO=you@example.com` | +| Mattermost text post | `MM_NOTIFY_ENABLED=true`, `MM_NOTIFY_CHANNEL_ID=` | + +You receive notifications for: +- Sunrise: video ready, upload success/failure, overlay failure +- Each daily seasons clip saved +- Montage complete (or degraded if audio/overlay failed) +- Year-end Four Seasons video complete +- Any step failure, with the surviving file path named + +--- + +## Manual operations + +**Re-run today's sunrise** (e.g. after fixing a font issue): +```bash +./daily_sunrise_video.sh +``` + +**Re-run a daily seasons clip** for a specific date: +```bash +./4-seasons.sh # reprocesses yesterday +``` + +**Rebuild a movement montage** (e.g. to retry audio after a failure): +```bash +./montage-mvt.sh # uses today's movement +./montage-mvt.sh 2025-06-15 sunrise # use a specific reference date + camera +``` + +**Rebuild the year-end video**: +```bash +./year-end-join.sh 2025 sunrise +``` + +**Check logs**: +```bash +journalctl --user -u sky-cam-sunrise.service +journalctl --user -u sky-cam-seasons-sunrise.service +journalctl --user -u sky-cam-fullday-sunrise.service +``` From 9fe0051f70fcc79bfd6752f54cfe041fd24784e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:59:40 +0000 Subject: [PATCH 2/5] Add pure-ffmpeg RTSP capture and camera audio for sunrise videos Removes dependency on MotionEye or any NVR software. Everything is now CLI/ffmpeg configured entirely from sky-cam.conf. capture.sh Long-running systemd service (one per camera) that pulls frames from the camera's RTSP stream at CAPTURE_INTERVAL seconds, writing HH-MM-SS.jpg into BASE_DIR//YYYY-MM-DD/. Restarts at midnight for the new date directory; auto-reconnects on camera disconnect. sunrise-audio-capture.sh One-shot service triggered at 03:00. Waits until (sunrise minus SUNRISE_PRE_MIN minus AUDIO_PRE_BUFFER_MIN), then records the RTSP audio stream for the full sunrise window. Output deleted after mixing. daily_sunrise_video.sh Step 3 now mixes in sunrise-audio.m4a when AUDIO_ENABLED=true and the file exists. Audio is centred on actual sunrise time at natural speed while the video plays as the sped-up timelapse. Uses filter_complex when audio is present (can't combine -vf with -filter_complex). install.sh Generates sky-cam-capture-.service for each camera with CAM_RTSP_ set, and sky-cam-audio-capture.{service,timer} when AUDIO_ENABLED=true. sky-cam.conf New settings: CAM_RTSP_, CAPTURE_INTERVAL, AUDIO_ENABLED, AUDIO_PRE_BUFFER_MIN, CAPTURE_AUDIO_BITRATE. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- README.md | 40 ++++++++++++++++++-- capture.sh | 62 +++++++++++++++++++++++++++++++ daily_sunrise_video.sh | 40 +++++++++++++++----- install.sh | 50 +++++++++++++++++++++++++ sky-cam.conf | 25 +++++++++++++ sunrise-audio-capture.sh | 80 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 13 deletions(-) create mode 100755 capture.sh create mode 100755 sunrise-audio-capture.sh diff --git a/README.md b/README.md index ed48fd1..7e1cbea 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ Automated sky / timelapse camera scripts that produce: | Dependency | Notes | |---|---| -| `ffmpeg` + `ffprobe` | Encoding and duration probing | +| `ffmpeg` + `ffprobe` | Encoding, capture, audio recording, duration probing | | `python3` | `ephem` package for sunrise calculation, `requests` for Mattermost upload | | `bc` | Shell arithmetic (floating-point speed factors) | | `fontconfig` (`fc-match`) | Font detection for overlays — optional, falls back to hardcoded paths | -| Camera software | e.g. `motion` — writes JPEG images named `HH-MM-SS.jpg` into `BASE_DIR///` | +| IP camera with RTSP stream | `capture.sh` pulls frames directly — no NVR software needed | | Vivaldi Four Seasons audio | 12 MP3 files named so that `*Spring*Mvt*1*`, `*Summer*Mvt*2*`, etc. match with `find -iname` | Install Python dependencies: @@ -60,6 +60,8 @@ Minimum settings to fill in (everything else has sensible defaults): | `CAMERAS` | Space-separated list of camera names, e.g. `(sunrise north)` | | `SUNRISE_CAM` | Which camera faces east and gets the sunrise job | | `LATITUDE` / `LONGITUDE` / `TIMEZONE` | Your location for sunrise calculation | +| `CAM_RTSP_` | RTSP stream URL for each camera, e.g. `rtsp://admin:pass@192.168.1.100:554/stream1` | +| `CAPTURE_INTERVAL` | Seconds between captured frames (default: 10) | | `mattermost_url` / `access_token` / `channel_id` | Mattermost upload credentials | ### 3. Install systemd timers @@ -84,12 +86,24 @@ journalctl --user -u sky-cam-sunrise.service -f ## How it works ``` -Camera writes JPEGs +Camera RTSP stream + │ + ├─ capture.sh (long-running systemd service, one per camera) + │ ffmpeg pulls one frame every CAPTURE_INTERVAL seconds + │ Writes: BASE_DIR//YYYY-MM-DD/HH-MM-SS.jpg + │ Restarts at midnight for the new date directory; auto-reconnects + │ + ├─ sunrise-audio-capture.sh (runs at 03:00, waits for sunrise window) + │ ffmpeg records audio-only from RTSP during sunrise window + │ Writes: BASE_DIR//YYYY-MM-DD/sunrise-audio.m4a + │ Deleted automatically after being mixed into the sunrise video + │ +Camera JPEGs + audio │ ├─ daily_sunrise_video.sh (runs at SCHEDULE_SUNRISE) │ Step 1: encode raw video from sunrise-window JPEGs │ Step 2: speed-adjust to SUNRISE_TARGET_SECS → saved permanently - │ Step 3: burn sunrise time overlay → final video + │ Step 3: burn sunrise time overlay + mix camera audio (if available) │ OnSuccess → sunrise2mm.py uploads to Mattermost │ ├─ 4-seasons.sh (runs at SCHEDULE_SEASONS_, processes yesterday) @@ -134,6 +148,18 @@ You receive notifications for: --- +## Camera audio (optional) + +If your camera has a microphone, sky-cam can mix a natural-speed 10-second audio clip (birds, rain, wind — whatever was actually happening at sunrise) into the daily sunrise video. + +1. Set `AUDIO_ENABLED=true` in `sky-cam.conf` +2. Set `CAM_RTSP_` for the sunrise camera (needed for both image capture and audio) +3. Re-run `./install.sh` to generate the `sky-cam-audio-capture.timer` + +The audio is recorded during the same window as the images, stored alongside them, and deleted automatically after being mixed into the final video. No audio library or AI required — it's the real sound from your camera. + +--- + ## Manual operations **Re-run today's sunrise** (e.g. after fixing a font issue): @@ -157,6 +183,12 @@ You receive notifications for: ./year-end-join.sh 2025 sunrise ``` +**Check capture status**: +```bash +systemctl --user status sky-cam-capture-sunrise.service +journalctl --user -u sky-cam-capture-sunrise.service -f +``` + **Check logs**: ```bash journalctl --user -u sky-cam-sunrise.service diff --git a/capture.sh b/capture.sh new file mode 100755 index 0000000..c447971 --- /dev/null +++ b/capture.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# capture.sh — continuous JPEG frame capture from a camera's RTSP +# stream. Replaces MotionEye or any NVR for sky-cam's timelapse pipeline. +# Pure ffmpeg — no NVR software required. +# +# Run as a long-running systemd service (Type=simple), one per camera. +# install.sh generates sky-cam-capture-.service automatically when +# CAM_RTSP_ is set in sky-cam.conf. +# +# Writes: BASE_DIR//YYYY-MM-DD/HH-MM-SS.jpg +# Restarts ffmpeg at midnight so images land in the correct date directory. +# On camera disconnect, waits 30 s then reconnects automatically. + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +source "$SCRIPT_DIR/sky-cam.conf" +export TZ="$TIMEZONE" + +CAM="${1:-$CAM_NAME}" + +rtsp_var="CAM_RTSP_${CAM}" +RTSP_URL="${!rtsp_var:-}" +if [ -z "$RTSP_URL" ]; then + echo "ERROR: CAM_RTSP_${CAM} not set in sky-cam.conf" + exit 1 +fi + +INTERVAL="${CAPTURE_INTERVAL:-10}" +echo "Starting capture: cam=$CAM interval=${INTERVAL}s" + +while true; do + today=$(date +%Y-%m-%d) + dir="$BASE_DIR/$CAM/$today" + mkdir -p "$dir" + + # Run until midnight + 5 s so the new day's directory is ready on restart + midnight=$(date -d "tomorrow 00:00:00" +%s) + now=$(date +%s) + duration=$(( midnight - now + 5 )) + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Capturing → $dir (rollover in ${duration}s)" + + # -strftime 1 expands %H-%M-%S in the output path using the frame timestamp + ffmpeg -loglevel warning \ + -rtsp_transport tcp \ + -i "$RTSP_URL" \ + -t "$duration" \ + -vf "fps=1/${INTERVAL}" \ + -f image2 \ + -strftime 1 \ + -q:v 2 \ + "${dir}/%H-%M-%S.jpg" || true + + # If ffmpeg exited before midnight the camera dropped — wait then retry + if [ "$(date +%s)" -lt "$midnight" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$CAM] stream lost — retrying in 30s" + sleep 30 + else + sleep 2 # brief pause at midnight before day rollover restart + fi +done diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index 0bfd3a4..8de1ca2 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -139,19 +139,41 @@ 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" -# ── Step 3: Overlay — sunrise time burned in ────────────────────────────────── -# If overlay fails, the sped video is promoted to the upload target so -# OnSuccess still fires and the upload happens (without timestamp overlay). +# ── Step 3: Overlay + optional audio ───────────────────────────────────────── +# With audio: DT must go into -filter_complex (can't mix -vf and -filter_complex) +# Without audio: plain -vf is simpler and avoids any filter_complex overhead. +# If overlay fails either way, the sped video is promoted so upload still fires. final_video="$output_dir/$current_date-daily-sunrise.mp4" -echo "Step 3/3: overlay → $final_video" -if ffmpeg -loglevel warning \ - -i "$sped_video" \ - -vf "${DT}" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \ - -y "$final_video"; then +audio_file="$image_dir/sunrise-audio.m4a" +step3_ok=true + +if [ "${AUDIO_ENABLED:-false}" = "true" ] && [ -f "$audio_file" ]; 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) + fade_out=$(echo "scale=1; $SUNRISE_TARGET_SECS - 0.5" | bc) + echo "Step 3/3: overlay + camera audio (offset ${audio_offset}s) → $final_video" + ffmpeg -loglevel warning \ + -i "$sped_video" \ + -ss "$audio_offset" -t "$SUNRISE_TARGET_SECS" -i "$audio_file" \ + -filter_complex "[0:v]${DT}[vout];[1:a]afade=t=in:st=0:d=0.5,afade=t=out:st=${fade_out}:d=0.5[aout]" \ + -map "[vout]" -map "[aout]" \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \ + -c:a aac -b:a 128k \ + -y "$final_video" || step3_ok=false +else + echo "Step 3/3: overlay (no audio) → $final_video" + ffmpeg -loglevel warning \ + -i "$sped_video" \ + -vf "${DT}" \ + -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \ + -y "$final_video" || step3_ok=false +fi + +if $step3_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" + [ -f "$audio_file" ] && rm -f "$audio_file" "$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \ "$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true else diff --git a/install.sh b/install.sh index 2166c34..a687a91 100755 --- a/install.sh +++ b/install.sh @@ -111,6 +111,50 @@ write_timer "sky-cam-sunrise" "$SUNRISE_CAM: daily sunrise video" "$SCHEDULE_SUN timers=(sky-cam-sunrise) +# ── Per-camera continuous capture services ──────────────────────────────────── +# capture.sh replaces MotionEye / any NVR for periodic JPEG capture. +# Generated when CAM_RTSP_ is set; Type=simple (long-running, not oneshot). +capture_services=() +for cam in "${CAMERAS[@]}"; do + rtsp_var="CAM_RTSP_${cam}" + if [ -n "${!rtsp_var:-}" ]; then + cat > "$UNIT_DIR/sky-cam-capture-${cam}.service" <, SCHEDULE_FULLDAY_ from sky-cam.conf. # Scripts receive the camera name as $1 so they know which camera to process. @@ -146,6 +190,12 @@ done echo "" $SC daemon-reload +for svc in "${capture_services[@]}"; do + $SC enable --now "${svc}.service" \ + && echo " enabled + started ${svc}.service" \ + || echo " WARNING: could not enable ${svc}.service" +done + for timer in "${timers[@]}"; do $SC enable --now "${timer}.timer" \ && echo " enabled + started ${timer}.timer" \ diff --git a/sky-cam.conf b/sky-cam.conf index 311db94..1c4e8a3 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -237,6 +237,31 @@ MONTAGE_FADE_DUR=2.0 # video + audio fade in/out (seconds) MONTAGE_ATTR_DUR=6 # attribution overlay total duration (seconds) MONTAGE_ATTR_FADE=1 # attribution fade-in and fade-out length (seconds) +# ── Direct RTSP capture (replaces MotionEye / any NVR) ──────────────────────── +# capture.sh pulls frames directly from each camera's RTSP stream. +# No NVR software required — just ffmpeg and the camera's stream URL. +# +# Per-camera RTSP URL: CAM_RTSP_=rtsp://user:pass@host:port/path +# Find the URL in your camera's web UI (usually under Network → Video → RTSP) +# or check your camera's manual. +# +#CAM_RTSP_sunrise=rtsp://admin:password@192.168.1.100:554/stream1 +#CAM_RTSP_north=rtsp://admin:password@192.168.1.101:554/stream1 + +# Seconds between captured frames (applies to all cameras). +# 10 = one frame every 10 s → 8640 frames/day → good timelapse density. +CAPTURE_INTERVAL=10 + +# ── Sunrise audio capture ────────────────────────────────────────────────────── +# Records the camera's RTSP audio stream during the sunrise window so that +# daily_sunrise_video.sh can mix in natural ambient sound (birds, rain, wind) +# at real speed, while the video plays as the sped-up timelapse. +# 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 + # ── Mattermost — daily sunrise upload ───────────────────────────────────────── mattermost_url=https://your-mattermost-server.example.com access_token=your-access-token-here diff --git a/sunrise-audio-capture.sh b/sunrise-audio-capture.sh new file mode 100755 index 0000000..a88e0dc --- /dev/null +++ b/sunrise-audio-capture.sh @@ -0,0 +1,80 @@ +#!/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. +# +# 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. +# +# Output: BASE_DIR/SUNRISE_CAM/YYYY-MM-DD/sunrise-audio.m4a +# Deleted automatically by daily_sunrise_video.sh after mixing. + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +source "$SCRIPT_DIR/sky-cam.conf" +export TZ="$TIMEZONE" + +CAM="${1:-$SUNRISE_CAM}" + +if [ "${AUDIO_ENABLED:-false}" != "true" ]; then + echo "AUDIO_ENABLED is not true — skipping." + exit 0 +fi + +rtsp_var="CAM_RTSP_${CAM}" +RTSP_URL="${!rtsp_var:-}" +if [ -z "$RTSP_URL" ]; then + echo "ERROR: CAM_RTSP_${CAM} not set in sky-cam.conf" + exit 1 +fi + +# ── Sunrise time ────────────────────────────────────────────────────────────── +sunrise_time=$(python3 "$SCRIPT_DIR/sunrise.py") +if [ -z "$sunrise_time" ]; then + "$SCRIPT_DIR/notify.sh" "FAILED: audio capture — no sunrise time" \ + "sunrise.py returned nothing — check internet/location config" || true + exit 1 +fi +sunrise_time_local=$(TZ="$TIMEZONE" date -d "$sunrise_time" +"%H:%M:%S") +echo "Sunrise (local): $sunrise_time_local" + +# ── Calculate wait and record duration ─────────────────────────────────────── +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 )) + +midnight=$(date -d "today 00:00:00" +%s) +now_day_sec=$(( $(date +%s) - midnight )) +wait_sec=$(( record_start_sec - now_day_sec )) + +if [ "$wait_sec" -gt 0 ]; then + echo "Waiting ${wait_sec}s until audio window ($(date -d "@$(( midnight + record_start_sec ))" '+%H:%M:%S'))..." + sleep "$wait_sec" +fi + +today=$(date +%Y-%m-%d) +dir="$BASE_DIR/$CAM/$today" +mkdir -p "$dir" +output="$dir/sunrise-audio.m4a" + +echo "Recording ${record_dur_sec}s of audio → $output" + +if ! ffmpeg -loglevel warning \ + -rtsp_transport tcp \ + -i "$RTSP_URL" \ + -t "$record_dur_sec" \ + -vn \ + -acodec aac -b:a "${CAPTURE_AUDIO_BITRATE:-96k}" \ + -y "$output"; then + "$SCRIPT_DIR/notify.sh" "FAILED: audio capture $today" \ + "ffmpeg audio recording failed — sunrise video will have no audio" || true + exit 1 +fi + +size=$(du -h "$output" | cut -f1) +echo "Audio capture complete: $output (${record_dur_sec}s, $size)" From baf886fd2379a16ba90e3dc440477dfa5188008c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 21:22:01 +0000 Subject: [PATCH 3/5] Audio resilience: three-tier fallback for sunrise audio; add freesound download script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daily_sunrise_video.sh: priority chain is camera mic → library random MP3 → overlay-only → sped video promotion. If audio mixing fails a warning notification fires and the script retries with overlay-only so a video is always produced and uploaded. download-sunrise-sounds.py: downloads CC-licensed ambient sounds from freesound.org into 11 weather/season category folders (~275 files total at 25 per category), writing a manifest.json with attribution data. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- daily_sunrise_video.sh | 63 ++++++-- download-sunrise-sounds.py | 293 +++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 download-sunrise-sounds.py diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index 8de1ca2..4b45c70 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -140,27 +140,62 @@ DT="${DT}:x=w-tw-18:y=(h-th)/2" DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1" # ── Step 3: Overlay + optional audio ───────────────────────────────────────── -# With audio: DT must go into -filter_complex (can't mix -vf and -filter_complex) -# Without audio: plain -vf is simpler and avoids any filter_complex overhead. -# If overlay fails either way, the sped video is promoted so upload still fires. +# Priority: (1) camera mic recording, (2) library fallback, (3) no audio. +# If audio mixing fails, step 3 retries with overlay-only so the video is +# always made. If overlay itself fails, the sped video is promoted so upload +# still fires. final_video="$output_dir/$current_date-daily-sunrise.mp4" -audio_file="$image_dir/sunrise-audio.m4a" +fade_out=$(echo "scale=1; $SUNRISE_TARGET_SECS - 0.5" | bc) + +# ── Pick audio source ───────────────────────────────────────────────────────── +audio_src="" +audio_offset="0" + +cam_audio="$image_dir/sunrise-audio.m4a" +if [ "${AUDIO_ENABLED:-false}" = "true" ]; then + if [ -f "$cam_audio" ]; then + # Camera recording: offset centres the clip on actual sunrise + buffer_sec=$(( ${AUDIO_PRE_BUFFER_MIN:-2} * 60 )) + audio_offset=$(echo "scale=1; $SUNRISE_PRE_MIN * 60 + $buffer_sec - $SUNRISE_TARGET_SECS / 2" | bc) + audio_src="$cam_audio" + echo "Audio: camera recording offset=${audio_offset}s" + else + # Library fallback: random file from sunrise-sounds/ + 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 + +# ── Attempt overlay + audio (falls back to overlay-only if audio fails) ─────── +audio_mixed=false step3_ok=true -if [ "${AUDIO_ENABLED:-false}" = "true" ] && [ -f "$audio_file" ]; 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) - fade_out=$(echo "scale=1; $SUNRISE_TARGET_SECS - 0.5" | bc) - echo "Step 3/3: overlay + camera audio (offset ${audio_offset}s) → $final_video" - ffmpeg -loglevel warning \ +if [ -n "$audio_src" ]; then + echo "Step 3/3: overlay + audio → $final_video" + if ffmpeg -loglevel warning \ -i "$sped_video" \ - -ss "$audio_offset" -t "$SUNRISE_TARGET_SECS" -i "$audio_file" \ + -ss "$audio_offset" -t "$SUNRISE_TARGET_SECS" -i "$audio_src" \ -filter_complex "[0:v]${DT}[vout];[1:a]afade=t=in:st=0:d=0.5,afade=t=out:st=${fade_out}:d=0.5[aout]" \ -map "[vout]" -map "[aout]" \ -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \ -c:a aac -b:a 128k \ - -y "$final_video" || step3_ok=false -else + -y "$final_video"; then + audio_mixed=true + else + echo "Audio mix failed — retrying with overlay only" + "$SCRIPT_DIR/notify.sh" "WARNING: sunrise audio mix failed $current_date" \ + "Audio could not be mixed — falling back to overlay-only" || true + [ "$audio_src" = "$cam_audio" ] && rm -f "$cam_audio" + fi +fi + +if ! $audio_mixed; then echo "Step 3/3: overlay (no audio) → $final_video" ffmpeg -loglevel warning \ -i "$sped_video" \ @@ -173,7 +208,7 @@ if $step3_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" - [ -f "$audio_file" ] && rm -f "$audio_file" + [ -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 diff --git a/download-sunrise-sounds.py b/download-sunrise-sounds.py new file mode 100644 index 0000000..3822633 --- /dev/null +++ b/download-sunrise-sounds.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +download-sunrise-sounds.py — download CC-licensed ambient sounds from Freesound +for use as sunrise video fallback audio. + +Usage: + python3 download-sunrise-sounds.py --api-key YOUR_KEY + python3 download-sunrise-sounds.py --api-key YOUR_KEY --dir /path/to/sunrise-sounds + python3 download-sunrise-sounds.py --api-key YOUR_KEY --per-category 30 + +Get a free API key at: https://freesound.org/apiv2/apply/ +(Freesound account required — registration is free.) + +Files are saved as HQ 128 kbps MP3 previews, which is sufficient for +background ambient audio. Full-quality downloads require OAuth2; if you want +lossless originals, log into freesound.org and download the files listed in +the manifest this script writes alongside the audio. + +Folder structure written: + /clear-spring/ dawn chorus, birds, spring morning + /clear-summer/ birds, insects, summer morning + /clear-autumn/ sparse birds, leaves, autumn morning + /clear-winter/ frost silence, sparse birds, winter morning + /cloudy/ muffled dawn, overcast ambience + /rain/ light rain, drizzle + /heavy-rain/ downpour, storm rain + /snow/ near-silence, snow ambience + /foggy/ mist, fog ambience + /thunder/ thunder + rain + /windy/ wind, breeze through trees + +daily_sunrise_video.sh picks a random file from sunrise-sounds/ as a fallback +when no camera mic recording is available for that day. A future update will +match the folder to the day's actual weather via the OpenWeatherMap API. + +Requires: requests (pip3 install requests) +""" + +import argparse +import json +import os +import random +import sys +import time +from pathlib import Path + +try: + import requests +except ImportError: + sys.exit("Missing dependency: pip3 install requests") + +FREESOUND_API = "https://freesound.org/apiv2" + +# Each category maps to a list of search queries tried in order. +# Queries are shuffled per-run so repeated runs fill in different sounds. +CATEGORIES = { + "clear-spring": [ + "dawn chorus spring birds", + "spring morning birds outdoor", + "birds chirping spring sunrise", + "dawn birds forest spring", + "bird song spring morning", + ], + "clear-summer": [ + "summer dawn birds outdoor", + "morning birds insects summer", + "dawn chorus summer", + "bird song summer morning outdoor", + "summer sunrise birds", + ], + "clear-autumn": [ + "autumn morning birds outdoor", + "fall dawn birds", + "autumn bird song morning", + "sparse birds autumn outdoor", + "fall morning outdoor ambience", + ], + "clear-winter": [ + "winter morning birds outdoor", + "frost morning quiet outdoor", + "winter dawn outdoor sparse", + "quiet winter morning outdoor", + "winter birds sparse outdoor", + ], + "cloudy": [ + "overcast morning outdoor birds", + "cloudy dawn outdoor ambience", + "grey morning birds outdoor", + "morning overcast outdoor", + "cloudy outdoor morning", + ], + "rain": [ + "light rain outdoor", + "gentle rain leaves", + "soft rain morning outdoor", + "drizzle outdoor ambience", + "rain birds outdoor", + ], + "heavy-rain": [ + "heavy rain outdoor", + "downpour rain", + "rain storm outdoor", + "heavy rainfall outdoor", + "strong rain outdoor", + ], + "snow": [ + "snow silence outdoor", + "winter snow ambience outdoor", + "quiet snow outdoor", + "snowfall outdoor", + "winter silence snow", + ], + "foggy": [ + "fog morning outdoor", + "mist ambience outdoor", + "foggy morning birds", + "misty dawn outdoor", + "fog outdoor ambience", + ], + "thunder": [ + "thunder rain outdoor", + "distant thunder outdoor", + "thunderstorm outdoor", + "thunder rumble rain outdoor", + "thunder lightning rain", + ], + "windy": [ + "wind outdoor morning", + "breeze through trees outdoor", + "wind trees outdoor", + "morning wind outdoor", + "gentle wind outdoor", + ], +} + +# Licenses acceptable for personal use +GOOD_LICENSES = {"Creative Commons 0", "Attribution", "Attribution NonCommercial"} + + +def search(api_key: str, query: str, min_dur: int, page_size: int = 15) -> list: + params = { + "token": api_key, + "query": query, + "filter": ( + f"duration:[{min_dur} TO 300] " + 'license:("Creative Commons 0" OR "Attribution" OR "Attribution NonCommercial")' + ), + "fields": "id,name,previews,license,duration,username,tags", + "page_size": page_size, + "sort": "rating_desc", + } + r = requests.get(f"{FREESOUND_API}/search/text/", params=params, timeout=20) + r.raise_for_status() + return r.json().get("results", []) + + +def download_preview(sound: dict, dest_dir: Path) -> Path | None: + preview_url = sound["previews"].get("preview-hq-mp3") + if not preview_url: + return None + + safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in sound["name"]) + filename = f"{sound['id']}-{safe}" + if not filename.lower().endswith(".mp3"): + filename += ".mp3" + dest = dest_dir / filename + + if dest.exists(): + return dest # already downloaded + + r = requests.get(preview_url, timeout=30, stream=True) + r.raise_for_status() + with open(dest, "wb") as f: + for chunk in r.iter_content(8192): + f.write(chunk) + return dest + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Download CC ambient sounds from Freesound for sky-cam sunrise audio" + ) + parser.add_argument( + "--api-key", required=True, + help="Freesound API key — get one free at freesound.org/apiv2/apply/" + ) + parser.add_argument( + "--dir", default="sunrise-sounds", + help="Output directory (default: ./sunrise-sounds)" + ) + parser.add_argument( + "--per-category", type=int, default=25, + help="Target number of files per category (default: 25 → ~275 total)" + ) + parser.add_argument( + "--min-duration", type=int, default=12, + help="Minimum sound duration in seconds (default: 12)" + ) + parser.add_argument( + "--categories", nargs="+", metavar="CAT", + help="Only download these categories (default: all)" + ) + args = parser.parse_args() + + base_dir = Path(args.dir) + base_dir.mkdir(parents=True, exist_ok=True) + + categories = args.categories or list(CATEGORIES.keys()) + unknown = set(categories) - set(CATEGORIES.keys()) + if unknown: + sys.exit(f"Unknown categories: {unknown}\nValid: {list(CATEGORIES.keys())}") + + manifest_path = base_dir / "manifest.json" + manifest: dict = {} + if manifest_path.exists(): + with open(manifest_path) as f: + manifest = json.load(f) + + grand_total = 0 + + for category in categories: + cat_dir = base_dir / category + cat_dir.mkdir(exist_ok=True) + + existing = list(cat_dir.glob("*.mp3")) + needed = args.per_category - len(existing) + if needed <= 0: + print(f"{category:20s} already has {len(existing)} files — skipping") + continue + + print(f"\n{category} — need {needed} more (have {len(existing)})") + queries = CATEGORIES[category][:] + random.shuffle(queries) + + collected = 0 + seen_ids = {p.name.split("-")[0] for p in existing if p.name[0].isdigit()} + cat_manifest = manifest.setdefault(category, {}) + + for query in queries: + if collected >= needed: + break + print(f" searching: '{query}'") + try: + results = search(args.api_key, query, args.min_duration) + random.shuffle(results) + for sound in results: + if collected >= needed: + break + sid = str(sound["id"]) + if sid in seen_ids: + continue + seen_ids.add(sid) + try: + dest = download_preview(sound, cat_dir) + if dest: + license_short = sound["license"].split("/")[-2] if "/" in sound["license"] else sound["license"] + print( + f" {dest.name} " + f"({sound['duration']:.0f}s, {license_short}, " + f"by {sound['username']})" + ) + cat_manifest[sid] = { + "file": dest.name, + "name": sound["name"], + "duration": sound["duration"], + "license": sound["license"], + "username": sound["username"], + } + collected += 1 + grand_total += 1 + time.sleep(0.4) + except Exception as e: + print(f" skipped {sid}: {e}") + except Exception as e: + print(f" search error: {e}") + time.sleep(1.0) + + total_now = len(existing) + collected + print(f" {category}: {collected} downloaded → {total_now} total") + + with open(manifest_path, "w") as f: + json.dump(manifest, f, indent=2) + print(f"\n{grand_total} new files downloaded to {base_dir}/") + print(f"Manifest written: {manifest_path}") + print() + print("Next steps:") + print(f" 1. Set AUDIO_ENABLED=true in sky-cam.conf") + print(f" 2. Verify sounds with: ls {base_dir}/*/ | head -40") + print(f" 3. Re-run with --per-category 40 any time to add more variety") + + +if __name__ == "__main__": + main() From 15b7970dfa50a39248399451383b57d96da70397 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 21:22:23 +0000 Subject: [PATCH 4/5] README: document audio fallback library and download-sunrise-sounds.py usage https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 7e1cbea..2e98066 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,28 @@ If your camera has a microphone, sky-cam can mix a natural-speed 10-second audio The audio is recorded during the same window as the images, stored alongside them, and deleted automatically after being mixed into the final video. No audio library or AI required — it's the real sound from your camera. +### Audio fallback library (optional) + +If the camera has no mic, or audio capture fails, `daily_sunrise_video.sh` picks a random ambient sound from `sunrise-sounds/` instead. The library is organised into 11 weather/season folders (`clear-spring`, `rain`, `thunder`, `windy`, etc.) — populate it once with: + +```bash +# Get a free API key at https://freesound.org/apiv2/apply/ +python3 download-sunrise-sounds.py --api-key YOUR_KEY +``` + +Default: ~275 CC-licensed 128 kbps MP3 previews (~25 per category). Re-run any time to top up: + +```bash +python3 download-sunrise-sounds.py --api-key YOUR_KEY --per-category 40 +``` + +Attribution data for every file is written to `sunrise-sounds/manifest.json`. + +**Priority order for sunrise audio:** +1. Camera mic recording (`sunrise-audio.m4a`) — real ambient sound at actual sunrise +2. Random file from `sunrise-sounds/` library — weather/season matched in a future update +3. No audio — overlay-only video (always produced regardless) + --- ## Manual operations From 16af99f0a360bc533f057d19986288fb00ad41a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 21:34:04 +0000 Subject: [PATCH 5/5] Split step 3 into independent audio (3a) and overlay (3b) stages Each stage can now fail independently, giving four upload paths: audio+overlay, audio-only, overlay-only, or speed-only video. Step 3a copies the video stream (no re-encode) when mixing audio, so step 3b failure still leaves a clean audio-mixed file to promote. Overlay failure notification now reports which quality was uploaded. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- daily_sunrise_video.sh | 76 ++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index 4b45c70..cba0e87 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -56,7 +56,8 @@ fi temp_list=$(mktemp --suffix=.txt) raw_video="" -trap 'rm -f "$temp_list" "$raw_video" 2>/dev/null || true' EXIT +temp_audio="" +trap 'rm -f "$temp_list" "$raw_video" "$temp_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)") @@ -139,11 +140,15 @@ 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" -# ── Step 3: Overlay + optional audio ───────────────────────────────────────── -# Priority: (1) camera mic recording, (2) library fallback, (3) no audio. -# If audio mixing fails, step 3 retries with overlay-only so the video is -# always made. If overlay itself fails, the sped video is promoted so upload -# still fires. +# ── 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) @@ -154,13 +159,11 @@ audio_offset="0" cam_audio="$image_dir/sunrise-audio.m4a" if [ "${AUDIO_ENABLED:-false}" = "true" ]; then if [ -f "$cam_audio" ]; then - # Camera recording: offset centres the clip on actual sunrise buffer_sec=$(( ${AUDIO_PRE_BUFFER_MIN:-2} * 60 )) audio_offset=$(echo "scale=1; $SUNRISE_PRE_MIN * 60 + $buffer_sec - $SUNRISE_TARGET_SECS / 2" | bc) audio_src="$cam_audio" echo "Audio: camera recording offset=${audio_offset}s" else - # Library fallback: random file from sunrise-sounds/ 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) @@ -172,48 +175,55 @@ if [ "${AUDIO_ENABLED:-false}" = "true" ]; then fi fi -# ── Attempt overlay + audio (falls back to overlay-only if audio fails) ─────── -audio_mixed=false -step3_ok=true +# ── Step 3a: mix audio (video copied, not re-encoded) ──────────────────────── +work_video="$sped_video" +has_audio=false if [ -n "$audio_src" ]; then - echo "Step 3/3: overlay + audio → $final_video" + 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 "[0:v]${DT}[vout];[1:a]afade=t=in:st=0:d=0.5,afade=t=out:st=${fade_out}:d=0.5[aout]" \ - -map "[vout]" -map "[aout]" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \ - -c:a aac -b:a 128k \ - -y "$final_video"; then - audio_mixed=true + -filter_complex "[1:a]afade=t=in:st=0:d=0.5,afade=t=out:st=${fade_out}:d=0.5[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 - echo "Audio mix failed — retrying with overlay only" + 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 — falling back to overlay-only" || true + "Audio could not be mixed — continuing with overlay only" || true [ "$audio_src" = "$cam_audio" ] && rm -f "$cam_audio" fi fi -if ! $audio_mixed; then - echo "Step 3/3: overlay (no audio) → $final_video" - ffmpeg -loglevel warning \ - -i "$sped_video" \ - -vf "${DT}" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \ - -y "$final_video" || step3_ok=false -fi +# ── Step 3b: burn overlay ───────────────────────────────────────────────────── +echo "Step 3b/3: overlay → $final_video" +if $has_audio; then audio_out_flags=(-c:a copy); else audio_out_flags=(-an); fi -if $step3_ok; then +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" + 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 - mv "$sped_video" "$final_video" - echo "Overlay failed — promoting speed-only video as upload target" + 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="" + [ -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 step failed — uploading speed-only video (no timestamp)" || true + "Overlay failed — uploading ${promote_label} video (no timestamp)" || true fi