@displayxr/inline3d 1.47.5 → 1.48.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/js/call/wire.js CHANGED
@@ -26,6 +26,31 @@ export const MESH_HARD_CAP = 4;
26
26
  /** `hint` messages are rate-limited to this many per second, on the sender AND the receiver. */
27
27
  export const HINT_MAX_HZ = 5;
28
28
 
29
+ /**
30
+ * `conv` (sender-measured auto-convergence, docs/call.md): a sender broadcasts at most this many
31
+ * per second; a receiver accepts at most `CONV_RECV_MAX_HZ`. A sender whose value has not changed
32
+ * still sends one every `CONV_HEARTBEAT_MS`; a receiver that has heard nothing for `CONV_FRESH_MS`
33
+ * goes back to measuring the received frames itself.
34
+ */
35
+ export const CONV_MAX_HZ = 5;
36
+ export const CONV_RECV_MAX_HZ = 10;
37
+ export const CONV_HEARTBEAT_MS = 400;
38
+ export const CONV_FRESH_MS = 1200;
39
+ /** `conv.d` is a fraction of the per-eye width; anything beyond ±this is not a face's disparity. */
40
+ export const CONV_MAX_FRAC = 0.5;
41
+
42
+ /**
43
+ * App messages (`handle.send` / `handle.broadcast`, docs/call.md § Headless): `{ type: 'app', d }`
44
+ * on the call's data channel. The serialised message is at most `APP_MAX_BYTES` (UTF-8), and a
45
+ * sender sends at most `APP_MAX_PER_SEC` to one peer in any second (a receiver drops anything
46
+ * beyond `APP_RECV_MAX_PER_SEC`: network jitter can bunch a legal burst).
47
+ */
48
+ export const APP_MAX_BYTES = 16 * 1024;
49
+ export const APP_MAX_PER_SEC = 20;
50
+ export const APP_RECV_MAX_PER_SEC = 40;
51
+ /** A display name (`hello.name`, the `name` option) is at most this many characters. */
52
+ export const NAME_MAX_CHARS = 64;
53
+
29
54
  /** Plausible subject distances, mm. A hint outside this is ignored as noise. */
30
55
  export const SUBJECT_Z_MIN_MM = 150;
31
56
  export const SUBJECT_Z_MAX_MM = 5000;
@@ -116,7 +141,7 @@ export function buildInviteLink(base, room) {
116
141
  return `${b}#room=${room}`;
117
142
  }
118
143
 
119
- // ── hello / hint / state ───────────────────────────────────────────────────────────────────
144
+ // ── hello / hint / state / conv ────────────────────────────────────────────────────────────
120
145
 
