@displayxr/inline3d 1.38.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/index.d.ts CHANGED
@@ -24,27 +24,93 @@ export interface TileOptions {
24
24
  * you know is not fresh; 0 means "the first stereo frame".
25
25
  */
26
26
  firstWovenHoldMs?: number;
27
+ /**
28
+ * Cover this canvas across a rect change, owned by the SDK. Default `'off'` on `addImage` /
29
+ * `addVideo` / `addScene` (frozen defaults); `'auto'` on `./splat` and `./model`.
30
+ *
31
+ * Once the window has woven, a change of the canvas's CSS size, devicePixelRatio or PAGE position
32
+ * (scrolling the document is not a move) raises a sibling element over the canvas on the frame
33
+ * the change is seen, and cuts it when {@link TileHandle.rewoven} settles; a further change
34
+ * while it is up restarts the wait. The cover is a 2D canvas, never a second woven canvas,
35
+ * placed as the canvas's next sibling (`pointer-events: none`, `data-inline3d-cover`). It is
36
+ * filled with `color` and, with `snapshot`, the last frame: the left eye, cover-fit to the new
37
+ * box, captured once at the change with one `drawImage` (never `toDataURL`).
38
+ *
39
+ * - `'auto'` — `{ color: '#000', snapshot: true }`.
40
+ * - `'off'` — nothing; a page that runs its own cover passes this.
41
+ * - `{ color?, snapshot? }` — `color` any CSS color (default `'#000'`); `snapshot` default true.
42
+ *
43
+ * Before the first join nothing is raised: cover a fresh canvas yourself until `firstWoven`
44
+ * (rule 5). A pure move is seen at the next session frame, so a move made after that frame's
45
+ * callback can show for one frame.
46
+ */
47
+ rectCover?: RectCoverOption;
27
48
  }
28
49
 
50
+ /** See {@link TileOptions.rectCover}. */
51
+ export type RectCoverOption = 'auto' | 'off' | { color?: string; snapshot?: boolean };
52
+
53
+ /**
54
+ * The browser's per-frame report for one layer (`XRDisplayLayer.wovenState`, DisplayXR Browser
55
+ * builds that carry it; absent elsewhere). A level, never latched.
56
+ *
57
+ * - `'pending'` — no presented frame has reported this layer yet (from construction, again after
58
+ * close(), and whenever the frame's reply does not list it).
59
+ * - `'woven'` — the latest swapped frame put this layer's rect into the weave.
60
+ * - `'withheld'` — it did not; {@link WovenWithheldReason} says why.
61
+ */
62
+ export type WovenState = 'pending' | 'woven' | 'withheld';
63
+
64
+ /**
65
+ * Why a layer reads `'withheld'` (`XRDisplayLayer.withheldReason`). The same tokens the browser
66
+ * logs; what each means and whose fix it is: `docs/woven-canvas-rules.md` §3. A future browser may
67
+ * add tokens, so treat an unknown string as "withheld, reason not listed".
68
+ */
69
+ export type WovenWithheldReason =
70
+ | 'no-quad'
71
+ | 'no-identity'
72
+ | 'no-join'
73
+ | 'cross-pass:mono'
74
+ | 'cross-pass:mono(cover,2:1)'
75
+ | 'resolve-dropped'
76
+ | (string & {});
77
+
29
78
  /**
30
79
  * What {@link TileHandle.firstWoven} resolves to. Settles once and never rejects.
31
80
  *
32
- * - `woven: true, reason: 'hold-elapsed'` — a stereo frame is on a layer that has existed for
33
- * `firstWovenHoldMs`. Drop the poster covering the canvas.
34
- * - `woven: true, reason: 'hold-capped'` — `rewoven()` only: the canvas kept resizing, so it
35
- * stopped waiting four holds after the call. Drop the cover; the rect may still be settling.
36
- * - `woven: false` — the window will not weave (`'layer-failed'`, `'session-ended'`,
37
- * `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat (image/video) or
38
- * its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback.
81
+ * - `woven: true, reason: 'woven'` (`confirmed: true`) — the BROWSER reported the layer woven
82
+ * (`XRDisplayLayer.wovenState`), after a stereo frame was drawn. No hold. Drop the poster.
83
+ * - `woven: true, reason: 'hold-elapsed'` (`confirmed: false`) — a browser without that report:
84
+ * a stereo frame is on a layer that has existed for `firstWovenHoldMs`. Drop the poster.
85
+ * - `woven: true, reason: 'hold-capped'` — `rewoven()`: the canvas kept resizing or moving, so
86
+ * it stopped waiting four holds after the call; with the report, also a 'woven' read that never
87
+ * qualified (see {@link TileHandle.rewoven}). Drop the cover; the rect may still be settling.
88
+ * - `woven: false, confirmed: true`, `reason` a {@link WovenWithheldReason} or `'pending'` — the
89
+ * browser kept reporting the layer withheld (or never listed it) for the whole cap: four holds,
90
+ * at least 4.8 s. A safety release, not a loss: the canvas is NOT taken flat and `rewoven()`
91
+ * keeps working. Drop the cover; what shows is what the browser draws for that reason
92
+ * (`cross-pass:mono` is flat in place; `no-identity` can be the raw pair).
93
+ * - `woven: false, confirmed: false` — the window will not weave (`'layer-failed'`,
94
+ * `'session-ended'`, `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat
95
+ * (image/video) or its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback.
39
96
  */
