@displayxr/inline3d 1.47.6 → 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.
@@ -85,7 +85,7 @@
85
85
 
86
86
  import { sharedInline3D } from './inline3d.js';
87
87
  import * as wire from './call/wire.js';
88
- import { DEFAULT_MAX_PEERS, HINT_MAX_HZ, newPeerId, parseInviteLink, normalizeHint, normalizeState, rateGate, createConvergence, eyeOutputSize, backoffMs } from './call/wire.js';
88
+ import { DEFAULT_MAX_PEERS, HINT_MAX_HZ, CONV_MAX_HZ, CONV_RECV_MAX_HZ, CONV_HEARTBEAT_MS, CONV_FRESH_MS, APP_MAX_PER_SEC, APP_RECV_MAX_PER_SEC, newPeerId, parseInviteLink, normalizeHint, normalizeState, normalizeConv, makeConv, makeAppMessage, normalizeApp, windowGate, selectedPath, sanitizeName, rateGate, createConvergence, eyeOutputSize, backoffMs } from './call/wire.js';
89
89
  import { MeshTransport as MeshTransportImpl, clampMaxPeers as clampMaxPeersImpl } from './call/transport.js';
90
90
  import { openMic } from './camera/capture.js';
91
91
  import { openCamera, addCameraView, isStereoCamera } from './inline3d-camera.js';
@@ -98,7 +98,8 @@ import * as signalingMod from './call/signaling.js';
98
98
  import { injectCallStyle, ICONS, el, show, CALL_ACCENTS as CALL_ACCENTS_IMPL, createStrings, CALL_STRINGS } from './call/ui.js';
99
99
  import { normalizeCallOptions as normalizeOptions } from './call/options.js';
100
100
  import { defineCallElement } from './call/element.js';
101
- import { lumaFromRgba, createDisparityTrack, createFocusTracker, downsampleLuma } from './call/disparity.js';
101
+ import { createDisparityTrack, createFocusTracker } from './call/disparity.js';
102
+ import { grabLumaFrame, createConvergenceMeter } from './camera/conv-meter.js';
102
103
  import { dxrSignaling } from './call/signaling.js';
103
104
 
104
105
  // ── the public surface (RFC 0003 §2) ────────────────────────────────────────────────────────
@@ -119,8 +120,6 @@ const AUTO_CONV_INTERVAL_MS = 200;
119
120
  const AUTO_CONV_EYE_WIDTH = 240;
120
121
  // While a depth cursor hovers a stereo tile, its source is copied this often (the cursor measures it).
121
122
  const CURSOR_SAMPLE_MS = 66;
