streamlapse automatically captures a JPEG frame from any public HLS stream every 15 minutes (during configurable work hours), stores the frames in Cloudflare R2, and lets you assemble a date range into an MP4 timelapse on demand — with no servers to manage and free tiers that cover modest usage.
Scheduling runs on a Cloudflare Worker Cron Trigger, capture and video jobs run on GitHub Actions, and media is stored in Cloudflare R2. Public repositories can use standard GitHub-hosted runners without minute charges, and R2 includes a free allowance plus free Internet egress; usage above the applicable allowances can still be billed.
Cloudflare Worker ── every 15 min, Mon–Sat ──► checks Zagreb 06:30–18:00
│
▼ eligible times only (workflow_dispatch)
GitHub Actions ──► capture.py ──► ffmpeg grabs 1 JPEG frame from the HLS stream
│
▼
Cloudflare R2 screenshots/YYYY-MM-DD/HH-MM-SS.jpg
│
▼ on demand (workflow_dispatch)
generate.py ──► downloads frames ──► ffmpeg assembles MP4
│
▼
Cloudflare R2 videos/timelapse_<from>_to_<to>.mp4
+ GitHub Actions artifact (direct download)
| Component | Role |
|---|---|
| Cloudflare Worker Cron Trigger | Applies the Wrangler schedule and dispatches eligible captures |
| GitHub Actions | Runs capture and on-demand timelapse jobs |
| Cloudflare R2 | S3-compatible object storage with a free allowance and free egress |
scripts/capture.py |
Grabs one JPEG with ffmpeg and uploads it to R2 |
scripts/generate.py |
Downloads frames from R2, assembles an MP4, uploads it back to R2 |
cloudflare/scheduler/wrangler.jsonc is
the single source of truth for automatic scheduling. Its Cron Trigger runs every
15 minutes from 04:00 through 17:45 UTC, Monday through Saturday, and its
variables define the Europe/Zagreb 06:30–18:00 window. The Worker does not
download repository configuration. config.yml contains only
capture, storage, and video settings.
Standard GitHub-hosted runners are free for public repositories, subject to GitHub's current Actions usage policy. The simplest path:
- Click Fork on this repo
- In the fork, go to Settings → General and make sure visibility is Public
- Make your edits (configure Wrangler and
config.yml, add secrets) and push tomain
- Log in to Cloudflare dashboard → R2 Object Storage → Create bucket
- Choose a name (e.g.
my-streamlapse) and pick your preferred region (EU, US, APAC) - Leave all other settings at their defaults and click Create bucket
After creation you'll land on the bucket overview page. Note the Account ID displayed in the top-right corner — you'll need it to build the endpoint URL in step 3.
- From the R2 overview page, click Manage R2 API Tokens → Create API Token
- Give it a name (e.g.
streamlapse) and set:- Permissions: Object Read & Write
- Bucket: restrict to the bucket you just created
- TTL: no expiry (or set one if you prefer rotating credentials)
- Click Create API Token — you'll see the credentials once only, so copy them now:
- Access Key ID (starts with a long alphanumeric string)
- Secret Access Key
- On the same page, copy the Jurisdiction-specific endpoint for S3 clients — this is your
R2_ENDPOINTvalue (looks likehttps://<account_id>.eu.r2.cloudflarestorage.com)
| Bucket region | R2_ENDPOINT value |
|---|---|
| US (default) | https://<account_id>.r2.cloudflarestorage.com |
| EU | https://<account_id>.eu.r2.cloudflarestorage.com |
| APAC | https://<account_id>.apac.r2.cloudflarestorage.com |
Replace <account_id> with the 32-character Cloudflare Account ID from the R2 overview page.
In your forked repo: 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 (see table above) |
Edit cloudflare/scheduler/wrangler.jsonc.
The Cron expression restricts invocations to Monday through Saturday and every
15 minutes; the variables define the local-time window:
Cloudflare Cron Triggers use UTC. The Cron expression limits invocations to the UTC hours that cover the Zagreb window in both CET and CEST, while the Worker performs the exact local-time boundary check before dispatching GitHub. This preserves 06:30–18:00 through daylight-saving changes, avoids overnight Worker invocations, and does not fetch configuration from GitHub.
First update the non-secret repository target in
cloudflare/scheduler/wrangler.jsonc. Forks must replace the defaults with their
own owner and repository:
"vars": {
"GITHUB_OWNER": "your-github-user",
"GITHUB_REPO": "streamlapse",
"GITHUB_REF": "main",
"SCHEDULE_TIME_ZONE": "Europe/Zagreb",
"SCHEDULE_START": "06:30",
"SCHEDULE_END": "18:00"
}The Worker also needs a fine-grained GitHub personal access token restricted to that repository with Actions: Read and write permission. Store this runtime token once in Cloudflare, then perform the first deployment:
cd cloudflare/scheduler
corepack enable
pnpm install
pnpm wrangler login
pnpm wrangler secret put GITHUB_TOKEN
pnpm deployFuture Worker changes are validated in pull requests and deployed automatically
after they reach main by the Deploy Cloudflare Scheduler workflow. Manual
deployments are also restricted to main. Create a GitHub environment named
production under Settings → Environments, restrict its deployment branch
to main (and add a required reviewer if desired), then add:
| Type | Name | Value |
|---|---|---|
| Environment secret | CLOUDFLARE_API_TOKEN |
Cloudflare API token created from the Edit Cloudflare Workers template |
| Environment variable | CLOUDFLARE_ACCOUNT_ID |
The Cloudflare account ID that owns the Worker |
Restrict the Cloudflare token to the account that owns this Worker. It is a CI
deployment credential and is separate from the Worker's GITHUB_TOKEN runtime
secret. No R2 permission is needed to deploy this scheduler. Existing Worker
secrets that are not supplied by the deployment are preserved.
Cloudflare invokes the Worker at 00, 15, 30, and 45 past each hour from
04:00 through 17:45 UTC, Monday through Saturday. The Worker checks the Wrangler
schedule variables and calls the Capture Frame workflow only inside the
configured local window. It is not invoked on Sunday. The GitHub workflow
intentionally has no schedule trigger; workflow_dispatch is its only entry
point.
The separate Keepalive workflow remains scheduled twice monthly as a repository activity safeguard. It does not trigger captures or affect the Cloudflare scheduler.
After pushing your changes, go to Actions → Capture Frame → Run workflow and
click Run workflow. Manual runs always capture; the optional timezone input
controls the R2 timestamp and defaults to Europe/Zagreb. The automatic Worker
passes its configured timezone explicitly.
- Go to Actions → Generate Timelapse → Run workflow
- Fill in the optional inputs:
| 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 from date range | Output filename (e.g. april.mp4) |
Dates must use exact YYYY-MM-DD form and the start cannot be after the end.
FPS must be a positive integer. A supplied output must be a simple .mp4
basename containing only letters, numbers, dots, dashes, or underscores.
- When the run finishes, the MP4 is available as a downloadable artifact in the run summary, and also uploaded to R2 at
videos/
curl -X POST \
-H "Authorization: Bearer <YOUR_GITHUB_PAT>" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/<owner>/<repo>/actions/workflows/generate.yml/dispatches \
-d '{
"ref": "main",
"inputs": {
"date_from": "2026-04-01",
"date_to": "2026-04-30",
"fps": "24",
"output": "timelapse_april_2026.mp4"
}
}'Generate a PAT at GitHub → Settings → Developer settings → Personal access tokens (fine-grained) with Actions: write permission on this repo.
Capture and media settings live in config.yml. The automatic
schedule and GitHub repository target 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 to wait for the stream before giving up
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' # output width; height is auto-scaled to keep aspect ratiopython -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
# Validate the Cloudflare scheduler
cd cloudflare/scheduler
pnpm install
pnpm typecheck
pnpm testrequirements.in lists direct Python dependencies; requirements.txt is the
hash-locked transitive install file generated from it. Dependency updates are
reviewed and applied manually; this repository does not run an automated
dependency-update bot.
Both media workflows call ffmpeg directly from PATH. GitHub Actions caches a
SHA-256-verified, immutable Linux build installed by
.github/scripts/setup-ffmpeg-linux.sh; this keeps CI reproducible and avoids a
mutable latest download. For local runs, install FFmpeg through your normal
package manager and ensure ffmpeg -version works.
This repo includes a documentation site in docs/. To enable it:
- Go to your repo Settings → Pages
- Under Build and deployment, set Source to Deploy from a branch
- Select branch
mainand folder/docs - Click Save — your docs will be live at
https://<your-username>.github.io/streamlapse