Social connections, publishing & live status
Krunc connects creator accounts through OAuth, stores tokens encrypted at rest (AES-256-GCM, ENCRYPTION_KEY), and only requests the scopes each feature uses. A provider with blank credentials in .env is simply unavailable in the UI — nothing crashes.
How it fits together
| Piece | Where |
|---|---|
Provider implementations (SocialProvider interface) | app/Providers/Social/*Provider.php |
| Registry + feature flags | app/Providers/Social/SocialRegistry.php |
| Connect / callback / disconnect UI + stream destinations | app/Modules/Social → /dashboard/social (Channels) |
| Composer, posts, calendar, publish worker | app/Modules/Publisher → /dashboard/publisher, /dashboard/posts, /dashboard/calendar |
| Live directory + polling | app/Modules/Live → /live, /live/now.json, /dashboard/live |
Redirect URI for every provider: https://krunc.com/social/callback/{provider} (provider keys: twitch, youtube, discord, kick, x, facebook, instagram, tiktok, reddit, linkedin).
Background work runs from php bin/worker (cron every minute with --once, or as a daemon):
publisher.due(60 s) — enqueues scheduled posts whose time has come (the publish job is also queued at schedule time; the scheduler is a safety net).social.refresh_tokens(30 min) — refreshes tokens that expire within 30 minutes; accounts whose refresh fails are markedexpiredand must be reconnected.live.poll(2 min) — checks Twitch / YouTube / KICK accounts, recordslive_streams, flips the creator profile live badge and firesstream.started/stream.endedautomations.live.eventsub_sync(10 min) — keeps Twitch EventSub subscriptions in step with connected accounts when thesocial.twitch_eventsubflag is on (see Twitch EventSub).
Feature flags
Insert a row in feature_flags to switch a provider off without removing credentials (e.g. while waiting for app review):
INSERT INTO feature_flags (`key`, enabled, description, updated_at) VALUES ('social.instagram', 0, 'Waiting for Meta app review', UTC_TIMESTAMP());
-- later
UPDATE feature_flags SET enabled = 1 WHERE `key` = 'social.instagram';
A missing row means "enabled if credentials exist". The exception is social.twitch_eventsub, which is off until a row enables it.
Per-platform status
| Provider | Env vars | Scopes requested | Connect | Live status | Publish | Approval / notes |
|---|---|---|---|---|---|---|
| Twitch | TWITCH_CLIENT_ID/SECRET | user:read:email, channel:read:stream_key, channel:manage:broadcast | works | works (Helix streams) | n/a (no post API) | None. channel:read:stream_key is only for fetching the stream key — accounts connected before it was added must reconnect. Follower counts need moderator:read:followers (not requested). |
| YouTube | GOOGLE_CLIENT_ID/SECRET | youtube.readonly, youtube.upload, youtube.force-ssl | works | liveBroadcasts?broadcastStatus=active (verify on a real channel) | video only, resumable videos.insert (verify) | Google OAuth consent-screen verification required for youtube.upload outside test users. 10k quota units/day. |
| Discord | DISCORD_CLIENT_ID/SECRET | identify, guilds | works; also sets users.discord_id for role delivery | n/a | n/a | None. |
| KICK | KICK_CLIENT_ID/SECRET | user:read, channel:read, streamkey:read, channel:write (PKCE) | works | public/v1/channels (stream.is_live, viewer_count, start_time, thumbnail, stream_title, category.name — verified against docs.kick.com 2026-09-24) | n/a | App registered 2026-09-24; credentials in .env. Kick's public API has no follower count, so KICK shows 0 followers on audience cards. |
| X | X_CLIENT_ID/SECRET | tweet.read, tweet.write, users.read, offline.access (PKCE) | works | n/a | text only, POST /2/tweets | Paid/limited tier: Free tier ~1,500 posts/month/app. Media upload not implemented. |
| Facebook Pages | META_APP_ID/SECRET | pages_show_list, pages_manage_posts, pages_read_engagement | works (first managed Page) | n/a | text + single image (/feed, /photos) | Meta App Review + Business verification before non-admin users can connect. Tokens are long-lived (~60 d), no refresh → reconnect. |
META_APP_ID/SECRET | instagram_basic, instagram_content_publish, pages_show_list | works (IG professional account linked to a Page) | n/a | image only (container → media_publish) | App Review for instagram_content_publish; Business/Creator account required; image must be a public URL. | |
| TikTok | TIKTOK_CLIENT_KEY/SECRET | user.info.basic, video.publish | works | n/a | video only via Content Posting API PULL_FROM_URL (verify) | App audit required; unaudited apps post as SELF_ONLY; video URL domain must be verified in the TikTok developer portal. |
REDDIT_CLIENT_ID/SECRET | identity, submit | works | n/a | self/link post: line 1 r/subreddit, line 2 title, rest body; a body that is just a URL (or attached media) is a link post, resubmit=true | Register a "web app"; API access is rate-limited (100 req/min). Live streams reach Reddit through the post_reddit_live automation (automation.md): a link post Reddit renders as a card or, for Twitch/YouTube URLs, an inline player. | |
LINKEDIN_CLIENT_ID/SECRET | openid, profile, w_member_social | works | n/a | text only, POST /rest/posts (version 202405) | "Share on LinkedIn" product must be added to the app; refresh tokens only for approved partners (60-day expiry otherwise). Organization posting needs Marketing Developer Platform. |
"verify" = implemented from the official documentation but not yet exercised against a live account from this host. The provider surfaces the platform's error message verbatim if a call fails — nothing is faked as success.
Setting up each OAuth app
- Create the app on the platform's developer portal and add the redirect URI above.
/admin/integrationsshows the exact URI to paste, links to each platform's console and lists the scopes we request. - Paste the client id/secret into
/admin/integrations(platform admin). They are stored inplatform_settings, encrypted withENCRYPTION_KEY, and override.env— nobody needs shell access to switch a platform on. The.envvariables still work as a fallback and as the way to seed a fresh install; clearing the stored values falls back to them.SocialRegistry::credentials()resolves the two. - The provider card on
/dashboard/socialturns from unavailable to Connect immediately. - Connect from
/dashboard/socialas a workspace staff member or higher. The account is attached to the workspace's first creator profile. - For platforms that need review, keep the feature flag off until approval; you can still connect as an app tester.
What an unavailable platform says, and to whom
SocialRegistry::status() returns a code (ok | no_credentials | flagged | unknown) rather than a sentence, so each audience is sent somewhere it can act. Creator-facing pages never name a server file:
| Who | No credentials | Feature flag off |
|---|---|---|
| Workspace member | "X is not switched on for Krunc yet" → prefilled /contact?topic=support | same |
| Platform admin | → /admin/integrations#<provider> | → /admin/flags |
Channels: accounts and stream destinations on one page
/dashboard/social is one card per platform holding both halves of "this is my YouTube channel":
- Account (staff and above) — the OAuth connection. This is what drives the live badge, viewer counts for sponsors, publishing and Discord role delivery.
- Streaming (admin and above) — the RTMP/RTMPS server URL + stream key for the same platform.
The two are independent: OAuth does not hand out stream keys (with one exception, below) and a stream key gives Krunc no ability to post or read stats. Platforms that only do one of the two still get a card — Discord, Reddit and LinkedIn are account-only; Trovo, GameChanger and Custom RTMP are stream-only.
/dashboard/social/destinations now 301s to this page; the mutation endpoints under it are unchanged and stay admin-only.
Stream destinations
Stored so they can be copied into OBS / Streamlabs (Settings → Stream → Service Custom…) or used by Krunc ingest to relay one stream to every enabled destination. On their own they are a secure address book, not a relay: without ingest, video goes from the encoder straight to each platform, and multistreaming needs a multi-output plugin or a relay service.
- Stream keys are encrypted at rest (AES-256-GCM,
ENCRYPTION_KEY); lists show only the last four characters. Reveal and Copy key fetch the key over an audited JSON POST (stream_destination.revealedin the audit log,last_revealed_aton the row) — the raw key is never rendered into page HTML. - Deleting a destination is a hard delete so the encrypted key does not linger. Rotate a leaked key on the platform first, then update it here.
- Server URLs must be
rtmp://orrtmps://. Presets live inApp\Modules\Social\Services\StreamPresetsand come from each platform's own streaming-software instructions:
| Platform | Server URL preset | Backup | Where the key lives |
|---|---|---|---|
| Twitch | rtmp://live.twitch.tv/app (auto-routes to nearest ingest; regional list at help.twitch.tv/s/twitch-ingest-recommendation) | rtmps://live.twitch.tv:443/app | Creator Dashboard → Settings → Stream |
| YouTube | rtmp://a.rtmp.youtube.com/live2 (RTMPS: rtmps://a.rtmps.youtube.com:443/live2) | rtmp://b.rtmp.youtube.com/live2?backup=1 | YouTube Studio → Go live → Stream |
| KICK | rtmps://fa723fc1b171.global-contribute.live-video.net/app — confirm against the URL shown next to your key | — | Creator Dashboard → Settings → Stream key |
rtmps://live-api-s.facebook.com:443/rtmp/ | — | Live Producer → Streaming software (enable persistent key) | |
rtmps://live-upload.instagram.com:443/rtmp/ | — | Instagram desktop → Live → Streaming software (per-broadcast key) | |
| X | blank — per-source URL from Media Studio → Producer | — | Media Studio → Producer → Sources |
| TikTok | blank — shown under Go LIVE → Streaming software once the account has LIVE Studio access | — | same screen |
| Trovo | rtmp://livepush.trovo.live/live/ | — | Stream Settings |
| GameChanger | blank — a new server URL per game, from the game on the team's schedule | — | GameChanger app → team → Schedule → the game → Stream |
| Custom RTMP | blank | — | your server |
Plan limit: limits.stream_destinations on the tenant (default 12).
Testing a stream key before you stream
Test key on a destination (or Test all keys in the page head) publishes two seconds of black video and silence to it and keeps the platform's answer: last_test_status (pending | ok | failed), last_test_note and last_test_at on stream_destinations. The card shows the verdict, and the go-live checklist counts verified keys rather than saved ones.
StreamTester::verdict() reads ffmpeg's exit code and output, measured against a throwaway local RTMP sink and a real platform ingest:
| ffmpeg says | Verdict |
|---|---|
| exit 0, no output | the platform accepted the key |
Error opening output <url>: Input/output error (exit 251) | connected, then the platform rejected the key — the usual symptom of a rotated or revoked key |
… : Connection refused (exit 145) | nothing is listening at that server URL |
… : Connection timed out (exit 146) | the server URL does not respond |
The probe is one output with no onfail, so a rejection is an exit code rather than a line to hunt for — the opposite of the relay, which deliberately ignores a failing destination to keep the rest of the broadcast alive.
Why it is queued. proc_open and popen are disabled in this host's PHP-FPM pool, so nothing can spawn ffmpeg from a web request. POST …/test only marks the destinations pending and pushes ingest.test_destinations with a run_at a few seconds out, which also stops the sync queue driver running it inline; php bin/worker (cron, every minute) does the probing. The page polls …/destinations/tests.json while anything is pending: a result appears within ~3 s when the worker runs as a service (runbook → worker & scheduler), or within a minute on the cron fallback. The stored note never contains the URL, because the URL contains the key.
Fetching a stream key from the connected account
Providers that implement App\Providers\Social\IngestCapable can fill in their own destination from the OAuth connection, so the creator copies nothing. POST /dashboard/social/destinations/import/{provider} (admin, throttled) resolves the workspace's active account for that provider, asks the provider for its endpoint, and creates or updates the destination for that platform. The key is encrypted like any other and the action is audited as stream_destination.imported.
| Platform | Fetch | Scope | How |
|---|---|---|---|
| YouTube | yes | — (youtube.readonly, already requested) | liveStreams.list(part=cdn,snippet&mine=true) returns cdn.ingestionInfo.streamName + ingestionAddress. Prefers the channel's default (persistent) RTMP stream. |
| Twitch | yes | channel:read:stream_key | GET helix/streams/key?broadcaster_id=… returns the primary stream key. Twitch publishes one ingest host for everyone, so the server URL comes from the preset, not the API. |
| KICK | yes | streamkey:read | GET public/v1/channels returns stream.url + stream.key once the scope is granted. A bare contribute host is completed to rtmps://<host>:443/app. |
| not built | — | POST /{page-id}/live_videos does return a stream URL, but it needs the publish_video permission and another round of Meta App Review. | |
| Instagram, TikTok, X, Trovo, GameChanger | no | — | No API exposes the stream key; it is always pasted from the platform's own dashboard. |
A provider declares the scope it needs through IngestCapable::ingestScope(). An account connected before that scope existed is asked to reconnect on its card (StreamDestinationController::needsReconnect() compares it against the scopes the platform actually granted) rather than being handed the platform's 401. When the platform reports no scopes at all, the call is allowed to speak for itself.
Nothing is written when the platform has no RTMP stream configured yet, when the URL it returns is not rtmp(s)://, or when the call fails — the platform's own message is surfaced. An ingest URL of '' from a provider means "the platform has one published host": the caller completes it from StreamPresets. Implemented from each platform's API docs but not yet exercised against a live channel from this host.
The go-live checklist
App\Modules\Social\Services\GoLiveChecklist is the single source of truth for "is this workspace ready to
stream", computed from the workspace rather than from anything a user ticks off. Two screens read it, which is
why it is a service: /dashboard/setup renders every step, and the dashboard's Launch checklist shows one
row with the score and the next thing to do. They cannot drift.
Steps are either required (connected platform, destinations, stream title, ingest key, first relayed session) or
optional (OBS configured by Krunc, sponsorships, server-side overlay). Only the required ones count towards
ready, so a workspace that wants no sponsors and streams from a phone is not told forever that it is unfinished.
The dashboard also orders its checklist by creator_profiles.creator_type, the answer given at onboarding: a
streamer sees going live second (right after their profile) and is never asked for a game server, while a FiveM
server is asked for its server first. See DashboardController::checklist().
One stream title everywhere
/dashboard/social/golive, /dashboard/social/ingest and /dashboard/social/obs all carry the same card
(social::_streaminfo): a title, a game/category, and a switch for pushing them when you go live. One row per
workspace in stream_info, with the last push's per-platform outcome in last_results. The card's "AI suggest"
button (POST /dashboard/social/stream-info/suggest, StreamInfoService::suggest, prompt stream.title.v1)
drafts three title + category pairs from the profile, the last eight streams and recordings and an optional hint,
charged as stream.title; a click fills the form and nothing is saved until the streamer does.
A provider opts in by implementing App\Providers\Social\BroadcastMetadata. What each platform actually takes,
from its own API reference:
| Platform | Call | Scope | Category |
|---|---|---|---|
| Twitch | PATCH helix/channels (title, game_id) | channel:manage:broadcast | yes — the typed name is resolved through GET helix/games?name=; a name Twitch does not know is reported and the title still goes out |
| KICK | PATCH public/v1/channels (stream_title, category_id) | channel:write | yes — resolved through GET public/v2/categories?name= (the v1 ?q= search is deprecated) |
| YouTube | liveBroadcasts.update (snippet.title) | youtube.force-ssl | no — snippet.categoryId is a video category (Gaming, Music…), not the game, so we do not map a game onto it |
Notes that cost an outage if forgotten:
- YouTube replaces the whole
snippetpart, so the existingscheduledStartTimeis read and sent back with
the new title; leaving it out would move the broadcast. A channel with nothing live or scheduled has no title to
set, and says so rather than inventing a broadcast.
- The scopes above are all new, so accounts connected earlier are shown as reconnect needed on the card and
are never called with a token that lacks the scope (SocialService::hasScope).
- Every push is recorded per platform. A refusal, a missing scope and a thrown error each appear against that
platform's name — nothing is rolled into a single "done".
When it is pushed. Pressing Save & push now does it in the request. Going live does it too, if the switch is
on: the browser path pushes inline in POST /dashboard/social/golive/start (and the page reports any platform
that refused), while the RTMP path queues social.apply_stream_info from IngestService::startSession() — that
code runs inside MediaMTX's runOnOnline hook, where three platform API calls would hold the relay up.
Krunc ingest
Stream once from OBS to rtmp://ingest.krunc.com/live and Krunc relays it to every enabled stream destination. It is a stream-copy relay (no transcoding): bitrate, resolution and codecs pass straight through, so CPU cost is a few percent per stream. Outbound bandwidth is bitrate × destinations — 6 Mbps to three platforms is 18 Mbps out, roughly 10 GB per streamed hour.
How it works
deploy/ingest/install.shinstalls MediaMTX as a systemd service running as the app user, listening on TCP 1935 (RTMP) with a local-only API on 127.0.0.1:9997.- Every MediaMTX action is authorised by
POST /hooks/ingest/auth?secret=…(IngestService::authorize). Publishing needs a valid, enabled workspace ingest key (ingest_keys, stored hashed, shown once) on pathlive/<key>, an active workspace, and a free slot (INGEST_MAX_CONCURRENT). Reads and API calls are allowed from loopback only — nobody can watch the raw feed. - When the stream comes online MediaMTX runs
bin/ingest-hook-online(a tiny sh wrapper — MediaMTX execs hooks without a shell), which callsbin/ingest-relay start: it opens aningest_sessionsrow (snapshot of the destinations), fires thestream.startedautomation trigger withsource: ingest, decrypts the enabled destinations' keys and prints anexec ffmpeg … -c copy -f tee …line that MediaMTX evals. One ffmpeg per session pushes to all destinations; a failing destination is ignored (onfail=ignore) while the others continue. - ffmpeg's output is piped into
bin/ingest-relay watch(IngestService::watchRelay), which masks the last path segment of every RTMP/RTSP URL — and the workspace's own ingest path — before a line reachesstorage/logs/ingest.log, so no key ever lands in a log file. See when a destination refuses the stream below. The watcher also ticks once a second whether or not ffmpeg said anything (stream_select, so it is not blocked infgets) and copies MediaMTX'sbytesReceivedfor the path onto the session every 10 s, with a final read before it exits —reconcile's minute tick never sees a short stream at all, which is why every session used to record 0 MB. - When the publisher disconnects MediaMTX sends SIGINT to ffmpeg and runs
bin/ingest-relay stop, which closes the session. The scheduler (ingest.reconcile, every minute) also closes sessions whose path is no longer publishing and records bytes received / peak kbps from the MediaMTX API, so a crash never leaves a slot occupied.
When a destination refuses the stream — onfail=ignore is what keeps one bad stream key from ending the whole broadcast, which also means it would otherwise be invisible until someone noticed the platform was dark. ffmpeg reports a destination it cannot publish to as [fifo @ …] Error opening <url>: <reason> (Input/output error for a key the platform rejected, Connection refused for an unreachable host). IngestService::parseRelayLine matches that URL against the session's own targets — the relay's input URL and a destination that was removed mid-session are deliberately not matched — and noteDestinationFailure then:
- flips that entry in
ingest_sessions.destinationstostatus: failedwith the reason, and writeslast_error(Twitch: Input/output error); - notifies the workspace's owners and admins once (
stream.destination_failed, deep-linked to that platform's card). ffmpeg retries a broken slave, so the first reason wins and nothing is sent twice.
The ingest page then shows not receiving next to that destination, the browser go-live page shows the same warning while live, and the recent-sessions table marks it in red. Everything else keeps streaming. endSession() only ever adds a reason: a normal publisher disconnect passes none, and erasing last_error there used to wipe the note the moment the stream stopped.
The relay runs at -loglevel error, not warning: a real stream emits a per-frame Non-monotonic DTS warning (measured at ~90,000 log lines an hour), while the [fifo] Error opening <url> line the watcher parses is error-level. The watcher collapses runs of an identical line as a backstop.
When the relay itself dies — onfail=ignore saves the broadcast from one bad destination, but ffmpeg's tee muxer ends the whole process once its last slave gives up (All tee outputs failed). Seen for real while rehearsing a composited stream on this 2-core box: startup ran past the destination's read timeout, ffmpeg exited, and the publisher kept pushing video into a relay that no longer existed — session open, every destination still reading relaying, green LIVE badge, nothing on air.
The absence of the MediaMTX path is what separates this from the normal case, so IngestService asks the API who is still publishing:
- the publisher is gone → an ordinary end; the stop hook closes the session and nothing is said;
- the publisher is still there and the restart budget holds → the destinations read
restarting, the page says Krunc is rebuilding the relay, and nobody is notified; - the budget is spent → destinations go to
stopped,last_errorsays nothing is reaching them and how many rebuilds were tried, and the owners are notified (stream.relay_stopped).
bin/ingest-hook-online is what rebuilds it: when ffmpeg exits on its own the wrapper waits two seconds and runs bin/ingest-relay retry, which prints another ffmpeg … line or exit 0. IngestService::restartRelay() is the only decision point — the watcher only reports — and it re-checks everything, so a publisher that left between the two answers simply gets exit 0. A publisher disconnect sets a flag in the wrapper's SIGINT trap and ends the hook without asking at all.
- Budget:
MAX_RELAY_RESTARTS(3) withinRESTART_WINDOW_SECONDS(300) — a rate limit, not a lifetime cap, so a five-hour stream that drops once an hour keeps being rescued while a relay failing every two seconds is given up on in about ten seconds.ingest_sessions.relay_restarts/relay_restarted_athold the count; each rebuild is audited asingest.relay_restarted. - Every destination gets another go. What kills a relay is the process, not one platform refusing one key, so a rebuild puts every entry back to
relaying. Anotifiedflag on the entry — not its status — is what keeps "told once" true, so a destination that fails again shows that again without a second notification per rebuild. - A composited session keeps its overlay clock. A new ffmpeg's filter clock starts at zero, which would replay the sponsor timeline from the top while impressions are still counted against the original stream clock.
shiftPlan()moves each item's offset back by the seconds already streamed (reduced modulo the item'sinterval × countcycle, which leaves both terms of the enable expression alone). The shifted plan reaches ffmpeg only;overlay_plan— what advertisers are billed from — is never rewritten. - The alarm fires once even though both the watcher and
restartRelay()reach the give-up:RELAY_STOPPED_NOTEis written and matched in one place. A rehearsal loggedingest.relay_stoppedtwice before this.
Dashboard — /dashboard/social/ingest (admin and above): generate or rotate the key (shown once, then only its last four characters), enable/disable, live status with destinations, recent sessions. Platform stream keys stay on the Channels page; the relay uses them server-side. Disabling a destination applies to the next session.
Configuration (.env): INGEST_HOST (blank disables the feature — the page explains how to use the destinations directly), INGEST_HOOK_SECRET (shared with mediamtx.yml), INGEST_MAX_CONCURRENT (default 3 — sized for this 2-vCPU VPS; the real limit is the plan's monthly transfer), INGEST_API_URL, INGEST_LOCAL_RTMP, INGEST_LOCAL_RTSP, INGEST_WHIP_URL, INGEST_MAX_COMPOSITED, INGEST_COMPOSITE_HEIGHT, INGEST_COMPOSITE_KBPS, OBS_WEBSOCKET_PORT.
DNS — Cloudflare cannot proxy RTMP. ingest.krunc.com must be a DNS-only (grey cloud) A record pointing at the VPS IP. Until it exists, OBS can use rtmp://149.28.95.15/live.
Security notes — keys are 24 random bytes, hashed with SHA-256; the auth hook is throttled per IP and refuses everything when no secret is configured. Platform keys are decrypted only inside the relay process and appear only in ffmpeg's process arguments (visible to local users — the box has no untrusted shell users). Rotate a destination's key on the platform if it leaks. RTMPS ingest (port 1936) and recording are deliberately out of scope for now.
Runbook — see runbook → Krunc ingest.
OBS setup
/dashboard/social/obs (admin+) removes the manual OBS configuration in two ways. Both hand over the workspace's existing ingest key, so a desktop and a laptop can both be set up and the zip can be downloaded afterwards without anything breaking:
- Configure OBS on this computer — the page talks to the OBS WebSocket server (OBS 28+, Tools → WebSocket Server Settings;
assets/js/obs-connect.js, obs-websocket protocol 5 with SHA-256 challenge auth) atws://localhost:4455. It fetches a fresh key fromPOST /dashboard/social/obs/key, sets Settings → Stream tortmp_customwith the Krunc server and key, adds (or updates) a locked browser source Krunc sponsor overlay with the profile's overlay URL sized to the canvas, and can start streaming. Nothing about OBS leaves the user's machine; browsers permitws://localhostfrom an https page. - Download krunc-obs.zip —
POST /dashboard/social/obs/profile.zip: an OBS profile folder (basic.ini1080p30 / 4.5 Mbps x264 +service.jsonwith server and key) and a scene collectionKrunc-scenes.jsonwith the overlay browser source, plus a README. Import both from the OBS menus.
Both accept rotate=1 to issue a new key instead, and both report rotated so the README and the page can say whether anything else needs updating. The key is only replaced without being asked when it cannot be read back — rows issued before migration 0028 store a hash and nothing else.
ingest_keys.key_enc (migration 0028) is why this works: the key is kept encrypted (AES-256-GCM under ENCRYPTION_KEY) beside its hash, which stays the lookup path for the auth hook. The same posture as stream_destinations.stream_key_enc, for the same reason — a key that has to reach an encoder cannot be write-only. POST /dashboard/social/ingest/reveal (admin, throttled) hands it back and writes ingest_key.revealed to the audit log, so losing it no longer means rotating every encoder.
Browser go-live (no software)
/dashboard/social/golive (admin+) captures the screen or camera with getDisplayMedia / getUserMedia and publishes to Krunc ingest over WebRTC / WHIP (assets/js/golive.js, H.264 preferred via setCodecPreferences, bitrate by quality preset).
POST /dashboard/social/golive/startmints a one-session browser token (ingest_keys.kind = browser, expires in 12 h, one live token per user); the tab publishes to pathlive/<token>.- The SDP exchange is proxied by the app:
POST /whip/{token}forwards the offer to MediaMTX's WHIP endpoint on loopback (INGEST_WHIP_URL) and returns the answer with aLocationof/whip/{token}/session/{id};DELETEthere ends the publish and disables the token. Both routes are session-authenticated, CSRF-checked and workspace-scoped (another workspace's member gets 404). Media itself flows browser → MediaMTX over UDP 8189 (webrtcLocalUDPAddress,webrtcAdditionalHosts= the public IP), so that port must be open. - MediaMTX fires the same
runOnOnlinehook; the relay reads the session over RTSP on loopback (INGEST_LOCAL_RTSP).ingest_sessions.kindrecordsbrowser. - Browser sessions are re-encoded, not copied (
ingest_sessions.transcoded = 1, migration 0031; since 2026-10-25 a portrait RTMP source with horizontal destinations takes the same path, see Streaming from a phone). The first real phone session (2026-10-12, iPhone Safari) arrived as 720p at 1–10 fps and ~130 kbps, then 360p at 0.4 fps — a WebRTC sender sheds frames and resolution the moment its uplink wobbles — and the-c:v copyrelay wrote packets whose presentation time preceded their decode time on that input. The platforms did start the stream (the Twitch VOD of that session exists) but the picture moved in multi-second steps. The relay now regenerates timestamps (-fflags +genpts), keeps one filter graph for the whole session however often the source changes size (-reinit_filter 0: without it ffmpeg rebuilt the graph on every size change,setpts=PTS-STARTPTSrestarted at zero and the cfr stage dropped every frame until the clock caught up — session 2126 on 2026-10-25 shrank and regrew three times and ran near 5 fps for two minutes while audio continued, 3,038 frames dropped; the relay log now saysrelay dropped N frames in the last blockwhen it happens), fits the picture into the fixed 16:9 canvas (INGEST_COMPOSITE_HEIGHT) withOverlayCompositor::fitChain— scaled to fit and centred over a blurred, cropped-to-fill copy of itself, so a 9:16 phone stream shows soft colour at the sides on a 16:9 Twitch/Kick page instead of black bars, while a 16:9 input covers the canvas (the overlay path uses the same chain for its base; a rotation mid-stream reinitialises the graph without changing the output size) — pads holes in the audio with silence (IngestService::AUDIO_FILL,aresample=async=1: WebRTC audio arrives with gaps from lost packets and DTX, and a low-latency player such as Kick's stalls on every timestamp jump), forces a constant 30 fps by duplicating frames (-fps_mode cfr -r 30), and encodes with x264 atINGEST_COMPOSITE_KBPSwith a keyframe every 2 s — the same encoder settings as the overlay path, and Opus → AAC. This takes an encode slot:IngestService::openComposited()counts composited and transcoded sessions together againstINGEST_MAX_COMPOSITED; a composited browser session already encodes and needs no second slot. With no slot free the session is copied as before (-c:v copy -c:a aac), the log saysingest.transcode_skipped, andgolive/status.jsonreportsencode: copy(otherwisetranscode/overlay) which the page shows next to the destinations. - Limits: browser encoders top out around 1080p30; system audio capture needs Chrome/Edge; closing the tab ends the stream. The page polls
/dashboard/social/golive/status.json. - Uplink readout. While live the page reads the sender's own
RTCPeerConnection.getStats()every 3 s (outbound-rtpvideo + itsremote-inbound-rtp) and prints kbps, fps, frame size, loss and round trip under the preview (#gl-net). Under 400 kbps, under 10 fps or 3 %+ loss it turns amber and says the connection is holding the picture back, withqualityLimitationReasonwhen the browser reports one. The same loop adapts the sender: while the readout shows under 10 fps and under 600 kbps it halves the frame size (RTCRtpEncodingParameters.scaleResolutionDownBy, one step per 10 s, down to ÷4, never in the first ~20 s while the bandwidth estimate ramps, and only while the frame's long side is still 720 px or more — the browser already sheds pixels on its own under a bandwidth limit, and halving on top of that produced an 83×180 camera picture that the relay enlarged to 720p, the blur seen on Twitch on 2026-10-25), and steps back up after 30 s of 25 fps+ with no bandwidth/CPU limit reported, under 1 % loss, a round trip under 300 ms and a bitrate plausible for the shrunken frame (half the preset's rate divided by scale², capped at 1.5 Mbps; the earlier flat 1.5 Mbps bar could never be met by a quarter-size picture, so one dip pinned a session at ÷4 for good) — a starved WebRTC encoder sheds frames before it sheds pixels, and a small moving picture beats a large frozen one. Note the relay duplicates frames only when the next source frame arrives, so a source at one frame per 4 s leaves the relay in 4-second bursts; Kick then reports the keyframe interval as wrong even though it is 2 s in media time. The cure is frames from the sender, which is what the downscale is for. Added after the first phone session arrived at 0.25 fps with 5 % loss and 100–370 ms jitter while the page simply said "live". - Camera step failures are spelled out.
getUserMediais raced against a 15 s timeout (iOS Safari can hang on it when another tab or app holds the camera);NotAllowedErrorexplains where iOS keeps the per-site camera permission,NotReadableError/AbortError/timeout say to close other Krunc tabs and camera apps,OverconstrainedErrorretries once with plain{video: true, audio: true}. On a phone, a page that is not live releases the camera when it goes to the background (visibilitychangehidden /pagehide) and asks for Camera again on return, because iOS hands the camera to one page at a time and a second tab opened later would otherwise never get it — which is what "the Go live button is not enabled" looked like on the first phone session. - Relay delay, measured. Every relay runs with
-progress pipe:2 -stats_period 10; the watcher turns each block'sout_time_usintoingest_sessions.relay_lag_ms(wall-clock since that ffmpeg started minus the stream time it has muxed: startup analysis + buffering + encode), logsrelay lag 1.2s speed 1.00xonce a minute, and drops the progress lines themselves.golive/status.jsoncarriesrelay_lag_msand the page shows "relay delay 1.2 s" next to the destinations — Krunc's own share of what a viewer waits, separate from the platform's 5–30 s. The two encode paths read the publisher with-fflags +genpts+nobuffer -flags low_delay -probesize 1M -analyzeduration 1M -max_delay 200000(RTSP over loopback TCP needs no reorder wait and the SDP already carries the codec parameters), which takes a few hundred ms off that figure. Twitch's documented ingest list (https://ingest.twitch.tv/ingests) has no Los Angeles entry; the genericlive.twitch.tvresolves from this box to US West (Oregon), the nearest listed, so pinning an ingest gains nothing. - Side rails for a portrait picture. When the frame MediaMTX reports for a browser session is taller than wide (
IngestService::sourceSize, from the H.264 SPS), the two bands beside the fitted picture become rails (OverlayCompositor::railFor: band width = (canvas − fitted width) / 2, needs 160 px of usable width, so 9:16 in 1280×720 gives 437 px rails with a 393 px panel). Each rail carries, top to bottom: the Krunc panel (renderRailPanel: wordmark,krunc.com/@handle, "scan to follow", and a QR of the profile URL drawn fromApp\Core\Qr's matrix — dark modules on a white tile so a phone camera reads it off a monitor), then sponsor cards when the overlay is on:plan()renders every creative at rail-card size (placement = rail, 16 % of the canvas height) andfilterGraph()draws each card on both rails from one input (split) with the campaign's usual enable expression, cycling through the slots that fit under the panel. Impressions are counted exactly as before (same items, same slots). Without the overlay (transcodedsession, no composite)railPlan()renders just the panel and storesrail+panelinoverlay_planbeside theskippedreason, so a relay rebuild redraws it; session PNGs are removed at the end for transcoded sessions too. Landscape sources get no rails and no extra inputs. The rail is decided once at session start: a phone rotated mid-stream keeps the layout it started with (the page already says to settle on one orientation). The phone reports its orientation with the token request (orientationonPOST /golive/start, stored iningest_keys.orientation, migration 0033):portraitwhen the preview's frames are already taller than wide,landscapewhen the phone is sideways or it is a desktop, andportrait-unrotatedwhen the phone is held upright but the camera hands over landscape frames — the server cannot tell that case from a landscape stream on its own (H.264 carries no orientation and MediaMTX does not negotiate WebRTC's rotation extension). Forportrait-unrotatedthe relay turns the picture 90° clockwise (transpose=1ahead of the fit chain,rotatestored inoverlay_plan) and lays out the rails for the turned size. If a phone turns out to need the other direction,rotatetakesccw. - Phone mode (same page, same server side). The script treats a touch device with no
getDisplayMedia(or a phone/tablet user agent) as a phone and addsgl-mobileto<body>: screen share is hidden, the quality presets drop to 2.5 / 1.2 Mbps for a Wi-Fi or cellular uplink (720p default; there is no 1080p capture on this page any more — the relay re-encodes at 720p, and a 1080p source pushed the encode past one core on this box, the relay ran at 0.91× real time and the measured relay delay climbed past 17 s), and the camera is asked for viafacingMode(front by default). Flip switches front/back — on a desktop with several webcams it cycles them — byRTCRtpSender.replaceTrackon the open connection, so a switch mid-stream does not renegotiate or drop. Mute toggles the audio track'senabledflag. Portrait or landscape follows how the phone is held (matchMedia('(orientation: portrait)')): the capture asks for 9:16 or 16:9 to match, the preview flips to a 9:16 box, and the page tells the streamer to settle on one before going live because a resolution change mid-stream makes RTMP platforms stutter. A portrait stream is relayed as-is (-c:v copy); with the server-side overlay on it is pillarboxed into the 16:9 composite canvas. While live the page holds a screen wake lock (navigator.wakeLock, iOS 16.4+ / Android Chrome) and re-requests it when the tab becomes visible. When the OS takes the camera (a call, the lock screen, another app) the track ends; the page keeps the session and, as soon as the tab is visible again, reacquires the camera/mic and swaps the tracks in — the ingest session never ends, viewers see a frozen frame meanwhile. The front camera is previewed mirrored (.gl-mirror); the stream itself is sent unmirrored. iOS Safari and Android Chrome both send H.264, so the relay still copies video and only transcodes audio.
1080p
Browser sessions are re-encoded at INGEST_COMPOSITE_HEIGHT (720 on the 2-core box: a 1080p encode ran at
0.91× real time there), and the go-live page only offers the 1080p preset when that setting is 1080 or more. On
a box with four cores set INGEST_COMPOSITE_HEIGHT=1080, INGEST_COMPOSITE_KBPS=6000 and
INGEST_MAX_COMPOSITED=2, restart nothing (the relay reads config per session) and the 1080p choice appears.
OBS sessions without the sponsor overlay are copied untouched, so OBS can send 1080p60 at any bitrate today;
Twitch caps ingest at 6 Mbps, Kick and YouTube accept more.
Stream quality monitor
Every session is watched by two witnesses and graded good / fair / poor against what its picture needs
(StreamQuality::targetKbps: 1080p 4,500 kbps, 720p 2,500, 480p 1,200, below that 800).
- Browser go-live posts its uplink every 15 s to
POST /dashboard/social/golive/sample(kbps, fps, frame
size, packet loss, round trip, the browser's qualityLimitationReason). Poor = under 35 % of the target, under
12 fps or 5 %+ loss; fair = under 60 %, under 22 fps or 2 %+ loss.
- The relay records a sample from every ffmpeg
-progressblock (10 s): lag behind real time, speed, output
bitrate, dropped frames. Poor = more than 6 s behind or slower than 0.95×; fair = more than 3 s or under 0.985×.
The first block is skipped (its speed is measured over a fraction of a second).
Samples live in ingest_samples (tenant-scoped, pruned after 30 days). While the stream runs, the go-live page
shows "Stream health: Poor — 420 kbps · 12 fps · 720p. A 720p picture needs about 2,500 kbps…" under the uplink
line (from the sample reply and status.json), the Ingest page shows the same verdict plus a bar per minute, and
the owner is notified once per session (stream.quality_poor) after three poor browser samples in a row (~45 s),
so the sender's bandwidth ramp-up never raises it. When the session ends (endSession, or reconcile closing a
stale one) the samples are folded into ingest_sessions.quality: session grade, average and minimum uplink, worst
lag, loss, a one-line verdict ("Uplink averaged 360 kbps where a 720p picture needs about 2,500 — 3 of 3 minutes
poor.") and the per-minute timeline the Recent sessions table draws. A session is poor with three poor minutes
or a quarter of them, fair with any poor minute or more than a tenth fair.
What it cannot see: what the platforms did with the stream after the relay (Twitch and Kick keep their own
ingest stats and expose none of it through their APIs), so a problem between the relay and a platform shows up
only as a destination failure, not as a grade. The 04:39 session on 2026-10-18 is the case this was built for:
peak 434 kbps into a 720p preset, relay lag 8.8 s, all three destinations "relaying" — a bad stream with no error.
Screen + camera (picture in picture, desktop)
Game audio. The share's audio track is requested with echo cancellation, noise suppression and auto gain off,
stereo, and contentHint = "music"; the WHIP offer and the ingest answer both get `stereo=1; sprop-stereo=1;
maxaveragebitrate=160000` on the Opus line and the audio sender is capped at 160 kbps, so game sound reaches the relay
as stereo music rather than a 32 kbps mono voice call. Firefox and Safari cannot capture system audio at all: with
them only the microphone reaches the stream, and the ready message says so. On Chrome/Edge the streamer must tick
"Share system audio" (Entire screen) or "Share audio" (tab) in the share dialog; a share without it gets the same
note. (2026-10-18, after Remote Play audio arrived muffled.)
The go-live page's Screen + camera button (assets/js/golive.js, pick('pip') → composePip) takes a
screen share and the camera and draws both into one canvas at the chosen quality (16:9, q.h high): the main
picture fitted, never cropped, and the other source as an inset in a corner (bottom right by default; small /
medium / large = 20 / 25 / 33 % of the width; Swap puts the camera in the main picture). The canvas is sent as
the video track via canvas.captureStream(30); system audio from the share and the microphone from the camera
are mixed into one track through an AudioContext → MediaStreamDestination, because the relay maps one audio
stream. Corner, size and swap change the drawing only — no renegotiation, usable while live. Mute silences
the microphone and leaves the game audio alone; Flip cycles webcams for the inset.
The draw loop ticks from a tiny inline Worker (makeTicker), not requestAnimationFrame or a page timer: once
the streamer switches to the game the page is a background tab, where rAF stops and the main thread's timers are
throttled to once a second; a worker's setInterval is not. A source whose video track ends (the share is
stopped, the OS takes the camera) drops out of the drawing and the other fills the frame; the stream carries on.
Server side nothing changes — a picture-in-picture session is an ordinary browser session (`orientation:
landscape), re-encoded like any other. Phones never see the button (no getDisplayMedia`).
Streaming from a phone
Two paths. The first needs nothing installed: open /dashboard/social/golive in the phone's browser — it is the
browser go-live page in its phone mode (above): camera, flip, mute, portrait or landscape, screen kept awake. The
second is a dedicated app, which gives a hardware encoder, adaptive bitrate and background streaming the browser
cannot. Nothing special server-side for that — a phone is just another RTMP publisher on the same ingest key — but
typing a 35-character key on a touchscreen is the kind of friction that stops people. POST /dashboard/social/ingest/phone
(admin, throttled) returns the connection in three forms: server + key, a single rtmp_url for apps with one
field, and qr_svg, a QR of a Larix Grove deep link.
The ingest page's Stream from your phone or tablet card has two tabs (#phone-tab-cam, #phone-tab-game;
the games tab is opened by the hash, and the last choice is kept in localStorage). Camera & IRL is the Larix
Broadcaster flow above. Mobile games & apps is for streaming the screen — a mobile game, any app — which no
phone browser can capture (getDisplayMedia does not exist on iOS Safari or Android Chrome), so it needs a
screen-capture broadcaster: the steps are written for Larix Screencaster (iOS via a ReplayKit broadcast
extension started from Control Center, Android via MediaProjection; game audio on iOS only when the game supports
ReplayKit audio, on Android 10+ through the app's Media sounds setting) and note that Streamlabs and Prism Live
Studio take the same custom RTMP URL. Softvelum documents Grove import for Larix Broadcaster only, so the games
tab labels the button Show server URL and the panel also lists server and key separately. The streamer-facing
version is docs/streamer-setup.md → Option D. **Not yet exercised with a real screen-capture stream from this
host** — the first one should be watched on the ingest page for frame size, orientation and audio.
Shape of the source. startSession() reads sourceSize() for every publisher now, not only browser
sessions. A portrait RTMP source (a phone streaming a game upright) is treated like a portrait browser
session: with horizontal destinations and a free encode slot it is transcoded into the 16:9 canvas with side
rails (link + QR panel) instead of reaching Twitch as a tall picture between black bars; without a slot it is
copied as before. For vertical destinations see dual output below: a source that is already 9:16 is copied to
them untouched.
Grove is Softvelum's documented settings-import format for Larix Broadcaster:
larix://set/v1?conn[][url]=<url-encoded rtmp url>&conn[][name]=<label>. Scanning it with the phone's camera opens
Larix with the connection added. Only conn[][url] is required; encoder settings are left to the phone's defaults
rather than guessed at from here.
The QR is drawn by App\Core\Qr (the same encoder as sponsor tracking links — no third-party service sees a
stream key). The payload is ~120 bytes, well inside the encoder's ~210-byte ceiling; if a long enough ingest host
ever passed it, the QR is dropped and the manual fields still work. Because all three forms embed the key, the
endpoint is audited as ingest_key.revealed with via: phone, and the page renders none of them until asked.
Horizontal and vertical per destination (dual output)
Each stream destination has an orientation (stream_destinations.orientation, migration 0034): horizontal
(16:9) or vertical (9:16), chosen on the Channels page when the key is added or edited. **TikTok and Instagram
default to vertical**, everything else to horizontal (StreamPresets::defaultOrientation); a blank value in the
form or the API takes the default. OBS streaming straight to a platform ignores it — it only tells Krunc ingest
which picture to send.
When a session has at least one enabled vertical destination the relay runs two outputs from one ffmpeg
(IngestService::relayCommand): the horizontal group gets exactly what it got before (stream copy, 30 fps
transcode or sponsor composite), and the vertical group gets its own 9:16 picture. **A source that is already
9:16** — IngestService::fitsVertical(): taller than wide and within 0.04 of 9/16, so 720×1280 and 1080×1920
qualify, a 9:19.5 phone screen or a 3:4 tablet do not — is copied to the vertical group as it is
(ingest_sessions.portrait = 2, PORTRAIT_COPY): full resolution, no decode, no encode slot, audio to AAC
only for a browser publish. TikTok then shows exactly what the phone captured. Anything else gets a second x264
encode into the 9:16 canvas (portrait = 1, PORTRAIT_ENCODE)
(OverlayCompositor::portraitCanvas, height INGEST_PORTRAIT_HEIGHT, default 960 → 540×960, about 56 % of the
720p encode's pixels; bitrate scaled from INGEST_COMPOSITE_KBPS by the same ratio). The canvas uses the same
fit chain as the 16:9 one, so a phone held upright fills it edge to edge, a landscape OBS scene is fitted with a
blurred copy of itself above and below (nothing is cropped), and a portrait-unrotated phone is turned first.
When the horizontal output is itself an encode the decoded picture is split once ([0:v]split=2[vl][vp]) so the
input is decoded once; when it is a copy the vertical chain reads the input on its own and the copy stays a copy.
With only vertical destinations there is no horizontal output at all.
Slots. The vertical canvas is an encode, so ingest_sessions.portrait = 1 counts in
IngestService::openComposited() beside composited and transcoded (portrait = 2, the copy, does not). A
session that already encodes (overlay or transcode) adds the vertical output inside its existing slot; a plain
copy session needs a free one. With none free the session is relayed the old way — vertical destinations receive
the source picture unchanged — and overlay_plan.portrait_skipped = capacity makes the ingest page say so
(vertical skipped badge). While live the status panel shows the source frame (status()['source']) and, next
to each destination, what it receives: 9:16 as sent, fitted to 9:16, side rails, or the black-bars /
not-fitted note when no slot was free. golive/status.json is unchanged.
Not yet on the vertical canvas: the sponsor overlay and the side-rail panel are drawn on the 16:9 output
only; vertical destinations get the clean picture. Impressions keep being counted against the 16:9 plan. Two
encodes on the 2-core relay box: the default 540×960 keeps a 720p + vertical session near one core; raise
INGEST_PORTRAIT_HEIGHT to 1280 (720×1280) only on a bigger box.
Krunc player (watch on the creator page)
Switch on /dashboard/social/ingest (POST /dashboard/social/ingest/player, stored on the workspace's RTMP key: ingest_keys.player, migration 0044). While a session for the profile is publishing, the creator page (/@handle) then plays the relayed stream itself instead of embedding Twitch / Kick / YouTube: IngestService::watchSession() finds the open session, LivePlayer::embed() returns platform: krunc, hls: true and the page mounts an HLS player (assets/js/vendor/hls-1.6.7.min.js where MediaSource exists, Safari natively). Viewers watch on krunc.com, a few seconds behind live and without platform ads; pre-roll ads play before the stream mounts and a post-roll when it ends (sponsors → rolls). They are not counted as viewers by the platforms — the page says so next to the switch.
How the media gets there: MediaMTX serves HLS on loopback only (hls: yes, hlsAddress: 127.0.0.1:8888, plain fMP4 segments of 2 s, hlsAlwaysRemux: yes so the first viewer does not wait for a muxer to spin up; deploy/ingest/mediamtx.yml), and the app proxies it at GET /watch/{handle}/{file} (WatchController, throttle:api): it resolves the handle to the open session, fetches INGEST_HLS_URL/live/<key>/<file> and returns it with the right content type (no-store for playlists, max-age=60 for segments). The MediaMTX path ends in the workspace's ingest key and never reaches a browser. MediaMTX 1.21 keeps one HLS session per viewer: the first playlist request is answered with a 302 to ?cookieCheck=1 plus a cookieCheck cookie, the playlist then sets an hlsSession cookie, and segment requests without it get 401 "session not found". The proxy is stateless, so the viewer's browser carries those cookies: the browser's Cookie header goes to MediaMTX, every Set-Cookie is passed back scoped to /watch/ (HttpOnly, SameSite=Lax), the redirect is passed on with its Location rewritten under /watch/{handle}/, and the query string travels through. Verified end to end on 2026-10-20 with a local test publish (redirect → master playlist → video1_stream.m3u8 / audio2_stream.m3u8 → init + segments, a second viewer gets its own session). Reads are excluded from the auth hook (authHTTPExclude: [{action: read}]): every read listener is bound to loopback, so only the relay's ffmpeg and this proxy can read, and one hook round trip per segment would double the cost of each viewer. 404 from /watch means no session, player off, or HLS not configured — the page then shows the platform embed (or nothing), and a player already open treats it as the stream's end.
Live state. A session for a profile whose workspace shows the player marks the profile live on platform krunc (IngestService::markLive, only when the profile is not live already), so a creator streaming to Krunc alone still shows as live, with the page's own URL. The live pollers (LiveService, Twitch EventSub) leave krunc alone like manual; a platform that does report the stream live overwrites the platform, and the page still plays the Krunc session. When the last open session for the profile closes — publisher left, superseded, or the reconcile tick — the krunc live state is cleared (sessionClosed).
Embed the player on other websites (/embed/{handle})
A bare player widget meant for an <iframe> on any other site; the snippet, a preview, and where it is being loaded are on /dashboard/social/ingest under the Krunc player card (EmbedController, view profiles::embed). What it shows:
- Streaming through Krunc with the player on → the Krunc player (same pre-roll / post-roll rolls as the creator page:
rolls.jsreads the widget root'sdata-rollsattributes; its beacons are sent astext/plainso they need no CORS preflight from a sandboxed frame). - Live on a platform → that platform's player where a nested frame can show it: YouTube and Kick always; Twitch only when the host site passed its own domain (
?parent=mysite.com,www.mysite.com— Twitch requires every domain that frames its player, and a nested frame cannot know its ancestors;LivePlayer::embed()takes the extra parents as its last argument). Otherwise a card that links out. The dashboard's "Your website's domain" field adds?parent=to the snippet. - Offline → a card with the next scheduled slot in the viewer's time zone.
The bar under the picture carries ♥ Donate (/@handle/support, only while the tip jar is on) and ★ Subscribe (one tier → that product's store page, several → /@handle#memberships, only when recurring products exist), plus a link to the creator page — all target=_blank, because a third-party iframe has no session cookies, so nothing signs in or pays in place. The picture fills whatever is left above the fixed 64 px bar (object-fit: contain), so any iframe size and either orientation works; the snippet is a 16:9 box plus 64 px. The page polls /live/profile/{handle}.json every 15 s and reloads when the live state flips; the stream's end runs the post-roll and reloads into the offline card. Unknown / private handles get a small frameable 404 page, noindex throughout.
Creator controls (profile editor → "Embed on other websites", EmbedSettings, migration 0046): creator_profiles.embed_enabled (off → a frameable 403 page that links to the creator page, no oEmbed, no discovery link) and embed_domains, an optional allow-list of host names (URLs, www., ports and paths are normalised away; max 20). The list becomes the widget's Content-Security-Policy: frame-ancestors 'self' https://host https://.host …; empty = frame-ancestors . CSP frame-ancestors is what browsers enforce for framing, and it takes precedence over X-Frame-Options.
Where it is loaded (EmbedStats, table embed_loads): one counter per profile × embedding host × day, the host from the Referer of the widget request — the embedding page's origin under the usual strict-origin-when-cross-origin policy, (direct) when there is none; loads from krunc.com itself (the dashboard preview) are not counted; no visitor data. Shown on the ingest page as "where it is loaded · last 30 days"; rows older than a year are pruned daily (embed.prune).
oEmbed (GET /oembed?url=https://krunc.com/@handle[&maxwidth&maxheight], JSON only, xml → 501): a rich payload whose html is the widget iframe at 16:9 plus the bar (640×424 by default), with the banner as thumbnail. Creator pages carry the discovery <link rel="alternate" type="application/json+oembed">, so pasting a profile URL into WordPress, Notion and similar auto-embeds the widget. WordPress frames discovered embeds with sandbox="allow-scripts" only: the page then runs as an opaque origin, which is why /watch/, /live/profile/.json, /rolls/ and /oembed all answer with Access-Control-Allow-Origin: ; the Donate / Subscribe links still need allow-popups, which WordPress does not grant (the dashboard copy says so for hosts that add their own sandbox).
Framing headers: .htaccess sends X-Frame-Options: SAMEORIGIN site-wide except for /embed/ (an expr= on THE_REQUEST, which the rewrite to index.php does not touch) — verified live on 2026-10-23 (curl -I https://krunc.com/embed/<handle>: CSP present, no X-Frame-Options; the home page keeps it).
Cookie-free media path. Inside a third-party iframe MediaMTX's session cookies never come back (SameSite, and Safari blocks third-party cookies outright), so the widget plays from GET /watch/{handle}/session.m3u8 (WatchController::start): the proxy runs MediaMTX's cookie check itself (first request → 302 → second request with MediaMTX's own cookieCheck cookie), reads the hlsSession id from the Set-Cookie, and answers 200 with the master playlist rewritten (WatchController::absolutize: bare URI lines and URI="…" attributes of #EXT-X-MEDIA etc. become /watch/{handle}/s/{sid}/…; absolute URLs are left alone). Media playlists are then fetched under /s/{sid}/ (sessionFile, Cookie: hlsSession={sid} added by the proxy); their relative segment names resolve there in every player, because no redirect is involved — iOS Safari's native player has an unresolved habit of resolving relative URIs against the pre-redirect playlist URL (WebKit bug 201068), which an earlier redirect-based version of this path would have hit. Nothing is set on the viewer's browser. A session MediaMTX has dropped (it restarts its cookie check) is answered 401, and the player starts over from session.m3u8, as it already did for the ~40 s idle timeout. sid is validated (WatchController::SID, a UUID) so nothing else reaches MediaMTX. Should MediaMTX hand out no session at all, the playlist is returned as is and relative files fall back to the cookie route. The cookie route (/watch/{handle}/{file}) is unchanged for the creator page. The HLS mounting code is shared: assets/js/krunc-player.js (KruncPlayer.mount(box, src, {title, onEnded})), used by both pages.
Throttle: all /watch/* routes use the media bucket (1800/min) keyed by the viewer's session id where the path has one, else by IP. A viewer makes ~2 requests/s (two playlists plus segments every 2 s ≈ 120/min), so the old api bucket (300/min per IP) broke at three viewers behind one NAT; per-IP the new limit holds ~15 cookie-route viewers.
Viewer count on Krunc. The platform pollers leave a profile live on Krunc alone, so its live_viewers used to stay 0. IngestService::reconcile() (every 60 s) now reads MediaMTX's per-viewer HLS sessions (/v3/hlssessions/list, apiHlsSessions(), each item carries its path), counts them per ingest path (viewersByPath) and writes the number to live_viewers while live_platform = krunc — creator page and embeds together. A platform's own number is never overwritten.
Orientation. watchSession() reads the source frame size from MediaMTX (sourceSize); a frame taller than wide gives the player box the portrait class (9:16, capped to 78 vh) and the roll decision asks for vertical ads. The picture is the raw source (not the vertical canvas built for TikTok / Instagram), so a phone streaming upright is shown upright.
Cost and limits. Remuxing to HLS is a few percent of a core per stream. In the default (PHP) mode each viewer costs one PHP request per playlist refresh and per 2 s segment (about one request per second), which the media throttle allows and the PHP-FPM pool absorbs for a small audience. nginx mode (INGEST_WATCH_ACCEL=1, on since 2026-10-23): for the per-session path (/watch/{handle}/s/{sid}/{file}, the embed and the creator page both play from session.m3u8) PHP only authorises (profile public, player on, session open, id well-formed) and answers an empty 200 with X-Accel-Redirect: /_mtx/live/<key>/<file>?…&mtxsid=<sid>; nginx then fetches the playlist or segment from MediaMTX on loopback itself (deploy/nginx/watch-accel.conf, installed as the per-domain include /home/nerdy/conf/web/krunc.com/nginx.ssl.conf_watchaccel), turning mtxsid into the hlsSession cookie MediaMTX expects, hiding MediaMTX's Set-Cookie, adding Access-Control-Allow-Origin: * and X-Krunc-Via: accel. No media bytes pass through PHP/Apache, the throttle and the authorisation stay where they were, and /_mtx/ is internal, so the MediaMTX path still never reaches a browser. The master playlist (session.m3u8) is still built in PHP (it runs the cookie check and rewrites the entries), as is the cookie route; a 401 for a session MediaMTX forgot now comes from MediaMTX through nginx, and the player answers it the same way. With the flag off, or on an install without the include, PHP proxies the bytes as before. Low-latency HLS is deliberately off: a blocking LL-HLS playlist request would hold a PHP worker. Requires re-running sudo bash deploy/ingest/install.sh after pulling (rewrites mediamtx.yml, adds INGEST_HLS_URL to .env); MediaMTX reloads its configuration on change, and the installer restarts it anyway.
Server-side sponsor overlay (burned in)
Switch on /dashboard/social/ingest or the go-live page (POST /dashboard/social/ingest/composite, stored on the workspace's RTMP key: composite, profile_id). When on, IngestService::startSession asks OverlayCompositor for a plan for that profile: every eligible campaign (SponsorService::eligibleCampaigns — consent, schedule, budget, frequency caps, same rules as the OBS overlay) and its approved creatives, up to 8. Each creative is rendered with GD into a transparent PNG the size of its placement box (banner / corner / lower third; artwork = creative image → video poster → sponsor logo; disclosure + sponsor, headline, CTA + tracked short link + promo code). The relay then runs one x264 encode (INGEST_COMPOSITE_HEIGHT 720p, INGEST_COMPOSITE_KBPS 3500, INGEST_COMPOSITE_PRESET ultrafast/zerolatency — measured on this 2-core VPS: ultrafast ≈ 0.75× realtime CPU for a 1080p30 source, veryfast is not realtime here) with an ffmpeg filter_complex where creative k of n for a campaign is enabled by the timeline expression lt(mod(t-offset,interval),display) * eq(mod(floor((t-offset)/interval),n),k) — the campaign's own cadence, deterministic, no scripting. Campaigns are staggered by offset so two never overlap.
- Impressions — the plan is stored on the session (
overlay_plan).IngestService::accountOverlay(every reconcile tick and at session end) computes which slots aired sinceoverlay_accounted_at(OverlayCompositor::airedSlots) and insertssponsor_impressionsrows with the profile's live viewer count — the same rows and unique key the browser overlay uses, so reports, budgets and creator earnings are unchanged. - Capacity —
INGEST_MAX_COMPOSITED(default 1: ~one CPU core per 720p30 encode on this 2-core box). When no slot is free, or the profile has sponsors off / no eligible campaign, the session is relayed untouched andoverlay_plan.skippedsays why (shown in the sessions table and the go-live status). - PNGs live in
storage/ingest/overlays/<session>/and are deleted when the session ends. Video creatives are shown through their poster frame (no video-in-video decode on the server).
Tests: tests/StudioTest.php (tokens, WHIP proxy, plan, filter graph, aired slots, accounting idempotency, capacity fallback, OBS payload and zip).
Your own AI graphics on the stream
/dashboard/social/graphics (admin+). Describe an overlay, Krunc generates it as a transparent cut-out at the exact size of its placement box, and the same compositor that burns in sponsors burns it into the broadcast — no browser source, no OBS plugin, nothing for the streamer to install. stream_graphics rows, StreamGraphics service, OverlayCompositor::graphicItems().
- It is not a sponsor row. A sponsor creative belongs to a campaign with a budget, an approval state and an advertiser billed per impression; a streamer's own channel bug is none of those. Putting it in the sponsor tables would mean billing nobody for rows that look billable, or special-casing every sponsor query forever.
- Nothing is billed for it. Workspace graphics carry no
campaign_id, andairedSlots()skips items without one — so they ride in the same plan and the same filter graph while staying out ofsponsor_impressionsentirely. That is the line these tests exist to hold. interval_seconds = 0means always on:filterGraph()emits noenable=expression at all, so a channel bug does not blink. Anything higher uses the sponsor cadence expression.- Sponsors off still composites.
plan()used to return early when a profile had sponsorships disabled, which meant a workspace that only wanted its own overlay got no composite at all. Sponsors are now a guarded block, not an early return. - Capped at
StreamGraphics::MAX_PER_TENANT(4), inside the overallMAX_CREATIVES(8): every graphic is another ffmpeg input in a live encode on a 2-core box. - Artwork is copied into the session directory at plan time via
StorageProvider::get(), not referenced in place, so it works when storage is S3 and socleanup()can delete the session's files without touching the original the workspace paid to generate. - Changes apply to the next stream: the overlay plan is fixed when a session starts.
Verified end to end: a generated wolf emblem (384×187, 51% genuine alpha) composited at the corner box of real 1280×720 video with the compositor's own filter string, and airedSlots() returned 0 billable slots over a simulated two hours.
Impression accounting is anchored to started_at. overlay_accounted_at used to be a second now() written just after the session insert; when the two straddled a clock second, from became 1 instead of 0 and the slot at stream second 0 was never counted — an impression that aired and no advertiser was billed for. Found as an intermittent test failure, which is the only reason it was found at all.
Publishing rules
- One post, many targets. Each target can carry its own text variant; the strictest character limit among selected accounts is shown live in the composer.
- Hard limits enforced server-side: X 280, Instagram 2200, TikTok 2200, LinkedIn 3000, YouTube 5000, Facebook 63,206, Reddit title 300.
- Media are public URLs (upload from the composer stores an image under
/uploads/...). Instagram/TikTok/YouTube need media; X/LinkedIn are text-only for now. - Scheduled times are entered in the user's timezone (
users.timezone) and stored in UTC. - Results are per platform:
published,failed(with the platform's error, retry button), post statuspublished/partial/failed. post.publishedautomation trigger fires when at least one target succeeds.
Live status
/live lists profiles with is_live = 1; category chips come from config('app.live_categories'). Platform categories are mapped (e.g. "Grand Theft Auto V" → "GTA RP"). A manual override from the profile editor (live_platform = manual) is never cleared by the poller.
Twitch EventSub (webhooks, behind a flag)
Twitch can push stream.online / stream.offline to https://krunc.com/webhooks/twitch within seconds instead of waiting for the 2-minute poll. It is off by default and gated by the feature flag social.twitch_eventsub (a missing row means off — unlike social.<provider> flags).
Enable
- Twitch credentials in
.env(TWITCH_CLIENT_ID/SECRET) — the same app the creators connect with. TWITCH_EVENTSUB_SECRETin.env: 10–100 characters (php bin/keygenprints one). Twitch signs every message with it.APP_URLmust behttps://— Twitch only calls HTTPS callbacks on port 443.- Turn the flag on at
/admin/flags(keysocial.twitch_eventsub), then either wait for the scheduler (live.eventsub_sync, every 10 min) or press Sync subscriptions on/dashboard/live.
How it works (App\Modules\Live\Services\EventSubService, App\Providers\Social\TwitchProvider implements EventSubCapable)
sync()keeps one subscription per (connected Twitch account, type) using an app access token (client credentials):POST helix/eventsub/subscriptionswithtransport = {method: webhook, callback, secret}, conditionbroadcaster_user_id. Rows live ineventsub_subscriptions(pending→enabledonce Twitch's challenge is answered;failedrows retry hourly;revokedrows keep Twitch's reason inerror). A 409 from Twitch is resolved by listing subscriptions and adopting the existing one, so a channel connected to several workspaces shares a single subscription (it is only deleted on Twitch when no workspace references it). Disconnecting an account, or turning the flag off, removes its subscriptions on the next sync.- Inbound: signature =
sha256=HMAC-SHA256(secret,Twitch-Eventsub-Message-Id+Twitch-Eventsub-Message-Timestamp+ raw body), compared withhash_equals; messages older than 10 minutes are rejected (403).webhook_callback_verificationis answered with the raw challenge astext/plain.notificationandrevocationare stored inwebhook_events(providertwitch, event id = message id, duplicate → 200 no-op) and processed by thetwitch.process_eventjob — so Twitch gets its 2xx within milliseconds and failed events can be re-run from/admin/webhooks. stream.onlineopens alive_streamsrow from the event (stream id, login,started_at), enriches it with title / category / thumbnail / viewers from Helix using the creator's token when that works, flips the profile live (stream.startedautomation) and samples the audience.stream.offlinecloses the stream and clears the badge unless another platform is still live or the profile is on themanualoverride.- The poller keeps running: it updates viewer counts and peak viewers and is the fallback if a notification is lost. Cost: each webhook subscription costs 1 point against Twitch's 10,000 default limit per app (
costis shown on the dashboard card).
YouTube offers PubSubHubbub for uploads but not live state, so YouTube and KICK stay on polling.
Creator page (/@handle)
What the public profile shows beyond the bio and store grid:
- Platform buttons —
creator_profiles.platforms(handle or URL per platform, typed on the editor) merged with
connected accounts by PlatformLinks; the Streams-on list links through the same handles.
- Live player — when
is_live,LivePlayer::embedpicks the embed fromlive_platform/live_urland the
channel handle: Twitch (player.twitch.tv, chat embed, parent= is the app host), Kick (player.kick.com,
chatroom), YouTube (video id from the live URL, else the channel's permanent live embed when a connected account
gives the UC… channel id). Chat opens under the player on demand. The page's 15 s poll reloads when the live
state flips, so the player appears and disappears on its own.
- Schedule —
creator_profiles.scheduleJSON (tz, up to 14 weekly slots, note;ScheduleService). The page
shows the next slot (server-side in the creator's zone, rewritten client-side into the visitor's zone with
Intl) and the weekly list.
- Live alerts — visitors leave an email (
stream_subscribers, double opt-in by token, one-click stop link in
every mail). ProfileService::setLive queues live.notify 20 s after a profile flips to live;
LiveNotifyService::send emails confirmed subscribers and sends signed-in followers an in-app stream.live
notice, at most once per profile per 6 hours (live_notified_at).
- Clips and VODs —
creator_videos(VideoLibrary). Sources: Kick's public channel API by the handle on
the profile (videos + clips, nothing to connect); YouTube's public RSS feed by channel id, resolved once from
the handle page and cached in videos_meta, or the Data API (YouTubeProvider::listUploads, 3 quota units)
when a YouTube account is connected; Twitch Helix (listVideos archives + listClips last 90 days) only
through a connected account. videos.refresh runs hourly for public profiles not synced in 6 hours, keeping
the newest 24 per provider and kind; the editor has a "Pull now" button and a Featured box (up to 12 pasted
Twitch/YouTube/Kick links, stored as manual rows, shown first). Rows a platform no longer lists are removed on
refresh (reconcile: a listing shorter than its page size is the whole list; otherwise anything newer than its
oldest entry and not in it is gone) — Kick expires recordings, and a card that led to one showed Kick's "Oops,
we can't find the page" (2026-10-26). YouTube sorts first everywhere (pinned → YouTube → newest): it is the one
source whose recordings stay up and embed.
- Video pages — every clip and VOD has its own page,
/@handle/videos/{id}-{slug}(ProfileController::video,
profiles::video): <title> "{title} — {creator}", meta description (the AI's line, else the platform
description, else a sentence from the facts), canonical, Open Graph video.other + Twitter card with the
thumbnail, og:video for the embeddable ones, VideoObject + BreadcrumbList JSON-LD, a click-to-play
player (YouTube/Twitch iframe mounted on click, pre/post-roll via rolls.js when the creator has a roll campaign),
"Watch on {platform}" for Kick (no embed for recordings), and six more videos. The id finds the row and the
slug follows the current title, so a renamed video's old link redirects (301). /@handle/videos lists them all,
24 a page. Both 404 when the profile's Videos section is off or the profile is private. sitemap.xml lists the
list page and every video page, with Google's video extension (thumbnail, title, description, player_loc,
duration, publication date, views, uploader) on the embeddable ones.
- AI titles — Kick names every recording "My first stream." (its default session title), so
VideoLibrary::generateTitles (job videos.ai_titles, queued by refresh and by the editor) asks the AI
(prompts/video.titles.v1.md, 25 videos a call, charged as video.titles against the workspace's credits) for a
plain title and a one-line description per row from the facts only (original title, platform description,
category, duration, date). Stored in ai_title / ai_description; the platform title stays in title and the
page says "Originally titled …". ai_at marks rows the AI has seen (NULL again when the platform title changes).
The mock provider never writes. The editor's "AI titles for videos" switch (videos_meta.ai_titles, default
on) stops it; a failed run (no credits, outage) is shown there (videos_meta.ai_titles_error) and retried on the
next refresh.
- Memberships — the workspace's active recurring products as tier cards (
Memberships::tiers): price per
interval, perks spelled out from the deliverables, member count (subscriptions active / trialing / past_due),
"Popular" on the featured tier (or the busiest one), and "Your tier" + Manage for a signed-in holder. Join goes
to the storefront's Subscribe flow; the store grid on the page keeps only one-time items.
- Tip jar — see docs/store.md#tips.
- Share tools — a Share button opens a dialog with copy, the browser's native share where it exists, X,
Facebook, WhatsApp, Reddit and email links, and a QR code drawn client-side with the vendored
assets/js/vendor/qrcode-generator-1.4.4.min.js (kazuhikoarase/qrcode-generator 1.4.4 from cdnjs, sha256
164fc2c1…bef7) with a PNG download. /@handle/qr is a printable page; /@handle/qr?overlay=1&corner=… is a
transparent OBS browser source with the code and handle. /@handle/card.png is a 1200×630 social card
(SocialCard, GD + DejaVu, cached under storage/cache/cards by a hash of its inputs) and is the
og:image / twitter:image whenever the creator has no banner.
- Discord — the invite code under "Find me on" becomes a join card (
DiscordWidget): guild name, icon,
member and online counts from Discord's public invite endpoint, cached on creator_profiles.discord_meta
for an hour; a dead invite hides the card and is retried an hour later.
- Signal —
Signal::forProfile: reach = Krunc followers + each connected account's followers + the Discord
server's members, with the breakdown; streams and hours live in the last 30 days and all time, the most
played category in 90 days (falling back to the declared audience category) and when they were last live,
all from live_streams.
- On-stream alerts — an OBS browser source at
/overlay/alerts/<overlay_token>(1920×1080, transparent; the
same token as the sponsor overlay, shown with a Copy button and a Preview link on the editor). It polls
/overlay/alerts/<token>/feed.json?since=<id> every 3 s; since=0 returns only the current id so nothing older
is replayed. Rows in overlay_alerts come from AlertService::onTip (TipService::paid, above the minimum),
onFollow (the follow action) and onMember (the Stripe webhook when a subscription row is new; every public
profile of the workspace with member alerts on). Settings on creator_profiles.alerts: kinds, minimum tip,
seconds on screen, chime (a WebAudio three-note, no file), corner. "Send a test alert" on the editor pushes a
sample; rows are pruned after a day.