@displayxr/inline3d 1.26.0 → 1.28.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.
@@ -0,0 +1,167 @@
1
+ // call/capture.js — getting a camera (+ microphone) and knowing what it is (RFC §4).
2
+ //
3
+ // `camera: 'auto'` looks for a device that delivers a side-by-side PAIR — one device whose frame
4
+ // is wider than 2.5:1 (a USB stereo camera enumerates as one wide device: field data has one at
5
+ // 1280x480, i.e. 640x480 per eye, grayscale and UNRECTIFIED). Found → sent as `sbs`. Otherwise the
6
+ // default webcam, sent as `mono`. Per-eye width is whatever the camera gives; nothing is upscaled.
7
+ //
8
+ // Two field facts shape the probing:
9
+ // - On some laptops the stereo camera is held EXCLUSIVELY by the eye tracker, so opening it
10
+ // fails with NotReadableError ("Device in use"). That device is skipped silently — a call must
11
+ // never fail because an optional camera is busy.
12
+ // - Labels are empty until one getUserMedia has succeeded, so the default camera is opened first
13
+ // (which is also the fallback), then the others are probed one at a time.
14
+ //
15
+ // RECTIFICATION is not done here (P2): it needs the camera's calibration (intrinsics/extrinsics
16
+ // for the ACTIVE device), which comes from a plug-in or the runtime. P1 sends the raw pair with
17
+ // `rectified: false`, and the `rectify` hook (addCall option) is where a calibrated step plugs in:
18
+ // `(stream, info) => MediaStream | Promise<MediaStream>`, returning a rectified SBS stream.
19
+
20
+ import { looksSbs } from './wire.js';
21
+
22
+ const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } };
23
+ // Ask a candidate stereo device for its widest mode; a 2560x720 or 3840x1080 pair comes back as
24
+ // itself, a 1280x480 one as 1280x480. `ideal`, never `min`: a smaller real pair is still a pair.
25
+ const PROBE_CONSTRAINTS = { width: { ideal: 3840 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } };
26
+ export const AUDIO_CONSTRAINTS = Object.freeze({ echoCancellation: true, noiseSuppression: true, autoGainControl: true });
27
+
28
+ /** Errors that mean "the device exists but another process holds it" (an eye tracker, say). */
29
+ export function isBusyError(err) {
30
+ return !!err && (err.name === 'NotReadableError' || err.name === 'TrackStartError' || /in use|busy/i.test(err.message || ''));
31
+ }
32
+
33
+ /**
34
+ * Why no camera could be opened, from the per-device failures. `'camera-busy'` when every device
35
+ * that exists is held by another process — on a 3D laptop/monitor whose only camera is the eye
36
+ * tracker's, that is the normal case while tracking, and the call should go audio-only and say so
37
+ * rather than fail or send an empty picture. Pure.
38
+ * @param {Array<{error: string, busy?: boolean}>} skipped
39
+ */
40
+ export function noCameraCode(skipped) {
41
+ return skipped.length && skipped.every((s) => s.busy) ? 'camera-busy' : 'no-camera';
42
+ }
43
+
44
+ /** A label that names a stereo camera, tried first when probing. Pure. */
45
+ export function stereoLabelHint(label) {
46
+ return /stereo|\b3d\b|\bsbs\b|dual/i.test(label || '');
47
+ }
48
+
49
+ /**
50
+ * Open the camera. Never rejects for a busy optional device; rejects only when NO camera can be
51
+ * opened at all (the caller then runs audio-only or reports the error).
52
+ *
53
+ * @param {'auto'|'stereo'|'mono'|string|MediaStream} want 'auto' | 'stereo' | 'mono' | a
54
+ * deviceId | a page-supplied MediaStream
55
+ * @param {{ format?: 'sbs'|'mono', calibration?: {baselineMm?: number, hfovDeg?: number, rectified?: boolean},
56
+ * mediaDevices?: MediaDevices, log?: Function }} [o]
57
+ * @returns {Promise<{ stream: MediaStream, format: 'sbs'|'mono', width: number, height: number,
58
+ * deviceId: string|null, label: string, owned: boolean, skipped: Array<{label: string, error: string}>,
59
+ * calibration: object }>}
60
+ */
61
+ export async function openCamera(want = 'auto', o = {}) {
62
+ const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
63
+ const log = o.log || (() => {});
64
+ const skipped = [];
65
+ const cal = o.calibration || {};
66
+
67
+ if (want && typeof want === 'object' && typeof want.getVideoTracks === 'function') {
68
+ const t = want.getVideoTracks()[0];
69
+ const st = t ? t.getSettings() : {};
70
+ // A page-supplied stream carries a DECLARED format; aspect is never used to guess 3D-ness here.
71
+ const format = o.format === 'sbs' ? 'sbs' : 'mono';
72
+ return { stream: want, format, width: st.width || 0, height: st.height || 0, deviceId: st.deviceId || null, label: t ? t.label : '', owned: false, skipped, calibration: cal };
73
+ }
74
+ if (!md || !md.getUserMedia) throw Object.assign(new Error('getUserMedia is not available (insecure context?)'), { code: 'no-camera' });
75
+
76
+ const open = async (constraints) => {
77
+ const s = await md.getUserMedia({ video: constraints, audio: false });
78
+ const t = s.getVideoTracks()[0];
79
+ const st = t ? t.getSettings() : {};
80
+ // Never hand back a 0x0 track: some engines open a device that then delivers nothing.
81
+ if (!t || t.readyState === 'ended' || !(st.width > 0) || !(st.height > 0)) {
82
+ s.getTracks().forEach((x) => x.stop());
83
+ throw Object.assign(new Error('the camera opened but delivers no picture (0x0)'), { name: 'NotReadableError' });
84
+ }
85
+ return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '' };
86
+ };
87
+ const skip = (label, err) => {
88
+ skipped.push({ label, error: `${err.name}: ${err.message}`, busy: isBusyError(err) });
89
+ log('camera-skip', { label, error: err.name });
90
+ };
91
+ const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
92
+ const result = (r, format) => ({
93
+ stream: r.stream, format, width: r.width, height: r.height, deviceId: r.deviceId, label: r.label, owned: true, skipped,
94
+ calibration: { rectified: false, ...cal },
95
+ });
96
+
97
+ // An explicit device id.
98
+ if (typeof want === 'string' && !['auto', 'stereo', 'mono'].includes(want)) {
99
+ let r;
100
+ try {
101
+ r = await open({ deviceId: { exact: want }, ...PROBE_CONSTRAINTS });
102
+ } catch (err) {
103
+ skip(want, err);
104
+ throw Object.assign(new Error(`camera unavailable: ${err.message}`), { code: noCameraCode(skipped), skipped });
105
+ }
106
+ if (looksSbs(r.width, r.height)) return result(r, 'sbs');
107
+ stop(r);
108
+ return result(await open({ deviceId: { exact: want }, ...MONO_CONSTRAINTS }), 'mono');
109
+ }
110
+
111
+ // The default camera first: permission, labels, and the fallback.
112
+ let mono = null;
113
+ try {
114
+ mono = await open(MONO_CONSTRAINTS);
115
+ } catch (err) {
116
+ skip('default', err);
117
+ }
118
+ if (want === 'mono' && mono) return result(mono, 'mono');
119
+ if (mono && looksSbs(mono.width, mono.height)) return result(mono, 'sbs'); // the default IS the pair
120
+
121
+ let devices = [];
122
+ try {
123
+ devices = (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput');
124
+ } catch {
125
+ /* no enumeration: keep the default */
126
+ }
127
+ devices.sort((a, b) => Number(stereoLabelHint(b.label)) - Number(stereoLabelHint(a.label)));
128
+ for (const d of devices) {
129
+ if (!d.deviceId) continue; // no permission yet: an anonymous entry cannot be opened by id
130
+ if (mono && d.deviceId === mono.deviceId) continue;
131
+ let r = null;
132
+ try {
133
+ r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
134
+ } catch (err) {
135
+ // NotReadableError = held by another process (e.g. an eye tracker). Skip, never fail.
136
+ skip(d.label, err);
137
+ continue;
138
+ }
139
+ if (looksSbs(r.width, r.height)) {
140
+ stop(mono);
141
+ log('camera-sbs', { label: r.label, width: r.width, height: r.height });
142
+ return result(r, 'sbs');
143
+ }
144
+ stop(r);
145
+ }
146
+ if (mono) {
147
+ if (want === 'stereo') log('camera-no-stereo', { fallback: mono.label });
148
+ return result(mono, 'mono');
149
+ }
150
+ const code = noCameraCode(skipped);
151
+ throw Object.assign(
152
+ new Error(code === 'camera-busy' ? 'every camera is in use by another app (e.g. eye tracking)' : 'no camera could be opened'),
153
+ { code, skipped }
154
+ );
155
+ }
156
+
157
+ /** The microphone, with echo cancellation and noise suppression. Null if unavailable/denied. */
158
+ export async function openMic(o = {}) {
159
+ const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
160
+ try {
161
+ const s = await md.getUserMedia({ audio: { ...AUDIO_CONSTRAINTS }, video: false });
162
+ return s.getAudioTracks()[0] || null;
163
+ } catch (err) {
164
+ (o.log || (() => {}))('mic-skip', { error: err && err.name });
165
+ return null;
166
+ }
167
+ }
@@ -0,0 +1,314 @@
1
+ // call/lift.js — mono→3D for mono peers, through the SDK's lift() provider chain (RFC 0002 §5).
2
+ //
3
+ // This module never contains a depth model or vendor code. It decides WHETHER a mono peer is
4
+ // lifted, hands its <video> to `lift()` (`@displayxr/inline3d/lift`), and steers the lifted
5
+ // streams: priority from the active speaker and tile visibility, convergence from the call's one
6
+ // depth control, a cap on concurrent lifts, and a frame-time watch for the web provider.
7
+ //
8
+ // `lift` is NEVER a hard dependency. `mono3D: 'auto'` imports it lazily at runtime; a missing
9
+ // module (not installed, not built into this copy of the SDK), a failed import, or a device that
10
+ // can do neither native nor WebGPU lifting resolves to FLAT with one log line. A page can inject
11
+ // its own `lift` function (`mono3D: lift`) — that is how tests, bundled apps and pre-merge demos
12
+ // work.
13
+ //
14
+ // Pure except for `resolveLift`'s default importer — see test/call.test.mjs.
15
+
16
+ /** How a mono peer's lifted stream is scheduled. Mirrors lift()'s `LiftPriority`. */
17
+ export const LIFT_PRIORITY = Object.freeze({ speaker: 'high', other: 'normal', hidden: 'paused' });
18
+ const PRIORITIES = ['high', 'normal', 'low', 'paused'];
19
+
20
+ /** Convergence dead band: a depth slider this close to 0 hands lift 'auto'. */
21
+ const DEPTH_AUTO_BAND = 0.02;
22
+
23
+ /** Web-provider frame-time watch: warn when frames average slower than this for this long. */
24
+ const DEGRADED_FRAME_MS = 1000 / 20;
25
+ const DEGRADED_HOLD_MS = 3000;
26
+ const RECOVERED_FRAME_MS = 1000 / 28;
27
+
28
+ /**
29
+ * `mono3D` option → `'auto' | 'off' | Function`. `false`/`'off'` → 'off'; a function is an
30
+ * injected `lift`; anything else → 'auto'.
31
+ */
32
+ export function normalizeMono3D(v) {
33
+ if (typeof v === 'function') return v;
34
+ if (v === false || v === 'off') return 'off';
35
+ return 'auto';
36
+ }
37
+
38
+ /**
39
+ * Where `mono3D: 'auto'` looks for the lift module: the `./lift` subpath of THIS copy of the SDK,
40
+ * resolved relative to this file (js/call/ → js/lift/index.js), exactly as the package maps
41
+ * `@displayxr/inline3d/lift`. The specifier is computed, so a bundler neither fails on a copy of
42
+ * the SDK without lift nor pulls lift into every call bundle; a bundled app that wants lifting
43
+ * passes `mono3D: lift` (imported itself) instead.
44
+ */
45
+ export function defaultLiftSpecifier(base = import.meta.url) {
46
+ return new URL('../lift/index.js', base).href;
47
+ }
48
+
49
+ function defaultImporter() {
50
+ return import(/* @vite-ignore */ /* webpackIgnore: true */ defaultLiftSpecifier());
51
+ }
52
+
53
+ /**
54
+ * Resolve the `mono3D` option to a usable lift function, or to null (flat) with a reason.
55
+ *
56
+ * 'off' → { lift: null, reason: 'off' }
57
+ * function → { lift: fn, capabilities: fn.liftCapabilities || null, source: 'injected' }
58
+ * 'auto' → import the lift module; then, when it has `liftCapabilities()`, require a native
59
+ * provider OR a WebGPU adapter for the web fallback (without it, the module has no
60
+ * live video path). Without `liftCapabilities`, a missing `navigator.gpu` is the
61
+ * same verdict. Any failure → { lift: null, reason }, never a throw.
62
+ *
63
+ * @param {'auto'|'off'|Function} mono3D
64
+ * @param {{ importer?: () => Promise<any>, nav?: any, log?: (tag: string, o: object) => void, capsOpts?: object }} [o]
65
+ * @returns {Promise<{ lift: Function|null, capabilities: Function|null, caps: object|null, source: string, reason: string|null }>}
66
+ */
67
+ export async function resolveLift(mono3D, { importer = defaultImporter, nav = globalThis.navigator, log = null, capsOpts = { webFallback: true } } = {}) {
68
+ const say = (tag, x) => log && log(tag, x);
69
+ const flat = (reason, extra = {}) => {
70
+ say('lift-unavailable', { reason, ...extra });
71
+ return { lift: null, capabilities: null, caps: null, source: 'none', reason };
72
+ };
73
+ const m = normalizeMono3D(mono3D);
74
+ if (m === 'off') return { lift: null, capabilities: null, caps: null, source: 'none', reason: 'off' };
75
+ if (typeof m === 'function') {
76
+ const capabilities = typeof m.liftCapabilities === 'function' ? m.liftCapabilities : null;
77
+ return { lift: m, capabilities, caps: null, source: 'injected', reason: null };
78
+ }
79
+ let mod;
80
+ try {
81
+ mod = await importer();
82
+ } catch (err) {
83
+ return flat('import-failed', { message: String((err && err.message) || err).slice(0, 200) });
84
+ }
85
+ const lift = mod && typeof mod.lift === 'function' ? mod.lift : null;
86
+ if (!lift) return flat('no-lift-export');
87
+ const capabilities = typeof mod.liftCapabilities === 'function' ? mod.liftCapabilities : null;
88
+ let caps = null;
89
+ if (capabilities) {
90
+ try {
91
+ caps = await capabilities(capsOpts);
92
+ } catch {
93
+ caps = null;
94
+ }
95
+ const web = !!(caps && caps.webFallback && caps.webFallback.webgpu);
96
+ if (caps && !caps.native && !web) return flat('no-provider', { native: false, webgpu: false });
97
+ } else if (!(nav && nav.gpu)) return flat('no-webgpu');
98
+ say('lift-ready', { native: !!(caps && caps.native), provider: (caps && caps.provider) || null });
99
+ return { lift, capabilities, caps, source: 'module', reason: null };
100
+ }
101
+
102
+ /**
103
+ * The call's ONE depth control ([-1, 1], + = push the picture back) as lift's convergence.
104
+ * lift's `convergence` is the normalised disparity (0 = far, 1 = near) placed on the glass, so a
105
+ * larger value puts more of the picture behind it. 0 (± a small band) = 'auto' (lift centres its
106
+ * own subject); otherwise 0.5 + 0.5·depth, clamped to [0, 1].
107
+ */
108
+ export function liftConvergenceFor(depth) {
109
+ const d = Number.isFinite(+depth) && depth !== null ? Math.max(-1, Math.min(1, +depth)) : 0;
110
+ if (Math.abs(d) < DEPTH_AUTO_BAND) return 'auto';
111
+ return Math.round((0.5 + 0.5 * d) * 1000) / 1000;
112
+ }
113
+
114
+ /** A lifted tile's priority: hidden/offscreen → paused, the active speaker → high, else normal. */
115
+ export function liftPriorityFor({ id, speakerId, visible = true }) {
116
+ if (!visible) return LIFT_PRIORITY.hidden;
117
+ return id !== null && id !== undefined && id === speakerId ? LIFT_PRIORITY.speaker : LIFT_PRIORITY.other;
118
+ }
119
+
120
+ /**
121
+ * THE HOOK FOR THE NATIVE PROVIDER'S PER-STREAM PRIORITY (`xrSetLiftStreamPriorityDXR`: HIGH =
122
+ * every round, NORMAL = round-robin, LOW = every Nth, PAUSED = keep the last frame).
123
+ *
124
+ * Today it forwards to the lift handle's `setPriority()` when it has one: in native mode that is
125
+ * the element's `dxr-lift-priority` attribute, which the browser maps onto the runtime's stream
126
+ * priority; on the web path lift runs one stream per element and it is a no-op. A handle without
127
+ * `setPriority` (an injected lift, an older lift build) is a no-op here — the call still works,
128
+ * every stream is just scheduled equally. Nothing else in the call module talks priority, so this
129
+ * one function is what changes if the native provider grows a different surface.
130
+ *
131
+ * @returns {boolean} true when the handle accepted it
132
+ */
133
+ export function setLiftPriority(handle, level) {
134
+ if (!handle || !PRIORITIES.includes(level)) return false;
135
+ if (typeof handle.setPriority !== 'function') return false;
136
+ try {
137
+ return handle.setPriority(level) !== false;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Frame-time watch for lifted tiles on the WEB provider (the native one runs in the display
145
+ * service, off the page's frame budget). Feed it the page's frame intervals; it reports
146
+ * `'degraded'` once when the smoothed interval stays above ~20 fps for 3 s, and `'recovered'`
147
+ * once it is back above ~28 fps. Pure.
148
+ */
149
+ export function createFrameWatch({ degradedMs = DEGRADED_FRAME_MS, recoveredMs = RECOVERED_FRAME_MS, holdMs = DEGRADED_HOLD_MS, alpha = 0.1 } = {}) {
150
+ let ema = null;
151
+ let slowFor = 0;
152
+ let degraded = false;
153
+ return {
154
+ /** @param {number} dtMs one frame interval @returns {'degraded'|'recovered'|null} */
155
+ feed(dtMs) {
156
+ if (!(dtMs > 0) || dtMs > 1000) return null; // a tab switch, not a frame
157
+ ema = ema === null ? dtMs : ema + alpha * (dtMs - ema);
158
+ if (!degraded) {
159
+ slowFor = ema > degradedMs ? slowFor + dtMs : 0;
160
+ if (slowFor >= holdMs) {
161
+ degraded = true;
162
+ return 'degraded';
163
+ }
164
+ } else if (ema < recoveredMs) {
165
+ degraded = false;
166
+ slowFor = 0;
167
+ return 'recovered';
168
+ }
169
+ return null;
170
+ },
171
+ reset() {
172
+ ema = null;
173
+ slowFor = 0;
174
+ degraded = false;
175
+ },
176
+ get frameMs() {
177
+ return ema;
178
+ },
179
+ get degraded() {
180
+ return degraded;
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * The call's lifted streams: at most `max` at once, each one lift() handle per peer, steered as a
187
+ * group (speaker → priority, depth → convergence). Pure apart from the `lift` it is given.
188
+ *
189
+ * @param {{ lift: Function, max?: number, log?: (tag: string, o: object) => void, options?: object }} o
190
+ */
191
+ export function createLiftPool({ lift, max = 4, log = null, options = null } = {}) {
192
+ // Page-supplied lift options (models, ort, quality, providers, …). The call owns the keys that
193
+ // make it a call tile — mode, wall, ui, convergence, priority — so those always win.
194
+ const extra = options && typeof options === 'object' ? options : {};
195
+ const entries = new Map(); // id → { gen, handle|null, visible, priority, pending }
196
+ let speakerId = null;
197
+ let depth = 0;
198
+ let gen = 0;
199
+ const say = (tag, x) => log && log(tag, x);
200
+
201
+ const apply = (id, e) => {
202
+ if (!e.handle) return;
203
+ const p = liftPriorityFor({ id, speakerId, visible: e.visible });
204
+ if (p === e.priority) return;
205
+ e.priority = p;
206
+ const ok = setLiftPriority(e.handle, p);
207
+ say('lift-priority', { peer: id, priority: p, applied: ok });
208
+ };
209
+
210
+ const pool = {
211
+ get size() {
212
+ return entries.size;
213
+ },
214
+ get max() {
215
+ return max;
216
+ },
217
+ set max(n) {
218
+ max = Math.max(0, n | 0);
219
+ },
220
+ has: (id) => entries.has(id),
221
+ handle: (id) => (entries.get(id) && entries.get(id).handle) || null,
222
+ priority: (id) => (entries.get(id) && entries.get(id).priority) || null,
223
+ /** Is there a slot for `id` (it already holds one, or fewer than `max` are taken)? */
224
+ canAcquire: (id) => entries.has(id) || entries.size < max,
225
+ /**
226
+ * Lift `element` for peer `id`. Resolves with the handle, or null when there is no slot, when
227
+ * `release(id)` ran meanwhile (the late handle is removed), or when lift() rejects (`onError`
228
+ * gets it). One pending/live stream per id: a second acquire returns the first.
229
+ */
230
+ acquire(id, element, { wall, visible = true, onError } = {}) {
231
+ const cur = entries.get(id);
232
+ if (cur) return cur.pending || Promise.resolve(cur.handle);
233
+ if (entries.size >= max) return Promise.resolve(null);
234
+ const my = ++gen;
235
+ const priority = liftPriorityFor({ id, speakerId, visible });
236
+ const e = { gen: my, handle: null, visible, priority, pending: null };
237
+ entries.set(id, e);
238
+ const opts = { quality: 'auto', ...extra, mode: 'live', wall, ui: 'none', convergence: liftConvergenceFor(depth), priority };
239
+ say('lift-acquire', { peer: id, priority, convergence: opts.convergence });
240
+ e.pending = Promise.resolve()
241
+ .then(() => lift(element, opts))
242
+ .then(
243
+ (h) => {
244
+ if (entries.get(id) !== e || e.gen !== my) {
245
+ try {
246
+ h && h.remove && h.remove();
247
+ } catch {
248
+ /* ignore */
249
+ }
250
+ return null;
251
+ }
252
+ e.pending = null;
253
+ e.handle = h || null;
254
+ // Converge on the CURRENT state (speaker/depth/visibility may have moved meanwhile).
255
+ e.priority = null;
256
+ apply(id, e);
257
+ if (h && typeof h.setConvergence === 'function') h.setConvergence(liftConvergenceFor(depth));
258
+ return e.handle;
259
+ },
260
+ (err) => {
261
+ if (entries.get(id) === e) entries.delete(id);
262
+ say('lift-failed', { peer: id, message: String((err && err.message) || err).slice(0, 200) });
263
+ if (onError) onError(err);
264
+ return null;
265
+ }
266
+ );
267
+ return e.pending;
268
+ },
269
+ /** Remove peer `id`'s lift (a pending one is removed when it lands). */
270
+ release(id) {
271
+ const e = entries.get(id);
272
+ if (!e) return;
273
+ entries.delete(id);
274
+ if (e.handle) {
275
+ try {
276
+ e.handle.remove();
277
+ } catch {
278
+ /* the session may be gone */
279
+ }
280
+ }
281
+ say('lift-release', { peer: id });
282
+ },
283
+ releaseAll() {
284
+ for (const id of [...entries.keys()]) pool.release(id);
285
+ },
286
+ setSpeaker(id) {
287
+ speakerId = id === undefined ? null : id;
288
+ for (const [pid, e] of entries) apply(pid, e);
289
+ },
290
+ setVisible(id, visible) {
291
+ const e = entries.get(id);
292
+ if (!e || e.visible === !!visible) return;
293
+ e.visible = !!visible;
294
+ apply(id, e);
295
+ },
296
+ /** The call's depth control → every lifted stream's convergence. */
297
+ setDepth(v) {
298
+ depth = v;
299
+ const c = liftConvergenceFor(v);
300
+ for (const e of entries.values()) if (e.handle && typeof e.handle.setConvergence === 'function') e.handle.setConvergence(c);
301
+ return c;
302
+ },
303
+ /** True when any live lifted stream runs on the page (the web provider), not in the service. */
304
+ get anyWeb() {
305
+ for (const e of entries.values()) if (e.handle && !e.handle.native) return true;
306
+ return false;
307
+ },
308
+ /** Snapshot for info()/tests. */
309
+ list() {
310
+ return [...entries].map(([id, e]) => ({ id, priority: e.priority, pending: !!e.pending, native: !!(e.handle && e.handle.native) }));
311
+ },
312
+ };
313
+ return pool;
314
+ }