@lumencast/runtime 0.9.0 → 0.11.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 (100) 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-DtHoU_fS.js} +3 -3
  7. package/dist/{broadcast-ryjLRD5q.js.map → broadcast-DtHoU_fS.js.map} +1 -1
  8. package/dist/{control-AgxbXOVS.js → control-B9frEbNG.js} +4 -4
  9. package/dist/{control-AgxbXOVS.js.map → control-B9frEbNG.js.map} +1 -1
  10. package/dist/index-Dz27r92m.js +1327 -0
  11. package/dist/index-Dz27r92m.js.map +1 -0
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.html +1 -1
  15. package/dist/index.js +16 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/lumencast.js +21 -12
  18. package/dist/mount.d.ts.map +1 -1
  19. package/dist/mount.js +22 -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 +10 -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-slot.d.ts +29 -0
  43. package/dist/render/primitives/meet-peer-slot.d.ts.map +1 -0
  44. package/dist/render/primitives/meet-peer-slot.js +46 -0
  45. package/dist/render/primitives/meet-peer-slot.js.map +1 -0
  46. package/dist/render/primitives/meet-peer.d.ts +31 -0
  47. package/dist/render/primitives/meet-peer.d.ts.map +1 -0
  48. package/dist/render/primitives/meet-peer.js +46 -0
  49. package/dist/render/primitives/meet-peer.js.map +1 -0
  50. package/dist/render/prop-allowlist.d.ts.map +1 -1
  51. package/dist/render/prop-allowlist.js +27 -1
  52. package/dist/render/prop-allowlist.js.map +1 -1
  53. package/dist/render/tree.js +42 -8
  54. package/dist/render/tree.js.map +1 -1
  55. package/dist/state/reserved-leaves.d.ts +37 -0
  56. package/dist/state/reserved-leaves.d.ts.map +1 -0
  57. package/dist/state/reserved-leaves.js +96 -0
  58. package/dist/state/reserved-leaves.js.map +1 -0
  59. package/dist/{status-pill-BxCdj-KZ.js → status-pill-B2vBTwRC.js} +2 -2
  60. package/dist/{status-pill-BxCdj-KZ.js.map → status-pill-B2vBTwRC.js.map} +1 -1
  61. package/dist/{test-CaRHj_J6.js → test-DD2SBDku.js} +4 -4
  62. package/dist/{test-CaRHj_J6.js.map → test-DD2SBDku.js.map} +1 -1
  63. package/dist/{tree-BLIxJbD3.js → tree-CgU_sUwI.js} +581 -479
  64. package/dist/tree-CgU_sUwI.js.map +1 -0
  65. package/dist/types.d.ts +25 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/webrtc/index.d.ts +76 -0
  68. package/dist/webrtc/index.d.ts.map +1 -0
  69. package/dist/webrtc/index.js +180 -0
  70. package/dist/webrtc/index.js.map +1 -0
  71. package/dist/webrtc/meet-viewer.d.ts +139 -0
  72. package/dist/webrtc/meet-viewer.d.ts.map +1 -0
  73. package/dist/webrtc/meet-viewer.js +379 -0
  74. package/dist/webrtc/meet-viewer.js.map +1 -0
  75. package/dist/webrtc/peer-stream-registry.d.ts +21 -0
  76. package/dist/webrtc/peer-stream-registry.d.ts.map +1 -0
  77. package/dist/webrtc/peer-stream-registry.js +77 -0
  78. package/dist/webrtc/peer-stream-registry.js.map +1 -0
  79. package/package.json +4 -4
  80. package/src/app.tsx +9 -0
  81. package/src/index.ts +47 -0
  82. package/src/mount.ts +23 -0
  83. package/src/overlay/runtime-context.tsx +10 -0
  84. package/src/render/bundle.ts +11 -1
  85. package/src/render/primitives/capture.tsx +73 -28
  86. package/src/render/primitives/index.ts +10 -0
  87. package/src/render/primitives/live-peer-video.tsx +90 -0
  88. package/src/render/primitives/media.tsx +66 -17
  89. package/src/render/primitives/meet-peer-slot.tsx +55 -0
  90. package/src/render/primitives/meet-peer.tsx +57 -0
  91. package/src/render/prop-allowlist.ts +27 -1
  92. package/src/render/tree.tsx +44 -8
  93. package/src/state/reserved-leaves.ts +121 -0
  94. package/src/types.ts +25 -0
  95. package/src/webrtc/index.ts +252 -0
  96. package/src/webrtc/meet-viewer.ts +497 -0
  97. package/src/webrtc/peer-stream-registry.ts +93 -0
  98. package/dist/index-DrXsLYhe.js +0 -903
  99. package/dist/index-DrXsLYhe.js.map +0 -1
  100. package/dist/tree-BLIxJbD3.js.map +0 -1
