@displayxr/inline3d 1.29.0 → 1.31.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.
- package/CHANGELOG.md +187 -0
- package/README.md +9 -5
- package/call.d.ts +318 -238
- package/camera.d.ts +257 -0
- package/dist/call.js +17 -12
- package/index.d.ts +56 -0
- package/js/call/capture.js +4 -212
- package/js/call/disparity.js +4 -432
- package/js/call/element.js +14 -10
- package/js/call/options.js +132 -0
- package/js/call/signaling.js +66 -9
- package/js/call/ui.js +106 -9
- package/js/call/wire.js +8 -153
- package/js/camera/capture.js +220 -0
- package/js/camera/converge.js +82 -0
- package/js/camera/disparity.js +432 -0
- package/js/camera/geometry.js +80 -0
- package/js/camera/metadata.js +398 -0
- package/js/inline3d-call.js +435 -320
- package/js/inline3d-camera.js +678 -0
- package/js/inline3d-model-playcanvas.js +1 -0
- package/js/inline3d-model.js +1 -0
- package/js/inline3d-splat-deferred.js +153 -0
- package/js/inline3d-splat-pc-entry.js +45 -0
- package/js/inline3d-splat-playcanvas.js +14 -3
- package/js/inline3d-splat.js +3 -145
- package/js/inline3d-viewer-ease.js +360 -0
- package/js/inline3d-viewer.js +15 -4
- package/js/inline3d.js +13 -0
- package/model.d.ts +8 -1
- package/package.json +22 -2
- package/splat-playcanvas.d.ts +4 -0
- package/splat.d.ts +8 -1
- package/viewer.d.ts +9 -0
package/index.d.ts
CHANGED
|
@@ -555,7 +555,63 @@ export interface CreateInline3DOptions {
|
|
|
555
555
|
* `trackingstatechange` and do the same for a scene.
|
|
556
556
|
*/
|
|
557
557
|
untrackedFallback?: 'none' | 'mono';
|
|
558
|
+
/**
|
|
559
|
+
* The TRACKING EASE, as the default for every SDK renderer drawing through this session
|
|
560
|
+
* (`./viewer`'s SceneViewer, `./splat` and `./model` on both engines). When a viewer is
|
|
561
|
+
* acquired — the views jump from the runtime's nominal viewer to the tracked eyes — or lost,
|
|
562
|
+
* the eye cameras glide from where they were drawn to the new views over `durationMs` instead
|
|
563
|
+
* of snapping in one frame; everything parented to the views (parallax, display-rig layers)
|
|
564
|
+
* follows. Triggered by a jump in the views around a `trackingState` edge (or, where the browser
|
|
565
|
+
* reports no state, by a large one-frame jump); a no-op when the vendor already animates the eyes. On by default; `false` restores
|
|
566
|
+
* the snap; a renderer's own `viewerEase` option overrides this. A page rendering its own
|
|
567
|
+
* `addScene` can use the exported {@link ViewerEase}.
|
|
568
|
+
*/
|
|
569
|
+
viewerEase?: ViewerEaseOption;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** `true`/unset = on with defaults, `false` = off, or tune it. */
|
|
573
|
+
export type ViewerEaseOption =
|
|
574
|
+
| boolean
|
|
575
|
+
| {
|
|
576
|
+
/** Default true. */
|
|
577
|
+
enabled?: boolean;
|
|
578
|
+
/** The glide, in ms (default 300). 0 disables. */
|
|
579
|
+
durationMs?: number;
|
|
580
|
+
/** 'smoothstep' (default) | 'linear' | 'easeOutCubic'. */
|
|
581
|
+
easing?: 'smoothstep' | 'linear' | 'easeOutCubic' | string;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/** One frame's matrices as an SDK renderer copies them out of an XRView (column-major). */
|
|
585
|
+
export interface ViewerEaseEntry {
|
|
586
|
+
proj: Float32Array | Float64Array | number[];
|
|
587
|
+
pose: Float32Array | Float64Array | number[];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The tracking ease on its own, for a page that renders its own `addScene`: copy each view's
|
|
592
|
+
* `projectionMatrix` and `transform.matrix` into an entry, call `apply(entries,
|
|
593
|
+
* frame.session.trackingState)` once per LIVE frame, and render from the entries (keep using the
|
|
594
|
+
* real XRView for `layer.getViewport`). It rewrites the entries only while an ease is running.
|
|
595
|
+
*/
|
|
596
|
+
export declare class ViewerEase {
|
|
597
|
+
constructor(opts?: ViewerEaseOption & object);
|
|
598
|
+
readonly enabled: boolean;
|
|
599
|
+
readonly durationMs: number;
|
|
600
|
+
readonly easing: string;
|
|
601
|
+
/** Is an ease running right now? */
|
|
602
|
+
readonly active: boolean;
|
|
603
|
+
/** What the last apply() did: the weight of the old views (0 = passed through), the trigger, the jump (eye separations). */
|
|
604
|
+
readonly last: { weight: number; reason: null | 'armed-jump' | 'jump' | 'easing'; jump: number };
|
|
605
|
+
apply(entries: ViewerEaseEntry[], trackingState?: XRTrackingState | string | null, timeMs?: number): number;
|
|
606
|
+
configure(opts?: ViewerEaseOption): void;
|
|
607
|
+
/** Forget the previous frame (call after a resize or a view-count change you handle yourself). */
|
|
608
|
+
reset(): void;
|
|
558
609
|
}
|
|
610
|
+
export function resolveViewerEaseOption(opt?: ViewerEaseOption): { enabled: boolean; durationMs: number; easing: string };
|
|
611
|
+
/** `frame.session.trackingState`, or null when the browser does not report one. */
|
|
612
|
+
export function frameTrackingState(frame: unknown): XRTrackingState | null;
|
|
613
|
+
/** 300. */
|
|
614
|
+
export const VIEWER_EASE_DEFAULT_MS: number;
|
|
559
615
|
|
|
560
616
|
/**
|
|
561
617
|
* The eased 2D<->3D transition — on by default, and the same sequencer (and the same defaults)
|
package/js/call/capture.js
CHANGED
|
@@ -1,212 +1,4 @@
|
|
|
1
|
-
// call/capture.js —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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 './wire.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
|
-
/**
|
|
34
|
-
* Why no camera could be opened, from the per-device failures. `'camera-busy'` when every device
|
|
35
|
-
* that exists is held by another process — on a 3D laptop/monitor whose only camera is the eye
|
|
36
|
-
* tracker's, that is the normal case while tracking, and the call should go audio-only and say so
|
|
37
|
-
* rather than fail or send an empty picture. Pure.
|
|
38
|
-
* @param {Array<{error: string, busy?: boolean}>} skipped
|
|
39
|
-
*/
|
|
40
|
-
export function noCameraCode(skipped) {
|
|
41
|
-
return skipped.length && skipped.every((s) => s.busy) ? 'camera-busy' : 'no-camera';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A label that names a stereo camera, tried first when probing. Pure. */
|
|
45
|
-
export function stereoLabelHint(label) {
|
|
46
|
-
return /stereo|\b3d\b|\bsbs\b|dual/i.test(label || '');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Open the camera. Never rejects for a busy optional device; rejects only when NO camera can be
|
|
51
|
-
* opened at all (the caller then runs audio-only or reports the error).
|
|
52
|
-
*
|
|
53
|
-
* @param {'auto'|'stereo'|'mono'|string|MediaStream} want 'auto' | 'stereo' | 'mono' | a
|
|
54
|
-
* deviceId | a page-supplied MediaStream
|
|
55
|
-
* @param {{ format?: 'sbs'|'mono', calibration?: {baselineMm?: number, hfovDeg?: number, rectified?: boolean},
|
|
56
|
-
* mediaDevices?: MediaDevices, log?: Function }} [o]
|
|
57
|
-
* @returns {Promise<{ stream: MediaStream, format: 'sbs'|'mono', width: number, height: number,
|
|
58
|
-
* deviceId: string|null, label: string, owned: boolean, skipped: Array<{label: string, error: string}>,
|
|
59
|
-
* calibration: object }>}
|
|
60
|
-
*/
|
|
61
|
-
export async function openCamera(want = 'auto', o = {}) {
|
|
62
|
-
const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
|
|
63
|
-
const log = o.log || (() => {});
|
|
64
|
-
const skipped = [];
|
|
65
|
-
const cal = o.calibration || {};
|
|
66
|
-
|
|
67
|
-
if (want && typeof want === 'object' && typeof want.getVideoTracks === 'function') {
|
|
68
|
-
const t = want.getVideoTracks()[0];
|
|
69
|
-
const st = t ? t.getSettings() : {};
|
|
70
|
-
// A page-supplied stream carries a DECLARED format; aspect is never used to guess 3D-ness here.
|
|
71
|
-
const format = o.format === 'sbs' ? 'sbs' : 'mono';
|
|
72
|
-
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 };
|
|
73
|
-
}
|
|
74
|
-
if (!md || !md.getUserMedia) throw Object.assign(new Error('getUserMedia is not available (insecure context?)'), { code: 'no-camera' });
|
|
75
|
-
|
|
76
|
-
const open = async (constraints) => {
|
|
77
|
-
const s = await md.getUserMedia({ video: constraints, audio: false });
|
|
78
|
-
const t = s.getVideoTracks()[0];
|
|
79
|
-
const st = t ? t.getSettings() : {};
|
|
80
|
-
// Never hand back a 0x0 track: some engines open a device that then delivers nothing.
|
|
81
|
-
if (!t || t.readyState === 'ended' || !(st.width > 0) || !(st.height > 0)) {
|
|
82
|
-
s.getTracks().forEach((x) => x.stop());
|
|
83
|
-
throw Object.assign(new Error('the camera opened but delivers no picture (0x0)'), { name: 'NotReadableError' });
|
|
84
|
-
}
|
|
85
|
-
// The DisplayXR Browser's "3D Camera (DisplayXR)" (the runtime's stereo camera, e.g. an SR
|
|
86
|
-
// eye-tracking camera shared without stealing it) describes itself on the opened track:
|
|
87
|
-
// `displayxrStereo: {layout, rectified, baselineMm, horizontalFovDeg}`.
|
|
88
|
-
const stereo = st.displayxrStereo && st.displayxrStereo.layout === 'side-by-side' ? st.displayxrStereo : null;
|
|
89
|
-
return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo };
|
|
90
|
-
};
|
|
91
|
-
const skip = (label, err) => {
|
|
92
|
-
skipped.push({ label, error: `${err.name}: ${err.message}`, busy: isBusyError(err) });
|
|
93
|
-
log('camera-skip', { label, error: err.name });
|
|
94
|
-
};
|
|
95
|
-
const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
|
|
96
|
-
const result = (r, format) => ({
|
|
97
|
-
stream: r.stream, format, width: r.width, height: r.height, deviceId: r.deviceId, label: r.label, owned: true, skipped,
|
|
98
|
-
calibration: {
|
|
99
|
-
rectified: false,
|
|
100
|
-
// A runtime stereo camera's own description, where the page gave none.
|
|
101
|
-
...(format === 'sbs' && r.stereo
|
|
102
|
-
? {
|
|
103
|
-
rectified: !!r.stereo.rectified,
|
|
104
|
-
...(r.stereo.baselineMm > 0 ? { baselineMm: r.stereo.baselineMm } : {}),
|
|
105
|
-
...(r.stereo.horizontalFovDeg > 0 ? { hfovDeg: r.stereo.horizontalFovDeg } : {}),
|
|
106
|
-
}
|
|
107
|
-
: {}),
|
|
108
|
-
...cal,
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// An explicit device id.
|
|
113
|
-
if (typeof want === 'string' && !['auto', 'stereo', 'mono'].includes(want)) {
|
|
114
|
-
let r;
|
|
115
|
-
try {
|
|
116
|
-
r = await open({ deviceId: { exact: want }, ...PROBE_CONSTRAINTS });
|
|
117
|
-
} catch (err) {
|
|
118
|
-
skip(want, err);
|
|
119
|
-
throw Object.assign(new Error(`camera unavailable: ${err.message}`), { code: noCameraCode(skipped), skipped });
|
|
120
|
-
}
|
|
121
|
-
if (looksSbs(r.width, r.height)) return result(r, 'sbs');
|
|
122
|
-
stop(r);
|
|
123
|
-
return result(await open({ deviceId: { exact: want }, ...MONO_CONSTRAINTS }), 'mono');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Fast path: when labels are already visible (camera permission persisted), open a device that
|
|
127
|
-
// NAMES itself stereo — e.g. the DisplayXR Browser's "3D Camera (DisplayXR)" — directly. Every
|
|
128
|
-
// camera start can take seconds (a busy capture stack took 7-20 s per start on a field box), and
|
|
129
|
-
// the default-first probe below costs two or three of them before it reaches the pair.
|
|
130
|
-
const tried = new Set();
|
|
131
|
-
if (want !== 'mono') {
|
|
132
|
-
let early = [];
|
|
133
|
-
try {
|
|
134
|
-
early = (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput' && d.deviceId && stereoLabelHint(d.label));
|
|
135
|
-
} catch {
|
|
136
|
-
/* no enumeration yet: the normal path below */
|
|
137
|
-
}
|
|
138
|
-
for (const d of early) {
|
|
139
|
-
tried.add(d.deviceId);
|
|
140
|
-
let r = null;
|
|
141
|
-
try {
|
|
142
|
-
r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
|
|
143
|
-
} catch (err) {
|
|
144
|
-
skip(d.label, err);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (looksSbs(r.width, r.height)) {
|
|
148
|
-
log('camera-sbs', { label: r.label, width: r.width, height: r.height, direct: true });
|
|
149
|
-
return result(r, 'sbs');
|
|
150
|
-
}
|
|
151
|
-
stop(r);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// The default camera first: permission, labels, and the fallback.
|
|
156
|
-
let mono = null;
|
|
157
|
-
try {
|
|
158
|
-
mono = await open(MONO_CONSTRAINTS);
|
|
159
|
-
} catch (err) {
|
|
160
|
-
skip('default', err);
|
|
161
|
-
}
|
|
162
|
-
if (want === 'mono' && mono) return result(mono, 'mono');
|
|
163
|
-
if (mono && looksSbs(mono.width, mono.height)) return result(mono, 'sbs'); // the default IS the pair
|
|
164
|
-
|
|
165
|
-
let devices = [];
|
|
166
|
-
try {
|
|
167
|
-
devices = (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput');
|
|
168
|
-
} catch {
|
|
169
|
-
/* no enumeration: keep the default */
|
|
170
|
-
}
|
|
171
|
-
devices.sort((a, b) => Number(stereoLabelHint(b.label)) - Number(stereoLabelHint(a.label)));
|
|
172
|
-
for (const d of devices) {
|
|
173
|
-
if (!d.deviceId) continue; // no permission yet: an anonymous entry cannot be opened by id
|
|
174
|
-
if (mono && d.deviceId === mono.deviceId) continue;
|
|
175
|
-
if (tried.has(d.deviceId)) continue; // the fast path already opened it (not a pair, or busy)
|
|
176
|
-
let r = null;
|
|
177
|
-
try {
|
|
178
|
-
r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
|
|
179
|
-
} catch (err) {
|
|
180
|
-
// NotReadableError = held by another process (e.g. an eye tracker). Skip, never fail.
|
|
181
|
-
skip(d.label, err);
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
if (looksSbs(r.width, r.height)) {
|
|
185
|
-
stop(mono);
|
|
186
|
-
log('camera-sbs', { label: r.label, width: r.width, height: r.height });
|
|
187
|
-
return result(r, 'sbs');
|
|
188
|
-
}
|
|
189
|
-
stop(r);
|
|
190
|
-
}
|
|
191
|
-
if (mono) {
|
|
192
|
-
if (want === 'stereo') log('camera-no-stereo', { fallback: mono.label });
|
|
193
|
-
return result(mono, 'mono');
|
|
194
|
-
}
|
|
195
|
-
const code = noCameraCode(skipped);
|
|
196
|
-
throw Object.assign(
|
|
197
|
-
new Error(code === 'camera-busy' ? 'every camera is in use by another app (e.g. eye tracking)' : 'no camera could be opened'),
|
|
198
|
-
{ code, skipped }
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** The microphone, with echo cancellation and noise suppression. Null if unavailable/denied. */
|
|
203
|
-
export async function openMic(o = {}) {
|
|
204
|
-
const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
|
|
205
|
-
try {
|
|
206
|
-
const s = await md.getUserMedia({ audio: { ...AUDIO_CONSTRAINTS }, video: false });
|
|
207
|
-
return s.getAudioTracks()[0] || null;
|
|
208
|
-
} catch (err) {
|
|
209
|
-
(o.log || (() => {}))('mic-skip', { error: err && err.name });
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
1
|
+
// call/capture.js — moved to js/camera/capture.js in C2 (RFC 0003 §4: device selection,
|
|
2
|
+
// calibration and the busy-device skip belong to `@displayxr/inline3d/camera`). This path
|
|
3
|
+
// re-exports it so test imports and any in-tree reference keep resolving.
|
|
4
|
+
export * from '../camera/capture.js';
|