122
- // WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion.
123
- const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
124
123
  /**
125
124
  * Put a 3D video call in `container`. Resolves once the camera is open and (with `ui:true`) the
126
125
  * lobby is showing; with `ui:false` (or `autoJoin:true`) once the call is joined.
@@ -177,9 +176,25 @@ class Call {
177
176
  this.depth = 0;
178
177
  this.speakerId = null;
179
178
  this.transport = null;
179
+ // Headless hooks (docs/call.md § Headless mode): this side's name, the signalling state, the
180
+ // self view switch, the audio output every tile plays to, and the app channel's per-peer gates.
181
+ this.name = o.name;
182
+ this.signalState = null;
183
+ this.selfViewOn = o.selfView;
184
+ this.sinkId = null;
185
+ this._appOut = new Map();
186
+ this._appIn = new Map();
180
187
  this._raf = 0;
181
188
  this._statsTimer = 0;
182
189
  this._hintGate = rateGate(HINT_MAX_HZ);
190
+ // Sender-side auto-convergence (docs/call.md): this side measures its OWN raw camera frames
191
+ // (before encoding) and broadcasts `conv`, so every receiver applies one good measurement
192
+ // instead of each measuring a compressed, possibly downscaled copy. The timer paces it.
193
+ this._convGate = rateGate(CONV_MAX_HZ, 20); // paced by a 5 Hz timer: 20 ms of slack for its jitter (rateGate)
194
+ this._convMeter = createConvergenceMeter({ intervalMs: 0, grab: o._convGrab });
195
+ this._convOut = { frac: null, sentAt: 0, msg: null };
196
+ this._convGen = 0;
197
+ this._convTimer = 0;
183
198
  // mono→3D (call/lift.js): the option, whether it is on right now (setMono3D), the resolved
184
199
  // lift (null until resolved), the pool of lifted streams, and the web-provider frame watch.
185
200
  this.mono3D = o.mono3D;
@@ -256,7 +271,7 @@ class Call {
256
271
  let camError = null;
257
272
  const given = isStereoCamera(want) ? want : null;
258
273
  try {
259
- cam = given || (await openCamera({ prefer: want, format: format || this.o.format, calibration: this.o.calibration, rectify: this.o.rectify || undefined, log }));
274
+ cam = given || (await openCamera({ prefer: want, format: format || this.o.format, calibration: this.o.calibration, rectify: this.o.rectify || undefined, exclude: this.o.cameraExclude, log }));
260
275
  if (given && given.state !== 'live') throw Object.assign(new Error('the camera given is not live'), { code: 'no-camera', skipped: [] });
261
276
  } catch (err) {
262
277
  camError = err;
@@ -330,6 +345,8 @@ class Call {
330
345
  baselineMm: l.calibration?.baselineMm,
331
346
  hfovDeg: l.calibration?.hfovDeg,
332
347
  rectified: !!l.calibration?.rectified,
348
+ conv: this.o.sendConvergence && l.format === 'sbs',
349
+ name: this.name,
333
350
  });
334
351
  }
335
352
 
@@ -382,6 +399,7 @@ class Call {
382
399
  onGhost: (gid, on) => this._onGhost(gid, on),
383
400
  onSignalingState: (st, err) => {
384
401
  this.log('signaling', { state: st });
402
+ this._setSignal(st, err);
385
403
  if (st === 'closed' && err) this.error(err.code || 'signaling-closed', err.message, err);
386
404
  },
387
405
  });
@@ -397,6 +415,7 @@ class Call {
397
415
  throw err;
398
416
  }
399
417
  this._setState('in-call');
418
+ this._setSignal('connected');
400
419
  // The grid is sized so the whole call block fits ONE viewport, but only if it starts at the
401
420
  // top: a tile below the fold is withheld from the weave (browser#167), so its layer goes
402
421
  // live late. Bring the block into view once, on join.
@@ -409,6 +428,7 @@ class Call {
409
428
  }
410
429
  }
411
430
  this._startStats();
431
+ this._startConvSend();
412
432
  this._pagehide = () => this.leave('pagehide');
413
433
  globalThis.addEventListener?.('pagehide', this._pagehide);
414
434
  this.log('joined', { id: this.id, peers: this.tiles.size });
@@ -492,6 +512,8 @@ class Call {
492
512
  if (msg.type === '__open') {
493
513
  this.transport.send(id, this.hello());
494
514
  this.transport.send(id, this.stateMsg());
515
+ // A peer that joins mid-call gets the current convergence at once, not at the next heartbeat.
516
+ if (this._convOut.msg && this._convSending()) this.transport.send(id, this._convOut.msg);
495
517
  tile.onChannelOpen();
496
518
  return;
497
519
  }
@@ -501,6 +523,167 @@ class Call {
501
523
  if (hint) return tile.setHint(hint);
502
524
  const st = normalizeState(msg);
503
525
  if (st) return tile.setRemoteState(st);
526
+ const cv = normalizeConv(msg);
527
+ if (cv) return tile.setConv(cv);
528
+ // An app message (handle.send / broadcast). The ONLY type that reaches the page: everything
529
+ // above is the SDK's own and never surfaces. Rate-capped per peer on receipt too.
530
+ const app = normalizeApp(msg);
531
+ if (app) {
532
+ let gate = this._appIn.get(id);
533
+ if (!gate) this._appIn.set(id, (gate = windowGate(APP_RECV_MAX_PER_SEC)));
534
+ if (gate(performance.now())) this.emit('message', { from: id, data: app.data });
535
+ }
536
+ }
537
+
538
+ /** The signalling connection's state for `handle.signal` + the `signal` event (transitions only). */
539
+ _setSignal(st, err) {
540
+ if (st === this.signalState) return;
541
+ this.signalState = st;
542
+ const ev = { state: st };
543
+ if (err && err.code) ev.code = err.code;
544
+ this.emit('signal', ev);
545
+ }
546
+
547
+ // ── headless hooks: names, app messages, devices, the self view ─────────────────────────
548
+
549
+ setName(n) {
550
+ this.name = sanitizeName(n);
551
+ this.transport?.broadcast(this.hello());
552
+ return this.name;
553
+ }
554
+
555
+ /** One app message to one peer: false when not connected, too big, not serialisable, or over its rate. */
556
+ sendApp(peerId, data) {
557
+ if (!this.transport) return false;
558
+ const m = makeAppMessage(data);
559
+ if (!m) return false;
560
+ return this._sendAppTo(peerId, m.json);
561
+ }
562
+
563
+ /** The same message to every peer; true only if every connected peer got it. */
564
+ broadcastApp(data) {
565
+ if (!this.transport) return false;
566
+ const m = makeAppMessage(data);
567
+ if (!m) return false;
568
+ let any = false;
569
+ let all = true;
570
+ for (const id of this.tiles.keys()) {
571
+ if (this.transport.peerState?.(id) === null) continue; // a ghost / a peer the mesh does not hold
572
+ any = true;
573
+ if (!this._sendAppTo(id, m.json)) all = false;
574
+ }
575
+ return any && all;
576
+ }
577
+
578
+ _sendAppTo(id, json) {
579
+ if (!this.tiles.has(id)) return false;
580
+ let gate = this._appOut.get(id);
581
+ if (!gate) this._appOut.set(id, (gate = windowGate(APP_MAX_PER_SEC)));
582
+ if (!gate(performance.now())) return false;
583
+ return this.transport.sendRaw ? this.transport.sendRaw(id, json) : this.transport.send(id, JSON.parse(json));
584
+ }
585
+
586
+ /** Switch the microphone to `deviceId` (null = the default). False (old mic kept) when it cannot open. */
587
+ async setMic(deviceId) {
588
+ const track = await openMic({ deviceId: deviceId || undefined, log: (t, x) => this.log(t, x) });
589
+ if (!track) return false;
590
+ const l = this.local;
591
+ if (!l) {
592
+ track.stop();
593
+ return false;
594
+ }
595
+ const old = l.audioTrack;
596
+ track.enabled = !this.muted;
597
+ l.audioTrack = track;
598
+ l.stream = new MediaStream([l.videoTrack, track].filter(Boolean));
599
+ if (this.transport) await this.transport.replaceTrack('audio', track, l.stream);
600
+ if (old && old !== track) old.stop();
601
+ this.log('mic', { deviceId: deviceId || null });
602
+ return true;
603
+ }
604
+
605
+ /** Every participant's audio to `sinkId` (also tiles that join later). True if every tile took it. */
606
+ async setAudioOutput(sinkId) {
607
+ this.sinkId = sinkId || '';
608
+ const results = await Promise.all([...this.tiles.values()].map((t) => t.applySink()));
609
+ return results.every(Boolean);
610
+ }
611
+
612
+ setSelfView(on) {
613
+ const want = on === undefined ? !this.selfViewOn : !!on;
614
+ if (want === this.selfViewOn) return want;
615
+ this.selfViewOn = want;
616
+ if (want) {
617
+ if (!this.self) this.self = new SelfTile(this, this.ui.selfSlot);
618
+ show(this.ui.selfSlot, true);
619
+ show(this.self.el, true);
620
+ this.self.update();
621
+ } else if (this.self) {
622
+ // Remove the LAYER first (never display:none over a live registration), then hide the box.
623
+ this.self.detach();
624
+ show(this.self.el, false);
625
+ }
626
+ this.log('self-view', { on: want });
627
+ return want;
628
+ }
629
+
630
+ // ── sender-side auto-convergence ─────────────────────────────────────────────────────────
631
+
632
+ _startConvSend() {
633
+ this._stopConvSend();
634
+ this._convTimer = setInterval(() => this._tickConv(), AUTO_CONV_INTERVAL_MS);
635
+ }
636
+
637
+ _stopConvSend() {
638
+ clearInterval(this._convTimer);
639
+ this._convTimer = 0;
640
+ }
641
+
642
+ /** Forget the measurement (a new camera, leaving): the next `conv` is a fresh one. */
643
+ _resetConvSend() {
644
+ this._convGen++;
645
+ this._convMeter.reset();
646
+ this._convOut = { frac: null, sentAt: 0, msg: null };
647
+ }
648
+
649
+ /** Is this side measuring and sending its convergence right now? */
650
+ _convSending() {
651
+ const l = this.local;
652
+ return !!(this.transport && this.o.sendConvergence && l && l.format === 'sbs' && l.videoTrack && !this.camOff && l.video);
653
+ }
654
+
655
+ /**
656
+ * One sample of this side's raw camera frame (`local.video` plays the camera's own stream: the
657
+ * pre-encode, unmirrored pair; the self view's mirroring is a paint, never on this video). Runs
658
+ * whatever the self view does, including `selfView: false`. Broadcasts `conv` when the gate
659
+ * passes and the value moved by a quarter source pixel, or the heartbeat is due.
660
+ */
661
+ _tickConv() {
662
+ if (!this._convSending() || this._convMeter.busy) return;
663
+ const v = this.local.video;
664
+ const gen = this._convGen;
665
+ this._convMeter
666
+ .sample(v, performance.now())
667
+ .then((r) => {
668
+ if (!r || gen !== this._convGen || !this._convSending()) return;
669
+ // Gate on the SEND time (what a receiver's own gate sees). A send the gate refuses is not
670
+ // lost: the value still differs from the last one sent, so the next tick sends it.
671
+ const now = performance.now();
672
+ const out = this._convOut;
673
+ const eyeW = (v.videoWidth || 0) / 2;
674
+ const minStep = eyeW > 0 ? 0.25 / eyeW : 0;
675
+ const moved = (r.frac === null) !== (out.frac === null) || (r.frac !== null && Math.abs(r.frac - out.frac) >= minStep);
676
+ const due = !out.msg || now - out.sentAt >= CONV_HEARTBEAT_MS;
677
+ if ((!moved && !due) || !this._convGate(now)) return;
678
+ const msg = makeConv(r.frac, r.ncc);
679
+ this.transport.broadcast(msg);
680
+ this._convOut = { frac: r.frac, sentAt: now, msg };
681
+ if (r.frac !== null && !this._convLogged) {
682
+ this._convLogged = true;
683
+ this.log('send-conv', { frac: Math.round(r.frac * 1e4) / 1e4, disparityPx: Math.round(r.d * 10) / 10, method: r.method, via: r.via, ms: Math.round(r.ms) });
684
+ }
685
+ })
686
+ .catch(() => {});
504
687
  }
505
688
 
506
689
  // ── handle ops ─────────────────────────────────────────────────────────────────────────
@@ -541,6 +724,7 @@ class Call {
541
724
  async setCamera(idOrStream, { format } = {}) {
542
725
  const before = this.local?.format;
543
726
  const l = await this._openMedia(idOrStream, format, { keepOnFail: true });
727
+ this._resetConvSend(); // a new camera: measure afresh, before the hello that announces it
544
728
  if (this.transport) {
545
729
  await this.transport.replaceTrack('video', l.videoTrack, l.stream);
546
730
  await this.transport.setSendFormat(l.format);
@@ -569,6 +753,8 @@ class Call {
569
753
  globalThis.removeEventListener?.('pagehide', this._pagehide);
570
754
  this.transport?.stop();
571
755
  this.transport = null;
756
+ this._stopConvSend();
757
+ this._resetConvSend();
572
758
  for (const t of this.tiles.values()) t.destroy();
573
759
  this.tiles.clear();
574
760
  this.liftPool?.releaseAll();
@@ -585,6 +771,9 @@ class Call {
585
771
  this.local.audioTrack = null;
586
772
  }
587
773
  this.self?.detach(); // a rejoin re-attaches it
774
+ this.signalState = null;
775
+ this._appOut.clear();
776
+ this._appIn.clear();
588
777
  this._unhookWall();
589
778
  this._setState('left');
590
779
  this._layout();
@@ -863,6 +1052,7 @@ class Call {
863
1052
  if (s.type === 'codec') codecs[s.id] = s.mimeType;
864
1053
  });
865
1054
  const q = { in: null, out: null };
1055
+ if (withQuality) Object.assign(q, selectedPath(report));
866
1056
  report.forEach((s) => {
867
1057
  if (s.type === 'inbound-rtp' && s.kind === 'audio' && typeof s.audioLevel === 'number') tile.level = Math.max(s.audioLevel, (tile.level || 0) * 0.6);
868
1058
  if (s.type === 'media-source' && s.kind === 'audio' && typeof s.audioLevel === 'number') localLevel = Math.max(localLevel, s.audioLevel);
@@ -893,7 +1083,7 @@ class Call {
893
1083
  tile._prevOut = { bytes: s.bytesSent, t: s.timestamp };
894
1084
  }
895
1085
  });
896
- if (withQuality && (q.in || q.out)) {
1086
+ if (withQuality && (q.in || q.out || q.path)) {
897
1087
  tile.quality = q;
898
1088
  this.emit('quality', { id, ...q });
899
1089
  }
@@ -916,6 +1106,7 @@ class Call {
916
1106
  this._selfSpeaking = speaking;
917
1107
  this.transport?.broadcast(this.stateMsg());
918
1108
  this.self?.setSpeaking(speaking);
1109
+ this.emit('speaking', { id: 'self', on: speaking });
919
1110
  }
920
1111
  }
921
1112
 
@@ -962,6 +1153,9 @@ class Call {
962
1153
  const t = this.t;
963
1154
  injectCallStyle(c.ownerDocument || document);
964
1155
  c.classList.add('dxr-call-host');
1156
+ // layout:'none': the footer and self slot step aside too (display:contents), so the page can
1157
+ // place the self tile ([data-dxr-peer="self"]) — style it in place, never move it.
1158
+ c.classList.toggle('dxr-call-host--none', this.o.layout === 'none');
965
1159
  // theme → CSS custom properties on the host (chrome only: no variable can reach a tile).
966
1160
  for (const [prop, v] of Object.entries(this.o.cssVars)) c.style.setProperty(prop, v);
967
1161
  const ui = (this.ui = {});
@@ -979,7 +1173,7 @@ class Call {
979
1173
  show(ui.bar, false);
980
1174
  }
981
1175
  c.append(ui.banner, ui.panel, ui.grid, ui.invite, ui.foot);
982
- if (this.o.selfView) this.self = new SelfTile(this, ui.selfSlot);
1176
+ if (this.selfViewOn) this.self = new SelfTile(this, ui.selfSlot);
983
1177
  else show(ui.selfSlot, false);
984
1178
  if (this.o.chrome) this._buildBar();
985
1179
  }
@@ -1203,8 +1397,26 @@ class Call {
1203
1397
  retryCamera: () => call.retryCamera(),
1204
1398
  setMono3D: (on) => call.setMono3D(on),
1205
1399
  leave: () => call.leave('left'),
1206
- /** A remote participant's tile element (for `layout: 'none'`), or null. */
1207
- tile: (peerId) => call.tiles.get(peerId)?.el || null,
1400
+ /** A participant's tile element (for `layout: 'none'`), or null; `'self'` is your self view. */
1401
+ tile: (peerId) => (peerId === 'self' ? call.self?.el || null : call.tiles.get(peerId)?.el || null),
1402
+ /** Your display name (sent in `hello`), or null. */
1403
+ get name() {
1404
+ return call.name;
1405
+ },
1406
+ setName: (n) => call.setName(n),
1407
+ send: (peerId, data) => call.sendApp(peerId, data),
1408
+ broadcast: (data) => call.broadcastApp(data),
1409
+ /** The signalling connection: 'connected' | 'reconnecting' | 'closed', null before join / after leave. */
1410
+ get signal() {
1411
+ return call.signalState;
1412
+ },
1413
+ setSelfView: (on) => call.setSelfView(on),
1414
+ setMic: (deviceId) => call.setMic(deviceId),
1415
+ setAudioOutput: (sinkId) => call.setAudioOutput(sinkId),
1416
+ /** What this side sends (camera + microphone), read-only; null before a camera / mic opened. */
1417
+ get localStream() {
1418
+ return call.local ? call.local.stream : null;
1419
+ },
1208
1420
  diagnostics: () => call.diagnostics(),
1209
1421
  on(type, cb) {
1210
1422
  if (typeof cb !== 'function') throw new TypeError(`${TAG} on() takes a function`);
@@ -1247,6 +1459,27 @@ class Call {
1247
1459
 
1248
1460
  // ── paint helpers ──────────────────────────────────────────────────────────────────────────
1249
1461
 
1462
+ /**
1463
+ * Observe a tile's stage box (`ResizeObserver`, where there is one): `onBox(w, h)` with its
1464
+ * `clientWidth` / `clientHeight` on every change. Returns the disconnect, or null without one.
1465
+ */
1466
+ function observeBox(el, onBox) {
1467
+ if (typeof ResizeObserver !== 'function') return null;
1468
+ const ro = new ResizeObserver(() => onBox(el.clientWidth || 0, el.clientHeight || 0));
1469
+ ro.observe(el);
1470
+ return () => ro.disconnect();
1471
+ }
1472
+
1473
+ /** The `weave` event payload of a settled `firstWoven` / `rewoven()`. */
1474
+ function weaveEvent(id, res) {
1475
+ const ev = { id, state: res.woven ? 'woven' : 'flat', ms: Math.round(res.ms || 0) };
1476
+ if (res.reason) ev.reason = res.reason;
1477
+ return ev;
1478
+ }
1479
+
1480
+ /** `theme.tileAspect: 'fill'`: a stage box's aspect, clamped to what a woven tile may take. */
1481
+ const clampAspect = (w, h) => Math.max(0.3, Math.min(4, w / h));
1482
+
1250
1483
  /** Size a FLAT (never woven) canvas to its box × dpr. Returns false if it has no box yet. */
1251
1484
  function sizeFlat(canvas) {
1252
1485
  const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
@@ -1304,6 +1537,7 @@ class Tile {
1304
1537
  this.autoCanvas = null;
1305
1538
  this.autoLocked = false;
1306
1539
  this.autoStats = { n: 0, ms: 0, readMs: 0, method: null, via: null };
1540
+ this.autoGrabs = 0; // local copies made: the receiver fallback, or the depth cursor's
1307
1541
  this.autoPending = false;
1308
1542
  // Bumped on every stream change and on destroy(): an async copyTo() that resolves after either
1309
1543
  // belongs to a stream this tile no longer shows, and is dropped (review of #92).
@@ -1312,6 +1546,10 @@ class Tile {
1312
1546
  this.destroyed = false;
1313
1547
  this.conv.depth = call.depth;
1314
1548
  this.hintGate = rateGate(HINT_MAX_HZ);
1549
+ // The sender's own measurement (`conv`, when its hello says `conv: true`): `{ frac, c, at }`,
1550
+ // `at` = when it ARRIVED here. Fresh for CONV_FRESH_MS; after that this side measures again.
1551
+ this.convGate = rateGate(CONV_RECV_MAX_HZ);
1552
+ this.senderConv = null;
1315
1553
  this.layerFails = 0;
1316
1554
  this.forceFlat = false;
1317
1555
  // lifted route (mono→3D): the lift handle once it lands, whether it is showing (live), and a
@@ -1332,13 +1570,20 @@ class Tile {
1332
1570
  this.badge = el('div', { class: 'dxr-call-badge', part: 'badge' });
1333
1571
  this.plate = el('div', { class: 'dxr-call-state', part: 'plate', text: call.t('connecting') });
1334
1572
  this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.cover, this.badge, this.plate]);
1335
- this.stage.style.aspectRatio = String(call.o.tileAspect);
1573
+ // 'fill' (theme.tileAspect): no aspect-ratio — the page sizes the stage and the crop follows it.
1574
+ if (call.o.tileAspect !== 'fill') this.stage.style.aspectRatio = String(call.o.tileAspect);
1575
+ /** The stage box `{ w, h }` from the ResizeObserver; null until it first reports (or without one). */
1576
+ this._box = null;
1577
+ this._aspect = null; // 'fill': the stage's own aspect, once measured
1578
+ this._rwP = null; // the rewoven() promise a `weave` event is waiting on
1579
+ this._unobserve = observeBox(this.stage, (w, h) => this._onBox(w, h));
1336
1580
  // `data-dxr-peer` is the documented hook (layout:'none', handle.tile()); `data-peer` is the 1.29 spelling.
1337
1581
  this.el = el('div', { class: 'dxr-call-tile', part: 'tile', 'data-dxr-peer': id, 'data-peer': id }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
1338
1582
  if (!call.o.ui) {
1339
1583
  show(this.badge, false);
1340
1584
  }
1341
1585
  call.ui.grid.appendChild(this.el);
1586
+ if (call.sinkId) this.applySink();
1342
1587
  this._renderBadge();
1343
1588
  /** The depth cursor (`cursor: 'depth'`) once its module has loaded; null otherwise. */
1344
1589
  this.cursor = null;
@@ -1365,9 +1610,38 @@ class Tile {
1365
1610
  muted: this.remote.muted,
1366
1611
  cameraOff: this.remote.cameraOff,
1367
1612
  speaking: this.speaking,
1613
+ name: (this.hello && this.hello.name) || null,
1614
+ });
1615
+ }
1616
+
1617
+ /** The stage box changed: the 'fill' aspect, and a woven tile's `weave` event once it re-joins. */
1618
+ _onBox(w, h) {
1619
+ const prev = this._box;
1620
+ this._box = { w, h };
1621
+ if (w > 0 && h > 0) this._aspect = clampAspect(w, h);
1622
+ if (!prev || !w || !h || (prev.w === w && prev.h === h)) return;
1623
+ const handle = this.handle;
1624
+ if (!handle || !this._fwSettled || typeof handle.rewoven !== 'function') return;
1625
+ const p = handle.rewoven();
1626
+ if (p === this._rwP || !p || typeof p.then !== 'function') return;
1627
+ this._rwP = p;
1628
+ p.then((res) => {
1629
+ if (this._rwP !== p || this.handle !== handle || !res) return;
1630
+ this._rwP = null;
1631
+ this.call.emit('weave', weaveEvent(this.id, res));
1368
1632
  });
1369
1633
  }
1370
1634
 
1635
+ /** Play this participant's audio to the call's output device (`handle.setAudioOutput`). */
1636
+ applySink() {
1637
+ const a = this.audio;
1638
+ if (typeof a.setSinkId !== 'function') return Promise.resolve(false);
1639
+ return a.setSinkId(this.call.sinkId || '').then(
1640
+ () => true,
1641
+ () => false
1642
+ );
1643
+ }
1644
+
1371
1645
  /** Everything else (UNSTABLE): `handle.diagnostics().peers[i]`. */
1372
1646
  diag() {
1373
1647
  return {
@@ -1387,6 +1661,12 @@ class Tile {
1387
1661
  readMs: this.autoStats.n ? this.autoStats.readMs / this.autoStats.n : null,
1388
1662
  method: this.autoStats.method,
1389
1663
  via: this.autoStats.via,
1664
+ // Local copies made (the receiver fallback, or the depth cursor's).
1665
+ samples: this.autoGrabs,
1666
+ // Where the applied disparity comes from: 'sender' (its `conv`, fresh), 'local' (measured
1667
+ // here: an older or silent sender), null (autoConverge off, or nothing measured yet).
1668
+ source: !this.call.o.autoConverge ? null : this._senderFrac(performance.now()) !== null ? 'sender' : this.conv.measuredPx !== null ? 'local' : null,
1669
+ senderFrac: this.senderConv ? this.senderConv.frac : null,
1390
1670
  },
1391
1671
  lift: this.lifted
1392
1672
  ? {
@@ -1423,8 +1703,10 @@ class Tile {
1423
1703
 
1424
1704
  setHello(h) {
1425
1705
  clearTimeout(this.helloTimer);
1706
+ const prevName = this.hello ? this.hello.name || null : null;
1426
1707
  this.hello = h;
1427
1708
  this.conv.hello = h;
1709
+ if ((h.name || null) !== prevName) this.call.emit('name', { id: this.id, name: h.name || null });
1428
1710
  const changed = this.format !== h.format;
1429
1711
  this.format = h.format;
1430
1712
  this.call.log('hello', { peer: this.id, format: h.format, width: h.width, height: h.height, rectified: h.rectified });
@@ -1437,6 +1719,28 @@ class Tile {
1437
1719
  this.conv.subjectZmm = h.subjectZmm;
1438
1720
  }
1439
1721
 
1722
+ /**
1723
+ * A `conv` from the sender (wire.normalizeConv already validated it). Rate-limited here too, so a
1724
+ * chatty or hostile sender cannot drive this tile at frame rate. `d: null` (nothing measured
1725
+ * yet) keeps a previous value alive and is otherwise ignored.
1726
+ */
1727
+ setConv(cv) {
1728
+ const now = performance.now();
1729
+ if (!this.convGate(now)) return;
1730
+ if (cv.d === null) {
1731
+ if (this.senderConv) this.senderConv.at = now;
1732
+ return;
1733
+ }
1734
+ this.senderConv = { frac: cv.d, c: cv.c === undefined ? null : cv.c, at: now };
1735
+ }
1736
+
1737
+ /** The sender's measured disparity as a fraction of its eye width, when fresh and announced; else null. */
1738
+ _senderFrac(now) {
1739
+ const sc = this.senderConv;
1740
+ if (!sc || !this.hello || !this.hello.conv || now - sc.at >= CONV_FRESH_MS) return null;
1741
+ return sc.frac;
1742
+ }
1743
+
1440
1744
  setRemoteState(st) {
1441
1745
  this.remote = st;
1442
1746
  this._renderBadge();
@@ -1448,6 +1752,7 @@ class Tile {
1448
1752
  if (on === this.speaking) return;
1449
1753
  this.speaking = on;
1450
1754
  this.el.classList.toggle('dxr-call-tile--speaking', on);
1755
+ this.call.emit('speaking', { id: this.id, on });
1451
1756
  }
1452
1757
 
1453
1758
  setConn(st) {
@@ -1533,12 +1838,18 @@ class Tile {
1533
1838
  if (!this.convCanvas) this.convCanvas = document.createElement('canvas');
1534
1839
  this._paintConv(); // the first frame exists before the layer does
1535
1840
  show(this.cover, true);
1536
- const handle = call.wall.addImage(this.canvas, this.convCanvas);
1841
+ // tileCover 'auto': the core's rect cover takes over after the first join, across every later
1842
+ // resize / move (layout: 'none' — the page moves the tiles). No cover machinery here.
1843
+ const handle = call.o.tileCover === 'auto' ? call.wall.addImage(this.canvas, this.convCanvas, { rectCover: 'auto' }) : call.wall.addImage(this.canvas, this.convCanvas);
1537
1844
  this.handle = handle;
1845
+ this._fwSettled = false;
1846
+ this._rwP = null;
1538
1847
  handle.firstWoven?.then((res) => {
1539
1848
  if (this.handle !== handle) return;
1849
+ this._fwSettled = true;
1540
1850
  show(this.cover, false); // a cut, never a fade
1541
1851
  call.log('first-woven', { peer: this.id, woven: res.woven, reason: res.reason, ms: Math.round(res.ms) });
1852
+ call.emit('weave', weaveEvent(this.id, res));
1542
1853
  if (!res.woven && res.reason === 'layer-failed') this._layerFailed();
1543
1854
  });
1544
1855
  }
@@ -1685,6 +1996,9 @@ class Tile {
1685
1996
 
1686
1997
  paint() {
1687
1998
  if (!this.route) return;
1999
+ // Hidden (display:none) or collapsed: nothing to see, so no copy, no measurement. Known from the
2000
+ // ResizeObserver; without one (or before it reports) the tile paints as before.
2001
+ if (this._box && (!this._box.w || !this._box.h)) return;
1688
2002
  if (this.route !== 'woven-sbs') this.cursor?.idle(); // only a woven pair has depth to measure
1689
2003
  if (this.route === 'woven-sbs') this._paintConv();
1690
2004
  else if (this.route === 'lifted') {
@@ -1699,7 +2013,7 @@ class Tile {
1699
2013
  const H = v.videoHeight;
1700
2014
  if (!W || !H || (v.readyState || 0) < 2 || !this.convCanvas) return;
1701
2015
  const eyeW = W / 2;
1702
- const A = this.call.o.tileAspect;
2016
+ const A = this.call.o.tileAspect === 'fill' ? this._aspect || 16 / 9 : this.call.o.tileAspect;
1703
2017
  const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
1704
2018
  const c = this.convCanvas;
1705
2019
  if (c.width !== 2 * outW || c.height !== outH) {
@@ -1707,7 +2021,12 @@ class Tile {
1707
2021
  c.height = outH;
1708
2022
  }
1709
2023
  const hover = !!(this.cursor && this.cursor.hovering);
1710
- if (this.call.o.autoConverge || hover) this._sampleDisparity(v, W, H, hover);
2024
+ // The sender's measurement, when it sends one (recomputed per paint: a WebRTC resolution change
2025
+ // keeps it right, since it is a fraction of the eye width). Otherwise measure here, as before.
2026
+ const auto = this.call.o.autoConverge;
2027
+ const frac = auto ? this._senderFrac(performance.now()) : null;
2028
+ if (frac !== null) this.conv.measuredPx = frac * eyeW;
2029
+ if (hover || (auto && frac === null)) this._sampleDisparity(v, W, H, hover);
1711
2030
  const shift = this.conv.step(eyeW);
1712
2031
  const g = c.getContext('2d');
1713
2032
  const rects = [];
@@ -1729,61 +2048,20 @@ class Tile {
1729
2048
  // ~15 Hz while a depth cursor hovers: it measures the same copy, under the pointer.
1730
2049
  if (this.autoPending || now - this.autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
1731
2050
  this.autoAt = now;
1732
- // Preferred: WebCodecs. `new VideoFrame(video)` + an ASYNC copyTo() of the decoded frame, reading
1733
- // the luma (Y) plane directly: no synchronous GPU->CPU canvas readback on the paint path (that
1734
- // readback, not the matching, was ~25 ms of a ~30 ms measurement).
1735
- if (typeof VideoFrame === 'function') {
1736
- let frame = null;
1737
- try {
1738
- frame = new VideoFrame(v);
1739
- } catch {
1740
- frame = null;
1741
- }
1742
- if (frame && Y_PLANE_FORMATS.has(frame.format)) {
1743
- this.autoPending = true;
1744
- const gen = this.autoGen;
1745
- const fw = frame.visibleRect ? frame.visibleRect.width : frame.displayWidth;
1746
- const fh = frame.visibleRect ? frame.visibleRect.height : frame.displayHeight;
1747
- const buf = new Uint8Array(frame.allocationSize());
1748
- frame
1749
- .copyTo(buf)
1750
- .then((layout) => {
1751
- if (gen !== this.autoGen) return; // the stream changed or the tile is gone: stale frame
1752
- const t0 = performance.now();
1753
- const f = Math.max(1, Math.ceil(fw / 2 / AUTO_CONV_EYE_WIDTH));
1754
- const ds = downsampleLuma(buf, layout[0].offset, layout[0].stride, fw, fh, f);
1755
- this._applyMeasurement(ds.img, ds.w, ds.h, ds.w / fw, now, performance.now() - t0, 'videoframe');
1756
- })
1757
- .catch(() => {
1758
- if (gen === this.autoGen) this._applyMeasurement(null, 0, 0, 1, now, 0, 'videoframe');
1759
- })
1760
- .finally(() => {
1761
- frame.close();
1762
- this.autoPending = false;
1763
- });
1764
- return;
1765
- }
1766
- if (frame) frame.close();
1767
- }
1768
- // Fallback: a small canvas + getImageData (synchronous readback).
1769
- const s = Math.min(1, AUTO_CONV_EYE_WIDTH / (W / 2));
1770
- const w = Math.max(2, Math.round((W * s) / 2) * 2);
1771
- const h = Math.max(2, Math.round(H * s));
2051
+ this.autoPending = true;
2052
+ const gen = this.autoGen;
1772
2053
  if (!this.autoCanvas) this.autoCanvas = document.createElement('canvas');
1773
- const c = this.autoCanvas;
1774
- if (c.width !== w || c.height !== h) {
1775
- c.width = w;
1776
- c.height = h;
1777
- }
1778
- const g = c.getContext('2d', { willReadFrequently: true });
1779
- let img = null;
1780
- try {
1781
- g.drawImage(v, 0, 0, w, h);
1782
- img = lumaFromRgba(g.getImageData(0, 0, w, h).data, w * h);
1783
- } catch {
1784
- img = null; // a frame that cannot be read (tainted, not decoded yet): skip it
1785
- }
1786
- this._applyMeasurement(img, w, h, w / W, now, performance.now() - now, 'canvas');
2054
+ // camera/conv-meter.js: WebCodecs Y plane (async copyTo, no canvas readback) when the decoded
2055
+ // frame has one, else a small canvas + getImageData.
2056
+ grabLumaFrame(v, { eyeMax: AUTO_CONV_EYE_WIDTH, scratch: this.autoCanvas })
2057
+ .then((g) => {
2058
+ if (gen !== this.autoGen) return; // the stream changed or the tile is gone: stale frame
2059
+ if (g) this._applyMeasurement(g.img, g.w, g.h, g.scale, now, g.readMs, g.via);
2060
+ else this._applyMeasurement(null, 0, 0, 1, now, 0, this.autoStats.via);
2061
+ })
2062
+ .finally(() => {
2063
+ this.autoPending = false;
2064
+ });
1787
2065
  }
1788
2066
 
1789
2067
  /**
@@ -1793,12 +2071,16 @@ class Tile {
1793
2071
  */
1794
2072
  _applyMeasurement(img, w, h, s, at, readMs, via) {
1795
2073
  if (this.destroyed) return;
2074
+ this.autoGrabs++;
1796
2075
  if (img) this.luma = { img, w, h, scale: s };
1797
2076
  if (!this.call.o.autoConverge) return; // the copy was for the depth cursor only
2077
+ if (this._senderFrac(performance.now()) !== null) return; // the sender's measurement wins; the copy was for the cursor
1798
2078
  const t = performance.now();
1799
2079
  const m = img ? this.autoFocus.measure(img, w, h, at) : null;
1800
2080
  const d = this.autoTrack.push(m ? m.d / s : null);
1801
- this.conv.measuredPx = d;
2081
+ // A failed measurement holds: the track holds its last value, and with an empty track (the
2082
+ // sender's `conv` just went stale) the sender's last value stays until this side locks.
2083
+ if (d !== null) this.conv.measuredPx = d;
1802
2084
  this.autoStats.n++;
1803
2085
  this.autoStats.readMs += readMs;
1804
2086
  this.autoStats.ms += readMs + (performance.now() - t);
@@ -1820,6 +2102,7 @@ class Tile {
1820
2102
 
1821
2103
  _resetAutoConverge() {
1822
2104
  this.autoGen++;
2105
+ this.senderConv = null;
1823
2106
  this.luma = null;
1824
2107
  this.autoTrack.reset();
1825
2108
  this.autoFocus.reset();
@@ -1837,6 +2120,8 @@ class Tile {
1837
2120
  clearTimeout(this.leaveTimer);
1838
2121
  clearTimeout(this.liftTimer);
1839
2122
  this._unregister();
2123
+ this._unobserve?.();
2124
+ this._unobserve = null;
1840
2125
  this.video.srcObject = null;
1841
2126
  this.audio.srcObject = null;
1842
2127
  this.el.remove();
@@ -1859,9 +2144,13 @@ class SelfTile {
1859
2144
  this.badge = el('div', { class: 'dxr-call-badge', part: 'badge' });
1860
2145
  this.plate = el('div', { class: 'dxr-call-state dxr-call-hidden', part: 'plate' });
1861
2146
  this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.badge, this.plate]);
1862
- this.stage.style.aspectRatio = String(call.o.tileAspect);
1863
- this.el = el('div', { class: 'dxr-call-tile dxr-call-tile--self', part: 'tile' }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
2147
+ if (call.o.tileAspect !== 'fill') this.stage.style.aspectRatio = String(call.o.tileAspect);
2148
+ // `data-dxr-peer="self"`: handle.tile('self'), and the page's CSS under layout: 'none'.
2149
+ this.el = el('div', { class: 'dxr-call-tile dxr-call-tile--self', part: 'tile', 'data-dxr-peer': 'self' }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
1864
2150
  slot.appendChild(this.el);
2151
+ this._box = null;
2152
+ this._rwP = null;
2153
+ this._unobserve = observeBox(this.stage, (w, h) => this._onBox(w, h));
1865
2154
  this.view = null; // the CameraView over the current camera
1866
2155
  this.cam = null;
1867
2156
  }
@@ -1884,7 +2173,7 @@ class SelfTile {
1884
2173
  const l = this.call.local;
1885
2174
  const cam = l && l.videoTrack ? l.cam : null;
1886
2175
  const wall = this.call.woven ? this.call.wall : { supported: false };
1887
- if (!cam || cam.state !== 'live') {
2176
+ if (!cam || cam.state !== 'live' || !this.call.selfViewOn) {
1888
2177
  this.detach();
1889
2178
  return this._badge();
1890
2179
  }
@@ -1899,16 +2188,48 @@ class SelfTile {
1899
2188
  autoConverge: !!this.call.o.autoConverge, // the call option (default on) governs the self view too
1900
2189
  cursor: this.call.o.cursor || undefined, // and so does `cursor: 'depth'`
1901
2190
  aspect: this.call.o.tileAspect,
2191
+ ...(this.call.o.tileCover === 'auto' ? { rectCover: 'auto' } : {}),
1902
2192
  onRouteChange: (route, st) => {
1903
2193
  if (this.cam !== cam) return; // a view this tile already replaced
1904
2194
  this.call.log('self-route', { route, woven: st.woven, reason: st.reason });
1905
2195
  this._badge();
2196
+ this._weave(st);
1906
2197
  },
1907
2198
  });
1908
2199
  }
1909
2200
  this._badge();
1910
2201
  }
1911
2202
 
2203
+ /**
2204
+ * The self view's `weave` event (`id: 'self'`): its tile's `firstWoven` settled (woven or not),
2205
+ * or the view dropped flat because the wall said the tile will not weave.
2206
+ */
2207
+ _weave(st) {
2208
+ const fw = st.firstWoven;
2209
+ let ev = null;
2210
+ if (fw && typeof fw === 'object') ev = weaveEvent('self', fw);
2211
+ else if (st.route !== 'woven-sbs' && st.reason) ev = { id: 'self', state: 'flat', reason: st.reason, ms: 0 };
2212
+ if (ev) this.call.emit('weave', ev);
2213
+ }
2214
+
2215
+ /** The self stage resized: once woven, the `weave` event of its re-join. */
2216
+ _onBox(w, h) {
2217
+ const prev = this._box;
2218
+ this._box = { w, h };
2219
+ if (!prev || !w || !h || (prev.w === w && prev.h === h)) return;
2220
+ const handle = this.view && this.view.woven ? this.view.handle : null;
2221
+ const fw = this.view ? this.view.weaveState().firstWoven : null;
2222
+ if (!handle || !fw || typeof fw !== 'object' || typeof handle.rewoven !== 'function') return;
2223
+ const p = handle.rewoven();
2224
+ if (p === this._rwP || !p || typeof p.then !== 'function') return;
2225
+ this._rwP = p;
2226
+ p.then((res) => {
2227
+ if (this._rwP !== p || !this.view || this.view.handle !== handle || !res) return;
2228
+ this._rwP = null;
2229
+ this.call.emit('weave', weaveEvent('self', res));
2230
+ });
2231
+ }
2232
+
1912
2233
  /** 3D only while the view is on the woven route — a layer the wall failed has already taken it flat. */
1913
2234
  _badge() {
1914
2235
  const t = this.call.t;