How it works
streamlapse is a serverless pipeline built on GitHub and Cloudflare.
A Cloudflare Worker Cron Trigger runs at 00, 15, 30,
and 45 minutes past each hour from 04:00 through 17:45 UTC, Monday
through Saturday. It uses the schedule in
wrangler.jsonc and dispatches the GitHub Actions capture
workflow only during eligible Zagreb-local times.
ffmpeg then pulls one JPEG frame from the HLS stream
and uploads it to Cloudflare R2. Timelapse generation
is a separate on-demand workflow that downloads a selected date
range, creates an MP4, and uploads the result to R2.
Configurable schedule
Set days, hours, timezone, and the 15-minute cadence in the Worker configuration; no repository config download is needed.
Free storage
Cloudflare R2 includes 10 GB-month of storage and free Internet egress. Storage and operations above the allowance are billed.
No servers
Scheduling runs on Cloudflare and media processing runs on standard GitHub-hosted runners, which are free for public repos.
On-demand timelapse
Trigger MP4 generation from the GitHub Actions UI with a date range, fps, and filename.
| Component | Role |
|---|---|
| Cloudflare Worker | Evaluates the schedule every 15 minutes in the UTC trigger window and dispatches capture |
| GitHub Actions | Runs capture and on-demand timelapse processing |
| Cloudflare R2 | S3-compatible object storage with a free allowance and free 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
Standard GitHub-hosted runners are free for public repositories, subject to GitHub's current Actions usage policy.
-
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 cloudflare/scheduler/wrangler.jsonc. The Cron
expression supplies the Monday-through-Saturday, 15-minute cadence
and limits invocations to the UTC hours covering both CET and CEST.
The variables define the local timezone and active hours, and the
Worker performs the exact local-time check to stay correct across
Zagreb daylight-saving changes.
"vars": {
"SCHEDULE_TIME_ZONE": "Europe/Zagreb",
"SCHEDULE_START": "06:30",
"SCHEDULE_END": "18:00"
},
"triggers": {
"crons": ["*/15 4-17 * * MON-SAT"]
}
6. Deploy the Cloudflare scheduler
In cloudflare/scheduler/wrangler.jsonc, set
GITHUB_OWNER, GITHUB_REPO, and
GITHUB_REF to your fork. Create a fine-grained GitHub
token restricted to that repository with
Actions: Read and write, then store it as the
Worker's GITHUB_TOKEN secret and deploy:
cd cloudflare/scheduler
corepack enable
pnpm install
pnpm wrangler login
pnpm wrangler secret put GITHUB_TOKEN
pnpm deploy
For automatic future deployments, create a GitHub environment named
production, restrict it to main, and add the
CLOUDFLARE_API_TOKEN environment secret plus the
CLOUDFLARE_ACCOUNT_ID environment variable.
7. Push and verify
After pushing, go to
Actions β Capture Frame β Run workflow. Manual runs
always capture. The optional timezone input controls the R2 timestamp
and defaults to Europe/Zagreb.
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 |
Dates must use exact YYYY-MM-DD form and the start date
cannot be after the end date. FPS must be a positive integer. A
supplied output must be a simple .mp4 basename using
only letters, numbers, dots, dashes, or underscores.
Configuration reference
Capture and media settings live in config.yml at the
repository root. The complete automatic schedule and repository
targeting live in cloudflare/scheduler/wrangler.jsonc.
capture:
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_TIME_ZONE |
Europe/Zagreb |
IANA timezone in wrangler.jsonc |
triggers.crons |
MonβSat | UTC weekday, hour-window, and 15-minute cadence |
SCHEDULE_START/END |
06:30β18:00 | Inclusive local-time window in wrangler.jsonc |
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 --require-hashes -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
export CAPTURE_TIME_ZONE=Europe/Zagreb
# Test capture (requires ffmpeg on PATH)
python scripts/capture.py
# Generate timelapse for a date range
python scripts/generate.py --date-from 2026-04-01 --date-to 2026-04-07 --fps 12