@displayxr/inline3d 1.39.0 → 1.40.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 +25 -0
- package/call.d.ts +9 -1
- package/camera.d.ts +17 -0
- package/dist/call.js +4 -4
- package/js/call/element.js +2 -0
- package/js/call/options.js +4 -0
- package/js/camera/pair-cursor.js +166 -0
- package/js/inline3d-call.js +28 -4
- package/js/inline3d-camera.js +30 -4
- package/js/inline3d-cursor-option.js +18 -0
- package/package.json +1 -1
package/js/call/element.js
CHANGED
|
@@ -71,6 +71,7 @@ const str = (v) => (v === null || v === undefined || String(v).trim() === '' ? u
|
|
|
71
71
|
* | `no-audio` | `audio: false` |
|
|
72
72
|
* | `no-self-view` | `selfView: false` |
|
|
73
73
|
* | `no-auto-converge` | `autoConverge: false` |
|
|
74
|
+
* | `cursor="depth"` | `cursor: 'depth'` |
|
|
74
75
|
* | `tile-aspect` | `theme.tileAspect` (a number, or `w/h`) |
|
|
75
76
|
* | `invite-base` | `invite.base` |
|
|
76
77
|
* | `browser-url` | `landing.browserUrl` |
|
|
@@ -101,6 +102,7 @@ export function attrsToOpts(source) {
|
|
|
101
102
|
if (isOn(get('no-audio'))) o.audio = false;
|
|
102
103
|
if (isOn(get('no-self-view'))) o.selfView = false;
|
|
103
104
|
if (isOn(get('no-auto-converge'))) o.autoConverge = false;
|
|
105
|
+
if (str(get('cursor')) === 'depth') o.cursor = 'depth';
|
|
104
106
|
// The C2 groups (RFC 0003 §2): the attribute names are the C1 ones, the option shapes are new.
|
|
105
107
|
const theme = { accent: str(get('accent')), tileAspect: num(get('tile-aspect')) };
|
|
106
108
|
if (theme.accent !== undefined || theme.tileAspect !== undefined) o.theme = Object.fromEntries(Object.entries(theme).filter(([, v]) => v !== undefined));
|
package/js/call/options.js
CHANGED
|
@@ -8,6 +8,7 @@ import { clampMaxPeers } from './transport.js';
|
|
|
8
8
|
import { normalizeMono3D } from './lift.js';
|
|
9
9
|
import { dxrSignaling } from './signaling.js';
|
|
10
10
|
import { resolveCallAccent, THEME_VARS } from './ui.js';
|
|
11
|
+
import { resolvePairCursorOption } from '../inline3d-cursor-option.js';
|
|
11
12
|
|
|
12
13
|
const TAG = '[inline3d/call]';
|
|
13
14
|
const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
|
|
@@ -89,6 +90,9 @@ export function normalizeCallOptions(opts = {}) {
|
|
|
89
90
|
// peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
|
|
90
91
|
// offset on top. Off = the pair as sent (plus any `hint`).
|
|
91
92
|
autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
|
|
93
|
+
// `cursor: 'depth'` (runtime ADR-046 on a pair): a crosshair just in front of the person under
|
|
94
|
+
// the pointer, measured off each stereo tile's two halves. Off by default (zero cost).
|
|
95
|
+
cursor: resolvePairCursorOption(opts.cursor, '@displayxr/inline3d/call'),
|
|
92
96
|
mono3D: normalizeMono3D(opts.mono3D),
|
|
93
97
|
maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
|
|
94
98
|
// Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// camera/pair-cursor.js — the depth-aware cursor on a STEREO PAIR: a call tile or a camera view.
|
|
2
|
+
//
|
|
3
|
+
// The OS cursor is drawn at zero disparity, on the glass. Over a person who sits in front of it
|
|
4
|
+
// (a converged stereo call puts the face AT the glass, so the shoulders, a raised hand, a cup held
|
|
5
|
+
// up are all in front), it is drawn on top of pixels that say they are nearer — the depth
|
|
6
|
+
// violation runtime ADR-046 is about. A pair has no scene to raycast and no depth buffer, but its
|
|
7
|
+
// two halves ARE the depth: this is ADR-046 Phase 3b done in the page. Block-match a cursor-sized
|
|
8
|
+
// footprint between the eyes, take the nearest confident match, and draw a crosshair into both
|
|
9
|
+
// halves with that disparity plus a small margin. The browser weaves the pair as usual, so the
|
|
10
|
+
// sprite lands just in front of the content under it.
|
|
11
|
+
//
|
|
12
|
+
// Same policy as the scene cursor (./inline3d-cursor-depth.js): the footprint ring, the fast-rise /
|
|
13
|
+
// slow-sink filter, the display plane when nothing matches. Disparities here are in OUTPUT pixels
|
|
14
|
+
// of one eye of the woven buffer, + = crossed (in front of the glass), the sign the rest of
|
|
15
|
+
// camera/ uses. Loaded only when a page asks for `cursor: 'depth'` — zero cost otherwise.
|
|
16
|
+
|
|
17
|
+
import { CursorPointer, cursorFootprint, cursorFilterStep, CURSOR_DEFAULT_TUNING, CURSOR_CROSSHAIR, CURSOR_FILL_COLOR, CURSOR_OUTLINE_COLOR } from '../inline3d-cursor-depth.js';
|
|
18
|
+
import { matchBlock, MIN_NCC } from './disparity.js';
|
|
19
|
+
|
|
20
|
+
/** Sprite height, as a fraction of the tile height (a call tile is small; 3% would be a speck). */
|
|
21
|
+
export const PAIR_CURSOR_HEIGHT = 0.06;
|
|
22
|
+
/** How far in front of the content the sprite floats, as a fraction of the eye width (crossed). */
|
|
23
|
+
export const PAIR_CURSOR_MARGIN = 0.002;
|
|
24
|
+
/** Comfort clamp on the sprite's disparity, as a fraction of the eye width, either way. */
|
|
25
|
+
export const PAIR_CURSOR_CLAMP = 0.08;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The displayed disparity of the nearest content under the cursor's footprint, or null when no
|
|
29
|
+
* point of the footprint matched confidently (flat, ambiguous, off the frame).
|
|
30
|
+
*
|
|
31
|
+
* @param {{img: ArrayLike<number>, w: number, h: number, scale: number}} luma a grayscale copy
|
|
32
|
+
* of the WHOLE side-by-side source frame; `scale` = copy px per source px.
|
|
33
|
+
* @param {{rL: {sx:number, sy:number, sw:number, sh:number}, rR: {sx:number}, outW: number, mirror?: boolean}} geo
|
|
34
|
+
* each eye's source crop (relative to its half, eyeCropRect) and the output eye width; a
|
|
35
|
+
* mirrored view (the self view) flips the pointer, never the disparity.
|
|
36
|
+
* @param {number} u pointer, tile-normalised (0..1, left → right as the viewer sees it)
|
|
37
|
+
* @param {number} v pointer, tile-normalised (0..1, top → bottom)
|
|
38
|
+
* @param {number} height sprite height (fraction of the tile height)
|
|
39
|
+
* @param {number} aspect the tile's width / height, for a round footprint
|
|
40
|
+
* @returns {number|null} output px, + = in front of the glass
|
|
41
|
+
*/
|
|
42
|
+
export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
|
|
43
|
+
if (!luma || !luma.img || !geo || !geo.rL || !geo.rR || !(geo.outW > 0)) return null;
|
|
44
|
+
const { img, w, h, scale } = luma;
|
|
45
|
+
const E = w / 2;
|
|
46
|
+
const b = Math.max(8, Math.round(E / 20));
|
|
47
|
+
const dMax = Math.round(E * 0.3);
|
|
48
|
+
const dMin = -Math.round(E * 0.05);
|
|
49
|
+
const { rL, rR } = geo;
|
|
50
|
+
const k = geo.outW / rL.sw; // output px per source px
|
|
51
|
+
let best = null;
|
|
52
|
+
for (const [pu, pv] of cursorFootprint(u, v, height, aspect)) {
|
|
53
|
+
if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
|
|
54
|
+
const su = geo.mirror ? 1 - pu : pu;
|
|
55
|
+
const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
|
|
56
|
+
const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
|
|
57
|
+
if (x < 0 || x + b > E || y < 0 || y + b > h) continue;
|
|
58
|
+
const m = matchBlock(img, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
|
|
59
|
+
if (!m || m.c < MIN_NCC) continue;
|
|
60
|
+
// Source disparity → displayed: each eye's crop moved its half by its own offset.
|
|
61
|
+
const d = (m.d / scale - (rL.sx - rR.sx)) * k;
|
|
62
|
+
if (best === null || d > best) best = d;
|
|
63
|
+
}
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The sprite's target disparity: the content plus the margin, clamped; the glass with nothing under it. */
|
|
68
|
+
export function pairCursorTarget(contentPx, outW) {
|
|
69
|
+
if (contentPx === null || !Number.isFinite(contentPx)) return 0;
|
|
70
|
+
const lim = PAIR_CURSOR_CLAMP * outW;
|
|
71
|
+
return Math.max(-lim, Math.min(lim, contentPx + PAIR_CURSOR_MARGIN * outW));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Draw the crosshair into both halves of a woven buffer: the left eye's copy `disparityPx / 2`
|
|
76
|
+
* right of the pointer, the right eye's as far left (crossed = in front). Each copy is clipped to
|
|
77
|
+
* its own half, so a sprite at the edge never bleeds into the other eye.
|
|
78
|
+
*/
|
|
79
|
+
export function drawPairCrosshair(g, outW, outH, u, v, disparityPx, height) {
|
|
80
|
+
const size = height * outH;
|
|
81
|
+
const cy = v * outH;
|
|
82
|
+
const rgba = (c) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${c[3]})`;
|
|
83
|
+
for (const eye of [0, 1]) {
|
|
84
|
+
const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
|
|
85
|
+
g.save();
|
|
86
|
+
g.beginPath();
|
|
87
|
+
g.rect(eye * outW, 0, outW, outH);
|
|
88
|
+
g.clip();
|
|
89
|
+
for (const [width, color, cap] of [
|
|
90
|
+
[0.15, CURSOR_OUTLINE_COLOR, 'square'],
|
|
91
|
+
[0.07, CURSOR_FILL_COLOR, 'butt'],
|
|
92
|
+
]) {
|
|
93
|
+
g.lineWidth = width * size;
|
|
94
|
+
g.lineCap = cap;
|
|
95
|
+
g.strokeStyle = rgba(color);
|
|
96
|
+
g.beginPath();
|
|
97
|
+
for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
|
|
98
|
+
g.moveTo(cx + x0 * size, cy + y0 * size);
|
|
99
|
+
g.lineTo(cx + x1 * size, cy + y1 * size);
|
|
100
|
+
}
|
|
101
|
+
g.stroke();
|
|
102
|
+
}
|
|
103
|
+
g.restore();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One cursor over one woven pair. The owner calls `draw()` after painting the pair each frame
|
|
109
|
+
* (`idle()` on any frame it does not paint a woven pair), and hands it the latest grayscale copy
|
|
110
|
+
* of the source when it has one; `hovering` says when that copy is worth grabbing more often.
|
|
111
|
+
*/
|
|
112
|
+
export class PairCursor {
|
|
113
|
+
/** @param {HTMLElement} canvas the tile — the pointer counts over its box */
|
|
114
|
+
constructor(canvas, { height, pointerScope = 'window' } = {}) {
|
|
115
|
+
this.pointer = new CursorPointer(canvas, { scope: pointerScope });
|
|
116
|
+
this.height = height > 0 ? height : PAIR_CURSOR_HEIGHT;
|
|
117
|
+
this.filter = {};
|
|
118
|
+
this._luma = null;
|
|
119
|
+
this._uv = null;
|
|
120
|
+
this._content = null;
|
|
121
|
+
/** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx }` or null. */
|
|
122
|
+
this.last = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get hovering() {
|
|
126
|
+
return !!this.pointer.uv;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {CanvasRenderingContext2D} g the woven buffer (2·outW × outH), already painted
|
|
131
|
+
* @param {object|null} luma the latest grayscale source copy (pairFootprintDisparity)
|
|
132
|
+
* @param {{rL: object, rR: object, outW: number, outH: number, mirror?: boolean}} geo
|
|
133
|
+
* @param {number} nowSec
|
|
134
|
+
*/
|
|
135
|
+
draw(g, luma, geo, nowSec) {
|
|
136
|
+
const uv = this.pointer.uv;
|
|
137
|
+
if (!uv) return this.idle();
|
|
138
|
+
const [u, v] = uv;
|
|
139
|
+
// Match only when there is something new to match: a new frame copy or a moved pointer.
|
|
140
|
+
if (luma !== this._luma || !this._uv || this._uv[0] !== u || this._uv[1] !== v) {
|
|
141
|
+
this._content = pairFootprintDisparity(luma, geo, u, v, this.height, geo.outW / geo.outH);
|
|
142
|
+
this._luma = luma;
|
|
143
|
+
this._uv = uv;
|
|
144
|
+
}
|
|
145
|
+
const target = pairCursorTarget(this._content, geo.outW);
|
|
146
|
+
// The shared filter rises fast toward SMALLER values (nearer, in its convention): negate.
|
|
147
|
+
const disparity = -cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, -target, nowSec);
|
|
148
|
+
drawPairCrosshair(g, geo.outW, geo.outH, u, v, disparity, this.height);
|
|
149
|
+
this.pointer.hideCss(true);
|
|
150
|
+
this.last = { contentPx: this._content, targetPx: target, disparityPx: disparity };
|
|
151
|
+
return this.last;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Not drawing this frame (pointer away, tile not woven): the CSS cursor comes back. */
|
|
155
|
+
idle() {
|
|
156
|
+
this.pointer.hideCss(false);
|
|
157
|
+
this.filter = {};
|
|
158
|
+
this._uv = null;
|
|
159
|
+
this.last = null;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
dispose() {
|
|
164
|
+
this.pointer.dispose();
|
|
165
|
+
}
|
|
166
|
+
}
|
package/js/inline3d-call.js
CHANGED
|
@@ -117,6 +117,8 @@ const LIFT_RETRIES = 2;
|
|
|
117
117
|
// disparity on a face; small enough to stay a few ms per measurement).
|
|
118
118
|
const AUTO_CONV_INTERVAL_MS = 200;
|
|
119
119
|
const AUTO_CONV_EYE_WIDTH = 240;
|
|
120
|
+
// While a depth cursor hovers a stereo tile, its source is copied this often (the cursor measures it).
|
|
121
|
+
const CURSOR_SAMPLE_MS = 66;
|
|
120
122
|
// WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion.
|
|
121
123
|
const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
|
|
122
124
|
/**
|
|
@@ -1306,6 +1308,7 @@ class Tile {
|
|
|
1306
1308
|
// Bumped on every stream change and on destroy(): an async copyTo() that resolves after either
|
|
1307
1309
|
// belongs to a stream this tile no longer shows, and is dropped (review of #92).
|
|
1308
1310
|
this.autoGen = 0;
|
|
1311
|
+
this.luma = null; // the latest grayscale source copy: auto-convergence and the depth cursor read it
|
|
1309
1312
|
this.destroyed = false;
|
|
1310
1313
|
this.conv.depth = call.depth;
|
|
1311
1314
|
this.hintGate = rateGate(HINT_MAX_HZ);
|
|
@@ -1337,6 +1340,14 @@ class Tile {
|
|
|
1337
1340
|
}
|
|
1338
1341
|
call.ui.grid.appendChild(this.el);
|
|
1339
1342
|
this._renderBadge();
|
|
1343
|
+
/** The depth cursor (`cursor: 'depth'`) once its module has loaded; null otherwise. */
|
|
1344
|
+
this.cursor = null;
|
|
1345
|
+
if (call.o.cursor) {
|
|
1346
|
+
call._pairCursorMod = call._pairCursorMod || import('./camera/pair-cursor.js');
|
|
1347
|
+
call._pairCursorMod.then((m) => {
|
|
1348
|
+
if (!this.destroyed) this.cursor = new m.PairCursor(this.canvas, call.o.cursor);
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1340
1351
|
}
|
|
1341
1352
|
|
|
1342
1353
|
/** How this side shows the participant: `3D` (woven pair) | `2D→3D` (lifted) | `2D` (flat). */
|
|
@@ -1366,6 +1377,8 @@ class Tile {
|
|
|
1366
1377
|
hello: this.hello,
|
|
1367
1378
|
quality: this.quality,
|
|
1368
1379
|
convergencePx: this.conv.current,
|
|
1380
|
+
// The depth cursor's last placement (output px, + = in front of the glass), null when not drawn.
|
|
1381
|
+
cursor: this.cursor ? this.cursor.last : null,
|
|
1369
1382
|
// Auto-convergence: the measured disparity (source px, null until a lock) and the mean cost of
|
|
1370
1383
|
// one measurement including the frame readback.
|
|
1371
1384
|
autoConverge: {
|
|
@@ -1672,6 +1685,7 @@ class Tile {
|
|
|
1672
1685
|
|
|
1673
1686
|
paint() {
|
|
1674
1687
|
if (!this.route) return;
|
|
1688
|
+
if (this.route !== 'woven-sbs') this.cursor?.idle(); // only a woven pair has depth to measure
|
|
1675
1689
|
if (this.route === 'woven-sbs') this._paintConv();
|
|
1676
1690
|
else if (this.route === 'lifted') {
|
|
1677
1691
|
if (!this.liftLive) paintFlat(this.canvas, this.video); // flat until lift is showing
|
|
@@ -1692,13 +1706,16 @@ class Tile {
|
|
|
1692
1706
|
c.width = 2 * outW;
|
|
1693
1707
|
c.height = outH;
|
|
1694
1708
|
}
|
|
1695
|
-
|
|
1709
|
+
const hover = !!(this.cursor && this.cursor.hovering);
|
|
1710
|
+
if (this.call.o.autoConverge || hover) this._sampleDisparity(v, W, H, hover);
|
|
1696
1711
|
const shift = this.conv.step(eyeW);
|
|
1697
1712
|
const g = c.getContext('2d');
|
|
1713
|
+
const rects = [];
|
|
1698
1714
|
for (const eye of [0, 1]) {
|
|
1699
|
-
const r = wire.eyeCropRect(eyeW, H, A, shift, eye);
|
|
1715
|
+
const r = (rects[eye] = wire.eyeCropRect(eyeW, H, A, shift, eye));
|
|
1700
1716
|
g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
|
|
1701
1717
|
}
|
|
1718
|
+
if (this.cursor) this.cursor.draw(g, this.luma, { rL: rects[0], rR: rects[1], outW, outH }, performance.now() / 1000);
|
|
1702
1719
|
}
|
|
1703
1720
|
|
|
1704
1721
|
/**
|
|
@@ -1707,9 +1724,10 @@ class Tile {
|
|
|
1707
1724
|
* feedback loop) and hand it to the convergence state, which halves it per eye and low-passes it.
|
|
1708
1725
|
* A failed measurement holds the last good value.
|
|
1709
1726
|
*/
|
|
1710
|
-
_sampleDisparity(v, W, H) {
|
|
1727
|
+
_sampleDisparity(v, W, H, hover = false) {
|
|
1711
1728
|
const now = performance.now();
|
|
1712
|
-
|
|
1729
|
+
// ~15 Hz while a depth cursor hovers: it measures the same copy, under the pointer.
|
|
1730
|
+
if (this.autoPending || now - this.autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
|
|
1713
1731
|
this.autoAt = now;
|
|
1714
1732
|
// Preferred: WebCodecs. `new VideoFrame(video)` + an ASYNC copyTo() of the decoded frame, reading
|
|
1715
1733
|
// the luma (Y) plane directly: no synchronous GPU->CPU canvas readback on the paint path (that
|
|
@@ -1775,6 +1793,8 @@ class Tile {
|
|
|
1775
1793
|
*/
|
|
1776
1794
|
_applyMeasurement(img, w, h, s, at, readMs, via) {
|
|
1777
1795
|
if (this.destroyed) return;
|
|
1796
|
+
if (img) this.luma = { img, w, h, scale: s };
|
|
1797
|
+
if (!this.call.o.autoConverge) return; // the copy was for the depth cursor only
|
|
1778
1798
|
const t = performance.now();
|
|
1779
1799
|
const m = img ? this.autoFocus.measure(img, w, h, at) : null;
|
|
1780
1800
|
const d = this.autoTrack.push(m ? m.d / s : null);
|
|
@@ -1800,6 +1820,7 @@ class Tile {
|
|
|
1800
1820
|
|
|
1801
1821
|
_resetAutoConverge() {
|
|
1802
1822
|
this.autoGen++;
|
|
1823
|
+
this.luma = null;
|
|
1803
1824
|
this.autoTrack.reset();
|
|
1804
1825
|
this.autoFocus.reset();
|
|
1805
1826
|
this.conv.measuredPx = null;
|
|
@@ -1809,6 +1830,8 @@ class Tile {
|
|
|
1809
1830
|
destroy() {
|
|
1810
1831
|
this.destroyed = true;
|
|
1811
1832
|
this.autoGen++;
|
|
1833
|
+
this.cursor?.dispose();
|
|
1834
|
+
this.cursor = null;
|
|
1812
1835
|
clearTimeout(this.helloTimer);
|
|
1813
1836
|
clearTimeout(this.layerTimer);
|
|
1814
1837
|
clearTimeout(this.leaveTimer);
|
|
@@ -1874,6 +1897,7 @@ class SelfTile {
|
|
|
1874
1897
|
this.view = addCameraView(wall, this.canvas, cam, {
|
|
1875
1898
|
mirror: true,
|
|
1876
1899
|
autoConverge: !!this.call.o.autoConverge, // the call option (default on) governs the self view too
|
|
1900
|
+
cursor: this.call.o.cursor || undefined, // and so does `cursor: 'depth'`
|
|
1877
1901
|
aspect: this.call.o.tileAspect,
|
|
1878
1902
|
onRouteChange: (route, st) => {
|
|
1879
1903
|
if (this.cam !== cam) return; // a view this tile already replaced
|
package/js/inline3d-camera.js
CHANGED
|
@@ -39,6 +39,7 @@ import { openCamera as openDevice } from './camera/capture.js';
|
|
|
39
39
|
import { eyeCropRect, eyeOutputSize, mirrorSwapOps } from './camera/geometry.js';
|
|
40
40
|
import { createConvergence } from './camera/converge.js';
|
|
41
41
|
import { lumaFromRgba, createDisparityTrack, createFocusTracker } from './camera/disparity.js';
|
|
42
|
+
import { resolvePairCursorOption } from './inline3d-cursor-option.js';
|
|
42
43
|
import { buildStereoXmp, jpegWithXmp, webmWithTags, stereoTagEntries, readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp } from './camera/metadata.js';
|
|
43
44
|
|
|
44
45
|
export { readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp };
|
|
@@ -50,6 +51,8 @@ export const CAMERA_SDK = 'inline3d-camera/1';
|
|
|
50
51
|
// on a face, a few ms per measurement — the same numbers as the call's remote tiles).
|
|
51
52
|
const AUTO_CONV_INTERVAL_MS = 200;
|
|
52
53
|
const AUTO_CONV_EYE_WIDTH = 240;
|
|
54
|
+
/** While a depth cursor hovers the view, the source is copied this often (it is what the cursor measures). */
|
|
55
|
+
const CURSOR_SAMPLE_MS = 66;
|
|
53
56
|
const FRAME_WAIT_MS = 4000;
|
|
54
57
|
// A self view whose layer the wall could not build (`firstWoven` → `woven:false, 'layer-failed'`)
|
|
55
58
|
// goes flat and re-registers a few times with backoff — the remote tile's rule (web#131).
|
|
@@ -521,7 +524,17 @@ class CameraView {
|
|
|
521
524
|
this._track = createDisparityTrack();
|
|
522
525
|
this._focus = createFocusTracker();
|
|
523
526
|
this._scratch = null;
|
|
527
|
+
this._luma = null; // the latest grayscale source copy (auto-convergence and the depth cursor read it)
|
|
524
528
|
this.disparityPx = null;
|
|
529
|
+
// `cursor: 'depth'` (ADR-046 on a pair): validated now, its module loaded only on opt-in.
|
|
530
|
+
this._cursorOpt = resolvePairCursorOption(o.cursor, '@displayxr/inline3d/camera');
|
|
531
|
+
/** The depth cursor once its module has loaded; null without `cursor: 'depth'`. */
|
|
532
|
+
this.cursor = null;
|
|
533
|
+
if (this._cursorOpt) {
|
|
534
|
+
import('./camera/pair-cursor.js').then((m) => {
|
|
535
|
+
if (!this.removed) this.cursor = new m.PairCursor(this.canvas, this._cursorOpt);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
525
538
|
this._reroute(false);
|
|
526
539
|
if (this._loop) this._start();
|
|
527
540
|
}
|
|
@@ -691,6 +704,7 @@ class CameraView {
|
|
|
691
704
|
}
|
|
692
705
|
|
|
693
706
|
_paintFlat(v) {
|
|
707
|
+
this.cursor?.idle(); // a flat picture has no depth to put a cursor at
|
|
694
708
|
if (!sizeFlat(this.canvas)) return;
|
|
695
709
|
const W = v.videoWidth;
|
|
696
710
|
const H = v.videoHeight;
|
|
@@ -726,7 +740,8 @@ class CameraView {
|
|
|
726
740
|
c.width = 2 * outW;
|
|
727
741
|
c.height = outH;
|
|
728
742
|
}
|
|
729
|
-
|
|
743
|
+
const hover = !!(this.cursor && this.cursor.hovering);
|
|
744
|
+
if (this.autoConverge || hover) this._sample(v, hover);
|
|
730
745
|
const shift = this.conv.step(eyeW);
|
|
731
746
|
const g = c.getContext('2d');
|
|
732
747
|
if (this.mirror) {
|
|
@@ -746,15 +761,24 @@ class CameraView {
|
|
|
746
761
|
g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
|
|
747
762
|
}
|
|
748
763
|
}
|
|
764
|
+
if (this.cursor) {
|
|
765
|
+
const geo = { rL: eyeCropRect(eyeW, H, A, shift, 0), rR: eyeCropRect(eyeW, H, A, shift, 1), outW, outH, mirror: this.mirror };
|
|
766
|
+
this.cursor.draw(g, this._luma, geo, performance.now() / 1000);
|
|
767
|
+
}
|
|
749
768
|
}
|
|
750
769
|
|
|
751
|
-
/**
|
|
752
|
-
|
|
770
|
+
/**
|
|
771
|
+
* A small grayscale copy of the SOURCE frame (before our shift): ~5 Hz for auto-convergence,
|
|
772
|
+
* ~15 Hz while a depth cursor hovers (it measures the same copy under the pointer).
|
|
773
|
+
*/
|
|
774
|
+
_sample(v, hover = false) {
|
|
753
775
|
const now = performance.now();
|
|
754
|
-
if (now - this._autoAt < AUTO_CONV_INTERVAL_MS) return;
|
|
776
|
+
if (now - this._autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
|
|
755
777
|
this._autoAt = now;
|
|
756
778
|
this._scratch = this._scratch || document.createElement('canvas');
|
|
757
779
|
const g = grabLuma(v, this._scratch);
|
|
780
|
+
if (g) this._luma = g;
|
|
781
|
+
if (!this.autoConverge) return;
|
|
758
782
|
const m = g ? this._focus.measure(g.img, g.w, g.h, now) : null;
|
|
759
783
|
const d = this._track.push(m ? m.d / g.scale : null);
|
|
760
784
|
this.conv.measuredPx = d;
|
|
@@ -770,6 +794,8 @@ class CameraView {
|
|
|
770
794
|
clearTimeout(this._layerTimer);
|
|
771
795
|
this._layerTimer = 0;
|
|
772
796
|
this._unregister();
|
|
797
|
+
this.cursor?.dispose();
|
|
798
|
+
this.cursor = null;
|
|
773
799
|
this.cam._views?.delete(this);
|
|
774
800
|
}
|
|
775
801
|
}
|
|
@@ -36,3 +36,21 @@ export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
|
|
|
36
36
|
}
|
|
37
37
|
return { ...opt };
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** The keys `cursor: { … }` accepts on a stereo PAIR (./camera's addCameraView, ./call's tiles). */
|
|
41
|
+
export const PAIR_CURSOR_OPTION_KEYS = Object.freeze(['height', 'pointerScope']);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `cursor` on a stereo pair — a camera view or a call tile: `'depth'`, or `{ height, pointerScope }`.
|
|
45
|
+
* A pair has no scene: its depth is MEASURED off the two halves, and the sprite is drawn into them,
|
|
46
|
+
* so there is no anchor mode (it is always on the line of sight) and the margin is the SDK's. Falsy
|
|
47
|
+
* is OFF, the zero-cost default.
|
|
48
|
+
* @returns {null | {height?: number, pointerScope?: string}}
|
|
49
|
+
*/
|
|
50
|
+
export function resolvePairCursorOption(opt, who = '@displayxr/inline3d') {
|
|
51
|
+
const o = resolveCursorOption(opt, who);
|
|
52
|
+
if (!o) return null;
|
|
53
|
+
const unknown = Object.keys(o).filter((k) => !PAIR_CURSOR_OPTION_KEYS.includes(k));
|
|
54
|
+
if (unknown.length) throw new Error(`${who}: cursor — ${unknown.join(', ')} not supported on a stereo pair; expected ${PAIR_CURSOR_OPTION_KEYS.join(', ')}.`);
|
|
55
|
+
return o;
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|