@displayxr/inline3d 1.48.0 → 1.49.1

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.
@@ -0,0 +1,114 @@
1
+ // camera/app-share.js — recognising a "3D screen share" device: a running DisplayXR app's
2
+ // pre-weave side-by-side views, which the DisplayXR Browser lists as an ordinary `videoinput`
3
+ // (runtime ADR-061, `XR_DXR_app_share`). Pure, no DOM.
4
+ //
5
+ // It lives under /camera, not /call, because the camera probe must know it too: such a device is
6
+ // NOT a camera. Its label ("DisplayXR: <app> (3D)") matches stereoLabelHint's `\b3d\b`, so without
7
+ // this rule `camera: 'auto'` would open somebody's running app and send it as their face. Every
8
+ // probe path in capture.js leaves it out, whatever `exclude` says. `/call`'s share.js builds the
9
+ // discovery API on top of it.
10
+ //
11
+ // The device contract (ADR-061 decision 13): label `DisplayXR: <app> (3D)`, device id prefix
12
+ // `dxr-app:`, and on the opened track `getSettings().displayxrStereo.source === 'app'` (plus
13
+ // `appName`, `zeroDisparityShiftFrac`). Browsers that predate the hint fields still use the label
14
+ // and the id, so any ONE of the three is enough.
15
+ //
16
+ // Browsers WITHOUT the app-share device patch (DisplayXR Browser 1.9.x) list a development-gated
17
+ // app source through their stereo-CAMERA device factory instead: label `3D Camera (DisplayXR) -
18
+ // <app>`, id `dxr-stereo:…`, no `source` in the hint. That label shape proves nothing — such a
19
+ // browser appends ` - <displayName>` to EVERY entry whenever the runtime lists more than one, so
20
+ // the real camera next to the app reads `3D Camera (DisplayXR) - <camera>` too. What does tell
21
+ // them apart is the opened track: an app source advertises `baselineMm: 0` and
22
+ // `horizontalFovDeg: 0` (runtime ADR-061 decision 2); a real camera never has baseline 0. So
23
+ // `isAppShareHint` checks the hint, and `legacyAppCandidate` only ever names a CANDIDATE, which
24
+ // must be opened and verified before anything treats it as an app.
25
+
26
+ /** `deviceId` prefix of an app-share device (cameras use `dxr-stereo:`). */
27
+ export const APP_SHARE_ID_PREFIX = 'dxr-app:';
28
+
29
+ /** The label of an app-share device; group 1 is the app's name. */
30
+ export const APP_SHARE_LABEL_RE = /^DisplayXR: (.+) \(3D\)$/;
31
+
32
+ /**
33
+ * The label a browser without the app-share device patch gives EVERY runtime stereo source when
34
+ * there is more than one (cameras and apps alike); group 1 is the source's display name. A shape,
35
+ * not an identity: see legacyAppCandidate.
36
+ */
37
+ export const APP_SHARE_LEGACY_LABEL_RE = /^3D Camera \(DisplayXR\)\s+-\s+(.+)$/;
38
+
39
+ /**
40
+ * Does a track's `displayxrStereo` hint describe a running app rather than a camera? True when
41
+ * `source === 'app'`; with no `source` (browsers without the app-share device patch), when the
42
+ * layout is side-by-side (or unstated) and BOTH `baselineMm` and `horizontalFovDeg` are exactly 0
43
+ * — what an app source advertises (ADR-061 decision 2) and no real camera does. A `source` that is
44
+ * anything else says "not an app". Never throws. Pure.
45
+ * @param {unknown} hint
46
+ * @returns {boolean}
47
+ */
48
+ export function isAppShareHint(hint) {
49
+ if (!hint || typeof hint !== 'object') return false;
50
+ try {
51
+ const h = /** @type {any} */ (hint);
52
+ if (typeof h.source === 'string') return h.source === 'app';
53
+ if (h.layout != null && h.layout !== 'side-by-side') return false;
54
+ return h.baselineMm === 0 && h.horizontalFovDeg === 0;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * On a browser WITHOUT the app-share device patch: is this listed device a CANDIDATE app source
62
+ * (`3D Camera (DisplayXR) - <name>`)? Returns `{ appName }` or null. A candidate is verified only
63
+ * after opening (`isAppShareHint` on the track's settings) — the same label shape is given to real
64
+ * cameras whenever the runtime lists more than one source. Never throws. Pure.
65
+ * @param {unknown} device a `MediaDeviceInfo`, `{ label }`, or a label string
66
+ * @returns {{ appName: string } | null}
67
+ */
68
+ export function legacyAppCandidate(device) {
69
+ let label = '';
70
+ try {
71
+ label = typeof device === 'string' ? device : device && typeof device === 'object' ? /** @type {any} */ (device).label : '';
72
+ } catch {
73
+ return null;
74
+ }
75
+ const m = typeof label === 'string' ? APP_SHARE_LEGACY_LABEL_RE.exec(label.trim()) : null;
76
+ const appName = m ? m[1].trim() : '';
77
+ return appName ? { appName } : null;
78
+ }
79
+
80
+ /** The app name an app-share label carries, or null. Pure. */
81
+ export function appNameFromLabel(label) {
82
+ const m = typeof label === 'string' ? APP_SHARE_LABEL_RE.exec(label.trim()) : null;
83
+ return m ? m[1].trim() || null : null;
84
+ }
85
+
86
+ /**
87
+ * Is this an app-share device rather than a camera? Takes a `MediaDeviceInfo` (or any
88
+ * `{ label, deviceId }`), a label string, a track's settings (`{ displayxrStereo }`), or a
89
+ * `MediaStreamTrack` (its label and settings). True on ANY of: the label shape, the `dxr-app:` id
90
+ * prefix, or a `displayxrStereo` hint that `isAppShareHint` accepts (`source: 'app'`, or baseline
91
+ * and FOV both 0). The legacy `3D Camera (DisplayXR) - <name>` label alone is NOT enough (cameras
92
+ * get it too). Never throws. Pure.
93
+ * @param {unknown} x
94
+ * @returns {boolean}
95
+ */
96
+ export function isAppShareDevice(x) {
97
+ if (!x) return false;
98
+ if (typeof x === 'string') return APP_SHARE_LABEL_RE.test(x.trim()) || x.startsWith(APP_SHARE_ID_PREFIX);
99
+ if (typeof x !== 'object') return false;
100
+ const o = /** @type {any} */ (x);
101
+ try {
102
+ if (typeof o.label === 'string' && APP_SHARE_LABEL_RE.test(o.label.trim())) return true;
103
+ if (typeof o.deviceId === 'string' && o.deviceId.startsWith(APP_SHARE_ID_PREFIX)) return true;
104
+ const hint = o.displayxrStereo || (typeof o.getSettings === 'function' ? (o.getSettings() || {}).displayxrStereo : null);
105
+ if (isAppShareHint(hint)) return true;
106
+ if (typeof o.getSettings === 'function') {
107
+ const id = (o.getSettings() || {}).deviceId;
108
+ if (typeof id === 'string' && id.startsWith(APP_SHARE_ID_PREFIX)) return true;
109
+ }
110
+ } catch {
111
+ /* a hostile getter: not an app */
112
+ }
113
+ return false;
114
+ }
@@ -24,6 +24,16 @@
24
24
  // description or by its label (stereoLabelHint), and after the prime the list is read again and
25
25
  // a named pair is opened by id BEFORE a default that is a pair only by its shape is kept. The
26
26
  // primed default is the fallback.
27
+ // - A DisplayXR Browser also lists every shareable running DisplayXR APP as a side-by-side
28
+ // `videoinput` ("DisplayXR: <app> (3D)", id `dxr-app:…`, runtime ADR-061) — 3D screen share.
29
+ // It is a pair, it names itself "3D", and it is NOT a camera: no probe path opens one, whatever
30
+ // `exclude` says (`exclude: false` included). Each is reported in `skipped` as `excluded` with
31
+ // `reason: 'app-share'`. `/call`'s `listShareableApps()` / `openAppShare()` are how a page uses them.
32
+ // A browser WITHOUT the app-share device patch lists such an app as a stereo camera instead
33
+ // ("3D Camera (DisplayXR) - <app>", no `source` in the hint) and gives the REAL camera the same
34
+ // label shape whenever there is more than one source — so the label cannot decide. Those are
35
+ // opened like any named pair, and the opened track's hint decides: baseline 0 and FOV 0 is an
36
+ // app (ADR-061 decision 2) → closed again, `reason: 'app-share'`, probing goes on.
27
37
  //
28
38
  // RECTIFICATION is not done here (P2): it needs the camera's calibration (intrinsics/extrinsics
29
39
  // for the ACTIVE device), which comes from a plug-in or the runtime. P1 sends the raw pair with
@@ -31,6 +41,7 @@
31
41
  // `(stream, info) => MediaStream | Promise<MediaStream>`, returning a rectified SBS stream.
32
42
 
33
43
  import { looksSbs } from './geometry.js';
44
+ import { isAppShareDevice, isAppShareHint } from './app-share.js';
34
45
 
35
46
  const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } };
36
47
  // Ask a candidate stereo device for its widest mode; a 2560x720 or 3840x1080 pair comes back as
@@ -69,21 +80,28 @@ export function noCameraCode(skipped) {
69
80
  * tracker's or a "tracking camera"'s IR stream, a depth camera. Generic words only. Applied on
70
81
  * every probe path unless `exclude: false`.
71
82
  */
72
- export const CAMERA_EXCLUDE_BUILTIN = /\b(hand[- ]?track|tracking camera|eye[- ]?track|depth camera)/i;
83
+ // Generic words, plus the one hand tracker that enumerates under its bare brand name: on Windows
84
+ // an Ultraleap (Leap 2 / Leap Motion Controller) is a UVC "Ultraleap", nothing else in the label —
85
+ // the generic words alone let it through (win box, 2026-11-04: it was the browser's default camera).
86
+ export const CAMERA_EXCLUDE_BUILTIN = /\b(hand[- ]?track|tracking camera|eye[- ]?track|depth camera|ultraleap|leap motion|infrared|ir camera)/i;
73
87
 
74
88
  /**
75
89
  * The exclusion list in force: the built-in pattern plus `exclude` (strings match a label
76
- * case-insensitively as a substring, or a deviceId exactly; RegExps test the label), or nothing at
77
- * all for `exclude: false`. Returns a predicate `(label, deviceId) => boolean`. Pure.
90
+ * case-insensitively as a substring, or a deviceId exactly; RegExps test the label), or only the
91
+ * app-share rule for `exclude: false`. Returns a predicate `(label, deviceId) => boolean`. An
92
+ * app-share device (camera/app-share.js: a running app's views, not a camera) is ALWAYS excluded —
93
+ * `exclude: false` turns the sensor list off, never that. Pure.
78
94
  * @param {false|null|undefined|ReadonlyArray<string|RegExp>|string|RegExp} exclude
79
95
  */
80
96
  export function cameraExcluder(exclude) {
81
- if (exclude === false) return () => false;
97
+ const app = (label, deviceId) => isAppShareDevice({ label: String(label || ''), deviceId: typeof deviceId === 'string' ? deviceId : '' });
98
+ if (exclude === false) return app;
82
99
  const list = [CAMERA_EXCLUDE_BUILTIN];
83
100
  for (const x of exclude == null ? [] : Array.isArray(exclude) ? exclude : [exclude]) {
84
101
  if (x instanceof RegExp || (typeof x === 'string' && x)) list.push(x);
85
102
  }
86
103
  return (label, deviceId) => {
104
+ if (app(label, deviceId)) return true;
87
105
  const l = String(label || '');
88
106
  for (const x of list) {
89
107
  if (x instanceof RegExp) {
@@ -95,8 +113,12 @@ export function cameraExcluder(exclude) {
95
113
  };
96
114
  }
97
115
 
98
- /** A label that names a stereo camera, tried first when probing. Pure. */
116
+ /**
117
+ * A label that names a stereo camera, tried first when probing. Never an app-share device's
118
+ * ("DisplayXR: <app> (3D)" says "3D" but is a running app, not a camera). Pure.
119
+ */
99
120
  export function stereoLabelHint(label) {
121
+ if (isAppShareDevice(String(label || ''))) return false;
100
122
  return /stereo|\b3d\b|\bsbs\b|dual/i.test(label || '');
101
123
  }
102
124
 
@@ -143,34 +165,64 @@ export async function openCamera(want = 'auto', o = {}) {
143
165
  // eye-tracking camera shared without stealing it) describes itself on the opened track:
144
166
  // `displayxrStereo: {layout, rectified, baselineMm, horizontalFovDeg}`.
145
167
  const stereo = st.displayxrStereo && st.displayxrStereo.layout === 'side-by-side' ? st.displayxrStereo : null;
146
- return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo };
168
+ // A running app's views (3D screen share, ADR-061) describe themselves too: `source: 'app'`,
169
+ // or — on a browser without the app-share device patch, which lists the app as a "3D Camera
170
+ // (DisplayXR) - <app>" — baseline 0 and FOV 0, which no real camera advertises. Every probe
171
+ // path checks this on the OPENED track (rejectOpened), since the label cannot tell them apart.
172
+ const appShare = isAppShareDevice({ label: t.label || '', deviceId: st.deviceId || '' }) || isAppShareHint(st.displayxrStereo);
173
+ return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo, appShare };
147
174
  };
148
175
  const skip = (label, err) => {
149
176
  skipped.push({ label, error: `${err.name}: ${err.message}`, busy: isBusyError(err), denied: isDeniedError(err) });
150
177
  log('camera-skip', { label, error: err.name });
151
178
  };
152
179
  const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
180
+ const listCameras = async () => {
181
+ try {
182
+ return (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput');
183
+ } catch {
184
+ return []; // no enumeration: the paths below still work
185
+ }
186
+ };
153
187
  // Excluded devices: never opened by id; a default that turns out to be one is closed again.
154
188
  const excludedIds = new Set();
155
- const exclude = (label, deviceId) => {
189
+ // `reason`: 'app-share' for a running app's views (never a camera), else 'excluded'.
190
+ const exclude = (label, deviceId, reason = 'excluded') => {
156
191
  if (deviceId && excludedIds.has(deviceId)) return;
157
192
  if (deviceId) excludedIds.add(deviceId);
158
- skipped.push({ label, error: 'excluded', excluded: true });
159
- log('camera-skip', { label, error: 'excluded' });
193
+ skipped.push({ label, error: 'excluded', excluded: true, reason });
194
+ log('camera-skip', { label, error: 'excluded', reason });
160
195
  };
196
+ const isApp = (d) => isAppShareDevice({ label: d.label || '', deviceId: d.deviceId || '' });
161
197
  const listedExcluded = (d) => {
198
+ if (isApp(d)) {
199
+ exclude(d.label || '', d.deviceId, 'app-share');
200
+ return true;
201
+ }
162
202
  if (!d.label || !isExcluded(d.label, d.deviceId)) return false;
163
203
  exclude(d.label, d.deviceId);
164
204
  return true;
165
205
  };
206
+ /** An opened device that must not be used: an app share (by its own hint too) or an excluded label. Closes it. */
207
+ const rejectOpened = (r) => {
208
+ if (r.appShare) {
209
+ stop(r);
210
+ exclude(r.label, r.deviceId, 'app-share');
211
+ return true;
212
+ }
213
+ if (isExcluded(r.label, r.deviceId)) {
214
+ stop(r);
215
+ exclude(r.label, r.deviceId);
216
+ return true;
217
+ }
218
+ return false;
219
+ };
166
220
  // An open of the DEFAULT (no id): only after it opened is its label known. An excluded default
167
221
  // is closed and never opened again in this probe.
168
222
  let defaultExcluded = false;
169
223
  const openDefault = async (constraints) => {
170
224
  const r = await open(constraints);
171
- if (isExcluded(r.label, r.deviceId)) {
172
- stop(r);
173
- exclude(r.label, r.deviceId);
225
+ if (rejectOpened(r)) {
174
226
  defaultExcluded = true;
175
227
  return null;
176
228
  }
@@ -206,6 +258,28 @@ export async function openCamera(want = 'auto', o = {}) {
206
258
  },
207
259
  });
208
260
 
261
+ // `label:<text>`: the first camera whose label contains the text (case-insensitive) — a way to
262
+ // force a device from a URL or a config, where a per-origin deviceId is useless. Labels need
263
+ // permission: with none visible yet, the default camera is primed once and let go. Explicit like
264
+ // a deviceId, so it opens an excluded device too.
265
+ if (typeof want === 'string' && want.startsWith('label:')) {
266
+ const needle = want.slice('label:'.length).trim().toLowerCase();
267
+ let list = await listCameras();
268
+ if (needle && list.length && list.every((d) => !d.label)) {
269
+ try {
270
+ stop(await open(true));
271
+ } catch (err) {
272
+ skip('default', err);
273
+ }
274
+ list = await listCameras();
275
+ }
276
+ // Never a running app's views: `label:3d` means a camera (open an app with openAppShare()).
277
+ const d = needle ? list.find((x) => x.deviceId && !isApp(x) && (x.label || '').toLowerCase().includes(needle)) : null;
278
+ if (!d) throw Object.assign(new Error(`camera unavailable: no camera labelled "${needle}"`), { code: noCameraCode(skipped), skipped });
279
+ log('camera-label', { label: d.label, needle });
280
+ want = d.deviceId;
281
+ }
282
+
209
283
  // An explicit device id.
210
284
  if (typeof want === 'string' && !['auto', 'stereo', 'mono'].includes(want)) {
211
285
  let r;
@@ -225,13 +299,6 @@ export async function openCamera(want = 'auto', o = {}) {
225
299
  // camera start can take seconds (a busy capture stack took 7-20 s per start on a field box), and
226
300
  // the default-first probe below costs two or three of them before it reaches the pair.
227
301
  const tried = new Set();
228
- const listCameras = async () => {
229
- try {
230
- return (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput');
231
- } catch {
232
- return []; // no enumeration: the paths below still work
233
- }
234
- };
235
302
  const tryNamedPairs = async (list) => {
236
303
  for (const d of list) {
237
304
  if (!d.deviceId || !stereoLabelHint(d.label) || tried.has(d.deviceId)) continue;
@@ -244,6 +311,7 @@ export async function openCamera(want = 'auto', o = {}) {
244
311
  skip(d.label, err);
245
312
  continue;
246
313
  }
314
+ if (rejectOpened(r)) continue; // an app that described itself only on its track
247
315
  if (isPair(r)) {
248
316
  log('camera-sbs', { label: r.label, width: r.width, height: r.height, direct: true });
249
317
  return r;
@@ -290,11 +358,27 @@ export async function openCamera(want = 'auto', o = {}) {
290
358
  }
291
359
 
292
360
  // The default camera: the fallback (and, with labels already visible, also the probe base).
361
+ // With labels visible and an excluded device listed, the default is not opened blind — it may BE
362
+ // the sensor (an idle Ultraleap is the default camera on some Windows boxes, and an eye tracker's
363
+ // camera should not be started at all) — the first camera that is not excluded is opened by id.
293
364
  if (!mono && !defaultExcluded) {
294
- try {
295
- mono = await openDefault(MONO_CONSTRAINTS);
296
- } catch (err) {
297
- skip('default', err);
365
+ const listed = await listCameras();
366
+ const anyExcluded = listed.filter(listedExcluded).length > 0; // every excluded device is noted, not just the first
367
+ const first = anyExcluded ? listed.find((d) => d.deviceId && !excludedIds.has(d.deviceId)) || null : null;
368
+ if (first) {
369
+ if (first.deviceId) tried.add(first.deviceId);
370
+ try {
371
+ mono = await open({ deviceId: { exact: first.deviceId }, ...MONO_CONSTRAINTS });
372
+ if (rejectOpened(mono)) mono = null;
373
+ } catch (err) {
374
+ skip(first.label, err);
375
+ }
376
+ } else {
377
+ try {
378
+ mono = await openDefault(MONO_CONSTRAINTS);
379
+ } catch (err) {
380
+ skip('default', err);
381
+ }
298
382
  }
299
383
  }
300
384
  if (want === 'mono' && mono) return result(mono, 'mono');
@@ -321,6 +405,7 @@ export async function openCamera(want = 'auto', o = {}) {
321
405
  skip(d.label, err);
322
406
  continue;
323
407
  }
408
+ if (rejectOpened(r)) continue;
324
409
  if (isPair(r) && want !== 'mono') {
325
410
  stop(mono);
326
411
  log('camera-sbs', { label: r.label, width: r.width, height: r.height });