@displayxr/inline3d 1.29.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.
- package/CHANGELOG.md +124 -0
- package/README.md +4 -0
- package/call.d.ts +269 -238
- package/camera.d.ts +257 -0
- package/dist/call.js +17 -12
- package/js/call/capture.js +4 -212
- package/js/call/disparity.js +4 -432
- package/js/call/element.js +14 -10
- package/js/call/options.js +132 -0
- package/js/call/ui.js +106 -9
- package/js/call/wire.js +8 -153
- package/js/camera/capture.js +220 -0
- package/js/camera/converge.js +82 -0
- package/js/camera/disparity.js +432 -0
- package/js/camera/geometry.js +80 -0
- package/js/camera/metadata.js +398 -0
- package/js/inline3d-call.js +404 -319
- package/js/inline3d-camera.js +678 -0
- package/js/inline3d-splat-deferred.js +153 -0
- package/js/inline3d-splat-pc-entry.js +45 -0
- package/js/inline3d-splat.js +2 -145
- package/package.json +19 -1
- package/splat-playcanvas.d.ts +4 -0
package/js/call/element.js
CHANGED
|
@@ -37,7 +37,7 @@ import { mountCall, dxrSignaling } from '../inline3d-call.js';
|
|
|
37
37
|
|
|
38
38
|
/** Every call event is re-dispatched as `dxr-call:<type>`; `dxr-call:ready` carries the handle. */
|
|
39
39
|
export const CALL_EVENT_PREFIX = 'dxr-call:';
|
|
40
|
-
const CALL_EVENTS = ['joined', 'left', 'peer', 'peerleft', 'state', '
|
|
40
|
+
const CALL_EVENTS = ['joined', 'left', 'peer', 'peerleft', 'state', 'display', 'speaker', 'quality', 'error', 'warning'];
|
|
41
41
|
const TAG = '[inline3d/call] <dxr-call>';
|
|
42
42
|
|
|
43
43
|
/** A boolean attribute: present is true, unless its value spells "false"/"0"/"off"/"no". */
|
|
@@ -61,18 +61,19 @@ const str = (v) => (v === null || v === undefined || String(v).trim() === '' ? u
|
|
|
61
61
|
* | `signaling` | `signaling: dxrSignaling(url)` |
|
|
62
62
|
* | `key` | `key` |
|
|
63
63
|
* | `camera` | `camera` (`auto`/`stereo`/`mono`/deviceId)|
|
|
64
|
-
* | `layout` | `layout` (`grid`/`speaker`)
|
|
65
|
-
* | `accent` | `accent`
|
|
64
|
+
* | `layout` | `layout` (`grid`/`speaker`/`none`) |
|
|
65
|
+
* | `accent` | `theme.accent` |
|
|
66
66
|
* | `max-peers` | `maxPeers` |
|
|
67
67
|
* | `no-ui` | `ui: false` |
|
|
68
|
+
* | `ui="tiles"` | `ui: 'tiles'` |
|
|
68
69
|
* | `auto-join` | `autoJoin: true` |
|
|
69
70
|
* | `mono3d="off"` | `mono3D: 'off'` |
|
|
70
71
|
* | `no-audio` | `audio: false` |
|
|
71
72
|
* | `no-self-view` | `selfView: false` |
|
|
72
73
|
* | `no-auto-converge` | `autoConverge: false` |
|
|
73
|
-
* | `tile-aspect` | `tileAspect` (a number, or `w/h`)
|
|
74
|
-
* | `invite-base` | `
|
|
75
|
-
* | `browser-url` | `browserUrl`
|
|
74
|
+
* | `tile-aspect` | `theme.tileAspect` (a number, or `w/h`) |
|
|
75
|
+
* | `invite-base` | `invite.base` |
|
|
76
|
+
* | `browser-url` | `landing.browserUrl` |
|
|
76
77
|
* | `debug` | `debug: true` |
|
|
77
78
|
*
|
|
78
79
|
* @param {{ getAttribute(name: string): string | null } | ((name: string) => string | null)} source
|
|
@@ -90,18 +91,21 @@ export function attrsToOpts(source) {
|
|
|
90
91
|
put('key', str(get('key')));
|
|
91
92
|
put('camera', str(get('camera')));
|
|
92
93
|
put('layout', str(get('layout')));
|
|
93
|
-
put('accent', str(get('accent')));
|
|
94
94
|
put('maxPeers', num(get('max-peers')));
|
|
95
|
+
const ui = str(get('ui'));
|
|
95
96
|
if (isOn(get('no-ui'))) o.ui = false;
|
|
97
|
+
else if (ui === 'tiles') o.ui = 'tiles';
|
|
96
98
|
if (isOn(get('auto-join'))) o.autoJoin = true;
|
|
97
99
|
const m3 = str(get('mono3d'));
|
|
98
100
|
if (m3 && /^(off|false|0|no)$/i.test(m3)) o.mono3D = 'off';
|
|
99
101
|
if (isOn(get('no-audio'))) o.audio = false;
|
|
100
102
|
if (isOn(get('no-self-view'))) o.selfView = false;
|
|
101
103
|
if (isOn(get('no-auto-converge'))) o.autoConverge = false;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
// The C2 groups (RFC 0003 §2): the attribute names are the C1 ones, the option shapes are new.
|
|
105
|
+
const theme = { accent: str(get('accent')), tileAspect: num(get('tile-aspect')) };
|
|
106
|
+
if (theme.accent !== undefined || theme.tileAspect !== undefined) o.theme = Object.fromEntries(Object.entries(theme).filter(([, v]) => v !== undefined));
|
|
107
|
+
if (str(get('invite-base'))) o.invite = { base: str(get('invite-base')) };
|
|
108
|
+
if (str(get('browser-url'))) o.landing = { browserUrl: str(get('browser-url')) };
|
|
105
109
|
if (isOn(get('debug'))) o.debug = true;
|
|
106
110
|
return o;
|
|
107
111
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// call/options.js — `CallOptions` → the module's internal shape (RFC 0003 §2): defaults,
|
|
2
|
+
// validation, the C2 groups (`theme`, `invite`, `landing`, `liftOptions.max`) folded flat, and the
|
|
3
|
+
// 1.29 spellings accepted for one release with a warning. Pure (no DOM, no network) — see
|
|
4
|
+
// test/call.test.mjs. Internal: importable by path, not part of the `./call` entry.
|
|
5
|
+
|
|
6
|
+
import { isValidRoomId, parseInviteLink, DEFAULT_MAX_PEERS } from './wire.js';
|
|
7
|
+
import { clampMaxPeers } from './transport.js';
|
|
8
|
+
import { normalizeMono3D } from './lift.js';
|
|
9
|
+
import { dxrSignaling } from './signaling.js';
|
|
10
|
+
import { resolveCallAccent, THEME_VARS } from './ui.js';
|
|
11
|
+
|
|
12
|
+
const TAG = '[inline3d/call]';
|
|
13
|
+
const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The 1.29 option spellings and where they went (RFC 0003 §2). Accepted for one release with a
|
|
17
|
+
* single console.warn per key; `normalizeCallOptions` folds them into the C2 shape. 1.31 drops
|
|
18
|
+
* them. The `<dxr-call>` ATTRIBUTES keep their names — the element maps them to the new shapes.
|
|
19
|
+
*/
|
|
20
|
+
export const LEGACY_OPTIONS = Object.freeze({
|
|
21
|
+
accent: 'theme.accent',
|
|
22
|
+
tileAspect: 'theme.tileAspect',
|
|
23
|
+
inviteBase: 'invite.base',
|
|
24
|
+
updateUrl: 'invite.updateUrl',
|
|
25
|
+
browserUrl: 'landing.browserUrl',
|
|
26
|
+
maxLifted: 'liftOptions.max',
|
|
27
|
+
calibration: "openCamera({ calibration }) of '@displayxr/inline3d/camera' (pass the camera as `camera`)",
|
|
28
|
+
rectify: "openCamera({ rectify }) of '@displayxr/inline3d/camera'",
|
|
29
|
+
log: 'debug: true (and diagnostics())',
|
|
30
|
+
recoverSession: "nothing — a call always recovers the document's session (sharedInline3D())",
|
|
31
|
+
wallOptions: "sharedInline3D(opts) — the document's wall is configured by the page",
|
|
32
|
+
scrollIntoView: 'nothing — always on with ui: true',
|
|
33
|
+
});
|
|
34
|
+
const legacyWarned = new Set();
|
|
35
|
+
function warnLegacy(key) {
|
|
36
|
+
if (legacyWarned.has(key)) return;
|
|
37
|
+
legacyWarned.add(key);
|
|
38
|
+
console.warn(`${TAG} option \`${key}\` is deprecated (removed in 1.31) — use ${LEGACY_OPTIONS[key]}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Apply defaults and validate. Pure (no DOM, no network) — see test/call.test.mjs. The result is
|
|
43
|
+
* the module's INTERNAL shape (flat), with the public groups (`theme`, `invite`, `landing`,
|
|
44
|
+
* `liftOptions`) folded in and the 1.29 spellings accepted with a warning (LEGACY_OPTIONS).
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeCallOptions(opts = {}) {
|
|
48
|
+
for (const k of Object.keys(LEGACY_OPTIONS)) if (opts[k] !== undefined) warnLegacy(k);
|
|
49
|
+
const theme = opts.theme && typeof opts.theme === 'object' ? opts.theme : {};
|
|
50
|
+
const invite = opts.invite && typeof opts.invite === 'object' ? opts.invite : {};
|
|
51
|
+
const landing = opts.landing && typeof opts.landing === 'object' ? opts.landing : {};
|
|
52
|
+
const ui = opts.ui === undefined ? true : opts.ui === 'tiles' ? 'tiles' : !!opts.ui;
|
|
53
|
+
const chrome = ui === true;
|
|
54
|
+
const tileAspect = theme.tileAspect !== undefined ? theme.tileAspect : opts.tileAspect;
|
|
55
|
+
const aspect = typeof tileAspect === 'number' && tileAspect > 0.3 && tileAspect < 4 ? tileAspect : 16 / 9;
|
|
56
|
+
let room = opts.room === undefined || opts.room === null ? 'auto' : opts.room;
|
|
57
|
+
if (room !== 'auto' && !isValidRoomId(room)) {
|
|
58
|
+
const fromLink = parseInviteLink(room);
|
|
59
|
+
if (!fromLink) throw new Error(`@displayxr/inline3d/call: room "${room}" is not a valid room id (16-64 base64url chars) or invite link`);
|
|
60
|
+
room = fromLink;
|
|
61
|
+
}
|
|
62
|
+
const liftOptions = opts.liftOptions && typeof opts.liftOptions === 'object' ? { ...opts.liftOptions } : null;
|
|
63
|
+
const maxLiftedRaw = liftOptions && liftOptions.max !== undefined ? liftOptions.max : opts.maxLifted;
|
|
64
|
+
if (liftOptions) delete liftOptions.max;
|
|
65
|
+
const accent = theme.accent !== undefined ? theme.accent : opts.accent;
|
|
66
|
+
const cssVars = {};
|
|
67
|
+
for (const [k, prop] of Object.entries(THEME_VARS)) {
|
|
68
|
+
const v = k === 'accent' ? accent : theme[k];
|
|
69
|
+
if (typeof v === 'string' && v) cssVars[prop] = k === 'accent' ? resolveCallAccent(v) : v;
|
|
70
|
+
else if (typeof v === 'number' && k === 'radius') cssVars[prop] = `${v}px`;
|
|
71
|
+
}
|
|
72
|
+
const inviteBase = invite.base !== undefined ? invite.base : opts.inviteBase;
|
|
73
|
+
const updateUrl = invite.updateUrl !== undefined ? invite.updateUrl : opts.updateUrl;
|
|
74
|
+
const browserUrl = landing.browserUrl !== undefined ? landing.browserUrl : opts.browserUrl;
|
|
75
|
+
return {
|
|
76
|
+
room,
|
|
77
|
+
// Default: the hosted DisplayXR signalling server (which also mints TURN credentials). A
|
|
78
|
+
// publishable key (RFC 0003 §5a) rides along on connect; the server may ignore it today.
|
|
79
|
+
signaling: opts.signaling || dxrSignaling(undefined, typeof opts.key === 'string' && opts.key ? { key: opts.key } : {}),
|
|
80
|
+
iceServers: Array.isArray(opts.iceServers) ? opts.iceServers : undefined,
|
|
81
|
+
// 'auto' | 'stereo' | 'mono' | deviceId | MediaStream | a StereoCamera from /camera.
|
|
82
|
+
camera: opts.camera === undefined ? 'auto' : opts.camera,
|
|
83
|
+
format: opts.format === 'sbs' ? 'sbs' : opts.format === 'mono' ? 'mono' : undefined,
|
|
84
|
+
// Camera-side knobs (moved to /camera in C2; still forwarded to openCamera() for one release).
|
|
85
|
+
calibration: opts.calibration && typeof opts.calibration === 'object' ? { ...opts.calibration } : {},
|
|
86
|
+
rectify: typeof opts.rectify === 'function' ? opts.rectify : null,
|
|
87
|
+
audio: opts.audio === undefined ? true : !!opts.audio,
|
|
88
|
+
// Auto-convergence (camera/disparity.js): measure the disparity of the point between each SBS
|
|
89
|
+
// peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
|
|
90
|
+
// offset on top. Off = the pair as sent (plus any `hint`).
|
|
91
|
+
autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
|
|
92
|
+
mono3D: normalizeMono3D(opts.mono3D),
|
|
93
|
+
maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
|
|
94
|
+
// Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
|
|
95
|
+
// keys (mode, wall, ui, convergence, priority) always win; `max` is the call's cap (below).
|
|
96
|
+
liftOptions: liftOptions && Object.keys(liftOptions).length ? liftOptions : null,
|
|
97
|
+
// Concurrent lifted tiles (`liftOptions.max`). Default = maxPeers (4): every mono peer can be lifted.
|
|
98
|
+
maxLifted: Number.isFinite(+maxLiftedRaw) && maxLiftedRaw !== null && maxLiftedRaw !== undefined ? Math.max(0, Math.min(DEFAULT_MAX_PEERS, Math.floor(+maxLiftedRaw))) : DEFAULT_MAX_PEERS,
|
|
99
|
+
// 'grid' | 'speaker' | 'none' (the page lays the tiles out; handle.tile(id) finds them).
|
|
100
|
+
layout: opts.layout === 'speaker' ? 'speaker' : opts.layout === 'none' ? 'none' : 'grid',
|
|
101
|
+
// true: full chrome | 'tiles': badges + plates only | false: nothing. `chrome` = the full set.
|
|
102
|
+
ui,
|
|
103
|
+
chrome,
|
|
104
|
+
autoJoin: opts.autoJoin === undefined ? !chrome : !!opts.autoJoin,
|
|
105
|
+
selfView: opts.selfView === undefined ? true : !!opts.selfView,
|
|
106
|
+
tileAspect: aspect,
|
|
107
|
+
// theme → the CSS custom properties written on the host, and the string table.
|
|
108
|
+
cssVars,
|
|
109
|
+
strings: theme.strings && typeof theme.strings === 'object' ? { ...theme.strings } : null,
|
|
110
|
+
invite: {
|
|
111
|
+
base: typeof inviteBase === 'string' ? inviteBase : null,
|
|
112
|
+
updateUrl: updateUrl === undefined ? chrome : !!updateUrl,
|
|
113
|
+
},
|
|
114
|
+
landing: {
|
|
115
|
+
browserUrl: typeof browserUrl === 'string' ? browserUrl : DEFAULT_BROWSER_URL,
|
|
116
|
+
allow2D: landing.allow2D === undefined ? true : !!landing.allow2D,
|
|
117
|
+
},
|
|
118
|
+
debug: !!opts.debug,
|
|
119
|
+
log: typeof opts.log === 'function' ? opts.log : opts.debug ? (tag, obj) => console.log(`${TAG} ${tag} ${JSON.stringify(obj)}`) : null,
|
|
120
|
+
// Internal (tests): replaces the lift module importer behind `mono3D: 'auto'`.
|
|
121
|
+
_liftImporter: typeof opts._liftImporter === 'function' ? opts._liftImporter : undefined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The 1.29 `./call` exports kept as deprecated wrappers for one release (removed in 1.31). */
|
|
126
|
+
export const DEPRECATED_CALL_EXPORTS = Object.freeze([
|
|
127
|
+
'normalizeCallOptions', 'newRoomId', 'isValidRoomId', 'buildInviteLink', 'normalizeHello', 'makeHello', 'routeFor', 'badgeFor', 'createLiveGate',
|
|
128
|
+
'convergenceShiftPx', 'lowPass', 'clampShift', 'eyeCropRect', 'mirrorSwapOps', 'mirrorSwapPixels', 'maxBitrateKbps', 'measureFocusDisparity',
|
|
129
|
+
'preferVideoCodecs', 'sortCodecCapabilities', 'VIDEO_CODEC_ORDER', 'MeshTransport', 'clampMaxPeers', 'qrEncode', 'roomKey', 'SIGNAL_PROTOCOL',
|
|
130
|
+
'WIRE_VERSION', 'CALL_SDK', 'PLATE_TEXT', 'CALL_ACCENTS', 'normalizeMono3D', 'resolveLift', 'createLiftPool', 'createFrameWatch',
|
|
131
|
+
'liftConvergenceFor', 'liftPriorityFor', 'setLiftPriority', 'defaultLiftSpecifier', 'LIFT_PRIORITY',
|
|
132
|
+
]);
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
17
|
-
|
|
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,143 +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
|
-
|
|
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
|
-
// 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,
|
|
280
|
-
depth: 0, // setDepth(), [-1, 1]
|
|
281
|
-
current: 0,
|
|
282
|
-
target(eyeWidthPx) {
|
|
283
|
-
const auto =
|
|
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;
|
|
294
|
-
return clampShift(auto + s.depth * DEPTH_RANGE_FRACTION * eyeWidthPx, eyeWidthPx);
|
|
295
|
-
},
|
|
296
|
-
step(eyeWidthPx) {
|
|
297
|
-
s.current = lowPass(s.current, s.target(eyeWidthPx));
|
|
298
|
-
return s.current;
|
|
299
|
-
},
|
|
300
|
-
};
|
|
301
|
-
return s;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* The source rectangle of ONE eye, cropped to the tile's aspect and shifted by the convergence
|
|
306
|
-
* offset. Never upscales past the source: a 640-wide eye (a real raw stereo camera) stays 640.
|
|
307
|
-
*
|
|
308
|
-
* @param {number} eyeW source per-eye width (px)
|
|
309
|
-
* @param {number} eyeH source height
|
|
310
|
-
* @param {number} aspect the tile's (= the woven buffer's per-eye) aspect, w/h
|
|
311
|
-
* @param {number} shift per-eye shift in source px (see convergenceShiftPx); + = push back
|
|
312
|
-
* @param {0|1} eye 0 = left, 1 = right
|
|
313
|
-
* @returns {{sx:number, sy:number, sw:number, sh:number}} relative to that eye's half
|
|
314
|
-
*/
|
|
315
|
-
export function eyeCropRect(eyeW, eyeH, aspect, shift, eye) {
|
|
316
|
-
const s = Math.abs(shift || 0);
|
|
317
|
-
let sw = Math.min(eyeW - 2 * s, eyeH * aspect);
|
|
318
|
-
sw = Math.max(1, sw);
|
|
319
|
-
const sh = Math.min(eyeH, sw / aspect);
|
|
320
|
-
sw = sh * aspect;
|
|
321
|
-
const cx = (eyeW - sw) / 2 + (eye === 0 ? shift : -shift);
|
|
322
|
-
return { sx: Math.max(0, Math.min(eyeW - sw, cx)), sy: (eyeH - sh) / 2, sw, sh };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** The output per-eye size for a source eye and a tile aspect: no upscaling, even numbers. */
|
|
326
|
-
export function eyeOutputSize(eyeW, eyeH, aspect) {
|
|
327
|
-
const w = Math.min(eyeW, eyeH * aspect);
|
|
328
|
-
const h = w / aspect;
|
|
329
|
-
return { w: Math.max(2, Math.round(w / 2) * 2), h: Math.max(2, Math.round(h / 2) * 2) };
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ── self view: the mirroring trap (RFC §3) ─────────────────────────────────────────────────
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* The draw operations for a MIRRORED stereo self-view: each half mirrored AND the halves swapped.
|
|
336
|
-
*
|
|
337
|
-
* Why both: mirroring a scene horizontally means the reflected left eye sees what the original
|
|
338
|
-
* right eye saw (mirrored), and vice versa. Mirroring each half IN PLACE keeps the eyes where they
|
|
339
|
-
* were and inverts every disparity — the face goes pseudoscopic (inside-out). Mirror + swap keeps
|
|
340
|
-
* crossed disparity crossed. (It is the same pixels as flipping the whole SBS frame; spelled out
|
|
341
|
-
* per half so the intent survives refactoring.) The WIRE is never mirrored — only this preview.
|
|
342
|
-
*
|
|
343
|
-
* @param {number} W full SBS frame width
|
|
344
|
-
* @param {number} H height
|
|
345
|
-
* @returns {Array<{src:'L'|'R', sx:number, sw:number, dx:number, dw:number, mirror:true}>}
|
|
346
|
-
*/
|
|
347
|
-
export function mirrorSwapOps(W, H) {
|
|
348
|
-
const half = W / 2;
|
|
349
|
-
return [
|
|
350
|
-
{ src: 'R', sx: half, sw: half, sy: 0, sh: H, dx: 0, dw: half, mirror: true },
|
|
351
|
-
{ src: 'L', sx: 0, sw: half, sy: 0, sh: H, dx: half, dw: half, mirror: true },
|
|
352
|
-
];
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Apply mirrorSwapOps to a row-major single-channel frame (tests, and a reference for the canvas
|
|
357
|
-
* path). Returns a new array.
|
|
358
|
-
*/
|
|
359
|
-
export function mirrorSwapPixels(px, W, H) {
|
|
360
|
-
const out = new px.constructor(px.length);
|
|
361
|
-
for (const op of mirrorSwapOps(W, H)) {
|
|
362
|
-
for (let y = 0; y < H; y++) {
|
|
363
|
-
for (let x = 0; x < op.dw; x++) {
|
|
364
|
-
const srcX = op.sx + (op.sw - 1 - x); // mirrored within the half
|
|
365
|
-
out[y * W + op.dx + x] = px[y * W + srcX];
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
return out;
|
|
370
|
-
}
|
|
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';
|
|
371
231
|
|
|
372
232
|
// ── weave liveness (displayxr-browser-pvt#172) ─────────────────────────────────────────────
|
|
373
233
|
|
|
@@ -427,8 +287,3 @@ export function backoffMs(attempt, { baseMs = 1000, maxMs = 30000, rand = Math.r
|
|
|
427
287
|
const raw = Math.min(maxMs, baseMs * Math.pow(2, Math.max(0, attempt)));
|
|
428
288
|
return Math.round(raw * (0.8 + 0.4 * rand()));
|
|
429
289
|
}
|
|
430
|
-
|
|
431
|
-
/** Does a delivered frame look like a side-by-side pair? (capture 'auto' only) */
|
|
432
|
-
export function looksSbs(width, height) {
|
|
433
|
-
return width > 0 && height > 0 && width / height > SBS_ASPECT_MIN;
|
|
434
|
-
}
|