- Print "Validating N frames..." before the loop, including active filter thresholds when grey-checking is on - Print "X / N frames checked..." every 500 frames so the user can see the loop is alive during the long ImageMagick pass - Print "Validation done: N valid frames" after the loop - Note "(may take several minutes)" on the Step 1 encode echo - Show the speed factor and target together: "28.8x → 11.08s target" https://claude.ai/code/session_01U29A8NpgJ41tboiggZNmk8
sky-cam
Automated sky / timelapse camera scripts that produce:
- Daily sunrise clip — a 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
Quick start
1. Install dependencies
sudo apt install ffmpeg bc fonts-dejavu curl python3-pip
pip3 install suntime pytz requests
| Package | Purpose |
|---|---|
ffmpeg + ffprobe |
Encoding, RTSP capture, audio recording, duration probing |
bc |
Floating-point arithmetic for speed factors |
fonts-dejavu |
Text overlays (sunrise time, attribution) |
python3 + suntime pytz |
Astronomical sunrise calculation — pure math, no internet, works indefinitely |
python3 + requests |
Mattermost upload |
2. Download
bash <(curl -fsSL https://raw.githubusercontent.com/outis1one/sky-cam/main/bootstrap.sh)
cd sky-cam
Or with a custom install directory:
curl -fsSL https://raw.githubusercontent.com/outis1one/sky-cam/main/bootstrap.sh | bash -s -- /opt/sky-cam
cd /opt/sky-cam
3. Configure
nano sky-cam.conf
SCRIPT_DIR and BASE_DIR are auto-detected — you only need to fill in settings specific to your setup:
| Setting | What it is |
|---|---|
BASE_DIR |
Root where camera images live — override if storing on a separate drive (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 MP3 files |
CAMERAS |
Space-separated list of camera names, e.g. (east north south west) |
SUNRISE_CAM |
Which camera faces east and gets the sunrise job |
LATITUDE / LONGITUDE / TIMEZONE |
Your location for sunrise calculation |
CAPTURE_INTERVAL |
Seconds between captured frames (default: 10) |
SCHEDULE_SUNRISE |
When to start the sunrise job — default 03:00, script waits internally until the capture window closes |
SCHEDULE_SEASONS_<cam> |
When to run the Four Seasons daily clip — processes yesterday's images; runs after midnight |
4. Set up credentials
./install.sh # generates .env.example alongside systemd units
cp .env.example .env
$EDITOR .env
.env holds all sensitive values and is never committed to git:
# RTSP stream URL — one per camera (variable name matches camera name)
CAM_RTSP_east=rtsp://admin:password@192.168.1.100:554/stream1
# Mattermost upload
mattermost_url=https://your-mattermost.example.com
access_token=your-token
channel_id=your-channel-id
# Notifications (uncomment what you use)
#NTFY_URL=https://ntfy.sh/your-topic
#EMAIL_TO=you@example.com
Re-run ./install.sh any time sky-cam.conf changes (schedules, cameras, etc.).
5. Verify
# Confirm cameras are capturing
systemctl --user status sky-cam-capture-east.service
ls BASE_DIR/east/$(date +%Y-%m-%d)/ # images should appear within CAPTURE_INTERVAL seconds
# Check all timers are scheduled
systemctl --user list-timers 'sky-cam-*'
# Test sunrise calculation
python3 sunrise.py
# export XDG_RUNTIME_DIR so system schedules will show
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user list-timers 'sky-cam-sunrise*'
# Add that export to your ~/.bashrc so it's always set in your shell:
echo 'export XDG_RUNTIME_DIR=/run/user/$(id -u)' >> ~/.bashrc
source ~/.bashrc
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 after 30s on loss
│
├─ sunrise-audio-capture.sh (starts at 03:00, waits internally for sunrise)
│ Calculates today's sunrise, then records exactly SUNRISE_TARGET_SECS of audio
│ centred on the moment of sunrise (odd second goes to post-sunrise)
│ 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 (starts at SCHEDULE_SUNRISE, waits for capture window)
│ Wakes at SCHEDULE_SUNRISE, sleeps until (sunrise + SUNRISE_POST_MIN)
│ Step 1: encode raw video from sunrise-window JPEGs
│ Step 2: speed-adjust to SUNRISE_TARGET_SECS → saved permanently
│ Step 3a: mix audio (camera mic → library fallback → no audio)
│ Step 3b: burn sunrise time overlay (optional, set SUNRISE_OVERLAY_ENABLED=false to skip)
│ Each step degrades independently — upload always fires
│ 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
│ On completion → notify with verify-mvt.sh command
└─ verify-mvt.sh <year> <season> <mvt> <cam> (run manually after notification)
Review montage, approve to delete source JPEG folders
Resilience
Every pipeline saves an intermediate file before the riskiest step, so a partial failure always leaves something uploadable:
Sunrise video — four-tier fallback, upload always fires:
| Audio | Overlay | What gets uploaded |
|---|---|---|
| ✓ | ✓ | Final video with audio + timestamp |
| ✓ | ✗ | Audio-mixed video, no timestamp — overlay failure notified |
| ✗ | ✓ | Video with timestamp, no audio — audio failure notified |
| ✗ | ✗ | Speed-only video — both failures notified |
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 | NTFY_ENABLED=true, NTFY_URL=https://ntfy.sh/your-topic |
EMAIL_ENABLED=true, EMAIL_TO=you@example.com |
|
| Mattermost text post | MM_NOTIFY_ENABLED=true, MM_NOTIFY_CHANNEL_ID=<channel-id> |
Notifications fire for:
- Sunrise video ready (with audio/overlay status), upload success/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 records exactly SUNRISE_TARGET_SECS of natural-speed audio centred on the actual sunrise moment and mixes it into the timelapse video.
- Set
AUDIO_ENABLED=trueinsky-cam.conf - Set
CAM_RTSP_<cam>in.envfor the sunrise camera - Re-run
./install.shto generate thesky-cam-audio-capture.timer
The recording is split evenly around sunrise (e.g. 5 s before + 5 s after for a 10 s clip; odd second goes to post-sunrise). It is deleted automatically after mixing.
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:
# 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:
python3 download-sunrise-sounds.py --api-key YOUR_KEY --per-category 40
Attribution data for every file is written to sunrise-sounds/manifest.json.
Audio priority order:
- Camera mic recording (
sunrise-audio.m4a) — real ambient sound at actual sunrise - Random file from
sunrise-sounds/library - No audio — overlay-only video (always produced regardless)
Manual operations
Re-run today's sunrise (e.g. after a config fix):
./daily_sunrise_video.sh
Re-run a daily seasons clip for yesterday:
./4-seasons.sh east
Re-run a daily seasons clip for a specific past date:
./4-seasons.sh east 2026-04-19
Backfill multiple past dates (useful after a camera outage or a fresh install with existing images):
for d in 2026-04-15 2026-04-16 2026-04-17 2026-04-18 2026-04-19; do
./4-seasons.sh east "$d"
done
Replace the date list with whatever range you need. Each run produces one *-final.mp4 in the movement's output directory. Once all days for a movement are present you can build the montage manually:
./montage-mvt.sh east 2026-04-19 # date of the last day of that movement
Rebuild a movement montage (e.g. to retry audio after a failure):
./montage-mvt.sh east # uses today's date
./montage-mvt.sh east 2025-06-15 # specific date
Rebuild the year-end video:
./year-end-join.sh 2025 east
Check capture status:
systemctl --user status sky-cam-capture-east.service
journalctl --user -u sky-cam-capture-east.service -f
The capture watchdog runs alongside each camera and sends a notification (via notify.sh) if no new frame arrives within CAPTURE_STALE_SECS seconds, and a second notification when capture resumes. Check watchdog logs with:
journalctl --user -u sky-cam-watchdog-east.service -f
Check logs:
journalctl --user -u sky-cam-sunrise.service
journalctl --user -u sky-cam-seasons-east.service
Keeping sky-cam up to date
Install git
sudo apt install git
First-time clone (if you don't have the repo yet)
git clone https://github.com/outis1one/sky-cam.git
cd sky-cam
Pull the latest changes from main
After merging a pull request on GitHub, or whenever you want to update your running copy:
git pull origin main
Then re-run install if any schedules or scripts changed:
./install.sh
Check what changed since your last pull
git log --oneline origin/main ^HEAD # commits on remote not yet on your machine
git diff HEAD origin/main # full diff of incoming changes
git fetch origin && git status # fetch first, then show your local state
You have local edits and want to pull anyway
Option A — save your changes first (recommended):
git stash # temporarily shelve your local edits
git pull origin main # pull updates
git stash pop # re-apply your edits on top
If stash pop reports a conflict, open the file and look for the <<<<<< markers — edit to resolve, then git add <file> and git stash drop.
Option B — discard your local edits completely:
git fetch origin
git reset --hard origin/main # WARNING: your local edits are gone permanently
Use this only when you are sure you do not need your local changes.
Check what you have changed locally
git status # which files are modified / untracked
git diff # show the actual changes (unstaged)
git diff --staged # show changes already staged with git add
Look at the history
git log --oneline # compact list of commits
git log --oneline -20 # last 20 only
git show <commit-hash> # full diff for one commit
Switch to a specific branch (e.g. a development branch)
git fetch origin
git checkout claude/seasonal-sunrise-montage-nn268
To switch back to main:
git checkout main
git pull origin main
Undo the last commit (before pushing)
git reset HEAD~1 # undo commit, keep the file changes
Credentials and .env are never in git
.env is listed in .gitignore and will never be overwritten by a pull. sky-cam.conf is in git — if you edited it locally, a pull may conflict. Keep your machine-specific values in .env and leave sky-cam.conf for settings you want to track.