@displayxr/inline3d 1.30.0 → 1.31.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,34 @@ 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.31.0 — 2026-10-05
9
+
10
+ The 1.29 `./call` option spellings stay accepted (with their warning) for one more release; they go in 1.32.
11
+
12
+ Touches the **preview tier** (`./viewer`, `./splat`, `./model`: the eye cameras they draw) and adds
13
+ one **core** option and export. Pixels move only around a tracking acquisition or loss.
14
+
15
+ ### Added — the tracking ease (`viewerEase`, default on)
16
+
17
+ - When a viewer is acquired (the views jump from the untracked fallback viewpoint to the tracked
18
+ eyes) or lost, the SDK's renderers now glide the eye cameras from where they were drawn to the
19
+ live views over 300 ms (smoothstep) instead of snapping in one frame. Triggered by a jump of more
20
+ than 0.15 eye separations in one frame within 600 ms of a `trackingState` edge (the state and the
21
+ jump need not land on the same frame; an edge with no jump changes nothing), or, where the browser
22
+ 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,
23
+ so rig changes, scene scale and canvas size do not trigger it.
24
+ - Exact, not approximate: every DisplayXR frustum is affine in the eye position, so offsetting the
25
+ pose (in the view's own frame) and the projection (element-wise) by the same weight gives the
26
+ frustum the runtime would report for the eased eye. The rig map and the layer rig see consistent
27
+ views throughout.
28
+ - Left alone: ordinary head motion, a vendor-animated ramp, view-count changes (2D<->3D mode switch),
29
+ buffer resizes, replayed frames, mono.
30
+ - `createInline3D({ viewerEase })` sets the session default; `viewerEase` on `SceneViewer`,
31
+ `addSplat` and `addModel` overrides it. `false` (or `{ durationMs: 0 }`) restores the snap;
32
+ `{ durationMs, easing }` tunes it. Core exports `ViewerEase`, `resolveViewerEaseOption`,
33
+ `frameTrackingState`, `VIEWER_EASE_DEFAULT_MS` for pages that render their own `addScene`.
34
+ `handle.viewer.viewerEase.last` reports the last frame's weight and trigger.
35
+
8
36
  ## 1.30.0 — 2026-10-05
9
37
 
10
38
  Touches the **preview tier** only (`./call`, `./call/full`, the new `./camera` and `./splat/playcanvas`). RFC 0003
@@ -129,6 +157,41 @@ Internal moves (no API): `js/call/capture.js` → `js/camera/capture.js`, `js/ca
129
157
  parses, a WebM whose tags parse). Panel runs still required: the mirrored self view on glass,
130
158
  an SBS photo opened in `/player`.
131
159
 
160
+ ### `./call` + the hosted service (RFC 0003 §5, phase C3)
161
+
162
+ Also in this release: the hosted signalling service productised, with the **reference server**
163
+ (`signaling/`). Additive for pages; `dxr-signal/1` gains optional fields and new error codes only.
164
+
165
+ - **Publishable keys.** `mountCall(el, { key: 'pk_…' })` / `<dxr-call key="pk_…">` now mean
166
+ something: the hosted server validates the key against its origin list on connect, applies the
167
+ keyed tier's limits (10× anonymous), and attributes every session to the key id (billing
168
+ identity from day one; billing is off). Issued by hand: `tools/signal-keys.mjs`. No key = the
169
+ anonymous tier, as before, with tighter limits.
170
+ - **Limits** (RFC §5b): joins per address per minute, concurrent rooms per address / per key, room
171
+ lifetime (2 h / 8 h → `expired`), TURN mints per address per hour. A refused join is an `error`
172
+ with `rate-limited` (+ `error.retryMs`) / `quota` / `bad-key` / `origin-not-allowed` / `blocked`
173
+ — never a silent hang; reconnects honour `retryMs`.
174
+ - **TURN metering and the monthly cap** (RFC §5c, Decision 2): the service meters minted
175
+ credentials (and, when configured, Cloudflare's real per-key relay bytes) against the free
176
+ 1000 GB + a $100 hard cap. At 70 % anonymous credentials shorten, at 90 % anonymous sessions
177
+ get none (`warning` **`turn-shed`**), at 100 % nobody does (`error` **`turn-cap`**) — the call
178
+ still joins and direct P2P keeps working. `welcome.turn` / `SignalingSession.turn` carry the
179
+ decision (`SignalingTurnStatus`).
180
+ - **Domain move prepared, not cut over** (RFC §5g): the hosted server answers on every host of
181
+ `DXR_SIGNAL_ALIASES` (`dxr-signal.displayxr.workers.dev`, `signal.displayxr.org`) and
182
+ `dxrSignaling()` fails over between them on connect and reconnect. `DXR_SIGNAL_DEFAULT` is
183
+ unchanged; the alias list is internal (`js/call/signaling.js`, importable by path — the
184
+ `./call` entry keeps its 9 exports). `dxrSignaling(url, { aliases })` adds fallbacks for a
185
+ self-hosted URL. Options and the recommendation: `docs/signaling-domain.md`.
186
+ - **Docs:** `docs/privacy-call.md` (what the server sees, retention, the E2E caveat),
187
+ `signaling/README.md` (keys, limits, budget, self-host vars, a coturn recipe, staging),
188
+ `docs/call.md` troubleshooting rows for the new codes.
189
+ - **Server:** `signaling/gate.mjs` + `limits.mjs` + `keys.mjs` + `turn-budget.mjs`, shared by
190
+ the Worker (Durable Objects per subject + one budget, a KV namespace for keys, an hourly
191
+ analytics cron, `/admin/*` behind `ADMIN_TOKEN`) and the Node dev server (same admission in
192
+ memory). Staging twin `signaling/deploy/staging.toml`; the C3 gate
193
+ `test/e2e/signal-staging.e2e.mjs`.
194
+
132
195
  ## 1.29.0 — 2026-09-30
133
196
 
134
197
  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
  }