Multi-source RTMP, a 32-track ceiling, a marketing site, and per-platform encoder guidance - #120
Conversation
Chat could show a bad message and let you moderate whoever sent it, but only while that message was still on screen. Three things were missing, and all three are the same complaint: the pane is a window, not a record. SEARCH hits the database, never the Hub's in-memory ring. The ring holds one process lifetime, and "where did that comment go" is precisely the question it cannot answer. It matches message text or author name, newest first -- the one read in internal/db/chat.go that is deliberately NOT chronological, because a result list should not bury the likeliest answer at the bottom. Escaping the LIKE wildcards is the subtle half. Searching for a literal "100%" would otherwise match every row, and a search box that answers a narrow question with the whole table reads as working right up until someone notices the results are unrelated. RIGHT-CLICK, and double-click for pointers with no secondary button, adds no capability the user card lacked. It buys the two-second path for the case that needs no reading: a line scrolls past, it is obviously bad, the moderator already knows what to do. A permanent ban deliberately does not fire from it -- the item opens the card, which confirms. The one irreversible action reachable from a right-click must not be a single click on a menu that appeared under the cursor. PLATFORM LINKS are honest about a real asymmetry. Only Twitch publishes a moderator viewer card at a URL; YouTube, Kick and Facebook have no per-viewer chat history anywhere, so those read "Open channel" or "Open profile" and carry the caveat. A uniform "Open on <platform>" would promise the same thing everywhere and quietly deliver it only on Twitch: a moderator clicks, lands on a profile, and concludes the viewer is clean. Search and the user card both carry retentionNote and truncated, and search renders it on an EMPTY result too. Search is the one place an operator can conclude something did not happen, and "no matches" invites "then they never said it" -- a claim about a purged table, not about a person. Tested at three levels because each catches what the others cannot: the LIKE escaping and truncation flag in Go, link construction in vitest (five platforms times several missing-field cases, which a browser cannot practically enumerate), and the menu wiring in Playwright. Every assertion was verified by mutating the code it guards -- the e2e ban test is the one that caught a menu item wired straight through to the ban API. vitest is new here and runs in CI beside tsc and oxlint. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
lib/i18n.ts said it plainly: "Only the shared chrome is extracted so far. Page
strings are a mechanical follow-up." Fifteen languages were complete and every
one of them was complete for 135 keys covering the nav shell and three widgets.
An operator who picked Deutsch got a German sidebar and an English application.
Sources, Recordings and Settings are now extracted -- 419 keys, all fifteen
languages at 100%. Settings is the bulk of it and needed no new help text: it
already carried the most thorough inline prose in the app, 15 card descriptions
and 37 notes, all of which were hard-coded English.
The (i) POPOVER is click rather than hover, because a hover tooltip is
unreachable on touch and awkward under a screen reader -- and it vanishes the
moment the pointer moves, which is exactly when someone is reading two sentences
about what a keyframe interval changes. The body is a catalogue key, so the
explanation is translated too; a locale that has not translated a given hint
falls back to English per key rather than showing nothing.
Each hint names its setting in the accessible name. "More information" twelve
times on one page gives a screen-reader user no way to tell which control they
are on.
Rich paragraphs lost their inline <strong>/<em>/<code>. Emphasis sat mid-sentence
and word order moves in translation, so a split key would wrap the emphasis
around the wrong words in most languages; where the stress mattered it was
promoted to a whole sentence instead.
The catalogue tests check what a reviewer cannot check by reading, and each
exists because it caught something real here:
- a corrupted {placeholder}, which stops substituting and renders a literal
brace at the operator;
- a key English does not define;
- an English function word left inside a non-Latin string;
- a Latin word spliced INTO a native one -- the actual defect was 応answerしない,
where "answer" landed inside 応答しない. Korean is excluded from that last
check on purpose: its particles attach directly to Latin words as correct
orthography, so URL이 and polyemesis가 are right and the check is nothing but
false positives there.
None of them judges whether a translation is good; no test can. Incompleteness
is not an error either, since lookup falls back to English per key, so the suite
prints a coverage table rather than failing a lagging locale.
e2e covers what the catalogue tests cannot: that a page actually READS from the
catalogue. A component holding a hard-coded literal passes every JSON check and
still renders English to a German operator.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Auth, the public player, meters and the chat page. Small enough that the interesting part is what they revealed rather than the strings themselves: useT() has to be added to the component that CALLS it, not to the page. Meters needed it on ComplianceRow and chat on ChatPage itself; a page-level hook does not reach a helper component defined below it in the same file, and the failure is a compile error rather than a wrong render, which is the good direction. The public player is the one screen an ordinary viewer sees rather than the operator, so its two sentences matter more per word than anything on a settings card -- somebody who followed a stale link needs to be told to ask for a new one in a language they read. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Two pages whose labels are mostly nouns, so the interesting decisions were about what NOT to translate. The expert panel's placeholders are FFmpeg argument examples -- "-thread_queue_size 2048", "-muxdelay 0.1" -- and they stay in English because they are literal flags a user copies, not prose. Translating them would produce a field whose example does not work. The useT() placement was found by the compiler rather than by reading: tsc reports every line that calls t(), and the owning component is the nearest function above it. Monitoring needed it on ExpertPanel as well as the page. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Dashboard, Routing, Library, ClipEditor, Playout, Jobs, Renditions and
Automation. Every page under src/pages now calls useT(); none holds a
hard-coded sentence.
English only so far. The other fourteen catalogues are unchanged, so those pages
render English in every locale -- exactly what they did before this commit,
because lookup falls back to English per key. The coverage table in
i18n.test.ts reports the gap rather than failing on it.
Placement of the hook was left to the compiler rather than to reading: tsc names
every line that calls t(), and the owning component is the nearest function
above it. That found Playout's CopyField, ExposureBanner, PackagingCard,
ProtectionCard, ShareCard and VariantsCard, none of which a manual pass would
reliably have caught.
KNOWN GAP, recorded because it is not obvious from the diff: the extractor
matched JSX text, string props, toasts and multi-line prose -- and NOT
ternaries. Roughly fifty user-facing strings of the form
`{busy ? "Pushing…" : "Push to platforms"}` are still English literals. A sweep
for them turns up mostly CSS class names and badge variants ("warn" : "muted"),
which must NOT be translated, so the remainder needs reading rather than a
regex. Two of them -- `count === 1 ? "is" : "are"` -- are not translatable at
all in this shape and want their own keys per form, the way lib/i18n.ts already
says plurals have to be handled.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Roughly fifty strings of the form `{busy ? "Pushing…" : "Push to platforms"}`,
which the earlier extractor never looked at.
Rewriting them needed a rule, because most ternaries in this codebase are not
text at all: `warn : muted`, `default : outline`, whole className strings. The
rule used was "rewrite only when BOTH arms are values English defines", and it
was not sufficient on its own -- the compiler caught three cases where both arms
were catalogue values and the expression was still data:
- `const direction: DelayDirection = … ? "video" : "audio"` -- a typed enum
- `const other = … ? "target" : "trigger"` -- an object key, later indexed
- MetersPage's `tracks.map((t) => …)` shadowed the translator, so a t() call
inserted inside it was calling a track object
The first two are reverted to literals; the third is fixed by renaming the map
parameter to `track`, which is what it is.
encodeCost is extracted properly rather than patched: it is a plain helper, not
a component, so it cannot call useT() -- rules of hooks -- and now takes a
Translator as its first parameter. Its four headlines and four details were
object-literal properties built from template literals, which is a third shape
the extractor did not match; the interpolations are now {label} {mpps} {machine}
{escape} placeholders.
STILL OUTSTANDING: a sweep for that shape finds ~61 more strings across twelve
pages, RenditionsPage holding 23 of them.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Object-literal properties, template literals and module-scope tables -- three
shapes the earlier extractor never matched. en.json goes 945 -> 1098 and the
page sweep for hard-coded prose now returns zero.
Module-scope constants were the interesting case. ASPECT_MODES, DEINTERLACE_MODES,
EVENT_LABELS, NORMALIZE_LABEL, LOUDNESS_PRESETS, DELAY_LABEL, DRY_RUN_LABEL and
tlsModeLabel all held English at module scope, where useT() cannot be called at
all. Each now holds a TranslationKey and is translated where it is rendered,
which the type system enforces -- a Record<K, TranslationKey> cannot hold a
sentence.
Plain helpers took the same treatment for the same reason: encodeCost,
encoderProblem, sourceNotes and eventLabel are not components, so they receive a
Translator as their first argument rather than calling a hook illegally.
Four shadowing bugs, all found by the compiler rather than by reading, all of
them a local named `t` that was not the translator:
- SettingsPage's `revoke(t: ApiToken)` -- so t("…") called a token
- RoutingPage's `const t = window.setTimeout(…)` -- a timer handle
- AutomationPage's `meta.events.map((t) => …)` -- an event name
- MetersPage's `tracks.map((t) => …)` -- a track, fixed earlier
Two things the automated pass got wrong and are corrected here. It rewrote a
string inside a DOC COMMENT, turning "Cannot load libcuda.so.1" into a t() call
and inventing a catalogue key for it; the comment is restored and the key
deleted. And it truncated a sentence containing escaped quotes at the first \",
leaving `t("…")created\" or \"ready\"."` behind -- that one was a syntax error,
so the compiler caught it, but the mangled key survived and now holds the whole
sentence with typographic quotes instead.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…ales
447 keys per locale, 6,258 strings, produced by seven parallel agents working
two locales each and verified independently here afterwards rather than on their
own say-so: zero stray keys, zero corrupted placeholders, zero empty values
across all fourteen files, and every one is +447/-0 so no pre-existing
translation was touched.
The catalogue guards did the load-bearing work. Each agent was pointed at
i18n.test.ts and told to make it pass, which is why the placeholder-heavy
strings survived intact -- rend.costMachineCores keeps its LEADING space,
rend.costBusyDetail keeps the space before {escape}, and the six
interpolated cost strings preserve {label} {mpps} {encoder} {machine} {cores}
character-for-character.
The only strings identical to their English source are URL placeholders --
rtsp://camera.local/stream1 and the Discord webhook example -- which is correct.
Playout and Routing stay untranslated as page titles because nav.playout and
nav.routing already are, in every locale including English. Translating the
heading alone would have made the sidebar disagree with the page it opens.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Ten of the fifteen catalogues disagreed with themselves. nav.renditions, rend.title, sources.renditions and dash.renditions name the same feature, and in Polish the sidebar said "Opcje jakości" while the page it opened was titled "Warianty" -- so clicking the link appeared to land somewhere else. Pre-existing, not introduced by the recent extraction: it comes from the original 135-key catalogue and the newer keys, written months apart, each picking their own word. Aligning to each locale's own majority fixed seven of them. It was WRONG for three: Japanese, Korean and Dutch had a majority of レンダリング設定 / 렌더링 설정 / "Renderings", which means "rendering settings" -- and a rendition is not a settings screen, it is one video variant at a given size and bitrate that several destinations share. Those three now use the variant sense (バリエーション / 변형 / Varianten), which is what the minority key already said and what the English means. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
It said three pages and 419 keys. It is every page and 1,098, and the interesting part is not the count but that the prose hid in four different shapes -- the last of which, module-scope tables, could never have called a hook at all. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Every catalogue now carries all 1,098 keys. The application is fully translated: a sweep of src/pages for hard-coded prose returns nothing, and no locale falls back to English for anything. Produced by four parallel agents and audited here rather than on their word: key counts equal, zero stray keys, zero corrupted placeholders, zero empty values, no English function words left in the five non-Latin catalogues, and every file +153/-0 so nothing already translated was disturbed. Two agents disagreed about decimal separators and one of them was right. The de, es, fr and pt-BR batch localised "1.0×" and "−0.4 dBFS" to a comma; the it, nl, pl and tr batch left the point, reasoning they were literal technical values. They are not: a speed multiplier and a dBFS ceiling are quantities, and a German or Polish reader writes those with a comma. Version numbers are the opposite case and keep the point in every locale -- MQTT 5.0 and 5.1 surround are identifiers, not measurements. Ten values normalised. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Six pieces of work that grew out of each other over one day. They are one
commit because they interlock: the RTMP work is what made the track ceiling
worth raising, the ceiling is what made the metering limit reachable, and the
site had to be corrected three times as each landed.
MULTI-SOURCE RTMP (internal/rtmpserver)
How many programmes an install can run no longer depends on which protocol the
encoder speaks. RTMP was capped at exactly one source because `ffmpeg -listen 1`
is a single-connection receiver that cannot demultiplex by path; SRT was
unlimited. That asymmetry was an implementation artifact, not a decision.
RTMP now has the shape SRT already had — one port, addressed by a key,
publish/subscribe in both directions:
srt://host:6000?streamid=<token> unchanged
rtmp://host:1935/live/<streamkey> same model, same one port
The first draft relayed each publisher outward to a per-source FFmpeg on its own
loopback port. datarhei Core does not need that and neither does anyone else:
its internal RTMP server is a pub/sub hub on a single port with FFmpeg pulling
back out of it. Corrected before it shipped.
Media is never decoded. Both ends of gortmplib's Conn work at RTMP message
level, so the bytes reaching FFmpeg are the bytes the encoder sent and Enhanced
RTMP multitrack rides through without this package knowing what a track is. The
only inspection is which messages are stream setup, cached and replayed so a
subscriber that joins late can still decode.
gortmplib was re-measured rather than assumed: 3 transitive modules at v1.0.0,
against yutopp/go-rtmp's 7 at v0.0.7 — which is what the original rejection was
about, and which has not moved since.
Subscribers are loopback-only. A stream key is a publish credential; letting it
authorise playback would turn every ingest key into a viewing key.
MaxTracks 6 -> 32
Six was OBS's limit, not the engine's. Raising it made FFmpeg's amerge ceiling
reachable for the first time, so MetersArgs now covers as many whole tracks as
fit in 64 channels and reports what it dropped, rather than compiling a command
FFmpeg rejects and crash-looping against it.
FIRST-RUN INGEST CHOICE
A fresh install no longer picks an ingest mode on the operator's behalf. SRT and
RTMP are not interchangeable and the difference is not recoverable by guessing.
Storing "unchosen" had to stay legal — the migration creates a source during DB
open, so refusing it stopped the database opening at all.
WEBSITE (web/)
Astro static behind nginx, ~5 KB of JS on one page. Motion earns its bytes on
the one interaction CSS cannot do; scroll reveals use animation-timeline where
it exists.
Two bugs found only by testing the built output: Lightning CSS folded
`animation-timeline` into an `animation` shorthand Chrome rejects, so the scroll
reveals never ran in production; and fixing that exposed a reduced-motion hole
where content was pinned invisible. scripts/check-build.mjs now asserts both,
plus link integrity and the amber reservation, and runs as part of the build.
Also two nginx bugs: `location ~* \.html$` never matched, because the site
serves extensionless URLs, so no page had Cache-Control; and every location
setting its own add_header silently dropped the inherited security headers, so
JS and fonts shipped with no nosniff and no CSP.
ACCURACY
Fifteen claims across the site and docs were checkable against the repo and
wrong. The worst were an install command that could not run, OBS instructions
pointing at a panel with no multi-track selector, moderation and chat-send
claimed for a platform that implements neither, and a "Proof, not a diagram"
block that misdescribed its own screenshot.
Enhanced RTMP multitrack is documented as it was measured: works on FFmpeg 7.1+,
verified end to end; impossible on 6.1.1, which is Ubuntu 24.04's stock build;
unconfirmed with OBS as the publisher. CI now pins the FFmpeg the container
ships instead of testing against a version no user has.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…y set
The RTMP listener binds only when a source actually uses RTMP, and the gate
signalled "not wanted" by passing port 0. reconcileListener treats 0 as
MISCONFIGURED — deliberately, because 0 means "any free port" to the kernel and
would otherwise bind something random and report itself as listening.
So one value carried two meanings, and every fresh install started with:
level=ERROR msg="ingest not started: listener port out of range" port=0
Nothing was broken; the listener was correctly absent. But an ERROR on a clean
start is a bug report waiting to be filed, and the port it names is one the
operator never configured.
Intent now travels as a `wanted bool` rather than smuggled through the port
number, so "deliberately off" and "wrong port" stay distinguishable. Found by
running a fresh install rather than by any test, which is why the acceptance
pass exists.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
A dropdown presented copying and encoding as the same kind of choice. They are
not. Copying is `-c:v copy` and costs nothing; an encode is the most expensive
thing an operator can switch on in this dialog. The control said otherwise, and
the fact that justifies renditions existing — that the encode is SHARED, so the
second destination to use it costs nothing — was a static sentence under a
select, which is where nobody reads it.
Designed against three independent reviews and a survey of datarhei Restreamer,
OBS, Livepeer Studio, Wowza, MediaLive, Ant Media, Oryx/SRS and Owncast. See
docs/notes/video-treatment-ui.md.
WHAT CHANGED
Two radio cards instead of one select, with everything under the second one
collapsed until it is chosen. Restreamer already does this — its filter controls
render only when the codec is not `copy` — and it makes the asymmetry structural
rather than a sentence.
The picker leads with the SPEC (1920×1080 · 60 fps · 6000 kbps · libx264) and
the operator's name second. An encode named by its creator tells the next person
nothing; what it produces tells them everything.
The consequence line is now computed rather than asserted, and says which of two
different things is about to happen:
Feeds 2 destinations · already encoding. This destination joins the
running encode — no new encode starts.
Starts one shared encode when an enabled destination uses it.
And, when leaving an encode — which nothing surveyed tells an operator, and
whose documented remediation in MediaLive is "make a note of the video encode,
in case you need to refer to it again":
Stops the "720p30 backup" encode — no other enabled destination is on it.
2 other enabled destinations stay on "1080p60". Nothing else changes.
THE BUG THAT MADE IT POSSIBLE
The dialog fetched `RenditionView[]` — which carries `destinations` and
`enabledDestinations` — and stripped both off one line later:
.then((rows) => setRenditions(rows.map((r) => r.rendition)))
Every fact above was already on the wire and discarded before render.
Also: one name for the free state. It read four ways for one concept —
"passthrough · copy", "Passthrough — source, copied", "Ingest (passthrough)" —
which is how Wowza ended up shipping "Encode", "Preset" and "Stream Name Group"
for a single thing.
DELIBERATELY NOT DONE
Renditions stay their own page. A rendition is a shared, source-owned resource
with several consumers, not an attribute of one destination; an editor for it
inside one consumer's form teaches that it belongs to that consumer, and the
second destination to select it then looks like it costs another encode. Oryx is
the cautionary tale on the other side: it split this into two unrelated sidebar
features with no field naming which encode a destination sends.
datarhei's `allowCopy` filter — which hides passthrough when the source codec is
not in a destination's accepted list — is rejected, not overlooked.
docs/PLATFORMS.md takes a deliberate position against asserting platform
ceilings, and a hard filter is that assertion. It would hide the cheap option on
a guess.
Tested in the Docker container and deployed to the OVH server.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Answers "can I tweak this encode just for this destination?" — which has no
honest yes, because there is no per-destination override of a shared encode and
there must not be. Editing the shared tier would silently change the picture
every other destination on it receives.
So customising means creating a SECOND encode, and the cost is stated before
the form rather than discovered after it:
Starts a second encode from your source, seeded from "1080p60 shared".
It is not a free variation and it does not change what the other
destinations on that encode receive.
The form is seeded from whatever encode is selected, so "customise" starts from
the thing being customised rather than from blank — the case this exists for is
"that tier, but 4500 kbps for the constrained uplink", and retyping eight fields
is how an operator ends up editing the shared one instead.
Bitrate is the one field that may not be blank. Every other field legitimately
means "keep the source's"; an encode with no target is not a variation of
anything.
Verified in the Docker container: seeded 6000 kbps / 1920×1080 from the shared
tier, refused a blank bitrate, created "1080p60 shared — Twitch" at 4500 kbps,
and left the shared encode at 6000 untouched — which is the safety property the
whole design turns on.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Eleven platforms now carry the encoder settings they themselves publish — resolution, frame rate, bitrate range, keyframe interval — so a destination can offer a starting point instead of an empty form. Every figure carries the URL it came from and the date it was read, and both are REQUIRED by the type rather than optional. That is the whole design. This catalogue already ships ingest hostnames under a disclaimer saying they move without notice; bitrates move faster. Once a number is sitting in a form field an operator cannot tell a researched one from a guess, and they find out during a broadcast. Advisory only. It seeds a form and annotates a choice. It never hides an option, refuses a value or blocks a save — datarhei's allowCopy filter, which suppresses the passthrough option when a platform's accepted-codec list does not match, was considered and rejected for exactly that reason. Suggesting is honest; hiding on the strength of a third-party number is not. WHAT IS DELIBERATELY ABSENT TikTok and PeerTube publish nothing usable — TikTok's LIVE Studio negotiates via its own speed test, PeerTube documents the OBS connection procedure and stops. Twitch publishes no RTMPS endpoint at all. Vimeo's own two pages disagree on the keyframe interval (2s vs 3s). Instagram is unsupported, and a recommended bitrate beside "polyemesis cannot stream here" is a contradiction the operator would have to resolve themselves. "Not published" is a real answer and the catalogue gives it, rather than interpolating from a neighbouring platform. Twitch's tiering is kept unflattened: its ENCODER guidance is the same for everyone, and what is tiered is what happens after ingest — Partners get transcodes on every broadcast, everyone else on availability. A single "Twitch = 6000" with no note would mislead precisely the operators who most need it. THE GUARD THAT WAS MISSING The preset catalogue exists twice: internal/db/platforms.go and a hand-written mirror in DestinationDialog.tsx that lets the picker render before any fetch. A deliberate trade, and completely unguarded — this guidance was added to the Go side and would never have reached the UI. No test, no type error, no warning; the mirror looked correct because it was correct for the fields it already had. preset-drift.test.ts now fails when the two disagree about which platforms exist or which carry guidance. Mutation-verified. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
I researched the platforms' own help pages and did not check the one source that
is machine-readable, version-controlled, and in several cases maintained by the
platforms themselves via pull request: OBS Studio's
plugins/rtmp-services/data/services.json.
It corroborates most of what was shipped and corrects three things.
CORROBORATED
X/Twitter 12000 kbps, keyint 3, 60 fps — exact agreement, independently
Twitch 6000 kbps, keyint 2
Facebook 9000 max, keyint 2, 60 fps, and the same resolution ladder
CORRECTED
Trovo Its own page omits the keyframe interval and is undated; OBS
carries keyint 2, in an entry Trovo maintains. Filled in, and
attributed — OBS also carries a 9000 ceiling against Trovo's
published 6000-for-non-subscribers, which is noted rather than
silently averaged.
Twitch The two sources DISAGREE on audio: Twitch's help page says
160 kbps maximum, OBS says 320. Both are now stated. Picking one
silently is how a catalogue becomes confidently wrong.
YouTube 12000 was labelled as though it were a ceiling. It is YouTube's
RECOMMENDED figure; OBS carries 51000 as the maximum. Different
facts, and conflating them is exactly the error this type's
Source field exists to make visible.
Kick is absent from services.json entirely, so its own documentation remains the
only source — worth knowing, since that makes it the least cross-checked entry
in the catalogue.
Not adopted: services.json also carries `supported video codecs` per service,
which is the data datarhei's allowCopy filter uses to HIDE the passthrough
option. Still rejected, for the reason already recorded — suggesting is honest,
hiding on the strength of a third-party list is not.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…estination
The researched guidance existed in the Go catalogue and nothing showed it. Now
the destination dialog reads it and says, for the platform this destination
actually targets:
Kick publishes: 1920×1080 · 60 fps · 1000–8000 kbps · 2s keyframes
H.264 only — Kick refuses H.265 — and CBR only; it does not accept VBR.
Starting point, not a rule — published by the platform, read 2026-08-06.
FETCHED, NOT MIRRORED
The UI keeps its own preset list so the picker renders before any request
resolves, and that mirror was already unguarded — this session added guidance to
the Go side and it reached nothing. So the numbers are read from
GET /platforms/presets rather than copied: a second copy of a figure that
carries a source and a date would drift silently, which is the one failure this
whole feature is built to avoid.
The provenance is on screen, not in a comment. A bitrate in a form field is
indistinguishable from a guess unless the UI says where it came from and when it
was last read, and this catalogue's disclaimer has always promised exactly that
for hostnames. Numbers move faster than hostnames.
Shown under BOTH treatment cards, not just the encode one: an operator on
passthrough needs to know their 4K source is above what Kick will take rather
more than one who is already encoding, since nothing is going to reshape it for
them.
ONE CLICK TO THOSE NUMBERS
"Fill from what this platform publishes" seeds the variant form, because the
case this exists for is "make this fit Kick" and transcribing four figures by
hand is how one of them gets mistyped.
It seeds the TOP of a published range. The first version seeded the bottom,
which looked reasonable and was wrong: Kick publishes 1000–8000, and 1000 kbps
at 1080p60 is a picture nobody would ship. Where a platform gives a range it is
a range of what it ACCEPTS, and the useful end is the one that looks best —
lowering it is one field away. Caught by running it, not by reading it.
Also fixed: text-primary-bright is a token from the marketing site, not the app.
The theme guard added earlier this session caught it — Tailwind would have
emitted nothing and the link would have had no hover state at all.
Tested in the Docker container and deployed to the OVH server.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR is a broad update across the Go backend, embedded UI, and a new Astro-based marketing site. It introduces a proper multi-source RTMP ingest path (matching SRT’s “one port, addressed by key” model), raises the ingest track ceiling, tightens metering behavior to stay within FFmpeg’s amerge limits, adds chat search and moderation affordances, and ships a static marketing site behind nginx with build-time assertions.
Changes:
- Add multi-source RTMP ingest via
internal/rtmpserver, with loopback-only subscribe semantics and updated ingest URL/args generation. - Raise routing track ceiling (6 → 32) and cap metering to what FFmpeg’s
amergesupports, reporting dropped tracks instead of crash-looping. - Add static marketing site under
web/(Astro + Tailwind + nginx) plus new UI tests/utilities (vitest, i18n e2e checks, theme/preset drift guards) and documentation updates.
Reviewed changes
Copilot reviewed 84 out of 175 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| web/tsconfig.json | Add strict Astro TS config |
| web/src/pages/features.astro | New marketing “Features” page |
| web/src/pages/download.astro | New “Install” page |
| web/src/pages/docs.astro | New docs directory page |
| web/src/pages/comparison.astro | New comparison page |
| web/src/pages/404.astro | New static 404 page |
| web/src/layouts/Base.astro | Base layout + meta/CSP + reveal script |
| web/src/components/SpecStrip.astro | Homepage spec strip component |
| web/src/components/Nav.astro | Marketing nav (desktop/mobile) |
| web/src/components/LoudnessBars.astro | Animated loudness meter component |
| web/src/components/Footer.astro | Marketing footer links |
| web/scripts/check-build.mjs | Post-build assertions (CSS/links/colors) |
| web/public/robots.txt | Robots + sitemap |
| web/public/favicon.svg | Site favicon |
| web/package.json | Astro/Tailwind deps + build scripts |
| web/nginx.conf | Static nginx config (cache + headers) |
| web/nginx-security-headers.conf | Security header snippet |
| web/Dockerfile | Build+serve container (node build + nginx runtime) |
| web/astro.config.mjs | Astro static build configuration |
| web/.dockerignore | Ignore dist/node_modules/etc |
| web/.astro/types.d.ts | Astro generated type refs |
| web/.astro/content.d.ts | Astro content types stub |
| web/.astro/content-modules.mjs | Astro content module map |
| web/.astro/content-assets.mjs | Astro content assets map |
| ui/vitest.config.ts | Scope vitest to unit tests only |
| ui/src/pages/PublicPlayer.tsx | i18n for public player strings |
| ui/src/pages/MetersPage.tsx | i18n + small refactors in meters page |
| ui/src/pages/AuthScreen.tsx | i18n for auth/setup strings |
| ui/src/lib/types.ts | Raise MAX_TRACKS + add new API types |
| ui/src/lib/theme.test.ts | Guard against missing Tailwind color tokens |
| ui/src/lib/preset-drift.test.ts | Guard Go/TS preset drift |
| ui/src/lib/platformLinks.ts | Platform profile/mod-card link logic |
| ui/src/lib/platformLinks.test.ts | Unit tests for platform link logic |
| ui/src/lib/chat.ts | Centralize timeout choices |
| ui/src/lib/api.ts | Add platformPresets + chatSearch API calls |
| ui/src/hooks/useChatSearch.ts | New debounced/stale-safe chat search hook |
| ui/src/components/ui/popover.tsx | Add Radix popover wrapper |
| ui/src/components/InfoHint.tsx | New per-setting help popover component |
| ui/src/components/DestinationCard.tsx | i18n state labels + backup feed rendering |
| ui/src/components/ChatUserCard.tsx | Use shared TIMEOUTS constant |
| ui/src/components/ChatMessageMenu.tsx | New right-click moderation menu |
| ui/src/components/AppLayout.tsx | Fix scrollbar containment via relative |
| ui/package.json | Add vitest + test scripts + radix popover |
| ui/e2e/layout.spec.ts | Strengthen scroll/overflow assertions |
| ui/e2e/i18n.spec.ts | New end-to-end translation coverage |
| ui/e2e/capture.spec.ts | Harden “hero” capture readiness checks |
| SECURITY.md | Update RTMP auth/security wording |
| README.md | Update RTMP/SRT + track ceiling claims |
| internal/rtmpserver/ffprobe_sub_test.go | End-to-end RTMP publish/subscribe test |
| internal/routing/routing_test.go | Update validation tests for MaxTracks |
| internal/routing/profile.go | MaxTracks=32 + MaxMeterChannels + placeholders |
| internal/routing/profile_test.go | Update tests to use MaxTracks constants |
| internal/oauth/capabilities.go | Update Instagram capability text |
| internal/ffmpeg/build.go | RTMP dial semantics + meter track capping |
| internal/ffmpeg/build_test.go | Update RTMP URL/args tests |
| internal/engine/failover_test.go | Allow RTMP primary+backup now |
| internal/db/sources.go | Remove RTMP exclusivity enforcement |
| internal/db/sources_test.go | Assert multiple RTMP sources allowed |
| internal/db/platform_guidance_test.go | Validate guidance provenance/coherence |
| internal/db/ingest_choice_test.go | Ensure fresh installs start ingest unset |
| internal/db/chat.go | Add DB-backed chat search + LIKE escaping |
| internal/api/sources_test.go | Ensure fixture sources explicitly choose SRT |
| internal/api/handlers.go | Enforce ingest choice semantics on settings PUT |
| internal/api/chat.go | Add /chat/search handler + response type |
| internal/api/chat_search_test.go | Add API-level chat search tests |
| internal/api/api.go | Route /chat/search |
| go.mod | Add gortmplib dependency |
| docs/TROUBLESHOOTING.md | Update shared-listener addressing guidance |
| docs/TLS.md | Clarify RTMP auth vs encryption |
| docs/TESTING.md | Update RTMP guidance + add unit test notes |
| docs/superpowers/plans/2026-07-31-whip-monitoring.md | Correct RTMP/FFmpeg assumption |
| docs/roadmap/WEBRTC.md | Correct RTMP dependency reasoning |
| docs/roadmap/README.md | Update track ceiling implications |
| docs/RESEARCH-COMPETITIVE.md | Mark RTMP token routing as shipped |
| docs/PLATFORMS.md | Clarify Instagram “unsupported” meaning |
| docs/OBS.md | Update RTMP/enhanced RTMP guidance |
| docs/notes/copy-review-2026-08-06.md | Marketing copy review notes |
| docs/MODULES.md | Document gortmplib + transitive deps |
| docs/INSTALL.md | Update one-port ingest language |
| docs/FAQ.md | Update RTMP and enhanced RTMP answers |
| docs/DESIGN-ONE-PORT-ONLY.md | Supersede RTMP section + add rationale |
| docs/DESIGN-ONE-PORT-INGEST.md | Update “not doing yet” RTMP section |
| docs/DEPENDENCIES.md | Add gortmplib rationale + corrections |
| docs/CONFIGURATION.md | Update enhancedRtmp removal explanation |
| docs/AUDIO-ROUTING.md | Update max tracks phrasing |
| docs/ARCHITECTURE.md | Update ingest diagram + RTMP rationale |
| docs/API.md | Document /chat/search |
| docker-compose.yml | Update RTMP port comment |
| cmd/polyemesis/main.go | Startup output for unset ingest |
| CHANGELOG.md | Add entries for chat/i18n/test changes |
| .gitignore | Ignore Playwright artifacts + web/dist |
| .github/workflows/ci.yml | Pin FFmpeg 8.1 in CI + run UI unit tests |
Files not reviewed (1)
- ui/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # HTML must revalidate, or a deploy does not reach anyone still holding a tab. | ||
| # | ||
| # This used to be `location ~* \.html$`, which never matched anything. The | ||
| # site serves extensionless URLs — /features is resolved to /features.html | ||
| # by try_files — so the REQUEST URI has no .html for the regex to match, and | ||
| # every page went out with no Cache-Control at all. A page cached by | ||
| # heuristic freshness then keeps referencing /_astro/ hashes that the next | ||
| # deploy has already removed. Matching on the served content type instead is | ||
| # what makes it actually apply. | ||
| location ~* \.html$ { | ||
| include /etc/nginx/snippets/security-headers.conf; | ||
| add_header Cache-Control "no-cache" always; | ||
| } |
| if (!active) { | ||
| seq.current++; // cancel whatever is in flight | ||
| setResults([]); | ||
| setLoading(false); | ||
| setError(""); | ||
| setTruncated(false); | ||
| return; | ||
| } |
| .catch((err: unknown) => { | ||
| if (seq.current !== token) return; | ||
| setResults([]); | ||
| setError(err instanceof Error ? err.message : "Search failed."); | ||
| }) |
| // One name for the free state, everywhere. It read four different ways | ||
| // across the UI — "passthrough · copy" here, "Passthrough — source, copied" | ||
| // in the dialog, "Ingest (passthrough)" in playout — for one concept, which | ||
| // is how Wowza ended up shipping "Encode", "Preset" and "Stream Name Group" | ||
| // for a single thing. | ||
| : "source, copied"; |
| location = /healthz { | ||
| access_log off; | ||
| return 200 "ok\n"; | ||
| add_header Content-Type text/plain always; | ||
| } |
…cally A DATA RACE IN THE NEW RTMP LISTENER. serveSubscriber assigned sub.conn AFTER publishing the subscriber into s.streams, so Stop could read the field while it was being written. The mutex around the map did not help: the write it needed to order was outside it. Publish-then-initialise is the bug; conn is now set before the subscriber is reachable. Every local run passed because none of them used -race, which is the entire reason this shipped. Two regression tests cover it: Stop racing twelve connecting subscribers, and eight concurrent Stops. Verified by reinstating the old ordering -- 11 races reported, 0 with the fix. A STALE PREMISE IN THE BROWSER SUITE. "editing a port does NOT commit on blur" grabbed the first number input on the Sources page. Ingest mode is now an explicit first-run choice with no default, so a fresh source renders only the mode picker and there is no number input to find -- the test died on a 30s timeout. It now chooses a mode, commits it, and then tests the draft mechanism it was always about. The port it was named for moved to Settings. THE FAILOVER SUITE PUBLISHED WITHOUT A STREAM KEY, and could not have noticed. RTMP ingest is now one shared listener addressed by key, so rtmp://host:1938/live reaches nothing and is refused at the handshake -- which surfaces a dozen checks later as an encoder that died with a broken pipe, looking like a failover fault. The driver now reports the source's publish token and the publisher uses it. It passed locally for a worse reason than luck: the suite never built the binary. It ran whatever was in the repo root, which was four hours old, so every local run measured code from before the change while CI built fresh and failed. It builds now, and the failure is fatal -- a suite that cannot build the thing it measures has nothing to say about it. With the build fixed, removing the key reproduces CI exactly: one refusal, exit 1. TESTS FOR THE VIDEO-TREATMENT REDESIGN, which had none. The consequence line was four ternaries inside a template literal in JSX, reachable only by rendering the component with the right server fixtures, so in practice never checked. The arithmetic moves to lib/rendition-consequence.ts and gets 15 unit tests; six mutants -- including the lastOut off-by-one and the enabled guard -- all die. A new Playwright spec covers what units cannot: that the cards are wired to the picker, that it collapses under Copy, and that the line is fed real server usage rather than a constant. Two mutations confirm it: forcing the picker to always render kills the collapse test, and feeding joinConsequence a literal kills the join test. The fourth state -- "N other enabled destinations stay on this encode" -- needed two destinations sharing one encode, which is why it had gone unverified. Also: role="radio" was outside any radiogroup, so the two cards were announced as unrelated controls with no shared name. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
45 strings in DestinationDialog.tsx were hardcoded English -- every label in the
form, the visibility and audience menus, the variant editor, and the two prose
warnings. It was the largest untranslated surface left in the app and the newest,
which is not a coincidence: nothing failed when it was written that way.
The two paragraphs with inline <strong> become one key each rather than being
split around the markup. A sentence fragmented across keys cannot be translated
correctly -- word order is not shared between languages -- so the emphasis is
dropped in favour of the sentence surviving. "{name}" is interpolated rather
than concatenated, for the same reason.
Translated into all 14 non-English locales. The strings that come back identical
to English are cognates and a protocol name, checked individually: "RTMP / RTMPS"
everywhere, plus Transport/Platform/Compliance/Audience/Public/source where that
IS the word in German, French, Dutch, Indonesian, Turkish and Polish.
AND THE GUARD THAT SHOULD HAVE CAUGHT THIS. The coverage check reports rather
than asserts, on the reasoning that i18n.ts falls back per key so a locale that
is behind degrades to readable English. That reasoning is right and is left
alone. What it did not survive is this commit: 45 keys added, translated
nowhere, whole suite green, fourteen locales silently rendering English. Being
visible in a console.log during a passing run is not being visible.
So the level is pinned instead, the way the failover suite pins its restart
counts. Falling behind still does not break the app; it just cannot happen
without someone agreeing to it. Verified by deleting one key and blanking
another -- both fail, with the locale and the key names named.
Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
The header read `status.ingest.state` and rendered "Offline" whenever it was absent. SRT has no ingest child: engine.reconcileIngest returns early for it on purpose, because srtserver delivers datagrams straight into the hub and a second thing on that socket would crash-loop behind a listener that was working. So `ingest` is null for every SRT source, `stateLabel(undefined)` is "Offline", and the most prominent status in the chrome contradicted the meters, the LIVE badge and the API on every healthy install. useIngestLive already existed, already had the right definition -- probed, plus bytes arriving at the relay in the last few seconds -- and its own comment already said `status.ingest.state === "running"` is not the answer. The header was the last place still asking the wrong question. Nothing new is invented here: a `publishing` field was added to the status payload first and then removed, because a second definition of "on air" is the thing that hook exists to prevent. FOUND IN A SCREENSHOT, AND TWICE NEARLY DISMISSED. docs/media showed "Ingest Offline" beside three live destinations and three metering tracks. It was first written off as an artefact of the capture harness injecting into the relay rather than publishing for real; capturing over genuine SRT reproduced it. It was then written off as a stale container image, which it also was -- the image on this machine was a week old. Both were real and both are fixed, and the header was still wrong underneath them. So the capture script builds what it photographs, the way the failover suite now builds what it measures. It checked only that the image EXISTED and reused whatever was there, which means screenshots committed from it documented whatever happened to be built last -- for this repo, code from seven days earlier. A tool whose entire job is to show the product cannot be indifferent to which version of the product it is showing. Screenshots recaptured, and the header now reads "Ingest Live". Guarded by two source-drift tests: that the chrome consults useIngestLive, and that useIngestLive keeps deriving from the relay rather than from the ingest process -- repointing it at status.ingest would restore the bug somewhere nobody would think to look. Behavioural tests cannot reach this: the api harness builds no Manager and drives no relay, so nothing there can make the series non-zero. Verified by reverting the tone to its old form, which fails. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
rtmpserver caches stream-configuration messages and replays them to each new subscriber, because a consumer that arrives after the sequence headers have gone past cannot decode anything without them. isSetup matched AudioExSequenceStart and friends — and E-RTMP does not send those for multitrack. Tracks 2..N arrive WRAPPED in AudioExMultitrack, which carries a TrackID and the real message inside, so what got cached was the legacy track's configuration and nothing else. A late subscriber therefore held coded frames for tracks it had no decoder configuration for. ffprobe, which is exactly such a subscriber, HUNG rather than failing — still waiting to identify streams whose data it already had — and a hang reads as a slow network rather than as a bug. Late is the normal case, not an edge one. The engine's ingest child subscribes when the source is enabled; the operator starts OBS whenever they like. So multitrack over RTMP — the feature this branch exists for — worked only if the subscriber happened to attach first. Found by writing the test that was missing rather than by anything failing: scripts/verify-ertmp-multitrack.py passes six tracks through `ffmpeg -listen 1` and always did, but that stopped being polyemesis's ingest path when the listener became ours, and the script still claimed otherwise. Its docstring now says what it does cover, which is FFmpeg's own multitrack conformance and is worth knowing on its own. The path that ships is covered by a real end-to-end test: FFmpeg publishes three audio tracks through the actual server, ffprobe subscribes, and the assertion is on the SEQUENCE of per-track sample rates rather than on a count. Order is the property that matters — destinations select ingest tracks by index, so a reordering sends the wrong audio to a platform with nothing on screen to show it. Removing the unwrap makes it fail with nothing arriving at all. Two further problems came out of the fix: Resent configuration was appended rather than replacing what it superseded, so the replay list grew for the life of a broadcast and ended with stale configuration ahead of current. Setup is now slot-keyed, one entry per track per kind. And admitSession clears the cache when an encoder reconnects, which is right, but cleared only half of a structure that is now two fields — leaving every index dangling, so the next sequence start wrote past the end of an empty slice and panicked the listener. On reconnect. The failover acceptance suite caught it, because killing the publisher and bringing it back is exactly what it does. Both halves are cleared together in one method now, and the tests call that method rather than a copy of its body — a copy would keep passing after the real one changed. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
It was the repo's only .py file. Everything else that stands up a real stream and measures what comes back — the acceptance drivers, the seeder, the smoketest — is already a `//go:build ignore` main in scripts/, run with `go run`, so this is now the same shape as its neighbours and the toolchain is one language again. Nothing about what it measures changed. Six tones into one FLV, published through `ffmpeg -listen 1`, each arriving track identified by CONTENT rather than counted — because six in and six out looks identical whether or not they were reordered, and a reordering is the failure that matters when destinations select tracks by index. The detector is still a Goertzel filter rather than an FFT, which is why the Python needed no numpy and the Go needs no numeric library. Verified as a port rather than a rewrite: both versions were run against the same FFmpeg and produce the same result, down to the FLV tag histogram — 0x95 x665 and 0xaf x132 for AAC, 0x90 x1 / 0x91 x151 / 0x94 x1 / 0x95 x765 for Opus. Identical counts mean the tag walker and the tone detector agree exactly, not merely that both said MATCH. `-shuffle` tracks the same permutation, and forcing a mismatch produces MISMATCH and exit 1. Flags are Go-style now (`-runs 5`, `-shuffle`), and a bare positional count still works because the docs and shell history are full of it. Intermediate FLVs and .ts files go to a temp directory that is removed on exit rather than being dropped in the working directory; `-keep` prints the path instead. NOTE, because "the only Python file" is not the same as "no Python": install.sh, acceptance-recording-stop.sh and test-lib-cleanup.sh each still shell out to `python3 -c` — for a download without curl, for building a settings document, and for a listening socket `nc -l` cannot provide on macOS. Two of those have real reasons to exist. Removing them is a separate decision and was not made here. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… multitrack scripts/acceptance-obs-multitrack.sh runs OBS headless in Docker and publishes into a real polyemesis. It went looking for one answer and returned two. CONFIRMED, and this was the point of the exercise: OBS's own RTMP connect and handshake are accepted by the shared listener, its stream key is admitted, and what it sends is probed and decodable. Every other test of this path uses FFmpeg as the publisher, and FFmpeg cannot stand in for OBS's handshake or its onMetaData. That gap is now closed. DISPROVED: OBS 30.2.3 does not send multitrack audio over RTMP at all. Three audio tracks, each routed to its own mixer, StreamMultiTrackAudioMixes=7, custom RTMP service — and the captured wire bytes are 0xaf legacy x3541 with no 0x95 multitrack tag anywhere. Captured and walked rather than inferred, using the same tag scanner as the FFmpeg harness. The gate is `supports_additional_audio_track`, tested in rtmp-services.so, and NO service in services.json declares it — 0 of 91. So it is unreachable for every service, custom RTMP included. Note the singular: even enabled, it appears to buy one additional track rather than six. The FAQ said OBS 30.2+ sends multitrack FLV. That was read out of OBS's flv-mux.c, which does implement the format correctly — nothing reaches it. Reading an implementation tells you what the code would do, not whether it runs. Both the FAQ and the design note now say what was measured. polyemesis is not at fault anywhere in this: it received exactly what OBS put on the wire. So the suite asserts fidelity — probed tracks == tracks sent — and pins what OBS sends as a RATCHET. If OBS gains the capability, this fails and says so, which is the notification worth having; a suite that keeps quietly passing while the interesting thing changes is worth nothing. HEADLESS OBS IS THE MACHINERY, and it is most of the work: OBS is a GUI application with no batch mode, so scripts/obs/ supplies a virtual display, a software OpenGL driver, an audio server it refuses to start without, and a generated profile and scene collection. Two things that cost real time and are written down so they do not again: the obsproject PPA publishes amd64 only, so the image silently fell back to universe's 30.0.2 on arm64 until it was switched to noble-backports; and audio sources that are not scene ITEMS are unreferenced, so OBS destroys them on load and streams silence. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
The FFmpeg upgrade downloaded through `python3 -c urllib.request.urlretrieve`, which follows redirects without restricting the scheme. Every other fetch in the installer pins it — the file's own header says so, at length, and explains why: this runs as root and installs binaries a service will execute, so a downgrade to plaintext is a code-execution path rather than a privacy problem. That claim was not true. This fetch writes into /usr/local/bin with install -m 0755, and it was the only one that could be walked down to http. The stated reason for python3 — "neither curl nor wget is guaranteed present" — is correct, and so is the objection to it. A bare ubuntu:24.04 image has curl, wget AND python3 all absent; Debian minimal has python3 and no curl; and the documented way to run this script is `curl ... | sh`, which rather implies curl. Every single choice is wrong on some host somebody actually has, so the fix is not to pick a better one. fetch_https tries curl, then wget --https-only, then python3 via urlopen with the FINAL url checked after redirects — urlretrieve reports only the last response and never the chain, which is what made the original unpinnable. It also refuses a non-https url outright. Verified in containers holding exactly one downloader each: all three fetch byte-identical results (107,974,872), all three refuse http, and a container with none of them fails cleanly rather than appearing to succeed. The full path was then run with curl absent and only python3 present: fetched, extracted, confirmed libsrt, and upgraded 6.1.1 to n8.1.2. That is the case worth caring about, because it is the one the old code was written for and the one a curl-only rewrite would have broken. The polyemesis binary and its checksums keep their inline curl. The header argues for writing the flags at the call site so a reader piping this into a shell can see the scheme being pinned on the line doing the download, and that argument holds hardest for the fetch that matters most. The helper is used only for this optional upgrade. Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
|




