@displayxr/inline3d 1.28.0 → 1.29.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.
@@ -1,17 +1,21 @@
1
1
  // inline3d-call.js — a 3D video call in any page, in one call.
2
2
  //
3
3
  // PREVIEW tier. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
4
- // Implements P1 of docs/rfcs/0002-video-call.md.
4
+ // Implements P1 of docs/rfcs/0002-video-call.md and the one-line path of RFC 0003 §1 (C1).
5
5
  //
6
- // import { createInline3D } from '@displayxr/inline3d';
7
- // import { addCall, dxrSignaling } from '@displayxr/inline3d/call';
8
- //
9
- // const wall = await createInline3D();
10
- // const call = await addCall(wall, document.getElementById('call'), {
11
- // signaling: dxrSignaling(), // hosted DisplayXR server; or dxrSignaling('wss://your.server')
12
- // });
6
+ // import { mountCall } from '@displayxr/inline3d/call';
7
+ // const call = await mountCall(document.getElementById('call')); // hosted signalling, auto camera, SDK UI
13
8
  // call.on('peer', ({ id }) => console.log('joined', id));
14
9
  //
10
+ // `mountCall(el, opts?)` is `addCall` with every argument optional: the wall is the document's
11
+ // shared manager (`sharedInline3D()` — never a second session next to the page's own), signalling
12
+ // defaults to the hosted server, the camera to 'auto', the chrome to on. `addCall(wall, container,
13
+ // opts)` stays as the explicit form for a page that manages its own wall. `<dxr-call>`
14
+ // (call/element.js) is the same thing as markup. A bundled app that wants 2D→3D imports
15
+ // `@displayxr/inline3d/call/full` (lift statically imported) or passes `mono3D: lift` itself —
16
+ // on the plain `/call` entry a mono peer on a 3D display raises `warning { code:
17
+ // 'lift-not-bundled' }` instead of silently staying flat.
18
+ //
15
19
  // WHAT IT OWNS. Unlike the other modules this one takes a CONTAINER, not a canvas: a call has a
16
20
  // variable number of participants, so it creates one persistent canvas per remote participant
17
21
  // (plus the self view) inside `container`, lays them out, and draws its own chrome around them.
@@ -67,7 +71,7 @@
67
71
  // SDK's teardown synchronously, so no listener can distinguish them afterwards); `leave()`
68
72
  // restores it.
69
73
 
