@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/index.d.ts CHANGED
@@ -555,7 +555,63 @@ export interface CreateInline3DOptions {
555
555
  * `trackingstatechange` and do the same for a scene.
556
556
  */
557
557
  untrackedFallback?: 'none' | 'mono';
558
+ /**
559
+ * The TRACKING EASE, as the default for every SDK renderer drawing through this session
560
+ * (`./viewer`'s SceneViewer, `./splat` and `./model` on both engines). When a viewer is
561
+ * acquired — the views jump from the runtime's nominal viewer to the tracked eyes — or lost,
562
+ * the eye cameras glide from where they were drawn to the new views over `durationMs` instead
563
+ * of snapping in one frame; everything parented to the views (parallax, display-rig layers)
564
+ * follows. Triggered by a jump in the views around a `trackingState` edge (or, where the browser
565
+ * reports no state, by a large one-frame jump); a no-op when the vendor already animates the eyes. On by default; `false` restores
566
+ * the snap; a renderer's own `viewerEase` option overrides this. A page rendering its own
567
+ * `addScene` can use the exported {@link ViewerEase}.
568
+ */
569
+ viewerEase?: ViewerEaseOption;
570
+ }
571
+
572
+ /** `true`/unset = on with defaults, `false` = off, or tune it. */
573
+ export type ViewerEaseOption =
574
+ | boolean
575
+ | {
576
+ /** Default true. */
577
+ enabled?: boolean;
578
+ /** The glide, in ms (default 300). 0 disables. */
579
+ durationMs?: number;
580
+ /** 'smoothstep' (default) | 'linear' | 'easeOutCubic'. */
581
+ easing?: 'smoothstep' | 'linear' | 'easeOutCubic' | string;
582
+ };
583
+
584
+ /** One frame's matrices as an SDK renderer copies them out of an XRView (column-major). */
585
+ export interface ViewerEaseEntry {
586
+ proj: Float32Array | Float64Array | number[];
587
+ pose: Float32Array | Float64Array | number[];
588
+ }
589
+
590
+ /**
591
+ * The tracking ease on its own, for a page that renders its own `addScene`: copy each view's
592
+ * `projectionMatrix` and `transform.matrix` into an entry, call `apply(entries,
593
+ * frame.session.trackingState)` once per LIVE frame, and render from the entries (keep using the
594
+ * real XRView for `layer.getViewport`). It rewrites the entries only while an ease is running.
595
+ */
596
+ export declare class ViewerEase {
597
+ constructor(opts?: ViewerEaseOption & object);
598
+ readonly enabled: boolean;
599
+ readonly durationMs: number;
600
+ readonly easing: string;
601
+ /** Is an ease running right now? */
602
+ readonly active: boolean;
603
+ /** What the last apply() did: the weight of the old views (0 = passed through), the trigger, the jump (eye separations). */
604
+ readonly last: { weight: number; reason: null | 'armed-jump' | 'jump' | 'easing'; jump: number };
605
+ apply(entries: ViewerEaseEntry[], trackingState?: XRTrackingState | string | null, timeMs?: number): number;
606
+ configure(opts?: ViewerEaseOption): void;
607
+ /** Forget the previous frame (call after a resize or a view-count change you handle yourself). */
608
+ reset(): void;
558
609
  }
610
+ export function resolveViewerEaseOption(opt?: ViewerEaseOption): { enabled: boolean; durationMs: number; easing: string };
611
+ /** `frame.session.trackingState`, or null when the browser does not report one. */
612
+ export function frameTrackingState(frame: unknown): XRTrackingState | null;
613
+ /** 300. */
614
+ export const VIEWER_EASE_DEFAULT_MS: number;
559
615
 
560
616
  /**
561
617
  * The eased 2D<->3D transition — on by default, and the same sequencer (and the same defaults)
@@ -53,6 +53,18 @@ export async function roomKey(room) {
53
53
  */
54
54
  export const DXR_SIGNAL_DEFAULT = 'wss://dxr-signal.displayxr.workers.dev';
55
55
 
56
+ /**
57
+ * Every host the hosted server answers on. The SAME Worker serves all of them (rooms are shared),
58
+ * so the adapter fails over between them when one cannot be reached: the first entry is tried
59
+ * first. `signal.displayxr.org` is the target of the domain move (RFC 0003 §5g); the workers.dev
60
+ * host stays an alias through the transition. Only applies to the hosted default — a self-hosted
61
+ * URL is tried as given (pass `aliases` to add your own fallbacks).
62
+ */
63
+ export const DXR_SIGNAL_ALIASES = Object.freeze([DXR_SIGNAL_DEFAULT, 'wss://signal.displayxr.org']);
64
+
65
+ /** Join-time error codes from the server that mean "do not retry with the same inputs". */
66
+ const FATAL_JOIN_CODES = ['bad-room', 'bad-id', 'bad-version', 'bad-key', 'origin-not-allowed', 'blocked'];
67
+
56
68
  export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
