@lumencast/runtime 0.8.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 (99) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/animate/keyframes.js +8 -1
  3. package/dist/animate/keyframes.js.map +1 -1
  4. package/dist/app.d.ts +9 -1
  5. package/dist/app.d.ts.map +1 -1
  6. package/dist/app.js +4 -1
  7. package/dist/app.js.map +1 -1
  8. package/dist/{broadcast-Gcd-dmC7.js → broadcast-L5wm2I6J.js} +3 -3
  9. package/dist/{broadcast-Gcd-dmC7.js.map → broadcast-L5wm2I6J.js.map} +1 -1
  10. package/dist/{control-C5TfClga.js → control-eEUG7unp.js} +4 -4
  11. package/dist/{control-C5TfClga.js.map → control-eEUG7unp.js.map} +1 -1
  12. package/dist/index-Clrya_9l.js +1281 -0
  13. package/dist/index-Clrya_9l.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.html +1 -1
  17. package/dist/index.js +11 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/lumencast.js +17 -12
  20. package/dist/mount.d.ts.map +1 -1
  21. package/dist/mount.js +16 -0
  22. package/dist/mount.js.map +1 -1
  23. package/dist/overlay/runtime-context.d.ts +21 -0
  24. package/dist/overlay/runtime-context.d.ts.map +1 -1
  25. package/dist/overlay/runtime-context.js +8 -0
  26. package/dist/overlay/runtime-context.js.map +1 -1
  27. package/dist/render/bundle.d.ts +1 -1
  28. package/dist/render/bundle.d.ts.map +1 -1
  29. package/dist/render/bundle.js +4 -0
  30. package/dist/render/bundle.js.map +1 -1
  31. package/dist/render/keyframe-player.d.ts.map +1 -1
  32. package/dist/render/keyframe-player.js +15 -1
  33. package/dist/render/keyframe-player.js.map +1 -1
  34. package/dist/render/primitives/capture.d.ts +49 -0
  35. package/dist/render/primitives/capture.d.ts.map +1 -0
  36. package/dist/render/primitives/capture.js +203 -0
  37. package/dist/render/primitives/capture.js.map +1 -0
  38. package/dist/render/primitives/index.d.ts.map +1 -1
  39. package/dist/render/primitives/index.js +7 -0
  40. package/dist/render/primitives/index.js.map +1 -1
  41. package/dist/render/primitives/live-peer-video.d.ts +27 -0
  42. package/dist/render/primitives/live-peer-video.d.ts.map +1 -0
  43. package/dist/render/primitives/live-peer-video.js +64 -0
  44. package/dist/render/primitives/live-peer-video.js.map +1 -0
  45. package/dist/render/primitives/media.d.ts +37 -12
  46. package/dist/render/primitives/media.d.ts.map +1 -1
  47. package/dist/render/primitives/media.js +43 -17
  48. package/dist/render/primitives/media.js.map +1 -1
  49. package/dist/render/primitives/meet-peer.d.ts +31 -0
  50. package/dist/render/primitives/meet-peer.d.ts.map +1 -0
  51. package/dist/render/primitives/meet-peer.js +46 -0
  52. package/dist/render/primitives/meet-peer.js.map +1 -0
  53. package/dist/render/prop-allowlist.d.ts.map +1 -1
  54. package/dist/render/prop-allowlist.js +32 -1
  55. package/dist/render/prop-allowlist.js.map +1 -1
  56. package/dist/render/tree.js +42 -8
  57. package/dist/render/tree.js.map +1 -1
  58. package/dist/{status-pill-BaLQoIDl.js → status-pill-elORkMrh.js} +2 -2
  59. package/dist/{status-pill-BaLQoIDl.js.map → status-pill-elORkMrh.js.map} +1 -1
  60. package/dist/{test-CA30C2By.js → test-7q_KJkdX.js} +4 -4
  61. package/dist/{test-CA30C2By.js.map → test-7q_KJkdX.js.map} +1 -1
  62. package/dist/{tree-1coZ32nd.js → tree-BMxx5170.js} +773 -604
  63. package/dist/tree-BMxx5170.js.map +1 -0
  64. package/dist/types.d.ts +23 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/webrtc/index.d.ts +76 -0
  67. package/dist/webrtc/index.d.ts.map +1 -0
  68. package/dist/webrtc/index.js +180 -0
  69. package/dist/webrtc/index.js.map +1 -0
  70. package/dist/webrtc/meet-viewer.d.ts +139 -0
  71. package/dist/webrtc/meet-viewer.d.ts.map +1 -0
  72. package/dist/webrtc/meet-viewer.js +379 -0
  73. package/dist/webrtc/meet-viewer.js.map +1 -0
  74. package/dist/webrtc/peer-stream-registry.d.ts +21 -0
  75. package/dist/webrtc/peer-stream-registry.d.ts.map +1 -0
  76. package/dist/webrtc/peer-stream-registry.js +77 -0
  77. package/dist/webrtc/peer-stream-registry.js.map +1 -0
  78. package/package.json +4 -4
  79. package/src/animate/keyframes.ts +8 -1
  80. package/src/app.tsx +14 -0
  81. package/src/index.ts +40 -0
  82. package/src/mount.ts +16 -0
  83. package/src/overlay/runtime-context.tsx +24 -0
  84. package/src/render/bundle.ts +19 -1
  85. package/src/render/keyframe-player.tsx +14 -1
  86. package/src/render/primitives/capture.tsx +255 -0
  87. package/src/render/primitives/index.ts +7 -0
  88. package/src/render/primitives/live-peer-video.tsx +90 -0
  89. package/src/render/primitives/media.tsx +66 -17
  90. package/src/render/primitives/meet-peer.tsx +57 -0
  91. package/src/render/prop-allowlist.ts +32 -1
  92. package/src/render/tree.tsx +44 -8
  93. package/src/types.ts +23 -0
  94. package/src/webrtc/index.ts +252 -0
  95. package/src/webrtc/meet-viewer.ts +497 -0
  96. package/src/webrtc/peer-stream-registry.ts +93 -0
  97. package/dist/index-N-VqrIxN.js +0 -885
  98. package/dist/index-N-VqrIxN.js.map +0 -1
  99. package/dist/tree-1coZ32nd.js.map +0 -1
