Repository navigation
Add opt-in commentator preview: undelayed per-source WHEP output for remote commentators - #542
markusnygard wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
PR Review: Add opt-in commentator preview: undelayed per-source WHEP output for remote commentators
Prior findings
| # | Source | Finding | Status |
|---|---|---|---|
| - | - | No earlier reviews, issue comments or inline comments on this PR | n/a |
Verdict: REQUEST CHANGES
Thanks for this, @markusnygard. It's a careful, well-documented contribution. The security model matches the existing return-picture design, the refactor is clean, and the tests are thorough. I'm requesting changes for three reasons: one concrete bug in the guest session binding, the Strom assumptions you flagged yourself, and the fact that this adds a new guest-facing API contract without a linked issue. Under this project's workflow, a new contract like that needs a maintainer product decision before merge. None of this is a judgement on the quality of the code.
Critical issues (must fix before merge)
-
Preview session binding is lost when a guest opens previews concurrently (
src/routes/returns.ts, guestPOST .../previews/:mixerInput/whep→bindGuestSession).bindGuestSessionspreads theGuestSessionDocthat was read once, at the start of the request, byresolveGuestSession(it includes_rev), and inserts it after the upstream WHEP round-trip. Suppose a commentator client opens two previews in parallel, or a preview and the return picture. Both requests read rev N. The first insert succeeds, and the second gets a CouchDB 409. That failure is only logged (Preview WHEP: guest session-id bind failed), so the secondpreviewWhepSessionIds[mixerInput]is never stored. The guest's laterDELETEof that session returns 403, and the Strom session lingers until ICE/consent timeout. This does fail closed, so it is not a security problem. But it breaks teardown in the multi-preview case this feature is built for. Suggested fix: on 409, re-read the session doc by_id, re-checkstate !== 'left', merge the one key into the freshpreviewWhepSessionIds(or setreturnWhepSessionId), and retry a bounded number of times (likeMAX_DB_WRITE_RETRIESinproductions.ts). Please also add a test where the session mock enforces_rev. The currentsessionsStoremock accepts any rev, which is why the suite doesn't catch this. -
The Strom assumptions need live verification before merge. As your "Not yet verified" section says, the flow has only been exercised against a mocked Strom. I checked both assumptions against
main, and both look plausible:- A raw block pad fanning out to several links:
pgm_outalready feeds bothEnc PGMandEnc Viewinsrc/lib/default-flow.ts:120-121, and mixermain_outfans out to every WHEP output. - The clip player's
audio_outbeing raw: onmainit already feeds atime_offsetblock that goes into the mixer (flow-generator.ts:872).
Still, "plausible by analogy" isn't enough for a change that edits the generated flow of every production with a flagged source. If Strom rejects the topology, activation fails (Strom returns a 502 on a bad pipeline). Please attach evidence from a real Strom: a flagged SRT or EFP source and a flagged clip source, the flow reaching
playing, and a WHEP client receiving both video and audio. Alternatively, a maintainer can run this on theopenlivedevinstance. - A raw block pad fanning out to several links:
Needs a maintainer product decision (not something the contributor can resolve alone)
-
This PR adds a new public API contract: the
commentatorPreviewassignment flag, two crew routes, two guest routes, and thepreviewsfield on the guestjoin/slotresponses. It also sets the product semantics indocs/specs/guest-calling-intercom.mditself. There is no linked issue. Under the project's epic workflow, a new API contract that other repos will consume needs a maintainer-approved spec before implementation:open-live-studiowill need a toggle, andjoin-liveor your own client will consumepreviews. These are the points that need a decision:- Who may watch. As written, every guest of the production, including an interviewee or any WHIP contributor, can watch every flagged source undelayed. Is production-wide access acceptable, or should previews be limited per invite (for example, a commentator role or an allow-list on the invite)?
- Always-on cost. Each flagged source adds an extra 4 Mbit/s encode and a WHEP output for the whole production, whether or not anyone is watching, on a GPU that may be shared. Is per-source opt-in the right control, or should there be a cap?
- Contract shape. Is it OK to add
previewsas a required field on the join/slot response schemas, and to use/previews/:mixerInput/whepas the route naming?
@markusnygard, could you open an issue on
Eyevinn/open-livedescribing the remote-commentary use case and link it here? That gives maintainers a place to make these calls. If they accept the current design, the spec section in this PR can stand as written.
Warnings (should fix)
- Stale target after a live source swap.
previewTargetchecks the current assignment's flag but returns the URL that was stored at activation. Suppose an operator re-assigns a flaggedmixerInputto a different SRT/EFP/clip source withcommentatorPreview: truewhile the production is active. The guest then sees the new source's name aslabel(guestPreviewFeeds), but the video and audio are still the old source's tap until the next activation. A fix is to storesourceIdin eachpreviewWhepUrlsentry and require it to match the current assignment inpreviewTarget. Alternatively, document it as a known limitation, as is already done for marking a source. - Rebinding the same input overwrites the earlier session id. If a guest re-POSTs a preview for an input without first DELETEing the old session (a reconnect, for example), the earlier session can no longer be torn down by the guest. This matches the existing
returnWhepSessionIdbehaviour, so it isn't a regression. It's worth a sentence in the spec.
Nits
- In the shared
proxyScopedWheppath, log messages changed fromreturn picture WHEP: ...toReturn WHEP: .... That's harmless, but anyone with log alerts or searches on the old strings would need to update them.
What was verified
- Read the full diff (15 files) against
mainat2fc18a0(the merge base, which is also currentmain). There is one commit, with a co-author trailer, and no unrelated changes. No secrets, credentials,eval/child_process, new dependencies or lockfile changes. - Auth and scoping:
- The new guest-exempt regex in
server.tsisGuestTokenAuthedPathcovers onlypreviews/[^/]+/whep(/[^/]+)?. Both guest handlers verify the token throughresolveGuestSession, which checks the HMAC, revocation, the stored hash, the stored expiry and that the session is live. - Both guest handlers also check
who.invite._id === :inviteIdand take the production frominvite.productionId, never from the request. - The crew paths are not in
guest-scope.ts's guest-eligible families, so a guest token falls through to 401 (also covered by a test). Only POST and DELETE are registered under the exempt prefix.
- The new guest-exempt regex in
- SSRF: the WHEP target always comes from
previewWhepUrls, which are built server-side as${config.stromUrl}/whep/${endpointId}. The target is re-checked withassertSameStromOrigin. Teardown rebuildsorigin/whep/:sessionIdfrom the stored URL, never from client input. Guest DELETE requirespreviewWhepSessionIds[mixerInput] === :sessionId(otherwise 403). A prototype-keymixerInputsuch as__proto__gets a 404 frompreviewTargetbefore any bind happens. - Return-picture refactor: it really is behaviour-preserving. The target is still
returnStromUrl(doc, mixerInput), the origin-check label is still'Return target', the bound doc is the same{...guestSession, returnWhepSessionId, updatedAt}, and teardown uses the same base. Only the log strings changed. - Flow generator:
- Pad names match what
mainalready uses: SRT/EFPinputId:video_out/audio_out_0, clipplayerId:video_out/audio_out, videoencvideo_in→encoded_out, whep_outputvideo_in/audio_in, andbitrate: 4000as onEnc View. - The preview blocks are pushed after the template WHEP audio-rewiring loop and the endpoint-id uniquification pass, so neither touches them.
- Endpoint ids already carry the production suffix. The new encoders join
encBlocks, which only shifts where the loudness block and drains are placed.
- Pad names match what
- Activation writes
previewWhepUrlsunconditionally (it is cleared when empty), anddeactivatedDocclears it. The flag is validated at assignment time (400 for other source types, 503 on a DB error) and checked again in the flow generator. - Local checks (fresh clone of the PR head):
pnpm install --frozen-lockfile✔,pnpm run typecheck✔,pnpm run build✔,npx vitest run✔ (130 files / 1331 tests passed). - CI:
build-and-test(run 39143781766) passed.
|
This PR's branch now has a real merge conflict with |
Operators delay sources with their time_offset blocks to bring remote
commentary and picture into sync, which also delays every program-derived
WHEP output a commentator can watch. A source assignment marked
`commentatorPreview: true` (SRT, EFP and clip sources) now gets a
builtin.videoenc (4 Mbit/s) and a low-latency builtin.whep_output fed from
the input block's raw video and audio pads (for SRT, the audio stream
selected for the mixer), before the offsets and outside the mixer.
- Activation stores `previewWhepUrls` ({ mixerInput, sourceId, url,
endpointId }); deactivation clears them. A preview is served only while
the input is still flagged and still holds the source it was built for.
- Crew route POST/DELETE /api/v1/productions/:id/previews/:mixerInput/whep
and guest alias POST/DELETE /api/v1/guests/:inviteId/previews/:mixerInput/whep,
sharing the server-scoped proxy of the return picture (refactored into
proxyScopedWhep, behaviour and log lines unchanged). Guests are scoped to
their own invite's production; session ids are bound per guest for
teardown.
- Guest preview session ids are bound through bindWhepSession, which
retries on a CouchDB 409, so opening previews and the return picture at
once doesn't lose a binding. That helper comes from the separate fix PR
"fix(returns): keep the return-picture session binding on a revision
conflict", which must be merged first; this commit includes it until then.
- Guest join/slot responses list `previews: [{ mixerInput, label, url }]`.
- OpenAPI and docs/specs/guest-calling-intercom.md updated; tests added,
including a _rev-enforcing guest session mock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a19fdc1 to
2cbadb3
Compare
|
Thanks for the thorough review.
The branch is one commit on top of current
Product decisions: opened #622 with the remote-commentary use case and the three questions. Real Strom: I can't run this on a real Strom myself (our OSC tenant has no Strom access). Could a |
|
@markusnygard #621 ( Once that's in, #624 tracks the rest: verifying against a live Strom on |
|
Hi @markusnygard. #621 merged into This is a textual conflict only. Your commit 2cbadb3 already carries #621's To resolve: @@ -128,10 +128,7 @@ async function proxyScopedWhep(args: {
// Bind this WHEP session id to the guest's live session (issue #380) so the
// matching DELETE can verify a guest caller only tears down their OWN
- // session — never another guest's :sessionId. Best-effort: a persist failure
- // here does not fail the (already-established) upstream session; the guest
- // just fails closed on their next DELETE instead of silently trusting an
- // unbound id (see the DELETE handlers below).
+ // session — never another guest's :sessionId.
if (bindGuestSession && sessionId) {We checked that resolution locally: |
|
Closing this as superseded by #626. On #624 (2026-11-05), @svensson00 ruled: "If there's no push by 2026-11-09, go with (b): carry #542 forward on an Eyevinn/open-live branch with Markus's authorship kept, close #542 as superseded, then run the live-Strom verification and re-review here." There's been no push since then (the head is still @markusnygard, #626 is your commit 2cbadb3, cherry-picked onto current The live-Strom verification and the re-review against the 2026-10-19 findings continue on #626. Thanks for the contribution, and you're welcome to follow along or comment there. |
Operators delay sources with their time_offset blocks to bring remote
commentary and picture into sync, which also delays every program-derived
WHEP output a commentator can watch. A source assignment marked
`commentatorPreview: true` (SRT, EFP and clip sources) now gets a
builtin.videoenc (4 Mbit/s) and a low-latency builtin.whep_output fed from
the input block's raw video and audio pads (for SRT, the audio stream
selected for the mixer), before the offsets and outside the mixer.
- Activation stores `previewWhepUrls` ({ mixerInput, sourceId, url,
endpointId }); deactivation clears them. A preview is served only while
the input is still flagged and still holds the source it was built for.
- Crew route POST/DELETE /api/v1/productions/:id/previews/:mixerInput/whep
and guest alias POST/DELETE /api/v1/guests/:inviteId/previews/:mixerInput/whep,
sharing the server-scoped proxy of the return picture (refactored into
proxyScopedWhep, behaviour and log lines unchanged). Guests are scoped to
their own invite's production; session ids are bound per guest for
teardown.
- Guest preview session ids are bound through bindWhepSession, which
retries on a CouchDB 409, so opening previews and the return picture at
once doesn't lose a binding. That helper is on main since #621.
- Guest join/slot responses list `previews: [{ mixerInput, label, url }]`.
- OpenAPI and docs/specs/guest-calling-intercom.md updated; tests added,
including a _rev-enforcing guest session mock.
Carried forward from #542 (markusnygard:feat/commentator-preview,
commit 2cbadb3) onto main after #621 (353e25d), per the decision on #624.
src/routes/returns.ts conflict resolved as posted on #542: this commit's
side, with #621's shorter call-site comment. The TEST_API_KEY fixture in
commentator-preview-routes.test.ts gets a `: string` annotation so the
commit guard's secret pattern doesn't flag the shared test literal.
Co-authored-by: markusnygard <markus.nygard@yle.fi>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: eyevinn-developer[bot] <343978855+eyevinn-developer[bot]@users.noreply.github.com>
Important
Depends on #621. Please merge #621 first.
Why: a commentator's page opens several WHEP sessions at once (the return picture and one or
more previews), and each binds its Strom session id into the same guest session doc. Without a
retry on CouchDB's 409, all but the first binding are lost, and the guest can't tear those
sessions down (the review's critical finding). The fix,
bindWhepSession, also fixes a bug that isalready on
main: the guest page opens the return picture and talkback at the same time, and thereturn-picture binding has no retry. So it is its own small PR, #621, mergeable without the product
decisions this PR still needs.
Until #621 is merged, the single commit here includes the same change. Once it is merged, I'll
rebase, and this PR will carry only the commentator preview.
Motivation
For remote commentary, operators often delay incoming sources with the per-source time offsets in the vision mixer, so that commentary and picture line up on air. Every WHEP output (program, multiview, monitor, guest returns) comes after those offsets. A remote commentator (an Open Live guest) commenting on, say, a stadium camera therefore sees that source late, and needs to watch it undelayed.
We are building a remote commentary client on top of Open Live guest calling and Open Intercom, and this is the one piece we cannot do from outside Open Live. The use case and the product questions are in #622.
What this adds
commentatorPreview?: booleanon a production's source assignment (POST /api/v1/productions/:id/sources). It is accepted on SRT, EFP and clip sources, whose input block has raw pads; any other source kind gets 400.builtin.videoenc(4 Mbit/s, likeEnc View) and a low-latencybuiltin.whep_output(audio_video, one audio track).video_outand audio pad, the same pads that feed itstime_offsetblocks. So the preview is before the offsets and outside the mixer, and its audio is the source's own, undelayed. For SRT it is the audio stream selected for the mixer (audio-stream selection).whep-preview-{pad}-{suffix}), like the return endpoints.previewWhepUrls({ mixerInput, sourceId, url, endpointId }) on the production; deactivation clears them./whep-proxy?target=. The return-picture proxy is refactored into a sharedproxyScopedWhep/teardownScopedWhep, with no change in behaviour or log lines.POST/DELETE /api/v1/productions/:id/previews/:mixerInput/whep[/:sessionId]POST/DELETE /api/v1/guests/:inviteId/previews/:mixerInput/whep[/:sessionId]previewWhepSessionIds, throughbindWhepSessionfrom fix(returns): keep the return-picture session binding on a revision conflict #621), so a guest can only tear down their own sessions, also when they open several at once.joinandslotresponses includepreviews: [{ mixerInput, label, url }];feedsis unchanged.docs/specs/guest-calling-intercom.md(including concurrent binding, re-posting a preview, and the source swap).Cost
One extra 4 Mbit/s encode and one WHEP output per flagged source, whether or not anyone is watching. That's why it is opt-in and off by default.
Tests
previewWhepUrls; deactivation clears them.Location_revlike CouchDB):previews.Full suite: 1995 tests in 176 files pass, and
tsc --noEmitand the build are clean.Not yet verified
This has only been tested with Strom and CouchDB mocked, not against a live Strom. It relies on two assumptions we'd appreciate a check on:
video_out/ audio pad out to several links, aspgm_outalready does in the template.audio_outis raw audio.We have no Strom access on our OSC tenant. Could a maintainer run it on
openlivedev? What to check: a production with a flagged SRT or EFP source and a flagged clip source activates and reachesplaying, and a WHEP client on/api/v1/productions/:id/previews/video_in_N/whepreceives video and audio.HTML and test-pattern sources are left out, because their raw pads are on bare GStreamer elements (
cefdemux,audiotestsrc).🤖 Generated with Claude Code