@displayxr/inline3d 1.29.0 → 1.31.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,678 @@
1
+ // inline3d-camera.js — the stereo camera as its own primitive (RFC 0003 §4, phase C2).
2
+ //
3
+ // PREVIEW tier. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
4
+ //
5
+ // import { sharedInline3D } from '@displayxr/inline3d';
6
+ // import { openCamera, addCameraView } from '@displayxr/inline3d/camera';
7
+ //
8
+ // const cam = await openCamera({ prefer: 'stereo' }); // 'auto' | 'stereo' | 'mono' | deviceId | MediaStream
9
+ // cam.format; // 'sbs' | 'mono'
10
+ // cam.stereo; // { rectified, baselineMm, horizontalFovDeg } | null
11
+ // cam.stream; // the MediaStream: hand it to WebRTC, a MediaRecorder, a canvas
12
+ //
13
+ // const view = addCameraView(await sharedInline3D(), canvas, cam, { mirror: true }); // a correct 3D self view
14
+ // const photo = await cam.capturePhoto(); // { blob, suggestedName: 'photo-…_2x1.jpg', convergencePx, … }
15
+ // const rec = cam.record({ mono: true }); // SBS + an optional left-eye copy (Decision 13)
16
+ // const clip = await rec.stop(); // { blob, mono?, suggestedName: 'clip-…_2x1.webm', … }
17
+ // cam.on('ended', () => …); // revoked, unplugged, or taken by another app
18
+ // cam.close();
19
+ //
20
+ // WHAT IT OWNS. Device selection (the busy-device skip, the `displayxrStereo` hint, the > 2.5:1
21
+ // pair heuristic — camera/capture.js), calibration (`calibration` / `rectify`, moved here from
22
+ // `/call`), the MIRRORED self view (each half mirrored AND the halves swapped —
23
+ // camera/geometry.js), auto-convergence on the face (camera/disparity.js + camera/converge.js),
24
+ // and captured media: plain side-by-side with the layout in the file name (`_2x1`) and the
25
+ // stereo record INSIDE the file (XMP in a JPEG, a Tags element in a WebM — camera/metadata.js,
26
+ // Decision 9), so it plays in `/player` and anything else that understands side-by-side.
27
+ //
28
+ // NOT here (RFC §4): 2D→3D of a mono camera (`/lift` composes: `lift(videoEl)`), and any network
29
+ // code — `/call` consumes this module for its self view and capture; the call's `camera: cam`
30
+ // accepts a StereoCamera, and `camera: 'auto'` makes the call open one itself.
31
+ //
32
+ // PHOTOS STORE THE RAW RECTIFIED PAIR, with the convergence reported alongside (`convergencePx`,
33
+ // also in the file): baking the shift would crop the edges; a viewer re-converges from the
34
+ // number. `convergencePx` is the horizontal disparity (left-eye x − right-eye x, source px) of
35
+ // the subject the camera converged on — the face — and a viewer shifts the eyes toward each
36
+ // other by half of it each to put that subject at the display plane.
37
+
38
+ import { openCamera as openDevice } from './camera/capture.js';
39
+ import { eyeCropRect, eyeOutputSize, mirrorSwapOps } from './camera/geometry.js';
40
+ import { createConvergence } from './camera/converge.js';
41
+ import { lumaFromRgba, createDisparityTrack, createFocusTracker } from './camera/disparity.js';
42
+ import { buildStereoXmp, jpegWithXmp, webmWithTags, stereoTagEntries, readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp } from './camera/metadata.js';
43
+
44
+ export { readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp };
45
+
46
+ const TAG = '[inline3d/camera]';
47
+ /** Stamped into every capture's metadata (`Software`), the twin of the call's `CALL_SDK`. */
48
+ export const CAMERA_SDK = 'inline3d-camera/1';
49
+ // Auto-convergence sampling: ~5 Hz on a copy whose eye is at most this wide (sub-pixel disparity
50
+ // on a face, a few ms per measurement — the same numbers as the call's remote tiles).
51
+ const AUTO_CONV_INTERVAL_MS = 200;
52
+ const AUTO_CONV_EYE_WIDTH = 240;
53
+ const FRAME_WAIT_MS = 4000;
54
+ const RECORD_MIME_CANDIDATES = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'];
55
+
56
+ const hasDoc = () => typeof document !== 'undefined' && document && typeof document.createElement === 'function';
57
+ const stamp = () => {
58
+ const d = new Date();
59
+ const p = (n) => String(n).padStart(2, '0');
60
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
61
+ };
62
+ const extFor = (type) => (/jpe?g/.test(type) ? 'jpg' : /png/.test(type) ? 'png' : /webp/.test(type) ? 'webp' : /mp4/.test(type) ? 'mp4' : /webm/.test(type) ? 'webm' : 'bin');
63
+
64
+ /**
65
+ * The file name a capture suggests: `<base>_2x1.<ext>` for a side-by-side pair (the layout suffix
66
+ * `/player` and the rest of the SDK read — columns×rows), `<base>.<ext>` for mono. Pure.
67
+ * @param {string} base e.g. 'photo-20260101-120000'
68
+ * @param {'sbs'|'mono'} layout
69
+ * @param {string} type a MIME type
70
+ */
71
+ export function suggestedName(base, layout, type) {
72
+ const safe = String(base || 'capture').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'capture';
73
+ return `${safe}${layout === 'sbs' ? '_2x1' : ''}.${extFor(type)}`;
74
+ }
75
+
76
+ const asError = (message, code, extra = {}) => Object.assign(new Error(`${TAG} ${message}`), { code, ...extra });
77
+
78
+ /** Resolve once `video` has a decoded frame (readyState >= 2 and a size), or reject after a wait. */
79
+ function waitForFrame(video, ms = FRAME_WAIT_MS) {
80
+ if (!video) return Promise.reject(asError('no video element (no document?)', 'no-frame'));
81
+ if ((video.readyState || 0) >= 2 && video.videoWidth) return Promise.resolve();
82
+ return new Promise((res, rej) => {
83
+ let done = false;
84
+ const ok = () => {
85
+ if (done || (video.readyState || 0) < 2 || !video.videoWidth) return;
86
+ done = true;
87
+ clearTimeout(t);
88
+ video.removeEventListener('loadeddata', ok);
89
+ video.removeEventListener('playing', ok);
90
+ video.removeEventListener('resize', ok);
91
+ res();
92
+ };
93
+ const t = setTimeout(() => {
94
+ if (done) return;
95
+ done = true;
96
+ rej(asError('the camera delivered no frame in time', 'no-frame'));
97
+ }, ms);
98
+ video.addEventListener('loadeddata', ok);
99
+ video.addEventListener('playing', ok);
100
+ video.addEventListener('resize', ok);
101
+ video.play?.().catch(() => {});
102
+ });
103
+ }
104
+
105
+ /**
106
+ * A small grayscale copy of the current frame (per-eye width <= `eyeMax`), for the disparity
107
+ * measurement. `{ img, w, h, scale }` with `scale` = copy px per source px; null if unreadable.
108
+ */
109
+ function grabLuma(video, scratch, eyeMax = AUTO_CONV_EYE_WIDTH) {
110
+ const W = video.videoWidth;
111
+ const H = video.videoHeight;
112
+ if (!W || !H || (video.readyState || 0) < 2) return null;
113
+ const s = Math.min(1, eyeMax / (W / 2));
114
+ const w = Math.max(2, Math.round((W * s) / 2) * 2);
115
+ const h = Math.max(2, Math.round(H * s));
116
+ if (scratch.width !== w || scratch.height !== h) {
117
+ scratch.width = w;
118
+ scratch.height = h;
119
+ }
120
+ const g = scratch.getContext('2d', { willReadFrequently: true });
121
+ try {
122
+ g.drawImage(video, 0, 0, w, h);
123
+ return { img: lumaFromRgba(g.getImageData(0, 0, w, h).data, w * h), w, h, scale: w / W };
124
+ } catch {
125
+ return null; // a frame that cannot be read yet (not decoded, tainted)
126
+ }
127
+ }
128
+
129
+ // ── the camera ─────────────────────────────────────────────────────────────────────────────
130
+
131
+ class StereoCamera {
132
+ constructor(r, { log = null } = {}) {
133
+ this.format = r.format;
134
+ this.stream = r.stream;
135
+ this.width = r.width || 0;
136
+ this.height = r.height || 0;
137
+ this.deviceId = r.deviceId || null;
138
+ this.label = r.label || '';
139
+ this.owned = !!r.owned;
140
+ this.skipped = Object.freeze([...(r.skipped || [])]);
141
+ /** Internal: the hello-shaped calibration (`rectified`, `baselineMm`, `hfovDeg`). The call reads it. */
142
+ this.calibration = Object.freeze({ ...(r.calibration || {}) });
143
+ const c = this.calibration;
144
+ this.stereo = this.format === 'sbs' ? Object.freeze({ rectified: !!c.rectified, baselineMm: c.baselineMm > 0 ? c.baselineMm : null, horizontalFovDeg: c.hfovDeg > 0 ? c.hfovDeg : null }) : null;
145
+ this.state = 'live';
146
+ this._log = log;
147
+ this._listeners = new Map();
148
+ this._scratch = null;
149
+ this._views = new Set();
150
+ // A detached <video> (never in the DOM — an in-DOM video is a second weave candidate) that
151
+ // every view, photo and recording reads from. One per camera, shared.
152
+ this.video = null;
153
+ if (hasDoc()) {
154
+ this.video = Object.assign(document.createElement('video'), { muted: true, playsInline: true, autoplay: true });
155
+ this.video.srcObject = this.stream;
156
+ this.video.play?.().catch(() => {});
157
+ }
158
+ this._onEnded = () => this._ended('ended');
159
+ for (const t of this.stream.getVideoTracks()) t.addEventListener?.('ended', this._onEnded);
160
+ }
161
+
162
+ /** Source width of ONE eye (the whole frame for mono). */
163
+ get eyeWidth() {
164
+ return this.format === 'sbs' ? this.width / 2 : this.width;
165
+ }
166
+
167
+ log(tag, obj = {}) {
168
+ if (this._log) this._log(tag, obj);
169
+ }
170
+
171
+ on(type, cb) {
172
+ if (typeof cb !== 'function') throw new TypeError(`${TAG} on() takes a function`);
173
+ if (!this._listeners.has(type)) this._listeners.set(type, new Set());
174
+ this._listeners.get(type).add(cb);
175
+ return () => this._listeners.get(type)?.delete(cb);
176
+ }
177
+
178
+ off(type, cb) {
179
+ this._listeners.get(type)?.delete(cb);
180
+ }
181
+
182
+ _emit(type, payload) {
183
+ for (const cb of [...(this._listeners.get(type) || [])]) {
184
+ try {
185
+ cb(payload);
186
+ } catch (err) {
187
+ console.error(`${TAG} '${type}' listener threw`, err);
188
+ }
189
+ }
190
+ }
191
+
192
+ /** A track ended under us: revoked by the runtime, unplugged, taken by another app. Once. */
193
+ _ended(reason) {
194
+ if (this.state !== 'live') return;
195
+ this.state = 'ended';
196
+ this.log('ended', { reason });
197
+ this._emit('ended', { reason });
198
+ }
199
+
200
+ /**
201
+ * The disparity (left-eye x − right-eye x, source px) of the point between the eyes in the
202
+ * CURRENT frame, or null (mono, no frame, no face/texture found). One measurement, ~ms.
203
+ */
204
+ async measureConvergence() {
205
+ if (this.format !== 'sbs' || !this.video || !hasDoc()) return null;
206
+ try {
207
+ await waitForFrame(this.video);
208
+ } catch {
209
+ return null;
210
+ }
211
+ this._scratch = this._scratch || document.createElement('canvas');
212
+ const g = grabLuma(this.video, this._scratch);
213
+ if (!g) return null;
214
+ const m = createFocusTracker().measure(g.img, g.w, g.h, 0);
215
+ return m ? m.d / g.scale : null;
216
+ }
217
+
218
+ /** The stereo record a capture carries (camera/metadata.js), with `convergencePx` filled in. */
219
+ _meta(convergencePx, width = this.width, height = this.height) {
220
+ const s = this.stereo;
221
+ return {
222
+ layout: this.format,
223
+ convergencePx,
224
+ baselineMm: s ? s.baselineMm : null,
225
+ horizontalFovDeg: s ? s.horizontalFovDeg : null,
226
+ rectified: s ? s.rectified : null,
227
+ eyeWidth: this.format === 'sbs' ? width / 2 : width,
228
+ eyeHeight: height,
229
+ software: CAMERA_SDK,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * One frame as an image file: the RAW pair (never mirrored, never shifted) at the camera's full
235
+ * size, with the stereo record written INTO a JPEG as XMP. `type` 'image/jpeg' (default) |
236
+ * 'image/png' | 'image/webp' (the last two carry no metadata — the result still reports it).
237
+ */
238
+ async capturePhoto({ type = 'image/jpeg', quality = 0.92, name, convergencePx } = {}) {
239
+ if (this.state === 'closed') throw asError('the camera is closed', 'closed');
240
+ if (!this.video || !hasDoc()) throw asError('capturePhoto needs a document', 'no-frame');
241
+ await waitForFrame(this.video);
242
+ const W = this.video.videoWidth;
243
+ const H = this.video.videoHeight;
244
+ const conv = typeof convergencePx === 'number' && Number.isFinite(convergencePx) ? convergencePx : await this.measureConvergence();
245
+ const c = document.createElement('canvas');
246
+ c.width = W;
247
+ c.height = H;
248
+ c.getContext('2d').drawImage(this.video, 0, 0, W, H);
249
+ let blob = await new Promise((res, rej) => c.toBlob((b) => (b ? res(b) : rej(asError('toBlob produced nothing', 'encode-failed'))), type, quality));
250
+ const meta = this._meta(conv, W, H);
251
+ let xmp = null;
252
+ if (blob.type === 'image/jpeg' || /jpe?g/.test(type)) {
253
+ xmp = buildStereoXmp(meta);
254
+ blob = new Blob([jpegWithXmp(new Uint8Array(await blob.arrayBuffer()), xmp)], { type: 'image/jpeg' });
255
+ }
256
+ const out = {
257
+ blob,
258
+ type: blob.type || type,
259
+ width: W,
260
+ height: H,
261
+ layout: this.format,
262
+ convergencePx: conv,
263
+ stereo: this.stereo,
264
+ suggestedName: suggestedName(name || `photo-${stamp()}`, this.format, blob.type || type),
265
+ xmp,
266
+ };
267
+ this.log('photo', { width: W, height: H, layout: this.format, convergencePx: conv, bytes: blob.size, name: out.suggestedName });
268
+ return out;
269
+ }
270
+
271
+ /**
272
+ * Record the camera: the SBS stream as sent (plus `audio` tracks if given), optionally a
273
+ * left-eye MONO copy for 2D platforms (`mono: true`, Decision 13 — drawn eye-by-eye on a canvas
274
+ * and captured, so it costs a canvas paint per frame while recording). `stop()` resolves with
275
+ * the files; a WebM carries the stereo record as a Tags element.
276
+ */
277
+ record({ mimeType, mono = false, audio = null, videoBitsPerSecond, timesliceMs = 1000, name, fps = 30 } = {}) {
278
+ if (this.state === 'closed') throw asError('the camera is closed', 'closed');
279
+ if (typeof MediaRecorder !== 'function') throw asError('MediaRecorder is not available here', 'unsupported');
280
+ const mime = mimeType || RECORD_MIME_CANDIDATES.find((m) => typeof MediaRecorder.isTypeSupported !== 'function' || MediaRecorder.isTypeSupported(m)) || '';
281
+ if (mime && typeof MediaRecorder.isTypeSupported === 'function' && !MediaRecorder.isTypeSupported(mime)) throw asError(`${mime} is not a supported recording type here`, 'unsupported');
282
+ const tracks = [...this.stream.getVideoTracks()];
283
+ const audioTracks = audio && typeof audio.getAudioTracks === 'function' ? audio.getAudioTracks() : audio && audio.kind === 'audio' ? [audio] : [];
284
+ tracks.push(...audioTracks);
285
+ const sbs = new MediaStream(tracks);
286
+ const opts = { ...(mime ? { mimeType: mime } : {}), ...(videoBitsPerSecond ? { videoBitsPerSecond } : {}) };
287
+ const rec = new MediaRecorder(sbs, opts);
288
+ const chunks = [];
289
+ rec.ondataavailable = (e) => e.data && e.data.size && chunks.push(e.data);
290
+ let monoRec = null;
291
+ const monoChunks = [];
292
+ let monoCanvas = null;
293
+ let monoRaf = 0;
294
+ const wantMono = !!mono && this.format === 'sbs' && hasDoc() && this.video;
295
+ if (wantMono) {
296
+ monoCanvas = document.createElement('canvas');
297
+ const paintMono = () => {
298
+ monoRaf = requestAnimationFrame(paintMono);
299
+ const v = this.video;
300
+ const W = v.videoWidth;
301
+ const H = v.videoHeight;
302
+ if (!W || !H || (v.readyState || 0) < 2) return;
303
+ if (monoCanvas.width !== W / 2 || monoCanvas.height !== H) {
304
+ monoCanvas.width = W / 2;
305
+ monoCanvas.height = H;
306
+ }
307
+ monoCanvas.getContext('2d').drawImage(v, 0, 0, W / 2, H, 0, 0, W / 2, H);
308
+ };
309
+ paintMono();
310
+ const ms = monoCanvas.captureStream(fps);
311
+ for (const t of audioTracks) ms.addTrack(t);
312
+ monoRec = new MediaRecorder(ms, opts);
313
+ monoRec.ondataavailable = (e) => e.data && e.data.size && monoChunks.push(e.data);
314
+ }
315
+ const t0 = performance.now();
316
+ const measured = [];
317
+ // Sample the convergence a few times while recording: the record stores the median.
318
+ const sampler = this.format === 'sbs' ? setInterval(() => this.measureConvergence().then((d) => d !== null && measured.push(d)), 1500) : 0;
319
+ this.measureConvergence().then((d) => d !== null && measured.push(d));
320
+ rec.start(timesliceMs);
321
+ monoRec?.start(timesliceMs);
322
+ this.log('record-start', { mimeType: mime, mono: wantMono, audio: audioTracks.length });
323
+
324
+ const stopOne = (r, list) =>
325
+ new Promise((res) => {
326
+ if (!r || r.state === 'inactive') return res(list);
327
+ r.onstop = () => res(list);
328
+ r.stop();
329
+ });
330
+ const base = name || `clip-${stamp()}`;
331
+ const camera = this;
332
+ const handle = {
333
+ get state() {
334
+ return rec.state;
335
+ },
336
+ get mimeType() {
337
+ return rec.mimeType || mime;
338
+ },
339
+ pause() {
340
+ if (rec.state === 'recording') rec.pause();
341
+ if (monoRec && monoRec.state === 'recording') monoRec.pause();
342
+ },
343
+ resume() {
344
+ if (rec.state === 'paused') rec.resume();
345
+ if (monoRec && monoRec.state === 'paused') monoRec.resume();
346
+ },
347
+ async stop() {
348
+ clearInterval(sampler);
349
+ if (monoRaf) cancelAnimationFrame(monoRaf);
350
+ const [a, b] = await Promise.all([stopOne(rec, chunks), stopOne(monoRec, monoChunks)]);
351
+ const type = rec.mimeType || mime || (a[0] && a[0].type) || 'video/webm';
352
+ const sorted = [...measured].sort((x, y) => x - y);
353
+ const conv = sorted.length ? sorted[Math.floor(sorted.length / 2)] : null;
354
+ const meta = camera._meta(conv);
355
+ const tagged = /webm|matroska/.test(type);
356
+ const finish = async (parts, layout) => {
357
+ let blob = new Blob(parts, { type });
358
+ if (tagged && blob.size) {
359
+ try {
360
+ blob = new Blob([webmWithTags(new Uint8Array(await blob.arrayBuffer()), stereoTagEntries({ ...meta, layout }))], { type });
361
+ } catch (err) {
362
+ camera.log('record-tag-failed', { message: String(err && err.message) });
363
+ }
364
+ }
365
+ return blob;
366
+ };
367
+ const blob = await finish(a, camera.format);
368
+ const out = {
369
+ blob,
370
+ type,
371
+ layout: camera.format,
372
+ durationMs: Math.round(performance.now() - t0),
373
+ convergencePx: conv,
374
+ stereo: camera.stereo,
375
+ suggestedName: suggestedName(base, camera.format, type),
376
+ tagged: tagged && blob.size > 0,
377
+ };
378
+ if (monoRec) {
379
+ out.mono = await finish(b, 'mono');
380
+ out.monoSuggestedName = suggestedName(base, 'mono', type);
381
+ }
382
+ camera.log('record-stop', { bytes: blob.size, mono: out.mono ? out.mono.size : null, ms: out.durationMs, convergencePx: conv, name: out.suggestedName });
383
+ return out;
384
+ },
385
+ };
386
+ return handle;
387
+ }
388
+
389
+ /** Stop the camera (only tracks this module opened; a page-supplied stream is left running). */
390
+ close() {
391
+ if (this.state === 'closed') return;
392
+ this.state = 'closed';
393
+ for (const t of this.stream.getVideoTracks()) t.removeEventListener?.('ended', this._onEnded);
394
+ for (const v of [...this._views]) v.remove();
395
+ if (this.owned) this.stream.getTracks().forEach((t) => t.stop());
396
+ if (this.video) {
397
+ this.video.srcObject = null;
398
+ }
399
+ this.log('closed', {});
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Open the best camera. `prefer`: `'auto'` (default — a stereo device when one is present: the
405
+ * DisplayXR Browser's "3D Camera", or a device delivering > 2.5:1 frames; else the default
406
+ * webcam), `'stereo'` (prefer the pair, fall back to mono), `'mono'` (never probe), a `deviceId`,
407
+ * or a `MediaStream` you own (then `format` declares what it is — 3D-ness is never guessed from
408
+ * a stream). A camera held by another process (an eye tracker) is skipped, never fatal; the
409
+ * promise rejects only when NO camera opens, with `code` `'camera-busy'` (every device is held),
410
+ * `'permission-denied'`, or `'no-camera'`, and `skipped` listing every device tried.
411
+ *
412
+ * @param {object|string|MediaStream} [opts] `{ prefer, format, calibration, rectify, debug }` (or just `prefer`)
413
+ */
414
+ export async function openCamera(opts = {}) {
415
+ const o = typeof opts === 'string' || (opts && typeof opts.getVideoTracks === 'function') ? { prefer: opts } : opts || {};
416
+ const prefer = o.prefer === undefined || o.prefer === null ? 'auto' : o.prefer;
417
+ const log = typeof o.log === 'function' ? o.log : o.debug ? (tag, obj) => console.log(`${TAG} ${tag} ${JSON.stringify(obj)}`) : null;
418
+ const cal = o.calibration && typeof o.calibration === 'object' ? { ...o.calibration } : {};
419
+ // `horizontalFovDeg` is the public spelling (RFC 0003 §4); the hello wire field is `hfovDeg`.
420
+ if (cal.horizontalFovDeg > 0 && !(cal.hfovDeg > 0)) cal.hfovDeg = cal.horizontalFovDeg;
421
+ delete cal.horizontalFovDeg;
422
+ const r = await openDevice(prefer, { format: o.format, calibration: cal, mediaDevices: o.mediaDevices, log: log || undefined });
423
+ if (r.format === 'sbs' && typeof o.rectify === 'function') {
424
+ // A calibrated rectification step (plug-in or runtime supplied): its output is a rectified
425
+ // SBS stream, and the record then says so. A throwing hook keeps the raw pair.
426
+ try {
427
+ const out = await o.rectify(r.stream, { width: r.width, height: r.height, deviceId: r.deviceId, label: r.label });
428
+ if (out && typeof out.getVideoTracks === 'function') {
429
+ r.stream = out;
430
+ r.calibration = { ...r.calibration, rectified: true };
431
+ }
432
+ } catch (err) {
433
+ log?.('rectify-failed', { message: String(err && err.message) });
434
+ r.rectifyError = err;
435
+ }
436
+ }
437
+ const cam = new StereoCamera(r, { log });
438
+ if (r.rectifyError) cam.rectifyError = r.rectifyError;
439
+ cam.log('open', { format: cam.format, width: cam.width, height: cam.height, label: cam.label, stereo: cam.stereo, skipped: cam.skipped.length });
440
+ return cam;
441
+ }
442
+
443
+ /** Is `x` a StereoCamera from {@link openCamera}? (Duck-typed, so copies of the SDK interoperate.) */
444
+ export const isStereoCamera = (x) => !!x && typeof x === 'object' && typeof x.capturePhoto === 'function' && typeof x.record === 'function' && x.stream && typeof x.stream.getVideoTracks === 'function';
445
+
446
+ // ── the self view ──────────────────────────────────────────────────────────────────────────
447
+
448
+ /** Size a FLAT (never woven) canvas to its box × dpr. False if it has no box yet. */
449
+ function sizeFlat(canvas) {
450
+ const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
451
+ const w = Math.round((canvas.clientWidth || 0) * dpr);
452
+ const h = Math.round((canvas.clientHeight || 0) * dpr);
453
+ if (!w || !h) return false;
454
+ if (canvas.width !== w || canvas.height !== h) {
455
+ canvas.width = w;
456
+ canvas.height = h;
457
+ }
458
+ return true;
459
+ }
460
+
461
+ /**
462
+ * Put a camera on a canvas as a self view. On a woven wall a stereo camera is a 3D tile —
463
+ * mirrored CORRECTLY: each half mirrored AND the halves swapped (mirroring a side-by-side frame
464
+ * half by half in place inverts every disparity and turns the face inside out); anywhere else
465
+ * it is the left eye (or the mono frame), flat and mirrored. The stream itself is never touched.
466
+ *
467
+ * `autoConverge` measures the disparity of the point between the eyes a few times a second and
468
+ * shifts the two eyes so the face sits at the display plane; `depth` ([-1, 1], + = push back)
469
+ * is an offset on top — the same control as the call's.
470
+ *
471
+ * @param {object|null} wall a `createInline3D()` / `sharedInline3D()` result (may be unsupported) or null
472
+ * @param {HTMLCanvasElement} canvas the tile — its CSS box is the shape the viewer sees
473
+ * @param {StereoCamera} cam
474
+ * @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number }} [opts]
475
+ */
476
+ export function addCameraView(wall, canvas, cam, opts = {}) {
477
+ if (!canvas || typeof canvas.getContext !== 'function') throw new TypeError(`${TAG} addCameraView(wall, canvas, cam, opts) needs a canvas`);
478
+ if (!isStereoCamera(cam)) throw new TypeError(`${TAG} addCameraView: cam must be a StereoCamera from openCamera()`);
479
+ const view = new CameraView(wall, canvas, cam, opts);
480
+ cam._views?.add(view);
481
+ return view;
482
+ }
483
+
484
+ class CameraView {
485
+ constructor(wall, canvas, cam, o) {
486
+ this.wall = wall || null;
487
+ this.canvas = canvas;
488
+ this.cam = cam;
489
+ this.mirror = o.mirror === undefined ? true : !!o.mirror;
490
+ this.aspect = typeof o.aspect === 'number' && o.aspect > 0.3 && o.aspect < 4 ? o.aspect : 16 / 9;
491
+ this.autoConverge = !!o.autoConverge;
492
+ this.conv = createConvergence();
493
+ this.conv.depth = 0;
494
+ this.setDepth(o.depth);
495
+ this.route = null;
496
+ this.handle = null;
497
+ this.buffer = null; // the SBS canvas the wall repaints the tile from (woven route)
498
+ this.removed = false;
499
+ this._raf = 0;
500
+ this._loop = o._loop !== false;
501
+ this._autoAt = 0;
502
+ this._track = createDisparityTrack();
503
+ this._focus = createFocusTracker();
504
+ this._scratch = null;
505
+ this.disparityPx = null;
506
+ this._reroute(false);
507
+ if (this._loop) this._start();
508
+ }
509
+
510
+ /** `'woven-sbs'` (3D on the panel) | `'flat-left'` (a pair, shown flat) | `'flat'` (mono). */
511
+ get woven() {
512
+ return this.route === 'woven-sbs';
513
+ }
514
+
515
+ /** The per-eye convergence shift currently painted, source px. */
516
+ get convergencePx() {
517
+ return this.conv.current;
518
+ }
519
+
520
+ get depth() {
521
+ return this.conv.depth;
522
+ }
523
+
524
+ setDepth(v) {
525
+ const n = v === null || v === undefined || !Number.isFinite(+v) ? 0 : Math.max(-1, Math.min(1, +v));
526
+ this.conv.depth = n;
527
+ return n;
528
+ }
529
+
530
+ setMirror(on) {
531
+ this.mirror = on === undefined ? !this.mirror : !!on;
532
+ return this.mirror;
533
+ }
534
+
535
+ setAutoConverge(on) {
536
+ this.autoConverge = on === undefined ? !this.autoConverge : !!on;
537
+ if (!this.autoConverge) {
538
+ this._track.reset();
539
+ this._focus.reset();
540
+ this.conv.measuredPx = null;
541
+ this.disparityPx = null;
542
+ }
543
+ return this.autoConverge;
544
+ }
545
+
546
+ /** Re-register on the wall (the call does this when the weave goes live, #172, or a session is recovered). */
547
+ _reroute(force, wall) {
548
+ if (wall !== undefined) this.wall = wall;
549
+ if (this.removed) return;
550
+ const cam = this.cam;
551
+ const live = cam.state === 'live' && cam.video;
552
+ const route = cam.format === 'sbs' && live && this.wall && this.wall.supported ? 'woven-sbs' : cam.format === 'sbs' ? 'flat-left' : 'flat';
553
+ if (!force && route === this.route) return;
554
+ this._unregister();
555
+ this.route = route;
556
+ if (route === 'woven-sbs') {
557
+ if (!this.buffer) this.buffer = document.createElement('canvas');
558
+ this._paintWoven(); // the first frame exists before the layer does
559
+ this.handle = this.wall.addImage(this.canvas, this.buffer);
560
+ }
561
+ }
562
+
563
+ /** The wall's session ended: the layer is gone; paint flat until a wall comes back. */
564
+ _onWallLost() {
565
+ // The layer died with the session; `remove()` on it is harmless (try/catch) and keeps the
566
+ // bookkeeping honest. Paint flat until a wall comes back (`_reroute(true, wall)`).
567
+ this._reroute(true, { supported: false });
568
+ }
569
+
570
+ _unregister() {
571
+ if (this.handle) {
572
+ try {
573
+ this.handle.remove();
574
+ } catch {
575
+ /* the session may be gone */
576
+ }
577
+ this.handle = null;
578
+ }
579
+ }
580
+
581
+ _start() {
582
+ const tick = () => {
583
+ this._raf = requestAnimationFrame(tick);
584
+ this.paint();
585
+ };
586
+ this._raf = requestAnimationFrame(tick);
587
+ }
588
+
589
+ /** Paint one frame (the view does this itself every animation frame). */
590
+ paint() {
591
+ if (this.removed) return;
592
+ const v = this.cam.video;
593
+ if (!v || this.cam.state === 'closed') return;
594
+ if (this.route === 'woven-sbs') this._paintWoven();
595
+ else this._paintFlat(v);
596
+ }
597
+
598
+ _paintFlat(v) {
599
+ if (!sizeFlat(this.canvas)) return;
600
+ const W = v.videoWidth;
601
+ const H = v.videoHeight;
602
+ if (!W || !H || (v.readyState || 0) < 2) return;
603
+ const g = this.canvas.getContext('2d');
604
+ const eyeW = this.route === 'flat-left' ? W / 2 : W;
605
+ const r = eyeCropRect(eyeW, H, this.canvas.width / this.canvas.height, 0, 0);
606
+ g.save();
607
+ if (this.mirror) {
608
+ g.translate(this.canvas.width, 0);
609
+ g.scale(-1, 1);
610
+ }
611
+ g.drawImage(v, r.sx, r.sy, r.sw, r.sh, 0, 0, this.canvas.width, this.canvas.height);
612
+ g.restore();
613
+ }
614
+
615
+ /**
616
+ * The woven pair into `buffer`: mirrored (each half mirrored AND swapped — geometry.js
617
+ * mirrorSwapOps) or as sent, cropped to the tile's aspect, with the convergence shift applied
618
+ * per eye in SOURCE space before any mirroring — which is why the shift's meaning survives the
619
+ * mirror (the pixels equal a whole-frame flip of the converged pair).
620
+ */
621
+ _paintWoven() {
622
+ const v = this.cam.video;
623
+ const W = v.videoWidth;
624
+ const H = v.videoHeight;
625
+ if (!W || !H || (v.readyState || 0) < 2 || !this.buffer) return;
626
+ const eyeW = W / 2;
627
+ const A = this.aspect;
628
+ const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
629
+ const c = this.buffer;
630
+ if (c.width !== 2 * outW || c.height !== outH) {
631
+ c.width = 2 * outW;
632
+ c.height = outH;
633
+ }
634
+ if (this.autoConverge) this._sample(v);
635
+ const shift = this.conv.step(eyeW);
636
+ const g = c.getContext('2d');
637
+ if (this.mirror) {
638
+ for (const op of mirrorSwapOps(W, H)) {
639
+ const eye = op.src === 'L' ? 0 : 1;
640
+ const r = eyeCropRect(eyeW, H, A, shift, eye);
641
+ const dx = op.dx === 0 ? 0 : outW;
642
+ g.save();
643
+ g.translate(dx + outW, 0);
644
+ g.scale(-1, 1);
645
+ g.drawImage(v, op.sx + r.sx, r.sy, r.sw, r.sh, 0, 0, outW, outH);
646
+ g.restore();
647
+ }
648
+ } else {
649
+ for (const eye of [0, 1]) {
650
+ const r = eyeCropRect(eyeW, H, A, shift, eye);
651
+ g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
652
+ }
653
+ }
654
+ }
655
+
656
+ /** Auto-convergence: ~5 Hz, a small grayscale copy of the SOURCE frame (before our shift). */
657
+ _sample(v) {
658
+ const now = performance.now();
659
+ if (now - this._autoAt < AUTO_CONV_INTERVAL_MS) return;
660
+ this._autoAt = now;
661
+ this._scratch = this._scratch || document.createElement('canvas');
662
+ const g = grabLuma(v, this._scratch);
663
+ const m = g ? this._focus.measure(g.img, g.w, g.h, now) : null;
664
+ const d = this._track.push(m ? m.d / g.scale : null);
665
+ this.conv.measuredPx = d;
666
+ this.disparityPx = d;
667
+ }
668
+
669
+ /** Stop painting and leave the wall. The canvas keeps its last frame; the camera stays open. */
670
+ remove() {
671
+ if (this.removed) return;
672
+ this.removed = true;
673
+ if (this._raf) cancelAnimationFrame(this._raf);
674
+ this._raf = 0;
675
+ this._unregister();
676
+ this.cam._views?.delete(this);
677
+ }
678
+ }
@@ -464,6 +464,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
464
464
  nearClip: opts.nearClip,
465
465
  farClip: opts.farClip,
466
466
  sky: false,
467
+ viewerEase: opts.viewerEase,
467
468
  // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
468
469
  toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
469
470
  });