@displayxr/inline3d 1.28.0 → 1.30.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 +233 -0
- package/README.md +10 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +328 -217
- package/camera.d.ts +257 -0
- package/dist/call.js +86 -0
- package/index.d.ts +27 -1
- package/js/call/capture.js +4 -167
- package/js/call/disparity.js +4 -0
- package/js/call/element.js +221 -0
- package/js/call/lift.js +9 -0
- package/js/call/options.js +132 -0
- package/js/call/signaling.js +7 -202
- package/js/call/ui.js +106 -9
- package/js/call/wire.js +8 -147
- package/js/camera/capture.js +220 -0
- package/js/camera/converge.js +82 -0
- package/js/camera/disparity.js +432 -0
- package/js/camera/geometry.js +80 -0
- package/js/camera/metadata.js +398 -0
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +609 -318
- package/js/inline3d-camera.js +678 -0
- package/js/inline3d-player.js +646 -216
- package/js/inline3d-splat-deferred.js +153 -0
- package/js/inline3d-splat-pc-entry.js +45 -0
- package/js/inline3d-splat-playcanvas.js +46 -3
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d-splat.js +2 -145
- package/js/inline3d.js +130 -1
- package/js/lift/index.js +15 -0
- package/package.json +39 -4
- package/player.d.ts +46 -2
- package/splat-playcanvas.d.ts +4 -0
- package/splat.d.ts +38 -0
package/js/call/capture.js
CHANGED
|
@@ -1,167 +1,4 @@
|
|
|
1
|
-
// call/capture.js —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
-
}
|
|
1
|
+
// call/capture.js — moved to js/camera/capture.js in C2 (RFC 0003 §4: device selection,
|
|
2
|
+
// calibration and the busy-device skip belong to `@displayxr/inline3d/camera`). This path
|
|
3
|
+
// re-exports it so test imports and any in-tree reference keep resolving.
|
|
4
|
+
export * from '../camera/capture.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// call/disparity.js — moved to js/camera/disparity.js in C2 (RFC 0003 §4: auto-convergence is a
|
|
2
|
+
// camera-side measurement that the call and `addCameraView` share). This path re-exports it so
|
|
3
|
+
// test imports and any in-tree reference keep resolving; new code imports the camera module.
|
|
4
|
+
export * from '../camera/disparity.js';
|
|
@@ -0,0 +1,221 @@
|
|
|
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', 'display', 'speaker', 'quality', 'error', 'warning'];
|
|
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`/`none`) |
|
|
65
|
+
* | `accent` | `theme.accent` |
|
|
66
|
+
* | `max-peers` | `maxPeers` |
|
|
67
|
+
* | `no-ui` | `ui: false` |
|
|
68
|
+
* | `ui="tiles"` | `ui: 'tiles'` |
|
|
69
|
+
* | `auto-join` | `autoJoin: true` |
|
|
70
|
+
* | `mono3d="off"` | `mono3D: 'off'` |
|
|
71
|
+
* | `no-audio` | `audio: false` |
|
|
72
|
+
* | `no-self-view` | `selfView: false` |
|
|
73
|
+
* | `no-auto-converge` | `autoConverge: false` |
|
|
74
|
+
* | `tile-aspect` | `theme.tileAspect` (a number, or `w/h`) |
|
|
75
|
+
* | `invite-base` | `invite.base` |
|
|
76
|
+
* | `browser-url` | `landing.browserUrl` |
|
|
77
|
+
* | `debug` | `debug: true` |
|
|
78
|
+
*
|
|
79
|
+
* @param {{ getAttribute(name: string): string | null } | ((name: string) => string | null)} source
|
|
80
|
+
* @returns {object} a partial `CallOptions`
|
|
81
|
+
*/
|
|
82
|
+
export function attrsToOpts(source) {
|
|
83
|
+
const get = typeof source === 'function' ? source : (n) => (source && typeof source.getAttribute === 'function' ? source.getAttribute(n) : null);
|
|
84
|
+
const o = {};
|
|
85
|
+
const put = (k, v) => {
|
|
86
|
+
if (v !== undefined) o[k] = v;
|
|
87
|
+
};
|
|
88
|
+
put('room', str(get('room')));
|
|
89
|
+
const signaling = str(get('signaling'));
|
|
90
|
+
if (signaling) o.signaling = dxrSignaling(signaling, str(get('key')) ? { key: str(get('key')) } : {});
|
|
91
|
+
put('key', str(get('key')));
|
|
92
|
+
put('camera', str(get('camera')));
|
|
93
|
+
put('layout', str(get('layout')));
|
|
94
|
+
put('maxPeers', num(get('max-peers')));
|
|
95
|
+
const ui = str(get('ui'));
|
|
96
|
+
if (isOn(get('no-ui'))) o.ui = false;
|
|
97
|
+
else if (ui === 'tiles') o.ui = 'tiles';
|
|
98
|
+
if (isOn(get('auto-join'))) o.autoJoin = true;
|
|
99
|
+
const m3 = str(get('mono3d'));
|
|
100
|
+
if (m3 && /^(off|false|0|no)$/i.test(m3)) o.mono3D = 'off';
|
|
101
|
+
if (isOn(get('no-audio'))) o.audio = false;
|
|
102
|
+
if (isOn(get('no-self-view'))) o.selfView = false;
|
|
103
|
+
if (isOn(get('no-auto-converge'))) o.autoConverge = false;
|
|
104
|
+
// The C2 groups (RFC 0003 §2): the attribute names are the C1 ones, the option shapes are new.
|
|
105
|
+
const theme = { accent: str(get('accent')), tileAspect: num(get('tile-aspect')) };
|
|
106
|
+
if (theme.accent !== undefined || theme.tileAspect !== undefined) o.theme = Object.fromEntries(Object.entries(theme).filter(([, v]) => v !== undefined));
|
|
107
|
+
if (str(get('invite-base'))) o.invite = { base: str(get('invite-base')) };
|
|
108
|
+
if (str(get('browser-url'))) o.landing = { browserUrl: str(get('browser-url')) };
|
|
109
|
+
if (isOn(get('debug'))) o.debug = true;
|
|
110
|
+
return o;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Usable without a DOM (unit tests, SSR imports): a plain base class where HTMLElement is absent.
|
|
114
|
+
const Base = typeof HTMLElement === 'function' ? HTMLElement : class {};
|
|
115
|
+
|
|
116
|
+
export class DxrCallElement extends Base {
|
|
117
|
+
/**
|
|
118
|
+
* The mount seam: null = `mountCall` of `./call`. `./call/full` points it at its lift-wired
|
|
119
|
+
* `mountCall`, so importing that entry makes every `<dxr-call>` lift 2D participants; tests
|
|
120
|
+
* inject a fake. Resolved at connect, never at import (the circular import above).
|
|
121
|
+
*/
|
|
122
|
+
static mount = null;
|
|
123
|
+
|
|
124
|
+
constructor() {
|
|
125
|
+
super();
|
|
126
|
+
/** Options that are not strings — set BEFORE connect; win over attributes. */
|
|
127
|
+
this.options = null;
|
|
128
|
+
/** The `CallHandle` once mounted; null before, and again after disconnect. */
|
|
129
|
+
this.call = null;
|
|
130
|
+
/** Resolves with the handle when the mount lands (null if the element left first); rejects if it failed. */
|
|
131
|
+
this.ready = null;
|
|
132
|
+
this._gen = 0;
|
|
133
|
+
this._off = [];
|
|
134
|
+
this._pending = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
connectedCallback() {
|
|
138
|
+
if (this.call || this._pending) return; // already mounted, or mounting
|
|
139
|
+
const gen = ++this._gen;
|
|
140
|
+
const opts = { ...attrsToOpts(this), ...(this.options && typeof this.options === 'object' ? this.options : {}) };
|
|
141
|
+
const mount = this.constructor.mount || DxrCallElement.mount || mountCall;
|
|
142
|
+
// Synchronous call (a throw becomes a rejection): the container's chrome exists on return.
|
|
143
|
+
this._pending = new Promise((res) => res(mount(this, opts)))
|
|
144
|
+
.then(
|
|
145
|
+
(handle) => {
|
|
146
|
+
this._pending = null;
|
|
147
|
+
if (gen !== this._gen) {
|
|
148
|
+
// Disconnected while the camera/lobby was coming up: the mount landed on a dead element.
|
|
149
|
+
handle.leave();
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
this.call = handle;
|
|
153
|
+
this._off = CALL_EVENTS.map((t) => handle.on(t, (detail) => this._dispatch(t, detail)));
|
|
154
|
+
// The call module only console.warns an error when NOBODY listens; the element always
|
|
155
|
+
// does, so it says it here — a drop-in widget's page usually has no JS listening at all.
|
|
156
|
+
this._off.push(handle.on('error', (e) => console.warn(`${TAG} ${e.code}: ${e.message}`)));
|
|
157
|
+
this._dispatch('ready', { call: handle });
|
|
158
|
+
// `auto-join` (and `no-ui`) join DURING the mount, before anything could subscribe —
|
|
159
|
+
// and a room that already had people reports them during the join too. Replay what a
|
|
160
|
+
// listener would have seen, from the handle's state: 'joined', then one 'peer' per
|
|
161
|
+
// participant already there. (Errors raised while mounting — 'camera-busy' — were
|
|
162
|
+
// console.warned by the call itself, since nothing was listening yet.)
|
|
163
|
+
if (handle.state === 'in-call') {
|
|
164
|
+
this._dispatch('joined', { room: handle.room, id: handle.id });
|
|
165
|
+
for (const p of handle.peers) this._dispatch('peer', { id: p.id });
|
|
166
|
+
}
|
|
167
|
+
return handle;
|
|
168
|
+
},
|
|
169
|
+
(err) => {
|
|
170
|
+
this._pending = null;
|
|
171
|
+
this._dispatch('error', { code: 'mount-failed', message: String((err && err.message) || err), error: err || null });
|
|
172
|
+
console.warn(`${TAG} mount failed: ${String((err && err.message) || err)}`);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
this.ready = this._pending;
|
|
177
|
+
// A rejected `ready` nobody awaits must not be an unhandled rejection in a plain page.
|
|
178
|
+
this.ready.catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
disconnectedCallback() {
|
|
182
|
+
this._gen++; // a mount still in flight lands on a dead element (see above)
|
|
183
|
+
const call = this.call;
|
|
184
|
+
this.call = null;
|
|
185
|
+
// leave() FIRST, so the call's 'left' still reaches listeners on the element as
|
|
186
|
+
// `dxr-call:left` (it no longer bubbles anywhere: the element is out of the tree); THEN unhook.
|
|
187
|
+
if (call) call.leave();
|
|
188
|
+
for (const off of this._off) {
|
|
189
|
+
try {
|
|
190
|
+
off();
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
this._off = [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_dispatch(type, detail) {
|
|
199
|
+
if (typeof CustomEvent !== 'function' || typeof this.dispatchEvent !== 'function') return;
|
|
200
|
+
this.dispatchEvent(new CustomEvent(CALL_EVENT_PREFIX + type, { detail, bubbles: true, composed: true }));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Register `<dxr-call>` (or another tag name) once. Returns true when this call registered it,
|
|
206
|
+
* false when the name was already taken (by this class: fine; by another: a warning) or when there
|
|
207
|
+
* is no `customElements` registry (Node, a worker).
|
|
208
|
+
*
|
|
209
|
+
* @param {string} [name='dxr-call']
|
|
210
|
+
* @param {CustomElementRegistry} [registry=globalThis.customElements]
|
|
211
|
+
*/
|
|
212
|
+
export function defineCallElement(name = 'dxr-call', registry = globalThis.customElements) {
|
|
213
|
+
if (!registry || typeof registry.define !== 'function') return false;
|
|
214
|
+
const existing = registry.get(name);
|
|
215
|
+
if (existing) {
|
|
216
|
+
if (existing !== DxrCallElement) console.warn(`${TAG} <${name}> is already defined by another class; leaving it`);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
registry.define(name, DxrCallElement);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
package/js/call/lift.js
CHANGED
|
@@ -43,9 +43,18 @@ export function normalizeMono3D(v) {
|
|
|
43
43
|
* passes `mono3D: lift` (imported itself) instead.
|
|
44
44
|
*/
|
|
45
45
|
export function defaultLiftSpecifier(base = import.meta.url) {
|
|
46
|
+
if (liftSpecifier) return typeof liftSpecifier === 'function' ? liftSpecifier() : liftSpecifier;
|
|
46
47
|
return new URL('../lift/index.js', base).href;
|
|
47
48
|
}
|
|
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
|
+
|
|
49
58
|
function defaultImporter() {
|
|
50
59
|
return import(/* @vite-ignore */ /* webpackIgnore: true */ defaultLiftSpecifier());
|
|
51
60
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// call/options.js — `CallOptions` → the module's internal shape (RFC 0003 §2): defaults,
|
|
2
|
+
// validation, the C2 groups (`theme`, `invite`, `landing`, `liftOptions.max`) folded flat, and the
|
|
3
|
+
// 1.29 spellings accepted for one release with a warning. Pure (no DOM, no network) — see
|
|
4
|
+
// test/call.test.mjs. Internal: importable by path, not part of the `./call` entry.
|
|
5
|
+
|
|
6
|
+
import { isValidRoomId, parseInviteLink, DEFAULT_MAX_PEERS } from './wire.js';
|
|
7
|
+
import { clampMaxPeers } from './transport.js';
|
|
8
|
+
import { normalizeMono3D } from './lift.js';
|
|
9
|
+
import { dxrSignaling } from './signaling.js';
|
|
10
|
+
import { resolveCallAccent, THEME_VARS } from './ui.js';
|
|
11
|
+
|
|
12
|
+
const TAG = '[inline3d/call]';
|
|
13
|
+
const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The 1.29 option spellings and where they went (RFC 0003 §2). Accepted for one release with a
|
|
17
|
+
* single console.warn per key; `normalizeCallOptions` folds them into the C2 shape. 1.31 drops
|
|
18
|
+
* them. The `<dxr-call>` ATTRIBUTES keep their names — the element maps them to the new shapes.
|
|
19
|
+
*/
|
|
20
|
+
export const LEGACY_OPTIONS = Object.freeze({
|
|
21
|
+
accent: 'theme.accent',
|
|
22
|
+
tileAspect: 'theme.tileAspect',
|
|
23
|
+
inviteBase: 'invite.base',
|
|
24
|
+
updateUrl: 'invite.updateUrl',
|
|
25
|
+
browserUrl: 'landing.browserUrl',
|
|
26
|
+
maxLifted: 'liftOptions.max',
|
|
27
|
+
calibration: "openCamera({ calibration }) of '@displayxr/inline3d/camera' (pass the camera as `camera`)",
|
|
28
|
+
rectify: "openCamera({ rectify }) of '@displayxr/inline3d/camera'",
|
|
29
|
+
log: 'debug: true (and diagnostics())',
|
|
30
|
+
recoverSession: "nothing — a call always recovers the document's session (sharedInline3D())",
|
|
31
|
+
wallOptions: "sharedInline3D(opts) — the document's wall is configured by the page",
|
|
32
|
+
scrollIntoView: 'nothing — always on with ui: true',
|
|
33
|
+
});
|
|
34
|
+
const legacyWarned = new Set();
|
|
35
|
+
function warnLegacy(key) {
|
|
36
|
+
if (legacyWarned.has(key)) return;
|
|
37
|
+
legacyWarned.add(key);
|
|
38
|
+
console.warn(`${TAG} option \`${key}\` is deprecated (removed in 1.31) — use ${LEGACY_OPTIONS[key]}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Apply defaults and validate. Pure (no DOM, no network) — see test/call.test.mjs. The result is
|
|
43
|
+
* the module's INTERNAL shape (flat), with the public groups (`theme`, `invite`, `landing`,
|
|
44
|
+
* `liftOptions`) folded in and the 1.29 spellings accepted with a warning (LEGACY_OPTIONS).
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeCallOptions(opts = {}) {
|
|
48
|
+
for (const k of Object.keys(LEGACY_OPTIONS)) if (opts[k] !== undefined) warnLegacy(k);
|
|
49
|
+
const theme = opts.theme && typeof opts.theme === 'object' ? opts.theme : {};
|
|
50
|
+
const invite = opts.invite && typeof opts.invite === 'object' ? opts.invite : {};
|
|
51
|
+
const landing = opts.landing && typeof opts.landing === 'object' ? opts.landing : {};
|
|
52
|
+
const ui = opts.ui === undefined ? true : opts.ui === 'tiles' ? 'tiles' : !!opts.ui;
|
|
53
|
+
const chrome = ui === true;
|
|
54
|
+
const tileAspect = theme.tileAspect !== undefined ? theme.tileAspect : opts.tileAspect;
|
|
55
|
+
const aspect = typeof tileAspect === 'number' && tileAspect > 0.3 && tileAspect < 4 ? tileAspect : 16 / 9;
|
|
56
|
+
let room = opts.room === undefined || opts.room === null ? 'auto' : opts.room;
|
|
57
|
+
if (room !== 'auto' && !isValidRoomId(room)) {
|
|
58
|
+
const fromLink = parseInviteLink(room);
|
|
59
|
+
if (!fromLink) throw new Error(`@displayxr/inline3d/call: room "${room}" is not a valid room id (16-64 base64url chars) or invite link`);
|
|
60
|
+
room = fromLink;
|
|
61
|
+
}
|
|
62
|
+
const liftOptions = opts.liftOptions && typeof opts.liftOptions === 'object' ? { ...opts.liftOptions } : null;
|
|
63
|
+
const maxLiftedRaw = liftOptions && liftOptions.max !== undefined ? liftOptions.max : opts.maxLifted;
|
|
64
|
+
if (liftOptions) delete liftOptions.max;
|
|
65
|
+
const accent = theme.accent !== undefined ? theme.accent : opts.accent;
|
|
66
|
+
const cssVars = {};
|
|
67
|
+
for (const [k, prop] of Object.entries(THEME_VARS)) {
|
|
68
|
+
const v = k === 'accent' ? accent : theme[k];
|
|
69
|
+
if (typeof v === 'string' && v) cssVars[prop] = k === 'accent' ? resolveCallAccent(v) : v;
|
|
70
|
+
else if (typeof v === 'number' && k === 'radius') cssVars[prop] = `${v}px`;
|
|
71
|
+
}
|
|
72
|
+
const inviteBase = invite.base !== undefined ? invite.base : opts.inviteBase;
|
|
73
|
+
const updateUrl = invite.updateUrl !== undefined ? invite.updateUrl : opts.updateUrl;
|
|
74
|
+
const browserUrl = landing.browserUrl !== undefined ? landing.browserUrl : opts.browserUrl;
|
|
75
|
+
return {
|
|
76
|
+
room,
|
|
77
|
+
// Default: the hosted DisplayXR signalling server (which also mints TURN credentials). A
|
|
78
|
+
// publishable key (RFC 0003 §5a) rides along on connect; the server may ignore it today.
|
|
79
|
+
signaling: opts.signaling || dxrSignaling(undefined, typeof opts.key === 'string' && opts.key ? { key: opts.key } : {}),
|
|
80
|
+
iceServers: Array.isArray(opts.iceServers) ? opts.iceServers : undefined,
|
|
81
|
+
// 'auto' | 'stereo' | 'mono' | deviceId | MediaStream | a StereoCamera from /camera.
|
|
82
|
+
camera: opts.camera === undefined ? 'auto' : opts.camera,
|
|
83
|
+
format: opts.format === 'sbs' ? 'sbs' : opts.format === 'mono' ? 'mono' : undefined,
|
|
84
|
+
// Camera-side knobs (moved to /camera in C2; still forwarded to openCamera() for one release).
|
|
85
|
+
calibration: opts.calibration && typeof opts.calibration === 'object' ? { ...opts.calibration } : {},
|
|
86
|
+
rectify: typeof opts.rectify === 'function' ? opts.rectify : null,
|
|
87
|
+
audio: opts.audio === undefined ? true : !!opts.audio,
|
|
88
|
+
// Auto-convergence (camera/disparity.js): measure the disparity of the point between each SBS
|
|
89
|
+
// peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
|
|
90
|
+
// offset on top. Off = the pair as sent (plus any `hint`).
|
|
91
|
+
autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
|
|
92
|
+
mono3D: normalizeMono3D(opts.mono3D),
|
|
93
|
+
maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
|
|
94
|
+
// Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
|
|
95
|
+
// keys (mode, wall, ui, convergence, priority) always win; `max` is the call's cap (below).
|
|
96
|
+
liftOptions: liftOptions && Object.keys(liftOptions).length ? liftOptions : null,
|
|
97
|
+
// Concurrent lifted tiles (`liftOptions.max`). Default = maxPeers (4): every mono peer can be lifted.
|
|
98
|
+
maxLifted: Number.isFinite(+maxLiftedRaw) && maxLiftedRaw !== null && maxLiftedRaw !== undefined ? Math.max(0, Math.min(DEFAULT_MAX_PEERS, Math.floor(+maxLiftedRaw))) : DEFAULT_MAX_PEERS,
|
|
99
|
+
// 'grid' | 'speaker' | 'none' (the page lays the tiles out; handle.tile(id) finds them).
|
|
100
|
+
layout: opts.layout === 'speaker' ? 'speaker' : opts.layout === 'none' ? 'none' : 'grid',
|
|
101
|
+
// true: full chrome | 'tiles': badges + plates only | false: nothing. `chrome` = the full set.
|
|
102
|
+
ui,
|
|
103
|
+
chrome,
|
|
104
|
+
autoJoin: opts.autoJoin === undefined ? !chrome : !!opts.autoJoin,
|
|
105
|
+
selfView: opts.selfView === undefined ? true : !!opts.selfView,
|
|
106
|
+
tileAspect: aspect,
|
|
107
|
+
// theme → the CSS custom properties written on the host, and the string table.
|
|
108
|
+
cssVars,
|
|
109
|
+
strings: theme.strings && typeof theme.strings === 'object' ? { ...theme.strings } : null,
|
|
110
|
+
invite: {
|
|
111
|
+
base: typeof inviteBase === 'string' ? inviteBase : null,
|
|
112
|
+
updateUrl: updateUrl === undefined ? chrome : !!updateUrl,
|
|
113
|
+
},
|
|
114
|
+
landing: {
|
|
115
|
+
browserUrl: typeof browserUrl === 'string' ? browserUrl : DEFAULT_BROWSER_URL,
|
|
116
|
+
allow2D: landing.allow2D === undefined ? true : !!landing.allow2D,
|
|
117
|
+
},
|
|
118
|
+
debug: !!opts.debug,
|
|
119
|
+
log: typeof opts.log === 'function' ? opts.log : opts.debug ? (tag, obj) => console.log(`${TAG} ${tag} ${JSON.stringify(obj)}`) : null,
|
|
120
|
+
// Internal (tests): replaces the lift module importer behind `mono3D: 'auto'`.
|
|
121
|
+
_liftImporter: typeof opts._liftImporter === 'function' ? opts._liftImporter : undefined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The 1.29 `./call` exports kept as deprecated wrappers for one release (removed in 1.31). */
|
|
126
|
+
export const DEPRECATED_CALL_EXPORTS = Object.freeze([
|
|
127
|
+
'normalizeCallOptions', 'newRoomId', 'isValidRoomId', 'buildInviteLink', 'normalizeHello', 'makeHello', 'routeFor', 'badgeFor', 'createLiveGate',
|
|
128
|
+
'convergenceShiftPx', 'lowPass', 'clampShift', 'eyeCropRect', 'mirrorSwapOps', 'mirrorSwapPixels', 'maxBitrateKbps', 'measureFocusDisparity',
|
|
129
|
+
'preferVideoCodecs', 'sortCodecCapabilities', 'VIDEO_CODEC_ORDER', 'MeshTransport', 'clampMaxPeers', 'qrEncode', 'roomKey', 'SIGNAL_PROTOCOL',
|
|
130
|
+
'WIRE_VERSION', 'CALL_SDK', 'PLATE_TEXT', 'CALL_ACCENTS', 'normalizeMono3D', 'resolveLift', 'createLiftPool', 'createFrameWatch',
|
|
131
|
+
'liftConvergenceFor', 'liftPriorityFor', 'setLiftPriority', 'defaultLiftSpecifier', 'LIFT_PRIORITY',
|
|
132
|
+
]);
|