@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/CHANGELOG.md +109 -0
- package/README.md +6 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +103 -23
- package/dist/call.js +81 -0
- package/index.d.ts +27 -1
- package/js/call/capture.js +47 -2
- package/js/call/disparity.js +432 -0
- package/js/call/element.js +217 -0
- package/js/call/lift.js +9 -0
- package/js/call/signaling.js +7 -202
- package/js/call/wire.js +14 -8
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +222 -16
- package/js/inline3d-player.js +646 -216
- package/js/inline3d-splat-playcanvas.js +46 -3
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d.js +130 -1
- package/js/lift/index.js +15 -0
- package/package.json +21 -4
- package/player.d.ts +46 -2
- package/splat.d.ts +38 -0
|
@@ -1915,6 +1915,9 @@ export class PlayCanvasSplatViewer {
|
|
|
1915
1915
|
const box = this.canvas.getBoundingClientRect();
|
|
1916
1916
|
const aspect = box.height > 0 ? box.width / box.height : 1;
|
|
1917
1917
|
this.boxAspect = aspect; // setVideo's plane reads it per frame (no layout read in the draw)
|
|
1918
|
+
// and this, for its on-screen rect: a NEW object only when the size changed (the plane's rect
|
|
1919
|
+
// check compares identity, so an unchanged frame allocates nothing).
|
|
1920
|
+
if (!this.boxCss || this.boxCss.w !== (box.width || 0) || this.boxCss.h !== (box.height || 0)) this.boxCss = { w: box.width || 0, h: box.height || 0 };
|
|
1918
1921
|
if (this.mono.capture) {
|
|
1919
1922
|
captureProjection(this.mono.capture, aspect, this.mono.near, this.mono.far, this.mono.proj, this.captureFit);
|
|
1920
1923
|
if (this.captureFit !== 'height') {
|
|
@@ -3125,6 +3128,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3125
3128
|
// copy a getter's value once.
|
|
3126
3129
|
Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
|
|
3127
3130
|
Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
|
|
3131
|
+
// setVideo's current element: the <video> the plane is SHOWING (not one still waiting for its first
|
|
3132
|
+
// frame), else null. Read-only. A player attached to this handle (./player attachPlayer) reads it
|
|
3133
|
+
// to notice another setVideo taking the slot, which no event reports.
|
|
3134
|
+
Object.defineProperty(out, 'videoElement', { get: () => (vid && vid.on ? vid.el : null), enumerable: true, configurable: true });
|
|
3135
|
+
// The tile's canvas, for page chrome that sits beside it (attachPlayer's controls are its siblings).
|
|
3136
|
+
Object.defineProperty(out, 'canvas', { value: canvas, enumerable: true, configurable: true });
|
|
3128
3137
|
// The current eye offset (after the unit-disc clamp), a fresh object per read.
|
|
3129
3138
|
Object.defineProperty(out, 'viewOffset', {
|
|
3130
3139
|
get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
|
|
@@ -4405,7 +4414,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4405
4414
|
* handle.setVideo(src, options) — play a stereo video on THIS handle: no new canvas, layer or
|
|
4406
4415
|
* session. The splat is hidden and a screen-locked plane on the display rig shows the video, each
|
|
4407
4416
|
* eye its own half (sbs: left/right, tb: top/bottom); flat (mono), the left half at full
|
|
4408
|
-
* resolution. Applies on the first frame the video has
|
|
4417
|
+
* resolution. Applies on the first frame the video has; `transition: 'crossfade'` (with
|
|
4418
|
+
* `durationMs`, `easing`) dissolves from a video already on the plane, on the GPU, and is a cut
|
|
4419
|
+
* from the splat. Resolves to `{ video, transition, remove(), stats() }`;
|
|
4409
4420
|
* the page drives transport through `video`. setVideo(null) exits and restores the splat, the rig
|
|
4410
4421
|
* and its declaration exactly as they were. Throws during an in-flight setSource.
|
|
4411
4422
|
*/
|
|
@@ -4432,7 +4443,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4432
4443
|
if (owned ? r.autoplay !== false : r.autoplay === true) autoplay(el);
|
|
4433
4444
|
// The guard state is taken NOW (setSource / setRig refuse from this call on), the pixels change
|
|
4434
4445
|
// on the first frame the video has.
|
|
4435
|
-
const state = { seq, el, owned, format: r.format, fit: r.fit, vH: r.vH ?? bootFraming.vH, on: false, saved: null, pending: true };
|
|
4446
|
+
const state = { seq, el, owned, format: r.format, fit: r.fit, band: r.band, vH: r.vH ?? bootFraming.vH, on: false, saved: null, pending: true, rectOffs: new Set() };
|
|
4436
4447
|
if (!prev || !prev.on) vid = state; // a pending one it supersedes is cancelled above
|
|
4437
4448
|
return first
|
|
4438
4449
|
.catch(() => null)
|
|
@@ -4444,6 +4455,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4444
4455
|
// Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
|
|
4445
4456
|
state.saved = live.saved;
|
|
4446
4457
|
state.on = true;
|
|
4458
|
+
// S2: the plane outlives this swap; the replaced video's rect subscriptions go with it.
|
|
4459
|
+
for (const off of live.rectOffs) off();
|
|
4460
|
+
live.rectOffs.clear();
|
|
4447
4461
|
if (live.owned && live.el !== el) releaseOwned(live.el);
|
|
4448
4462
|
if (state.vH !== live.vH) {
|
|
4449
4463
|
viewer.vH = state.vH;
|
|
@@ -4454,13 +4468,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4454
4468
|
} else enterVideo(state);
|
|
4455
4469
|
vid = state;
|
|
4456
4470
|
state.pending = false;
|
|
4471
|
+
// A crossfade only dissolves between two videos: from the splat (entry) it is a cut.
|
|
4472
|
+
const fade = live && r.transition.type === 'crossfade' && r.transition.durationMs > 0 ? r.transition : null;
|
|
4457
4473
|
viewer._videoPlane ||= new VideoPlane(viewer);
|
|
4458
|
-
viewer._videoPlane.setSource(el, { format: state.format, fit: state.fit, vH: state.vH });
|
|
4474
|
+
const faded = viewer._videoPlane.setSource(el, { format: state.format, fit: state.fit, vH: state.vH, band: state.band }, fade);
|
|
4459
4475
|
const plane = viewer._videoPlane;
|
|
4460
4476
|
return Object.freeze({
|
|
4461
4477
|
video: el,
|
|
4462
4478
|
format: state.format,
|
|
4463
4479
|
fit: state.fit,
|
|
4480
|
+
/** The letterbox band's aspect, or null. */
|
|
4481
|
+
band: state.band,
|
|
4482
|
+
/**
|
|
4483
|
+
* Where the picture is on screen, in CSS px from the canvas's top-left, clipped to the
|
|
4484
|
+
* canvas: `{ x, y, width, height }`. The bars are the canvas minus this. Null once
|
|
4485
|
+
* another setVideo replaced this one, or before the first drawn frame.
|
|
4486
|
+
*/
|
|
4487
|
+
get rect() {
|
|
4488
|
+
return vid === state && viewer._videoPlane === plane ? plane.rect : null;
|
|
4489
|
+
},
|
|
4490
|
+
/** Called with the new rect whenever it changes (resize, band, a new size). Returns an unsubscribe. */
|
|
4491
|
+
onRectChange(cb) {
|
|
4492
|
+
if (typeof cb !== 'function') throw new TypeError('@displayxr/inline3d/splat: onRectChange(cb) — expected a function.');
|
|
4493
|
+
if (vid !== state) return () => {};
|
|
4494
|
+
// S2: never outlives this video — every subscription is dropped at the swap that
|
|
4495
|
+
// replaces it (and with the plane on setVideo(null)).
|
|
4496
|
+
// Each subscription is its own entry on the plane, so calling this twice is harmless.
|
|
4497
|
+
const off = plane.onRect(cb);
|
|
4498
|
+
const unsubscribe = () => {
|
|
4499
|
+
off();
|
|
4500
|
+
state.rectOffs.delete(unsubscribe);
|
|
4501
|
+
};
|
|
4502
|
+
state.rectOffs.add(unsubscribe);
|
|
4503
|
+
return unsubscribe;
|
|
4504
|
+
},
|
|
4505
|
+
/** What this swap did: 'crossfade' only when a fade actually started, else 'cut'. */
|
|
4506
|
+
transition: faded ? 'crossfade' : 'cut',
|
|
4464
4507
|
/** Exit (setVideo(null)) — a no-op once another setVideo replaced this one. */
|
|
4465
4508
|
remove: () => (vid === state ? setVideo(null) : Promise.resolve(null)),
|
|
4466
4509
|
/** Upload accounting: frames drawn with the plane up, and texture uploads (new frames). */
|
|
@@ -26,11 +26,33 @@
|
|
|
26
26
|
// per-frame allocation on our side. RGBA8, sampled and written UNCHANGED — the same encoded sRGB
|
|
27
27
|
// values a 2D canvas drawImage() of the frame puts in the buffer (addVideo's paint), so the woven
|
|
28
28
|
// video and the flat one are the same pixels.
|
|
29
|
+
//
|
|
30
|
+
// CROSSFADE (transition: 'crossfade', A5.1 of RFC 0001). A video -> video swap can dissolve on the
|
|
31
|
+
// GPU instead of cutting. At the swap the plane's current quad is handed off, as it is, to a GHOST:
|
|
32
|
+
// its texture is frozen (no more uploads, so the outgoing <video> can be released at once), and its
|
|
33
|
+
// material turns blended, drawn after the incoming quad, fading 1 -> 0 over `durationMs`. The
|
|
34
|
+
// incoming video goes onto a fresh quad underneath, opaque from its first frame. So each quad keeps
|
|
35
|
+
// its own size, format and fit, and a swap between two aspects fades bar-for-bar. Only between two
|
|
36
|
+
// videos: entering from the splat and setVideo(null) stay cuts. A swap while a fade is still
|
|
37
|
+
// running drops the old ghost (a small pop) and fades from the video then on screen.
|
|
38
|
+
//
|
|
39
|
+
// BAND (A5.2). `band: 2.39` (or '2.39:1') narrows the window to a centred letterbox slot of that
|
|
40
|
+
// aspect before fitting, exactly as ./player's `band` narrows its tile: the rest of the element
|
|
41
|
+
// stays clear. 'contain' in the band is a smaller quad. 'cover' in the band cannot lean on the
|
|
42
|
+
// window's frustum edges to cut the overflow (the band is inside the window), so the quad is the
|
|
43
|
+
// band exactly and the overflow is cropped in TEXTURE space instead: each eye's region is narrowed
|
|
44
|
+
// about its centre. Without a band nothing changes.
|
|
45
|
+
import { EASINGS } from './inline3d-splat-effects.js';
|
|
29
46
|
|
|
30
47
|
/** handle.setVideo's formats, fits and rigs. */
|
|
31
48
|
export const VIDEO_FORMATS = Object.freeze(['sbs', 'tb', 'mono']);
|
|
32
49
|
export const VIDEO_FITS = Object.freeze(['contain', 'cover']);
|
|
33
|
-
const VIDEO_OPTION_KEYS = new Set(['format', 'fit', 'rig', 'virtualDisplayHeight', 'loop', 'muted', 'autoplay']);
|
|
50
|
+
const VIDEO_OPTION_KEYS = new Set(['format', 'fit', 'rig', 'virtualDisplayHeight', 'loop', 'muted', 'autoplay', 'transition', 'durationMs', 'easing', 'outgoing', 'band']);
|
|
51
|
+
/** setVideo's transitions: the player's vocabulary (./player), not setSource's gaussian ones. */
|
|
52
|
+
export const VIDEO_TRANSITIONS = Object.freeze(['cut', 'crossfade']);
|
|
53
|
+
/** The player's defaults (DEFAULT_CROSSFADE_MS / easeInOutSine), so both surfaces fade alike. */
|
|
54
|
+
export const VIDEO_CROSSFADE_MS = 600;
|
|
55
|
+
const VIDEO_CROSSFADE_EASING = 'easeInOutSine';
|
|
34
56
|
let warnedVideoKeys = false;
|
|
35
57
|
|
|
36
58
|
export const PAGE_VIDEO_ERROR =
|
|
@@ -41,7 +63,9 @@ export const PAGE_VIDEO_ERROR =
|
|
|
41
63
|
* setVideo's arguments, validated and resolved (throws at the call, before anything runs).
|
|
42
64
|
* `src` null/undefined is the exit call and is not validated here.
|
|
43
65
|
* @returns {{ src: string|HTMLVideoElement, format: 'sbs'|'tb'|'mono', fit: 'contain'|'cover',
|
|
44
|
-
* vH: number|undefined, loop?: boolean, muted?: boolean, autoplay?: boolean
|
|
66
|
+
* vH: number|undefined, loop?: boolean, muted?: boolean, autoplay?: boolean,
|
|
67
|
+
* transition: { type: 'cut'|'crossfade', durationMs: number, easing: string|Function },
|
|
68
|
+
* band: number|null }}
|
|
45
69
|
*/
|
|
46
70
|
export function validateSetVideo(src, o = {}, pageMode = false) {
|
|
47
71
|
if (pageMode) throw new Error(PAGE_VIDEO_ERROR);
|
|
@@ -70,7 +94,43 @@ export function validateSetVideo(src, o = {}, pageMode = false) {
|
|
|
70
94
|
if (!Number.isFinite(vH) || !(vH > 0)) throw new Error(`@displayxr/inline3d/splat: setVideo — bad virtualDisplayHeight: ${o.virtualDisplayHeight}.`);
|
|
71
95
|
}
|
|
72
96
|
const bool = (k) => (o[k] === undefined ? undefined : !!o[k]);
|
|
73
|
-
|
|
97
|
+
let band = null;
|
|
98
|
+
if (o.band !== undefined && o.band !== null) {
|
|
99
|
+
band = parseBandAspect(o.band);
|
|
100
|
+
if (band === null) throw new Error(`@displayxr/inline3d/splat: setVideo — bad band: ${JSON.stringify(o.band)} (a number > 0, or 'W:H').`);
|
|
101
|
+
}
|
|
102
|
+
return { src, format, fit, vH, loop: bool('loop'), muted: bool('muted'), autoplay: bool('autoplay'), transition: resolveVideoTransition(o), band };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A band aspect: a number > 0, or 'W:H' / 'W/H' / 'WxH' / a numeric string. Null if unusable. */
|
|
106
|
+
export function parseBandAspect(a) {
|
|
107
|
+
if (typeof a === 'number') return Number.isFinite(a) && a > 0 ? a : null;
|
|
108
|
+
if (typeof a !== 'string') return null;
|
|
109
|
+
const m = a.trim().match(/^(\d+(?:\.\d+)?)\s*[:/x]\s*(\d+(?:\.\d+)?)$/i);
|
|
110
|
+
const v = m ? Number(m[1]) / Number(m[2]) : Number(a);
|
|
111
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** setVideo's transition options: `{ type, durationMs, easing }`. Throws on a page bug. */
|
|
115
|
+
export function resolveVideoTransition(o = {}) {
|
|
116
|
+
const type = o.transition === undefined ? 'cut' : o.transition;
|
|
117
|
+
if (!VIDEO_TRANSITIONS.includes(type)) {
|
|
118
|
+
const name = typeof type === 'string' ? type : type && typeof type === 'object' ? type.type || 'object' : String(type);
|
|
119
|
+
throw new Error(
|
|
120
|
+
`@displayxr/inline3d/splat: setVideo transition '${name}' — expected ${VIDEO_TRANSITIONS.join(' | ')}. ` +
|
|
121
|
+
"setSource's other transitions move a 3D photo's gaussians; a video frame has none.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (o.outgoing !== undefined && o.outgoing !== 'frozen') {
|
|
125
|
+
throw new Error(`@displayxr/inline3d/splat: setVideo outgoing '${o.outgoing}' — a video crossfade dissolves from the outgoing video's last frame ('frozen').`);
|
|
126
|
+
}
|
|
127
|
+
const durationMs = o.durationMs === undefined ? VIDEO_CROSSFADE_MS : o.durationMs;
|
|
128
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) throw new RangeError(`@displayxr/inline3d/splat: setVideo durationMs must be ≥ 0, got ${o.durationMs}.`);
|
|
129
|
+
const easing = o.easing === undefined ? VIDEO_CROSSFADE_EASING : o.easing;
|
|
130
|
+
if (typeof easing !== 'function' && !(typeof easing === 'string' && Object.prototype.hasOwnProperty.call(EASINGS, easing))) {
|
|
131
|
+
throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
|
|
132
|
+
}
|
|
133
|
+
return { type, durationMs, easing };
|
|
74
134
|
}
|
|
75
135
|
|
|
76
136
|
/**
|
|
@@ -96,15 +156,58 @@ export function eyeAspect(format, w, h) {
|
|
|
96
156
|
* the window contained ('contain': all of the frame, bars where the aspects differ) or covered
|
|
97
157
|
* ('cover': the window full, the overflow cut by the window's own frustum edges).
|
|
98
158
|
*/
|
|
99
|
-
export function videoPlaneSize({ boxAspect, eyeAspect: a, vH, fit = 'contain' }) {
|
|
159
|
+
export function videoPlaneSize({ boxAspect, eyeAspect: a, vH, fit = 'contain', band = null }) {
|
|
100
160
|
const W = vH * boxAspect;
|
|
101
161
|
const H = vH;
|
|
102
162
|
if (!(a > 0) || !(boxAspect > 0)) return { w: W, h: H };
|
|
163
|
+
if (band > 0) {
|
|
164
|
+
const b = videoBandSlot(W, H, band);
|
|
165
|
+
// 'cover': the quad IS the band, and the overflow is cut in texture space (videoCrop).
|
|
166
|
+
if (fit === 'cover') return { w: b.w, h: b.h };
|
|
167
|
+
return a >= band ? { w: b.w, h: b.w / a } : { w: b.h * a, h: b.h }; // the slot's aspect is `band`
|
|
168
|
+
}
|
|
103
169
|
const wider = a >= boxAspect;
|
|
104
170
|
if ((fit === 'contain') === wider) return { w: W, h: W / a };
|
|
105
171
|
return { w: H * a, h: H };
|
|
106
172
|
}
|
|
107
173
|
|
|
174
|
+
/** The centred band slot of aspect `band` inside a W × H window (./player's bandBox, sizes only). */
|
|
175
|
+
export function videoBandSlot(W, H, band) {
|
|
176
|
+
if (!(band > 0) || !(W > 0 && H > 0)) return { w: W, h: H };
|
|
177
|
+
return band >= W / H ? { w: W, h: W / band } : { w: H * band, h: H };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The fraction of each eye's region the quad shows, [fx, fy], centred: [1, 1] except 'cover' in a
|
|
182
|
+
* band, where the quad is the band and the overflow is cropped in texture space.
|
|
183
|
+
*/
|
|
184
|
+
export function videoCrop({ boxAspect, eyeAspect: a, fit, band }) {
|
|
185
|
+
if (fit !== 'cover' || !(band > 0) || !(a > 0) || !(boxAspect > 0)) return [1, 1];
|
|
186
|
+
// The slot keeps the band's aspect whatever the window, so crop the eye to `band` directly.
|
|
187
|
+
return a >= band ? [band / a, 1] : [1, a / band];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The quad's on-screen rect in CSS px of a `boxW × boxH` canvas: the display rig's z = 0 plane spans
|
|
192
|
+
* the element, so window units map linearly onto it, centred. Clipped to the canvas (a 'cover' quad
|
|
193
|
+
* outside a band overflows it), rounded to 1/100 px.
|
|
194
|
+
*/
|
|
195
|
+
export function videoScreenRect({ w, h, W, H }, boxW, boxH) {
|
|
196
|
+
const pw = (w / W) * boxW;
|
|
197
|
+
const ph = (h / H) * boxH;
|
|
198
|
+
const x0 = Math.max(0, (boxW - pw) / 2);
|
|
199
|
+
const y0 = Math.max(0, (boxH - ph) / 2);
|
|
200
|
+
const x1 = Math.min(boxW, (boxW + pw) / 2);
|
|
201
|
+
const y1 = Math.min(boxH, (boxH + ph) / 2);
|
|
202
|
+
const q = (v) => Math.round(v * 100) / 100;
|
|
203
|
+
return Object.freeze({ x: q(x0), y: q(y0), width: q(x1 - x0), height: q(y1 - y0) });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** An eye region [s0, t0, ds, dt] narrowed about its centre to [fx, fy] of itself. */
|
|
207
|
+
export function cropRegion(r, [fx, fy]) {
|
|
208
|
+
return [r[0] + (r[2] * (1 - fx)) / 2, r[1] + (r[3] * (1 - fy)) / 2, r[2] * fx, r[3] * fy];
|
|
209
|
+
}
|
|
210
|
+
|
|
108
211
|
/**
|
|
109
212
|
* The first right-eye pixel column in the buffer for this frame's views (entries in BUFFER px,
|
|
110
213
|
* through `rect`), or +∞ for one view (mono: every fragment is the left eye). With N > 2 views the
|
|
@@ -132,11 +235,12 @@ uniform vec4 dxrVidL; // left eye's region: s0, t0, ds, dt (t = image rows,
|
|
|
132
235
|
uniform vec4 dxrVidR; // right eye's
|
|
133
236
|
uniform vec2 dxrVidTexel; // half a texel, per axis
|
|
134
237
|
uniform float dxrVidSplit; // first right-eye pixel column in the buffer (1e9 = mono)
|
|
238
|
+
uniform float dxrVidAlpha; // 1, except on a crossfade's outgoing ghost
|
|
135
239
|
void main() {
|
|
136
240
|
vec4 r = gl_FragCoord.x >= dxrVidSplit ? dxrVidR : dxrVidL;
|
|
137
241
|
vec2 st = r.xy + vec2(vUv.x, 1.0 - vUv.y) * r.zw;
|
|
138
242
|
st = clamp(st, r.xy + dxrVidTexel, r.xy + r.zw - dxrVidTexel);
|
|
139
|
-
gl_FragColor = vec4(texture2D(dxrVid, st).rgb,
|
|
243
|
+
gl_FragColor = vec4(texture2D(dxrVid, st).rgb, dxrVidAlpha);
|
|
140
244
|
}`;
|
|
141
245
|
|
|
142
246
|
/**
|
|
@@ -154,6 +258,45 @@ export class VideoPlane {
|
|
|
154
258
|
mesh.setIndices([0, 1, 2, 0, 2, 3]);
|
|
155
259
|
mesh.update();
|
|
156
260
|
this.mesh = mesh;
|
|
261
|
+
this.layer = viewer.app.scene.layers.getLayerById(pc.LAYERID_WORLD ?? 0);
|
|
262
|
+
this._split = NaN;
|
|
263
|
+
this._build();
|
|
264
|
+
/** A crossfade's outgoing quad: { mat, node, mi, tex, durationMs, ease, t0 }, or null. */
|
|
265
|
+
this.ghost = null;
|
|
266
|
+
|
|
267
|
+
this.video = null;
|
|
268
|
+
this.tex = null;
|
|
269
|
+
this.format = 'sbs';
|
|
270
|
+
this.fit = 'contain';
|
|
271
|
+
this.band = null;
|
|
272
|
+
this.vH = viewer.vH;
|
|
273
|
+
this._dirty = false;
|
|
274
|
+
this._lastT = -1;
|
|
275
|
+
this._rvfc = 0;
|
|
276
|
+
this._sizeKey = '';
|
|
277
|
+
this._onSeeked = () => (this._dirty = true);
|
|
278
|
+
/** The picture's on-screen rect (CSS px, canvas-relative, clipped), and who wants to know. */
|
|
279
|
+
this.rect = null;
|
|
280
|
+
this._rectKey = '';
|
|
281
|
+
this._planeSize = null;
|
|
282
|
+
this._rectListeners = new Set();
|
|
283
|
+
this._rectBox = null;
|
|
284
|
+
this._rectPlane = null;
|
|
285
|
+
this._texSource = null;
|
|
286
|
+
this._eyeA = 0; // the current video's eye aspect, as last sized (the ghost's geometry)
|
|
287
|
+
this._drawn = false; // sized + cropped for the current setSource's options (review N-B)
|
|
288
|
+
this._texW = 0;
|
|
289
|
+
this._texH = 0;
|
|
290
|
+
/** How many times the rect was recomputed (a test hook for the no-per-frame-allocation promise). */
|
|
291
|
+
this.rectComputes = 0;
|
|
292
|
+
/** Upload accounting (handle.setVideo(...).stats()). */
|
|
293
|
+
this.uploads = 0;
|
|
294
|
+
this.frames = 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The quad this plane draws the current video on: material, node (under the rig), instance. */
|
|
298
|
+
_build() {
|
|
299
|
+
const pc = this.pc;
|
|
157
300
|
const mat = new pc.ShaderMaterial({
|
|
158
301
|
uniqueName: 'inline3dVideoPlane',
|
|
159
302
|
attributes: { vertex_position: pc.SEMANTIC_POSITION, vertex_texCoord0: pc.SEMANTIC_TEXCOORD0 },
|
|
@@ -161,40 +304,130 @@ export class VideoPlane {
|
|
|
161
304
|
fragmentGLSL: FRAG,
|
|
162
305
|
});
|
|
163
306
|
mat.cull = pc.CULLFACE_NONE;
|
|
164
|
-
mat.setParameter('dxrVidSplit', 1e9);
|
|
307
|
+
mat.setParameter('dxrVidSplit', Number.isFinite(this._split) ? this._split : 1e9);
|
|
308
|
+
mat.setParameter('dxrVidAlpha', 1);
|
|
165
309
|
mat.update();
|
|
166
310
|
this.mat = mat;
|
|
167
311
|
// Under the rig node: fixed relative to the eyes (see the header).
|
|
168
312
|
this.node = new pc.GraphNode('inline3d-video');
|
|
169
|
-
viewer.rigNode.addChild(this.node);
|
|
170
|
-
const mi = new pc.MeshInstance(mesh, mat, this.node);
|
|
313
|
+
this.viewer.rigNode.addChild(this.node);
|
|
314
|
+
const mi = new pc.MeshInstance(this.mesh, mat, this.node);
|
|
171
315
|
mi.cull = false;
|
|
172
316
|
mi.visible = false;
|
|
173
317
|
this.mi = mi;
|
|
174
|
-
this.layer = viewer.app.scene.layers.getLayerById(pc.LAYERID_WORLD ?? 0);
|
|
175
318
|
this.layer.addMeshInstances([mi]);
|
|
319
|
+
}
|
|
176
320
|
|
|
177
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Hand the current quad, as it is, to the ghost that fades out over it, and build a fresh one for
|
|
323
|
+
* the incoming video. The ghost's texture is never uploaded again, so its <video> may go.
|
|
324
|
+
* (One exception, accepted: a WebGL context loss mid-fade re-uploads every texture on restore,
|
|
325
|
+
* and the ghost's element may have been released by then — the ghost is blank for what is left
|
|
326
|
+
* of the fade, under a second.)
|
|
327
|
+
*/
|
|
328
|
+
_startFade({ durationMs, easing }) {
|
|
329
|
+
this._dropGhost();
|
|
330
|
+
this._unwatch();
|
|
331
|
+
const pc = this.pc;
|
|
332
|
+
const mat = this.mat;
|
|
333
|
+
// Colour: over. Alpha: ONE / ONE_MINUS_SRC_ALPHA, so the buffer stays opaque where the incoming
|
|
334
|
+
// quad is (1·a + 1·(1−a) = 1) and fades to the page where only the ghost is (its bars differ):
|
|
335
|
+
// plain SRC_ALPHA on alpha too would leave a² + (1−a) < 1 mid-fade, and the page would show
|
|
336
|
+
// through the picture.
|
|
337
|
+
// (The factors are straight-alpha colour + ONE on alpha: a premultiplied result into the
|
|
338
|
+
// premultipliedAlpha canvas. No guard: the 2.22.3 floor always has BlendState, and without it
|
|
339
|
+
// the ghost would silently be an opaque quad with depth test off.)
|
|
340
|
+
mat.blendState = new pc.BlendState(true, pc.BLENDEQUATION_ADD, pc.BLENDMODE_SRC_ALPHA, pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
|
341
|
+
pc.BLENDEQUATION_ADD, pc.BLENDMODE_ONE, pc.BLENDMODE_ONE_MINUS_SRC_ALPHA);
|
|
342
|
+
// The same plane as the incoming quad: drawn over it, never z-fighting it. The cost: for the
|
|
343
|
+
// fade's length the ghost also paints over opaque page geometry IN FRONT of the screen plane
|
|
344
|
+
// (a makeSbsMaterial quad, handle.engine content), which the incoming quad sits behind.
|
|
345
|
+
mat.depthTest = false;
|
|
346
|
+
mat.depthWrite = false;
|
|
347
|
+
mat.setParameter('dxrVidAlpha', 1);
|
|
348
|
+
mat.update();
|
|
349
|
+
const ease = typeof easing === 'function' ? easing : EASINGS[easing] || EASINGS.linear;
|
|
350
|
+
// S4: its size as a fraction of the window, so a refit (a new virtualDisplayHeight) or a resize
|
|
351
|
+
// during the fade keeps the outgoing picture where it was on screen instead of jumping.
|
|
352
|
+
// S4 / review R4: the ghost keeps its OWN geometry (eye aspect, fit, band) and is re-sized every
|
|
353
|
+
// tick against the current window, so a refit (a new virtualDisplayHeight) or a resize mid-fade
|
|
354
|
+
// keeps the outgoing picture where it was, without stretching it.
|
|
355
|
+
// The eye aspect cached when this quad was last sized, NOT read off the element now: for a URL
|
|
356
|
+
// source setVideo has already released the outgoing <video> (load() zeroes videoWidth), and a
|
|
357
|
+
// NaN aspect would stretch the ghost over the letterbox bars for the whole fade.
|
|
358
|
+
const geom = { a: this._eyeA, fit: this.fit, band: this.band };
|
|
359
|
+
this.ghost = { mat, node: this.node, mi: this.mi, tex: this.tex, durationMs, ease, t0: null, geom };
|
|
178
360
|
this.tex = null;
|
|
179
|
-
this.
|
|
180
|
-
this.
|
|
181
|
-
this.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
361
|
+
this._texSource = null;
|
|
362
|
+
this.video = null;
|
|
363
|
+
this._build();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_dropGhost() {
|
|
367
|
+
const g = this.ghost;
|
|
368
|
+
if (!g) return;
|
|
369
|
+
this.ghost = null;
|
|
370
|
+
this.layer?.removeMeshInstances?.([g.mi]);
|
|
371
|
+
g.mi.destroy?.(); // its reference on the shared mesh
|
|
372
|
+
g.node.parent?.removeChild?.(g.node);
|
|
373
|
+
g.tex?.destroy?.();
|
|
374
|
+
g.mat.destroy?.();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** One engine tick of the fade: the ghost's opacity and eye split; gone at the end. */
|
|
378
|
+
_tickGhost(split) {
|
|
379
|
+
const g = this.ghost;
|
|
380
|
+
if (!g) return;
|
|
381
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
382
|
+
if (g.t0 === null) g.t0 = now; // the clock starts on the incoming video's first drawn frame
|
|
383
|
+
const x = g.durationMs > 0 ? Math.min(1, Math.max(0, (now - g.t0) / g.durationMs)) : 1;
|
|
384
|
+
if (x >= 1) return this._dropGhost();
|
|
385
|
+
const s = videoPlaneSize({ boxAspect: this.viewer.boxAspect, eyeAspect: g.geom.a, vH: this.vH, fit: g.geom.fit, band: g.geom.band });
|
|
386
|
+
g.node.setLocalScale(s.w, s.h, 1);
|
|
387
|
+
// Review R2: a page easing may overshoot (easeOutBack) or throw. Clamp it (a negative
|
|
388
|
+
// ONE_MINUS_SRC_ALPHA would wreck the colours and the buffer's opacity), and a throw ends the
|
|
389
|
+
// fade instead of aborting every frame's draw until it would have finished.
|
|
390
|
+
let e;
|
|
391
|
+
try {
|
|
392
|
+
e = g.ease(x);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error('[inline3d/splat] setVideo easing threw; ending the crossfade:', err);
|
|
395
|
+
return this._dropGhost();
|
|
396
|
+
}
|
|
397
|
+
const alpha = Number.isFinite(e) ? 1 - Math.min(1, Math.max(0, e)) : 0;
|
|
398
|
+
g.mat.setParameter('dxrVidSplit', split);
|
|
399
|
+
g.mat.setParameter('dxrVidAlpha', alpha);
|
|
191
400
|
}
|
|
192
401
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
402
|
+
/** True while a crossfade is running. */
|
|
403
|
+
get fading() {
|
|
404
|
+
return !!this.ghost;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Play `video` on the quad (a new element, format or fit). The element must have a frame.
|
|
409
|
+
* `fade` ({ durationMs, easing }): dissolve from the video now on the quad instead of cutting.
|
|
410
|
+
* Returns true when a crossfade actually started.
|
|
411
|
+
*/
|
|
412
|
+
setSource(video, { format, fit, vH, band = null }, fade = null) {
|
|
413
|
+
const newElement = video !== this.video;
|
|
414
|
+
// Review R1: only a video that has been DRAWN can be a ghost (it has a size, crop and a bound
|
|
415
|
+
// texture). Two swaps before the next frame make the middle one a cut, never a blank quad.
|
|
416
|
+
const fading = !!(fade && fade.durationMs > 0 && this.video && this.tex && this._drawn && newElement);
|
|
417
|
+
if (fading) this._startFade(fade);
|
|
418
|
+
else if (newElement) this._dropGhost(); // S1: a cut ends any fade still running
|
|
419
|
+
if (newElement) {
|
|
420
|
+
// S3: the new video has no on-screen rect until its first drawn frame.
|
|
421
|
+
this.rect = null;
|
|
422
|
+
this._rectKey = '';
|
|
423
|
+
this._planeSize = null;
|
|
424
|
+
this._eyeA = 0;
|
|
425
|
+
this._rectBox = null;
|
|
426
|
+
}
|
|
195
427
|
this.format = format;
|
|
196
428
|
this.fit = fit;
|
|
197
429
|
this.vH = vH;
|
|
430
|
+
this.band = band;
|
|
198
431
|
const r = eyeRegions(format);
|
|
199
432
|
this.mat.setParameter('dxrVidL', r.L);
|
|
200
433
|
this.mat.setParameter('dxrVidR', r.R);
|
|
@@ -208,6 +441,8 @@ export class VideoPlane {
|
|
|
208
441
|
this._dirty = true;
|
|
209
442
|
this.mat.update();
|
|
210
443
|
this.mi.visible = true;
|
|
444
|
+
this._drawn = false; // the quad's regions/size for these options arrive with the next draw
|
|
445
|
+
return fading;
|
|
211
446
|
}
|
|
212
447
|
|
|
213
448
|
_watch() {
|
|
@@ -236,8 +471,24 @@ export class VideoPlane {
|
|
|
236
471
|
const v = this.video;
|
|
237
472
|
const w = v.videoWidth || 0;
|
|
238
473
|
const h = v.videoHeight || 0;
|
|
239
|
-
|
|
474
|
+
// Our own record of the size the texture was made for: the real engine re-derives tex.width at
|
|
475
|
+
// upload from `video.width || videoWidth`, and `video.width` is the HTML attribute, so a
|
|
476
|
+
// <video width="640"> playing 1920 wide would never match and every swap would re-make it.
|
|
477
|
+
if (this.tex && this._texW === w && this._texH === h) {
|
|
478
|
+
// B1: same size, but maybe a different element (a cut between two same-size titles): the
|
|
479
|
+
// texture must be re-pointed, or it keeps sampling the old video.
|
|
480
|
+
if (this._texSource !== v) {
|
|
481
|
+
this.tex.setSource?.(v);
|
|
482
|
+
this._texSource = v;
|
|
483
|
+
this._lastT = v.currentTime;
|
|
484
|
+
this._dirty = false;
|
|
485
|
+
this.uploads++;
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
240
489
|
this.tex?.destroy?.();
|
|
490
|
+
this._texW = w;
|
|
491
|
+
this._texH = h;
|
|
241
492
|
this.tex = new pc.Texture(this.viewer.app.graphicsDevice, {
|
|
242
493
|
name: 'inline3d-video',
|
|
243
494
|
width: Math.max(1, w),
|
|
@@ -251,6 +502,7 @@ export class VideoPlane {
|
|
|
251
502
|
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
252
503
|
});
|
|
253
504
|
this.tex.setSource?.(v);
|
|
505
|
+
this._texSource = v;
|
|
254
506
|
this.mat.setParameter('dxrVid', this.tex);
|
|
255
507
|
this.mat.setParameter('dxrVidTexel', [0.5 / Math.max(1, w), 0.5 / Math.max(1, h)]);
|
|
256
508
|
this._lastT = v.currentTime;
|
|
@@ -268,14 +520,24 @@ export class VideoPlane {
|
|
|
268
520
|
this._split = split;
|
|
269
521
|
this.mat.setParameter('dxrVidSplit', split);
|
|
270
522
|
}
|
|
523
|
+
this._tickGhost(split);
|
|
271
524
|
const boxAspect = this.viewer.boxAspect;
|
|
272
|
-
const key = `${boxAspect}|${v.videoWidth}x${v.videoHeight}|${this.format}|${this.fit}|${this.vH}`;
|
|
525
|
+
const key = `${boxAspect}|${v.videoWidth}x${v.videoHeight}|${this.format}|${this.fit}|${this.vH}|${this.band}`;
|
|
273
526
|
if (key !== this._sizeKey) {
|
|
274
527
|
this._sizeKey = key;
|
|
275
528
|
this._ensureTexture();
|
|
276
|
-
const
|
|
529
|
+
const a = eyeAspect(this.format, v.videoWidth, v.videoHeight);
|
|
530
|
+
this._eyeA = a;
|
|
531
|
+
this._drawn = true; // sized and cropped for the current options: it can be a ghost now
|
|
532
|
+
const s = videoPlaneSize({ boxAspect, eyeAspect: a, vH: this.vH, fit: this.fit, band: this.band });
|
|
277
533
|
this.node.setLocalScale(s.w, s.h, 1);
|
|
534
|
+
this._planeSize = { w: s.w, h: s.h, W: this.vH * boxAspect, H: this.vH };
|
|
535
|
+
const crop = videoCrop({ boxAspect, eyeAspect: a, fit: this.fit, band: this.band });
|
|
536
|
+
const r = eyeRegions(this.format);
|
|
537
|
+
this.mat.setParameter('dxrVidL', cropRegion(r.L, crop));
|
|
538
|
+
this.mat.setParameter('dxrVidR', cropRegion(r.R, crop));
|
|
278
539
|
}
|
|
540
|
+
this._updateRect();
|
|
279
541
|
// A new frame: rVFC said so, a seek landed, or (no rVFC) the clock moved.
|
|
280
542
|
const t = v.currentTime;
|
|
281
543
|
if (this._dirty || (!this._rvfc && t !== this._lastT)) {
|
|
@@ -288,11 +550,48 @@ export class VideoPlane {
|
|
|
288
550
|
}
|
|
289
551
|
}
|
|
290
552
|
|
|
291
|
-
|
|
292
|
-
|
|
553
|
+
/** `cb(rect)` on every change of the on-screen rect; returns an unsubscribe. */
|
|
554
|
+
onRect(cb) {
|
|
555
|
+
// One entry per SUBSCRIPTION, not per function: the same `place` subscribed on two results (or
|
|
556
|
+
// twice) must not share an entry, or the first unsubscribe would silently remove both.
|
|
557
|
+
const sub = { cb };
|
|
558
|
+
this._rectListeners.add(sub);
|
|
559
|
+
return () => this._rectListeners.delete(sub);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The quad in CSS px of the canvas box, per frame; listeners hear only a change. */
|
|
563
|
+
_updateRect() {
|
|
564
|
+
const box = this.viewer.boxCss;
|
|
565
|
+
const s = this._planeSize;
|
|
566
|
+
if (!box || !s || !(box.w > 0) || !(box.h > 0)) return;
|
|
567
|
+
// N3: nothing moved, nothing allocated (the viewer replaces boxCss only when it re-measures).
|
|
568
|
+
if (box === this._rectBox && s === this._rectPlane) return;
|
|
569
|
+
this._rectBox = box;
|
|
570
|
+
this._rectPlane = s;
|
|
571
|
+
this.rectComputes++;
|
|
572
|
+
const r = videoScreenRect(s, box.w, box.h);
|
|
573
|
+
const key = `${r.x}|${r.y}|${r.width}|${r.height}`;
|
|
574
|
+
if (key === this._rectKey) return;
|
|
575
|
+
this._rectKey = key;
|
|
576
|
+
this.rect = r;
|
|
577
|
+
for (const sub of [...this._rectListeners]) {
|
|
578
|
+
// A microtask: after this draw, still inside the same rAF task (before paint), so controls
|
|
579
|
+
// move in the same frame and a throw is isolated. A callback that READS layout still forces
|
|
580
|
+
// layout inside the frame.
|
|
581
|
+
queueMicrotask(() => {
|
|
582
|
+
if (!this._rectListeners.has(sub)) return; // unsubscribed (or its video replaced) since
|
|
583
|
+
try {
|
|
584
|
+
sub.cb(r);
|
|
585
|
+
} catch (err) {
|
|
586
|
+
console.error('[inline3d/splat] onRectChange callback threw:', err);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
293
590
|
}
|
|
294
591
|
|
|
295
592
|
destroy() {
|
|
593
|
+
this._rectListeners.clear();
|
|
594
|
+
this._dropGhost();
|
|
296
595
|
this._unwatch();
|
|
297
596
|
this.video = null;
|
|
298
597
|
this.layer?.removeMeshInstances?.([this.mi]);
|