@displayxr/inline3d 1.26.0 → 1.28.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,140 @@ 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.28.0 — 2026-09-27
9
+
10
+ Touches the **preview tier** (a new subpath, `./call`; the SBS store clamp below) and two **core**
11
+ fixes: image/video backing buffers are capped at the source's resolution, and every SBS store is
12
+ clamped to the device's GL limits.
13
+
14
+ ### Fixed — core + preview: SBS backing stores clamped to the device's GL limits
15
+
16
+ - A woven canvas wider than the device's WebGL limit was **silently** clamped by the browser, and
17
+ the SDK still laid the eyes out from `canvas.width / 2`. On an Android 3D tablet (Adreno 740,
18
+ DisplayXR Browser 154) `MAX_TEXTURE_SIZE` is 4096 (Chromium's
19
+ `webgl_or_caps_max_texture_size_limit_4096` workaround, browser-pvt#187) where Windows and Linux
20
+ report 16384. A full-screen SBS canvas at `renderScale` 1 asked for 5120×1348, got a 4096-wide
21
+ drawing buffer, and put the eye boundary at 62.5% of it while the weave split it at 50%: a large
22
+ double image on the panel, nothing logged.
23
+ - Every path that sizes an SBS store now clamps the request **before** setting `canvas.width/height`,
24
+ so 2 × eye width and the height stay within min(`MAX_TEXTURE_SIZE`, `MAX_RENDERBUFFER_SIZE`,
25
+ `MAX_VIEWPORT_DIMS`). Both axes shrink by one factor, so the eye keeps its aspect and the store
26
+ stays 2:1 of the per-eye size. Mono (the 2D tier) is clamped with one eye across. Limits are
27
+ queried once per GL context and cached. One shared helper (`js/inline3d-buffer-limit.js`) serves
28
+ the core (`addImage` / `addVideo`, explicit `{width, height}` included), `./viewer`, `./splat` on
29
+ both engines and `./model` on both engines.
30
+ - Belt and braces: if the drawing buffer still differs from `canvas.width/height` after a resize (a
31
+ browser clamp the query did not predict), the viewers map the layer's viewports onto the real
32
+ drawing buffer instead of `canvas.width / 2`, for live frames, replays and mono. An `addScene`
33
+ canvas is the page's to size; the core warns once if the browser clamped it.
34
+ - One warning per handle when the clamp bites, naming the limit and the numbers, e.g.
35
+ `[inline3d/splat] SBS buffer 5120×1348 exceeds MAX_TEXTURE_SIZE 4096 on this device; rendering at
36
+ 4096×1078 (renderScale 1 → 0.8)`.
37
+ - `handle.renderScale` (`./splat`, both engines; `./model`, both engines) now reports the scale **in
38
+ force** (0.8 in the case above); the new `handle.renderScaleRequested` is the request as given.
39
+ `setRenderScale` still accepts (0, 4]. `SceneViewer` gains `effectiveRenderScale`. Nothing changes
40
+ on a device whose limit holds the store.
41
+
42
+ ### Fixed — core: image/video buffers capped at the source's per-eye resolution
43
+
44
+ - An image or video window's backing buffer was sized box x devicePixelRatio per eye regardless of
45
+ the source, so a large tile over a small clip re-drew many times the clip's pixels every frame.
46
+ Measured on an NP02J tablet (DisplayXR Browser 1.0.6), the player sample's 90vw tile got a
47
+ 4608x1296 buffer for a 640x360-per-eye clip: 25 fps, 47 of 97 video frames dropped. The buffer
48
+ is now capped at the source's per-eye size (box aspect kept, only ever shrinks), which on the same
49
+ page gives 1280x360, 58 fps, 0 dropped. The compositor scales the layer to the box either way, so
50
+ the picture is the same. An explicit `{width, height}` is never capped; `cornerRadius` / `feather`
51
+ keep their on-screen size; the buffer re-derives when a new title has a different size.
52
+
53
+ ### Docs — MP4 (H.264/AAC) plays in DisplayXR Browser 1.0.6+
54
+
55
+ - The codec guidance no longer says the DisplayXR Browser cannot play H.264/AAC. From **1.0.6** it
56
+ plays MP4 through the operating system's decoders (Windows GPU + Media Foundation, Android
57
+ MediaCodec). VP9/AV1 + Opus in WebM stays the recommended first source: it also plays in 1.0.5 and
58
+ earlier, stock Chromium, and on machines with no hardware H.264 decode (where `canPlayType` still
59
+ answers "probably" for H.264). Updated in the authoring guide (media traps, hosting video), the
60
+ PlayCanvas adapter's `setVideo`, and `pickSource`'s doc comment and fallback log text (text only,
61
+ no behaviour change). WebRTC calls are unaffected: the browser still ships no H.264 for WebRTC,
62
+ so `./call` keeps preferring VP9.
63
+
64
+ ### Added — call: mono→3D through `lift()` (P2a of RFC 0002)
65
+
66
+ - **Mono participants are lifted to 3D** on a 3D display: a mono peer on a woven wall is handed to
67
+ `lift()` (`@displayxr/inline3d/lift`) with `{ mode: 'live', wall }` — one lifted tile per peer,
68
+ in its own slot, lifted at once and re-created once when the weave session goes live (#172). `mono3D: 'auto'` (default) loads
69
+ the lift module lazily and falls back to flat, with one log line, when it is missing or there is
70
+ neither a native 2D→3D provider nor WebGPU; `'off'` keeps mono peers flat; a function injects your
71
+ own `lift`. `setMono3D(on)` switches at runtime; `liftOptions` passes `models` / `ort` /
72
+ `quality` through; `maxLifted` (default 4) caps concurrent lifts.
73
+ - **One depth control**: the depth slider is lift's convergence on lifted tiles (0 = `'auto'`) and
74
+ the crop shift on stereo tiles.
75
+ - **Priority**: the active speaker's lifted stream is `high`, the others `normal`, offscreen or
76
+ camera-off tiles `paused`, through `setLiftPriority()` — the hook for the native provider's
77
+ per-stream priority (a no-op on the web path).
78
+ - **Badges** `3D` / `2D→3D` / `2D`; the lobby says what a mono participant will look like
79
+ (`liftCapabilities()` when available); `handle.mono3D` reports the state. On the web provider a
80
+ sustained frame-rate drop emits a `quality` event with `lift: { degraded, frameMs, tiles }`.
81
+ - **Changed:** `badgeFor('lifted')` is now `'2D→3D'` (was `'3D'`); `routeFor()` takes
82
+ `overBudget` / `failed` and reports `mono3d: 'budget' | 'failed'`.
83
+
84
+ ### Added — call (`@displayxr/inline3d/call`, P1 of RFC 0002)
85
+
86
+ - **`addCall(wall, container, opts)`** — a 3D video call in any page. Full-mesh WebRTC, up to 4
87
+ participants (`maxPeers`, enforced on the client and the server), behind an internal `Transport`
88
+ seam so an SFU can replace the mesh later. VP9 > VP8 > AV1 (never H.264, which the DisplayXR
89
+ Browser does not have); senders hold resolution (`contentHint: 'detail'`,
90
+ `degradationPreference: 'maintain-resolution'`) with a `maxBitrate` that falls as the mesh grows.
91
+ A dropped pair walks ICE restart → rebuild with backoff; a peer that leaves shows "Left the call"
92
+ instead of a black tile.
93
+ - **Stereo from any side-by-side camera**: `camera: 'auto'` picks a device delivering > 2.5:1
94
+ frames (a raw 1280x480 stereo camera works — nothing is upscaled) and skips a camera held by an
95
+ eye tracker instead of failing. The pair is sent raw with `rectified: false`; `opts.rectify` is
96
+ the seam for a calibrated step (P2).
97
+ - **The receiver decides** (RFC §3): an SBS peer is woven with a convergence shift
98
+ (`f·B/(2Z)` from `hello` + `hint`, low-passed, clamped, plus the depth slider) on the DisplayXR
99
+ Browser and shown as its left eye elsewhere; a mono peer is flat (the `mono3D` hook is where
100
+ `lift()` lands in P2). Plain Chrome/Safari/Firefox participants are first-class. Built on
101
+ `wall.addImage` over a module-owned canvas, so the core still owns every woven buffer; a failed
102
+ layer is retried and an ended session re-opened without a reload.
103
+ - **Signalling is pluggable** (`SignalingAdapter`): `dxrSignaling(url)` speaks the new
104
+ `dxr-signal/1` protocol, with a reference Cloudflare Worker + Durable Object (template, not
105
+ deployed; mints short-lived TURN credentials server-side) and a zero-dependency Node dev server
106
+ in `signaling/`. `peerjsCloud()` is a demo-only zero-setup path. Rooms are 128-bit random ids
107
+ carried in the invite link's `#room=` fragment, which never reaches a server.
108
+ - **SDK chrome** (`ui: true`; `ui: false` for headless): lobby with a 3D self-preview, invite link
109
+ + QR code (a small built-in encoder, no dependency), bottom bar (mute, camera, depth, invite,
110
+ leave), per-tile `3D`/`2D` badge, a "View in 3D with DisplayXR Browser" banner on other browsers,
111
+ `grid` or `speaker` layout (active speaker from inbound audio levels). The mirrored stereo self
112
+ view mirrors each half AND swaps them; the sent stream is never mirrored.
113
+ - **Hardened after the first panel run**: woven tiles (self view included) register only once the
114
+ session delivers stereo frames, working around a browser bug where a tile registered before the
115
+ weave session is live is fed the whole side-by-side frame per eye (displayxr-browser-pvt#172). A
116
+ participant with no connection after ~10 s shows "Can't reach this participant — the network
117
+ needs a relay (TURN)" and emits `error` `unreachable` while retries continue. A camera held by
118
+ another app (e.g. eye tracking) gives `error` `camera-busy` and an audio-only join with
119
+ `retryCamera()`, and no 0x0 video track is ever sent. `peerjsCloud()` no longer counts stale
120
+ slots toward a full room and frees its slot on `pagehide`.
121
+ - Sample: [`samples/call/`](samples/call/). Types: `call.d.ts`.
122
+
123
+ ## 1.27.0 — 2026-09-26
124
+
125
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only) and adds one optional field
126
+ to the core `XRDisplayInfo` type. A page on a browser that does not report the new field sees no
127
+ change.
128
+
129
+ - **`setLayerRig`'s default `viewerDistance` now comes from the browser.** Where
130
+ `XRDisplayInfo.nominalViewerPosition` is reported (DisplayXR Browser patch 0221, Windows and
131
+ Android), the layer rig's n is its **z** — the runtime's nominal viewer distance for the panel,
132
+ the value its camera rig is calibrated to — instead of the fixed 0.6 m. The tile asks the frame's
133
+ `XRDisplayLayer.getDisplayInfo()` once, asynchronously; frames drawn before it answers use 0.6 m.
134
+ A page's `viewerDistance` still wins, and clearing it (`null`) falls back to the browser's value,
135
+ then 0.6 m. **Pixels move** on a panel whose nominal distance is not 0.6 m: display-rig objects'
136
+ pop-out scales by 0.6/n (that was the bug); the contact plane and the 2D picture do not move.
137
+ - **`layerRigState().viewerDistanceSource`** — `'page'` | `'browser'` | `'default'` — says where
138
+ `viewerDistance` came from, and the WARN line prints it: `viewerDistance=0.45m(browser)`.
139
+ - **Types:** `XRDisplayInfo.nominalViewerPosition?: DOMPointReadOnly` (metres, display space, +z
140
+ out of the glass; absent when unreported), `SplatLayerRigState.viewerDistanceSource`.
141
+
8
142
  ## 1.26.0 — 2026-09-25
9
143
 
10
144
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
package/call.d.ts ADDED
@@ -0,0 +1,374 @@
1
+ // Type definitions for @displayxr/inline3d/call.
2
+ // PREVIEW tier — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
+ //
4
+ // P1 of docs/rfcs/0002-video-call.md: full-mesh WebRTC (<= 4 participants), pluggable signalling,
5
+ // side-by-side stereo from a stereo camera, convergence from `hello` + `hint`, SDK chrome or
6
+ // headless. P2a: mono peers lifted to 3D through `@displayxr/inline3d/lift` (`mono3D`).
7
+
8
+ /**
9
+ * The `lift()` of `@displayxr/inline3d/lift`, as the call uses it: `lift(video, { mode: 'live',
10
+ * wall, ui: 'none', convergence, priority, quality })` → a handle. Only the members below are
11
+ * used; `setPriority` / `setConvergence` / `on` / `native` / `canvas` / `state` are optional.
12
+ */
13
+ export interface CallLiftHandle {
14
+ remove(): void;
15
+ setPriority?(p: LiftStreamPriority): boolean;
16
+ setConvergence?(x: 'auto' | number): void;
17
+ on?(type: string, cb: (d: any) => void): () => void;
18
+ readonly native?: boolean;
19
+ readonly woven?: boolean;
20
+ readonly state?: string;
21
+ readonly canvas?: HTMLCanvasElement;
22
+ readonly stats?: { provider?: string | null } & Record<string, unknown>;
23
+ }
24
+ export type CallLiftFunction = ((element: HTMLVideoElement, opts: Record<string, unknown> & {
25
+ mode: 'live';
26
+ wall: unknown;
27
+ ui: 'none';
28
+ convergence: 'auto' | number;
29
+ priority: LiftStreamPriority;
30
+ }) => Promise<CallLiftHandle>) & { liftCapabilities?: (o?: { webFallback?: boolean }) => Promise<any> };
31
+
32
+ /** A lifted stream's scheduling (the native provider's per-stream priority). */
33
+ export type LiftStreamPriority = 'high' | 'normal' | 'low' | 'paused';
34
+
35
+ /** Why a mono peer is not lifted (or `'lifted'`). */
36
+ export type Mono3DReason = 'unavailable' | 'off' | 'lifted' | 'budget' | 'failed';
37
+
38
+ /** What a participant sends: a side-by-side pair (left eye left) or one flat picture. */
39
+ export type CallFormat = 'sbs' | 'mono';
40
+
41
+ /** How this page draws a remote participant (RFC §3). */
42
+ export type CallRoute = 'woven-sbs' | 'flat-left' | 'flat' | 'lifted';
43
+
44
+ /** A remote participant's connection state as the tile shows it. */
45
+ /**
46
+ * `'unreachable'`: known to exist but no connection for ~10 s — usually a network that needs a
47
+ * relay (TURN). Sticky until it connects; retries continue in the background.
48
+ */
49
+ export type PeerState = 'new' | 'connecting' | 'connected' | 'reconnecting' | 'unreachable' | 'left';
50
+
51
+ /** The out-of-band 3D flag, sent on the data channel when it opens. A peer that sends none is mono. */
52
+ export interface CallHello {
53
+ type: 'hello';
54
+ v: number;
55
+ format: CallFormat;
56
+ width: number | null;
57
+ height: number | null;
58
+ /** Camera baseline, mm. Needed (with `hfovDeg`) for automatic convergence. */
59
+ baselineMm: number | null;
60
+ /** Horizontal field of view of ONE eye, degrees. */
61
+ hfovDeg: number | null;
62
+ /** False for a raw stereo camera (the P1 default); true only after a calibrated rectify step. */
63
+ rectified: boolean;
64
+ sdk: string | null;
65
+ }
66
+
67
+ export interface CallCalibration {
68
+ baselineMm?: number;
69
+ hfovDeg?: number;
70
+ rectified?: boolean;
71
+ }
72
+
73
+ /**
74
+ * The signalling seam. Any object with `join()` works — bring your own accounts / push service.
75
+ * `join` rejects with an Error whose `code` is `'room-full'` when the room is at capacity.
76
+ */
77
+ export interface SignalingAdapter {
78
+ readonly name?: string;
79
+ join(room: string, hooks: SignalingHooks): Promise<SignalingSession>;
80
+ }
81
+ export interface SignalingHooks {
82
+ /** This peer's id — stable across signalling reconnects. */
83
+ id: string;
84
+ maxPeers: number;
85
+ onPeerJoined?(id: string): void;
86
+ onPeerLeft?(id: string): void;
87
+ /** An opaque offer / answer / ICE blob relayed from `from`. */
88
+ onSignal?(from: string, data: unknown): void;
89
+ onDisconnect?(err?: Error): void;
90
+ /** A participant known to exist that signalling itself cannot reach (shown as an unreachable tile). */
91
+ onPeerUnreachable?(ghostId: string): void;
92
+ onPeerReachable?(ghostId: string): void;
93
+ onReconnect?(peerIds: string[]): void;
94
+ }
95
+ export interface SignalingSession {
96
+ id: string;
97
+ /** Peers already in the room when this one joined. */
98
+ peers: string[];
99
+ /** Short-lived TURN credentials minted by the server, if it has any. */
100
+ iceServers?: RTCIceServer[];
101
+ send(to: string, data: unknown): void;
102
+ leave(): void;
103
+ }
104
+
105
+ /**
106
+ * The `dxr-signal/1` JSON-over-WebSocket client (protocol: signaling/README.md). `url` is your
107
+ * server's base, e.g. `wss://signal.example.com` or `ws://localhost:8787` — the reference servers
108
+ * are `signaling/worker.mjs` (Cloudflare) and `node signaling/dev-server.mjs`. There is no hosted
109
+ * default yet, so the URL is required.
110
+ */
111
+ /** The hosted DisplayXR signalling server (`dxrSignaling()` with no URL). */
112
+ 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;
132
+
133
+ export type CallAccent = 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' | 'lime' | 'mint' | 'ice';
134
+
135
+ export interface CallOptions {
136
+ /** Default `dxrSignaling()` (the hosted DisplayXR server). Or `dxrSignaling(url)` to self-host,
137
+ * `peerjsCloud()` (demo only), or your own adapter. */
138
+ signaling?: SignalingAdapter;
139
+ /**
140
+ * `'auto'` (default): the room in this page's `#room=` fragment, else a new one on join. Or a
141
+ * room id / invite link. Room ids are >= 96 random bits (generated ones: 128), base64url.
142
+ */
143
+ room?: 'auto' | string;
144
+ /** Overrides the server's STUN/TURN list. Default: public STUN + the server's TURN, if any. */
145
+ iceServers?: RTCIceServer[];
146
+ /**
147
+ * `'auto'` (default): a device delivering > 2.5:1 frames is a stereo pair, sent as `'sbs'`;
148
+ * otherwise the default webcam, `'mono'`. A camera held by another process (e.g. an eye
149
+ * tracker: NotReadableError) is skipped, never fatal. `'stereo'` prefers the pair (falls back
150
+ * to mono), `'mono'` never probes, a string is a deviceId, a MediaStream is used as given with
151
+ * `format` declaring what it is.
152
+ */
153
+ camera?: 'auto' | 'stereo' | 'mono' | string | MediaStream;
154
+ /** The format of a page-supplied `camera` stream. Default `'mono'` — 3D-ness is never guessed. */
155
+ format?: CallFormat;
156
+ /** Sent in `hello`; enables automatic convergence on the receivers. */
157
+ calibration?: CallCalibration;
158
+ /**
159
+ * P2 seam: a calibrated rectification step for a raw stereo stream (needs the camera's
160
+ * intrinsics/extrinsics, which come from a plug-in or the runtime). Return a rectified SBS
161
+ * stream; the hello then says `rectified: true`. P1 ships none: the raw pair is sent.
162
+ */
163
+ rectify?: (stream: MediaStream, info: { width: number; height: number; deviceId: string | null; label: string }) => MediaStream | Promise<MediaStream>;
164
+ /** Microphone (echo cancellation + noise suppression). Default true. */
165
+ audio?: boolean;
166
+ /**
167
+ * Lift mono peers to 3D on a 3D display (RFC §5). `'auto'` (default): import
168
+ * `@displayxr/inline3d/lift` lazily from this copy of the SDK, and use it when it reports a
169
+ * native provider or a WebGPU web fallback — otherwise mono peers stay flat (one log line, no
170
+ * error). `'off'` / `false`: flat. A function: your own `lift` (a bundled app imports
171
+ * `lift` from `@displayxr/inline3d/lift` and passes it here). Switch at runtime with
172
+ * `setMono3D()`.
173
+ */
174
+ mono3D?: 'auto' | 'off' | false | CallLiftFunction;
175
+ /**
176
+ * Extra `lift()` options for lifted tiles — e.g. `models` (where the depth model is served from),
177
+ * `ort`, `quality`, `providers`. The call's own keys (`mode`, `wall`, `ui`, `convergence`,
178
+ * `priority`) always win.
179
+ */
180
+ liftOptions?: Record<string, unknown>;
181
+ /** Concurrent lifted tiles. Default 4 (= the mesh maximum); further mono peers stay flat. */
182
+ maxLifted?: number;
183
+ /** Participants INCLUDING you. Default 4, clamped to 2..4 (full mesh). */
184
+ maxPeers?: number;
185
+ /** `'grid'` (default) or `'speaker'` (the active speaker spans the row). */
186
+ layout?: 'grid' | 'speaker';
187
+ /** SDK chrome: lobby, invite (link + QR), bottom bar, badges, banner. Default true. */
188
+ ui?: boolean;
189
+ /** Join without the lobby. Default: `!ui`. */
190
+ autoJoin?: boolean;
191
+ /** The small self view (mirrored; a stereo self view is mirrored AND eye-swapped). Default true. */
192
+ selfView?: boolean;
193
+ /** Tile aspect (w/h) = the woven buffer's per-eye aspect. Default 16/9. */
194
+ tileAspect?: number;
195
+ /** A named accent or any CSS colour, written to `--dxr-accent`. */
196
+ accent?: CallAccent | string;
197
+ /** Base URL of invite links (default: this page's URL, query kept, fragment replaced). */
198
+ inviteBase?: string;
199
+ /** Write `#room=` into this page's URL on join (so a reload rejoins). Default: `ui`. */
200
+ updateUrl?: boolean;
201
+ /** Where the "View in 3D with DisplayXR Browser" banner links. */
202
+ browserUrl?: string;
203
+ /** Re-open the inline-3D session if it ends without the page closing it. Default true. */
204
+ recoverSession?: boolean;
205
+ /** Scroll the call block to the top of the viewport once, on join, so every tile (and the self
206
+ * view) is on screen and woven. Default true (only with `ui`). */
207
+ scrollIntoView?: boolean;
208
+ /** `createInline3D()` options used for that re-open. */
209
+ wallOptions?: object;
210
+ debug?: boolean;
211
+ log?: (tag: string, detail: object) => void;
212
+ }
213
+
214
+ export interface CallQuality {
215
+ in: { width: number; height: number; fps: number; codec: string | null; kbps: number | null; dropped: number; decoder: string | null } | null;
216
+ out: { width: number; height: number; fps: number; codec: string | null; kbps: number | null; limitation: string | null; encoder: string | null } | null;
217
+ }
218
+
219
+ export interface CallPeer {
220
+ readonly id: string;
221
+ readonly format: CallFormat;
222
+ readonly route: CallRoute | null;
223
+ readonly state: PeerState;
224
+ readonly muted: boolean;
225
+ readonly cameraOff: boolean;
226
+ readonly speaking: boolean;
227
+ readonly rectified: boolean;
228
+ readonly hello: CallHello | null;
229
+ readonly quality: CallQuality | null;
230
+ /** The per-eye convergence shift currently painted, source px. */
231
+ readonly convergencePx: number;
232
+ /** A lifted tile: whether lift is showing yet, native or web, and its current priority. */
233
+ readonly lift: { readonly live: boolean; readonly native: boolean; readonly priority: LiftStreamPriority | null; readonly state: string | null } | null;
234
+ }
235
+
236
+ export interface CallMono3DInfo {
237
+ readonly on: boolean;
238
+ readonly state: 'off' | 'idle' | 'loading' | 'ready' | 'unavailable';
239
+ /** Why it is unavailable: `'import-failed'`, `'no-lift-export'`, `'no-provider'`, `'no-webgpu'`. */
240
+ readonly reason: string | null;
241
+ /** From `liftCapabilities()` when the lift module has it; null when unknown. */
242
+ readonly native: boolean | null;
243
+ readonly provider: string | null;
244
+ readonly lifted: number;
245
+ readonly max: number;
246
+ /** A lift has gone live in this call. */
247
+ readonly proven: boolean;
248
+ }
249
+
250
+ export interface CallEvents {
251
+ peer: { id: string };
252
+ peerleft: { id: string; reason: string };
253
+ format: { id: string; format: CallFormat; route: CallRoute; mono3d: Mono3DReason | null; hello: CallHello | null };
254
+ /**
255
+ * Per peer (`id`) every 2 s; or, with `id: null` and `lift`, when lifted tiles on the web
256
+ * provider degrade the page's frame rate (~< 20 fps for 3 s) and when it recovers.
257
+ */
258
+ quality: ({ id: string } & CallQuality) | ({ id: null; in: null; out: null; lift: { degraded: boolean; frameMs: number; tiles: number } });
259
+ speaker: { id: string | null };
260
+ /** Codes include `'camera-busy'`, `'no-camera'`, `'unreachable'` (error.peer = the tile id), `'room-full'`, `'session-ended'`. */
261
+ error: { code: string; message: string; error: Error | null };
262
+ state: { id: string; state: PeerState };
263
+ joined: { room: string; id: string };
264
+ left: { room: string };
265
+ session: { wall: unknown };
266
+ }
267
+
268
+ export interface CallHandle {
269
+ readonly room: string | null;
270
+ readonly id: string;
271
+ readonly state: 'idle' | 'lobby' | 'joining' | 'in-call' | 'full' | 'left';
272
+ readonly wall: unknown;
273
+ readonly format: CallFormat | null;
274
+ readonly muted: boolean;
275
+ readonly depth: number;
276
+ readonly speaker: string | null;
277
+ /** `'busy'` = held by another app (e.g. eye tracking): the call runs audio-only until `retryCamera()`. */
278
+ readonly camera: 'ok' | 'busy' | 'none' | 'pending';
279
+ /** mono→3D status (see {@link CallMono3DInfo}). */
280
+ readonly mono3D: CallMono3DInfo;
281
+ /** Read-only snapshot of the remote participants. */
282
+ readonly peers: ReadonlyArray<CallPeer>;
283
+ join(): Promise<CallHandle>;
284
+ /** `…#room=<id>` — the room rides in the fragment, which never reaches a server. Null before a room exists. */
285
+ inviteLink(): string | null;
286
+ /** Mute (true), unmute (false) or toggle (no argument). Returns the new state. */
287
+ mute(on?: boolean): boolean;
288
+ cameraOff(on?: boolean): boolean;
289
+ setCamera(idOrStream: string | MediaStream, opts?: { format?: CallFormat }): Promise<{ format: CallFormat; width: number; height: number; label: string }>;
290
+ /**
291
+ * The ONE depth control, [-1, 1] (+ = push back). Stereo tiles: ± 5% of the eye width added to
292
+ * the automatic convergence. Lifted tiles: lift's convergence (0 → `'auto'`, else 0.5 + 0.5·v).
293
+ */
294
+ setDepth(v: number | null): number;
295
+ /** Try the configured camera again (e.g. after the eye tracker released it). Keeps the current one on failure. */
296
+ retryCamera(): Promise<{ format: CallFormat; width: number; height: number; label: string }>;
297
+ /** Send `hint {subjectZmm}` (rate-limited to 5 Hz) if the page can estimate the face distance. */
298
+ sendHint(subjectZmm: number): boolean;
299
+ /** Turn mono→3D on/off at runtime (no argument toggles). Off releases every lift; tiles go flat. */
300
+ setMono3D(on?: boolean): boolean;
301
+ leave(): void;
302
+ on<K extends keyof CallEvents>(type: K, cb: (e: CallEvents[K]) => void): () => void;
303
+ off<K extends keyof CallEvents>(type: K, cb: (e: CallEvents[K]) => void): void;
304
+ }
305
+
306
+ export function addCall(wall: unknown, container: HTMLElement, opts: CallOptions): Promise<CallHandle>;
307
+
308
+ // ── pure helpers (exported for tests and advanced pages) ──────────────────────────────────
309
+ export function normalizeCallOptions(opts?: Partial<CallOptions>): Record<string, unknown>;
310
+ export function newRoomId(getRandomValues?: (a: Uint8Array) => Uint8Array): string;
311
+ export function isValidRoomId(room: unknown): boolean;
312
+ export function parseInviteLink(link: string | { hash?: string; href?: string } | null | undefined): string | null;
313
+ export function buildInviteLink(base: string, room: string): string;
314
+ export function normalizeHello(msg: unknown): CallHello | null;
315
+ export function makeHello(o?: { format?: CallFormat; width?: number; height?: number; baselineMm?: number; hfovDeg?: number; rectified?: boolean }): object;
316
+ export function routeFor(p: { format?: string | null; woven: boolean; mono3D?: 'auto' | 'off' | false | CallLiftFunction; lift?: boolean; overBudget?: boolean; failed?: boolean }): { route: CallRoute; mono3d?: Mono3DReason };
317
+ export function badgeFor(route: CallRoute | null): '3D' | '2D→3D' | '2D';
318
+ export function normalizeMono3D(v: unknown): 'auto' | 'off' | CallLiftFunction;
319
+ export function resolveLift(
320
+ mono3D: 'auto' | 'off' | false | CallLiftFunction,
321
+ o?: { importer?: () => Promise<any>; nav?: any; log?: (tag: string, o: object) => void; capsOpts?: object },
322
+ ): Promise<{ lift: CallLiftFunction | null; capabilities: ((o?: object) => Promise<any>) | null; caps: any; source: 'injected' | 'module' | 'none'; reason: string | null }>;
323
+ export function liftConvergenceFor(depth: number | null | undefined): 'auto' | number;
324
+ export function liftPriorityFor(p: { id: string | null; speakerId: string | null; visible?: boolean }): LiftStreamPriority;
325
+ /** The hook for the native provider's per-stream priority: forwards to `handle.setPriority` (no-op without one). */
326
+ export function setLiftPriority(handle: CallLiftHandle | null | undefined, level: LiftStreamPriority): boolean;
327
+ export function defaultLiftSpecifier(base?: string): string;
328
+ export const LIFT_PRIORITY: Readonly<{ speaker: 'high'; other: 'normal'; hidden: 'paused' }>;
329
+ export function createFrameWatch(o?: { degradedMs?: number; recoveredMs?: number; holdMs?: number; alpha?: number }): {
330
+ feed(dtMs: number): 'degraded' | 'recovered' | null;
331
+ reset(): void;
332
+ readonly frameMs: number | null;
333
+ readonly degraded: boolean;
334
+ };
335
+ export interface CallLiftPool {
336
+ readonly size: number;
337
+ max: number;
338
+ has(id: string): boolean;
339
+ handle(id: string): CallLiftHandle | null;
340
+ priority(id: string): LiftStreamPriority | null;
341
+ canAcquire(id: string): boolean;
342
+ acquire(id: string, element: HTMLVideoElement, o?: { wall?: unknown; visible?: boolean; onError?: (e: unknown) => void }): Promise<CallLiftHandle | null>;
343
+ release(id: string): void;
344
+ releaseAll(): void;
345
+ setSpeaker(id: string | null): void;
346
+ setVisible(id: string, visible: boolean): void;
347
+ setDepth(v: number): 'auto' | number;
348
+ readonly anyWeb: boolean;
349
+ list(): Array<{ id: string; priority: LiftStreamPriority | null; pending: boolean; native: boolean }>;
350
+ }
351
+ export function createLiftPool(o: { lift: CallLiftFunction; max?: number; log?: (tag: string, o: object) => void; options?: Record<string, unknown> }): CallLiftPool;
352
+ export function convergenceShiftPx(p: { eyeWidthPx: number; hfovDeg?: number | null; baselineMm?: number | null; subjectZmm?: number | null }): number;
353
+ export function lowPass(prev: number, target: number, alpha?: number): number;
354
+ export function clampShift(px: number, eyeWidthPx: number, maxFraction?: number): number;
355
+ export function eyeCropRect(eyeW: number, eyeH: number, aspect: number, shift: number, eye: 0 | 1): { sx: number; sy: number; sw: number; sh: number };
356
+ 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 }>;
357
+ export function mirrorSwapPixels<T extends ArrayLike<number>>(px: T, W: number, H: number): T;
358
+ export function maxBitrateKbps(format: CallFormat, remotePeers: number): number;
359
+ export function preferVideoCodecs(sdp: string, order?: readonly string[]): string;
360
+ export function sortCodecCapabilities<T extends { mimeType?: string }>(codecs: T[], order?: readonly string[]): T[];
361
+ export const VIDEO_CODEC_ORDER: readonly string[];
362
+ export function clampMaxPeers(n?: number): number;
363
+ export function qrEncode(text: string, opts?: { ecl?: 'L' | 'M'; mask?: number }): { version: number; size: number; mask: number; modules: boolean[][] };
364
+ export function roomKey(room: string): Promise<string>;
365
+ export const SIGNAL_PROTOCOL: string;
366
+ export const WIRE_VERSION: number;
367
+ export const CALL_SDK: string;
368
+ export const PLATE_TEXT: Readonly<{ unreachable: string; cameraBusy: string }>;
369
+ export function createLiveGate(o?: { need?: number; needNoPose?: number }): { feed(viewCount: number | null): boolean; readonly live: boolean };
370
+ export const CALL_ACCENTS: Readonly<Record<CallAccent, string>>;
371
+ export class MeshTransport {
372
+ constructor(o: { signaling: SignalingAdapter; id: string; maxPeers?: number; iceServers?: RTCIceServer[]; RTCPeerConnection?: any; log?: (tag: string, obj: object) => void });
373
+ readonly size: number;
374
+ }
package/index.d.ts CHANGED
@@ -3,7 +3,13 @@
3
3
 
