@displayxr/inline3d 1.47.2 → 1.47.4

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.
@@ -9,8 +9,11 @@
9
9
  // - On some laptops the stereo camera is held EXCLUSIVELY by the eye tracker, so opening it
10
10
  // fails with NotReadableError ("Device in use"). That device is skipped silently — a call must
11
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.
12
+ // - Labels are empty until one getUserMedia has succeeded. Without them, permission is primed on
13
+ // an environment-facing camera where there is one (a phone's front lens is the head tracker's,
14
+ // and opening it while the tracker holds it can reset the whole camera stack), then the list
15
+ // is read again and a stereo device is opened by id BEFORE the default. The default is the
16
+ // fallback.
14
17
  //
15
18
  // RECTIFICATION is not done here (P2): it needs the camera's calibration (intrinsics/extrinsics
16
19
  // for the ACTIVE device), which comes from a plug-in or the runtime. P1 sends the raw pair with
@@ -101,6 +104,9 @@ export async function openCamera(want = 'auto', o = {}) {
101
104
  log('camera-skip', { label, error: err.name });
102
105
  };
103
106
  const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
107
+ // A track that declares itself a side-by-side pair IS one, whatever its aspect (a phone's portrait
108
+ // eyes make a 1440x1280 pair). Shape is only the guess for an unknown device, e.g. a USB pair.
109
+ const isPair = (r) => !!r && (!!r.stereo || looksSbs(r.width, r.height));
104
110
  const result = (r, format) => ({
105
111
  stream: r.stream, format, width: r.width, height: r.height, deviceId: r.deviceId, label: r.label, owned: true, skipped,
106
112
  calibration: {
@@ -126,7 +132,7 @@ export async function openCamera(want = 'auto', o = {}) {
126
132
  skip(want, err);
127
133
  throw Object.assign(new Error(`camera unavailable: ${err.message}`), { code: noCameraCode(skipped), skipped });
128
134
  }
129
- if (looksSbs(r.width, r.height)) return result(r, 'sbs');
135
+ if (isPair(r)) return result(r, 'sbs');
130
136
  stop(r);
131
137
  return result(await open({ deviceId: { exact: want }, ...MONO_CONSTRAINTS }), 'mono');
132
138
  }
@@ -136,14 +142,16 @@ export async function openCamera(want = 'auto', o = {}) {
136
142
  // camera start can take seconds (a busy capture stack took 7-20 s per start on a field box), and
137
143
  // the default-first probe below costs two or three of them before it reaches the pair.
138
144
  const tried = new Set();
139
- if (want !== 'mono') {
140
- let early = [];
145
+ const listCameras = async () => {
141
146
  try {
142
- early = (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput' && d.deviceId && stereoLabelHint(d.label));
147
+ return (await md.enumerateDevices()).filter((d) => d.kind === 'videoinput');
143
148
  } catch {
144
- /* no enumeration yet: the normal path below */
149
+ return []; // no enumeration: the paths below still work
145
150
  }
146
- for (const d of early) {
151
+ };
152
+ const tryNamedPairs = async (list) => {
153
+ for (const d of list) {
154
+ if (!d.deviceId || !stereoLabelHint(d.label) || tried.has(d.deviceId)) continue;
147
155
  tried.add(d.deviceId);
148
156
  let r = null;
149
157
  try {
@@ -152,23 +160,51 @@ export async function openCamera(want = 'auto', o = {}) {
152
160
  skip(d.label, err);
153
161
  continue;
154
162
  }
155
- if (looksSbs(r.width, r.height)) {
163
+ if (isPair(r)) {
156
164
  log('camera-sbs', { label: r.label, width: r.width, height: r.height, direct: true });
157
- return result(r, 'sbs');
165
+ return r;
158
166
  }
159
167
  stop(r);
160
168
  }
169
+ return null;
170
+ };
171
+ let mono = null;
172
+ if (want !== 'mono') {
173
+ const listed = await listCameras();
174
+ const pair = await tryNamedPairs(listed);
175
+ if (pair) return result(pair, 'sbs');
176
+ // No labels yet (no permission). Prime it on an environment-facing camera, so a phone does not
177
+ // open its front lens (the head tracker's) just to learn the names; then open a pair by id.
178
+ if (listed.length && listed.every((d) => !d.label)) {
179
+ let primed = null;
180
+ try {
181
+ primed = await open({ ...MONO_CONSTRAINTS, facingMode: { ideal: 'environment' } });
182
+ } catch (err) {
183
+ skip('default', err);
184
+ }
185
+ if (primed && isPair(primed)) return result(primed, 'sbs');
186
+ const named = await tryNamedPairs(await listCameras());
187
+ if (named) {
188
+ stop(primed);
189
+ return result(named, 'sbs');
190
+ }
191
+ // A camera that faces the user (or does not say: a desktop webcam) is the fallback as is; a
192
+ // rear one is not what a self view falls back to, so the default is opened below instead.
193
+ if (primed && primed.track?.getSettings?.().facingMode !== 'environment') mono = primed;
194
+ else stop(primed);
195
+ }
161
196
  }
162
197
 
163
- // The default camera first: permission, labels, and the fallback.
164
- let mono = null;
165
- try {
166
- mono = await open(MONO_CONSTRAINTS);
167
- } catch (err) {
168
- skip('default', err);
198
+ // The default camera: the fallback (and, with labels already visible, also the probe base).
199
+ if (!mono) {
200
+ try {
201
+ mono = await open(MONO_CONSTRAINTS);
202
+ } catch (err) {
203
+ skip('default', err);
204
+ }
169
205
  }
170
206
  if (want === 'mono' && mono) return result(mono, 'mono');
171
- if (mono && looksSbs(mono.width, mono.height)) return result(mono, 'sbs'); // the default IS the pair
207
+ if (mono && isPair(mono)) return result(mono, 'sbs'); // the default IS the pair
172
208
 
173
209
  let devices = [];
174
210
  try {
@@ -189,7 +225,7 @@ export async function openCamera(want = 'auto', o = {}) {
189
225
  skip(d.label, err);
190
226
  continue;
191
227
  }
192
- if (looksSbs(r.width, r.height)) {
228
+ if (isPair(r)) {
193
229
  stop(mono);
194
230
  log('camera-sbs', { label: r.label, width: r.width, height: r.height });
195
231
  return result(r, 'sbs');
@@ -202,7 +238,7 @@ export async function openCamera(want = 'auto', o = {}) {
202
238
  }
203
239
  const code = noCameraCode(skipped);
204
240
  throw Object.assign(
205
- new Error(code === 'camera-busy' ? 'every camera is in use by another app (e.g. eye tracking)' : code === 'permission-denied' ? 'camera access was denied' : 'no camera could be opened'),
241
+ new Error(code === 'camera-busy' ? 'camera unavailable: in use by another app (e.g. eye tracking) or unable to deliver a picture' : code === 'permission-denied' ? 'camera access was denied' : 'no camera could be opened'),
206
242
  { code, skipped }
207
243
  );
208
244
  }
@@ -1895,7 +1895,7 @@ class SelfTile {
1895
1895
  // onRouteChange: the view drops to its flat left eye by itself when the wall says the tile
1896
1896
  // will not weave (firstWoven → woven:false, web#131), and the badge must follow it.
1897
1897
  this.view = addCameraView(wall, this.canvas, cam, {
1898
- mirror: true,
1898
+ // mirror: unset — a selfie camera mirrors, a rear ('environment') camera does not.
1899
1899
  autoConverge: !!this.call.o.autoConverge, // the call option (default on) governs the self view too
1900
1900
  cursor: this.call.o.cursor || undefined, // and so does `cursor: 'depth'`
1901
1901
  aspect: this.call.o.tileAspect,
@@ -167,6 +167,20 @@ class StereoCamera {
167
167
  for (const t of this.stream.getVideoTracks()) t.addEventListener?.('ended', this._onEnded);
168
168
  }
169
169
 
170
+ /**
171
+ * Which way the camera faces, from the track's settings: 'user' (a selfie / front camera),
172
+ * 'environment' (a rear camera), 'left' / 'right', or null when the browser does not say (most
173
+ * desktop webcams).
174
+ */
175
+ get facing() {
176
+ try {
177
+ const f = this.stream.getVideoTracks()[0]?.getSettings?.().facingMode;
178
+ return typeof f === 'string' && f ? f : null;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
170
184
  /** Source width of ONE eye (the whole frame for mono). */
171
185
  get eyeWidth() {
172
186
  return this.format === 'sbs' ? this.width / 2 : this.width;
@@ -499,7 +513,9 @@ class CameraView {
499
513
  this.wall = wall || null;
500
514
  this.canvas = canvas;
501
515
  this.cam = cam;
502
- this.mirror = o.mirror === undefined ? true : !!o.mirror;
516
+ // A self view mirrors; a rear camera shows the world as it is. Unset: mirror unless the track
517
+ // says it faces the environment (an unknown facing is a webcam pointed at the viewer).
518
+ this.mirror = o.mirror === undefined ? cam.facing !== 'environment' : !!o.mirror;
503
519
  this.aspect = typeof o.aspect === 'number' && o.aspect > 0.3 && o.aspect < 4 ? o.aspect : 16 / 9;
504
520
  this.autoConverge = !!o.autoConverge;
505
521
  this.conv = createConvergence();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.47.2",
3
+ "version": "1.47.4",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",