@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.
@@ -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. Resolves to `{ video, remove(), stats() }`;
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
- return { src, format, fit, vH, loop: bool('loop'), muted: bool('muted'), autoplay: bool('autoplay') };
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, 1.0);
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
- this.video = null;
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.format = 'sbs';
180
- this.fit = 'contain';
181
- this.vH = viewer.vH;
182
- this._dirty = false;
183
- this._lastT = -1;
184
- this._rvfc = 0;
185
- this._sizeKey = '';
186
- this._split = NaN;
187
- this._onSeeked = () => (this._dirty = true);
188
- /** Upload accounting (handle.setVideo(...).stats()). */
189
- this.uploads = 0;
190
- this.frames = 0;
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
- /** Play `video` on the quad (a new element, format or fit). The element must have a frame. */
194
- setSource(video, { format, fit, vH }) {
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
- if (this.tex && this.tex.width === w && this.tex.height === h) return;
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 s = videoPlaneSize({ boxAspect, eyeAspect: eyeAspect(this.format, v.videoWidth, v.videoHeight), vH: this.vH, fit: this.fit });
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
- hide() {
292
- this.mi.visible = false;
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]);