@displayxr/inline3d 1.27.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.
@@ -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]);
@@ -245,6 +245,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
245
245
  idleSpin,
246
246
  renderScale,
247
247
  feather,
248
+ logTag: '[inline3d/splat]',
248
249
  }).useEyeCamera(EyeCamera, EdgeFeather);
249
250
 
250
251
  // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
@@ -411,6 +412,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
411
412
  },
412
413
  effects: () => [],
413
414
  };
415
+ // The per-eye buffer scale in force (the request times the device-limit clamp,
416
+ // ./inline3d-buffer-limit.js) and the request as given. Accessors, so they stay live.
417
+ Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
418
+ Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
414
419
 
415
420
  // `src` may be a URL or the bytes themselves.
416
421
  //
@@ -64,6 +64,14 @@ import {
64
64
  MONO_NEAR,
65
65
  MONO_FAR,
66
66
  } from './inline3d-splat-shared.js';
67
+ import {
68
+ glBufferLimits,
69
+ clampEyeBuffer,
70
+ clampWarning,
71
+ bufferScale,
72
+ scaleViewport,
73
+ mismatchWarning,
74
+ } from './inline3d-buffer-limit.js';
67
75
 
68
76
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
69
77
  // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
@@ -283,6 +291,14 @@ export class SceneViewer {
283
291
  this.depthLimit = depthLimit;
284
292
  this.fitSweep = fitSweep;
285
293
  this.renderScale = renderScale;
294
+ // The device-limit clamp on the backing store (./inline3d-buffer-limit.js): the factor _resize
295
+ // last had to apply on top of renderScale, 1 when the request fit. effectiveRenderScale reports
296
+ // the product. Warned once per viewer (one viewer per handle).
297
+ this._bufClamp = 1;
298
+ this._warnedBufClamp = false;
299
+ this._warnedBufMismatch = false;
300
+ /** Prefix for this viewer's warnings; ./splat and ./model set their own. */
301
+ this.logTag = opts.logTag || '[inline3d/viewer]';
286
302
  this.pitchLimit = pitchLimit;
287
303
  this.idleSpin = idleSpin;
288
304
 
@@ -711,10 +727,13 @@ export class SceneViewer {
711
727
 
712
728
  // Validated: this frame WILL draw over everything it clears.
713
729
  const r = this.renderer;
730
+ // getViewport() splits canvas.width; a drawing buffer the browser clamped behind our back
731
+ // (bufferScale().mismatch) gets the same split mapped onto its real size, never canvas.width/2.
732
+ const b = this._bufScale();
714
733
  r.clear();
715
734
  r.setScissorTest(true);
716
735
  for (let i = 0; i < views.length; i++) {
717
- const vp = vps[i];
736
+ const vp = scaleViewport(vps[i], b.sx, b.sy);
718
737
  r.setViewport(vp.x, vp.y, vp.width, vp.height);
719
738
  r.setScissor(vp.x, vp.y, vp.width, vp.height);
720
739
  if (eye) {
@@ -753,7 +772,8 @@ export class SceneViewer {
753
772
  this._tick();
754
773
  const r = this.renderer;
755
774
  r.clear();
756
- r.setViewport(0, 0, this.canvas.width, this.canvas.height);
775
+ const b = this._bufScale();
776
+ r.setViewport(0, 0, b.w, b.h);
757
777
  r.render(this.scene, this.monoCamera);
758
778
  };
759
779
  this._monoRaf = requestAnimationFrame(loop);
@@ -849,9 +869,11 @@ export class SceneViewer {
849
869
  const r = this.renderer;
850
870
  const eye = g.mono ? null : this._ensureEye();
851
871
  const el = this.renderer.domElement || this.canvas;
852
- // A resize between the cache and the replay changes the buffer, not the split.
853
- const sx = g.bufW > 0 && el.width ? el.width / g.bufW : 1;
854
- const sy = g.bufH > 0 && el.height ? el.height / g.bufH : 1;
872
+ // A resize between the cache and the replay changes the buffer, not the split. The cache is in
873
+ // canvas-attribute px (getViewport's space); the target is the REAL drawing buffer.
874
+ const b = this._bufScale();
875
+ const sx = g.bufW > 0 && b.w ? b.w / g.bufW : 1;
876
+ const sy = g.bufH > 0 && b.h ? b.h / g.bufH : 1;
855
877
  const scaled = sx !== 1 || sy !== 1;
856
878
  r.clear();
857
879
  r.setScissorTest(true);
@@ -903,7 +925,8 @@ export class SceneViewer {
903
925
  if (this._mode === 'mono') {
904
926
  const r = this.renderer;
905
927
  r.clear();
906
- r.setViewport(0, 0, this.canvas.width, this.canvas.height);
928
+ const b = this._bufScale();
929
+ r.setViewport(0, 0, b.w, b.h);
907
930
  r.render(this.scene, this.monoCamera);
908
931
  return;
909
932
  }
@@ -955,18 +978,71 @@ export class SceneViewer {
955
978
  const box = this.canvas.getBoundingClientRect();
956
979
  if (box.width < 1 || box.height < 1) return;
957
980
  const dpr = Math.min(window.devicePixelRatio || 1, 2) * this.renderScale;
958
- const w = Math.max(1, Math.round(box.width * dpr));
959
- const h = Math.max(1, Math.round(box.height * dpr));
960
- const bufW = this._mode === 'mono' ? w : w * 2;
981
+ // Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js): a store past
982
+ // MAX_TEXTURE_SIZE is silently clamped by the browser while getViewport() keeps splitting
983
+ // canvas.width, which puts the eye boundary off-centre in the woven buffer.
984
+ const c = clampEyeBuffer(
985
+ Math.max(1, Math.round(box.width * dpr)),
986
+ Math.max(1, Math.round(box.height * dpr)),
987
+ glBufferLimits(this._gl()),
988
+ { cols: this._mode === 'mono' ? 1 : 2 },
989
+ );
990
+ this._noteClamp(c);
991
+ const bufW = c.bufW;
992
+ const h = c.bufH;
961
993
  // Cheap and always correct to refresh, whether or not the backing store moves.
962
994
  this.monoCamera.aspect = box.width / box.height;
963
995
  this.monoCamera.updateProjectionMatrix();
964
996
  const el = this.renderer.domElement || this.canvas;
965
- if (el.width === bufW && el.height === h) return; // observer fired, geometry didn't move
997
+ if (el.width === bufW && el.height === h) {
998
+ this._bufScale(); // observer fired, geometry didn't move; still name a browser-side clamp
999
+ return;
1000
+ }
966
1001
  this.renderer.setSize(bufW, h, false);
1002
+ this._bufScale(); // warns once if the browser clamped anyway
967
1003
  this._repaintAfterResize();
968
1004
  }
969
1005
 
1006
+ /**
1007
+ * The renderScale actually in force: the request (`renderScale`) times the device-limit clamp
1008
+ * the last resize applied. Equal to renderScale wherever the store fits.
1009
+ */
1010
+ get effectiveRenderScale() {
1011
+ return this.renderScale * this._bufClamp;
1012
+ }
1013
+
1014
+ _gl() {
1015
+ try {
1016
+ return this.renderer?.getContext?.() || null;
1017
+ } catch {
1018
+ return null;
1019
+ }
1020
+ }
1021
+
1022
+ /** Record a resize's clamp; warn once per viewer the first time the device limit bites. */
1023
+ _noteClamp(c) {
1024
+ this._bufClamp = c.scale;
1025
+ if (c.clamped && !this._warnedBufClamp) {
1026
+ this._warnedBufClamp = true;
1027
+ console.warn(clampWarning(this.logTag, c, this.renderScale));
1028
+ }
1029
+ }
1030
+
1031
+ /**
1032
+ * The drawing buffer's real size and its ratio to canvas.width/height (the space getViewport()
1033
+ * reports in). Identity unless the browser clamped a store the limit query did not predict;
1034
+ * warns once per viewer when it did.
1035
+ */
1036
+ _bufScale() {
1037
+ const el = this.renderer.domElement || this.canvas;
1038
+ const b = bufferScale(el, this._gl());
1039
+ if (b.mismatch && !this._warnedBufMismatch) {
1040
+ this._warnedBufMismatch = true;
1041
+ console.warn(mismatchWarning(this.logTag, el, b));
1042
+ }
1043
+ return b;
1044
+ }
1045
+
970
1046
  /** Damping + idle turntable. Called once per rendered frame, 3D or mono. */
971
1047
  _tick() {
972
1048
  const t = now();