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/<cam>/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-<cam>.service for each camera with CAM_RTSP_<cam>
set, and sky-cam-audio-capture.{service,timer} when AUDIO_ENABLED=true.
sky-cam.conf
New settings: CAM_RTSP_<cam>, CAPTURE_INTERVAL, AUDIO_ENABLED,
AUDIO_PRE_BUFFER_MIN, CAPTURE_AUDIO_BITRATE.
https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
This commit is contained in:
@@ -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/<cam>/<YYYY-MM-DD>/` |
|
||||
| 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_<cam>` | 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 <cam> (long-running systemd service, one per camera)
|
||||
│ ffmpeg pulls one frame every CAPTURE_INTERVAL seconds
|
||||
│ Writes: BASE_DIR/<cam>/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/<cam>/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 <cam> (runs at SCHEDULE_SEASONS_<cam>, 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_<cam>` 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
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# capture.sh <cam-name> — 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-<cam>.service automatically when
|
||||
# CAM_RTSP_<cam> is set in sky-cam.conf.
|
||||
#
|
||||
# Writes: BASE_DIR/<cam>/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
|
||||
+31
-9
@@ -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
|
||||
|
||||
+50
@@ -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_<cam> 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" <<EOF
|
||||
[Unit]
|
||||
Description=Sky-Cam — ${cam}: continuous RTSP frame capture
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$SCRIPT_DIR/capture.sh ${cam}
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
echo " wrote sky-cam-capture-${cam}.service"
|
||||
capture_services+=("sky-cam-capture-${cam}")
|
||||
else
|
||||
echo " WARNING: CAM_RTSP_${cam} not set — skipping capture service for ${cam}"
|
||||
echo " (set CAM_RTSP_${cam}=rtsp://... in sky-cam.conf to enable)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Sunrise audio capture ─────────────────────────────────────────────────────
|
||||
# Triggered at 03:00; waits internally until the right time before recording.
|
||||
if [ "${AUDIO_ENABLED:-false}" = "true" ]; then
|
||||
write_service "sky-cam-audio-capture" \
|
||||
"$SUNRISE_CAM: sunrise audio capture" \
|
||||
"sunrise-audio-capture.sh"
|
||||
write_timer "sky-cam-audio-capture" \
|
||||
"$SUNRISE_CAM: sunrise audio capture" \
|
||||
"03:00:00"
|
||||
timers+=("sky-cam-audio-capture")
|
||||
fi
|
||||
|
||||
# ── Per-camera Four Seasons and full-day jobs ─────────────────────────────────
|
||||
# Reads CAMERAS, SCHEDULE_SEASONS_<cam>, SCHEDULE_FULLDAY_<cam> 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" \
|
||||
|
||||
@@ -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_<cam>=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
|
||||
|
||||
Executable
+80
@@ -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)"
|
||||
Reference in New Issue
Block a user