diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e98066 --- /dev/null +++ b/README.md @@ -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///.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_` | 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 (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 + mix camera audio (if available) + │ 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 + +--- + +## 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. + +### 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 # 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 +``` 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..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,24 +140,90 @@ 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: 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" -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 \ - -i "$sped_video" \ + -i "$work_video" \ -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 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 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() 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)"