@displayxr/inline3d 1.30.0 → 1.32.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,83 @@ 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.32.0 — 2026-10-06
9
+
10
+ Touches the **core** additively (one new handle method, two new exports) and the **preview tier**
11
+ (`./splat`, PlayCanvas backend).
12
+
13
+
14
+ ### Hosted signalling server (`signaling/`, not part of the npm package)
15
+
16
+ - **`welcome` is now always the first message a joined peer receives.** With a real TURN key the
17
+ server awaits a credential mint between reserving a peer's slot and welcoming it; another peer
18
+ joining or signalling in that window used to reach the first peer *before* its own welcome, and
19
+ that welcome's `peers` then omitted the newcomer. Messages addressed to a not-yet-welcomed peer
20
+ are now held and flushed behind its welcome, and the roster is read when the welcome is sent. The
21
+ SDK was already tolerant of the old order (its transport replays early `peer-joined`s); raw
22
+ `dxr-signal/1` clients were not. No protocol field changed.
23
+
24
+ ### Core — `handle.displayMetrics()`
25
+
26
+ - **`handle.displayMetrics()`** → `{ canvasSizeM, metersPerCssPx, displaySizeM, nominalViewerM,
27
+ eyeSeparationM, source }`: the tile in physical units, derived from `getDisplayInfo()` (panel
28
+ pitch × `devicePixelRatio` × the canvas's CSS box). Never rejects. Anything the display can't
29
+ report comes from `DISPLAY_METRICS_DEFAULTS` and is flagged in `source`. **Eye separation is
30
+ always the 63 mm default**, because no browser or runtime surface reports one. Also exported as
31
+ the pure `displayMetricsFrom(info, rect, dpr)`.
32
+
33
+ ### `./splat` (PlayCanvas backend)
34
+
35
+ - **`handle.setStereo({ ipdFactor, parallaxFactor })`**: live stereo strength. It re-declares the
36
+ rig in force on the next frame with no cut (pose, focus and framing kept). Keys merge, a `null`
37
+ key returns to the asset's own value, and `setStereo(null)` clears both. Sticky across
38
+ `setSource` / `setRig`. `handle.rig.stereoDefault` holds the asset's own values.
39
+ docs/playcanvas-adapter.md §setStereo.
40
+ - **`handle.setDepthEnvelope({ maxFrontM, edgeM, bandM, stage, rects, viewerM, canvasSizeM })`**:
41
+ the frame envelope. Content may come out of the glass only well inside the frame and away from 2D
42
+ elements. Gaussians past the cap slide back along their own rays from the declared camera rig,
43
+ so the picture, the sort order and the stereo rule all hold. It is tile-wide and keyed on the
44
+ live declared rig (convergence, lens, `ipdFactor × metersToVirtual`), so every asset is covered
45
+ from its first frame, through crossfades and through `setStereo`, with no per-asset install.
46
+ Camera rigs only. docs/splat-effects.md §Depth envelope.
47
+ - `setStereo({ ipdScale, parallaxScale })`: the same scalars RELATIVE to each asset's own
48
+ (`rig.stereoDefault`), re-applied to every asset `setSource` brings in. Per scalar, the absolute
49
+ and relative keys are exclusive; the last one given wins.
50
+ - **`splatHandle.displayMetrics()`**: the core tile's `displayMetrics()`, on the splat handle.
51
+ - The deferred PlayCanvas handle (what `addSplat` returns before the engine module loads) now has
52
+ `setStereo`, `setDepthEnvelope` and `displayMetrics`. A call right after `addSplat` was
53
+ `not a function`. A test now pins this.
54
+ - `STAGE_ORDER` gains `'envelope'` (between `custom` and `cull`), so a page's custom effect runs
55
+ before the cap.
56
+
57
+ ## 1.31.0 — 2026-10-05
58
+
59
+ The 1.29 `./call` option spellings stay accepted (with their warning) for one more release; they go in 1.32.
60
+
61
+ Touches the **preview tier** (`./viewer`, `./splat`, `./model`: the eye cameras they draw) and adds
62
+ one **core** option and export. Pixels move only around a tracking acquisition or loss.
63
+
64
+ ### Added — the tracking ease (`viewerEase`, default on)
65
+
66
+ - When a viewer is acquired (the views jump from the untracked fallback viewpoint to the tracked
67
+ eyes) or lost, the SDK's renderers now glide the eye cameras from where they were drawn to the
68
+ live views over 300 ms (smoothstep) instead of snapping in one frame. Triggered by a jump of more
69
+ than 0.15 eye separations in one frame within 600 ms of a `trackingState` edge (the state and the
70
+ jump need not land on the same frame; an edge with no jump changes nothing), or, where the browser
71
+ reports no state, by a one-frame jump of more than half an eye separation. The eye positions for that test are read off the projections,
72
+ so rig changes, scene scale and canvas size do not trigger it.
73
+ - Exact, not approximate: every DisplayXR frustum is affine in the eye position, so offsetting the
74
+ pose (in the view's own frame) and the projection (element-wise) by the same weight gives the
75
+ frustum the runtime would report for the eased eye. The rig map and the layer rig see consistent
76
+ views throughout.
77
+ - Left alone: ordinary head motion, a vendor-animated ramp, view-count changes (2D<->3D mode switch),
78
+ buffer resizes, replayed frames, mono.
79
+ - `createInline3D({ viewerEase })` sets the session default; `viewerEase` on `SceneViewer`,
80
+ `addSplat` and `addModel` overrides it. `false` (or `{ durationMs: 0 }`) restores the snap;
81
+ `{ durationMs, easing }` tunes it. Core exports `ViewerEase`, `resolveViewerEaseOption`,
82
+ `frameTrackingState`, `VIEWER_EASE_DEFAULT_MS` for pages that render their own `addScene`.
83
+ `handle.viewer.viewerEase.last` reports the last frame's weight and trigger.
84
+
8
85
  ## 1.30.0 — 2026-10-05
9
86
 
10
87
  Touches the **preview tier** only (`./call`, `./call/full`, the new `./camera` and `./splat/playcanvas`). RFC 0003
@@ -129,6 +206,41 @@ Internal moves (no API): `js/call/capture.js` → `js/camera/capture.js`, `js/ca
129
206
  parses, a WebM whose tags parse). Panel runs still required: the mirrored self view on glass,
130
207
  an SBS photo opened in `/player`.
131
208
 
209
+ ### `./call` + the hosted service (RFC 0003 §5, phase C3)
210
+
211
+ Also in this release: the hosted signalling service productised, with the **reference server**
212
+ (`signaling/`). Additive for pages; `dxr-signal/1` gains optional fields and new error codes only.
213
+
214
+ - **Publishable keys.** `mountCall(el, { key: 'pk_…' })` / `<dxr-call key="pk_…">` now mean
215
+ something: the hosted server validates the key against its origin list on connect, applies the
216
+ keyed tier's limits (10× anonymous), and attributes every session to the key id (billing
217
+ identity from day one; billing is off). Issued by hand: `tools/signal-keys.mjs`. No key = the
218
+ anonymous tier, as before, with tighter limits.
219
+ - **Limits** (RFC §5b): joins per address per minute, concurrent rooms per address / per key, room
220
+ lifetime (2 h / 8 h → `expired`), TURN mints per address per hour. A refused join is an `error`
221
+ with `rate-limited` (+ `error.retryMs`) / `quota` / `bad-key` / `origin-not-allowed` / `blocked`
222
+ — never a silent hang; reconnects honour `retryMs`.
223
+ - **TURN metering and the monthly cap** (RFC §5c, Decision 2): the service meters minted
224
+ credentials (and, when configured, Cloudflare's real per-key relay bytes) against the free
225
+ 1000 GB + a $100 hard cap. At 70 % anonymous credentials shorten, at 90 % anonymous sessions
226
+ get none (`warning` **`turn-shed`**), at 100 % nobody does (`error` **`turn-cap`**) — the call
227
+ still joins and direct P2P keeps working. `welcome.turn` / `SignalingSession.turn` carry the
228
+ decision (`SignalingTurnStatus`).
229
+ - **Domain move prepared, not cut over** (RFC §5g): the hosted server answers on every host of
230
+ `DXR_SIGNAL_ALIASES` (`dxr-signal.displayxr.workers.dev`, `signal.displayxr.org`) and
231
+ `dxrSignaling()` fails over between them on connect and reconnect. `DXR_SIGNAL_DEFAULT` is
232
+ unchanged; the alias list is internal (`js/call/signaling.js`, importable by path — the
233
+ `./call` entry keeps its 9 exports). `dxrSignaling(url, { aliases })` adds fallbacks for a
234
+ self-hosted URL. Options and the recommendation: `docs/signaling-domain.md`.
235
+ - **Docs:** `docs/privacy-call.md` (what the server sees, retention, the E2E caveat),
236
+ `signaling/README.md` (keys, limits, budget, self-host vars, a coturn recipe, staging),
237
+ `docs/call.md` troubleshooting rows for the new codes.
238
+ - **Server:** `signaling/gate.mjs` + `limits.mjs` + `keys.mjs` + `turn-budget.mjs`, shared by
239
+ the Worker (Durable Objects per subject + one budget, a KV namespace for keys, an hourly
240
+ analytics cron, `/admin/*` behind `ADMIN_TOKEN`) and the Node dev server (same admission in
241
+ memory). Staging twin `signaling/deploy/staging.toml`; the C3 gate
242
+ `test/e2e/signal-staging.e2e.mjs`.
243
+
132
244
  ## 1.29.0 — 2026-09-30
133
245
 
134
246
  Touches the **core** (additive: `sharedInline3D()`, `handle.rewoven()`) and the **preview tier**
package/README.md CHANGED
@@ -3,12 +3,12 @@
3
3
  Inline-3D **web samples** and a small JS SDK for the
4
4
  [DisplayXR Browser](https://github.com/DisplayXR/displayxr-browser) — the DisplayXR analog of
5
5
  [`immersive-web/webxr-samples`](https://github.com/immersive-web/webxr-samples). This is the canonical
6
- repo web developers clone to build glasses-free 3D pages, and the site the browser navigates to for the
6
+ repo web developers clone to build 3D pages for spatial displays, and the site the browser navigates to for the
7
7
  live demos.
8
8
 
9
9
  **▶ See it live:** <https://displayxr.github.io/displayxr-web/> — open in the
10
- [DisplayXR Browser](https://github.com/DisplayXR/displayxr-browser/releases) on DisplayXR hardware for
11
- glasses-free 3D; in any other browser the pages render as a normal 2D fallback, so they're safe to view
10
+ [DisplayXR Browser](https://github.com/DisplayXR/displayxr-browser/releases) on a spatial display to see
11
+ them in 3D; in any other browser the pages render as a normal 2D fallback, so they're safe to view
12
12
  anywhere.
13
13
 
14
14
  ## Install
@@ -107,7 +107,7 @@ that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stabil
107
107
 
108
108
  ## Quick start
109
109
 
110
- One SDK call turns a `<canvas>` into a glasses-free-3D window. Everything degrades to plain 2D on a
110
+ One SDK call turns a `<canvas>` into a 3D window on a spatial display. Everything degrades to plain 2D on a
111
111
  non-DisplayXR browser, so a page is safe to ship anywhere.
112
112
 
113
113
  ```js
@@ -117,7 +117,7 @@ const wall = await createInline3D(); // opens an inline-3d session (detect
117
117
  if (!wall.supported) {
118
118
  // Not the DisplayXR Browser (or no 3D display) — your page's normal 2D content shows. Done.
119
119
  } else {
120
- // Woven, glasses-free 3D. Add content — one call per element:
120
+ // Woven 3D. Add content — one call per element:
121
121
  wall.addImage(canvas, 'photo-sbs.png'); // a still side-by-side 3D photo
122
122
  wall.addVideo(canvas, videoEl); // an SBS 3D video
123
123
  wall.addScene(canvas, (views, layer) => { /* render */ });// a live three.js / WebGL stereo scene
package/call.d.ts CHANGED
@@ -60,19 +60,51 @@ export interface SignalingSession {
60
60
  peers: string[];
61
61
  /** Short-lived TURN credentials minted by the server, if it has any. */
62
62
  iceServers?: RTCIceServer[];
63
+ /** Hosted service (RFC 0003 §5): the tier this session was admitted as. */
64
+ tier?: 'anon' | 'key';
65
+ /** Hosted service: the publishable key id this session is attributed to. */
66
+ key?: string;
67
+ /** Hosted service: why `iceServers` is (or is not) there — see {@link SignalingTurnStatus}. */
68
+ turn?: SignalingTurnStatus;
63
69
  send(to: string, data: unknown): void;
64
70
  leave(): void;
65
71
  }
66
72
 
67
73
  /** The hosted DisplayXR signalling server (`dxrSignaling()` with no URL). */
68
74
  export const DXR_SIGNAL_DEFAULT: string;
75
+ /**
76
+ * The server's TURN decision for one session (`welcome.turn`, `dxr-signal/1`). `status` is the
77
+ * service-wide state of the monthly relay budget (`ok` / `degraded` / `off`); `reason` says why
78
+ * THIS session got no credentials: `unconfigured` (the server has no TURN — self-hosted), `budget`
79
+ * (anonymous session shed near the budget → `warning` `turn-shed`), `rate` (too many mints from
80
+ * this address → `turn-shed`), `cap` (the monthly cap → `error` `turn-cap`), `mint-failed`.
81
+ */
82
+ export interface SignalingTurnStatus {
83
+ status: 'ok' | 'degraded' | 'off';
84
+ reason?: 'unconfigured' | 'budget' | 'cap' | 'rate' | 'mint-failed';
85
+ /** Credential lifetime, seconds, when minted. */
86
+ ttl?: number;
87
+ }
88
+
69
89
  /**
70
90
  * The `dxr-signal/1` JSON-over-WebSocket client (protocol: signaling/README.md). `url` is your
71
91
  * server's base, e.g. `wss://signal.example.com` or `ws://localhost:8787` — the reference servers
72
92
  * are `signaling/worker.mjs` (Cloudflare) and `node signaling/dev-server.mjs`. No URL = the hosted
73
93
  * server. `key`: a publishable key for the hosted service (public by design; sent on connect).
94
+ * The hosted server answers on more than one host (RFC 0003 §5g) and the adapter fails over
95
+ * between them on connect and reconnect by itself; `aliases` adds fallbacks for a self-hosted URL.
74
96
  */
