@displayxr/inline3d 1.27.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 +224 -0
- package/README.md +6 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +454 -0
- package/dist/call.js +81 -0
- package/index.d.ts +34 -2
- package/js/call/capture.js +212 -0
- package/js/call/disparity.js +432 -0
- package/js/call/element.js +217 -0
- package/js/call/lift.js +323 -0
- package/js/call/qr.js +283 -0
- package/js/call/sdp.js +86 -0
- package/js/call/signaling.js +203 -0
- package/js/call/transport.js +589 -0
- package/js/call/ui.js +146 -0
- package/js/call/wire.js +434 -0
- package/js/inline3d-buffer-limit.js +198 -0
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +1901 -0
- package/js/inline3d-model-playcanvas.js +6 -0
- package/js/inline3d-model.js +6 -0
- package/js/inline3d-player.js +649 -218
- package/js/inline3d-splat-playcanvas.js +134 -18
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +86 -10
- package/js/inline3d.js +257 -15
- package/js/lift/index.js +15 -0
- package/model.d.ts +10 -1
- package/package.json +29 -4
- package/player.d.ts +49 -4
- package/splat.d.ts +52 -3
- package/viewer.d.ts +12 -1
package/js/call/qr.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// call/qr.js — a small, dependency-free QR Code encoder for the invite link (a tablet scanning a
|
|
2
|
+
// laptop screen, RFC §6). Byte mode only, versions 1-10, error correction L or M: plenty for an
|
|
3
|
+
// invite URL (10-M holds 213 bytes). The algorithm follows ISO/IEC 18004 as laid out in Project
|
|
4
|
+
// Nayuki's reference encoder (MIT); verified against OpenCV's decoder while developing, and pinned
|
|
5
|
+
// by a golden matrix in test/call.test.mjs.
|
|
6
|
+
|
|
7
|
+
// Error-correction codewords per block, and number of blocks, indexed [ecl][version]; ecl L=0, M=1.
|
|
8
|
+
const ECC_PER_BLOCK = [
|
|
9
|
+
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18],
|
|
10
|
+
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26],
|
|
11
|
+
];
|
|
12
|
+
const NUM_BLOCKS = [
|
|
13
|
+
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4],
|
|
14
|
+
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5],
|
|
15
|
+
];
|
|
16
|
+
const FORMAT_BITS = [1, 0]; // L, M
|
|
17
|
+
const MAX_VERSION = 10;
|
|
18
|
+
|
|
19
|
+
function rawDataModules(ver) {
|
|
20
|
+
let r = (16 * ver + 128) * ver + 64;
|
|
21
|
+
if (ver >= 2) {
|
|
22
|
+
const n = Math.floor(ver / 7) + 2;
|
|
23
|
+
r -= (25 * n - 10) * n - 55;
|
|
24
|
+
if (ver >= 7) r -= 36;
|
|
25
|
+
}
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
const dataCodewords = (ver, e) => Math.floor(rawDataModules(ver) / 8) - ECC_PER_BLOCK[e][ver] * NUM_BLOCKS[e][ver];
|
|
29
|
+
|
|
30
|
+
function gfMul(x, y) {
|
|
31
|
+
let z = 0;
|
|
32
|
+
for (let i = 7; i >= 0; i--) {
|
|
33
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
34
|
+
z ^= ((y >>> i) & 1) * x;
|
|
35
|
+
}
|
|
36
|
+
return z;
|
|
37
|
+
}
|
|
38
|
+
function rsDivisor(degree) {
|
|
39
|
+
const r = new Array(degree - 1).fill(0);
|
|
40
|
+
r.push(1);
|
|
41
|
+
let root = 1;
|
|
42
|
+
for (let i = 0; i < degree; i++) {
|
|
43
|
+
for (let j = 0; j < r.length; j++) {
|
|
44
|
+
r[j] = gfMul(r[j], root);
|
|
45
|
+
if (j + 1 < r.length) r[j] ^= r[j + 1];
|
|
46
|
+
}
|
|
47
|
+
root = gfMul(root, 0x02);
|
|
48
|
+
}
|
|
49
|
+
return r;
|
|
50
|
+
}
|
|
51
|
+
function rsRemainder(data, divisor) {
|
|
52
|
+
const r = divisor.map(() => 0);
|
|
53
|
+
for (const b of data) {
|
|
54
|
+
const f = b ^ r.shift();
|
|
55
|
+
r.push(0);
|
|
56
|
+
divisor.forEach((c, i) => (r[i] ^= gfMul(c, f)));
|
|
57
|
+
}
|
|
58
|
+
return r;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Encode `text` (UTF-8) as a QR matrix.
|
|
63
|
+
* @param {string} text
|
|
64
|
+
* @param {{ ecl?: 'L'|'M', mask?: number }} [opts] `mask` forces one of the 8 masks (tests)
|
|
65
|
+
* @returns {{ version: number, size: number, mask: number, modules: boolean[][] }} true = dark
|
|
66
|
+
*/
|
|
67
|
+
export function qrEncode(text, opts = {}) {
|
|
68
|
+
const e = opts.ecl === 'L' ? 0 : 1;
|
|
69
|
+
const bytes = [...new TextEncoder().encode(String(text))];
|
|
70
|
+
let ver = 1;
|
|
71
|
+
for (; ver <= MAX_VERSION; ver++) {
|
|
72
|
+
const ccBits = ver <= 9 ? 8 : 16;
|
|
73
|
+
if (4 + ccBits + bytes.length * 8 <= dataCodewords(ver, e) * 8) break;
|
|
74
|
+
}
|
|
75
|
+
if (ver > MAX_VERSION) throw new Error(`qrEncode: ${bytes.length} bytes is too long for a version-${MAX_VERSION} code`);
|
|
76
|
+
|
|
77
|
+
// Data bits: mode 0100 (byte), count, payload, terminator, byte pad, 0xEC/0x11 fill.
|
|
78
|
+
const bits = [];
|
|
79
|
+
const put = (v, n) => {
|
|
80
|
+
for (let i = n - 1; i >= 0; i--) bits.push((v >>> i) & 1);
|
|
81
|
+
};
|
|
82
|
+
put(0b0100, 4);
|
|
83
|
+
put(bytes.length, ver <= 9 ? 8 : 16);
|
|
84
|
+
for (const b of bytes) put(b, 8);
|
|
85
|
+
const cap = dataCodewords(ver, e) * 8;
|
|
86
|
+
put(0, Math.min(4, cap - bits.length));
|
|
87
|
+
put(0, (8 - (bits.length % 8)) % 8);
|
|
88
|
+
for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8);
|
|
89
|
+
const data = [];
|
|
90
|
+
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
|
|
91
|
+
|
|
92
|
+
// Split into blocks, add Reed-Solomon ECC, interleave.
|
|
93
|
+
const nb = NUM_BLOCKS[e][ver];
|
|
94
|
+
const eccLen = ECC_PER_BLOCK[e][ver];
|
|
95
|
+
const raw = Math.floor(rawDataModules(ver) / 8);
|
|
96
|
+
const nShort = nb - (raw % nb);
|
|
97
|
+
const shortLen = Math.floor(raw / nb);
|
|
98
|
+
const div = rsDivisor(eccLen);
|
|
99
|
+
const blocks = [];
|
|
100
|
+
for (let i = 0, k = 0; i < nb; i++) {
|
|
101
|
+
const dat = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1));
|
|
102
|
+
k += dat.length;
|
|
103
|
+
const ecc = rsRemainder(dat, div);
|
|
104
|
+
if (i < nShort) dat.push(0);
|
|
105
|
+
blocks.push(dat.concat(ecc));
|
|
106
|
+
}
|
|
107
|
+
const codewords = [];
|
|
108
|
+
for (let i = 0; i < blocks[0].length; i++) {
|
|
109
|
+
for (let j = 0; j < blocks.length; j++) if (i !== shortLen - eccLen || j >= nShort) codewords.push(blocks[j][i]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Function patterns.
|
|
113
|
+
const size = ver * 4 + 17;
|
|
114
|
+
const mod = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
115
|
+
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
116
|
+
const set = (x, y, dark) => {
|
|
117
|
+
mod[y][x] = dark;
|
|
118
|
+
fn[y][x] = true;
|
|
119
|
+
};
|
|
120
|
+
for (let i = 0; i < size; i++) {
|
|
121
|
+
set(6, i, i % 2 === 0);
|
|
122
|
+
set(i, 6, i % 2 === 0);
|
|
123
|
+
}
|
|
124
|
+
const finder = (cx, cy) => {
|
|
125
|
+
for (let dy = -4; dy <= 4; dy++)
|
|
126
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
127
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
128
|
+
const x = cx + dx;
|
|
129
|
+
const y = cy + dy;
|
|
130
|
+
if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
finder(3, 3);
|
|
134
|
+
finder(size - 4, 3);
|
|
135
|
+
finder(3, size - 4);
|
|
136
|
+
const align = [];
|
|
137
|
+
if (ver > 1) {
|
|
138
|
+
const n = Math.floor(ver / 7) + 2;
|
|
139
|
+
const step = Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
|
|
140
|
+
align.push(6);
|
|
141
|
+
for (let pos = size - 7; align.length < n; pos -= step) align.splice(1, 0, pos);
|
|
142
|
+
}
|
|
143
|
+
for (let i = 0; i < align.length; i++)
|
|
144
|
+
for (let j = 0; j < align.length; j++) {
|
|
145
|
+
if ((i === 0 && j === 0) || (i === 0 && j === align.length - 1) || (i === align.length - 1 && j === 0)) continue;
|
|
146
|
+
for (let dy = -2; dy <= 2; dy++)
|
|
147
|
+
for (let dx = -2; dx <= 2; dx++) set(align[i] + dx, align[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
148
|
+
}
|
|
149
|
+
const drawFormat = (mask) => {
|
|
150
|
+
const d = (FORMAT_BITS[e] << 3) | mask;
|
|
151
|
+
let rem = d;
|
|
152
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
153
|
+
const b = ((d << 10) | rem) ^ 0x5412;
|
|
154
|
+
const bit = (i) => ((b >>> i) & 1) !== 0;
|
|
155
|
+
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
|
|
156
|
+
set(8, 7, bit(6));
|
|
157
|
+
set(8, 8, bit(7));
|
|
158
|
+
set(7, 8, bit(8));
|
|
159
|
+
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
|
|
160
|
+
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
|
|
161
|
+
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
|
|
162
|
+
set(8, size - 8, true);
|
|
163
|
+
};
|
|
164
|
+
drawFormat(0); // reserve the format areas before placing data
|
|
165
|
+
if (ver >= 7) {
|
|
166
|
+
let rem = ver;
|
|
167
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
|
168
|
+
const b = (ver << 12) | rem;
|
|
169
|
+
for (let i = 0; i < 18; i++) {
|
|
170
|
+
const dark = ((b >>> i) & 1) !== 0;
|
|
171
|
+
const a = size - 11 + (i % 3);
|
|
172
|
+
const c = Math.floor(i / 3);
|
|
173
|
+
set(a, c, dark);
|
|
174
|
+
set(c, a, dark);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Data, zig-zag from the bottom-right, skipping the vertical timing column.
|
|
179
|
+
let i = 0;
|
|
180
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
181
|
+
if (right === 6) right = 5;
|
|
182
|
+
for (let vert = 0; vert < size; vert++)
|
|
183
|
+
for (let j = 0; j < 2; j++) {
|
|
184
|
+
const x = right - j;
|
|
185
|
+
const up = ((right + 1) & 2) === 0;
|
|
186
|
+
const y = up ? size - 1 - vert : vert;
|
|
187
|
+
if (!fn[y][x] && i < codewords.length * 8) {
|
|
188
|
+
mod[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0;
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const applyMask = (m) => {
|
|
195
|
+
for (let y = 0; y < size; y++)
|
|
196
|
+
for (let x = 0; x < size; x++) {
|
|
197
|
+
if (fn[y][x]) continue;
|
|
198
|
+
let inv;
|
|
199
|
+
switch (m) {
|
|
200
|
+
case 0: inv = (x + y) % 2 === 0; break;
|
|
201
|
+
case 1: inv = y % 2 === 0; break;
|
|
202
|
+
case 2: inv = x % 3 === 0; break;
|
|
203
|
+
case 3: inv = (x + y) % 3 === 0; break;
|
|
204
|
+
case 4: inv = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break;
|
|
205
|
+
case 5: inv = ((x * y) % 2) + ((x * y) % 3) === 0; break;
|
|
206
|
+
case 6: inv = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; break;
|
|
207
|
+
default: inv = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
|
|
208
|
+
}
|
|
209
|
+
if (inv) mod[y][x] = !mod[y][x];
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
let mask = Number.isInteger(opts.mask) ? opts.mask : -1;
|
|
214
|
+
if (mask < 0) {
|
|
215
|
+
let best = Infinity;
|
|
216
|
+
for (let m = 0; m < 8; m++) {
|
|
217
|
+
applyMask(m);
|
|
218
|
+
drawFormat(m);
|
|
219
|
+
const p = penalty(mod, size);
|
|
220
|
+
if (p < best) {
|
|
221
|
+
best = p;
|
|
222
|
+
mask = m;
|
|
223
|
+
}
|
|
224
|
+
applyMask(m); // XOR again = undo
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
applyMask(mask);
|
|
228
|
+
drawFormat(mask);
|
|
229
|
+
return { version: ver, size, mask, modules: mod };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The standard mask penalty (N1 runs, N2 2x2 blocks, N3 finder-like runs, N4 balance). */
|
|
233
|
+
function penalty(m, size) {
|
|
234
|
+
let p = 0;
|
|
235
|
+
const lines = [];
|
|
236
|
+
for (let y = 0; y < size; y++) lines.push(m[y]);
|
|
237
|
+
for (let x = 0; x < size; x++) lines.push(m.map((row) => row[x]));
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
let run = 1;
|
|
240
|
+
for (let i = 1; i <= size; i++) {
|
|
241
|
+
if (i < size && line[i] === line[i - 1]) run++;
|
|
242
|
+
else {
|
|
243
|
+
if (run >= 5) p += run - 2;
|
|
244
|
+
run = 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const s = line.map((v) => (v ? '1' : '0')).join('');
|
|
248
|
+
for (const pat of ['10111010000', '00001011101']) {
|
|
249
|
+
for (let at = s.indexOf(pat); at >= 0; at = s.indexOf(pat, at + 1)) p += 40;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
let dark = 0;
|
|
253
|
+
for (let y = 0; y < size; y++)
|
|
254
|
+
for (let x = 0; x < size; x++) {
|
|
255
|
+
if (m[y][x]) dark++;
|
|
256
|
+
if (x < size - 1 && y < size - 1) {
|
|
257
|
+
const c = m[y][x];
|
|
258
|
+
if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) p += 3;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const total = size * size;
|
|
262
|
+
p += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10;
|
|
263
|
+
return p;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Draw a QR code for `text` into a 2D canvas (with the standard 4-module quiet zone). A plain 2D
|
|
268
|
+
* canvas, never registered with the wall — it is chrome, not content.
|
|
269
|
+
* @param {HTMLCanvasElement} canvas
|
|
270
|
+
* @param {string} text
|
|
271
|
+
* @param {{ px?: number, dark?: string, light?: string, ecl?: 'L'|'M' }} [opts]
|
|
272
|
+
*/
|
|
273
|
+
export function drawQr(canvas, text, { px = 4, dark = '#000', light = '#fff', ecl = 'M' } = {}) {
|
|
274
|
+
const q = qrEncode(text, { ecl });
|
|
275
|
+
const n = q.size + 8;
|
|
276
|
+
canvas.width = canvas.height = n * px;
|
|
277
|
+
const g = canvas.getContext('2d');
|
|
278
|
+
g.fillStyle = light;
|
|
279
|
+
g.fillRect(0, 0, n * px, n * px);
|
|
280
|
+
g.fillStyle = dark;
|
|
281
|
+
for (let y = 0; y < q.size; y++) for (let x = 0; x < q.size; x++) if (q.modules[y][x]) g.fillRect((x + 4) * px, (y + 4) * px, px, px);
|
|
282
|
+
return q;
|
|
283
|
+
}
|
package/js/call/sdp.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// call/sdp.js — video codec preference: VP9 > VP8 > AV1, then whatever else the browser offers.
|
|
2
|
+
//
|
|
3
|
+
// The DisplayXR Browser is a proprietary-codec-free build: it has NO H.264. A call must never
|
|
4
|
+
// require H.264, so it is never preferred — it stays in the list (after ours) only so a peer that
|
|
5
|
+
// can do nothing else still negotiates. Safari negotiates VP8 (and VP9 on recent builds).
|
|
6
|
+
//
|
|
7
|
+
// Two mechanisms, same order: `RTCRtpTransceiver.setCodecPreferences` where it exists (every
|
|
8
|
+
// current engine), and SDP munging of the `m=video` line as the fallback. Both are pure here.
|
|
9
|
+
|
|
10
|
+
/** Preferred video codecs, best first (mime subtype, upper case). */
|
|
11
|
+
export const VIDEO_CODEC_ORDER = Object.freeze(['VP9', 'VP8', 'AV1']);
|
|
12
|
+
|
|
13
|
+
/** Rank of a codec name: its index in VIDEO_CODEC_ORDER, or order.length for everything else. */
|
|
14
|
+
export function codecRank(name, order = VIDEO_CODEC_ORDER) {
|
|
15
|
+
const i = order.indexOf(String(name || '').toUpperCase());
|
|
16
|
+
return i < 0 ? order.length : i;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sort `RTCRtpCodecCapability[]` (from `RTCRtpReceiver.getCapabilities('video').codecs`) into
|
|
21
|
+
* our preference order. Stable within a rank, so the browser's own order among VP9 profiles, and
|
|
22
|
+
* RTX/RED/FEC placement, is kept. Pure.
|
|
23
|
+
*/
|
|
24
|
+
export function sortCodecCapabilities(codecs, order = VIDEO_CODEC_ORDER) {
|
|
25
|
+
const rank = (c) => codecRank(String(c.mimeType || '').split('/')[1], order);
|
|
26
|
+
return codecs
|
|
27
|
+
.map((c, i) => ({ c, i, r: rank(c) }))
|
|
28
|
+
.sort((a, b) => a.r - b.r || a.i - b.i)
|
|
29
|
+
.map((x) => x.c);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reorder the payload types on the `m=video` line so VP9 > VP8 > AV1 come first, each followed
|
|
34
|
+
* by its RTX (`a=fmtp:<rtx> apt=<pt>`). Everything else keeps its relative order behind them.
|
|
35
|
+
* Returns the SDP unchanged if there is no video section. Pure.
|
|
36
|
+
* @param {string} sdp
|
|
37
|
+
*/
|
|
38
|
+
export function preferVideoCodecs(sdp, order = VIDEO_CODEC_ORDER) {
|
|
39
|
+
if (!sdp) return sdp;
|
|
40
|
+
const eol = sdp.includes('\r\n') ? '\r\n' : '\n';
|
|
41
|
+
const lines = sdp.split(eol);
|
|
42
|
+
const mi = lines.findIndex((l) => l.startsWith('m=video'));
|
|
43
|
+
if (mi < 0) return sdp;
|
|
44
|
+
let end = lines.findIndex((l, i) => i > mi && l.startsWith('m='));
|
|
45
|
+
if (end < 0) end = lines.length;
|
|
46
|
+
const section = lines.slice(mi, end);
|
|
47
|
+
const nameOf = new Map(); // pt -> codec name
|
|
48
|
+
const rtxFor = new Map(); // apt -> [rtx pts]
|
|
49
|
+
for (const l of section) {
|
|
50
|
+
const m = l.match(/^a=rtpmap:(\d+) ([\w-]+)\//);
|
|
51
|
+
if (m) nameOf.set(m[1], m[2].toUpperCase());
|
|
52
|
+
const f = l.match(/^a=fmtp:(\d+) apt=(\d+)/);
|
|
53
|
+
if (f) {
|
|
54
|
+
if (!rtxFor.has(f[2])) rtxFor.set(f[2], []);
|
|
55
|
+
rtxFor.get(f[2]).push(f[1]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parts = lines[mi].split(' ');
|
|
59
|
+
const pts = parts.slice(3);
|
|
60
|
+
const picked = [];
|
|
61
|
+
for (const want of order) {
|
|
62
|
+
for (const pt of pts) {
|
|
63
|
+
if (nameOf.get(pt) === want && !picked.includes(pt)) {
|
|
64
|
+
picked.push(pt);
|
|
65
|
+
for (const rtx of rtxFor.get(pt) || []) if (pts.includes(rtx) && !picked.includes(rtx)) picked.push(rtx);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!picked.length) return sdp;
|
|
70
|
+
const rest = pts.filter((p) => !picked.includes(p));
|
|
71
|
+
lines[mi] = [...parts.slice(0, 3), ...picked, ...rest].join(' ');
|
|
72
|
+
return lines.join(eol);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The codec names on the `m=video` line, in order (for tests and diagnostics). */
|
|
76
|
+
export function videoCodecOrder(sdp) {
|
|
77
|
+
const lines = String(sdp || '').split(/\r?\n/);
|
|
78
|
+
const mi = lines.findIndex((l) => l.startsWith('m=video'));
|
|
79
|
+
if (mi < 0) return [];
|
|
80
|
+
const names = new Map();
|
|
81
|
+
for (const l of lines) {
|
|
82
|
+
const m = l.match(/^a=rtpmap:(\d+) ([\w-]+)\//);
|
|
83
|
+
if (m) names.set(m[1], m[2].toUpperCase());
|
|
84
|
+
}
|
|
85
|
+
return lines[mi].split(' ').slice(3).map((pt) => names.get(pt) || pt);
|
|
86
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// call/signaling.js — the SignalingAdapter seam, and the adapter the SDK ships.
|
|
2
|
+
//
|
|
3
|
+
// A SignalingAdapter is ANY object with `join(room, hooks) → Promise<SignalingSession>`:
|
|
4
|
+
//
|
|
5
|
+
// join(room, {
|
|
6
|
+
// id, // this peer's id (the transport picks it; stable across reconnects)
|
|
7
|
+
// maxPeers, // the room size this peer asks for
|
|
8
|
+
// onPeerJoined(id), onPeerLeft(id), onSignal(fromId, data),
|
|
9
|
+
// onDisconnect(err?), onReconnect(peerIds),
|
|
10
|
+
// }) → Promise<{ id, peers: string[], iceServers?: RTCIceServer[], send(to, data), leave() }>
|
|
11
|
+
//
|
|
12
|
+
// It rejects with an Error whose `code` is 'room-full' when the room is at capacity. That is the
|
|
13
|
+
// whole contract: presence, a relay for opaque offer/answer/ICE blobs, and leaving. Bring your
|
|
14
|
+
// own (your accounts, your push service) by implementing it — see call.d.ts.
|
|
15
|
+
//
|
|
16
|
+
// dxrSignaling(url) the `dxr-signal/1` JSON-over-WebSocket protocol (signaling/README.md), served
|
|
17
|
+
// by the reference Cloudflare Worker or `node signaling/dev-server.mjs`.
|
|
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
|
+
|
|
21
|
+
import { backoffMs } from './wire.js';
|
|
22
|
+
|
|
23
|
+
export const SIGNAL_PROTOCOL = 'dxr-signal/1';
|
|
24
|
+
|
|
25
|
+
function codedError(code, message) {
|
|
26
|
+
const e = new Error(`@displayxr/inline3d/call: ${message}`);
|
|
27
|
+
/** @type {any} */ (e).code = code;
|
|
28
|
+
return e;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** SHA-256 hex of the room id: the only room-derived value that goes into a URL. */
|
|
32
|
+
export async function roomKey(room) {
|
|
33
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(room)));
|
|
34
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `dxr-signal/1` client. `url` is the server's base (`wss://signal.example.com` or
|
|
39
|
+
* `ws://localhost:8787`); the adapter connects to `<url>/v1/connect?k=<sha256(room)>`.
|
|
40
|
+
*
|
|
41
|
+
* A dropped socket is reconnected with backoff and the SAME peer id, so media that is flowing
|
|
42
|
+
* peer-to-peer is not torn down by a signalling blip.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} url
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* The hosted DisplayXR `dxr-signal/1` server (a Cloudflare Worker running `signaling/worker.mjs`,
|
|
51
|
+
* config in `signaling/deploy/displayxr.toml`). It also mints short-lived TURN credentials per
|
|
52
|
+
* join. `dxrSignaling()` with no URL uses it; pass your own URL to self-host.
|
|
53
|
+
*/
|
|
54
|
+
export const DXR_SIGNAL_DEFAULT = 'wss://dxr-signal.displayxr.workers.dev';
|
|
55
|
+
|
|
56
|
+
export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
|
|
57
|
+
if (!url || typeof url !== 'string') {
|
|
58
|
+
throw codedError(
|
|
59
|
+
'no-signaling-url',
|
|
60
|
+
'dxrSignaling(url): url must be a ws:// or wss:// string (omit it for the hosted DisplayXR server)'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const base = url.replace(/\/+$/, '');
|
|
64
|
+
const WS = opts.WebSocket || globalThis.WebSocket;
|
|
65
|
+
const pingMs = opts.pingMs || 20000;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: 'dxr',
|
|
69
|
+
url: base,
|
|
70
|
+
async join(room, hooks) {
|
|
71
|
+
const key = await roomKey(room);
|
|
72
|
+
const endpoint = `${base}/v1/connect?k=${key}${typeof opts.key === 'string' && opts.key ? `&key=${encodeURIComponent(opts.key)}` : ''}`;
|
|
73
|
+
let ws = null;
|
|
74
|
+
let left = false;
|
|
75
|
+
let attempt = 0;
|
|
76
|
+
let pingTimer = null;
|
|
77
|
+
let known = new Set();
|
|
78
|
+
let queue = [];
|
|
79
|
+
|
|
80
|
+
const open = (isReconnect) =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
let joined = false;
|
|
84
|
+
const sock = new WS(endpoint);
|
|
85
|
+
ws = sock;
|
|
86
|
+
const fail = (err) => {
|
|
87
|
+
if (!settled) {
|
|
88
|
+
settled = true;
|
|
89
|
+
try {
|
|
90
|
+
sock.close(); // a failed join never leaves a socket behind
|
|
91
|
+
} catch {
|
|
92
|
+
/* already closing */
|
|
93
|
+
}
|
|
94
|
+
reject(err);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
sock.onopen = () => {
|
|
98
|
+
sock.send(JSON.stringify({ t: 'join', v: 1, room, id: hooks.id, max: hooks.maxPeers }));
|
|
99
|
+
};
|
|
100
|
+
sock.onmessage = (ev) => {
|
|
101
|
+
let msg;
|
|
102
|
+
try {
|
|
103
|
+
msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
switch (msg.t) {
|
|
108
|
+
case 'welcome': {
|
|
109
|
+
attempt = 0;
|
|
110
|
+
const peers = Array.isArray(msg.peers) ? msg.peers.filter((p) => typeof p === 'string') : [];
|
|
111
|
+
clearInterval(pingTimer);
|
|
112
|
+
pingTimer = setInterval(() => sendRaw({ t: 'ping' }), pingMs);
|
|
113
|
+
const flush = queue;
|
|
114
|
+
queue = [];
|
|
115
|
+
for (const m of flush) sendRaw(m);
|
|
116
|
+
if (isReconnect) {
|
|
117
|
+
const now = new Set(peers);
|
|
118
|
+
for (const p of known) if (!now.has(p)) hooks.onPeerLeft?.(p);
|
|
119
|
+
known = now;
|
|
120
|
+
hooks.onReconnect?.(peers);
|
|
121
|
+
} else known = new Set(peers);
|
|
122
|
+
settled = true;
|
|
123
|
+
joined = true;
|
|
124
|
+
resolve({ id: msg.id, peers, iceServers: Array.isArray(msg.iceServers) ? msg.iceServers : undefined, max: msg.max });
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'full':
|
|
128
|
+
left = true;
|
|
129
|
+
fail(codedError('room-full', `this call is full (${msg.max} participants)`));
|
|
130
|
+
break;
|
|
131
|
+
case 'error':
|
|
132
|
+
if (!settled && ['bad-room', 'bad-id', 'bad-version'].includes(msg.code)) {
|
|
133
|
+
left = true;
|
|
134
|
+
fail(codedError(msg.code, msg.message || msg.code));
|
|
135
|
+
} else if (!settled && msg.code === 'id-taken') {
|
|
136
|
+
// A reconnect raced the server noticing the old socket close: retry shortly.
|
|
137
|
+
fail(codedError('id-taken', 'peer id still held; retrying'));
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
case 'peer-joined':
|
|
141
|
+
if (typeof msg.id === 'string' && !known.has(msg.id)) {
|
|
142
|
+
known.add(msg.id);
|
|
143
|
+
hooks.onPeerJoined?.(msg.id);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case 'peer-left':
|
|
147
|
+
if (typeof msg.id === 'string') {
|
|
148
|
+
known.delete(msg.id);
|
|
149
|
+
hooks.onPeerLeft?.(msg.id);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case 'signal':
|
|
153
|
+
if (typeof msg.from === 'string') hooks.onSignal?.(msg.from, msg.data);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
sock.onerror = () => fail(codedError('signaling-unreachable', `cannot reach ${base}`));
|
|
158
|
+
sock.onclose = () => {
|
|
159
|
+
clearInterval(pingTimer);
|
|
160
|
+
fail(codedError('signaling-closed', 'signalling closed before joining'));
|
|
161
|
+
if (joined && !left && ws === sock) reconnect();
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const sendRaw = (m) => {
|
|
166
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify(m));
|
|
167
|
+
else if (!left) queue.push(m);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const reconnect = () => {
|
|
171
|
+
hooks.onDisconnect?.();
|
|
172
|
+
const tryAgain = () => {
|
|
173
|
+
if (left) return;
|
|
174
|
+
open(true).catch((err) => {
|
|
175
|
+
if (left || (err && err.code === 'room-full')) {
|
|
176
|
+
hooks.onDisconnect?.(err);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 500, maxMs: 15000 }));
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 300, maxMs: 15000 }));
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const first = await open(false);
|
|
186
|
+
return {
|
|
187
|
+
...first,
|
|
188
|
+
send: (to, data) => sendRaw({ t: 'signal', to, data }),
|
|
189
|
+
leave: () => {
|
|
190
|
+
if (left) return;
|
|
191
|
+
left = true;
|
|
192
|
+
clearInterval(pingTimer);
|
|
193
|
+
try {
|
|
194
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ t: 'leave' }));
|
|
195
|
+
ws && ws.close(1000, 'leave');
|
|
196
|
+
} catch {
|
|
197
|
+
/* ignore */
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|