@displayxr/inline3d 1.49.2 → 1.50.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/hands.d.ts ADDED
@@ -0,0 +1,198 @@
1
+ // Type definitions for @displayxr/inline3d/hands.
2
+ // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
+ //
4
+ // Tracked hands on an inline-3D session (WebXR Hand Input, DisplayXR Browser 1.8.0+ on Windows
5
+ // with a hand provider), and the pinch gestures that drive a viewer's orbit from them. Inert
6
+ // everywhere else: `watchHands` reports no hands and `handControls` moves nothing.
7
+
8
+ import type { Inline3D, Inline3DUnsupported } from './index.js';
9
+
10
+ /** The 25 WebXR hand joint names, in `XRHand` iteration order. */
11
+ export const HAND_JOINTS: ReadonlyArray<string>;
12
+
13
+ /** Gesture tuning. Metres in the joints' frame (the rig frame), radians. */
14
+ export interface HandGestureConfig {
15
+ /** Thumb-tip to index-tip distance that starts a pinch, for a hand of `referencePalmM` (default 0.025). */
16
+ pinchEnterM: number;
17
+ /** Distance that ends it, > enter (default 0.04). */
18
+ pinchExitM: number;
19
+ /**
20
+ * Palm length every distance here is stated for; a sample's own palm length scales them (pinch
21
+ * thresholds, orbit gain, separation guard), so a scaled hand behaves the same. 0 = never scale
22
+ * (default 0.095).
23
+ */
24
+ referencePalmM: number;
25
+ /** Radians of orbit per metre of pinch-point drag, for a reference-size hand (default 10). */
26
+ orbitRadPerM: number;
27
+ /** EMA time constant on the pinch point, seconds; 0 = off (default 0.05). */
28
+ smoothTauS: number;
29
+ /** Per-frame zoom ratio clamp, both ways (default 1.25). */
30
+ maxZoomStep: number;
31
+ /** Two-hand gestures are ignored while the hands are further apart than this (default 1.2). */
32
+ maxHandSeparationM: number;
33
+ }
34
+ export const HAND_GESTURE_DEFAULTS: Readonly<HandGestureConfig>;
35
+
36
+ /** Two-threshold pinch state. */
37
+ export declare class PinchDetector {
38
+ readonly pinched: boolean;
39
+ reset(): void;
40
+ /** `tracked: false` releases regardless of distance. Returns the new state. */
41
+ update(tracked: boolean, tipDistance: number, enter: number, exit: number): boolean;
42
+ }
43
+
44
+ /** One hand's input to the gesture controller. Positions `[x, y, z]`, metres. */
45
+ export interface HandGestureSample {
46
+ tracked?: boolean;
47
+ thumbTip: ArrayLike<number>;
48
+ indexTip: ArrayLike<number>;
49
+ /** Wrist → middle knuckle, metres; 0 / absent = unknown (the metre thresholds apply as given). */
50
+ palmLength?: number;
51
+ }
52
+
53
+ /**
54
+ * The pose delta for one frame, in the SUBJECT's convention (what `SceneViewer.nudgePose` takes):
55
+ * yaw / pitch / roll in radians to ADD, zoom a ratio to MULTIPLY. A pinch dragged right → yaw +;
56
+ * dragged up → pitch −; two hands turning counter-clockwise → roll +; spreading → zoom > 1.
57
+ */
58
+ export interface HandGestureDelta {
59
+ yaw: number;
60
+ pitch: number;
61
+ roll: number;
62
+ zoom: number;
63
+ /** Exactly one hand pinched this frame. */
64
+ oneHand: boolean;
65
+ /** Both hands pinched this frame. */
66
+ twoHand: boolean;
67
+ any: boolean;
68
+ }
69
+
70
+ /**
71
+ * Pinch gestures → pose deltas, per frame. Pure arithmetic (no DOM, no WebXR): a port of the
72
+ * DisplayXR Model Viewer's controls. Hand-relative pinch (thresholds scale by palm length), one
73
+ * pinched hand orbits, two pinched hands zoom (separation ratio), roll (twist in the display
74
+ * plane) and orbit (midpoint drag). The first frame of a gesture anchors; a hand gained or lost
75
+ * re-anchors, so a mode change never jumps.
76
+ */
77
+ export declare class HandGestureController {
78
+ constructor(config?: Partial<HandGestureConfig>);
79
+ config: HandGestureConfig;
80
+ update(frame: {
81
+ /** Seconds, any origin (drives the EMA). */
82
+ time?: number;
83
+ left?: HandGestureSample | null;
84
+ right?: HandGestureSample | null;
85
+ /** Multiplies every position first (default 1). */
86
+ lengthScale?: number;
87
+ }): HandGestureDelta;
88
+ /** Pinch state after the last update: `0` / `'left'`, `1` / `'right'`. */
89
+ pinched(hand: 0 | 1 | 'left' | 'right'): boolean;
90
+ /** Last thumb-index tip distance, metres; < 0 when untracked. */
91
+ tipDistance(hand: 0 | 1 | 'left' | 'right'): number;
92
+ /** Forget every anchor. */
93
+ reset(): void;
94
+ }
95
+
96
+ /** Can this document deliver hand sources at all (`XRHand` present)? */
97
+ export function handInputAvailable(): boolean;
98
+
99
+ /**
100
+ * The browser's per-frame hand gate: `'available'`, a withheld reason (`'camera-rig'`,
101
+ * `'posed-rig'`, `'rig-factors'`, `'no-canvas'`, `'not-fullscreen'`, `'scaled-rig'`), or
102
+ * `'unknown'` where the session has no such surface.
103
+ */
104
+ export function handGate(session: XRSession | null | undefined): string;
105
+
106
+ /** One tracked hand as `watchHands` reports it. Positions `[x, y, z]` metres in the watch's reference space. */
107
+ export interface HandSample {
108
+ readonly tracked: boolean;
109
+ readonly handedness: 'left' | 'right' | 'none';
110
+ /** 25 × 16 column-major joint transforms (`XRFrame.fillPoses`). */
111
+ readonly poses: Float32Array;
112
+ /** 25 joint radii, metres. */
113
+ readonly radii: Float32Array;
114
+ /** Position of a joint by WebXR name, or null for an unknown name. */
115
+ joint(name: string): number[] | null;
116
+ readonly wrist: number[];
117
+ readonly thumbTip: number[];
118
+ readonly indexTip: number[];
119
+ /** Wrist → middle knuckle, metres. */
120
+ readonly palmLength: number;
121
+ readonly source: XRInputSource;
122
+ }
123
+
124
+ export interface HandsFrame {
125
+ /** Seconds (the session frame time / 1000). */
126
+ time: number;
127
+ /** As `handGate()`. */
128
+ gate: string;
129
+ left: HandSample | null;
130
+ right: HandSample | null;
131
+ /** Hands tracked this frame. */
132
+ hands: number;
133
+ frame: XRFrame;
134
+ }
135
+
136
+ export interface HandsWatch {
137
+ stop(): void;
138
+ readonly running: boolean;
139
+ readonly state: { gate: string; hands: number; supported: boolean };
140
+ }
141
+
142
+ export interface WatchHandsOptions {
143
+ /** Joints are located in this space (default: the wall's `refSpace`). */
144
+ referenceSpace?: XRReferenceSpace;
145
+ /** `inputsourceschange`, hand sources only. */
146
+ onChange?: (e: { added: XRInputSource[]; removed: XRInputSource[] }) => void;
147
+ }
148
+
149
+ /**
150
+ * Watch the hands on an inline-3D session: `onFrame` every session frame with the tracked hands
151
+ * (or none). Runs its own `session.requestAnimationFrame` loop beside the SDK's; stops itself
152
+ * when the session ends.
153
+ */
154
+ export function watchHands(
155
+ wall: Inline3D | Inline3DUnsupported | null | undefined,
156
+ onFrame: (f: HandsFrame) => void,
157
+ opts?: WatchHandsOptions,
158
+ ): HandsWatch;
159
+
160
+ export interface HandControlOptions extends WatchHandsOptions {
161
+ /** Controller tuning. */
162
+ gestures?: Partial<HandGestureConfig>;
163
+ /** Multiplies every hand position before the gesture math (default 1). */
164
+ lengthScale?: number;
165
+ /** Apply the two-hand roll where the viewer has a roll axis (default true). */
166
+ roll?: boolean;
167
+ /** Apply the two-hand zoom (default true). */
168
+ zoom?: boolean;
169
+ /** After each frame: the hands frame and the gesture delta. */
170
+ onFrame?: (f: HandsFrame, d: HandGestureDelta) => void;
171
+ }
172
+
173
+ /** What `handControls` returns, and what `handle.hands` is on an `addModel` / `addSplat` handle. */
174
+ export interface HandControls {
175
+ stop(): void;
176
+ readonly running: boolean;
177
+ readonly controller: HandGestureController;
178
+ readonly state: { gate: string; hands: number; supported: boolean };
179
+ }
180
+
181
+ /** Anything with the viewer pose surface: a SceneViewer, the PlayCanvas viewer, or a handle whose `viewer` is one. */
182
+ export interface HandControlTarget {
183
+ nudgePose?(delta: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
184
+ getPose?(opts?: { target?: boolean }): { yaw: number; pitch: number; zoom: number; roll?: number };
185
+ setPose?(pose: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
186
+ readonly viewer?: HandControlTarget | null;
187
+ }
188
+
189
+ /**
190
+ * Drive a viewer's orbit from the hands: one pinch dragged turns it, two pinches spread / twist
191
+ * / dragged zoom / roll / turn it. Prefers `viewer.nudgePose()` (eased, counts as input for the
192
+ * idle turntable), falls back to `getPose` + `setPose`. Pointer and wheel keep working.
193
+ */
194
+ export function handControls(
195
+ wall: Inline3D | Inline3DUnsupported | null | undefined,
196
+ target: HandControlTarget,
197
+ opts?: HandControlOptions,
198
+ ): HandControls;
@@ -41,6 +41,7 @@
41
41
  // `(stream, info) => MediaStream | Promise<MediaStream>`, returning a rectified SBS stream.
42
42
 
43
43
  import { looksSbs } from './geometry.js';
44
+ import { isHalfSbsCamera, unsqueezeHalfSbs } from './half-sbs.js';
44
45
  import { isAppShareDevice, isAppShareHint } from './app-share.js';
45
46
 
46
47
  const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } };
