@displayxr/inline3d 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +5 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/js/inline3d-model-playcanvas.js +351 -8
- package/js/inline3d-playcanvas-engine.js +1 -0
- package/js/inline3d-splat-playcanvas.js +327 -17
- package/js/inline3d-splat-video.js +305 -0
- package/js/inline3d-splat.js +10 -0
- package/model.d.ts +12 -6
- package/package.json +2 -1
- package/splat.d.ts +57 -4
|
@@ -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/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):
|
|
44
|
-
* from an in-memory neutral photo studio fitted to
|
|
45
|
-
* Neutral", with Khronos PBR Neutral tone mapping and
|
|
46
|
-
*
|
|
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
|
|
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.
|
|
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'`
|
|
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
|
-
*
|
|
537
|
-
* when the
|
|
538
|
-
*
|
|
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 }` —
|