package/src/mount.ts CHANGED
@@ -118,6 +118,22 @@ export function mount(options: MountOptions): LumencastHandle {
118
118
  statusSignal,
119
119
  crossfadeKeySignal,
120
120
  sendInput: (patches) => ws.sendInput(patches),
121
+ // ADR 004 §A1.3 — thread the host capture resolver to the runtime context
122
+ // so the `x-zab.capture` primitive's ACQUIRE mode can pin a device.
123
+ ...(options.resolveCaptureDevice !== undefined
124
+ ? { resolveCaptureDevice: options.resolveCaptureDevice }
125
+ : {}),
126
+ // ADR 006 #4 — thread the host peer-stream resolver (supplied by the
127
+ // WebRTC viewer #3) so the `media` primitive's LIVE mode can render a
128
+ // peer's MediaStream in `srcObject`.
129
+ ...(options.resolvePeerStream !== undefined
130
+ ? { resolvePeerStream: options.resolvePeerStream }
131
+ : {}),
132
+ // ADR 006 #3 — reactive variant : the LIVE `media` node re-renders when a
133
+ // peer connects/leaves mid-show. `createPeerViewer()` supplies it.
134
+ ...(options.subscribePeerStream !== undefined
135
+ ? { subscribePeerStream: options.subscribePeerStream }
136
+ : {}),
121
137
  }),
122
138
  );
123
139
 
@@ -4,6 +4,8 @@ import type { Store } from "../state/store";
4
4
  import type { RenderBundle } from "../render/bundle";
5
5
  import type { ConnectionStatus } from "../transport/ws";
6
6
  import type { LumencastMode } from "../types";
7
+ import type { ResolveCaptureDevice } from "../render/primitives/capture";
8
+ import type { ResolvePeerStream, SubscribePeerStream } from "../render/primitives/media";
7
9
 
8
10
  export interface LumencastRuntime {
9
11
  mode: LumencastMode;
@@ -12,6 +14,19 @@ export interface LumencastRuntime {
12
14
  status: ConnectionStatus;
13
15
  /** Send LSDP/1 input patches to the server. */
14
16
  sendInput: (patches: Patch[]) => void;
17
+ /** ADR 004 §A1.3 — host-provided resolver mapping a LOGICAL `deviceRef` to a
18
+ * physical `deviceId` for the `x-zab.capture` primitive's ACQUIRE mode.
19
+ * Injected from `MountOptions`, NOT the bundle. Absent → default device. */
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;
15
30
  }
16
31
 
17
32
  const Ctx = createContext<LumencastRuntime | null>(null);
@@ -35,3 +50,12 @@ export function useLumencastRuntime(): LumencastRuntime {
35
50
  }
36
51
  return v;
37
52
  }
