"The best time to plant a tree was 20 years ago. The second best time is now." — Chinese Proverb
The UniFi Timelapse System is a comprehensive Docker-based solution that captures images from UniFi cameras (or any HTTP-accessible cameras) at configurable intervals, organizes them in a structured directory hierarchy, and automatically generates beautiful timelapse videos using FFMPEG.
The system captures images every 30 seconds (configurable) from multiple cameras concurrently, stores them in an organized {camera_name}/{YYYYMMDD}/ structure, and generates daily 24-hour timelapse videos automatically each night. For longer-term storytelling, it also creates multi-day summary timelapses by intelligently selecting a configurable number of images per hour across multiple days, with those selected images protected from automatic cleanup.
A dedicated health monitor continuously checks camera connectivity, detects blank or frozen images, and sends notifications via Apprise to any platform you choose (Slack, Discord, NTFY, Email, and 80+ others). The Vue.js web interface provides real-time monitoring, camera management, timelapse browsing, and system configuration—all in a clean, dark-themed dashboard.
The entire system runs as a self-contained Docker Compose stack with 8 containers working in harmony, designed for hands-off 24/7 operation with automatic cleanup and intelligent retention policies.
{camera_name}/{YYYYMMDD}/{timestamp}_{camera}.jpeg-movflags +faststart for instant web playbackflowchart TB
subgraph CAMERAS["📷 Camera Network"]
CAM1["Camera 1"]
CAM2["Camera 2"]
CAM3["Camera N"]
end
subgraph DOCKER["Docker Compose Stack"]
subgraph FRONTEND["Frontend Layer"]
NGINX["Nginx<br/>Reverse Proxy"]
VUE["Vue.js<br/>Frontend"]
end
subgraph API_LAYER["API Layer"]
API["FastAPI<br/>REST API"]
end
subgraph WORKERS["Worker Layer"]
WORKER["Worker<br/>Capture & FFMPEG"]
HEALTH["Health<br/>Monitor"]
end
subgraph DATA["Data Layer"]
PG[("PostgreSQL<br/>Database")]
REDIS[("Redis<br/>Cache")]
end
subgraph NOTIFY["Notification Layer"]
APPRISE["Apprise<br/>Notifications"]
end
subgraph STORAGE["Storage Layer"]
IMAGES[("Images<br/>/output/images")]
VIDEOS[("Videos<br/>/output/videos")]
end
end
subgraph PLATFORMS["📱 Notification Platforms"]
SLACK["Slack"]
DISCORD["Discord"]
NTFY["NTFY"]
EMAIL["Email"]
end
CAM1 & CAM2 & CAM3 -->|"HTTP GET"| WORKER
WORKER -->|"Store"| IMAGES
WORKER -->|"Encode"| VIDEOS
WORKER <-->|"Read/Write"| PG
WORKER -->|"Cache"| REDIS
HEALTH -->|"Check"| CAM1 & CAM2 & CAM3
HEALTH <-->|"Read/Write"| PG
HEALTH -->|"Alerts"| APPRISE
API <-->|"CRUD"| PG
API -->|"Cache"| REDIS
API -->|"Notify"| APPRISE
VUE -->|"/api/*"| NGINX
NGINX -->|"Proxy"| API
NGINX -->|"Static"| VUE
NGINX -->|"Media"| IMAGES & VIDEOS
APPRISE -->|"Send"| SLACK & DISCORD & NTFY & EMAIL
style NGINX fill:#009639,color:#fff
style API fill:#009688,color:#fff
style WORKER fill:#FF6D00,color:#fff
style HEALTH fill:#E91E63,color:#fff
style PG fill:#336791,color:#fff
style REDIS fill:#DC382D,color:#fff
style VUE fill:#4FC08D,color:#fff
style APPRISE fill:#9C27B0,color:#fff
The system runs as 8 Docker containers:
| Container | Image | Purpose |
|---|---|---|
timelapse_postgres | postgres:16-alpine | PostgreSQL database for all persistent data |
timelapse_redis | redis:7-alpine | Redis cache for session data and rate limiting |
timelapse_api | rjsears/unifi-timelapse-api | FastAPI REST API for all operations |
timelapse_worker | rjsears/unifi-timelapse-worker | APScheduler worker for capture and FFMPEG |
timelapse_health | rjsears/unifi-timelapse-health | Dedicated health monitoring service |
timelapse_frontend | rjsears/unifi-timelapse-frontend | Vue.js web interface |
timelapse_apprise | caronc/apprise | Notification service |
timelapse_nginx | nginx:alpine | Reverse proxy and static file serving |
| Requirement | Version | Notes |
|---|---|---|
| Docker | 20.10+ | With Docker Compose v2 |
| Memory | 16GB+ | 4K timelapse encoding uses ~12GB RAM |
| CPU | 8+ cores | 4K encoding uses 10+ threads; 16+ cores recommended |
| Camera Access | — | HTTP endpoint serving JPEG images |
| Storage | — | ~50MB/camera/day for images at 30s intervals |
Cameras must be accessible via HTTP and return JPEG images. Examples:
# UniFi Protect cameras (via snapshot URL)
http://camera-ip/snap.jpeg
# Generic IP cameras
http://camera-ip/cgi-bin/snapshot.cgi
# RTSP cameras with snapshot endpoint
http://camera-ip/snapshot.jpg
git clone https://github.com/rjsears/unifi_timelapse.git
cd unifi_timelapse
# Copy the example environment file
cp .env.example .env
# Edit with your settings
nano .env
Required settings to change:
# Security (REQUIRED - generate with: openssl rand -hex 32)
POSTGRES_PASSWORD=your_secure_database_password
SECRET_KEY=your_32_character_secret_key
ADMIN_PASSWORD=your_admin_login_password
# Timezone
TZ=America/Los_Angeles
# Pull and start all containers
docker compose up -d
# Watch the logs
docker compose logs -f
Open your browser to http://localhost (or your server IP).
Login with:
admin (or your ADMIN_USERNAME)ADMIN_PASSWORDfront-doorhttp://192.168.1.100/snap.jpeg30 secondsThe worker will begin capturing images immediately.
All configuration is done via environment variables in .env:
| Variable | Default | Description |
|---|---|---|
POSTGRES_USER | timelapse | Database username |
POSTGRES_PASSWORD | — | Required. Database password |
POSTGRES_DB | timelapse | Database name |
SECRET_KEY | — | Required. JWT signing key (32+ chars) |
ADMIN_USERNAME | admin | Initial admin username |
ADMIN_PASSWORD | — | Required. Initial admin password |
| Variable | Default | Description |
|---|---|---|
DEFAULT_CAPTURE_INTERVAL | 30 | Seconds between captures |
MAX_CONCURRENT_CAPTURES | 50 | Maximum parallel camera captures |
CAPTURE_TIMEOUT | 30 | HTTP request timeout in seconds |
CAPTURE_RETRIES | 3 | Retry attempts on failure |
Blackout periods allow you to skip image captures during specific hours (e.g., nighttime when there's no activity). Configure per-camera via the web interface or API:
curl -X PUT http://localhost/api/cameras/{id} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"blackout_start": "22:00",
"blackout_end": "06:00"
}'
Features:
Use cases:
| Variable | Default | Description |
|---|---|---|
DEFAULT_FRAME_RATE | 30 | Output video FPS |
DEFAULT_CRF | 20 | Quality (0-51, lower = better) |
DEFAULT_PIXEL_FORMAT | yuv444p | Pixel format (yuv420p, yuv444p, rgb24) |
FFMPEG_TIMEOUT | 14400 | Max encoding time in seconds (4 hours) |
DAILY_TIMELAPSE_TIME | 01:00 | When to generate daily timelapses |
| Variable | Default | Description |
|---|---|---|
MULTIDAY_IMAGES_PER_HOUR | 2 | Images to select per hour |
MULTIDAY_DAYS_TO_INCLUDE | 7 | Days to include in summary |
MULTIDAY_GENERATION_DAY | sunday | Day of week to generate |
MULTIDAY_GENERATION_TIME | 02:00 | Time to generate |
| Variable | Default | Description |
|---|---|---|
RETENTION_DAYS_IMAGES | 7 | Days to keep images |
RETENTION_DAYS_VIDEOS | 365 | Days to keep videos |
CLEANUP_TIME | 03:00 | When to run cleanup |
| Variable | Default | Description |
|---|---|---|
HEALTH_CHECK_INTERVAL | 60 | Seconds between connectivity checks |
BLANK_CHECK_INTERVAL | 300 | Seconds between blank image checks |
FROZEN_CHECK_INTERVAL | 300 | Seconds between frozen image checks |
BLANK_THRESHOLD | 0.02 | Pixel variance threshold for blank detection |
| Variable | Default | Description |
|---|---|---|
APPRISE_ENABLED | false | Enable notifications |
APPRISE_DEFAULT_URLS | — | Comma-separated Apprise URLs |
MIN_FAILURES_BEFORE_ALERT | 3 | Consecutive failures before alerting |
ALERT_COOLDOWN_MINUTES | 30 | Minutes between repeat alerts |
The Vue.js dashboard provides complete system management with a modern, responsive design featuring dark and light mode support.
Three-tab interface for complete timelapse management:
Collapsible card interface for organized health monitoring:
Collapsible card interface organized by category:
Full OpenAPI documentation available at /api/docs when running.
| Method | Endpoint | Description |
|---|---|---|
POST | /api/auth/login | Authenticate and get JWT token |
GET | /api/cameras | List all cameras |
POST | /api/cameras | Add a new camera |
GET | /api/cameras/{id} | Get camera details |
PUT | /api/cameras/{id} | Update camera |
DELETE | /api/cameras/{id} | Delete camera |
GET | /api/cameras/{id}/images | List camera images |
POST | /api/cameras/{id}/capture | Trigger manual capture |
POST | /api/cameras/{id}/timelapse | Trigger manual timelapse |
GET | /api/timelapses | List all timelapses |
GET | /api/timelapses/{id} | Get timelapse details |
GET | /api/health/cameras | Camera health status |
GET | /api/health/system | System health check |
GET | /api/settings | Get system settings |
PUT | /api/settings | Update system settings |
GET | /api/images/camera/{id}/available-dates | Get available image dates for a camera |
GET | /api/multiday | List multi-day timelapse configs |
POST | /api/multiday | Create multi-day config |
POST | /api/multiday/generate-historical | Generate one-off timelapse from date range |
POST | /api/multiday/{id}/start-collection | Start prospective collection |
GET | /api/multiday/{id}/progress | Get collection progress |
POST | /api/multiday/{id}/cancel-collection | Cancel prospective collection |
All endpoints except /api/auth/login and /api/health/system require JWT authentication:
# Login
TOKEN=$(curl -s -X POST http://localhost/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpassword"}' | jq -r '.access_token')
# Use token
curl -H "Authorization: Bearer $TOKEN" http://localhost/api/cameras
Output files are organized as:
/output/
├── images/
│ └── {camera_name}/
│ └── {YYYYMMDD}/
│ ├── {YYYYMMDDHHMMSS}_{camera_name}.jpeg
│ ├── {YYYYMMDDHHMMSS}_{camera_name}.jpeg
│ └── ...
└── videos/
└── {camera_name}/
├── daily/
│ ├── {YYYYMMDD}.mp4
│ └── ...
└── summary/
├── {YYYYMMDD}-{YYYYMMDD}_summary.mp4
└── ...
| Interval | Images/Day | Size/Day (per camera) |
|---|---|---|
| 30 sec | 2,880 | ~150 MB |
| 60 sec | 1,440 | ~75 MB |
| 120 sec | 720 | ~40 MB |
Multi-day timelapses create summary videos spanning multiple days—perfect for weekly progress videos or long-term project documentation.
The system supports two distinct modes for multi-day timelapses:
Historical mode generates timelapses from images that already exist. This is the traditional approach:
Use case: Weekly summary videos from the past 7 days of captures.
Limitation: If your image retention is 7 days, you can't build a 30-day timelapse because older images are deleted.
Prospective mode solves the retention limitation by protecting images as they're captured:
Use case: Long-term construction timelapses, seasonal changes, or any period longer than your retention window.
Benefit: Protected images survive automatic cleanup, so you can build 30, 60, or 90-day timelapses even with 7-day retention.
For ad-hoc timelapse generation, use the Custom Timelapse tab:
This creates a one-time timelapse without creating a recurring schedule.
images_per_hour images evenly distributed across each houris_protected = true to prevent cleanupvideos/{camera}/summary/In .env (defaults for new configs):
# Select 2 images per hour
MULTIDAY_IMAGES_PER_HOUR=2
# Include 7 days of footage
MULTIDAY_DAYS_TO_INCLUDE=7
# Generate every Sunday at 2 AM
MULTIDAY_GENERATION_DAY=sunday
MULTIDAY_GENERATION_TIME=02:00
Each camera can have multiple multi-day configurations via the web interface or API:
# Create a historical config (weekly summary)
curl -X POST http://localhost/api/multiday \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"camera_id": "uuid-here",
"name": "Weekly Summary",
"mode": "historical",
"is_enabled": true,
"days_to_include": 7,
"images_per_hour": 2,
"generation_day": "sunday",
"generation_time": "02:00",
"frame_rate": 30,
"crf": 20,
"pixel_format": "yuv444p"
}'
# Create a prospective config (30-day collection)
curl -X POST http://localhost/api/multiday \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"camera_id": "uuid-here",
"name": "Monthly Progress",
"mode": "prospective",
"is_enabled": true,
"days_to_include": 30,
"images_per_hour": 2,
"auto_generate": true,
"frame_rate": 30,
"crf": 20,
"pixel_format": "yuv444p"
}'
# Start prospective collection
curl -X POST http://localhost/api/multiday/{config_id}/start-collection \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"days": 30}'
# Check collection progress
curl http://localhost/api/multiday/{config_id}/progress \
-H "Authorization: Bearer $TOKEN"
# Generate custom timelapse from existing images
curl -
Content type
Image
Digest
sha256:54f4e6a1d…
Size
159.1 MB
Last updated
5 months ago
docker pull rjsears/unifi-timelapse-api