40
97
  export interface FirstWovenResult {
41
98
  readonly woven: boolean;
42
99
  /**
43
- * `true` only when the BROWSER reported the join. Always `false` today: no browser exposes
44
- * that, so the result is the SDK's worst-case hold rather than a report.
100
+ * `true` only when the BROWSER reported the state (`XRDisplayLayer.wovenState`). `false` on a
101
+ * browser without that report: the result is then the SDK's worst-case hold.
45
102
  */
46
103
  readonly confirmed: boolean;
47
- readonly reason: 'hold-elapsed' | 'hold-capped' | 'layer-failed' | 'session-ended' | 'removed' | 'unsupported';
104
+ readonly reason:
105
+ | 'woven'
106
+ | 'hold-elapsed'
107
+ | 'hold-capped'
108
+ | 'layer-failed'
109
+ | 'session-ended'
110
+ | 'removed'
111
+ | 'unsupported'
112
+ | 'pending'
113
+ | WovenWithheldReason;
48
114
  /** Milliseconds from the add*() call to settling. */
49
115
  readonly ms: number;
50
116
  }
@@ -289,6 +355,15 @@ export interface SceneOptions extends TileOptions {
289
355
  */
290
356
  bufferClamped?: boolean;
291
357
  onLayerLost?: () => void;
358
+ /**
359
+ * Called INSIDE the ResizeObserver callback when the canvas's CSS box or devicePixelRatio
360
+ * changes — after the frame's animation callbacks, before its paint. Resize your backing store
361
+ * and draw one frame here (replay your last views) and the old store is never shown stretched
362
+ * onto the new box, which is what waiting for the next frame shows. Called only on a real
363
+ * change, never re-entrantly; a throw is caught and warned about once. Default: none, and then
364
+ * no observer is attached to a scene canvas (unchanged).
365
+ */
366
+ onResize?: (box: { width: number; height: number; dpr: number }) => void;
292
367
  }
293
368
 
294
369
  /** The per-frame render callback passed to {@link Inline3D.addScene}. */
@@ -298,7 +373,14 @@ export type SceneFrameCallback = (
298
373
  frame: XRFrame,
299
374
  ) => void;
300
375
 
