@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 +139 -0
- package/README.md +15 -0
- package/index.d.ts +57 -0
- package/js/inline3d-player.js +2219 -0
- package/js/inline3d-splat-diag.js +15 -1
- package/js/inline3d-splat-layer-rig.js +343 -125
- package/js/inline3d-splat-live.js +87 -7
- package/js/inline3d-splat-playcanvas.js +128 -24
- package/js/inline3d-splat-rig-map.js +369 -0
- package/js/inline3d-splat.js +8 -1
- package/js/inline3d.js +208 -12
- package/package.json +8 -1
- package/player.d.ts +273 -0
- package/splat.d.ts +41 -9
package/js/inline3d.js
CHANGED
|
@@ -130,6 +130,34 @@ const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRen
|
|
|
130
130
|
// mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
|
|
131
131
|
// subscription covers every window in the document.
|
|
132
132
|
const DISPLAY_EVENTS = ['renderingmodechange', 'hardwaredisplaystatechange'];
|
|
133
|
+
// TRACKING STATE — is anyone in the display's 3D zone right now?
|
|
134
|
+
//
|
|
135
|
+
// `session.trackingState` is a DOMString ('tracking' | 'searching' | 'unknown', filled in on every
|
|
136
|
+
// frame) and `trackingstatechange` is a PLAIN Event with no payload, so the state is always READ
|
|
137
|
+
// off the session and never parsed out of the event. It comes from the DisplayXR runtime, which
|
|
138
|
+
// may hand the decision to the vendor plug-in (DisplayXR Browser patch 0195).
|
|
139
|
+
//
|
|
140
|
+
// 'searching' means the runtime's derived isTracking went false: nobody is in the display's
|
|
141
|
+
// supported 3D zone, or the display is in an untracked / 2D mode. It does NOT necessarily mean the
|
|
142
|
+
// tracker lost a face that is still there, and a page must not word its UI as if it did.
|
|
143
|
+
//
|
|
144
|
+
// An older browser has NEITHER the attribute NOR the event. That is not warning-worthy — it is
|
|
145
|
+
// every release up to and including 1.0.5 — so the SDK reports 'unknown', silently, and every
|
|
146
|
+
// consumer of this surface treats 'unknown' as "no opinion".
|
|
147
|
+
const TRACKING_EVENT = 'trackingstatechange';
|
|
148
|
+
const TRACKING_STATES = ['unknown', 'tracking', 'searching'];
|
|
149
|
+
// Everything on()/off() accept. `onDisplayModeChange` deliberately still fans out over
|
|
150
|
+
// DISPLAY_EVENTS alone: pages pass it one callback shaped for a mode event.
|
|
151
|
+
const MANAGER_EVENTS = [...DISPLAY_EVENTS, TRACKING_EVENT];
|
|
152
|
+
/** This session's tracking state, or null when the browser does not expose one at all. */
|
|
153
|
+
function readTrackingState(session) {
|
|
154
|
+
try {
|
|
155
|
+
const v = session ? session.trackingState : undefined;
|
|
156
|
+
return TRACKING_STATES.includes(v) ? v : null;
|
|
157
|
+
} catch {
|
|
158
|
+
return null; // an attribute that refuses to be read is not a capability
|
|
159
|
+
}
|
|
160
|
+
}
|
|
133
161
|
const hasDisplayModes = () => {
|
|
134
162
|
if (!hasLayer()) return false;
|
|
135
163
|
try {
|
|
@@ -514,7 +542,20 @@ function noteRigWinsOverHeight() {
|
|
|
514
542
|
* keeps the eye set coherent either way — and a mode change the page did NOT request
|
|
515
543
|
* (another tab, the shell, a panel opening flat) always snaps, because there is nothing
|
|
516
544
|
* to ramp from. Read the live state on `wall.modeSwitch`.
|
|
517
|
-
* @
|
|
545
|
+
* @param {'none'|'mono'} [opts.untrackedFallback='none'] What the SDK does to the image and video
|
|
546
|
+
* windows it owns the pixels of while NOBODY IS TRACKED (`wall.trackingState ===
|
|
547
|
+
* 'searching'`). WHOSE JOB THIS IS depends on the display's eye-tracking mode (runtime
|
|
548
|
+
* spec docs/specs/vendor/eye-tracking-modes.md). MANAGED — the default, and Leia's — the
|
|
549
|
+
* vendor owns tracking loss: it eases the eyes together and only reports 'searching' once
|
|
550
|
+
* the display is already 2D, so leave this at `'none'` (a flatten here would be a second
|
|
551
|
+
* transition). MANUAL — the vendor does nothing and the app handles it: set `'mono'`.
|
|
552
|
+
* `'none'` (default) changes nothing. `'mono'` eases each image/video window to its LEFT
|
|
553
|
+
* EYE IN BOTH HALVES of its side-by-side buffer on 'searching' (still a valid pair for the
|
|
554
|
+
* layer; no buffer reallocation) and back on 'tracking', over the mode switch's duration;
|
|
555
|
+
* 'unknown' leaves it where it is. Scene windows are never touched: the page owns those
|
|
556
|
+
* pixels — listen for `trackingstatechange` and do the same for a scene.
|
|
557
|
+
* @returns {Promise<Inline3D | {supported:false, trackingState:'unknown', error?:Error}>} the
|
|
558
|
+
* manager, which also carries
|
|
518
559
|
* the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
|
|
519
560
|
* `setStereoEnabled`, `on`/`off`) and `undock` — `{model, splat}` on a browser with
|
|
520
561
|
* `XRDisplayLayer.undock`, `null` on one without.
|
|
@@ -526,15 +567,18 @@ export async function createInline3D(opts = {}) {
|
|
|
526
567
|
rootMargin = '50% 0px',
|
|
527
568
|
autoChrome = true,
|
|
528
569
|
modeSwitch = null,
|
|
570
|
+
untrackedFallback = 'none',
|
|
529
571
|
} = opts;
|
|
530
|
-
|
|
572
|
+
// The unsupported shapes carry `trackingState` too, so a page can read it without branching on
|
|
573
|
+
// `supported` first. There is no on() here to pair with it: nothing would ever fire.
|
|
574
|
+
if (!inline3DAvailable()) return { supported: false, trackingState: 'unknown' };
|
|
531
575
|
let session;
|
|
532
576
|
try {
|
|
533
577
|
// requestSession is Blink-local and resolves immediately when the feature is present —
|
|
534
578
|
// the correct detection path (see inline3DAvailable's note on isSessionSupported).
|
|
535
579
|
session = await navigator.xr.requestSession('inline-3d');
|
|
536
580
|
} catch (e) {
|
|
537
|
-
return { supported: false, error: e };
|
|
581
|
+
return { supported: false, trackingState: 'unknown', error: e };
|
|
538
582
|
}
|
|
539
583
|
let refSpace = null;
|
|
540
584
|
try {
|
|
@@ -542,7 +586,7 @@ export async function createInline3D(opts = {}) {
|
|
|
542
586
|
} catch {
|
|
543
587
|
/* rAF still fires without a ref space; views are just null (fine for image/video). */
|
|
544
588
|
}
|
|
545
|
-
return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch });
|
|
589
|
+
return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch, untrackedFallback });
|
|
546
590
|
}
|
|
547
591
|
|
|
548
592
|
/**
|
|
@@ -594,7 +638,11 @@ function chromeTextPlates(root) {
|
|
|
594
638
|
}
|
|
595
639
|
|
|
596
640
|
class Inline3D {
|
|
597
|
-
constructor(
|
|
641
|
+
constructor(
|
|
642
|
+
session,
|
|
643
|
+
refSpace,
|
|
644
|
+
{ lazy, rootMargin, autoChrome = true, modeSwitch = null, untrackedFallback = 'none' }
|
|
645
|
+
) {
|
|
598
646
|
this.supported = true;
|
|
599
647
|
this.session = session;
|
|
600
648
|
this.refSpace = refSpace;
|
|
@@ -651,6 +699,25 @@ class Inline3D {
|
|
|
651
699
|
this._msArmedUp = false; // a ->3D request went out; the up-ramp waits for the panel to say 3D
|
|
652
700
|
this._msLastMs = null; // wall clock of the previous advance (null = the ramp has not ticked)
|
|
653
701
|
this._msTick = null; // the frames-stopped fallback timer; see _armModeSwitchTick
|
|
702
|
+
// ── tracking state (see TRACKING_EVENT) ───────────────────────────────────────────
|
|
703
|
+
// Read once here so `wall.trackingState` is honest before any event arrives. A page can open
|
|
704
|
+
// with nobody in front of the display, and that is a starting value, not a CHANGE, so nothing
|
|
705
|
+
// is emitted for it. A browser without the attribute reads null and stays 'unknown'.
|
|
706
|
+
this._trackingSupported = readTrackingState(session) !== null;
|
|
707
|
+
this._trackingState = this._trackingSupported ? readTrackingState(session) : 'unknown';
|
|
708
|
+
// What to do to image/video windows while nobody is tracked. 'none' is the default so every
|
|
709
|
+
// page that predates the option renders bit-identically (and MANAGED displays need nothing).
|
|
710
|
+
this._untrackedFallback = untrackedFallback === 'mono' ? 'mono' : 'none';
|
|
711
|
+
// ── untrackedFallback:'mono' — baked stereo eased to flat (see _trackBakedStereo) ───────
|
|
712
|
+
// An image/video tile's stereo is in its source pixels, so neither the rig collapse above nor
|
|
713
|
+
// the runtime's own easing of the views can reach it. 0..1 = how much of that baked disparity
|
|
714
|
+
// to show, eased toward the target the tracking state sets, over the mode switch's duration.
|
|
715
|
+
this._bakedStereo = 1;
|
|
716
|
+
this._bakedTarget = 1;
|
|
717
|
+
this._bakedAt = null; // frame time (ms) of the last ease step
|
|
718
|
+
this._bakedEaseS =
|
|
719
|
+
(Number.isFinite(msOpts.durationMs) ? Math.max(0, msOpts.durationMs) : MODE_SWITCH_DEFAULT_DURATION_MS) / 1000;
|
|
720
|
+
this._applyUntrackedTarget(); // a page that opens with nobody tracked starts heading flat
|
|
654
721
|
// Undock capabilities, refreshed off the first live layer (see _refreshUndock). Null is the
|
|
655
722
|
// load-bearing value: it means this browser has no XRDisplayLayer.undock at all.
|
|
656
723
|
this.undock = hasUndock() ? { model: false, splat: false } : null;
|
|
@@ -680,6 +747,7 @@ class Inline3D {
|
|
|
680
747
|
liveManager = this;
|
|
681
748
|
session.addEventListener('end', () => this._teardown());
|
|
682
749
|
this._bindDisplayEvents();
|
|
750
|
+
this._bindTrackingEvents();
|
|
683
751
|
this._scanChrome(); // page chrome usually exists before the session does
|
|
684
752
|
this._bindLifecycle();
|
|
685
753
|
this._armDprWatch();
|
|
@@ -901,6 +969,7 @@ class Inline3D {
|
|
|
901
969
|
* 3D per-pixel, so exclude()/unexclude() are stored-and-ignored (see _applyExclusion).
|
|
902
970
|
*/
|
|
903
971
|
_handle(canvas, win) {
|
|
972
|
+
const mgr = this; // for the getters below: an object-literal getter's `this` is the handle
|
|
904
973
|
return {
|
|
905
974
|
remove: () => this._remove(canvas),
|
|
906
975
|
exclude: (el) => {
|
|
@@ -940,6 +1009,10 @@ class Inline3D {
|
|
|
940
1009
|
* asked for. During the eased 2D<->3D transition the same applies with a FRACTION in place
|
|
941
1010
|
* of the 0: what leaves for the layer is your rig scaled by `wall.modeSwitch.factor`.
|
|
942
1011
|
*/
|
|
1012
|
+
/** The manager's `trackingState`, on the tile handle too ('tracking' | 'searching' | 'unknown'). */
|
|
1013
|
+
get trackingState() {
|
|
1014
|
+
return mgr._trackingState;
|
|
1015
|
+
},
|
|
943
1016
|
setViewRig: (rig) => {
|
|
944
1017
|
win.viewRig = rig || null;
|
|
945
1018
|
return this._pushViewRig(win);
|
|
@@ -1136,6 +1209,22 @@ class Inline3D {
|
|
|
1136
1209
|
* The hardware display state as last REPORTED by `hardwaredisplaystatechange` — `'2d'`,
|
|
1137
1210
|
* `'3d'`, or `null` when the browser has not said yet. Never what was last requested.
|
|
1138
1211
|
*/
|
|
1212
|
+
/**
|
|
1213
|
+
* Is anyone being tracked in front of this display right now?
|
|
1214
|
+
*
|
|
1215
|
+
* `'tracking'` a viewer is in the display's 3D zone.
|
|
1216
|
+
* `'searching'` the runtime's derived isTracking is FALSE — nobody is in the zone, or the
|
|
1217
|
+
* display is in an untracked / 2D mode. NOT "the tracker glitched".
|
|
1218
|
+
* `'unknown'` this browser has no tracking-state surface, or the session has ended.
|
|
1219
|
+
*
|
|
1220
|
+
* Mirrors `session.trackingState` as last read. Changes arrive as
|
|
1221
|
+
* `on('trackingstatechange', (state, ev) => …)`. Pages use it for "step back into view" hints;
|
|
1222
|
+
* `untrackedFallback: 'mono'` uses it to flatten image/video windows.
|
|
1223
|
+
*/
|
|
1224
|
+
get trackingState() {
|
|
1225
|
+
return this._trackingState;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1139
1228
|
get hardwareDisplayState() {
|
|
1140
1229
|
return this._hardwareDisplayState;
|
|
1141
1230
|
}
|
|
@@ -1711,13 +1800,20 @@ class Inline3D {
|
|
|
1711
1800
|
});
|
|
1712
1801
|
}
|
|
1713
1802
|
|
|
1714
|
-
/**
|
|
1715
|
-
|
|
1803
|
+
/**
|
|
1804
|
+
* Deliver one normalised event to every listener. A throwing page handler is contained.
|
|
1805
|
+
*
|
|
1806
|
+
* `args` overrides what the callback is CALLED with (the event object still selects the listener
|
|
1807
|
+
* set): `trackingstatechange` leads with the state string, because a page listening to it wants
|
|
1808
|
+
* the one value and not a wrapper.
|
|
1809
|
+
*/
|
|
1810
|
+
_emitDisplay(ev, args) {
|
|
1716
1811
|
const set = this._displayListeners.get(ev.type);
|
|
1717
1812
|
if (!set) return;
|
|
1813
|
+
const argv = args || [ev];
|
|
1718
1814
|
for (const cb of [...set]) {
|
|
1719
1815
|
try {
|
|
1720
|
-
cb(
|
|
1816
|
+
cb(...argv);
|
|
1721
1817
|
} catch (err) {
|
|
1722
1818
|
console.error(`[inline3d] ${ev.type} listener threw`, err);
|
|
1723
1819
|
}
|
|
@@ -1725,14 +1821,18 @@ class Inline3D {
|
|
|
1725
1821
|
}
|
|
1726
1822
|
|
|
1727
1823
|
/**
|
|
1728
|
-
* Listen for `renderingmodechange` / `hardwaredisplaystatechange`
|
|
1729
|
-
* handle's `on()` doc for the payload shapes
|
|
1824
|
+
* Listen for `renderingmodechange` / `hardwaredisplaystatechange` / `trackingstatechange` on
|
|
1825
|
+
* this manager. See the handle's `on()` doc for the payload shapes — the tracking callback is
|
|
1826
|
+
* handed the STATE STRING first: `(state, { type, state })`. Returns an unsubscribe function.
|
|
1827
|
+
*
|
|
1828
|
+
* Subscribing to `trackingstatechange` on a browser with no tracking state is legal and silent:
|
|
1829
|
+
* the listener is kept, nothing fires it, and `trackingState` stays 'unknown'.
|
|
1730
1830
|
*/
|
|
1731
1831
|
on(type, cb) {
|
|
1732
1832
|
if (typeof cb !== 'function') throw new TypeError('[inline3d] on() takes (type, function).');
|
|
1733
|
-
if (!
|
|
1833
|
+
if (!MANAGER_EVENTS.includes(type)) {
|
|
1734
1834
|
throw new TypeError(
|
|
1735
|
-
`[inline3d] on() knows ${
|
|
1835
|
+
`[inline3d] on() knows ${MANAGER_EVENTS.join(' / ')}, got ${JSON.stringify(type)}.`
|
|
1736
1836
|
);
|
|
1737
1837
|
}
|
|
1738
1838
|
let set = this._displayListeners.get(type);
|
|
@@ -2380,6 +2480,17 @@ class Inline3D {
|
|
|
2380
2480
|
return;
|
|
2381
2481
|
}
|
|
2382
2482
|
const halfDst = c.width / 2;
|
|
2483
|
+
// How much of the source's baked disparity to show: the eased 2D<->3D ramp (the mode) and
|
|
2484
|
+
// the runtime's tracking state (_trackBakedStereo), whichever is flatter. Below 1 the RIGHT half becomes
|
|
2485
|
+
// the left eye with the right eye blended over it, so 0 is the left eye in both halves —
|
|
2486
|
+
// flat, and still a valid SBS pair for the live layer (a 1:1 mono buffer here would be woven
|
|
2487
|
+
// as a squeezed pair). See _trackBakedStereo.
|
|
2488
|
+
const stereo = Math.min(this._stereoFactor, this._bakedStereo);
|
|
2489
|
+
if (stereo < 1) {
|
|
2490
|
+
drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
|
|
2491
|
+
drawEyeBlend(ctx, src, srcW / 2, srcH, halfDst, c.height, stereo, win.cornerRadius, win.feather); // L+R
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2383
2494
|
// A single stretched draw maps SBS source → SBS buffer (left→left, right→right); the
|
|
2384
2495
|
// per-eye path is only needed to bake decoration (rounded corners / edge feather), which
|
|
2385
2496
|
// MUST be applied to each eye separately — see drawEye/featherEye.
|
|
@@ -2391,6 +2502,68 @@ class Inline3D {
|
|
|
2391
2502
|
}
|
|
2392
2503
|
}
|
|
2393
2504
|
|
|
2505
|
+
/**
|
|
2506
|
+
* Subscribe to `trackingstatechange`, once per manager, and only where the ATTRIBUTE exists: the
|
|
2507
|
+
* event is payload-free, so a browser that fired it without `session.trackingState` would leave
|
|
2508
|
+
* nothing to read. Independent of `_bindDisplayEvents` — a browser may ship them in either order.
|
|
2509
|
+
*/
|
|
2510
|
+
_bindTrackingEvents() {
|
|
2511
|
+
const session = this.session;
|
|
2512
|
+
if (!session || typeof session.addEventListener !== 'function') return;
|
|
2513
|
+
if (!this._trackingSupported) return;
|
|
2514
|
+
session.addEventListener(TRACKING_EVENT, () => {
|
|
2515
|
+
this._setTrackingState(readTrackingState(session) || 'unknown'); // READ, never parse
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/**
|
|
2520
|
+
* Adopt a new tracking state: act on it first (the fallback target, so a listener sees the state
|
|
2521
|
+
* it is told about already in effect), then tell the page. On CHANGE only — a repeated state is
|
|
2522
|
+
* not an event, and the value read at construction is a starting value, not a change.
|
|
2523
|
+
*/
|
|
2524
|
+
_setTrackingState(state) {
|
|
2525
|
+
const next = TRACKING_STATES.includes(state) ? state : 'unknown';
|
|
2526
|
+
if (next === this._trackingState) return;
|
|
2527
|
+
this._trackingState = next;
|
|
2528
|
+
this._applyUntrackedTarget();
|
|
2529
|
+
const ev = { type: TRACKING_EVENT, state: next };
|
|
2530
|
+
this._emitDisplay(ev, [next, ev]);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* Where baked stereo is heading under `untrackedFallback`. `'none'`: always the full pair.
|
|
2535
|
+
* `'mono'`: `'searching'` heads to flat, `'tracking'` back; `'unknown'` (no display yet, the
|
|
2536
|
+
* first frame, a session that ended) leaves it where it is — never read as flat.
|
|
2537
|
+
*/
|
|
2538
|
+
_applyUntrackedTarget() {
|
|
2539
|
+
if (this._untrackedFallback !== 'mono') {
|
|
2540
|
+
this._bakedTarget = 1;
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (this._trackingState === 'searching') this._bakedTarget = 0;
|
|
2544
|
+
else if (this._trackingState === 'tracking') this._bakedTarget = 1;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* One frame of the baked-stereo ease (untrackedFallback:'mono'): toward the target the tracking
|
|
2549
|
+
* state set, at a rate that
|
|
2550
|
+
* crosses 0..1 in the mode switch's duration, so an untracked flatten looks like a page-requested
|
|
2551
|
+
* 2D switch. Scene tiles are not involved — they render from the views, which the runtime
|
|
2552
|
+
* eases itself.
|
|
2553
|
+
*/
|
|
2554
|
+
_trackBakedStereo(t) {
|
|
2555
|
+
// `trackingState` is filled in on every frame; reading it here as well as on the event keeps
|
|
2556
|
+
// the state right even if an event was coalesced away. Emits only on a real change.
|
|
2557
|
+
if (this._trackingSupported) this._setTrackingState(readTrackingState(this.session) || 'unknown');
|
|
2558
|
+
const dt = this._bakedAt === null || !Number.isFinite(t) ? 0 : Math.max(0, (t - this._bakedAt) / 1000);
|
|
2559
|
+
this._bakedAt = Number.isFinite(t) ? t : this._bakedAt;
|
|
2560
|
+
const cur = this._bakedStereo;
|
|
2561
|
+
const tgt = this._bakedTarget;
|
|
2562
|
+
if (cur === tgt) return;
|
|
2563
|
+
const step = this._bakedEaseS > 0 ? dt / this._bakedEaseS : 1;
|
|
2564
|
+
this._bakedStereo = tgt > cur ? Math.min(tgt, cur + step) : Math.max(tgt, cur - step);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2394
2567
|
/**
|
|
2395
2568
|
* Re-commit the pixels the canvas already holds, unchanged — the cheapest "last decoded frame"
|
|
2396
2569
|
* there is, because the last decoded frame is already in the backing store.
|
|
@@ -2447,6 +2620,7 @@ class Inline3D {
|
|
|
2447
2620
|
this._advanceModeSwitch();
|
|
2448
2621
|
const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
|
|
2449
2622
|
const views = pose ? pose.views : null;
|
|
2623
|
+
this._trackBakedStereo(t);
|
|
2450
2624
|
for (const win of this._windows.values()) {
|
|
2451
2625
|
if (!win.layer) continue;
|
|
2452
2626
|
if (win.kind === 'scene') {
|
|
@@ -2669,6 +2843,10 @@ class Inline3D {
|
|
|
2669
2843
|
this._settleFirstWoven(win, false, 'session-ended');
|
|
2670
2844
|
}
|
|
2671
2845
|
this._windows.clear();
|
|
2846
|
+
// Nobody is tracked through a session that has ended: say so ONCE, before the listeners are
|
|
2847
|
+
// dropped, so a page that gated its UI on 'tracking' is released rather than left latched. A
|
|
2848
|
+
// browser that never reported one is already 'unknown' and this is silent.
|
|
2849
|
+
this._setTrackingState('unknown');
|
|
2672
2850
|
// Page listeners go with the session that fed them: a manager whose session has ended will
|
|
2673
2851
|
// never emit again, and holding the callbacks would keep the page's closures alive.
|
|
2674
2852
|
this._displayListeners.clear();
|
|
@@ -2711,6 +2889,24 @@ function drawEye(ctx, src, sx, sy, sw, sh, dx, dy, dw, dh, radius, feather) {
|
|
|
2711
2889
|
}
|
|
2712
2890
|
}
|
|
2713
2891
|
|
|
2892
|
+
// The right half of a partly flattened SBS paint: the LEFT eye, with the right eye over it at
|
|
2893
|
+
// `amount` (0 = left only). Clipped and feathered once, as one eye, for the same reason drawEye is.
|
|
2894
|
+
function drawEyeBlend(ctx, src, halfSrc, srcH, dx, dh, amount, radius, feather) {
|
|
2895
|
+
ctx.save();
|
|
2896
|
+
if (radius > 0 && ctx.roundRect) {
|
|
2897
|
+
ctx.beginPath();
|
|
2898
|
+
ctx.roundRect(dx, 0, dx, dh, radius);
|
|
2899
|
+
ctx.clip();
|
|
2900
|
+
}
|
|
2901
|
+
ctx.drawImage(src, 0, 0, halfSrc, srcH, dx, 0, dx, dh);
|
|
2902
|
+
if (amount > 0) {
|
|
2903
|
+
ctx.globalAlpha = amount;
|
|
2904
|
+
ctx.drawImage(src, halfSrc, 0, halfSrc, srcH, dx, 0, dx, dh);
|
|
2905
|
+
}
|
|
2906
|
+
ctx.restore();
|
|
2907
|
+
if (feather > 0) featherEye(ctx, dx, 0, dx, dh, feather);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2714
2910
|
// Fade this EYE's outer edges to transparent, so the 3D window dissolves into the page
|
|
2715
2911
|
// instead of ending at a hard rectangle. Same spirit as the runtime feathering a 3D zone's
|
|
2716
2912
|
// edge — but note that is the hardware WISH MASK (it drives the hardware display state, never
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.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",
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"import": "./js/inline3d-model.js"
|
|
33
33
|
},
|
|
34
34
|
"./package.json": "./package.json",
|
|
35
|
+
"./player": {
|
|
36
|
+
"types": "./player.d.ts",
|
|
37
|
+
"import": "./js/inline3d-player.js"
|
|
38
|
+
},
|
|
35
39
|
"./undock": {
|
|
36
40
|
"types": "./index.d.ts",
|
|
37
41
|
"import": "./js/inline3d-undock.js"
|
|
@@ -55,16 +59,19 @@
|
|
|
55
59
|
"js/inline3d-splat-diag.js",
|
|
56
60
|
"js/inline3d-splat-video.js",
|
|
57
61
|
"js/inline3d-splat-layer-rig.js",
|
|
62
|
+
"js/inline3d-splat-rig-map.js",
|
|
58
63
|
"js/inline3d-model.js",
|
|
59
64
|
"js/inline3d-model-entry.js",
|
|
60
65
|
"js/inline3d-model-playcanvas.js",
|
|
61
66
|
"js/inline3d-pc-look.js",
|
|
62
67
|
"js/inline3d-playcanvas-engine.js",
|
|
68
|
+
"js/inline3d-player.js",
|
|
63
69
|
"index.d.ts",
|
|
64
70
|
"three.d.ts",
|
|
65
71
|
"viewer.d.ts",
|
|
66
72
|
"splat.d.ts",
|
|
67
73
|
"model.d.ts",
|
|
74
|
+
"player.d.ts",
|
|
68
75
|
"CHANGELOG.md",
|
|
69
76
|
"README.md",
|
|
70
77
|
"LICENSE",
|
package/player.d.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Type definitions for @displayxr/inline3d/player.
|
|
2
|
+
// PREVIEW tier — not covered by the 1.x semver promise. See docs/sdk-stability.md.
|
|
3
|
+
//
|
|
4
|
+
// v1 slice of docs/rfcs/0001-media-player.md. Narrower than the RFC's full plan — no `'tb'`/
|
|
5
|
+
// `'auto'` format, no sidecar/filename format detection, no `opts.group` playback policy, no
|
|
6
|
+
// `opts.fit`/letterboxing — see the report that shipped this file for the reasoning. Widening
|
|
7
|
+
// any of those later is additive (new optional fields), so this stays forward-compatible.
|
|
8
|
+
|
|
9
|
+
/** `'sbs'` side-by-side, `'tb'` top/bottom (left eye on top) — ./splat setVideo's names — or `'mono'`. */
|
|
10
|
+
export type PlayerFormat = 'sbs' | 'tb' | 'mono';
|
|
11
|
+
|
|
12
|
+
/** A source: a URL or Blob, or candidates best-first for {@link pickSource}. */
|
|
13
|
+
export type PlayerSource = string | Blob | Array<string | Blob | { src: string | Blob; type?: string }>;
|
|
14
|
+
export type PlayerControls = 'sdk' | 'none';
|
|
15
|
+
|
|
16
|
+
export interface PlayerOptions {
|
|
17
|
+
/**
|
|
18
|
+
* `'sbs'` (default) is a real stereo pair, woven via `wall.addVideo()` — every fallback
|
|
19
|
+
* `addVideo` already has (including the browser-unsupported / off-screen-lazy one) applies for
|
|
20
|
+
* free. `'mono'` is genuinely flat content: painted full-frame, never split into eyes, on a
|
|
21
|
+
* small paint loop this module owns (NOT the same code path as an unsupported wall — see
|
|
22
|
+
* js/inline3d-player.js's module doc comment).
|
|
23
|
+
*/
|
|
24
|
+
format?: PlayerFormat;
|
|
25
|
+
/** Painted before the first frame, and again on the video's `error` event. */
|
|
26
|
+
poster?: string;
|
|
27
|
+
autoplay?: boolean;
|
|
28
|
+
/** Default true — required for autoplay in every browser that has one. */
|
|
29
|
+
muted?: boolean;
|
|
30
|
+
loop?: boolean;
|
|
31
|
+
/** Default `'sdk'`. `'none'` leaves chrome to the page (handle + events + its own overlays). */
|
|
32
|
+
controls?: PlayerControls;
|
|
33
|
+
/**
|
|
34
|
+
* Space/K play-pause, ←/→ seek ±5 s, J/L seek ±10 s, M mute. Bound to the canvas and its
|
|
35
|
+
* transport, never `document`, so a page with several players drives only the focused one.
|
|
36
|
+
* Default true.
|
|
37
|
+
*/
|
|
38
|
+
keyboard?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* What `setSource()` does by default — the `./splat` vocabulary, the part a video can mean.
|
|
41
|
+
* `'crossfade'` is opt-in HERE: it creates the mixer canvas that runs it (one extra full-frame
|
|
42
|
+
* draw per painted frame), so a player that never asks keeps the byte-identical
|
|
43
|
+
* `addVideo(canvas, video)` paint path and pays nothing. Default `'cut'`.
|
|
44
|
+
*
|
|
45
|
+
* The crossfade runs from the outgoing title's LAST FRAME to the incoming one (`./splat`'s
|
|
46
|
+
* `outgoing: 'frozen'`) — not a blend of two decoding streams. On an SBS video that is not
|
|
47
|
+
* the "tracking paused" look it is on a splat: the frame's disparity is baked in either way.
|
|
48
|
+
* `./splat`'s other transitions (`flip`, `wavefront`, particles, sequences) move a photo's
|
|
49
|
+
* gaussians and are refused by name.
|
|
50
|
+
*/
|
|
51
|
+
transition?: PlayerTransition;
|
|
52
|
+
/** The crossfade's length, ms. Default 600. */
|
|
53
|
+
durationMs?: number;
|
|
54
|
+
/** A `./splat` easing name, or `(x) => y` on [0, 1]. Default `'easeInOutSine'`. */
|
|
55
|
+
easing?: PlayerEasing;
|
|
56
|
+
/** LEGACY (the 1.10 spelling, kept as `./splat` keeps it): `> 0` = `transition: 'crossfade'` of this length. */
|
|
57
|
+
fadeMs?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Accent colour for the transport — a named preset (`'azure'` default, `'violet'`, `'magenta'`,
|
|
60
|
+
* `'sunset'`, `'amber'`, `'lime'`, `'mint'`, `'ice'`) or any CSS colour. Written to the
|
|
61
|
+
* `--dxr-accent` custom property on the host, so it re-skins the scrub fill, the knob, the play
|
|
62
|
+
* orb, the focus rings and the spinner in one value.
|
|
63
|
+
*/
|
|
64
|
+
accent?: PlayerAccent | (string & {});
|
|
65
|
+
/**
|
|
66
|
+
* Show a small pill in the control row (`true` → "3D", or your own short string). Opt-in and
|
|
67
|
+
* page-driven on purpose: the module will NOT infer it from `wall.supported`, because a
|
|
68
|
+
* supported wall whose tile is scrolled out of view — or whose panel is in a 2D mode — is not
|
|
69
|
+
* showing 3D at that moment, and a badge that says otherwise is worse than no badge.
|
|
70
|
+
*/
|
|
71
|
+
badge3d?: boolean | string;
|
|
72
|
+
/**
|
|
73
|
+
* The transport's look: `'classic'` (default) — a full-width bottom band; `'dock'` — a floating
|
|
74
|
+
* rounded dock with lit round buttons and an accent orb for play.
|
|
75
|
+
* `'bars'` — one row inside the bottom black bar: back / play / elapsed / scrub / remaining.
|
|
76
|
+
* `'call'` — the name and a timer bar on top, the controls at the bottom, and a 2.39:1 `band`
|
|
77
|
+
* by default. With a `band`, every skin puts its controls INSIDE the black bars when the bottom
|
|
78
|
+
* bar is tall enough, and nothing sits over the picture. Same controls, CSS only. Both
|
|
79
|
+
* keep every glow inside the overlay's own box (a shadow outside it would be woven).
|
|
80
|
+
*/
|
|
81
|
+
skin?: 'classic' | 'dock' | 'bars' | 'call';
|
|
82
|
+
/**
|
|
83
|
+
* How an eye image meets a tile of a different aspect — the same names and meaning as `./splat`
|
|
84
|
+
* `setVideo`'s `fit`. `'contain'`: the whole eye image, transparent bars where the aspects
|
|
85
|
+
* differ (the page shows through). `'cover'`: the tile is full and the overflow is cut. Unset:
|
|
86
|
+
* stretched to the tile (the 1.x behaviour). On the woven path it costs one extra draw per frame.
|
|
87
|
+
*/
|
|
88
|
+
fit?: 'contain' | 'cover';
|
|
89
|
+
/**
|
|
90
|
+
* A letterbox "band" slot: the picture is fitted into a centred band of this aspect inside the
|
|
91
|
+
* tile (e.g. `2.39` or `'2.39:1'` for a scope band in a 16:9 tile); the rest stays clear.
|
|
92
|
+
* Implies `fit: 'contain'` unless `fit` says otherwise.
|
|
93
|
+
*/
|
|
94
|
+
band?: number | string;
|
|
95
|
+
/**
|
|
96
|
+
* What the poster image is: `'mono'` (one image for both eyes, the default), or a stereo still
|
|
97
|
+
* laid out like the video (`'sbs'` / `'tb'`), painted eye by eye so it is 3D before the first frame.
|
|
98
|
+
*/
|
|
99
|
+
posterFormat?: 'mono' | 'sbs' | 'tb';
|
|
100
|
+
/**
|
|
101
|
+
* A playlist. With `titles` and no `src`, the first title loads. Entries may be bare sources.
|
|
102
|
+
* Drive it with `play(id)`, `next()`, `back()`; `'titlechange'` fires on each switch.
|
|
103
|
+
*/
|
|
104
|
+
titles?: ReadonlyArray<PlayerSource | { id?: string; src: PlayerSource; title?: string; poster?: string }>;
|
|
105
|
+
/** `next()` past the last title wraps to the first (and `back()` from the first to the last). Default false. */
|
|
106
|
+
loopList?: boolean;
|
|
107
|
+
/** A title that ends moves on to the next one and plays it. Default false. */
|
|
108
|
+
autoAdvance?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Players sharing a group decode one at a time: starting one pauses every other player in the
|
|
111
|
+
* group. For a shelf of previews that each play muted and looped. Any string; unset = no group.
|
|
112
|
+
*/
|
|
113
|
+
group?: string;
|
|
114
|
+
/** Transport scale — `'s'` (0.84×), `'m'` (default), `'l'` (1.28×). Icons, fonts and hit targets scale together. */
|
|
115
|
+
size?: 's' | 'm' | 'l';
|
|
116
|
+
/** A now-playing line over the top of the tile, fading with the transport. `setSource(src, { title })` changes it. */
|
|
117
|
+
title?: string;
|
|
118
|
+
/** −10 s / +10 s buttons beside play. Default true (hidden on tiles narrower than 420 px). J / L work either way. */
|
|
119
|
+
skipButtons?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* A fullscreen button (and the F key). The canvas's CONTAINER goes fullscreen, so the transport
|
|
122
|
+
* comes with it and the tile letterboxes on black. Default true; absent where the browser has no
|
|
123
|
+
* Fullscreen API.
|
|
124
|
+
*/
|
|
125
|
+
fullscreen?: boolean;
|
|
126
|
+
/** Default: `'anonymous'` iff `src` is a cross-origin URL; unset (browser default) otherwise. */
|
|
127
|
+
crossOrigin?: 'anonymous' | 'use-credentials';
|
|
128
|
+
/** Per-eye buffer resolution in px. `'sbs'` + supported wall only — see {@link TileOptions}. */
|
|
129
|
+
width?: number;
|
|
130
|
+
height?: number;
|
|
131
|
+
/** `'sbs'` + supported wall only. */
|
|
132
|
+
cornerRadius?: number;
|
|
133
|
+
feather?: number;
|
|
134
|
+
/** Element whose visibility gates the lazy create/close lifecycle (`'sbs'` path only). */
|
|
135
|
+
observe?: Element;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type PlayerEvent = 'play' | 'pause' | 'ended' | 'timeupdate' | 'ready' | 'error' | 'titlechange';
|
|
139
|
+
|
|
140
|
+
/** One playlist entry (RFC 0001 Addendum A4). */
|
|
141
|
+
export interface PlayerTitle {
|
|
142
|
+
/** Stable id for `play(id)`; defaults to the entry's index as a string. */
|
|
143
|
+
readonly id: string;
|
|
144
|
+
readonly src: PlayerSource;
|
|
145
|
+
/** Shown in the title line while this title plays. */
|
|
146
|
+
readonly title?: string;
|
|
147
|
+
readonly poster?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What {@link addPlayer} returns. Deliberately the `<video>` element's own vocabulary. */
|
|
151
|
+
export interface PlayerHandle {
|
|
152
|
+
/** The hidden `<video>` this handle owns — escape hatch for anything not covered above. */
|
|
153
|
+
readonly video: HTMLVideoElement;
|
|
154
|
+
|
|
155
|
+
/** Resume; or, with an id (or an index into `titles`), switch to that title and play it. */
|
|
156
|
+
play(id?: string | number): Promise<void>;
|
|
157
|
+
pause(): void;
|
|
158
|
+
/** Play if paused or ended, else pause. */
|
|
159
|
+
toggle(): Promise<void>;
|
|
160
|
+
/** The playlist, read-only. */
|
|
161
|
+
readonly titles: ReadonlyArray<PlayerTitle>;
|
|
162
|
+
/** The title playing now, or null (no playlist, or a source that is not one of its titles). */
|
|
163
|
+
readonly current: PlayerTitle | null;
|
|
164
|
+
/** Replace the playlist; the current title stays current if the new list has its id. */
|
|
165
|
+
setTitles(list: PlayerOptions['titles']): void;
|
|
166
|
+
/** The next title, playing. At the end: nothing, unless `loopList`. */
|
|
167
|
+
next(): Promise<void>;
|
|
168
|
+
/** A remote's "previous": restart if more than 3 s in, else the previous title. */
|
|
169
|
+
back(): Promise<void>;
|
|
170
|
+
/** Clamped to `[0, duration]` (or `[0, t]` before `duration` is known). */
|
|
171
|
+
seek(t: number): void;
|
|
172
|
+
currentTime: number;
|
|
173
|
+
readonly duration: number;
|
|
174
|
+
readonly paused: boolean;
|
|
175
|
+
readonly ended: boolean;
|
|
176
|
+
volume: number;
|
|
177
|
+
muted: boolean;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Swap the source in place (and its poster, if given). Each field of `opts` overrides the
|
|
181
|
+
* player's own for this one swap — `{ durationMs: 1200 }` lengthens its crossfade,
|
|
182
|
+
* `{ transition: 'cut' }` skips it. A crossfade cannot be switched ON here for a player built
|
|
183
|
+
* with `transition: 'cut'`: the mixer would have to become the woven window's paint source
|
|
184
|
+
* mid-flight, which rebuilds the weave layer (the blink a crossfade removes). That swap is a
|
|
185
|
+
* cut, and a console warning says so once.
|
|
186
|
+
*
|
|
187
|
+
* With a cut the old frame holds until the new source reaches `readyState >= 2`. Throws, and
|
|
188
|
+
* changes nothing, on an unknown transition or easing.
|
|
189
|
+
*/
|
|
190
|
+
setSource(src: PlayerSource, opts?: PlayerSourceOptions): void;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Re-skin the SDK transport live — any of `accent` (a CSS colour; `''` = the default), `size`,
|
|
194
|
+
* `skin`. Invalid values warn and are ignored; a no-op with `controls: 'none'`.
|
|
195
|
+
*/
|
|
196
|
+
setAppearance(a: { accent?: PlayerAccent | (string & {}); size?: 's' | 'm' | 'l'; skin?: 'classic' | 'dock' | 'bars' | 'call' }): void;
|
|
197
|
+
|
|
198
|
+
/** Mark a 2D element painted over this window so the weave leaves it crisp — `'sbs'` + a
|
|
199
|
+
* supported wall only; a no-op everywhere else (there is no weave to protect it from). */
|
|
200
|
+
exclude(el: Element): void;
|
|
201
|
+
unexclude(el: Element): void;
|
|
202
|
+
|
|
203
|
+
/** Stop the paint loop / weave window, tear down the transport, release the `<video>`. */
|
|
204
|
+
remove(): void;
|
|
205
|
+
|
|
206
|
+
/** Returns an unsubscribe function, like {@link Inline3D.on}. */
|
|
207
|
+
on(event: PlayerEvent, fn: (payload?: unknown) => void): () => void;
|
|
208
|
+
off(event: PlayerEvent, fn: (payload?: unknown) => void): void;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Load a media title into an inline-3D window with real transport, in one call. Safe with a
|
|
213
|
+
* `null`/unsupported `wall` — it renders flat 2D instead, so pages need no branch.
|
|
214
|
+
* @param wall the manager from `createInline3D()`, or null/unsupported.
|
|
215
|
+
* @param canvas a 2D canvas ALREADY inside a container element — `controls:'sdk'` attaches the
|
|
216
|
+
* transport as a sibling of the canvas, inside `canvas.parentElement`.
|
|
217
|
+
* @param src the video URL, or a Blob/File (an object URL is created for you).
|
|
218
|
+
*/
|
|
219
|
+
export function addPlayer(
|
|
220
|
+
wall: object | null | undefined,
|
|
221
|
+
canvas: HTMLCanvasElement,
|
|
222
|
+
/** `null` with `opts.titles` loads the first title. */
|
|
223
|
+
src: PlayerSource | null,
|
|
224
|
+
opts?: PlayerOptions,
|
|
225
|
+
): PlayerHandle;
|
|
226
|
+
|
|
227
|
+
/** The `setSource()` transitions a video can mean. */
|
|
228
|
+
export type PlayerTransition = 'cut' | 'crossfade';
|
|
229
|
+
|
|
230
|
+
/** `./splat`'s easing names (the same curves), or a function on [0, 1]. */
|
|
231
|
+
export type PlayerEasing =
|
|
232
|
+
| 'linear'
|
|
233
|
+
| 'easeInQuad'
|
|
234
|
+
| 'easeOutQuad'
|
|
235
|
+
| 'easeInOutQuad'
|
|
236
|
+
| 'easeInCubic'
|
|
237
|
+
| 'easeOutCubic'
|
|
238
|
+
| 'easeInOutCubic'
|
|
239
|
+
| 'easeInOutSine'
|
|
240
|
+
| ((x: number) => number);
|
|
241
|
+
|
|
242
|
+
/** Per-call {@link PlayerHandle.setSource} options; each overrides the player's own. */
|
|
243
|
+
export interface PlayerSourceOptions {
|
|
244
|
+
poster?: string;
|
|
245
|
+
/** Replace the now-playing line ('' clears it). */
|
|
246
|
+
title?: string;
|
|
247
|
+
transition?: PlayerTransition;
|
|
248
|
+
durationMs?: number;
|
|
249
|
+
easing?: PlayerEasing;
|
|
250
|
+
/** Only `'frozen'` — the player dissolves from the outgoing title's last frame. */
|
|
251
|
+
outgoing?: 'frozen';
|
|
252
|
+
/** LEGACY: `> 0` = a crossfade of this length, `0` = a cut. `durationMs` wins when both are given. */
|
|
253
|
+
fadeMs?: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The resolved form `resolveTransition()` returns. */
|
|
257
|
+
export interface ResolvedPlayerTransition {
|
|
258
|
+
type: PlayerTransition;
|
|
259
|
+
/** 0 for a cut. */
|
|
260
|
+
durationMs: number;
|
|
261
|
+
easing: PlayerEasing;
|
|
262
|
+
ease: (x: number) => number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The named accent presets `accent` accepts (any CSS colour works too). */
|
|
266
|
+
export type PlayerAccent = 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' | 'lime' | 'mint' | 'ice';
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Pick the first source this browser can play from candidates listed best first; each is a URL, or
|
|
270
|
+
* `{ src, type }` with a FULL `canPlayType` string (`'video/webm; codecs="vp9, opus"'`). The
|
|
271
|
+
* DisplayXR Browser has no H.264/AAC, and a codec-less `'video/mp4'` still answers 'maybe'.
|
|
272
|
+
*/
|
|
273
|
+
export function pickSource(candidates: PlayerSource, canPlayType?: (type: string) => string): string | Blob | undefined;
|