@@ -213,6 +214,14 @@ export async function openCamera(want = 'auto', o = {}) {
213
214
  // or — on a browser without the app-share device patch, which lists the app as a "3D Camera
214
215
  // (DisplayXR) - <app>" — baseline 0 and FOV 0, which no real camera advertises. Every probe
215
216
  // path checks this on the OPENED track (rejectOpened), since the label cannot tell them apart.
217
+ // A half-SBS webcam: send true side-by-side eyes, whichever path opened it. 'mono' stays raw.
218
+ if (want !== 'mono' && isHalfSbsCamera(t.label)) {
219
+ const pair = await unsqueezeHalfSbs(t);
220
+ if (pair) {
221
+ log('camera-half-sbs', { label: t.label, width: pair.width, height: pair.height });
222
+ return { ...pair, deviceId: st.deviceId || null, label: t.label, stereo: null, appShare: false };
223
+ }
224
+ }
216
225
  const appShare = isAppShareDevice({ label: t.label || '', deviceId: st.deviceId || '' }) || isAppShareHint(st.displayxrStereo);
217
226
  return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo, appShare };
218
227
  };
@@ -0,0 +1,83 @@
1
+ // camera/half-sbs.js — USB stereo webcams that send a half-width side-by-side frame: one 16:9
2
+ // image whose left and right halves are each an eye squeezed 2:1 in width.
3
+ //
4
+ // Shape cannot identify these: a 3840x2160 half-SBS frame looks exactly like a 4K webcam's, so they
5
+ // are listed by USB id. Asking one for a wide mode does not help either: the browser reaches
6
+ // 3840x1080 by cropping the middle rows of 3840x2160, which still reads as a pair but leaves each
7
+ // eye 2x too tall with half the vertical view. So the camera is read in its native mode and every
8
+ // frame is redrawn, eyes un-squeezed, into a true side-by-side track.
9
+
10
+ /** USB `vvvv:pppp` ids of cameras that send half-SBS (the browser's label ends with the id). */
11
+ export const HALF_SBS_USB_IDS = Object.freeze(['0733:011a']);
12
+
13
+ /** The widest eye sent: the runtime's own output for such a camera (2560x720 side by side). */
14
+ const MAX_EYE_WIDTH = 1280;
15
+ const NATIVE_CONSTRAINTS = { width: { ideal: 3840 }, height: { ideal: 2160 }, frameRate: { ideal: 30 }, resizeMode: 'none' };
16
+
17
+ /** Does this device label name a half-SBS camera? */
18
+ export function isHalfSbsCamera(label) {
19
+ const l = String(label || '').toLowerCase();
20
+ return HALF_SBS_USB_IDS.some((id) => l.includes(`(${id})`));
21
+ }
22
+
23
+ /** The full-SBS frame size for a half-SBS source: square-pixel eyes, no upscaling, even numbers. */
24
+ export function unsqueezedSize(width, height) {
25
+ const eyeW = Math.min(MAX_EYE_WIDTH, width);
26
+ const eyeH = Math.max(2, Math.round((eyeW * height) / width / 2) * 2);
27
+ return { width: eyeW * 2, height: eyeH };
28
+ }
29
+
30
+ /**
31
+ * Put `source` in its native mode and return a side-by-side track with the eyes un-squeezed, or
32
+ * null when that cannot be done here (the engine has no MediaStreamTrackProcessor or the camera
33
+ * refuses a 16:9 mode); the source is then left open as it is. Stopping the returned track stops
34
+ * the source.
35
+ *
36
+ * @param {MediaStreamTrack} source
37
+ * @returns {Promise<{stream: MediaStream, track: MediaStreamTrack, width: number, height: number}|null>}
38
+ */
39
+ export async function unsqueezeHalfSbs(source) {
40
+ if (typeof MediaStreamTrackProcessor !== 'function' || typeof document === 'undefined') return null;
41
+ try {
42
+ await source.applyConstraints(NATIVE_CONSTRAINTS);
43
+ } catch {
44
+ return null;
45
+ }
46
+ const st = source.getSettings();
47
+ if (!(st.width > 0) || !(st.height > 0) || Math.abs(st.width / st.height - 16 / 9) > 0.01) return null;
48
+
49
+ const { width, height } = unsqueezedSize(st.width, st.height);
50
+ const canvas = document.createElement('canvas');
51
+ canvas.width = width;
52
+ canvas.height = height;
53
+ const ctx = canvas.getContext('2d', { alpha: false });
54
+ if (!ctx) return null;
55
+ const stream = canvas.captureStream(st.frameRate > 0 ? st.frameRate : 30);
56
+ const track = stream.getVideoTracks()[0];
57
+ const reader = new MediaStreamTrackProcessor({ track: source }).readable.getReader();
58
+
59
+ let stopped = false;
60
+ const stopTrack = track.stop.bind(track);
61
+ track.stop = () => {
62
+ stopped = true;
63
+ reader.cancel().catch(() => {});
64
+ source.stop();
65
+ stopTrack();
66
+ };
67
+ (async () => {
68
+ for (;;) {
69
+ const { value: frame, done } = await reader.read();
70
+ if (done) break;
71
+ ctx.drawImage(frame, 0, 0, width, height);
72
+ frame.close();
73
+ }
74
+ })()
75
+ .catch(() => {})
76
+ .finally(() => {
77
+ if (stopped) return;
78
+ // The camera went away: this track ends with it, like the device's own would.
79
+ track.stop();
80
+ track.dispatchEvent(new Event('ended'));
81
+ });
82
+ return { stream, track, width, height };
83
+ }
@@ -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
 
