What Framevane is

One phone publishes a single RTMP or SRT stream to your own ingest edge. The compositor picks it up, burns overlays into the picture, encodes once, and tees the result out to every destination you have added — YouTube Live, Facebook Live, TikTok, or any RTMP URL. The archive is written on the same machine, alongside the stream rather than after it.

Overlays belong to the server, and that is the point. A lower third, a ticker or a caption is pushed mid-broadcast from a laptop or from the phone itself, by someone who is not holding the camera. Doing that on the device would put the graphics in the one place they cannot be changed from anywhere else.

There are two modes, and the difference is the main cost boundary in the whole system. passthrough remuxes with -c copy at near-zero CPU and any resolution. composite decodes, draws, and re-encodes at roughly 1.5–2.5 vCPU per 1080p30 stream. Overlays need composite mode — in passthrough there are no pixels to draw on, so the slots do nothing.

The shape of the system

Phone(Framevane)Ingest (MediaMTX)RTMP :1935 / SRT :8890Compositoroverlay → encode→ teeTikTokYouTube LiveFacebook Livearchive .mp4

Scroll the diagram sideways to follow the whole path.

That is five containers in one Docker Compose application. Each does one job, and only the dashboard is reachable from the internet over HTTP.

ingest — MediaMTX, listening on RTMP 1935/tcp and SRT 8890/udp
A deliberately dumb edge: it terminates the feed and authenticates it, and does nothing else to the video.
compositor — ffmpeg, reachable only from inside the stack
The overlay filtergraph, the encode, the tee out to every destination, and the archive file.
dashboard — HTTP on 3000, behind your proxy
The studio, the settings, the pairing code, and this page.
db and migrate — Postgres, internal only
The database, and the migration run that has to finish before the dashboard is allowed to start.

What you need

A server

Sizing follows the mode. Passthrough is near-free; one 1080p30 composite job costs roughly 1.5–2.5 vCPU. A Hetzner AX41 (Ryzen 5 3600, 6 cores) handles four to five concurrent composite streams. The compositor is capped at 3 CPUs and 2 GB in the compose file so a runaway session cannot starve the ingest edge or the dashboard.

A domain

One A record pointing at the server is enough on its own. If the dashboard sits behind Cloudflare’s proxy, ingest needs a second record. The orange-cloud proxy carries HTTP(S) ports only — not RTMP on 1935, not SRT on 8890 — so a phone dialling a proxied hostname reaches Cloudflare and finds nothing listening. Add a flat sibling record, DNS-only, and point INGEST_HOST at that. Flat rather than nested because Cloudflare’s universal certificate covers one level of subdomain.

Docker

The stack is one Compose application, defined at the repository root. Deploy it however you deploy Compose; docs/DEPLOY.md is written against Dokploy because that is what it was deployed on. Two variables have no defaults on purpose, and the deploy fails by name without them: INGEST_HOST (the hostname the phone dials, no scheme, no port) and VITE_BASE_URL (the public dashboard URL, with scheme). Generate the secrets fresh:

openssl rand -hex 16   # POSTGRES_PASSWORD
openssl rand -hex 32   # BETTER_AUTH_SECRET
openssl rand -hex 32   # COMPOSITOR_TOKEN
openssl rand -hex 24   # INGEST_READ_SECRET

Three more are optional and each enables a feature rather than tuning one: GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET for the YouTube connection and Google sign-in, FACEBOOK_APP_ID / FACEBOOK_APP_SECRET for a Facebook Page, and GROQ_API_KEY for live captions. Each announces itself once at startup rather than failing silently later.

Two ports open

RTMP and SRT bypass the HTTP proxy and bind host ports, so 1935/tcp and 8890/udp have to be open on whatever firewall is actually in front of the machine. On a cloud host that is the provider’s firewall: Docker publishes ports by writing into iptables ahead of ufw, so a ufw deny will not block a published container port.

First run

  1. Open the dashboard and sign up. Registration closes itself — the first account claims the instance and every later attempt is refused. Set ALLOW_SIGNUP=true and redeploy to add a second operator. There is no admin role yet: every account has the same rights.
  2. Create a stream. It owns the ingest key, the control token, the mode, the overlay timings and the resolution, and it survives every broadcast made on it.
  3. Add a destination before the first broadcast. A stream with none still starts and still records, but nothing reaches any platform.
  4. Start publishing from the phone. The broadcast opens by itself when the feed arrives; GO LIVE in the studio is for starting early, or for a stream with automatic start switched off.

