@lumencast/runtime 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/app.d.ts +6 -1
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +3 -1
  5. package/dist/app.js.map +1 -1
  6. package/dist/{broadcast-ryjLRD5q.js → broadcast-L5wm2I6J.js} +3 -3
  7. package/dist/{broadcast-ryjLRD5q.js.map → broadcast-L5wm2I6J.js.map} +1 -1
  8. package/dist/{control-AgxbXOVS.js → control-eEUG7unp.js} +4 -4
  9. package/dist/{control-AgxbXOVS.js.map → control-eEUG7unp.js.map} +1 -1
  10. package/dist/index-Clrya_9l.js +1281 -0
  11. package/dist/index-Clrya_9l.js.map +1 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.html +1 -1
  15. package/dist/index.js +11 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/lumencast.js +18 -13
  18. package/dist/mount.d.ts.map +1 -1
  19. package/dist/mount.js +11 -0
  20. package/dist/mount.js.map +1 -1
  21. package/dist/overlay/runtime-context.d.ts +10 -0
  22. package/dist/overlay/runtime-context.d.ts.map +1 -1
  23. package/dist/overlay/runtime-context.js.map +1 -1
  24. package/dist/render/bundle.d.ts +1 -1
  25. package/dist/render/bundle.d.ts.map +1 -1
  26. package/dist/render/bundle.js.map +1 -1
  27. package/dist/render/primitives/capture.d.ts +13 -4
  28. package/dist/render/primitives/capture.d.ts.map +1 -1
  29. package/dist/render/primitives/capture.js +54 -22
  30. package/dist/render/primitives/capture.js.map +1 -1
  31. package/dist/render/primitives/index.d.ts.map +1 -1
  32. package/dist/render/primitives/index.js +4 -0
  33. package/dist/render/primitives/index.js.map +1 -1
  34. package/dist/render/primitives/live-peer-video.d.ts +27 -0
  35. package/dist/render/primitives/live-peer-video.d.ts.map +1 -0
  36. package/dist/render/primitives/live-peer-video.js +64 -0
  37. package/dist/render/primitives/live-peer-video.js.map +1 -0
  38. package/dist/render/primitives/media.d.ts +37 -12
  39. package/dist/render/primitives/media.d.ts.map +1 -1
  40. package/dist/render/primitives/media.js +43 -17
  41. package/dist/render/primitives/media.js.map +1 -1
  42. package/dist/render/primitives/meet-peer.d.ts +31 -0
  43. package/dist/render/primitives/meet-peer.d.ts.map +1 -0
  44. package/dist/render/primitives/meet-peer.js +46 -0
  45. package/dist/render/primitives/meet-peer.js.map +1 -0
  46. package/dist/render/prop-allowlist.d.ts.map +1 -1
  47. package/dist/render/prop-allowlist.js +27 -1
  48. package/dist/render/prop-allowlist.js.map +1 -1
  49. package/dist/render/tree.js +42 -8
  50. package/dist/render/tree.js.map +1 -1
  51. package/dist/{status-pill-BxCdj-KZ.js → status-pill-elORkMrh.js} +2 -2
  52. package/dist/{status-pill-BxCdj-KZ.js.map → status-pill-elORkMrh.js.map} +1 -1
  53. package/dist/{test-CaRHj_J6.js → test-7q_KJkdX.js} +4 -4
  54. package/dist/{test-CaRHj_J6.js.map → test-7q_KJkdX.js.map} +1 -1
  55. package/dist/{tree-BLIxJbD3.js → tree-BMxx5170.js} +522 -436
  56. package/dist/tree-BMxx5170.js.map +1 -0
  57. package/dist/types.d.ts +13 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/webrtc/index.d.ts +76 -0
  60. package/dist/webrtc/index.d.ts.map +1 -0
  61. package/dist/webrtc/index.js +180 -0
  62. package/dist/webrtc/index.js.map +1 -0
  63. package/dist/webrtc/meet-viewer.d.ts +139 -0
  64. package/dist/webrtc/meet-viewer.d.ts.map +1 -0
  65. package/dist/webrtc/meet-viewer.js +379 -0
  66. package/dist/webrtc/meet-viewer.js.map +1 -0
  67. package/dist/webrtc/peer-stream-registry.d.ts +21 -0
  68. package/dist/webrtc/peer-stream-registry.d.ts.map +1 -0
  69. package/dist/webrtc/peer-stream-registry.js +77 -0
  70. package/dist/webrtc/peer-stream-registry.js.map +1 -0
  71. package/package.json +4 -4
  72. package/src/app.tsx +9 -0
  73. package/src/index.ts +35 -0
  74. package/src/mount.ts +11 -0
  75. package/src/overlay/runtime-context.tsx +10 -0
  76. package/src/render/bundle.ts +11 -1
  77. package/src/render/primitives/capture.tsx +73 -28
  78. package/src/render/primitives/index.ts +4 -0
  79. package/src/render/primitives/live-peer-video.tsx +90 -0
  80. package/src/render/primitives/media.tsx +66 -17
  81. package/src/render/primitives/meet-peer.tsx +57 -0
  82. package/src/render/prop-allowlist.ts +27 -1
  83. package/src/render/tree.tsx +44 -8
  84. package/src/types.ts +13 -0
  85. package/src/webrtc/index.ts +252 -0
  86. package/src/webrtc/meet-viewer.ts +497 -0
  87. package/src/webrtc/peer-stream-registry.ts +93 -0
  88. package/dist/index-DrXsLYhe.js +0 -903
  89. package/dist/index-DrXsLYhe.js.map +0 -1
  90. package/dist/tree-BLIxJbD3.js.map +0 -1