4
4
  /** Options shared by every add*() call. */
5
5
  export interface TileOptions {
6
- /** Per-eye buffer resolution in px (defaults to the CSS box × devicePixelRatio, dpr capped at 2). */
6
+ /**
7
+ * Per-eye buffer resolution in px (defaults to the CSS box × devicePixelRatio, dpr capped at 2).
8
+ * `addImage` / `addVideo` clamp the store to the device's GL limits — 2 × width and the height
9
+ * within min(MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE, MAX_VIEWPORT_DIMS), both axes by one
10
+ * factor, warned once — explicit sizes included. An `addScene` canvas is the page's to size;
11
+ * the SDK warns once if the browser clamped its drawing buffer below `canvas.width/height`.
12
+ */
7
13
  width?: number;
8
14
  /** Per-eye buffer height in px (see `width`). */
9
15
  height?: number;
@@ -103,6 +109,16 @@ export interface XRDisplayInfo {
103
109
  displayPixelHeight: number;
104
110
  recommendedViewScaleX: number;
105
111
  recommendedViewScaleY: number;
112
+ /**
113
+ * The runtime's NOMINAL viewer position, metres, in display space: origin at the panel
114
+ * centre, +x right, +y up, +z out of the glass toward the viewer (w = 1). A constant of the
115
+ * panel — where the runtime seats the viewer when nobody is tracked, and the distance its
116
+ * camera rig is calibrated to (`z` is that rig's n) — NOT the live eye position.
117
+ *
118
+ * Absent on browsers that predate it (DisplayXR Browser patch 0221) and when the runtime
119
+ * reports nothing usable, so read it as `info.nominalViewerPosition?.z ?? fallback`.
120
+ */
121
+ nominalViewerPosition?: DOMPointReadOnly;
106
122
  }
107
123
 
108
124
  /**