@lumencast/runtime 0.12.0 → 0.12.2
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/{broadcast-ydSpPUje.js → broadcast-DrifeSRm.js} +3 -3
- package/dist/{broadcast-ydSpPUje.js.map → broadcast-DrifeSRm.js.map} +1 -1
- package/dist/{control-zTsF-bHP.js → control-CdGT0wrz.js} +4 -4
- package/dist/{control-zTsF-bHP.js.map → control-CdGT0wrz.js.map} +1 -1
- package/dist/{index-ClWi5UzJ.js → index-BH-3p9mt.js} +47 -38
- package/dist/index-BH-3p9mt.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/lumencast.js +1 -1
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js +14 -0
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/primitives/capture-stream-cache.d.ts +26 -0
- package/dist/render/primitives/capture-stream-cache.d.ts.map +1 -0
- package/dist/render/primitives/capture-stream-cache.js +141 -0
- package/dist/render/primitives/capture-stream-cache.js.map +1 -0
- package/dist/render/primitives/capture.d.ts +1 -1
- package/dist/render/primitives/capture.d.ts.map +1 -1
- package/dist/render/primitives/capture.js +20 -87
- package/dist/render/primitives/capture.js.map +1 -1
- package/dist/{status-pill-DkHIOL5V.js → status-pill-0rJyg4p3.js} +2 -2
- package/dist/{status-pill-DkHIOL5V.js.map → status-pill-0rJyg4p3.js.map} +1 -1
- package/dist/{test-COpMkyms.js → test-CYmNprVS.js} +4 -4
- package/dist/{test-COpMkyms.js.map → test-CYmNprVS.js.map} +1 -1
- package/dist/{tree-Cubmxeqo.js → tree-CyxbJbsP.js} +589 -567
- package/dist/tree-CyxbJbsP.js.map +1 -0
- package/package.json +4 -4
- package/src/render/bundle.ts +14 -0
- package/src/render/primitives/capture-stream-cache.ts +164 -0
- package/src/render/primitives/capture.tsx +19 -93
- package/dist/index-ClWi5UzJ.js.map +0 -1
- package/dist/tree-Cubmxeqo.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumencast/runtime",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "Browser runtime for Lumencast — mount(), LSDP/1 transport, leaf-grain store, LSML render, animations, overlays.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"framer-motion": "^12.0.0",
|
|
37
37
|
"react": "^19.0.0",
|
|
38
38
|
"react-dom": "^19.0.0",
|
|
39
|
-
"@lumencast/protocol": "0.12.
|
|
39
|
+
"@lumencast/protocol": "0.12.2"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@playwright/test": "^1.49.1",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"vite-plugin-dts": "^4.5.0",
|
|
51
51
|
"vitest": "^4.1.5",
|
|
52
52
|
"ws": "^8.18.0",
|
|
53
|
-
"@lumencast/
|
|
54
|
-
"@lumencast/server": "0.12.
|
|
53
|
+
"@lumencast/server": "0.12.2",
|
|
54
|
+
"@lumencast/dev-server": "0.12.2"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"dev": "vite",
|
package/src/render/bundle.ts
CHANGED
|
@@ -287,6 +287,7 @@ export interface BundleFetcherOptions {
|
|
|
287
287
|
|
|
288
288
|
class FetcherImpl implements BundleFetcher {
|
|
289
289
|
private readonly cache = new Map<string, RenderBundle>();
|
|
290
|
+
private readonly inFlight = new Map<string, Promise<RenderBundle>>();
|
|
290
291
|
private readonly baseUrl: string;
|
|
291
292
|
private readonly pathPrefix: string;
|
|
292
293
|
private readonly resolveUrl: BundleUrlResolver | undefined;
|
|
@@ -329,6 +330,19 @@ class FetcherImpl implements BundleFetcher {
|
|
|
329
330
|
async get(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
|
|
330
331
|
const cached = this.cache.get(sceneVersion);
|
|
331
332
|
if (cached) return cached;
|
|
333
|
+
// Bundles are content-addressed by scene_version, so a concurrent miss for
|
|
334
|
+
// the same version can share a single fetch. The entry is cleared in
|
|
335
|
+
// `finally` — a rejected fetch is never cached, so a later call retries.
|
|
336
|
+
const existing = this.inFlight.get(sceneVersion);
|
|
337
|
+
if (existing) return existing;
|
|
338
|
+
const promise = this.fetchBundle(sceneId, sceneVersion).finally(() => {
|
|
339
|
+
this.inFlight.delete(sceneVersion);
|
|
340
|
+
});
|
|
341
|
+
this.inFlight.set(sceneVersion, promise);
|
|
342
|
+
return promise;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async fetchBundle(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
|
|
332
346
|
const url = this.buildUrl(sceneId, sceneVersion);
|
|
333
347
|
const init = await this.buildInit();
|
|
334
348
|
const response = init ? await this.fetchImpl(url, init) : await this.fetchImpl(url);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Per-device stream cache for `x-zab.capture` ACQUIRE mode.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: a scene switch remounts the WHOLE render tree
|
|
4
|
+
// (`AnimatePresence` keyed on the scene id in app.tsx / crossfade.tsx), so every
|
|
5
|
+
// `Capture` node unmounts and a fresh one mounts. Without sharing, each switch
|
|
6
|
+
// stops the old node's tracks (RC11) and re-acquires the same physical device in
|
|
7
|
+
// the new node. A real USB webcam reopens near-instantly, but a synthetic
|
|
8
|
+
// DirectShow filter (OBS Virtual Camera) renegotiates slowly → a visible blink
|
|
9
|
+
// on every switch, because — by design (ADR 007 Prism) — every scene references
|
|
10
|
+
// the SAME shared vcam device.
|
|
11
|
+
//
|
|
12
|
+
// The fix mirrors the editor's `use-live-source.ts`: a ref-counted cache keyed by
|
|
13
|
+
// the resolved PHYSICAL device. Two `Capture` nodes on the same device share one
|
|
14
|
+
// `getUserMedia`; tracks stop (RC11) only when the LAST consumer releases. During
|
|
15
|
+
// a crossfade both the exiting and entering scenes are mounted at once
|
|
16
|
+
// (`AnimatePresence mode="sync"`), so the ref-count never reaches 0 across a
|
|
17
|
+
// switch and the device is never renegotiated — no blink.
|
|
18
|
+
//
|
|
19
|
+
// Cache key stability: the key is the resolved `deviceId` / `captureSourceId`, not
|
|
20
|
+
// the raw logical `deviceRef`. Physical ids are salted per origin/partition (see
|
|
21
|
+
// capture.tsx §A1.3), but the salt is constant WITHIN one runtime origin, so the
|
|
22
|
+
// resolved id is stable across scene switches inside a single mount/session — the
|
|
23
|
+
// exact scope over which sharing must hold. Keying on the physical id also
|
|
24
|
+
// correctly de-dupes two distinct `deviceRef`s that resolve to the same device.
|
|
25
|
+
|
|
26
|
+
import type { ResolveCaptureDevice } from "./capture";
|
|
27
|
+
|
|
28
|
+
interface CacheEntry {
|
|
29
|
+
promise: Promise<MediaStream>;
|
|
30
|
+
stream: MediaStream | null;
|
|
31
|
+
refs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const cache = new Map<string, CacheEntry>();
|
|
35
|
+
|
|
36
|
+
/** Outcome of a claim: either a shared stream promise (with the key to release
|
|
37
|
+
* later) or PLACEHOLDER — the unknown-kind / declared-but-unresolved-ref path
|
|
38
|
+
* that acquires nothing, exactly as the old inline `acquireStream` returning
|
|
39
|
+
* `null` did. A PLACEHOLDER claim holds NO ref (nothing to release). */
|
|
40
|
+
export type CaptureClaim =
|
|
41
|
+
| { kind: "stream"; key: string; promise: Promise<MediaStream> }
|
|
42
|
+
| { kind: "placeholder" };
|
|
43
|
+
|
|
44
|
+
/** Resolve the device, then claim a shared stream for it (incrementing the
|
|
45
|
+
* ref-count), or return PLACEHOLDER. Awaits the resolver first — physical ids
|
|
46
|
+
* are salted per origin/partition, so the host may re-resolve a portable key
|
|
47
|
+
* (label) against THIS context (capture.tsx §A1.3). A throw from the underlying
|
|
48
|
+
* `getUserMedia` surfaces via the returned promise (caller → PLACEHOLDER). */
|
|
49
|
+
export async function claimCaptureStream(
|
|
50
|
+
sourceKind: string,
|
|
51
|
+
deviceRef: string,
|
|
52
|
+
resolveCaptureDevice: ResolveCaptureDevice | undefined,
|
|
53
|
+
): Promise<CaptureClaim> {
|
|
54
|
+
const md = navigator.mediaDevices;
|
|
55
|
+
const resolved = (await resolveCaptureDevice?.(deviceRef, sourceKind)) ?? null;
|
|
56
|
+
const deviceId = resolved?.deviceId;
|
|
57
|
+
const captureSourceId = resolved?.captureSourceId;
|
|
58
|
+
const declaredRef = deviceRef.length > 0;
|
|
59
|
+
|
|
60
|
+
// Compute a stable physical key + a lazy acquisition thunk, or bail to
|
|
61
|
+
// PLACEHOLDER for the same reasons the inline switch used to return `null`.
|
|
62
|
+
let physicalId: string;
|
|
63
|
+
let acquire: () => Promise<MediaStream>;
|
|
64
|
+
switch (sourceKind) {
|
|
65
|
+
case "media.webcam":
|
|
66
|
+
case "media.mic":
|
|
67
|
+
case "media.app_audio": {
|
|
68
|
+
// §A1.3 (amended) — NO default-device fallback for a DECLARED deviceRef
|
|
69
|
+
// that did not resolve. Acquiring the host default here is the silent
|
|
70
|
+
// "automatic allocation" of the WRONG camera. → PLACEHOLDER. The bare
|
|
71
|
+
// default constraint stays ONLY when no deviceRef is declared at all.
|
|
72
|
+
if (declaredRef && (typeof deviceId !== "string" || deviceId.length === 0)) {
|
|
73
|
+
return { kind: "placeholder" };
|
|
74
|
+
}
|
|
75
|
+
const channel = sourceKind === "media.webcam" ? "video" : "audio";
|
|
76
|
+
physicalId = typeof deviceId === "string" && deviceId.length > 0 ? deviceId : "default";
|
|
77
|
+
acquire = () => md.getUserMedia({ [channel]: deviceConstraint(deviceId) });
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "media.screen":
|
|
81
|
+
case "media.window": {
|
|
82
|
+
// DIRECT capture of the picked desktopCapturer surface (no system picker)
|
|
83
|
+
// via Electron's legacy `chromeMediaSource:desktop` + resolved id.
|
|
84
|
+
if (typeof captureSourceId === "string" && captureSourceId.length > 0) {
|
|
85
|
+
physicalId = captureSourceId;
|
|
86
|
+
acquire = () =>
|
|
87
|
+
md.getUserMedia({
|
|
88
|
+
video: {
|
|
89
|
+
mandatory: {
|
|
90
|
+
chromeMediaSource: "desktop",
|
|
91
|
+
chromeMediaSourceId: captureSourceId,
|
|
92
|
+
},
|
|
93
|
+
} as unknown as MediaTrackConstraints,
|
|
94
|
+
});
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
// A declared surface ref that didn't resolve → PLACEHOLDER, never a
|
|
98
|
+
// default picker. The picker stays only when no ref is declared.
|
|
99
|
+
if (declaredRef) return { kind: "placeholder" };
|
|
100
|
+
physicalId = "display";
|
|
101
|
+
acquire = () => md.getDisplayMedia({ video: true });
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
return { kind: "placeholder" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const key = `${sourceKind}:${physicalId}`;
|
|
109
|
+
const existing = cache.get(key);
|
|
110
|
+
if (existing) {
|
|
111
|
+
existing.refs += 1;
|
|
112
|
+
return { kind: "stream", key, promise: existing.promise };
|
|
113
|
+
}
|
|
114
|
+
const promise = acquire();
|
|
115
|
+
const entry: CacheEntry = { promise, stream: null, refs: 1 };
|
|
116
|
+
cache.set(key, entry);
|
|
117
|
+
promise
|
|
118
|
+
.then((s) => {
|
|
119
|
+
entry.stream = s;
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {
|
|
122
|
+
// Acquisition failed — evict so a later mount retries instead of sharing a
|
|
123
|
+
// rejected promise. Consumers of this promise all fall back to PLACEHOLDER.
|
|
124
|
+
cache.delete(key);
|
|
125
|
+
});
|
|
126
|
+
return { kind: "stream", key, promise };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Drop one consumer's claim. Stops every track (RC11 — kill the device light)
|
|
130
|
+
* ONLY when the last consumer releases. A no-op for an already-evicted key
|
|
131
|
+
* (e.g. an acquisition that rejected and self-evicted). */
|
|
132
|
+
export function releaseCaptureStream(key: string): void {
|
|
133
|
+
const entry = cache.get(key);
|
|
134
|
+
if (entry === undefined) return;
|
|
135
|
+
entry.refs -= 1;
|
|
136
|
+
if (entry.refs > 0) return;
|
|
137
|
+
cache.delete(key);
|
|
138
|
+
if (entry.stream !== null) stopStream(entry.stream);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A `getUserMedia` track constraint. A resolved deviceId is pinned with
|
|
142
|
+
* `exact`, NOT a bare (ideal) deviceId: an *ideal* constraint SILENTLY falls
|
|
143
|
+
* back to the host default camera when the requested device can't start (e.g.
|
|
144
|
+
* an INACTIVE virtual cam enumerated but producing no stream). `exact` yields
|
|
145
|
+
* the requested device (its placeholder frame if idle), or an
|
|
146
|
+
* OverconstrainedError the caller catches into PLACEHOLDER — never the wrong
|
|
147
|
+
* cam. No deviceId → `true` (host default) applies ONLY when no deviceRef was
|
|
148
|
+
* declared. */
|
|
149
|
+
function deviceConstraint(deviceId: string | undefined): MediaTrackConstraints | boolean {
|
|
150
|
+
return typeof deviceId === "string" && deviceId.length > 0
|
|
151
|
+
? { deviceId: { exact: deviceId } }
|
|
152
|
+
: true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Stop every track of a stream (RC11 — release the camera/mic, kill the light). */
|
|
156
|
+
function stopStream(stream: MediaStream): void {
|
|
157
|
+
for (const track of stream.getTracks()) track.stop();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Test-only: drop all cached entries WITHOUT stopping tracks. Lets a test file
|
|
161
|
+
* start from a clean ref-count without leaking state between cases. */
|
|
162
|
+
export function __resetCaptureStreamCache(): void {
|
|
163
|
+
cache.clear();
|
|
164
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import type { PrimitiveProps } from "./index";
|
|
3
3
|
import { useOptionalLumencastRuntime } from "../../overlay/runtime-context";
|
|
4
|
+
import { claimCaptureStream, releaseCaptureStream } from "./capture-stream-cache";
|
|
4
5
|
|
|
5
6
|
/** `x-zab.capture` — context-aware capture primitive (Zab vendor primitive,
|
|
6
7
|
* RFC-0001 / ADR 004 §Amendment 1).
|
|
@@ -62,32 +63,38 @@ export function Capture({ resolved }: PrimitiveProps) {
|
|
|
62
63
|
if (!isCaptureCapable()) return;
|
|
63
64
|
|
|
64
65
|
let cancelled = false;
|
|
65
|
-
|
|
66
|
+
// The cache key of the claim we hold, so cleanup releases exactly one ref.
|
|
67
|
+
let claimedKey: string | null = null;
|
|
66
68
|
|
|
67
69
|
void (async () => {
|
|
68
70
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
// Claim a per-device shared stream (ref-counted): a scene switch that
|
|
72
|
+
// remounts a Capture on the SAME device reuses the live stream instead
|
|
73
|
+
// of stopping and re-acquiring it — the vcam-blink fix.
|
|
74
|
+
const claim = await claimCaptureStream(sourceKind, deviceRef, resolveCaptureDevice);
|
|
75
|
+
if (claim.kind === "placeholder") return; // unknown/unresolved → PLACEHOLDER
|
|
76
|
+
const media = await claim.promise;
|
|
71
77
|
if (cancelled) {
|
|
72
|
-
// Unmounted (or scene-changed) during acquisition —
|
|
73
|
-
//
|
|
74
|
-
|
|
78
|
+
// Unmounted (or scene-changed) during acquisition — drop our claim.
|
|
79
|
+
// The shared stream stays alive if another consumer still holds it,
|
|
80
|
+
// and stops (RC11) only when the last one releases.
|
|
81
|
+
releaseCaptureStream(claim.key);
|
|
75
82
|
return;
|
|
76
83
|
}
|
|
77
|
-
|
|
84
|
+
claimedKey = claim.key;
|
|
78
85
|
setStream(media);
|
|
79
86
|
} catch {
|
|
80
87
|
// §A1.2(2)(a) — any acquisition failure (permission denied, no device,
|
|
81
88
|
// getUserMedia rejected) falls back to PLACEHOLDER, no throw, no
|
|
82
|
-
// diagnostic.
|
|
89
|
+
// diagnostic. The rejected cache entry self-evicts; our ref went with it.
|
|
83
90
|
}
|
|
84
91
|
})();
|
|
85
92
|
|
|
86
93
|
return () => {
|
|
87
94
|
cancelled = true;
|
|
88
|
-
// RC11 —
|
|
89
|
-
//
|
|
90
|
-
if (
|
|
95
|
+
// RC11 — release our claim at unmount / scene change. Tracks stop only
|
|
96
|
+
// when this was the last consumer of the shared stream.
|
|
97
|
+
if (claimedKey !== null) releaseCaptureStream(claimedKey);
|
|
91
98
|
};
|
|
92
99
|
// Re-acquire when the logical source identity changes (a scene switch can
|
|
93
100
|
// reuse the node with a new sourceKind/deviceRef).
|
|
@@ -142,7 +149,7 @@ export type ResolvedCaptureDevice = {
|
|
|
142
149
|
* hash. MAY be async: physical ids (e.g. getUserMedia `deviceId`) are salted
|
|
143
150
|
* per origin/partition, so the host often must re-resolve a portable key
|
|
144
151
|
* (label) against THIS context's devices — an inherently asynchronous step
|
|
145
|
-
* (`enumerateDevices`). `
|
|
152
|
+
* (`enumerateDevices`). `claimCaptureStream` awaits it, so the device is bound
|
|
146
153
|
* before acquisition rather than racing a late global mutation. */
|
|
147
154
|
export type ResolveCaptureDevice = (
|
|
148
155
|
deviceRef: string,
|
|
@@ -165,87 +172,6 @@ function isVisualKind(sourceKind: string): boolean {
|
|
|
165
172
|
);
|
|
166
173
|
}
|
|
167
174
|
|
|
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
175
|
/** A render dimension: a finite number → px, a non-empty string → verbatim,
|
|
250
176
|
* anything else → the fallback (matches the `image` primitive's helper). */
|
|
251
177
|
function dimOr(v: unknown, fallback: string): string {
|