@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/app.d.ts +6 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +3 -1
- package/dist/app.js.map +1 -1
- package/dist/{broadcast-ryjLRD5q.js → broadcast-L5wm2I6J.js} +3 -3
- package/dist/{broadcast-ryjLRD5q.js.map → broadcast-L5wm2I6J.js.map} +1 -1
- package/dist/{control-AgxbXOVS.js → control-eEUG7unp.js} +4 -4
- package/dist/{control-AgxbXOVS.js.map → control-eEUG7unp.js.map} +1 -1
- package/dist/index-Clrya_9l.js +1281 -0
- package/dist/index-Clrya_9l.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/lumencast.js +18 -13
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +11 -0
- package/dist/mount.js.map +1 -1
- package/dist/overlay/runtime-context.d.ts +10 -0
- package/dist/overlay/runtime-context.d.ts.map +1 -1
- package/dist/overlay/runtime-context.js.map +1 -1
- package/dist/render/bundle.d.ts +1 -1
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/primitives/capture.d.ts +13 -4
- package/dist/render/primitives/capture.d.ts.map +1 -1
- package/dist/render/primitives/capture.js +54 -22
- package/dist/render/primitives/capture.js.map +1 -1
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js +4 -0
- package/dist/render/primitives/index.js.map +1 -1
- package/dist/render/primitives/live-peer-video.d.ts +27 -0
- package/dist/render/primitives/live-peer-video.d.ts.map +1 -0
- package/dist/render/primitives/live-peer-video.js +64 -0
- package/dist/render/primitives/live-peer-video.js.map +1 -0
- package/dist/render/primitives/media.d.ts +37 -12
- package/dist/render/primitives/media.d.ts.map +1 -1
- package/dist/render/primitives/media.js +43 -17
- package/dist/render/primitives/media.js.map +1 -1
- package/dist/render/primitives/meet-peer.d.ts +31 -0
- package/dist/render/primitives/meet-peer.d.ts.map +1 -0
- package/dist/render/primitives/meet-peer.js +46 -0
- package/dist/render/primitives/meet-peer.js.map +1 -0
- package/dist/render/prop-allowlist.d.ts.map +1 -1
- package/dist/render/prop-allowlist.js +27 -1
- package/dist/render/prop-allowlist.js.map +1 -1
- package/dist/render/tree.js +42 -8
- package/dist/render/tree.js.map +1 -1
- package/dist/{status-pill-BxCdj-KZ.js → status-pill-elORkMrh.js} +2 -2
- package/dist/{status-pill-BxCdj-KZ.js.map → status-pill-elORkMrh.js.map} +1 -1
- package/dist/{test-CaRHj_J6.js → test-7q_KJkdX.js} +4 -4
- package/dist/{test-CaRHj_J6.js.map → test-7q_KJkdX.js.map} +1 -1
- package/dist/{tree-BLIxJbD3.js → tree-BMxx5170.js} +522 -436
- package/dist/tree-BMxx5170.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/webrtc/index.d.ts +76 -0
- package/dist/webrtc/index.d.ts.map +1 -0
- package/dist/webrtc/index.js +180 -0
- package/dist/webrtc/index.js.map +1 -0
- package/dist/webrtc/meet-viewer.d.ts +139 -0
- package/dist/webrtc/meet-viewer.d.ts.map +1 -0
- package/dist/webrtc/meet-viewer.js +379 -0
- package/dist/webrtc/meet-viewer.js.map +1 -0
- package/dist/webrtc/peer-stream-registry.d.ts +21 -0
- package/dist/webrtc/peer-stream-registry.d.ts.map +1 -0
- package/dist/webrtc/peer-stream-registry.js +77 -0
- package/dist/webrtc/peer-stream-registry.js.map +1 -0
- package/package.json +4 -4
- package/src/app.tsx +9 -0
- package/src/index.ts +35 -0
- package/src/mount.ts +11 -0
- package/src/overlay/runtime-context.tsx +10 -0
- package/src/render/bundle.ts +11 -1
- package/src/render/primitives/capture.tsx +73 -28
- package/src/render/primitives/index.ts +4 -0
- package/src/render/primitives/live-peer-video.tsx +90 -0
- package/src/render/primitives/media.tsx +66 -17
- package/src/render/primitives/meet-peer.tsx +57 -0
- package/src/render/prop-allowlist.ts +27 -1
- package/src/render/tree.tsx +44 -8
- package/src/types.ts +13 -0
- package/src/webrtc/index.ts +252 -0
- package/src/webrtc/meet-viewer.ts +497 -0
- package/src/webrtc/peer-stream-registry.ts +93 -0
- package/dist/index-DrXsLYhe.js +0 -903
- package/dist/index-DrXsLYhe.js.map +0 -1
- package/dist/tree-BLIxJbD3.js.map +0 -1
package/src/render/bundle.ts
CHANGED
|
@@ -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 =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
134
|
-
* The result NEVER enters the bundle or the content
|
|
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
|
-
) =>
|
|
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
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
//
|
|
185
|
-
//
|
|
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
|
|
193
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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"]),
|
package/src/render/tree.tsx
CHANGED
|
@@ -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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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 {
|