@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.
- package/CHANGELOG.md +55 -0
- package/call.d.ts +107 -7
- package/camera.d.ts +16 -8
- package/dist/call.js +9 -6
- package/js/call/element.js +3 -1
- package/js/call/options.js +2 -0
- package/js/call/share.js +147 -0
- package/js/call/transport.js +150 -9
- package/js/call/ui.js +3 -0
- package/js/call/wire.js +65 -1
- package/js/camera/app-share.js +54 -0
- package/js/camera/capture.js +100 -23
- package/js/inline3d-call.js +361 -36
- package/js/inline3d-camera.js +9 -5
- package/package.json +1 -1
package/js/camera/capture.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
77
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 });
|