@displayxr/inline3d 1.17.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 CHANGED
@@ -5,6 +5,33 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.18.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive: a page that never
11
+ calls `setVideo` renders the same pixels.
12
+
13
+ ### Added
14
+
15
+ - **`handle.setVideo(src, options?)`** (#36): a stereo video on the persistent PlayCanvas splat
16
+ handle, with no second canvas, layer or session. This is for pages that keep one woven canvas for
17
+ the whole app, where a fresh `addVideo` canvas after a navigation is the side-by-side flash.
18
+ - `src` is a URL (an SDK-owned `<video>`, autoplaying) or your `HTMLVideoElement`. Options:
19
+ `format: 'sbs' | 'tb' | 'mono'`, `fit: 'contain' | 'cover'`, `rig: 'display'`,
20
+ `virtualDisplayHeight`, `loop`, `muted`, `autoplay`.
21
+ - It resolves on the video's first frame to `{ video, format, fit, remove(), stats() }`. The page
22
+ drives transport through `video`.
23
+ - The splat is hidden and a screen-locked plane on the display rig shows the video. Each eye
24
+ samples only its own half. Flat (mono), it shows the left half at full resolution.
25
+ - Upload is gated on `requestVideoFrameCallback`. The colour is `addVideo`'s own.
26
+ - `setVideo(null)` restores the splat, the pose, the lens and the declared view rig exactly.
27
+ - It throws during an in-flight `setSource`. While a video is on, `setSource` rejects and
28
+ `setRig` throws. `controls:'page'` throws.
29
+ - Gates, on a real GPU: each eye shows 0 px of the other half, exit renders **MAE 0.0000**
30
+ against the pre-video frame with the same declared rig, and colour matches `addVideo`'s paint
31
+ at MAE 0.003. At 3840×1080 the upload runs 29.9/s with `texImage2D` at p50 0.1 ms, and 0 video
32
+ frames drop on a 120 Hz panel.
33
+ - See [`docs/playcanvas-adapter.md` § setVideo](docs/playcanvas-adapter.md#setvideo--a-stereo-video-on-the-persistent-handle-36).
34
+
8
35
  ## 1.17.0 — 2026-09-24
9
36
 
10
37
  Touches the **preview tier** (`./model` and `./splat`, `engine: 'playcanvas'` only). The default look
package/README.md CHANGED
@@ -48,7 +48,9 @@ engine's knobs) — and adds `setSource(src, { fadeMs })` (crossfading asset swa
48
48
  tilt-and-relax orbit, `handle.engine` (the engine objects, for advanced pages) and
49
49
  `handle.setRig('display' | 'camera' | 'auto')`: a live, reversible rig switch, so one persistent tile
50
50
  can show a glTF under `handle.engine.root` exactly as `addModel` would and then go back to the photo's
51
- capture rig ([§setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36)). It needs the
51
+ capture rig ([§setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36)), and
52
+ `handle.setVideo(src, { format: 'sbs' | 'tb' | 'mono' })`: a stereo video on that same tile, each eye
53
+ its own half, with no second canvas ([§setVideo](docs/playcanvas-adapter.md#setvideo--a-stereo-video-on-the-persistent-handle-36)). It needs the
52
54
  optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Streamed-SOG
53
55
  `lod-meta.json`. **Spark stays the default**; a page that never passes `engine` never loads
54
56
  `playcanvas`. What differs, and why: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
@@ -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,
@@ -664,6 +665,11 @@ export class PlayCanvasSplatViewer {
664
665
  this.orbitMaxDeg = orbitMaxDeg;
665
666
  this.orbitEase = { drag: orbitEase.drag ?? ORBIT_TAU_DRAG_S, rest: orbitEase.rest ?? ORBIT_TAU_REST_S };
666
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;
667
673
  this.featherPx = feather > 0 ? feather : 0;
668
674
  this.captureFit = captureFit;
669
675
  /** Called when the capture camera's vertical FOV changes (captureFit 'cover' on a resize). */
@@ -1024,6 +1030,8 @@ export class PlayCanvasSplatViewer {
1024
1030
  }
1025
1031
  this._live?.destroy();
1026
1032
  this._live = null;
1033
+ this._videoPlane?.destroy();
1034
+ this._videoPlane = null;
1027
1035
  try {
1028
1036
  this.app?.destroy();
1029
1037
  } catch (err) {
@@ -1544,6 +1552,7 @@ export class PlayCanvasSplatViewer {
1544
1552
  _updateMonoProjection() {
1545
1553
  const box = this.canvas.getBoundingClientRect();
1546
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)
1547
1556
  if (this.mono.capture) {
1548
1557
  captureProjection(this.mono.capture, aspect, this.mono.near, this.mono.far, this.mono.proj, this.captureFit);
1549
1558
  if (this.captureFit !== 'height') {
@@ -1652,6 +1661,8 @@ export class PlayCanvasSplatViewer {
1652
1661
  }
1653
1662
  }
1654
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);
1655
1666
  app.tick(now());
1656
1667
  this._afterTick();
1657
1668
  return true;
@@ -1859,6 +1870,7 @@ export class PlayCanvasSplatViewer {
1859
1870
  let restYaw = 0;
1860
1871
  let restPitch = 0;
1861
1872
  this._onDown = (ev) => {
1873
+ if (this.inputLocked) return; // handle.setVideo: a screen-locked plane has nothing to orbit
1862
1874
  dragging = true;
1863
1875
  startX = ev.clientX;
1864
1876
  startY = ev.clientY;
@@ -1893,6 +1905,7 @@ export class PlayCanvasSplatViewer {
1893
1905
  el.releasePointerCapture?.(ev.pointerId);
1894
1906
  };
1895
1907
  this._onWheel = (ev) => {
1908
+ if (this.inputLocked) return;
1896
1909
  ev.preventDefault();
1897
1910
  let px = ev.deltaY;
1898
1911
  if (ev.deltaMode === 1) px *= WHEEL_LINE_PX;
@@ -2210,6 +2223,10 @@ export const SET_RIG_TYPES = Object.freeze(['display', 'camera', 'auto']);
2210
2223
  const SET_RIG_FITS = ['contain', 'cover', 'height', 'none'];
2211
2224
  let warnedSetRigKeys = false;
2212
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
+
2213
2230
  const PAGE_SETRIG_ERROR =
2214
2231
  "@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
2215
2232
  'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
@@ -2423,6 +2440,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2423
2440
  const livePrepared = new Set();
2424
2441
  /** The asset on screen: its resource, kind, the pick data it keeps. */
2425
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;
2426
2449
 
2427
2450
  Object.assign(out, {
2428
2451
  backend: 'playcanvas',
@@ -2491,6 +2514,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2491
2514
  setSource,
2492
2515
  prepareSource,
2493
2516
  setRig,
2517
+ setVideo,
2494
2518
  /**
2495
2519
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2496
2520
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -2517,6 +2541,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2517
2541
  effects: () => (fx ? fx.list() : []),
2518
2542
  remove() {
2519
2543
  removed = true;
2544
+ dropVideo(); // before the app goes: the plane's rVFC, and a <video> this handle made
2520
2545
  current = null;
2521
2546
  for (const p of [...livePrepared]) p.dispose();
2522
2547
  fx?.dispose();
@@ -2727,6 +2752,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2727
2752
  let declaredDisplay = pageMode ? 'page' : 'shorthand';
2728
2753
  function pushViewRig(force) {
2729
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
2730
2756
  if (!out.rig || out.rig.type !== 'camera') return;
2731
2757
  const pose = viewer.mono.pose;
2732
2758
  const f = viewer.getFocus();
@@ -2841,6 +2867,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2841
2867
  if (pageMode || focusInput === false || unbindFocusInput) return;
2842
2868
  unbindFocusInput = bindFocusGestures(canvas, {
2843
2869
  onDoubleClick: (e) => {
2870
+ if (vid?.on) return false; // a video plane: nothing to focus on
2844
2871
  const m = pickModel(e.clientX, e.clientY);
2845
2872
  if (!m) return false;
2846
2873
  out.rig.focus = m;
@@ -3239,6 +3266,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3239
3266
  */
3240
3267
  function setRig(type, o = {}) {
3241
3268
  const req = validateSetRig(type, o, pageMode);
3269
+ if (vid) throw new Error(VIDEO_BUSY('setRig'));
3242
3270
  const seq = ++rigSeq;
3243
3271
  return first.then(async () => {
3244
3272
  if (removed || seq !== rigSeq || !current) return out;
@@ -3312,6 +3340,231 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3312
3340
  return out;
3313
3341
  });
3314
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
+
3315
3568
  // ── prepareSource: load the next asset in the background, for a setSource with no load ──
3316
3569
  /**
3317
3570
  * Fetch, decode and upload `src` now, without rendering it: the engine's load (its own unpack
@@ -3453,6 +3706,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3453
3706
 
3454
3707
  // ── setSource: swap the asset — a cut, a crossfade, or a transition ──
3455
3708
  let sourceGen = 0;
3709
+ /** setSource calls between their call and their settle (setVideo refuses to start inside one). */
3710
+ let sourceInFlight = 0;
3456
3711
  let pendingSwap = null;
3457
3712
  /** setSource's crossfade FALLBACK: the coverage remap on one entity (null clears it). */
3458
3713
  const setFade = (entity, k) => fx?.setInternal(entity, 'xfade', k === null ? null : { k });
@@ -3469,7 +3724,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3469
3724
  * `onFocusChange` fires); the pose is kept unless `resetPose: true`. A newer call supersedes an
3470
3725
  * older one still loading. Resolves to the handle once the swap has finished.
3471
3726
  */
3472
- 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 = {}) {
3473
3734
  const plan = resolveSwap(o);
3474
3735
  const { resetPose = false } = o;
3475
3736
  const gen = ++sourceGen;
@@ -0,0 +1,305 @@
1
+ // inline3d-splat-video.js — handle.setVideo(): a stereo video ON the persistent PlayCanvas splat
2
+ // handle (docs/playcanvas-adapter.md §setVideo). Internal to ./inline3d-splat-playcanvas.js.
3
+ //
4
+ // WHY ON THE HANDLE. A page that renders everything through ONE persistent woven canvas (rule 3 of
5
+ // docs/woven-canvas-rules.md) cannot put a movie in a second canvas without re-creating the very
6
+ // window the persistent stage exists to remove (a fresh canvas is fresh to the compositor: the raw
7
+ // side-by-side flash). So the video is drawn by the tile's own engine, in the tile's own frame.
8
+ //
9
+ // HOW IT IS DRAWN. One quad, parented under the RIG NODE (the eye camera's parent), at display
10
+ // space z = 0: RenderView composes the rig node into every view, so a child of it is fixed relative
11
+ // to the eyes whatever the pose (orbit, zoom, focus) — the plane is screen-locked by construction,
12
+ // and exit restores the pose untouched. On the display rig the z = 0 plane spans the element
13
+ // (docs/authoring-inline-3d.md §display rig), so the quad is the element box contained / covered by
14
+ // the video's per-eye aspect. Zero disparity for the quad itself: the depth is the video's own.
15
+ //
16
+ // EACH EYE SEES ONLY ITS HALF. One camera draws every view (the RenderView path), so the shader
17
+ // cannot be told "this is the right eye" by a uniform per view. It does not need to be: the eye
18
+ // viewports sit side by side in the buffer, so gl_FragCoord.x against the first right-eye
19
+ // viewport's x says which eye this fragment belongs to. Mono (not woven): the split is +∞, every
20
+ // fragment samples the LEFT half, at the full buffer resolution. The sample is clamped half a texel
21
+ // inside its half, so linear filtering never bleeds the other eye across the seam.
22
+ //
23
+ // UPLOAD. Gated on requestVideoFrameCallback (a new frame was presented), with `seeked` and a
24
+ // currentTime change as fallbacks (no rVFC; a paused seek on a build that does not fire it). One
25
+ // texture, re-specified from the <video> by the engine (texImage2D; GPU-to-GPU in Chromium): no
26
+ // per-frame allocation on our side. RGBA8, sampled and written UNCHANGED — the same encoded sRGB
27
+ // values a 2D canvas drawImage() of the frame puts in the buffer (addVideo's paint), so the woven
28
+ // video and the flat one are the same pixels.
29
+
30
+ /** handle.setVideo's formats, fits and rigs. */
31
+ export const VIDEO_FORMATS = Object.freeze(['sbs', 'tb', 'mono']);
32
+ export const VIDEO_FITS = Object.freeze(['contain', 'cover']);
33
+ const VIDEO_OPTION_KEYS = new Set(['format', 'fit', 'rig', 'virtualDisplayHeight', 'loop', 'muted', 'autoplay']);
34
+ let warnedVideoKeys = false;
35
+
36
+ export const PAGE_VIDEO_ERROR =
37
+ "@displayxr/inline3d/splat: setVideo() is not available with controls:'page' — the page owns the " +
38
+ 'camera, and a video plane needs the display rig. Draw the video into your own scene instead.';
39
+
40
+ /**
41
+ * setVideo's arguments, validated and resolved (throws at the call, before anything runs).
42
+ * `src` null/undefined is the exit call and is not validated here.
43
+ * @returns {{ src: string|HTMLVideoElement, format: 'sbs'|'tb'|'mono', fit: 'contain'|'cover',
44
+ * vH: number|undefined, loop?: boolean, muted?: boolean, autoplay?: boolean }}
45
+ */
46
+ export function validateSetVideo(src, o = {}, pageMode = false) {
47
+ if (pageMode) throw new Error(PAGE_VIDEO_ERROR);
48
+ const isVideo = typeof HTMLVideoElement !== 'undefined' ? src instanceof HTMLVideoElement : !!src && typeof src === 'object' && 'videoWidth' in src;
49
+ if (!(typeof src === 'string' && src.length > 0) && !isVideo) {
50
+ throw new TypeError('@displayxr/inline3d/splat: setVideo(src) — expected a URL string, an HTMLVideoElement, or null to exit.');
51
+ }
52
+ if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setVideo options must be an object.');
53
+ const unknown = Object.keys(o).filter((k) => !VIDEO_OPTION_KEYS.has(k));
54
+ if (unknown.length && !warnedVideoKeys) {
55
+ warnedVideoKeys = true;
56
+ console.warn(`[inline3d/splat] setVideo ignores ${unknown.join(', ')}.`);
57
+ }
58
+ const format = o.format === undefined ? 'sbs' : o.format;
59
+ if (!VIDEO_FORMATS.includes(format)) {
60
+ throw new Error(`@displayxr/inline3d/splat: setVideo — format "${format}", expected ${VIDEO_FORMATS.join(' | ')}.`);
61
+ }
62
+ const fit = o.fit === undefined ? 'contain' : o.fit;
63
+ if (!VIDEO_FITS.includes(fit)) throw new Error(`@displayxr/inline3d/splat: setVideo — fit "${fit}", expected ${VIDEO_FITS.join(' | ')}.`);
64
+ if (o.rig !== undefined && o.rig !== 'display') {
65
+ throw new Error(`@displayxr/inline3d/splat: setVideo — rig "${o.rig}"; a video plays on the display rig only ('display').`);
66
+ }
67
+ let vH;
68
+ if (o.virtualDisplayHeight !== undefined) {
69
+ vH = o.virtualDisplayHeight;
70
+ if (!Number.isFinite(vH) || !(vH > 0)) throw new Error(`@displayxr/inline3d/splat: setVideo — bad virtualDisplayHeight: ${o.virtualDisplayHeight}.`);
71
+ }
72
+ const bool = (k) => (o[k] === undefined ? undefined : !!o[k]);
73
+ return { src, format, fit, vH, loop: bool('loop'), muted: bool('muted'), autoplay: bool('autoplay') };
74
+ }
75
+
76
+ /**
77
+ * The per-eye source regions in texture space (st: s → right, t → DOWN, i.e. image rows), as
78
+ * [s0, t0, ds, dt] for the left and the right eye.
79
+ */
80
+ export function eyeRegions(format) {
81
+ if (format === 'tb') return { L: [0, 0, 1, 0.5], R: [0, 0.5, 1, 0.5] };
82
+ if (format === 'mono') return { L: [0, 0, 1, 1], R: [0, 0, 1, 1] };
83
+ return { L: [0, 0, 0.5, 1], R: [0.5, 0, 0.5, 1] };
84
+ }
85
+
86
+ /** One eye's aspect (width / height) of a `videoWidth × videoHeight` frame in `format`. */
87
+ export function eyeAspect(format, w, h) {
88
+ if (!(w > 0) || !(h > 0)) return 0;
89
+ if (format === 'sbs') return w / 2 / h;
90
+ if (format === 'tb') return w / (h / 2);
91
+ return w / h;
92
+ }
93
+
94
+ /**
95
+ * The quad's size at display-space z = 0, where the window is `vH · boxAspect` wide and `vH` tall:
96
+ * the window contained ('contain': all of the frame, bars where the aspects differ) or covered
97
+ * ('cover': the window full, the overflow cut by the window's own frustum edges).
98
+ */
99
+ export function videoPlaneSize({ boxAspect, eyeAspect: a, vH, fit = 'contain' }) {
100
+ const W = vH * boxAspect;
101
+ const H = vH;
102
+ if (!(a > 0) || !(boxAspect > 0)) return { w: W, h: H };
103
+ const wider = a >= boxAspect;
104
+ if ((fit === 'contain') === wider) return { w: W, h: W / a };
105
+ return { w: H * a, h: H };
106
+ }
107
+
108
+ /**
109
+ * The first right-eye pixel column in the buffer for this frame's views (entries in BUFFER px,
110
+ * through `rect`), or +∞ for one view (mono: every fragment is the left eye). With N > 2 views the
111
+ * first half of them are left eyes.
112
+ */
113
+ export function eyeSplit(entries, rect) {
114
+ if (!entries || entries.length < 2) return 1e9;
115
+ return rect(entries[entries.length >> 1])[0];
116
+ }
117
+
118
+ const VERT = `
119
+ attribute vec3 vertex_position;
120
+ attribute vec2 vertex_texCoord0;
121
+ uniform mat4 matrix_model;
122
+ uniform mat4 matrix_viewProjection;
123
+ varying vec2 vUv;
124
+ void main() {
125
+ vUv = vertex_texCoord0;
126
+ gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0);
127
+ }`;
128
+ const FRAG = `
129
+ varying vec2 vUv;
130
+ uniform sampler2D dxrVid;
131
+ uniform vec4 dxrVidL; // left eye's region: s0, t0, ds, dt (t = image rows, top = 0)
132
+ uniform vec4 dxrVidR; // right eye's
133
+ uniform vec2 dxrVidTexel; // half a texel, per axis
134
+ uniform float dxrVidSplit; // first right-eye pixel column in the buffer (1e9 = mono)
135
+ void main() {
136
+ vec4 r = gl_FragCoord.x >= dxrVidSplit ? dxrVidR : dxrVidL;
137
+ vec2 st = r.xy + vec2(vUv.x, 1.0 - vUv.y) * r.zw;
138
+ st = clamp(st, r.xy + dxrVidTexel, r.xy + r.zw - dxrVidTexel);
139
+ gl_FragColor = vec4(texture2D(dxrVid, st).rgb, 1.0);
140
+ }`;
141
+
142
+ /**
143
+ * The quad, its material and its texture, owned by the viewer (`viewer._videoPlane`) while a video
144
+ * is on. `beforeDraw` is called by the viewer's draw, once per engine tick, before the tick.
145
+ */
146
+ export class VideoPlane {
147
+ constructor(viewer) {
148
+ this.viewer = viewer;
149
+ const pc = (this.pc = viewer.pc);
150
+ const device = viewer.app.graphicsDevice;
151
+ const mesh = new pc.Mesh(device);
152
+ mesh.setPositions(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]));
153
+ mesh.setUvs(0, new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]));
154
+ mesh.setIndices([0, 1, 2, 0, 2, 3]);
155
+ mesh.update();
156
+ this.mesh = mesh;
157
+ const mat = new pc.ShaderMaterial({
158
+ uniqueName: 'inline3dVideoPlane',
159
+ attributes: { vertex_position: pc.SEMANTIC_POSITION, vertex_texCoord0: pc.SEMANTIC_TEXCOORD0 },
160
+ vertexGLSL: VERT,
161
+ fragmentGLSL: FRAG,
162
+ });
163
+ mat.cull = pc.CULLFACE_NONE;
164
+ mat.setParameter('dxrVidSplit', 1e9);
165
+ mat.update();
166
+ this.mat = mat;
167
+ // Under the rig node: fixed relative to the eyes (see the header).
168
+ this.node = new pc.GraphNode('inline3d-video');
169
+ viewer.rigNode.addChild(this.node);
170
+ const mi = new pc.MeshInstance(mesh, mat, this.node);
171
+ mi.cull = false;
172
+ mi.visible = false;
173
+ this.mi = mi;
174
+ this.layer = viewer.app.scene.layers.getLayerById(pc.LAYERID_WORLD ?? 0);
175
+ this.layer.addMeshInstances([mi]);
176
+
177
+ this.video = null;
178
+ this.tex = null;
179
+ this.format = 'sbs';
180
+ this.fit = 'contain';
181
+ this.vH = viewer.vH;
182
+ this._dirty = false;
183
+ this._lastT = -1;
184
+ this._rvfc = 0;
185
+ this._sizeKey = '';
186
+ this._split = NaN;
187
+ this._onSeeked = () => (this._dirty = true);
188
+ /** Upload accounting (handle.setVideo(...).stats()). */
189
+ this.uploads = 0;
190
+ this.frames = 0;
191
+ }
192
+
193
+ /** Play `video` on the quad (a new element, format or fit). The element must have a frame. */
194
+ setSource(video, { format, fit, vH }) {
195
+ this.format = format;
196
+ this.fit = fit;
197
+ this.vH = vH;
198
+ const r = eyeRegions(format);
199
+ this.mat.setParameter('dxrVidL', r.L);
200
+ this.mat.setParameter('dxrVidR', r.R);
201
+ if (video !== this.video) {
202
+ this._unwatch();
203
+ this.video = video;
204
+ this._watch();
205
+ }
206
+ this._ensureTexture();
207
+ this._sizeKey = '';
208
+ this._dirty = true;
209
+ this.mat.update();
210
+ this.mi.visible = true;
211
+ }
212
+
213
+ _watch() {
214
+ const v = this.video;
215
+ v.addEventListener?.('seeked', this._onSeeked);
216
+ if (typeof v.requestVideoFrameCallback === 'function') {
217
+ const cb = () => {
218
+ this._dirty = true;
219
+ if (this.video === v) this._rvfc = v.requestVideoFrameCallback(cb);
220
+ };
221
+ this._rvfc = v.requestVideoFrameCallback(cb);
222
+ }
223
+ }
224
+
225
+ _unwatch() {
226
+ const v = this.video;
227
+ if (!v) return;
228
+ v.removeEventListener?.('seeked', this._onSeeked);
229
+ if (this._rvfc && typeof v.cancelVideoFrameCallback === 'function') v.cancelVideoFrameCallback(this._rvfc);
230
+ this._rvfc = 0;
231
+ }
232
+
233
+ /** One texture per video size; re-made only when the frame size changes. */
234
+ _ensureTexture() {
235
+ const pc = this.pc;
236
+ const v = this.video;
237
+ const w = v.videoWidth || 0;
238
+ const h = v.videoHeight || 0;
239
+ if (this.tex && this.tex.width === w && this.tex.height === h) return;
240
+ this.tex?.destroy?.();
241
+ this.tex = new pc.Texture(this.viewer.app.graphicsDevice, {
242
+ name: 'inline3d-video',
243
+ width: Math.max(1, w),
244
+ height: Math.max(1, h),
245
+ format: pc.PIXELFORMAT_RGBA8,
246
+ mipmaps: false,
247
+ flipY: false,
248
+ minFilter: pc.FILTER_LINEAR,
249
+ magFilter: pc.FILTER_LINEAR,
250
+ addressU: pc.ADDRESS_CLAMP_TO_EDGE,
251
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
252
+ });
253
+ this.tex.setSource?.(v);
254
+ this.mat.setParameter('dxrVid', this.tex);
255
+ this.mat.setParameter('dxrVidTexel', [0.5 / Math.max(1, w), 0.5 / Math.max(1, h)]);
256
+ this._lastT = v.currentTime;
257
+ this._dirty = false;
258
+ this.uploads++; // setSource uploads
259
+ }
260
+
261
+ /** Per engine tick, before it: size the quad, place the eye split, upload a new frame. */
262
+ beforeDraw(entries, rect) {
263
+ const v = this.video;
264
+ if (!v || !this.mi.visible) return;
265
+ this.frames++;
266
+ const split = eyeSplit(entries, rect);
267
+ if (split !== this._split) {
268
+ this._split = split;
269
+ this.mat.setParameter('dxrVidSplit', split);
270
+ }
271
+ const boxAspect = this.viewer.boxAspect;
272
+ const key = `${boxAspect}|${v.videoWidth}x${v.videoHeight}|${this.format}|${this.fit}|${this.vH}`;
273
+ if (key !== this._sizeKey) {
274
+ this._sizeKey = key;
275
+ this._ensureTexture();
276
+ const s = videoPlaneSize({ boxAspect, eyeAspect: eyeAspect(this.format, v.videoWidth, v.videoHeight), vH: this.vH, fit: this.fit });
277
+ this.node.setLocalScale(s.w, s.h, 1);
278
+ }
279
+ // A new frame: rVFC said so, a seek landed, or (no rVFC) the clock moved.
280
+ const t = v.currentTime;
281
+ if (this._dirty || (!this._rvfc && t !== this._lastT)) {
282
+ if ((v.readyState || 0) >= 2) {
283
+ this.tex.upload();
284
+ this.uploads++;
285
+ }
286
+ this._dirty = false;
287
+ this._lastT = t;
288
+ }
289
+ }
290
+
291
+ hide() {
292
+ this.mi.visible = false;
293
+ }
294
+
295
+ destroy() {
296
+ this._unwatch();
297
+ this.video = null;
298
+ this.layer?.removeMeshInstances?.([this.mi]);
299
+ this.node.parent?.removeChild?.(this.node);
300
+ this.tex?.destroy?.();
301
+ this.tex = null;
302
+ this.mat.destroy?.();
303
+ this.mesh.destroy?.();
304
+ }
305
+ }
@@ -373,6 +373,13 @@ export function addSplat(wall, canvas, src, opts = {}) {
373
373
  prepareSource() {
374
374
  throw new Error("@displayxr/inline3d/splat: prepareSource() is implemented on the PlayCanvas backend only.");
375
375
  },
376
+ /** Not on this backend: a video on the splat handle is a PlayCanvas-backend feature. */
377
+ setVideo() {
378
+ throw new Error(
379
+ "@displayxr/inline3d/splat: setVideo() is implemented on the PlayCanvas backend only " +
380
+ "(addSplat(…, { engine:'playcanvas' })); with Spark, use wall.addVideo on its own canvas.",
381
+ );
382
+ },
376
383
  /** Not on this backend: the live rig switch is a PlayCanvas-backend feature. */
377
384
  setRig() {
378
385
  throw new Error(
@@ -736,6 +743,9 @@ function addSplatDeferred(wall, canvas, src, opts) {
736
743
  validateSetRigArgs(type, o);
737
744
  return out.ready.then(() => out.setRig(type, o));
738
745
  },
746
+ // A video before the first asset has landed: controls:'page' throws at the call's own line,
747
+ // the rest is validated by the adapter's setVideo, which runs once the asset is on screen.
748
+ setVideo: page ? pageOnly('setVideo') : (src, o) => out.ready.then(() => out.setVideo(src, o)),
739
749
  // Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
740
750
  // then run once the first asset is on screen.
741
751
  playEffect: (name, o) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -52,6 +52,7 @@
52
52
  "js/inline3d-splat-shared.js",
53
53
  "js/inline3d-splat-effects.js",
54
54
  "js/inline3d-splat-live.js",
55
+ "js/inline3d-splat-video.js",
55
56
  "js/inline3d-model.js",
56
57
  "js/inline3d-model-entry.js",
57
58
  "js/inline3d-model-playcanvas.js",
package/splat.d.ts CHANGED
@@ -178,6 +178,35 @@ export interface ResolvedRig {
178
178
  parallaxFactor: number;
179
179
  }
180
180
 
181
+ /** `handle.setVideo` options. */
182
+ export interface SplatVideoOptions {
183
+ /** Stereo layout of the frame: `'sbs'` (default, left eye = left half), `'tb'` (left = top), `'mono'` (both eyes the whole frame). */
184
+ format?: 'sbs' | 'tb' | 'mono';
185
+ /** `'contain'` (default): the whole eye image, bars where the aspects differ. `'cover'`: the window full, the overflow cropped. */
186
+ fit?: 'contain' | 'cover';
187
+ /** Only `'display'`. */
188
+ rig?: 'display';
189
+ /** The display rig's height while the video is on (default: the tile's own `virtualDisplayHeight`). No visible effect on a flat plane at the window. */
190
+ virtualDisplayHeight?: number;
191
+ /** Applied to the element when given. */
192
+ loop?: boolean;
193
+ muted?: boolean;
194
+ /** Default true for a URL (the SDK's own element), false for an element you pass. */
195
+ autoplay?: boolean;
196
+ }
197
+
198
+ /** What `handle.setVideo(src)` resolves to. */
199
+ export interface SplatVideo {
200
+ /** The element: play / pause / currentTime / duration / ended / events are the page's to drive. */
201
+ readonly video: HTMLVideoElement;
202
+ readonly format: 'sbs' | 'tb' | 'mono';
203
+ readonly fit: 'contain' | 'cover';
204
+ /** `handle.setVideo(null)`, or a no-op if another setVideo has replaced this one. */
205
+ remove(): Promise<null>;
206
+ /** Frames drawn with the plane up, and texture uploads (new video frames). */
207
+ stats(): { frames: number; uploads: number };
208
+ }
209
+
181
210
  export interface SplatOptions {
182
211
  /**
183
212
  * Which renderer. `'spark'` (the default) is three.js + Spark. `'playcanvas'` is the PlayCanvas
@@ -800,6 +829,25 @@ export interface SplatHandle {
800
829
  */
801
830
  setRig(type: 'display', opts?: SplatSetRigDisplayOptions): Promise<SplatHandle>;
802
831
  setRig(type: 'camera' | 'auto'): Promise<SplatHandle>;
832
+ /**
833
+ * PlayCanvas backend only — throws on Spark, and with `controls:'page'`. Play a stereo video ON
834
+ * this handle: no new canvas, layer or session (docs/playcanvas-adapter.md §setVideo). The splat
835
+ * is hidden and a screen-locked plane on the display rig shows the video, sized to its per-eye
836
+ * aspect by `fit`; each eye samples only its own half (`'sbs'`: left/right, `'tb'`: top/bottom).
837
+ * Not woven (the mono path): the LEFT half at the full buffer resolution. The texture is
838
+ * re-uploaded only when the video presents a new frame (requestVideoFrameCallback).
839
+ *
840
+ * Applies on the first frame the video has; resolves to a {@link SplatVideo}. The page drives
841
+ * transport through `v.video`. A URL makes an SDK-owned `<video>` (autoplays unless
842
+ * `autoplay: false`; muted automatically if the browser refuses sound without a gesture); an
843
+ * element you pass stays yours (the SDK never plays or pauses it unless `autoplay: true`).
844
+ * `setVideo(null)` exits and restores the splat's visibility, the pose, the lens and the declared
845
+ * view rig exactly as they were. A second setVideo replaces the video and keeps the pre-video
846
+ * state. Throws during an in-flight `setSource`; while a video is on, `setSource` rejects and
847
+ * `setRig` throws (`setVideo(null)` first). `prepareSource` stays available.
848
+ */
849
+ setVideo(src: string | HTMLVideoElement, opts?: SplatVideoOptions): Promise<SplatVideo>;
850
+ setVideo(src: null): Promise<null>;
803
851
  /**
804
852
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
805
853
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —