@displayxr/inline3d 1.28.0 → 1.30.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.
@@ -1,4 +1,4 @@
1
- // call/signaling.js — the SignalingAdapter seam, and the two adapters the SDK ships.
1
+ // call/signaling.js — the SignalingAdapter seam, and the adapter the SDK ships.
2
2
  //
3
3
  // A SignalingAdapter is ANY object with `join(room, hooks) → Promise<SignalingSession>`:
4
4
  //
@@ -15,8 +15,8 @@
15
15
  //
16
16
  // dxrSignaling(url) the `dxr-signal/1` JSON-over-WebSocket protocol (signaling/README.md), served
17
17
  // by the reference Cloudflare Worker or `node signaling/dev-server.mjs`.
18
- // peerjsCloud() DEMO ONLY: the free public PeerJS broker. No uptime guarantee, not ours, and
19
- // every peer id it sees is derived from a hash of the room.
18
+ // (peerjsCloud, the demo adapter over the public PeerJS broker, left the package in 1.29 —
19
+ // RFC 0003 Decision 12 — and lives in samples/call/peerjs-cloud.js as a worked example.)
20
20
 
21
21
  import { backoffMs } from './wire.js';
22
22
 
@@ -42,7 +42,9 @@ export async function roomKey(room) {
42
42
  * peer-to-peer is not torn down by a signalling blip.
43
43
  *
44
44
  * @param {string} url
45
- * @param {{ WebSocket?: any, pingMs?: number }} [opts]
45
+ * @param {{ WebSocket?: any, pingMs?: number, key?: string }} [opts] `key`: a publishable key for
46
+ * the hosted service (RFC 0003 §5a), sent as a query parameter on connect. Public by design
47
+ * (it sits in page source); a server that does not know keys ignores it.
46
48
  */
47
49
  /**
48
50
  * The hosted DisplayXR `dxr-signal/1` server (a Cloudflare Worker running `signaling/worker.mjs`,
@@ -67,7 +69,7 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
67
69
  url: base,
68
70
  async join(room, hooks) {
69
71
  const key = await roomKey(room);
70
- const endpoint = `${base}/v1/connect?k=${key}`;
72
+ const endpoint = `${base}/v1/connect?k=${key}${typeof opts.key === 'string' && opts.key ? `&key=${encodeURIComponent(opts.key)}` : ''}`;
71
73
  let ws = null;
72
74
  let left = false;
73
75
  let attempt = 0;
@@ -199,200 +201,3 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
199
201
  },
200
202
  };
201
203
  }
202
-
203
- /**
204
- * DEMO ONLY — zero-setup signalling over the free public PeerJS broker (0.peerjs.com). It has no
205
- * uptime guarantee and is not operated by DisplayXR; use `dxrSignaling(url)` for anything real.
206
- *
207
- * PeerJS has no rooms, so this adapter makes one out of SLOTS: each peer registers the broker id
208
- * `dxrcall-<hash(room)>-<k>` for the first free k (a taken id is how "slot in use" is discovered),
209
- * opens a PeerJS data connection to every other slot, and relays our signalling over those.
210
- *
211
- * A taken slot is NOT the same as a live participant, and that is what the rest of this adapter
212
- * deals with:
213
- * - A page that went away without its broker socket closing leaves a ZOMBIE slot. So there are
214
- * twice as many slots as participants (a zombie never blocks a join), a slot counts as a
215
- * participant only once it has said `hi`, and the room size is enforced on those. The page's own
216
- * Peer is destroyed on `pagehide` / `beforeunload`, so a reload frees its slot at once.
217
- * - A slot that exists but never says `hi` within `unreachableMs` is reported through
218
- * `onPeerUnreachable('slot-<k>')`: the usual cause is a network where even the data
219
- * connection's ICE never completes (it needs a relay). Silent slots are re-dialled every
220
- * `retryMs` in the background; a `hi` (or the broker saying the slot is free) clears it again
221
- * via `onPeerReachable`.
222
- *
223
- * @param {{ Peer?: any, peerOptions?: object, url?: string, helloMs?: number, unreachableMs?: number,
224
- * retryMs?: number, heartbeatMs?: number, settleMs?: number }} [opts]
225
- * `Peer`: the PeerJS constructor (default: `globalThis.Peer`, else the ESM build from jsDelivr).
226
- */
227
- export function peerjsCloud(opts = {}) {
228
- const T = {
229
- hello: opts.helloMs || 5000,
230
- unreachable: opts.unreachableMs || 10000,
231
- retry: opts.retryMs || 10000,
232
- heartbeat: opts.heartbeatMs || 3000,
233
- settle: opts.settleMs || 1500,
234
- };
235
- const loadPeer = async () => {
236
- if (opts.Peer) return opts.Peer;
237
- if (globalThis.Peer) return globalThis.Peer;
238
- const mod = await import(/* @vite-ignore */ opts.url || 'https://cdn.jsdelivr.net/npm/peerjs@1.5.4/+esm');
239
- return mod.Peer || mod.default;
240
- };
241
- return {
242
- name: 'peerjs',
243
- async join(room, hooks) {
244
- const Peer = await loadPeer();
245
- const tag = (await roomKey(room)).slice(0, 24);
246
- const max = hooks.maxPeers || 4;
247
- const nSlots = max * 2; // zombies must never make a room look full
248
- const slotId = (k) => `dxrcall-${tag}-${k}`;
249
-
250
- let peer = null;
251
- let mySlot = -1;
252
- for (let k = 0; k < nSlots && !peer; k++) {
253
- peer = await new Promise((resolve) => {
254
- const p = new Peer(slotId(k), opts.peerOptions || {});
255
- const onErr = (e) => {
256
- if (e && e.type === 'unavailable-id') {
257
- p.destroy();
258
- resolve(null);
259
- }
260
- };
261
- p.on('error', onErr);
262
- p.on('open', () => {
263
- p.off?.('error', onErr);
264
- mySlot = k;
265
- resolve(p);
266
- });
267
- });
268
- }
269
- if (!peer) throw codedError('room-full', `every slot of this call is taken (${nSlots})`);
270
-
271
- const byId = new Map(); // logical peer id -> { conn, seen }
272
- const bySlot = new Map(); // broker id -> logical peer id
273
- const silent = new Map(); // broker id -> { since, reported, conn }
274
- let left = false;
275
-
276
- const markReachable = (brokerId) => {
277
- const s = silent.get(brokerId);
278
- if (!s) return;
279
- silent.delete(brokerId);
280
- if (s.reported) hooks.onPeerReachable?.(`slot-${brokerId.split('-').pop()}`);
281
- };
282
- const wire = (conn) => {
283
- conn.on('open', () => conn.send({ t: 'hi', id: hooks.id }));
284
- conn.on('data', (msg) => {
285
- if (!msg || typeof msg !== 'object') return;
286
- const known = bySlot.get(conn.peer);
287
- if (msg.t === 'hi' && typeof msg.id === 'string') {
288
- markReachable(conn.peer);
289
- if (!known) {
290
- bySlot.set(conn.peer, msg.id);
291
- const prev = byId.get(msg.id);
292
- byId.set(msg.id, { conn, seen: Date.now() });
293
- if (!prev) hooks.onPeerJoined?.(msg.id);
294
- if (conn.open) conn.send({ t: 'hi', id: hooks.id });
295
- }
296
- return;
297
- }
298
- if (!known) return;
299
- const rec = byId.get(known);
300
- if (rec) rec.seen = Date.now();
301
- if (msg.t === 'signal') hooks.onSignal?.(known, msg.data);
302
- else if (msg.t === 'bye') drop(conn.peer);
303
- });
304
- conn.on('close', () => drop(conn.peer));
305
- conn.on('error', () => {});
306
- };
307
- const drop = (brokerId) => {
308
- const id = bySlot.get(brokerId);
309
- if (!id) return;
310
- bySlot.delete(brokerId);
311
- byId.delete(id);
312
- hooks.onPeerLeft?.(id);
313
- };
314
- const dial = (k) => {
315
- const b = slotId(k);
316
- if (!silent.has(b)) silent.set(b, { since: Date.now(), reported: false, conn: null });
317
- const c = peer.connect(b, { reliable: true });
318
- silent.get(b).conn = c;
319
- wire(c);
320
- };
321
-
322
- peer.on('connection', wire);
323
- peer.on('error', (e) => {
324
- // 'peer-unavailable' names the broker id: that slot is FREE, so it is not a participant.
325
- const m = e && e.type === 'peer-unavailable' && String(e.message || '').match(/(dxrcall-[0-9a-f]+-\d+)/);
326
- if (m) markReachable(m[1]);
327
- });
328
- for (let k = 0; k < nSlots; k++) if (k !== mySlot) dial(k);
329
-
330
- const hb = setInterval(() => {
331
- const now = Date.now();
332
- for (const [id, rec] of byId) {
333
- if (now - rec.seen > 4 * T.heartbeat) {
334
- for (const [b, pid] of bySlot) if (pid === id) drop(b);
335
- } else if (rec.conn.open) rec.conn.send({ t: 'hb' });
336
- }
337
- for (const [b, st] of silent) {
338
- // Silent past the hello window → not counted; past the unreachable window → reported.
339
- if (!st.reported && now - st.since > T.unreachable) {
340
- st.reported = true;
341
- hooks.onPeerUnreachable?.(`slot-${b.split('-').pop()}`);
342
- }
343
- if (now - (st.lastDial || st.since) > T.retry) {
344
- st.lastDial = now;
345
- try {
346
- if (st.conn && !st.conn.open) st.conn.close(); // one pending attempt per slot
347
- } catch {
348
- /* ignore */
349
- }
350
- st.conn = peer.connect(b, { reliable: true }); // background retry of a silent slot
351
- wire(st.conn);
352
- }
353
- }
354
- }, Math.min(T.heartbeat, 1000));
355
-
356
- // Free this page's slot the moment it goes away — otherwise a reload leaves a zombie.
357
- const unload = () => {
358
- try {
359
- for (const rec of byId.values()) if (rec.conn.open) rec.conn.send({ t: 'bye' });
360
- peer.destroy();
361
- } catch {
362
- /* ignore */
363
- }
364
- };
365
- globalThis.addEventListener?.('pagehide', unload);
366
- globalThis.addEventListener?.('beforeunload', unload);
367
-
368
- // Give the slot mesh a moment to say hello, so `peers` is useful. Only slots that said `hi`
369
- // count (the transport enforces the room size on this list); late ones arrive through
370
- // onPeerJoined like any other join.
371
- await new Promise((r) => setTimeout(r, Math.min(T.settle, T.hello)));
372
- // Hand the media connections the same relays PeerJS itself uses (its public TURN, or a
373
- // page's `peerOptions.config`), so a demo call crosses the NATs a bare-PeerJS app crosses.
374
- // Demo-grade: shared, no uptime guarantee — production TURN comes from dxrSignaling().
375
- const relays = (peer.options?.config?.iceServers || []).filter((s) =>
376
- [].concat(s.urls || s.url || []).some((u) => /^turns?:/.test(u)));
377
- return {
378
- id: hooks.id,
379
- peers: [...byId.keys()],
380
- iceServers: relays.length ? relays : undefined,
381
- slot: mySlot,
382
- send: (to, data) => {
383
- const rec = byId.get(to);
384
- if (rec && rec.conn.open) rec.conn.send({ t: 'signal', data });
385
- },
386
- leave: () => {
387
- if (left) return;
388
- left = true;
389
- clearInterval(hb);
390
- globalThis.removeEventListener?.('pagehide', unload);
391
- globalThis.removeEventListener?.('beforeunload', unload);
392
- for (const rec of byId.values()) if (rec.conn.open) rec.conn.send({ t: 'bye' });
393
- setTimeout(() => peer.destroy(), 200);
394
- },
395
- };
396
- },
397
- };
398
- }
package/js/call/ui.js CHANGED
@@ -15,6 +15,16 @@
15
15
  // 4. Hidden chrome is `display: none`, never `opacity: 0`.
16
16
  // 5. The one full-tile element is the opaque COVER held until `handle.firstWoven` (rule 5 of the
17
17
  // woven-canvas rules), cut — never faded — when it resolves.
18
+ //
19
+ // THE THEMING API (RFC 0003 §2, C2) is three things, and the class names are none of them:
20
+ // - CSS custom properties on the host: --dxr-accent, --dxr-ink, --dxr-shell, --dxr-danger,
21
+ // --dxr-radius (chrome only, never a tile), --dxr-font. `theme` in JS sets the same ones.
22
+ // - `part` names on the chrome: bar, invite, badge, plate, lobby, banner, self, tile, grid.
23
+ // The chrome is LIGHT DOM (a woven canvas must live in the document's own tree), so they are
24
+ // reached as `dxr-call [part="bar"]` rather than `::part(bar)`; the names are the contract.
25
+ // - `theme.strings`: every string the chrome shows, by key (CALL_STRINGS) — also localisation.
26
+ // The woven-canvas constraints are enforced here, not documented: every variable styles chrome
27
+ // only, so no theme can reach a tile.
18
28
 
19
29
  /** Named accents, shared with ./player (same names, same colours). */
20
30
  export const CALL_ACCENTS = Object.freeze({
@@ -29,14 +39,97 @@ export const CALL_ACCENTS = Object.freeze({
29
39
  });
30
40
  export const resolveCallAccent = (a) => (typeof a === 'string' && CALL_ACCENTS[a.toLowerCase()]) || a;
31
41
 
42
+ /** The CSS custom properties `theme` writes (key → property). `tileAspect` / `strings` are not CSS. */
43
+ export const THEME_VARS = Object.freeze({ accent: '--dxr-accent', ink: '--dxr-ink', shell: '--dxr-shell', danger: '--dxr-danger', radius: '--dxr-radius', font: '--dxr-font' });
44
+
45
+ /**
46
+ * Every string the chrome shows, by key. `theme.strings` overrides any subset (a flat map), which
47
+ * is also how the chrome is localised. `{n}`-style placeholders are filled by the module.
48
+ */
49
+ export const CALL_STRINGS = Object.freeze({
50
+ // lobby
51
+ lobbyStartTitle: 'Start a 3D call',
52
+ lobbyJoinTitle: 'Join the 3D call',
53
+ lobbyFullTitle: 'This call is full',
54
+ lobbyLeftTitle: 'You left the call',
55
+ lobbyFullText: 'It already has {maxPeers} participants.',
56
+ lobbyText: 'Check your framing below. Camera: {camera} — {kind}.',
57
+ cameraDefault: 'default',
58
+ kindBusy: 'busy (joining audio-only)',
59
+ kindNone: 'none (joining audio-only)',
60
+ kindSbs: 'sending 3D (side-by-side, {width}×{height})',
61
+ kindMono: 'sending 2D ({width}×{height})',
62
+ liftOff: 'Mono cameras: 2D (2D→3D is off).',
63
+ liftChecking: 'Mono cameras: checking 2D→3D…',
64
+ liftMissing: 'Mono cameras: 2D→3D unavailable in this build.',
65
+ liftNoProvider: 'Mono cameras: 2D (no 2D→3D provider here).',
66
+ liftNative: 'Mono cameras: 2D→3D (native{provider}).',
67
+ liftWeb: 'Mono cameras: 2D→3D (in this page, WebGPU).',
68
+ liftProven: 'Mono cameras: 2D→3D.',
69
+ liftUnproven: 'Mono cameras: 2D→3D (confirmed on the first mono participant).',
70
+ start: 'Start 3D call',
71
+ join: 'Join call',
72
+ rejoin: 'Rejoin',
73
+ retryCamera: 'Retry camera',
74
+ cameraSelect: 'Camera',
75
+ joining: 'Joining…',
76
+ // invite
77
+ waitingTitle: 'Waiting for others',
78
+ waitingText: 'Share this link (or scan the code). Anyone who opens it joins — keep it private.',
79
+ inviteTitle: 'Invite',
80
+ inviteText: 'Anyone with this link can join.',
81
+ inviteLink: 'Invite link',
82
+ copyLink: 'Copy link',
83
+ copied: 'Copied',
84
+ pressCopy: 'Press Ctrl/Cmd+C',
85
+ qrLabel: 'QR code of the invite link',
86
+ // banner
87
+ banner2D: 'You are seeing this call in 2D.',
88
+ bannerLink: 'View in 3D with DisplayXR Browser',
89
+ // bar
90
+ mute: 'Mute microphone',
91
+ unmute: 'Unmute microphone',
92
+ cameraOn: 'Turn camera on',
93
+ cameraOff: 'Turn camera off',
94
+ cameraRetry: 'No camera — click to retry',
95
+ cameraBusyRetry: '{cameraBusy} — click to retry',
96
+ depth: 'Depth',
97
+ invite: 'Invite',
98
+ leave: 'Leave call',
99
+ // plates and badges
100
+ connecting: 'Connecting…',
101
+ reconnecting: 'Reconnecting…',
102
+ leftCall: 'Left the call',
103
+ cameraOffPlate: 'Camera off',
104
+ noCamera: 'No camera',
105
+ unreachable: "Can't reach this participant — the network needs a relay (TURN)",
106
+ cameraBusy: 'Camera busy — in use by another app (e.g. eye tracking)',
107
+ you: 'You',
108
+ badge3D: '3D',
109
+ badge2D3D: '2D→3D',
110
+ badge2D: '2D',
111
+ badgePending: '…',
112
+ });
113
+
114
+ /** A string table over CALL_STRINGS with `overrides`: `t(key, vars)` fills `{var}` placeholders. */
115
+ export function createStrings(overrides) {
116
+ const table = { ...CALL_STRINGS, ...(overrides && typeof overrides === 'object' ? overrides : {}) };
117
+ return (key, vars) => {
118
+ let s = typeof table[key] === 'string' ? table[key] : CALL_STRINGS[key] || key;
119
+ if (vars) for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v));
120
+ return s;
121
+ };
122
+ }
123
+
32
124
  const STYLE_ID = 'dxr-call-style';
33
125
  const CSS = `
34
- .dxr-call-host{--dxr-accent:#4da3ff;--dxr-ink:#fff;--dxr-shell:rgba(16,17,22,.92);--dxr-danger:#ff5a5f;
126
+ .dxr-call-host{--dxr-accent:#4da3ff;--dxr-ink:#fff;--dxr-shell:rgba(16,17,22,.92);--dxr-danger:#ff5a5f;--dxr-radius:12px;
127
+ --dxr-font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
35
128
  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;}
129
+ font:14px/1.4 var(--dxr-font);}
37
130
  .dxr-call-host *{box-sizing:border-box;}
38
131
  .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;
132
+ .dxr-call-banner{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 14px;border-radius:var(--dxr-radius);
40
133
  background:var(--dxr-shell);border:1px solid rgba(255,255,255,.12);font-size:13px;}
41
134
  .dxr-call-banner a{color:var(--dxr-accent);font-weight:600;}
42
135
  /* ── grid: bare boxes, nothing that makes a render surface ── */
@@ -50,6 +143,10 @@ const CSS = `
50
143
  .dxr-call-grid[data-layout="speaker"]{grid-template-columns:repeat(3,1fr);}
51
144
  .dxr-call-grid[data-layout="speaker"] .dxr-call-tile--main{grid-column:1/-1;}
52
145
  .dxr-call-grid[data-layout="speaker"][data-n="1"]{grid-template-columns:1fr;}
146
+ /* layout:'none' — the module creates the tiles but does not position them: the grid box steps
147
+ aside (display:contents) so each [data-dxr-peer] tile is laid out by the PAGE's own CSS on the
148
+ host. A tile is never moved; it is styled in place (woven-canvas rule 2). */
149
+ .dxr-call-grid[data-layout="none"]{display:contents;}
53
150
  .dxr-call-tile{position:relative;min-width:0;}
54
151
  .dxr-call-stage{position:relative;background:#000;}
55
152
  .dxr-call-stage>canvas{display:block;width:100%;height:100%;}
@@ -70,29 +167,29 @@ const CSS = `
70
167
  will-change:transform;pointer-events:none;}
71
168
  .dxr-call-cover{position:absolute;inset:0;z-index:1;background:#000;}
72
169
  /* ── empty room / lobby ── */
73
- .dxr-call-panel{padding:18px;border-radius:12px;background:var(--dxr-shell);border:1px solid rgba(255,255,255,.1);}
170
+ .dxr-call-panel{padding:18px;border-radius:var(--dxr-radius);background:var(--dxr-shell);border:1px solid rgba(255,255,255,.1);}
74
171
  .dxr-call-panel h3{margin:0 0 6px;font-size:17px;}
75
172
  .dxr-call-panel p{margin:0 0 12px;opacity:.75;}
76
173
  .dxr-call-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
77
174
  .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;}
175
+ font:600 13px/1 var(--dxr-font);padding:9px 14px;border-radius:calc(var(--dxr-radius) * .75);cursor:pointer;}
79
176
  .dxr-call-btn:hover{background:rgba(255,255,255,.16);}
80
177
  .dxr-call-btn:focus-visible{outline:2px solid var(--dxr-accent);outline-offset:2px;}
81
178
  .dxr-call-btn--primary{background:var(--dxr-accent);border-color:var(--dxr-accent);color:#07111d;}
82
179
  .dxr-call-btn--primary:hover{background:var(--dxr-accent);}
83
180
  .dxr-call-btn--danger{background:var(--dxr-danger);border-color:var(--dxr-danger);color:#fff;}
84
181
  .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;}
182
+ border-radius:calc(var(--dxr-radius) * .66);padding:7px 9px;max-width:260px;}
86
183
  .dxr-call-invite{display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap;}
87
184
  .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;}
185
+ background:rgba(0,0,0,.35);border:1px solid rgba(255,255,255,.14);border-radius:calc(var(--dxr-radius) * .66);padding:8px;}
89
186
  .dxr-call-invite canvas{width:132px;height:132px;image-rendering:pixelated;background:#fff;}
90
187
  /* ── footer: self view + bar, BELOW the grid (over no tile) ── */
91
188
  .dxr-call-foot{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;}
92
189
  .dxr-call-self{width:200px;flex:none;}
93
190
  .dxr-call-host--lobby .dxr-call-self{width:min(560px,100%);}
94
191
  .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;
192
+ .dxr-call-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border-radius:calc(var(--dxr-radius) + 2px);
96
193
  background:var(--dxr-shell);border:1px solid rgba(255,255,255,.1);will-change:transform;}
97
194
  .dxr-call-host--lobby .dxr-call-bar{display:none;}
98
195
  .dxr-call-ib{appearance:none;border:0;width:40px;height:40px;border-radius:50%;padding:9px;cursor:pointer;
@@ -127,7 +224,7 @@ export const ICONS = {
127
224
  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
225
  };
129
226
 
130
- /** `el('div', {class: 'x'}, [children])` — a tiny builder, attributes as properties or attrs. */
227
+ /** `el('div', {class: 'x', part: 'bar'}, [children])` — a tiny builder, attributes as properties or attrs. */
131
228
  export function el(tag, props = {}, children = []) {
132
229
  const n = document.createElement(tag);
133
230
  for (const [k, v] of Object.entries(props)) {
package/js/call/wire.js CHANGED
@@ -13,8 +13,11 @@ export const CALL_SDK = 'inline3d-call/1';
13
13
  /** Formats a sender may declare. Anything else — or no hello at all — is read as `'mono'`. */
14
14
  export const CALL_FORMATS = Object.freeze(['sbs', 'mono']);
15
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;
16
+ // The side-by-side geometry (what counts as a pair, eye crops, the mirror-and-swap self view)
17
+ // moved to js/camera/geometry.js in C2 — `/camera` owns it, `/call` consumes it. Re-exported here
18
+ // so every in-tree import and test path keeps resolving.
19
+ export { SBS_ASPECT_MIN, looksSbs, eyeCropRect, eyeOutputSize, mirrorSwapOps, mirrorSwapPixels } from '../camera/geometry.js';
20
+ export { CONVERGENCE_ALPHA, CONVERGENCE_MAX_FRACTION, DEPTH_RANGE_FRACTION } from '../camera/converge.js';
18
21
 
19
22
  /** Default and hard cap of participants in a full-mesh call (including yourself). */
20
23
  export const DEFAULT_MAX_PEERS = 4;
@@ -23,15 +26,6 @@ export const MESH_HARD_CAP = 4;
23
26
  /** `hint` messages are rate-limited to this many per second, on the sender AND the receiver. */
24
27
  export const HINT_MAX_HZ = 5;
25
28
 
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
29
  /** Plausible subject distances, mm. A hint outside this is ignored as noise. */
36
30
  export const SUBJECT_Z_MIN_MM = 150;
37
31
  export const SUBJECT_Z_MAX_MM = 5000;
@@ -231,137 +225,9 @@ export function badgeFor(route) {
231
225
  return route === 'woven-sbs' ? '3D' : route === 'lifted' ? '2D→3D' : '2D';
232
226
  }
233
227
 
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
- }
228
+ // ── convergence (RFC 0002 §3) — moved to js/camera/converge.js in C2 (the self view and the remote
229
+ // tiles share one convergence state); re-exported so in-tree imports and test paths keep resolving.
230
+ export { focalPx, convergenceShiftPx, clampShift, lowPass, createConvergence } from '../camera/converge.js';
365
231
 
366
232
  // ── weave liveness (displayxr-browser-pvt#172) ─────────────────────────────────────────────
367
233
 
@@ -421,8 +287,3 @@ export function backoffMs(attempt, { baseMs = 1000, maxMs = 30000, rand = Math.r
421
287
  const raw = Math.min(maxMs, baseMs * Math.pow(2, Math.max(0, attempt)));
422
288
  return Math.round(raw * (0.8 + 0.4 * rand()));
423
289
  }
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
- }