From 9fe0051f70fcc79bfd6752f54cfe041fd24784e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:59:40 +0000 Subject: [PATCH] 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)"