@displayxr/inline3d 1.28.0 → 1.30.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,33 @@
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, the one-line path of RFC 0003 §1 (C1) and the
5
+ // public surface of RFC 0003 §2 (C2).
5
6
  //
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
- // });
7
+ // import { mountCall } from '@displayxr/inline3d/call';
8
+ // const call = await mountCall(document.getElementById('call')); // hosted signalling, auto camera, SDK UI
13
9
  // call.on('peer', ({ id }) => console.log('joined', id));
14
10
  //
11
+ // `mountCall(el, opts?)` is `addCall` with every argument optional: the wall is the document's
12
+ // shared manager (`sharedInline3D()` — never a second session next to the page's own), signalling
13
+ // defaults to the hosted server, the camera to 'auto', the chrome to on. `addCall(wall, container,
14
+ // opts)` stays as the explicit form for a page that manages its own wall. `<dxr-call>`
15
+ // (call/element.js) is the same thing as markup. A bundled app that wants 2D→3D imports
16
+ // `@displayxr/inline3d/call/full` (lift statically imported) or passes `mono3D: lift` itself —
17
+ // on the plain `/call` entry a mono peer on a 3D display raises `warning { code:
18
+ // 'lift-not-bundled' }` instead of silently staying flat.
19
+ //
20
+ // THE PUBLIC SURFACE (RFC 0003 §2, C2) is what a page needs to RUN, OBSERVE and DRESS a call:
21
+ // `mountCall` / `addCall`, `dxrSignaling` + `DXR_SIGNAL_DEFAULT`, `parseInviteLink` (Decision 7),
22
+ // the `<dxr-call>` element, and the types. Everything that exists so the module can be unit-tested
23
+ // stays importable by file path (`js/call/*.js`) and is no longer part of this entry — for ONE
24
+ // release (1.30) the old names remain here as deprecated wrappers that `console.warn` once (the
25
+ // block at the end of this file); 1.31 removes them. The surface is pinned by test/api-snapshot.
26
+ //
27
+ // THE CAMERA is `@displayxr/inline3d/camera` (RFC 0003 §4): `camera: 'auto'` makes this module
28
+ // call `openCamera()` itself, `camera: cam` takes a StereoCamera the page opened, and the self
29
+ // view IS an `addCameraView` — one implementation of capture, mirroring and convergence.
30
+ //
15
31
  // WHAT IT OWNS. Unlike the other modules this one takes a CONTAINER, not a canvas: a call has a
16
32
  // variable number of participants, so it creates one persistent canvas per remote participant
17
33
  // (plus the self view) inside `container`, lays them out, and draws its own chrome around them.
@@ -67,68 +83,28 @@
67
83
  // SDK's teardown synchronously, so no listener can distinguish them afterwards); `leave()`
68
84
  // restores it.
69
85
 
70
- import { createInline3D } from './inline3d.js';
71
- import {
72
- WIRE_VERSION,
73
- CALL_SDK,
74
- DEFAULT_MAX_PEERS,
75
- HINT_MAX_HZ,
76
- newRoomId,
77
- newPeerId,
78
- isValidRoomId,
79
- parseInviteLink,
80
- buildInviteLink,
81
- normalizeHello,
82
- makeHello,
83
- normalizeHint,
84
- normalizeState,
85
- rateGate,
86
- routeFor,
87
- badgeFor,
88
- createConvergence,
89
- eyeCropRect,
90
- eyeOutputSize,
91
- mirrorSwapOps,
92
- backoffMs,
93
- createLiveGate,
94
- } from './call/wire.js';
95
- import { MeshTransport, clampMaxPeers } from './call/transport.js';
96
- import { openCamera, openMic } from './call/capture.js';
86
+ import { sharedInline3D } from './inline3d.js';
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';
89
+ import { MeshTransport as MeshTransportImpl, clampMaxPeers as clampMaxPeersImpl } from './call/transport.js';
90
+ import { openMic } from './camera/capture.js';
91
+ import { openCamera, addCameraView, isStereoCamera } from './inline3d-camera.js';
97
92
  import { drawQr } from './call/qr.js';
98
- import { injectCallStyle, ICONS, el, show, resolveCallAccent, CALL_ACCENTS } from './call/ui.js';
99
- import { normalizeMono3D, resolveLift, createLiftPool, createFrameWatch } from './call/lift.js';
100
-
93
+ import * as qrMod from './call/qr.js';
94
+ import * as sdp from './call/sdp.js';
95
+ import * as liftMod from './call/lift.js';
96
+ import * as disparity from './call/disparity.js';
97
+ import * as signalingMod from './call/signaling.js';
98
+ import { injectCallStyle, ICONS, el, show, CALL_ACCENTS as CALL_ACCENTS_IMPL, createStrings, CALL_STRINGS } from './call/ui.js';
99
+ import { normalizeCallOptions as normalizeOptions } from './call/options.js';
100
+ import { defineCallElement } from './call/element.js';
101
+ import { lumaFromRgba, createDisparityTrack, createFocusTracker, downsampleLuma } from './call/disparity.js';
101
102
  import { dxrSignaling } from './call/signaling.js';
