@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/animate/keyframes.js +8 -1
- package/dist/animate/keyframes.js.map +1 -1
- package/dist/app.d.ts +9 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +4 -1
- package/dist/app.js.map +1 -1
- package/dist/{broadcast-Gcd-dmC7.js → broadcast-L5wm2I6J.js} +3 -3
- package/dist/{broadcast-Gcd-dmC7.js.map → broadcast-L5wm2I6J.js.map} +1 -1
- package/dist/{control-C5TfClga.js → control-eEUG7unp.js} +4 -4
- package/dist/{control-C5TfClga.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 +3 -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 +17 -12
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +16 -0
- package/dist/mount.js.map +1 -1
- package/dist/overlay/runtime-context.d.ts +21 -0
- package/dist/overlay/runtime-context.d.ts.map +1 -1
- package/dist/overlay/runtime-context.js +8 -0
- 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 +4 -0
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/keyframe-player.d.ts.map +1 -1
- package/dist/render/keyframe-player.js +15 -1
- package/dist/render/keyframe-player.js.map +1 -1
- package/dist/render/primitives/capture.d.ts +49 -0
- package/dist/render/primitives/capture.d.ts.map +1 -0
- package/dist/render/primitives/capture.js +203 -0
- package/dist/render/primitives/capture.js.map +1 -0
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js +7 -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 +32 -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-BaLQoIDl.js → status-pill-elORkMrh.js} +2 -2
- package/dist/{status-pill-BaLQoIDl.js.map → status-pill-elORkMrh.js.map} +1 -1
- package/dist/{test-CA30C2By.js → test-7q_KJkdX.js} +4 -4
- package/dist/{test-CA30C2By.js.map → test-7q_KJkdX.js.map} +1 -1
- package/dist/{tree-1coZ32nd.js → tree-BMxx5170.js} +773 -604
- package/dist/tree-BMxx5170.js.map +1 -0
- package/dist/types.d.ts +23 -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/animate/keyframes.ts +8 -1
- package/src/app.tsx +14 -0
- package/src/index.ts +40 -0
- package/src/mount.ts +16 -0
- package/src/overlay/runtime-context.tsx +24 -0
- package/src/render/bundle.ts +19 -1
- package/src/render/keyframe-player.tsx +14 -1
- package/src/render/primitives/capture.tsx +255 -0
- package/src/render/primitives/index.ts +7 -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 +32 -1
- package/src/render/tree.tsx +44 -8
- package/src/types.ts +23 -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-N-VqrIxN.js +0 -885
- package/dist/index-N-VqrIxN.js.map +0 -1
- 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
|
+
}
|
package/src/render/bundle.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|