@displayxr/inline3d 1.29.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.
@@ -0,0 +1,220 @@
1
+ // camera/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 './geometry.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
+ /** Errors that mean "the user (or a policy) refused this page the camera". */
34
+ export function isDeniedError(err) {
35
+ return !!err && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError' || err.name === 'SecurityError');
36
+ }
37
+
38
+ /**
39
+ * Why no camera could be opened, from the per-device failures. `'camera-busy'` when every device
40
+ * that exists is held by another process — on a 3D laptop/monitor whose only camera is the eye
41
+ * tracker's, that is the normal case while tracking, and the call should go audio-only and say so
42
+ * rather than fail or send an empty picture. `'permission-denied'` when any attempt was refused
43
+ * (a denied prompt, a policy, an insecure context's SecurityError); else `'no-camera'`. Pure.
44
+ * @param {Array<{error: string, busy?: boolean, denied?: boolean}>} skipped
45
+ */
46
+ export function noCameraCode(skipped) {
47
+ if (skipped.length && skipped.every((s) => s.busy)) return 'camera-busy';
48
+ if (skipped.some((s) => s.denied)) return 'permission-denied';
49
+ return 'no-camera';
50
+ }
51
+
52
+ /** A label that names a stereo camera, tried first when probing. Pure. */
53
+ export function stereoLabelHint(label) {
54
+ return /stereo|\b3d\b|\bsbs\b|dual/i.test(label || '');
55
+ }
56
+
57
+ /**
58
+ * Open the camera. Never rejects for a busy optional device; rejects only when NO camera can be
59
+ * opened at all (the caller then runs audio-only or reports the error).
60
+ *
61
+ * @param {'auto'|'stereo'|'mono'|string|MediaStream} want 'auto' | 'stereo' | 'mono' | a
62
+ * deviceId | a page-supplied MediaStream
63
+ * @param {{ format?: 'sbs'|'mono', calibration?: {baselineMm?: number, hfovDeg?: number, rectified?: boolean},
64
+ * mediaDevices?: MediaDevices, log?: Function }} [o]
65
+ * @returns {Promise<{ stream: MediaStream, format: 'sbs'|'mono', width: number, height: number,
66
+ * deviceId: string|null, label: string, owned: boolean, skipped: Array<{label: string, error: string}>,
67
+ * calibration: object }>}
68
+ */
69
+ export async function openCamera(want = 'auto', o = {}) {
70
+ const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
71
+ const log = o.log || (() => {});
72
+ const skipped = [];
73
+ const cal = o.calibration || {};
74
+
75
+ if (want && typeof want === 'object' && typeof want.getVideoTracks === 'function') {
76
+ const t = want.getVideoTracks()[0];
77
+ const st = t ? t.getSettings() : {};
78
+ // A page-supplied stream carries a DECLARED format; aspect is never used to guess 3D-ness here.
79
+ const format = o.format === 'sbs' ? 'sbs' : 'mono';
80
+ 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 };
81
+ }
82
+ if (!md || !md.getUserMedia) throw Object.assign(new Error('getUserMedia is not available (insecure context?)'), { code: 'no-camera' });
83
+
84
+ const open = async (constraints) => {
85
+ const s = await md.getUserMedia({ video: constraints, audio: false });
86
+ const t = s.getVideoTracks()[0];
87
+ const st = t ? t.getSettings() : {};
88
+ // Never hand back a 0x0 track: some engines open a device that then delivers nothing.
89
+ if (!t || t.readyState === 'ended' || !(st.width > 0) || !(st.height > 0)) {
90
+ s.getTracks().forEach((x) => x.stop());
91
+ throw Object.assign(new Error('the camera opened but delivers no picture (0x0)'), { name: 'NotReadableError' });
92
+ }
93
+ // The DisplayXR Browser's "3D Camera (DisplayXR)" (the runtime's stereo camera, e.g. an SR
94
+ // eye-tracking camera shared without stealing it) describes itself on the opened track:
95
+ // `displayxrStereo: {layout, rectified, baselineMm, horizontalFovDeg}`.
96
+ const stereo = st.displayxrStereo && st.displayxrStereo.layout === 'side-by-side' ? st.displayxrStereo : null;
97
+ return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo };
98
+ };
99
+ const skip = (label, err) => {
100
+ skipped.push({ label, error: `${err.name}: ${err.message}`, busy: isBusyError(err), denied: isDeniedError(err) });
101
+ log('camera-skip', { label, error: err.name });
102
+ };
103
+ const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
104
+ const result = (r, format) => ({
105
+ stream: r.stream, format, width: r.width, height: r.height, deviceId: r.deviceId, label: r.label, owned: true, skipped,
106
+ calibration: {
107
+ rectified: false,
108
+ // A runtime stereo camera's own description, where the page gave none.
109
+ ...(format === 'sbs' && r.stereo
110
+ ? {
111
+ rectified: !!r.stereo.rectified,
112
+ ...(r.stereo.baselineMm > 0 ? { baselineMm: r.stereo.baselineMm } : {}),
113
+ ...(r.stereo.horizontalFovDeg > 0 ? { hfovDeg: r.stereo.horizontalFovDeg } : {}),
114
+ }
115
+ : {}),
116
+ ...cal,
117
+ },
118
+ });
119
+
120
+ // An explicit device id.
121
+ if (typeof want === 'string' && !['auto', 'stereo', 'mono'].includes(want)) {
122
+ let r;
123
+ try {
124
+ r = await open({ deviceId: { exact: want }, ...PROBE_CONSTRAINTS });
125
+ } catch (err) {
126
+ skip(want, err);
127
+ throw Object.assign(new Error(`camera unavailable: ${err.message}`), { code: noCameraCode(skipped), skipped });
128
+ }
129
+ if (looksSbs(r.width, r.height)) return result(r, 'sbs');
130
+ stop(r);
131
+ return result(await open({ deviceId: { exact: want }, ...MONO_CONSTRAINTS }), 'mono');
132
+ }
133
+
134
+ // Fast path: when labels are already visible (camera permission persisted), open a device that
135
+ // NAMES itself stereo — e.g. the DisplayXR Browser's "3D Camera (DisplayXR)" — directly. Every
136
+ // camera start can take seconds (a busy capture stack took 7-20 s per start on a field box), and
137
+ // the default-first probe below costs two or three of them before it reaches the pair.
138
+ const tried = new Set();
139
+ if (want !== 'mono') {
140
+ let early = [];
141
+ try {
142
+ early = (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput' && d.deviceId && stereoLabelHint(d.label));
143
+ } catch {
144
+ /* no enumeration yet: the normal path below */
145
+ }
146
+ for (const d of early) {
147
+ tried.add(d.deviceId);
148
+ let r = null;
149
+ try {
150
+ r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
151
+ } catch (err) {
152
+ skip(d.label, err);
153
+ continue;
154
+ }
155
+ if (looksSbs(r.width, r.height)) {
156
+ log('camera-sbs', { label: r.label, width: r.width, height: r.height, direct: true });
157
+ return result(r, 'sbs');
158
+ }
159
+ stop(r);
160
+ }
161
+ }
162
+
163
+ // The default camera first: permission, labels, and the fallback.
164
+ let mono = null;
165
+ try {
166
+ mono = await open(MONO_CONSTRAINTS);
167
+ } catch (err) {
168
+ skip('default', err);
169
+ }
170
+ if (want === 'mono' && mono) return result(mono, 'mono');
171
+ if (mono && looksSbs(mono.width, mono.height)) return result(mono, 'sbs'); // the default IS the pair
172
+
173
+ let devices = [];
174
+ try {
175
+ devices = (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput');
176
+ } catch {
177
+ /* no enumeration: keep the default */
178
+ }
179
+ devices.sort((a, b) => Number(stereoLabelHint(b.label)) - Number(stereoLabelHint(a.label)));
180
+ for (const d of devices) {
181
+ if (!d.deviceId) continue; // no permission yet: an anonymous entry cannot be opened by id
182
+ if (mono && d.deviceId === mono.deviceId) continue;
183
+ if (tried.has(d.deviceId)) continue; // the fast path already opened it (not a pair, or busy)
184
+ let r = null;
185
+ try {
186
+ r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
187
+ } catch (err) {
188
+ // NotReadableError = held by another process (e.g. an eye tracker). Skip, never fail.
189
+ skip(d.label, err);
190
+ continue;
191
+ }
192
+ if (looksSbs(r.width, r.height)) {
193
+ stop(mono);
194
+ log('camera-sbs', { label: r.label, width: r.width, height: r.height });
195
+ return result(r, 'sbs');
196
+ }
197
+ stop(r);
198
+ }
199
+ if (mono) {
200
+ if (want === 'stereo') log('camera-no-stereo', { fallback: mono.label });
201
+ return result(mono, 'mono');
202
+ }
203
+ const code = noCameraCode(skipped);
204
+ throw Object.assign(
205
+ new Error(code === 'camera-busy' ? 'every camera is in use by another app (e.g. eye tracking)' : code === 'permission-denied' ? 'camera access was denied' : 'no camera could be opened'),
206
+ { code, skipped }
207
+ );
208
+ }
209
+
210
+ /** The microphone, with echo cancellation and noise suppression. Null if unavailable/denied. */
211
+ export async function openMic(o = {}) {
212
+ const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
213
+ try {
214
+ const s = await md.getUserMedia({ audio: { ...AUDIO_CONSTRAINTS }, video: false });
215
+ return s.getAudioTracks()[0] || null;
216
+ } catch (err) {
217
+ (o.log || (() => {}))('mic-skip', { error: err && err.name });
218
+ return null;
219
+ }
220
+ }
@@ -0,0 +1,82 @@
1
+ // camera/converge.js — the convergence state of a side-by-side tile (RFC 0002 §3): the per-eye
2
+ // shift that puts a subject at the display plane, from a MEASURED disparity (camera/disparity.js),
3
+ // a sender's hint (focal length · baseline / distance), and the page's depth offset, low-passed
4
+ // per painted frame. Shared by `/camera` (the self view, capture metadata) and `/call` (remote
5
+ // tiles). Pure — see test/call.test.mjs and test/call-autoconv.test.mjs.
6
+
7
+ /** Low-pass factor for the convergence shift (RFC §3): next = prev + α·(target − prev). */
8
+ export const CONVERGENCE_ALPHA = 0.2;
9
+
10
+ /** The convergence shift never exceeds this fraction of the per-eye width, either way. */
11
+ export const CONVERGENCE_MAX_FRACTION = 0.12;
12
+
13
+ /** `setDepth(v)`: v in [-1, 1] adds v times this fraction of the per-eye width to the shift. */
14
+ export const DEPTH_RANGE_FRACTION = 0.05;
15
+
16
+ /** Focal length in px of an eye image `eyeWidthPx` wide with horizontal FOV `hfovDeg`. */
17
+ export function focalPx(eyeWidthPx, hfovDeg) {
18
+ if (!(eyeWidthPx > 0) || !(hfovDeg > 0) || hfovDeg >= 180) return null;
19
+ return eyeWidthPx / 2 / Math.tan(((hfovDeg / 2) * Math.PI) / 180);
20
+ }
21
+
22
+ /**
23
+ * Per-eye horizontal shift, in SOURCE eye pixels, that puts a subject at `subjectZmm` on the
24
+ * display plane: `f_px · baseline / (2 · subjectZ)`. A parallel stereo camera gives everything a
25
+ * crossed disparity of `f·B/Z` (full, between the eyes); half of it comes off each eye.
26
+ *
27
+ * Positive = the left eye's crop moves RIGHT and the right eye's LEFT (content pushed back).
28
+ * Returns 0 when anything it needs is unknown — the receiver then shows the pair as sent.
29
+ */
30
+ export function convergenceShiftPx({ eyeWidthPx, hfovDeg, baselineMm, subjectZmm }) {
31
+ const f = focalPx(eyeWidthPx, hfovDeg);
32
+ if (f === null || !(baselineMm > 0) || !(subjectZmm > 0)) return 0;
33
+ return (f * baselineMm) / (2 * subjectZmm);
34
+ }
35
+
36
+ /** Clamp a shift to ±CONVERGENCE_MAX_FRACTION of the per-eye width. */
37
+ export function clampShift(px, eyeWidthPx, maxFraction = CONVERGENCE_MAX_FRACTION) {
38
+ const lim = Math.max(0, eyeWidthPx * maxFraction);
39
+ return Math.max(-lim, Math.min(lim, px || 0));
40
+ }
41
+
42
+ /** One low-pass step: `prev + α(target − prev)`. Snaps when within 0.05 px so it can settle. */
43
+ export function lowPass(prev, target, alpha = CONVERGENCE_ALPHA) {
44
+ const next = prev + alpha * (target - prev);
45
+ return Math.abs(target - next) < 0.05 ? target : next;
46
+ }
47
+
48
+ /**
49
+ * A tile's convergence state: the target from hello+hint, the page's depth offset, and the
50
+ * smoothed value actually painted. `step()` once per painted frame.
51
+ */
52
+ export function createConvergence() {
53
+ const s = {
54
+ hello: null,
55
+ subjectZmm: null,
56
+ // Auto-convergence (call/disparity.js): the MEASURED disparity of the point between the remote
57
+ // person's eyes, in source eye pixels (left x − right x). When set it wins over the hint: it is
58
+ // read off the frames, so it needs no calibration and cannot disagree with them.
59
+ measuredPx: null,
60
+ depth: 0, // setDepth(), [-1, 1]
61
+ current: 0,
62
+ target(eyeWidthPx) {
63
+ const auto =
64
+ s.measuredPx !== null
65
+ ? s.measuredPx / 2 // half the disparity comes off each eye: the eyes land at zero parallax
66
+ : s.hello && s.subjectZmm
67
+ ? convergenceShiftPx({
68
+ eyeWidthPx,
69
+ hfovDeg: s.hello.hfovDeg,
70
+ baselineMm: s.hello.baselineMm,
71
+ subjectZmm: s.subjectZmm,
72
+ })
73
+ : 0;
74
+ return clampShift(auto + s.depth * DEPTH_RANGE_FRACTION * eyeWidthPx, eyeWidthPx);
75
+ },
76
+ step(eyeWidthPx) {
77
+ s.current = lowPass(s.current, s.target(eyeWidthPx));
78
+ return s.current;
79
+ },
80
+ };
81
+ return s;
82
+ }