@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/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)
@@ -1,212 +1,4 @@
1
- // call/capture.js — getting a camera (+ microphone) and knowing what it is (RFC §4).
2
- //
3
- // `camera: 'auto'` looks for a device that delivers a side-by-side PAIR — one device whose frame
4
- // is wider than 2.5:1 (a USB stereo camera enumerates as one wide device: field data has one at
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';