@displayxr/inline3d 1.27.0 → 1.29.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 +224 -0
- package/README.md +6 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +454 -0
- package/dist/call.js +81 -0
- package/index.d.ts +34 -2
- package/js/call/capture.js +212 -0
- package/js/call/disparity.js +432 -0
- package/js/call/element.js +217 -0
- package/js/call/lift.js +323 -0
- package/js/call/qr.js +283 -0
- package/js/call/sdp.js +86 -0
- package/js/call/signaling.js +203 -0
- package/js/call/transport.js +589 -0
- package/js/call/ui.js +146 -0
- package/js/call/wire.js +434 -0
- package/js/inline3d-buffer-limit.js +198 -0
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +1901 -0
- package/js/inline3d-model-playcanvas.js +6 -0
- package/js/inline3d-model.js +6 -0
- package/js/inline3d-player.js +649 -218
- package/js/inline3d-splat-playcanvas.js +134 -18
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +86 -10
- package/js/inline3d.js +257 -15
- package/js/lift/index.js +15 -0
- package/model.d.ts +10 -1
- package/package.json +29 -4
- package/player.d.ts +49 -4
- package/splat.d.ts +52 -3
- package/viewer.d.ts +12 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// call/element.js — `<dxr-call>`: the one-line path as markup (RFC 0003 §1, C1).
|
|
2
|
+
//
|
|
3
|
+
// <script type="module" src="https://cdn.jsdelivr.net/npm/@displayxr/inline3d@1/dist/call.js"></script>
|
|
4
|
+
// <dxr-call accent="violet"></dxr-call>
|
|
5
|
+
//
|
|
6
|
+
// REGISTRATION. Importing `@displayxr/inline3d/call` (or `/call/full`, or the CDN bundle) defines
|
|
7
|
+
// the element — the entry calls `defineCallElement()` at the bottom of inline3d-call.js, which is
|
|
8
|
+
// why that file is listed in package.json `sideEffects`. This file itself defines nothing on
|
|
9
|
+
// import; it is the class, importable by path for tests.
|
|
10
|
+
//
|
|
11
|
+
// The element is SUGAR over `mountCall(this, attrsToOpts(this))`: it mounts on connect, `leave()`s
|
|
12
|
+
// on disconnect, re-dispatches every call event as a DOM `CustomEvent` (`dxr-call:peer`,
|
|
13
|
+
// `dxr-call:joined`, …, `detail` = the call's payload, bubbling and composed) and exposes the handle
|
|
14
|
+
// as `el.call`. It is the element's LIGHT DOM that hosts the tiles — no shadow root: woven canvases
|
|
15
|
+
// live in the document's own tree, and the SDK's chrome stylesheet is document-level.
|
|
16
|
+
//
|
|
17
|
+
// Attributes are the string-typed subset of `CallOptions` (attrsToOpts below). Anything else — a
|
|
18
|
+
// `MediaStream` camera, a custom `SignalingAdapter`, a `lift` function, a `wall` — is a JS
|
|
19
|
+
// property set BEFORE the element connects: `el.options = { … }`. Options win over attributes.
|
|
20
|
+
// Attributes are read once, at connect: this element is not reactive to later attribute changes
|
|
21
|
+
// (change them, then re-append it — which is a new call; see below).
|
|
22
|
+
//
|
|
23
|
+
// DISCONNECT = LEAVE. Removing the element ends the call, deliberately: a woven canvas must never
|
|
24
|
+
// be remounted mid-call (woven-canvas rule 2), so a DOM move IS a teardown. SPA routers: mount
|
|
25
|
+
// `<dxr-call>` in a layout that survives navigation. Re-appending a disconnected element starts a
|
|
26
|
+
// fresh call (a new mount; the old handle is gone).
|
|
27
|
+
//
|
|
28
|
+
// NO GLOBALS (Decision 11): nothing here touches `globalThis` beyond `customElements.define`,
|
|
29
|
+
// which `defineCallElement()` does exactly once and only when a registry exists. The class is
|
|
30
|
+
// usable — and unit-testable — without a DOM: the base class falls back to a plain class when
|
|
31
|
+
// `HTMLElement` is absent, and the mount seam (`DxrCallElement.mount`) is overridable.
|
|
32
|
+
//
|
|
33
|
+
// The import below is CIRCULAR (inline3d-call.js imports this file to register the element), so
|
|
34
|
+
// nothing from it may be read while this module evaluates: `mount` defaults lazily, at connect.
|
|
35
|
+
|
|
36
|
+
import { mountCall, dxrSignaling } from '../inline3d-call.js';
|
|
37
|
+
|
|
38
|
+
/** Every call event is re-dispatched as `dxr-call:<type>`; `dxr-call:ready` carries the handle. */
|
|
39
|
+
export const CALL_EVENT_PREFIX = 'dxr-call:';
|
|
40
|
+
const CALL_EVENTS = ['joined', 'left', 'peer', 'peerleft', 'state', 'format', 'speaker', 'quality', 'error', 'warning', 'session'];
|
|
41
|
+
const TAG = '[inline3d/call] <dxr-call>';
|
|
42
|
+
|
|
43
|
+
/** A boolean attribute: present is true, unless its value spells "false"/"0"/"off"/"no". */
|
|
44
|
+
const isOn = (v) => v !== null && v !== undefined && !/^(false|0|off|no)$/i.test(String(v).trim());
|
|
45
|
+
const num = (v) => {
|
|
46
|
+
if (v === null || v === undefined || String(v).trim() === '') return undefined;
|
|
47
|
+
const m = /^\s*([0-9.]+)\s*\/\s*([0-9.]+)\s*$/.exec(String(v)); // "16/9"
|
|
48
|
+
const n = m ? +m[1] / +m[2] : +v;
|
|
49
|
+
return Number.isFinite(n) ? n : undefined;
|
|
50
|
+
};
|
|
51
|
+
const str = (v) => (v === null || v === undefined || String(v).trim() === '' ? undefined : String(v).trim());
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Attribute → option mapping, pure. `source` is an element (or anything with `getAttribute`) or a
|
|
55
|
+
* `(name) => value | null` function. Unset attributes contribute nothing (the call's own defaults
|
|
56
|
+
* apply), so the result can be spread under `el.options`.
|
|
57
|
+
*
|
|
58
|
+
* | attribute | option |
|
|
59
|
+
* |----------------------|------------------------------------------|
|
|
60
|
+
* | `room` | `room` (an id or an invite link) |
|
|
61
|
+
* | `signaling` | `signaling: dxrSignaling(url)` |
|
|
62
|
+
* | `key` | `key` |
|
|
63
|
+
* | `camera` | `camera` (`auto`/`stereo`/`mono`/deviceId)|
|
|
64
|
+
* | `layout` | `layout` (`grid`/`speaker`) |
|
|
65
|
+
* | `accent` | `accent` |
|
|
66
|
+
* | `max-peers` | `maxPeers` |
|
|
67
|
+
* | `no-ui` | `ui: false` |
|
|
68
|
+
* | `auto-join` | `autoJoin: true` |
|
|
69
|
+
* | `mono3d="off"` | `mono3D: 'off'` |
|
|
70
|
+
* | `no-audio` | `audio: false` |
|
|
71
|
+
* | `no-self-view` | `selfView: false` |
|
|
72
|
+
* | `no-auto-converge` | `autoConverge: false` |
|
|
73
|
+
* | `tile-aspect` | `tileAspect` (a number, or `w/h`) |
|
|
74
|
+
* | `invite-base` | `inviteBase` |
|
|
75
|
+
* | `browser-url` | `browserUrl` |
|
|
76
|
+
* | `debug` | `debug: true` |
|
|
77
|
+
*
|
|
78
|
+
* @param {{ getAttribute(name: string): string | null } | ((name: string) => string | null)} source
|
|
79
|
+
* @returns {object} a partial `CallOptions`
|
|
80
|
+
*/
|
|
81
|
+
export function attrsToOpts(source) {
|
|
82
|
+
const get = typeof source === 'function' ? source : (n) => (source && typeof source.getAttribute === 'function' ? source.getAttribute(n) : null);
|
|
83
|
+
const o = {};
|
|
84
|
+
const put = (k, v) => {
|
|
85
|
+
if (v !== undefined) o[k] = v;
|
|
86
|
+
};
|
|
87
|
+
put('room', str(get('room')));
|
|
88
|
+
const signaling = str(get('signaling'));
|
|
89
|
+
if (signaling) o.signaling = dxrSignaling(signaling, str(get('key')) ? { key: str(get('key')) } : {});
|
|
90
|
+
put('key', str(get('key')));
|
|
91
|
+
put('camera', str(get('camera')));
|
|
92
|
+
put('layout', str(get('layout')));
|
|
93
|
+
put('accent', str(get('accent')));
|
|
94
|
+
put('maxPeers', num(get('max-peers')));
|
|
95
|
+
if (isOn(get('no-ui'))) o.ui = false;
|
|
96
|
+
if (isOn(get('auto-join'))) o.autoJoin = true;
|
|
97
|
+
const m3 = str(get('mono3d'));
|
|
98
|
+
if (m3 && /^(off|false|0|no)$/i.test(m3)) o.mono3D = 'off';
|
|
99
|
+
if (isOn(get('no-audio'))) o.audio = false;
|
|
100
|
+
if (isOn(get('no-self-view'))) o.selfView = false;
|
|
101
|
+
if (isOn(get('no-auto-converge'))) o.autoConverge = false;
|
|
102
|
+
put('tileAspect', num(get('tile-aspect')));
|
|
103
|
+
put('inviteBase', str(get('invite-base')));
|
|
104
|
+
put('browserUrl', str(get('browser-url')));
|
|
105
|
+
if (isOn(get('debug'))) o.debug = true;
|
|
106
|
+
return o;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Usable without a DOM (unit tests, SSR imports): a plain base class where HTMLElement is absent.
|
|
110
|
+
const Base = typeof HTMLElement === 'function' ? HTMLElement : class {};
|
|
111
|
+
|
|
112
|
+
export class DxrCallElement extends Base {
|
|
113
|
+
/**
|
|
114
|
+
* The mount seam: null = `mountCall` of `./call`. `./call/full` points it at its lift-wired
|
|
115
|
+
* `mountCall`, so importing that entry makes every `<dxr-call>` lift 2D participants; tests
|
|
116
|
+
* inject a fake. Resolved at connect, never at import (the circular import above).
|
|
117
|
+
*/
|
|
118
|
+
static mount = null;
|
|
119
|
+
|
|
120
|
+
constructor() {
|
|
121
|
+
super();
|
|
122
|
+
/** Options that are not strings — set BEFORE connect; win over attributes. */
|
|
123
|
+
this.options = null;
|
|
124
|
+
/** The `CallHandle` once mounted; null before, and again after disconnect. */
|
|
125
|
+
this.call = null;
|
|
126
|
+
/** Resolves with the handle when the mount lands (null if the element left first); rejects if it failed. */
|
|
127
|
+
this.ready = null;
|
|
128
|
+
this._gen = 0;
|
|
129
|
+
this._off = [];
|
|
130
|
+
this._pending = null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
connectedCallback() {
|
|
134
|
+
if (this.call || this._pending) return; // already mounted, or mounting
|
|
135
|
+
const gen = ++this._gen;
|
|
136
|
+
const opts = { ...attrsToOpts(this), ...(this.options && typeof this.options === 'object' ? this.options : {}) };
|
|
137
|
+
const mount = this.constructor.mount || DxrCallElement.mount || mountCall;
|
|
138
|
+
// Synchronous call (a throw becomes a rejection): the container's chrome exists on return.
|
|
139
|
+
this._pending = new Promise((res) => res(mount(this, opts)))
|
|
140
|
+
.then(
|
|
141
|
+
(handle) => {
|
|
142
|
+
this._pending = null;
|
|
143
|
+
if (gen !== this._gen) {
|
|
144
|
+
// Disconnected while the camera/lobby was coming up: the mount landed on a dead element.
|
|
145
|
+
handle.leave();
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
this.call = handle;
|
|
149
|
+
this._off = CALL_EVENTS.map((t) => handle.on(t, (detail) => this._dispatch(t, detail)));
|
|
150
|
+
// The call module only console.warns an error when NOBODY listens; the element always
|
|
151
|
+
// does, so it says it here — a drop-in widget's page usually has no JS listening at all.
|
|
152
|
+
this._off.push(handle.on('error', (e) => console.warn(`${TAG} ${e.code}: ${e.message}`)));
|
|
153
|
+
this._dispatch('ready', { call: handle });
|
|
154
|
+
// `auto-join` (and `no-ui`) join DURING the mount, before anything could subscribe —
|
|
155
|
+
// and a room that already had people reports them during the join too. Replay what a
|
|
156
|
+
// listener would have seen, from the handle's state: 'joined', then one 'peer' per
|
|
157
|
+
// participant already there. (Errors raised while mounting — 'camera-busy' — were
|
|
158
|
+
// console.warned by the call itself, since nothing was listening yet.)
|
|
159
|
+
if (handle.state === 'in-call') {
|
|
160
|
+
this._dispatch('joined', { room: handle.room, id: handle.id });
|
|
161
|
+
for (const p of handle.peers) this._dispatch('peer', { id: p.id });
|
|
162
|
+
}
|
|
163
|
+
return handle;
|
|
164
|
+
},
|
|
165
|
+
(err) => {
|
|
166
|
+
this._pending = null;
|
|
167
|
+
this._dispatch('error', { code: 'mount-failed', message: String((err && err.message) || err), error: err || null });
|
|
168
|
+
console.warn(`${TAG} mount failed: ${String((err && err.message) || err)}`);
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
this.ready = this._pending;
|
|
173
|
+
// A rejected `ready` nobody awaits must not be an unhandled rejection in a plain page.
|
|
174
|
+
this.ready.catch(() => {});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
disconnectedCallback() {
|
|
178
|
+
this._gen++; // a mount still in flight lands on a dead element (see above)
|
|
179
|
+
const call = this.call;
|
|
180
|
+
this.call = null;
|
|
181
|
+
// leave() FIRST, so the call's 'left' still reaches listeners on the element as
|
|
182
|
+
// `dxr-call:left` (it no longer bubbles anywhere: the element is out of the tree); THEN unhook.
|
|
183
|
+
if (call) call.leave();
|
|
184
|
+
for (const off of this._off) {
|
|
185
|
+
try {
|
|
186
|
+
off();
|
|
187
|
+
} catch {
|
|
188
|
+
/* ignore */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
this._off = [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_dispatch(type, detail) {
|
|
195
|
+
if (typeof CustomEvent !== 'function' || typeof this.dispatchEvent !== 'function') return;
|
|
196
|
+
this.dispatchEvent(new CustomEvent(CALL_EVENT_PREFIX + type, { detail, bubbles: true, composed: true }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Register `<dxr-call>` (or another tag name) once. Returns true when this call registered it,
|
|
202
|
+
* false when the name was already taken (by this class: fine; by another: a warning) or when there
|
|
203
|
+
* is no `customElements` registry (Node, a worker).
|
|
204
|
+
*
|
|
205
|
+
* @param {string} [name='dxr-call']
|
|
206
|
+
* @param {CustomElementRegistry} [registry=globalThis.customElements]
|
|
207
|
+
*/
|
|
208
|
+
export function defineCallElement(name = 'dxr-call', registry = globalThis.customElements) {
|
|
209
|
+
if (!registry || typeof registry.define !== 'function') return false;
|
|
210
|
+
const existing = registry.get(name);
|
|
211
|
+
if (existing) {
|
|
212
|
+
if (existing !== DxrCallElement) console.warn(`${TAG} <${name}> is already defined by another class; leaving it`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
registry.define(name, DxrCallElement);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
package/js/call/lift.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
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
|
+
if (liftSpecifier) return typeof liftSpecifier === 'function' ? liftSpecifier() : liftSpecifier;
|
|
47
|
+
return new URL('../lift/index.js', base).href;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The pre-bundled CDN build (dist/call.js) has no js/call/ directory to resolve against: it points
|
|
51
|
+
// this at its own sibling chunk (`dist/lift.js`) instead. A URL string, or a function returning
|
|
52
|
+
// one (evaluated at import time, so `import.meta.url` of the caller can be used). Null = default.
|
|
53
|
+
let liftSpecifier = null;
|
|
54
|
+
export function setLiftSpecifier(spec) {
|
|
55
|
+
liftSpecifier = spec || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultImporter() {
|
|
59
|
+
return import(/* @vite-ignore */ /* webpackIgnore: true */ defaultLiftSpecifier());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the `mono3D` option to a usable lift function, or to null (flat) with a reason.
|
|
64
|
+
*
|
|
65
|
+
* 'off' → { lift: null, reason: 'off' }
|
|
66
|
+
* function → { lift: fn, capabilities: fn.liftCapabilities || null, source: 'injected' }
|
|
67
|
+
* 'auto' → import the lift module; then, when it has `liftCapabilities()`, require a native
|
|
68
|
+
* provider OR a WebGPU adapter for the web fallback (without it, the module has no
|
|
69
|
+
* live video path). Without `liftCapabilities`, a missing `navigator.gpu` is the
|
|
70
|
+
* same verdict. Any failure → { lift: null, reason }, never a throw.
|
|
71
|
+
*
|
|
72
|
+
* @param {'auto'|'off'|Function} mono3D
|
|
73
|
+
* @param {{ importer?: () => Promise<any>, nav?: any, log?: (tag: string, o: object) => void, capsOpts?: object }} [o]
|
|
74
|
+
* @returns {Promise<{ lift: Function|null, capabilities: Function|null, caps: object|null, source: string, reason: string|null }>}
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveLift(mono3D, { importer = defaultImporter, nav = globalThis.navigator, log = null, capsOpts = { webFallback: true } } = {}) {
|
|
77
|
+
const say = (tag, x) => log && log(tag, x);
|
|
78
|
+
const flat = (reason, extra = {}) => {
|
|
79
|
+
say('lift-unavailable', { reason, ...extra });
|
|
80
|
+
return { lift: null, capabilities: null, caps: null, source: 'none', reason };
|
|
81
|
+
};
|
|
82
|
+
const m = normalizeMono3D(mono3D);
|
|
83
|
+
if (m === 'off') return { lift: null, capabilities: null, caps: null, source: 'none', reason: 'off' };
|
|
84
|
+
if (typeof m === 'function') {
|
|
85
|
+
const capabilities = typeof m.liftCapabilities === 'function' ? m.liftCapabilities : null;
|
|
86
|
+
return { lift: m, capabilities, caps: null, source: 'injected', reason: null };
|
|
87
|
+
}
|
|
88
|
+
let mod;
|
|
89
|
+
try {
|
|
90
|
+
mod = await importer();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return flat('import-failed', { message: String((err && err.message) || err).slice(0, 200) });
|
|
93
|
+
}
|
|
94
|
+
const lift = mod && typeof mod.lift === 'function' ? mod.lift : null;
|
|
95
|
+
if (!lift) return flat('no-lift-export');
|
|
96
|
+
const capabilities = typeof mod.liftCapabilities === 'function' ? mod.liftCapabilities : null;
|
|
97
|
+
let caps = null;
|
|
98
|
+
if (capabilities) {
|
|
99
|
+
try {
|
|
100
|
+
caps = await capabilities(capsOpts);
|
|
101
|
+
} catch {
|
|
102
|
+
caps = null;
|
|
103
|
+
}
|
|
104
|
+
const web = !!(caps && caps.webFallback && caps.webFallback.webgpu);
|
|
105
|
+
if (caps && !caps.native && !web) return flat('no-provider', { native: false, webgpu: false });
|
|
106
|
+
} else if (!(nav && nav.gpu)) return flat('no-webgpu');
|
|
107
|
+
say('lift-ready', { native: !!(caps && caps.native), provider: (caps && caps.provider) || null });
|
|
108
|
+
return { lift, capabilities, caps, source: 'module', reason: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The call's ONE depth control ([-1, 1], + = push the picture back) as lift's convergence.
|
|
113
|
+
* lift's `convergence` is the normalised disparity (0 = far, 1 = near) placed on the glass, so a
|
|
114
|
+
* larger value puts more of the picture behind it. 0 (± a small band) = 'auto' (lift centres its
|
|
115
|
+
* own subject); otherwise 0.5 + 0.5·depth, clamped to [0, 1].
|
|
116
|
+
*/
|
|
117
|
+
export function liftConvergenceFor(depth) {
|
|
118
|
+
const d = Number.isFinite(+depth) && depth !== null ? Math.max(-1, Math.min(1, +depth)) : 0;
|
|
119
|
+
if (Math.abs(d) < DEPTH_AUTO_BAND) return 'auto';
|
|
120
|
+
return Math.round((0.5 + 0.5 * d) * 1000) / 1000;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A lifted tile's priority: hidden/offscreen → paused, the active speaker → high, else normal. */
|
|
124
|
+
export function liftPriorityFor({ id, speakerId, visible = true }) {
|
|
125
|
+
if (!visible) return LIFT_PRIORITY.hidden;
|
|
126
|
+
return id !== null && id !== undefined && id === speakerId ? LIFT_PRIORITY.speaker : LIFT_PRIORITY.other;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* THE HOOK FOR THE NATIVE PROVIDER'S PER-STREAM PRIORITY (`xrSetLiftStreamPriorityDXR`: HIGH =
|
|
131
|
+
* every round, NORMAL = round-robin, LOW = every Nth, PAUSED = keep the last frame).
|
|
132
|
+
*
|
|
133
|
+
* Today it forwards to the lift handle's `setPriority()` when it has one: in native mode that is
|
|
134
|
+
* the element's `dxr-lift-priority` attribute, which the browser maps onto the runtime's stream
|
|
135
|
+
* priority; on the web path lift runs one stream per element and it is a no-op. A handle without
|
|
136
|
+
* `setPriority` (an injected lift, an older lift build) is a no-op here — the call still works,
|
|
137
|
+
* every stream is just scheduled equally. Nothing else in the call module talks priority, so this
|
|
138
|
+
* one function is what changes if the native provider grows a different surface.
|
|
139
|
+
*
|
|
140
|
+
* @returns {boolean} true when the handle accepted it
|
|
141
|
+
*/
|
|
142
|
+
export function setLiftPriority(handle, level) {
|
|
143
|
+
if (!handle || !PRIORITIES.includes(level)) return false;
|
|
144
|
+
if (typeof handle.setPriority !== 'function') return false;
|
|
145
|
+
try {
|
|
146
|
+
return handle.setPriority(level) !== false;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Frame-time watch for lifted tiles on the WEB provider (the native one runs in the display
|
|
154
|
+
* service, off the page's frame budget). Feed it the page's frame intervals; it reports
|
|
155
|
+
* `'degraded'` once when the smoothed interval stays above ~20 fps for 3 s, and `'recovered'`
|
|
156
|
+
* once it is back above ~28 fps. Pure.
|
|
157
|
+
*/
|
|
158
|
+
export function createFrameWatch({ degradedMs = DEGRADED_FRAME_MS, recoveredMs = RECOVERED_FRAME_MS, holdMs = DEGRADED_HOLD_MS, alpha = 0.1 } = {}) {
|
|
159
|
+
let ema = null;
|
|
160
|
+
let slowFor = 0;
|
|
161
|
+
let degraded = false;
|
|
162
|
+
return {
|
|
163
|
+
/** @param {number} dtMs one frame interval @returns {'degraded'|'recovered'|null} */
|
|
164
|
+
feed(dtMs) {
|
|
165
|
+
if (!(dtMs > 0) || dtMs > 1000) return null; // a tab switch, not a frame
|
|
166
|
+
ema = ema === null ? dtMs : ema + alpha * (dtMs - ema);
|
|
167
|
+
if (!degraded) {
|
|
168
|
+
slowFor = ema > degradedMs ? slowFor + dtMs : 0;
|
|
169
|
+
if (slowFor >= holdMs) {
|
|
170
|
+
degraded = true;
|
|
171
|
+
return 'degraded';
|
|
172
|
+
}
|
|
173
|
+
} else if (ema < recoveredMs) {
|
|
174
|
+
degraded = false;
|
|
175
|
+
slowFor = 0;
|
|
176
|
+
return 'recovered';
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
},
|
|
180
|
+
reset() {
|
|
181
|
+
ema = null;
|
|
182
|
+
slowFor = 0;
|
|
183
|
+
degraded = false;
|
|
184
|
+
},
|
|
185
|
+
get frameMs() {
|
|
186
|
+
return ema;
|
|
187
|
+
},
|
|
188
|
+
get degraded() {
|
|
189
|
+
return degraded;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The call's lifted streams: at most `max` at once, each one lift() handle per peer, steered as a
|
|
196
|
+
* group (speaker → priority, depth → convergence). Pure apart from the `lift` it is given.
|
|
197
|
+
*
|
|
198
|
+
* @param {{ lift: Function, max?: number, log?: (tag: string, o: object) => void, options?: object }} o
|
|
199
|
+
*/
|
|
200
|
+
export function createLiftPool({ lift, max = 4, log = null, options = null } = {}) {
|
|
201
|
+
// Page-supplied lift options (models, ort, quality, providers, …). The call owns the keys that
|
|
202
|
+
// make it a call tile — mode, wall, ui, convergence, priority — so those always win.
|
|
203
|
+
const extra = options && typeof options === 'object' ? options : {};
|
|
204
|
+
const entries = new Map(); // id → { gen, handle|null, visible, priority, pending }
|
|
205
|
+
let speakerId = null;
|
|
206
|
+
let depth = 0;
|
|
207
|
+
let gen = 0;
|
|
208
|
+
const say = (tag, x) => log && log(tag, x);
|
|
209
|
+
|
|
210
|
+
const apply = (id, e) => {
|
|
211
|
+
if (!e.handle) return;
|
|
212
|
+
const p = liftPriorityFor({ id, speakerId, visible: e.visible });
|
|
213
|
+
if (p === e.priority) return;
|
|
214
|
+
e.priority = p;
|
|
215
|
+
const ok = setLiftPriority(e.handle, p);
|
|
216
|
+
say('lift-priority', { peer: id, priority: p, applied: ok });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const pool = {
|
|
220
|
+
get size() {
|
|
221
|
+
return entries.size;
|
|
222
|
+
},
|
|
223
|
+
get max() {
|
|
224
|
+
return max;
|
|
225
|
+
},
|
|
226
|
+
set max(n) {
|
|
227
|
+
max = Math.max(0, n | 0);
|
|
228
|
+
},
|
|
229
|
+
has: (id) => entries.has(id),
|
|
230
|
+
handle: (id) => (entries.get(id) && entries.get(id).handle) || null,
|
|
231
|
+
priority: (id) => (entries.get(id) && entries.get(id).priority) || null,
|
|
232
|
+
/** Is there a slot for `id` (it already holds one, or fewer than `max` are taken)? */
|
|
233
|
+
canAcquire: (id) => entries.has(id) || entries.size < max,
|
|
234
|
+
/**
|
|
235
|
+
* Lift `element` for peer `id`. Resolves with the handle, or null when there is no slot, when
|
|
236
|
+
* `release(id)` ran meanwhile (the late handle is removed), or when lift() rejects (`onError`
|
|
237
|
+
* gets it). One pending/live stream per id: a second acquire returns the first.
|
|
238
|
+
*/
|
|
239
|
+
acquire(id, element, { wall, visible = true, onError } = {}) {
|
|
240
|
+
const cur = entries.get(id);
|
|
241
|
+
if (cur) return cur.pending || Promise.resolve(cur.handle);
|
|
242
|
+
if (entries.size >= max) return Promise.resolve(null);
|
|
243
|
+
const my = ++gen;
|
|
244
|
+
const priority = liftPriorityFor({ id, speakerId, visible });
|
|
245
|
+
const e = { gen: my, handle: null, visible, priority, pending: null };
|
|
246
|
+
entries.set(id, e);
|
|
247
|
+
const opts = { quality: 'auto', ...extra, mode: 'live', wall, ui: 'none', convergence: liftConvergenceFor(depth), priority };
|
|
248
|
+
say('lift-acquire', { peer: id, priority, convergence: opts.convergence });
|
|
249
|
+
e.pending = Promise.resolve()
|
|
250
|
+
.then(() => lift(element, opts))
|
|
251
|
+
.then(
|
|
252
|
+
(h) => {
|
|
253
|
+
if (entries.get(id) !== e || e.gen !== my) {
|
|
254
|
+
try {
|
|
255
|
+
h && h.remove && h.remove();
|
|
256
|
+
} catch {
|
|
257
|
+
/* ignore */
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
e.pending = null;
|
|
262
|
+
e.handle = h || null;
|
|
263
|
+
// Converge on the CURRENT state (speaker/depth/visibility may have moved meanwhile).
|
|
264
|
+
e.priority = null;
|
|
265
|
+
apply(id, e);
|
|
266
|
+
if (h && typeof h.setConvergence === 'function') h.setConvergence(liftConvergenceFor(depth));
|
|
267
|
+
return e.handle;
|
|
268
|
+
},
|
|
269
|
+
(err) => {
|
|
270
|
+
if (entries.get(id) === e) entries.delete(id);
|
|
271
|
+
say('lift-failed', { peer: id, message: String((err && err.message) || err).slice(0, 200) });
|
|
272
|
+
if (onError) onError(err);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
return e.pending;
|
|
277
|
+
},
|
|
278
|
+
/** Remove peer `id`'s lift (a pending one is removed when it lands). */
|
|
279
|
+
release(id) {
|
|
280
|
+
const e = entries.get(id);
|
|
281
|
+
if (!e) return;
|
|
282
|
+
entries.delete(id);
|
|
283
|
+
if (e.handle) {
|
|
284
|
+
try {
|
|
285
|
+
e.handle.remove();
|
|
286
|
+
} catch {
|
|
287
|
+
/* the session may be gone */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
say('lift-release', { peer: id });
|
|
291
|
+
},
|
|
292
|
+
releaseAll() {
|
|
293
|
+
for (const id of [...entries.keys()]) pool.release(id);
|
|
294
|
+
},
|
|
295
|
+
setSpeaker(id) {
|
|
296
|
+
speakerId = id === undefined ? null : id;
|
|
297
|
+
for (const [pid, e] of entries) apply(pid, e);
|
|
298
|
+
},
|
|
299
|
+
setVisible(id, visible) {
|
|
300
|
+
const e = entries.get(id);
|
|
301
|
+
if (!e || e.visible === !!visible) return;
|
|
302
|
+
e.visible = !!visible;
|
|
303
|
+
apply(id, e);
|
|
304
|
+
},
|
|
305
|
+
/** The call's depth control → every lifted stream's convergence. */
|
|
306
|
+
setDepth(v) {
|
|
307
|
+
depth = v;
|
|
308
|
+
const c = liftConvergenceFor(v);
|
|
309
|
+
for (const e of entries.values()) if (e.handle && typeof e.handle.setConvergence === 'function') e.handle.setConvergence(c);
|
|
310
|
+
return c;
|
|
311
|
+
},
|
|
312
|
+
/** True when any live lifted stream runs on the page (the web provider), not in the service. */
|
|
313
|
+
get anyWeb() {
|
|
314
|
+
for (const e of entries.values()) if (e.handle && !e.handle.native) return true;
|
|
315
|
+
return false;
|
|
316
|
+
},
|
|
317
|
+
/** Snapshot for info()/tests. */
|
|
318
|
+
list() {
|
|
319
|
+
return [...entries].map(([id, e]) => ({ id, priority: e.priority, pending: !!e.pending, native: !!(e.handle && e.handle.native) }));
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
return pool;
|
|
323
|
+
}
|