@displayxr/inline3d 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/call/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
+ }