Open source · Free to run

Automatic timelapse
from any HLS stream

Capture frames from any public HLS stream on a configurable schedule, store them in Cloudflare R2, and assemble them into an MP4 timelapse on demand β€” no servers, no cron jobs, no cloud bills.

How it flows
πŸ“‘
HLS streamAny public .m3u8 URL
βš™οΈ
GitHub Actions cronFires every 15 min β€” no server needed
πŸ“·
capture.pySchedule check β†’ ffmpeg β†’ JPEG upload
☁️
Cloudflare R2screenshots/YYYY-MM-DD/HH-MM-SS.jpg
🎬
generate.py (on demand)Frames β†’ ffmpeg β†’ MP4 β†’ R2

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.

  1. Click Fork on this GitHub repo
  2. In your fork go to Settings β†’ General and confirm visibility is Public
  3. Complete the steps below, then push to main

2. Create a Cloudflare R2 bucket

  1. Log in to dash.cloudflare.com β†’ R2 Object Storage β†’ Create bucket
  2. Choose a name (e.g. my-streamlapse) and pick your region β€” EU, US, or APAC
  3. Leave all other settings at defaults and click Create bucket
πŸ’‘ After creation you land on the bucket overview page. Note the Account ID in the top-right corner β€” you'll need it to build your R2_ENDPOINT URL.

3. Create an R2 API token

  1. Click Manage R2 API Tokens β†’ Create API Token
  2. Set Permissions: Object Read & Write and restrict to your specific bucket
  3. Click Create API Token β€” copy the Access Key ID and Secret Access Key now (shown once only)
  4. 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.

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.

config.yml β€” full reference
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

bash
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