Big Buck Bunny 320px (container)
?imwidth=320 — 725MB → 38MB via FFmpeg container → R2 cacheHow video-resizer-2 handles media transformation on Cloudflare Workers.
Single endpoint for video resize, frame extraction, spritesheet generation, and audio extraction. Three-tier transform routing: Media binding for R2 sources, cdn-cgi/media for remote sources, FFmpeg container for oversized/advanced transforms. Results stored persistently in R2 with edge cache on top. Zero memory buffering — all streams flow through without loading into Worker memory.
Three transform tiers + four transformation modes:
| Tier | Source | Size Limit | Method | Latency | Worker Memory |
|---|---|---|---|---|---|
| 1 | R2 | ≤100 MiB (configurable) | env.MEDIA.input(stream) | ~2-10s | Stream only |
| 2 | Remote/Fallback | ≤100 MiB (configurable) | cdn-cgi/media URL fetch | ~3-15s | Zero |
| 3a | Any | 100-256 MiB or container-only params | FFmpeg Container DO (sync) | ~30-120s (sync) | Zero |
| 3b | Any | >256 MiB or container-only params | FFmpeg Container DO (via Queue) | ~60-300s (async) | Zero |
Transformation modes:
Container-only features (fps, speed, rotate, crop, bitrate, h265/vp9/av1 codecs, duration >60s) route to the FFmpeg container regardless of file size. Sync container transforms run inline; async transforms (>256 MiB) are dispatched via Cloudflare Queue for deploy safety and automatic retry.
| v1 Pattern | v2 Pattern | Why |
|---|---|---|
| Strategy per mode (5 files) | Single transform handler, mode via params | Modes are just param combinations, not separate code paths |
| Reactive error handling (9402 -> container) | Proactive size-based routing + reactive Cf-Resized parsing | HEAD check + size thresholds for proactive routing; Cf-Resized header parsing for reactive fallback on CF error codes (9402, 9404, etc.) |
| 5 singleton config managers | Single Zod 4 schema + KV hot-reload | One source of truth, validated on upload |
| Akamai translation in middleware | Pure function translateAkamaiParams() | Testable, no side effects |
| KV chunked storage (5 MiB chunks) | R2 persistent store + edge cache | No chunking needed, R2 handles any size |
__r2src self-referencing URL | env.MEDIA.input(stream) binding | Direct R2 stream, no HTTP subrequest |
| Layer | Scope | Speed | Range Support | Persistence |
|---|---|---|---|---|
Edge cache (caches.default) | Per data center | Workers run before cache; same local store as CDN | Native (206) | Ephemeral |
R2 persistent store (_transformed/) | Global | Fast | Via cache.match | Permanent until bust |
| KV version registry | Global | Fast | N/A | Manual bust |
On every successful transform, the output flows sequentially with zero memory buffering:
FixedLengthStreamcache.putcache.match -> serve to client (native range request support)Subsequent requests: edge cache HIT (fastest) or R2 HIT -> cache.put -> cache.match (cross-colo).
Every response includes:
| Header | Value | Notes |
|---|---|---|
X-R2-Cache | HIT or MISS | Whether result came from R2 persistent store |
cf-cache-status | HIT, MISS, etc. | CF edge cache status |
X-Transform-Source | binding, cdn-cgi, container | Which tier performed the transform |
X-Source-Type | r2, remote, fallback | Which source provided the original |
X-Cache-Key | video:path:w=1280:c=auto | Deterministic cache key |
X-Request-ID | UUID | Per-request trace ID |
X-Processing-Time-Ms | number | Transform duration |
X-Derivative | name | Resolved derivative |
X-Resolved-Width/Height | number | Final dimensions |
Via | video-resizer | Loop prevention |
Cache-Tag | comma-separated | Purge-by-tag support |
| Feature | Video | Frame | Spritesheet | Audio | Container |
|---|---|---|---|---|---|
| Dimensions | 10-2000px | 10-2000px | Required | - | 10-2000px |
| Fit modes | Yes | Yes | Yes | - | Yes |
| Time | 0-10m | 0-10m | 0-10m | 0-10m | Unlimited |
| Duration | 1s-60s | - | 1s-60s | 1s-60s | Unlimited |
| Format | - | jpg/png | JPEG only | m4a only | mp4/webm |
| Quality/compression | Yes | - | - | - | Yes (CRF) |
| FPS/Speed/Rotate/Crop | No | No | No | No | Yes |
| Bitrate control | No | No | No | No | Yes |
| Input size limit | 100 MiB (binding, configurable) | 100 MiB (cdn-cgi, configurable) | 100 MiB | 100 MiB | 20 GB disk |
Direct streaming from R2 into the Media Transformations binding. No HTTP subrequest, no video bytes in Worker memory.
R2 bucket.get(key) -> ReadableStream -> env.MEDIA.input(stream).transform(params).output()Fallback: if the binding rejects the input (MediaError), falls back to container.
Constructs a cdn-cgi/media URL with transform params and lets the edge handle both fetch and transform. Zero Worker memory usage.
/cdn-cgi/media/width=1280,height=720,fit=contain/{sourceUrl}?v={version}Fallback: if cdn-cgi returns 404/5xx, tries next source in priority order.
For transforms the binding/cdn-cgi can’t handle. Two sub-modes based on size:
Sync: -> dispatch to Container DO -> stream result -> R2 put -> cache.put -> serveAsync: Request 1: -> 202 Processing (jobId + SSE URL, Retry-After: 10) Queue: -> consumer dispatches to container DO Container: -> downloads source -> ffmpeg -> stores in R2 (via outbound handler) Request 2: -> R2 HIT -> cache.put -> cache.match -> 200 video/mp4Container-only params that trigger this tier: fps, speed, rotate, crop, bitrate, h265/vp9/av1 codecs, duration >60s.
FFmpegContainer extends Container Durable Object with outbound handlerffmpeg:{origin}:{path}:{paramsHash} (FNV-1a hash ensures unique DO per transform){ vcpu: 4, memory_mib: 12288, disk_mb: 20000 } (max available)sleepAfter: 15m, enableInternet: trueinflightJobs Map in server.mjs tracks running async transforms by sourceUrl|params key; duplicate /transform-url dispatches return 202 { dedup: true } instead of spawning another ffmpegContainers only intercept HTTP traffic (not HTTPS). FFmpegContainer.outbound intercepts all HTTP:
GET /internal/job-progress -> updates D1 status + percent progressPOST /internal/container-result -> stores transcoded output in R2, updates D1GET /internal/r2-source -> serves raw R2 objects via binding (for R2-only sources)GET (large files) -> proxy with source dedup (tee to R2 _source-cache/ + container)fetch() with http->https upgradeSource downloads use HTTPS directly (enableInternet=true, not intercepted).
Node.js 22 + ffmpeg in node:22-slim Docker image.
| Endpoint | Method | Description |
|---|---|---|
/transform | POST | Sync: stream source in, receive output |
/transform-async | POST | Async: stream source + callbackUrl, 202 |
/transform-url | POST | Async URL-based: container fetches source directly |
/health | GET | Health check |
os.availableParallelism() (up to 4 on max instance)-ss before -ipipeline() to disk (no OOM on 725MB+)createReadStream() + explicit Content-Length from stat()fps=N/duration,tile=COLSxROWS filter, imageCount defaults to 20, JPEG outputtime=HH:MM:SS parsing → /internal/job-progress → D1 percent update → SSENote: av1 codec triggers container routing but the container currently encodes as h264 (no libaom/libsvtav1 installed). h265 (libx265) and vp9 (libvpx-vp9) are fully supported.
| Preset | CRF | FFmpeg Preset |
|---|---|---|
| low | 28 | fast |
| medium | 23 | medium |
| high | 18 | medium |
Container transforms are dispatched via Cloudflare Queue for durability (messages survive deploys, automatic retry with dead letter queue).
TRANSFORM_QUEUE + registers in D1 transform_jobs tablejobId + SSE URL for real-time progress/transform-url/internal/r2-source), streams to disk/internal/job-progress -> D1 percent updatehttp:// (outbound handler intercepts)_transformed/{cacheKey}), updates D1cache.put -> cache.match -> serve'failed' when all 10 retries exhaustedPOST /admin/jobs/retry (resets D1, cleans R2, re-enqueues)Verified: 725MB .mov -> 31MB .mp4, served from edge cache in 0.1s with range requests.
The outbound handler caches remote source downloads in R2 (_source-cache/{path}). Uses body.tee() to stream to both the container and R2. Concurrent containers transforming the same 725MB source share one download instead of each downloading independently.
For spritesheets routed to the container (oversized sources), ffmpeg uses fps=1,tile=COLSxROWS filter. imageCount defaults to 20, grid layout via ceil(sqrt(N)) columns, output as JPEG.
| Param | Type | Range/Values | Example |
|---|---|---|---|
width | int | 10-2000 | ?width=1280 |
height | int | 10-2000 | ?height=720 |
fit | enum | contain, cover, scale-down | ?fit=cover |
mode | enum | video, frame, spritesheet, audio | ?mode=frame |
time | string | 0s-10m | ?time=5s |
duration | string | 1s-60s (binding), unlimited (container) | ?duration=10s |
audio | bool | true/false | ?audio=false |
format | enum | jpg, png (frame); m4a (audio) | ?format=png |
filename | string | alphanumeric, max 120 | ?filename=clip |
derivative | string | config key | ?derivative=tablet |
quality | enum | low, medium, high, auto | ?quality=high |
compression | enum | low, medium, high, auto | ?compression=low |
fps | float | >0 (container) | ?fps=24 |
speed | float | >0 (container) | ?speed=2 |
rotate | float | any (container) | ?rotate=90 |
crop | string | geometry (container) | ?crop=640:480:0:0 |
bitrate | string | (container) | ?bitrate=2M |
dpr | float | >0 | ?dpr=2 |
imageCount | int | >0 | ?imageCount=10 |
loop | bool | playback hint header | ?loop=true |
autoplay | bool | playback hint header | ?autoplay=true |
muted | bool | playback hint header | ?muted=true |
preload | enum | none, metadata, auto | ?preload=auto |
debug | any | view for JSON diagnostics | ?debug=view |
Full Akamai Image & Video Manager parameter translation. Explicit canonical params always win.
| Akamai Param | Canonical | Value Translation |
|---|---|---|
imwidth | width | Direct; triggers derivative matching |
imheight | height | Direct |
impolicy | derivative | Policy = derivative |
imformat | format | h264->mp4; h265/vp9/av1->container |
imdensity | dpr | Pixel density multiplier |
imref | consumed | Parsed for derivative matching context |
im-viewwidth | — | Sets Sec-CH-Viewport-Width hint |
im-viewheight | — | Sets Viewport-Height hint |
im-density | — | Sets Sec-CH-DPR hint |
w, h, q, f | width, height, quality, format | Shorthands |
obj-fit | fit | crop->cover, fill->contain |
start, dur | time, duration | Shorthands |
mute | audio | Inverted: mute=true -> audio=false |
Named presets that bundle dimensions + quality + mode into a single parameter:
{ "tablet": { "width": 1280, "height": 720, "fit": "contain", "duration": "5m" }, "mobile": { "width": 854, "height": 640, "fit": "contain", "duration": "5m" }, "thumbnail": { "width": 640, "height": 360, "mode": "frame", "format": "png", "time": "0s" }}Canonical invariant: derivative dimensions replace any explicit params. ?imwidth=1280 is used for derivative selection only, never for the actual transform or cache key.
When no explicit dimensions are provided, auto-sizing from client signals:
Sec-CH-Viewport-Width, Sec-CH-DPR, Width)CF-Device-Type header (mobile/tablet/desktop)Deterministic, built from resolved params (after derivative resolution):
{mode}:{path}[:w={width}][:h={height}][:mode-specific-params][:e={etag}][:v={version}]Same derivative always produces the same key regardless of trigger: ?derivative=tablet, ?impolicy=tablet, and ?imwidth=1280 (resolved via responsive to tablet) all produce identical keys.
POST /admin/cache/bustCache-Tag header with derivative, origin, mode tags for CF purge APITransform output | v (FixedLengthStream, streaming)R2 put (_transformed/{cacheKey}) | v (R2 get, streaming)cache.put (edge cache) | v (cache.match, native range support)Client (200 or 206 with Content-Range)No tee(), no arrayBuffer(), no memory buffering. Sequential streaming through R2 then cache.put. The final cache.match with the original client request (which may include a Range header) provides automatic 206 + Content-Range handling for video seeking — no manual byte math needed.
Origins configured as an array with regex matcher, capture groups, and prioritized sources:
{ "origins": [ { "name": "standard", "matcher": "^/([^.]+)\\.(mp4|webm|mov)", "sources": [ { "type": "remote", "priority": 0, "url": "https://videos.erfi.dev" }, { "type": "r2", "priority": 1, "bucketBinding": "VIDEOS" } ], "ttl": { "ok": 86400, "redirects": 300, "clientError": 60, "serverError": 10 } } ]}Sources tried in priority order. If one fails (404, 5xx, timeout), falls through to next. Last resort: raw passthrough from any source.
| Type | How |
|---|---|
aws-s3 | Presigned URLs via aws4fetch (cached in KV with auto-refresh) |
bearer | Authorization: Bearer {token} from env var |
header | Custom header name + value from env var |
| Strategy | When | What happens |
|---|---|---|
Cf-Resized error parsing | cdn-cgi returns 200 with err=XXXX in header | Parse CF error code, route to appropriate recovery |
| Reactive container fallback (9402) | cdn-cgi Cf-Resized: err=9402 (origin too large) | Route to FFmpeg container, return 202 |
| Source retry (9404/9407/9504) | cdn-cgi Cf-Resized: err=9404/9407/9504 | Try next source in priority order |
| Duration limit retry | Binding rejects duration | Extract max from error, retry with capped duration |
| Alternative source retry | Source 404/5xx HTTP status | Try next source in priority order |
| Binding → container fallback | Binding MediaError | Re-fetch from R2, route to container |
| Raw passthrough | All transforms fail | Serve untransformed source |
| 202 Processing | Container async (>256 MiB) or reactive 9402 | Return JSON with jobId, SSE URL, Retry-After: 10 |
Cf-Resized headercdn-cgi/media may return HTTP 200 with an error embedded in the Cf-Resized response header (format: err=XXXX). Without parsing this header, the Worker would treat the response as a successful transform.
Known CF error codes:
| Code | Meaning | v2 Action |
|---|---|---|
| 9401 | Invalid or missing transform options | Try next source |
| 9402 | Video too large or origin did not respond | Route to FFmpeg container (202) |
| 9404 | Video not found at origin | Try next source |
| 9406 | Non-HTTPS URL or URL has spaces/unescaped Unicode | Try next source |
| 9407 | DNS lookup error for origin hostname | Try next source |
| 9408 | Origin returned HTTP 4xx (access denied) | Try next source |
| 9412 | Origin returned non-video content (HTML/error page) | Try next source |
| 9419 | Non-HTTPS URL or URL has spaces/unescaped Unicode | Try next source |
| 9504 | Origin unreachable (timeout/refused) | Try next source |
| 9509 | Origin returned HTTP 5xx | Try next source |
| 9517 | Internal CF transform error | Try next source |
| 9523 | Internal CF transform error | Try next source |
format=m4a without explicit mode=audio automatically switches to audio mode, clearing irrelevant params (width, height, fit). This maintains compatibility with v1 clients that relied on format-based mode inference.
All errors return structured JSON:
{ "error": { "code": "NO_MATCHING_ORIGIN", "message": "No origin matched: /path" }}Five layers prevent duplicate work:
| Layer | Scope | What it deduplicates |
|---|---|---|
Edge cache (caches.default) | Per-colo | All requests — cf-cache-status: HIT |
| R2 persistent cache | Global | All transform results across colos |
RequestCoalescer (signal pattern) | Per-isolate | Concurrent requests in same isolate |
| Queue consumer R2 check | Global | Re-dispatch after container already completed |
Source cache (_source-cache/) | Global | Multiple containers downloading same source file |
Auth-gated dashboard at /admin/dashboard (Astro + React + Tailwind v4):
Auth: HMAC-SHA256 signed session cookie (HttpOnly, Secure, SameSite=Strict, 24h expiry). Login validates against CONFIG_API_TOKEN with timing-safe comparison.
All endpoints require Authorization: Bearer {CONFIG_API_TOKEN}.
| Endpoint | Method | Description |
|---|---|---|
/admin/config | GET | Retrieve current config |
/admin/config | POST | Upload new config (Zod 4 validated) |
/admin/cache/bust | POST | Bump cache version for a path |
/admin/analytics | GET | Request summary (?hours=24) |
/admin/analytics/errors | GET | Recent errors (?hours=24&limit=50) |
/admin/jobs | GET | List active/recent container jobs (?hours=24&filter=bunny&active=true) |
/admin/jobs/retry | POST | Retry/delete/clear stuck jobs ({jobId}, {staleMinutes}, {jobId, delete: true}) |
/sse/job/:id | GET | SSE stream for real-time job progress (D1 polling) |
/admin/dashboard | GET | Dashboard UI (session auth) |
Every request logged to D1 via waitUntil. Weekly cron drops + recreates for 7-day rolling window.
src/ index.ts # Hono app wiring (~95 lines) middleware/ # via, config, passthrough, auth, error handlers/ # admin, internal, transform, jobs (SSE), dashboard config/ # Zod 4 schema, KV loader params/ # Canonical params, Akamai translation, derivatives, responsive transform/ # Media binding, cdn-cgi, FFmpeg container DO, job types sources/ # Origin routing, auth, presigned URLs cache/ # Cache key, version (KV), coalescing (signal pattern) queue/ # Queue consumer + DLQ, D1 job registry analytics/ # D1 middleware, aggregation queries, schema.sql (SSOT)container/ Dockerfile # node:22-slim + ffmpeg server.mjs # /transform, /transform-url, /health (with progress reporting)dashboard/ src/components/ # Dashboard, AnalyticsTab, JobsTab, DebugTab, sharedscripts/ smoke.ts # 84 smoke tests with tail log capture| Binding | Type | Resource |
|---|---|---|
MEDIA | Media | Media Transformations binding |
VIDEOS | R2 | Source videos + transform cache (_transformed/) + source cache (_source-cache/) |
CONFIG | KV | Worker config (hot-reload, 5-min TTL) |
CACHE_VERSIONS | KV | Cache version management |
ANALYTICS | D1 | Request analytics + job registry (transform_log + transform_jobs) |
FFMPEG_CONTAINER | Container DO | FFmpeg container instances (4 vCPU, 12GB, 20GB disk) |
TRANSFORM_QUEUE | Queue | Durable job dispatch (retry + DLQ) |
ASSETS | Static Assets | Dashboard UI |
npm run test:run # 186 unit tests (vitest + workers pool)npm run test:e2e # 92 E2E tests against live (vitest, 60s timeout)npx tsx scripts/smoke.ts # 84 smoke tests against livenpx tsx scripts/smoke.ts --container # + container async polling (~10 min)npm run test:browser # 22 Playwright browser testsnpm run check # TypeScript strictnpm run deploy # deploy worker + container + dashboardnpm run dashboard:build # rebuild Astro dashboardnpm run dev # local dev (requires Docker for containers)Examples use two sources: rocky.mp4 (40MB, instant via binding/cdn-cgi) and Big Buck Bunny (725MB, container path).
Big Buck Bunny 320px (container)
?imwidth=320 — 725MB → 38MB via FFmpeg container → R2 cacheRocky 640x360 (cdn-cgi)
?width=640&height=360&fit=cover — via cdn-cgi/mediaBig Buck Bunny PNG at 30s
?mode=frame&time=30s&width=640&format=png — from R2 cacheRocky JPEG at 4s
?mode=frame&time=4s&width=640&format=jpg — via bindingBig Buck Bunny 800x600
?mode=spritesheet&width=800&height=600 — from R2 cacheRocky 640x480
?mode=spritesheet&width=640&height=480 — via cdn-cgi/media Rocky audio (30s)
?mode=audio&duration=30sRocky auto m4a switch
?format=m4a&duration=30s — auto-switches to audio modeRocky tablet (1280x720)
?derivative=tablet (or ?impolicy=tablet) — instant via cdn-cgiRocky thumbnail (frame)
?derivative=thumbnail — 640x360 PNG frame via binding# Resize (Akamai params)curl -I "https://videos.erfi.io/big_buck_bunny_1080p.mov?imwidth=320"curl -I "https://videos.erfi.io/big_buck_bunny_1080p.mov?w=640&h=360&obj-fit=crop"
# Frame + audiocurl -I "https://videos.erfi.io/big_buck_bunny_1080p.mov?mode=frame&time=30s&width=640&format=png"curl -I "https://videos.erfi.io/big_buck_bunny_1080p.mov?format=m4a&duration=30s"
# Clip with time offsetcurl -I "https://videos.erfi.io/big_buck_bunny_1080p.mov?w=320&start=2m&dur=10s&mute=true"
# Debug diagnosticscurl -s "https://videos.erfi.io/big_buck_bunny_1080p.mov?derivative=tablet&debug=view" | jq .diagnostics
# Cache + range headerscurl -I "https://videos.erfi.io/big_buck_bunny_1080p.mov?imwidth=320"# -> X-R2-Cache: HIT, cf-cache-status: HIT
curl -I -H "Range: bytes=0-1023" "https://videos.erfi.io/big_buck_bunny_1080p.mov?imwidth=320"# -> 206 Partial Content, Content-Range: bytes 0-1023/38924753
# Container async (uncached transform)curl -I "https://videos.erfi.io/big_buck_bunny_1080p.mov?imwidth=480"# -> 202 Processing (Retry-After: 10) or 200 video/mp4 (if cached)