A day's work that grew out of itself. Grouped here rather than split because
each piece forced a correction in the next — the RTMP work is what made the
track ceiling worth raising, the ceiling made the metering limit reachable, and
the site had to be corrected three times as each landed.
Multi-source RTMP
How many programmes an install can run no longer depends on which protocol the
encoder speaks. RTMP was capped at one source because
ffmpeg -listen 1cannotdemultiplex by path; SRT was unlimited.
internal/rtmpservergives RTMP theshape SRT already had — one port, addressed by a key, publish/subscribe in both
directions, with this install's FFmpeg subscribing to the same listener the
encoder published to.
Media is never decoded: the relay works at RTMP message level, so Enhanced RTMP
multitrack rides through without the package knowing what a track is.
Subscribers are loopback-only — a stream key is a publish credential and must
not double as a viewing one.
Verified end to end locally and on a real server: two publishers, two named
sources, two independent probes.
MaxTracks 6 → 32
Six was OBS's limit, not the engine's. Raising it made FFmpeg's
amergeceilingreachable for the first time, so the meters now cover as many whole tracks as
fit in 64 channels and report what they dropped, rather than compiling a command
FFmpeg rejects and crash-looping against it.
First-run ingest choice
A fresh install no longer picks an ingest mode on the operator's behalf.
Marketing site (
web/)Astro static behind nginx. Two bugs found only by testing the built output:
Lightning CSS folded
animation-timelineinto a shorthand Chrome rejects, sothe scroll reveals never ran in production; and two nginx rules were wrong — no
HTML page had
Cache-Control, and every location setting its ownadd_headersilently dropped the inherited security headers, so JS and fonts shipped with no
nosniffand no CSP.Per-platform encoder guidance
Eleven platforms carry the settings they themselves publish, cross-checked
against OBS's
services.json. Every figure carries its source URL and the dateit was read, both required by the type. Advisory only — it seeds a form and
never hides an option.
What reviewers should look at first
internal/rtmpserver— new, and the subscriber lifecycle is the subtle part.web/nginx.conf.docs/DESIGN-ONE-PORT-ONLY.md— its RTMP section is superseded rather thanrewritten, and the original argument is kept.
Known-outstanding
web/public/shots/04-meters.pngreads "Ingest Offline". It is a captureartifact, not a product bug —
capture-media.shdocuments the fallback paththat causes it — but it should be recaptured.
ui/src/components.6.1.1, which is itself worth watching.
https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX