@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.
- package/CHANGELOG.md +224 -0
- package/README.md +6 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +454 -0
- package/dist/call.js +81 -0
- package/index.d.ts +34 -2
- package/js/call/capture.js +212 -0
- package/js/call/disparity.js +432 -0
- package/js/call/element.js +217 -0
- package/js/call/lift.js +323 -0
- package/js/call/qr.js +283 -0
- package/js/call/sdp.js +86 -0
- package/js/call/signaling.js +203 -0
- package/js/call/transport.js +589 -0
- package/js/call/ui.js +146 -0
- package/js/call/wire.js +434 -0
- package/js/inline3d-buffer-limit.js +198 -0
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +1901 -0
- package/js/inline3d-model-playcanvas.js +6 -0
- package/js/inline3d-model.js +6 -0
- package/js/inline3d-player.js +649 -218
- package/js/inline3d-splat-playcanvas.js +134 -18
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +86 -10
- package/js/inline3d.js +257 -15
- package/js/lift/index.js +15 -0
- package/model.d.ts +10 -1
- package/package.json +29 -4
- package/player.d.ts +49 -4
- package/splat.d.ts +52 -3
- package/viewer.d.ts +12 -1
|
@@ -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]);
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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
|
//
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
854
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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)
|
|
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();
|