Pairing the phone

The stream page shows a QR code. It is rendered on your own server, by a library in this repository — a pairing code carries a live credential, and handing that to a third-party QR service would be the one careless step in an otherwise self-hosted system. The code is a single link:

framevane://pair?server=<ingest url>&key=<ingest key>&name=<stream>
              &api=<dashboard url>&token=<control token>
              &w=&h=&fps=&bitrate=

server and key are where the video goes. api and token are what let the phone put a saved overlay up without a laptop — the ingest edge and the dashboard are routinely different hosts, so the phone has to be told both. Everything after name is optional, and a code without it still pairs.

The control token is a second credential on the stream, separate from the ingest key and separately revocable. That is deliberate: a lost phone is revoked by rotating the control token, which breaks nothing else, where rotating the ingest key would knock every encoder pointed at that stream off the air to solve a problem none of them caused. Treat the QR code as you would the stream key. A photograph of it is permission to broadcast on that stream and to write what appears on screen.

Under the QR there is an eight-character code, for the case the QR cannot serve: one person, one device, the studio open on the very handset that is meant to be filming. That code is not a credential. It is a ticket the app spends at POST /api/pair/claim for the link above, it works once, and it is gone ten minutes after it appears. Typing a server and a key by hand is still possible and is still a worse answer: there is nowhere in those two fields to put api or token, so a destination built that way has no overlays, no broadcast title and no announcement, for as long as it exists.

The phone app in this repository is Android today — proven end to end on 13 September 2026, camera to YouTube. The iOS side is a stub that implements the whole contract and fails every call with a reason. Larix Broadcaster, or any other RTMP/SRT encoder, works just as well: the studio page shows the server address and key to type in by hand.

Overlays and presets

Five slots, written by the studio, by the phone, or by the control API. They are ordinary broadcast furniture and behave the way their industry equivalents do:

SlotKnown asBehaviour
title + subtitleLower third — chyron, astonSlides on, holds, leaves by itself
badgeBug, or DOGStays until cleared
tickerCrawlScrolls right to left, continuously
captionOpen caption, or a superAppears and clears

A name card is an introduction, not a label, so a lower third holds for eight seconds by default and then leaves. A stream can set that to anything, or to zero for “stays until taken down”, and can ask for the card to come back on an interval — the usual way to re-introduce a speaker during a long session.

A preset is a saved set of slot values: a guest’s name card, a recurring ticker. Presets are created in the studio and applied from either end. The phone can apply one and clear everything, and deliberately cannot create or edit one — typing a name card on a handset while filming with it is the one thing nobody wants to do.

Captions are separate, and off by default. Set to transcribe or translate, a second process cuts the broadcast’s audio into four-second segments and sends each one to Groq’s Whisper endpoint; what comes back is written into the caption slot. It lands a few seconds behind the speech, because a batch model cannot produce a caption before the segment carrying it has finished, and it is burned into the picture — every platform gets it without supporting captions, but a viewer cannot switch it off.

Connecting platforms

Where the platform’s API allows it, Framevane holds the account rather than a pasted key, and opens and ends the live event itself. The three targets are not equal in this, and the difference is what each company permits rather than how much work it is.

PlatformFetch the key?Start the broadcast?What you do
YouTube LiveYesYesConnect the Google account once
Facebook LiveYesYesConnect the Page once
TikTok LIVENoNoPaste the key before every broadcast

TikTok is not an omission. Its developer platform has no LIVE product and no live.* scope of any kind, and its keys are session-scoped — a new one per broadcast, worthless the next time. The studio prompts for a re-key when the stored one is a few hours old, which is the only workflow the platform supports.

YouTube needs the full youtube scope, because reading a stream key is not enough to put a channel on air. Google classes that as sensitive: until the OAuth client is verified the project sits in testing mode, where authorisations expire after seven days and have to be reconnected. Verification wants a demo video, per-scope justification, and a privacy policy on the same domain — which is what /privacy is for. Facebook works today against a Page you hold a role on; serving other people needs App Review.

The deeper documents

Everything above is the short version. These live in the repository and are kept current with the code: