@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.
@@ -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: 'room'` installs addModel's neutral-studio IBL when
2174
- * the page has no `scene.envAtlas` of its own, and removes it again when the rig changes back.
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: 'room',
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 neutral-studio IBL setRig('display') installed, and what it replaced — or null. */
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 want = rigOverride?.type === 'display' && !splatShown() ? rigOverride.o.toneMapping : baseToneMapping;
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
- /** Install addModel's default IBL for the display rig, if the page has none of its own. */
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
- if (disp.environment === 'none' || viewer.sky || rigEnv) return;
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') dropRigEnvironment();
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
- async function setSource(next, o = {}) {
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;