@@ -326,20 +326,47 @@ function finite(v: unknown): number | undefined {
326
326
  * the compiler-flattened `resolved.x`/`resolved.y`. BOTH axes must be
327
327
  * finite numbers : a partial or malformed pair yields `undefined` (the
328
328
  * node stays in the normal flow — RC#3 mistyped-position is inert, not
329
- * injected). Values are plain numbers, never untrusted strings. */
329
+ * injected). Values are plain numbers, never untrusted strings.
330
+ *
331
+ * ADR 006 §3.3 — a `meet.peer` node produced DIRECTLY by the Prism from-scene
332
+ * export bypasses `@lumencast/compiler`, so its geometry arrives in the NESTED
333
+ * LSML shape (`position:{x,y}`) rather than flattened `x`/`y`. We accept that
334
+ * nested form as a fallback ONLY when the flat form is absent. This is purely
335
+ * additive : every compiled bundle always carries flat `x`/`y` (which win), so
336
+ * no existing node's placement changes. */
330
337
  function extractPosition(resolved: Record<string, unknown>): { x: number; y: number } | undefined {
331
- const x = finite(resolved.x);
332
- const y = finite(resolved.y);
338
+ let x = finite(resolved.x);
339
+ let y = finite(resolved.y);
340
+ if (x === undefined && y === undefined) {
341
+ const nested = resolved.position as { x?: unknown; y?: unknown } | undefined;
342
+ if (nested && typeof nested === "object") {
343
+ x = finite(nested.x);
344
+ y = finite(nested.y);
345
+ }
346
+ }
333
347
  if (x === undefined || y === undefined) return undefined;
334
348
  return { x, y };
335
349
  }
336
350
 
337
351
  /** ADR 002 §3.1 (D1) — the absolute box size from `resolved.width`/
338
352
  * `resolved.height`. Only meaningful alongside `position` (the wrapper
339
- * ignores it otherwise). Partial sizes are allowed (one axis hugs). */
353
+ * ignores it otherwise). Partial sizes are allowed (one axis hugs).
354
+ *
355
+ * ADR 006 §3.3 — like `extractPosition`, accept the NESTED `size:{w,h}` shape
356
+ * as a fallback for an uncompiled `meet.peer` node (flat `width`/`height`
357
+ * always win when present, so compiled bundles are unaffected). Without this,
358
+ * the `meet.peer` wrapper got a position but NO size → the `<video>` filled a
359
+ * collapsed box instead of the authored geometry (the observed RC-Geo bug). */
340
360
  function extractSize(resolved: Record<string, unknown>): { w?: number; h?: number } | undefined {
341
- const w = finite(resolved.width);
342
- const h = finite(resolved.height);
361
+ let w = finite(resolved.width);
362
+ let h = finite(resolved.height);
363
+ if (w === undefined && h === undefined) {
364
+ const nested = resolved.size as { w?: unknown; h?: unknown } | undefined;
365
+ if (nested && typeof nested === "object") {
366
+ w = finite(nested.w);
367
+ h = finite(nested.h);
368
+ }
369
+ }
343
370
  if (w === undefined && h === undefined) return undefined;
344
371
  return { w, h };
345
372
  }
