@displayxr/inline3d 1.47.6 → 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/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,39 @@ 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),
171
+ // This side can carry a SECOND video track, the content tile (3D app share). Absent (an older
172
+ // SDK) = false: the offerer then never adds the content transceiver for this pair.
173
+ content: msg.content === true,
141
174
  };
142
175
  }
143
176
 
144
- /** Build the `hello` this side sends. */
145
- export function makeHello({ format, width, height, baselineMm, hfovDeg, rectified } = {}) {
177
+ /**
178
+ * A display name as it may travel and be shown: a string, control characters removed (C0, DEL,
179
+ * C1, and the bidi / zero-width format controls that can disguise a name), whitespace collapsed
180
+ * and trimmed, at most `NAME_MAX_CHARS` characters (code points). Null for anything else or an
181
+ * empty result. Never throws: this reads data from another browser. Pure.
182
+ * @param {unknown} v
183
+ * @returns {string|null}
184
+ */
185
+ export function sanitizeName(v) {
186
+ if (typeof v !== 'string') return null;
187
+ // eslint-disable-next-line no-control-regex
188
+ const clean = v.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, ' ').replace(/\s+/g, ' ').trim();
189
+ if (!clean) return null;
190
+ const cps = Array.from(clean);
191
+ return cps.length > NAME_MAX_CHARS ? cps.slice(0, NAME_MAX_CHARS).join('').trim() : clean;
192
+ }
193
+
194
+ /**
195
+ * Build the `hello` this side sends. `conv: true` (advertised for a side-by-side sender only)
196
+ * says this side sends `conv` messages.
197
+ */
198
+ export function makeHello({ format, width, height, baselineMm, hfovDeg, rectified, conv, name, content } = {}) {
146
199
  const h = {
147
200
  type: 'hello',
148
201
  v: WIRE_VERSION,
@@ -154,9 +207,151 @@ export function makeHello({ format, width, height, baselineMm, hfovDeg, rectifie
154
207
  };
155
208
  if (Number.isFinite(baselineMm)) h.baselineMm = baselineMm;
156
209
  if (Number.isFinite(hfovDeg)) h.hfovDeg = hfovDeg;
210
+ if (h.format === 'sbs' && conv) h.conv = true;
211
+ const n = sanitizeName(name);
212
+ if (n) h.name = n;
213
+ if (content) h.content = true;
157
214
  return h;
158
215
  }
159
216
 
217
+ // ── content (3D app share: a second, per-peer video track) ────────────────────────────────
218
+
219
+ /** A content stream's size is at most this on either side (a receiver ignores anything bigger). */
220
+ export const CONTENT_MAX_DIM = 16384;
221
+
222
+ /**
223
+ * `content {on, format, width, height, convergence: {known, frac}, appName?}`: what this side is
224
+ * sharing on its content track (docs/call.md § Sharing a 3D app). `on: false` = stopped (only `on`
225
+ * is sent). `convergence.known` = the frames are display-referred and `frac` IS the disparity of
226
+ * the sender's display plane as a fraction of the per-eye width (`zeroDisparityShiftFrac`, 0 for
227
+ * a DisplayXR app in v1): a receiver applies it and never measures. Pure.
228
+ */
229
+ export function makeContent({ on = true, format, width, height, convergence, appName } = {}) {
230
+ if (!on) return { type: 'content', on: false };
231
+ const fmt = CALL_FORMATS.includes(format) ? format : 'sbs';
232
+ const dim = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 1 && v <= CONTENT_MAX_DIM ? Math.round(v) : 0);
233
+ const known = !!(convergence && convergence.known);
234
+ const f = known ? convergence.frac : null;
235
+ const m = {
236
+ type: 'content',
237
+ on: true,
238
+ format: fmt,
239
+ width: dim(width),
240
+ height: dim(height),
241
+ convergence: known ? { known: true, frac: typeof f === 'number' && Number.isFinite(f) ? Math.max(-CONV_MAX_FRAC, Math.min(CONV_MAX_FRAC, f)) : 0 } : { known: false, frac: null },
242
+ };
243
+ const n = sanitizeName(appName);
244
+ if (n) m.appName = n;
245
+ return m;
246
+ }
247
+
248
+ /**
249
+ * Validate an incoming `content`. Null for anything that is not one. `on` must be `true` to mean
250
+ * on; a bad `format` reads as `'mono'` (never assume more than was said); sizes outside
251
+ * 1..16384 read as null; a `convergence.frac` that is not finite or beyond ±CONV_MAX_FRAC makes
252
+ * the convergence UNKNOWN; `appName` is sanitised (≤ 64 characters). Never throws: this reads data
253
+ * from another browser.
254
+ */
255
+ export function normalizeContent(msg) {
256
+ if (!msg || typeof msg !== 'object' || msg.type !== 'content') return null;
257
+ if (msg.on !== true) return { type: 'content', on: false, format: null, width: null, height: null, convergence: { known: false, frac: null }, appName: null };
258
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 1 && v <= CONTENT_MAX_DIM ? v : null);
259
+ let convergence = { known: false, frac: null };
260
+ try {
261
+ const c = msg.convergence;
262
+ if (c && typeof c === 'object' && c.known === true && typeof c.frac === 'number' && Number.isFinite(c.frac) && Math.abs(c.frac) <= CONV_MAX_FRAC) convergence = { known: true, frac: c.frac };
263
+ } catch {
264
+ /* a hostile getter */
265
+ }
266
+ return {
267
+ type: 'content',
268
+ on: true,
269
+ format: CALL_FORMATS.includes(msg.format) ? msg.format : 'mono',
270
+ width: num(msg.width),
271
+ height: num(msg.height),
272
+ convergence,
273
+ appName: sanitizeName(msg.appName),
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Wrap a page's app payload: `{ type: 'app', d: data }` serialised, or null when `data` cannot be
279
+ * serialised (undefined, a function, a cycle, a BigInt) or the message is over `APP_MAX_BYTES`
280
+ * (UTF-8). Returns the JSON string the data channel carries. Pure.
281
+ * @param {unknown} data
282
+ * @returns {{ json: string, bytes: number } | null}
283
+ */
284
+ export function makeAppMessage(data) {
285
+ if (data === undefined || typeof data === 'function') return null;
286
+ let json;
287
+ try {
288
+ json = JSON.stringify({ type: 'app', d: data });
289
+ } catch {
290
+ return null;
291
+ }
292
+ if (typeof json !== 'string') return null;
293
+ const bytes = utf8Length(json);
294
+ return bytes > APP_MAX_BYTES ? null : { json, bytes };
295
+ }
296
+
297
+ /** An incoming `app` message: `{ data }`, or null when it is not one. Only this type reaches a page. */
298
+ export function normalizeApp(msg) {
299
+ if (!msg || typeof msg !== 'object' || msg.type !== 'app' || !('d' in msg)) return null;
300
+ return { data: msg.d };
301
+ }
302
+
303
+ function utf8Length(s) {
304
+ if (typeof TextEncoder === 'function') return new TextEncoder().encode(s).length;
305
+ let n = 0;
306
+ for (const ch of s) {
307
+ const c = ch.codePointAt(0);
308
+ n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
309
+ }
310
+ return n;
311
+ }
312
+
313
+ /**
314
+ * A sliding-window counter: `gate(nowMs)` is true at most `max` times in any `windowMs`. The app
315
+ * channel's per-peer cap (a burst of `max` is fine; the next one waits for the window). Pure.
316
+ */
317
+ export function windowGate(max, windowMs = 1000) {
318
+ const at = [];
319
+ return (now) => {
320
+ while (at.length && now - at[0] >= windowMs) at.shift();
321
+ if (at.length >= max) return false;
322
+ at.push(now);
323
+ return true;
324
+ };
325
+ }
326
+
327
+ /**
328
+ * `conv {d, c?}`: the sender's measured disparity of the point between its subject's eyes, as a
329
+ * FRACTION of its per-eye width (resolution-free: a receiver multiplies by the width of the frame it
330
+ * decodes), `null` = nothing measured yet (the receiver holds what it had). `c` = the match
331
+ * confidence (NCC, 0..1), informational.
332
+ * @param {number|null} frac
333
+ * @param {number|null} [ncc]
334
+ */
335
+ export function makeConv(frac, ncc) {
336
+ const m = { type: 'conv', d: typeof frac === 'number' && Number.isFinite(frac) ? Math.max(-CONV_MAX_FRAC, Math.min(CONV_MAX_FRAC, frac)) : null };
337
+ if (typeof ncc === 'number' && Number.isFinite(ncc)) m.c = Math.max(0, Math.min(1, ncc));
338
+ return m;
339
+ }
340
+
341
+ /**
342
+ * Validate an incoming `conv`. Null for anything that is not one, or whose `d` is neither null nor
343
+ * a finite number within ±CONV_MAX_FRAC. `c` is clamped to 0..1, or dropped. Never throws: this
344
+ * reads data from another browser.
345
+ */
346
+ export function normalizeConv(msg) {
347
+ if (!msg || typeof msg !== 'object' || msg.type !== 'conv') return null;
348
+ const d = msg.d;
349
+ if (d !== null && !(typeof d === 'number' && Number.isFinite(d) && Math.abs(d) <= CONV_MAX_FRAC)) return null;
350
+ const out = { type: 'conv', d };
351
+ if (typeof msg.c === 'number' && Number.isFinite(msg.c)) out.c = Math.max(0, Math.min(1, msg.c));
352
+ return out;
353
+ }
354
+
160
355
  /** `hint {subjectZmm}`: a number in range, or null (ignored). */
161
356
  export function normalizeHint(msg) {
162
357
  if (!msg || msg.type !== 'hint') return null;
@@ -173,11 +368,14 @@ export function normalizeState(msg) {
173
368
  }
174
369
 
175
370
  /**
176
- * A minimum-interval gate: `gate(nowMs)` is true at most `hz` times a second. Used on both ends
371
+ * A minimum-interval gate: `gate(nowMs)` is true at most `hz` times a second (`slackMs` shortens
372
+ * the interval by that much). Used on both ends
177
373
  * of `hint` so a chatty (or hostile) sender cannot drive the receiver's convergence at frame rate.
178
374
  */
179
- export function rateGate(hz) {
180
- const minMs = 1000 / hz;
375
+ export function rateGate(hz, slackMs = 0) {
376
+ // `slackMs`: a timer-paced caller at exactly `hz` (the sender's `conv` tick) lands a few ms early
377
+ // now and then; without slack every such tick is refused and the rate halves.
378
+ const minMs = 1000 / hz - Math.max(0, slackMs);
181
379
  let last = -Infinity;
182
380
  return (now) => {
183
381
  if (now - last < minMs) return false;
@@ -186,6 +384,29 @@ export function rateGate(hz) {
186
384
  };
187
385
  }
188
386
 
387
+ // ── connection quality ─────────────────────────────────────────────────────────────────────
388
+
389
+ /**
390
+ * The selected ICE candidate pair of one getStats() report: `{ path, rttMs }`. `path` is
391
+ * `'relay'` when either end is relayed (TURN), else the local candidate's type (`'host'` /
392
+ * `'srflx'` / `'prflx'`); null when no pair is selected yet. Chrome names the pair on the
393
+ * `transport` stats; Firefox flags it `selected`. Pure: reads an RTCStatsReport (maplike).
394
+ */
395
+ export function selectedPath(report) {
396
+ const all = [];
397
+ report.forEach((s) => all.push(s));
398
+ const byId = (id) => (id && typeof report.get === 'function' ? report.get(id) : all.find((s) => s.id === id)) || null;
399
+ const tr = all.find((s) => s.type === 'transport' && s.selectedCandidatePairId);
400
+ 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');
401
+ if (!pair) return { path: null, rttMs: null };
402
+ const lc = byId(pair.localCandidateId);
403
+ const rc = byId(pair.remoteCandidateId);
404
+ const lt = lc ? lc.candidateType : null;
405
+ const path = lt === 'relay' || (rc && rc.candidateType === 'relay') ? 'relay' : ['host', 'srflx', 'prflx'].includes(lt) ? lt : null;
406
+ const rtt = typeof pair.currentRoundTripTime === 'number' ? Math.round(pair.currentRoundTripTime * 1000) : null;
407
+ return { path, rttMs: rtt };
408
+ }
409
+
189
410
  // ── routing (RFC §3) ───────────────────────────────────────────────────────────────────────
190
411
 
191
412
  /**
@@ -0,0 +1,54 @@
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
+ /** `deviceId` prefix of an app-share device (cameras use `dxr-stereo:`). */
17
+ export const APP_SHARE_ID_PREFIX = 'dxr-app:';
18
+
19
+ /** The label of an app-share device; group 1 is the app's name. */
20
+ export const APP_SHARE_LABEL_RE = /^DisplayXR: (.+) \(3D\)$/;
21
+
22
+ /** The app name an app-share label carries, or null. Pure. */
23
+ export function appNameFromLabel(label) {
24
+ const m = typeof label === 'string' ? APP_SHARE_LABEL_RE.exec(label.trim()) : null;
25
+ return m ? m[1].trim() || null : null;
26
+ }
27
+
28
+ /**
29
+ * Is this an app-share device rather than a camera? Takes a `MediaDeviceInfo` (or any
30
+ * `{ label, deviceId }`), a label string, a track's settings (`{ displayxrStereo }`), or a
31
+ * `MediaStreamTrack` (its label and settings). True on ANY of: the label shape, the `dxr-app:` id
32
+ * prefix, or `displayxrStereo.source === 'app'`. Never throws. Pure.
33
+ * @param {unknown} x
34
+ * @returns {boolean}
35
+ */
36
+ export function isAppShareDevice(x) {
37
+ if (!x) return false;
38
+ if (typeof x === 'string') return APP_SHARE_LABEL_RE.test(x.trim()) || x.startsWith(APP_SHARE_ID_PREFIX);
39
+ if (typeof x !== 'object') return false;
40
+ const o = /** @type {any} */ (x);
41
+ try {
42
+ if (typeof o.label === 'string' && APP_SHARE_LABEL_RE.test(o.label.trim())) return true;
43
+ if (typeof o.deviceId === 'string' && o.deviceId.startsWith(APP_SHARE_ID_PREFIX)) return true;
44
+ const hint = o.displayxrStereo || (typeof o.getSettings === 'function' ? (o.getSettings() || {}).displayxrStereo : null);
45
+ if (hint && typeof hint === 'object' && hint.source === 'app') return true;
46
+ if (typeof o.getSettings === 'function') {
47
+ const id = (o.getSettings() || {}).deviceId;
48
+ if (typeof id === 'string' && id.startsWith(APP_SHARE_ID_PREFIX)) return true;
49
+ }
50
+ } catch {
51
+ /* a hostile getter: not an app */
52
+ }
53
+ return false;
54
+ }