@displayxr/inline3d 1.23.0 → 1.25.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,145 @@ 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.25.0 — 2026-09-25
9
+
10
+ Touches the **preview tier** (a new subpath, `./player`) and the **core** (`.`, frozen tier): new
11
+ tracking-state API and an opt-in `untrackedFallback`. No pixel change for a page that does not opt
12
+ in, and none on a browser without the tracking-state surface.
13
+
14
+ ### Fixed — `./splat` (`engine: 'playcanvas'`), `setLayerRig`
15
+
16
+ Reported on the panel: layers set to the display rig still looked flat, with no way to tell why. 1.23
17
+ could fail silently; now it cannot:
18
+
19
+ - **Works on both view paths.** The N-camera fallback (one camera per view) used to leave the layer
20
+ on the photo rig with a single warning at the call. It now gets one display camera per view, the
21
+ shear carried in its projection, and measures identical to the RenderView path.
22
+ - **`layerRigState()` says what happened**: `path` ('renderviews' | 'ncamera' | 'mono'), `engaged`
23
+ (the display-rig views were applied on the last frame), `reason` when not (e.g. a layer drawn by
24
+ another camera, a layer not in the composition), `viewerDistance`, `gain`, `planeM`,
25
+ `photoConvergenceM`, `planeOffset`, `located`. The same line is WARNed on the first 3D frame and on
26
+ every change.
27
+ - A display layer that no camera draws is now drawn by the display camera. A layer another camera
28
+ draws is named, not taken.
29
+ - **`planeOffset` / `planeDistance`** choose which stage plane lands on the glass (a stage whose
30
+ "z = 0" is not the photo's convergence plane read as recessed). The change is exact, and the 2D
31
+ picture does not move. **`handle.setLayerRigOptions(opts)`** changes these options live, and
32
+ options now merge across calls (`null` clears a key).
33
+
34
+ ### Added — core
35
+
36
+ - **Tracking state: `wall.trackingState` and `on('trackingstatechange', (state, ev) => …)`**, on
37
+ every tile handle too (`'tracking' | 'searching' | 'unknown'`), mirroring the browser's
38
+ `session.trackingState` (DisplayXR Browser patch 0195; browser-pvt#161). Read off the session at
39
+ construction and on every frame, emitted on change only, released to `'unknown'` when the
40
+ session ends. The unsupported shape carries `trackingState: 'unknown'`. A browser without the
41
+ surface (1.0.5 and earlier) reports `'unknown'`, silently. Pages use it for "step back into
42
+ view" hints. (API and tests from #35.)
43
+ - **`createInline3D({ untrackedFallback: 'none' | 'mono' })`**, default `'none'`. `'mono'` eases
44
+ every image/video window to its left eye in both halves of its SBS buffer on `'searching'`, and
45
+ back on `'tracking'`, over the mode switch's duration; `'unknown'` leaves it where it is. No
46
+ buffer reallocation and no layer close, so there is no squeezed-pair flash. Scene windows are
47
+ never touched. It is for **MANUAL** eye-tracking displays, where the app handles tracking loss;
48
+ on **MANAGED** displays (the default, and Leia's) the vendor already goes 2D before it reports
49
+ `'searching'`, so leave it off. Docs: authoring guide § Knowing when nobody is tracked. RFC
50
+ 0001 and the player header say where `untrackedFallback` lives again.
51
+
52
+ ### Added — player
53
+
54
+ - **`addPlayer(wall, canvas, src, opts)` — a media player module (preview tier).** Built on
55
+ `addVideo`: creates and owns a hidden `<video>`, and for `format:'sbs'` (default) hands it
56
+ straight to `wall.addVideo()`, so the stereo paint, the SBS buffer contract and the
57
+ display-mode fallback are inherited rather than re-implemented. Adds real transport
58
+ (`play/pause/seek/currentTime/duration/volume/muted/setSource/remove`), an `on/off` event pair
59
+ (`play|pause|ended|timeupdate|ready|error`), an SDK-drawn partial-region transport bar
60
+ (`data-inline3d-overlay`, auto-hiding, keyboard-accessible), and a keyboard map
61
+ (Space/K/←/→/J/L/M) scoped to the canvas/transport. `format:'mono'` paints genuinely flat
62
+ content full-frame on a small paint loop of its own — see `js/inline3d-player.js`'s module doc
63
+ comment for why this is a different code path from the unsupported-`wall` fallback, and why
64
+ neither one is the RFC's `untrackedFallback`/`trackingState` mechanism, which does not exist in
65
+ this codebase. v1 is narrower than `docs/rfcs/0001-media-player.md`: no `'tb'`/`'auto'` format
66
+ detection, no `opts.group` one-active-player policy, no `opts.fit`/letterboxing. Sample:
67
+ `samples/player/`.
68
+ - **`setSource()` transitions in `./splat`'s vocabulary.** `transition: 'cut' | 'crossfade'`,
69
+ `durationMs` (600), `easing` (the same named curves, `'easeInOutSine'`), `outgoing: 'frozen'`,
70
+ and `fadeMs` as the legacy alias. Per-call options override the player's own field by field.
71
+ The crossfade dissolves from the outgoing title's last frame through an opt-in mixer canvas (a
72
+ player built without one keeps the plain `addVideo` path). `./splat`'s other transitions,
73
+ unknown easings and `outgoing: 'live'` throw by name.
74
+ - **Transport: title line, ±10 s skip, fullscreen.** `title` (a now-playing line in a bounded top
75
+ band, its own overlay, fading with the bar; `setSource(src, { title })` changes it),
76
+ `skipButtons` (default on, plain rewind / fast-forward glyphs, hidden under 420 px) and `fullscreen` (default on, plus `F`): the
77
+ canvas's container goes fullscreen so the transport comes along, and the canvas is sized to fit
78
+ at its own aspect — never `object-fit`, which would letterbox inside the woven rect. The sample
79
+ gains chapter chips (real `setSource()` crossfades) and an honest caption for the mono tile.
80
+ - **`skin: 'dock'`** — a floating rounded dock, lit round buttons, an accent orb for play, a glowing
81
+ scrub fill and knob, a title pill. `'classic'` stays the default. Every glow is inside its
82
+ overlay's own box: a shadow outside the excluded rect would be woven with the canvas. The sample
83
+ uses `'dock'` (`?skin=classic` for the band) and stacks its tiles at 90% of the window.
84
+ - **Developer customisation: `size: 's' | 'm' | 'l'` and `handle.setAppearance({ accent, size, skin })`**
85
+ (live re-skin). Size is CSS `zoom` on each overlay's content, so icons, fonts and hit targets
86
+ scale together and the centre badge stays centred (measured at all three). Round controls draw
87
+ their own feathered edge (a 1.6 px mask) instead of relying on the border-radius clip, which can
88
+ stair-step where an overlay is re-composited on its own. The sample gains a developer bar
89
+ (accent picker, S/M/L, skin) showing the exact `setAppearance` call.
90
+ - **Docs: the overlay no-glow rule and three media traps** (codec: VP9/AV1 + Opus WebM only, no
91
+ H.264/AAC; non-square-pixel sources play squashed, rewrap with square pixels; black picture with
92
+ working sound = an opaque page background over the woven canvas), and a note that `addPlayer`
93
+ shares `./splat` `setVideo`'s option names (`format`, later `fit`).
94
+ - **`group`: one decoding player at a time.** Players sharing a group take turns: a `play` pauses
95
+ every other member, so a shelf of muted, looping previews decodes one video at a time. Leaving
96
+ on `remove()`.
97
+ - **Controls inside the black bars, and skins `'bars'` / `'call'`** (the Show Spatial team's
98
+ layouts). With a `band`, when the bottom bar is at least 44 CSS px (times `size`), every skin
99
+ puts its controls in it and the title in the top bar; the centre button and key pip stand down,
100
+ so nothing sits over the picture; a thinner bar falls back to the overlay. Re-measured on
101
+ resize. `'bars'`: back / play / elapsed / scrub / remaining in one row (a solid bottom strip
102
+ without a band). `'call'`: name + timer bar on top, controls at the bottom, band 2.39 by default.
103
+ - **Playlist API** (RFC 0001 Addendum A4, for the Show Spatial team's voice commands): `titles`
104
+ (`[{ id, src, title?, poster? }]`, bare sources accepted), `loopList`, `autoAdvance`; on the
105
+ handle `titles`, `current`, `setTitles()`, `play(id)`, `toggle()`, `next()`, `back()` (restart
106
+ past 3 s, else the previous title), and a `'titlechange'` event. Switches go through
107
+ `setSource`, so transitions, the title line and posters follow. `normalizeTitles`,
108
+ `nextIndex` and `backTarget` are pure and tested.
109
+ - **For the Show Spatial team** (quick wins from their list): `format: 'tb'` (top/bottom, repacked
110
+ into the woven pair; `setVideo`'s vocabulary); `band` — a letterbox slot of a target aspect
111
+ (`2.39`, `'2.39:1'`) the picture is fitted into; `posterFormat: 'sbs' | 'tb'` — stereo poster
112
+ stills painted eye by eye; `on('ended')` also fires once if the clip had already ended when the
113
+ listener was attached; `src` / `setSource` take candidates best first and `pickSource()` plays
114
+ the first the browser supports by full `canPlayType` string. Docs: woven-canvas rule 9 (nothing
115
+ with a background above the woven canvas) and a Hosting video section (codec, range requests,
116
+ cache headers, CORS, square pixels).
117
+ - **`fit: 'contain' | 'cover'`** — `./splat` `setVideo`'s names and meaning: contain letterboxes each
118
+ eye (transparent bars), cover fills the tile and crops. Unset keeps the stretch-to-tile pixels.
119
+ On the woven path a small per-eye fit canvas sits after the crossfade mixer (one extra draw per
120
+ frame, only when set); the flat paths fit directly. `fitRect()` is exported and tested.
121
+ - **Named accents** — `accent: 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' | 'lime' | 'mint' |
122
+ 'ice'` (or any CSS colour), exported as `PLAYER_ACCENTS`. Each is light enough for the dock's dark
123
+ play glyph. The sample shows them as swatches.
124
+
125
+ ## 1.24.0 — 2026-09-25
126
+
127
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change. Pixels change
128
+ during a live transition between two camera-rig photos (they were wrong), and for the frames in
129
+ which a newly declared rig has not yet reached the runtime's views.
130
+
131
+ - **No camera jump at the start of a live transition.** Crossfade, wavefront and the particle
132
+ transitions keep the outgoing photo alive next to the incoming one. Between two photos with
133
+ camera rigs (SOG camera blocks: each its own convergence and vertical FOV), the outgoing photo
134
+ was drawn from eyes scaled by the two rigs' window ratio from the swap on, a jump in its
135
+ disparity and head parallax. For the frame(s) before the declaration reached the views, both
136
+ photos were drawn through the wrong rig. Each photo is now drawn through its own rig for the whole
137
+ window. The SDK reads off the views which declared rig they were located for, and remaps them
138
+ exactly (window onto window, eye onto eye; the runtime's projection verbatim, no Kooima) where
139
+ they are not the photo's own. `reassemble` was never affected. Headless: 102–208 px of
140
+ reference-point error → < 0.005 px, and the live outgoing image equals the frame before the swap
141
+ (MAE 0.000). Docs: `docs/playcanvas-adapter.md` § Each photo through its own rig.
142
+ - **Kill switch `?dxrdiag=oldrig`** (the 1.23 behaviour). The diag's per-frame record gains
143
+ `rigAt` / `rigIn` / `rigOut`: the rig the views were located for, the rig the current photo was
144
+ drawn through (`+` = remapped), and how the live outgoing photo was drawn.
145
+ - `tools/rig-swap-capture`: the headless harness behind the numbers.
146
+
8
147
  ## 1.23.0 — 2026-09-25
9
148
 
10
149
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New API; no pixel change for a
package/README.md CHANGED
@@ -27,6 +27,8 @@ import { addSplat } from '@displayxr/inline3d/splat'; // experimen
27
27
  import { addModel } from '@displayxr/inline3d/model'; // experimental: glTF/GLB in a tile
28
28
  // (Draco / meshopt / KTX2 too —
29
29
  // you serve the decoder files)
30
+ import { addPlayer } from '@displayxr/inline3d/player'; // preview: a media player, built
31
+ // on addVideo, with transport
30
32
  import { SceneViewer } from '@displayxr/inline3d/viewer'; // experimental: framing + orbit
31
33
  ```
32
34
 
@@ -147,6 +149,15 @@ No projection math lands in your page or in the SDK — the off-axis frustum sta
147
149
  > OS weave service has bound (typically at page load), a false-negative that silently drops you to 2D.
148
150
  > `createInline3D()` detects by actually acquiring a session, which is authoritative.
149
151
 
152
+ > **Nobody in front of the display?** `wall.trackingState` is `'tracking'`, `'searching'` or
153
+ > `'unknown'`, and `wall.on('trackingstatechange', state => …)` tells you when it moves (tile
154
+ > handles carry both too). `'searching'` means nobody is being tracked: the viewer left the
155
+ > display's 3D zone, or the panel is in a 2D mode. On a MANAGED display (the default, and Leia's)
156
+ > the vendor has already gone 2D by then, so use it for hints only. On a MANUAL display,
157
+ > `createInline3D({ untrackedFallback: 'mono' })` eases `addImage` / `addVideo` windows flat for you.
158
+ > `'unknown'` is "no opinion" (an older browser, or a closed session): render normally. See
159
+ > [knowing when nobody is tracked](docs/authoring-inline-3d.md#knowing-when-nobody-is-tracked).
160
+
150
161
  Full API + authoring guidance: [`docs/authoring-inline-3d.md`](docs/authoring-inline-3d.md).
151
162
  Before you ship a page that navigates or remounts, read
152
163
  [`docs/woven-canvas-rules.md`](docs/woven-canvas-rules.md): how to avoid a raw side-by-side
@@ -164,6 +175,8 @@ samples/
164
175
  each woven with one SDK call, all on one session
165
176
  splat/ a 3D Gaussian splat in a tile (PlayCanvas; ?engine=spark), with a 2D plate over it
166
177
  model/ a glTF mesh, a mesh+splat scene, and a Draco-COMPRESSED glTF (PlayCanvas; ?engine=three)
178
+ player/ a media player — SDK transport (play/pause/seek/mute), one call, built on
179
+ addVideo; default (SBS) and format:'mono' side by side
167
180
  composition/ the 14-case 2D/3D overlap matrix — demo AND standing hardware regression
168
181
  surface; red cases ship red (see samples/README.md)
169
182
  vendor/draco/ three's Draco decoder, served for samples/model (compressed glTF needs it)
@@ -185,6 +198,8 @@ js/
185
198
  inline3d-splat-effects.js splat shader effects: the runner, the effect registry (PlayCanvas)
186
199
  inline3d-splat-live.js setSource's live outgoing photo: its own camera, layer and target (PlayCanvas)
187
200
  from what the asset declares (you serve the decoder files — see the guide)
201
+ inline3d-player.js preview: addPlayer() — a media player window built on addVideo (stereo
202
+ paint, display modes and the fallback are inherited) + an SDK transport bar
188
203
  docs/
189
204
  authoring-inline-3d.md the authoring guide
190
205
  authoring-motion-and-effects.md
package/index.d.ts CHANGED
@@ -157,6 +157,24 @@ export interface HardwareDisplayStateChange {
157
157
 
158
158
  export type DisplayModeChange = RenderingModeChange | HardwareDisplayStateChange;
159
159
 
160
+ /**
161
+ * Is anyone being tracked in front of the display? From the DisplayXR runtime (which may hand the
162
+ * decision to the vendor plug-in), via the browser's `session.trackingState` (patch 0195).
163
+ *
164
+ * - `'tracking'` — a viewer is in the display's 3D zone.
165
+ * - `'searching'` — the runtime's derived isTracking is FALSE: nobody is in the zone, **or** the
166
+ * display is in an untracked / 2D mode. It does **not** necessarily mean the tracker lost lock
167
+ * on a viewer who is still there, so do not word a page's UI as if it did.
168
+ * - `'unknown'` — no opinion: this browser has no tracking-state surface, or the session ended.
169
+ */
170
+ export type XRTrackingState = 'unknown' | 'tracking' | 'searching';
171
+
172
+ /** The tracking state changed. The callback is handed the STATE first, this object second. */
173
+ export interface TrackingStateChange {
174
+ type: 'trackingstatechange';
175
+ state: XRTrackingState;
176
+ }
177
+
160
178
  /** What a page may lift into the floating native viewer. `null` = no `XRDisplayLayer.undock`. */
161
179
  export interface UndockCapabilities {
162
180
  model: boolean;
@@ -314,9 +332,17 @@ export interface TileHandle {
314
332
  /** Listen for one display event, re-emitted on this handle. Returns an unsubscribe function. */
315
333
  on(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): () => void;
316
334
  on(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): () => void;
335
+ /**
336
+ * Nobody-is-tracked changes, with the STATE as the first argument. The same subscription as the
337
+ * manager's; silent forever on a browser without the surface. See {@link Inline3D.trackingState}.
338
+ */
339
+ on(type: 'trackingstatechange', cb: (state: XRTrackingState, e: TrackingStateChange) => void): () => void;
317
340
  /** Drop a listener registered with {@link TileHandle.on}. */
318
341
  off(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): void;
319
342
  off(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): void;
343
+ off(type: 'trackingstatechange', cb: (state: XRTrackingState, e: TrackingStateChange) => void): void;
344
+ /** The manager's {@link Inline3D.trackingState}, read live. */
345
+ readonly trackingState: XRTrackingState;
320
346
  /**
321
347
  * Both display events through one callback — the older shape, still supported. Returns an
322
348
  * unsubscribe function; inert (a no-op unsubscribe) on a browser without the API.
@@ -367,10 +393,19 @@ export interface Inline3D {
367
393
  setStereoEnabled(enabled: boolean): Promise<boolean>;
368
394
  on(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): () => void;
369
395
  on(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): () => void;
396
+ on(type: 'trackingstatechange', cb: (state: XRTrackingState, e: TrackingStateChange) => void): () => void;
370
397
  off(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): void;
371
398
  off(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): void;
399
+ off(type: 'trackingstatechange', cb: (state: XRTrackingState, e: TrackingStateChange) => void): void;
372
400
  /** As last REPORTED by `hardwaredisplaystatechange` — never what was last requested. */
373
401
  readonly hardwareDisplayState: XRHardwareDisplayMode | null;
402
+ /**
403
+ * Is anyone being tracked in front of the display right now? Mirrors `session.trackingState` as
404
+ * last read — `'unknown'` where the browser has no such surface (silently), and again once the
405
+ * session ends. Pages use it for "step back into view" hints; see
406
+ * {@link CreateInline3DOptions.untrackedFallback} for having the SDK flatten image/video tiles.
407
+ */
408
+ readonly trackingState: XRTrackingState;
374
409
  /** The active mode as last read/reported. `viewCount: 0` means "not read yet". */
375
410
  readonly activeMode: { modeIndex: number; viewCount: number };
376
411
  /** True while the SDK is holding every window's rig flat because a 1-view mode is active. */
@@ -441,6 +476,8 @@ export interface Inline3D {
441
476
  /** The shape {@link createInline3D} resolves to when inline-3D is unavailable. */
442
477
  export interface Inline3DUnsupported {
443
478
  supported: false;
479
+ /** Always `'unknown'`: no session, nobody to track. Present so a page need not branch first. */
480
+ trackingState: 'unknown';
444
481
  error?: unknown;
445
482
  }
446
483
 
@@ -470,6 +507,26 @@ export interface CreateInline3DOptions {
470
507
  autoChrome?: boolean;
471
508
  /** The eased 2D<->3D transition. On by default; see {@link ModeSwitchOptions}. */
472
509
  modeSwitch?: ModeSwitchOptions;
510
+ /**
511
+ * What the SDK does to the image and video windows it owns the pixels of while **nobody is
512
+ * tracked** (`trackingState === 'searching'`). Default `'none'`.
513
+ *
514
+ * Whose job this is depends on the display's eye-tracking mode:
515
+ * - **MANAGED** (the default, and Leia's): the vendor owns tracking loss. It eases the eyes
516
+ * together and reports `'searching'` only once the display is already 2D. Leave this at
517
+ * `'none'`: a flatten here would be a second transition.
518
+ * - **MANUAL**: the vendor does nothing and the app handles tracking loss. Use `'mono'`.
519
+ *
520
+ * - `'none'` — nothing changes.
521
+ * - `'mono'` — each `addImage` / `addVideo` window eases to its LEFT eye in both halves of its
522
+ * side-by-side buffer on `'searching'`, and back on `'tracking'`, over the mode switch's
523
+ * duration. The buffer is never reallocated and the layer never closed; `'unknown'` leaves it
524
+ * where it is.
525
+ *
526
+ * **Scene windows are never touched** — the page owns those pixels. Listen for
527
+ * `trackingstatechange` and do the same for a scene.
528
+ */
529
+ untrackedFallback?: 'none' | 'mono';
473
530
  }
474
531
 
475
532
  /**