@displayxr/inline3d 1.48.0 → 1.49.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.
@@ -24,6 +24,11 @@
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.
27
32
  //
28
33
  // RECTIFICATION is not done here (P2): it needs the camera's calibration (intrinsics/extrinsics
29
34
  // for the ACTIVE device), which comes from a plug-in or the runtime. P1 sends the raw pair with
@@ -31,6 +36,7 @@
31
36
  // `(stream, info) => MediaStream | Promise<MediaStream>`, returning a rectified SBS stream.
32
37
 
33
38
  import { looksSbs } from './geometry.js';
39
+ import { isAppShareDevice } from './app-share.js';
34
40
 
35
41
  const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } };
36
42
  // Ask a candidate stereo device for its widest mode; a 2560x720 or 3840x1080 pair comes back as
@@ -69,21 +75,28 @@ export function noCameraCode(skipped) {
69
75
  * tracker's or a "tracking camera"'s IR stream, a depth camera. Generic words only. Applied on
70
76
  * every probe path unless `exclude: false`.
71
77
  */
72
- export const CAMERA_EXCLUDE_BUILTIN = /\b(hand[- ]?track|tracking camera|eye[- ]?track|depth camera)/i;
78
+ // Generic words, plus the one hand tracker that enumerates under its bare brand name: on Windows
79
+ // an Ultraleap (Leap 2 / Leap Motion Controller) is a UVC "Ultraleap", nothing else in the label —
80
+ // the generic words alone let it through (win box, 2026-11-04: it was the browser's default camera).
81
+ export const CAMERA_EXCLUDE_BUILTIN = /\b(hand[- ]?track|tracking camera|eye[- ]?track|depth camera|ultraleap|leap motion|infrared|ir camera)/i;
73
82
 
74
83
  /**
75
84
  * 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.
85
+ * case-insensitively as a substring, or a deviceId exactly; RegExps test the label), or only the
86
+ * app-share rule for `exclude: false`. Returns a predicate `(label, deviceId) => boolean`. An
87
+ * app-share device (camera/app-share.js: a running app's views, not a camera) is ALWAYS excluded —
88
+ * `exclude: false` turns the sensor list off, never that. Pure.
78
89
  * @param {false|null|undefined|ReadonlyArray<string|RegExp>|string|RegExp} exclude
79
90
  */
80
91
  export function cameraExcluder(exclude) {
81
- if (exclude === false) return () => false;
92
+ const app = (label, deviceId) => isAppShareDevice({ label: String(label || ''), deviceId: typeof deviceId === 'string' ? deviceId : '' });
93
+ if (exclude === false) return app;
82
94
  const list = [CAMERA_EXCLUDE_BUILTIN];
83
95
  for (const x of exclude == null ? [] : Array.isArray(exclude) ? exclude : [exclude]) {
84
96
  if (x instanceof RegExp || (typeof x === 'string' && x)) list.push(x);
85
97
  }
86
98
  return (label, deviceId) => {
99
+ if (app(label, deviceId)) return true;
87
100
  const l = String(label || '');
88
101
  for (const x of list) {
89
102
  if (x instanceof RegExp) {
@@ -95,8 +108,12 @@ export function cameraExcluder(exclude) {
95
108
  };
96
109
  }
97
110
 
98
- /** A label that names a stereo camera, tried first when probing. Pure. */
111
+ /**
112
+ * A label that names a stereo camera, tried first when probing. Never an app-share device's
113
+ * ("DisplayXR: <app> (3D)" says "3D" but is a running app, not a camera). Pure.
114
+ */
99
115
  export function stereoLabelHint(label) {
116
+ if (isAppShareDevice(String(label || ''))) return false;
100
117
  return /stereo|\b3d\b|\bsbs\b|dual/i.test(label || '');
101
118
  }
102
119
 
@@ -143,34 +160,61 @@ export async function openCamera(want = 'auto', o = {}) {
143
160
  // eye-tracking camera shared without stealing it) describes itself on the opened track:
144
161
  // `displayxrStereo: {layout, rectified, baselineMm, horizontalFovDeg}`.
145
162
  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 };
163
+ // A running app's views (3D screen share, ADR-061) describe themselves too: `source: 'app'`.
164
+ const appShare = isAppShareDevice({ label: t.label || '', deviceId: st.deviceId || '' }) || !!(st.displayxrStereo && st.displayxrStereo.source === 'app');
165
+ return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo, appShare };
147
166
  };
148
167
  const skip = (label, err) => {
149
168
  skipped.push({ label, error: `${err.name}: ${err.message}`, busy: isBusyError(err), denied: isDeniedError(err) });
150
169
  log('camera-skip', { label, error: err.name });
151
170
  };
152
171
  const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
172
+ const listCameras = async () => {
173
+ try {
174
+ return (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput');
175
+ } catch {
176
+ return []; // no enumeration: the paths below still work
177
+ }
178
+ };
153
179
  // Excluded devices: never opened by id; a default that turns out to be one is closed again.
154
180
  const excludedIds = new Set();
155
- const exclude = (label, deviceId) => {
181
+ // `reason`: 'app-share' for a running app's views (never a camera), else 'excluded'.
182
+ const exclude = (label, deviceId, reason = 'excluded') => {
156
183
  if (deviceId && excludedIds.has(deviceId)) return;
157
184
  if (deviceId) excludedIds.add(deviceId);
158
- skipped.push({ label, error: 'excluded', excluded: true });
159
- log('camera-skip', { label, error: 'excluded' });
185
+ skipped.push({ label, error: 'excluded', excluded: true, reason });
186
+ log('camera-skip', { label, error: 'excluded', reason });
160
187
  };
188
+ const isApp = (d) => isAppShareDevice({ label: d.label || '', deviceId: d.deviceId || '' });
161
189
  const listedExcluded = (d) => {
190
+ if (isApp(d)) {
191
+ exclude(d.label || '', d.deviceId, 'app-share');
192
+ return true;
193
+ }
162
194
  if (!d.label || !isExcluded(d.label, d.deviceId)) return false;
163
195
  exclude(d.label, d.deviceId);
164
196
  return true;
165
197
  };
198
+ /** An opened device that must not be used: an app share (by its own hint too) or an excluded label. Closes it. */
199
+ const rejectOpened = (r) => {
200
+ if (r.appShare) {
201
+ stop(r);
202
+ exclude(r.label, r.deviceId, 'app-share');
203
+ return true;
204
+ }
205
+ if (isExcluded(r.label, r.deviceId)) {
206
+ stop(r);
207
+ exclude(r.label, r.deviceId);
208
+ return true;
209
+ }
210
+ return false;
211
+ };
166
212
  // An open of the DEFAULT (no id): only after it opened is its label known. An excluded default
167
213
  // is closed and never opened again in this probe.
168
214
  let defaultExcluded = false;
169
215
  const openDefault = async (constraints) => {
170
216
  const r = await open(constraints);
171
- if (isExcluded(r.label, r.deviceId)) {
172
- stop(r);
173
- exclude(r.label, r.deviceId);
217
+ if (rejectOpened(r)) {
174
218
  defaultExcluded = true;
175
219
  return null;
176
220
  }
@@ -206,6 +250,28 @@ export async function openCamera(want = 'auto', o = {}) {
206
250
  },
207
251
  });
208
252
 
253
+ // `label:<text>`: the first camera whose label contains the text (case-insensitive) — a way to
254
+ // force a device from a URL or a config, where a per-origin deviceId is useless. Labels need
255
+ // permission: with none visible yet, the default camera is primed once and let go. Explicit like
256
+ // a deviceId, so it opens an excluded device too.
257
+ if (typeof want === 'string' && want.startsWith('label:')) {
258
+ const needle = want.slice('label:'.length).trim().toLowerCase();
259
+ let list = await listCameras();
260
+ if (needle && list.length && list.every((d) => !d.label)) {
261
+ try {
262
+ stop(await open(true));
263
+ } catch (err) {
264
+ skip('default', err);
265
+ }
266
+ list = await listCameras();
267
+ }
268
+ // Never a running app's views: `label:3d` means a camera (open an app with openAppShare()).
269
+ const d = needle ? list.find((x) => x.deviceId && !isApp(x) && (x.label || '').toLowerCase().includes(needle)) : null;
270
+ if (!d) throw Object.assign(new Error(`camera unavailable: no camera labelled "${needle}"`), { code: noCameraCode(skipped), skipped });
271
+ log('camera-label', { label: d.label, needle });
272
+ want = d.deviceId;
273
+ }
274
+
209
275
  // An explicit device id.
210
276
  if (typeof want === 'string' && !['auto', 'stereo', 'mono'].includes(want)) {
211
277
  let r;
@@ -225,13 +291,6 @@ export async function openCamera(want = 'auto', o = {}) {
225
291
  // camera start can take seconds (a busy capture stack took 7-20 s per start on a field box), and
226
292
  // the default-first probe below costs two or three of them before it reaches the pair.
227
293
  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
294
  const tryNamedPairs = async (list) => {
236
295
  for (const d of list) {
237
296
  if (!d.deviceId || !stereoLabelHint(d.label) || tried.has(d.deviceId)) continue;
@@ -244,6 +303,7 @@ export async function openCamera(want = 'auto', o = {}) {
244
303
  skip(d.label, err);
245
304
  continue;
246
305
  }
306
+ if (rejectOpened(r)) continue; // an app that described itself only on its track
247
307
  if (isPair(r)) {
248
308
  log('camera-sbs', { label: r.label, width: r.width, height: r.height, direct: true });
249
309
  return r;
@@ -290,11 +350,27 @@ export async function openCamera(want = 'auto', o = {}) {
290
350
  }
291
351
 
292
352
  // The default camera: the fallback (and, with labels already visible, also the probe base).
353
+ // With labels visible and an excluded device listed, the default is not opened blind — it may BE
354
+ // the sensor (an idle Ultraleap is the default camera on some Windows boxes, and an eye tracker's
355
+ // camera should not be started at all) — the first camera that is not excluded is opened by id.
293
356
  if (!mono && !defaultExcluded) {
294
- try {
295
- mono = await openDefault(MONO_CONSTRAINTS);
296
- } catch (err) {
297
- skip('default', err);
357
+ const listed = await listCameras();
358
+ const anyExcluded = listed.filter(listedExcluded).length > 0; // every excluded device is noted, not just the first
359
+ const first = anyExcluded ? listed.find((d) => d.deviceId && !excludedIds.has(d.deviceId)) || null : null;
360
+ if (first) {
361
+ if (first.deviceId) tried.add(first.deviceId);
362
+ try {
363
+ mono = await open({ deviceId: { exact: first.deviceId }, ...MONO_CONSTRAINTS });
364
+ if (rejectOpened(mono)) mono = null;
365
+ } catch (err) {
366
+ skip(first.label, err);
367
+ }
368
+ } else {
369
+ try {
370
+ mono = await openDefault(MONO_CONSTRAINTS);
371
+ } catch (err) {
372
+ skip('default', err);
373
+ }
298
374
  }
299
375
  }
300
376
  if (want === 'mono' && mono) return result(mono, 'mono');
@@ -321,6 +397,7 @@ export async function openCamera(want = 'auto', o = {}) {
321
397
  skip(d.label, err);
322
398
  continue;
323
399
  }
400
+ if (rejectOpened(r)) continue;
324
401
  if (isPair(r) && want !== 'mono') {
325
402
  stop(mono);
326
403
  log('camera-sbs', { label: r.label, width: r.width, height: r.height });