301
- /** The handle returned by every add*() call. */
376
+ /**
377
+ * The handle returned by every add*() call.
378
+ *
379
+ * An add*() on a canvas that is still registered (not `remove()`d) on the same manager warns once
380
+ * per canvas (1.38) and then proceeds as it always has: the layer is closed and rebuilt (a fresh
381
+ * identity gap), or a subpath puts a second renderer on the context, and the call returns its own
382
+ * new handle. Change what is in the canvas instead, or `remove()` first.
383
+ */
302
384
  export interface TileHandle {
303
385
  /** Remove this window: close its weave layer and stop driving it. */
304
386
  remove(): void;
@@ -342,6 +424,19 @@ export interface TileHandle {
342
424
  /**
343
425
  * The panel this window weaves on, or `null` where there is no glasses-free display.
344
426
  *
427
+ * `null` is returned only once the layer has answered for real or the cap has passed, never for
428
+ * "not ready yet". Called before this window's layer has delivered its first frame (right after
429
+ * `add*()`, the natural place), the call waits for the release signal — where the browser
430
+ * reports {@link TileHandle.wovenState}, the first `'woven'` read (a `'withheld'` read right
431
+ * after the layer is built does not count: it comes before the weave session can answer);
432
+ * elsewhere, the layer's first stereo frame; no first-woven hold — and asks then. If the layer
433
+ * still answers `null` before the cap, that answer is not returned: the call is asked again on
434
+ * each later release signal (the next `'woven'` read, or the next session frame once a stereo
435
+ * frame has been drawn) and resolves with the first non-`null` answer. Once a read on this layer
436
+ * has answered for real, later calls go straight to the layer. The cap is the first-woven cap
437
+ * (four holds from the layer's construction, never under 4.8 s): past it the layer's `null` is
438
+ * returned as a real absence. Without a live layer it settles at once, as below.
439
+ *
345
440
  * Rejects with an `Error` on a browser without the display-mode API
346
441
  * ({@link inline3dDisplayModesSupported}) or while this window has no live layer (lazy mode,
347
442
  * tile off screen).
@@ -354,7 +449,14 @@ export interface TileHandle {
354
449
  * The canvas size is read at the call: call again after a resize.
355
450
  */
356
451
  displayMetrics(): Promise<DisplayMetrics>;
357
- /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
452
+ /**
453
+ * Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}.
454
+ *
455
+ * An empty list is returned only once the cap has passed, never for "not ready yet": like
456
+ * {@link getDisplayInfo}, a call made before this window's layer has delivered its first frame
457
+ * waits for the same release signal, an empty answer before the cap is asked again on the next
458
+ * one, and the cap is the same. Rejects as {@link getDisplayInfo} does.
459
+ */
358
460
  getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
359
461
  /**
360
462
  * Ask the runtime to switch the display to `modeIndex`. A thin pass-through — it resolves and
@@ -368,6 +470,10 @@ export interface TileHandle {
368
470
  *
369
471
  * A `viewCount === 1` mode is requestable and is how a page goes flat.
370
472
  *
473
+ * Made before this window's layer has delivered its first frame, the request waits for it (the
474
+ * same release signal and cap as {@link getDisplayInfo}; forwarded once, not retried): before
475
+ * then the browser has no weave session to forward it to.
476
+ *
371
477
  * With the eased transition on (the default — see {@link ModeSwitchOptions}) a going-flat
372
478
  * request is HELD while the disparity ramps out, so the promise resolves when the request has
373
479
  * been forwarded rather than on the call; a request dropped by a reversal in that window
@@ -422,22 +528,45 @@ export interface TileHandle {
422
528
  * Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`.
423
529
  */
424
530
  stats(): { frames: number; monoFrames: number };
531
+ /**
532
+ * The browser's report for this window's layer, read live ({@link WovenState}); `'pending'`
533
+ * while the window has no live layer; `null` on a browser that does not report it (the
534
+ * attribute is absent on mac, Linux, Android and Windows builds without it). Diagnostics:
535
+ * `firstWoven` / `rewoven()` already settle on it.
536
+ */
537
+ readonly wovenState: WovenState | null;
538
+ /** The why-token while {@link TileHandle.wovenState} is `'withheld'`, else `null`. */
539
+ readonly withheldReason: WovenWithheldReason | null;
425
540
  /**
426
541
  * Resolves once, when it is safe to reveal this canvas: see {@link FirstWovenResult}. THE way to
427
542
  * release a poster held over a woven canvas — `await Promise.all([ready, handle.firstWoven])`
428
- * and cut, never fade. Approximate until a browser reports joins (`confirmed` stays `false`).
543
+ * and cut, never fade. On a browser that reports `wovenState` it settles on that report
544
+ * (`confirmed: true`, no hold); elsewhere it is the worst-case hold (`confirmed: false`).
429
545
  */
430
546
  readonly firstWoven: Promise<FirstWovenResult>;
431
547
  /** Callback form of {@link TileHandle.firstWoven}: called once, asynchronously. Returns an unsubscribe. */
432
548
  onFirstWoven(cb: (result: FirstWovenResult) => void): () => void;
433
549
  /**
434
550
  * {@link TileHandle.firstWoven}, measured from NOW. Cover an already-woven canvas across a rect
435
- * change (fullscreen, a layout resize), then release on this. A change of the canvas's CSS size
436
- * or devicePixelRatio while pending restarts the hold (checked every frame, any window kind; a
437
- * move without a resize is not detected). It settles anyway, `woven: true, reason:
551
+ * change (fullscreen, a layout resize, a move), then release on this ({@link TileOptions.rectCover}
552
+ * does both for you). A change of the canvas's CSS size, devicePixelRatio or page position (since
553
+ * 1.38; document scroll excluded) while pending restarts the hold (checked every frame, any
554
+ * window kind). It settles anyway, `woven: true, reason:
438
555
  * 'hold-capped'`, four holds after the call, so a size that never stops animating cannot hold a
439
556
  * cover up for good. A second call while pending returns the same promise, restarted. Before the
440
557
  * first join it is `firstWoven`; on a window that will not weave it is that `woven: false` result.
558
+ *
559
+ * On a browser that reports `wovenState` it settles `confirmed: true, reason: 'woven'` on the
560
+ * first 'woven' read (with a stereo frame drawn since the call) that either follows a
561
+ * 'withheld'/'pending' read seen after the call, or comes three session frames after the last
562
+ * box change seen after the call, or — when no box change is seen after the call, the usual
563
+ * case of a page that moves the canvas and THEN calls this — comes three session frames after
564
+ * the call. The report is a live per-frame level trailing the join by 1–3 frames, so a read
565
+ * three frames on reflects the rect as it was at the call, and a 'woven' from the old rect
566
+ * never settles it; a read on the frame of the call never does. Expect ~50 ms instead of the
567
+ * 1.2 s hold.
568
+ * The cap stays: still 'withheld' four holds after the call (at least 4.8 s), it settles `woven:
569
+ * false, confirmed: true, reason: <withheldReason>`.
441
570
  */
442
571
  rewoven(): Promise<FirstWovenResult>;
443
572
  }
@@ -455,9 +584,17 @@ export interface Inline3D {
455
584
  // The panel is the DOCUMENT's, not a tile's, so the display API lives here; the same names are
456
585
  // on every tile handle, routed to whichever window currently holds a live layer.
457
586
 
458
- /** The panel, or `null` where there is no glasses-free display. See {@link TileHandle.getDisplayInfo}. */
587
+ /**
588
+ * The panel, or `null` where there is no glasses-free display — `null` only once the cap has
589
+ * passed, never for "not ready yet" (a call before the live window has answered waits for its
590
+ * release signal and is asked again while it answers `null`, capped). See
591
+ * {@link TileHandle.getDisplayInfo}.
592
+ */
459
593
  getDisplayInfo(): Promise<XRDisplayInfo | null>;
460
- /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
594
+ /**
595
+ * Every rendering mode the display can be put in; an empty list only once the cap has passed,
596
+ * never for "not ready yet". See {@link TileHandle.getRenderingModes}.
597
+ */
461
598
  getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
462
599
  /** Switch the display to `modeIndex`. See {@link TileHandle.requestRenderingMode}. */
463
600
  requestRenderingMode(modeIndex: number): Promise<void>;
@@ -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));
@@ -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
+ }
@@ -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
- if (this.call.o.autoConverge) this._sampleDisparity(v, W, H);
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
- if (this.autoPending || now - this.autoAt < AUTO_CONV_INTERVAL_MS) return;
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
@@ -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
- if (this.autoConverge) this._sample(v);
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
- /** Auto-convergence: ~5 Hz, a small grayscale copy of the SOURCE frame (before our shift). */
752
- _sample(v) {
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
  }