@displayxr/inline3d 1.49.0 → 1.49.2

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/js/call/share.js CHANGED
@@ -14,11 +14,21 @@
14
14
  // runtime asks the user on the SENDER's machine, every start ("Share <app> with <browser>?"), when
15
15
  // `openAppShare()` opens the device; a Deny rejects it (`code: 'share-denied'`). The track ends
16
16
  // (`ended`) when the app exits or the share is stopped from the runtime side.
17
+ //
18
+ // Browsers WITHOUT the app-share device patch (DisplayXR Browser 1.9.x) list an app through their
19
+ // stereo-camera factory as `3D Camera (DisplayXR) - <app>` — and, whenever the runtime lists more
20
+ // than one source, give the real camera the same shape. So when no ADR-form device exists,
21
+ // `listShareableApps()` returns those as CANDIDATES (`legacy: true`), and `openAppShare()` verifies
22
+ // the opened track (`isAppShareHint`: baseline 0 and FOV 0 is an app) — a real camera is closed
23
+ // again with `code: 'not-app-share'`.
17
24
 
18
- import { isAppShareDevice, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE } from '../camera/app-share.js';
25
+ import { isAppShareDevice, isAppShareHint, legacyAppCandidate, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE, APP_SHARE_LEGACY_LABEL_RE } from '../camera/app-share.js';
19
26
  import { sanitizeName, CONV_MAX_FRAC } from './wire.js';
20
27
 
21
- export { isAppShareDevice, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE };
28
+ export { isAppShareDevice, isAppShareHint, legacyAppCandidate, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE, APP_SHARE_LEGACY_LABEL_RE };
29
+
30
+ /** The app name a label carries: the ADR form, else a legacy candidate's `<name>`. */
31
+ const nameOfLabel = (label) => sanitizeName(appNameFromLabel(label)) || sanitizeName((legacyAppCandidate(label) || {}).appName) || null;
22
32
 
23
33
  /** What `openAppShare` asks for by default: the runtime's default stream (ADR-061 decision 5). */
24
34
  const SHARE_CONSTRAINTS = Object.freeze({ width: 2560, height: 720, frameRate: 30 });