@@ -353,8 +380,17 @@ function childIsAbsolute(child: RenderNode): boolean {
353
380
  if (child.kind === "frame") return false; // a frame positions itself
354
381
  const props = child.props ?? {};
355
382
  const bindings = child.bindings ?? {};
356
- const hasX = finite(props.x) !== undefined || "x" in bindings;
357
- const hasY = finite(props.y) !== undefined || "y" in bindings;
383
+ // ADR 006 §3.3 also recognise the nested `position:{x,y}` shape (an
384
+ // uncompiled `meet.peer` node), mirroring extractPosition's fallback.
385
+ const nested = props.position as { x?: unknown; y?: unknown } | undefined;
386
+ const hasX =
387
+ finite(props.x) !== undefined ||
388
+ "x" in bindings ||
389
+ (nested ? finite(nested.x) !== undefined : false);
390
+ const hasY =
391
+ finite(props.y) !== undefined ||
392
+ "y" in bindings ||
393
+ (nested ? finite(nested.y) !== undefined : false);
358
394
  return hasX && hasY;
359
395
  }
360
396
 
@@ -0,0 +1,121 @@
1
+ // Reserved `__cam.*` LSDP leaves — surfaced to the host, never rendered.
2
+ //
3
+ // ADR Blue 009 §3.2–3.3 (axe 1, antenne). The meet-cam antenne path needs two
4
+ // pieces of RUNTIME, stream-level ZabCam state that travel on the LSDP wire as
5
+ // RESERVED leaves (they bind to no node and are never painted) :
6
+ //
7
+ // - `__cam.slots.<slotRef>` = "<peer_label>" (Orion #267 deltas, §3.3)
8
+ // which peer currently fills an authored `x-zab.meet-peer` slot.
9
+ // - `__cam.viewer` = { rooms: [{ signalingUrl, roomId, token }] } (Orion #268)
10
+ // the receive-only viewer credentials for the active stream.
11
+ //
12
+ // The runtime does NOT join rooms, hold creds, or re-key slots itself — that is
13
+ // the host's WebRTC viewer (Solar `peer-viewer/*`). The runtime's only job is to
14
+ // SURFACE these reserved leaves to the host through `MountOptions.onReservedLeaves`
15
+ // so Solar can feed `__cam.viewer` into its viewer injection and drive its
16
+ // slot-binding registry's `assign(slotRef, peer_label | null)` from `__cam.slots.*`.
17
+ // Receive-only : the token flows host→viewer→join ; the runtime never reads it.
18
+
19
+ /** Every reserved cam leaf lives under this prefix. */
20
+ export const CAM_RESERVED_PREFIX = "__cam.";
21
+
22
+ /** One scalar leaf per bound slot : `__cam.slots.<slotRef>` = "<peer_label>"
23
+ * (ADR Blue 009 §3.3). Mirrors Solar's `CAM_SLOTS_PREFIX` — keep them in sync. */
24
+ export const CAM_SLOTS_PREFIX = "__cam.slots.";
25
+
26
+ /** The single viewer-credentials leaf (ADR Blue 009 §3.2, Orion #268). */
27
+ export const CAM_VIEWER_LEAF = "__cam.viewer";
28
+
29
+ /** The reserved cam state surfaced to the host in one shot on every change. */
30
+ export interface ReservedCamLeaves {
31
+ /** `__cam.viewer` — receive-only viewer creds for the active stream. Opaque to
32
+ * the runtime (shape `{ rooms: [{ signalingUrl, roomId, token }] }` validated
33
+ * host-side) ; `undefined` when the leaf is absent. */
34
+ viewer?: unknown;
35
+ /** `slotRef → peer_label` snapshot from the `__cam.slots.*` subtree. A slot
36
+ * ABSENT from this map is UNBOUND — the host releases it (`assign(slotRef,
37
+ * null)`) so the `x-zab.meet-peer` node falls back to its placeholder. Only
38
+ * non-empty string values are kept. */
39
+ slots: Record<string, string>;
40
+ }
41
+
42
+ /** A reserved cam leaf the runtime forwards rather than renders. */
43
+ export function isReservedCamPath(path: string): boolean {
44
+ return path === CAM_VIEWER_LEAF || path.startsWith(CAM_SLOTS_PREFIX);
45
+ }
46
+
47
+ /** Project a raw reserved-leaf state into the host-facing shape. Defensive : a
48
+ * malformed slot value (non-string / empty) or empty slotRef is dropped. */
49
+ function project(raw: Map<string, unknown>): ReservedCamLeaves {
50
+ const slots: Record<string, string> = {};
51
+ let viewer: unknown;
52
+ for (const [path, value] of raw) {
53
+ if (path === CAM_VIEWER_LEAF) {
54
+ if (value !== undefined && value !== null) viewer = value;
55
+ } else if (path.startsWith(CAM_SLOTS_PREFIX)) {
56
+ const slotRef = path.slice(CAM_SLOTS_PREFIX.length);
57
+ if (slotRef !== "" && typeof value === "string" && value !== "") slots[slotRef] = value;
58
+ }
59
+ }
60
+ return viewer !== undefined ? { viewer, slots } : { slots };
61
+ }
62
+
63
+ /** A stable identity key for change detection — two `ReservedCamLeaves` with the
64
+ * same content always produce the same key regardless of insertion order. */
65
+ function identity(leaves: ReservedCamLeaves): string {
66
+ const slots = Object.keys(leaves.slots)
67
+ .sort()
68
+ .map((k) => `${k}=${leaves.slots[k]}`)
69
+ .join("&");
70
+ const viewer = leaves.viewer === undefined ? "" : JSON.stringify(leaves.viewer);
71
+ return `${slots}|${viewer}`;
72
+ }
73
+
74
+ export interface ReservedLeafObserver {
75
+ /** Reseed from a full snapshot's state (reserved leaves not present are
76
+ * dropped). Emits when the projected state changed. */
77
+ onSnapshot(state: Record<string, unknown>): void;
78
+ /** Apply a delta's patches ; emits only when a reserved leaf actually moved. */
79
+ onDelta(patches: ReadonlyArray<{ path: string; value: unknown }>): void;
80
+ }
81
+
82
+ /** Track the reserved `__cam.*` leaves across snapshots + deltas and `emit` the
83
+ * host-facing projection whenever it changes (de-duplicated by content, so an
84
+ * unrelated scene's deltas never call back). Created only when the host supplies
85
+ * `onReservedLeaves` — zero cost otherwise. */
86
+ export function createReservedLeafObserver(
87
+ emit: (leaves: ReservedCamLeaves) => void,
88
+ ): ReservedLeafObserver {
89
+ const raw = new Map<string, unknown>();
90
+ // Seed with the empty projection's identity so a plain scene (no cam leaves)
91
+ // never fires a spurious empty emit ; a later transition to/from cam state does.
92
+ let last = identity({ slots: {} });
93
+
94
+ const flush = (): void => {
95
+ const leaves = project(raw);
96
+ const key = identity(leaves);
97
+ if (key === last) return;
98
+ last = key;
99
+ emit(leaves);
100
+ };
101
+
102
+ return {
103
+ onSnapshot(state) {
104
+ raw.clear();
105
+ for (const [path, value] of Object.entries(state)) {
106
+ if (isReservedCamPath(path)) raw.set(path, value);
107
+ }
108
+ flush();
109
+ },
110
+ onDelta(patches) {
111
+ let touched = false;
112
+ for (const patch of patches) {
113
+ if (isReservedCamPath(patch.path)) {
114
+ raw.set(patch.path, patch.value);
115
+ touched = true;
116
+ }
117
+ }
118
+ if (touched) flush();
119
+ },
120
+ };
121
+ }
package/src/types.ts CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type { ErrorCode } from "@lumencast/protocol";
4
4
  import type { ResolveCaptureDevice } from "./render/primitives/capture";