57
69
  if (!url || typeof url !== 'string') {
58
70
  throw codedError(
@@ -60,16 +72,21 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
60
72
  'dxrSignaling(url): url must be a ws:// or wss:// string (omit it for the hosted DisplayXR server)'
61
73
  );
62
74
  }
63
- const base = url.replace(/\/+$/, '');
75
+ const strip = (u) => String(u).replace(/\/+$/, '');
76
+ const base = strip(url);
64
77
  const WS = opts.WebSocket || globalThis.WebSocket;
65
78
  const pingMs = opts.pingMs || 20000;
79
+ const extra = Array.isArray(opts.aliases) ? opts.aliases.filter((a) => typeof a === 'string' && a).map(strip) : DXR_SIGNAL_ALIASES.includes(base) ? [...DXR_SIGNAL_ALIASES] : [];
80
+ const bases = [base, ...extra.filter((b) => b !== base)];
81
+ let baseIdx = 0;
66
82
 
67
83
  return {
68
84
  name: 'dxr',
69
85
  url: base,
70
86
  async join(room, hooks) {
71
87
  const key = await roomKey(room);
72
- const endpoint = `${base}/v1/connect?k=${key}${typeof opts.key === 'string' && opts.key ? `&key=${encodeURIComponent(opts.key)}` : ''}`;
88
+ const query = `k=${key}${typeof opts.key === 'string' && opts.key ? `&key=${encodeURIComponent(opts.key)}` : ''}`;
89
+ const endpointAt = (i) => `${bases[i]}/v1/connect?${query}`;
73
90
  let ws = null;
74
91
  let left = false;
75
92
  let attempt = 0;
@@ -77,11 +94,15 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
77
94
  let known = new Set();
78
95
  let queue = [];
79
96
 
80
- const open = (isReconnect) =>
97
+ // One open() tries every alias once (in order from the last one that worked) before it
98
+ // reports `signaling-unreachable`; a host that cannot be reached is skipped, not waited on.
99
+ const open = (isReconnect) => openAt(isReconnect, bases.length);
100
+ const openAt = (isReconnect, triesLeft) =>
81
101
  new Promise((resolve, reject) => {
82
102
  let settled = false;
83
103
  let joined = false;
84
- const sock = new WS(endpoint);
104
+ let opened = false;
105
+ const sock = new WS(endpointAt(baseIdx));
85
106
  ws = sock;
86
107
  const fail = (err) => {
87
108
  if (!settled) {
@@ -95,6 +116,7 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
95
116
  }
96
117
  };
97
118
  sock.onopen = () => {
119
+ opened = true;
98
120
  sock.send(JSON.stringify({ t: 'join', v: 1, room, id: hooks.id, max: hooks.maxPeers }));
99
121
  };
100
122
  sock.onmessage = (ev) => {
@@ -121,17 +143,42 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
121
143
  } else known = new Set(peers);
122
144
  settled = true;
123
145
  joined = true;
124
- resolve({ id: msg.id, peers, iceServers: Array.isArray(msg.iceServers) ? msg.iceServers : undefined, max: msg.max });
146
+ resolve({
147
+ id: msg.id,
148
+ peers,
149
+ iceServers: Array.isArray(msg.iceServers) ? msg.iceServers : undefined,
150
+ max: msg.max,
151
+ // Hosted-service fields (RFC 0003 §5): the tier this session was admitted as,
152
+ // its key id, and the TURN decision — `turn.reason === 'cap'` is the SDK's
153
+ // `turn-cap`. A server that predates them sends none; nothing here is required.
154
+ tier: msg.tier === 'key' ? 'key' : 'anon',
155
+ key: typeof msg.key === 'string' ? msg.key : undefined,
156
+ turn: msg.turn && typeof msg.turn === 'object' ? msg.turn : undefined,
157
+ });
125
158
  break;
126
159
  }
127
160
  case 'full':
128
161
  left = true;
129
162
  fail(codedError('room-full', `this call is full (${msg.max} participants)`));
130
163
  break;
164
+ case 'expired': {
165
+ // The room's lifetime is up (hosted limits): no reconnect — the call reports it.
166
+ left = true;
167
+ clearInterval(pingTimer);
168
+ const e = codedError('expired', 'this room has reached its lifetime on the signalling server');
169
+ if (!settled) fail(e);
170
+ else hooks.onDisconnect?.(e);
171
+ break;
172
+ }
131
173
  case 'error':
132
- if (!settled && ['bad-room', 'bad-id', 'bad-version'].includes(msg.code)) {
174
+ if (!settled && FATAL_JOIN_CODES.includes(msg.code)) {
133
175
  left = true;
134
176
  fail(codedError(msg.code, msg.message || msg.code));
177
+ } else if (!settled && (msg.code === 'rate-limited' || msg.code === 'quota')) {
178
+ // Refused by a limit, not by the inputs: a reconnect may retry after `retryMs`.
179
+ const e = codedError(msg.code, msg.message || msg.code);
180
+ /** @type {any} */ (e).retryMs = msg.retryMs > 0 ? msg.retryMs : 0;
181
+ fail(e);
135
182
  } else if (!settled && msg.code === 'id-taken') {
136
183
  // A reconnect raced the server noticing the old socket close: retry shortly.
137
184
  fail(codedError('id-taken', 'peer id still held; retrying'));
@@ -154,7 +201,16 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
154
201
  break;
155
202
  }
156
203
  };
157
- sock.onerror = () => fail(codedError('signaling-unreachable', `cannot reach ${base}`));
204
+ sock.onerror = () => {
205
+ if (!opened && !settled && triesLeft > 1) {
206
+ // This host is unreachable: fail over to the next alias right away.
207
+ settled = true;
208
+ baseIdx = (baseIdx + 1) % bases.length;
209
+ openAt(isReconnect, triesLeft - 1).then(resolve, reject);
210
+ return;
211
+ }
212
+ fail(codedError('signaling-unreachable', `cannot reach ${bases[baseIdx]}`));
213
+ };
158
214
  sock.onclose = () => {
159
215
  clearInterval(pingTimer);
160
216
  fail(codedError('signaling-closed', 'signalling closed before joining'));
@@ -172,11 +228,12 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
172
228
  const tryAgain = () => {
173
229
  if (left) return;
174
230
  open(true).catch((err) => {
175
- if (left || (err && err.code === 'room-full')) {
231
+ if (left || (err && (err.code === 'room-full' || FATAL_JOIN_CODES.includes(err.code)))) {
176
232
  hooks.onDisconnect?.(err);
177
233
  return;
178
234
  }
179
- setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 500, maxMs: 15000 }));
235
+ // A `rate-limited` / `quota` refusal says when to come back; never sooner.
236
+ setTimeout(tryAgain, Math.max(backoffMs(attempt++, { baseMs: 500, maxMs: 15000 }), (err && err.retryMs) || 0));
180
237
  });
181
238
  };
182
239
  setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 300, maxMs: 15000 }));