121
146
  /**
122
147
  * Validate an incoming `hello`, filling the defaults a receiver may assume. Returns null for
@@ -138,11 +163,36 @@ export function normalizeHello(msg) {
138
163
  hfovDeg: num(msg.hfovDeg, 5, 170),
139
164
  rectified: msg.rectified === true,
140
165
  sdk: typeof msg.sdk === 'string' ? msg.sdk.slice(0, 64) : null,
166
+ // The sender measures its own convergence and sends `conv` messages. Absent (an older sender) =
167
+ // false: the receiver measures the decoded frames itself, as before.
168
+ conv: msg.conv === true,
169
+ // The sender's display name (`name` option, 1.48+), sanitised; null when absent or empty.
170
+ name: sanitizeName(msg.name),
141
171
  };
142
172
  }
143
173
 
144
- /** Build the `hello` this side sends. */
145
- export function makeHello({ format, width, height, baselineMm, hfovDeg, rectified } = {}) {
174
+ /**
175
+ * A display name as it may travel and be shown: a string, control characters removed (C0, DEL,
176
+ * C1, and the bidi / zero-width format controls that can disguise a name), whitespace collapsed
177
+ * and trimmed, at most `NAME_MAX_CHARS` characters (code points). Null for anything else or an
178
+ * empty result. Never throws: this reads data from another browser. Pure.
179
+ * @param {unknown} v
180
+ * @returns {string|null}
181
+ */
182
+ export function sanitizeName(v) {
183
+ if (typeof v !== 'string') return null;
184
+ // eslint-disable-next-line no-control-regex
185
+ const clean = v.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, ' ').replace(/\s+/g, ' ').trim();
186
+ if (!clean) return null;
187
+ const cps = Array.from(clean);
188
+ return cps.length > NAME_MAX_CHARS ? cps.slice(0, NAME_MAX_CHARS).join('').trim() : clean;
189
+ }
190
+
191
+ /**
192
+ * Build the `hello` this side sends. `conv: true` (advertised for a side-by-side sender only)
193
+ * says this side sends `conv` messages.
194
+ */
195
+ export function makeHello({ format, width, height, baselineMm, hfovDeg, rectified, conv, name } = {}) {
146
196
  const h = {
147
197
  type: 'hello',
148
198
  v: WIRE_VERSION,
@@ -154,9 +204,90 @@ export function makeHello({ format, width, height, baselineMm, hfovDeg, rectifie
154
204
  };
155
205
  if (Number.isFinite(baselineMm)) h.baselineMm = baselineMm;
156
206
  if (Number.isFinite(hfovDeg)) h.hfovDeg = hfovDeg;
207
+ if (h.format === 'sbs' && conv) h.conv = true;
208
+ const n = sanitizeName(name);
209
+ if (n) h.name = n;
157
210
  return h;
158
211
  }
159
212
 
213
+ /**
214
+ * Wrap a page's app payload: `{ type: 'app', d: data }` serialised, or null when `data` cannot be
215
+ * serialised (undefined, a function, a cycle, a BigInt) or the message is over `APP_MAX_BYTES`
216
+ * (UTF-8). Returns the JSON string the data channel carries. Pure.
217
+ * @param {unknown} data
218
+ * @returns {{ json: string, bytes: number } | null}
219
+ */
220
+ export function makeAppMessage(data) {
221
+ if (data === undefined || typeof data === 'function') return null;
222
+ let json;
223
+ try {
224
+ json = JSON.stringify({ type: 'app', d: data });
225
+ } catch {
226
+ return null;
227
+ }
228
+ if (typeof json !== 'string') return null;
229
+ const bytes = utf8Length(json);
230
+ return bytes > APP_MAX_BYTES ? null : { json, bytes };
231
+ }
232
+
233
+ /** An incoming `app` message: `{ data }`, or null when it is not one. Only this type reaches a page. */
234
+ export function normalizeApp(msg) {
235
+ if (!msg || typeof msg !== 'object' || msg.type !== 'app' || !('d' in msg)) return null;
236
+ return { data: msg.d };
237
+ }
238
+
239
+ function utf8Length(s) {
240
+ if (typeof TextEncoder === 'function') return new TextEncoder().encode(s).length;
241
+ let n = 0;
242
+ for (const ch of s) {
243
+ const c = ch.codePointAt(0);
244
+ n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
245
+ }
246
+ return n;
247
+ }
248
+
249
+ /**
250
+ * A sliding-window counter: `gate(nowMs)` is true at most `max` times in any `windowMs`. The app
251
+ * channel's per-peer cap (a burst of `max` is fine; the next one waits for the window). Pure.
252
+ */
253
+ export function windowGate(max, windowMs = 1000) {
254
+ const at = [];
255
+ return (now) => {
256
+ while (at.length && now - at[0] >= windowMs) at.shift();
257
+ if (at.length >= max) return false;
258
+ at.push(now);
259
+ return true;
260
+ };
261
+ }
262
+
263
+ /**
264
+ * `conv {d, c?}`: the sender's measured disparity of the point between its subject's eyes, as a
265
+ * FRACTION of its per-eye width (resolution-free: a receiver multiplies by the width of the frame it
266
+ * decodes), `null` = nothing measured yet (the receiver holds what it had). `c` = the match
267
+ * confidence (NCC, 0..1), informational.
268
+ * @param {number|null} frac
269
+ * @param {number|null} [ncc]
270
+ */
271
+ export function makeConv(frac, ncc) {
272
+ const m = { type: 'conv', d: typeof frac === 'number' && Number.isFinite(frac) ? Math.max(-CONV_MAX_FRAC, Math.min(CONV_MAX_FRAC, frac)) : null };
273
+ if (typeof ncc === 'number' && Number.isFinite(ncc)) m.c = Math.max(0, Math.min(1, ncc));
274
+ return m;
275
+ }
276
+
277
+ /**
278
+ * Validate an incoming `conv`. Null for anything that is not one, or whose `d` is neither null nor
279
+ * a finite number within ±CONV_MAX_FRAC. `c` is clamped to 0..1, or dropped. Never throws: this
280
+ * reads data from another browser.
281
+ */
282
+ export function normalizeConv(msg) {
283
+ if (!msg || typeof msg !== 'object' || msg.type !== 'conv') return null;
284
+ const d = msg.d;
285
+ if (d !== null && !(typeof d === 'number' && Number.isFinite(d) && Math.abs(d) <= CONV_MAX_FRAC)) return null;
286
+ const out = { type: 'conv', d };
287
+ if (typeof msg.c === 'number' && Number.isFinite(msg.c)) out.c = Math.max(0, Math.min(1, msg.c));
288
+ return out;
289
+ }
290
+
160
291
  /** `hint {subjectZmm}`: a number in range, or null (ignored). */
161
292
  export function normalizeHint(msg) {
162
293
  if (!msg || msg.type !== 'hint') return null;
@@ -173,11 +304,14 @@ export function normalizeState(msg) {
173
304
  }
174
305
 
175
306
  /**
176
- * A minimum-interval gate: `gate(nowMs)` is true at most `hz` times a second. Used on both ends
307
+ * A minimum-interval gate: `gate(nowMs)` is true at most `hz` times a second (`slackMs` shortens
308
+ * the interval by that much). Used on both ends
177
309
  * of `hint` so a chatty (or hostile) sender cannot drive the receiver's convergence at frame rate.
178
310
  */
179
- export function rateGate(hz) {
180
- const minMs = 1000 / hz;
311
+ export function rateGate(hz, slackMs = 0) {
312
+ // `slackMs`: a timer-paced caller at exactly `hz` (the sender's `conv` tick) lands a few ms early
313
+ // now and then; without slack every such tick is refused and the rate halves.
314
+ const minMs = 1000 / hz - Math.max(0, slackMs);
181
315
  let last = -Infinity;
182
316
  return (now) => {
183
317
  if (now - last < minMs) return false;
@@ -186,6 +320,29 @@ export function rateGate(hz) {
186
320
  };
187
321
  }
188
322
 
323
+ // ── connection quality ─────────────────────────────────────────────────────────────────────
324
+
325
+ /**
326
+ * The selected ICE candidate pair of one getStats() report: `{ path, rttMs }`. `path` is
327
+ * `'relay'` when either end is relayed (TURN), else the local candidate's type (`'host'` /
328
+ * `'srflx'` / `'prflx'`); null when no pair is selected yet. Chrome names the pair on the
329
+ * `transport` stats; Firefox flags it `selected`. Pure: reads an RTCStatsReport (maplike).
330
+ */
331
+ export function selectedPath(report) {
332
+ const all = [];
333
+ report.forEach((s) => all.push(s));
334
+ const byId = (id) => (id && typeof report.get === 'function' ? report.get(id) : all.find((s) => s.id === id)) || null;
335
+ const tr = all.find((s) => s.type === 'transport' && s.selectedCandidatePairId);
336
+ const pair = (tr && byId(tr.selectedCandidatePairId)) || all.find((s) => s.type === 'candidate-pair' && s.selected) || all.find((s) => s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded');
337
+ if (!pair) return { path: null, rttMs: null };
338
+ const lc = byId(pair.localCandidateId);
339
+ const rc = byId(pair.remoteCandidateId);
340
+ const lt = lc ? lc.candidateType : null;
341
+ const path = lt === 'relay' || (rc && rc.candidateType === 'relay') ? 'relay' : ['host', 'srflx', 'prflx'].includes(lt) ? lt : null;
342
+ const rtt = typeof pair.currentRoundTripTime === 'number' ? Math.round(pair.currentRoundTripTime * 1000) : null;
343
+ return { path, rttMs: rtt };
344
+ }
345
+
189
346
  // ── routing (RFC §3) ───────────────────────────────────────────────────────────────────────
190
347
 
191
348
  /**
@@ -5,16 +5,25 @@
5
5
  // 1280x480, i.e. 640x480 per eye, grayscale and UNRECTIFIED). Found → sent as `sbs`. Otherwise the
6
6
  // default webcam, sent as `mono`. Per-eye width is whatever the camera gives; nothing is upscaled.
7
7
  //
8
- // Two field facts shape the probing:
8
+ // Three field facts shape the probing:
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
+ // - Not every `videoinput` is a camera a person should be seen through. A hand-tracking camera
13
+ // (and an eye tracker's or a depth sensor's IR stream) enumerates as an ordinary video input
14
+ // and can even look like a pair by shape; on a Windows box with no stereo camera open, the
15
+ // last-resort "try every device" loop opened one and sent it as the call's 3D picture. Labels
16
+ // matching CAMERA_EXCLUDE_BUILTIN, plus the page's `exclude` list, are never opened by any probe
17
+ // path (an excluded default is closed again), and are reported in `skipped` as `excluded`.
12
18
  // - Labels are empty until one getUserMedia has succeeded. Without them, permission is primed on
13
- // the DEFAULT camera with no constraints: in the DisplayXR Browser the runtime's 3D camera is
14
- // listed first, so the prime opens the pair itself and no phone lens is opened at all (a plain
15
- // open of one lens of the pair, or its teardown, crashes some phones' camera HAL; any facing or
16
- // size constraint lets the engine pick a lens instead). Then the list is read again and a
17
- // stereo device is opened by id BEFORE falling back. The primed default is the fallback.
19
+ // the DEFAULT camera with no constraints (a plain open of one lens of a phone's pair, or its
20
+ // teardown, crashes some phones' camera HAL; any facing or size constraint lets the engine pick
21
+ // a lens instead). Where the runtime's 3D camera is the default, the prime opens the pair
22
+ // itself. NOTHING here assumes a list position: on Windows the DisplayXR Browser lists its
23
+ // "3D Camera (DisplayXR)" LAST. A pair is recognised by the track's own `displayxrStereo`
24
+ // description or by its label (stereoLabelHint), and after the prime the list is read again and
25
+ // a named pair is opened by id BEFORE a default that is a pair only by its shape is kept. The
26
+ // primed default is the fallback.
18
27
  //
19
28
  // RECTIFICATION is not done here (P2): it needs the camera's calibration (intrinsics/extrinsics
20
29
  // for the ACTIVE device), which comes from a plug-in or the runtime. P1 sends the raw pair with
@@ -48,11 +57,44 @@ export function isDeniedError(err) {
48
57
  * @param {Array<{error: string, busy?: boolean, denied?: boolean}>} skipped
49
58
  */
50
59
  export function noCameraCode(skipped) {
51
- if (skipped.length && skipped.every((s) => s.busy)) return 'camera-busy';
52
- if (skipped.some((s) => s.denied)) return 'permission-denied';
60
+ // A device left out on purpose (`excluded`) says nothing about why the others failed.
61
+ const tried = skipped.filter((s) => !s.excluded);
62
+ if (tried.length && tried.every((s) => s.busy)) return 'camera-busy';
63
+ if (tried.some((s) => s.denied)) return 'permission-denied';
53
64
  return 'no-camera';
54
65
  }
55
66
 
67
+ /**
68
+ * Labels of video inputs that are sensors, not cameras to be seen through: hand tracking, an eye
69
+ * tracker's or a "tracking camera"'s IR stream, a depth camera. Generic words only. Applied on
70
+ * every probe path unless `exclude: false`.
71
+ */
72
+ export const CAMERA_EXCLUDE_BUILTIN = /\b(hand[- ]?track|tracking camera|eye[- ]?track|depth camera)/i;
73
+
74
+ /**
75
+ * 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.
78
+ * @param {false|null|undefined|ReadonlyArray<string|RegExp>|string|RegExp} exclude
79
+ */
80
+ export function cameraExcluder(exclude) {
81
+ if (exclude === false) return () => false;
82
+ const list = [CAMERA_EXCLUDE_BUILTIN];
83
+ for (const x of exclude == null ? [] : Array.isArray(exclude) ? exclude : [exclude]) {
84
+ if (x instanceof RegExp || (typeof x === 'string' && x)) list.push(x);
85
+ }
86
+ return (label, deviceId) => {
87
+ const l = String(label || '');
88
+ for (const x of list) {
89
+ if (x instanceof RegExp) {
90
+ x.lastIndex = 0; // a /g or /y pattern is stateful
91
+ if (l && x.test(l)) return true;
92
+ } else if ((l && l.toLowerCase().includes(x.toLowerCase())) || (deviceId && deviceId === x)) return true;
93
+ }
94
+ return false;
95
+ };
96
+ }
97
+
56
98
  /** A label that names a stereo camera, tried first when probing. Pure. */
57
99
  export function stereoLabelHint(label) {
58
100
  return /stereo|\b3d\b|\bsbs\b|dual/i.test(label || '');
@@ -65,7 +107,9 @@ export function stereoLabelHint(label) {
65
107
  * @param {'auto'|'stereo'|'mono'|string|MediaStream} want 'auto' | 'stereo' | 'mono' | a
66
108
  * deviceId | a page-supplied MediaStream
67
109
  * @param {{ format?: 'sbs'|'mono', calibration?: {baselineMm?: number, hfovDeg?: number, rectified?: boolean},
68
- * mediaDevices?: MediaDevices, log?: Function }} [o]
110
+ * mediaDevices?: MediaDevices, log?: Function, exclude?: false|Array<string|RegExp> }} [o]
111
+ * `exclude`: devices no probe may open (see cameraExcluder); an explicit deviceId is the
112
+ * page's own choice and is opened regardless.
69
113
  * @returns {Promise<{ stream: MediaStream, format: 'sbs'|'mono', width: number, height: number,
70
114
  * deviceId: string|null, label: string, owned: boolean, skipped: Array<{label: string, error: string}>,
71
115
  * calibration: object }>}
@@ -75,6 +119,7 @@ export async function openCamera(want = 'auto', o = {}) {
75
119
  const log = o.log || (() => {});
76
120
  const skipped = [];
77
121
  const cal = o.calibration || {};
122
+ const isExcluded = cameraExcluder(o.exclude);
78
123
 
79
124
  if (want && typeof want === 'object' && typeof want.getVideoTracks === 'function') {
80
125
  const t = want.getVideoTracks()[0];
@@ -105,6 +150,32 @@ export async function openCamera(want = 'auto', o = {}) {
105
150
  log('camera-skip', { label, error: err.name });
106
151
  };
107
152
  const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
153
+ // Excluded devices: never opened by id; a default that turns out to be one is closed again.
154
+ const excludedIds = new Set();
155
+ const exclude = (label, deviceId) => {
156
+ if (deviceId && excludedIds.has(deviceId)) return;
157
+ if (deviceId) excludedIds.add(deviceId);
158
+ skipped.push({ label, error: 'excluded', excluded: true });
159
+ log('camera-skip', { label, error: 'excluded' });
160
+ };
161
+ const listedExcluded = (d) => {
162
+ if (!d.label || !isExcluded(d.label, d.deviceId)) return false;
163
+ exclude(d.label, d.deviceId);
164
+ return true;
165
+ };
166
+ // An open of the DEFAULT (no id): only after it opened is its label known. An excluded default
167
+ // is closed and never opened again in this probe.
168
+ let defaultExcluded = false;
169
+ const openDefault = async (constraints) => {
170
+ const r = await open(constraints);
171
+ if (isExcluded(r.label, r.deviceId)) {
172
+ stop(r);
173
+ exclude(r.label, r.deviceId);
174
+ defaultExcluded = true;
175
+ return null;
176
+ }
177
+ return r;
178
+ };
108
179
  // Ask an open camera for another mode without a second start; keep it as it is if it refuses.
109
180
  const withMode = async (r, constraints) => {
110
181
  try {
@@ -165,6 +236,7 @@ export async function openCamera(want = 'auto', o = {}) {
165
236
  for (const d of list) {
166
237
  if (!d.deviceId || !stereoLabelHint(d.label) || tried.has(d.deviceId)) continue;
167
238
  tried.add(d.deviceId);
239
+ if (listedExcluded(d)) continue;
168
240
  let r = null;
169
241
  try {
170
242
  r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
@@ -192,31 +264,35 @@ export async function openCamera(want = 'auto', o = {}) {
192
264
  pair = await tryNamedPairs(listed);
193
265
  if (pair) return result(pair, 'sbs');
194
266
  }
195
- // No labels yet (no permission). Prime it on the default camera with NO constraints: in the
196
- // DisplayXR Browser that is the 3D camera (listed first), so no phone lens is opened; a facing
197
- // or size constraint would let the engine pick a lens instead. Then open a pair by id.
267
+ // No labels yet (no permission). Prime it on the default camera with NO constraints (a facing
268
+ // or size constraint would let the engine pick a phone lens instead). Where the runtime's 3D
269
+ // camera is the default this opens the pair itself; elsewhere (Windows lists it LAST) the list
270
+ // is read again and a NAMED pair is opened by id. A default that is a pair only by its SHAPE is
271
+ // kept as the pair only when no named one opens (it could be any wide sensor).
198
272
  if (listed.length && listed.every((d) => !d.label)) {
199
273
  let primed = null;
200
274
  try {
201
- primed = await open(true);
275
+ primed = await openDefault(true);
202
276
  } catch (err) {
203
277
  skip('default', err);
204
278
  }
205
- if (primed && isPair(primed)) return result(primed, 'sbs');
279
+ if (primed && primed.stereo) return result(primed, 'sbs'); // the device says it is the pair
280
+ if (primed && primed.deviceId) tried.add(primed.deviceId);
206
281
  const named = await tryNamedPairs(await listCameras());
207
282
  if (named) {
208
283
  stop(primed);
209
284
  return result(named, 'sbs');
210
285
  }
286
+ if (primed && isPair(primed)) return result(primed, 'sbs');
211
287
  // The primed default is the fallback as is (one camera start); ask it for the usual mode.
212
288
  if (primed) mono = await withMode(primed, MONO_CONSTRAINTS);
213
289
  }
214
290
  }
215
291
 
216
292
  // The default camera: the fallback (and, with labels already visible, also the probe base).
217
- if (!mono) {
293
+ if (!mono && !defaultExcluded) {
218
294
  try {
219
- mono = await open(MONO_CONSTRAINTS);
295
+ mono = await openDefault(MONO_CONSTRAINTS);
220
296
  } catch (err) {
221
297
  skip('default', err);
222
298
  }
@@ -230,11 +306,13 @@ export async function openCamera(want = 'auto', o = {}) {
230
306
  } catch {
231
307
  /* no enumeration: keep the default */
232
308
  }
309
+ // A label that names a pair first; never by list position (see the header).
233
310
  devices.sort((a, b) => Number(stereoLabelHint(b.label)) - Number(stereoLabelHint(a.label)));
234
311
  for (const d of devices) {
235
312
  if (!d.deviceId) continue; // no permission yet: an anonymous entry cannot be opened by id
236
313
  if (mono && d.deviceId === mono.deviceId) continue;
237
- if (tried.has(d.deviceId)) continue; // the fast path already opened it (not a pair, or busy)
314
+ if (tried.has(d.deviceId) || excludedIds.has(d.deviceId)) continue; // already opened (not a pair, or busy) or left out
315
+ if (listedExcluded(d)) continue; // the last resort never opens a sensor (a hand-tracking camera)
238
316
  let r = null;
239
317
  try {
240
318
  r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
@@ -243,11 +321,17 @@ export async function openCamera(want = 'auto', o = {}) {
243
321
  skip(d.label, err);
244
322
  continue;
245
323
  }
246
- if (isPair(r)) {
324
+ if (isPair(r) && want !== 'mono') {
247
325
  stop(mono);
248
326
  log('camera-sbs', { label: r.label, width: r.width, height: r.height });
249
327
  return result(r, 'sbs');
250
328
  }
329
+ // No default could be used (excluded, busy): the first plain camera found is the fallback.
330
+ if (!mono && !isPair(r)) {
331
+ mono = await withMode(r, MONO_CONSTRAINTS);
332
+ if (want === 'mono') return result(mono, 'mono');
333
+ continue;
334
+ }
251
335
  stop(r);
252
336
  }
253
337
  if (mono) {
@@ -261,11 +345,14 @@ export async function openCamera(want = 'auto', o = {}) {
261
345
  );
262
346
  }
263
347
 
264
- /** The microphone, with echo cancellation and noise suppression. Null if unavailable/denied. */
348
+ /**
349
+ * The microphone, with echo cancellation and noise suppression. Null if unavailable/denied.
350
+ * `deviceId`: that input exactly (`handle.setMic`).
351
+ */
265
352
  export async function openMic(o = {}) {
266
353
  const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
267
354
  try {
268
- const s = await md.getUserMedia({ audio: { ...AUDIO_CONSTRAINTS }, video: false });
355
+ const s = await md.getUserMedia({ audio: { ...AUDIO_CONSTRAINTS, ...(o.deviceId ? { deviceId: { exact: o.deviceId } } : {}) }, video: false });
269
356
  return s.getAudioTracks()[0] || null;
270
357
  } catch (err) {
271
358
  (o.log || (() => {}))('mic-skip', { error: err && err.name });
@@ -0,0 +1,152 @@
1
+ // camera/conv-meter.js — the frame grab and the measuring loop behind auto-convergence, shared by
2
+ // a call's SENDER (it measures its own raw, pre-encode frames and sends the result to every peer:
3
+ // docs/call.md "Auto-convergence is measured by the sender") and a receiver's FALLBACK (an older
4
+ // sender that sends nothing; the depth cursor's copy). Internal: importable by path, not part of
5
+ // any entry. The measurement itself is camera/disparity.js.
6
+
7
+ import { lumaFromRgba, createDisparityTrack, createFocusTracker, downsampleLuma } from './disparity.js';
8
+
9
+ /** Sampling interval and the per-eye width of the measured copy: enough for sub-pixel disparity on a face, a few ms per measurement. */
10
+ export const CONV_METER_INTERVAL_MS = 200;
11
+ export const CONV_METER_EYE_WIDTH = 240;
12
+
13
+ /** WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion. */
14
+ export const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
15
+
16
+ const nowMs = () => (globalThis.performance ? performance.now() : Date.now());
17
+
18
+ /**
19
+ * A small grayscale copy of `video`'s current frame, per-eye width at most `eyeMax`.
20
+ *
21
+ * Preferred: WebCodecs. `new VideoFrame(video)` + an ASYNC `copyTo()`, reading the luma (Y) plane
22
+ * directly: no synchronous GPU->CPU canvas readback on the paint path (that readback, not the
23
+ * matching, was ~25 ms of a ~30 ms measurement). Otherwise a small canvas (`scratch`, reused) +
24
+ * `getImageData`. Resolves null for a frame that cannot be read (not decoded yet, tainted, a
25
+ * failed copy). Never rejects.
26
+ *
27
+ * `scale` = copy px per source px; `readMs` = main-thread time spent getting the pixels.
28
+ *
29
+ * @param {HTMLVideoElement} video
30
+ * @param {{eyeMax?: number, scratch?: HTMLCanvasElement|null}} [o]
31
+ * @returns {Promise<{img: Uint8Array, w: number, h: number, scale: number, via: 'videoframe'|'canvas', readMs: number}|null>}
32
+ */
33
+ export async function grabLumaFrame(video, { eyeMax = CONV_METER_EYE_WIDTH, scratch = null } = {}) {
34
+ const W = video ? video.videoWidth : 0;
35
+ const H = video ? video.videoHeight : 0;
36
+ if (!W || !H || (video.readyState || 0) < 2) return null;
37
+ if (typeof VideoFrame === 'function') {
38
+ let frame = null;
39
+ try {
40
+ frame = new VideoFrame(video);
41
+ } catch {
42
+ frame = null;
43
+ }
44
+ if (frame && Y_PLANE_FORMATS.has(frame.format)) {
45
+ try {
46
+ const fw = frame.visibleRect ? frame.visibleRect.width : frame.displayWidth;
47
+ const fh = frame.visibleRect ? frame.visibleRect.height : frame.displayHeight;
48
+ const buf = new Uint8Array(frame.allocationSize());
49
+ const layout = await frame.copyTo(buf);
50
+ const t0 = nowMs();
51
+ const f = Math.max(1, Math.ceil(fw / 2 / eyeMax));
52
+ const ds = downsampleLuma(buf, layout[0].offset, layout[0].stride, fw, fh, f);
53
+ return { img: ds.img, w: ds.w, h: ds.h, scale: ds.w / fw, via: 'videoframe', readMs: nowMs() - t0 };
54
+ } catch {
55
+ return null;
56
+ } finally {
57
+ frame.close();
58
+ }
59
+ }
60
+ if (frame) frame.close();
61
+ }
62
+ // Fallback: a small canvas + getImageData (synchronous readback).
63
+ const t0 = nowMs();
64
+ const s = Math.min(1, eyeMax / (W / 2));
65
+ const w = Math.max(2, Math.round((W * s) / 2) * 2);
66
+ const h = Math.max(2, Math.round(H * s));
67
+ const c = scratch || (globalThis.document ? document.createElement('canvas') : null);
68
+ if (!c) return null;
69
+ if (c.width !== w || c.height !== h) {
70
+ c.width = w;
71
+ c.height = h;
72
+ }
73
+ try {
74
+ const g = c.getContext('2d', { willReadFrequently: true });
75
+ g.drawImage(video, 0, 0, w, h);
76
+ const img = lumaFromRgba(g.getImageData(0, 0, w, h).data, w * h);
77
+ return { img, w, h, scale: w / W, via: 'canvas', readMs: nowMs() - t0 };
78
+ } catch {
79
+ return null; // a frame that cannot be read (tainted, not decoded yet): skip it
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The measuring loop: grab a copy at most every `intervalMs`, find the disparity of the point
85
+ * between the subject's eyes (camera/disparity.js focus tracker), median-of-3 it. A failed grab or
86
+ * measurement HOLDS the last value (a blink, a hand over the face). `reset()` forgets everything,
87
+ * including a grab still in flight.
88
+ *
89
+ * `sample(video, at)` resolves null when it did not sample (too soon, still busy, or reset while
90
+ * in flight), else `{ d, frac, luma, ncc, method, via, ms }`: `d` the held disparity in SOURCE px
91
+ * (left x − right x; null until a first lock), `frac = d / (videoWidth / 2)` (resolution-free, what
92
+ * goes on the wire), `luma` the copy (null if the grab failed), `ncc`/`method` of THIS measurement
93
+ * (null when it failed), `ms` = read + match time.
94
+ *
95
+ * @param {{intervalMs?: number, eyeMax?: number, grab?: typeof grabLumaFrame, now?: () => number}} [o]
96
+ */
97
+ export function createConvergenceMeter({ intervalMs = CONV_METER_INTERVAL_MS, eyeMax = CONV_METER_EYE_WIDTH, grab = grabLumaFrame, now = nowMs } = {}) {
98
+ const focus = createFocusTracker();
99
+ const track = createDisparityTrack();
100
+ let lastAt = -Infinity;
101
+ let busy = false;
102
+ let gen = 0;
103
+ let scratch = null;
104
+ const meter = {
105
+ get busy() {
106
+ return busy;
107
+ },
108
+ /** The held disparity (source px), null until a first lock. */
109
+ get d() {
110
+ return track.value;
111
+ },
112
+ async sample(video, at = now()) {
113
+ if (busy || at - lastAt < intervalMs) return null;
114
+ lastAt = at;
115
+ busy = true;
116
+ const g0 = gen;
117
+ try {
118
+ if (!scratch && globalThis.document && typeof document.createElement === 'function') scratch = document.createElement('canvas');
119
+ let got = null;
120
+ try {
121
+ got = await grab(video, { eyeMax, scratch });
122
+ } catch {
123
+ got = null;
124
+ }
125
+ if (g0 !== gen) return null; // reset() while the grab was in flight: a stale frame
126
+ const t = now();
127
+ const m = got ? focus.measure(got.img, got.w, got.h, at) : null;
128
+ const d = track.push(m ? m.d / got.scale : null);
129
+ const W = video && video.videoWidth;
130
+ return {
131
+ d,
132
+ frac: d !== null && W > 0 ? d / (W / 2) : null,
133
+ luma: got ? { img: got.img, w: got.w, h: got.h, scale: got.scale } : null,
134
+ ncc: m ? m.c : null,
135
+ method: m ? m.method : null,
136
+ via: got ? got.via : null,
137
+ ms: (got ? got.readMs : 0) + (now() - t),
138
+ };
139
+ } finally {
140
+ if (g0 === gen) busy = false;
141
+ }
142
+ },
143
+ reset() {
144
+ gen++;
145
+ busy = false;
146
+ lastAt = -Infinity;
147
+ focus.reset();
148
+ track.reset();
149
+ },
150
+ };
151
+ return meter;
152
+ }