@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 +27 -0
- package/README.md +3 -1
- package/js/inline3d-splat-playcanvas.js +262 -1
- package/js/inline3d-splat-video.js +305 -0
- package/js/inline3d-splat.js +10 -0
- package/package.json +2 -1
- package/splat.d.ts +48 -0
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))
|
|
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
|
-
|
|
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
|
+
}
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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.
|
|
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 }` —
|