@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/js/inline3d.js CHANGED
@@ -593,6 +593,39 @@ export async function createInline3D(opts = {}) {
593
593
  return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch, untrackedFallback });
594
594
  }
595
595
 
596
+ // The one shared creation in flight, so two callers racing before the first session resolves
597
+ // (a <dxr-call> and a player both mounting on load) share ONE createInline3D() rather than
598
+ // opening two sessions. Cleared once it settles: from then on `liveManager` is the answer.
599
+ let sharedPending = null;
600
+
601
+ /**
602
+ * The document's shared manager: the live `createInline3D()` result if one exists, else a new one
603
+ * (registered as the shared one by construction, since every manager is). Woven-canvas rule 1 —
604
+ * one inline-3D session per document — is what this exists for: a module that needs a wall it
605
+ * did not create (`mountCall`, `<dxr-call>`) must not open a second session next to the page's
606
+ * own, and a page that made its wall first and then adds such a module gets that same wall back.
607
+ *
608
+ * `opts` are `createInline3D`'s and apply only when this call creates the manager; a manager
609
+ * that already exists is returned as it was configured. An unsupported browser resolves to the
610
+ * same `{ supported: false }` shape as `createInline3D`, and it is NOT cached: nothing was
611
+ * created, and a later call re-probes (the weave service can bind after page load).
612
+ *
613
+ * Closing: whoever `close()`s the shared manager ends the document's session for everyone using
614
+ * it; the next `sharedInline3D()` opens a fresh one. Modules that borrow the wall must not close
615
+ * it (the call module never does; it only closes a wall it created — which it no longer does).
616
+ *
617
+ * @param {object} [opts] see {@link createInline3D}
618
+ * @returns {Promise<Inline3D | {supported:false, trackingState:'unknown', error?:Error}>}
619
+ */
620
+ export function sharedInline3D(opts = {}) {
621
+ if (liveManager && liveManager._running) return Promise.resolve(liveManager);
622
+ if (sharedPending) return sharedPending;
623
+ sharedPending = createInline3D(opts).finally(() => {
624
+ sharedPending = null;
625
+ });
626
+ return sharedPending;
627
+ }
628
+
596
629
  /**
597
630
  * Back-compatible single-scene helper: open a session, weave one canvas, drive a render
598
631
  * callback with the two eye views each frame. Equivalent to
@@ -641,6 +674,15 @@ function chromeTextPlates(root) {
641
674
  return plates;
642
675
  }
643
676
 
677
+ /** A pending rewoven() settles anyway ('hold-capped') after this many holds from the call. */
678
+ const REWOVEN_MAX_HOLDS = 4;
679
+
680
+ /** The CSS size + dpr of a window's canvas: what a pending rewoven() restarts on. */
681
+ function boxKeyOf(canvas) {
682
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
683
+ return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}`;
684
+ }
685
+
644
686
  class Inline3D {
645
687
  constructor(
646
688
  session,
@@ -1179,6 +1221,20 @@ class Inline3D {
1179
1221
  live = false;
1180
1222
  };
1181
1223
  },
1224
+ /**
1225
+ * {@link firstWoven}, measured from NOW: resolves once this canvas, as it is at the call,
1226
+ * has drawn a stereo frame and then held for `firstWovenHoldMs`. For a canvas that is
1227
+ * already woven but whose rect is about to change (fullscreen, a layout resize): the
1228
+ * browser re-registers the moved rect and the same identity gap as a fresh canvas
1229
+ * applies, so cover the canvas across the change and release on this. A change of the
1230
+ * canvas's CSS size or devicePixelRatio while it is pending restarts the hold (checked every
1231
+ * frame, for every window kind; a move without a resize is not detected), and it settles
1232
+ * anyway with `reason: 'hold-capped'` four holds after the call, so a size that never stops
1233
+ * animating cannot keep a cover up. Calling it again while pending returns the same
1234
+ * promise, restarted. Before the first join it IS `firstWoven`; on a window that will not
1235
+ * weave it resolves that `woven: false` result. Same shape, never rejects.
1236
+ */
1237
+ rewoven: () => this._rewoven(win),
1182
1238
  };
1183
1239
  }
1184
1240
 
@@ -1957,6 +2013,10 @@ class Inline3D {
1957
2013
  fwRegAt: nowMs(),
1958
2014
  fwLayerAt: null,
1959
2015
  fwStereo: false,
2016
+ // handle.rewoven(): null, or the pending { promise, resolve, calledAt, at, stereo, box } —
2017
+ // firstWoven's two halves again, counted from the call (and from each later box change).
2018
+ rw: null,
2019
+ rwGone: null, // the reason this window will never weave again, once one arrived
1960
2020
  };
1961
2021
  win.fwPromise = new Promise((resolve) => {
1962
2022
  win.fwResolve = resolve;
@@ -2051,6 +2111,7 @@ class Inline3D {
2051
2111
  win.fwLayerAt = nowMs();
2052
2112
  win.fwStereo = false;
2053
2113
  }
2114
+ this._restartRewoven(win);
2054
2115
  win.layerLostSent = false; // a live layer again: a future loss is worth reporting again
2055
2116
  // Nothing about the hardware state is re-asserted here, and that is the point: the panel's
2056
2117
  // mode is the DISPLAY's, it survives a tile scrolling away, and this SDK never requests it
@@ -2492,8 +2553,12 @@ class Inline3D {
2492
2553
  if (!win.resizePending) return;
2493
2554
  win.resizePending = false;
2494
2555
  if (!win.layer || !win.ownsBuffer) return;
2556
+ // The on-screen box and the buffer are separate questions since the source cap (#88): a
2557
+ // box that moves (fullscreen) re-joins the weave at a new rect even when a capped buffer
2558
+ // stays the same size, so a pending rewoven() restarts on the BOX, not on the buffer.
2559
+ this._noteRewovenBox(win);
2495
2560
  const { w, h } = this._eyeSize(win);
2496
- if (w === win.eyeW && h === win.eyeH) return; // observer fired, geometry didn't move
2561
+ if (w === win.eyeW && h === win.eyeH) return; // observer fired, the buffer didn't move
2497
2562
  this._sizeBuffer(win, /*sbs*/ true);
2498
2563
  this._paint(win, null); // repaint NOW: setting canvas.width cleared the buffer
2499
2564
  };
@@ -2769,6 +2834,7 @@ class Inline3D {
2769
2834
  if (views.length >= 2) {
2770
2835
  win.fwStereo = true;
2771
2836
  if (!win.warnedBufMismatch) this._checkSceneBuffer(win);
2837
+ if (win.rw) win.rw.stereo = true;
2772
2838
  }
2773
2839
  } catch (err) {
2774
2840
  if (!win.frameThrewWarned) {
@@ -2792,6 +2858,7 @@ class Inline3D {
2792
2858
  // video has never had a frame) the tile holds nothing worth revealing yet.
2793
2859
  if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : !!win.img)) {
2794
2860
  win.fwStereo = true;
2861
+ if (win.rw) win.rw.stereo = true;
2795
2862
  }
2796
2863
  }
2797
2864
  this._tickFirstWoven(win);
@@ -2809,6 +2876,20 @@ class Inline3D {
2809
2876
  * which knows nothing about whether the compositor has matched this canvas yet.
2810
2877
  */
2811
2878
  _tickFirstWoven(win) {
2879
+ if (win.rw) {
2880
+ // Every frame, for every window kind: the ResizeObserver only watches SDK-sized buffers, and
2881
+ // no observer sees a dpr-only change.
2882
+ this._noteRewovenBox(win);
2883
+ const rw = win.rw;
2884
+ const t = nowMs();
2885
+ if (rw.stereo && t - rw.at >= win.fwHoldMs) this._settleRewoven(win, true, 'hold-elapsed');
2886
+ // A box that never stops changing (a long CSS transition on width) would restart the hold
2887
+ // forever and leave a cover up for good: once it has restarted, give up waiting
2888
+ // REWOVEN_MAX_HOLDS holds after the call.
2889
+ else if (rw.at > rw.calledAt && t - rw.calledAt >= REWOVEN_MAX_HOLDS * win.fwHoldMs) {
2890
+ this._settleRewoven(win, true, 'hold-capped');
2891
+ }
2892
+ }
2812
2893
  if (win.fwResult || !win.fwStereo || win.fwLayerAt === null) return;
2813
2894
  if (nowMs() - win.fwLayerAt < win.fwHoldMs) return;
2814
2895
  this._settleFirstWoven(win, true, 'hold-elapsed');
@@ -2816,12 +2897,60 @@ class Inline3D {
2816
2897
 
2817
2898
  /** One-shot: the first call wins, later ones are ignored. */
2818
2899
  _settleFirstWoven(win, woven, reason) {
2900
+ // Every "will not weave" path lands here, so a pending rewoven() is released with it, and a
2901
+ // later one answers at once (firstWoven may have settled woven:true long before).
2902
+ if (!woven) {
2903
+ if (!win.rwGone) win.rwGone = reason;
2904
+ this._settleRewoven(win, false, reason);
2905
+ }
2819
2906
  if (win.fwResult) return;
2820
2907
  win.fwResult = Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - win.fwRegAt) });
2821
2908
  win.fwResolve(win.fwResult);
2822
2909
  win.fwResolve = null;
2823
2910
  }
2824
2911
 
2912
+ /** handle.rewoven(): see the handle's doc comment. */
2913
+ _rewoven(win) {
2914
+ if (!win.fwResult) return win.fwPromise; // still on the first join: the same question
2915
+ if (!win.fwResult.woven) return Promise.resolve(win.fwResult);
2916
+ if (win.rwGone) return Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: win.rwGone, ms: 0 }));
2917
+ if (win.rw) {
2918
+ this._restartRewoven(win);
2919
+ return win.rw.promise;
2920
+ }
2921
+ let resolve;
2922
+ const promise = new Promise((r) => {
2923
+ resolve = r;
2924
+ });
2925
+ const t = nowMs();
2926
+ win.rw = { promise, resolve, calledAt: t, at: t, stereo: false, box: boxKeyOf(win.canvas) };
2927
+ return promise;
2928
+ }
2929
+
2930
+ /** A pending rewoven() restarts when the canvas's CSS size or dpr differs from the last look. */
2931
+ _noteRewovenBox(win) {
2932
+ const rw = win.rw;
2933
+ if (!rw) return;
2934
+ const box = boxKeyOf(win.canvas);
2935
+ if (box === rw.box) return;
2936
+ rw.box = box;
2937
+ this._restartRewoven(win);
2938
+ }
2939
+
2940
+ /** The canvas moved again (a box change, a new layer): the hold starts over from here. */
2941
+ _restartRewoven(win) {
2942
+ if (!win.rw) return;
2943
+ win.rw.at = nowMs();
2944
+ win.rw.stereo = false;
2945
+ }
2946
+
2947
+ _settleRewoven(win, woven, reason) {
2948
+ const rw = win.rw;
2949
+ if (!rw) return;
2950
+ win.rw = null;
2951
+ rw.resolve(Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - rw.calledAt) }));
2952
+ }
2953
+
2825
2954
  // ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
2826
2955
  //
2827
2956
  // A weaved window's rect reaches the compositor from the session's own rAF: every frame the
@@ -0,0 +1,15 @@
1
+ // js/lift/index.js — PLACEHOLDER for the lift module (`@displayxr/inline3d/lift`), which is on
2
+ // the `feat/lift` branch and not yet on main. That branch replaces this file wholesale (resolve
3
+ // any add/add conflict by taking feat/lift's file).
4
+ //
5
+ // It exists so `./call/full` (js/inline3d-call-full.js) can import lift STATICALLY today — the
6
+ // one shape a bundler can follow — and so the `./call` entry's computed import of this path
7
+ // resolves to "a lift module with no `lift` export" (reason `no-lift-export`) rather than a
8
+ // network 404. Either way the call raises `warning { code: 'lift-not-bundled' }` when a 2D
9
+ // participant would have been lifted, and the lobby reads "2D→3D unavailable in this build".
10
+ //
11
+ // `lift` is exported as null (not omitted): webpack turns a namespace access to a MISSING export
12
+ // into a build error, and the point of this file is that `/call/full` builds everywhere today.
13
+
14
+ export const LIFT_PLACEHOLDER = true;
15
+ export const lift = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -40,12 +40,20 @@
40
40
  "types": "./call.d.ts",
41
41
  "import": "./js/inline3d-call.js"
42
42
  },
43
+ "./call/full": {
44
+ "types": "./call-full.d.ts",
45
+ "import": "./js/inline3d-call-full.js"
46
+ },
43
47
  "./undock": {
44
48
  "types": "./index.d.ts",
45
49
  "import": "./js/inline3d-undock.js"
46
50
  }
47
51
  },
48
- "sideEffects": false,
52
+ "sideEffects": [
53
+ "./js/inline3d-call.js",
54
+ "./js/inline3d-call-full.js",
55
+ "./dist/*.js"
56
+ ],
49
57
  "files": [
50
58
  "js/inline3d.js",
51
59
  "js/inline3d-undock.js",
@@ -72,7 +80,10 @@
72
80
  "js/inline3d-playcanvas-engine.js",
73
81
  "js/inline3d-player.js",
74
82
  "js/inline3d-call.js",
83
+ "js/inline3d-call-full.js",
75
84
  "js/call/",
85
+ "js/lift/",
86
+ "dist/",
76
87
  "index.d.ts",
77
88
  "three.d.ts",
78
89
  "viewer.d.ts",
@@ -80,6 +91,7 @@
80
91
  "model.d.ts",
81
92
  "player.d.ts",
82
93
  "call.d.ts",
94
+ "call-full.d.ts",
83
95
  "CHANGELOG.md",
84
96
  "README.md",
85
97
  "LICENSE",
@@ -133,10 +145,15 @@
133
145
  },
134
146
  "scripts": {
135
147
  "typecheck": "tsc --noEmit -p tsconfig.json",
136
- "test": "tsc --noEmit -p tsconfig.json && node --test \"test/*.test.mjs\""
148
+ "test": "tsc --noEmit -p tsconfig.json && node --test \"test/*.test.mjs\"",
149
+ "build:dist": "node tools/dist/build.mjs",
150
+ "prepack": "node tools/dist/build.mjs",
151
+ "test:e2e": "node test/e2e/call-element.e2e.mjs",
152
+ "test:bundlers": "node test/bundlers/run.mjs"
137
153
  },
138
154
  "devDependencies": {
139
155
  "typescript": "^5.4.0",
140
- "@types/webxr": "^0.5.0"
156
+ "@types/webxr": "^0.5.0",
157
+ "esbuild": "^0.25.0"
141
158
  }
142
159
  }
package/player.d.ts CHANGED
@@ -91,7 +91,7 @@ export interface PlayerOptions {
91
91
  * tile (e.g. `2.39` or `'2.39:1'` for a scope band in a 16:9 tile); the rest stays clear.
92
92
  * Implies `fit: 'contain'` unless `fit` says otherwise.
93
93
  */
94
- band?: number | string;
94
+ band?: number | string | null;
95
95
  /**
96
96
  * What the poster image is: `'mono'` (one image for both eyes, the default), or a stereo still
97
97
  * laid out like the video (`'sbs'` / `'tb'`), painted eye by eye so it is 3D before the first frame.
@@ -123,6 +123,12 @@ export interface PlayerOptions {
123
123
  * Fullscreen API.
124
124
  */
125
125
  fullscreen?: boolean;
126
+ /**
127
+ * Cover the canvas with black across a fullscreen change until `handle.rewoven()` settles
128
+ * (woven-canvas rule 11). Default false: on the Leia panel fullscreen showed no raw side-by-side
129
+ * pair without it, and the cover costs ~1.2 s with no picture.
130
+ */
131
+ fullscreenCover?: boolean;
126
132
  /** Default: `'anonymous'` iff `src` is a cross-origin URL; unset (browser default) otherwise. */
127
133
  crossOrigin?: 'anonymous' | 'use-credentials';
128
134
  /** Per-eye buffer resolution in px. `'sbs'` + supported wall only — see {@link TileOptions}. */
@@ -135,7 +141,7 @@ export interface PlayerOptions {
135
141
  observe?: Element;
136
142
  }
137
143
 
138
- export type PlayerEvent = 'play' | 'pause' | 'ended' | 'timeupdate' | 'ready' | 'error' | 'titlechange';
144
+ export type PlayerEvent = 'play' | 'pause' | 'ended' | 'timeupdate' | 'ready' | 'error' | 'titlechange' | 'detached';
139
145
 
140
146
  /** One playlist entry (RFC 0001 Addendum A4). */
141
147
  export interface PlayerTitle {
@@ -224,6 +230,44 @@ export function addPlayer(
224
230
  opts?: PlayerOptions,
225
231
  ): PlayerHandle;
226
232
 
233
+ /** {@link attachPlayer}'s options: the player's, plus where its controls go. */
234
+ export interface AttachPlayerOptions extends PlayerOptions {
235
+ /** Where the SDK controls go. Default: the handle's canvas's parent element. */
236
+ chromeContainer?: Element;
237
+ }
238
+
239
+ /** `'detached'`'s payload: something else took (`'superseded'`) or emptied (`'released'`) the slot. */
240
+ export interface PlayerDetachedEvent {
241
+ reason: 'superseded' | 'released';
242
+ }
243
+
244
+ /** What {@link attachPlayer} returns: a {@link PlayerHandle} that can give its slot back. */
245
+ export interface AttachedPlayerHandle extends PlayerHandle {
246
+ /**
247
+ * Give the slot back: `setVideo(null)`, so the splat, pose, lens and declared rig are exactly as
248
+ * they were, then tear down the controls and release the elements. Resolves once restored. Leave
249
+ * a Watch screen with `await player.detach()` before the handle's own `setSource`.
250
+ */
251
+ detach(): Promise<void>;
252
+ }
253
+
254
+ /**
255
+ * PREVIEW. Surface mode (RFC 0001 Addendum A): the same transport, playlist and events as
256
+ * {@link addPlayer}, drawn through an EXISTING `addSplat(…, { engine: 'playcanvas' })` handle's
257
+ * `setVideo` — no canvas, layer or session of its own. The scene stays on screen until the first
258
+ * frame (it is the poster); a source swap lands at the next title's first frame, as a cut or, with
259
+ * `transition: 'crossfade'`, `./splat` setVideo's GPU crossfade. Differences from `addPlayer`:
260
+ * `fullscreen` defaults to false; `band` goes to setVideo's letterbox slot; `poster`, `posterFormat` and the tile options are ignored
261
+ * (warns once). If another `setVideo` takes the slot, the player stops and emits `'detached'`
262
+ * ({@link PlayerDetachedEvent}); it never fights for it.
263
+ */
264
+ export function attachPlayer(
265
+ splat: { setVideo: (...args: any[]) => Promise<unknown>; canvas?: HTMLCanvasElement; videoElement?: HTMLVideoElement | null },
266
+ /** `null` with `opts.titles` loads the first title. */
267
+ src: PlayerSource | null,
268
+ opts?: AttachPlayerOptions,
269
+ ): AttachedPlayerHandle;
270
+
227
271
  /** The `setSource()` transitions a video can mean. */
228
272
  export type PlayerTransition = 'cut' | 'crossfade';
229
273
 
package/splat.d.ts CHANGED
@@ -271,6 +271,25 @@ export interface SplatVideoOptions {
271
271
  muted?: boolean;
272
272
  /** Default true for a URL (the SDK's own element), false for an element you pass. */
273
273
  autoplay?: boolean;
274
+ /**
275
+ * `'cut'` (default) or `'crossfade'`: a GPU dissolve from the video already on the plane (its
276
+ * last frame, frozen) to this one. From the splat, and on `setVideo(null)`, it is always a cut.
277
+ * The same vocabulary as `./player`'s `setSource`; `./splat`'s gaussian transitions throw here.
278
+ */
279
+ transition?: 'cut' | 'crossfade';
280
+ /** Crossfade length. Default 600 (the player's). 0 = a cut. */
281
+ durationMs?: number;
282
+ /** A named easing ({@link SplatEasing}) or `(x) => y` on [0, 1]. Default `'easeInOutSine'`. */
283
+ easing?: SplatEasing | ((x: number) => number);
284
+ /** Only `'frozen'`: the outgoing video dissolves from its last frame. */
285
+ outgoing?: 'frozen';
286
+ /**
287
+ * A letterbox slot: the picture is fitted into a centred band of this aspect inside the tile
288
+ * (`2.39` or `'2.39:1'` for a scope band in a 16:9 tile), the rest of the element left clear —
289
+ * `./player`'s `band`, on the plane. `fit: 'cover'` fills the band and crops the eye image about
290
+ * its centre.
291
+ */
292
+ band?: number | string | null;
274
293
  }
275
294
 
276
295
  /** What `handle.setVideo(src)` resolves to. */
@@ -279,6 +298,18 @@ export interface SplatVideo {
279
298
  readonly video: HTMLVideoElement;
280
299
  readonly format: 'sbs' | 'tb' | 'mono';
281
300
  readonly fit: 'contain' | 'cover';
301
+ /** The band's aspect, or null. */
302
+ readonly band: number | null;
303
+ /**
304
+ * Where the picture is on screen, in CSS px from the canvas's top-left, clipped to the canvas;
305
+ * the letterbox bars are the canvas minus this. Null before the first drawn frame and once
306
+ * another `setVideo` replaced this one. Place controls against the picture with it.
307
+ */
308
+ readonly rect: { readonly x: number; readonly y: number; readonly width: number; readonly height: number } | null;
309
+ /** `cb(rect)` whenever the rect changes (a resize, a new size, a band): a microtask right after the draw, same frame, before paint. Returns an unsubscribe. */
310
+ onRectChange(cb: (rect: { x: number; y: number; width: number; height: number }) => void): () => void;
311
+ /** What the swap did: `'crossfade'` only when it replaced a video on screen and asked to. Resolved at the swap, before the fade ends. */
312
+ readonly transition: 'cut' | 'crossfade';
282
313
  /** `handle.setVideo(null)`, or a no-op if another setVideo has replaced this one. */
283
314
  remove(): Promise<null>;
284
315
  /** Frames drawn with the plane up, and texture uploads (new video frames). */
@@ -1024,6 +1055,13 @@ export interface SplatHandle {
1024
1055
  readonly renderScale: number;
1025
1056
  /** The per-eye buffer scale as requested (the option, or the last `setRenderScale`). Both engines. */
1026
1057
  readonly renderScaleRequested: number;
1058
+ /**
1059
+ * `engine: 'playcanvas'` only. The `<video>` `setVideo`'s plane is showing now, or null (none, or
1060
+ * one still waiting for its first frame). Read-only; no event reports it changing.
1061
+ */
1062
+ readonly videoElement?: HTMLVideoElement | null;
1063
+ /** `engine: 'playcanvas'` only. The tile's canvas (page chrome sits beside it). */
1064
+ readonly canvas?: HTMLCanvasElement;
1027
1065
  /**
1028
1066
  * `engine: 'playcanvas'` only. The 2D tier's EYE offset — a head-parallax analogue for a flat
1029
1067
  * screen with no tracked eyes (e.g. a phone's tilt). `{x, y}` is normalised in the mono camera's