@@ -564,6 +564,8 @@ class CameraView {
564
564
  this._scratch = null;
565
565
  this._luma = null; // the latest grayscale source copy (auto-convergence and the depth cursor read it)
566
566
  this.disparityPx = null;
567
+ this._lastFrameAt = 0; // performance.now() of the last NEW camera frame (0 = none yet)
568
+ this._lastVideoTime = -1;
567
569
  // `cursor: 'depth'` (ADR-046 on a pair): validated now, its module loaded only on opt-in.
568
570
  this._cursorOpt = resolvePairCursorOption(o.cursor, '@displayxr/inline3d/camera');
569
571
  /** The depth cursor once its module has loaded; null without `cursor: 'depth'`. */
@@ -598,9 +600,28 @@ class CameraView {
598
600
  reason: this.fallbackReason,
599
601
  firstWoven: this.route === 'woven-sbs' ? (this._fw ? { ...this._fw } : 'pending') : null,
600
602
  layerRetries: this._layerFails,
603
+ frameAgeMs: this.frameAgeMs,
601
604
  };
602
605
  }
603
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
+
604
625
  /** The per-eye convergence shift currently painted, source px. */
605
626
  get convergencePx() {
606
627
  return this.conv.current;
@@ -658,7 +679,14 @@ class CameraView {
658
679
  }
659
680
 
660
681
  _registerWoven() {
661
- 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
+ }
662
690
  this._paintWoven(); // the first frame exists before the layer does
663
691
  const handle = this.rectCover === undefined ? this.wall.addImage(this.canvas, this.buffer) : this.wall.addImage(this.canvas, this.buffer, { rectCover: this.rectCover });
664
692
  this.handle = handle;
@@ -737,6 +765,7 @@ class CameraView {
737
765
  if (this.removed) return;
738
766
  const v = this.cam.video;
739
767
  if (!v || this.cam.state === 'closed') return;
768
+ this._noteFrame(v);
740
769
  if (this.route === 'woven-sbs') this._paintWoven();
741
770
  else this._paintFlat(v);
742
771
  }