@displayxr/inline3d 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,106 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.29.0 — 2026-09-30
9
+
10
+ Touches the **core** (additive: `sharedInline3D()`, `handle.rewoven()`) and the **preview tier**
11
+ (`./call` — RFC 0003 C1, the one-line path; `./player`, `./splat` `engine: 'playcanvas'`). One
12
+ **removal** in the preview tier: `peerjsCloud`. Otherwise additive; no change for a page that does
13
+ not call the new API.
14
+
15
+ ### `./call` — the one-line path (RFC 0003 §1, phase C1)
16
+
17
+ - **`mountCall(el, opts?)`** — `addCall` with every argument optional: the wall is the document's
18
+ shared manager (`sharedInline3D()`, below), signalling defaults to the hosted server, the camera
19
+ to `'auto'`, the SDK chrome to on. `addCall(wall, container, opts)` stays as the explicit form.
20
+ A call's session recovery now re-opens through the shared manager too, so it joins a wall the
21
+ page re-created rather than opening a second session.
22
+ - **`<dxr-call>`** — the same thing as markup, registered by importing `./call` (or `./call/full`,
23
+ or the CDN bundle): mounts on connect, `leave()`s on disconnect (a DOM move IS a teardown —
24
+ woven-canvas rule 2), re-dispatches every call event as a bubbling, composed `CustomEvent`
25
+ named `dxr-call:<event>` with the payload in `detail`, and exposes the handle as `el.call`
26
+ (plus a `ready` promise). Attributes are the string-typed options: `room`, `signaling` (a
27
+ URL), `key`, `camera`, `layout`, `accent`, `max-peers`, `no-ui`, `auto-join`, `mono3d="off"`,
28
+ `no-audio`, `no-self-view`, `no-auto-converge`, `tile-aspect`, `invite-base`, `browser-url`,
29
+ `debug`; anything else is `el.options = { … }`, set before connect (options win). Light DOM, no
30
+ shadow root, **no globals** (RFC 0003 Decision 11). `auto-join` joins during the mount, so the
31
+ element replays `joined` and one `peer` per participant already in the room once it can
32
+ subscribe. Exported: `DxrCallElement`, `defineCallElement`, `attrsToOpts`, `CALL_EVENT_PREFIX`.
33
+ - **`@displayxr/inline3d/call/full`** — `./call` with `mono3D` defaulted to a **statically
34
+ imported** `lift`, the one shape a bundler can follow; importing it also points `<dxr-call>` at
35
+ that `mountCall`. `liftBundled` says whether this copy of the SDK carries the module. **Until
36
+ `./lift` lands on `main`, `js/lift/index.js` is a placeholder** (`lift === null`): the entry
37
+ builds and behaves like `./call`, and `liftBundled` is `false`.
38
+ - **CDN bundle `dist/call.js`** — one pre-bundled ESM file (core + call + element, ~124 KB min,
39
+ no three/PlayCanvas), built by `npm run build:dist` (esbuild) and by `prepack` into every
40
+ published tarball, so jsDelivr serves it as `https://cdn.jsdelivr.net/npm/@displayxr/inline3d@1/dist/call.js`.
41
+ `<script type="module">` only; lift is a lazy sibling chunk (`dist/lift.js`) once the module
42
+ is present. `dist/` is not committed.
43
+ - **`warning` event, code `'lift-not-bundled'`** — a 2D participant is on a 3D display, `mono3D`
44
+ is `'auto'`, and the lift module could not be imported from this build (a bundler that could
45
+ not follow the computed import, or a copy without `./lift`). Emitted once per call with one
46
+ `console.warn` naming the fix (`mono3D: lift` or `./call/full`); the lobby reads "2D→3D
47
+ unavailable in this build". Previously a silent flat tile and an info line. A display that
48
+ honestly cannot lift is unchanged (`handle.mono3D.reason`).
49
+ - **`key`** — `dxrSignaling(url, { key })` / `CallOptions.key` / `<dxr-call key>`: a publishable
50
+ key for the hosted service (RFC 0003 §5a), sent as a query parameter on connect. Accepted and
51
+ forwarded now; the server ignores it until C3.
52
+ - **Removed: `peerjsCloud`** (RFC 0003 Decision 12 — no known external user, so no warning
53
+ release). The demo adapter over the public PeerJS broker lives on as
54
+ `samples/call/peerjs-cloud.js`; the sample's `?signal=peerjs` still works. `dxrSignaling()`
55
+ (the hosted server) has been the default since 1.28.
56
+ - **Types:** `mountCall`, `CallOptions.wall` / `.key`, `CallEvents.warning`, `DxrCallElement`,
57
+ `attrsToOpts`, `defineCallElement`, `DxrCallEventMap` (+ `HTMLElementTagNameMap` /
58
+ `HTMLElementEventMap` augmentations), `call-full.d.ts`.
59
+ - **Tests:** element unit tests (attribute→option table, events, disconnect = leave); a
60
+ headless Chrome ↔ Chrome end-to-end run of `<dxr-call>` (source and CDN bundle) over the
61
+ hosted server (`npm run test:e2e`); Vite/webpack/esbuild smoke builds of `./call/full` and
62
+ `./call` that also run the bundles (`npm run test:bundlers`). Neither is part of `npm test`.
63
+
64
+ ### Core — additive
65
+
66
+ - **`sharedInline3D(opts?)`** — the document's shared manager: the live `createInline3D()`
67
+ result if one exists, else a new one (concurrent callers share one creation; an unsupported
68
+ browser is never cached). Woven-canvas rule 1 as an API: a module that needs a wall it did not
69
+ create (`mountCall`, `<dxr-call>`) uses this and never opens a second session next to the
70
+ page's own; a page that made its wall first gets that same wall back here. Semver-covered.
71
+
72
+ - **`./splat` `setVideo` crossfade, band and on-screen rect** (#105, RFC 0001 A5.1-A5.3,
73
+ `engine: 'playcanvas'`). `transition: 'crossfade'` (`durationMs` 600, `easing`
74
+ 'easeInOutSine', the player's defaults) dissolves on the GPU from the video already on the plane:
75
+ its quad is frozen and drawn blended over the incoming one, the buffer opaque throughout; from the
76
+ splat and on `setVideo(null)` it stays a cut. `band: 2.39 | '2.39:1'` fits the picture into a
77
+ centred letterbox slot (`cover` fills the band and crops each eye about its centre). The result
78
+ gains `transition`, `band`, `rect` (the picture in CSS px, canvas-relative, clipped) and
79
+ `onRectChange(cb)`. `attachPlayer` now crossfades and takes `band` (controls in the bars) through
80
+ these instead of cutting / ignoring them. **Behaviour change for `attachPlayer(…, { skin: 'call' })`:**
81
+ that skin defaults `band` to 2.39, which surface mode used to ignore; the video now plays in a
82
+ 2.39 letterbox. Pass `band: null` to keep the full tile.
83
+ - **`handle.rewoven()`** — `firstWoven`, measured from the call, for a canvas that is already woven
84
+ but whose rect is about to change (fullscreen, a layout resize). Same `{ woven, confirmed,
85
+ reason, ms }` shape, never rejects; a change of the canvas's CSS size or devicePixelRatio while
86
+ pending restarts the hold (checked every frame, every window kind), and it settles anyway with
87
+ the new reason `'hold-capped'` four holds after the call if the size never stops changing;
88
+ before the first join it is `firstWoven`. Woven-canvas rule 11.
89
+ - **Player: `fullscreenCover`** (opt-in, default false). Entering or leaving fullscreen puts an
90
+ opaque cover over the canvas (on top, hard cut, below the controls) until `rewoven()` settles.
91
+ Off by default because a blind A/B on the Leia panel showed no raw side-by-side pair without it
92
+ and ~1.2 s of no picture with it; the flash that did show is browser-wide (rule 11).
93
+ - **Docs:** woven-canvas rules 9–11 (black goes under the canvas — previously only in the
94
+ authoring guide; no `backdrop-filter` over the weave; a resize is a fresh-canvas moment). The
95
+ authoring guide's short list now matches, 1–11.
96
+ - **`attachPlayer(splat, src, opts)`** — surface mode (RFC 0001 Addendum A): the player's
97
+ transport, playlist and events on an existing PlayCanvas splat handle's `setVideo`, with no
98
+ canvas of its own. `detach()` gives the slot back (`setVideo(null)`: scene, pose, lens and rig
99
+ as they were); `'detached'` `{ reason: 'superseded' | 'released' }` when something else takes or
100
+ empties the slot. Cut transitions only for now; `fullscreen` defaults off. Panel-checked on one
101
+ persistent canvas: attach, next/back, detach, scene state identical after.
102
+ - **`addPlayer` refactor:** one player core on a surface adapter. No behaviour change.
103
+ - **`./splat`:** read-only `handle.videoElement` (the `<video>` the plane shows, or null) and
104
+ `handle.canvas`.
105
+ - **Types:** `attachPlayer`, `AttachPlayerOptions`, `AttachedPlayerHandle`, `PlayerDetachedEvent`,
106
+ `'detached'` in `PlayerEvent`; `SplatHandle.videoElement` / `.canvas`.
107
+
8
108
  ## 1.28.0 — 2026-09-27
9
109
 
10
110
  Touches the **preview tier** (a new subpath, `./call`; the SBS store clamp below) and two **core**
@@ -39,6 +139,15 @@ clamped to the device's GL limits.
39
139
  `setRenderScale` still accepts (0, 4]. `SceneViewer` gains `effectiveRenderScale`. Nothing changes
40
140
  on a device whose limit holds the store.
41
141
 
142
+ ### Added — call: auto-convergence on the point between the remote person's eyes
143
+
144
+ - SBS tiles now converge by MEASUREMENT: the receiver finds the disparity of the point between the
145
+ remote person's eyes in the decoded frames and shifts each eye by half of it, so that person sits at
146
+ the display plane. No calibration, works for any SBS sender. Tracks a small template after the first
147
+ lock (~1 ms per measurement at 5 Hz). `autoConverge: false` restores the pair as sent; the depth
148
+ slider stays an offset on top; lifted (mono) tiles are untouched. `measureFocusDisparity` is
149
+ exported for pages that want to reuse it.
150
+
42
151
  ### Fixed — core: image/video buffers capped at the source's per-eye resolution
43
152
 
44
153
  - An image or video window's backing buffer was sized box x devicePixelRatio per eye regardless of
package/README.md CHANGED
@@ -199,7 +199,8 @@ js/
199
199
  inline3d-splat-live.js setSource's live outgoing photo: its own camera, layer and target (PlayCanvas)
200
200
  from what the asset declares (you serve the decoder files — see the guide)
201
201
  inline3d-player.js preview: addPlayer() — a media player window built on addVideo (stereo
202
- paint, display modes and the fallback are inherited) + an SDK transport bar
202
+ paint, display modes and the fallback are inherited) + an SDK transport bar;
203
+ attachPlayer() — the same player on a PlayCanvas splat handle's setVideo
203
204
  docs/
