@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.
- package/CHANGELOG.md +134 -0
- package/call.d.ts +374 -0
- package/index.d.ts +17 -1
- package/js/call/capture.js +167 -0
- package/js/call/lift.js +314 -0
- package/js/call/qr.js +283 -0
- package/js/call/sdp.js +86 -0
- package/js/call/signaling.js +398 -0
- package/js/call/transport.js +589 -0
- package/js/call/ui.js +146 -0
- package/js/call/wire.js +428 -0
- package/js/inline3d-buffer-limit.js +198 -0
- package/js/inline3d-call.js +1695 -0
- package/js/inline3d-model-playcanvas.js +6 -0
- package/js/inline3d-model.js +6 -0
- package/js/inline3d-player.js +3 -2
- package/js/inline3d-splat-layer-rig.js +79 -7
- package/js/inline3d-splat-playcanvas.js +94 -17
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +86 -10
- package/js/inline3d.js +127 -14
- package/model.d.ts +10 -1
- package/package.json +9 -1
- package/player.d.ts +3 -2
- package/splat.d.ts +26 -4
- package/viewer.d.ts +12 -1
|
@@ -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
|
+
}
|
package/js/call/lift.js
ADDED
|
@@ -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
|
+
}
|