@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
package/src/mount.ts CHANGED
@@ -7,6 +7,7 @@ import { createElement } from "react";
7
7
  import { LumencastApp } from "./app.js";
8
8
  import { applyDelta } from "./state/apply-delta.js";
9
9
  import { applySnapshot } from "./state/apply-snapshot.js";
10
+ import { createReservedLeafObserver } from "./state/reserved-leaves.js";
10
11
  import { createStore } from "./state/store.js";
11
12
  import { createBundleFetcher, type BundleFetcher, type RenderBundle } from "./render/bundle.js";
12
13
  import { WsClient, type ConnectionStatus, type TransportError } from "./transport/ws.js";
@@ -45,6 +46,15 @@ export function mount(options: MountOptions): LumencastHandle {
45
46
 
46
47
  let active = true;
47
48
 
49
+ // ADR Blue 009 §3.2–3.3 — surface the reserved `__cam.*` LSDP leaves (the
50
+ // slot→peer assignments + the receive-only viewer creds) to the host so its
51
+ // WebRTC viewer (Solar) can drive room joins + `x-zab.meet-peer` slot re-keying.
52
+ // The runtime never joins, holds creds, or re-keys ; it only forwards. Created
53
+ // only when the host opts in — zero cost on the preview/headless paths.
54
+ const reservedLeaves = options.onReservedLeaves
55
+ ? createReservedLeafObserver(options.onReservedLeaves)
56
+ : undefined;
57
+
48
58
  // ADR 001 §3.4 (issue #34) — anti-silent-drop diagnostics are events
49
59
  // surfaced to the host, never console logs in `broadcast` mode.
50
60
  const removeDiagnosticsHandler = options.onDiagnostic
@@ -59,6 +69,7 @@ export function mount(options: MountOptions): LumencastHandle {
59
69
  onStatus: setStatus,
60
70
  onSnapshot: (frame) => {
61
71
  if (!active) return;
72
+ reservedLeaves?.onSnapshot(frame.state);
62
73
  void onSnapshot(
63
74
  bundleFetcher,
64
75
  bundleSignal,
@@ -78,6 +89,7 @@ export function mount(options: MountOptions): LumencastHandle {
78
89
  if (!active) return;
79
90
  const start = performance.now();
80
91
  applyDelta(store, frame);
92
+ reservedLeaves?.onDelta(frame.patches);
81
93
  options.onMetric?.({
82
94
  name: "delta_applied",
83
95
  duration_ms: performance.now() - start,
@@ -123,6 +135,17 @@ export function mount(options: MountOptions): LumencastHandle {
123
135
  ...(options.resolveCaptureDevice !== undefined
124
136
  ? { resolveCaptureDevice: options.resolveCaptureDevice }
125
137
  : {}),
138
+ // ADR 006 #4 — thread the host peer-stream resolver (supplied by the
139
+ // WebRTC viewer #3) so the `media` primitive's LIVE mode can render a
140
+ // peer's MediaStream in `srcObject`.
141
+ ...(options.resolvePeerStream !== undefined
142
+ ? { resolvePeerStream: options.resolvePeerStream }
143
+ : {}),
144
+ // ADR 006 #3 — reactive variant : the LIVE `media` node re-renders when a
145
+ // peer connects/leaves mid-show. `createPeerViewer()` supplies it.
146
+ ...(options.subscribePeerStream !== undefined
147
+ ? { subscribePeerStream: options.subscribePeerStream }
148
+ : {}),
126
149
  }),
127
150
  );
128
151
 
@@ -5,6 +5,7 @@ import type { RenderBundle } from "../render/bundle";
5
5
  import type { ConnectionStatus } from "../transport/ws";
6
6
  import type { LumencastMode } from "../types";
7
7
  import type { ResolveCaptureDevice } from "../render/primitives/capture";
8
+ import type { ResolvePeerStream, SubscribePeerStream } from "../render/primitives/media";
8
9
 
9
10
  export interface LumencastRuntime {
10
11
  mode: LumencastMode;
@@ -17,6 +18,15 @@ export interface LumencastRuntime {
17
18
  * physical `deviceId` for the `x-zab.capture` primitive's ACQUIRE mode.
18
19
  * Injected from `MountOptions`, NOT the bundle. Absent → default device. */
19
20
  resolveCaptureDevice?: ResolveCaptureDevice;
21
+ /** ADR 006 #4 — host-provided resolver mapping a LOGICAL `peerLabel` to the
22
+ * live `MediaStream` of a `meet.peer`, for the `media` primitive's LIVE mode.
23
+ * Injected from `MountOptions` (supplied by the WebRTC viewer #3), NOT the
24
+ * bundle. Absent → the live `media` node is a stream-less box. */
25
+ resolvePeerStream?: ResolvePeerStream;
26
+ /** ADR 006 #3 — reactive variant : the viewer pushes a peer's stream on
27
+ * connect and `null` on leave, so a LIVE `media` node re-renders when its
28
+ * peer joins mid-show. Preferred over `resolvePeerStream` when present. */
29
+ subscribePeerStream?: SubscribePeerStream;
20
30
  }
21
31
 
22
32
  const Ctx = createContext<LumencastRuntime | null>(null);
@@ -26,10 +26,20 @@ export type RenderKind =
26
26
  | "media"
27
27
  | "repeat"
28
28
  | "instance"
29
+ // ADR 006 §3.3/§3.5 — the unified source kind. Every source crossing the
30
+ // Prism export arrives as a `meet.peer` node ; the runtime resolves its
31
+ // `peer_label → MediaStream` (WebRTC viewer) and renders it in `srcObject`.
32
+ | "meet.peer"
29
33
  // Zab vendor primitive (RFC-0001, §17.1) — a transparent capture
30
34
  // placeholder. Recognised by the Zab-plugin runtime ; reserves a box and
31
35
  // renders nothing.
32
- | "x-zab.capture";
36
+ | "x-zab.capture"
37
+ // Zab vendor primitive (ADR Blue 009 §3.1, Amendment 2) — a transparent
38
+ // meet-peer SLOT placeholder. Declares only a logical `x-zab.slotRef` (which
39
+ // slot receives a meet peer) + geometry ; carries NO cam/peer identity. The
40
+ // runtime resolves `slotRef → peer_label` from stream-level ZabCam state and
41
+ // renders the bound peer ; an unbound slot renders a transparent box.
42
+ | "x-zab.meet-peer";
33
43
 
34
44
  export interface RenderNode {
35
45
  kind: RenderKind;
@@ -36,12 +36,12 @@ import { useOptionalLumencastRuntime } from "../../overlay/runtime-context";
36
36
  export function Capture({ resolved }: PrimitiveProps) {
37
37
  const width = dimOr(resolved.width, "100%");
38
38
  const height = dimOr(resolved.height, "100%");
39
- const sourceKind = typeof resolved["x-zab.sourceKind"] === "string"
40
- ? (resolved["x-zab.sourceKind"] as string)
41
- : "";
42
- const deviceRef = typeof resolved["x-zab.deviceRef"] === "string"
43
- ? (resolved["x-zab.deviceRef"] as string)
44
- : "";
39
+ const sourceKind =
40
+ typeof resolved["x-zab.sourceKind"] === "string"
41
+ ? (resolved["x-zab.sourceKind"] as string)
42
+ : "";
43
+ const deviceRef =
44
+ typeof resolved["x-zab.deviceRef"] === "string" ? (resolved["x-zab.deviceRef"] as string) : "";
45
45
 
46
46
  // §A1.3 — the host-provided resolver, injected at mount through the runtime
47
47
  // context (NOT the bundle, NOT the LSDP wire). Absent when the tree renders
@@ -129,30 +129,39 @@ export function Capture({ resolved }: PrimitiveProps) {
129
129
  );
130
130
  }
131
131
 
132
+ /** A resolved physical device for a live capture constraint, or `null` when the
133
+ * host could not bind the logical `deviceRef`. */
134
+ export type ResolvedCaptureDevice = {
135
+ deviceId?: string;
136
+ captureSourceId?: string;
137
+ } | null;
138
+
132
139
  /** Resolver injected by the consuming app (ADR 004 §A1.3). Maps the LOGICAL
133
- * `deviceRef` to a physical `deviceId` for a live `getUserMedia` constraint.
134
- * The result NEVER enters the bundle or the content hash. */
140
+ * `deviceRef` to a physical `deviceId`/`captureSourceId` for a live
141
+ * `getUserMedia` constraint. The result NEVER enters the bundle or the content
142
+ * hash. MAY be async: physical ids (e.g. getUserMedia `deviceId`) are salted
143
+ * per origin/partition, so the host often must re-resolve a portable key
144
+ * (label) against THIS context's devices — an inherently asynchronous step
145
+ * (`enumerateDevices`). `acquireStream` awaits it, so the device is bound
146
+ * before acquisition rather than racing a late global mutation. */
135
147
  export type ResolveCaptureDevice = (
136
148
  deviceRef: string,
137
149
  sourceKind: string,
138
- ) => { deviceId?: string } | null;
150
+ ) => ResolvedCaptureDevice | Promise<ResolvedCaptureDevice>;
139
151
 
140
152
  /** §A1.2(2) — capture-capable iff `navigator.mediaDevices.getUserMedia`
141
153
  * exists and is callable in the current context. Feature detection only ;
142
154
  * CEF/Pulsar on-air and jsdom (without a mock) report non-capable. */
143
155
  function isCaptureCapable(): boolean {
144
156
  return (
145
- typeof navigator !== "undefined" &&
146
- typeof navigator.mediaDevices?.getUserMedia === "function"
157
+ typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function"
147
158
  );
148
159
  }
149
160
 
150
161
  /** Visual kinds render a `<video>` ; audio kinds stay visually empty. */
151
162
  function isVisualKind(sourceKind: string): boolean {
152
163
  return (
153
- sourceKind === "media.webcam" ||
154
- sourceKind === "media.screen" ||
155
- sourceKind === "media.window"
164
+ sourceKind === "media.webcam" || sourceKind === "media.screen" || sourceKind === "media.window"
156
165
  );
157
166
  }
158
167
 
@@ -166,33 +175,69 @@ async function acquireStream(
166
175
  ): Promise<MediaStream | null> {
167
176
  const md = navigator.mediaDevices;
168
177
 
169
- // §A1.3 a resolver maps the LOGICAL deviceRef to a physical deviceId. No
170
- // resolver / `null` result default constraints (no deviceId), so "the cam
171
- // traverses" on the first pass. The deviceId is a live constraint only.
172
- const resolved = resolveCaptureDevice?.(deviceRef, sourceKind) ?? null;
178
+ // §A1.3 (amended 2026-06-27) AWAIT the resolver: physical ids are salted
179
+ // per origin/partition, so the host may need an async re-resolution by a
180
+ // portable key (label) in THIS context. Awaiting binds the device before
181
+ // acquisition instead of racing a late global update (the previous sync call
182
+ // let the node acquire with a stale/absent id first).
183
+ const resolved = (await resolveCaptureDevice?.(deviceRef, sourceKind)) ?? null;
173
184
  const deviceId = resolved?.deviceId;
185
+ const declaredRef = deviceRef.length > 0;
174
186
 
175
187
  switch (sourceKind) {
176
188
  case "media.webcam":
177
- return md.getUserMedia({ video: deviceConstraint(deviceId) });
178
189
  case "media.mic":
179
- return md.getUserMedia({ audio: deviceConstraint(deviceId) });
180
- case "media.app_audio":
181
- return md.getUserMedia({ audio: deviceConstraint(deviceId) });
190
+ case "media.app_audio": {
191
+ // §A1.3 (amended) — NO default-device fallback for a DECLARED deviceRef
192
+ // that did not resolve to a real device. Acquiring the host default cam
193
+ // here is the silent "automatic allocation" of the WRONG camera the
194
+ // consuming app must never get. → PLACEHOLDER (return null). The bare
195
+ // default constraint stays ONLY when no deviceRef is declared at all.
196
+ if (declaredRef && (typeof deviceId !== "string" || deviceId.length === 0)) {
197
+ return null;
198
+ }
199
+ const channel = sourceKind === "media.webcam" ? "video" : "audio";
200
+ return md.getUserMedia({ [channel]: deviceConstraint(deviceId) });
201
+ }
182
202
  case "media.screen":
183
- case "media.window":
184
- // Display capture has no deviceId constraint (the picker selects the
185
- // surface). The resolver is consulted but its id is not applicable here.
203
+ case "media.window": {
204
+ // DIRECT capture of the picked desktopCapturer surface (no system picker)
205
+ // via Electron's legacy `chromeMediaSource:desktop` + the resolved
206
+ // `captureSourceId`.
207
+ const captureSourceId = resolved?.captureSourceId;
208
+ if (typeof captureSourceId === "string" && captureSourceId.length > 0) {
209
+ return md.getUserMedia({
210
+ video: {
211
+ mandatory: {
212
+ chromeMediaSource: "desktop",
213
+ chromeMediaSourceId: captureSourceId,
214
+ },
215
+ } as unknown as MediaTrackConstraints,
216
+ });
217
+ }
218
+ // A declared surface ref that didn't resolve → PLACEHOLDER, never a
219
+ // default `getDisplayMedia` picker. The picker stays only when no ref is
220
+ // declared (a bare capture node on a non-Electron host).
221
+ if (declaredRef) return null;
186
222
  return md.getDisplayMedia({ video: true });
223
+ }
187
224
  default:
188
225
  return null;
189
226
  }
190
227
  }
191
228
 
192
- /** A `getUserMedia` track constraint : a specific `deviceId` when resolved,
193
- * else `true` (the host's default device). */
229
+ /** A `getUserMedia` track constraint. A resolved deviceId is pinned with
230
+ * `exact`, NOT a bare (ideal) deviceId: an *ideal* constraint SILENTLY falls
231
+ * back to the host default camera when the requested device can't start (e.g.
232
+ * an INACTIVE virtual cam that's enumerated but produces no stream) — the
233
+ * "automatic allocation" of the WRONG camera. `exact` yields the requested
234
+ * device (its placeholder frame if idle), or an OverconstrainedError the
235
+ * caller catches into PLACEHOLDER — never the wrong cam. No deviceId → `true`
236
+ * (host default) applies ONLY when no deviceRef was declared. */
194
237
  function deviceConstraint(deviceId: string | undefined): MediaTrackConstraints | boolean {
195
- return typeof deviceId === "string" && deviceId.length > 0 ? { deviceId } : true;
238
+ return typeof deviceId === "string" && deviceId.length > 0
239
+ ? { deviceId: { exact: deviceId } }
240
+ : true;
196
241
  }
197
242
 
198
243
  /** Stop every track of a stream (RC11 — release the camera/mic, kill the
@@ -12,6 +12,8 @@ import { Text } from "./text";
12
12
  import { Image } from "./image";
13
13
  import { Shape } from "./shape";
14
14
  import { Media } from "./media";
15
+ import { MeetPeer } from "./meet-peer";
16
+ import { MeetPeerSlot } from "./meet-peer-slot";
15
17
  import { Instance } from "./instance";
16
18
  import { Capture } from "./capture";
17
19
  // `repeat` is dispatched specially in the tree (it iterates a bound
@@ -48,7 +50,15 @@ export const PRIMITIVES: Partial<Record<RenderKind, ComponentType<PrimitiveProps
48
50
  image: Image,
49
51
  shape: Shape,
50
52
  media: Media,
53
+ // ADR 006 §3.3/§3.5 — the unified source kind : every exported source is a
54
+ // `meet.peer` node rendered in `<video srcObject>` from the WebRTC viewer.
55
+ "meet.peer": MeetPeer,
51
56
  instance: Instance,
52
57
  // RFC-0001 / ADR 004 — Zab vendor capture placeholder (transparent, inert).
53
58
  "x-zab.capture": Capture,
59
+ // ADR Blue 009 §3.1 (Amendment 2) — Zab vendor meet-peer SLOT placeholder.
60
+ // Carries only a logical `x-zab.slotRef` ; the host's slot-aware peer-stream
61
+ // registry resolves `slotRef → peer_label → MediaStream` (transparent when
62
+ // unbound). Closes the kind→primitive gap that left it an unknown-kind drop.
63
+ "x-zab.meet-peer": MeetPeerSlot,
54
64
  };
@@ -0,0 +1,90 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useOptionalLumencastRuntime } from "../../overlay/runtime-context";
3
+
4
+ /** Shared LIVE peer-stream rendering (ADR 006 §3.3/§3.5) — the SINGLE srcObject
5
+ * path used by BOTH the `media` primitive's live mode (#4, keyed on `peerLabel`)
6
+ * AND the generic `meet.peer` source kind (the unified source abstraction).
7
+ * There is no special renderer per source : every live source resolves
8
+ * `peerLabel → MediaStream` through the host viewer and paints it the same way.
9
+ *
10
+ * Resolution : prefer the reactive channel (`subscribePeerStream`, #3) so a node
11
+ * mounted BEFORE its peer connects re-renders on arrival ; fall back to the
12
+ * one-shot `resolvePeerStream` (#4 contract) ; no host resolver → stream-less
13
+ * box. Reading a stream is the ONLY side effect — the scene is never mutated
14
+ * (RC-ReadOnly).
15
+ *
16
+ * Geometry (RC-Geo) : the `<video>` fills `100%`/`100%` of the box the Tree's
17
+ * UniversalWrapper sized from the node's `x/y/width/height`. The geometry lives
18
+ * on the wrapper, NEVER on the video, so it is structurally impossible to force
19
+ * a full-viewport size ; `object-fit` is the scene-authored value.
20
+ *
21
+ * Ownership : the stream is owned by the viewer (#3). This component is a pure
22
+ * consumer — unmounting clears its own `srcObject` and stops NO track (a mirror
23
+ * must never tear a peer down for the on-air composite). */
24
+ export function LivePeerVideo({
25
+ peerLabel,
26
+ objectFit,
27
+ muted = true,
28
+ }: {
29
+ peerLabel: string;
30
+ objectFit: string;
31
+ /** Audio playout hint. Always muted for now (broadcast audio is Pulsar-side). */
32
+ muted?: boolean;
33
+ }) {
34
+ const runtime = useOptionalLumencastRuntime();
35
+ const resolvePeerStream = runtime?.resolvePeerStream;
36
+ const subscribePeerStream = runtime?.subscribePeerStream;
37
+
38
+ const videoRef = useRef<HTMLVideoElement | null>(null);
39
+ const [stream, setStream] = useState<MediaStream | null>(null);
40
+
41
+ useEffect(() => {
42
+ if (subscribePeerStream !== undefined) {
43
+ return subscribePeerStream(peerLabel, setStream);
44
+ }
45
+ if (resolvePeerStream !== undefined) {
46
+ setStream(resolvePeerStream(peerLabel));
47
+ return;
48
+ }
49
+ setStream(null);
50
+ }, [peerLabel, resolvePeerStream, subscribePeerStream]);
51
+
52
+ // `srcObject` is not a serialisable attribute — attach imperatively. Never
53
+ // stop the tracks here (the viewer owns them).
54
+ useEffect(() => {
55
+ const el = videoRef.current;
56
+ if (el === null) return;
57
+ el.srcObject = stream;
58
+ return () => {
59
+ if (el !== null) el.srcObject = null;
60
+ };
61
+ }, [stream]);
62
+
63
+ if (stream === null) {
64
+ // Stream-less box of the wrapper geometry — transparent, inert, paints
65
+ // nothing. NOT an error : the peer can connect mid-show.
66
+ return (
67
+ <div
68
+ aria-hidden
69
+ data-lumencast-media-live
70
+ style={{ width: "100%", height: "100%", opacity: 0, pointerEvents: "none" }}
71
+ />
72
+ );
73
+ }
74
+
75
+ return (
76
+ <video
77
+ ref={videoRef}
78
+ data-lumencast-media-live
79
+ autoPlay
80
+ muted={muted}
81
+ playsInline
82
+ style={{
83
+ width: "100%",
84
+ height: "100%",
85
+ objectFit: objectFit as React.CSSProperties["objectFit"],
86
+ pointerEvents: "none",
87
+ }}
88
+ />
89
+ );
90
+ }
@@ -1,26 +1,68 @@
1
1
  import type { PrimitiveProps } from "./index";
2
2
  import { gateSrc, useAllowedHosts } from "../allowed-hosts";
3
+ import { LivePeerVideo } from "./live-peer-video";
3
4
 
4
- /** Embedded video. `src`, `loop`, `mute`, `autoplay`. Audio is muted
5
- * by default broadcast audio is Pulsar-side, not from the browser
6
- * source.
5
+ /** Resolver injected by the consuming app (ADR 006 §3.3, #4). Maps a LOGICAL
6
+ * `peerLabel` (the `meet.peer.peer_label` carried by the scene) to the live
7
+ * `MediaStream` of that peer — supplied by the WebRTC viewer (issue #3). The
8
+ * stream is rendered in `srcObject` ; it NEVER enters the bundle or the content
9
+ * hash. Returns `null` when the peer is not (yet) connected → the node stays a
10
+ * stream-less box, no throw, no diagnostic (a peer can join mid-show). */
11
+ export type ResolvePeerStream = (peerLabel: string) => MediaStream | null;
12
+
13
+ /** Reactive variant (ADR 006 #3) : the viewer pushes a peer's stream when it
14
+ * connects and `null` when it leaves. The LIVE primitive prefers this over the
15
+ * one-shot resolver so a node that mounted BEFORE the peer connected re-renders
16
+ * on arrival (a peer joins mid-show). The listener is invoked immediately with
17
+ * the current value, then on every change ; the return value unsubscribes.
18
+ * Like `resolvePeerStream`, it is injected at mount — never the bundle. */
19
+ export type SubscribePeerStream = (
20
+ peerLabel: string,
21
+ listener: (stream: MediaStream | null) => void,
22
+ ) => () => void;
23
+
24
+ /** Embedded video. Two source modes, picked by the node's props :
25
+ *
26
+ * - **BUNDLE** (`src`, the original mode) : a `<video src>` of a bundled /
27
+ * gated URL. Audio muted by default (broadcast audio is Pulsar-side). `src`
28
+ * is the sole network sink and MUST pass `gateSrc` before reaching the
29
+ * `<video>` (Bastion, ADR 003 — an off-allowlist request is an SSRF surface
30
+ * in headless `zabrender`). A rejected host/scheme omits the source.
7
31
  *
8
- * Security (Bastion, ADR 003) : `src` is the media primitive's sole
9
- * network sink (no `poster` / `<source>` / `<track>` are rendered). Like
10
- * every other asset leaf (image / image-fill / mask) it MUST pass
11
- * `gateSrc` BEFORE reaching the `<video>` — otherwise a `kind:"media"`
12
- * node would make the headless Chromium of `zabrender` emit an
13
- * off-allowlist request (an SSRF surface). A rejected host/scheme omits
14
- * the source entirely (no passthrough), with an R9-clean diagnostic
15
- * ({ nodeId, field, reason } — never the URL). */
32
+ * - **LIVE** (`peerLabel`, ADR 006 #4) : the source is a `meet.peer`'s
33
+ * `peer_label`. The runtime resolves the peer's `MediaStream` through a
34
+ * host-provided resolver (`resolvePeerStream`, injected at mount NOT the
35
+ * bundle, like `resolveCaptureDevice`) and renders it imperatively via
36
+ * `<video>.srcObject` in real time. No URL, no `gateSrc` (a `MediaStream`
37
+ * is not a network sink). An absent resolver or an unconnected peer leaves a
38
+ * stream-less box no throw, no diagnostic (peers join mid-show).
39
+ *
40
+ * `peerLabel` takes precedence when present (a live node), else `src` (bundle).
41
+ *
42
+ * Geometry is read-only (ADR 006 A1.6) : the node's `x/y/width/height` are
43
+ * applied by the Tree's UniversalWrapper around this primitive ; the `<video>`
44
+ * fills that box (`100%`/`100%`) with the scene-authored `object-fit`. The
45
+ * primitive NEVER forces full-screen and NEVER writes any geometry back — it
46
+ * only reads `resolved`. */
16
47
  export function Media({ resolved, nodeId }: PrimitiveProps) {
17
48
  const allowedHosts = useAllowedHosts();
49
+ const fit = (resolved.fit as string | undefined) ?? "cover";
50
+ const peerLabel =
51
+ typeof resolved.peerLabel === "string" && resolved.peerLabel.length > 0
52
+ ? resolved.peerLabel
53
+ : "";
54
+
55
+ if (peerLabel !== "") {
56
+ // LIVE mode — the SAME srcObject path as the generic `meet.peer` source.
57
+ return <LivePeerVideo peerLabel={peerLabel} objectFit={fit} />;
58
+ }
59
+
60
+ // BUNDLE mode (unchanged) — gated `<video src>`.
18
61
  const src = gateSrc(resolved.src, allowedHosts, "media.src", nodeId);
19
62
  if (!src) return null;
20
63
  const loop = (resolved.loop as boolean | undefined) ?? true;
21
64
  const mute = (resolved.mute as boolean | undefined) ?? true;
22
65
  const autoplay = (resolved.autoplay as boolean | undefined) ?? true;
23
- const fit = (resolved.fit as string | undefined) ?? "cover";
24
66
 
25
67
  return (
26
68
  <video
@@ -29,11 +71,18 @@ export function Media({ resolved, nodeId }: PrimitiveProps) {
29
71
  loop={loop}
30
72
  muted={mute}
31
73
  playsInline
32
- style={{
33
- width: "100%",
34
- height: "100%",
35
- objectFit: fit as React.CSSProperties["objectFit"],
36
- }}
74
+ style={fillBox(fit)}
37
75
  />
38
76
  );
39
77
  }
78
+
79
+ /** The bundle `<video src>` fills the box the UniversalWrapper sized from the
80
+ * node's `width`/`height` (RC-Geo) ; `object-fit` is the scene-authored `fit`.
81
+ * The geometry lives on the wrapper, never on the video. */
82
+ function fillBox(fit: string): React.CSSProperties {
83
+ return {
84
+ width: "100%",
85
+ height: "100%",
86
+ objectFit: fit as React.CSSProperties["objectFit"],
87
+ };
88
+ }
@@ -0,0 +1,55 @@
1
+ import type { PrimitiveProps } from "./index";
2
+ import { LivePeerVideo } from "./live-peer-video";
3
+
4
+ /** `x-zab.meet-peer` — the transparent meet-peer SLOT placeholder (Zab vendor
5
+ * primitive, ADR Blue 009 §3.1 Amendment 2 ; type shipped in v0.10.0 / #81).
6
+ *
7
+ * Distinct from `meet.peer` (the cam-identity source) : this node carries NO
8
+ * peer identity, only a hash-stable LOGICAL `x-zab.slotRef` (e.g. `cam-caster-1`)
9
+ * + geometry. WHICH `peer_label` fills a slot is RUNTIME, stream-level ZabCam
10
+ * state — never baked in the scene. The slot→peer binding is ported by Orion on
11
+ * the LSDP as `__cam.slots.<slotRef>` = "<peer_label>" (§3.3) and re-keyed into
12
+ * the host's peer-stream registry (Solar `slot-binding.ts`) so the registry
13
+ * resolves `slotRef → peer_label → MediaStream`.
14
+ *
15
+ * Contract (rendered verbatim) :
16
+ * - `x-zab.slotRef` (string) — the SLOT REFERENCE, used as the resolver KEY.
17
+ * The host's peer-stream resolver (`resolvePeerStream`/`subscribePeerStream`)
18
+ * is keyed by `slotRef` on the antenne (Solar's slot-aware registry maps it
19
+ * to a `peer_label`, then to a stream). Empty / missing → a transparent inert
20
+ * box (the slot is not addressable).
21
+ * - geometry (`width`/`height` + position) — applied by the Tree's
22
+ * UniversalWrapper, exactly like `meet.peer` ; the `<video>` fills the box
23
+ * 100%/100% and is never forced full-screen (RC-Geo).
24
+ *
25
+ * Receive-only : the slot reads its stream through the host viewer (Solar joins
26
+ * the room and owns the peer connections / track lifecycle) ; the primitive
27
+ * carries no creds and never mutates the scene (RC-ReadOnly). An UNBOUND slot
28
+ * (no `__cam.slots.*` assignment) or a not-yet-connected peer → a transparent
29
+ * placeholder, no throw, no diagnostic (R3). */
30
+ export function MeetPeerSlot({ resolved }: PrimitiveProps) {
31
+ const slotRef =
32
+ typeof resolved["x-zab.slotRef"] === "string" &&
33
+ (resolved["x-zab.slotRef"] as string).length > 0
34
+ ? (resolved["x-zab.slotRef"] as string)
35
+ : "";
36
+
37
+ // No slotRef → not addressable : a transparent inert box of the wrapper
38
+ // geometry (no throw, no diagnostic), exactly like an unbound slot.
39
+ if (slotRef === "") {
40
+ return (
41
+ <div
42
+ aria-hidden
43
+ data-lumencast-meet-peer-slot
44
+ style={{ width: "100%", height: "100%", opacity: 0, pointerEvents: "none" }}
45
+ />
46
+ );
47
+ }
48
+
49
+ // Key the peer-viewer resolver by `slotRef` (NOT a peer_label). The shared
50
+ // `LivePeerVideo` is resolver-key agnostic : it passes its `peerLabel` prop
51
+ // straight to `resolvePeerStream`/`subscribePeerStream`, and the host's
52
+ // slot-aware registry translates the slotRef to the bound peer's stream. An
53
+ // unbound slot resolves to `null` → the transparent placeholder.
54
+ return <LivePeerVideo peerLabel={slotRef} objectFit="cover" muted />;
55
+ }
@@ -0,0 +1,57 @@
1
+ import type { PrimitiveProps } from "./index";
2
+ import { LivePeerVideo } from "./live-peer-video";
3
+
4
+ /** `meet.peer` — the UNIFIED source primitive (ADR 006 §3.3/§3.5). Every source
5
+ * that crosses the Prism export (cam, screen, game_capture, …) arrives as a
6
+ * single `meet.peer` LSML node ; this is the ONE renderer for all of them — not
7
+ * a special-case path. It generalises the `media` primitive's live mode (#4) to
8
+ * the source abstraction : read `peer_label`, resolve the peer's `MediaStream`
9
+ * through the host viewer (#3), and paint it in `<video srcObject>` constrained
10
+ * to the node's box.
11
+ *
12
+ * Contract (rendered verbatim, ADR §3.3 — no variation) :
13
+ * - `peer_label` (string) — the STREAM REFERENCE. Resolved `peer_label →
14
+ * MediaStream` via `subscribePeerStream`/`resolvePeerStream`. Empty / missing
15
+ * → a transparent inert box (the source is not addressable).
16
+ * - `x-zab.sourceKind` (string) — ADVISORY only. Rendering is UNIFORM whatever
17
+ * the kind ; at most it could hint audio-only, but Phase 0 paints every
18
+ * visual source identically.
19
+ * - `object_fit` ("cover"|"contain"|"fill") — how the video fills the box.
20
+ * - `muted` (bool, optional) — audio playout hint (default muted ; broadcast
21
+ * audio is Pulsar-side).
22
+ * - `position{x,y}` + `size{w,h}` — geometry via the Tree's UniversalWrapper
23
+ * (compiler-flattened to `x/y/width/height`). Z-ORDER = sibling order (cam
24
+ * over game = two ordered `meet.peer` nodes — no special z handling here).
25
+ * - `metadata.figma` — advisory (editor round-trip), never read for rendering.
26
+ *
27
+ * RC-Geo : the `<video>` fills `100%`/`100%` of the wrapper box at the exact
28
+ * authored geometry / `object_fit` — never forced full-screen (the geometry is
29
+ * on the wrapper, not the video). RC-ReadOnly : the primitive only READS
30
+ * `resolved` ; it never writes geometry or any field back to the scene. An
31
+ * unconnected peer → transparent inert box, no throw, no diagnostic. */
32
+ export function MeetPeer({ resolved }: PrimitiveProps) {
33
+ const peerLabel =
34
+ typeof resolved.peer_label === "string" && resolved.peer_label.length > 0
35
+ ? resolved.peer_label
36
+ : "";
37
+
38
+ // Empty / missing label → not addressable yet : a transparent inert box of the
39
+ // wrapper geometry, exactly like an unconnected peer (no throw, no diagnostic).
40
+ if (peerLabel === "") {
41
+ return (
42
+ <div
43
+ aria-hidden
44
+ data-lumencast-meet-peer
45
+ style={{ width: "100%", height: "100%", opacity: 0, pointerEvents: "none" }}
46
+ />
47
+ );
48
+ }
49
+
50
+ const objectFit =
51
+ typeof resolved.object_fit === "string" && resolved.object_fit.length > 0
52
+ ? resolved.object_fit
53
+ : "cover";
54
+ const muted = resolved.muted === undefined ? true : resolved.muted !== false;
55
+
56
+ return <LivePeerVideo peerLabel={peerLabel} objectFit={objectFit} muted={muted} />;
57
+ }
@@ -97,13 +97,39 @@ export const PRIMITIVE_PROP_ALLOWLIST: Readonly<Record<RenderKind, ReadonlySet<s
97
97
  "paths",
98
98
  "ariaLabel",
99
99
  ]),
100
- media: allow(["src", "loop", "mute", "autoplay", "fit"]),
100
+ // `peerLabel` (ADR 006 #4) selects the live MediaStream mode : a node whose
101
+ // source is a `meet.peer.peer_label` is rendered in `srcObject` from a host
102
+ // resolver instead of `<video src>`. Listed so it is NOT flagged as a silent
103
+ // drop by the anti-drop audit when a scene carries a live source.
104
+ media: allow(["src", "peerLabel", "loop", "mute", "autoplay", "fit"]),
105
+ // ADR 006 §3.3/§3.5 — the unified source kind. `peer_label` is the stream
106
+ // reference (resolved to a MediaStream → srcObject) ; `object_fit`/`muted`
107
+ // drive the video ; `x-zab.sourceKind` is advisory ; `metadata` carries the
108
+ // editor round-trip (figma). Geometry is universal as flat `x/y/width/height`,
109
+ // but an UNCOMPILED from-scene node carries the NESTED `position`/`size` shape
110
+ // (the Tree flattens it as a fallback) — listed so neither form is flagged as
111
+ // a silent drop by the anti-drop audit.
112
+ "meet.peer": allow([
113
+ "peer_label",
114
+ "object_fit",
115
+ "muted",
116
+ "x-zab.sourceKind",
117
+ "metadata",
118
+ "position",
119
+ "size",
120
+ ]),
101
121
  instance: allow(["scene_id", "scene_version", "size", "position"]),
102
122
  // RFC-0001 / ADR 004 — vendor capture placeholder. `width`/`height` are the
103
123
  // flattened geometry (universal) ; the `x-zab.*` props are carried as
104
124
  // metadata (the renderer reserves the box, ignores deviceRef). Listed so
105
125
  // they are NOT flagged as silent drops by the anti-drop audit.
106
126
  "x-zab.capture": allow(["x-zab.sourceKind", "x-zab.deviceRef", "width", "height"]),
127
+ // ADR Blue 009 §3.1 (Amendment 2) — vendor meet-peer SLOT placeholder.
128
+ // `width`/`height` are the flattened geometry (universal) ; `x-zab.slotRef`
129
+ // is the logical slot identity carried as metadata (the runtime resolves
130
+ // `slotRef → peer_label` from stream-level ZabCam state). NO cam/peer
131
+ // identity is carried. Listed so they are NOT flagged as silent drops.
132
+ "x-zab.meet-peer": allow(["x-zab.slotRef", "width", "height"]),
107
133
  // `repeat` is dispatched specially by the tree ; its only consumed
108
134
  // binding is `items`.
109
135
  repeat: new Set(["items"]),