102
- export { dxrSignaling, peerjsCloud, SIGNAL_PROTOCOL, roomKey, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
103
- export {
104
- WIRE_VERSION,
105
- CALL_SDK,
106
- newRoomId,
107
- isValidRoomId,
108
- parseInviteLink,
109
- buildInviteLink,
110
- normalizeHello,
111
- makeHello,
112
- routeFor,
113
- badgeFor,
114
- CALL_ACCENTS,
115
- };
116
- export { createLiveGate } from './call/wire.js';
117
- export { convergenceShiftPx, lowPass, clampShift, eyeCropRect, mirrorSwapOps, mirrorSwapPixels, maxBitrateKbps } from './call/wire.js';
118
- export { preferVideoCodecs, sortCodecCapabilities, VIDEO_CODEC_ORDER } from './call/sdp.js';
119
- export { MeshTransport, clampMaxPeers } from './call/transport.js';
120
- export { qrEncode } from './call/qr.js';
121
- export {
122
- normalizeMono3D,
123
- resolveLift,
124
- createLiftPool,
125
- createFrameWatch,
126
- liftConvergenceFor,
127
- liftPriorityFor,
128
- setLiftPriority,
129
- defaultLiftSpecifier,
130
- LIFT_PRIORITY,
131
- } from './call/lift.js';
103
+
104
+ // ── the public surface (RFC 0003 §2) ────────────────────────────────────────────────────────
105
+ export { DxrCallElement, defineCallElement, attrsToOpts, CALL_EVENT_PREFIX } from './call/element.js';
106
+ export { dxrSignaling, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
107
+ export { parseInviteLink };
132
108
 
133
109
  const TAG = '[inline3d/call]';
134
110
  const HELLO_WAIT_MS = 2000;
@@ -137,58 +113,12 @@ const STATS_TICK_MS = 500;
137
113
  const SPEAKING_LEVEL = 0.02;
138
114
  const LAYER_RETRIES = 4;
139
115
  const LIFT_RETRIES = 2;
140
- const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
141
- export const PLATE_TEXT = Object.freeze({
142
- unreachable: "Can't reach this participant — the network needs a relay (TURN)",
143
- cameraBusy: 'Camera busy — in use by another app (e.g. eye tracking)',
144
- });
145
-
146
- /**
147
- * Apply defaults and validate. Pure (no DOM, no network) — see test/call.test.mjs.
148
- * @param {object} [opts]
149
- */
150
- export function normalizeCallOptions(opts = {}) {
151
- const ui = opts.ui === undefined ? true : !!opts.ui;
152
- const aspect = typeof opts.tileAspect === 'number' && opts.tileAspect > 0.3 && opts.tileAspect < 4 ? opts.tileAspect : 16 / 9;
153
- let room = opts.room === undefined || opts.room === null ? 'auto' : opts.room;
154
- if (room !== 'auto' && !isValidRoomId(room)) {
155
- const fromLink = parseInviteLink(room);
156
- if (!fromLink) throw new Error(`@displayxr/inline3d/call: room "${room}" is not a valid room id (16-64 base64url chars) or invite link`);
157
- room = fromLink;
158
- }
159
- return {
160
- room,
161
- // Default: the hosted DisplayXR signalling server (which also mints TURN credentials).
162
- signaling: opts.signaling || dxrSignaling(),
163
- iceServers: Array.isArray(opts.iceServers) ? opts.iceServers : undefined,
164
- camera: opts.camera === undefined ? 'auto' : opts.camera,
165
- format: opts.format === 'sbs' ? 'sbs' : opts.format === 'mono' ? 'mono' : undefined,
166
- calibration: opts.calibration && typeof opts.calibration === 'object' ? { ...opts.calibration } : {},
167
- rectify: typeof opts.rectify === 'function' ? opts.rectify : null,
168
- audio: opts.audio === undefined ? true : !!opts.audio,
169
- mono3D: normalizeMono3D(opts.mono3D),
170
- maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
171
- // Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
172
- // keys (mode, wall, ui, convergence, priority) always win.
173
- liftOptions: opts.liftOptions && typeof opts.liftOptions === 'object' ? { ...opts.liftOptions } : null,
174
- // Concurrent lifted tiles. Default = maxPeers (4): every mono peer can be lifted.
175
- maxLifted: Number.isFinite(+opts.maxLifted) && opts.maxLifted !== null ? Math.max(0, Math.min(DEFAULT_MAX_PEERS, Math.floor(+opts.maxLifted))) : DEFAULT_MAX_PEERS,
176
- layout: opts.layout === 'speaker' ? 'speaker' : 'grid',
177
- ui,
178
- autoJoin: opts.autoJoin === undefined ? !ui : !!opts.autoJoin,
179
- selfView: opts.selfView === undefined ? true : !!opts.selfView,
180
- tileAspect: aspect,
181
- accent: typeof opts.accent === 'string' && opts.accent ? resolveCallAccent(opts.accent) : null,
182
- inviteBase: typeof opts.inviteBase === 'string' ? opts.inviteBase : null,
183
- updateUrl: opts.updateUrl === undefined ? ui : !!opts.updateUrl,
184
- browserUrl: typeof opts.browserUrl === 'string' ? opts.browserUrl : DEFAULT_BROWSER_URL,
185
- recoverSession: opts.recoverSession === undefined ? true : !!opts.recoverSession,
186
- scrollIntoView: opts.scrollIntoView === undefined ? true : !!opts.scrollIntoView,
187
- wallOptions: opts.wallOptions && typeof opts.wallOptions === 'object' ? opts.wallOptions : {},
188
- log: typeof opts.log === 'function' ? opts.log : opts.debug ? (tag, obj) => console.log(`${TAG} ${tag} ${JSON.stringify(obj)}`) : null,
189
- };
190
- }
191
-
116
+ // Auto-convergence sampling: ~5 Hz on a copy whose eye is at most this wide (enough for sub-pixel
117
+ // disparity on a face; small enough to stay a few ms per measurement).
118
+ const AUTO_CONV_INTERVAL_MS = 200;
119
+ const AUTO_CONV_EYE_WIDTH = 240;
120
+ // WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion.
121
+ const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
192
122
  /**
193
123
  * Put a 3D video call in `container`. Resolves once the camera is open and (with `ui:true`) the
194
124
  * lobby is showing; with `ui:false` (or `autoJoin:true`) once the call is joined.
@@ -199,18 +129,36 @@ export function normalizeCallOptions(opts = {}) {
199
129
  */
200
130
  export async function addCall(wall, container, opts = {}) {
201
131
  if (!container || typeof container.appendChild !== 'function') throw new TypeError('@displayxr/inline3d/call: addCall(wall, container, opts) needs a container element');
202
- const o = normalizeCallOptions(opts);
132
+ const o = normalizeOptions(opts);
203
133
  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');
134
+ throw new TypeError('@displayxr/inline3d/call: opts.signaling must be a SignalingAdapter — dxrSignaling([url]) or your own');
205
135
  }
206
136
  const call = new Call(wall, container, o);
207
137
  await call._init();
208
138
  return call.handle;
209
139
  }
210
140
 
141
+ /**
142
+ * The one-line path (RFC 0003 §1): `addCall` with every argument optional. The wall is
143
+ * `opts.wall` if given, else the document's shared manager — `sharedInline3D(opts.wallOptions)`,
144
+ * which returns the page's existing session rather than opening a second one (woven-canvas
145
+ * rule 1). Everything else defaults as `addCall` does: hosted signalling, `camera: 'auto'`,
146
+ * `ui: true`.
147
+ *
148
+ * @param {HTMLElement} el the container (a `<dxr-call>`, a `<div>`, …)
149
+ * @param {object} [opts] see call.d.ts `CallOptions`
150
+ */
151
+ export async function mountCall(el, opts = {}) {
152
+ if (!el || typeof el.appendChild !== 'function') throw new TypeError('@displayxr/inline3d/call: mountCall(el, opts) needs a container element');
153
+ const { wall: given, ...rest } = opts;
154
+ const wall = given || (await sharedInline3D(rest.wallOptions));
155
+ return addCall(wall, el, rest);
156
+ }
157
+
211
158
  class Call {
212
159
  constructor(wall, container, o) {
213
160
  this.o = o;
161
+ this.t = createStrings(o.strings); // every chrome string, overridable via theme.strings
214
162
  this.container = container;
215
163
  this.wall = wall || null;
216
164
  this.wallLive = !!(wall && wall.supported);
@@ -238,7 +186,9 @@ class Call {
238
186
  this.liftPool = null;
239
187
  this._liftP = null;
240
188
  this.liftProven = false;
241
- this._frameWatch = createFrameWatch();
189
+ this.liftMissing = false; // the lift MODULE failed to import (vs. a display that can't lift)
190
+ this._liftWarned = false;
191
+ this._frameWatch = liftMod.createFrameWatch();
242
192
  this.handle = this._makeHandle();
243
193
  if (this.wallLive) this._hookWall(this.wall);
244
194
  }
@@ -272,6 +222,13 @@ class Call {
272
222
  this.emit('error', { code, message, error: err || null });
273
223
  }
274
224
 
225
+ /** Degraded, not broken (RFC 0003 §2): one console.warn (always — the page must see it) + an event. */
226
+ warning(code, message) {
227
+ this.log('warning', { code, message });
228
+ console.warn(`${TAG} ${code}: ${message}`);
229
+ this.emit('warning', { code, message });
230
+ }
231
+
275
232
  // ── setup ────────────────────────────────────────────────────────────────────────────────
276
233
 
277
234
  async _init() {
@@ -285,35 +242,31 @@ class Call {
285
242
  else this._setState('lobby');
286
243
  }
287
244
 
245
+ /**
246
+ * Open the camera through `@displayxr/inline3d/camera` (`want` = 'auto' | 'stereo' | 'mono' |
247
+ * deviceId | MediaStream | a StereoCamera the page opened) and the microphone, and rebuild the
248
+ * local stream. A camera this module opened is closed when replaced; a page's StereoCamera or
249
+ * MediaStream is left alone.
250
+ */
288
251
  async _openMedia(want, format, { keepOnFail = false } = {}) {
289
252
  const log = (t, x) => this.log(t, x);
290
253
  let cam = null;
291
254
  let camError = null;
255
+ const given = isStereoCamera(want) ? want : null;
292
256
  try {
293
- cam = await openCamera(want, { format: format || this.o.format, calibration: this.o.calibration, log });
257
+ cam = given || (await openCamera({ prefer: want, format: format || this.o.format, calibration: this.o.calibration, rectify: this.o.rectify || undefined, log }));
258
+ if (given && given.state !== 'live') throw Object.assign(new Error('the camera given is not live'), { code: 'no-camera', skipped: [] });
294
259
  } catch (err) {
295
260
  camError = err;
296
261
  // No camera is never fatal: the call goes on audio-only, the self view and every receiver
297
262
  // say why, and the user can retry (the tracker may let go) or pick another camera.
298
263
  const busy = err.code === 'camera-busy';
299
- this.error(busy ? 'camera-busy' : err.code || 'no-camera', busy ? `${PLATE_TEXT.cameraBusy}. Joining audio-only.` : `no camera: ${err.message}. Joining audio-only.`, err);
264
+ this.error(busy ? 'camera-busy' : err.code || 'no-camera', busy ? `${this.t('cameraBusy')}. Joining audio-only.` : `no camera: ${err.message}. Joining audio-only.`, err);
300
265
  if (keepOnFail && this.local && this.local.videoTrack) return this.local; // keep the working one
301
266
  this.camStatus = busy ? 'busy' : 'none';
302
267
  }
303
268
  if (cam) this.camStatus = 'ok';
304
- if (cam && cam.format === 'sbs' && this.o.rectify) {
305
- // P2 seam: a calibrated rectification step (plug-in or runtime supplied). Its output is a
306
- // rectified SBS stream; the hello then says so.
307
- try {
308
- const out = await this.o.rectify(cam.stream, { width: cam.width, height: cam.height, deviceId: cam.deviceId, label: cam.label });
309
- if (out && typeof out.getVideoTracks === 'function') {
310
- cam.stream = out;
311
- cam.calibration = { ...cam.calibration, rectified: true };
312
- }
313
- } catch (err) {
314
- this.error('rectify-failed', `rectify() threw — sending the raw pair: ${err.message}`, err);
315
- }
316
- }
269
+ if (cam && cam.rectifyError) this.error('rectify-failed', `rectify() threw — sending the raw pair: ${cam.rectifyError.message}`, cam.rectifyError);
317
270
  let audioTrack = this.local ? this.local.audioTrack : null;
318
271
  if (this.o.audio && !audioTrack) audioTrack = await openMic({ log });
319
272
  if (audioTrack) audioTrack.enabled = !this.muted;
@@ -328,28 +281,39 @@ class Call {
328
281
  videoTrack.enabled = !this.camOff;
329
282
  }
330
283
  const stream = new MediaStream([videoTrack, audioTrack].filter(Boolean));
331
- const video = this.local?.video || Object.assign(document.createElement('video'), { muted: true, playsInline: true, autoplay: true });
332
- video.srcObject = cam ? cam.stream : null;
333
- video.play().catch(() => {});
334
284
  const prev = this.local;
335
285
  this.local = {
336
286
  stream,
287
+ cam, // the StereoCamera (null = audio-only)
337
288
  camStream: cam ? cam.stream : null,
338
- video,
289
+ video: cam ? cam.video : null,
339
290
  videoTrack,
340
291
  audioTrack,
341
292
  format: cam ? cam.format : 'mono',
342
293
  width: cam ? cam.width : 0,
343
294
  height: cam ? cam.height : 0,
344
295
  calibration: cam ? cam.calibration : {},
345
- owned: cam ? cam.owned : false,
296
+ owned: cam ? cam.owned && cam !== given : false,
346
297
  label: cam ? cam.label : '',
347
298
  // Kept on the error path too: which cameras were held, and why, is the one clue a
348
299
  // 'camera-busy' / 'no-camera' report carries.
349
300
  skipped: cam ? cam.skipped : camError?.skipped || [],
350
301
  };
302
+ if (cam && cam !== prev?.cam) {
303
+ // The camera ending under us (revoked, unplugged, taken): audio-only + "Retry camera".
304
+ this._camEndedOff?.();
305
+ this._camEndedOff = cam.on('ended', () => {
306
+ if (this.local?.cam !== cam) return;
307
+ this.local.videoTrack = null;
308
+ this.camStatus = 'none';
309
+ this.error('no-camera', 'the camera ended (revoked, unplugged, or taken by another app). Audio-only until "Retry camera".');
310
+ this.transport?.broadcast(this.stateMsg());
311
+ this._refreshSelf();
312
+ this._refreshBar();
313
+ });
314
+ }
351
315
  this.log('camera', { format: this.local.format, width: this.local.width, height: this.local.height, label: this.local.label, skipped: this.local.skipped.length });
352
- if (prev && prev.owned && prev.camStream && prev.camStream !== this.local.camStream) prev.camStream.getVideoTracks().forEach((t) => t.stop());
316
+ if (prev && prev.owned && prev.cam && prev.cam !== cam) prev.cam.close();
353
317
  this._refreshSelf();
354
318
  this._refreshLobby();
355
319
  return this.local;
@@ -357,7 +321,7 @@ class Call {
357
321
 
358
322
  hello() {
359
323
  const l = this.local || {};
360
- return makeHello({
324
+ return wire.makeHello({
361
325
  format: l.format,
362
326
  width: l.width,
363
327
  height: l.height,
@@ -379,18 +343,18 @@ class Call {
379
343
  if (this.state === 'joining' || this.state === 'in-call') return this.handle;
380
344
  if (this.state === 'left' && !this.local?.videoTrack) await this._openMedia(this.o.camera);
381
345
  if (!this.room) {
382
- this.room = newRoomId();
346
+ this.room = wire.newRoomId();
383
347
  this.log('room-created', {});
384
348
  }
385
- if (this.o.updateUrl && globalThis.history && globalThis.location) {
349
+ if (this.o.invite.updateUrl && globalThis.history && globalThis.location) {
386
350
  try {
387
- history.replaceState(history.state, '', buildInviteLink(location.href, this.room));
351
+ history.replaceState(history.state, '', wire.buildInviteLink(location.href, this.room));
388
352
  } catch {
389
353
  /* sandboxed iframe */
390
354
  }
391
355
  }
392
356
  this._setState('joining');
393
- const t = new MeshTransport({
357
+ const t = new MeshTransportImpl({
394
358
  signaling: this.o.signaling,
395
359
  id: this.id,
396
360
  maxPeers: this.o.maxPeers,
@@ -433,7 +397,7 @@ class Call {
433
397
  // The grid is sized so the whole call block fits ONE viewport, but only if it starts at the
434
398
  // top: a tile below the fold is withheld from the weave (browser#167), so its layer goes
435
399
  // live late. Bring the block into view once, on join.
436
- if (this.o.ui && this.o.scrollIntoView) {
400
+ if (this.o.chrome) {
437
401
  try {
438
402
  this.container.scrollIntoView?.({ block: 'start', behavior: 'instant' });
439
403
  this._fitGrid();
@@ -442,7 +406,7 @@ class Call {
442
406
  }
443
407
  }
444
408
  this._startStats();
445
- this._pagehide = () => this.leave();
409
+ this._pagehide = () => this.leave('pagehide');
446
410
  globalThis.addEventListener?.('pagehide', this._pagehide);
447
411
  this.log('joined', { id: this.id, peers: this.tiles.size });
448
412
  this.emit('joined', { room: this.room, id: this.id });
@@ -499,7 +463,7 @@ class Call {
499
463
  tile.onChannelOpen();
500
464
  return;
501
465
  }
502
- const h = normalizeHello(msg);
466
+ const h = wire.normalizeHello(msg);
503
467
  if (h) return tile.setHello(h);
504
468
  const hint = normalizeHint(msg);
505
469
  if (hint) return tile.setHint(hint);
@@ -511,8 +475,8 @@ class Call {
511
475
 
512
476
  inviteLink() {
513
477
  if (!this.room) return null;
514
- const base = this.o.inviteBase || (globalThis.location ? location.href : '');
515
- return buildInviteLink(base, this.room);
478
+ const base = this.o.invite.base || (globalThis.location ? location.href : '');
479
+ return wire.buildInviteLink(base, this.room);
516
480
  }
517
481
 
518
482
  mute(on) {
@@ -524,7 +488,7 @@ class Call {
524
488
  return this.muted;
525
489
  }
526
490
 
527
- cameraOff(on) {
491
+ setCameraOff(on) {
528
492
  this.camOff = on === undefined ? !this.camOff : !!on;
529
493
  if (this.local?.videoTrack) this.local.videoTrack.enabled = !this.camOff;
530
494
  this.transport?.broadcast(this.stateMsg());
@@ -568,7 +532,7 @@ class Call {
568
532
  return true;
569
533
  }
570
534
 
571
- leave() {
535
+ leave(reason = 'left') {
572
536
  if (this.state === 'left') return;
573
537
  globalThis.removeEventListener?.('pagehide', this._pagehide);
574
538
  this.transport?.stop();
@@ -579,19 +543,20 @@ class Call {
579
543
  clearInterval(this._statsTimer);
580
544
  this._setSpeaker(null);
581
545
  if (this.local) {
546
+ this._camEndedOff?.();
547
+ this._camEndedOff = null;
582
548
  this.local.stream.getTracks().forEach((t) => t.stop());
583
- if (this.local.owned && this.local.camStream) this.local.camStream.getTracks().forEach((t) => t.stop());
549
+ // A camera THIS module opened is closed; a page's StereoCamera / MediaStream is left running.
550
+ if (this.local.owned && this.local.cam) this.local.cam.close();
551
+ else if (this.local.cam) this.self?.detach();
584
552
  this.local.videoTrack = null;
585
553
  this.local.audioTrack = null;
586
554
  }
587
- if (this.self) {
588
- this.self.unregister();
589
- this.self.route = null; // a rejoin re-registers it
590
- }
555
+ this.self?.detach(); // a rejoin re-attaches it
591
556
  this._unhookWall();
592
557
  this._setState('left');
593
558
  this._layout();
594
- this.emit('left', { room: this.room });
559
+ this.emit('left', { room: this.room, reason });
595
560
  }
596
561
 
597
562
  // ── the wall: session loss and recovery ───────────────────────────────────────────────
@@ -625,7 +590,7 @@ class Call {
625
590
  this.weaveLive = true; // nothing to wait on
626
591
  return;
627
592
  }
628
- const gate = createLiveGate();
593
+ const gate = wire.createLiveGate();
629
594
  const t0 = performance.now();
630
595
  const onFrame = (_t, f) => {
631
596
  if (w !== this.wall || !this.wallLive || this.state === 'left') return;
@@ -680,7 +645,7 @@ class Call {
680
645
  this.log('session-ended', { byPage: hook.pageClosed });
681
646
  for (const t of this.tiles.values()) t.onWallLost();
682
647
  this.self?.onWallLost();
683
- if (hook.pageClosed || !this.o.recoverSession || this.state === 'left') {
648
+ if (hook.pageClosed || this.state === 'left') {
684
649
  this._unhookWall();
685
650
  return;
686
651
  }
@@ -695,7 +660,9 @@ class Call {
695
660
  if (this.state === 'left') return;
696
661
  let w = null;
697
662
  try {
698
- w = await createInline3D(this.o.wallOptions);
663
+ // The shared manager: if the page has already re-opened its own wall, join it rather than
664
+ // opening a second session (rule 1); else this creates the document's new one.
665
+ w = await sharedInline3D();
699
666
  } catch {
700
667
  w = null;
701
668
  }
@@ -706,7 +673,6 @@ class Call {
706
673
  this.log('session-recovered', { attempt });
707
674
  for (const t of this.tiles.values()) t.reroute(true);
708
675
  this.self?.reroute(true);
709
- this.emit('session', { wall: w });
710
676
  }, backoffMs(attempt, { baseMs: 1000, maxMs: 15000 }));
711
677
  }
712
678
 
@@ -716,7 +682,6 @@ class Call {
716
682
  let last = 0;
717
683
  const tick = (now) => {
718
684
  this._raf = requestAnimationFrame(tick);
719
- this.self?.paint();
720
685
  for (const t of this.tiles.values()) t.paint();
721
686
  if (last && this.liftPool && this.liftPool.anyWeb) this._watchFrames(now - last);
722
687
  last = now;
@@ -748,10 +713,10 @@ class Call {
748
713
  const lo = this.o.liftOptions;
749
714
  // liftCapabilities probes the same model source the lifts will use.
750
715
  const capsOpts = { webFallback: true, ...(lo && lo.models ? { models: lo.models } : {}) };
751
- this._liftP = resolveLift(this.mono3D, { log, capsOpts }).then((r) => {
716
+ this._liftP = liftMod.resolveLift(this.mono3D, { log, capsOpts, ...(this.o._liftImporter ? { importer: this.o._liftImporter } : {}) }).then((r) => {
752
717
  this.liftApi = r;
753
718
  if (r.lift) {
754
- this.liftPool = createLiftPool({ lift: r.lift, max: this.o.maxLifted, log, options: this.o.liftOptions });
719
+ this.liftPool = liftMod.createLiftPool({ lift: r.lift, max: this.o.maxLifted, log, options: this.o.liftOptions });
755
720
  this.liftPool.setDepth(this.depth);
756
721
  this.liftPool.setSpeaker(this.speakerId);
757
722
  if (!r.caps && r.capabilities) {
@@ -763,6 +728,12 @@ class Call {
763
728
  })
764
729
  .catch(() => {});
765
730
  }
731
+ } else if (r.reason === 'import-failed' || r.reason === 'no-lift-export') {
732
+ // The MODULE is missing (a bundled app whose bundler could not follow the computed
733
+ // import, or a copy of the SDK built without lift) — not a display that can't lift. Said
734
+ // loudly, once, when a mono peer actually arrives (_warnLiftMissing): a developer testing
735
+ // in the DisplayXR Browser sees it the first time a 2D caller joins.
736
+ this.liftMissing = true;
766
737
  } else if (r.reason !== 'off') {
767
738
  console.info(`${TAG} mono→3D unavailable (${r.reason}) — mono participants are shown flat`);
768
739
  }
@@ -774,6 +745,21 @@ class Call {
774
745
  return this._liftP;
775
746
  }
776
747
 
748
+ /**
749
+ * `warning { code: 'lift-not-bundled' }`, once per call: a mono peer is on a 3D display, lift
750
+ * was asked for ('auto'), and the lift module could not be imported. Names the fix.
751
+ */
752
+ _warnLiftMissing() {
753
+ if (this._liftWarned || !this.liftMissing || !this.mono3DOn) return;
754
+ this._liftWarned = true;
755
+ this.warning(
756
+ 'lift-not-bundled',
757
+ 'a 2D participant is on a 3D display but the 2D→3D module could not be imported from this build, so they stay flat. ' +
758
+ "Import it explicitly — `import { lift } from '@displayxr/inline3d/lift'; mountCall(el, { mono3D: lift })` — " +
759
+ "or use `@displayxr/inline3d/call/full`, which bundles it."
760
+ );
761
+ }
762
+
777
763
  /** Re-run the routing table for every mono tile (lift resolved, a slot freed, setMono3D). */
778
764
  _rerouteMono() {
779
765
  for (const t of this.tiles.values()) if (t.format && t.format !== 'sbs') t.reroute(false);
@@ -795,14 +781,15 @@ class Call {
795
781
  /** What the lobby says a mono participant will look like here. */
796
782
  _liftHint() {
797
783
  if (!this.wallLive) return '';
798
- if (!this.mono3DOn) return 'Mono cameras: 2D (2D→3D is off).';
784
+ const t = this.t;
785
+ if (!this.mono3DOn) return t('liftOff');
799
786
  const r = this.liftApi;
800
- if (!r) return 'Mono cameras: checking 2D→3D…';
801
- if (!r.lift) return 'Mono cameras: 2D (no 2D→3D provider here).';
787
+ if (!r) return t('liftChecking');
788
+ if (!r.lift) return this.liftMissing ? t('liftMissing') : t('liftNoProvider');
802
789
  const c = r.caps;
803
- if (c && c.native) return `Mono cameras: 2D→3D (native${c.provider ? `, ${c.provider}` : ''}).`;
804
- if (c && c.webFallback && c.webFallback.webgpu) return 'Mono cameras: 2D→3D (in this page, WebGPU).';
805
- return this.liftProven ? 'Mono cameras: 2D→3D.' : 'Mono cameras: 2D→3D (confirmed on the first mono participant).';
790
+ if (c && c.native) return t('liftNative', { provider: c.provider ? `, ${c.provider}` : '' });
791
+ if (c && c.webFallback && c.webFallback.webgpu) return t('liftWeb');
792
+ return this.liftProven ? t('liftProven') : t('liftUnproven');
806
793
  }
807
794
 
808
795
  _mono3DInfo() {
@@ -919,7 +906,7 @@ class Call {
919
906
  */
920
907
  _fitGrid() {
921
908
  const ui = this.ui;
922
- if (!ui || !this.o.ui || typeof globalThis.innerHeight !== 'number') return;
909
+ if (!ui || !this.o.chrome || typeof globalThis.innerHeight !== 'number') return;
923
910
  const host = this.container.getBoundingClientRect?.();
924
911
  const grid = ui.grid.getBoundingClientRect?.();
925
912
  if (!host || !grid || !grid.height) return;
@@ -940,55 +927,59 @@ class Call {
940
927
 
941
928
  _buildDom() {
942
929
  const c = this.container;
930
+ const t = this.t;
943
931
  injectCallStyle(c.ownerDocument || document);
944
932
  c.classList.add('dxr-call-host');
945
- if (this.o.accent) c.style.setProperty('--dxr-accent', this.o.accent);
933
+ // theme → CSS custom properties on the host (chrome only: no variable can reach a tile).
934
+ for (const [prop, v] of Object.entries(this.o.cssVars)) c.style.setProperty(prop, v);
946
935
  const ui = (this.ui = {});
947
- ui.banner = el('div', { class: 'dxr-call-banner dxr-call-hidden' }, [
948
- el('span', { text: 'You are seeing this call in 2D.' }),
949
- el('a', { href: this.o.browserUrl, target: '_blank', rel: 'noopener', text: 'View in 3D with DisplayXR Browser' }),
936
+ ui.banner = el('div', { class: 'dxr-call-banner dxr-call-hidden', part: 'banner' }, [
937
+ el('span', { text: t('banner2D') }),
938
+ el('a', { href: this.o.landing.browserUrl, target: '_blank', rel: 'noopener', text: t('bannerLink') }),
950
939
  ]);
951
- ui.grid = el('div', { class: 'dxr-call-grid', 'data-layout': this.o.layout, 'data-n': '0' });
952
- ui.panel = el('div', { class: 'dxr-call-panel dxr-call-hidden' });
953
- ui.selfSlot = el('div', { class: 'dxr-call-self' });
954
- ui.bar = el('div', { class: 'dxr-call-bar' });
940
+ ui.grid = el('div', { class: 'dxr-call-grid', part: 'grid', 'data-layout': this.o.layout, 'data-n': '0' });
941
+ ui.panel = el('div', { class: 'dxr-call-panel dxr-call-hidden', part: 'lobby' });
942
+ ui.selfSlot = el('div', { class: 'dxr-call-self', part: 'self' });
943
+ ui.bar = el('div', { class: 'dxr-call-bar', part: 'bar' });
955
944
  ui.foot = el('div', { class: 'dxr-call-foot' }, [ui.selfSlot, ui.bar]);
956
- ui.invite = el('div', { class: 'dxr-call-panel dxr-call-hidden' });
957
- if (!this.o.ui) {
945
+ ui.invite = el('div', { class: 'dxr-call-panel dxr-call-hidden', part: 'invite' });
946
+ if (!this.o.chrome) {
958
947
  show(ui.bar, false);
959
948
  }
960
949
  c.append(ui.banner, ui.panel, ui.grid, ui.invite, ui.foot);
961
950
  if (this.o.selfView) this.self = new SelfTile(this, ui.selfSlot);
962
951
  else show(ui.selfSlot, false);
963
- if (this.o.ui) this._buildBar();
952
+ if (this.o.chrome) this._buildBar();
964
953
  }
965
954
 
966
955
  _buildBar() {
967
956
  const ui = this.ui;
957
+ const t = this.t;
968
958
  const ib = (label, icon, onclick, extra = '') => el('button', { class: `dxr-call-ib ${extra}`, type: 'button', 'aria-label': label, title: label, html: icon, onclick });
969
- ui.mic = ib('Mute microphone', ICONS.mic, () => this.mute());
970
- ui.cam = ib('Turn camera off', ICONS.cam, () => (this.local?.videoTrack ? this.cameraOff() : this.retryCamera().catch(() => {})));
971
- ui.depth = el('input', { type: 'range', min: '-1', max: '1', step: '0.05', value: '0', 'aria-label': 'Depth' });
959
+ ui.mic = ib(t('mute'), ICONS.mic, () => this.mute());
960
+ ui.cam = ib(t('cameraOff'), ICONS.cam, () => (this.local?.videoTrack ? this.setCameraOff() : this.retryCamera().catch(() => {})));
961
+ ui.depth = el('input', { type: 'range', min: '-1', max: '1', step: '0.05', value: '0', 'aria-label': t('depth') });
972
962
  ui.depth.addEventListener('input', () => this.setDepth(+ui.depth.value));
973
963
  ui.depth.addEventListener('dblclick', () => this.setDepth(0));
974
- ui.inviteBtn = ib('Invite', ICONS.link, () => {
964
+ ui.inviteBtn = ib(t('invite'), ICONS.link, () => {
975
965
  this._showInvite = !this._showInvite;
976
966
  this._layout();
977
967
  });
978
- ui.leave = ib('Leave call', ICONS.leave, () => this.leave(), 'dxr-call-ib--leave');
979
- ui.bar.append(ui.mic, ui.cam, el('label', { class: 'dxr-call-depth' }, ['Depth', ui.depth]), ui.inviteBtn, ui.leave);
968
+ ui.leave = ib(t('leave'), ICONS.leave, () => this.leave(), 'dxr-call-ib--leave');
969
+ ui.bar.append(ui.mic, ui.cam, el('label', { class: 'dxr-call-depth' }, [t('depth'), ui.depth]), ui.inviteBtn, ui.leave);
980
970
  }
981
971
 
982
972
  _refreshBar() {
983
973
  const ui = this.ui;
984
974
  if (!ui || !ui.mic) return;
975
+ const t = this.t;
985
976
  ui.mic.innerHTML = this.muted ? ICONS.micOff : ICONS.mic;
986
977
  ui.mic.setAttribute('aria-pressed', String(this.muted));
987
- ui.mic.title = this.muted ? 'Unmute microphone' : 'Mute microphone';
978
+ ui.mic.title = this.muted ? t('unmute') : t('mute');
988
979
  const noCam = !this.local?.videoTrack;
989
980
  ui.cam.innerHTML = this.camOff || noCam ? ICONS.camOff : ICONS.cam;
990
981
  ui.cam.setAttribute('aria-pressed', String(this.camOff || noCam));
991
- ui.cam.title = noCam ? (this.camStatus === 'busy' ? `${PLATE_TEXT.cameraBusy} — click to retry` : 'No camera — click to retry') : this.camOff ? 'Turn camera on' : 'Turn camera off';
982
+ ui.cam.title = noCam ? (this.camStatus === 'busy' ? t('cameraBusyRetry', { cameraBusy: t('cameraBusy') }) : t('cameraRetry')) : this.camOff ? t('cameraOn') : t('cameraOff');
992
983
  }
993
984
 
994
985
  _refreshSelf() {
@@ -1003,20 +994,21 @@ class Call {
1003
994
  target.replaceChildren();
1004
995
  const link = this.inviteLink();
1005
996
  if (!link) return;
1006
- const input = el('input', { type: 'text', readOnly: true, value: link, 'aria-label': 'Invite link' });
997
+ const t = this.t;
998
+ const input = el('input', { type: 'text', readOnly: true, value: link, 'aria-label': t('inviteLink') });
1007
999
  input.addEventListener('focus', () => input.select());
1008
- const copy = el('button', { class: 'dxr-call-btn dxr-call-btn--primary', type: 'button', text: 'Copy link' });
1000
+ const copy = el('button', { class: 'dxr-call-btn dxr-call-btn--primary', type: 'button', text: t('copyLink') });
1009
1001
  copy.addEventListener('click', async () => {
1010
1002
  try {
1011
1003
  await navigator.clipboard.writeText(link);
1012
- copy.textContent = 'Copied';
1004
+ copy.textContent = t('copied');
1013
1005
  } catch {
1014
1006
  input.select();
1015
- copy.textContent = 'Press Ctrl/Cmd+C';
1007
+ copy.textContent = t('pressCopy');
1016
1008
  }
1017
- setTimeout(() => (copy.textContent = 'Copy link'), 1800);
1009
+ setTimeout(() => (copy.textContent = t('copyLink')), 1800);
1018
1010
  });
1019
- const qr = el('canvas', { 'aria-label': 'QR code of the invite link', role: 'img' });
1011
+ const qr = el('canvas', { 'aria-label': t('qrLabel'), role: 'img' });
1020
1012
  try {
1021
1013
  drawQr(qr, link, { px: 4 });
1022
1014
  } catch {
@@ -1033,16 +1025,17 @@ class Call {
1033
1025
  const ui = this.ui;
1034
1026
  if (!ui) return;
1035
1027
  const c = this.container;
1036
- const lobby = this.o.ui && (this.state === 'lobby' || this.state === 'idle' || this.state === 'full' || this.state === 'left');
1028
+ const t = this.t;
1029
+ const lobby = this.o.chrome && (this.state === 'lobby' || this.state === 'idle' || this.state === 'full' || this.state === 'left');
1037
1030
  c.classList.toggle('dxr-call-host--lobby', lobby);
1038
- show(ui.banner, this.o.ui && !this.woven);
1031
+ show(ui.banner, this.o.chrome && !this.woven);
1039
1032
  const n = this.tiles.size;
1040
1033
  ui.grid.dataset.n = String(Math.min(n, 4));
1041
1034
  show(ui.grid, n > 0 && this.state !== 'left');
1042
- // Speaker layout: the active speaker (or the first tile) spans the row.
1035
+ // Speaker layout: the active speaker (or the first tile) spans the row. layout:'none' positions nothing.
1043
1036
  const main = this.o.layout === 'speaker' ? this.speakerId || [...this.tiles.keys()][0] : null;
1044
- for (const [id, t] of this.tiles) t.el.classList.toggle('dxr-call-tile--main', id === main);
1045
- if (!this.o.ui) return;
1037
+ for (const [id, tile] of this.tiles) tile.el.classList.toggle('dxr-call-tile--main', id === main);
1038
+ if (!this.o.chrome) return;
1046
1039
  this._refreshBar();
1047
1040
  queueMicrotask(() => this._fitGrid());
1048
1041
  const p = ui.panel;
@@ -1051,39 +1044,67 @@ class Call {
1051
1044
  p.replaceChildren();
1052
1045
  const l = this.local;
1053
1046
  const joining = !!this.room;
1054
- const title =
1055
- this.state === 'full' ? 'This call is full' : this.state === 'left' ? 'You left the call' : joining ? 'Join the 3D call' : 'Start a 3D call';
1056
- const kind = this.camStatus === 'busy' ? 'busy (joining audio-only)' : !l || !l.videoTrack ? 'none (joining audio-only)' : l.format === 'sbs' ? `sending 3D (side-by-side, ${l.width}×${l.height})` : `sending 2D (${l.width}×${l.height})`;
1047
+ const title = this.state === 'full' ? t('lobbyFullTitle') : this.state === 'left' ? t('lobbyLeftTitle') : joining ? t('lobbyJoinTitle') : t('lobbyStartTitle');
1048
+ const dims = { width: l?.width || 0, height: l?.height || 0 };
1049
+ const kind = this.camStatus === 'busy' ? t('kindBusy') : !l || !l.videoTrack ? t('kindNone') : l.format === 'sbs' ? t('kindSbs', dims) : t('kindMono', dims);
1057
1050
  const text =
1058
1051
  this.state === 'full'
1059
- ? `It already has ${this.o.maxPeers} participants.`
1060
- : `Check your framing below. Camera: ${l?.label || 'default'} — ${kind}.${this._liftHint() ? ` ${this._liftHint()}` : ''}`;
1052
+ ? t('lobbyFullText', { maxPeers: this.o.maxPeers })
1053
+ : `${t('lobbyText', { camera: l?.label || t('cameraDefault'), kind })}${this._liftHint() ? ` ${this._liftHint()}` : ''}`;
1061
1054
  const go = el('button', {
1062
1055
  class: 'dxr-call-btn dxr-call-btn--primary',
1063
1056
  type: 'button',
1064
- text: this.state === 'left' ? 'Rejoin' : joining ? 'Join call' : 'Start 3D call',
1057
+ text: this.state === 'left' ? t('rejoin') : joining ? t('join') : t('start'),
1065
1058
  });
1066
1059
  go.addEventListener('click', () => this.join().catch(() => {}));
1067
- const sel = el('select', { class: 'dxr-call-select', 'aria-label': 'Camera' });
1060
+ const sel = el('select', { class: 'dxr-call-select', 'aria-label': t('cameraSelect') });
1068
1061
  this._fillCameras(sel);
1069
1062
  sel.addEventListener('change', () => this.setCamera(sel.value).catch((e) => this.error('camera-failed', e.message, e)));
1070
- const retry = !l || !l.videoTrack ? el('button', { class: 'dxr-call-btn', type: 'button', text: 'Retry camera' }) : null;
1063
+ const retry = !l || !l.videoTrack ? el('button', { class: 'dxr-call-btn', type: 'button', text: t('retryCamera') }) : null;
1071
1064
  retry?.addEventListener('click', () => this.retryCamera().catch(() => {}));
1072
1065
  p.append(el('h3', { text: title }), el('p', { text }), el('div', { class: 'dxr-call-row' }, [this.state === 'full' ? null : go, retry, sel]));
1073
1066
  } else if (this.state === 'in-call' && n === 0) {
1074
1067
  show(p, true);
1075
- this._renderInvite(p, 'Waiting for others', 'Share this link (or scan the code). Anyone who opens it joins — keep it private.');
1068
+ this._renderInvite(p, t('waitingTitle'), t('waitingText'));
1076
1069
  } else if (this.state === 'joining') {
1077
1070
  show(p, true);
1078
- p.replaceChildren(el('p', { text: 'Joining…' }));
1071
+ p.replaceChildren(el('p', { text: t('joining') }));
1079
1072
  } else show(p, false);
1080
1073
  const inviteOpen = this.state === 'in-call' && n > 0 && this._showInvite;
1081
- if (inviteOpen && !ui.invite.childElementCount) this._renderInvite(ui.invite, 'Invite', 'Anyone with this link can join.');
1074
+ if (inviteOpen && !ui.invite.childElementCount) this._renderInvite(ui.invite, t('inviteTitle'), t('inviteText'));
1082
1075
  show(ui.invite, inviteOpen);
1083
1076
  if (!inviteOpen) ui.invite.replaceChildren();
1084
1077
  ui.inviteBtn?.setAttribute('aria-pressed', String(!!inviteOpen));
1085
1078
  }
1086
1079
 
1080
+ /**
1081
+ * Diagnostics (explicitly UNSTABLE — RFC 0003 §2): everything that used to sit on the public
1082
+ * handle and `CallPeer` as debug data. Shape free to change; read it in devtools, never in code.
1083
+ */
1084
+ diagnostics() {
1085
+ const l = this.local;
1086
+ return {
1087
+ sdk: wire.CALL_SDK,
1088
+ wire: wire.WIRE_VERSION,
1089
+ state: this.state,
1090
+ wall: this.wall,
1091
+ wallLive: this.wallLive,
1092
+ weaveLive: this.weaveLive,
1093
+ woven: this.woven,
1094
+ local: l
1095
+ ? { format: l.format, width: l.width, height: l.height, label: l.label, calibration: l.calibration, owned: l.owned, skipped: l.skipped, camera: l.cam || null, stream: l.stream }
1096
+ : null,
1097
+ hello: this.hello(),
1098
+ transport: this.transport,
1099
+ peers: [...this.tiles.values()].map((tile) => tile.diag()),
1100
+ mono3D: { ...this._mono3DInfo(), lift: this.liftApi, pool: this.liftPool ? this.liftPool.list() : [] },
1101
+ speaker: this.speakerId,
1102
+ depth: this.depth,
1103
+ options: this.o,
1104
+ kill: (id) => this.transport?._debugKill(id),
1105
+ };
1106
+ }
1107
+
1087
1108
  async _fillCameras(sel) {
1088
1109
  let cams = [];
1089
1110
  try {
@@ -1099,7 +1120,8 @@ class Call {
1099
1120
 
1100
1121
  _makeHandle() {
1101
1122
  const call = this;
1102
- return {
1123
+ const renamedEvents = { format: 'display', session: null };
1124
+ const handle = {
1103
1125
  get room() {
1104
1126
  return call.room;
1105
1127
  },
@@ -1109,15 +1131,16 @@ class Call {
1109
1131
  get state() {
1110
1132
  return call.state;
1111
1133
  },
1112
- get wall() {
1113
- return call.wall;
1114
- },
1115
- get format() {
1134
+ /** What YOU send: 'sbs' | 'mono'; null before a camera opened. */
1135
+ get localFormat() {
1116
1136
  return call.local ? call.local.format : null;
1117
1137
  },
1118
1138
  get muted() {
1119
1139
  return call.muted;
1120
1140
  },
1141
+ get cameraOff() {
1142
+ return call.camOff;
1143
+ },
1121
1144
  get depth() {
1122
1145
  return call.depth;
1123
1146
  },
@@ -1136,18 +1159,25 @@ class Call {
1136
1159
  get peers() {
1137
1160
  return Object.freeze([...call.tiles.values()].map((t) => t.info()));
1138
1161
  },
1139
- join: () => call.join(),
1162
+ join: () => call.join().then(() => undefined),
1140
1163
  inviteLink: () => call.inviteLink(),
1141
1164
  mute: (on) => call.mute(on),
1142
- cameraOff: (on) => call.cameraOff(on),
1143
- setCamera: (idOrStream, o) => call.setCamera(idOrStream, o),
1165
+ setCameraOff: (on) => call.setCameraOff(on),
1166
+ setCamera: (src, o) => call.setCamera(src, o),
1144
1167
  setDepth: (v) => call.setDepth(v),
1145
1168
  retryCamera: () => call.retryCamera(),
1146
- sendHint: (z) => call.sendHint(z),
1147
1169
  setMono3D: (on) => call.setMono3D(on),
1148
- leave: () => call.leave(),
1170
+ leave: () => call.leave('left'),
1171
+ /** A remote participant's tile element (for `layout: 'none'`), or null. */
1172
+ tile: (peerId) => call.tiles.get(peerId)?.el || null,
1173
+ diagnostics: () => call.diagnostics(),
1149
1174
  on(type, cb) {
1150
1175
  if (typeof cb !== 'function') throw new TypeError(`${TAG} on() takes a function`);
1176
+ if (type in renamedEvents && !call._eventWarned?.has(type)) {
1177
+ (call._eventWarned ||= new Set()).add(type);
1178
+ const to = renamedEvents[type];
1179
+ console.warn(`${TAG} the '${type}' event is deprecated (removed in 1.31)${to ? ` — listen for '${to}'` : ' — it is internal now; diagnostics() has the wall'}`);
1180
+ }
1151
1181
  if (!call.listeners.has(type)) call.listeners.set(type, new Set());
1152
1182
  call.listeners.get(type).add(cb);
1153
1183
  return () => call.listeners.get(type)?.delete(cb);
@@ -1155,14 +1185,28 @@ class Call {
1155
1185
  off(type, cb) {
1156
1186
  call.listeners.get(type)?.delete(cb);
1157
1187
  },
1158
- /** Diagnostics: the transport (mesh) and a way to simulate a dropped connection. */
1159
- _debug: {
1160
- get transport() {
1161
- return call.transport;
1162
- },
1163
- kill: (id) => call.transport?._debugKill(id),
1164
- },
1165
1188
  };
1189
+ // 1.29 handle members, one release of warnings (RFC 0003 §2): removed in 1.31.
1190
+ const legacy = {
1191
+ wall: ['diagnostics().wall (or the wall you passed / sharedInline3D())', () => call.wall],
1192
+ format: ['localFormat', () => handle.localFormat],
1193
+ sendHint: ['nothing — auto-convergence superseded it', () => (z) => call.sendHint(z)],
1194
+ _debug: ['diagnostics()', () => ({ transport: call.transport, kill: (id) => call.transport?._debugKill(id) })],
1195
+ };
1196
+ for (const [name, [to, get]] of Object.entries(legacy)) {
1197
+ Object.defineProperty(handle, name, {
1198
+ enumerable: false,
1199
+ configurable: true,
1200
+ get() {
1201
+ if (!call._handleWarned?.has(name)) {
1202
+ (call._handleWarned ||= new Set()).add(name);
1203
+ console.warn(`${TAG} handle.${name} is deprecated (removed in 1.31) — use ${to}`);
1204
+ }
1205
+ return get();
1206
+ },
1207
+ });
1208
+ }
1209
+ return handle;
1166
1210
  }
1167
1211
  }
1168
1212
 
@@ -1192,7 +1236,7 @@ function paintFlat(canvas, video, { eyeHalf = null, mirror = false } = {}) {
1192
1236
  const H = video.videoHeight;
1193
1237
  if (!W || !H || (video.readyState || 0) < 2) return;
1194
1238
  const eyeW = eyeHalf === null ? W : W / 2;
1195
- const r = eyeCropRect(eyeW, H, canvas.width / canvas.height, 0, 0);
1239
+ const r = wire.eyeCropRect(eyeW, H, canvas.width / canvas.height, 0, 0);
1196
1240
  g.save();
1197
1241
  if (mirror) {
1198
1242
  g.translate(canvas.width, 0);
@@ -1219,6 +1263,17 @@ class Tile {
1219
1263
  this.speaking = false;
1220
1264
  this.quality = null;
1221
1265
  this.conv = createConvergence();
1266
+ this.autoTrack = createDisparityTrack();
1267
+ this.autoFocus = createFocusTracker();
1268
+ this.autoAt = 0;
1269
+ this.autoCanvas = null;
1270
+ this.autoLocked = false;
1271
+ this.autoStats = { n: 0, ms: 0, readMs: 0, method: null, via: null };
1272
+ this.autoPending = false;
1273
+ // Bumped on every stream change and on destroy(): an async copyTo() that resolves after either
1274
+ // belongs to a stream this tile no longer shows, and is dropped (review of #92).
1275
+ this.autoGen = 0;
1276
+ this.destroyed = false;
1222
1277
  this.conv.depth = call.depth;
1223
1278
  this.hintGate = rateGate(HINT_MAX_HZ);
1224
1279
  this.layerFails = 0;
@@ -1238,11 +1293,12 @@ class Tile {
1238
1293
  this.video.addEventListener('resize', () => this._maybeRoute());
1239
1294
  this.canvas = el('canvas');
1240
1295
  this.cover = el('div', { class: 'dxr-call-cover dxr-call-hidden' });
1241
- this.badge = el('div', { class: 'dxr-call-badge' });
1242
- this.plate = el('div', { class: 'dxr-call-state', text: 'Connecting…' });
1296
+ this.badge = el('div', { class: 'dxr-call-badge', part: 'badge' });
1297
+ this.plate = el('div', { class: 'dxr-call-state', part: 'plate', text: call.t('connecting') });
1243
1298
  this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.cover, this.badge, this.plate]);
1244
1299
  this.stage.style.aspectRatio = String(call.o.tileAspect);
1245
- this.el = el('div', { class: 'dxr-call-tile', 'data-peer': id }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
1300
+ // `data-dxr-peer` is the documented hook (layout:'none', handle.tile()); `data-peer` is the 1.29 spelling.
1301
+ 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' })]);
1246
1302
  if (!call.o.ui) {
1247
1303
  show(this.badge, false);
1248
1304
  }
@@ -1250,34 +1306,59 @@ class Tile {
1250
1306
  this._renderBadge();
1251
1307
  }
1252
1308
 
1309
+ /** How this side shows the participant: `3D` (woven pair) | `2D→3D` (lifted) | `2D` (flat). */
1310
+ display() {
1311
+ return wire.badgeFor(this.route);
1312
+ }
1313
+
1314
+ /** The public `CallPeer` (RFC 0003 §2): what a page needs to run and observe. Debug data is `diag()`. */
1253
1315
  info() {
1254
1316
  return Object.freeze({
1255
1317
  id: this.id,
1256
1318
  format: this.format || 'mono',
1257
- route: this.route,
1319
+ display: this.display(),
1258
1320
  state: this.conn,
1259
1321
  muted: this.remote.muted,
1260
1322
  cameraOff: this.remote.cameraOff,
1261
1323
  speaking: this.speaking,
1324
+ });
1325
+ }
1326
+
1327
+ /** Everything else (UNSTABLE): `handle.diagnostics().peers[i]`. */
1328
+ diag() {
1329
+ return {
1330
+ ...this.info(),
1331
+ route: this.route,
1262
1332
  rectified: !!(this.hello && this.hello.rectified),
1263
1333
  hello: this.hello,
1264
1334
  quality: this.quality,
1265
1335
  convergencePx: this.conv.current,
1336
+ // Auto-convergence: the measured disparity (source px, null until a lock) and the mean cost of
1337
+ // one measurement including the frame readback.
1338
+ autoConverge: {
1339
+ disparityPx: this.conv.measuredPx,
1340
+ ms: this.autoStats.n ? this.autoStats.ms / this.autoStats.n : null,
1341
+ readMs: this.autoStats.n ? this.autoStats.readMs / this.autoStats.n : null,
1342
+ method: this.autoStats.method,
1343
+ via: this.autoStats.via,
1344
+ },
1266
1345
  lift: this.lifted
1267
- ? Object.freeze({
1346
+ ? {
1268
1347
  live: this.liftLive,
1269
1348
  native: !!(this.liftHandle && this.liftHandle.native),
1270
1349
  priority: this.call.liftPool ? this.call.liftPool.priority(this.id) : null,
1271
1350
  state: this.liftHandle ? this.liftHandle.state || null : 'pending',
1272
- })
1351
+ }
1273
1352
  : null,
1274
- });
1353
+ element: this.el,
1354
+ };
1275
1355
  }
1276
1356
 
1277
1357
  setStream(stream) {
1278
1358
  if (this.video.srcObject !== stream) {
1279
1359
  this.video.srcObject = stream;
1280
1360
  this.audio.srcObject = stream;
1361
+ this._resetAutoConverge(); // a new stream may be a new camera: measure afresh
1281
1362
  }
1282
1363
  this.video.play().catch(() => {});
1283
1364
  this.audio.play().catch(() => {});
@@ -1325,7 +1406,7 @@ class Tile {
1325
1406
 
1326
1407
  setConn(st) {
1327
1408
  if (st === 'unreachable' && this.conn !== 'unreachable') {
1328
- this.call.error('unreachable', `${PLATE_TEXT.unreachable}. Still retrying in the background.`, Object.assign(new Error('unreachable'), { peer: this.id }));
1409
+ this.call.error('unreachable', `${this.call.t('unreachable')}. Still retrying in the background.`, Object.assign(new Error('unreachable'), { peer: this.id }));
1329
1410
  }
1330
1411
  this.conn = st;
1331
1412
  if (st === 'connected' && this.leaveTimer) {
@@ -1336,26 +1417,28 @@ class Tile {
1336
1417
  }
1337
1418
 
1338
1419
  _renderPlate() {
1420
+ const s = this.call.t;
1339
1421
  const t =
1340
1422
  this.conn === 'left'
1341
- ? 'Left the call'
1423
+ ? s('leftCall')
1342
1424
  : this.conn === 'unreachable'
1343
- ? PLATE_TEXT.unreachable
1425
+ ? s('unreachable')
1344
1426
  : this.conn === 'reconnecting'
1345
- ? 'Reconnecting…'
1346
- : this.conn !== 'connected'
1347
- ? 'Connecting…'
1348
- : this.remote.cameraOff
1349
- ? 'Camera off'
1350
- : null;
1427
+ ? s('reconnecting')
1428
+ : this.conn !== 'connected'
1429
+ ? s('connecting')
1430
+ : this.remote.cameraOff
1431
+ ? s('cameraOffPlate')
1432
+ : null;
1351
1433
  this.plate.textContent = t || '';
1352
1434
  show(this.plate, !!t);
1353
1435
  }
1354
1436
 
1355
1437
  _renderBadge() {
1356
- const b = badgeFor(this.route);
1438
+ const s = this.call.t;
1439
+ const b = this.route === 'woven-sbs' ? s('badge3D') : this.route === 'lifted' ? s('badge2D3D') : s('badge2D');
1357
1440
  // A lifted tile whose lift is still loading reads "2D→3D…" (it is painted flat meanwhile).
1358
- const text = !this.route ? '…' : this.route === 'lifted' && !this.liftLive ? `${b}…` : b;
1441
+ const text = !this.route ? s('badgePending') : this.route === 'lifted' && !this.liftLive ? `${b}${s('badgePending')}` : b;
1359
1442
  this.badge.replaceChildren(el('b', { text }));
1360
1443
  this.badge.dataset.route = this.route || '';
1361
1444
  if (this.remote.muted) this.badge.insertAdjacentHTML('beforeend', ICONS.mutedSmall);
@@ -1373,7 +1456,7 @@ class Tile {
1373
1456
  const r =
1374
1457
  this.forceFlat && this.format === 'sbs'
1375
1458
  ? { route: 'flat-left' }
1376
- : routeFor({
1459
+ : wire.routeFor({
1377
1460
  format: this.format,
1378
1461
  woven: call.woven,
1379
1462
  mono3D: call.mono3DOn ? 'auto' : 'off',
@@ -1383,6 +1466,8 @@ class Tile {
1383
1466
  });
1384
1467
  // First mono peer on a woven wall and lift not asked for yet (e.g. setMono3D(true) later).
1385
1468
  if (this.format !== 'sbs' && call.woven && call.mono3DOn && !call._liftP) call._ensureLift();
1469
+ // A mono peer on a 3D display that stays flat because the lift MODULE is missing: say so.
1470
+ if (this.format !== 'sbs' && call.woven && call.liftMissing) call._warnLiftMissing();
1386
1471
  const key = `${r.route}|${this.format}`;
1387
1472
  if (!force && key === this.routedKey) return;
1388
1473
  this.routedKey = key;
@@ -1391,7 +1476,9 @@ class Tile {
1391
1476
  if (r.route === 'woven-sbs') this._registerWoven();
1392
1477
  else if (r.route === 'lifted') this._registerLifted();
1393
1478
  call.log('route', { peer: this.id, route: r.route, format: this.format, mono3d: r.mono3d || null });
1394
- call.emit('format', { id: this.id, format: this.format, route: r.route, mono3d: r.mono3d || null, hello: this.hello });
1479
+ call.emit('display', { id: this.id, display: this.display() });
1480
+ // 1.29's 'format' event (route / mono3d detail → diagnostics()): one release of compatibility.
1481
+ if (call.listeners.get('format')?.size) call.emit('format', { id: this.id, format: this.format, route: r.route, mono3d: r.mono3d || null, hello: this.hello });
1395
1482
  this._renderBadge();
1396
1483
  }
1397
1484
 
@@ -1572,15 +1659,123 @@ class Tile {
1572
1659
  c.width = 2 * outW;
1573
1660
  c.height = outH;
1574
1661
  }
1662
+ if (this.call.o.autoConverge) this._sampleDisparity(v, W, H);
1575
1663
  const shift = this.conv.step(eyeW);
1576
1664
  const g = c.getContext('2d');
1577
1665
  for (const eye of [0, 1]) {
1578
- const r = eyeCropRect(eyeW, H, A, shift, eye);
1666
+ const r = wire.eyeCropRect(eyeW, H, A, shift, eye);
1579
1667
  g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
1580
1668
  }
1581
1669
  }
1582
1670
 
1671
+ /**
1672
+ * Auto-convergence: a few times a second, measure the disparity of the point between the remote
1673
+ * person's eyes on a small grayscale copy of the SOURCE frame (before our shift, so there is no
1674
+ * feedback loop) and hand it to the convergence state, which halves it per eye and low-passes it.
1675
+ * A failed measurement holds the last good value.
1676
+ */
1677
+ _sampleDisparity(v, W, H) {
1678
+ const now = performance.now();
1679
+ if (this.autoPending || now - this.autoAt < AUTO_CONV_INTERVAL_MS) return;
1680
+ this.autoAt = now;
1681
+ // Preferred: WebCodecs. `new VideoFrame(video)` + an ASYNC copyTo() of the decoded frame, reading
1682
+ // the luma (Y) plane directly: no synchronous GPU->CPU canvas readback on the paint path (that
1683
+ // readback, not the matching, was ~25 ms of a ~30 ms measurement).
1684
+ if (typeof VideoFrame === 'function') {
1685
+ let frame = null;
1686
+ try {
1687
+ frame = new VideoFrame(v);
1688
+ } catch {
1689
+ frame = null;
1690
+ }
1691
+ if (frame && Y_PLANE_FORMATS.has(frame.format)) {
1692
+ this.autoPending = true;
1693
+ const gen = this.autoGen;
1694
+ const fw = frame.visibleRect ? frame.visibleRect.width : frame.displayWidth;
1695
+ const fh = frame.visibleRect ? frame.visibleRect.height : frame.displayHeight;
1696
+ const buf = new Uint8Array(frame.allocationSize());
1697
+ frame
1698
+ .copyTo(buf)
1699
+ .then((layout) => {
1700
+ if (gen !== this.autoGen) return; // the stream changed or the tile is gone: stale frame
1701
+ const t0 = performance.now();
1702
+ const f = Math.max(1, Math.ceil(fw / 2 / AUTO_CONV_EYE_WIDTH));
1703
+ const ds = downsampleLuma(buf, layout[0].offset, layout[0].stride, fw, fh, f);
1704
+ this._applyMeasurement(ds.img, ds.w, ds.h, ds.w / fw, now, performance.now() - t0, 'videoframe');
1705
+ })
1706
+ .catch(() => {
1707
+ if (gen === this.autoGen) this._applyMeasurement(null, 0, 0, 1, now, 0, 'videoframe');
1708
+ })
1709
+ .finally(() => {
1710
+ frame.close();
1711
+ this.autoPending = false;
1712
+ });
1713
+ return;
1714
+ }
1715
+ if (frame) frame.close();
1716
+ }
1717
+ // Fallback: a small canvas + getImageData (synchronous readback).
1718
+ const s = Math.min(1, AUTO_CONV_EYE_WIDTH / (W / 2));
1719
+ const w = Math.max(2, Math.round((W * s) / 2) * 2);
1720
+ const h = Math.max(2, Math.round(H * s));
1721
+ if (!this.autoCanvas) this.autoCanvas = document.createElement('canvas');
1722
+ const c = this.autoCanvas;
1723
+ if (c.width !== w || c.height !== h) {
1724
+ c.width = w;
1725
+ c.height = h;
1726
+ }
1727
+ const g = c.getContext('2d', { willReadFrequently: true });
1728
+ let img = null;
1729
+ try {
1730
+ g.drawImage(v, 0, 0, w, h);
1731
+ img = lumaFromRgba(g.getImageData(0, 0, w, h).data, w * h);
1732
+ } catch {
1733
+ img = null; // a frame that cannot be read (tainted, not decoded yet): skip it
1734
+ }
1735
+ this._applyMeasurement(img, w, h, w / W, now, performance.now() - now, 'canvas');
1736
+ }
1737
+
1738
+ /**
1739
+ * Measure one grabbed frame and hand the disparity to the convergence state (which halves it per
1740
+ * eye and low-passes it). A failed measurement holds the last good value. `readMs` = main-thread
1741
+ * time spent getting the pixels.
1742
+ */
1743
+ _applyMeasurement(img, w, h, s, at, readMs, via) {
1744
+ if (this.destroyed) return;
1745
+ const t = performance.now();
1746
+ const m = img ? this.autoFocus.measure(img, w, h, at) : null;
1747
+ const d = this.autoTrack.push(m ? m.d / s : null);
1748
+ this.conv.measuredPx = d;
1749
+ this.autoStats.n++;
1750
+ this.autoStats.readMs += readMs;
1751
+ this.autoStats.ms += readMs + (performance.now() - t);
1752
+ this.autoStats.method = m ? m.method : null;
1753
+ this.autoStats.via = via;
1754
+ if (m && !this.autoLocked) {
1755
+ this.autoLocked = true;
1756
+ this.call.log('auto-converge', {
1757
+ peer: this.id,
1758
+ disparityPx: Math.round(d * 10) / 10,
1759
+ at: { x: Math.round(m.x / s), y: Math.round(m.y / s) },
1760
+ ncc: Math.round(m.c * 100) / 100,
1761
+ method: m.method,
1762
+ via,
1763
+ ms: Math.round(readMs + (performance.now() - t)),
1764
+ });
1765
+ }
1766
+ }
1767
+
1768
+ _resetAutoConverge() {
1769
+ this.autoGen++;
1770
+ this.autoTrack.reset();
1771
+ this.autoFocus.reset();
1772
+ this.conv.measuredPx = null;
1773
+ this.autoLocked = false;
1774
+ }
1775
+
1583
1776
  destroy() {
1777
+ this.destroyed = true;
1778
+ this.autoGen++;
1584
1779
  clearTimeout(this.helloTimer);
1585
1780
  clearTimeout(this.layerTimer);
1586
1781
  clearTimeout(this.leaveTimer);
@@ -1594,102 +1789,198 @@ class Tile {
1594
1789
 
1595
1790
  // ── the self view ─────────────────────────────────────────────────────────────────────────
1596
1791
 
1792
+ /**
1793
+ * The self view IS an `addCameraView` of `@displayxr/inline3d/camera` (RFC 0003 §4): mirrored
1794
+ * correctly (each half mirrored AND the halves swapped — mirroring each half in place would invert
1795
+ * every disparity), woven on a 3D wall, flat elsewhere. The stream that is SENT is never touched.
1796
+ * This class owns the tile's chrome (badge, plate) and re-attaches the view when the camera or
1797
+ * the wall changes; the view owns the pixels.
1798
+ */
1597
1799
  class SelfTile {
1598
1800
  constructor(call, slot) {
1599
1801
  this.call = call;
1600
1802
  this.canvas = el('canvas');
1601
- this.badge = el('div', { class: 'dxr-call-badge' });
1602
- this.plate = el('div', { class: 'dxr-call-state dxr-call-hidden' });
1803
+ this.badge = el('div', { class: 'dxr-call-badge', part: 'badge' });
1804
+ this.plate = el('div', { class: 'dxr-call-state dxr-call-hidden', part: 'plate' });
1603
1805
  this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.badge, this.plate]);
1604
1806
  this.stage.style.aspectRatio = String(call.o.tileAspect);
1605
- this.el = el('div', { class: 'dxr-call-tile dxr-call-tile--self' }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
1807
+ this.el = el('div', { class: 'dxr-call-tile dxr-call-tile--self', part: 'tile' }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
1606
1808
  slot.appendChild(this.el);
1607
- this.route = null;
1608
- this.handle = null;
1609
- this.mirror = null;
1809
+ this.view = null; // the CameraView over the current camera
1810
+ this.cam = null;
1811
+ }
1812
+
1813
+ /** 'woven-sbs' | 'flat-left' | 'flat' | null (no camera). */
1814
+ get route() {
1815
+ return this.view ? this.view.route : null;
1610
1816
  }
1611
1817
 
1612
1818
  update() {
1613
1819
  const l = this.call.local;
1820
+ const t = this.call.t;
1614
1821
  const noCam = !l || !l.videoTrack;
1615
- this.plate.textContent = noCam ? (this.call.camStatus === 'busy' ? PLATE_TEXT.cameraBusy : this.call.camStatus === 'pending' ? '' : 'No camera') : this.call.camOff ? 'Camera off' : '';
1822
+ this.plate.textContent = noCam ? (this.call.camStatus === 'busy' ? t('cameraBusy') : this.call.camStatus === 'pending' ? '' : t('noCamera')) : this.call.camOff ? t('cameraOffPlate') : '';
1616
1823
  show(this.plate, noCam || this.call.camOff);
1617
1824
  this.reroute(false);
1618
1825
  }
1619
1826
 
1620
1827
  reroute(force) {
1621
1828
  const l = this.call.local;
1622
- // Local SBS on a woven wall → woven mirror-and-swap preview; anything else is flat.
1623
- const route = l && l.format === 'sbs' && this.call.woven && l.videoTrack ? 'woven-sbs' : l && l.format === 'sbs' ? 'flat-left' : 'flat';
1624
- if (!force && route === this.route) return this._badge();
1625
- this.unregister();
1626
- this.route = route;
1627
- if (route === 'woven-sbs') {
1628
- if (!this.mirror) this.mirror = document.createElement('canvas');
1629
- this._paintMirror();
1630
- this.handle = this.call.wall.addImage(this.canvas, this.mirror);
1829
+ const cam = l && l.videoTrack ? l.cam : null;
1830
+ const wall = this.call.woven ? this.call.wall : { supported: false };
1831
+ if (!cam || cam.state !== 'live') {
1832
+ this.detach();
1833
+ return this._badge();
1834
+ }
1835
+ if (this.view && this.cam === cam) this.view._reroute(force, wall);
1836
+ else {
1837
+ this.detach();
1838
+ this.cam = cam;
1839
+ this.view = addCameraView(wall, this.canvas, cam, { mirror: true, autoConverge: false, aspect: this.call.o.tileAspect });
1631
1840
  }
1632
1841
  this._badge();
1633
1842
  }
1634
1843
 
1635
1844
  _badge() {
1636
- this.badge.replaceChildren(el('b', { text: `You · ${this.route === 'woven-sbs' ? '3D' : '2D'}` }));
1845
+ const t = this.call.t;
1846
+ this.badge.replaceChildren(el('b', { text: `${t('you')} · ${this.route === 'woven-sbs' ? t('badge3D') : t('badge2D')}` }));
1637
1847
  }
1638
1848
 
1639
1849
  onWallLost() {
1640
- this.handle = null;
1641
- this.reroute(true);
1850
+ this.view?._onWallLost();
1851
+ this._badge();
1642
1852
  }
1643
1853
 
1644
- unregister() {
1645
- if (this.handle) {
1646
- try {
1647
- this.handle.remove();
1648
- } catch {
1649
- /* ignore */
1650
- }
1651
- this.handle = null;
1652
- }
1854
+ /** Drop the view (leave(), a camera change). The canvas keeps its last frame; a rejoin re-attaches. */
1855
+ detach() {
1856
+ if (this.view) this.view.remove();
1857
+ this.view = null;
1858
+ this.cam = null;
1653
1859
  }
1654
1860
 
1655
1861
  setSpeaking(on) {
1656
1862
  this.el.classList.toggle('dxr-call-tile--speaking', on);
1657
1863
  }
1864
+ }
1658
1865
 
1659
- paint() {
1660
- const l = this.call.local;
1661
- if (!l || !l.video || this.call.state === 'left') return;
1662
- if (this.route === 'woven-sbs') this._paintMirror();
1663
- else paintFlat(this.canvas, l.video, { eyeHalf: this.route === 'flat-left' ? 0 : null, mirror: true });
1866
+ // ── 1.29 exports kept for ONE release (RFC 0003 §2 "Mechanics") ───────────────────────────
1867
+ //
1868
+ // Every helper below is INTERNAL: it exists so the module can be unit-tested, and it stays
1869
+ // importable by file path (`js/call/*.js`). It is no longer part of this entry's public surface
1870
+ // (call.d.ts marks each `@deprecated`; test/api-snapshot pins the list) and 1.31 removes it from
1871
+ // here. Until then a function or class warns once on its first call, an object on its first
1872
+ // property read; a primitive constant cannot warn (ESM bindings have no getter) and is simply
1873
+ // typed `@deprecated`. `peerjsCloud` left in 1.29 outright (Decision 12).
1874
+
1875
+ const REMOVED_IN = '1.31';
1876
+ const deprecatedWarned = new Set();
1877
+ function deprecate(name, from, value) {
1878
+ const warn = () => {
1879
+ if (deprecatedWarned.has(name)) return;
1880
+ deprecatedWarned.add(name);
1881
+ console.warn(`${TAG} \`${name}\` is internal and leaves '@displayxr/inline3d/call' in ${REMOVED_IN} — import it from ${from} if you must, or stop depending on it`);
1882
+ };
1883
+ if (typeof value === 'function') {
1884
+ return new Proxy(value, {
1885
+ apply(target, self, args) {
1886
+ warn();
1887
+ return Reflect.apply(target, self, args);
1888
+ },
1889
+ construct(target, args) {
1890
+ warn();
1891
+ return Reflect.construct(target, args);
1892
+ },
1893
+ });
1664
1894
  }
1665
-
1666
- /**
1667
- * The MIRRORED stereo preview: each half mirrored AND the halves swapped (wire.js
1668
- * mirrorSwapOps). Mirroring each half in place would invert every disparity. The stream that is
1669
- * SENT is never touched — only this canvas.
1670
- */
1671
- _paintMirror() {
1672
- const v = this.call.local.video;
1673
- const W = v.videoWidth;
1674
- const H = v.videoHeight;
1675
- if (!W || !H || (v.readyState || 0) < 2) return;
1676
- const A = this.call.o.tileAspect;
1677
- const eyeW = W / 2;
1678
- const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
1679
- const c = this.mirror;
1680
- if (c.width !== 2 * outW || c.height !== outH) {
1681
- c.width = 2 * outW;
1682
- c.height = outH;
1683
- }
1684
- const g = c.getContext('2d');
1685
- const r = eyeCropRect(eyeW, H, A, 0, 0);
1686
- for (const op of mirrorSwapOps(W, H)) {
1687
- const dx = op.dx === 0 ? 0 : outW;
1688
- g.save();
1689
- g.translate(dx + outW, 0);
1690
- g.scale(-1, 1);
1691
- g.drawImage(v, op.sx + r.sx, r.sy, r.sw, r.sh, 0, 0, outW, outH);
1692
- g.restore();
1693
- }
1895
+ if (value && typeof value === 'object') {
1896
+ return new Proxy(value, {
1897
+ get(target, prop, receiver) {
1898
+ if (typeof prop === 'string') warn();
1899
+ return Reflect.get(target, prop, receiver);
1900
+ },
1901
+ });
1694
1902
  }
1903
+ return value;
1695
1904
  }
1905
+
1906
+ /** @deprecated internal (js/call/options.js) — removed from this entry in 1.31 */
1907
+ export const normalizeCallOptions = deprecate('normalizeCallOptions', 'js/call/options.js', normalizeOptions);
1908
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31 */
1909
+ export const newRoomId = deprecate('newRoomId', 'js/call/wire.js', wire.newRoomId);
1910
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31 */
1911
+ export const isValidRoomId = deprecate('isValidRoomId', 'js/call/wire.js', wire.isValidRoomId);
1912
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31; `handle.inviteLink()` is the API */
1913
+ export const buildInviteLink = deprecate('buildInviteLink', 'js/call/wire.js', wire.buildInviteLink);
1914
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31; the wire is documented in docs/call-wire.md */
1915
+ export const normalizeHello = deprecate('normalizeHello', 'js/call/wire.js', wire.normalizeHello);
1916
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31 */
1917
+ export const makeHello = deprecate('makeHello', 'js/call/wire.js', wire.makeHello);
1918
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31; `CallPeer.display` is the API */
1919
+ export const routeFor = deprecate('routeFor', 'js/call/wire.js', wire.routeFor);
1920
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31 */
1921
+ export const badgeFor = deprecate('badgeFor', 'js/call/wire.js', wire.badgeFor);
1922
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31 */
1923
+ export const createLiveGate = deprecate('createLiveGate', 'js/call/wire.js', wire.createLiveGate);
1924
+ /** @deprecated internal (js/camera/converge.js) — removed from this entry in 1.31 */
1925
+ export const convergenceShiftPx = deprecate('convergenceShiftPx', 'js/camera/converge.js', wire.convergenceShiftPx);
1926
+ /** @deprecated internal (js/camera/converge.js) — removed from this entry in 1.31 */
1927
+ export const lowPass = deprecate('lowPass', 'js/camera/converge.js', wire.lowPass);
1928
+ /** @deprecated internal (js/camera/converge.js) — removed from this entry in 1.31 */
1929
+ export const clampShift = deprecate('clampShift', 'js/camera/converge.js', wire.clampShift);
1930
+ /** @deprecated internal (js/camera/geometry.js) — removed from this entry in 1.31 */
1931
+ export const eyeCropRect = deprecate('eyeCropRect', 'js/camera/geometry.js', wire.eyeCropRect);
1932
+ /** @deprecated internal (js/camera/geometry.js) — removed from this entry in 1.31; `addCameraView` of `/camera` mirrors a self view */
1933
+ export const mirrorSwapOps = deprecate('mirrorSwapOps', 'js/camera/geometry.js', wire.mirrorSwapOps);
1934
+ /** @deprecated internal (js/camera/geometry.js) — removed from this entry in 1.31 */
1935
+ export const mirrorSwapPixels = deprecate('mirrorSwapPixels', 'js/camera/geometry.js', wire.mirrorSwapPixels);
1936
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31 */
1937
+ export const maxBitrateKbps = deprecate('maxBitrateKbps', 'js/call/wire.js', wire.maxBitrateKbps);
1938
+ /** @deprecated internal (js/camera/disparity.js) — removed from this entry in 1.31; `autoConverge` of `/camera` and `/call` is the behaviour */
1939
+ export const measureFocusDisparity = deprecate('measureFocusDisparity', 'js/camera/disparity.js', disparity.measureFocusDisparity);
1940
+ /** @deprecated internal (js/call/sdp.js) — removed from this entry in 1.31 */
1941
+ export const preferVideoCodecs = deprecate('preferVideoCodecs', 'js/call/sdp.js', sdp.preferVideoCodecs);
1942
+ /** @deprecated internal (js/call/sdp.js) — removed from this entry in 1.31 */
1943
+ export const sortCodecCapabilities = deprecate('sortCodecCapabilities', 'js/call/sdp.js', sdp.sortCodecCapabilities);
1944
+ /** @deprecated internal (js/call/sdp.js) — removed from this entry in 1.31 */
1945
+ export const VIDEO_CODEC_ORDER = deprecate('VIDEO_CODEC_ORDER', 'js/call/sdp.js', sdp.VIDEO_CODEC_ORDER);
1946
+ /** @deprecated internal (js/call/transport.js) — removed from this entry in 1.31; an SFU adapter will get a designed Transport seam */
1947
+ export const MeshTransport = deprecate('MeshTransport', 'js/call/transport.js', MeshTransportImpl);
1948
+ /** @deprecated internal (js/call/transport.js) — removed from this entry in 1.31 */
1949
+ export const clampMaxPeers = deprecate('clampMaxPeers', 'js/call/transport.js', clampMaxPeersImpl);
1950
+ /** @deprecated internal (js/call/qr.js) — removed from this entry in 1.31 */
1951
+ export const qrEncode = deprecate('qrEncode', 'js/call/qr.js', qrMod.qrEncode);
1952
+ /** @deprecated internal (js/call/signaling.js) — removed from this entry in 1.31; documented in signaling/README.md */
1953
+ export const roomKey = deprecate('roomKey', 'js/call/signaling.js', signalingMod.roomKey);
1954
+ /** @deprecated internal (js/call/signaling.js) — removed from this entry in 1.31; a primitive: no runtime warning */
1955
+ export const SIGNAL_PROTOCOL = signalingMod.SIGNAL_PROTOCOL;
1956
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31; a primitive: no runtime warning */
1957
+ export const WIRE_VERSION = wire.WIRE_VERSION;
1958
+ /** @deprecated internal (js/call/wire.js) — removed from this entry in 1.31; a primitive: no runtime warning */
1959
+ export const CALL_SDK = wire.CALL_SDK;
1960
+ /** @deprecated replaced by `theme.strings` (js/call/ui.js CALL_STRINGS) — removed from this entry in 1.31 */
1961
+ export const PLATE_TEXT = deprecate('PLATE_TEXT', 'js/call/ui.js (CALL_STRINGS)', Object.freeze({ unreachable: CALL_STRINGS.unreachable, cameraBusy: CALL_STRINGS.cameraBusy }));
1962
+ /** @deprecated folded into `theme.accent` (which takes the names) — removed from this entry in 1.31 */
1963
+ export const CALL_ACCENTS = deprecate('CALL_ACCENTS', 'js/call/ui.js', CALL_ACCENTS_IMPL);
1964
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1965
+ export const normalizeMono3D = deprecate('normalizeMono3D', 'js/call/lift.js', liftMod.normalizeMono3D);
1966
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1967
+ export const resolveLift = deprecate('resolveLift', 'js/call/lift.js', liftMod.resolveLift);
1968
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1969
+ export const createLiftPool = deprecate('createLiftPool', 'js/call/lift.js', liftMod.createLiftPool);
1970
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1971
+ export const createFrameWatch = deprecate('createFrameWatch', 'js/call/lift.js', liftMod.createFrameWatch);
1972
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1973
+ export const liftConvergenceFor = deprecate('liftConvergenceFor', 'js/call/lift.js', liftMod.liftConvergenceFor);
1974
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1975
+ export const liftPriorityFor = deprecate('liftPriorityFor', 'js/call/lift.js', liftMod.liftPriorityFor);
1976
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1977
+ export const setLiftPriority = deprecate('setLiftPriority', 'js/call/lift.js', liftMod.setLiftPriority);
1978
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1979
+ export const defaultLiftSpecifier = deprecate('defaultLiftSpecifier', 'js/call/lift.js', liftMod.defaultLiftSpecifier);
1980
+ /** @deprecated internal (js/call/lift.js) — removed from this entry in 1.31 */
1981
+ export const LIFT_PRIORITY = deprecate('LIFT_PRIORITY', 'js/call/lift.js', liftMod.LIFT_PRIORITY);
1982
+
1983
+ // `<dxr-call>` — the one-line path as markup — is registered by importing this entry (RFC 0003 §1;
1984
+ // the ONE side effect of this module, declared in package.json `sideEffects`). A no-op where there
1985
+ // is no `customElements` registry (Node, workers) or the name is already defined.
1986
+ defineCallElement();