75
- export function dxrSignaling(url?: string, opts?: { WebSocket?: any; pingMs?: number; key?: string }): SignalingAdapter;
97
+ export function dxrSignaling(
98
+ url?: string,
99
+ opts?: {
100
+ WebSocket?: any;
101
+ pingMs?: number;
102
+ /** A publishable key for the hosted service (public by design; sent on connect). */
103
+ key?: string;
104
+ /** Extra base URLs to fail over to when `url` is unreachable (the hosted default has its own). */
105
+ aliases?: string[];
106
+ }
107
+ ): SignalingAdapter;
76
108
 
77
109
  /** The eight named accents `theme.accent` takes (the same names `/player` uses). */
78
110
  export type CallAccent = 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' | 'lime' | 'mint' | 'ice';
@@ -248,7 +280,13 @@ export interface CallPeer {
248
280
  * `error` codes — fatal for a feature, never for the widget. `'camera-busy'` (held by another
249
281
  * app: audio-only until `retryCamera()`), `'no-camera'`, `'permission-denied'`, `'unreachable'`
250
282
  * (`error.peer` = the tile), `'room-full'`, `'session-ended'`, `'signaling-closed'`,
251
- * `'join-failed'`, `'camera-failed'`, `'rectify-failed'`, `'mount-failed'` (the element).
283
+ * `'signaling-unreachable'`, `'join-failed'`, `'camera-failed'`, `'rectify-failed'`,
284
+ * `'mount-failed'` (the element). From the hosted signalling service (RFC 0003 §5, C3):
285
+ * `'rate-limited'` / `'quota'` (a limit of the anonymous or keyed tier — the join is refused;
286
+ * `error.retryMs` says when a retry may work), `'bad-key'` / `'origin-not-allowed'` (the
287
+ * publishable key is unknown, revoked, or not allowed from this page's origin), `'blocked'`,
288
+ * `'expired'` (the room's lifetime is up), and `'turn-cap'` (the service's monthly relay budget
289
+ * is spent: the call still joins and direct connections work, but nobody gets TURN — not fatal).
252
290
  */
253
291
  export type CallErrorCode =
254
292
  | 'camera-busy'
@@ -262,11 +300,19 @@ export type CallErrorCode =
262
300
  | 'camera-failed'
263
301
  | 'rectify-failed'
264
302
  | 'mount-failed'
265
- // Hosted-service codes (RFC 0003 §5, phase C3) are added here by the service work.
303
+ | 'signaling-unreachable'
304
+ // Hosted signalling service (RFC 0003 §5, C3).
305
+ | 'rate-limited'
306
+ | 'quota'
307
+ | 'bad-key'
308
+ | 'origin-not-allowed'
309
+ | 'blocked'
310
+ | 'expired'
311
+ | 'turn-cap'
266
312
  | (string & {});
267
313
 
268
314
  /** `warning` codes — degraded, not broken. */
269
- export type CallWarningCode = 'lift-not-bundled' | (string & {});
315
+ export type CallWarningCode = 'lift-not-bundled' | 'turn-shed' | (string & {});
270
316
 
271
317
  export interface CallEvents {
272
318
  joined: { room: string; id: string };
@@ -289,6 +335,9 @@ export interface CallEvents {
289
335
  * module could not be imported from this build — they stay flat. Fix: `mono3D: lift` or
290
336
  * `./call/full`. Emitted once per call with one `console.warn`. A display that honestly cannot
291
337
  * lift is NOT this: that is `handle.mono3D.reason` (`'no-provider'` / `'no-webgpu'`).
338
+ * `'turn-shed'`: only THIS session got no TURN from the hosted service (an anonymous session
339
+ * while the monthly relay budget is nearly spent, or too many credential requests from this
340
+ * address this hour); the call runs STUN-only. A publishable key keeps TURN.
292
341
  */
293
342
  warning: { code: CallWarningCode; message: string };
294
343
  }