ClipMux
Self-hosted video infrastructure: uploads, transcoding and signed playback that run on accounts you control.
Open-source video infrastructure for developers who want a small upload and playback API on storage and GPU accounts they control. I am the primary author.
Browser upload, transcoding, packaging, and signed playback have been run against real media. AI subtitles and the transcribe endpoint are not finished, and there is no hosted product.
- The API never carries video bytes. The browser uploads straight to object storage, and completion checks that the object arrived before any GPU work starts.
- A transcode is claimed in the database before it is dispatched, so a request that times out does not start a second job.

What it is
ClipMux is a video backend you host yourself. Point it at your own Cloudflare R2
buckets and GPU account and you get what a video product needs: multipart
uploads, transcoding to HLS and DASH, signed playback, webhooks, a dashboard.
It's Apache-2.0, VOD only, and there's no hosted version, so the bytes, the
transcode bill and the metadata stay on your accounts. It's for developers who
want Mux's API shape without a metered service in the middle, so the /v1
surface is small: upload token, part URLs, playback token.
The pieces
The API is the control plane, a Hono app that runs as a Cloudflare Worker or as a
Node process in Docker. It talks to Postgres, over Neon's HTTP driver on the
Workers path and pg on Node. Two R2 buckets hold the media, raw uploads in one
and transcoded HLS and DASH in the other. A GPU function on Modal does the
encoding, or a self-hosted agent on your own machine does it instead. The
delivery Worker sits in front of the transcoded bucket and verifies playback
tokens, rewrites manifests and meters playback.
Around the edges sit QStash for webhook dispatch, Upstash Redis for rate limiting, and Cloudflare's Analytics Engine for playback telemetry. The client side is two packages: an uploader that does windowed multipart, and a player that refreshes its own playback token.
Very little of that is mandatory. R2 and the delivery Worker are fixed, because storage and signed playback are the product. The API runtime, the Postgres transport, the transcoder, the dispatch transport and the rate-limit store are all choices, and every optional piece has a fallback: no QStash keeps dispatch in the database, no Upstash falls back to in-memory limits, no Modal means the self-hosted agent.
The path a video takes
The API is control plane only and never carries video bytes. Your server mints a short-lived upload token so the API key never reaches a browser, and the browser sends the file itself. When it finishes, the API checks the object really arrived and hands the job to a transcoder, which builds the ladder and packages HLS and DASH. A Cloudflare Worker at the edge checks the playback token and streams the segments.
Two R2 buckets sit either side of that pipeline, and the delivery Worker is bound
to the transcoded one. Whichever transcoder you pick, the self-hosted agent holds
no storage credentials, only presigned PUT URLs for the artifacts it registers.
Uploads bypass the API entirely
Proxying every byte through the server is the obvious design and the wrong one here. Video is large, uploads are slow, and a request-body limit is a ceiling autoscaling can't remove. So the API issues presigned part URLs, and the browser talks to R2 directly.
Presigned URLs expire, so the client fetches them a window at a time and re-signs
mid-upload instead of minting ten thousand up front. The raw bucket has to expose
ETag through CORS or the multipart complete call fails. And since the API never
sees the bytes, it can't trust the client's word that they arrived. Completion
HEAD-checks the size and answers 409 with OBJECT_MISSING or SIZE_MISMATCH
before spending any GPU time.
Playback is authorized at the edge
When a viewer asks for a video, the API signs an HS256 JWT with the video id, organization, a browser-family hash, an optional domain allowlist and an expiry. The delivery Worker verifies it and reads the playback policy from R2 custom metadata instead of Postgres. It rewrites the HLS manifest so every segment URI carries the same token, and refuses to attach it to an absolute foreign-host URL, which would leak the token to a third party.
That token is a bearer credential in a URL, so anyone with the manifest URL can play until it expires. The same token rides every segment, there's no per-request nonce, and the only binding is a coarse browser-family hash. Deleting a video doesn't revoke playback either, since the Worker never reads the database. The URLs keep working until the bytes are gone, which rules out calling this DRM.
Claim the job before you dispatch it
Starting a transcode is where a duplicate costs real money and a lost job means a
video nobody can watch. Upload completion, retries and the sweeper all go through
one function. It flips the row from uploading to processing with a
conditional update, records an attempt id and a twenty-minute lease, and only
then calls the transcoder.
A 4xx from the transcoder is definitive and fails the row. A timeout or 5xx
is not, since the request may still have been accepted. That case leaves the
claim intact and returns "uncertain" rather than retrying into a second GPU
container. The lease expires and the sweeper retries the same attempt id, which
the transcoder suppresses if it already started. A lease, a sweeper and two
ownership records is more machinery than the problem looks like it needs, and
it's what stops a retry from quietly starting a second GPU job.
Why the webhooks are pushed, not polled
The alternative to webhooks is a tenant polling GET /v1/videos/{id} until the
status changes, which burns their requests, delays their UI, and turns the API
into a status service. So the API pushes instead.
video.ready and video.failed are the two events a product actually waits on,
and both go through a transactional outbox. The event row is written in the same
transaction as the status change, so the process cannot die between the two and
lose the notification.
Delivery runs through QStash, which retries with backoff, keeps a dead-letter queue and signs every request. That removes an entire component from the installation, because there is no queue worker to run and no retry loop to write. QStash is optional: a self-hosted install keeps dispatch in the database and the API does not care which one it is talking to.
The remaining eleven webhook events dispatch directly, with no retry and no atomicity. That's a gap rather than a decision, and it's written down as one.
What it costs to run
The expensive parts of video are storage, the transcode and moving bytes around, and this design pushes all three onto services with free tiers.
R2 charges nothing for egress at any volume, which is the main reason a self-hosted video product is affordable at all. The first 10 GB of storage and the first million Class A and ten million Class B operations each month are free, and past that storage is $0.015 per GB-month.
Modal's Starter plan includes $30 of GPU compute every month. An L4 costs $0.000222 a second, about $0.80 an hour, so that credit is roughly 37 hours of encoding before you pay for any. QStash covers 1,000 messages a day, Upstash Redis gives you 256 MB and 500K commands a month, and Cloudflare Workers and Neon both have free plans.
A small installation therefore runs at $0 beyond accounts you already have. The bill starts when you outgrow those ceilings, and the things that eat GPU credit first are a 4K ladder and AI subtitles on every video. There's no hard spending cap in the code yet.
What works and what does not
The upload, transcode, package and playback path has been exercised against real media: FFmpeg with a 2160p-to-360p ladder capped at 1080p, Shaka Packager emitting HLS and DASH, and a self-hosted agent completing an end-to-end job.
AI subtitles and chapters only work on the Modal path, since the agent image
ships neither Whisper nor the chapters client. The transcribe endpoint is a
flag flip with a TODO where the job should be enqueued, so nothing acts on it.
Concurrency is bounded and spend is not.
What is next
Retention curves are stubbed behind an endpoint that returns comingSoon. Cost
control is next: encoding presets, and measured time and bytes per video, because
nothing tells you what a video cost to produce.
If this kind of full-stack work is relevant to a role, email me.