@displayxr/inline3d 1.16.0 → 1.18.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 +64 -0
- package/README.md +5 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/js/inline3d-model-playcanvas.js +351 -8
- package/js/inline3d-playcanvas-engine.js +1 -0
- package/js/inline3d-splat-playcanvas.js +327 -17
- package/js/inline3d-splat-video.js +305 -0
- package/js/inline3d-splat.js +10 -0
- package/model.d.ts +12 -6
- package/package.json +2 -1
- package/splat.d.ts +57 -4
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
particleSpan,
|
|
64
64
|
} from './inline3d-splat-effects.js';
|
|
65
65
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
66
|
+
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
|
|
66
67
|
import {
|
|
67
68
|
clamp,
|
|
68
69
|
finite,
|
|
@@ -652,6 +653,7 @@ export class PlayCanvasSplatViewer {
|
|
|
652
653
|
// loaded). 'none' for splats — their colours are already display-referred; ./model's mesh
|
|
653
654
|
// tiles pass 'neutral' (Khronos PBR Neutral), the glTF Sample Viewer's default.
|
|
654
655
|
this.toneMapping = toneMapping;
|
|
656
|
+
this.sceneColor = false;
|
|
655
657
|
// Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
|
|
656
658
|
// other; splats only test against them). The projections' own near/far stay the adapter's —
|
|
657
659
|
// these only raise the near (floor) and lower the far (cap). Unset: untouched.
|
|
@@ -663,6 +665,11 @@ export class PlayCanvasSplatViewer {
|
|
|
663
665
|
this.orbitMaxDeg = orbitMaxDeg;
|
|
664
666
|
this.orbitEase = { drag: orbitEase.drag ?? ORBIT_TAU_DRAG_S, rest: orbitEase.rest ?? ORBIT_TAU_REST_S };
|
|
665
667
|
this._orbitMode = null;
|
|
668
|
+
/** handle.setVideo: orbit/zoom input ignored while a video plane is on. */
|
|
669
|
+
this.inputLocked = false;
|
|
670
|
+
/** handle.setVideo's plane (./inline3d-splat-video.js) while a video is on, else null. */
|
|
671
|
+
this._videoPlane = null;
|
|
672
|
+
this.boxAspect = 1;
|
|
666
673
|
this.featherPx = feather > 0 ? feather : 0;
|
|
667
674
|
this.captureFit = captureFit;
|
|
668
675
|
/** Called when the capture camera's vertical FOV changes (captureFit 'cover' on a resize). */
|
|
@@ -927,6 +934,18 @@ export class PlayCanvasSplatViewer {
|
|
|
927
934
|
if (this._viewPath === 'cameras') for (const c of this._views) if (c?.camera) c.camera.toneMapping = v;
|
|
928
935
|
}
|
|
929
936
|
|
|
937
|
+
/**
|
|
938
|
+
* Turn the scene-colour grab pass on (true) or off on every eye camera — what a
|
|
939
|
+
* KHR_materials_transmission material samples (./model's prepareTransmission). Idempotent: the
|
|
940
|
+
* engine's `renderSceneColorMap` setter counts one request per camera. Cameras made later (a
|
|
941
|
+
* view-path switch) inherit it via `_makeCamera`.
|
|
942
|
+
*/
|
|
943
|
+
useSceneColor(on = true) {
|
|
944
|
+
this.sceneColor = !!on;
|
|
945
|
+
const cams = [this.eye, ...(this._viewPath === 'cameras' ? this._views : [])];
|
|
946
|
+
for (const c of cams) if (c?.camera && c.camera.renderSceneColorMap !== undefined) c.camera.renderSceneColorMap = this.sceneColor;
|
|
947
|
+
}
|
|
948
|
+
|
|
930
949
|
/** Forget the auto-fit (scale 1, no subject box) — the camera rig's framing. */
|
|
931
950
|
resetFit() {
|
|
932
951
|
this._fitScale = 1;
|
|
@@ -1011,6 +1030,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1011
1030
|
}
|
|
1012
1031
|
this._live?.destroy();
|
|
1013
1032
|
this._live = null;
|
|
1033
|
+
this._videoPlane?.destroy();
|
|
1034
|
+
this._videoPlane = null;
|
|
1014
1035
|
try {
|
|
1015
1036
|
this.app?.destroy();
|
|
1016
1037
|
} catch (err) {
|
|
@@ -1490,6 +1511,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1490
1511
|
// decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
|
|
1491
1512
|
// NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
|
|
1492
1513
|
e.camera.toneMapping = TONE_MAPPINGS[this.toneMapping] ? pc[TONE_MAPPINGS[this.toneMapping]] ?? pc.TONEMAP_NONE : pc.TONEMAP_NONE;
|
|
1514
|
+
if (this.sceneColor) e.camera.renderSceneColorMap = true;
|
|
1493
1515
|
this.rigNode.addChild(e);
|
|
1494
1516
|
return e;
|
|
1495
1517
|
}
|
|
@@ -1530,6 +1552,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1530
1552
|
_updateMonoProjection() {
|
|
1531
1553
|
const box = this.canvas.getBoundingClientRect();
|
|
1532
1554
|
const aspect = box.height > 0 ? box.width / box.height : 1;
|
|
1555
|
+
this.boxAspect = aspect; // setVideo's plane reads it per frame (no layout read in the draw)
|
|
1533
1556
|
if (this.mono.capture) {
|
|
1534
1557
|
captureProjection(this.mono.capture, aspect, this.mono.near, this.mono.far, this.mono.proj, this.captureFit);
|
|
1535
1558
|
if (this.captureFit !== 'height') {
|
|
@@ -1638,6 +1661,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1638
1661
|
}
|
|
1639
1662
|
}
|
|
1640
1663
|
this._updateFeather(entries[0].width * sx, entries[0].height * sy);
|
|
1664
|
+
// handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
|
|
1665
|
+
this._videoPlane?.beforeDraw(entries, rect);
|
|
1641
1666
|
app.tick(now());
|
|
1642
1667
|
this._afterTick();
|
|
1643
1668
|
return true;
|
|
@@ -1845,6 +1870,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1845
1870
|
let restYaw = 0;
|
|
1846
1871
|
let restPitch = 0;
|
|
1847
1872
|
this._onDown = (ev) => {
|
|
1873
|
+
if (this.inputLocked) return; // handle.setVideo: a screen-locked plane has nothing to orbit
|
|
1848
1874
|
dragging = true;
|
|
1849
1875
|
startX = ev.clientX;
|
|
1850
1876
|
startY = ev.clientY;
|
|
@@ -1879,6 +1905,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1879
1905
|
el.releasePointerCapture?.(ev.pointerId);
|
|
1880
1906
|
};
|
|
1881
1907
|
this._onWheel = (ev) => {
|
|
1908
|
+
if (this.inputLocked) return;
|
|
1882
1909
|
ev.preventDefault();
|
|
1883
1910
|
let px = ev.deltaY;
|
|
1884
1911
|
if (ev.deltaMode === 1) px *= WHEEL_LINE_PX;
|
|
@@ -2170,8 +2197,10 @@ export function describeResource(res) {
|
|
|
2170
2197
|
* handle.setRig's DISPLAY-rig defaults: addModel's, so a mesh framed through setRig('display')
|
|
2171
2198
|
* sits exactly where `addModel(url, { engine: 'playcanvas' })` puts it (docs/playcanvas-adapter.md
|
|
2172
2199
|
* §setRig). `toneMapping` applies to the page's meshes while the splat is hidden (the splat keeps
|
|
2173
|
-
* 'none' whenever it is shown); `environment
|
|
2174
|
-
*
|
|
2200
|
+
* 'none' whenever it is shown); `environment` installs addModel's IBL of the same name when the
|
|
2201
|
+
* page has no `scene.envAtlas` of its own, and removes it again when the rig changes back —
|
|
2202
|
+
* `'neutral'` (default, the Sample Viewer's studio) or `'room'` (three's RoomEnvironment; its
|
|
2203
|
+
* tone mapping defaults to `'none'`, three's, unless `toneMapping` is passed).
|
|
2175
2204
|
*/
|
|
2176
2205
|
export const SET_RIG_DISPLAY_DEFAULTS = Object.freeze({
|
|
2177
2206
|
virtualDisplayHeight: 0.24,
|
|
@@ -2184,13 +2213,20 @@ export const SET_RIG_DISPLAY_DEFAULTS = Object.freeze({
|
|
|
2184
2213
|
parallaxFactor: 1,
|
|
2185
2214
|
perspectiveFactor: 1,
|
|
2186
2215
|
toneMapping: 'neutral',
|
|
2187
|
-
environment: '
|
|
2216
|
+
environment: 'neutral',
|
|
2188
2217
|
});
|
|
2189
2218
|
|
|
2219
|
+
/** The tone mapping each setRig environment defaults to (addModel's ENVIRONMENT_TONE_MAPPING). */
|
|
2220
|
+
const SET_RIG_ENV_TONE_MAPPING = Object.freeze({ neutral: 'neutral', room: 'none', none: 'neutral' });
|
|
2221
|
+
|
|
2190
2222
|
export const SET_RIG_TYPES = Object.freeze(['display', 'camera', 'auto']);
|
|
2191
2223
|
const SET_RIG_FITS = ['contain', 'cover', 'height', 'none'];
|
|
2192
2224
|
let warnedSetRigKeys = false;
|
|
2193
2225
|
|
|
2226
|
+
const VIDEO_BUSY = (what) =>
|
|
2227
|
+
`@displayxr/inline3d/splat: ${what}() while a video is on — call handle.setVideo(null) first ` +
|
|
2228
|
+
'(the video holds the display rig and hides the splat until it exits).';
|
|
2229
|
+
|
|
2194
2230
|
const PAGE_SETRIG_ERROR =
|
|
2195
2231
|
"@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
|
|
2196
2232
|
'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
|
|
@@ -2224,14 +2260,14 @@ export function validateSetRig(type, o = {}, pageMode = false) {
|
|
|
2224
2260
|
};
|
|
2225
2261
|
const fit = o.fit === undefined ? D.fit : o.fit;
|
|
2226
2262
|
if (!SET_RIG_FITS.includes(fit)) throw new Error(`@displayxr/inline3d/splat: setRig('display') — fit "${fit}", expected ${SET_RIG_FITS.join(' | ')}.`);
|
|
2227
|
-
const toneMapping = o.toneMapping === undefined ? D.toneMapping : o.toneMapping;
|
|
2228
|
-
if (!TONE_MAPPINGS[toneMapping]) {
|
|
2229
|
-
throw new Error(`@displayxr/inline3d/splat: setRig('display') — toneMapping "${toneMapping}", expected ${Object.keys(TONE_MAPPINGS).join(' | ')}.`);
|
|
2230
|
-
}
|
|
2231
2263
|
const environment = o.environment === undefined ? D.environment : o.environment;
|
|
2232
2264
|
if (!['room', 'neutral', 'none'].includes(environment)) {
|
|
2233
2265
|
throw new Error(`@displayxr/inline3d/splat: setRig('display') — environment "${environment}", expected 'room' | 'neutral' | 'none'.`);
|
|
2234
2266
|
}
|
|
2267
|
+
const toneMapping = o.toneMapping === undefined ? SET_RIG_ENV_TONE_MAPPING[environment] : o.toneMapping;
|
|
2268
|
+
if (!TONE_MAPPINGS[toneMapping]) {
|
|
2269
|
+
throw new Error(`@displayxr/inline3d/splat: setRig('display') — toneMapping "${toneMapping}", expected ${Object.keys(TONE_MAPPINGS).join(' | ')}.`);
|
|
2270
|
+
}
|
|
2235
2271
|
let frame = null;
|
|
2236
2272
|
if (o.frame != null) {
|
|
2237
2273
|
const c = o.frame.center;
|
|
@@ -2404,6 +2440,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2404
2440
|
const livePrepared = new Set();
|
|
2405
2441
|
/** The asset on screen: its resource, kind, the pick data it keeps. */
|
|
2406
2442
|
let current = null; // { asset, entity, res, kind, streamedBounds, alpha8, pickCentres }
|
|
2443
|
+
// handle.setVideo (below). Declared up here: a remove() queued before load runs during attach.
|
|
2444
|
+
let vid = null;
|
|
2445
|
+
let videoSeq = 0;
|
|
2446
|
+
/** The pending setVideo's canceller (a newer call, setVideo(null) or remove() supersedes it). */
|
|
2447
|
+
let cancelPendingVideo = null;
|
|
2448
|
+
let warnedAutoplayMuted = false;
|
|
2407
2449
|
|
|
2408
2450
|
Object.assign(out, {
|
|
2409
2451
|
backend: 'playcanvas',
|
|
@@ -2472,6 +2514,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2472
2514
|
setSource,
|
|
2473
2515
|
prepareSource,
|
|
2474
2516
|
setRig,
|
|
2517
|
+
setVideo,
|
|
2475
2518
|
/**
|
|
2476
2519
|
* Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
|
|
2477
2520
|
* docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
|
|
@@ -2498,6 +2541,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2498
2541
|
effects: () => (fx ? fx.list() : []),
|
|
2499
2542
|
remove() {
|
|
2500
2543
|
removed = true;
|
|
2544
|
+
dropVideo(); // before the app goes: the plane's rVFC, and a <video> this handle made
|
|
2501
2545
|
current = null;
|
|
2502
2546
|
for (const p of [...livePrepared]) p.dispose();
|
|
2503
2547
|
fx?.dispose();
|
|
@@ -2708,6 +2752,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2708
2752
|
let declaredDisplay = pageMode ? 'page' : 'shorthand';
|
|
2709
2753
|
function pushViewRig(force) {
|
|
2710
2754
|
if (pageMode) return; // pageTick declares the rig every frame
|
|
2755
|
+
if (vid?.on) return; // setVideo holds the display rig; exit re-declares what was there
|
|
2711
2756
|
if (!out.rig || out.rig.type !== 'camera') return;
|
|
2712
2757
|
const pose = viewer.mono.pose;
|
|
2713
2758
|
const f = viewer.getFocus();
|
|
@@ -2822,6 +2867,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2822
2867
|
if (pageMode || focusInput === false || unbindFocusInput) return;
|
|
2823
2868
|
unbindFocusInput = bindFocusGestures(canvas, {
|
|
2824
2869
|
onDoubleClick: (e) => {
|
|
2870
|
+
if (vid?.on) return false; // a video plane: nothing to focus on
|
|
2825
2871
|
const m = pickModel(e.clientX, e.clientY);
|
|
2826
2872
|
if (!m) return false;
|
|
2827
2873
|
out.rig.focus = m;
|
|
@@ -3080,8 +3126,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3080
3126
|
/** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
|
|
3081
3127
|
let rigOverride = null;
|
|
3082
3128
|
let rigSeq = 0;
|
|
3083
|
-
/** The
|
|
3129
|
+
/** The IBL setRig('display') installed ({ kind, atlas, prev }), and what it replaced — or null. */
|
|
3084
3130
|
let rigEnv = null;
|
|
3131
|
+
/** ./inline3d-model-playcanvas.js once a display rig has loaded it (transmission, environments). */
|
|
3132
|
+
let rigModelModule = null;
|
|
3133
|
+
/** Mesh instances / materials prepareTransmission has handled, and whether it turned the grab on. */
|
|
3134
|
+
const rigTransmissionSeen = new WeakSet();
|
|
3135
|
+
let rigGrab = false;
|
|
3136
|
+
let rigTick = 0;
|
|
3085
3137
|
const baseToneMapping = viewer.toneMapping;
|
|
3086
3138
|
|
|
3087
3139
|
/** Is the splat on screen: its entity (and every ancestor) enabled. */
|
|
@@ -3155,21 +3207,39 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3155
3207
|
viewer.onTick = pageMode
|
|
3156
3208
|
? viewer.onTick
|
|
3157
3209
|
: () => {
|
|
3158
|
-
const
|
|
3210
|
+
const display = rigOverride?.type === 'display';
|
|
3211
|
+
const want = display && !splatShown() ? rigOverride.o.toneMapping : baseToneMapping;
|
|
3159
3212
|
if (want !== viewer.toneMapping) viewer.setToneMapping(want);
|
|
3213
|
+
// A transmissive glTF the page hangs under root on the display rig (a glass
|
|
3214
|
+
// lantern): addModel's grab pass + pass order + per-eye grab UV. Re-checked every 30
|
|
3215
|
+
// frames, since a page adds its meshes whenever they load — before or after setRig.
|
|
3216
|
+
if (display && rigModelModule && pcModule && (rigTick++ % 30 === 0)) syncRigTransmission();
|
|
3160
3217
|
};
|
|
3161
3218
|
|
|
3162
|
-
/**
|
|
3219
|
+
/** prepareTransmission over root; the grab pass on while a transmissive mesh is there. */
|
|
3220
|
+
function syncRigTransmission() {
|
|
3221
|
+
const n = rigModelModule.prepareTransmission(pcModule, viewer.content, viewer.app?.graphicsDevice, rigTransmissionSeen);
|
|
3222
|
+
if ((n > 0) !== rigGrab) {
|
|
3223
|
+
rigGrab = n > 0;
|
|
3224
|
+
viewer.useSceneColor(rigGrab);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
/** Install addModel's IBL (`disp.environment`) for the display rig, if the page has none of its own. */
|
|
3163
3229
|
async function ensureRigEnvironment(disp, seq) {
|
|
3164
|
-
|
|
3230
|
+
const m = (rigModelModule ||= await import('./inline3d-model-playcanvas.js'));
|
|
3231
|
+
if (removed || seq !== rigSeq || rigOverride?.type !== 'display') return;
|
|
3232
|
+
rigTick = 0; // transmission check on the next tick
|
|
3233
|
+
const kind = disp.environment;
|
|
3234
|
+
if (rigEnv && rigEnv.kind === kind) return;
|
|
3235
|
+
dropRigEnvironment(); // a different environment of ours (setRig('display') called again)
|
|
3236
|
+
if (kind === 'none' || viewer.sky) return;
|
|
3165
3237
|
const app = viewer.app;
|
|
3166
3238
|
const scene = app?.scene;
|
|
3167
3239
|
if (!scene || scene.envAtlas) return; // the page lights its own meshes: leave it alone
|
|
3168
|
-
const m = await import('./inline3d-model-playcanvas.js');
|
|
3169
|
-
if (removed || seq !== rigSeq || rigOverride?.type !== 'display' || scene.envAtlas || rigEnv) return;
|
|
3170
3240
|
const prev = { skyboxIntensity: scene.skyboxIntensity, exposure: scene.exposure, skyboxRotation: scene.skyboxRotation };
|
|
3171
|
-
const atlas = m.useNeutralStudio(pcModule, app, m.ENV_YAW_DEG);
|
|
3172
|
-
rigEnv = { atlas, prev };
|
|
3241
|
+
const atlas = kind === 'room' ? m.useRoomEnvironment(pcModule, app, m.ROOM_YAW_DEG) : m.useNeutralStudio(pcModule, app, m.ENV_YAW_DEG);
|
|
3242
|
+
rigEnv = { kind, atlas, prev };
|
|
3173
3243
|
}
|
|
3174
3244
|
|
|
3175
3245
|
/** Undo ensureRigEnvironment (only what it installed, only if the page has not replaced it). */
|
|
@@ -3196,11 +3266,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3196
3266
|
*/
|
|
3197
3267
|
function setRig(type, o = {}) {
|
|
3198
3268
|
const req = validateSetRig(type, o, pageMode);
|
|
3269
|
+
if (vid) throw new Error(VIDEO_BUSY('setRig'));
|
|
3199
3270
|
const seq = ++rigSeq;
|
|
3200
3271
|
return first.then(async () => {
|
|
3201
3272
|
if (removed || seq !== rigSeq || !current) return out;
|
|
3202
3273
|
rigOverride = req.type === 'auto' ? null : req;
|
|
3203
|
-
if (req.type !== 'display')
|
|
3274
|
+
if (req.type !== 'display') {
|
|
3275
|
+
dropRigEnvironment();
|
|
3276
|
+
if (rigGrab) {
|
|
3277
|
+
rigGrab = false;
|
|
3278
|
+
viewer.useSceneColor(false);
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3204
3281
|
const resolved = resolveFor(current.rigIn, req.type);
|
|
3205
3282
|
out.rig = resolved;
|
|
3206
3283
|
applyRig(resolved, current.rigIn.bounds, true);
|
|
@@ -3263,6 +3340,231 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3263
3340
|
return out;
|
|
3264
3341
|
});
|
|
3265
3342
|
|
|
3343
|
+
// ── handle.setVideo: a stereo video on this handle (docs/playcanvas-adapter.md §setVideo) ──
|
|
3344
|
+
/**
|
|
3345
|
+
* The video state: null = no video. `on` once the plane is up (the splat hidden, the display rig
|
|
3346
|
+
* declared); `saved` is everything the entry changed, restored exactly by setVideo(null).
|
|
3347
|
+
*/
|
|
3348
|
+
// (state: `vid`, `videoSeq`, `cancelPendingVideo` — declared with `current`, above: remove() reads them)
|
|
3349
|
+
|
|
3350
|
+
/** The <video> for a URL: made here, owned (and released) by this handle. */
|
|
3351
|
+
function makeVideoElement(url) {
|
|
3352
|
+
const v = document.createElement('video');
|
|
3353
|
+
v.crossOrigin = 'anonymous'; // WebGL refuses a tainted frame; same-origin is unaffected
|
|
3354
|
+
v.playsInline = true;
|
|
3355
|
+
v.preload = 'auto';
|
|
3356
|
+
v.src = url;
|
|
3357
|
+
return v;
|
|
3358
|
+
}
|
|
3359
|
+
/** Let go of a <video> this handle made: stop it and free its decoder. */
|
|
3360
|
+
function releaseOwned(v) {
|
|
3361
|
+
try {
|
|
3362
|
+
v.pause();
|
|
3363
|
+
v.removeAttribute('src');
|
|
3364
|
+
v.load();
|
|
3365
|
+
} catch {
|
|
3366
|
+
/* already gone */
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
/** Resolves once `v` has a current frame (readyState ≥ 2); rejects on error or cancel. */
|
|
3370
|
+
function waitForFrame(v, seq) {
|
|
3371
|
+
return new Promise((resolve, reject) => {
|
|
3372
|
+
const off = () => {
|
|
3373
|
+
v.removeEventListener('loadeddata', ok);
|
|
3374
|
+
v.removeEventListener('error', bad);
|
|
3375
|
+
if (cancelPendingVideo === cancel) cancelPendingVideo = null;
|
|
3376
|
+
};
|
|
3377
|
+
const ok = () => (off(), resolve());
|
|
3378
|
+
const bad = () => {
|
|
3379
|
+
off();
|
|
3380
|
+
const e = v.error;
|
|
3381
|
+
reject(new Error(`@displayxr/inline3d/splat: setVideo — the video failed to load${e ? ` (${e.message || `code ${e.code}`})` : ''}.`));
|
|
3382
|
+
};
|
|
3383
|
+
const cancel = () => {
|
|
3384
|
+
off();
|
|
3385
|
+
const err = new Error('@displayxr/inline3d/splat: setVideo — superseded by a later setVideo / remove() before its first frame.');
|
|
3386
|
+
err.name = 'AbortError';
|
|
3387
|
+
reject(err);
|
|
3388
|
+
};
|
|
3389
|
+
cancelPendingVideo = cancel;
|
|
3390
|
+
if (seq !== videoSeq) return cancel();
|
|
3391
|
+
if ((v.readyState || 0) >= 2 && v.videoWidth > 0) return ok();
|
|
3392
|
+
v.addEventListener('loadeddata', ok);
|
|
3393
|
+
v.addEventListener('error', bad);
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
function autoplay(v) {
|
|
3397
|
+
const p = v.play?.();
|
|
3398
|
+
if (!p || typeof p.catch !== 'function') return;
|
|
3399
|
+
p.catch((err) => {
|
|
3400
|
+
// No user gesture yet: the browser allows a MUTED play. The page unmutes on its next gesture.
|
|
3401
|
+
if (err?.name !== 'NotAllowedError' || v.muted) return;
|
|
3402
|
+
v.muted = true;
|
|
3403
|
+
if (!warnedAutoplayMuted) {
|
|
3404
|
+
warnedAutoplayMuted = true;
|
|
3405
|
+
console.info('[inline3d/splat] setVideo: autoplay with sound was refused (no user gesture yet) — playing MUTED; set video.muted = false on a gesture.');
|
|
3406
|
+
}
|
|
3407
|
+
v.play().catch(() => {});
|
|
3408
|
+
});
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
/** Everything the entry changes, for an exact restore. */
|
|
3412
|
+
function snapshotForVideo() {
|
|
3413
|
+
const v = viewer;
|
|
3414
|
+
const entity = current?.entity || null;
|
|
3415
|
+
return {
|
|
3416
|
+
entity,
|
|
3417
|
+
splatEnabled: entity ? entity.enabled : null,
|
|
3418
|
+
vH: v.vH,
|
|
3419
|
+
idleSpin: v.idleSpin,
|
|
3420
|
+
mono: { fov: v.mono.fov, near: v.mono.near, far: v.mono.far, pose: Float64Array.from(v.mono.pose), capture: v.mono.capture },
|
|
3421
|
+
pose: {
|
|
3422
|
+
_yaw: v._yaw, _pitch: v._pitch, _targetYaw: v._targetYaw, _targetPitch: v._targetPitch,
|
|
3423
|
+
_zoom: v._zoom, _targetZoom: v._targetZoom, _depthOffset: v._depthOffset,
|
|
3424
|
+
_focusSettled: v._focusSettled, _focusRecentres: v._focusRecentres, _orbitMode: v._orbitMode, _lastInput: v._lastInput,
|
|
3425
|
+
},
|
|
3426
|
+
focus: { ...v._focus },
|
|
3427
|
+
targetFocus: { ...v._targetFocus },
|
|
3428
|
+
viewRig: out.viewRig,
|
|
3429
|
+
declaredDisplay,
|
|
3430
|
+
lastConvergence,
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
/** Enter: the splat hidden, the display camera + rig, the plane up. One task: one frame sees it all. */
|
|
3435
|
+
function enterVideo(state) {
|
|
3436
|
+
const v = viewer;
|
|
3437
|
+
state.saved = snapshotForVideo();
|
|
3438
|
+
if (state.saved.entity) state.saved.entity.enabled = false;
|
|
3439
|
+
v.inputLocked = true;
|
|
3440
|
+
v.idleSpin = 0;
|
|
3441
|
+
v.vH = state.vH;
|
|
3442
|
+
if (v.mono.capture) v.useDisplayCamera(); // camera rig → the display rig's mono camera
|
|
3443
|
+
else {
|
|
3444
|
+
v._placeMonoForFit();
|
|
3445
|
+
v._updateMonoProjection();
|
|
3446
|
+
}
|
|
3447
|
+
declareDisplay({ vH: state.vH });
|
|
3448
|
+
state.on = true;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
/** Exit: put back exactly what enterVideo changed, and the declaration that was there. */
|
|
3452
|
+
function exitVideo(state) {
|
|
3453
|
+
const v = viewer;
|
|
3454
|
+
const s = state.saved;
|
|
3455
|
+
v._videoPlane?.destroy();
|
|
3456
|
+
v._videoPlane = null;
|
|
3457
|
+
if (s.entity) s.entity.enabled = s.splatEnabled;
|
|
3458
|
+
v.vH = s.vH;
|
|
3459
|
+
v.idleSpin = s.idleSpin;
|
|
3460
|
+
Object.assign(v.mono, { fov: s.mono.fov, near: s.mono.near, far: s.mono.far, pose: s.mono.pose, capture: s.mono.capture });
|
|
3461
|
+
Object.assign(v, s.pose);
|
|
3462
|
+
v._focus = { ...s.focus };
|
|
3463
|
+
v._targetFocus = { ...s.targetFocus };
|
|
3464
|
+
v._updateMonoProjection(); // recomputed, not copied: the box may have been resized meanwhile
|
|
3465
|
+
v._applyTransform();
|
|
3466
|
+
v.inputLocked = false;
|
|
3467
|
+
lastConvergence = s.lastConvergence;
|
|
3468
|
+
if (declaredDisplay !== s.declaredDisplay) {
|
|
3469
|
+
if (s.declaredDisplay === 'shorthand') {
|
|
3470
|
+
// The boot shorthand cannot be restored: its explicit form (what it is shorthand for).
|
|
3471
|
+
declaredDisplay = null;
|
|
3472
|
+
declareDisplay({ vH: bootFraming.vH });
|
|
3473
|
+
} else {
|
|
3474
|
+
declaredDisplay = s.declaredDisplay;
|
|
3475
|
+
out.viewRig = s.viewRig;
|
|
3476
|
+
handle?.setViewRig?.(out.viewRig);
|
|
3477
|
+
}
|
|
3478
|
+
} else out.viewRig = s.viewRig;
|
|
3479
|
+
lastConvergence = s.lastConvergence;
|
|
3480
|
+
state.on = false;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/** Tear the video down now (setVideo(null) / remove()). */
|
|
3484
|
+
function dropVideo() {
|
|
3485
|
+
videoSeq++;
|
|
3486
|
+
cancelPendingVideo?.();
|
|
3487
|
+
const state = vid;
|
|
3488
|
+
vid = null;
|
|
3489
|
+
if (!state) return;
|
|
3490
|
+
if (state.on) exitVideo(state);
|
|
3491
|
+
if (state.owned) releaseOwned(state.el);
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
/**
|
|
3495
|
+
* handle.setVideo(src, options) — play a stereo video on THIS handle: no new canvas, layer or
|
|
3496
|
+
* session. The splat is hidden and a screen-locked plane on the display rig shows the video, each
|
|
3497
|
+
* eye its own half (sbs: left/right, tb: top/bottom); flat (mono), the left half at full
|
|
3498
|
+
* resolution. Applies on the first frame the video has. Resolves to `{ video, remove(), stats() }`;
|
|
3499
|
+
* the page drives transport through `video`. setVideo(null) exits and restores the splat, the rig
|
|
3500
|
+
* and its declaration exactly as they were. Throws during an in-flight setSource.
|
|
3501
|
+
*/
|
|
3502
|
+
function setVideo(src, o = {}) {
|
|
3503
|
+
if (src === null || src === undefined) {
|
|
3504
|
+
if (pageMode) throw new Error(PAGE_VIDEO_ERROR);
|
|
3505
|
+
dropVideo();
|
|
3506
|
+
return Promise.resolve(null);
|
|
3507
|
+
}
|
|
3508
|
+
const r = validateSetVideo(src, o, pageMode);
|
|
3509
|
+
if (sourceInFlight > 0) {
|
|
3510
|
+
throw new Error(
|
|
3511
|
+
'@displayxr/inline3d/splat: setVideo() during an in-flight setSource() — await the swap first ' +
|
|
3512
|
+
'(a video and a transition cannot share the tile).',
|
|
3513
|
+
);
|
|
3514
|
+
}
|
|
3515
|
+
const seq = ++videoSeq;
|
|
3516
|
+
cancelPendingVideo?.();
|
|
3517
|
+
const prev = vid;
|
|
3518
|
+
const owned = typeof r.src === 'string';
|
|
3519
|
+
const el = owned ? makeVideoElement(r.src) : r.src;
|
|
3520
|
+
if (r.loop !== undefined) el.loop = r.loop;
|
|
3521
|
+
if (r.muted !== undefined) el.muted = r.muted;
|
|
3522
|
+
if (owned ? r.autoplay !== false : r.autoplay === true) autoplay(el);
|
|
3523
|
+
// The guard state is taken NOW (setSource / setRig refuse from this call on), the pixels change
|
|
3524
|
+
// on the first frame the video has.
|
|
3525
|
+
const state = { seq, el, owned, format: r.format, fit: r.fit, vH: r.vH ?? bootFraming.vH, on: false, saved: null, pending: true };
|
|
3526
|
+
if (!prev || !prev.on) vid = state; // a pending one it supersedes is cancelled above
|
|
3527
|
+
return first
|
|
3528
|
+
.catch(() => null)
|
|
3529
|
+
.then(() => waitForFrame(el, seq))
|
|
3530
|
+
.then(() => {
|
|
3531
|
+
if (removed || seq !== videoSeq || !viewer.app) throw Object.assign(new Error('@displayxr/inline3d/splat: setVideo — superseded.'), { name: 'AbortError' });
|
|
3532
|
+
const live = vid && vid.on ? vid : null;
|
|
3533
|
+
if (live) {
|
|
3534
|
+
// Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
|
|
3535
|
+
state.saved = live.saved;
|
|
3536
|
+
state.on = true;
|
|
3537
|
+
if (live.owned && live.el !== el) releaseOwned(live.el);
|
|
3538
|
+
if (state.vH !== live.vH) {
|
|
3539
|
+
viewer.vH = state.vH;
|
|
3540
|
+
viewer._placeMonoForFit();
|
|
3541
|
+
viewer._updateMonoProjection();
|
|
3542
|
+
declareDisplay({ vH: state.vH });
|
|
3543
|
+
}
|
|
3544
|
+
} else enterVideo(state);
|
|
3545
|
+
vid = state;
|
|
3546
|
+
state.pending = false;
|
|
3547
|
+
viewer._videoPlane ||= new VideoPlane(viewer);
|
|
3548
|
+
viewer._videoPlane.setSource(el, { format: state.format, fit: state.fit, vH: state.vH });
|
|
3549
|
+
const plane = viewer._videoPlane;
|
|
3550
|
+
return Object.freeze({
|
|
3551
|
+
video: el,
|
|
3552
|
+
format: state.format,
|
|
3553
|
+
fit: state.fit,
|
|
3554
|
+
/** Exit (setVideo(null)) — a no-op once another setVideo replaced this one. */
|
|
3555
|
+
remove: () => (vid === state ? setVideo(null) : Promise.resolve(null)),
|
|
3556
|
+
/** Upload accounting: frames drawn with the plane up, and texture uploads (new frames). */
|
|
3557
|
+
stats: () => ({ frames: plane.frames, uploads: plane.uploads }),
|
|
3558
|
+
});
|
|
3559
|
+
})
|
|
3560
|
+
.catch((err) => {
|
|
3561
|
+
// Failed or superseded before it showed: nothing changed on screen; free what we made.
|
|
3562
|
+
if (vid === state) vid = null;
|
|
3563
|
+
if (owned && (!vid || vid.el !== el)) releaseOwned(el);
|
|
3564
|
+
throw err;
|
|
3565
|
+
});
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3266
3568
|
// ── prepareSource: load the next asset in the background, for a setSource with no load ──
|
|
3267
3569
|
/**
|
|
3268
3570
|
* Fetch, decode and upload `src` now, without rendering it: the engine's load (its own unpack
|
|
@@ -3404,6 +3706,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3404
3706
|
|
|
3405
3707
|
// ── setSource: swap the asset — a cut, a crossfade, or a transition ──
|
|
3406
3708
|
let sourceGen = 0;
|
|
3709
|
+
/** setSource calls between their call and their settle (setVideo refuses to start inside one). */
|
|
3710
|
+
let sourceInFlight = 0;
|
|
3407
3711
|
let pendingSwap = null;
|
|
3408
3712
|
/** setSource's crossfade FALLBACK: the coverage remap on one entity (null clears it). */
|
|
3409
3713
|
const setFade = (entity, k) => fx?.setInternal(entity, 'xfade', k === null ? null : { k });
|
|
@@ -3420,7 +3724,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3420
3724
|
* `onFocusChange` fires); the pose is kept unless `resetPose: true`. A newer call supersedes an
|
|
3421
3725
|
* older one still loading. Resolves to the handle once the swap has finished.
|
|
3422
3726
|
*/
|
|
3423
|
-
|
|
3727
|
+
function setSource(next, o = {}) {
|
|
3728
|
+
// A video owns the tile (display rig, splat hidden) until setVideo(null).
|
|
3729
|
+
if (vid) return Promise.reject(new Error(VIDEO_BUSY('setSource')));
|
|
3730
|
+
sourceInFlight++;
|
|
3731
|
+
return setSourceNow(next, o).finally(() => sourceInFlight--);
|
|
3732
|
+
}
|
|
3733
|
+
async function setSourceNow(next, o = {}) {
|
|
3424
3734
|
const plan = resolveSwap(o);
|
|
3425
3735
|
const { resetPose = false } = o;
|
|
3426
3736
|
const gen = ++sourceGen;
|