5
+ import type { ResolvePeerStream, SubscribePeerStream } from "./render/primitives/media";
6
+ import type { ReservedCamLeaves } from "./state/reserved-leaves";
5
7
 
6
8
  export type LumencastMode = "broadcast" | "control" | "test";
7
9
 
@@ -79,6 +81,29 @@ export interface MountOptions {
79
81
  * Only consulted on a capture-capable host (e.g. the Electron preview
80
82
  * webview) ; ignored on-air (CEF/Pulsar render the placeholder). */
81
83
  resolveCaptureDevice?: ResolveCaptureDevice;
84
+ /** ADR 006 #4 — host resolver for the `media` primitive's LIVE mode. Given a
85
+ * LOGICAL `peerLabel` (a `meet.peer.peer_label` from the scene), return the
86
+ * live `MediaStream` of that peer, or `null` when it is not connected yet.
87
+ * Supplied by the WebRTC viewer (issue #3) ; the stream is rendered in
88
+ * `<video>.srcObject` and NEVER enters the bundle or the content hash. Omit
89
+ * it and a live `media` node renders a stream-less box, never throwing. */
90
+ resolvePeerStream?: ResolvePeerStream;
91
+ /** ADR 006 #3 — reactive variant of `resolvePeerStream` : the viewer pushes a
92
+ * peer's stream on connect and `null` on leave, so a LIVE `media` node
93
+ * re-renders when its peer joins mid-show. `createPeerViewer()` returns one.
94
+ * Preferred over `resolvePeerStream` when both are supplied. */
95
+ subscribePeerStream?: SubscribePeerStream;
96
+ /** ADR Blue 009 §3.2–3.3 — host sink for the reserved `__cam.*` LSDP leaves
97
+ * (never rendered, never bound to a node). Called with the full current
98
+ * projection whenever it changes : `viewer` carries the receive-only viewer
99
+ * creds (`__cam.viewer`, Orion #268) and `slots` the `slotRef → peer_label`
100
+ * snapshot (`__cam.slots.*`, Orion #267). The host (Solar) feeds `viewer` into
101
+ * its peer-viewer injection and drives its slot-binding registry's
102
+ * `assign(slotRef, peer_label | null)` so `x-zab.meet-peer` nodes re-key by
103
+ * `slotRef`. Receive-only : the runtime forwards the token verbatim, never
104
+ * reads it. Omit it and the reserved leaves are simply not surfaced (the
105
+ * preview/headless paths are unaffected). */
106
+ onReservedLeaves?: (leaves: ReservedCamLeaves) => void;
82
107
  }