204
205
  authoring-inline-3d.md the authoring guide
205
206
  authoring-motion-and-effects.md
@@ -207,8 +208,10 @@ docs/
207
208
  that is not the API
208
209
  porting-three-js-apps.md porting an existing three.js app (WebXR or plain) to inline 3D —
209
210
  the WebXR→inline-3d mapping table and the whole render loop
210
- woven-canvas-rules.md never show a raw side-by-side frame: the join, the eight rules,
211
- handle.firstWoven, reading the browser's `withheld` log line
211
+ woven-canvas-rules.md never show a raw side-by-side frame: the join, the rules,
212
+ handle.firstWoven / rewoven(), reading the browser's `withheld` log line
213
+ call.md 3D video calls: the <dxr-call> / mountCall() one-line embed, options,
214
+ events, headless, theming, React, self-hosting, troubleshooting
212
215
  proposals/ browser-side asks the SDK is waiting on
213
216
  ```
214
217
 
package/call-full.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ // Type definitions for @displayxr/inline3d/call/full — the call entry with 2D→3D bundled.
2
+ // PREVIEW tier — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
+ //
4
+ // Same surface as `./call` (re-exported), except `mountCall` / `addCall` default `mono3D` to a
5
+ // STATICALLY imported `lift` (`./lift`), which is what lets a bundler ship it. Use this entry in a
6
+ // bundled app that wants 2D participants lifted to 3D; use `./call` when you do not want the
7
+ // depth-model plumbing (it then warns `lift-not-bundled` the first time a 2D peer would have been
8
+ // lifted on a 3D display). Importing this entry also points `<dxr-call>` at this `mountCall`.
9
+
10
+ import type { CallHandle, CallOptions } from './call.js';
11
+
12
+ export * from './call.js';
13
+
14
+ /** True when this copy of the SDK carries the lift module; false on a build where `./lift` has not landed yet. */
15
+ export const liftBundled: boolean;
16
+
17
+ /** `mountCall` of `./call` with `mono3D` defaulted to the bundled `lift` (unless you pass your own). */
18
+ export function mountCall(el: HTMLElement, opts?: CallOptions): Promise<CallHandle>;
19
+
20
+ /** `addCall` of `./call` with `mono3D` defaulted to the bundled `lift` (unless you pass your own). */
21
+ export function addCall(wall: unknown, container: HTMLElement, opts?: CallOptions): Promise<CallHandle>;
package/call.d.ts CHANGED
@@ -4,6 +4,11 @@
4
4
  // P1 of docs/rfcs/0002-video-call.md: full-mesh WebRTC (<= 4 participants), pluggable signalling,
5
5
  // side-by-side stereo from a stereo camera, convergence from `hello` + `hint`, SDK chrome or
6
6
  // headless. P2a: mono peers lifted to 3D through `@displayxr/inline3d/lift` (`mono3D`).
7
+ // RFC 0003 C1: `mountCall(el, opts?)` (the one-line path, the wall from `sharedInline3D()`), the
8
+ // `warning` event (`lift-not-bundled`), `key`; `<dxr-call>` is `./call/element`, and
9
+ // `./call/full` is this entry with lift statically imported.
10
+
11
+ import type { Inline3D, Inline3DUnsupported, CreateInline3DOptions } from './index.js';
7
12
 
8
13
  /**
9
14
  * The `lift()` of `@displayxr/inline3d/lift`, as the call uses it: `lift(video, { mode: 'live',
@@ -110,32 +115,24 @@ export interface SignalingSession {
110
115
  */
111
116
  /** The hosted DisplayXR signalling server (`dxrSignaling()` with no URL). */
112
117
  export const DXR_SIGNAL_DEFAULT: string;
113
- export function dxrSignaling(url?: string, opts?: { WebSocket?: any; pingMs?: number }): SignalingAdapter;
114
-
115
- /**
116
- * DEMO ONLY — the free public PeerJS broker. No uptime guarantee and not operated by DisplayXR.
117
- * `Peer` defaults to `globalThis.Peer`, else the ESM build is imported from jsDelivr.
118
- */
119
- export function peerjsCloud(opts?: {
120
- Peer?: any;
121
- peerOptions?: object;
122
- url?: string;
123
- /** Upper bound on the join-time wait for slots to say hello (ms, default 5000). */
124
- helloMs?: number;
125
- /** A taken slot silent this long is reported unreachable (ms, default 10000). */
126
- unreachableMs?: number;
127
- /** Background re-dial interval for silent slots (ms, default 10000). */
128
- retryMs?: number;
129
- heartbeatMs?: number;
130
- settleMs?: number;
131
- }): SignalingAdapter;
118
+ export function dxrSignaling(url?: string, opts?: { WebSocket?: any; pingMs?: number; /** A publishable key for the hosted service (public by design; sent on connect). */ key?: string }): SignalingAdapter;
132
119
 
133
120
  export type CallAccent = 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' | 'lime' | 'mint' | 'ice';
134
121
 
135
122
  export interface CallOptions {
123
+ /**
124
+ * The inline-3D manager to put the tiles on. `mountCall` defaults it to the document's shared
125
+ * one (`sharedInline3D()`); `addCall` takes it as its first argument instead.
126
+ */
127
+ wall?: Inline3D | Inline3DUnsupported | null;
136
128
  /** Default `dxrSignaling()` (the hosted DisplayXR server). Or `dxrSignaling(url)` to self-host,
137
- * `peerjsCloud()` (demo only), or your own adapter. */
129
+ * or your own adapter. */
138
130
  signaling?: SignalingAdapter;
131
+ /**
132
+ * A publishable key for the hosted signalling service (`pk_…`, RFC 0003 §5a). Public by design
133
+ * — it sits in page source — and only used when `signaling` is left at the default.
134
+ */
135
+ key?: string;
139
136
  /**
140
137
  * `'auto'` (default): the room in this page's `#room=` fragment, else a new one on join. Or a
141
138
  * room id / invite link. Room ids are >= 96 random bits (generated ones: 128), base64url.
@@ -163,6 +160,13 @@ export interface CallOptions {
163
160
  rectify?: (stream: MediaStream, info: { width: number; height: number; deviceId: string | null; label: string }) => MediaStream | Promise<MediaStream>;
164
161
  /** Microphone (echo cancellation + noise suppression). Default true. */
165
162
  audio?: boolean;
163
+ /**
164
+ * Auto-convergence (default true): measure the disparity of the point between each SBS peer's
165
+ * eyes on the received frames and shift the two eyes by half of it each, so the remote person sits
166
+ * at the display plane. No calibration needed. The depth slider stays an offset on top; a peer's
167
+ * `hint` is used only while no measurement has succeeded. Lifted (mono) tiles are unaffected.
168
+ */
169
+ autoConverge?: boolean;
166
170
  /**
167
171
  * Lift mono peers to 3D on a 3D display (RFC §5). `'auto'` (default): import
168
172
  * `@displayxr/inline3d/lift` lazily from this copy of the SDK, and use it when it reports a
@@ -205,8 +209,8 @@ export interface CallOptions {
205
209
  /** Scroll the call block to the top of the viewport once, on join, so every tile (and the self
206
210
  * view) is on screen and woven. Default true (only with `ui`). */
207
211
  scrollIntoView?: boolean;
208
- /** `createInline3D()` options used for that re-open. */
209
- wallOptions?: object;
212
+ /** `createInline3D()` options for the wall `mountCall` creates, and for a session re-open. */
213
+ wallOptions?: CreateInline3DOptions;
210
214
  debug?: boolean;
211
215
  log?: (tag: string, detail: object) => void;
212
216
  }
@@ -229,6 +233,8 @@ export interface CallPeer {
229
233
  readonly quality: CallQuality | null;
230
234
  /** The per-eye convergence shift currently painted, source px. */
231
235
  readonly convergencePx: number;
236
+ /** Auto-convergence: measured disparity (source px; null until a lock), mean ms per measurement, last method. */
237
+ readonly autoConverge: { disparityPx: number | null; ms: number | null; readMs: number | null; method: 'mode' | 'focus' | 'track' | null; via: 'videoframe' | 'canvas' | null };
232
238
  /** A lifted tile: whether lift is showing yet, native or web, and its current priority. */
233
239
  readonly lift: { readonly live: boolean; readonly native: boolean; readonly priority: LiftStreamPriority | null; readonly state: string | null } | null;
234
240
  }
@@ -259,6 +265,16 @@ export interface CallEvents {
259
265
  speaker: { id: string | null };
260
266
  /** Codes include `'camera-busy'`, `'no-camera'`, `'unreachable'` (error.peer = the tile id), `'room-full'`, `'session-ended'`. */
261
267
  error: { code: string; message: string; error: Error | null };
268
+ /**
269
+ * Degraded, not broken. `'lift-not-bundled'`: a 2D participant is on a 3D display, `mono3D`
270
+ * is `'auto'`, and the lift module could not be imported from this build (a bundler that could
271
+ * not follow the computed import, or a copy of the SDK without `./lift`) — they stay flat. Fix:
272
+ * `import { lift } from '@displayxr/inline3d/lift'` and pass `mono3D: lift`, or import
273
+ * `@displayxr/inline3d/call/full`. Emitted once per call, the first time such a peer routes,
274
+ * with one `console.warn`. A display that honestly cannot lift is NOT this: that is
275
+ * `handle.mono3D.reason` (`'no-provider'` / `'no-webgpu'`) and an info line.
276
+ */
277
+ warning: { code: 'lift-not-bundled' | string; message: string };
262
278
  state: { id: string; state: PeerState };
263
279
  joined: { room: string; id: string };
264
280
  left: { room: string };
@@ -303,7 +319,64 @@ export interface CallHandle {
303
319
  off<K extends keyof CallEvents>(type: K, cb: (e: CallEvents[K]) => void): void;
304
320
  }
305
321
 
306
- export function addCall(wall: unknown, container: HTMLElement, opts: CallOptions): Promise<CallHandle>;
322
+ export function addCall(wall: unknown, container: HTMLElement, opts?: CallOptions): Promise<CallHandle>;
323
+
324
+ /**
325
+ * The one-line path (RFC 0003 §1): `addCall` with every argument optional. The wall is
326
+ * `opts.wall` if given, else the document's shared manager (`sharedInline3D(opts.wallOptions)` —
327
+ * the page's existing session, never a second one). Hosted signalling, `camera: 'auto'` and the
328
+ * SDK chrome by default; resolves once the lobby shows (or, with `autoJoin` / `ui: false`, once
329
+ * joined). `<dxr-call>` (`@displayxr/inline3d/call/element`) is this as markup.
330
+ */
331
+ export function mountCall(el: HTMLElement, opts?: CallOptions): Promise<CallHandle>;
332
+
333
+ // ── <dxr-call> — the one-line path as markup ───────────────────────────────────────────────
334
+ //
335
+ // Importing this entry (or `./call/full`, or the CDN bundle `dist/call.js`) registers the element.
336
+ // It mounts on connect (`mountCall(this, { ...attrsToOpts(this), ...this.options })`), leaves on
337
+ // disconnect (a DOM move IS a teardown — woven-canvas rule 2), re-dispatches every call event as
338
+ // a bubbling, composed `CustomEvent` named `dxr-call:<event>` with the payload in `detail`, and
339
+ // exposes the handle as `el.call`. Attributes (read once, at connect): `room`, `signaling` (a
340
+ // URL), `key`, `camera`, `layout`, `accent`, `max-peers`, `no-ui`, `auto-join`, `mono3d="off"`,
341
+ // `no-audio`, `no-self-view`, `no-auto-converge`, `tile-aspect`, `invite-base`, `browser-url`,
342
+ // `debug`. Everything else is `el.options`, set before connect; options win over attributes.
343
+
344
+ export class DxrCallElement extends HTMLElement {
345
+ /** The mount seam: null = `mountCall` of `./call`; `./call/full` sets its own. */
346
+ static mount: ((el: HTMLElement, opts?: CallOptions) => Promise<CallHandle>) | null;
347
+ /** Non-string options (a `MediaStream`, an adapter, `lift`, a `wall`) — set BEFORE connecting. */
348
+ options: CallOptions | null;
349
+ /** The handle once mounted; null before, and again after disconnect. */
350
+ readonly call: CallHandle | null;
351
+ /** Resolves with the handle when the mount lands (null if the element left the DOM first); rejects if it failed. */
352
+ readonly ready: Promise<CallHandle | null> | null;
353
+ connectedCallback(): void;
354
+ disconnectedCallback(): void;
355
+ }
356
+
357
+ /** The attribute → option mapping the element applies at connect. Pure; unset attributes contribute nothing. */
358
+ export function attrsToOpts(source: { getAttribute(name: string): string | null } | ((name: string) => string | null)): Partial<CallOptions>;
359
+
360
+ /**
361
+ * Register the element (default name `dxr-call`) once. True when this call registered it; false
362
+ * when the name was taken or there is no `customElements` registry. The entries call it for you.
363
+ */
364
+ export function defineCallElement(name?: string, registry?: CustomElementRegistry): boolean;
365
+
366
+ /** `'dxr-call:'` — every DOM event the element dispatches starts with it. */
367
+ export const CALL_EVENT_PREFIX: 'dxr-call:';
368
+
369
+ /** The DOM events: one per {@link CallEvents} key, plus `dxr-call:ready` once the handle exists. */
370
+ export type DxrCallEventMap = { [K in keyof CallEvents as `dxr-call:${K}`]: CustomEvent<CallEvents[K]> } & {
371
+ 'dxr-call:ready': CustomEvent<{ call: CallHandle }>;
372
+ };
373
+
374
+ declare global {
375
+ interface HTMLElementTagNameMap {
376
+ 'dxr-call': DxrCallElement;
377
+ }
378
+ interface HTMLElementEventMap extends DxrCallEventMap {}
379
+ }
307
380
 
308
381
  // ── pure helpers (exported for tests and advanced pages) ──────────────────────────────────
309
382
  export function normalizeCallOptions(opts?: Partial<CallOptions>): Record<string, unknown>;
@@ -351,6 +424,13 @@ export interface CallLiftPool {
351
424
  export function createLiftPool(o: { lift: CallLiftFunction; max?: number; log?: (tag: string, o: object) => void; options?: Record<string, unknown> }): CallLiftPool;
352
425
  export function convergenceShiftPx(p: { eyeWidthPx: number; hfovDeg?: number | null; baselineMm?: number | null; subjectZmm?: number | null }): number;
353
426
  export function lowPass(prev: number, target: number, alpha?: number): number;
427
+ /** The disparity (left x − right x, input px) of the point between the eyes in a grayscale SBS frame. */
428
+ export function measureFocusDisparity(
429
+ img: ArrayLike<number>,
430
+ W: number,
431
+ H: number,
432
+ o?: { focus?: { x: number; y: number }; block?: number; dMin?: number; dMax?: number; dyMax?: number; minNcc?: number },
433
+ ): { d: number; x: number; y: number; c: number; method: 'focus' | 'mode'; blocks: number } | null;
354
434
  export function clampShift(px: number, eyeWidthPx: number, maxFraction?: number): number;
355
435
  export function eyeCropRect(eyeW: number, eyeH: number, aspect: number, shift: number, eye: 0 | 1): { sx: number; sy: number; sw: number; sh: number };
356
436
  export function mirrorSwapOps(W: number, H: number): Array<{ src: 'L' | 'R'; sx: number; sw: number; sy: number; sh: number; dx: number; dw: number; mirror: true }>;