53
+
54
+ /** Read the runtime context WITHOUT throwing when no provider is mounted.
55
+ * Render primitives (e.g. `x-zab.capture`) may render via `<Tree>` directly —
56
+ * embedded hosts, tooling, tests — outside `mount()`'s provider. They use this
57
+ * to pick up mount-level host config (the capture resolver) when present and
58
+ * fall back to defaults when not. */
59
+ export function useOptionalLumencastRuntime(): LumencastRuntime | null {
60
+ return useContext(Ctx);
61
+ }
@@ -25,7 +25,21 @@ export type RenderKind =
25
25
  | "shape"
26
26
  | "media"
27
27
  | "repeat"
28
- | "instance";
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"
33
+ // Zab vendor primitive (RFC-0001, §17.1) — a transparent capture
34
+ // placeholder. Recognised by the Zab-plugin runtime ; reserves a box and
35
+ // renders nothing.
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";
29
43
 
30
44
  export interface RenderNode {
31
45
  kind: RenderKind;
@@ -139,6 +153,10 @@ export interface RenderBundle {
139
153
  */
140
154
  export const SUPPORTED_PROFILES: ReadonlySet<string> = new Set<string>([
141
155
  "x-lumencast.color-srgb-1.0",
156
+ // RFC-0001 / ADR 004 — this runtime ships the Zab capture plugin, so a
157
+ // bundle declaring `x-zab.capture/1` in `profiles[]` is compatible (it is
158
+ // NOT rejected as BUNDLE_INCOMPATIBLE, §17.3.1).
159
+ "x-zab.capture/1",
142
160
  ]);
143
161
 
144
162
  // LSML 1.1 §17.5.1 + ADR 001 RC#14 — authoring-profile detection.
@@ -64,7 +64,20 @@ export function KeyframePlayer({
64
64
  return (
65
65
  <motion.div
66
66
  key={replayTokenRef.current}
67
- style={{ display: "contents" }}
67
+ // A `display:contents` element generates NO box, so the browser
68
+ // never composites the animated `transform`/`opacity`/`filter` this
69
+ // player writes — they are silently dropped and the subtree renders
70
+ // dead at its child's default origin (ADR 011 I7 live bug). The
71
+ // player must be a REAL compositing box. `position:absolute; inset:0`
72
+ // overlays the parent without disturbing sibling layout, and — being
73
+ // positioned — becomes the containing block for the absolutely-
74
+ // positioned primitive nested beneath it (Frame is `position:absolute;
75
+ // left:0; top:0`), so the child's authored `x`/`y` resolve against the
76
+ // player's (0,0) exactly as they did against the grandparent under
77
+ // `display:contents`. The animated channels now composite onto a live
78
+ // box and the whole subtree (the nested target's geometry + fill)
79
+ // moves and fades with the keyframes.
80
+ style={{ position: "absolute", inset: 0 }}
68
81
  initial={firstFrame(compiled.animate)}
69
82
  animate={compiled.animate}
70
83
  transition={transition}
@@ -0,0 +1,255 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { PrimitiveProps } from "./index";
3
+ import { useOptionalLumencastRuntime } from "../../overlay/runtime-context";
4
+
5
+ /** `x-zab.capture` — context-aware capture primitive (Zab vendor primitive,
6
+ * RFC-0001 / ADR 004 §Amendment 1).
7
+ *
8
+ * The primitive reserves a box of the declared geometry so downstream layout
9
+ * (siblings, masks, stacks, grids) is unaffected — exactly as an `image` of
10
+ * the same geometry, in BOTH modes below. It picks a mode by **capability
11
+ * detection at mount** (feature detection, not an env flag) :
12
+ *
13
+ * - **ACQUIRE** (capable host, e.g. the Electron preview webview with
14
+ * auto-granted media permissions) : it acquires a live stream itself via
15
+ * `getUserMedia` (webcam/mic) or `getDisplayMedia` (screen/window) per
16
+ * `x-zab.sourceKind`, and renders it in a `<video>` for visual kinds
17
+ * (audio kinds stay visually empty). The physical device is resolved from
18
+ * the LOGICAL `x-zab.deviceRef` through a host-provided resolver
19
+ * (`resolveCaptureDevice`, §A1.3) — never a bundle-baked id. Any failure
20
+ * (no resolver, no device, permission denied, acquisition error) falls
21
+ * back to PLACEHOLDER WITHOUT throwing or blanking the surrounding tree.
22
+ *
23
+ * - **PLACEHOLDER** (non-capable host, e.g. CEF/Pulsar on-air) : the box is
24
+ * fully transparent, acquires nothing, reaches no device — the original
25
+ * §3.2 behaviour. The consuming app composites a native source behind it
26
+ * (ON-AIR PATH UNCHANGED).
27
+ *
28
+ * A stream-less box is a valid mode, not an error : NO diagnostic is emitted
29
+ * for PLACEHOLDER mode or for an ACQUIRE→PLACEHOLDER fallback.
30
+ *
31
+ * Geometry is the only layout input. `width`/`height` are the
32
+ * compiler-flattened `size:{w,h}` ; universal props (visible/opacity/
33
+ * position) are applied by the Tree's UniversalWrapper. An audio-only
34
+ * capture (`media.mic` / `media.app_audio`) may omit `size` → a zero-area
35
+ * box that never paints. */
36
+ export function Capture({ resolved }: PrimitiveProps) {
37
+ const width = dimOr(resolved.width, "100%");
38
+ const height = dimOr(resolved.height, "100%");
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
+
46
+ // §A1.3 — the host-provided resolver, injected at mount through the runtime
47
+ // context (NOT the bundle, NOT the LSDP wire). Absent when the tree renders
48
+ // outside a host (direct embedding, tooling, tests) → the default-device
49
+ // path applies.
50
+ const runtime = useOptionalLumencastRuntime();
51
+ const resolveCaptureDevice = runtime?.resolveCaptureDevice;
52
+
53
+ const videoRef = useRef<HTMLVideoElement | null>(null);
54
+ // `null` → PLACEHOLDER (non-capable, or ACQUIRE→PLACEHOLDER fallback).
55
+ // A `MediaStream` → ACQUIRE succeeded and a visual stream is mounted.
56
+ const [stream, setStream] = useState<MediaStream | null>(null);
57
+
58
+ useEffect(() => {
59
+ // §A1.2(2) — capability detection at mount. A non-capable host (no
60
+ // `getUserMedia`, e.g. CEF/Pulsar on-air, or jsdom without a mock) stays
61
+ // in PLACEHOLDER : acquire nothing, no diagnostic.
62
+ if (!isCaptureCapable()) return;
63
+
64
+ let cancelled = false;
65
+ let acquired: MediaStream | null = null;
66
+
67
+ void (async () => {
68
+ try {
69
+ const media = await acquireStream(sourceKind, deviceRef, resolveCaptureDevice);
70
+ if (media === null) return; // unknown/unsupported kind → PLACEHOLDER
71
+ if (cancelled) {
72
+ // Unmounted (or scene-changed) during acquisition — stop immediately
73
+ // so we never leave a camera light on (RC11).
74
+ stopStream(media);
75
+ return;
76
+ }
77
+ acquired = media;
78
+ setStream(media);
79
+ } catch {
80
+ // §A1.2(2)(a) — any acquisition failure (permission denied, no device,
81
+ // getUserMedia rejected) falls back to PLACEHOLDER, no throw, no
82
+ // diagnostic.
83
+ }
84
+ })();
85
+
86
+ return () => {
87
+ cancelled = true;
88
+ // RC11 — stop the tracks at unmount / scene change. Clearing state is
89
+ // unnecessary (the component is gone) but stopping the device is not.
90
+ if (acquired !== null) stopStream(acquired);
91
+ };
92
+ // Re-acquire when the logical source identity changes (a scene switch can
93
+ // reuse the node with a new sourceKind/deviceRef).
94
+ }, [sourceKind, deviceRef, resolveCaptureDevice]);
95
+
96
+ // Attach / detach the live stream to the <video> element imperatively —
97
+ // `srcObject` is not a serialisable attribute.
98
+ useEffect(() => {
99
+ const el = videoRef.current;
100
+ if (el === null) return;
101
+ el.srcObject = stream;
102
+ return () => {
103
+ if (el !== null) el.srcObject = null;
104
+ };
105
+ }, [stream]);
106
+
107
+ // ACQUIRE with a visual stream → render the <video>. Audio-only kinds keep
108
+ // the transparent box (no visible element) even when acquired.
109
+ if (stream !== null && isVisualKind(sourceKind)) {
110
+ return (
111
+ <video
112
+ ref={videoRef}
113
+ data-lumencast-capture
114
+ autoPlay
115
+ muted
116
+ playsInline
117
+ style={{ width, height, objectFit: "cover", pointerEvents: "none" }}
118
+ />
119
+ );
120
+ }
121
+
122
+ // PLACEHOLDER (or audio-only ACQUIRE) — fully transparent, inert box.
123
+ return (
124
+ <div
125
+ aria-hidden
126
+ data-lumencast-capture
127
+ style={{ width, height, opacity: 0, pointerEvents: "none" }}
128
+ />
129
+ );
130
+ }
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
+
139
+ /** Resolver injected by the consuming app (ADR 004 §A1.3). Maps the LOGICAL
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. */
147
+ export type ResolveCaptureDevice = (
148
+ deviceRef: string,
149
+ sourceKind: string,
150
+ ) => ResolvedCaptureDevice | Promise<ResolvedCaptureDevice>;
151
+
152
+ /** §A1.2(2) — capture-capable iff `navigator.mediaDevices.getUserMedia`
153
+ * exists and is callable in the current context. Feature detection only ;
154
+ * CEF/Pulsar on-air and jsdom (without a mock) report non-capable. */
155
+ function isCaptureCapable(): boolean {
156
+ return (
157
+ typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function"
158
+ );
159
+ }
160
+
161
+ /** Visual kinds render a `<video>` ; audio kinds stay visually empty. */
162
+ function isVisualKind(sourceKind: string): boolean {
163
+ return (
164
+ sourceKind === "media.webcam" || sourceKind === "media.screen" || sourceKind === "media.window"
165
+ );
166
+ }
167
+
168
+ /** Acquire a live stream for `sourceKind`, applying a host-resolved `deviceId`
169
+ * when available. Returns `null` for an unsupported/unknown kind (→
170
+ * PLACEHOLDER) ; throws are caught by the caller (→ PLACEHOLDER fallback). */
171
+ async function acquireStream(
172
+ sourceKind: string,
173
+ deviceRef: string,
174
+ resolveCaptureDevice: ResolveCaptureDevice | undefined,
175
+ ): Promise<MediaStream | null> {
176
+ const md = navigator.mediaDevices;
177
+
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;
184
+ const deviceId = resolved?.deviceId;
185
+ const declaredRef = deviceRef.length > 0;
186
+
187
+ switch (sourceKind) {
188
+ case "media.webcam":
189
+ case "media.mic":
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
+ }
202
+ case "media.screen":
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;
222
+ return md.getDisplayMedia({ video: true });
223
+ }
224
+ default:
225
+ return null;
226
+ }
227
+ }
228
+
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. */
237
+ function deviceConstraint(deviceId: string | undefined): MediaTrackConstraints | boolean {
238
+ return typeof deviceId === "string" && deviceId.length > 0
239
+ ? { deviceId: { exact: deviceId } }
240
+ : true;
241
+ }
242
+
243
+ /** Stop every track of a stream (RC11 — release the camera/mic, kill the
244
+ * device light). */
245
+ function stopStream(stream: MediaStream): void {
246
+ for (const track of stream.getTracks()) track.stop();
247
+ }
248
+
249
+ /** A render dimension: a finite number → px, a non-empty string → verbatim,
250
+ * anything else → the fallback (matches the `image` primitive's helper). */
251
+ function dimOr(v: unknown, fallback: string): string {
252
+ if (typeof v === "number" && Number.isFinite(v)) return `${v}px`;
253
+ if (typeof v === "string" && v.length > 0) return v;
254
+ return fallback;
255
+ }
@@ -12,7 +12,9 @@ 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";
17
+ import { Capture } from "./capture";
16
18
  // `repeat` is dispatched specially in the tree (it iterates a bound
17
19
  // array and provides a path scope to its children) ; it does not
18
20
  // appear here as a regular primitive.
@@ -47,5 +49,10 @@ export const PRIMITIVES: Partial<Record<RenderKind, ComponentType<PrimitiveProps
47
49
  image: Image,
48
50
  shape: Shape,
49
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,
50
55
  instance: Instance,
56
+ // RFC-0001 / ADR 004 — Zab vendor capture placeholder (transparent, inert).
57
+ "x-zab.capture": Capture,
51
58
  };
@@ -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
+ }