@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
@@ -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,7 @@ 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";
15
16
  import { Instance } from "./instance";
16
17
  import { Capture } from "./capture";
17
18
  // `repeat` is dispatched specially in the tree (it iterates a bound
@@ -48,6 +49,9 @@ export const PRIMITIVES: Partial<Record<RenderKind, ComponentType<PrimitiveProps
48
49
  image: Image,
49
50
  shape: Shape,
50
51
  media: Media,
52
+ // ADR 006 §3.3/§3.5 — the unified source kind : every exported source is a
53
+ // `meet.peer` node rendered in `<video srcObject>` from the WebRTC viewer.
54
+ "meet.peer": MeetPeer,
51
55
  instance: Instance,
52
56
  // RFC-0001 / ADR 004 — Zab vendor capture placeholder (transparent, inert).
53
57
  "x-zab.capture": Capture,
@@ -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,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"]),
@@ -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
 
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@
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";
5
6
 
6
7
  export type LumencastMode = "broadcast" | "control" | "test";
7
8
 
@@ -79,6 +80,18 @@ export interface MountOptions {
79
80
  * Only consulted on a capture-capable host (e.g. the Electron preview
80
81
  * webview) ; ignored on-air (CEF/Pulsar render the placeholder). */
81
82
  resolveCaptureDevice?: ResolveCaptureDevice;
83
+ /** ADR 006 #4 — host resolver for the `media` primitive's LIVE mode. Given a
84
+ * LOGICAL `peerLabel` (a `meet.peer.peer_label` from the scene), return the
85
+ * live `MediaStream` of that peer, or `null` when it is not connected yet.
86
+ * Supplied by the WebRTC viewer (issue #3) ; the stream is rendered in
87
+ * `<video>.srcObject` and NEVER enters the bundle or the content hash. Omit
88
+ * it and a live `media` node renders a stream-less box, never throwing. */
89
+ resolvePeerStream?: ResolvePeerStream;
90
+ /** ADR 006 #3 — reactive variant of `resolvePeerStream` : the viewer pushes a
91
+ * peer's stream on connect and `null` on leave, so a LIVE `media` node
92
+ * re-renders when its peer joins mid-show. `createPeerViewer()` returns one.
93
+ * Preferred over `resolvePeerStream` when both are supplied. */
94
+ subscribePeerStream?: SubscribePeerStream;
82
95
  }
83
96
 
84
97
  export interface LumencastHandle {