@displayxr/inline3d 1.27.0 → 1.28.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/js/call/ui.js ADDED
@@ -0,0 +1,146 @@
1
+ // call/ui.js — the SDK-drawn call chrome: lobby, tiles' badges and state plates, the bottom bar,
2
+ // the invite panel, the "View in 3D" banner. DOM + CSS only; the call module drives it.
3
+ //
4
+ // The overlay contract (docs/authoring-inline-3d.md, docs/woven-canvas-rules.md) is the same one
5
+ // the player obeys, and it shapes every rule below:
6
+ //
7
+ // 1. Chrome ON a tile (the 3D/2D badge, the "Reconnecting…" plate) is a PARTIAL region — a small
8
+ // pill, never a plate congruent with the tile (it would match the canvas's own quad on a
9
+ // legacy browser and take the tile out of the weave). The bottom bar and self view live in a
10
+ // footer BELOW the grid, over no tile at all.
11
+ // 2. NO `backdrop-filter`. The near-solid `--dxr-shell` (rgba(16,17,22,.92)) is the substitute.
12
+ // 3. Promotion is `will-change: transform`. No CSS filter / opacity / border-radius / shadow on a
13
+ // woven canvas OR ANY ANCESTOR of one — the tile, the grid and the host stay visually bare.
14
+ // A speaking highlight is a separate bar UNDER the stage, not an outline around it.
15
+ // 4. Hidden chrome is `display: none`, never `opacity: 0`.
16
+ // 5. The one full-tile element is the opaque COVER held until `handle.firstWoven` (rule 5 of the
17
+ // woven-canvas rules), cut — never faded — when it resolves.
18
+
19
+ /** Named accents, shared with ./player (same names, same colours). */
20
+ export const CALL_ACCENTS = Object.freeze({
21
+ azure: '#4da3ff',
22
+ violet: '#9b7bff',
23
+ magenta: '#ff4fa3',
24
+ sunset: '#ff7a45',
25
+ amber: '#ffc23d',
26
+ lime: '#9be15d',
27
+ mint: '#35e0b0',
28
+ ice: '#8fe3ff',
29
+ });
30
+ export const resolveCallAccent = (a) => (typeof a === 'string' && CALL_ACCENTS[a.toLowerCase()]) || a;
31
+
32
+ const STYLE_ID = 'dxr-call-style';
33
+ const CSS = `
34
+ .dxr-call-host{--dxr-accent:#4da3ff;--dxr-ink:#fff;--dxr-shell:rgba(16,17,22,.92);--dxr-danger:#ff5a5f;
35
+ display:flex;flex-direction:column;gap:12px;color:var(--dxr-ink);
36
+ font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;}
37
+ .dxr-call-host *{box-sizing:border-box;}
38
+ .dxr-call-hidden{display:none !important;}
39
+ .dxr-call-banner{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 14px;border-radius:10px;
40
+ background:var(--dxr-shell);border:1px solid rgba(255,255,255,.12);font-size:13px;}
41
+ .dxr-call-banner a{color:var(--dxr-accent);font-weight:600;}
42
+ /* ── grid: bare boxes, nothing that makes a render surface ── */
43
+ .dxr-call-grid{display:grid;gap:10px;grid-template-columns:1fr;margin-inline:auto;width:100%;
44
+ /* Fit the tiles AND the footer (self view + bar) in one viewport: tiles are 16:9 per eye, so
45
+ the grid's width is capped by the height left after --dxr-call-reserve (the footer). */
46
+ --dxr-call-h:var(--dxr-call-fit,calc(100vh - var(--dxr-call-reserve,150px)));max-width:calc(var(--dxr-call-h)*16/9);}
47
+ .dxr-call-grid[data-n="2"]{max-width:calc(var(--dxr-call-h)*32/9);}
48
+ .dxr-call-grid[data-layout="speaker"]:not([data-n="1"]){max-width:calc(var(--dxr-call-h)*4/3);}
49
+ .dxr-call-grid[data-n="2"],.dxr-call-grid[data-n="3"]{grid-template-columns:1fr 1fr;}
50
+ .dxr-call-grid[data-layout="speaker"]{grid-template-columns:repeat(3,1fr);}
51
+ .dxr-call-grid[data-layout="speaker"] .dxr-call-tile--main{grid-column:1/-1;}
52
+ .dxr-call-grid[data-layout="speaker"][data-n="1"]{grid-template-columns:1fr;}
53
+ .dxr-call-tile{position:relative;min-width:0;}
54
+ .dxr-call-stage{position:relative;background:#000;}
55
+ .dxr-call-stage>canvas{display:block;width:100%;height:100%;}
56
+ /* a LIFTED tile: its <video> is mounted in the stage for lift() (which floats its canvas over the
57
+ element's rect, z-index 1); the flat canvas sits above it until lift is live, then hides. */
58
+ .dxr-call-stage>video.dxr-call-liftsrc{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:cover;}
59
+ .dxr-call-stage>canvas.dxr-call-liftflat{position:relative;z-index:1;}
60
+ .dxr-call-talk{height:3px;margin-top:4px;background:transparent;}
61
+ .dxr-call-tile--speaking .dxr-call-talk{background:var(--dxr-accent);}
62
+ /* ── partial overlays on a tile ── */
63
+ .dxr-call-badge{position:absolute;left:8px;top:8px;z-index:2;display:flex;align-items:center;gap:6px;
64
+ padding:3px 8px;border-radius:99px;background:var(--dxr-shell);font-size:11px;font-weight:600;
65
+ letter-spacing:.4px;will-change:transform;pointer-events:none;}
66
+ .dxr-call-badge b{font-weight:800;color:var(--dxr-accent);}
67
+ .dxr-call-badge svg{width:13px;height:13px;fill:var(--dxr-danger);}
68
+ .dxr-call-state{position:absolute;left:50%;top:50%;z-index:2;transform:translate(-50%,-50%);max-width:70%;
69
+ padding:8px 14px;border-radius:99px;background:var(--dxr-shell);font-size:13px;text-align:center;
70
+ will-change:transform;pointer-events:none;}
71
+ .dxr-call-cover{position:absolute;inset:0;z-index:1;background:#000;}
72
+ /* ── empty room / lobby ── */
73
+ .dxr-call-panel{padding:18px;border-radius:12px;background:var(--dxr-shell);border:1px solid rgba(255,255,255,.1);}
74
+ .dxr-call-panel h3{margin:0 0 6px;font-size:17px;}
75
+ .dxr-call-panel p{margin:0 0 12px;opacity:.75;}
76
+ .dxr-call-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
77
+ .dxr-call-btn{appearance:none;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.08);color:inherit;
78
+ font:600 13px/1 system-ui,-apple-system,"Segoe UI",sans-serif;padding:9px 14px;border-radius:9px;cursor:pointer;}
79
+ .dxr-call-btn:hover{background:rgba(255,255,255,.16);}
80
+ .dxr-call-btn:focus-visible{outline:2px solid var(--dxr-accent);outline-offset:2px;}
81
+ .dxr-call-btn--primary{background:var(--dxr-accent);border-color:var(--dxr-accent);color:#07111d;}
82
+ .dxr-call-btn--primary:hover{background:var(--dxr-accent);}
83
+ .dxr-call-btn--danger{background:var(--dxr-danger);border-color:var(--dxr-danger);color:#fff;}
84
+ .dxr-call-select{font:inherit;color:inherit;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);
85
+ border-radius:8px;padding:7px 9px;max-width:260px;}
86
+ .dxr-call-invite{display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap;}
87
+ .dxr-call-invite input{flex:1 1 240px;min-width:0;font:12px ui-monospace,Menlo,monospace;color:inherit;
88
+ background:rgba(0,0,0,.35);border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:8px;}
89
+ .dxr-call-invite canvas{width:132px;height:132px;image-rendering:pixelated;background:#fff;}
90
+ /* ── footer: self view + bar, BELOW the grid (over no tile) ── */
91
+ .dxr-call-foot{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;}
92
+ .dxr-call-self{width:200px;flex:none;}
93
+ .dxr-call-host--lobby .dxr-call-self{width:min(560px,100%);}
94
+ .dxr-call-self .dxr-call-badge b{color:var(--dxr-ink);}
95
+ .dxr-call-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border-radius:14px;
96
+ background:var(--dxr-shell);border:1px solid rgba(255,255,255,.1);will-change:transform;}
97
+ .dxr-call-host--lobby .dxr-call-bar{display:none;}
98
+ .dxr-call-ib{appearance:none;border:0;width:40px;height:40px;border-radius:50%;padding:9px;cursor:pointer;
99
+ background:rgba(255,255,255,.1);color:var(--dxr-ink);}
100
+ .dxr-call-ib:hover{background:rgba(255,255,255,.2);}
101
+ .dxr-call-ib:focus-visible{outline:2px solid var(--dxr-accent);outline-offset:2px;}
102
+ .dxr-call-ib svg{width:100%;height:100%;fill:currentColor;display:block;}
103
+ .dxr-call-ib[aria-pressed="true"]{background:var(--dxr-ink);color:#111;}
104
+ .dxr-call-ib--leave{background:var(--dxr-danger);width:52px;border-radius:20px;}
105
+ .dxr-call-ib--leave:hover{background:var(--dxr-danger);}
106
+ .dxr-call-depth{display:flex;align-items:center;gap:6px;padding:0 8px;font-size:12px;opacity:.9;}
107
+ .dxr-call-depth input{width:110px;accent-color:var(--dxr-accent);}
108
+ .dxr-call-note{font-size:12px;opacity:.65;}
109
+ `;
110
+
111
+ export function injectCallStyle(doc = document) {
112
+ if (doc.getElementById(STYLE_ID)) return;
113
+ const s = doc.createElement('style');
114
+ s.id = STYLE_ID;
115
+ s.textContent = CSS;
116
+ (doc.head || doc.documentElement).appendChild(s);
117
+ }
118
+
119
+ const P = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${d}"/></svg>`;
120
+ export const ICONS = {
121
+ mic: P('M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.92V21h2v-3.08A7 7 0 0 0 19 11z'),
122
+ micOff: P('M19 11h-2a5 5 0 0 1-.4 1.97l1.47 1.47A6.96 6.96 0 0 0 19 11zM15 11V5a3 3 0 0 0-5.94-.6L15 10.34zM4.27 3 3 4.27l6 6V11a3 3 0 0 0 4.52 2.59l1.46 1.46A5 5 0 0 1 7 11H5a7 7 0 0 0 6 6.92V21h2v-3.08a6.9 6.9 0 0 0 3.02-1.14L19.73 21 21 19.73z'),
123
+ cam: P('M17 10.5V7a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.5l4 4v-11z'),
124
+ camOff: P('M21 6.5l-4 4V7a1 1 0 0 0-1-1H9.82L21 17.18zM3.27 2 2 3.27 4.73 6H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73z'),
125
+ link: P('M3.9 12a3.1 3.1 0 0 1 3.1-3.1h4V7H7a5 5 0 0 0 0 10h4v-1.9H7A3.1 3.1 0 0 1 3.9 12zM8 13h8v-2H8zm9-6h-4v1.9h4a3.1 3.1 0 0 1 0 6.2h-4V17h4a5 5 0 0 0 0-10z'),
126
+ leave: P('M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85a1 1 0 0 1-1.41-.02L.29 13.08a1 1 0 0 1 0-1.41C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67a1 1 0 0 1 0 1.41l-2.48 2.48a1 1 0 0 1-1.41.02 11.3 11.3 0 0 0-2.66-1.85 1 1 0 0 1-.56-.9v-3.1A15.3 15.3 0 0 0 12 9z'),
127
+ mutedSmall: P('M19 11h-2a5 5 0 0 1-.4 1.97l1.47 1.47A6.96 6.96 0 0 0 19 11zM15 11V5a3 3 0 0 0-5.94-.6L15 10.34zM4.27 3 3 4.27l6 6V11a3 3 0 0 0 4.52 2.59l1.46 1.46A5 5 0 0 1 7 11H5a7 7 0 0 0 6 6.92V21h2v-3.08a6.9 6.9 0 0 0 3.02-1.14L19.73 21 21 19.73z'),
128
+ };
129
+
130
+ /** `el('div', {class: 'x'}, [children])` — a tiny builder, attributes as properties or attrs. */
131
+ export function el(tag, props = {}, children = []) {
132
+ const n = document.createElement(tag);
133
+ for (const [k, v] of Object.entries(props)) {
134
+ if (v === undefined || v === null) continue;
135
+ if (k === 'class') n.className = v;
136
+ else if (k === 'html') n.innerHTML = v;
137
+ else if (k === 'text') n.textContent = v;
138
+ else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v);
139
+ else if (k in n && typeof v !== 'string') n[k] = v;
140
+ else n.setAttribute(k, v);
141
+ }
142
+ for (const c of [].concat(children)) if (c) n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
143
+ return n;
144
+ }
145
+
146
+ export const show = (n, on) => n && n.classList.toggle('dxr-call-hidden', !on);
@@ -0,0 +1,428 @@
1
+ // call/wire.js — the pure half of @displayxr/inline3d/call: the wire format, the routing table,
2
+ // the convergence math, room ids and invite links. No DOM, no WebRTC, so every rule the module
3
+ // relies on is checkable under `node --test` (test/call.test.mjs).
4
+ //
5
+ // PREVIEW tier — see docs/sdk-stability.md and docs/rfcs/0002-video-call.md.
6
+
7
+ /** Wire-format version carried in every `hello`. Bumped only for an incompatible change. */
8
+ export const WIRE_VERSION = 1;
9
+
10
+ /** The SDK version string a `hello` reports (informational; never used for routing). */
11
+ export const CALL_SDK = 'inline3d-call/1';
12
+
13
+ /** Formats a sender may declare. Anything else — or no hello at all — is read as `'mono'`. */
14
+ export const CALL_FORMATS = Object.freeze(['sbs', 'mono']);
15
+
16
+ /** A delivered frame wider than this (w / h) is a side-by-side pair (e.g. 1280x480, 2560x720). */
17
+ export const SBS_ASPECT_MIN = 2.5;
18
+
19
+ /** Default and hard cap of participants in a full-mesh call (including yourself). */
20
+ export const DEFAULT_MAX_PEERS = 4;
21
+ export const MESH_HARD_CAP = 4;
22
+
23
+ /** `hint` messages are rate-limited to this many per second, on the sender AND the receiver. */
24
+ export const HINT_MAX_HZ = 5;
25
+
26
+ /** Low-pass factor for the convergence shift (RFC §3): next = prev + α·(target − prev). */
27
+ export const CONVERGENCE_ALPHA = 0.2;
28
+
29
+ /** The convergence shift never exceeds this fraction of the per-eye width, either way. */
30
+ export const CONVERGENCE_MAX_FRACTION = 0.12;
31
+
32
+ /** `setDepth(v)`: v in [-1, 1] adds v times this fraction of the per-eye width to the shift. */
33
+ export const DEPTH_RANGE_FRACTION = 0.05;
34
+
35
+ /** Plausible subject distances, mm. A hint outside this is ignored as noise. */
36
+ export const SUBJECT_Z_MIN_MM = 150;
37
+ export const SUBJECT_Z_MAX_MM = 5000;
38
+
39
+ // ── room ids and invite links ──────────────────────────────────────────────────────────────
40
+
41
+ /** Room ids are base64url, at least 16 chars (96 bits). Generated ones are 22 chars (128 bits). */
42
+ export const ROOM_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
43
+ export const ROOM_ID_BYTES = 16;
44
+
45
+ /**
46
+ * An unguessable, URL-safe room id: 128 random bits, base64url (22 chars). The id IS the only
47
+ * access control an anonymous call has, so it comes from `crypto.getRandomValues`, never
48
+ * `Math.random`.
49
+ * @param {(a: Uint8Array) => Uint8Array} [getRandomValues] injectable for tests
50
+ */
51
+ export function newRoomId(getRandomValues) {
52
+ const bytes = new Uint8Array(ROOM_ID_BYTES);
53
+ const fill = getRandomValues || ((a) => globalThis.crypto.getRandomValues(a));
54
+ fill(bytes);
55
+ return base64url(bytes);
56
+ }
57
+
58
+ /** A random peer id: 64 bits, base64url (11 chars). Unique within a room, not a secret. */
59
+ export function newPeerId(getRandomValues) {
60
+ const bytes = new Uint8Array(8);
61
+ const fill = getRandomValues || ((a) => globalThis.crypto.getRandomValues(a));
62
+ fill(bytes);
63
+ return base64url(bytes);
64
+ }
65
+
66
+ export function base64url(bytes) {
67
+ const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
68
+ let out = '';
69
+ let i = 0;
70
+ for (; i + 2 < bytes.length; i += 3) {
71
+ const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
72
+ out += A[(n >> 18) & 63] + A[(n >> 12) & 63] + A[(n >> 6) & 63] + A[n & 63];
73
+ }
74
+ const rest = bytes.length - i;
75
+ if (rest === 1) {
76
+ const n = bytes[i] << 16;
77
+ out += A[(n >> 18) & 63] + A[(n >> 12) & 63];
78
+ } else if (rest === 2) {
79
+ const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
80
+ out += A[(n >> 18) & 63] + A[(n >> 12) & 63] + A[(n >> 6) & 63];
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export function isValidRoomId(room) {
86
+ return typeof room === 'string' && ROOM_ID_RE.test(room);
87
+ }
88
+
89
+ /**
90
+ * The room carried by an invite link, or null. Reads the URL FRAGMENT only (`#room=…`), which a
91
+ * browser never sends to a server, so the room never lands in an access log. Accepts a full URL,
92
+ * a bare fragment (`#room=…` / `room=…`), or a `Location`. Other fragment params are ignored.
93
+ * @param {string | {hash?: string, href?: string}} link
94
+ */
95
+ export function parseInviteLink(link) {
96
+ if (!link) return null;
97
+ let hash = '';
98
+ if (typeof link === 'object') hash = link.hash || (link.href ? String(link.href).split('#')[1] || '' : '');
99
+ else {
100
+ const s = String(link);
101
+ const at = s.indexOf('#');
102
+ hash = at >= 0 ? s.slice(at + 1) : s.includes('=') && !s.includes('://') ? s : '';
103
+ }
104
+ hash = hash.replace(/^#/, '');
105
+ for (const part of hash.split('&')) {
106
+ const eq = part.indexOf('=');
107
+ if (eq < 0) continue;
108
+ if (decodeURIComponent(part.slice(0, eq)) !== 'room') continue;
109
+ const room = decodeURIComponent(part.slice(eq + 1));
110
+ return isValidRoomId(room) ? room : null;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Build an invite link: `base` with its fragment replaced by `#room=<room>`. The query string is
117
+ * kept (a sample's `?signal=` must survive the hop); any existing fragment is dropped.
118
+ */
119
+ export function buildInviteLink(base, room) {
120
+ if (!isValidRoomId(room)) throw new Error(`@displayxr/inline3d/call: invalid room id "${room}"`);
121
+ const b = String(base || '').split('#')[0];
122
+ return `${b}#room=${room}`;
123
+ }
124
+
125
+ // ── hello / hint / state ───────────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Validate an incoming `hello`, filling the defaults a receiver may assume. Returns null for
129
+ * something that is not a hello at all. Never throws: this reads data from another browser.
130
+ *
131
+ * `rectified` defaults to FALSE — a raw stereo camera (unrectified, possibly grayscale) is the
132
+ * common case in the field, and a receiver must never assume more than it was told.
133
+ */
134
+ export function normalizeHello(msg) {
135
+ if (!msg || typeof msg !== 'object' || msg.type !== 'hello') return null;
136
+ const num = (v, lo, hi) => (typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null);
137
+ return {
138
+ type: 'hello',
139
+ v: typeof msg.v === 'number' ? msg.v : WIRE_VERSION,
140
+ format: CALL_FORMATS.includes(msg.format) ? msg.format : 'mono',
141
+ width: num(msg.width, 1, 16384),
142
+ height: num(msg.height, 1, 16384),
143
+ baselineMm: num(msg.baselineMm, 1, 1000),
144
+ hfovDeg: num(msg.hfovDeg, 5, 170),
145
+ rectified: msg.rectified === true,
146
+ sdk: typeof msg.sdk === 'string' ? msg.sdk.slice(0, 64) : null,
147
+ };
148
+ }
149
+
150
+ /** Build the `hello` this side sends. */
151
+ export function makeHello({ format, width, height, baselineMm, hfovDeg, rectified } = {}) {
152
+ const h = {
153
+ type: 'hello',
154
+ v: WIRE_VERSION,
155
+ format: CALL_FORMATS.includes(format) ? format : 'mono',
156
+ width: width || 0,
157
+ height: height || 0,
158
+ rectified: !!rectified,
159
+ sdk: CALL_SDK,
160
+ };
161
+ if (Number.isFinite(baselineMm)) h.baselineMm = baselineMm;
162
+ if (Number.isFinite(hfovDeg)) h.hfovDeg = hfovDeg;
163
+ return h;
164
+ }
165
+
166
+ /** `hint {subjectZmm}`: a number in range, or null (ignored). */
167
+ export function normalizeHint(msg) {
168
+ if (!msg || msg.type !== 'hint') return null;
169
+ const z = msg.subjectZmm;
170
+ return typeof z === 'number' && Number.isFinite(z) && z >= SUBJECT_Z_MIN_MM && z <= SUBJECT_Z_MAX_MM
171
+ ? { type: 'hint', subjectZmm: z }
172
+ : null;
173
+ }
174
+
175
+ /** `state {muted, cameraOff, speaking}` — booleans only. */
176
+ export function normalizeState(msg) {
177
+ if (!msg || msg.type !== 'state') return null;
178
+ return { type: 'state', muted: msg.muted === true, cameraOff: msg.cameraOff === true, speaking: msg.speaking === true };
179
+ }
180
+
181
+ /**
182
+ * A minimum-interval gate: `gate(nowMs)` is true at most `hz` times a second. Used on both ends
183
+ * of `hint` so a chatty (or hostile) sender cannot drive the receiver's convergence at frame rate.
184
+ */
185
+ export function rateGate(hz) {
186
+ const minMs = 1000 / hz;
187
+ let last = -Infinity;
188
+ return (now) => {
189
+ if (now - last < minMs) return false;
190
+ last = now;
191
+ return true;
192
+ };
193
+ }
194
+
195
+ // ── routing (RFC §3) ───────────────────────────────────────────────────────────────────────
196
+
197
+ /**
198
+ * How a remote tile is drawn. The ONE table every tile goes through:
199
+ *
200
+ * | remote sends | local wall woven | local wall 2D / absent |
201
+ * |--------------|-----------------------|------------------------|
202
+ * | `sbs` | `woven-sbs` | `flat-left` |
203
+ * | `mono` | `lifted` (or `flat`) | `flat` |
204
+ * | no hello | treated as `mono` | `flat` |
205
+ *
206
+ * A mono peer on a woven wall is `lifted` — one lift() stream per peer (call/lift.js) — when the
207
+ * `mono3D` option is on, a lift function resolved (`lift: true`) and a lift slot is free. Otherwise
208
+ * it is `flat`, and `mono3d` says why: `'off'` (the page turned it off), `'unavailable'` (no lift
209
+ * module, no provider, or it is still loading), `'budget'` (every lift slot is taken), or
210
+ * `'failed'` (lift() failed for this tile).
211
+ *
212
+ * @param {{ format?: string|null, woven: boolean, mono3D?: 'auto'|'off'|Function, lift?: boolean, overBudget?: boolean, failed?: boolean }} p
213
+ * @returns {{ route: 'woven-sbs'|'flat-left'|'flat'|'lifted', mono3d?: 'unavailable'|'off'|'lifted'|'budget'|'failed' }}
214
+ */
215
+ export function routeFor({ format, woven, mono3D = 'auto', lift = false, overBudget = false, failed = false }) {
216
+ const fmt = format === 'sbs' ? 'sbs' : 'mono';
217
+ if (fmt === 'sbs') return { route: woven ? 'woven-sbs' : 'flat-left' };
218
+ if (!woven) return { route: 'flat' };
219
+ if (mono3D === 'off' || mono3D === false) return { route: 'flat', mono3d: 'off' };
220
+ if (!lift) return { route: 'flat', mono3d: 'unavailable' };
221
+ if (failed) return { route: 'flat', mono3d: 'failed' };
222
+ if (overBudget) return { route: 'flat', mono3d: 'budget' };
223
+ return { route: 'lifted', mono3d: 'lifted' };
224
+ }
225
+
226
+ /**
227
+ * The badge a tile shows for a route, from THIS side's point of view: `3D` for a woven stereo
228
+ * pair, `2D→3D` for a mono peer this side lifts, `2D` for anything shown flat.
229
+ */
230
+ export function badgeFor(route) {
231
+ return route === 'woven-sbs' ? '3D' : route === 'lifted' ? '2D→3D' : '2D';
232
+ }
233
+
234
+ // ── convergence (RFC §3) ───────────────────────────────────────────────────────────────────
235
+
236
+ /** Focal length in px of an eye image `eyeWidthPx` wide with horizontal FOV `hfovDeg`. */
237
+ export function focalPx(eyeWidthPx, hfovDeg) {
238
+ if (!(eyeWidthPx > 0) || !(hfovDeg > 0) || hfovDeg >= 180) return null;
239
+ return eyeWidthPx / 2 / Math.tan(((hfovDeg / 2) * Math.PI) / 180);
240
+ }
241
+
242
+ /**
243
+ * Per-eye horizontal shift, in SOURCE eye pixels, that puts a subject at `subjectZmm` on the
244
+ * display plane: `f_px · baseline / (2 · subjectZ)`. A parallel stereo camera gives everything a
245
+ * crossed disparity of `f·B/Z` (full, between the eyes); half of it comes off each eye.
246
+ *
247
+ * Positive = the left eye's crop moves RIGHT and the right eye's LEFT (content pushed back).
248
+ * Returns 0 when anything it needs is unknown — the receiver then shows the pair as sent.
249
+ */
250
+ export function convergenceShiftPx({ eyeWidthPx, hfovDeg, baselineMm, subjectZmm }) {
251
+ const f = focalPx(eyeWidthPx, hfovDeg);
252
+ if (f === null || !(baselineMm > 0) || !(subjectZmm > 0)) return 0;
253
+ return (f * baselineMm) / (2 * subjectZmm);
254
+ }
255
+
256
+ /** Clamp a shift to ±CONVERGENCE_MAX_FRACTION of the per-eye width. */
257
+ export function clampShift(px, eyeWidthPx, maxFraction = CONVERGENCE_MAX_FRACTION) {
258
+ const lim = Math.max(0, eyeWidthPx * maxFraction);
259
+ return Math.max(-lim, Math.min(lim, px || 0));
260
+ }
261
+
262
+ /** One low-pass step: `prev + α(target − prev)`. Snaps when within 0.05 px so it can settle. */
263
+ export function lowPass(prev, target, alpha = CONVERGENCE_ALPHA) {
264
+ const next = prev + alpha * (target - prev);
265
+ return Math.abs(target - next) < 0.05 ? target : next;
266
+ }
267
+
268
+ /**
269
+ * A tile's convergence state: the target from hello+hint, the page's depth offset, and the
270
+ * smoothed value actually painted. `step()` once per painted frame.
271
+ */
272
+ export function createConvergence() {
273
+ const s = {
274
+ hello: null,
275
+ subjectZmm: null,
276
+ depth: 0, // setDepth(), [-1, 1]
277
+ current: 0,
278
+ target(eyeWidthPx) {
279
+ const auto =
280
+ s.hello && s.subjectZmm
281
+ ? convergenceShiftPx({
282
+ eyeWidthPx,
283
+ hfovDeg: s.hello.hfovDeg,
284
+ baselineMm: s.hello.baselineMm,
285
+ subjectZmm: s.subjectZmm,
286
+ })
287
+ : 0;
288
+ return clampShift(auto + s.depth * DEPTH_RANGE_FRACTION * eyeWidthPx, eyeWidthPx);
289
+ },
290
+ step(eyeWidthPx) {
291
+ s.current = lowPass(s.current, s.target(eyeWidthPx));
292
+ return s.current;
293
+ },
294
+ };
295
+ return s;
296
+ }
297
+
298
+ /**
299
+ * The source rectangle of ONE eye, cropped to the tile's aspect and shifted by the convergence
300
+ * offset. Never upscales past the source: a 640-wide eye (a real raw stereo camera) stays 640.
301
+ *
302
+ * @param {number} eyeW source per-eye width (px)
303
+ * @param {number} eyeH source height
304
+ * @param {number} aspect the tile's (= the woven buffer's per-eye) aspect, w/h
305
+ * @param {number} shift per-eye shift in source px (see convergenceShiftPx); + = push back
306
+ * @param {0|1} eye 0 = left, 1 = right
307
+ * @returns {{sx:number, sy:number, sw:number, sh:number}} relative to that eye's half
308
+ */
309
+ export function eyeCropRect(eyeW, eyeH, aspect, shift, eye) {
310
+ const s = Math.abs(shift || 0);
311
+ let sw = Math.min(eyeW - 2 * s, eyeH * aspect);
312
+ sw = Math.max(1, sw);
313
+ const sh = Math.min(eyeH, sw / aspect);
314
+ sw = sh * aspect;
315
+ const cx = (eyeW - sw) / 2 + (eye === 0 ? shift : -shift);
316
+ return { sx: Math.max(0, Math.min(eyeW - sw, cx)), sy: (eyeH - sh) / 2, sw, sh };
317
+ }
318
+
319
+ /** The output per-eye size for a source eye and a tile aspect: no upscaling, even numbers. */
320
+ export function eyeOutputSize(eyeW, eyeH, aspect) {
321
+ const w = Math.min(eyeW, eyeH * aspect);
322
+ const h = w / aspect;
323
+ return { w: Math.max(2, Math.round(w / 2) * 2), h: Math.max(2, Math.round(h / 2) * 2) };
324
+ }
325
+
326
+ // ── self view: the mirroring trap (RFC §3) ─────────────────────────────────────────────────
327
+
328
+ /**
329
+ * The draw operations for a MIRRORED stereo self-view: each half mirrored AND the halves swapped.
330
+ *
331
+ * Why both: mirroring a scene horizontally means the reflected left eye sees what the original
332
+ * right eye saw (mirrored), and vice versa. Mirroring each half IN PLACE keeps the eyes where they
333
+ * were and inverts every disparity — the face goes pseudoscopic (inside-out). Mirror + swap keeps
334
+ * crossed disparity crossed. (It is the same pixels as flipping the whole SBS frame; spelled out
335
+ * per half so the intent survives refactoring.) The WIRE is never mirrored — only this preview.
336
+ *
337
+ * @param {number} W full SBS frame width
338
+ * @param {number} H height
339
+ * @returns {Array<{src:'L'|'R', sx:number, sw:number, dx:number, dw:number, mirror:true}>}
340
+ */
341
+ export function mirrorSwapOps(W, H) {
342
+ const half = W / 2;
343
+ return [
344
+ { src: 'R', sx: half, sw: half, sy: 0, sh: H, dx: 0, dw: half, mirror: true },
345
+ { src: 'L', sx: 0, sw: half, sy: 0, sh: H, dx: half, dw: half, mirror: true },
346
+ ];
347
+ }
348
+
349
+ /**
350
+ * Apply mirrorSwapOps to a row-major single-channel frame (tests, and a reference for the canvas
351
+ * path). Returns a new array.
352
+ */
353
+ export function mirrorSwapPixels(px, W, H) {
354
+ const out = new px.constructor(px.length);
355
+ for (const op of mirrorSwapOps(W, H)) {
356
+ for (let y = 0; y < H; y++) {
357
+ for (let x = 0; x < op.dw; x++) {
358
+ const srcX = op.sx + (op.sw - 1 - x); // mirrored within the half
359
+ out[y * W + op.dx + x] = px[y * W + srcX];
360
+ }
361
+ }
362
+ }
363
+ return out;
364
+ }
365
+
366
+ // ── weave liveness (displayxr-browser-pvt#172) ─────────────────────────────────────────────
367
+
368
+ /**
369
+ * Is the inline-3D session actually LIVE — delivering stereo frames — yet?
370
+ *
371
+ * Browser bug displayxr-browser-pvt#172: a woven canvas registered BEFORE the browser's weave
372
+ * session is live gets the whole side-by-side frame in EACH eye (L|R|L|R, flat) until a reload.
373
+ * A call's self view is created at page load, exactly the case. The core exposes no "weave is
374
+ * live" signal, so the module watches the session's own frames (the frozen `wall.session` /
375
+ * `wall.refSpace` fields) and registers woven tiles only once `need` CONSECUTIVE frames have
376
+ * located two or more views — the runtime is up and locating eyes. A session with no reference
377
+ * space (views never readable) counts as live after `needNoPose` frames. Pure: feed it view counts.
378
+ *
379
+ * @param {{need?: number, needNoPose?: number}} [o]
380
+ */
381
+ export function createLiveGate({ need = 10, needNoPose = 30 } = {}) {
382
+ let run = 0;
383
+ let frames = 0;
384
+ let live = false;
385
+ return {
386
+ /** @param {number|null} viewCount views located this frame; null = no reference space */
387
+ feed(viewCount) {
388
+ if (live) return true;
389
+ frames++;
390
+ if (viewCount === null) live = frames >= needNoPose;
391
+ else {
392
+ // >= 1, not >= 2: the browser's inline session reports ONE view on the viewer pose
393
+ // (per-eye views live on each layer), verified on a real panel.
394
+ run = viewCount >= 1 ? run + 1 : 0;
395
+ live = run >= need;
396
+ }
397
+ return live;
398
+ },
399
+ get live() {
400
+ return live;
401
+ },
402
+ };
403
+ }
404
+
405
+ // ── sending ────────────────────────────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * Video `maxBitrate` (kbps) for one outgoing stream in a mesh. Full mesh means one upload per
409
+ * remote peer, so the per-peer budget falls as the call grows. SBS carries two eyes, so it gets
410
+ * roughly twice mono's. Numbers from P0 (2560x720 VP9 30 fps at ~3-4 Mbps).
411
+ */
412
+ export function maxBitrateKbps(format, remotePeers) {
413
+ const n = Math.max(1, remotePeers | 0);
414
+ const sbs = format === 'sbs';
415
+ const table = sbs ? [6000, 4000, 3000] : [2500, 1800, 1400];
416
+ return table[Math.min(n, table.length) - 1];
417
+ }
418
+
419
+ /** Exponential backoff with a cap, and ±20% jitter from `rand` (0..1). */
420
+ export function backoffMs(attempt, { baseMs = 1000, maxMs = 30000, rand = Math.random } = {}) {
421
+ const raw = Math.min(maxMs, baseMs * Math.pow(2, Math.max(0, attempt)));
422
+ return Math.round(raw * (0.8 + 0.4 * rand()));
423
+ }
424
+
425
+ /** Does a delivered frame look like a side-by-side pair? (capture 'auto' only) */
426
+ export function looksSbs(width, height) {
427
+ return width > 0 && height > 0 && width / height > SBS_ASPECT_MIN;
428
+ }