@displayxr/inline3d 1.28.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +6 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +103 -23
- package/dist/call.js +81 -0
- package/index.d.ts +27 -1
- package/js/call/capture.js +47 -2
- package/js/call/disparity.js +432 -0
- package/js/call/element.js +217 -0
- package/js/call/lift.js +9 -0
- package/js/call/signaling.js +7 -202
- package/js/call/wire.js +14 -8
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +222 -16
- package/js/inline3d-player.js +646 -216
- package/js/inline3d-splat-playcanvas.js +46 -3
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d.js +130 -1
- package/js/lift/index.js +15 -0
- package/package.json +21 -4
- package/player.d.ts +46 -2
- package/splat.d.ts +38 -0
package/js/call/signaling.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// call/signaling.js — the SignalingAdapter seam, and the
|
|
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
|
|
19
|
-
//
|
|
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/wire.js
CHANGED
|
@@ -273,18 +273,24 @@ export function createConvergence() {
|
|
|
273
273
|
const s = {
|
|
274
274
|
hello: null,
|
|
275
275
|
subjectZmm: null,
|
|
276
|
+
// Auto-convergence (call/disparity.js): the MEASURED disparity of the point between the remote
|
|
277
|
+
// person's eyes, in source eye pixels (left x − right x). When set it wins over the hint: it is
|
|
278
|
+
// read off the frames, so it needs no calibration and cannot disagree with them.
|
|
279
|
+
measuredPx: null,
|
|
276
280
|
depth: 0, // setDepth(), [-1, 1]
|
|
277
281
|
current: 0,
|
|
278
282
|
target(eyeWidthPx) {
|
|
279
283
|
const auto =
|
|
280
|
-
s.
|
|
281
|
-
?
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
284
|
+
s.measuredPx !== null
|
|
285
|
+
? s.measuredPx / 2 // half the disparity comes off each eye: the eyes land at zero parallax
|
|
286
|
+
: s.hello && s.subjectZmm
|
|
287
|
+
? convergenceShiftPx({
|
|
288
|
+
eyeWidthPx,
|
|
289
|
+
hfovDeg: s.hello.hfovDeg,
|
|
290
|
+
baselineMm: s.hello.baselineMm,
|
|
291
|
+
subjectZmm: s.subjectZmm,
|
|
292
|
+
})
|
|
293
|
+
: 0;
|
|
288
294
|
return clampShift(auto + s.depth * DEPTH_RANGE_FRACTION * eyeWidthPx, eyeWidthPx);
|
|
289
295
|
},
|
|
290
296
|
step(eyeWidthPx) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// inline3d-call-full.js — `@displayxr/inline3d/call/full`: the call entry with 2D→3D bundled.
|
|
2
|
+
//
|
|
3
|
+
// PREVIEW tier (docs/sdk-stability.md). RFC 0003 §1 "Lift under bundlers", mechanism 2.
|
|
4
|
+
//
|
|
5
|
+
// import { mountCall } from '@displayxr/inline3d/call/full';
|
|
6
|
+
// const call = await mountCall(el); // 2D participants are lifted to 3D on a 3D display
|
|
7
|
+
//
|
|
8
|
+
// The plain `./call` entry finds the lift module through a COMPUTED import, which a bundler cannot
|
|
9
|
+
// follow: a Vite/webpack/esbuild build of it resolves the specifier against the chunk URL, 404s,
|
|
10
|
+
// and — since 1.29 — says so (`warning { code: 'lift-not-bundled' }`) rather than staying flat in
|
|
11
|
+
// silence. This entry is the fix for the common case: it imports lift STATICALLY, so a bundler
|
|
12
|
+
// sees it, code-splits it, and ships it; `mono3D` defaults to that `lift`. Pages that do not want
|
|
13
|
+
// the depth-model plumbing keep importing `./call`. A page with its own `mono3D` (a function, or
|
|
14
|
+
// `'off'`) is left alone.
|
|
15
|
+
//
|
|
16
|
+
// Everything else — `addCall`, `dxrSignaling`, the types, the `<dxr-call>` registration — is the
|
|
17
|
+
// `./call` entry re-exported, and `<dxr-call>` is pointed at THIS `mountCall`, so importing this
|
|
18
|
+
// entry makes every element on the page lift too.
|
|
19
|
+
//
|
|
20
|
+
// Until `./lift` (js/lift/) lands on main, that path holds a PLACEHOLDER exporting no `lift`;
|
|
21
|
+
// this entry then behaves exactly like `./call` (`liftBundled === false`, and the same warning
|
|
22
|
+
// fires). The import is `* as`, so the day the real module replaces the placeholder nothing here
|
|
23
|
+
// changes.
|
|
24
|
+
|
|
25
|
+
import * as liftMod from './lift/index.js';
|
|
26
|
+
import { mountCall as mountCallBase, addCall as addCallBase, DxrCallElement } from './inline3d-call.js';
|
|
27
|
+
|
|
28
|
+
export * from './inline3d-call.js';
|
|
29
|
+
|
|
30
|
+
const lift = typeof liftMod.lift === 'function' ? liftMod.lift : null;
|
|
31
|
+
|
|
32
|
+
/** True when this copy of the SDK carries the lift module (false on a build before `./lift` landed). */
|
|
33
|
+
export const liftBundled = lift !== null;
|
|
34
|
+
|
|
35
|
+
const withLift = (opts) => (lift && (!opts || opts.mono3D === undefined) ? { ...(opts || {}), mono3D: lift } : opts || {});
|
|
36
|
+
|
|
37
|
+
/** `mountCall` of `./call`, with `mono3D` defaulted to the statically imported `lift`. */
|
|
38
|
+
export function mountCall(el, opts) {
|
|
39
|
+
return mountCallBase(el, withLift(opts));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `addCall` of `./call`, with `mono3D` defaulted to the statically imported `lift`. */
|
|
43
|
+
export function addCall(wall, container, opts) {
|
|
44
|
+
return addCallBase(wall, container, withLift(opts));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `<dxr-call>` on a page that imported this entry lifts too (the element's mount seam).
|
|
48
|
+
DxrCallElement.mount = mountCall;
|