@displayxr/inline3d 1.28.0 → 1.30.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 +233 -0
- package/README.md +10 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +328 -217
- package/camera.d.ts +257 -0
- package/dist/call.js +86 -0
- package/index.d.ts +27 -1
- package/js/call/capture.js +4 -167
- package/js/call/disparity.js +4 -0
- package/js/call/element.js +221 -0
- package/js/call/lift.js +9 -0
- package/js/call/options.js +132 -0
- package/js/call/signaling.js +7 -202
- package/js/call/ui.js +106 -9
- package/js/call/wire.js +8 -147
- package/js/camera/capture.js +220 -0
- package/js/camera/converge.js +82 -0
- package/js/camera/disparity.js +432 -0
- package/js/camera/geometry.js +80 -0
- package/js/camera/metadata.js +398 -0
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +609 -318
- package/js/inline3d-camera.js +678 -0
- package/js/inline3d-player.js +646 -216
- package/js/inline3d-splat-deferred.js +153 -0
- package/js/inline3d-splat-pc-entry.js +45 -0
- package/js/inline3d-splat-playcanvas.js +46 -3
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d-splat.js +2 -145
- package/js/inline3d.js +130 -1
- package/js/lift/index.js +15 -0
- package/package.json +39 -4
- package/player.d.ts +46 -2
- package/splat-playcanvas.d.ts +4 -0
- package/splat.d.ts +38 -0
|
@@ -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
|
@@ -23,8 +23,9 @@ import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
|
|
|
23
23
|
import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js';
|
|
24
24
|
import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
|
|
25
25
|
import { readSogCamera } from './inline3d-sog.js';
|
|
26
|
-
import { resolveRevealOption, effectsNotOnSpark
|
|
26
|
+
import { resolveRevealOption, effectsNotOnSpark } from './inline3d-splat-effects.js';
|
|
27
27
|
import { applySplatPerf, splatPerfMeshOptions } from './inline3d-splat-perf.js';
|
|
28
|
+
import { addSplatDeferred } from './inline3d-splat-deferred.js';
|
|
28
29
|
import {
|
|
29
30
|
toArray3,
|
|
30
31
|
canvasNdc,
|
|
@@ -36,7 +37,6 @@ import {
|
|
|
36
37
|
isStreamedUrl,
|
|
37
38
|
STREAMED_NEEDS_PLAYCANVAS,
|
|
38
39
|
resolveControls,
|
|
39
|
-
normalizeCameraPose,
|
|
40
40
|
declareViewRig,
|
|
41
41
|
} from './inline3d-splat-shared.js';
|
|
42
42
|
import {
|
|
@@ -677,149 +677,6 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
677
677
|
return out;
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
-
/**
|
|
681
|
-
* The `engine: 'playcanvas'` handle, returned SYNCHRONOUSLY like the Spark one.
|
|
682
|
-
*
|
|
683
|
-
* The adapter module is loaded on demand, so for a moment the handle exists before its
|
|
684
|
-
* implementation does. Rather than make every caller await something new, the handle starts as
|
|
685
|
-
* stubs that QUEUE: `exclude()` (which a product page calls on the very next line), `setPose`,
|
|
686
|
-
* `setFocus`, `remove` — and the adapter replays the queue into the real implementation, on the
|
|
687
|
-
* SAME object, the moment it arrives. `ready` resolves to this object, as it does on Spark.
|
|
688
|
-
* Fields (`viewer`, `mesh`, `rig`, …) are null until then; `viewer` in particular appears one
|
|
689
|
-
* module-load later than on Spark.
|
|
690
|
-
*/
|
|
691
|
-
/** setRig's type/options shape, checked at the call before the adapter has loaded (it re-checks all of it). */
|
|
692
|
-
function validateSetRigArgs(type, o) {
|
|
693
|
-
if (!['display', 'camera', 'auto'].includes(type)) {
|
|
694
|
-
throw new Error(`@displayxr/inline3d/splat: setRig("${type}") — expected 'display', 'camera' or 'auto'.`);
|
|
695
|
-
}
|
|
696
|
-
if (o !== undefined && (o === null || typeof o !== 'object')) {
|
|
697
|
-
throw new TypeError('@displayxr/inline3d/splat: setRig options must be an object.');
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function addSplatDeferred(wall, canvas, src, opts) {
|
|
702
|
-
const pending = [];
|
|
703
|
-
const queue = (name) => (...args) => {
|
|
704
|
-
pending.push([name, args]);
|
|
705
|
-
return name === 'setFocus' ? out : undefined;
|
|
706
|
-
};
|
|
707
|
-
const page = opts.controls === 'page';
|
|
708
|
-
const pageOnly = (name) => () => {
|
|
709
|
-
throw new Error(
|
|
710
|
-
`@displayxr/inline3d/splat: ${name}() is not available with controls:'page' — the page owns the ` +
|
|
711
|
-
'camera. Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).',
|
|
712
|
-
);
|
|
713
|
-
};
|
|
714
|
-
// Before the adapter lands, a page calling setCameraPose every frame keeps ONE pending pose
|
|
715
|
-
// (validated now, so a bad call throws at its own line): last call wins, as it will after.
|
|
716
|
-
let pendingPose = null;
|
|
717
|
-
const out = {
|
|
718
|
-
backend: 'playcanvas',
|
|
719
|
-
engine: null,
|
|
720
|
-
viewer: null,
|
|
721
|
-
mesh: null,
|
|
722
|
-
frame: null,
|
|
723
|
-
camera: null,
|
|
724
|
-
rig: null,
|
|
725
|
-
perf: null,
|
|
726
|
-
setPose: page ? pageOnly('setPose') : queue('setPose'),
|
|
727
|
-
resetPose: page ? pageOnly('resetPose') : queue('resetPose'),
|
|
728
|
-
setCameraPose(matrixWorld, o) {
|
|
729
|
-
if (!page) {
|
|
730
|
-
throw new Error(
|
|
731
|
-
"@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
|
|
732
|
-
'with the default controls the SDK owns the camera (use setPose).',
|
|
733
|
-
);
|
|
734
|
-
}
|
|
735
|
-
const pose = normalizeCameraPose(matrixWorld, o);
|
|
736
|
-
if (pendingPose) pendingPose[1] = [pose.matrixWorld, pose];
|
|
737
|
-
else pending.push((pendingPose = ['setCameraPose', [pose.matrixWorld, pose]]));
|
|
738
|
-
return out;
|
|
739
|
-
},
|
|
740
|
-
getCameraPose: () =>
|
|
741
|
-
pendingPose
|
|
742
|
-
? {
|
|
743
|
-
matrixWorld: Float32Array.from(pendingPose[1][1].matrixWorld),
|
|
744
|
-
verticalFovDeg: pendingPose[1][1].verticalFovDeg,
|
|
745
|
-
near: pendingPose[1][1].near,
|
|
746
|
-
far: pendingPose[1][1].far,
|
|
747
|
-
convergence: pendingPose[1][1].convergence,
|
|
748
|
-
}
|
|
749
|
-
: null,
|
|
750
|
-
setFocus: queue('setFocus'),
|
|
751
|
-
// A swap requested before the first asset has landed runs once it has (the adapter's own
|
|
752
|
-
// setSource replaces this stub on the same object by then).
|
|
753
|
-
setSource: (...args) => out.ready.then(() => out.setSource(...args)),
|
|
754
|
-
prepareSource: (...args) => out.ready.then(() => out.prepareSource(...args)),
|
|
755
|
-
// A rig switch before the first asset has landed: validated now (controls:'page' and a bad
|
|
756
|
-
// type throw at the call's own line), applied once it has.
|
|
757
|
-
setRig: page
|
|
758
|
-
? pageOnly('setRig')
|
|
759
|
-
: (type, o) => {
|
|
760
|
-
validateSetRigArgs(type, o);
|
|
761
|
-
return out.ready.then(() => out.setRig(type, o));
|
|
762
|
-
},
|
|
763
|
-
// A video before the first asset has landed: controls:'page' throws at the call's own line,
|
|
764
|
-
// the rest is validated by the adapter's setVideo, which runs once the asset is on screen.
|
|
765
|
-
setVideo: page ? pageOnly('setVideo') : (src, o) => out.ready.then(() => out.setVideo(src, o)),
|
|
766
|
-
// Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
|
|
767
|
-
// then run once the first asset is on screen.
|
|
768
|
-
playEffect: (name, o) => {
|
|
769
|
-
validateEffectCall(name, o, 'play');
|
|
770
|
-
return out.ready.then(() => out.playEffect(name, o));
|
|
771
|
-
},
|
|
772
|
-
setEffect: (name, params) => {
|
|
773
|
-
validateEffectCall(name, params, 'set');
|
|
774
|
-
pending.push(['setEffect', [name, params]]);
|
|
775
|
-
return out;
|
|
776
|
-
},
|
|
777
|
-
stopEffect: queue('stopEffect'),
|
|
778
|
-
// A layer-rig request before the adapter has loaded: queued, replayed in order once it has
|
|
779
|
-
// (it needs no asset, only the engine's layers — which the adapter resolves lazily).
|
|
780
|
-
setLayerRig: (...args) => {
|
|
781
|
-
pending.push(['setLayerRig', args]);
|
|
782
|
-
return out;
|
|
783
|
-
},
|
|
784
|
-
setLayerRigOptions: (...args) => {
|
|
785
|
-
pending.push(['setLayerRigOptions', args]);
|
|
786
|
-
return out;
|
|
787
|
-
},
|
|
788
|
-
layerRigState: () => ({ display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'the PlayCanvas adapter has not loaded yet', viewerDistance: 0.6, gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null }),
|
|
789
|
-
makeSbsMaterial: () => {
|
|
790
|
-
throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
|
|
791
|
-
},
|
|
792
|
-
effects: () => [],
|
|
793
|
-
getFocus: () => null,
|
|
794
|
-
// A plain data slot the adapter reads at call time, so a callback assigned on the very next
|
|
795
|
-
// line after addSplat — before the module has loaded — is the one that fires.
|
|
796
|
-
onFocusChange: null,
|
|
797
|
-
pick: () => null,
|
|
798
|
-
// Splat accounting (resident / budget / first frame); null until the adapter has loaded.
|
|
799
|
-
stats: () => null,
|
|
800
|
-
remove: queue('remove'),
|
|
801
|
-
exclude: queue('exclude'),
|
|
802
|
-
unexclude: queue('unexclude'),
|
|
803
|
-
};
|
|
804
|
-
// `firstWoven` exists from the first line, like every other field a page reads right away; the
|
|
805
|
-
// adapter settles it with the core handle's own once the module has loaded.
|
|
806
|
-
out.firstWoven = new Promise((resolve) => {
|
|
807
|
-
out._resolveFirstWoven = resolve;
|
|
808
|
-
});
|
|
809
|
-
// The ONE owner of `ready`: the adapter returns its load promise and never touches this field.
|
|
810
|
-
out.ready = import('./inline3d-splat-playcanvas.js')
|
|
811
|
-
.then((m) => m.attachPlayCanvasSplat(out, wall, canvas, src, opts, pending))
|
|
812
|
-
.catch((err) => {
|
|
813
|
-
// The adapter warns about its own load failures; this is for the module not arriving.
|
|
814
|
-
if (!out.viewer) console.warn('[inline3d/splat] engine:playcanvas failed to start', err);
|
|
815
|
-
if (out._resolveFirstWoven) {
|
|
816
|
-
out._resolveFirstWoven(Object.freeze({ woven: false, confirmed: false, reason: 'layer-failed', ms: 0 }));
|
|
817
|
-
delete out._resolveFirstWoven;
|
|
818
|
-
}
|
|
819
|
-
throw err;
|
|
820
|
-
});
|
|
821
|
-
return out;
|
|
822
|
-
}
|
|
823
680
|
|
|
824
681
|
/**
|
|
825
682
|
* Percentile bounds from the loaded splats — the fallback when no sidecar was supplied.
|