@displayxr/inline3d 1.49.1 → 1.49.3

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);
@@ -2565,7 +2565,7 @@ class SelfTile {
2565
2565
 
2566
2566
  /** For `call.diagnostics().self`: what the self view actually shows. */
2567
2567
  diag() {
2568
- if (!this.view) return { route: null, woven: false, reason: 'no-camera', firstWoven: null, layerRetries: 0 };
2568
+ if (!this.view) return { route: null, woven: false, reason: 'no-camera', firstWoven: null, layerRetries: 0, frameAgeMs: null };
2569
2569
  return this.view.weaveState();
2570
2570
  }
2571
2571
 
@@ -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.
@@ -560,6 +564,8 @@ class CameraView {
560
564
  this._scratch = null;
561
565
  this._luma = null; // the latest grayscale source copy (auto-convergence and the depth cursor read it)
562
566
  this.disparityPx = null;
567
+ this._lastFrameAt = 0; // performance.now() of the last NEW camera frame (0 = none yet)
568
+ this._lastVideoTime = -1;
563
569
  // `cursor: 'depth'` (ADR-046 on a pair): validated now, its module loaded only on opt-in.
564
570
  this._cursorOpt = resolvePairCursorOption(o.cursor, '@displayxr/inline3d/camera');
565
571
  /** The depth cursor once its module has loaded; null without `cursor: 'depth'`. */
@@ -594,9 +600,28 @@ class CameraView {
594
600
  reason: this.fallbackReason,
595
601
  firstWoven: this.route === 'woven-sbs' ? (this._fw ? { ...this._fw } : 'pending') : null,
596
602
  layerRetries: this._layerFails,
603
+ frameAgeMs: this.frameAgeMs,
597
604
  };
598
605
  }
599
606
 
607
+ /**
608
+ * Milliseconds since the camera last delivered a NEW frame to this view (its video's
609
+ * `currentTime` moved), or null before the first one. A view whose track stalls (a camera taken
610
+ * by another app, a suspended capture) keeps painting its last frame; this is how a page tells.
611
+ */
612
+ get frameAgeMs() {
613
+ return this._lastFrameAt ? Math.round(performance.now() - this._lastFrameAt) : null;
614
+ }
615
+
616
+ /** Note a new camera frame (the video's clock moved since the last paint). */
617
+ _noteFrame(v) {
618
+ if ((v.readyState || 0) < 2) return;
619
+ const t = v.currentTime;
620
+ if (t === this._lastVideoTime) return;
621
+ this._lastVideoTime = t;
622
+ this._lastFrameAt = performance.now();
623
+ }
624
+
600
625
  /** The per-eye convergence shift currently painted, source px. */
601
626
  get convergencePx() {
602
627
  return this.conv.current;
@@ -654,7 +679,14 @@ class CameraView {
654
679
  }
655
680
 
656
681
  _registerWoven() {
657
- if (!this.buffer) this.buffer = document.createElement('canvas');
682
+ if (!this.buffer) {
683
+ // Empty until the camera's first frame is painted into it: the wall does not count an empty
684
+ // buffer as a stereo frame, so firstWoven cannot settle "woven" over a tile that never
685
+ // showed the camera (a track that delivers no frames).
686
+ this.buffer = document.createElement('canvas');
687
+ this.buffer.width = 0;
688
+ this.buffer.height = 0;
689
+ }
658
690
  this._paintWoven(); // the first frame exists before the layer does
659
691
  const handle = this.rectCover === undefined ? this.wall.addImage(this.canvas, this.buffer) : this.wall.addImage(this.canvas, this.buffer, { rectCover: this.rectCover });
660
692
  this.handle = handle;
@@ -733,6 +765,7 @@ class CameraView {
733
765
  if (this.removed) return;
734
766
  const v = this.cam.video;
735
767
  if (!v || this.cam.state === 'closed') return;
768
+ this._noteFrame(v);
736
769
  if (this.route === 'woven-sbs') this._paintWoven();
737
770
  else this._paintFlat(v);
738
771
  }
package/js/inline3d.js CHANGED
@@ -3345,7 +3345,10 @@ class Inline3D {
3345
3345
  this._paint(win, views);
3346
3346
  // An SBS paint with a real source behind it. Before the image has loaded (or while a
3347
3347
  // video has never had a frame) the tile holds nothing worth revealing yet.
3348
- if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : !!win.img)) {
3348
+ // A canvas source counts once it has pixels: an empty (0x0) buffer is a page that has not
3349
+ // drawn its first frame yet (a camera view before the camera's first frame), and settling
3350
+ // firstWoven on it reported a tile woven that had never shown anything.
3351
+ if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : sourceHasPixels(win.img))) {
3349
3352
  win.fwStereo = true;
3350
3353
  win.lfStereo = true;
3351
3354
  if (win.rw) win.rw.stereo = true;
@@ -3867,6 +3870,18 @@ function firstWovenHold(v) {
3867
3870
  return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : FIRST_WOVEN_HOLD_MS;
3868
3871
  }
3869
3872
 
3873
+ /**
3874
+ * True when an image source has something to draw: a loaded image, a sized canvas or bitmap. A
3875
+ * source that reports no size at all (a test double) keeps the old rule: present = drawable.
3876
+ */
3877
+ function sourceHasPixels(src) {
3878
+ if (!src) return false;
3879
+ const w = src.naturalWidth ?? src.videoWidth ?? src.width;
3880
+ const h = src.naturalHeight ?? src.videoHeight ?? src.height;
3881
+ if (w === undefined && h === undefined) return true;
3882
+ return w > 0 && h > 0;
3883
+ }
3884
+
3870
3885
  function loadImage(source) {
3871
3886
  if (typeof source !== 'string') return Promise.resolve(source); // element/bitmap/canvas
3872
3887
  return new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.49.1",
3
+ "version": "1.49.3",
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",