@@ -362,8 +362,9 @@ class Call {
362
362
  log: (tag, obj) => this.log(tag, obj),
363
363
  });
364
364
  this.transport = t;
365
+ let session = null;
365
366
  try {
366
- await t.start({
367
+ session = await t.start({
367
368
  room: this.room,
368
369
  localStream: this.local.stream,
369
370
  sendFormat: this.local.format,
@@ -410,9 +411,38 @@ class Call {
410
411
  globalThis.addEventListener?.('pagehide', this._pagehide);
411
412
  this.log('joined', { id: this.id, peers: this.tiles.size });
412
413
  this.emit('joined', { room: this.room, id: this.id });
414
+ // The TURN decision is reported on the next task, after join() / mountCall() have resolved:
415
+ // a page (or <dxr-call>) subscribes once it has the handle, and an event raised mid-join
416
+ // would reach nobody.
417
+ setTimeout(() => {
418
+ if (this.transport === t) this._reportTurn(session);
419
+ }, 0);
413
420
  return this.handle;
414
421
  }
415
422
 
423
+ /**
424
+ * The signalling server's TURN decision for this session (hosted service, RFC 0003 §5c), once
425
+ * per join. `turn-cap` (an `error`): the service's monthly relay budget is spent, nobody gets
426
+ * TURN — direct connections still work. `turn-shed` (a `warning`): only THIS session has no
427
+ * TURN (an anonymous session near the budget, or too many credential requests from this
428
+ * address). A server with no TURN at all (self-hosted) says nothing here: that is a setup
429
+ * choice, documented in docs/call.md.
430
+ */
431
+ _reportTurn(session) {
432
+ const turn = session && session.turn;
433
+ if (!turn || typeof turn !== 'object') return;
434
+ if (turn.status === 'off' && turn.reason === 'cap') {
435
+ this.error('turn-cap', 'relay (TURN) capacity for this month is used up; direct connections still work, but a participant behind a strict NAT may be unreachable', null);
436
+ } else if (turn.reason === 'budget' || turn.reason === 'rate') {
437
+ this.warning(
438
+ 'turn-shed',
439
+ turn.reason === 'rate'
440
+ ? 'no relay (TURN) for this session: too many credential requests from this address this hour'
441
+ : 'no relay (TURN) for this anonymous session: the hosted service is near its monthly relay budget (a publishable key keeps TURN)'
442
+ );
443
+ }
444
+ }
445
+
416
446
  _onPeer(id) {
417
447
  if (this.tiles.has(id)) return;
418
448
  const tile = new Tile(this, id);
@@ -464,6 +464,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
464
464
  nearClip: opts.nearClip,
465
465
  farClip: opts.farClip,
466
466
  sky: false,
467
+ viewerEase: opts.viewerEase,
467
468
  // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
468
469
  toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
469
470
  });
@@ -380,6 +380,7 @@ export function addModel(wall, canvas, src, opts = {}) {
380
380
  renderScale,
381
381
  feather,
382
382
  logTag: '[inline3d/model]',
383
+ viewerEase: opts.viewerEase,
383
384
  }).useEyeCamera(EyeCamera, EdgeFeather);
384
385
 
385
386
  if (envMap) viewer.scene.environment = envMap;
@@ -67,6 +67,7 @@ import {
67
67
  sequenceSpans,
68
68
  } from './inline3d-splat-effects.js';
69
69
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
70
+ import { viewerEaseFor, frameTrackingState } from './inline3d-viewer-ease.js';
70
71
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
71
72
  import { LayerRigCameras, validateLayerRig, validateLayerRigOptions, DEFAULT_VIEWER_DISTANCE_M } from './inline3d-splat-layer-rig.js';
72
73
  import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
@@ -817,8 +818,13 @@ export class PlayCanvasSplatViewer {
817
818
  sky = false,
818
819
  pageCamera = false,
819
820
  toneMapping = 'none',
821
+ viewerEase,
820
822
  } = opts;
821
823
  this.canvas = canvas;
824
+ // The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
825
+ // createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
826
+ this._viewerEaseOpt = viewerEase;
827
+ this.viewerEase = null;
822
828
  // controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
823
829
  // rig node carries the page's matrix instead of the inverse pivot.
824
830
  this.pageCamera = pageCamera === true;
@@ -1194,7 +1200,7 @@ export class PlayCanvasSplatViewer {
1194
1200
  }
1195
1201
 
1196
1202
  /** wall.addScene's frame callback. Validate BEFORE drawing; replay the last good frame else. */
1197
- onFrame(views, layer) {
1203
+ onFrame(views, layer, frame) {
1198
1204
  if (this._disposed) return;
1199
1205
  if (this._mode !== '3d') this.stopMono();
1200
1206
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
@@ -1224,6 +1230,9 @@ export class PlayCanvasSplatViewer {
1224
1230
  vps.push(vp);
1225
1231
  }
1226
1232
  this._cacheGood(views, vps);
1233
+ // Ease a tracking acquisition/loss on the copies (never the XRViews), before anything —
1234
+ // the rig map, the layer rig, the live outgoing photo — reads them.
1235
+ (this.viewerEase ||= viewerEaseFor(frame, this._viewerEaseOpt)).apply(this._lastGood.entries, frameTrackingState(frame));
1227
1236
  this._drawEntries(this._lastGood.entries, this._lastGood);
1228
1237
  }
1229
1238
 
@@ -2139,6 +2148,7 @@ export class PlayCanvasSplatViewer {
2139
2148
  if (this._disposed) return;
2140
2149
  const box = this.canvas.getBoundingClientRect();
2141
2150
  if (box.width < 1 || box.height < 1) return;
2151
+ this.viewerEase?.reset(); // a new window size moves every projection: not a viewer jump
2142
2152
  const dpr = Math.min((typeof window !== 'undefined' && window.devicePixelRatio) || 1, 2) * this.renderScale;
2143
2153
  // Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js). Measured on an
2144
2154
  // Adreno 740 tablet: MAX_TEXTURE_SIZE 4096, a 5120-wide request silently became a 4096-wide
@@ -3072,6 +3082,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3072
3082
  nearClip: opts.nearClip,
3073
3083
  farClip: opts.farClip,
3074
3084
  sky: opts.sky,
3085
+ viewerEase: opts.viewerEase,
3075
3086
  });
3076
3087
 
3077
3088
  let handle = null;
@@ -3394,9 +3405,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3394
3405
  let initialViewRig = null; // the rig addScene is created with (controls:'page'), else the shorthand
3395
3406
  if (wall && wall.supported) {
3396
3407
  const onFrame = diag
3397
- ? (views, layer) => {
3408
+ ? (views, layer, frame) => {
3398
3409
  const t = performance.now();
3399
- viewer.onFrame(views, layer);
3410
+ viewer.onFrame(views, layer, frame);
3400
3411
  diag.frame(views, t); // after the draw: the overlay state is what this frame showed
3401
3412
  }
3402
3413
  : viewer.onFrame;
@@ -246,6 +246,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
246
246
  renderScale,
247
247
  feather,
248
248
  logTag: '[inline3d/splat]',
249
+ viewerEase: opts.viewerEase,
249
250
  }).useEyeCamera(EyeCamera, EdgeFeather);
250
251
 
251
252
  // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and