@@ -0,0 +1,497 @@
1
+ // WebRTC viewer (mesh, VIEWER role) — ADR 006 §3.3 (C1), issue #3.
2
+ //
3
+ // A viewer-only port of `Prism/src/renderer/src/lib/meet-client.ts` (§1.4) : it
4
+ // joins a Meet room, receives N peers and exposes one `MediaStream` per peer.
5
+ // It NEVER publishes : no `getUserMedia`, no local stream, every transceiver is
6
+ // `recvonly`. Recovering a WebRTC flow needs no capture permission (ADR §2),
7
+ // which is the whole point of the pivot — Solar-CEF (on-air) and the Prism
8
+ // return webview can both be viewers without a capture grant.
9
+ //
10
+ // It reuses the reference's hard-won mesh logic verbatim in spirit :
11
+ // - perfect-negotiation (polite/impolite glare handling) ;
12
+ // - symmetric m-line ordering (audio transceiver first, video second) ;
13
+ // - STUN-first + per-URL TURN iceServers ;
14
+ // - one aggregated `MediaStream` per peer (`track` → addTrack, `ended` →
15
+ // removeTrack), handed to the consumer as `RemoteTrackEvent`.
16
+ //
17
+ // OWNERSHIP : the viewer owns each peer's `RTCPeerConnection` AND the
18
+ // `MediaStream` it aggregates. It is the SOLE authority on the track lifecycle —
19
+ // created on `track`, removed on `ended` / `peer-left` / `connectionstatechange
20
+ // failed|closed`, all torn down on `leave()`. A downstream `<video srcObject>`
21
+ // (the `media` primitive #4) is a pure consumer : unmounting it clears its own
22
+ // `srcObject` and stops nothing. A returning mirror can never kill a peer for
23
+ // the on-air composite.
24
+ //
25
+ // PEER LABEL : the transverse invariant gravé by Conduit (ZabCam contract #6) is
26
+ // a strict STRING equality — `peer_label == MeetClientOptions.name ==
27
+ // RemoteTrackEvent.peerName`, all matching `^[a-z][a-z0-9_-]{0,63}$`. Publishers
28
+ // (#5 Prism / #2 Pulsar) join with `name = peer_label` ; it comes back on the
29
+ // remote side as `PeerInfo.name`, surfaced verbatim as `RemoteTrackEvent.peerName`.
30
+ // The viewer therefore resolves `peer_label → MediaStream` by indexing peers by
31
+ // `peerName` (== label), NEVER by the opaque `peerId`. No separate label channel
32
+ // is needed : the join announce already carries it.
33
+
34
+ export type PeerRole = "publisher" | "viewer";
35
+
36
+ export interface PeerInfo {
37
+ id: string;
38
+ /** The peer's join name — the STABLE `peer_label` (ZabCam contract #6). */
39
+ name: string;
40
+ role: PeerRole;
41
+ }
42
+
43
+ export type SignalPayload =
44
+ | {
45
+ kind: "sdp";
46
+ description: { type: "offer" | "answer" | "pranswer" | "rollback"; sdp: string };
47
+ }
48
+ | {
49
+ kind: "ice";
50
+ candidate: {
51
+ candidate: string;
52
+ sdpMid: string | null;
53
+ sdpMLineIndex: number | null;
54
+ usernameFragment?: string | null;
55
+ };
56
+ }
57
+ | { kind: "control"; event: "screen-start" | "screen-stop" | "mute" | "unmute" };
58
+
59
+ export type ClientMessage =
60
+ | { type: "join"; name: string; role?: PeerRole }
61
+ | { type: "signal"; to: string; payload: SignalPayload }
62
+ | { type: "leave" };
63
+
64
+ export type ServerMessage =
65
+ | {
66
+ type: "joined";
67
+ peerId: string;
68
+ roomId: string;
69
+ role: PeerRole;
70
+ peers: PeerInfo[];
71
+ turn: { urls: string[]; username: string; credential: string; ttl: number };
72
+ }
73
+ | { type: "peer-joined"; peer: PeerInfo }
74
+ | { type: "peer-left"; peerId: string }
75
+ | { type: "signal"; from: string; payload: SignalPayload }
76
+ | { type: "error"; code: string; message: string };
77
+
78
+ export interface RemoteTrackEvent {
79
+ peerId: string;
80
+ /** The peer's join name == its STABLE `peer_label` (Conduit invariant #6 :
81
+ * `peer_label == name == peerName`, strict string equality). This is the key
82
+ * a `meet.peer` LSML node's `peerLabel` resolves against — never `peerId`. */
83
+ peerName: string;
84
+ stream: MediaStream;
85
+ }
86
+
87
+ type EventMap = {
88
+ joined: { peerId: string; peers: PeerInfo[] };
89
+ "peer-joined": PeerInfo;
90
+ "peer-left": { peerId: string; peerName: string };
91
+ "remote-track": RemoteTrackEvent;
92
+ "connection-state": { peerId: string; state: RTCPeerConnectionState };
93
+ error: { code: string; message: string };
94
+ close: { code: number; reason: string };
95
+ };
96
+
97
+ type Listener<K extends keyof EventMap> = (event: EventMap[K]) => void;
98
+
99
+ interface RemoteState {
100
+ info: PeerInfo;
101
+ pc: RTCPeerConnection;
102
+ stream: MediaStream;
103
+ makingOffer: boolean;
104
+ ignoreOffer: boolean;
105
+ pendingCandidates: RTCIceCandidateInit[];
106
+ }
107
+
108
+ /** Minimal injectable factory for the WebSocket + RTCPeerConnection, so the
109
+ * viewer is testable without a real browser stack. Defaults to the globals. */
110
+ export interface MeetViewerDeps {
111
+ WebSocket: typeof WebSocket;
112
+ RTCPeerConnection: typeof RTCPeerConnection;
113
+ MediaStream: typeof MediaStream;
114
+ }
115
+
116
+ export interface MeetViewerOptions {
117
+ signalingUrl: string;
118
+ roomId: string;
119
+ /** Room/viewer token, sent as a query param to the signaling WS. */
120
+ token: string;
121
+ /** This viewer's announce name on the mesh (it does not publish a stream). */
122
+ name: string;
123
+ deps?: Partial<MeetViewerDeps>;
124
+ }
125
+
126
+ export class MeetViewer {
127
+ private ws: WebSocket | null = null;
128
+ private remotes = new Map<string, RemoteState>();
129
+ private iceServers: RTCIceServer[] = [];
130
+ private selfId: string | null = null;
131
+ private listeners = new Map<keyof EventMap, Set<Listener<never>>>();
132
+ private readonly deps: MeetViewerDeps;
133
+
134
+ constructor(private readonly options: MeetViewerOptions) {
135
+ this.deps = {
136
+ WebSocket: options.deps?.WebSocket ?? globalThis.WebSocket,
137
+ RTCPeerConnection: options.deps?.RTCPeerConnection ?? globalThis.RTCPeerConnection,
138
+ MediaStream: options.deps?.MediaStream ?? globalThis.MediaStream,
139
+ };
140
+ }
141
+
142
+ on<K extends keyof EventMap>(type: K, listener: Listener<K>): () => void {
143
+ const set = this.listeners.get(type) ?? new Set<Listener<never>>();
144
+ this.listeners.set(type, set);
145
+ set.add(listener as Listener<never>);
146
+ return () => set.delete(listener as Listener<never>);
147
+ }
148
+
149
+ /** Join the room as a VIEWER (recvonly). No capture, no publish. */
150
+ join(): Promise<void> {
151
+ return this.openSocket();
152
+ }
153
+
154
+ /** Leave and tear down every peer connection + aggregated stream. As the
155
+ * track owner, this is where the streams (and the device-side tracks) end. */
156
+ leave(): void {
157
+ this.send({ type: "leave" });
158
+ this.ws?.close(1000, "viewer-leave");
159
+ }
160
+
161
+ /* ---- Socket ------------------------------------------------------- */
162
+
163
+ private openSocket(): Promise<void> {
164
+ const url = new URL(this.options.signalingUrl);
165
+ url.searchParams.set("room", this.options.roomId);
166
+ url.searchParams.set("token", this.options.token);
167
+
168
+ return new Promise((resolve, reject) => {
169
+ const ws = new this.deps.WebSocket(url.toString());
170
+ this.ws = ws;
171
+
172
+ const onOpen = () => {
173
+ ws.removeEventListener("error", onError);
174
+ // role:"viewer" — the signaling server allocates no publish slot.
175
+ this.send({ type: "join", name: this.options.name, role: "viewer" });
176
+ resolve();
177
+ };
178
+ const onError = (event: Event) => {
179
+ ws.removeEventListener("open", onOpen);
180
+ reject(event);
181
+ };
182
+
183
+ ws.addEventListener("open", onOpen, { once: true });
184
+ ws.addEventListener("error", onError, { once: true });
185
+ ws.addEventListener("message", (ev) => void this.onMessage((ev as MessageEvent).data));
186
+ ws.addEventListener("close", (ev) => {
187
+ this.tearDown();
188
+ this.emit("close", { code: (ev as CloseEvent).code, reason: (ev as CloseEvent).reason });
189
+ });
190
+ });
191
+ }
192
+
193
+ private send(msg: ClientMessage): void {
194
+ if (this.ws && this.ws.readyState === this.deps.WebSocket.OPEN) {
195
+ this.ws.send(JSON.stringify(msg));
196
+ }
197
+ }
198
+
199
+ private tearDown(): void {
200
+ for (const r of this.remotes.values()) r.pc.close();
201
+ this.remotes.clear();
202
+ }
203
+
204
+ /* ---- Protocol ----------------------------------------------------- */
205
+
206
+ private async onMessage(raw: unknown): Promise<void> {
207
+ let msg: ServerMessage;
208
+ try {
209
+ msg = JSON.parse(String(raw)) as ServerMessage;
210
+ } catch {
211
+ return;
212
+ }
213
+
214
+ switch (msg.type) {
215
+ case "joined": {
216
+ this.selfId = msg.peerId;
217
+ // TURN ONLY (no STUN). This viewer runs in an Electron <webview> whose
218
+ // P2P stack has NO mDNS resolver, so host `.local` candidates never
219
+ // resolve (-105), and it can't resolve STUN *hostnames* either
220
+ // (stun.l.google.com / stun.cloudflare.com → -105). The only viable
221
+ // path is the relay, and the server now advertises TURN by IP
222
+ // (turn:51.91.126.43:3478), which needs no DNS. Dropping the unusable
223
+ // STUN hostnames removes the -105 noise and the dead srflx gathering.
224
+ this.iceServers = msg.turn.urls.map((url) => ({
225
+ urls: url,
226
+ username: msg.turn.username,
227
+ credential: msg.turn.credential,
228
+ }));
229
+ this.emit("joined", { peerId: msg.peerId, peers: msg.peers });
230
+ for (const peer of msg.peers) this.ensureRemote(peer);
231
+ break;
232
+ }
233
+ case "peer-joined": {
234
+ this.emit("peer-joined", msg.peer);
235
+ this.ensureRemote(msg.peer);
236
+ break;
237
+ }
238
+ case "peer-left": {
239
+ const remote = this.remotes.get(msg.peerId);
240
+ if (remote) {
241
+ // The pc owns the tracks — closing it ends them. The registry/consumer
242
+ // are notified via the peer-left event (label-keyed).
243
+ remote.pc.close();
244
+ this.remotes.delete(msg.peerId);
245
+ this.emit("peer-left", { peerId: msg.peerId, peerName: remote.info.name });
246
+ }
247
+ break;
248
+ }
249
+ case "signal": {
250
+ await this.handleSignal(msg.from, msg.payload);
251
+ break;
252
+ }
253
+ case "error": {
254
+ this.emit("error", { code: msg.code, message: msg.message });
255
+ break;
256
+ }
257
+ }
258
+ }
259
+
260
+ private async handleSignal(from: string, payload: SignalPayload): Promise<void> {
261
+ let remote = this.remotes.get(from);
262
+ if (!remote) {
263
+ remote = this.ensureRemote({ id: from, name: from.slice(0, 8), role: "publisher" });
264
+ }
265
+ const { pc } = remote;
266
+
267
+ if (payload.kind === "sdp") {
268
+ const desc = payload.description;
269
+ const offerCollision =
270
+ desc.type === "offer" && (remote.makingOffer || pc.signalingState !== "stable");
271
+ remote.ignoreOffer = !this.isPolite(from) && offerCollision;
272
+ if (remote.ignoreOffer) return;
273
+
274
+ await pc.setRemoteDescription(desc);
275
+ for (const c of remote.pendingCandidates) {
276
+ try {
277
+ await pc.addIceCandidate(c);
278
+ } catch {
279
+ /* ignore late/stale candidates */
280
+ }
281
+ }
282
+ remote.pendingCandidates = [];
283
+
284
+ if (desc.type === "offer") {
285
+ await pc.setLocalDescription();
286
+ if (pc.localDescription) {
287
+ this.sendSignal(from, {
288
+ kind: "sdp",
289
+ description: {
290
+ type: pc.localDescription.type as "offer" | "answer" | "pranswer" | "rollback",
291
+ sdp: pc.localDescription.sdp,
292
+ },
293
+ });
294
+ }
295
+ }
296
+ return;
297
+ }
298
+
299
+ if (payload.kind === "ice") {
300
+ const init = payload.candidate;
301
+ if (pc.remoteDescription) {
302
+ try {
303
+ await pc.addIceCandidate(init);
304
+ } catch (err) {
305
+ if (!remote.ignoreOffer) throw err;
306
+ }
307
+ } else {
308
+ remote.pendingCandidates.push(init);
309
+ }
310
+ return;
311
+ }
312
+ // control payloads ignored — the viewer does not act on screen/mute hints.
313
+ }
314
+
315
+ /* ---- Peer setup --------------------------------------------------- */
316
+
317
+ private ensureRemote(peer: PeerInfo): RemoteState {
318
+ const existing = this.remotes.get(peer.id);
319
+ if (existing) return existing;
320
+
321
+ // relay-only: in the Electron <webview> host (.local) and srflx candidates
322
+ // are unusable (no mDNS resolver, STUN hostnames unresolvable), so force the
323
+ // ICE agent to gather/use ONLY relay candidates via the by-IP TURN server.
324
+ // The publisher (browser) gathers all transports incl. relay, so the
325
+ // relay↔relay pair connects. Avoids ICE stalling on dead host/srflx checks.
326
+ const pc = new this.deps.RTCPeerConnection({
327
+ iceServers: this.iceServers,
328
+ iceTransportPolicy: "relay",
329
+ });
330
+ const stream = new this.deps.MediaStream();
331
+
332
+ // VIEWER : both transceivers are recvonly and carry NO local track. Order
333
+ // (audio first, video second) must match the publisher's so the m-lines
334
+ // line up — the same invariant as the reference.
335
+ const audioTx = pc.addTransceiver("audio", { direction: "recvonly" });
336
+ const videoTx = pc.addTransceiver("video", { direction: "recvonly" });
337
+
338
+ // BUNDLE codec-collision fix (ADR 006 #3). A recvonly viewer offering the
339
+ // FULL Chromium codec catalogue (VP8/VP9×profiles/H264×profiles/AV1/H265 +
340
+ // rtx/red/ulpfec/flexfec, audio opus + telephone-event) maximises payload-
341
+ // type pressure. Under BUNDLE all m-lines share ONE PT namespace, so when
342
+ // the publisher (answerer, with pre-allocated sendrecv transceivers — see
343
+ // `meet-client.ts`) reconciles that dense offer, PTs collide ACROSS m-lines
344
+ // (the observed `126` audio telephone-event vs `39` video H264). A pure
345
+ // viewer needs ONE coherent codec per kind, not the whole catalogue : we
346
+ // pin a deduplicated, minimal preference set so the offered PT space is
347
+ // small and collision-free by construction. This is NOT SDP munging — it is
348
+ // the spec'd `setCodecPreferences` API ; Chromium still owns PT assignment.
349
+ pinViewerCodecs(audioTx, videoTx);
350
+
351
+ const state: RemoteState = {
352
+ info: peer,
353
+ pc,
354
+ stream,
355
+ makingOffer: false,
356
+ ignoreOffer: false,
357
+ pendingCandidates: [],
358
+ };
359
+
360
+ pc.addEventListener("negotiationneeded", () => {
361
+ void (async () => {
362
+ try {
363
+ state.makingOffer = true;
364
+ await pc.setLocalDescription();
365
+ if (pc.localDescription) {
366
+ this.sendSignal(peer.id, {
367
+ kind: "sdp",
368
+ description: {
369
+ type: pc.localDescription.type as "offer" | "answer" | "pranswer" | "rollback",
370
+ sdp: pc.localDescription.sdp,
371
+ },
372
+ });
373
+ }
374
+ } catch {
375
+ /* transient — glare or mid-close */
376
+ } finally {
377
+ state.makingOffer = false;
378
+ }
379
+ })();
380
+ });
381
+
382
+ pc.addEventListener("icecandidate", (ev) => {
383
+ const candidate = (ev as RTCPeerConnectionIceEvent).candidate;
384
+ if (!candidate) return;
385
+ this.sendSignal(peer.id, {
386
+ kind: "ice",
387
+ candidate: {
388
+ candidate: candidate.candidate,
389
+ sdpMid: candidate.sdpMid,
390
+ sdpMLineIndex: candidate.sdpMLineIndex,
391
+ usernameFragment: candidate.usernameFragment,
392
+ },
393
+ });
394
+ });
395
+
396
+ pc.addEventListener("track", (ev) => {
397
+ const track = (ev as RTCTrackEvent).track;
398
+ // Aggregate every incoming track into the peer's single MediaStream so
399
+ // the consumer gets one coherent source for `<video srcObject>`.
400
+ if (!state.stream.getTracks().includes(track)) {
401
+ state.stream.addTrack(track);
402
+ }
403
+ track.addEventListener("ended", () => {
404
+ state.stream.removeTrack(track);
405
+ });
406
+ this.emit("remote-track", {
407
+ peerId: peer.id,
408
+ peerName: peer.name,
409
+ stream: state.stream,
410
+ });
411
+ });
412
+
413
+ pc.addEventListener("connectionstatechange", () => {
414
+ this.emit("connection-state", { peerId: peer.id, state: pc.connectionState });
415
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") {
416
+ this.remotes.delete(peer.id);
417
+ this.emit("peer-left", { peerId: peer.id, peerName: peer.name });
418
+ }
419
+ });
420
+
421
+ this.remotes.set(peer.id, state);
422
+ return state;
423
+ }
424
+
425
+ /* ---- Helpers ------------------------------------------------------ */
426
+
427
+ private isPolite(otherId: string): boolean {
428
+ if (!this.selfId) return false;
429
+ return this.selfId > otherId;
430
+ }
431
+
432
+ private sendSignal(to: string, payload: SignalPayload): void {
433
+ this.send({ type: "signal", to, payload });
434
+ }
435
+
436
+ private emit<K extends keyof EventMap>(type: K, event: EventMap[K]): void {
437
+ const set = this.listeners.get(type);
438
+ if (!set) return;
439
+ for (const listener of set) (listener as Listener<K>)(event);
440
+ }
441
+ }
442
+
443
+ /* ---- Codec preference (BUNDLE collision fix) ----------------------- */
444
+
445
+ /** Pin a minimal, deduplicated codec preference on a viewer's recvonly
446
+ * transceivers so the offered payload-type space stays small and BUNDLE-safe.
447
+ *
448
+ * AUDIO : opus (+ keep telephone-event for spec completeness, it is harmless).
449
+ * VIDEO : H264 + its rtx ONLY (drop VP9/AV1/H265 multi-profile clutter). H264
450
+ * is what the publisher (Prism cam / Pulsar NVENC) emits ; a viewer
451
+ * that only ever RECEIVES needs no broader set. rtx is kept so NACK
452
+ * retransmission still works.
453
+ *
454
+ * Feature-detected end-to-end : if `setCodecPreferences` or
455
+ * `RTCRtpReceiver.getCapabilities` is unavailable (older engines, jsdom/test
456
+ * fakes), this is a silent no-op and the transceiver keeps Chromium's default
457
+ * full list — behaviour is never WORSE than before the fix. */
458
+ function pinViewerCodecs(audioTx: unknown, videoTx: unknown): void {
459
+ const getCaps = (
460
+ globalThis as {
461
+ RTCRtpReceiver?: { getCapabilities?: (k: string) => RTCRtpCapabilities | null };
462
+ }
463
+ ).RTCRtpReceiver?.getCapabilities;
464
+ if (typeof getCaps !== "function") return;
465
+
466
+ pinKind(videoTx, getCaps("video"), (mime) => {
467
+ const m = mime.toLowerCase();
468
+ // Keep H264 and the generic rtx (retransmission) codec only.
469
+ return m === "video/h264" || m === "video/rtx";
470
+ });
471
+ pinKind(audioTx, getCaps("audio"), (mime) => {
472
+ const m = mime.toLowerCase();
473
+ return m === "audio/opus" || m === "audio/telephone-event";
474
+ });
475
+ }
476
+
477
+ /** Apply `setCodecPreferences` with the subset of `caps` whose mimeType passes
478
+ * `keep`, preserving the platform's preferred order. Guarded : no transceiver,
479
+ * no `setCodecPreferences`, no caps, or an empty filtered set → no-op. */
480
+ function pinKind(
481
+ tx: unknown,
482
+ caps: RTCRtpCapabilities | null | undefined,
483
+ keep: (mimeType: string) => boolean,
484
+ ): void {
485
+ const setPrefs = (tx as { setCodecPreferences?: (codecs: RTCRtpCodec[]) => void } | null)
486
+ ?.setCodecPreferences;
487
+ if (typeof setPrefs !== "function" || !caps) return;
488
+ const codecs = caps.codecs.filter((c) => keep(c.mimeType));
489
+ if (codecs.length === 0) return; // never offer an empty m-line
490
+ try {
491
+ setPrefs.call(tx, codecs);
492
+ } catch {
493
+ // Some engines reject a preference list (e.g. rtx without its apt primary in
494
+ // the same call) — fall back to the default full list rather than break the
495
+ // transceiver. The fix is best-effort hardening, never a hard dependency.
496
+ }
497
+ }
@@ -0,0 +1,93 @@
1
+ // Peer-stream registry — the bridge between the WebRTC viewer (#3) and the
2
+ // `media` primitive's LIVE mode (#4).
3
+ //
4
+ // The viewer feeds this registry one entry per CONNECTED peer, keyed by the
5
+ // peer's STABLE `peer_label` (the ZabCam contract label #6, announced on the
6
+ // mesh as the peer's join `name` — see `PeerInfo.name` / `RemoteTrackEvent`).
7
+ // The `media` primitive resolves `peerLabel → MediaStream` through it.
8
+ //
9
+ // Reactivity : a peer's stream becomes available ASYNCHRONOUSLY (after the room
10
+ // join + SDP/ICE), so a `media` node that mounted before the peer connected
11
+ // must be notified when its stream arrives. The registry exposes both a
12
+ // one-shot `resolvePeerStream` (the #4 contract, synchronous) AND a
13
+ // `subscribePeerStream(label, cb)` push channel the LIVE primitive uses to
14
+ // re-render on connect / disconnect. The store is a plain Map guarded by a
15
+ // listener set — no signals dependency, so it adds nothing to the render path.
16
+ //
17
+ // OWNERSHIP (the #3↔#4 lifecycle decision) : the registry NEVER creates or
18
+ // stops a track. It only HOLDS a reference to a `MediaStream` owned by the
19
+ // viewer's `RTCPeerConnection`. The viewer is the sole authority on the track
20
+ // lifecycle (created on `track`, removed on `ended` / `peer-left` / teardown).
21
+ // A consuming `<video>` unmounting clears its own `srcObject` and nothing else
22
+ // — it can never tear a peer down for the on-air composite.
23
+
24
+ export type PeerStreamListener = (stream: MediaStream | null) => void;
25
+
26
+ export interface PeerStreamRegistry {
27
+ /** #4 contract — the current stream for a label, or `null` if the peer is not
28
+ * connected (yet / any more). Synchronous, side-effect free. */
29
+ resolve(peerLabel: string): MediaStream | null;
30
+ /** Push channel for the LIVE `media` primitive : invoked immediately with the
31
+ * current value, then on every change for `peerLabel`. Returns an
32
+ * unsubscribe. */
33
+ subscribe(peerLabel: string, listener: PeerStreamListener): () => void;
34
+ /** Viewer-side : publish / replace a peer's stream (peer connected). */
35
+ set(peerLabel: string, stream: MediaStream): void;
36
+ /** Viewer-side : drop a peer's stream (peer left / connection failed). The
37
+ * registry forgets the reference ; it does NOT stop the tracks (the viewer's
38
+ * pc.close() does, as the track owner). */
39
+ remove(peerLabel: string): void;
40
+ /** Viewer-side : forget every entry (room teardown). Reference-only, no track
41
+ * stops. */
42
+ clear(): void;
43
+ }
44
+
45
+ export function createPeerStreamRegistry(): PeerStreamRegistry {
46
+ const streams = new Map<string, MediaStream>();
47
+ const listeners = new Map<string, Set<PeerStreamListener>>();
48
+
49
+ function notify(peerLabel: string): void {
50
+ const set = listeners.get(peerLabel);
51
+ if (set === undefined) return;
52
+ const value = streams.get(peerLabel) ?? null;
53
+ for (const listener of set) listener(value);
54
+ }
55
+
56
+ return {
57
+ resolve(peerLabel) {
58
+ return streams.get(peerLabel) ?? null;
59
+ },
60
+ subscribe(peerLabel, listener) {
61
+ let set = listeners.get(peerLabel);
62
+ if (set === undefined) {
63
+ set = new Set();
64
+ listeners.set(peerLabel, set);
65
+ }
66
+ set.add(listener);
67
+ // Emit the current value synchronously so a late subscriber sees an
68
+ // already-connected peer without waiting for the next change.
69
+ listener(streams.get(peerLabel) ?? null);
70
+ return () => {
71
+ const s = listeners.get(peerLabel);
72
+ if (s === undefined) return;
73
+ s.delete(listener);
74
+ if (s.size === 0) listeners.delete(peerLabel);
75
+ };
76
+ },
77
+ set(peerLabel, stream) {
78
+ if (streams.get(peerLabel) === stream) return; // idempotent re-emit guard
79
+ streams.set(peerLabel, stream);
80
+ notify(peerLabel);
81
+ },
82
+ remove(peerLabel) {
83
+ if (!streams.has(peerLabel)) return;
84
+ streams.delete(peerLabel);
85
+ notify(peerLabel);
86
+ },
87
+ clear() {
88
+ const labels = [...streams.keys()];
89
+ streams.clear();
90
+ for (const label of labels) notify(label);
91
+ },
92
+ };
93
+ }