70
- import { createInline3D } from './inline3d.js';
74
+ import { sharedInline3D } from './inline3d.js';
71
75
  import {
72
76
  WIRE_VERSION,
73
77
  CALL_SDK,
@@ -97,9 +101,12 @@ import { openCamera, openMic } from './call/capture.js';
97
101
  import { drawQr } from './call/qr.js';
98
102
  import { injectCallStyle, ICONS, el, show, resolveCallAccent, CALL_ACCENTS } from './call/ui.js';
99
103
  import { normalizeMono3D, resolveLift, createLiftPool, createFrameWatch } from './call/lift.js';
104
+ import { defineCallElement } from './call/element.js';
105
+ export { DxrCallElement, defineCallElement, attrsToOpts, CALL_EVENT_PREFIX } from './call/element.js';
100
106
 
101
107
  import { dxrSignaling } from './call/signaling.js';
102
- export { dxrSignaling, peerjsCloud, SIGNAL_PROTOCOL, roomKey, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
108
+ import { lumaFromRgba, createDisparityTrack, createFocusTracker, downsampleLuma } from './call/disparity.js';
109
+ export { dxrSignaling, SIGNAL_PROTOCOL, roomKey, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
103
110
  export {
104
111
  WIRE_VERSION,
105
112
  CALL_SDK,
@@ -114,6 +121,7 @@ export {
114
121
  CALL_ACCENTS,
115
122
  };
116
123
  export { createLiveGate } from './call/wire.js';
124
+ export { measureFocusDisparity } from './call/disparity.js';
117
125
  export { convergenceShiftPx, lowPass, clampShift, eyeCropRect, mirrorSwapOps, mirrorSwapPixels, maxBitrateKbps } from './call/wire.js';
118
126
  export { preferVideoCodecs, sortCodecCapabilities, VIDEO_CODEC_ORDER } from './call/sdp.js';
119
127
  export { MeshTransport, clampMaxPeers } from './call/transport.js';
@@ -137,6 +145,12 @@ const STATS_TICK_MS = 500;
137
145
  const SPEAKING_LEVEL = 0.02;
138
146
  const LAYER_RETRIES = 4;
139
147
  const LIFT_RETRIES = 2;
148
+ // Auto-convergence sampling: ~5 Hz on a copy whose eye is at most this wide (enough for sub-pixel
149
+ // disparity on a face; small enough to stay a few ms per measurement).
150
+ const AUTO_CONV_INTERVAL_MS = 200;
151
+ const AUTO_CONV_EYE_WIDTH = 240;
152
+ // WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion.
153
+ const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
140
154
  const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
141
155
  export const PLATE_TEXT = Object.freeze({
142
156
  unreachable: "Can't reach this participant — the network needs a relay (TURN)",
@@ -158,14 +172,19 @@ export function normalizeCallOptions(opts = {}) {
158
172
  }
159
173
  return {
160
174
  room,
161
- // Default: the hosted DisplayXR signalling server (which also mints TURN credentials).
162
- signaling: opts.signaling || dxrSignaling(),
175
+ // Default: the hosted DisplayXR signalling server (which also mints TURN credentials). A
176
+ // publishable key (RFC 0003 §5a) rides along on connect; the server may ignore it today.
177
+ signaling: opts.signaling || dxrSignaling(undefined, typeof opts.key === 'string' && opts.key ? { key: opts.key } : {}),
163
178
  iceServers: Array.isArray(opts.iceServers) ? opts.iceServers : undefined,
164
179
  camera: opts.camera === undefined ? 'auto' : opts.camera,
165
180
  format: opts.format === 'sbs' ? 'sbs' : opts.format === 'mono' ? 'mono' : undefined,
166
181
  calibration: opts.calibration && typeof opts.calibration === 'object' ? { ...opts.calibration } : {},
167
182
  rectify: typeof opts.rectify === 'function' ? opts.rectify : null,
168
183
  audio: opts.audio === undefined ? true : !!opts.audio,
184
+ // Auto-convergence (call/disparity.js): measure the disparity of the point between each SBS
185
+ // peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
186
+ // offset on top. Off = the pair as sent (plus any `hint`).
187
+ autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
169
188
  mono3D: normalizeMono3D(opts.mono3D),
170
189
  maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
171
190
  // Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
@@ -186,6 +205,8 @@ export function normalizeCallOptions(opts = {}) {
186
205
  scrollIntoView: opts.scrollIntoView === undefined ? true : !!opts.scrollIntoView,
187
206
  wallOptions: opts.wallOptions && typeof opts.wallOptions === 'object' ? opts.wallOptions : {},
188
207
  log: typeof opts.log === 'function' ? opts.log : opts.debug ? (tag, obj) => console.log(`${TAG} ${tag} ${JSON.stringify(obj)}`) : null,
208
+ // Internal (tests): replaces the lift module importer behind `mono3D: 'auto'`.
209
+ _liftImporter: typeof opts._liftImporter === 'function' ? opts._liftImporter : undefined,
189
210
  };
190
211
  }
191
212
 
@@ -201,13 +222,30 @@ export async function addCall(wall, container, opts = {}) {
201
222
  if (!container || typeof container.appendChild !== 'function') throw new TypeError('@displayxr/inline3d/call: addCall(wall, container, opts) needs a container element');
202
223
  const o = normalizeCallOptions(opts);
203
224
  if (!o.signaling || typeof o.signaling.join !== 'function') {
204
- throw new TypeError('@displayxr/inline3d/call: opts.signaling must be a SignalingAdapter — dxrSignaling([url]), peerjsCloud() (demo only), or your own');
225
+ throw new TypeError('@displayxr/inline3d/call: opts.signaling must be a SignalingAdapter — dxrSignaling([url]) or your own');
205
226
  }
206
227
  const call = new Call(wall, container, o);
207
228
  await call._init();
208
229
  return call.handle;
209
230
  }
210
231
 
232
+ /**
233
+ * The one-line path (RFC 0003 §1): `addCall` with every argument optional. The wall is
234
+ * `opts.wall` if given, else the document's shared manager — `sharedInline3D(opts.wallOptions)`,
235
+ * which returns the page's existing session rather than opening a second one (woven-canvas
236
+ * rule 1). Everything else defaults as `addCall` does: hosted signalling, `camera: 'auto'`,
237
+ * `ui: true`.
238
+ *
239
+ * @param {HTMLElement} el the container (a `<dxr-call>`, a `<div>`, …)
240
+ * @param {object} [opts] see call.d.ts `CallOptions`
241
+ */
242
+ export async function mountCall(el, opts = {}) {
243
+ if (!el || typeof el.appendChild !== 'function') throw new TypeError('@displayxr/inline3d/call: mountCall(el, opts) needs a container element');
244
+ const { wall: given, ...rest } = opts;
245
+ const wall = given || (await sharedInline3D(rest.wallOptions));
246
+ return addCall(wall, el, rest);
247
+ }
248
+
211
249
  class Call {
212
250
  constructor(wall, container, o) {
213
251
  this.o = o;
@@ -238,6 +276,8 @@ class Call {
238
276
  this.liftPool = null;
239
277
  this._liftP = null;
240
278
  this.liftProven = false;
279
+ this.liftMissing = false; // the lift MODULE failed to import (vs. a display that can't lift)
280
+ this._liftWarned = false;
241
281
  this._frameWatch = createFrameWatch();
242
282
  this.handle = this._makeHandle();
243
283
  if (this.wallLive) this._hookWall(this.wall);
@@ -272,6 +312,13 @@ class Call {
272
312
  this.emit('error', { code, message, error: err || null });
273
313
  }
274
314
 
315
+ /** Degraded, not broken (RFC 0003 §2): one console.warn (always — the page must see it) + an event. */
316
+ warning(code, message) {
317
+ this.log('warning', { code, message });
318
+ console.warn(`${TAG} ${code}: ${message}`);
319
+ this.emit('warning', { code, message });
320
+ }
321
+
275
322
  // ── setup ────────────────────────────────────────────────────────────────────────────────
276
323
 
277
324
  async _init() {
@@ -695,7 +742,9 @@ class Call {
695
742
  if (this.state === 'left') return;
696
743
  let w = null;
697
744
  try {
698
- w = await createInline3D(this.o.wallOptions);
745
+ // The shared manager: if the page has already re-opened its own wall, join it rather than
746
+ // opening a second session (rule 1); else this creates the document's new one.
747
+ w = await sharedInline3D(this.o.wallOptions);
699
748
  } catch {
700
749
  w = null;
701
750
  }
@@ -748,7 +797,7 @@ class Call {
748
797
  const lo = this.o.liftOptions;
749
798
  // liftCapabilities probes the same model source the lifts will use.
750
799
  const capsOpts = { webFallback: true, ...(lo && lo.models ? { models: lo.models } : {}) };
751
- this._liftP = resolveLift(this.mono3D, { log, capsOpts }).then((r) => {
800
+ this._liftP = resolveLift(this.mono3D, { log, capsOpts, ...(this.o._liftImporter ? { importer: this.o._liftImporter } : {}) }).then((r) => {
752
801
  this.liftApi = r;
753
802
  if (r.lift) {
754
803
  this.liftPool = createLiftPool({ lift: r.lift, max: this.o.maxLifted, log, options: this.o.liftOptions });
@@ -763,6 +812,12 @@ class Call {
763
812
  })
764
813
  .catch(() => {});
765
814
  }
815
+ } else if (r.reason === 'import-failed' || r.reason === 'no-lift-export') {
816
+ // The MODULE is missing (a bundled app whose bundler could not follow the computed
817
+ // import, or a copy of the SDK built without lift) — not a display that can't lift. Said
818
+ // loudly, once, when a mono peer actually arrives (_warnLiftMissing): a developer testing
819
+ // in the DisplayXR Browser sees it the first time a 2D caller joins.
820
+ this.liftMissing = true;
766
821
  } else if (r.reason !== 'off') {
767
822
  console.info(`${TAG} mono→3D unavailable (${r.reason}) — mono participants are shown flat`);
768
823
  }
@@ -774,6 +829,21 @@ class Call {
774
829
  return this._liftP;
775
830
  }
776
831
 
832
+ /**
833
+ * `warning { code: 'lift-not-bundled' }`, once per call: a mono peer is on a 3D display, lift
834
+ * was asked for ('auto'), and the lift module could not be imported. Names the fix.
835
+ */
836
+ _warnLiftMissing() {
837
+ if (this._liftWarned || !this.liftMissing || !this.mono3DOn) return;
838
+ this._liftWarned = true;
839
+ this.warning(
840
+ 'lift-not-bundled',
841
+ 'a 2D participant is on a 3D display but the 2D→3D module could not be imported from this build, so they stay flat. ' +
842
+ "Import it explicitly — `import { lift } from '@displayxr/inline3d/lift'; mountCall(el, { mono3D: lift })` — " +
843
+ "or use `@displayxr/inline3d/call/full`, which bundles it."
844
+ );
845
+ }
846
+
777
847
  /** Re-run the routing table for every mono tile (lift resolved, a slot freed, setMono3D). */
778
848
  _rerouteMono() {
779
849
  for (const t of this.tiles.values()) if (t.format && t.format !== 'sbs') t.reroute(false);
@@ -798,7 +868,7 @@ class Call {
798
868
  if (!this.mono3DOn) return 'Mono cameras: 2D (2D→3D is off).';
799
869
  const r = this.liftApi;
800
870
  if (!r) return 'Mono cameras: checking 2D→3D…';
801
- if (!r.lift) return 'Mono cameras: 2D (no 2D→3D provider here).';
871
+ if (!r.lift) return this.liftMissing ? 'Mono cameras: 2D→3D unavailable in this build.' : 'Mono cameras: 2D (no 2D→3D provider here).';
802
872
  const c = r.caps;
803
873
  if (c && c.native) return `Mono cameras: 2D→3D (native${c.provider ? `, ${c.provider}` : ''}).`;
804
874
  if (c && c.webFallback && c.webFallback.webgpu) return 'Mono cameras: 2D→3D (in this page, WebGPU).';
@@ -1219,6 +1289,17 @@ class Tile {
1219
1289
  this.speaking = false;
1220
1290
  this.quality = null;
1221
1291
  this.conv = createConvergence();
1292
+ this.autoTrack = createDisparityTrack();
1293
+ this.autoFocus = createFocusTracker();
1294
+ this.autoAt = 0;
1295
+ this.autoCanvas = null;
1296
+ this.autoLocked = false;
1297
+ this.autoStats = { n: 0, ms: 0, readMs: 0, method: null, via: null };
1298
+ this.autoPending = false;
1299
+ // Bumped on every stream change and on destroy(): an async copyTo() that resolves after either
1300
+ // belongs to a stream this tile no longer shows, and is dropped (review of #92).
1301
+ this.autoGen = 0;
1302
+ this.destroyed = false;
1222
1303
  this.conv.depth = call.depth;
1223
1304
  this.hintGate = rateGate(HINT_MAX_HZ);
1224
1305
  this.layerFails = 0;
@@ -1263,6 +1344,15 @@ class Tile {
1263
1344
  hello: this.hello,
1264
1345
  quality: this.quality,
1265
1346
  convergencePx: this.conv.current,
1347
+ // Auto-convergence: the measured disparity (source px, null until a lock) and the mean cost of
1348
+ // one measurement including the frame readback.
1349
+ autoConverge: {
1350
+ disparityPx: this.conv.measuredPx,
1351
+ ms: this.autoStats.n ? this.autoStats.ms / this.autoStats.n : null,
1352
+ readMs: this.autoStats.n ? this.autoStats.readMs / this.autoStats.n : null,
1353
+ method: this.autoStats.method,
1354
+ via: this.autoStats.via,
1355
+ },
1266
1356
  lift: this.lifted
1267
1357
  ? Object.freeze({
1268
1358
  live: this.liftLive,
@@ -1278,6 +1368,7 @@ class Tile {
1278
1368
  if (this.video.srcObject !== stream) {
1279
1369
  this.video.srcObject = stream;
1280
1370
  this.audio.srcObject = stream;
1371
+ this._resetAutoConverge(); // a new stream may be a new camera: measure afresh
1281
1372
  }
1282
1373
  this.video.play().catch(() => {});
1283
1374
  this.audio.play().catch(() => {});
@@ -1383,6 +1474,8 @@ class Tile {
1383
1474
  });
1384
1475
  // First mono peer on a woven wall and lift not asked for yet (e.g. setMono3D(true) later).
1385
1476
  if (this.format !== 'sbs' && call.woven && call.mono3DOn && !call._liftP) call._ensureLift();
1477
+ // A mono peer on a 3D display that stays flat because the lift MODULE is missing: say so.
1478
+ if (this.format !== 'sbs' && call.woven && call.liftMissing) call._warnLiftMissing();
1386
1479
  const key = `${r.route}|${this.format}`;
1387
1480
  if (!force && key === this.routedKey) return;
1388
1481
  this.routedKey = key;
@@ -1572,6 +1665,7 @@ class Tile {
1572
1665
  c.width = 2 * outW;
1573
1666
  c.height = outH;
1574
1667
  }
1668
+ if (this.call.o.autoConverge) this._sampleDisparity(v, W, H);
1575
1669
  const shift = this.conv.step(eyeW);
1576
1670
  const g = c.getContext('2d');
1577
1671
  for (const eye of [0, 1]) {
@@ -1580,7 +1674,114 @@ class Tile {
1580
1674
  }
1581
1675
  }
1582
1676
 
1677
+ /**
1678
+ * Auto-convergence: a few times a second, measure the disparity of the point between the remote
1679
+ * person's eyes on a small grayscale copy of the SOURCE frame (before our shift, so there is no
1680
+ * feedback loop) and hand it to the convergence state, which halves it per eye and low-passes it.
1681
+ * A failed measurement holds the last good value.
1682
+ */
1683
+ _sampleDisparity(v, W, H) {
1684
+ const now = performance.now();
1685
+ if (this.autoPending || now - this.autoAt < AUTO_CONV_INTERVAL_MS) return;
1686
+ this.autoAt = now;
1687
+ // Preferred: WebCodecs. `new VideoFrame(video)` + an ASYNC copyTo() of the decoded frame, reading
1688
+ // the luma (Y) plane directly: no synchronous GPU->CPU canvas readback on the paint path (that
1689
+ // readback, not the matching, was ~25 ms of a ~30 ms measurement).
1690
+ if (typeof VideoFrame === 'function') {
1691
+ let frame = null;
1692
+ try {
1693
+ frame = new VideoFrame(v);
1694
+ } catch {
1695
+ frame = null;
1696
+ }
1697
+ if (frame && Y_PLANE_FORMATS.has(frame.format)) {
1698
+ this.autoPending = true;
1699
+ const gen = this.autoGen;
1700
+ const fw = frame.visibleRect ? frame.visibleRect.width : frame.displayWidth;
1701
+ const fh = frame.visibleRect ? frame.visibleRect.height : frame.displayHeight;
1702
+ const buf = new Uint8Array(frame.allocationSize());
1703
+ frame
1704
+ .copyTo(buf)
1705
+ .then((layout) => {
1706
+ if (gen !== this.autoGen) return; // the stream changed or the tile is gone: stale frame
1707
+ const t0 = performance.now();
1708
+ const f = Math.max(1, Math.ceil(fw / 2 / AUTO_CONV_EYE_WIDTH));
1709
+ const ds = downsampleLuma(buf, layout[0].offset, layout[0].stride, fw, fh, f);
1710
+ this._applyMeasurement(ds.img, ds.w, ds.h, ds.w / fw, now, performance.now() - t0, 'videoframe');
1711
+ })
1712
+ .catch(() => {
1713
+ if (gen === this.autoGen) this._applyMeasurement(null, 0, 0, 1, now, 0, 'videoframe');
1714
+ })
1715
+ .finally(() => {
1716
+ frame.close();
1717
+ this.autoPending = false;
1718
+ });
1719
+ return;
1720
+ }
1721
+ if (frame) frame.close();
1722
+ }
1723
+ // Fallback: a small canvas + getImageData (synchronous readback).
1724
+ const s = Math.min(1, AUTO_CONV_EYE_WIDTH / (W / 2));
1725
+ const w = Math.max(2, Math.round((W * s) / 2) * 2);
1726
+ const h = Math.max(2, Math.round(H * s));
1727
+ if (!this.autoCanvas) this.autoCanvas = document.createElement('canvas');
1728
+ const c = this.autoCanvas;
1729
+ if (c.width !== w || c.height !== h) {
1730
+ c.width = w;
1731
+ c.height = h;
1732
+ }
1733
+ const g = c.getContext('2d', { willReadFrequently: true });
1734
+ let img = null;
1735
+ try {
1736
+ g.drawImage(v, 0, 0, w, h);
1737
+ img = lumaFromRgba(g.getImageData(0, 0, w, h).data, w * h);
1738
+ } catch {
1739
+ img = null; // a frame that cannot be read (tainted, not decoded yet): skip it
1740
+ }
1741
+ this._applyMeasurement(img, w, h, w / W, now, performance.now() - now, 'canvas');
1742
+ }
1743
+
1744
+ /**
1745
+ * Measure one grabbed frame and hand the disparity to the convergence state (which halves it per
1746
+ * eye and low-passes it). A failed measurement holds the last good value. `readMs` = main-thread
1747
+ * time spent getting the pixels.
1748
+ */
1749
+ _applyMeasurement(img, w, h, s, at, readMs, via) {
1750
+ if (this.destroyed) return;
1751
+ const t = performance.now();
1752
+ const m = img ? this.autoFocus.measure(img, w, h, at) : null;
1753
+ const d = this.autoTrack.push(m ? m.d / s : null);
1754
+ this.conv.measuredPx = d;
1755
+ this.autoStats.n++;
1756
+ this.autoStats.readMs += readMs;
1757
+ this.autoStats.ms += readMs + (performance.now() - t);
1758
+ this.autoStats.method = m ? m.method : null;
1759
+ this.autoStats.via = via;
1760
+ if (m && !this.autoLocked) {
1761
+ this.autoLocked = true;
1762
+ this.call.log('auto-converge', {
1763
+ peer: this.id,
1764
+ disparityPx: Math.round(d * 10) / 10,
1765
+ at: { x: Math.round(m.x / s), y: Math.round(m.y / s) },
1766
+ ncc: Math.round(m.c * 100) / 100,
1767
+ method: m.method,
1768
+ via,
1769
+ ms: Math.round(readMs + (performance.now() - t)),
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ _resetAutoConverge() {
1775
+ this.autoGen++;
1776
+ this.autoTrack.reset();
1777
+ this.autoFocus.reset();
1778
+ this.conv.measuredPx = null;
1779
+ this.autoLocked = false;
1780
+ }
1781
+
1583
1782
  destroy() {
1783
+ this.destroyed = true;
1784
+ this.autoGen++;
1584
1785
  clearTimeout(this.helloTimer);
1585
1786
  clearTimeout(this.layerTimer);
1586
1787
  clearTimeout(this.leaveTimer);
@@ -1693,3 +1894,8 @@ class SelfTile {
1693
1894
  }
1694
1895
  }
1695
1896
  }
1897
+
1898
+ // `<dxr-call>` — the one-line path as markup — is registered by importing this entry (RFC 0003 §1;
1899
+ // the ONE side effect of this module, declared in package.json `sideEffects`). A no-op where there
1900
+ // is no `customElements` registry (Node, workers) or the name is already defined.
1901
+ defineCallElement();