@@ -29,6 +39,11 @@ const mdOf = (o) => (o && o.mediaDevices) || (globalThis.navigator && navigator.
29
39
  * The running apps this browser offers to share: `[{ deviceId, label, appName }]`, in the
30
40
  * browser's order. Empty without camera permission (the browser lists none before it), on any
31
41
  * other browser, or when nothing is shareable. Never rejects.
42
+ *
43
+ * On a browser without the app-share device patch (no ADR-form device listed), the entries are the
44
+ * `3D Camera (DisplayXR) - <name>` CANDIDATES instead, marked `legacy: true`. A real camera can be
45
+ * among them (that browser labels every source that way once there are two); `openAppShare()`
46
+ * tells them apart and rejects a camera with `not-app-share`.
32
47
  * @param {{ mediaDevices?: MediaDevices }} [o]
33
48
  */
34
49
  export async function listShareableApps(o = {}) {
@@ -40,9 +55,14 @@ export async function listShareableApps(o = {}) {
40
55
  } catch {
41
56
  return [];
42
57
  }
43
- return list
44
- .filter((d) => d && d.kind === 'videoinput' && isAppShareDevice(d))
58
+ const videos = list.filter((d) => d && d.kind === 'videoinput');
59
+ const apps = videos
60
+ .filter((d) => isAppShareDevice(d))
45
61
  .map((d) => Object.freeze({ deviceId: d.deviceId, label: d.label || '', appName: sanitizeName(appNameFromLabel(d.label)) || sanitizeName(d.label) || null }));
62
+ if (apps.length) return apps;
63
+ return videos
64
+ .filter((d) => d.deviceId && legacyAppCandidate(d))
65
+ .map((d) => Object.freeze({ deviceId: d.deviceId, label: d.label || '', appName: nameOfLabel(d.label), legacy: true }));
46
66
  }
47
67
 
48
68
  /**
@@ -61,7 +81,7 @@ export function watchShareableApps(cb, o = {}) {
61
81
  const mine = ++seq;
62
82
  const apps = await listShareableApps({ mediaDevices: md });
63
83
  if (!live || mine !== seq) return; // a newer read is in flight (devicechange bursts)
64
- const key = JSON.stringify(apps.map((a) => [a.deviceId, a.label]));
84
+ const key = JSON.stringify(apps.map((a) => [a.deviceId, a.label, !!a.legacy]));
65
85
  if (key === last) return;
66
86
  last = key;
67
87
  try {
@@ -97,7 +117,8 @@ export function describeAppShare(track, fallbackLabel = '') {
97
117
  const hint = st.displayxrStereo && typeof st.displayxrStereo === 'object' ? st.displayxrStereo : {};
98
118
  const z = hint.zeroDisparityShiftFrac;
99
119
  const frac = typeof z === 'number' && Number.isFinite(z) ? Math.max(-CONV_MAX_FRAC, Math.min(CONV_MAX_FRAC, z)) : 0;
100
- const appName = sanitizeName(hint.appName) || sanitizeName(appNameFromLabel(track && track.label)) || sanitizeName(appNameFromLabel(fallbackLabel)) || null;
120
+ // The hint's name, else the label's: the ADR form, or a legacy candidate's ` - <name>`.
121
+ const appName = sanitizeName(hint.appName) || nameOfLabel(track && track.label) || nameOfLabel(fallbackLabel) || null;
101
122
  return {
102
123
  kind: 'content',
103
124
  format: hint.layout && hint.layout !== 'side-by-side' ? 'mono' : 'sbs',
@@ -111,7 +132,9 @@ export function describeAppShare(track, fallbackLabel = '') {
111
132
  /**
112
133
  * Open one shareable app (a `deviceId` from `listShareableApps`) as a side-by-side track. This is
113
134
  * when the runtime asks the user on this machine; Deny rejects with `code: 'share-denied'`. A device
114
- * that turns out not to be an app share is closed again (`code: 'not-app-share'`). The track is
135
+ * that turns out not to be an app share is closed again (`code: 'not-app-share'`) — on a browser
136
+ * without the app-share device patch that is how a `legacy` candidate that is really a camera
137
+ * (its track's baseline is not 0) is told apart from an app. The track is
115
138
  * yours: stop it when you stop sharing (`track.stop()`), or the runtime keeps the share open.
116
139
  *
117
140
  * @param {string} deviceId
@@ -134,7 +157,16 @@ export async function openAppShare(deviceId, o = {}) {
134
157
  throw Object.assign(new Error(`could not share the app: ${(err && err.message) || err}`), { code: denied ? 'share-denied' : 'share-failed', cause: err });
135
158
  }
136
159
  const track = stream.getVideoTracks()[0] || null;
137
- if (!track || !(isAppShareDevice(track) || deviceId.startsWith(APP_SHARE_ID_PREFIX))) {
160
+ let hint = null;
161
+ try {
162
+ hint = track && typeof track.getSettings === 'function' ? (track.getSettings() || {}).displayxrStereo : null;
163
+ } catch {
164
+ hint = null;
165
+ }
166
+ // A track whose hint describes a camera (a `source` other than 'app', or a real baseline) is not
167
+ // an app, whatever its label or id; otherwise the label, the id or the hint must say "app".
168
+ const cameraHint = !!hint && typeof hint === 'object' && !isAppShareHint(hint) && (typeof hint.source === 'string' || hint.baselineMm > 0);
169
+ if (!track || cameraHint || !(isAppShareDevice(track) || deviceId.startsWith(APP_SHARE_ID_PREFIX))) {
138
170
  stream.getTracks().forEach((t) => t.stop());
139
171
  throw Object.assign(new Error('that device is not a shared DisplayXR app'), { code: 'not-app-share' });
140
172
  }
@@ -12,6 +12,16 @@
12
12
  // `dxr-app:`, and on the opened track `getSettings().displayxrStereo.source === 'app'` (plus
13
13
  // `appName`, `zeroDisparityShiftFrac`). Browsers that predate the hint fields still use the label
14
14
  // and the id, so any ONE of the three is enough.
15
+ //
16
+ // Browsers WITHOUT the app-share device patch (DisplayXR Browser 1.9.x) list a development-gated
17
+ // app source through their stereo-CAMERA device factory instead: label `3D Camera (DisplayXR) -
18
+ // <app>`, id `dxr-stereo:…`, no `source` in the hint. That label shape proves nothing — such a
19
+ // browser appends ` - <displayName>` to EVERY entry whenever the runtime lists more than one, so
20
+ // the real camera next to the app reads `3D Camera (DisplayXR) - <camera>` too. What does tell
21
+ // them apart is the opened track: an app source advertises `baselineMm: 0` and
22
+ // `horizontalFovDeg: 0` (runtime ADR-061 decision 2); a real camera never has baseline 0. So
23
+ // `isAppShareHint` checks the hint, and `legacyAppCandidate` only ever names a CANDIDATE, which
24
+ // must be opened and verified before anything treats it as an app.
15
25
 
16
26
  /** `deviceId` prefix of an app-share device (cameras use `dxr-stereo:`). */
17
27
  export const APP_SHARE_ID_PREFIX = 'dxr-app:';
@@ -19,6 +29,54 @@ export const APP_SHARE_ID_PREFIX = 'dxr-app:';
19
29
  /** The label of an app-share device; group 1 is the app's name. */
20
30
  export const APP_SHARE_LABEL_RE = /^DisplayXR: (.+) \(3D\)$/;
21
31
 
32
+ /**
33
+ * The label a browser without the app-share device patch gives EVERY runtime stereo source when
34
+ * there is more than one (cameras and apps alike); group 1 is the source's display name. A shape,
35
+ * not an identity: see legacyAppCandidate.
36
+ */
37
+ export const APP_SHARE_LEGACY_LABEL_RE = /^3D Camera \(DisplayXR\)\s+-\s+(.+)$/;
38
+
39
+ /**
40
+ * Does a track's `displayxrStereo` hint describe a running app rather than a camera? True when
41
+ * `source === 'app'`; with no `source` (browsers without the app-share device patch), when the
42
+ * layout is side-by-side (or unstated) and BOTH `baselineMm` and `horizontalFovDeg` are exactly 0
43
+ * — what an app source advertises (ADR-061 decision 2) and no real camera does. A `source` that is
44
+ * anything else says "not an app". Never throws. Pure.
45
+ * @param {unknown} hint
46
+ * @returns {boolean}
47
+ */
48
+ export function isAppShareHint(hint) {
49
+ if (!hint || typeof hint !== 'object') return false;
50
+ try {
51
+ const h = /** @type {any} */ (hint);
52
+ if (typeof h.source === 'string') return h.source === 'app';
53
+ if (h.layout != null && h.layout !== 'side-by-side') return false;
54
+ return h.baselineMm === 0 && h.horizontalFovDeg === 0;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * On a browser WITHOUT the app-share device patch: is this listed device a CANDIDATE app source
62
+ * (`3D Camera (DisplayXR) - <name>`)? Returns `{ appName }` or null. A candidate is verified only
63
+ * after opening (`isAppShareHint` on the track's settings) — the same label shape is given to real
64
+ * cameras whenever the runtime lists more than one source. Never throws. Pure.
65
+ * @param {unknown} device a `MediaDeviceInfo`, `{ label }`, or a label string
66
+ * @returns {{ appName: string } | null}
67
+ */
68
+ export function legacyAppCandidate(device) {
69
+ let label = '';
70
+ try {
71
+ label = typeof device === 'string' ? device : device && typeof device === 'object' ? /** @type {any} */ (device).label : '';
72
+ } catch {
73
+ return null;
74
+ }
75
+ const m = typeof label === 'string' ? APP_SHARE_LEGACY_LABEL_RE.exec(label.trim()) : null;
76
+ const appName = m ? m[1].trim() : '';
77
+ return appName ? { appName } : null;
78
+ }
79
+
22
80
  /** The app name an app-share label carries, or null. Pure. */
23
81
  export function appNameFromLabel(label) {
24
82
  const m = typeof label === 'string' ? APP_SHARE_LABEL_RE.exec(label.trim()) : null;
@@ -29,7 +87,9 @@ export function appNameFromLabel(label) {
29
87
  * Is this an app-share device rather than a camera? Takes a `MediaDeviceInfo` (or any
30
88
  * `{ label, deviceId }`), a label string, a track's settings (`{ displayxrStereo }`), or a
31
89
  * `MediaStreamTrack` (its label and settings). True on ANY of: the label shape, the `dxr-app:` id
32
- * prefix, or `displayxrStereo.source === 'app'`. Never throws. Pure.
90
+ * prefix, or a `displayxrStereo` hint that `isAppShareHint` accepts (`source: 'app'`, or baseline
91
+ * and FOV both 0). The legacy `3D Camera (DisplayXR) - <name>` label alone is NOT enough (cameras
92
+ * get it too). Never throws. Pure.
33
93
  * @param {unknown} x
34
94
  * @returns {boolean}
35
95
  */
@@ -42,7 +102,7 @@ export function isAppShareDevice(x) {
42
102
  if (typeof o.label === 'string' && APP_SHARE_LABEL_RE.test(o.label.trim())) return true;
43
103
  if (typeof o.deviceId === 'string' && o.deviceId.startsWith(APP_SHARE_ID_PREFIX)) return true;
44
104
  const hint = o.displayxrStereo || (typeof o.getSettings === 'function' ? (o.getSettings() || {}).displayxrStereo : null);
45
- if (hint && typeof hint === 'object' && hint.source === 'app') return true;
105
+ if (isAppShareHint(hint)) return true;
46
106
  if (typeof o.getSettings === 'function') {
47
107
  const id = (o.getSettings() || {}).deviceId;
48
108
  if (typeof id === 'string' && id.startsWith(APP_SHARE_ID_PREFIX)) return true;
@@ -29,6 +29,11 @@
29
29
  // It is a pair, it names itself "3D", and it is NOT a camera: no probe path opens one, whatever
30
30
  // `exclude` says (`exclude: false` included). Each is reported in `skipped` as `excluded` with
31
31
  // `reason: 'app-share'`. `/call`'s `listShareableApps()` / `openAppShare()` are how a page uses them.
32
+ // A browser WITHOUT the app-share device patch lists such an app as a stereo camera instead
33
+ // ("3D Camera (DisplayXR) - <app>", no `source` in the hint) and gives the REAL camera the same
34
+ // label shape whenever there is more than one source — so the label cannot decide. Those are
35
+ // opened like any named pair, and the opened track's hint decides: baseline 0 and FOV 0 is an
36
+ // app (ADR-061 decision 2) → closed again, `reason: 'app-share'`, probing goes on.
32
37
  //
33
38
  // RECTIFICATION is not done here (P2): it needs the camera's calibration (intrinsics/extrinsics
34
39
  // for the ACTIVE device), which comes from a plug-in or the runtime. P1 sends the raw pair with
@@ -36,7 +41,7 @@
36
41
  // `(stream, info) => MediaStream | Promise<MediaStream>`, returning a rectified SBS stream.
37
42
 
38
43
  import { looksSbs } from './geometry.js';
39
- import { isAppShareDevice } from './app-share.js';
44
+ import { isAppShareDevice, isAppShareHint } from './app-share.js';
40
45
 
41
46
  const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } };
42
47
  // Ask a candidate stereo device for its widest mode; a 2560x720 or 3840x1080 pair comes back as
@@ -44,6 +49,47 @@ const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frame
44
49
  const PROBE_CONSTRAINTS = { width: { ideal: 3840 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } };
45
50
  export const AUDIO_CONSTRAINTS = Object.freeze({ echoCancellation: true, noiseSuppression: true, autoGainControl: true });
46
51
 
52
+ // How long `'auto'` / `'stereo'` waits, in a DisplayXR Browser with labels visible, for the
53
+ // runtime's 3D camera to be listed before it opens any plain lens (see openCamera), and how often
54
+ // it re-reads the list meanwhile (a `devicechange` re-reads at once).
55
+ export const STEREO_WAIT_MS = 2500;
56
+ const STEREO_WAIT_STEP_MS = 500;
57
+
58
+ /**
59
+ * The default `stereoWaitMs`: STEREO_WAIT_MS in a DisplayXR Browser (the same test as
60
+ * inline3DAvailable(): a runtime 3D camera can be listed there), else 0. Pure apart from globals.
61
+ */
62
+ export function defaultStereoWaitMs() {
63
+ return inDisplayXRBrowser() ? STEREO_WAIT_MS : 0;
64
+ }
65
+
66
+ /** The same test as inline3DAvailable() (navigator.xr + XRDisplayLayer), without importing the core. */
67
+ function inDisplayXRBrowser() {
68
+ return typeof navigator !== 'undefined' && !!navigator.xr && typeof window !== 'undefined' && typeof window.XRDisplayLayer === 'function';
69
+ }
70
+
71
+ /** Resolves on the next `devicechange` of `md`, or after `ms`, whichever is first. */
72
+ function nextDeviceChange(md, ms) {
73
+ return new Promise((resolve) => {
74
+ let timer = 0;
75
+ const done = () => {
76
+ clearTimeout(timer);
77
+ try {
78
+ md.removeEventListener?.('devicechange', done);
79
+ } catch {
80
+ /* no events */
81
+ }
82
+ resolve();
83
+ };
84
+ timer = setTimeout(done, ms);
85
+ try {
86
+ md.addEventListener?.('devicechange', done);
87
+ } catch {
88
+ /* no events: the timer alone */
89
+ }
90
+ });
91
+ }
92
+
47
93
  /** Errors that mean "the device exists but another process holds it" (an eye tracker, say). */
48
94
  export function isBusyError(err) {
49
95
  return !!err && (err.name === 'NotReadableError' || err.name === 'TrackStartError' || /in use|busy/i.test(err.message || ''));
@@ -124,9 +170,12 @@ export function stereoLabelHint(label) {
124
170
  * @param {'auto'|'stereo'|'mono'|string|MediaStream} want 'auto' | 'stereo' | 'mono' | a
125
171
  * deviceId | a page-supplied MediaStream
126
172
  * @param {{ format?: 'sbs'|'mono', calibration?: {baselineMm?: number, hfovDeg?: number, rectified?: boolean},
127
- * mediaDevices?: MediaDevices, log?: Function, exclude?: false|Array<string|RegExp> }} [o]
173
+ * mediaDevices?: MediaDevices, log?: Function, exclude?: false|Array<string|RegExp>,
174
+ * stereoWaitMs?: number }} [o]
128
175
  * `exclude`: devices no probe may open (see cameraExcluder); an explicit deviceId is the
129
176
  * page's own choice and is opened regardless.
177
+ * `stereoWaitMs`: with labels visible and no stereo-named device listed, how long to wait
178
+ * for one before opening a plain lens (default defaultStereoWaitMs(); 0 = one re-read).
130
179
  * @returns {Promise<{ stream: MediaStream, format: 'sbs'|'mono', width: number, height: number,
131
180
  * deviceId: string|null, label: string, owned: boolean, skipped: Array<{label: string, error: string}>,
132
181
  * calibration: object }>}
@@ -160,8 +209,11 @@ export async function openCamera(want = 'auto', o = {}) {
160
209
  // eye-tracking camera shared without stealing it) describes itself on the opened track:
161
210
  // `displayxrStereo: {layout, rectified, baselineMm, horizontalFovDeg}`.
162
211
  const stereo = st.displayxrStereo && st.displayxrStereo.layout === 'side-by-side' ? st.displayxrStereo : null;
163
- // A running app's views (3D screen share, ADR-061) describe themselves too: `source: 'app'`.
164
- const appShare = isAppShareDevice({ label: t.label || '', deviceId: st.deviceId || '' }) || !!(st.displayxrStereo && st.displayxrStereo.source === 'app');
212
+ // A running app's views (3D screen share, ADR-061) describe themselves too: `source: 'app'`,
213
+ // or — on a browser without the app-share device patch, which lists the app as a "3D Camera
214
+ // (DisplayXR) - <app>" — baseline 0 and FOV 0, which no real camera advertises. Every probe
215
+ // path checks this on the OPENED track (rejectOpened), since the label cannot tell them apart.
216
+ const appShare = isAppShareDevice({ label: t.label || '', deviceId: st.deviceId || '' }) || isAppShareHint(st.displayxrStereo);
165
217
  return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo, appShare };
166
218
  };
167
219
  const skip = (label, err) => {
@@ -312,16 +364,38 @@ export async function openCamera(want = 'auto', o = {}) {
312
364
  }
313
365
  return null;
314
366
  };
367
+ // Re-read the list until a stereo-named device opens or `ms` is up: on each `devicechange` and
368
+ // every STEREO_WAIT_STEP_MS. `ms` <= 0: one re-read. Stops early once a named pair was listed and
369
+ // failed to open (busy): waiting longer would not free it.
370
+ const waitMs = Number.isFinite(o.stereoWaitMs) ? Math.max(0, o.stereoWaitMs) : defaultStereoWaitMs();
371
+ const waitForNamedPair = async (ms) => {
372
+ if (!(ms > 0)) return tryNamedPairs(await listCameras());
373
+ const deadline = Date.now() + ms;
374
+ log('camera-wait-stereo', { ms });
375
+ for (;;) {
376
+ const left = deadline - Date.now();
377
+ if (left <= 0) break;
378
+ await nextDeviceChange(md, Math.min(STEREO_WAIT_STEP_MS, left));
379
+ const list = await listCameras();
380
+ const r = await tryNamedPairs(list);
381
+ if (r) return r;
382
+ if (list.some((d) => stereoLabelHint(d.label) && !isApp(d))) break; // listed, tried, not usable
383
+ }
384
+ log('camera-no-stereo-listed', { ms });
385
+ return null;
386
+ };
315
387
  let mono = null;
316
388
  if (want !== 'mono') {
317
389
  let listed = await listCameras();
318
390
  let pair = await tryNamedPairs(listed);
319
391
  if (pair) return result(pair, 'sbs');
320
392
  // Labels visible but no stereo-named device: a runtime 3D camera can join the list late (after
321
- // a runtime restart), so read it once more before falling back.
393
+ // a runtime restart; on Android the runtime lists its cameras lazily). In a DisplayXR Browser
394
+ // wait for it BEFORE any plain lens is opened (stereoWaitMs): on some phones opening a physical
395
+ // lens while head tracking streams crashes the camera HAL, and the runtime's pair goes down with
396
+ // it. Elsewhere read the list once more.
322
397
  if (listed.some((d) => d.label) && !listed.some((d) => stereoLabelHint(d.label))) {
323
- listed = await listCameras();
324
- pair = await tryNamedPairs(listed);
398
+ pair = await waitForNamedPair(waitMs);
325
399
  if (pair) return result(pair, 'sbs');
326
400
  }
327
401
  // No labels yet (no permission). Prime it on the default camera with NO constraints (a facing
@@ -384,9 +458,14 @@ export async function openCamera(want = 'auto', o = {}) {
384
458
  }
385
459
  // A label that names a pair first; never by list position (see the header).
386
460
  devices.sort((a, b) => Number(stereoLabelHint(b.label)) - Number(stereoLabelHint(a.label)));
461
+ // In a DisplayXR Browser a stereo camera (a USB SBS webcam included) is the runtime's named
462
+ // "3D Camera", so once a plain lens is in hand no other plain lens is opened to test its shape:
463
+ // on some phones that extra open crashes the camera HAL.
464
+ const shapeProbe = !inDisplayXRBrowser();
387
465
  for (const d of devices) {
388
466
  if (!d.deviceId) continue; // no permission yet: an anonymous entry cannot be opened by id
389
467
  if (mono && d.deviceId === mono.deviceId) continue;
468
+ if (mono && !shapeProbe && !stereoLabelHint(d.label)) continue;
390
469
  if (tried.has(d.deviceId) || excludedIds.has(d.deviceId)) continue; // already opened (not a pair, or busy) or left out
391
470
  if (listedExcluded(d)) continue; // the last resort never opens a sensor (a hand-tracking camera)
392
471
  let r = null;
@@ -406,7 +485,7 @@ export async function openCamera(want = 'auto', o = {}) {
406
485
  // No default could be used (excluded, busy): the first plain camera found is the fallback.
407
486
  if (!mono && !isPair(r)) {
408
487
  mono = await withMode(r, MONO_CONSTRAINTS);
409
- if (want === 'mono') return result(mono, 'mono');
488
+ if (want === 'mono' || !shapeProbe) return result(mono, 'mono');
410
489
  continue;
411
490
  }
412
491
  stop(r);
@@ -441,7 +441,11 @@ class StereoCamera {
441
441
  * are in `skipped` with `excluded: true`. An explicit `deviceId` or `label:` is the page's choice
442
442
  * and is opened regardless.
443
443
  *
444
- * @param {object|string|MediaStream} [opts] `{ prefer, format, calibration, rectify, exclude, debug }` (or just `prefer`)
444
+ * In a DisplayXR Browser with labels visible and no 3D camera listed yet, `'auto'` / `'stereo'`
445
+ * waits up to `stereoWaitMs` (default 2500) for the runtime's 3D camera to be listed before it
446
+ * opens any plain lens (docs/camera.md, "Waiting for the 3D camera").
447
+ *
448
+ * @param {object|string|MediaStream} [opts] `{ prefer, format, calibration, rectify, exclude, stereoWaitMs, debug }` (or just `prefer`)
445
449
  */
446
450
  export async function openCamera(opts = {}) {
447
451
  const o = typeof opts === 'string' || (opts && typeof opts.getVideoTracks === 'function') ? { prefer: opts } : opts || {};
@@ -451,7 +455,7 @@ export async function openCamera(opts = {}) {
451
455
  // `horizontalFovDeg` is the public spelling (RFC 0003 §4); the hello wire field is `hfovDeg`.
452
456
  if (cal.horizontalFovDeg > 0 && !(cal.hfovDeg > 0)) cal.hfovDeg = cal.horizontalFovDeg;
453
457
  delete cal.horizontalFovDeg;
454
- const r = await openDevice(prefer, { format: o.format, calibration: cal, mediaDevices: o.mediaDevices, log: log || undefined, exclude: o.exclude });
458
+ const r = await openDevice(prefer, { format: o.format, calibration: cal, mediaDevices: o.mediaDevices, log: log || undefined, exclude: o.exclude, stereoWaitMs: o.stereoWaitMs });
455
459
  if (r.format === 'sbs' && typeof o.rectify === 'function') {
456
460
  // A calibrated rectification step (plug-in or runtime supplied): its output is a rectified
457
461
  // SBS stream, and the record then says so. A throwing hook keeps the raw pair.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.49.0",
3
+ "version": "1.49.2",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",