83
108
 
84
109
  export interface LumencastHandle {
@@ -0,0 +1,252 @@
1
+ // WebRTC viewer — public surface (ADR 006 §3.3, issue #3).
2
+ //
3
+ // `createPeerViewer` wires a `MeetViewer` (mesh, viewer role) to a
4
+ // `PeerStreamRegistry` and returns the `resolvePeerStream` / `subscribePeerStream`
5
+ // the `media` primitive's LIVE mode (#4) consumes via `MountOptions`. This is
6
+ // the #3↔#4 bridge : the viewer receives peers, the registry maps
7
+ // `peer_label → MediaStream`, the primitive renders it in `<video srcObject>`.
8
+ //
9
+ // MULTI-ROOM (final model) : `createMultiRoomPeerViewer({ rooms: [...] })` joins
10
+ // EVERY room with its own mesh and feeds ONE shared registry, so the `meet.peer`
11
+ // renderer resolves a `peer_label` to its stream whatever room it came from.
12
+
13
+ import { MeetViewer, type MeetViewerOptions, type RemoteTrackEvent } from "./meet-viewer.js";
14
+ import {
15
+ createPeerStreamRegistry,
16
+ type PeerStreamListener,
17
+ type PeerStreamRegistry,
18
+ } from "./peer-stream-registry.js";
19
+
20
+ export {
21
+ MeetViewer,
22
+ type MeetViewerOptions,
23
+ type MeetViewerDeps,
24
+ type PeerInfo,
25
+ type RemoteTrackEvent,
26
+ } from "./meet-viewer.js";
27
+ export {
28
+ createPeerStreamRegistry,
29
+ type PeerStreamRegistry,
30
+ type PeerStreamListener,
31
+ } from "./peer-stream-registry.js";
32
+
33
+ export interface PeerViewer {
34
+ /** Join the room(s) (viewer role, no capture). */
35
+ join: () => Promise<void>;
36
+ /** Leave + tear down all peer connections (the track owner releases here). */
37
+ leave: () => void;
38
+ /** #4 contract — pass to `mount({ resolvePeerStream })`. Synchronous. */
39
+ resolvePeerStream: (peerLabel: string) => MediaStream | null;
40
+ /** Push channel for the LIVE primitive to re-render on connect/disconnect. */
41
+ subscribePeerStream: (peerLabel: string, listener: PeerStreamListener) => () => void;
42
+ /** The underlying registry, for advanced hosts. */
43
+ registry: PeerStreamRegistry;
44
+ /** Single-room only — the underlying mesh, for diagnostics. Absent in the
45
+ * multi-room viewer (it owns N meshes). */
46
+ viewer?: MeetViewer;
47
+ }
48
+
49
+ /** Feed a (possibly shared) registry from ONE viewer's lifecycle. A label is
50
+ * owned by the FIRST room that connects it (`label-collision` policy) : `claim`
51
+ * decides whether this viewer may publish/withdraw a given label, so a second
52
+ * room carrying the same `peer_label` never clobbers the first. Returns the
53
+ * viewer + a `dispose` that closes the mesh and releases this viewer's labels. */
54
+ /** Normalise a peer name / peer_label into the SAME key both sides agree on.
55
+ * The publisher announces a FREE name on the mesh (e.g. "Publisher 366"), but
56
+ * the scene's `peer_label` is slugified at LSML export (from-scene → "publisher
57
+ * _366", per source-node.ts `slugifyToLabel`). Strict `peerName === peer_label`
58
+ * therefore never matches. Applying the SAME slug to BOTH the indexing side
59
+ * (peerName) and the resolution side (peer_label) makes the map work regardless
60
+ * of format (a raw label and its slug collapse to the same key). Mirrors
61
+ * Prism `slugifyToLabel`. */
62
+ export function labelKey(s: string): string {
63
+ return s
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9_-]+/g, "_")
66
+ .replace(/^[_-]+|[_-]+$/g, "");
67
+ }
68
+
69
+ function wireViewer(
70
+ viewer: MeetViewer,
71
+ registry: PeerStreamRegistry,
72
+ claim: {
73
+ acquire: (label: string, viewer: MeetViewer) => boolean;
74
+ release: (label: string, viewer: MeetViewer) => void;
75
+ },
76
+ ): void {
77
+ // Index the registry by the NORMALISED label (slug of peerName), so it matches
78
+ // the scene node's slugified `peer_label`. Never the opaque `peerId`.
79
+ viewer.on("remote-track", (e: RemoteTrackEvent) => {
80
+ const key = labelKey(e.peerName);
81
+ if (claim.acquire(key, viewer)) registry.set(key, e.stream);
82
+ });
83
+ viewer.on("peer-left", (e) => {
84
+ const key = labelKey(e.peerName);
85
+ if (claim.acquire(key, viewer)) {
86
+ registry.remove(key);
87
+ claim.release(key, viewer);
88
+ }
89
+ });
90
+ }
91
+
92
+ /** Single-room ownership : trivially, this lone viewer owns every label. */
93
+ const SOLE_OWNER = {
94
+ acquire: () => true,
95
+ release: () => {},
96
+ };
97
+
98
+ /** Build a viewer + registry for a SINGLE room and expose the #4 resolver.
99
+ * (Back-compat surface — `createMultiRoomPeerViewer` is the final model.) */
100
+ export function createPeerViewer(options: MeetViewerOptions): PeerViewer {
101
+ const registry = createPeerStreamRegistry();
102
+ const viewer = new MeetViewer(options);
103
+ wireViewer(viewer, registry, SOLE_OWNER);
104
+ return {
105
+ join: () => viewer.join(),
106
+ leave: () => {
107
+ viewer.leave();
108
+ registry.clear();
109
+ },
110
+ resolvePeerStream: (peerLabel) => registry.resolve(labelKey(peerLabel)),
111
+ subscribePeerStream: (peerLabel, listener) => registry.subscribe(labelKey(peerLabel), listener),
112
+ registry,
113
+ viewer,
114
+ };
115
+ }
116
+
117
+ /** A single room's connection params (one mesh per entry). */
118
+ export type RoomOptions = Omit<MeetViewerOptions, "name"> & {
119
+ /** This viewer's announce name on the mesh. Defaults to "solar-viewer". */
120
+ name?: string;
121
+ };
122
+
123
+ export interface MultiRoomPeerViewerOptions {
124
+ rooms: RoomOptions[];
125
+ /** Shared deps (WS/RTCPeerConnection/MediaStream) applied to every room when a
126
+ * room entry does not override them — used to test without a browser stack. */
127
+ deps?: MeetViewerOptions["deps"];
128
+ }
129
+
130
+ export interface MultiRoomPeerViewer extends PeerViewer {
131
+ /** Reconcile the live room set : open meshes for new rooms, close meshes for
132
+ * removed ones (matched by `roomId`). Best-effort ; idempotent for an
133
+ * unchanged set. Newly opened rooms are joined immediately. */
134
+ setRooms: (rooms: RoomOptions[]) => Promise<void>;
135
+ }
136
+
137
+ /** The FINAL viewer model : join N rooms, aggregate every peer into ONE shared
138
+ * registry keyed by `peer_label`. The `meet.peer` renderer resolves a label to
139
+ * its stream regardless of which room the peer published in.
140
+ *
141
+ * LABEL COLLISION (improbable at the POC) : FIRST-CONNECTED-WINS. The room that
142
+ * first connects a `peer_label` owns it ; a second room carrying the same label
143
+ * is ignored until the owner releases it (on the owner's `peer-left`). This is
144
+ * deterministic and never flips a live source under the compositor.
145
+ *
146
+ * LIFECYCLE : one mesh per room ; `leave()` closes all. `setRooms()` reconciles
147
+ * on re-arm (close removed rooms, open new ones), matched by `roomId`. */
148
+ export function createMultiRoomPeerViewer(
149
+ options: MultiRoomPeerViewerOptions,
150
+ ): MultiRoomPeerViewer {
151
+ const registry = createPeerStreamRegistry();
152
+ // roomId → { viewer, joined }
153
+ const meshes = new Map<string, { viewer: MeetViewer }>();
154
+ // peer_label → owning viewer (first-connected-wins).
155
+ const owners = new Map<string, MeetViewer>();
156
+
157
+ const claim = {
158
+ acquire: (label: string, viewer: MeetViewer): boolean => {
159
+ const owner = owners.get(label);
160
+ if (owner === undefined) {
161
+ owners.set(label, viewer);
162
+ return true;
163
+ }
164
+ return owner === viewer; // only the owning room may publish/withdraw
165
+ },
166
+ release: (label: string, viewer: MeetViewer): void => {
167
+ if (owners.get(label) === viewer) owners.delete(label);
168
+ },
169
+ };
170
+
171
+ function openRoom(room: RoomOptions): void {
172
+ if (meshes.has(room.roomId)) return; // idempotent
173
+ const viewer = new MeetViewer({
174
+ name: room.name ?? "solar-viewer",
175
+ ...room,
176
+ ...(options.deps !== undefined && room.deps === undefined ? { deps: options.deps } : {}),
177
+ });
178
+ wireViewer(viewer, registry, claim);
179
+ meshes.set(room.roomId, { viewer });
180
+ }
181
+
182
+ function closeRoom(roomId: string): void {
183
+ const mesh = meshes.get(roomId);
184
+ if (mesh === undefined) return;
185
+ // Release every label this viewer owns BEFORE closing, so a surviving room
186
+ // can take over the label and the registry drops the gone stream.
187
+ for (const [label, owner] of [...owners.entries()]) {
188
+ if (owner === mesh.viewer) {
189
+ registry.remove(label);
190
+ owners.delete(label);
191
+ }
192
+ }
193
+ mesh.viewer.leave();
194
+ meshes.delete(roomId);
195
+ }
196
+
197
+ for (const room of options.rooms) openRoom(room);
198
+
199
+ return {
200
+ join: async () => {
201
+ await Promise.all([...meshes.values()].map((m) => m.viewer.join()));
202
+ },
203
+ leave: () => {
204
+ for (const roomId of [...meshes.keys()]) closeRoom(roomId);
205
+ registry.clear();
206
+ },
207
+ setRooms: async (rooms) => {
208
+ const next = new Set(rooms.map((r) => r.roomId));
209
+ // Close rooms no longer present.
210
+ for (const roomId of [...meshes.keys()]) {
211
+ if (!next.has(roomId)) closeRoom(roomId);
212
+ }
213
+ // Open + join rooms newly added.
214
+ const added: MeetViewer[] = [];
215
+ for (const room of rooms) {
216
+ if (!meshes.has(room.roomId)) {
217
+ openRoom(room);
218
+ const m = meshes.get(room.roomId);
219
+ if (m) added.push(m.viewer);
220
+ }
221
+ }
222
+ await Promise.all(added.map((v) => v.join()));
223
+ },
224
+ resolvePeerStream: (peerLabel) => registry.resolve(labelKey(peerLabel)),
225
+ subscribePeerStream: (peerLabel, listener) => registry.subscribe(labelKey(peerLabel), listener),
226
+ registry,
227
+ };
228
+ }
229
+
230
+ /** The shape the Prism host injects on `window.__ZAB_PEER_VIEWER__`. The FINAL
231
+ * model is the multi-room `{ rooms: [...] }` ; the legacy single-room shape is
232
+ * still accepted for back-compat (treated as a one-room array). */
233
+ export type PeerViewerInjection =
234
+ | MultiRoomPeerViewerOptions
235
+ | (Omit<MeetViewerOptions, "name"> & { name?: string });
236
+
237
+ /** Normalise either injection shape into a multi-room viewer. forge-prism passes
238
+ * `window.__ZAB_PEER_VIEWER__` straight through ; a bare single-room object is
239
+ * wrapped as `{ rooms: [it] }`. */
240
+ export function createPeerViewerFromInjection(injection: PeerViewerInjection): MultiRoomPeerViewer {
241
+ if ("rooms" in injection && Array.isArray(injection.rooms)) {
242
+ return createMultiRoomPeerViewer(injection);
243
+ }
244
+ // Legacy single-room shape → one-room array.
245
+ const { name, deps, ...room } = injection as Omit<MeetViewerOptions, "name"> & {
246
+ name?: string;
247
+ };
248
+ return createMultiRoomPeerViewer({
249
+ rooms: [{ ...room, ...(name !== undefined ? { name } : {}) }],
250
+ ...(deps !== undefined ? { deps } : {}),
251
+ });
252
+ }