@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.
- package/CHANGELOG.md +43 -0
- package/camera.d.ts +12 -0
- package/dist/call.js +6 -6
- package/js/camera/capture.js +76 -5
- package/js/inline3d-call.js +1 -1
- package/js/inline3d-camera.js +36 -3
- package/js/inline3d.js +16 -1
- package/package.json +1 -1
package/js/camera/capture.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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);
|
package/js/inline3d-call.js
CHANGED
|
@@ -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
|
|
package/js/inline3d-camera.js
CHANGED
|
@@ -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
|
-
*
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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",
|