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 | Register a "web app"; API access is rate-limited (100 req/min). | |
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.
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). 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), 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 steps back up after 30 s of 25 fps+, 1.5 Mbps+ and under 1 % loss — 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.
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.
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.
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.