@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.
@@ -0,0 +1,398 @@
1
+ // camera/metadata.js — stereo metadata that travels INSIDE the captured file (RFC 0003 Decision 9):
2
+ // an XMP packet in a JPEG (APP1 segment), a `Tags` element in a WebM. No sidecar.
3
+ //
4
+ // What is recorded is what a viewer needs to show the pair correctly and to re-converge it: the
5
+ // layout (side-by-side, 2×1, left eye left), the disparity of the subject the capture converged
6
+ // on, and the camera's baseline / field of view / rectified flag when known. Photos store the RAW
7
+ // rectified pair and report the convergence alongside (baking the shift crops the edges — RFC
8
+ // 0003 §4), so `convergencePx` is advisory: "shift the eyes toward each other by half of this to
9
+ // put the face at the display plane". Keys are `dxr:` in XMP and `DXR_*` in WebM.
10
+ //
11
+ // Pure byte work on Uint8Arrays — no DOM, no codecs — so every writer has a reader and both are
12
+ // round-tripped under `node --test` (test/camera.test.mjs). Robustness over completeness: a
13
+ // JPEG without an XMP segment reads as `null`, a WebM with a known-size Segment gets its size
14
+ // patched in place, and a reader that cannot walk a file falls back to the one place the writer
15
+ // ever puts the tags (the end of the file).
16
+
17
+ export const XMP_NS = 'http://displayxr.org/ns/stereo/1.0/';
18
+ const XMP_HEADER = 'http://ns.adobe.com/xap/1.0/\0';
19
+
20
+ /** The stereo metadata record both containers carry. `layout` is `'sbs'` (2×1, left eye left) or `'mono'`. */
21
+ export function normalizeStereoMeta(m = {}) {
22
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
23
+ const layout = m.layout === 'sbs' || m.layout === 'side-by-side' ? 'sbs' : 'mono';
24
+ return {
25
+ layout,
26
+ columns: layout === 'sbs' ? 2 : 1,
27
+ rows: 1,
28
+ convergencePx: num(m.convergencePx),
29
+ baselineMm: num(m.baselineMm),
30
+ horizontalFovDeg: num(m.horizontalFovDeg),
31
+ rectified: m.rectified === true ? true : m.rectified === false ? false : null,
32
+ eyeWidth: num(m.eyeWidth),
33
+ eyeHeight: num(m.eyeHeight),
34
+ software: typeof m.software === 'string' && m.software ? m.software : null,
35
+ };
36
+ }
37
+
38
+ // ── XMP ────────────────────────────────────────────────────────────────────────────────────
39
+
40
+ const xmlAttr = (v) => String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
41
+ const r3 = (v) => Math.round(v * 1000) / 1000;
42
+
43
+ /** The XMP packet (a string) for a stereo record. Attribute form, one `rdf:Description`. */
44
+ export function buildStereoXmp(meta) {
45
+ const m = normalizeStereoMeta(meta);
46
+ const attrs = [
47
+ ['Layout', m.layout === 'sbs' ? 'side-by-side' : 'mono'],
48
+ ['Columns', m.columns],
49
+ ['Rows', m.rows],
50
+ ['ConvergencePx', m.convergencePx === null ? null : r3(m.convergencePx)],
51
+ ['BaselineMm', m.baselineMm === null ? null : r3(m.baselineMm)],
52
+ ['HorizontalFovDeg', m.horizontalFovDeg === null ? null : r3(m.horizontalFovDeg)],
53
+ ['Rectified', m.rectified === null ? null : m.rectified ? 'True' : 'False'],
54
+ ['EyeWidth', m.eyeWidth],
55
+ ['EyeHeight', m.eyeHeight],
56
+ ['Software', m.software],
57
+ ]
58
+ .filter(([, v]) => v !== null && v !== undefined)
59
+ .map(([k, v]) => ` dxr:${k}="${xmlAttr(v)}"`)
60
+ .join('');
61
+ return (
62
+ '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
63
+ '<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
64
+ `<rdf:Description rdf:about="" xmlns:dxr="${XMP_NS}"${attrs}/>` +
65
+ '</rdf:RDF></x:xmpmeta><?xpacket end="w"?>'
66
+ );
67
+ }
68
+
69
+ /** The stereo record in an XMP packet, or null when it carries none (`dxr:` attributes or elements). */
70
+ export function parseStereoXmp(xml) {
71
+ if (typeof xml !== 'string' || !xml.includes(XMP_NS)) return null;
72
+ const get = (k) => {
73
+ const a = new RegExp(`\\bdxr:${k}="([^"]*)"`).exec(xml);
74
+ if (a) return a[1];
75
+ const e = new RegExp(`<dxr:${k}>([^<]*)</dxr:${k}>`).exec(xml);
76
+ return e ? e[1] : null;
77
+ };
78
+ const num = (k) => {
79
+ const v = get(k);
80
+ return v === null || v === '' || !Number.isFinite(+v) ? null : +v;
81
+ };
82
+ const rect = get('Rectified');
83
+ const layout = get('Layout');
84
+ const sw = get('Software');
85
+ return normalizeStereoMeta({
86
+ layout: layout === 'side-by-side' || layout === 'sbs' ? 'sbs' : 'mono',
87
+ convergencePx: num('ConvergencePx'),
88
+ baselineMm: num('BaselineMm'),
89
+ horizontalFovDeg: num('HorizontalFovDeg'),
90
+ rectified: rect === null ? null : /^true$/i.test(rect),
91
+ eyeWidth: num('EyeWidth'),
92
+ eyeHeight: num('EyeHeight'),
93
+ software: sw === null ? null : sw.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&amp;/g, '&'),
94
+ });
95
+ }
96
+
97
+ const enc = (s) => new TextEncoder().encode(s);
98
+ const dec = (b) => new TextDecoder().decode(b);
99
+
100
+ /** Is this a JPEG (starts with SOI)? */
101
+ export const isJpeg = (bytes) => bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8;
102
+
103
+ /**
104
+ * Walk a JPEG's marker segments up to SOS. Yields `{ marker, start, end, data }` where `start` is
105
+ * the marker's 0xFF byte and `end` the first byte after the segment.
106
+ */
107
+ function* jpegSegments(bytes) {
108
+ let i = 2;
109
+ while (i + 4 <= bytes.length) {
110
+ if (bytes[i] !== 0xff) return;
111
+ const marker = bytes[i + 1];
112
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) {
113
+ i += marker === 0xff ? 1 : 2; // standalone markers / fill bytes
114
+ continue;
115
+ }
116
+ if (marker === 0xda || marker === 0xd9) return; // SOS / EOI: entropy-coded data follows
117
+ const len = (bytes[i + 2] << 8) | bytes[i + 3];
118
+ if (len < 2 || i + 2 + len > bytes.length) return;
119
+ yield { marker, start: i, end: i + 2 + len, data: bytes.subarray(i + 4, i + 2 + len) };
120
+ i += 2 + len;
121
+ }
122
+ }
123
+
124
+ function startsWith(data, str) {
125
+ if (data.length < str.length) return false;
126
+ for (let i = 0; i < str.length; i++) if (data[i] !== str.charCodeAt(i)) return false;
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * A copy of `bytes` with `xmp` as an APP1 XMP segment — replacing an existing XMP segment, else
132
+ * inserted after the SOI and any APP0/APP1 (JFIF/EXIF) segments, where readers expect it.
133
+ * Throws on a non-JPEG or a packet that does not fit one segment (65 KB).
134
+ * @param {Uint8Array} bytes
135
+ * @param {string} xmp
136
+ */
137
+ export function jpegWithXmp(bytes, xmp) {
138
+ if (!isJpeg(bytes)) throw new TypeError('jpegWithXmp: not a JPEG');
139
+ const payload = new Uint8Array([...enc(XMP_HEADER), ...enc(xmp)]);
140
+ const len = payload.length + 2;
141
+ if (len > 0xffff) throw new RangeError('jpegWithXmp: XMP packet too large for one APP1 segment');
142
+ const seg = new Uint8Array(4 + payload.length);
143
+ seg[0] = 0xff;
144
+ seg[1] = 0xe1;
145
+ seg[2] = len >> 8;
146
+ seg[3] = len & 0xff;
147
+ seg.set(payload, 4);
148
+ let at = 2;
149
+ let replace = null;
150
+ for (const s of jpegSegments(bytes)) {
151
+ if (s.marker === 0xe1 && startsWith(s.data, XMP_HEADER)) {
152
+ replace = s;
153
+ break;
154
+ }
155
+ if (s.marker === 0xe0 || s.marker === 0xe1) at = s.end;
156
+ else break;
157
+ }
158
+ const [cutA, cutB] = replace ? [replace.start, replace.end] : [at, at];
159
+ const out = new Uint8Array(bytes.length - (cutB - cutA) + seg.length);
160
+ out.set(bytes.subarray(0, cutA), 0);
161
+ out.set(seg, cutA);
162
+ out.set(bytes.subarray(cutB), cutA + seg.length);
163
+ return out;
164
+ }
165
+
166
+ /** The XMP packet string of a JPEG, or null. */
167
+ export function readJpegXmp(bytes) {
168
+ if (!isJpeg(bytes)) return null;
169
+ for (const s of jpegSegments(bytes)) if (s.marker === 0xe1 && startsWith(s.data, XMP_HEADER)) return dec(s.data.subarray(XMP_HEADER.length));
170
+ return null;
171
+ }
172
+
173
+ /** The stereo record of a JPEG (`parseStereoXmp(readJpegXmp(bytes))`), or null. */
174
+ export const readJpegStereoMeta = (bytes) => parseStereoXmp(readJpegXmp(bytes));
175
+
176
+ // ── WebM (Matroska / EBML) ─────────────────────────────────────────────────────────────────
177
+
178
+ const ID = { EBML: 0x1a45dfa3, Segment: 0x18538067, Tags: 0x1254c367, Tag: 0x7373, Targets: 0x63c0, SimpleTag: 0x67c8, TagName: 0x45a3, TagString: 0x4487 };
179
+
180
+ /** Is this an EBML/WebM file? */
181
+ export const isWebm = (bytes) => bytes.length > 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3;
182
+
183
+ function idBytes(id) {
184
+ const out = [];
185
+ for (let v = id; v > 0; v = Math.floor(v / 256)) out.unshift(v % 256);
186
+ return out;
187
+ }
188
+
189
+ /** An EBML size vint of `width` bytes (1..8) for `n`; `width` 0 = the shortest that fits. */
190
+ function vint(n, width = 0) {
191
+ let w = width;
192
+ if (!w) for (w = 1; w < 8 && n > 2 ** (7 * w) - 2; w++);
193
+ const out = new Array(w).fill(0);
194
+ let v = n;
195
+ for (let i = w - 1; i >= 1; i--) {
196
+ out[i] = v % 256;
197
+ v = Math.floor(v / 256);
198
+ }
199
+ out[0] = (0x80 >> (w - 1)) | v;
200
+ return out;
201
+ }
202
+
203
+ /** Read an element ID at `i` (1..4 bytes, by leading zeros). */
204
+ function readId(bytes, i) {
205
+ const b = bytes[i];
206
+ const w = b >= 0x80 ? 1 : b >= 0x40 ? 2 : b >= 0x20 ? 3 : b >= 0x10 ? 4 : 0;
207
+ if (!w || i + w > bytes.length) return null;
208
+ let id = 0;
209
+ for (let k = 0; k < w; k++) id = id * 256 + bytes[i + k];
210
+ return { id, width: w };
211
+ }
212
+
213
+ /** Read a size vint at `i`. `unknown` = the all-ones "unknown size" (live/streamed files). */
214
+ function readSize(bytes, i) {
215
+ const b = bytes[i];
216
+ let w = 1;
217
+ while (w <= 8 && !(b & (0x80 >> (w - 1)))) w++;
218
+ if (w > 8 || i + w > bytes.length) return null;
219
+ let v = b & (0xff >> w);
220
+ let allOnes = v === 0x7f >> (w - 1);
221
+ for (let k = 1; k < w; k++) {
222
+ v = v * 256 + bytes[i + k];
223
+ if (bytes[i + k] !== 0xff) allOnes = false;
224
+ }
225
+ return { size: v, width: w, unknown: allOnes };
226
+ }
227
+
228
+ function element(id, payload) {
229
+ return [...idBytes(id), ...vint(payload.length), ...payload];
230
+ }
231
+
232
+ /** The bytes of a `Tags` element holding one whole-file `Tag` with a `SimpleTag` per entry. */
233
+ export function buildWebmTags(entries) {
234
+ const simple = [];
235
+ for (const [name, value] of Object.entries(entries)) {
236
+ if (value === null || value === undefined) continue;
237
+ simple.push(...element(ID.SimpleTag, [...element(ID.TagName, [...enc(String(name))]), ...element(ID.TagString, [...enc(String(value))])]));
238
+ }
239
+ const tag = element(ID.Tag, [...element(ID.Targets, []), ...simple]);
240
+ return new Uint8Array(element(ID.Tags, tag));
241
+ }
242
+
243
+ /** The `DXR_*` tag entries for a stereo record (the WebM twin of {@link buildStereoXmp}). */
244
+ export function stereoTagEntries(meta) {
245
+ const m = normalizeStereoMeta(meta);
246
+ return {
247
+ DXR_LAYOUT: m.layout === 'sbs' ? 'side-by-side' : 'mono',
248
+ DXR_COLUMNS: String(m.columns),
249
+ DXR_ROWS: String(m.rows),
250
+ DXR_CONVERGENCE_PX: m.convergencePx === null ? null : String(r3(m.convergencePx)),
251
+ DXR_BASELINE_MM: m.baselineMm === null ? null : String(r3(m.baselineMm)),
252
+ DXR_HFOV_DEG: m.horizontalFovDeg === null ? null : String(r3(m.horizontalFovDeg)),
253
+ DXR_RECTIFIED: m.rectified === null ? null : m.rectified ? 'true' : 'false',
254
+ DXR_EYE_WIDTH: m.eyeWidth === null ? null : String(m.eyeWidth),
255
+ DXR_EYE_HEIGHT: m.eyeHeight === null ? null : String(m.eyeHeight),
256
+ DXR_SOFTWARE: m.software,
257
+ };
258
+ }
259
+
260
+ /** The stereo record in a `DXR_*` tag map, or null when it carries none. */
261
+ export function parseStereoTags(tags) {
262
+ if (!tags || !('DXR_LAYOUT' in tags)) return null;
263
+ const num = (k) => (tags[k] === undefined || tags[k] === '' || !Number.isFinite(+tags[k]) ? null : +tags[k]);
264
+ return normalizeStereoMeta({
265
+ layout: tags.DXR_LAYOUT === 'side-by-side' ? 'sbs' : 'mono',
266
+ convergencePx: num('DXR_CONVERGENCE_PX'),
267
+ baselineMm: num('DXR_BASELINE_MM'),
268
+ horizontalFovDeg: num('DXR_HFOV_DEG'),
269
+ rectified: tags.DXR_RECTIFIED === undefined ? null : tags.DXR_RECTIFIED === 'true',
270
+ eyeWidth: num('DXR_EYE_WIDTH'),
271
+ eyeHeight: num('DXR_EYE_HEIGHT'),
272
+ software: tags.DXR_SOFTWARE ?? null,
273
+ });
274
+ }
275
+
276
+ /** Locate the Segment: `{ sizeAt, sizeWidth, unknown, dataStart, size }`, or null. */
277
+ function findSegment(bytes) {
278
+ if (!isWebm(bytes)) return null;
279
+ const hs = readSize(bytes, 4);
280
+ if (!hs) return null;
281
+ let i = 4 + hs.width + hs.size;
282
+ while (i < bytes.length) {
283
+ const id = readId(bytes, i);
284
+ if (!id) return null;
285
+ const sz = readSize(bytes, i + id.width);
286
+ if (!sz) return null;
287
+ const dataStart = i + id.width + sz.width;
288
+ if (id.id === ID.Segment) return { sizeAt: i + id.width, sizeWidth: sz.width, unknown: sz.unknown, dataStart, size: sz.size };
289
+ if (sz.unknown) return null;
290
+ i = dataStart + sz.size; // a Void or other top-level element before the Segment
291
+ }
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * A copy of `bytes` with a `Tags` element appended to the Segment. A Segment of unknown size
297
+ * (what a `MediaRecorder` writes) just grows; a known size is patched in place when its vint
298
+ * has room, else the vint is re-written at 8 bytes (the only shift this function ever makes,
299
+ * and only ahead of the first child). Throws on a non-WebM file.
300
+ * @param {Uint8Array} bytes
301
+ * @param {Record<string, string | null | undefined>} entries
302
+ */
303
+ export function webmWithTags(bytes, entries) {
304
+ const seg = findSegment(bytes);
305
+ if (!seg) throw new TypeError('webmWithTags: not a WebM/Matroska file (no Segment)');
306
+ const tags = buildWebmTags(entries);
307
+ if (seg.unknown) {
308
+ const out = new Uint8Array(bytes.length + tags.length);
309
+ out.set(bytes, 0);
310
+ out.set(tags, bytes.length);
311
+ return out;
312
+ }
313
+ const end = Math.min(bytes.length, seg.dataStart + seg.size);
314
+ const newSize = end - seg.dataStart + tags.length;
315
+ const fits = newSize <= 2 ** (7 * seg.sizeWidth) - 2;
316
+ const sizeBytes = vint(newSize, fits ? seg.sizeWidth : 8);
317
+ const out = new Uint8Array(seg.sizeAt + sizeBytes.length + (end - seg.dataStart) + tags.length + (bytes.length - end));
318
+ out.set(bytes.subarray(0, seg.sizeAt), 0);
319
+ out.set(sizeBytes, seg.sizeAt);
320
+ let o = seg.sizeAt + sizeBytes.length;
321
+ out.set(bytes.subarray(seg.dataStart, end), o);
322
+ o += end - seg.dataStart;
323
+ out.set(tags, o);
324
+ o += tags.length;
325
+ out.set(bytes.subarray(end), o);
326
+ return out;
327
+ }
328
+
329
+ function parseTagsElement(bytes, start, end) {
330
+ const out = {};
331
+ const walk = (a, b, fn) => {
332
+ let i = a;
333
+ while (i < b) {
334
+ const id = readId(bytes, i);
335
+ if (!id) return;
336
+ const sz = readSize(bytes, i + id.width);
337
+ if (!sz || sz.unknown) return;
338
+ const ds = i + id.width + sz.width;
339
+ fn(id.id, ds, Math.min(b, ds + sz.size));
340
+ i = ds + sz.size;
341
+ }
342
+ };
343
+ walk(start, end, (id, a, b) => {
344
+ if (id !== ID.Tag) return;
345
+ walk(a, b, (id2, c, d) => {
346
+ if (id2 !== ID.SimpleTag) return;
347
+ let name = null;
348
+ let value = null;
349
+ walk(c, d, (id3, e, f) => {
350
+ if (id3 === ID.TagName) name = dec(bytes.subarray(e, f));
351
+ else if (id3 === ID.TagString) value = dec(bytes.subarray(e, f));
352
+ });
353
+ if (name !== null) out[name] = value ?? '';
354
+ });
355
+ });
356
+ return out;
357
+ }
358
+
359
+ /**
360
+ * Every `SimpleTag` name → string in the file's `Tags` elements (later ones win), or null when
361
+ * there are none. Walks the Segment's children; where a child of unknown size blocks the walk
362
+ * (a live-written Cluster), falls back to the trailing `Tags` element the writer appends.
363
+ */
364
+ export function readWebmTags(bytes) {
365
+ const seg = findSegment(bytes);
366
+ if (!seg) return null;
367
+ const end = seg.unknown ? bytes.length : Math.min(bytes.length, seg.dataStart + seg.size);
368
+ let found = null;
369
+ let i = seg.dataStart;
370
+ while (i < end) {
371
+ const id = readId(bytes, i);
372
+ if (!id) break;
373
+ const sz = readSize(bytes, i + id.width);
374
+ if (!sz) break;
375
+ const ds = i + id.width + sz.width;
376
+ if (id.id === ID.Tags && !sz.unknown) found = { ...(found || {}), ...parseTagsElement(bytes, ds, Math.min(end, ds + sz.size)) };
377
+ if (sz.unknown) {
378
+ found = found || trailingTags(bytes, end);
379
+ break;
380
+ }
381
+ i = ds + sz.size;
382
+ }
383
+ return found;
384
+ }
385
+
386
+ /** The `Tags` element that ends exactly at `end` (where {@link webmWithTags} puts it), if any. */
387
+ function trailingTags(bytes, end) {
388
+ const sig = idBytes(ID.Tags);
389
+ for (let i = end - sig.length - 1; i >= 0; i--) {
390
+ if (bytes[i] !== sig[0] || bytes[i + 1] !== sig[1] || bytes[i + 2] !== sig[2] || bytes[i + 3] !== sig[3]) continue;
391
+ const sz = readSize(bytes, i + 4);
392
+ if (sz && !sz.unknown && i + 4 + sz.width + sz.size === end) return parseTagsElement(bytes, i + 4 + sz.width, end);
393
+ }
394
+ return null;
395
+ }
396
+
397
+ /** The stereo record of a WebM (`parseStereoTags(readWebmTags(bytes))`), or null. */
398
+ export const readWebmStereoMeta = (bytes) => parseStereoTags(readWebmTags(bytes));