@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
|
@@ -97,8 +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"]),
|
|
122
|
+
// RFC-0001 / ADR 004 — vendor capture placeholder. `width`/`height` are the
|
|
123
|
+
// flattened geometry (universal) ; the `x-zab.*` props are carried as
|
|
124
|
+
// metadata (the renderer reserves the box, ignores deviceRef). Listed so
|
|
125
|
+
// they are NOT flagged as silent drops by the anti-drop audit.
|
|
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"]),
|
|
102
133
|
// `repeat` is dispatched specially by the tree ; its only consumed
|
|
103
134
|
// binding is `items`.
|
|
104
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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Public types of @lumencast/runtime — must align with RUNTIME-API.md.
|
|
2
2
|
|
|
3
3
|
import type { ErrorCode } from "@lumencast/protocol";
|
|
4
|
+
import type { ResolveCaptureDevice } from "./render/primitives/capture";
|
|
5
|
+
import type { ResolvePeerStream, SubscribePeerStream } from "./render/primitives/media";
|
|
4
6
|
|
|
5
7
|
export type LumencastMode = "broadcast" | "control" | "test";
|
|
6
8
|
|
|
@@ -69,6 +71,27 @@ export interface MountOptions {
|
|
|
69
71
|
* logs — `broadcast` builds stay console-silent. When omitted, the
|
|
70
72
|
* runtime falls back to a DEV-only console.warn. */
|
|
71
73
|
onDiagnostic?: (diagnostic: LumencastDiagnostic) => void;
|
|
74
|
+
/** ADR 004 §A1.3 — host resolver for the `x-zab.capture` primitive's ACQUIRE
|
|
75
|
+
* mode. Given the LOGICAL `(deviceRef, sourceKind)` from the bundle, return
|
|
76
|
+
* `{ deviceId }` to pin a physical device, or `null` for the host's default
|
|
77
|
+
* device. The runtime passes `deviceId` only as a live `getUserMedia`
|
|
78
|
+
* constraint — it NEVER enters the bundle or the content hash. Omit it and
|
|
79
|
+
* ACQUIRE uses the default device ("the cam traverses"), never throwing.
|
|
80
|
+
* Only consulted on a capture-capable host (e.g. the Electron preview
|
|
81
|
+
* webview) ; ignored on-air (CEF/Pulsar render the placeholder). */
|
|
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;
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
export interface LumencastHandle {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// WebRTC viewer — public surface (ADR 006 §3.3, issue #3).
|
|
2
|
+
//
|
|
3
|
+
// `createPeerViewer` wires a `MeetViewer` (mesh, viewer role) to a
|
|
4
|
+
// `PeerStreamRegistry` and returns the `resolvePeerStream` / `subscribePeerStream`
|
|
5
|
+
// the `media` primitive's LIVE mode (#4) consumes via `MountOptions`. This is
|
|
6
|
+
// the #3↔#4 bridge : the viewer receives peers, the registry maps
|
|
7
|
+
// `peer_label → MediaStream`, the primitive renders it in `<video srcObject>`.
|
|
8
|
+
//
|
|
9
|
+
// MULTI-ROOM (final model) : `createMultiRoomPeerViewer({ rooms: [...] })` joins
|
|
10
|
+
// EVERY room with its own mesh and feeds ONE shared registry, so the `meet.peer`
|
|
11
|
+
// renderer resolves a `peer_label` to its stream whatever room it came from.
|
|
12
|
+
|
|
13
|
+
import { MeetViewer, type MeetViewerOptions, type RemoteTrackEvent } from "./meet-viewer.js";
|
|
14
|
+
import {
|
|
15
|
+
createPeerStreamRegistry,
|
|
16
|
+
type PeerStreamListener,
|
|
17
|
+
type PeerStreamRegistry,
|
|
18
|
+
} from "./peer-stream-registry.js";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
MeetViewer,
|
|
22
|
+
type MeetViewerOptions,
|
|
23
|
+
type MeetViewerDeps,
|
|
24
|
+
type PeerInfo,
|
|
25
|
+
type RemoteTrackEvent,
|
|
26
|
+
} from "./meet-viewer.js";
|
|
27
|
+
export {
|
|
28
|
+
createPeerStreamRegistry,
|
|
29
|
+
type PeerStreamRegistry,
|
|
30
|
+
type PeerStreamListener,
|
|
31
|
+
} from "./peer-stream-registry.js";
|
|
32
|
+
|
|
33
|
+
export interface PeerViewer {
|
|
34
|
+
/** Join the room(s) (viewer role, no capture). */
|
|
35
|
+
join: () => Promise<void>;
|
|
36
|
+
/** Leave + tear down all peer connections (the track owner releases here). */
|
|
37
|
+
leave: () => void;
|
|
38
|
+
/** #4 contract — pass to `mount({ resolvePeerStream })`. Synchronous. */
|
|
39
|
+
resolvePeerStream: (peerLabel: string) => MediaStream | null;
|
|
40
|
+
/** Push channel for the LIVE primitive to re-render on connect/disconnect. */
|
|
41
|
+
subscribePeerStream: (peerLabel: string, listener: PeerStreamListener) => () => void;
|
|
42
|
+
/** The underlying registry, for advanced hosts. */
|
|
43
|
+
registry: PeerStreamRegistry;
|
|
44
|
+
/** Single-room only — the underlying mesh, for diagnostics. Absent in the
|
|
45
|
+
* multi-room viewer (it owns N meshes). */
|
|
46
|
+
viewer?: MeetViewer;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Feed a (possibly shared) registry from ONE viewer's lifecycle. A label is
|
|
50
|
+
* owned by the FIRST room that connects it (`label-collision` policy) : `claim`
|
|
51
|
+
* decides whether this viewer may publish/withdraw a given label, so a second
|
|
52
|
+
* room carrying the same `peer_label` never clobbers the first. Returns the
|
|
53
|
+
* viewer + a `dispose` that closes the mesh and releases this viewer's labels. */
|
|
54
|
+
/** Normalise a peer name / peer_label into the SAME key both sides agree on.
|
|
55
|
+
* The publisher announces a FREE name on the mesh (e.g. "Publisher 366"), but
|
|
56
|
+
* the scene's `peer_label` is slugified at LSML export (from-scene → "publisher
|
|
57
|
+
* _366", per source-node.ts `slugifyToLabel`). Strict `peerName === peer_label`
|
|
58
|
+
* therefore never matches. Applying the SAME slug to BOTH the indexing side
|
|
59
|
+
* (peerName) and the resolution side (peer_label) makes the map work regardless
|
|
60
|
+
* of format (a raw label and its slug collapse to the same key). Mirrors
|
|
61
|
+
* Prism `slugifyToLabel`. */
|
|
62
|
+
export function labelKey(s: string): string {
|
|
63
|
+
return s
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9_-]+/g, "_")
|
|
66
|
+
.replace(/^[_-]+|[_-]+$/g, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wireViewer(
|
|
70
|
+
viewer: MeetViewer,
|
|
71
|
+
registry: PeerStreamRegistry,
|
|
72
|
+
claim: {
|
|
73
|
+
acquire: (label: string, viewer: MeetViewer) => boolean;
|
|
74
|
+
release: (label: string, viewer: MeetViewer) => void;
|
|
75
|
+
},
|
|
76
|
+
): void {
|
|
77
|
+
// Index the registry by the NORMALISED label (slug of peerName), so it matches
|
|
78
|
+
// the scene node's slugified `peer_label`. Never the opaque `peerId`.
|
|
79
|
+
viewer.on("remote-track", (e: RemoteTrackEvent) => {
|
|
80
|
+
const key = labelKey(e.peerName);
|
|
81
|
+
if (claim.acquire(key, viewer)) registry.set(key, e.stream);
|
|
82
|
+
});
|
|
83
|
+
viewer.on("peer-left", (e) => {
|
|
84
|
+
const key = labelKey(e.peerName);
|
|
85
|
+
if (claim.acquire(key, viewer)) {
|
|
86
|
+
registry.remove(key);
|
|
87
|
+
claim.release(key, viewer);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Single-room ownership : trivially, this lone viewer owns every label. */
|
|
93
|
+
const SOLE_OWNER = {
|
|
94
|
+
acquire: () => true,
|
|
95
|
+
release: () => {},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Build a viewer + registry for a SINGLE room and expose the #4 resolver.
|
|
99
|
+
* (Back-compat surface — `createMultiRoomPeerViewer` is the final model.) */
|
|
100
|
+
export function createPeerViewer(options: MeetViewerOptions): PeerViewer {
|
|
101
|
+
const registry = createPeerStreamRegistry();
|
|
102
|
+
const viewer = new MeetViewer(options);
|
|
103
|
+
wireViewer(viewer, registry, SOLE_OWNER);
|
|
104
|
+
return {
|
|
105
|
+
join: () => viewer.join(),
|
|
106
|
+
leave: () => {
|
|
107
|
+
viewer.leave();
|
|
108
|
+
registry.clear();
|
|
109
|
+
},
|
|
110
|
+
resolvePeerStream: (peerLabel) => registry.resolve(labelKey(peerLabel)),
|
|
111
|
+
subscribePeerStream: (peerLabel, listener) => registry.subscribe(labelKey(peerLabel), listener),
|
|
112
|
+
registry,
|
|
113
|
+
viewer,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A single room's connection params (one mesh per entry). */
|
|
118
|
+
export type RoomOptions = Omit<MeetViewerOptions, "name"> & {
|
|
119
|
+
/** This viewer's announce name on the mesh. Defaults to "solar-viewer". */
|
|
120
|
+
name?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export interface MultiRoomPeerViewerOptions {
|
|
124
|
+
rooms: RoomOptions[];
|
|
125
|
+
/** Shared deps (WS/RTCPeerConnection/MediaStream) applied to every room when a
|
|
126
|
+
* room entry does not override them — used to test without a browser stack. */
|
|
127
|
+
deps?: MeetViewerOptions["deps"];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface MultiRoomPeerViewer extends PeerViewer {
|
|
131
|
+
/** Reconcile the live room set : open meshes for new rooms, close meshes for
|
|
132
|
+
* removed ones (matched by `roomId`). Best-effort ; idempotent for an
|
|
133
|
+
* unchanged set. Newly opened rooms are joined immediately. */
|
|
134
|
+
setRooms: (rooms: RoomOptions[]) => Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The FINAL viewer model : join N rooms, aggregate every peer into ONE shared
|
|
138
|
+
* registry keyed by `peer_label`. The `meet.peer` renderer resolves a label to
|
|
139
|
+
* its stream regardless of which room the peer published in.
|
|
140
|
+
*
|
|
141
|
+
* LABEL COLLISION (improbable at the POC) : FIRST-CONNECTED-WINS. The room that
|
|
142
|
+
* first connects a `peer_label` owns it ; a second room carrying the same label
|
|
143
|
+
* is ignored until the owner releases it (on the owner's `peer-left`). This is
|
|
144
|
+
* deterministic and never flips a live source under the compositor.
|
|
145
|
+
*
|
|
146
|
+
* LIFECYCLE : one mesh per room ; `leave()` closes all. `setRooms()` reconciles
|
|
147
|
+
* on re-arm (close removed rooms, open new ones), matched by `roomId`. */
|
|
148
|
+
export function createMultiRoomPeerViewer(
|
|
149
|
+
options: MultiRoomPeerViewerOptions,
|
|
150
|
+
): MultiRoomPeerViewer {
|
|
151
|
+
const registry = createPeerStreamRegistry();
|
|
152
|
+
// roomId → { viewer, joined }
|
|
153
|
+
const meshes = new Map<string, { viewer: MeetViewer }>();
|
|
154
|
+
// peer_label → owning viewer (first-connected-wins).
|
|
155
|
+
const owners = new Map<string, MeetViewer>();
|
|
156
|
+
|
|
157
|
+
const claim = {
|
|
158
|
+
acquire: (label: string, viewer: MeetViewer): boolean => {
|
|
159
|
+
const owner = owners.get(label);
|
|
160
|
+
if (owner === undefined) {
|
|
161
|
+
owners.set(label, viewer);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return owner === viewer; // only the owning room may publish/withdraw
|
|
165
|
+
},
|
|
166
|
+
release: (label: string, viewer: MeetViewer): void => {
|
|
167
|
+
if (owners.get(label) === viewer) owners.delete(label);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function openRoom(room: RoomOptions): void {
|
|
172
|
+
if (meshes.has(room.roomId)) return; // idempotent
|
|
173
|
+
const viewer = new MeetViewer({
|
|
174
|
+
name: room.name ?? "solar-viewer",
|
|
175
|
+
...room,
|
|
176
|
+
...(options.deps !== undefined && room.deps === undefined ? { deps: options.deps } : {}),
|
|
177
|
+
});
|
|
178
|
+
wireViewer(viewer, registry, claim);
|
|
179
|
+
meshes.set(room.roomId, { viewer });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function closeRoom(roomId: string): void {
|
|
183
|
+
const mesh = meshes.get(roomId);
|
|
184
|
+
if (mesh === undefined) return;
|
|
185
|
+
// Release every label this viewer owns BEFORE closing, so a surviving room
|
|
186
|
+
// can take over the label and the registry drops the gone stream.
|
|
187
|
+
for (const [label, owner] of [...owners.entries()]) {
|
|
188
|
+
if (owner === mesh.viewer) {
|
|
189
|
+
registry.remove(label);
|
|
190
|
+
owners.delete(label);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
mesh.viewer.leave();
|
|
194
|
+
meshes.delete(roomId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const room of options.rooms) openRoom(room);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
join: async () => {
|
|
201
|
+
await Promise.all([...meshes.values()].map((m) => m.viewer.join()));
|
|
202
|
+
},
|
|
203
|
+
leave: () => {
|
|
204
|
+
for (const roomId of [...meshes.keys()]) closeRoom(roomId);
|
|
205
|
+
registry.clear();
|
|
206
|
+
},
|
|
207
|
+
setRooms: async (rooms) => {
|
|
208
|
+
const next = new Set(rooms.map((r) => r.roomId));
|
|
209
|
+
// Close rooms no longer present.
|
|
210
|
+
for (const roomId of [...meshes.keys()]) {
|
|
211
|
+
if (!next.has(roomId)) closeRoom(roomId);
|
|
212
|
+
}
|
|
213
|
+
// Open + join rooms newly added.
|
|
214
|
+
const added: MeetViewer[] = [];
|
|
215
|
+
for (const room of rooms) {
|
|
216
|
+
if (!meshes.has(room.roomId)) {
|
|
217
|
+
openRoom(room);
|
|
218
|
+
const m = meshes.get(room.roomId);
|
|
219
|
+
if (m) added.push(m.viewer);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
await Promise.all(added.map((v) => v.join()));
|
|
223
|
+
},
|
|
224
|
+
resolvePeerStream: (peerLabel) => registry.resolve(labelKey(peerLabel)),
|
|
225
|
+
subscribePeerStream: (peerLabel, listener) => registry.subscribe(labelKey(peerLabel), listener),
|
|
226
|
+
registry,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The shape the Prism host injects on `window.__ZAB_PEER_VIEWER__`. The FINAL
|
|
231
|
+
* model is the multi-room `{ rooms: [...] }` ; the legacy single-room shape is
|
|
232
|
+
* still accepted for back-compat (treated as a one-room array). */
|
|
233
|
+
export type PeerViewerInjection =
|
|
234
|
+
| MultiRoomPeerViewerOptions
|
|
235
|
+
| (Omit<MeetViewerOptions, "name"> & { name?: string });
|
|
236
|
+
|
|
237
|
+
/** Normalise either injection shape into a multi-room viewer. forge-prism passes
|
|
238
|
+
* `window.__ZAB_PEER_VIEWER__` straight through ; a bare single-room object is
|
|
239
|
+
* wrapped as `{ rooms: [it] }`. */
|
|
240
|
+
export function createPeerViewerFromInjection(injection: PeerViewerInjection): MultiRoomPeerViewer {
|
|
241
|
+
if ("rooms" in injection && Array.isArray(injection.rooms)) {
|
|
242
|
+
return createMultiRoomPeerViewer(injection);
|
|
243
|
+
}
|
|
244
|
+
// Legacy single-room shape → one-room array.
|
|
245
|
+
const { name, deps, ...room } = injection as Omit<MeetViewerOptions, "name"> & {
|
|
246
|
+
name?: string;
|
|
247
|
+
};
|
|
248
|
+
return createMultiRoomPeerViewer({
|
|
249
|
+
rooms: [{ ...room, ...(name !== undefined ? { name } : {}) }],
|
|
250
|
+
...(deps !== undefined ? { deps } : {}),
|
|
251
|
+
});
|
|
252
|
+
}
|