@displayxr/inline3d 1.24.0 → 1.25.1

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,134 @@ 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.1 — 2026-09-25
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
11
+ for a page that does not call it.
12
+
13
+ - **`handle.setRenderScale(s)`** changes the per-eye buffer scale (the `renderScale` option) live,
14
+ in (0, 4], and resizes the backing store on the next animation frame; **`handle.renderScale`**
15
+ reads it back. For an app whose screens differ: full resolution where the splat is hidden and
16
+ only stage quads draw, a lower scale where a large splat is on screen. Replaces poking the
17
+ viewer's internal field.
18
+
19
+ ## 1.25.0 — 2026-09-25
20
+
21
+ Touches the **preview tier** (a new subpath, `./player`) and the **core** (`.`, frozen tier): new
22
+ tracking-state API and an opt-in `untrackedFallback`. No pixel change for a page that does not opt
23
+ in, and none on a browser without the tracking-state surface.
24
+
25
+ ### Fixed — `./splat` (`engine: 'playcanvas'`), `setLayerRig`
26
+
27
+ Reported on the panel: layers set to the display rig still looked flat, with no way to tell why. 1.23
28
+ could fail silently; now it cannot:
29
+
30
+ - **Works on both view paths.** The N-camera fallback (one camera per view) used to leave the layer
31
+ on the photo rig with a single warning at the call. It now gets one display camera per view, the
32
+ shear carried in its projection, and measures identical to the RenderView path.
33
+ - **`layerRigState()` says what happened**: `path` ('renderviews' | 'ncamera' | 'mono'), `engaged`
34
+ (the display-rig views were applied on the last frame), `reason` when not (e.g. a layer drawn by
35
+ another camera, a layer not in the composition), `viewerDistance`, `gain`, `planeM`,
36
+ `photoConvergenceM`, `planeOffset`, `located`. The same line is WARNed on the first 3D frame and on
37
+ every change.
38
+ - A display layer that no camera draws is now drawn by the display camera. A layer another camera
39
+ draws is named, not taken.
40
+ - **`planeOffset` / `planeDistance`** choose which stage plane lands on the glass (a stage whose
41
+ "z = 0" is not the photo's convergence plane read as recessed). The change is exact, and the 2D
42
+ picture does not move. **`handle.setLayerRigOptions(opts)`** changes these options live, and
43
+ options now merge across calls (`null` clears a key).
44
+
45
+ ### Added — core
46
+
47
+ - **Tracking state: `wall.trackingState` and `on('trackingstatechange', (state, ev) => …)`**, on
48
+ every tile handle too (`'tracking' | 'searching' | 'unknown'`), mirroring the browser's
49
+ `session.trackingState` (DisplayXR Browser patch 0195; browser-pvt#161). Read off the session at
50
+ construction and on every frame, emitted on change only, released to `'unknown'` when the
51
+ session ends. The unsupported shape carries `trackingState: 'unknown'`. A browser without the
52
+ surface (1.0.5 and earlier) reports `'unknown'`, silently. Pages use it for "step back into
53
+ view" hints. (API and tests from #35.)
54
+ - **`createInline3D({ untrackedFallback: 'none' | 'mono' })`**, default `'none'`. `'mono'` eases
55
+ every image/video window to its left eye in both halves of its SBS buffer on `'searching'`, and
56
+ back on `'tracking'`, over the mode switch's duration; `'unknown'` leaves it where it is. No
57
+ buffer reallocation and no layer close, so there is no squeezed-pair flash. Scene windows are
58
+ never touched. It is for **MANUAL** eye-tracking displays, where the app handles tracking loss;
59
+ on **MANAGED** displays (the default, and Leia's) the vendor already goes 2D before it reports
60
+ `'searching'`, so leave it off. Docs: authoring guide § Knowing when nobody is tracked. RFC
61
+ 0001 and the player header say where `untrackedFallback` lives again.
62
+
63
+ ### Added — player
64
+
65
+ - **`addPlayer(wall, canvas, src, opts)` — a media player module (preview tier).** Built on
66
+ `addVideo`: creates and owns a hidden `<video>`, and for `format:'sbs'` (default) hands it
67
+ straight to `wall.addVideo()`, so the stereo paint, the SBS buffer contract and the
68
+ display-mode fallback are inherited rather than re-implemented. Adds real transport
69
+ (`play/pause/seek/currentTime/duration/volume/muted/setSource/remove`), an `on/off` event pair
70
+ (`play|pause|ended|timeupdate|ready|error`), an SDK-drawn partial-region transport bar
71
+ (`data-inline3d-overlay`, auto-hiding, keyboard-accessible), and a keyboard map
72
+ (Space/K/←/→/J/L/M) scoped to the canvas/transport. `format:'mono'` paints genuinely flat
73
+ content full-frame on a small paint loop of its own — see `js/inline3d-player.js`'s module doc
74
+ comment for why this is a different code path from the unsupported-`wall` fallback, and why
75
+ neither one is the RFC's `untrackedFallback`/`trackingState` mechanism, which does not exist in
76
+ this codebase. v1 is narrower than `docs/rfcs/0001-media-player.md`: no `'tb'`/`'auto'` format
77
+ detection, no `opts.group` one-active-player policy, no `opts.fit`/letterboxing. Sample:
78
+ `samples/player/`.
79
+ - **`setSource()` transitions in `./splat`'s vocabulary.** `transition: 'cut' | 'crossfade'`,
80
+ `durationMs` (600), `easing` (the same named curves, `'easeInOutSine'`), `outgoing: 'frozen'`,
81
+ and `fadeMs` as the legacy alias. Per-call options override the player's own field by field.
82
+ The crossfade dissolves from the outgoing title's last frame through an opt-in mixer canvas (a
83
+ player built without one keeps the plain `addVideo` path). `./splat`'s other transitions,
84
+ unknown easings and `outgoing: 'live'` throw by name.
85
+ - **Transport: title line, ±10 s skip, fullscreen.** `title` (a now-playing line in a bounded top
86
+ band, its own overlay, fading with the bar; `setSource(src, { title })` changes it),
87
+ `skipButtons` (default on, plain rewind / fast-forward glyphs, hidden under 420 px) and `fullscreen` (default on, plus `F`): the
88
+ canvas's container goes fullscreen so the transport comes along, and the canvas is sized to fit
89
+ at its own aspect — never `object-fit`, which would letterbox inside the woven rect. The sample
90
+ gains chapter chips (real `setSource()` crossfades) and an honest caption for the mono tile.
91
+ - **`skin: 'dock'`** — a floating rounded dock, lit round buttons, an accent orb for play, a glowing
92
+ scrub fill and knob, a title pill. `'classic'` stays the default. Every glow is inside its
93
+ overlay's own box: a shadow outside the excluded rect would be woven with the canvas. The sample
94
+ uses `'dock'` (`?skin=classic` for the band) and stacks its tiles at 90% of the window.
95
+ - **Developer customisation: `size: 's' | 'm' | 'l'` and `handle.setAppearance({ accent, size, skin })`**
96
+ (live re-skin). Size is CSS `zoom` on each overlay's content, so icons, fonts and hit targets
97
+ scale together and the centre badge stays centred (measured at all three). Round controls draw
98
+ their own feathered edge (a 1.6 px mask) instead of relying on the border-radius clip, which can
99
+ stair-step where an overlay is re-composited on its own. The sample gains a developer bar
100
+ (accent picker, S/M/L, skin) showing the exact `setAppearance` call.
101
+ - **Docs: the overlay no-glow rule and three media traps** (codec: VP9/AV1 + Opus WebM only, no
102
+ H.264/AAC; non-square-pixel sources play squashed, rewrap with square pixels; black picture with
103
+ working sound = an opaque page background over the woven canvas), and a note that `addPlayer`
104
+ shares `./splat` `setVideo`'s option names (`format`, later `fit`).
105
+ - **`group`: one decoding player at a time.** Players sharing a group take turns: a `play` pauses
106
+ every other member, so a shelf of muted, looping previews decodes one video at a time. Leaving
107
+ on `remove()`.
108
+ - **Controls inside the black bars, and skins `'bars'` / `'call'`** (the Show Spatial team's
109
+ layouts). With a `band`, when the bottom bar is at least 44 CSS px (times `size`), every skin
110
+ puts its controls in it and the title in the top bar; the centre button and key pip stand down,
111
+ so nothing sits over the picture; a thinner bar falls back to the overlay. Re-measured on
112
+ resize. `'bars'`: back / play / elapsed / scrub / remaining in one row (a solid bottom strip
113
+ without a band). `'call'`: name + timer bar on top, controls at the bottom, band 2.39 by default.
114
+ - **Playlist API** (RFC 0001 Addendum A4, for the Show Spatial team's voice commands): `titles`
115
+ (`[{ id, src, title?, poster? }]`, bare sources accepted), `loopList`, `autoAdvance`; on the
116
+ handle `titles`, `current`, `setTitles()`, `play(id)`, `toggle()`, `next()`, `back()` (restart
117
+ past 3 s, else the previous title), and a `'titlechange'` event. Switches go through
118
+ `setSource`, so transitions, the title line and posters follow. `normalizeTitles`,
119
+ `nextIndex` and `backTarget` are pure and tested.
120
+ - **For the Show Spatial team** (quick wins from their list): `format: 'tb'` (top/bottom, repacked
121
+ into the woven pair; `setVideo`'s vocabulary); `band` — a letterbox slot of a target aspect
122
+ (`2.39`, `'2.39:1'`) the picture is fitted into; `posterFormat: 'sbs' | 'tb'` — stereo poster
123
+ stills painted eye by eye; `on('ended')` also fires once if the clip had already ended when the
124
+ listener was attached; `src` / `setSource` take candidates best first and `pickSource()` plays
125
+ the first the browser supports by full `canPlayType` string. Docs: woven-canvas rule 9 (nothing
126
+ with a background above the woven canvas) and a Hosting video section (codec, range requests,
127
+ cache headers, CORS, square pixels).
128
+ - **`fit: 'contain' | 'cover'`** — `./splat` `setVideo`'s names and meaning: contain letterboxes each
129
+ eye (transparent bars), cover fills the tile and crops. Unset keeps the stretch-to-tile pixels.
130
+ On the woven path a small per-eye fit canvas sits after the crossfade mixer (one extra draw per
131
+ frame, only when set); the flat paths fit directly. `fitRect()` is exported and tested.
132
+ - **Named accents** — `accent: 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' | 'lime' | 'mint' |
133
+ 'ice'` (or any CSS colour), exported as `PLAYER_ACCENTS`. Each is light enough for the dock's dark
134
+ play glyph. The sample shows them as swatches.
135
+
8
136
  ## 1.24.0 — 2026-09-25
9
137
 
10
138
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change. Pixels change
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
  /**