Merge pull request #10 from outis1one/claude/seasonal-sunrise-montage-nn268
Claude/seasonal sunrise montage nn268
This commit is contained in:
@@ -0,0 +1,219 @@
|
|||||||
|
# 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, 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 |
|
||||||
|
| 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:
|
||||||
|
|
||||||
|
```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/<cam>/<date>/<HH-MM-SS>.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 |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
```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 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 + mix camera audio (if available)
|
||||||
|
│ OnSuccess → sunrise2mm.py uploads to Mattermost
|
||||||
|
│
|
||||||
|
├─ 4-seasons.sh <cam> (runs at SCHEDULE_SEASONS_<cam>, 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 <cam> (runs at SCHEDULE_FULLDAY_<cam>, 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=<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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
**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 <cam> # 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 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
|
||||||
|
journalctl --user -u sky-cam-seasons-sunrise.service
|
||||||
|
journalctl --user -u sky-cam-fullday-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
|
||||||
+78
-11
@@ -56,7 +56,8 @@ fi
|
|||||||
|
|
||||||
temp_list=$(mktemp --suffix=.txt)
|
temp_list=$(mktemp --suffix=.txt)
|
||||||
raw_video=""
|
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
|
find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do
|
||||||
img_sec=$(time_to_seconds "$(basename "$img" .jpg)")
|
img_sec=$(time_to_seconds "$(basename "$img" .jpg)")
|
||||||
@@ -139,24 +140,90 @@ DT="${DT}:line_spacing=4"
|
|||||||
DT="${DT}:x=w-tw-18:y=(h-th)/2"
|
DT="${DT}:x=w-tw-18:y=(h-th)/2"
|
||||||
DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1"
|
DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1"
|
||||||
|
|
||||||
# ── Step 3: Overlay — sunrise time burned in ──────────────────────────────────
|
# ── Step 3: Audio mix + overlay — each independently optional/fallible ────────
|
||||||
# If overlay fails, the sped video is promoted to the upload target so
|
# Step 3a: mixes audio into a temp copy of the sped video (video stream is
|
||||||
# OnSuccess still fires and the upload happens (without timestamp overlay).
|
# 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"
|
final_video="$output_dir/$current_date-daily-sunrise.mp4"
|
||||||
echo "Step 3/3: overlay → $final_video"
|
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
|
||||||
|
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_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
|
||||||
|
|
||||||
|
# ── 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=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
|
||||||
|
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 — continuing with overlay only" || true
|
||||||
|
[ "$audio_src" = "$cam_audio" ] && rm -f "$cam_audio"
|
||||||
|
fi
|
||||||
|
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 ffmpeg -loglevel warning \
|
if ffmpeg -loglevel warning \
|
||||||
-i "$sped_video" \
|
-i "$work_video" \
|
||||||
-vf "${DT}" \
|
-vf "${DT}" \
|
||||||
-c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \
|
-c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \
|
||||||
|
"${audio_out_flags[@]}" \
|
||||||
-y "$final_video"; then
|
-y "$final_video"; then
|
||||||
actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_video")
|
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})"
|
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" \
|
"$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \
|
||||||
"$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true
|
"$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true
|
||||||
else
|
else
|
||||||
mv "$sped_video" "$final_video"
|
if $has_audio; then promote_label="audio-mixed"; else promote_label="speed-only"; fi
|
||||||
echo "Overlay failed — promoting speed-only video as upload target"
|
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" \
|
"$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
|
fi
|
||||||
|
|||||||
@@ -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:
|
||||||
|
<dir>/clear-spring/ dawn chorus, birds, spring morning
|
||||||
|
<dir>/clear-summer/ birds, insects, summer morning
|
||||||
|
<dir>/clear-autumn/ sparse birds, leaves, autumn morning
|
||||||
|
<dir>/clear-winter/ frost silence, sparse birds, winter morning
|
||||||
|
<dir>/cloudy/ muffled dawn, overcast ambience
|
||||||
|
<dir>/rain/ light rain, drizzle
|
||||||
|
<dir>/heavy-rain/ downpour, storm rain
|
||||||
|
<dir>/snow/ near-silence, snow ambience
|
||||||
|
<dir>/foggy/ mist, fog ambience
|
||||||
|
<dir>/thunder/ thunder + rain
|
||||||
|
<dir>/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()
|
||||||
+50
@@ -111,6 +111,50 @@ write_timer "sky-cam-sunrise" "$SUNRISE_CAM: daily sunrise video" "$SCHEDULE_SUN
|
|||||||
|
|
||||||
timers=(sky-cam-sunrise)
|
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 ─────────────────────────────────
|
# ── Per-camera Four Seasons and full-day jobs ─────────────────────────────────
|
||||||
# Reads CAMERAS, SCHEDULE_SEASONS_<cam>, SCHEDULE_FULLDAY_<cam> from sky-cam.conf.
|
# 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.
|
# Scripts receive the camera name as $1 so they know which camera to process.
|
||||||
@@ -146,6 +190,12 @@ done
|
|||||||
echo ""
|
echo ""
|
||||||
$SC daemon-reload
|
$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
|
for timer in "${timers[@]}"; do
|
||||||
$SC enable --now "${timer}.timer" \
|
$SC enable --now "${timer}.timer" \
|
||||||
&& echo " enabled + started ${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_DUR=6 # attribution overlay total duration (seconds)
|
||||||
MONTAGE_ATTR_FADE=1 # attribution fade-in and fade-out length (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 — daily sunrise upload ─────────────────────────────────────────
|
||||||
mattermost_url=https://your-mattermost-server.example.com
|
mattermost_url=https://your-mattermost-server.example.com
|
||||||
access_token=your-access-token-here
|
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