# 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
- **Nightly moon-track timelapse** — every east night frame where the moon is visible is cropped around the moon and stitched into a short mp4 (the moon roughly held still while clouds and stars drift past)
- **Monthly moon-phase close-ups** — one full-screen image per phase (full moon, first quarter, third quarter).  East acts as the witness, confirming the moon was visible in your sky during the collection window and supplying the timestamp.  The image itself is a NASA SVS Dial-a-Moon render for that exact UTC hour — accurate phase, real libration, correct crater shadows — sized to fill the frame (~92% of height) on a black background, the way a long-telephoto shot looks.  Posted to Mattermost.

---

## Quick start

### 1. Install dependencies

```bash
sudo apt install ffmpeg bc fonts-dejavu curl python3-pip
pip3 install suntime pytz requests
pip3 install skyfield Pillow numpy scipy   # moon jobs (moon-track + moon-phase-monthly)
```

| 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 |
| `python3` + `skyfield` | Moon phases + altitude/azimuth + parallactic angle (offline after first ephemeris download) |
| `python3` + `Pillow numpy scipy` | Moon detection + phase compositing |

### 2. 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
```

### 3. Configure

```bash
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

```bash
./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:

```bash
# 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

```bash
# 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](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>` |

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.

1. Set `AUDIO_ENABLED=true` in `sky-cam.conf`
2. Set `CAM_RTSP_<cam>` in `.env` for the sunrise camera
3. Re-run `./install.sh` to generate the `sky-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:

```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`.

**Audio priority order:**
1. Camera mic recording (`sunrise-audio.m4a`) — real ambient sound at actual sunrise
2. Random file from `sunrise-sounds/` library
3. No audio — overlay-only video (always produced regardless)

---

## Manual operations

**Re-run today's sunrise** (e.g. after a config fix):
```bash
./daily_sunrise_video.sh
```

**Re-run a daily seasons clip** for yesterday:
```bash
./4-seasons.sh east
```

**Re-run a daily seasons clip** for a specific past date:
```bash
./4-seasons.sh east 2026-04-19
```

**Backfill multiple past dates** (useful after a camera outage or a fresh install with existing images):
```bash
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:
```bash
./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):
```bash
./montage-mvt.sh east                   # uses today's date
./montage-mvt.sh east 2025-06-15        # specific date
```

**Rebuild the year-end video**:
```bash
./year-end-join.sh 2025 east
```

**Moon jobs**:
```bash
# Re-run last night's moon-track timelapse for east
./moon-track.sh east

# Back-fill moon-track for a specific past night
./moon-track.sh east 2026-04-15

# Auto mode — runs whichever phase composite is due today (no-ops otherwise)
./moon-phase-monthly.sh

# Force a single phase, picking the most recent occurrence
./moon-phase-monthly.sh --phase full
./moon-phase-monthly.sh --phase first-quarter
./moon-phase-monthly.sh --phase third-quarter

# Build but skip the Mattermost post
./moon-phase-monthly.sh --phase full --no-upload

# Dry run — find best frame, log it, build nothing
./moon-phase-monthly.sh --phase third-quarter --dry-run

# Back-fill a specific event by exact UTC moment
./moon-phase-monthly.sh --phase full --target 2026-04-01T15:51:00Z

# Inspect any moon-related stats for a frame
python3 moon_detect.py BASE_DIR/east/2026-04-29/21-07-00.jpg --debug /tmp/dbg.png
python3 moon_phase.py info 2026-04-29T21:07:00Z

# End-to-end test: fetch NASA render + produce composite for the last full moon
python3 test_last_full_moon.py
# Output: test_last_full_moon_out.jpg
```

`test_last_full_moon.py` is equivalent to `moon-phase-monthly.sh --phase full --no-upload` for
the most recent full moon, with the output redirected to `test_last_full_moon_out.jpg`.  It runs
the complete production pipeline: dark-window filtering, east-frame moon detection, NASA
Dial-a-Moon fetch, and `render_phase_closeup` with the full caption.  It also calls
`sun_events_in_range()` explicitly before the main pipeline as a regression check for the
bare-topos fix.

Useful after updating skyfield, changing `LATITUDE`/`LONGITUDE`, or touching the compositing code.

**Posting schedule**:

| Job | When the timer fires | When the artifact actually appears |
|---|---|---|
| Sunrise video | `SCHEDULE_SUNRISE` (default 03:00 local) | A few minutes after sunrise + `SUNRISE_POST_MIN` |
| Moon-track timelapse | `SCHEDULE_MOON_TRACK` (default 02:30 local) | ~5 min after the timer, covers the previous night |
| 🌕 Full Moon close-up | `SCHEDULE_MOON_PHASE` (default 09:30 local) | 3 days after exact full moon (configurable: `MOON_FULL_POST_DELAY_DAYS`) |
| 🌓 First Quarter close-up | same timer | 2 days after exact first quarter (configurable: `MOON_QUARTER_POST_DELAY_DAYS`) — best-effort, see geometry note below |
| 🌗 Third Quarter close-up | same timer | 2 days after exact third quarter |

The moon-phase timer fires every day; the script no-ops on days that aren't a post-day for any phase, so you'll see exactly three posts per lunar cycle in Mattermost (sometimes only two if first quarter detection fails — see geometry note in `sky-cam.conf`).

### How the moon close-ups work

The phase posts are **honest composites**: east is the witness, NASA is the photographer.

- East scans the collection window (D-1 .. D+2 for full, D-1 .. D+1 for quarters), runs moon detection on each frame, and picks the moment closest to the exact phase that meets quality / altitude / illumination thresholds.
- That picked moment becomes the timestamp we send to NASA's [SVS Dial-a-Moon](https://svs.gsfc.nasa.gov/api/dialamoon/) — a free public service that returns a real-physics moon render for any UTC hour, complete with correct phase, libration, and crater shadows.
- The render is sized to fill ~92% of a 1920×1080 frame on a black background and posted to Mattermost with a caption listing the witnessed-by-east moment and the NASA attribution.
- One API call per phase event (~36/year), cached forever locally.
- If you'd rather post the NASA render every cycle regardless of weather over east, set `MOON_REQUIRE_EAST_VERIFY=false` in `sky-cam.conf`.

**Check capture status**:
```bash
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:
```bash
journalctl --user -u sky-cam-watchdog-east.service -f
```

**Check logs**:
```bash
journalctl --user -u sky-cam-sunrise.service
journalctl --user -u sky-cam-seasons-east.service
```

---

## Keeping sky-cam up to date

### Install git

```bash
sudo apt install git
```

### First-time clone (if you don't have the repo yet)

```bash
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:

```bash
git pull origin main
```

Then re-run install if any schedules or scripts changed:

```bash
./install.sh
```

### Check what changed since your last pull

```bash
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):**
```bash
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:**
```bash
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

```bash
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

```bash
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)

```bash
git fetch origin
git checkout claude/seasonal-sunrise-montage-nn268
```

To switch back to main:
```bash
git checkout main
git pull origin main
```

### Undo the last commit (before pushing)

```bash
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.
