@displayxr/inline3d 1.49.1 → 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.
@@ -49,6 +49,47 @@ const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frame
49
49
  const PROBE_CONSTRAINTS = { width: { ideal: 3840 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } };
50
50
  export const AUDIO_CONSTRAINTS = Object.freeze({ echoCancellation: true, noiseSuppression: true, autoGainControl: true });
51
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
+
52
93
  /** Errors that mean "the device exists but another process holds it" (an eye tracker, say). */
53
94
  export function isBusyError(err) {
54
95
  return !!err && (err.name === 'NotReadableError' || err.name === 'TrackStartError' || /in use|busy/i.test(err.message || ''));
@@ -129,9 +170,12 @@ export function stereoLabelHint(label) {
129
170
  * @param {'auto'|'stereo'|'mono'|string|MediaStream} want 'auto' | 'stereo' | 'mono' | a
130
171
  * deviceId | a page-supplied MediaStream
131
172
  * @param {{ format?: 'sbs'|'mono', calibration?: {baselineMm?: number, hfovDeg?: number, rectified?: boolean},
132
- * mediaDevices?: MediaDevices, log?: Function, exclude?: false|Array<string|RegExp> }} [o]
173
+ * mediaDevices?: MediaDevices, log?: Function, exclude?: false|Array<string|RegExp>,
174
+ * stereoWaitMs?: number }} [o]
133
175
  * `exclude`: devices no probe may open (see cameraExcluder); an explicit deviceId is the
134
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).
135
179
  * @returns {Promise<{ stream: MediaStream, format: 'sbs'|'mono', width: number, height: number,
136
180
  * deviceId: string|null, label: string, owned: boolean, skipped: Array<{label: string, error: string}>,
137
181
  * calibration: object }>}
@@ -320,16 +364,38 @@ export async function openCamera(want = 'auto', o = {}) {
320
364
  }
321
365
  return null;
322
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
+ };
323
387
  let mono = null;
324
388
  if (want !== 'mono') {
325
389
  let listed = await listCameras();
326
390
  let pair = await tryNamedPairs(listed);
327
391
  if (pair) return result(pair, 'sbs');
328
392
  // Labels visible but no stereo-named device: a runtime 3D camera can join the list late (after
329
- // 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.
330
397
  if (listed.some((d) => d.label) && !listed.some((d) => stereoLabelHint(d.label))) {
331
- listed = await listCameras();
332
- pair = await tryNamedPairs(listed);
398
+ pair = await waitForNamedPair(waitMs);
333
399
  if (pair) return result(pair, 'sbs');
334
400
  }
335
401
  // No labels yet (no permission). Prime it on the default camera with NO constraints (a facing
@@ -392,9 +458,14 @@ export async function openCamera(want = 'auto', o = {}) {
392
458
  }
393
459
  // A label that names a pair first; never by list position (see the header).
394
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();
395
465
  for (const d of devices) {
396
466
  if (!d.deviceId) continue; // no permission yet: an anonymous entry cannot be opened by id
397
467
  if (mono && d.deviceId === mono.deviceId) continue;
468
+ if (mono && !shapeProbe && !stereoLabelHint(d.label)) continue;
398
469
  if (tried.has(d.deviceId) || excludedIds.has(d.deviceId)) continue; // already opened (not a pair, or busy) or left out
399
470
  if (listedExcluded(d)) continue; // the last resort never opens a sensor (a hand-tracking camera)
400
471
  let r = null;
@@ -414,7 +485,7 @@ export async function openCamera(want = 'auto', o = {}) {
414
485
  // No default could be used (excluded, busy): the first plain camera found is the fallback.
415
486
  if (!mono && !isPair(r)) {
416
487
  mono = await withMode(r, MONO_CONSTRAINTS);
417
- if (want === 'mono') return result(mono, 'mono');
488
+ if (want === 'mono' || !shapeProbe) return result(mono, 'mono');
418
489
  continue;
419
490
  }
420
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.1",
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",