Repository navigation
feat: add video support for intercom lines (WHIP/WHEP, H264) - #684
Conversation
Adds in-call participant video: publish the local camera via WHIP and consume remote video via WHEP (H264 over SMB), with self-preview, per-tile video grid, source pinning, return-feed and fullscreen controls, plus the video-enabled toggle in production setup.
e37fafa to
e94e56f
Compare
birme
left a comment
There was a problem hiding this comment.
Automated code-reviewer verdict (daily-backlog-pr Phase 3): NEEDS CHANGES
Reviewed the full +4752/−261 diff across 69 files with the branch checked out. Feature-scoped work that mirrors the existing audio patterns well, with good coverage of the new pure logic. Two concrete media-lifecycle bugs should be fixed before shipping a camera feature:
High
- Camera capture is never stopped on call exit (privacy/hardware leak).
production-line.tsx:149destructures onlyconst [inputVideoStream] = useVideoInput({...}), discarding the third tuple element — thereset()that stops the camera tracks.useVideoInput.reset(use-video-input.ts:67-72) is the only placevideoInput.getTracks().forEach(t => t.stop())runs, and it's never called. The effect cleanup (:62-64) only setsaborted = true;REMOVE_CALL(global-state-reducer.ts:123) doesn't stopmediaStreamVideoInput; andrtcPeerConnection.close()doesn't stop local getUserMedia tracks. Net: camera stays live + recording indicator on after leaving a video line, until the tab closes. Capture and call the videoreseton exit/unmount. - Frame-monitor
setIntervals leak on unmount.attachShowWhenReady(video-element-factory.ts:470-490) starts a 500ms interval per remote video element, cleared only bystopFrameMonitor, which is called only fromremoveThisElementon trackended/removetrack.useVideoElementscleanup (use-video-elements.ts:11-24) nullssrcObjectbut doesn't callstopFrameMonitor, nor does theuseRtcConnectionteardown. On abrupt teardown/navigation the interval + rVFC loop keep running against detached elements. CallstopFrameMonitorfor every element incleanUpVideo.
Medium
checkbox.tsx:76-87— input madereadOnly+tabIndex={-1}with toggle delegated to wrapperonClick, synthesizing a fakeChangeEventviaas unknown as. Removes keyboard focus/space-to-toggle from a shared primitive (a11y regression + scope creep). Prefer a nativedisabledprop.- PC held in
useState(never recreated) but stream refs are in the effect deps — a stream-ref change wouldclose()the PC and the re-run no-ops behindsignalingState === "closed"guards, dead-ending the call. Latent today (no device-switch re-acquire); add a guard/comment. - No test asserting camera tracks stop / frame monitors clear on exit — exactly where the leaks live.
Nits
- Raw
console.warn/errorin prod paths vs the projectloggerutil;video-element-factory.tsbuilds ~540 lines of tile chrome via imperativecreateElement+inline styles vs Emotionstyled()used elsewhere.
Notes: no user-input URL construction (all via backend API_URL), no new bundle secrets, reducer ERROR change well-contained, CI green. Both High items are small localized fixes.
…n#692) Capture the useVideoInput reset() and invoke it on exit and on unmount so camera tracks (getUserMedia) are stopped, releasing the hardware/indicator. Call stopFrameMonitor for every element in useVideoElements cleanUpVideo so the 500ms frame-monitor setInterval no longer leaks on teardown. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
This PR looks like part of a multi-repo/multi-PR dependency stack together with Eyevinn/intercom-manager#315 (backend video support). Holding off on automated review/merge here — this needs a human (or the relevant implementation agent) to assess the whole stack together, not a per-PR pass. |
|
#692 covers both High items — I'd written the same two fixes independently and landed on the same Two things still open:
This PR currently conflicts with Unrelated, noticed while in there: |
# Conflicts: # src/components/calls-page/calls-page.tsx # src/components/production-line/use-line-polling.ts # src/components/production-line/use-rtc-connection.ts
birme
left a comment
There was a problem hiding this comment.
Code Review — video support (WHIP/WHEP, H264) — Needs Changes
Reviewed by daily-backlog-pr (Phase 3). A large, well-structured video feature that correctly routes all state through the reducer (reusing UPDATE_CALL/extending DEVICES_UPDATED, no ad-hoc action types) and all REST calls through the API object — no blocking architectural or WebRTC violations. But several substantial new modules ship with no tests, there is a camera-track resource leak, and a block of pin-styling code is dead. Per project rules this cluster warrants Needs Changes. CI (build/lint/prettier/tests/e2e) is green — these are correctness/coverage findings, not formatting.
Blocking
- None.
Warnings
- No tests for the largest new modules.
video-element-factory.ts(543 lines: tile factory, draggable PiP, fullscreen, rVFC stall monitor) has no test file;use-video-source-pin.ts(retry/backoff-on-HTTP-425, latest-wins ref) has norenderHooktest;pin-data-channel.tssendPinnedEndpoint(data-channel message shape — a correctness-sensitive SMB contract) is untested;use-video-tile-grid.tsanduse-shadow-rtc-connection.ts(a full second WHIP/WHEP session lifecycle: peer connection, heartbeat, teardown) are untested. These pure/factory/hook units are the highest-value test targets. - Camera-track leak on input change —
use-video-input.ts:45-65. WhenvideoInputIdchanges, the effect cleanup only setsaborted = trueand never stops the previously acquired stream's tracks;setVideoInput(stream)replaces the reference. Switching cameras mid-call leaves the old camera active (indicator light stays on) and the old track detached but unstopped. Cleanup should stop the prior stream's tracks (only the explicitreset()on exit does today). - Dead pin-styling code —
video-element-factory.ts:394-403.updateVideoTileContainerPinnedqueries[data-tile-options-btn], but that attribute is never set on any element created increateVideoTileContainer, so the function always early-returns andcontainer.dataset.pinnedis never set. Pin reordering still works viatile.style.order, so the effect is cosmetic — but this code cannot run as written. - Duplicated sibling-line polling —
production-line.tsx:626-644. Reimplements participant polling as a rawwindow.setInterval(... API.fetchProductionLine ...)every 2s with.catch(() => {}), duplicatinguse-line-polling.tsand silently swallowing all errors.
Suggestions
- Use the project
logger(logger.red/logger.yellow) instead ofconsole.warn/console.error(use-rtc-connection.ts:84,use-video-input.ts:52). use-video-input.ts:54-59— theERRORdispatch omitscallId, so a per-camera failure shows a global banner rather than a call-scoped one.production-line.tsx:620,631,655— inlineimport("./types.ts").TParticipant/TLinerefs; use the existing named import.ice-gathering.tswaitForIceGatheringchanged from reject-on-timeout to resolve-on-timeout (5s→8s). This is a shared function, so it also alters the audio-only flow (a timeout now proceeds to PATCH with partial candidates instead of tearing down). Defensible robustness improvement, but validate against SMB in a real environment.
Cross-repo coupling (hard dependency on intercom-manager #315)
Non-functional in isolation: requires #315's new routes (PATCH .../whep-source, PATCH /session/:id/video-source), the new hasVideo (required)/isWhepReceiver/videoEnabled/whepSourceSessionId response fields (the auto-pin filter p.hasVideo && !p.isWhepReceiver silently no-ops if hasVideo is absent), the HTTP 425 not-ready contract (retry only triggers on 425), and SMB forwarding H264 + honoring PinnedEndpointsChanged. Do not merge ahead of #315 — treat as an atomic pair.
Moving the board item back to Ready for these changes.
# Conflicts: # src/components/production-line/use-line-polling.test.ts
# Conflicts: # src/components/production-line/production-line.tsx # src/components/production-line/use-rtc-connection.test.ts # src/components/production-line/user-list.tsx
# Conflicts: # src/components/production-line/production-line.tsx # src/components/production-line/user-list.tsx
birme
left a comment
There was a problem hiding this comment.
Code Review
Verdict: Needs Changes
Summary: A large, generally well-structured frontend video feature. The core architectural invariants hold: all REST goes through src/api/api.ts, global state flows through globalReducer/UPDATE_CALL, no inline fetch, no jsdom, no yarn, no SDP string-munging or index-based media access, no hardcoded secrets. There are no hard Blocking violations. However, there is a cluster of Warnings — most notably a large set of new pure functions and custom hooks shipped with no tests, an accessibility/typing regression in checkbox.tsx, and a module-level mutable singleton — and per the review rules 3+ Warnings with no Blocking still means Needs Changes. Several places also lean on a backend contract that #315 must honor (see Domain Note).
Blocking
None.
Warnings
src/components/production-line/pin-data-channel.ts:2— New pure functionsendPinnedEndpoint(data-channelPinnedEndpointsChangedserialization, a WHIP/WHEP-domain primitive) ships with no unit test. Pure functions are the highest-value test target in this codebase and this one encodes an SMB message contract. Add a unit test.src/components/production-line/use-video-source-pin.ts,use-video-tile-grid.ts,use-shadow-rtc-connection.ts,src/hooks/use-video-device-labels.ts— Four new custom hooks, none withrenderHooktests, including two large/complex ones (use-video-tile-grid.ts241 lines of imperative DOM reconciliation;use-shadow-rtc-connection.ts185 lines duplicating RTC negotiation).use-video-input.tsanduse-video-elements.tswere correctly tested — the rest should match that bar.src/components/production-line/video-element-factory.ts:1— 550-line factory (frame-stall monitor, fullscreen, PiP, pin overlay) with no test, while the siblingaudio-element-factory.tshas a test. At minimum the pure/observable exports should be covered.src/components/checkbox/checkbox.tsx:70-73—toggle()fabricates a synthetic event withas unknown as React.ChangeEvent<HTMLInputElement>(type cast without a justifying comment, and fragile — it only works because consumers reade.target.checked). Compounding this, the real<input>isreadOnlywithtabIndex={-1}(line 82-83) and the toggle is wired to the wrappingdiv'sonClick, so the control is not keyboard-focusable and has norole/key handler — an accessibility regression versus a native checkbox.src/hooks/use-video-device-labels.ts:5—warmedUpInSessionis a module-level mutable boolean only reset inside the.catch. After one successful warm-up the effect never re-runs, so the video device list will not refresh when a camera is plugged in/out mid-session, and the module-level state makes the hook stateful across unmounts/remounts and hard to test deterministically. Prefer a ref or state keyed to the hook/provider.src/components/production-line/use-shadow-rtc-connection.ts:68-69—parseInt(productionId, 10)/parseInt(lineId, 10)are forwarded toAPI.offerAudioSessionwith noisNaNguard. This is the exact parseInt-without-NaN-check pattern the project flags; a malformed id silently forwardsNaNto the backend.- Silent error swallowing:
src/components/production-line/use-rtc-connection.ts:182(videoElement.play().catch(() => {})in theunmutehandler) andsrc/components/production-line/use-video-source-pin.ts:41(.catch(() => {})onsetLineWhepSource). These hide failures with no logging. At least route throughloggerso pin/playback failures are diagnosable. src/components/production-line/types.ts:32/src/api/api.ts:50—TParticipant.hasVideois typed as requiredboolean, but it crosses an untypedfetch→JSON boundary and is a new field that only backend #315 provides. If #315 omits it on any path, everyp.hasVideoisundefined(falsy), socomputeVideoTileLabelsand the stale-tile logic silently drop/mislabel tiles with no type error. Consider typing it optional until #315 is confirmed to always return it (noteisWhepReceiver?right above is already optional — the two are inconsistent).
Suggestions
src/components/production-line/use-rtc-connection.ts:328—callIdadded to thenoStreamErroreffect deps but not read in the body; drop it.src/components/production-line/ice-gathering.ts:30— Behavior changed from reject-on-timeout to resolve-on-timeout (5s→8s), now also affecting the audio path. Reasonable (half-trickle), but add a one-line comment that resolve-on-timeout is intentional.src/components/production-line/use-rtc-connection.ts:49-61—applyVideoSenderParametersis fire-and-forget; a short comment noting the intentional non-await would help.src/components/production-line/video-options-modal.tsx— Named*-modal.tsxbut renders a custom dropdown, not the unifiedModal. It's genuinely a popover, so the rule doesn't strictly apply — consider renaming to*-dialogue.tsx.- Pinning has three parallel mechanisms (
setLineWhepSource,setSessionVideoSourcewith 425-retry, and thePinnedEndpointsChangeddata-channel message). A short architecture comment tying them together would reduce divergence risk.
Domain Note
This change is squarely in the WHIP/WHEP session-lifecycle, video routing, and data-channel-pinning domain. It assumes several backend contracts from the companion PR Eyevinn/intercom-manager#315 that cannot be validated from the frontend alone:
- New response fields:
TParticipant.hasVideo/isWhepReceiver,TLine.videoEnabled/whepSourceSessionId. - New endpoints:
PATCH production/{id}/line/{id}/whep-source,PATCH session/{id}/video-source, plusvideoEnabledonPOST .../line. use-video-source-pin.tsretries only on HTTP 425 Too Early — if #315 signals "WHEP source not ready" with any other status, the retry loop is dead code.- The SMB data-channel message shape
{ type: "PinnedEndpointsChanged", pinnedEndpoints: [...] }.
Recommend consulting the intercom-expert agent to validate the pinning/WHEP-source lifecycle and the 425 retry handshake against #315 before merge.
Automated review by the daily-backlog-pr pipeline (code-reviewer). This PR is the frontend half of a cross-repo video-support stack with intercom-manager#315 — see the note on that PR about coordinated merge.
|
Update from today's daily-backlog-pr run: the backend companion Eyevinn/intercom-manager#315 ("feat: add video support for intercom lines (WHIP/WHEP, H264)") has since merged. The reason this PR was held (an open cross-repo dependency stack needing joint assessment) has therefore partly cleared — the backend half it depends on is now in This run is still not auto-reviewing/merging this one: it's a large, human-authored video-support feature, and a maintainer should confirm the frontend half matches the merged backend contract (and whether the still-draft |
birme
left a comment
There was a problem hiding this comment.
Code Review
Verdict: LGTM
Summary: Re-review of the current head after the author's follow-up commits ("address review feedback" 2026-10-20 and "stale video tile bug fix" 2026-10-22). All concerns from the three prior Needs-Changes rounds are resolved.
Blocking
None.
Warnings
None.
Notes
- Missing tests from the prior reviews now all exist:
pin-data-channel.test.ts,use-video-source-pin.test.ts,use-video-tile-grid.test.ts+.lifecycle.test.ts,use-shadow-rtc-connection.test.ts(stubsRTCPeerConnection, assertssrcObject),use-video-device-labels.test.ts, plususe-video-input/use-video-elements/video-element-factory/match-video-tile-labels/pinnable-participants/fullscreen-overlay-utils/ice-gathering. Nojsdomenv override, nowaitForTimeout. checkbox.tsxa11y/typing regression fixed (real<label>wrapper,:focus-visible/:disabled,!!checked, eslint-disable removed).- No module-level mutable singleton in the new code. REST goes through
src/api/api.ts+handleFetchRequest(setLineWhepSource,setSessionVideoSource). No new dependencies,yarn.lockuntouched.use-shadow-rtc-connection.tspasses backend SDP through without string munging ormedia[n]indexing and guards a missing answer SDP;parseIntuses radix 10.
Backend companion Eyevinn/intercom-manager#315 is already merged; CI is green (lint, prettier, builds, tests, e2e).
Optional (non-blocking) suggestions: a short lifecycle-invariant comment atop use-video-tile-grid.ts, and shouldForwardProp/$disabled for the disabled prop on the styled.label in checkbox.tsx.
Adds in-call participant video: publish the local camera via WHIP and consume remote video via WHEP (H264/VP8 over SMB), with self-preview, per-tile video grid, source pinning, return-feed and fullscreen controls, plus the video-enabled toggle in production setup.
Closes #692
Companion PR (backend): Eyevinn/intercom-manager#315