@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.
@@ -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/model.d.ts CHANGED
@@ -40,18 +40,24 @@ export interface ModelOptions {
40
40
  /**
41
41
  * How the mesh is lit.
42
42
  *
43
- * PlayCanvas (default engine): `room` (default; `neutral` is an alias) is image-based lighting
44
- * from an in-memory neutral photo studio fitted to the Khronos glTF Sample Viewer's "Studio
45
- * Neutral", with Khronos PBR Neutral tone mapping and exposure 1 — the Sample Viewer's look.
46
- * three: `room` bakes three's procedural RoomEnvironment, untonemapped (unchanged since 1.11).
43
+ * PlayCanvas (default engine):
44
+ * - `neutral` (default): image-based lighting from an in-memory neutral photo studio fitted to
45
+ * the Khronos glTF Sample Viewer's "Studio Neutral", with Khronos PBR Neutral tone mapping and
46
+ * exposure 1 — the Sample Viewer's look.
47
+ * - `room`: three.js's procedural RoomEnvironment, regenerated in memory (no asset), untonemapped
48
+ * — the look `engine: 'three'` gives `room`. Pass it when a page was tuned on the three path.
49
+ * (Through 1.16 `room` was an alias of `neutral` here.)
50
+ *
51
+ * three: `room` (default) bakes three's procedural RoomEnvironment, untonemapped (unchanged since
52
+ * 1.11); `neutral` is not offered.
47
53
  *
48
54
  * Both: `studio` is the three-point punctual rig (metal renders dark under it — nothing to
49
55
  * reflect); `none` adds no light at all. Anything else throws on PlayCanvas.
50
56
  */
51
57
  environment?: 'room' | 'neutral' | 'studio' | 'none';
52
58
  /**
53
- * PlayCanvas only: yaw of the environment about +Y, degrees (default 0 = the Sample Viewer's
54
- * default orientation).
59
+ * PlayCanvas only: yaw of the environment about +Y, degrees, on top of its own calibrated
60
+ * orientation (default 0 = the Sample Viewer's orientation for `neutral`, three's for `room`).
55
61
  */
56
62
  environmentRotation?: number;
57
63
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.16.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
@@ -529,13 +558,18 @@ export interface SplatSetRigDisplayOptions {
529
558
  perspectiveFactor?: number;
530
559
  /**
531
560
  * The eye camera's tone mapping WHILE THE SPLAT IS HIDDEN (the splat's display-referred colours
532
- * keep `'none'` whenever it is shown). Default `'neutral'` — addModel's (Khronos PBR Neutral).
561
+ * keep `'none'` whenever it is shown). Default: the environment's own — `'neutral'` (Khronos PBR
562
+ * Neutral) for `environment: 'neutral'`, `'none'` (three's) for `environment: 'room'`.
533
563
  */
534
564
  toneMapping?: 'none' | 'linear' | 'neutral' | 'aces' | 'aces2' | 'filmic' | 'hejl';
535
565
  /**
536
- * `'room'` (default; `'neutral'` is the same): install addModel's generated neutral-studio IBL
537
- * when the scene has no `envAtlas` of its own, removed again when the rig switches away. A page
538
- * that lights its own meshes is never touched. `'none'`: never install it.
566
+ * addModel's IBL of the same name, installed when the scene has no `envAtlas` of its own and
567
+ * removed again when the rig switches away (a page that lights its own meshes is never touched):
568
+ * `'neutral'` (default) the Sample-Viewer-matched studio; `'room'` three's RoomEnvironment (the
569
+ * look `addModel(…, { engine: 'three', environment: 'room' })` gives), with tone mapping
570
+ * `'none'` unless `toneMapping` is passed; `'none'` installs nothing. (Through 1.16 `'room'` was
571
+ * the default and an alias of `'neutral'`.) A transmissive glTF under root (KHR_materials_
572
+ * transmission) also gets the scene-colour grab pass while the display rig is active.
539
573
  */
540
574
  environment?: 'room' | 'neutral' | 'none';
541
575
  /**
@@ -795,6 +829,25 @@ export interface SplatHandle {
795
829
  */
796
830
  setRig(type: 'display', opts?: SplatSetRigDisplayOptions): Promise<SplatHandle>;
797
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>;
798
851
  /**
799
852
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
800
853
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —