How it works
streamlapse is a zero-infrastructure pipeline built on two free
tiers.
GitHub Actions runs capture.py on a
5-minute cron. The script checks whether the current time falls
inside your configured work hours and capture interval β if not, it
exits with [SKIP]. If yes,
ffmpeg pulls a single JPEG frame from your HLS
stream and uploads it to Cloudflare R2. When you
want a timelapse, you manually trigger generate.py β it
downloads every frame for a date range, stitches them into an MP4,
and uploads the result back to R2.
Configurable schedule
Set days, hours, timezone, and capture interval β all in one YAML file.
Free storage
Cloudflare R2 gives 10 GB free with zero egress fees. No surprise bills.
No servers
Everything runs on GitHub Actions. Public repos get unlimited free minutes.
On-demand timelapse
Trigger MP4 generation from the GitHub Actions UI with a date range, fps, and filename.
| Component | Role |
|---|---|
| GitHub Actions | Free cron scheduler β triggers every 15 min |
| Cloudflare R2 | S3-compatible object storage β 10 GB free, zero egress |
scripts/capture.py |
Schedule check β grabs JPEG via ffmpeg β uploads to R2 |
scripts/generate.py |
Downloads frames from R2 β assembles MP4 β uploads back |
Frame path:
screenshots/YYYY-MM-DD/HH-MM-SS.jpg Β· Video
path: videos/timelapse_<from>_to_<to>.mp4
Setup
1. Fork the repo and make it public
GitHub Actions on public repos have unlimited free minutes.
-
Click Fork on this GitHub repo
-
In your fork go to Settings β General and confirm visibility is Public
-
Complete the steps below, then push to
main
2. Create a Cloudflare R2 bucket
-
Log in to dash.cloudflare.com β R2 Object Storage β Create bucket
-
Choose a name (e.g.
my-streamlapse) and pick your region β EU, US, or APAC -
Leave all other settings at defaults and click Create bucket
R2_ENDPOINT URL.
3. Create an R2 API token
-
Click Manage R2 API Tokens β Create API Token
-
Set Permissions: Object Read & Write and restrict to your specific bucket
-
Click Create API Token β copy the Access Key ID and Secret Access Key now (shown once only)
-
Copy the Jurisdiction-specific endpoint for S3 clients β this is your
R2_ENDPOINT
Endpoint by bucket region:
| Region | R2_ENDPOINT |
|---|---|
| US (default) |
https://<account_id>.r2.cloudflarestorage.com
|
| EU |
https://<account_id>.eu.r2.cloudflarestorage.com
|
| APAC |
https://<account_id>.apac.r2.cloudflarestorage.com
|
4. Add GitHub Secrets
In your fork: Settings β Secrets and variables β Actions β New repository secret
| Secret | Value |
|---|---|
STREAM_URL |
Full HLS stream URL, e.g.
https://example.com/stream.m3u8
|
R2_ACCESS_KEY_ID |
Access Key ID from your R2 API token |
R2_SECRET_ACCESS_KEY |
Secret Access Key from your R2 API token |
R2_BUCKET_NAME |
Your R2 bucket name, e.g. my-streamlapse |
R2_ENDPOINT |
Full S3 endpoint URL for your bucket's region |
5. Configure the schedule
Edit config.yml to match your timezone, active days,
hours, and capture frequency. The GitHub Actions cron always fires
every 15 minutes β all schedule logic runs
inside capture.py, so you only ever edit
config.yml.
schedule:
timezone: 'Europe/Zagreb' # any IANA timezone
work_days: [Mon, Tue, Wed, Thu, Fri, Sat]
work_hours:
start: '07:00'
end: '17:00'
capture:
interval_minutes: 15 # 5, 10, 15, 30, or 60
6. Push and verify
After pushing go to
Actions β Capture Frame β Run workflow. Outside
configured work hours the run exits with [SKIP] β that
is expected. Set the force dispatch input to
true to bypass all checks for a quick test.
Generating a timelapse
Go to Actions β Generate Timelapse β Run workflow,
fill in the optional inputs, and click
Run workflow. When the run finishes the MP4 is
available as a downloadable artifact in the run
summary and is also uploaded to R2 under videos/.
| Input | Default | Description |
|---|---|---|
date_from |
first day of current month | Start date YYYY-MM-DD |
date_to |
today | End date YYYY-MM-DD |
fps |
24 |
Output video frame rate |
output |
auto-generated | Output filename, e.g. april.mp4 |
Configuration reference
All tuneable settings live in config.yml at the root of
the repo.
schedule:
timezone: 'Europe/Zagreb' # IANA timezone for work_days/work_hours checks
work_days: [Mon, Tue, Wed, Thu, Fri, Sat]
work_hours:
start: '07:00'
end: '17:00'
capture:
interval_minutes: 15 # capture frequency (5, 10, 15, 30, or 60)
# cron fires every 15 min; this check runs in capture.py
jpeg_quality: 3 # ffmpeg -q:v: 1 (best) β 31 (worst); 2β4 is a good range
ffmpeg_timeout: 30 # seconds before giving up on the stream
storage:
r2_prefix: 'screenshots' # R2 key prefix for captured frames
videos_prefix: 'videos' # R2 key prefix for generated timelapse videos
generate:
default_fps: 24 # output video frame rate
video_scale: '1920:-2' # width; -2 auto-scales height (must be divisible by 2)
| Key | Default | Description |
|---|---|---|
schedule.timezone |
Europe/Zagreb |
Any valid IANA timezone string |
schedule.work_days |
MonβSat | Days on which captures are allowed |
schedule.work_hours |
07:00β17:00 | Time window in the configured timezone |
capture.interval_minutes |
5 |
How often a frame is captured β multiple of 5, up to 60 |
capture.jpeg_quality |
3 |
ffmpeg -q:v β lower is better quality and
larger file size
|
capture.ffmpeg_timeout |
30 |
Seconds before ffmpeg gives up on the stream |
generate.default_fps |
24 |
Frames per second for the assembled MP4 |
generate.video_scale |
1920:-2 |
Output width; -2 auto-scales height preserving
aspect ratio
|
Local development
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Set required environment variables
export STREAM_URL=https://example.com/stream.m3u8
export R2_ACCESS_KEY_ID=your_access_key_id
export R2_SECRET_ACCESS_KEY=your_secret_access_key
export R2_BUCKET_NAME=my-streamlapse
export R2_ENDPOINT=https://<account_id>.eu.r2.cloudflarestorage.com
# Test capture (--force bypasses work-hours and interval checks)
python scripts/capture.py --force
# Generate timelapse for a date range
python scripts/generate.py --date-from 2026-04-01 --date-to 2026-04-07 --fps 12