@kammcs/buildit-mcp 0.8.0 → 0.10.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,1855 @@
1
+ /**
2
+ * Location metadata stripping for photos and videos: a port of the
3
+ * buildIt.Social app's lossless location stripping, so that a file sent
4
+ * from here loses exactly the bytes the app would remove. Pure byte work,
5
+ * with no image or video codec.
6
+ *
7
+ * Every edit is lossless: the image or video data is never decoded or
8
+ * re-encoded, and except for PNG (whose chunks carry no offsets) the file
9
+ * keeps its length, so every offset in it stays valid. GPS and other
10
+ * location fields go; the rest of the metadata (capture date, camera, lens,
11
+ * orientation, colour profile) stays.
12
+ *
13
+ * | Format | What goes |
14
+ * | --- | --- |
15
+ * | JPEG | Exif GPS IFD, XMP and extended XMP location, IPTC location (APP13), the same in MPF images (gain map, second view) and the Exif thumbnail |
16
+ * | HEIC, HEIF, AVIF | the Exif item's GPS IFD, the XMP item's location |
17
+ * | PNG | `eXIf` GPS IFD, XMP (`iTXt`) location; compressed XMP and `Raw profile` chunks are dropped |
18
+ * | WebP | `EXIF` GPS IFD, `XMP ` location |
19
+ * | TIFF | GPS IFD, XMP and IPTC location |
20
+ * | MOV, MP4, M4V, 3GP (and M4A) | `©xyz` and `loci` atoms, location items in `meta` (`keys`/`ilst`), XMP location, in `moov`, `trak`, `udta` and `meta` |
21
+ *
22
+ * Formats are told apart by their bytes, not their names. Anything else
23
+ * (GIF, BMP, WebM, documents) comes back unchanged.
24
+ *
25
+ * A video kept as a file is handled without reading it whole:
26
+ * `videoLocationPatches` walks its top-level boxes by their headers and
27
+ * returns small patches at fixed offsets, and `copyWithPatches` streams a
28
+ * copy with them applied.
29
+ */
30
+ import { Buffer } from 'node:buffer';
31
+ import { createReadStream } from 'node:fs';
32
+ import { open, rm } from 'node:fs/promises';
33
+ /**
34
+ * The location can't be looked for: a HEIF's `meta` box, a TIFF file's
35
+ * first IFD, or a video's top-level, `moov` or `trak` boxes can't be read.
36
+ * Removing it blindly would lose the file, so the file should be refused.
37
+ */
38
+ export class LocationUnreadableError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = 'LocationUnreadableError';
42
+ }
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Byte helpers
46
+ /** The byte at [i]; a RangeError past the end (never expected: callers check first). */
47
+ function byte(b, i) {
48
+ const v = b[i];
49
+ if (v === undefined)
50
+ throw new RangeError(`index ${i} out of range`);
51
+ return v;
52
+ }
53
+ /** A big- or little-endian view of [b] from [from] to [to]. */
54
+ function view(b, from = 0, to = b.length) {
55
+ return new DataView(b.buffer, b.byteOffset + from, to - from);
56
+ }
57
+ /** The bytes from [from] to [to] as a string, one character per byte (Latin-1). */
58
+ function latin1(b, from, to) {
59
+ const s = b.subarray(from, to);
60
+ return Buffer.from(s.buffer, s.byteOffset, s.byteLength).toString('latin1');
61
+ }
62
+ function startsWith(b, s) {
63
+ if (b.length < s.length)
64
+ return false;
65
+ for (let k = 0; k < s.length; k++) {
66
+ if (b[k] !== s.charCodeAt(k))
67
+ return false;
68
+ }
69
+ return true;
70
+ }
71
+ /** A copy of [b], never a view of the same memory. */
72
+ function copyOf(b) {
73
+ return new Uint8Array(b);
74
+ }
75
+ /** Zeroes [bytes]; whether anything changed. */
76
+ function zero(bytes) {
77
+ if (bytes.every((c) => c === 0))
78
+ return false;
79
+ bytes.fill(0);
80
+ return true;
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // The entry point
84
+ /**
85
+ * [bytes] without location metadata: [bytes] itself when there's nothing
86
+ * to remove, otherwise a new array. [bytes] is never modified.
87
+ *
88
+ * A metadata block that can't be read is removed whole (an empty Exif, a
89
+ * blank XMP, a video's `udta` or `meta` box), which keeps the image or
90
+ * video. Throws LocationUnreadableError only when a HEIF's `meta` box, a
91
+ * TIFF file's first IFD, or a video's top-level, `moov` or `trak` boxes
92
+ * can't be read: the location can't be found then, and removing it
93
+ * blindly would lose the file.
94
+ *
95
+ * A video is only copied when it has a location to remove: its boxes are
96
+ * walked in place and the media data skipped.
97
+ */
98
+ export function stripLocation(bytes) {
99
+ if (isJpeg(bytes))
100
+ return inPlace(bytes, (b) => stripJpegLocation(b, true));
101
+ if (isPng(bytes))
102
+ return stripPngLocation(bytes) ?? bytes;
103
+ if (isWebp(bytes))
104
+ return inPlace(bytes, stripWebpLocation);
105
+ if (isHeifContainer(bytes)) {
106
+ const photo = inPlace(bytes, stripHeifLocation);
107
+ // An image sequence can carry a movie's metadata too.
108
+ if (!hasMovieBox(photo))
109
+ return photo;
110
+ return stripMovieLocation(photo) ?? photo;
111
+ }
112
+ if (isMovie(bytes))
113
+ return stripMovieLocation(bytes) ?? bytes;
114
+ if (isTiff(bytes))
115
+ return inPlace(bytes, (b) => stripTiffLocation(b, false));
116
+ return bytes;
117
+ }
118
+ function inPlace(bytes, strip) {
119
+ const copy = copyOf(bytes);
120
+ return strip(copy) ? copy : bytes;
121
+ }
122
+ function isTiff(b) {
123
+ return (b.length >= 4 &&
124
+ ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a && b[3] === 0x00) ||
125
+ (b[0] === 0x4d && b[1] === 0x4d && b[2] === 0x00 && b[3] === 0x2a)));
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // XMP
129
+ const SPACE = 0x20;
130
+ /**
131
+ * Blanks the location properties in an XMP packet, in place and without
132
+ * changing its length: each one, attribute or element with all its
133
+ * content, is overwritten with spaces, which XML and RDF ignore there. So
134
+ * offsets around the packet stay valid and nothing needs re-encoding.
135
+ *
136
+ * Location properties (isXmpLocation): any whose name starts with `GPS` or
137
+ * names a latitude or longitude, in any namespace (`exif:GPSLatitude`, a
138
+ * drone's `drone-dji:GpsLongitude`), plus `photoshop:City`, `State` and
139
+ * `Country`, `Iptc4xmpCore:Location` and `CountryCode`,
140
+ * `Iptc4xmpExt:LocationCreated` and `LocationShown`, and
141
+ * `xmpDM:shotLocation`.
142
+ *
143
+ * A packet that can't be parsed is blanked entirely. Returns whether
144
+ * anything changed.
145
+ */
146
+ export function stripXmpLocation(xmp) {
147
+ let ranges;
148
+ try {
149
+ ranges = new XmpScanner(xmp).locationRanges();
150
+ }
151
+ catch (e) {
152
+ if (!(e instanceof LocationUnreadableError))
153
+ throw e;
154
+ if (xmp.every((c) => c === SPACE))
155
+ return false;
156
+ xmp.fill(SPACE);
157
+ return true;
158
+ }
159
+ for (const [start, end] of ranges)
160
+ xmp.fill(SPACE, start, end);
161
+ return ranges.length > 0;
162
+ }
163
+ const NS_PHOTOSHOP = 'http://ns.adobe.com/photoshop/1.0/';
164
+ const NS_IPTC_CORE = 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/';
165
+ const NS_IPTC_EXT = 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/';
166
+ const NS_DYNAMIC_MEDIA = 'http://ns.adobe.com/xmp/1.0/DynamicMedia/';
167
+ /** The usual prefixes, for a packet that uses one without declaring it. */
168
+ const KNOWN_PREFIXES = new Map([
169
+ ['photoshop', NS_PHOTOSHOP],
170
+ ['Iptc4xmpCore', NS_IPTC_CORE],
171
+ ['Iptc4xmpExt', NS_IPTC_EXT],
172
+ ['xmpDM', NS_DYNAMIC_MEDIA],
173
+ ]);
174
+ const LOCATION_NAMES = new Map([
175
+ [NS_PHOTOSHOP, new Set(['City', 'State', 'Country'])],
176
+ [NS_IPTC_CORE, new Set(['Location', 'CountryCode'])],
177
+ [NS_IPTC_EXT, new Set(['LocationCreated', 'LocationShown'])],
178
+ [NS_DYNAMIC_MEDIA, new Set(['shotLocation'])],
179
+ ]);
180
+ /** Whether the property [local] in [namespace] is location data. */
181
+ function isXmpLocation(namespace, local) {
182
+ const name = local.toLowerCase();
183
+ if (name.startsWith('gps') || name.includes('latitude') || name.includes('longitude')) {
184
+ return true;
185
+ }
186
+ if (namespace === undefined)
187
+ return false;
188
+ return LOCATION_NAMES.get(namespace)?.has(local) ?? false;
189
+ }
190
+ const NO_NAMESPACES = new Map();
191
+ function isXmlSpace(c) {
192
+ return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d;
193
+ }
194
+ function isNameEnd(c) {
195
+ return isXmlSpace(c) || c === 0x3e || c === 0x2f || c === 0x3d; // > / =
196
+ }
197
+ function namespaceOf(qname, scope) {
198
+ const colon = qname.indexOf(':');
199
+ const prefix = colon < 0 ? '' : qname.substring(0, colon);
200
+ return scope.get(prefix) ?? KNOWN_PREFIXES.get(prefix);
201
+ }
202
+ function localOf(qname) {
203
+ return qname.substring(qname.indexOf(':') + 1);
204
+ }
205
+ /**
206
+ * A small, strict XML scanner: enough to find elements, attributes and
207
+ * namespace declarations by byte offset. Throws LocationUnreadableError for
208
+ * anything it can't follow.
209
+ */
210
+ class XmpScanner {
211
+ b;
212
+ constructor(b) {
213
+ this.b = b;
214
+ }
215
+ indexOf(s, from) {
216
+ const b = this.b;
217
+ const first = s.charCodeAt(0);
218
+ if (s.length === 1)
219
+ return from >= b.length ? -1 : b.indexOf(first, from);
220
+ outer: for (let i = from; i + s.length <= b.length; i++) {
221
+ if (b[i] !== first)
222
+ continue;
223
+ for (let k = 1; k < s.length; k++) {
224
+ if (b[i + k] !== s.charCodeAt(k))
225
+ continue outer;
226
+ }
227
+ return i;
228
+ }
229
+ return -1;
230
+ }
231
+ at(i, s) {
232
+ const b = this.b;
233
+ if (i + s.length > b.length)
234
+ return false;
235
+ for (let k = 0; k < s.length; k++) {
236
+ if (b[i + k] !== s.charCodeAt(k))
237
+ return false;
238
+ }
239
+ return true;
240
+ }
241
+ skipTo(s, from) {
242
+ const i = this.indexOf(s, from);
243
+ if (i < 0)
244
+ throw new LocationUnreadableError('XMP: unterminated markup');
245
+ return i + s.length;
246
+ }
247
+ name(i) {
248
+ const b = this.b;
249
+ const start = i;
250
+ while (i < b.length && !isNameEnd(byte(b, i)))
251
+ i++;
252
+ if (i === start || i >= b.length)
253
+ throw new LocationUnreadableError('XMP: bad name');
254
+ return [latin1(b, start, i), i];
255
+ }
256
+ skipSpace(i) {
257
+ const b = this.b;
258
+ while (i < b.length && isXmlSpace(byte(b, i)))
259
+ i++;
260
+ return i;
261
+ }
262
+ locationRanges() {
263
+ const b = this.b;
264
+ const ranges = [];
265
+ const stack = [];
266
+ let i = 0;
267
+ for (;;) {
268
+ const lt = this.indexOf('<', i);
269
+ if (lt < 0)
270
+ break;
271
+ if (this.at(lt, '<!--')) {
272
+ i = this.skipTo('-->', lt + 4);
273
+ }
274
+ else if (this.at(lt, '<![CDATA[')) {
275
+ i = this.skipTo(']]>', lt + 9);
276
+ }
277
+ else if (this.at(lt, '<?')) {
278
+ i = this.skipTo('?>', lt + 2);
279
+ }
280
+ else if (this.at(lt, '<!')) {
281
+ i = this.skipTo('>', lt + 2);
282
+ }
283
+ else if (this.at(lt, '</')) {
284
+ const [name, after] = this.name(lt + 2);
285
+ const gt = this.skipSpace(after);
286
+ if (gt >= b.length || b[gt] !== 0x3e) {
287
+ throw new LocationUnreadableError('XMP: bad end tag');
288
+ }
289
+ const open = stack.pop();
290
+ if (open?.name !== name) {
291
+ throw new LocationUnreadableError('XMP: mismatched end tag');
292
+ }
293
+ if (open.start !== null)
294
+ ranges.push([open.start, gt + 1]);
295
+ i = gt + 1;
296
+ }
297
+ else {
298
+ i = this.startTag(lt, stack, ranges);
299
+ }
300
+ }
301
+ if (stack.length > 0)
302
+ throw new LocationUnreadableError('XMP: unclosed element');
303
+ return ranges;
304
+ }
305
+ /** Reads the start tag at [lt]; returns where it ends. */
306
+ startTag(lt, stack, ranges) {
307
+ const b = this.b;
308
+ const [name, afterName] = this.name(lt + 1);
309
+ const attributes = []; // name, value, from, to
310
+ let i = afterName;
311
+ let selfClosing;
312
+ for (;;) {
313
+ i = this.skipSpace(i);
314
+ if (i >= b.length)
315
+ throw new LocationUnreadableError('XMP: unterminated tag');
316
+ if (b[i] === 0x3e) {
317
+ selfClosing = false;
318
+ break;
319
+ }
320
+ if (b[i] === 0x2f) {
321
+ if (i + 1 >= b.length || b[i + 1] !== 0x3e) {
322
+ throw new LocationUnreadableError('XMP: bad tag end');
323
+ }
324
+ selfClosing = true;
325
+ i++;
326
+ break;
327
+ }
328
+ const [attribute, afterAttribute] = this.name(i);
329
+ let j = this.skipSpace(afterAttribute);
330
+ if (j >= b.length || b[j] !== 0x3d) {
331
+ throw new LocationUnreadableError('XMP: attribute without value');
332
+ }
333
+ j = this.skipSpace(j + 1);
334
+ if (j >= b.length || (b[j] !== 0x22 && b[j] !== 0x27)) {
335
+ throw new LocationUnreadableError('XMP: unquoted attribute');
336
+ }
337
+ const quote = byte(b, j);
338
+ let k = j + 1;
339
+ while (k < b.length && b[k] !== quote) {
340
+ if (b[k] === 0x3c)
341
+ throw new LocationUnreadableError('XMP: < in attribute');
342
+ k++;
343
+ }
344
+ if (k >= b.length)
345
+ throw new LocationUnreadableError('XMP: unterminated value');
346
+ attributes.push([attribute, latin1(b, j + 1, k), i, k + 1]);
347
+ i = k + 1;
348
+ }
349
+ const end = i + 1;
350
+ const parent = stack[stack.length - 1]?.namespaces ?? NO_NAMESPACES;
351
+ let scope = parent;
352
+ for (const [attribute, value] of attributes) {
353
+ if (attribute === 'xmlns' || attribute.startsWith('xmlns:')) {
354
+ if (scope === parent)
355
+ scope = new Map(parent);
356
+ scope.set(attribute === 'xmlns' ? '' : attribute.substring(6), value);
357
+ }
358
+ }
359
+ for (const [attribute, , from, to] of attributes) {
360
+ if (attribute === 'xmlns' || attribute.startsWith('xmlns:'))
361
+ continue;
362
+ // Unprefixed attributes have no namespace.
363
+ const namespace = attribute.includes(':') ? namespaceOf(attribute, scope) : undefined;
364
+ if (isXmpLocation(namespace, localOf(attribute)))
365
+ ranges.push([from, to]);
366
+ }
367
+ const location = isXmpLocation(namespaceOf(name, scope), localOf(name));
368
+ if (selfClosing) {
369
+ if (location)
370
+ ranges.push([lt, end]);
371
+ }
372
+ else {
373
+ stack.push({ name, namespaces: scope, start: location ? lt : null });
374
+ }
375
+ return end;
376
+ }
377
+ }
378
+ // ---------------------------------------------------------------------------
379
+ // IPTC-IIM and Photoshop image resources
380
+ /**
381
+ * IPTC-IIM application record (2) datasets that hold a place: content
382
+ * location code and name, city, sublocation, province or state, country
383
+ * code and country name.
384
+ */
385
+ const IIM_LOCATION_DATASETS = new Set([26, 27, 90, 92, 95, 100, 101]);
386
+ /**
387
+ * Blanks the location datasets of an IPTC-IIM block in place, with spaces,
388
+ * keeping every length. A block that can't be read is zeroed, so readers
389
+ * find nothing in it. Returns whether anything changed.
390
+ */
391
+ function stripIimLocation(iim) {
392
+ const ranges = [];
393
+ let i = 0;
394
+ let readable = true;
395
+ while (i + 5 <= iim.length) {
396
+ if (iim[i] !== 0x1c) {
397
+ // Padding at the end is fine; anything else isn't IIM.
398
+ readable = iim.subarray(i).every((c) => c === 0);
399
+ break;
400
+ }
401
+ const record = byte(iim, i + 1);
402
+ const dataset = byte(iim, i + 2);
403
+ let length = (byte(iim, i + 3) << 8) | byte(iim, i + 4);
404
+ let start = i + 5;
405
+ if ((length & 0x8000) !== 0) {
406
+ // An extended dataset: the next (length & 0x7FFF) bytes are its length.
407
+ const size = length & 0x7fff;
408
+ if (size > 4 || start + size > iim.length) {
409
+ readable = false;
410
+ break;
411
+ }
412
+ length = 0;
413
+ for (let k = 0; k < size; k++)
414
+ length = length * 256 + byte(iim, start + k);
415
+ start += size;
416
+ }
417
+ const end = start + length;
418
+ if (end > iim.length) {
419
+ readable = false;
420
+ break;
421
+ }
422
+ if (record === 2 && IIM_LOCATION_DATASETS.has(dataset) && length > 0) {
423
+ ranges.push([start, end]);
424
+ }
425
+ i = end;
426
+ }
427
+ if (!readable)
428
+ return zero(iim);
429
+ let changed = false;
430
+ for (const [start, end] of ranges) {
431
+ for (let k = start; k < end; k++) {
432
+ if (iim[k] !== SPACE) {
433
+ iim[k] = SPACE;
434
+ changed = true;
435
+ }
436
+ }
437
+ }
438
+ return changed;
439
+ }
440
+ /** `8BIM` and the older signatures Photoshop image resources may carry. */
441
+ const RESOURCE_SIGNATURES = new Set(['8BIM', 'PHUT', 'AgHg', 'DCSR', 'MeSa']);
442
+ /** Photoshop's IPTC-IIM resource. */
443
+ const IPTC_RESOURCE = 0x0404;
444
+ /**
445
+ * Blanks the IPTC location in Photoshop image resources (a JPEG's APP13
446
+ * after `Photoshop 3.0\0`), in place. Resources that can't be read are
447
+ * zeroed. Returns whether anything changed.
448
+ */
449
+ function stripPhotoshopLocation(resources) {
450
+ const d = view(resources);
451
+ let i = 0;
452
+ let changed = false;
453
+ while (i + 12 <= resources.length) {
454
+ const signature = latin1(resources, i, i + 4);
455
+ if (!RESOURCE_SIGNATURES.has(signature)) {
456
+ if (resources.subarray(i).every((c) => c === 0))
457
+ return changed;
458
+ return zero(resources) || changed;
459
+ }
460
+ const id = d.getUint16(i + 4);
461
+ // A Pascal string, padded to an even length.
462
+ const nameLength = byte(resources, i + 6);
463
+ const sizeAt = i + 6 + ((nameLength + 2) & ~1);
464
+ if (sizeAt + 4 > resources.length)
465
+ return zero(resources) || changed;
466
+ const size = d.getUint32(sizeAt);
467
+ const start = sizeAt + 4;
468
+ const end = start + size;
469
+ if (end > resources.length)
470
+ return zero(resources) || changed;
471
+ if (id === IPTC_RESOURCE) {
472
+ changed = stripIimLocation(resources.subarray(start, end)) || changed;
473
+ }
474
+ i = end + (size & 1);
475
+ }
476
+ return changed;
477
+ }
478
+ // ---------------------------------------------------------------------------
479
+ // TIFF (and every Exif block)
480
+ /**
481
+ * Removes the GPS IFD from a TIFF structure: the Exif block of a JPEG,
482
+ * PNG, WebP or HEIF, or a whole TIFF file.
483
+ *
484
+ * Edits [tiff] in place without changing its length, so every offset in
485
+ * and around it stays valid: IFD0's GPS pointer (tag 0x8825) is removed
486
+ * (the entries after it move up, and the 12 bytes freed at the end of the
487
+ * IFD are zeroed), and the GPS IFD and the values it points to are zeroed.
488
+ * IFD0's XMP (tag 700) and IPTC (tag 33723) lose their location fields,
489
+ * and the thumbnail JPEG in IFD1 is cleaned the same way as a JPEG. Every
490
+ * other tag is kept.
491
+ *
492
+ * When [tiff] can't be read, an [embedded] block is replaced by an empty
493
+ * TIFF (no tags at all, same length); a whole TIFF file throws
494
+ * LocationUnreadableError, since blanking it would lose the image. Returns
495
+ * whether anything changed.
496
+ */
497
+ function stripTiffLocation(tiff, embedded) {
498
+ try {
499
+ return new TiffEditor(tiff).strip();
500
+ }
501
+ catch (e) {
502
+ if (!(e instanceof LocationUnreadableError) || !embedded)
503
+ throw e;
504
+ blankTiff(tiff);
505
+ return true;
506
+ }
507
+ }
508
+ /**
509
+ * Overwrites [tiff] with an empty big-endian TIFF (header, an IFD0 with no
510
+ * entries) followed by zeros, or with zeros if it's too short for that.
511
+ */
512
+ function blankTiff(tiff) {
513
+ tiff.fill(0);
514
+ if (tiff.length < 14)
515
+ return;
516
+ // MM, 42, IFD0 at 8; IFD0 has no entries and no next IFD.
517
+ tiff.set([0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], 0);
518
+ }
519
+ const TAG_GPS_IFD = 0x8825;
520
+ const TAG_EXIF_IFD = 0x8769;
521
+ const TAG_XMP = 0x02bc;
522
+ const TAG_IPTC = 0x83bb;
523
+ const TAG_THUMBNAIL_OFFSET = 0x0201;
524
+ const TAG_THUMBNAIL_LENGTH = 0x0202;
525
+ /** Bytes per value, by TIFF field type (1 BYTE ... 13 IFD). */
526
+ const TYPE_SIZES = new Map([
527
+ [1, 1],
528
+ [2, 1],
529
+ [3, 2],
530
+ [4, 4],
531
+ [5, 8],
532
+ [6, 1],
533
+ [7, 1],
534
+ [8, 2],
535
+ [9, 4],
536
+ [10, 8],
537
+ [11, 4],
538
+ [12, 8],
539
+ [13, 4],
540
+ ]);
541
+ /**
542
+ * The most IFDs followed in IFD0's chain: IFD0 and IFD1 in practice, more
543
+ * in a multi-page TIFF.
544
+ */
545
+ const MAX_CHAINED_IFDS = 64;
546
+ class TiffEditor {
547
+ b;
548
+ d;
549
+ little = false;
550
+ constructor(b) {
551
+ this.b = b;
552
+ this.d = view(b);
553
+ }
554
+ u16(at) {
555
+ return this.d.getUint16(at, this.little);
556
+ }
557
+ u32(at) {
558
+ return this.d.getUint32(at, this.little);
559
+ }
560
+ strip() {
561
+ const b = this.b;
562
+ if (b.length < 8)
563
+ throw new LocationUnreadableError('TIFF header');
564
+ if (b[0] === 0x49 && b[1] === 0x49) {
565
+ this.little = true;
566
+ }
567
+ else if (b[0] === 0x4d && b[1] === 0x4d) {
568
+ this.little = false;
569
+ }
570
+ else {
571
+ throw new LocationUnreadableError('TIFF byte order');
572
+ }
573
+ if (this.u16(2) !== 42)
574
+ throw new LocationUnreadableError('TIFF magic');
575
+ const ifd0 = this.u32(4);
576
+ // IFD0 has to be readable; later IFDs that aren't are skipped (a reader
577
+ // can't find anything in them either).
578
+ const first = this.entries(ifd0);
579
+ if (first === null)
580
+ throw new LocationUnreadableError('TIFF IFD0');
581
+ let changed = false;
582
+ const seen = new Set([ifd0]);
583
+ let ifd = ifd0;
584
+ let entries = first;
585
+ for (let index = 0;; index++) {
586
+ // The Exif IFD gets checked too, for writers that put the GPS pointer
587
+ // there by mistake. Read before cleaning moves entries.
588
+ const subs = [];
589
+ for (const e of entries) {
590
+ if (e.tag !== TAG_EXIF_IFD)
591
+ continue;
592
+ const p = this.pointer(e);
593
+ if (p !== null)
594
+ subs.push(p);
595
+ }
596
+ changed = this.clean(ifd, entries, index === 1) || changed;
597
+ for (const sub of subs) {
598
+ if (seen.has(sub))
599
+ continue;
600
+ seen.add(sub);
601
+ const subEntries = this.entries(sub);
602
+ if (subEntries !== null)
603
+ changed = this.clean(sub, subEntries, false) || changed;
604
+ }
605
+ if (index + 1 >= MAX_CHAINED_IFDS)
606
+ break;
607
+ const next = this.next(ifd);
608
+ if (next === null || next === 0 || seen.has(next))
609
+ break;
610
+ seen.add(next);
611
+ const nextEntries = this.entries(next);
612
+ if (nextEntries === null)
613
+ break;
614
+ ifd = next;
615
+ entries = nextEntries;
616
+ }
617
+ return changed;
618
+ }
619
+ /** The IFD at [at]'s entries, or null if its table isn't inside the block. */
620
+ entries(at) {
621
+ const b = this.b;
622
+ if (at < 8 || at + 2 > b.length)
623
+ return null;
624
+ const count = this.u16(at);
625
+ if (at + 2 + count * 12 > b.length)
626
+ return null;
627
+ const out = [];
628
+ for (let n = 0; n < count; n++) {
629
+ const position = at + 2 + n * 12;
630
+ out.push({
631
+ position,
632
+ tag: this.u16(position),
633
+ type: this.u16(position + 2),
634
+ count: this.u32(position + 4),
635
+ });
636
+ }
637
+ return out;
638
+ }
639
+ /** The next-IFD offset after the IFD at [at], or null if it's missing. */
640
+ next(at) {
641
+ const end = at + 2 + this.u16(at) * 12;
642
+ return end + 4 <= this.b.length ? this.u32(end) : null;
643
+ }
644
+ /** The offset a pointer entry (LONG or IFD) holds, or null. */
645
+ pointer(e) {
646
+ if (e.count !== 1 || !(e.type === 4 || e.type === 13))
647
+ return null;
648
+ return this.u32(e.position + 8);
649
+ }
650
+ /**
651
+ * Where [e]'s value is, inline or not, or null if its type is unknown or
652
+ * it starts past the end. With [clamp], a value that runs past the end is
653
+ * cut there (a cut-off XMP or IPTC block still gets cleaned); without, it
654
+ * gives null.
655
+ */
656
+ value(e, clamp = false) {
657
+ const size = TYPE_SIZES.get(e.type);
658
+ if (size === undefined)
659
+ return null;
660
+ const length = size * e.count;
661
+ const start = length <= 4 ? e.position + 8 : this.u32(e.position + 8);
662
+ if (start < 0 || start >= this.b.length)
663
+ return null;
664
+ if (start + length > this.b.length)
665
+ return clamp ? [start, this.b.length] : null;
666
+ return [start, start + length];
667
+ }
668
+ /**
669
+ * Cleans the IFD at [at]: removes its GPS pointer and the GPS IFD, and
670
+ * the location in its XMP, IPTC and (IFD1) thumbnail.
671
+ */
672
+ clean(at, entries, isIfd1) {
673
+ const b = this.b;
674
+ let changed = false;
675
+ let thumbnailStart = null;
676
+ let thumbnailLength = null;
677
+ for (const e of entries) {
678
+ if (e.tag === TAG_XMP) {
679
+ const v = this.value(e, true);
680
+ if (v !== null)
681
+ changed = stripXmpLocation(b.subarray(v[0], v[1])) || changed;
682
+ }
683
+ else if (e.tag === TAG_IPTC) {
684
+ const v = this.value(e, true);
685
+ if (v !== null)
686
+ changed = stripIimLocation(b.subarray(v[0], v[1])) || changed;
687
+ }
688
+ else if (e.tag === TAG_THUMBNAIL_OFFSET && isIfd1) {
689
+ thumbnailStart = this.pointer(e);
690
+ }
691
+ else if (e.tag === TAG_THUMBNAIL_LENGTH && isIfd1) {
692
+ thumbnailLength =
693
+ e.count === 1 && e.type === 3 ? this.u16(e.position + 8) : this.pointer(e);
694
+ }
695
+ }
696
+ if (thumbnailStart !== null &&
697
+ thumbnailLength !== null &&
698
+ thumbnailStart + thumbnailLength <= b.length) {
699
+ changed =
700
+ stripJpegLocation(b.subarray(thumbnailStart, thumbnailStart + thumbnailLength), true) ||
701
+ changed;
702
+ }
703
+ // Remove every GPS pointer, last first so earlier positions hold.
704
+ const gps = entries.filter((e) => e.tag === TAG_GPS_IFD);
705
+ for (const e of gps.reverse()) {
706
+ const target = this.pointer(e) ?? this.u32(e.position + 8);
707
+ this.removeEntry(at, e);
708
+ this.zeroGpsIfd(target);
709
+ changed = true;
710
+ }
711
+ return changed;
712
+ }
713
+ /**
714
+ * Removes the entry [e] from the IFD at [at]: the later entries and the
715
+ * next-IFD offset move up 12 bytes, and the freed bytes are zeroed.
716
+ */
717
+ removeEntry(at, e) {
718
+ const b = this.b;
719
+ const count = this.u16(at);
720
+ const end = at + 2 + count * 12;
721
+ // The next-IFD offset moves too, when it's there.
722
+ const tail = end + 4 <= b.length ? end + 4 : end;
723
+ b.copyWithin(e.position, e.position + 12, tail);
724
+ b.fill(0, tail - 12, tail);
725
+ this.d.setUint16(at, count - 1, this.little);
726
+ }
727
+ /**
728
+ * Zeroes the GPS IFD at [at] and its values, as far as they're inside
729
+ * the block. Nothing points to them any more.
730
+ */
731
+ zeroGpsIfd(at) {
732
+ const b = this.b;
733
+ if (at < 8 || at + 2 > b.length)
734
+ return;
735
+ const entries = this.entries(at);
736
+ if (entries !== null) {
737
+ for (const e of entries) {
738
+ const v = this.value(e);
739
+ if (v !== null && v[1] - v[0] > 4)
740
+ b.fill(0, v[0], v[1]);
741
+ }
742
+ }
743
+ const end = at + 2 + this.u16(at) * 12 + 4;
744
+ b.fill(0, at, end < b.length ? end : b.length);
745
+ }
746
+ }
747
+ // ---------------------------------------------------------------------------
748
+ // JPEG
749
+ /** Whether [b] starts like a JPEG (SOI, then a marker). */
750
+ export function isJpeg(b) {
751
+ return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
752
+ }
753
+ /** `Exif` then NUL (and usually a second NUL or 0xFF, skipped too). */
754
+ const JPEG_EXIF = 'Exif\x00';
755
+ const JPEG_XMP = 'http://ns.adobe.com/xap/1.0/\x00';
756
+ const JPEG_EXTENDED_XMP = 'http://ns.adobe.com/xmp/extension/\x00';
757
+ const JPEG_PHOTOSHOP = 'Photoshop 3.0\x00';
758
+ const JPEG_MPF = 'MPF\x00';
759
+ /** Largest extended XMP put back together (they are a few MB at most). */
760
+ const MAX_EXTENDED_XMP = 64 * 1024 * 1024;
761
+ /**
762
+ * Removes the location metadata from a JPEG, in place, without changing
763
+ * its length or any byte of the image data:
764
+ *
765
+ * - APP1 Exif: the GPS IFD (stripTiffLocation); an Exif block that can't
766
+ * be read becomes an empty one.
767
+ * - APP1 XMP, and extended XMP split over several APP1 segments: the
768
+ * location properties (stripXmpLocation).
769
+ * - APP13 Photoshop resources: the IPTC location (stripPhotoshopLocation).
770
+ * - With [followMpf], the other images an APP2 MPF segment lists (a
771
+ * phone's HDR gain map, a 3D camera's second view) are cleaned the same
772
+ * way.
773
+ *
774
+ * Only the segments before the image data (SOS) are read, the way a
775
+ * decoder reads them, resynchronising past stray bytes. A segment cut off
776
+ * by the end of the file has its remaining bytes zeroed when it's a
777
+ * metadata segment. Returns whether anything changed.
778
+ */
779
+ function stripJpegLocation(jpeg, followMpf) {
780
+ if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8)
781
+ return false;
782
+ let changed = false;
783
+ let mpfHeader = null;
784
+ const extendedXmp = new Map();
785
+ let i = 2;
786
+ while (i < jpeg.length) {
787
+ // The next marker: 0xFF, any fill bytes, then a marker code.
788
+ if (jpeg[i] !== 0xff) {
789
+ i++;
790
+ continue;
791
+ }
792
+ while (i < jpeg.length && jpeg[i] === 0xff)
793
+ i++;
794
+ if (i >= jpeg.length)
795
+ break;
796
+ const marker = byte(jpeg, i++);
797
+ if (marker === 0x00 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
798
+ continue; // no length
799
+ }
800
+ if (marker === 0xd9 || marker === 0xda)
801
+ break; // EOI, or the image data
802
+ if (i + 2 > jpeg.length)
803
+ break;
804
+ const length = (byte(jpeg, i) << 8) | byte(jpeg, i + 1);
805
+ if (length < 2)
806
+ continue; // not a real segment; resynchronise
807
+ const start = i + 2;
808
+ const end = i + length;
809
+ const metadata = (marker >= 0xe0 && marker <= 0xef) || marker === 0xfe;
810
+ if (end > jpeg.length) {
811
+ if (metadata && jpeg.subarray(start).some((c) => c !== 0)) {
812
+ jpeg.fill(0, start, jpeg.length);
813
+ changed = true;
814
+ }
815
+ break;
816
+ }
817
+ const payload = jpeg.subarray(start, end);
818
+ if (marker === 0xe1 && startsWith(payload, JPEG_EXIF) && payload.length > JPEG_EXIF.length) {
819
+ changed = stripTiffLocation(payload.subarray(JPEG_EXIF.length + 1), true) || changed;
820
+ }
821
+ else if (marker === 0xe1 && startsWith(payload, JPEG_XMP)) {
822
+ changed = stripXmpLocation(payload.subarray(JPEG_XMP.length)) || changed;
823
+ }
824
+ else if (marker === 0xe1 && startsWith(payload, JPEG_EXTENDED_XMP)) {
825
+ const h = JPEG_EXTENDED_XMP.length;
826
+ if (payload.length >= h + 40) {
827
+ const d = view(payload);
828
+ const guid = latin1(payload, h, h + 32);
829
+ let chunks = extendedXmp.get(guid);
830
+ if (chunks === undefined) {
831
+ chunks = [];
832
+ extendedXmp.set(guid, chunks);
833
+ }
834
+ chunks.push([d.getUint32(h + 32), d.getUint32(h + 36), start + h + 40, end]);
835
+ }
836
+ }
837
+ else if (marker === 0xed && startsWith(payload, JPEG_PHOTOSHOP)) {
838
+ changed = stripPhotoshopLocation(payload.subarray(JPEG_PHOTOSHOP.length)) || changed;
839
+ }
840
+ else if (marker === 0xe2 && startsWith(payload, JPEG_MPF)) {
841
+ mpfHeader ??= start + JPEG_MPF.length;
842
+ }
843
+ i = end;
844
+ }
845
+ for (const chunks of extendedXmp.values()) {
846
+ changed = stripExtendedXmp(jpeg, chunks) || changed;
847
+ }
848
+ if (followMpf && mpfHeader !== null) {
849
+ for (const [offset, size] of mpfImages(jpeg, mpfHeader)) {
850
+ const at = mpfHeader + offset;
851
+ if (offset <= 0 || at + size > jpeg.length)
852
+ continue;
853
+ changed = stripJpegLocation(jpeg.subarray(at, at + size), false) || changed;
854
+ }
855
+ }
856
+ return changed;
857
+ }
858
+ /**
859
+ * Puts one extended XMP (its [chunks]) back together, strips it, and
860
+ * writes it back. Chunks that don't fit together are blanked.
861
+ */
862
+ function stripExtendedXmp(jpeg, chunks) {
863
+ const firstChunk = chunks[0];
864
+ if (firstChunk === undefined)
865
+ return false;
866
+ const full = firstChunk[0];
867
+ const fits = full <= MAX_EXTENDED_XMP && chunks.every((c) => c[0] === full && c[1] + (c[3] - c[2]) <= full);
868
+ if (!fits) {
869
+ let changed = false;
870
+ for (const [, , start, end] of chunks) {
871
+ changed = blankSpaces(jpeg.subarray(start, end)) || changed;
872
+ }
873
+ return changed;
874
+ }
875
+ const packet = new Uint8Array(full).fill(SPACE);
876
+ for (const [, offset, start, end] of chunks)
877
+ packet.set(jpeg.subarray(start, end), offset);
878
+ if (!stripXmpLocation(packet))
879
+ return false;
880
+ for (const [, offset, start, end] of chunks) {
881
+ jpeg.set(packet.subarray(offset, offset + end - start), start);
882
+ }
883
+ return true;
884
+ }
885
+ /** Fills [bytes] with spaces; whether anything changed. */
886
+ function blankSpaces(bytes) {
887
+ if (bytes.every((c) => c === SPACE))
888
+ return false;
889
+ bytes.fill(SPACE);
890
+ return true;
891
+ }
892
+ /**
893
+ * The (offset, size) of each image an MPF segment's MP Index IFD lists,
894
+ * offsets relative to [header] (its byte-order mark). Empty if it can't be
895
+ * read.
896
+ */
897
+ function mpfImages(jpeg, header) {
898
+ if (header + 8 > jpeg.length)
899
+ return [];
900
+ const d = view(jpeg, header);
901
+ let little;
902
+ if (jpeg[header] === 0x49 && jpeg[header + 1] === 0x49) {
903
+ little = true;
904
+ }
905
+ else if (jpeg[header] === 0x4d && jpeg[header + 1] === 0x4d) {
906
+ little = false;
907
+ }
908
+ else {
909
+ return [];
910
+ }
911
+ const ifd = d.getUint32(4, little);
912
+ if (ifd + 2 > d.byteLength)
913
+ return [];
914
+ const count = d.getUint16(ifd, little);
915
+ for (let n = 0; n < count; n++) {
916
+ const entry = ifd + 2 + n * 12;
917
+ if (entry + 12 > d.byteLength)
918
+ return [];
919
+ if (d.getUint16(entry, little) !== 0xb002)
920
+ continue; // MPEntry
921
+ const length = d.getUint32(entry + 4, little);
922
+ const at = d.getUint32(entry + 8, little);
923
+ if (length % 16 !== 0 || at + length > d.byteLength)
924
+ return [];
925
+ const images = [];
926
+ for (let e = at; e < at + length; e += 16) {
927
+ images.push([d.getUint32(e + 8, little), d.getUint32(e + 4, little)]);
928
+ }
929
+ return images;
930
+ }
931
+ return [];
932
+ }
933
+ // ---------------------------------------------------------------------------
934
+ // PNG
935
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
936
+ /** Whether [b] starts with the PNG signature. */
937
+ export function isPng(b) {
938
+ if (b.length < 8)
939
+ return false;
940
+ return PNG_SIGNATURE.every((c, k) => b[k] === c);
941
+ }
942
+ const PNG_METADATA = new Set(['eXIf', 'iTXt', 'tEXt', 'zTXt']);
943
+ /**
944
+ * [png] without its location metadata, or null if it has none. The image
945
+ * chunks are copied as they are.
946
+ *
947
+ * - `eXIf`: the GPS IFD is removed in place (stripTiffLocation) and the
948
+ * chunk's CRC recomputed.
949
+ * - XMP in an uncompressed `iTXt` (`XML:com.adobe.xmp`): the location
950
+ * properties are blanked (stripXmpLocation) and the CRC recomputed. A
951
+ * compressed one is dropped, since inflating it isn't worth a codec.
952
+ * - ImageMagick's `Raw profile type ...` text chunks (hex-encoded Exif,
953
+ * IPTC or XMP) are dropped.
954
+ * - A metadata chunk cut off by the end of the file is dropped.
955
+ */
956
+ function stripPngLocation(png) {
957
+ const out = copyOf(png);
958
+ const d = view(out);
959
+ const keep = [];
960
+ let changed = false;
961
+ let i = 8;
962
+ let keptFrom = 0;
963
+ while (i + 8 <= out.length) {
964
+ const length = d.getUint32(i);
965
+ const type = latin1(out, i + 4, i + 8);
966
+ const start = i + 8;
967
+ const end = start + length;
968
+ const metadata = PNG_METADATA.has(type);
969
+ if (end + 4 > out.length) {
970
+ if (metadata) {
971
+ keep.push([keptFrom, i]);
972
+ keptFrom = out.length;
973
+ changed = true;
974
+ }
975
+ break;
976
+ }
977
+ const data = out.subarray(start, end);
978
+ let edited = false;
979
+ let drop = false;
980
+ if (type === 'eXIf') {
981
+ const tiffAt = startsWithExif(data) ? 6 : 0;
982
+ edited = stripTiffLocation(data.subarray(tiffAt), true);
983
+ }
984
+ else if (metadata) {
985
+ const keyword = pngKeyword(data);
986
+ if (keyword.startsWith('Raw profile type ')) {
987
+ drop = true;
988
+ }
989
+ else if (type === 'iTXt' && keyword === 'XML:com.adobe.xmp') {
990
+ const text = iTxtText(data);
991
+ if (text === null) {
992
+ drop = true;
993
+ }
994
+ else {
995
+ edited = stripXmpLocation(data.subarray(text));
996
+ }
997
+ }
998
+ }
999
+ if (edited) {
1000
+ d.setUint32(end, crc32(out.subarray(i + 4, end)));
1001
+ changed = true;
1002
+ }
1003
+ if (drop) {
1004
+ keep.push([keptFrom, i]);
1005
+ keptFrom = end + 4;
1006
+ changed = true;
1007
+ }
1008
+ i = end + 4;
1009
+ }
1010
+ if (!changed)
1011
+ return null;
1012
+ keep.push([keptFrom, out.length]);
1013
+ let total = 0;
1014
+ for (const [from, to] of keep)
1015
+ if (to > from)
1016
+ total += to - from;
1017
+ const result = new Uint8Array(total);
1018
+ let at = 0;
1019
+ for (const [from, to] of keep) {
1020
+ if (to > from) {
1021
+ result.set(out.subarray(from, to), at);
1022
+ at += to - from;
1023
+ }
1024
+ }
1025
+ return result;
1026
+ }
1027
+ function startsWithExif(b) {
1028
+ return (b.length >= 6 && b[0] === 0x45 && b[1] === 0x78 && b[2] === 0x69 && b[3] === 0x66 && b[4] === 0);
1029
+ }
1030
+ /** A text chunk's keyword (Latin-1, up to the first NUL). */
1031
+ function pngKeyword(data) {
1032
+ const nul = data.indexOf(0);
1033
+ return latin1(data, 0, nul < 0 ? data.length : nul);
1034
+ }
1035
+ /**
1036
+ * Where an uncompressed iTXt chunk's text starts, or null if it's
1037
+ * compressed or can't be read: keyword NUL, compression flag, method,
1038
+ * language tag NUL, translated keyword NUL, text.
1039
+ */
1040
+ function iTxtText(data) {
1041
+ let i = data.indexOf(0);
1042
+ if (i < 0 || i + 2 >= data.length)
1043
+ return null;
1044
+ if (data[i + 1] !== 0)
1045
+ return null; // compressed
1046
+ i += 3;
1047
+ for (let field = 0; field < 2; field++) {
1048
+ const nul = data.indexOf(0, i);
1049
+ if (nul < 0)
1050
+ return null;
1051
+ i = nul + 1;
1052
+ }
1053
+ return i;
1054
+ }
1055
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
1056
+ let c = n;
1057
+ for (let k = 0; k < 8; k++)
1058
+ c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) >>> 0 : c >>> 1;
1059
+ return c;
1060
+ });
1061
+ /** The CRC-32 PNG uses (ISO 3309), over [bytes]. */
1062
+ export function crc32(bytes) {
1063
+ let c = 0xffffffff;
1064
+ for (const b of bytes)
1065
+ c = ((CRC_TABLE[(c ^ b) & 0xff] ?? 0) ^ (c >>> 8)) >>> 0;
1066
+ return (c ^ 0xffffffff) >>> 0;
1067
+ }
1068
+ // ---------------------------------------------------------------------------
1069
+ // WebP
1070
+ /** Whether [b] is a RIFF WebP file. */
1071
+ export function isWebp(b) {
1072
+ return b.length >= 12 && latin1(b, 0, 4) === 'RIFF' && latin1(b, 8, 12) === 'WEBP';
1073
+ }
1074
+ /**
1075
+ * Removes the location metadata from a WebP, in place, without changing
1076
+ * its length: the `EXIF` chunk's GPS IFD (stripTiffLocation) and the
1077
+ * `XMP ` chunk's location properties (stripXmpLocation). RIFF has no
1078
+ * checksums, and the VP8X flags stay true. A metadata chunk cut off by the
1079
+ * end of the file has its remaining bytes zeroed. Returns whether anything
1080
+ * changed.
1081
+ */
1082
+ function stripWebpLocation(webp) {
1083
+ const d = view(webp);
1084
+ let changed = false;
1085
+ let i = 12;
1086
+ while (i + 8 <= webp.length) {
1087
+ const type = latin1(webp, i, i + 4);
1088
+ const size = d.getUint32(i + 4, true);
1089
+ const start = i + 8;
1090
+ const end = start + size;
1091
+ const metadata = type === 'EXIF' || type === 'XMP ';
1092
+ if (end > webp.length) {
1093
+ if (metadata && webp.subarray(start).some((c) => c !== 0)) {
1094
+ webp.fill(0, start, webp.length);
1095
+ changed = true;
1096
+ }
1097
+ break;
1098
+ }
1099
+ const data = webp.subarray(start, end);
1100
+ if (type === 'EXIF') {
1101
+ // Some writers keep the JPEG-style "Exif\0\0" prefix.
1102
+ const prefixed = data.length >= 6 && latin1(data, 0, 4) === 'Exif' && data[4] === 0;
1103
+ changed = stripTiffLocation(data.subarray(prefixed ? 6 : 0), true) || changed;
1104
+ }
1105
+ else if (type === 'XMP ') {
1106
+ changed = stripXmpLocation(data) || changed;
1107
+ }
1108
+ i = end + (size & 1);
1109
+ }
1110
+ return changed;
1111
+ }
1112
+ // ---------------------------------------------------------------------------
1113
+ // HEIF (HEIC, AVIF)
1114
+ /**
1115
+ * `ftyp` brands of HEIF still images and sequences, HEIC and AVIF alike
1116
+ * (ISO/IEC 23008-12, AV1 Image File Format).
1117
+ */
1118
+ const HEIF_BRANDS = new Set([
1119
+ 'heic',
1120
+ 'heix',
1121
+ 'heim',
1122
+ 'heis',
1123
+ 'hevc',
1124
+ 'hevx',
1125
+ 'hevm',
1126
+ 'hevs',
1127
+ 'mif1',
1128
+ 'mif2',
1129
+ 'msf1',
1130
+ 'avif',
1131
+ 'avis',
1132
+ ]);
1133
+ /**
1134
+ * Whether [b] is a HEIF file (HEIC, HEIF or AVIF), by the `ftyp` box's
1135
+ * major or compatible brands.
1136
+ */
1137
+ export function isHeifContainer(b) {
1138
+ if (b.length < 16 || latin1(b, 4, 8) !== 'ftyp')
1139
+ return false;
1140
+ const size = view(b).getUint32(0);
1141
+ if (size < 16 || size > b.length)
1142
+ return false;
1143
+ if (HEIF_BRANDS.has(latin1(b, 8, 12)))
1144
+ return true;
1145
+ for (let i = 16; i + 4 <= size; i += 4) {
1146
+ if (HEIF_BRANDS.has(latin1(b, i, i + 4)))
1147
+ return true;
1148
+ }
1149
+ return false;
1150
+ }
1151
+ /**
1152
+ * Removes the location metadata from a HEIF file, in place, without
1153
+ * changing its length: the `Exif` item's GPS IFD (stripTiffLocation) and
1154
+ * the XMP item's location properties (stripXmpLocation), found through the
1155
+ * `meta` box's `iinf` and `iloc`. The image items aren't touched.
1156
+ *
1157
+ * Throws LocationUnreadableError when the `meta` box can't be read, since
1158
+ * the metadata can't be found then. Returns whether anything changed.
1159
+ */
1160
+ function stripHeifLocation(heif) {
1161
+ const reader = new HeifReader(heif);
1162
+ let meta;
1163
+ for (const box of reader.boxes(0, heif.length)) {
1164
+ if (box.type === 'meta') {
1165
+ meta = box;
1166
+ break;
1167
+ }
1168
+ }
1169
+ if (meta === undefined)
1170
+ return false;
1171
+ const children = [...reader.boxes(meta.content + 4, meta.end)];
1172
+ const iinf = children.find((b) => b.type === 'iinf');
1173
+ const iloc = children.find((b) => b.type === 'iloc');
1174
+ const idat = children.find((b) => b.type === 'idat');
1175
+ if (iinf === undefined)
1176
+ return false;
1177
+ const items = reader.metadataItems(iinf);
1178
+ if (items.size === 0)
1179
+ return false;
1180
+ if (iloc === undefined)
1181
+ throw new LocationUnreadableError('HEIF: no iloc');
1182
+ const locations = reader.locations(iloc, new Set(items.keys()), idat);
1183
+ let changed = false;
1184
+ for (const [id, kind] of items) {
1185
+ const extents = locations.get(id);
1186
+ if (extents === undefined)
1187
+ continue;
1188
+ const payload = gather(heif, extents);
1189
+ let edited;
1190
+ switch (kind) {
1191
+ case 'exif':
1192
+ edited = stripExifItem(payload);
1193
+ break;
1194
+ case 'xmp':
1195
+ edited = stripXmpLocation(payload);
1196
+ break;
1197
+ case 'encodedXmp':
1198
+ edited = zero(payload);
1199
+ break;
1200
+ }
1201
+ if (edited) {
1202
+ scatter(heif, extents, payload);
1203
+ changed = true;
1204
+ }
1205
+ }
1206
+ return changed;
1207
+ }
1208
+ /** An Exif item: a 4-byte offset to the TIFF header, then the TIFF. */
1209
+ function stripExifItem(payload) {
1210
+ if (payload.length < 4)
1211
+ return zero(payload);
1212
+ const offset = view(payload).getUint32(0);
1213
+ if (4 + offset >= payload.length)
1214
+ return zero(payload);
1215
+ return stripTiffLocation(payload.subarray(4 + offset), true);
1216
+ }
1217
+ /** A copy of the bytes of [extents] (start, end), one after the other. */
1218
+ function gather(b, extents) {
1219
+ let total = 0;
1220
+ for (const [start, end] of extents)
1221
+ total += end - start;
1222
+ const out = new Uint8Array(total);
1223
+ let at = 0;
1224
+ for (const [start, end] of extents) {
1225
+ out.set(b.subarray(start, end), at);
1226
+ at += end - start;
1227
+ }
1228
+ return out;
1229
+ }
1230
+ function scatter(b, extents, payload) {
1231
+ let at = 0;
1232
+ for (const [start, end] of extents) {
1233
+ b.set(payload.subarray(at, at + end - start), start);
1234
+ at += end - start;
1235
+ }
1236
+ }
1237
+ class HeifReader {
1238
+ b;
1239
+ d;
1240
+ constructor(b) {
1241
+ this.b = b;
1242
+ this.d = view(b);
1243
+ }
1244
+ static bad(what) {
1245
+ throw new LocationUnreadableError(`HEIF: ${what}`);
1246
+ }
1247
+ need(end, limit) {
1248
+ if (end > limit)
1249
+ HeifReader.bad('truncated box');
1250
+ }
1251
+ u8(at, limit) {
1252
+ this.need(at + 1, limit);
1253
+ return byte(this.b, at);
1254
+ }
1255
+ u16(at, limit) {
1256
+ this.need(at + 2, limit);
1257
+ return this.d.getUint16(at);
1258
+ }
1259
+ u32(at, limit) {
1260
+ this.need(at + 4, limit);
1261
+ return this.d.getUint32(at);
1262
+ }
1263
+ /** An unsigned [size]-byte number (0, 4 or 8); 64-bit numbers as two 32-bit halves. */
1264
+ uN(at, size, limit) {
1265
+ switch (size) {
1266
+ case 0:
1267
+ return 0;
1268
+ case 4:
1269
+ return this.u32(at, limit);
1270
+ case 8:
1271
+ return this.u32(at, limit) * 0x100000000 + this.u32(at + 4, limit);
1272
+ default:
1273
+ return HeifReader.bad(`field size ${size}`);
1274
+ }
1275
+ }
1276
+ /** The boxes from [from] to [to]. Throws at a box that runs past [to]. */
1277
+ *boxes(from, to) {
1278
+ let i = from;
1279
+ while (i + 8 <= to) {
1280
+ let size = this.d.getUint32(i);
1281
+ const type = latin1(this.b, i + 4, i + 8);
1282
+ let header = 8;
1283
+ if (size === 1) {
1284
+ size = this.uN(i + 8, 8, to);
1285
+ header = 16;
1286
+ }
1287
+ else if (size === 0) {
1288
+ size = to - i;
1289
+ }
1290
+ if (size < header || i + size > to)
1291
+ HeifReader.bad(`box ${type} size`);
1292
+ yield { type, content: i + header, end: i + size };
1293
+ i += size;
1294
+ }
1295
+ }
1296
+ /** A NUL-terminated string at [at]; returns it and where it ends. */
1297
+ cString(at, limit) {
1298
+ let i = at;
1299
+ while (i < limit && this.b[i] !== 0)
1300
+ i++;
1301
+ return [latin1(this.b, at, i), i < limit ? i + 1 : i];
1302
+ }
1303
+ /** The Exif and XMP items `iinf` lists, by item ID. */
1304
+ metadataItems(iinf) {
1305
+ const version = this.u8(iinf.content, iinf.end);
1306
+ const first = iinf.content + 4 + (version === 0 ? 2 : 4);
1307
+ this.need(first, iinf.end);
1308
+ const items = new Map();
1309
+ for (const infe of this.boxes(first, iinf.end)) {
1310
+ if (infe.type !== 'infe')
1311
+ continue;
1312
+ const v = this.u8(infe.content, infe.end);
1313
+ if (v < 2)
1314
+ continue; // versions 0 and 1 have no item type
1315
+ let p = infe.content + 4;
1316
+ const id = v === 2 ? this.u16(p, infe.end) : this.u32(p, infe.end);
1317
+ p += (v === 2 ? 2 : 4) + 2; // item ID, protection index
1318
+ this.need(p + 4, infe.end);
1319
+ const type = latin1(this.b, p, p + 4);
1320
+ p += 4;
1321
+ if (type === 'Exif') {
1322
+ items.set(id, 'exif');
1323
+ }
1324
+ else if (type === 'mime') {
1325
+ const [, afterName] = this.cString(p, infe.end);
1326
+ const [contentType, afterType] = this.cString(afterName, infe.end);
1327
+ const [encoding] = this.cString(afterType, infe.end);
1328
+ if (contentType === 'application/rdf+xml') {
1329
+ items.set(id, encoding.length === 0 ? 'xmp' : 'encodedXmp');
1330
+ }
1331
+ }
1332
+ }
1333
+ return items;
1334
+ }
1335
+ /**
1336
+ * Where each of [wanted]'s items is in the file, as (start, end) extents,
1337
+ * from `iloc`. Extents are cut at the end of the file.
1338
+ */
1339
+ locations(iloc, wanted, idat) {
1340
+ const end = iloc.end;
1341
+ const version = this.u8(iloc.content, end);
1342
+ let p = iloc.content + 4;
1343
+ const sizes = this.u16(p, end);
1344
+ const offsetSize = sizes >> 12;
1345
+ const lengthSize = (sizes >> 8) & 0xf;
1346
+ const baseSize = (sizes >> 4) & 0xf;
1347
+ const indexSize = version === 1 || version === 2 ? sizes & 0xf : 0;
1348
+ p += 2;
1349
+ const count = version < 2 ? this.u16(p, end) : this.u32(p, end);
1350
+ p += version < 2 ? 2 : 4;
1351
+ const result = new Map();
1352
+ for (let n = 0; n < count; n++) {
1353
+ const id = version < 2 ? this.u16(p, end) : this.u32(p, end);
1354
+ p += version < 2 ? 2 : 4;
1355
+ let method = 0;
1356
+ if (version === 1 || version === 2) {
1357
+ method = this.u16(p, end) & 0xf;
1358
+ p += 2;
1359
+ }
1360
+ const reference = this.u16(p, end);
1361
+ p += 2;
1362
+ const base = this.uN(p, baseSize, end);
1363
+ p += baseSize;
1364
+ const extentCount = this.u16(p, end);
1365
+ p += 2;
1366
+ const extents = [];
1367
+ for (let e = 0; e < extentCount; e++) {
1368
+ p += indexSize;
1369
+ const offset = this.uN(p, offsetSize, end);
1370
+ p += offsetSize;
1371
+ const length = this.uN(p, lengthSize, end);
1372
+ p += lengthSize;
1373
+ extents.push([offset, length]);
1374
+ }
1375
+ if (!wanted.has(id))
1376
+ continue;
1377
+ if (reference !== 0)
1378
+ HeifReader.bad(`item ${id} in another file`);
1379
+ let origin;
1380
+ let limit;
1381
+ switch (method) {
1382
+ case 0:
1383
+ origin = base;
1384
+ limit = this.b.length;
1385
+ break;
1386
+ case 1:
1387
+ if (idat === undefined)
1388
+ return HeifReader.bad('no idat');
1389
+ origin = idat.content + base;
1390
+ limit = idat.end;
1391
+ break;
1392
+ default:
1393
+ return HeifReader.bad(`construction method ${method}`);
1394
+ }
1395
+ const spans = [];
1396
+ for (const [offset, length] of extents) {
1397
+ if (origin + offset >= limit)
1398
+ continue;
1399
+ spans.push([
1400
+ origin + offset,
1401
+ length === 0 || origin + offset + length > limit ? limit : origin + offset + length,
1402
+ ]);
1403
+ }
1404
+ result.set(id, spans);
1405
+ }
1406
+ return result;
1407
+ }
1408
+ }
1409
+ // ---------------------------------------------------------------------------
1410
+ // Movies (QuickTime, ISO BMFF)
1411
+ /**
1412
+ * Box types a QuickTime movie or an ISO BMFF file (MP4, M4V, M4A, 3GP)
1413
+ * starts with. HEIF images start with `ftyp` too, and are told apart first
1414
+ * (isHeifContainer).
1415
+ */
1416
+ const FIRST_BOXES = new Set(['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip', 'pnot']);
1417
+ /** The `uuid` of an XMP box (XMP Specification Part 3). */
1418
+ const XMP_UUID = [
1419
+ 0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac,
1420
+ ];
1421
+ /** The QuickTime location atom's type, `©xyz` (0xA9 then "xyz"). */
1422
+ const ATOM_XYZ = '©xyz';
1423
+ const FREE = [0x66, 0x72, 0x65, 0x65];
1424
+ /** Whether [b] is a QuickTime movie or an ISO BMFF file, by its first box. */
1425
+ export function isMovie(b) {
1426
+ if (b.length < 8 || !FIRST_BOXES.has(latin1(b, 4, 8)))
1427
+ return false;
1428
+ const size = view(b, 0, 4).getUint32(0);
1429
+ return size <= 1 || size >= 8;
1430
+ }
1431
+ /**
1432
+ * Whether a HEIF file also holds a movie (an image sequence's `moov`).
1433
+ * Lenient: stops at the first box it can't read.
1434
+ */
1435
+ function hasMovieBox(b) {
1436
+ const d = view(b);
1437
+ for (let i = 0; i + 8 <= b.length;) {
1438
+ if (latin1(b, i + 4, i + 8) === 'moov')
1439
+ return true;
1440
+ const size = d.getUint32(i);
1441
+ if (size < 8)
1442
+ return false;
1443
+ i += size;
1444
+ }
1445
+ return false;
1446
+ }
1447
+ /**
1448
+ * [movie] without its location metadata, or null if it has none. [movie]
1449
+ * is never modified, and is only copied when something changes.
1450
+ *
1451
+ * Lossless and in place: no box moves or changes size, so the chunk
1452
+ * offsets (`stco`, `co64`) stay valid, and the media is never re-encoded.
1453
+ * What goes, in `moov`, its `trak`s, and their `udta` and `meta` boxes:
1454
+ * - `©xyz` (QuickTime and Android) and 3GPP `loci` atoms become `free`
1455
+ * boxes of the same size, zeroed;
1456
+ * - in a `meta` box's `ilst`, location items (by their `keys` name, such
1457
+ * as `com.apple.quicktime.location.ISO6709`, or a `©xyz` item) become
1458
+ * zeroed `free` boxes, which readers skip; their key stays in `keys`;
1459
+ * - XMP location properties (stripXmpLocation) in `udta/XMP_` and in XMP
1460
+ * `uuid` boxes, at the top level too.
1461
+ *
1462
+ * A `udta` or `meta` box that can't be read becomes a zeroed `free` box
1463
+ * whole. Throws LocationUnreadableError when the location can't be looked
1464
+ * for: the top-level boxes, or the `moov` and `trak` boxes, can't be
1465
+ * walked. The last top-level box may be cut off by the end of the file (a
1466
+ * truncated `mdat`); a file cut before its `moov` has no location left.
1467
+ */
1468
+ function stripMovieLocation(movie) {
1469
+ const scan = new MovieScanner(movie);
1470
+ scan.scan();
1471
+ if (scan.frees.length === 0 && scan.writes.length === 0)
1472
+ return null;
1473
+ const out = copyOf(movie);
1474
+ for (const [at, bytes] of scan.writes)
1475
+ out.set(bytes, at);
1476
+ for (const box of scan.frees) {
1477
+ out.set(FREE, box.start + 4);
1478
+ out.fill(0, box.content, box.end);
1479
+ }
1480
+ return out;
1481
+ }
1482
+ /**
1483
+ * Whether the key of a QuickTime metadata item names a location: Apple's
1484
+ * `com.apple.quicktime.location.*`, ffmpeg's `location`, and anything
1485
+ * naming GPS, a latitude or a longitude.
1486
+ */
1487
+ function isMovieLocationKey(key) {
1488
+ const k = key.toLowerCase();
1489
+ return (k.includes('location') ||
1490
+ k.includes('iso6709') ||
1491
+ k.includes('gps') ||
1492
+ k.includes('latitude') ||
1493
+ k.includes('longitude'));
1494
+ }
1495
+ class MovieScanner {
1496
+ b;
1497
+ d;
1498
+ /** Boxes to turn into zeroed `free` boxes. */
1499
+ frees = [];
1500
+ /** Bytes to write over the file, at an offset. */
1501
+ writes = [];
1502
+ constructor(b) {
1503
+ this.b = b;
1504
+ this.d = view(b);
1505
+ }
1506
+ static bad(what) {
1507
+ throw new LocationUnreadableError(`Movie: ${what}`);
1508
+ }
1509
+ scan() {
1510
+ for (const box of this.boxes(0, this.b.length, true)) {
1511
+ switch (box.type) {
1512
+ case 'moov':
1513
+ this.container(box, true);
1514
+ break;
1515
+ case 'udta':
1516
+ this.udta(box);
1517
+ break;
1518
+ case 'meta':
1519
+ this.meta(box);
1520
+ break;
1521
+ case 'uuid':
1522
+ this.uuid(box);
1523
+ break;
1524
+ }
1525
+ }
1526
+ }
1527
+ /** `moov` or a `trak`, which must be readable. */
1528
+ container(parent, moov) {
1529
+ for (const box of this.boxes(parent.content, parent.end)) {
1530
+ if (box.type === 'trak' && moov)
1531
+ this.container(box, false);
1532
+ else if (box.type === 'udta')
1533
+ this.udta(box);
1534
+ else if (box.type === 'meta')
1535
+ this.meta(box);
1536
+ else if (box.type === 'uuid')
1537
+ this.uuid(box);
1538
+ }
1539
+ }
1540
+ udta(udta) {
1541
+ this.orFree(udta, () => {
1542
+ for (const box of this.boxes(udta.content, udta.end)) {
1543
+ if (box.type === ATOM_XYZ || box.type === 'loci')
1544
+ this.frees.push(box);
1545
+ else if (box.type === 'XMP_')
1546
+ this.xmp(box.content, box.end);
1547
+ else if (box.type === 'meta')
1548
+ this.meta(box);
1549
+ else if (box.type === 'uuid')
1550
+ this.uuid(box);
1551
+ }
1552
+ });
1553
+ }
1554
+ /** A QuickTime `meta` (no version) or an ISO one (a full box), with `keys` and `ilst`. */
1555
+ meta(meta) {
1556
+ this.orFree(meta, () => {
1557
+ let from = meta.content;
1558
+ // An ISO full box's version and flags are zero; a QuickTime box's
1559
+ // first child (`hdlr`) has a size there.
1560
+ if (this.u32(from, meta.end) === 0)
1561
+ from += 4;
1562
+ let keys = null;
1563
+ const ilsts = [];
1564
+ for (const box of this.boxes(from, meta.end)) {
1565
+ if (box.type === 'keys')
1566
+ keys = this.keys(box);
1567
+ else if (box.type === 'ilst')
1568
+ ilsts.push(box);
1569
+ else if (box.type === 'uuid')
1570
+ this.uuid(box);
1571
+ }
1572
+ for (const ilst of ilsts) {
1573
+ for (const item of this.boxes(ilst.content, ilst.end)) {
1574
+ if (this.isLocationItem(item, keys))
1575
+ this.frees.push(item);
1576
+ }
1577
+ }
1578
+ });
1579
+ }
1580
+ isLocationItem(item, keys) {
1581
+ if (item.type === ATOM_XYZ)
1582
+ return true;
1583
+ if (keys === null)
1584
+ return false;
1585
+ // In a `keys` list's `ilst`, an item's type is its 1-based key index.
1586
+ const index = this.d.getUint32(item.start + 4);
1587
+ const key = index >= 1 && index <= keys.length ? keys[index - 1] : undefined;
1588
+ return key !== undefined && isMovieLocationKey(key);
1589
+ }
1590
+ /** A `keys` box's key names, without their namespace (`mdta`). */
1591
+ keys(keys) {
1592
+ const count = this.u32(keys.content + 4, keys.end);
1593
+ const names = [];
1594
+ let p = keys.content + 8;
1595
+ for (let n = 0; n < count; n++) {
1596
+ const size = this.u32(p, keys.end);
1597
+ if (size < 8 || p + size > keys.end)
1598
+ MovieScanner.bad('key size');
1599
+ names.push(latin1(this.b, p + 8, p + size));
1600
+ p += size;
1601
+ }
1602
+ return names;
1603
+ }
1604
+ uuid(box) {
1605
+ if (box.end - box.content < 16)
1606
+ return;
1607
+ for (let k = 0; k < 16; k++) {
1608
+ if (this.b[box.content + k] !== XMP_UUID[k])
1609
+ return;
1610
+ }
1611
+ this.xmp(box.content + 16, box.end);
1612
+ }
1613
+ xmp(from, to) {
1614
+ const packet = copyOf(this.b.subarray(from, to));
1615
+ if (stripXmpLocation(packet))
1616
+ this.writes.push([from, packet]);
1617
+ }
1618
+ /**
1619
+ * Runs [scan] over [box]; if [box] can't be read, it's freed whole
1620
+ * instead, and whatever [scan] found inside it is dropped.
1621
+ */
1622
+ orFree(box, scan) {
1623
+ const f = this.frees.length;
1624
+ const w = this.writes.length;
1625
+ try {
1626
+ scan();
1627
+ }
1628
+ catch (e) {
1629
+ if (!(e instanceof LocationUnreadableError))
1630
+ throw e;
1631
+ this.frees.length = f;
1632
+ this.writes.length = w;
1633
+ this.frees.push(box);
1634
+ }
1635
+ }
1636
+ u32(at, limit) {
1637
+ if (at + 4 > limit)
1638
+ MovieScanner.bad('truncated box');
1639
+ return this.d.getUint32(at);
1640
+ }
1641
+ /**
1642
+ * The boxes from [from] to [to]. A box that runs past [to] is an error,
1643
+ * except at the [topLevel], where the file may be cut off: the last box
1644
+ * then ends with the file. Top-level types must be printable, so a walk
1645
+ * that strays into media data stops instead of skipping over `moov`.
1646
+ */
1647
+ *boxes(from, to, topLevel = false) {
1648
+ const b = this.b;
1649
+ const d = this.d;
1650
+ let i = from;
1651
+ while (i + 8 <= to) {
1652
+ const type = latin1(b, i + 4, i + 8);
1653
+ if (topLevel && !this.printable(i + 4))
1654
+ MovieScanner.bad('box type');
1655
+ let size = d.getUint32(i);
1656
+ let header = 8;
1657
+ if (size === 1) {
1658
+ if (i + 16 > to) {
1659
+ if (topLevel)
1660
+ return;
1661
+ MovieScanner.bad(`box ${type} size`);
1662
+ }
1663
+ // As two 32-bit halves.
1664
+ size = d.getUint32(i + 8) * 0x100000000 + d.getUint32(i + 12);
1665
+ header = 16;
1666
+ }
1667
+ else if (size === 0) {
1668
+ size = to - i;
1669
+ }
1670
+ if (size < header)
1671
+ MovieScanner.bad(`box ${type} size`);
1672
+ if (size > to - i) {
1673
+ if (!topLevel)
1674
+ MovieScanner.bad(`box ${type} size`);
1675
+ size = to - i;
1676
+ }
1677
+ yield { type, start: i, content: i + header, end: i + size };
1678
+ i += size;
1679
+ }
1680
+ }
1681
+ printable(at) {
1682
+ for (let k = at; k < at + 4; k++) {
1683
+ const c = byte(this.b, k);
1684
+ if (c < 0x20 || c > 0x7e)
1685
+ return false;
1686
+ }
1687
+ return true;
1688
+ }
1689
+ }
1690
+ // ---------------------------------------------------------------------------
1691
+ // A video kept as a file
1692
+ /**
1693
+ * The largest top-level box read into memory to look for a location: 256
1694
+ * MiB. A `moov` that large would need hours of video; a larger one is
1695
+ * refused rather than sent with its location.
1696
+ */
1697
+ export const maxLocationBoxBytes = 256 * 1024 * 1024;
1698
+ /** The top-level boxes a location can be in (as stripMovieLocation looks). */
1699
+ const BOXES_WITH_LOCATION = new Set(['moov', 'udta', 'meta', 'uuid']);
1700
+ /**
1701
+ * The patches that remove the location from the video file at [path], as
1702
+ * stripLocation removes it from a video in memory: the file is never read
1703
+ * whole. The top-level boxes are walked by their headers (`mdat` skipped
1704
+ * by its size), and only `moov`, `udta`, `meta` and `uuid` boxes are read,
1705
+ * one at a time, and scanned the same way. Empty when there's no location,
1706
+ * or when the file isn't a QuickTime or ISO BMFF movie (WebM, Matroska and
1707
+ * AVI go as they are).
1708
+ *
1709
+ * Throws LocationUnreadableError when the location can't be looked for
1710
+ * (the top-level boxes, or a `moov` or `trak`, can't be walked, or a box is
1711
+ * over [maxBoxBytes]): the video should then be refused, never sent with
1712
+ * its location. A file that can't be read rejects with the file system's
1713
+ * own error.
1714
+ */
1715
+ export async function videoLocationPatches(path, opts = {}) {
1716
+ const maxBoxBytes = opts.maxBoxBytes ?? maxLocationBoxBytes;
1717
+ const file = await open(path, 'r');
1718
+ try {
1719
+ const length = (await file.stat()).size;
1720
+ const head = await readAt(file, 0, Math.min(64, length));
1721
+ if (isHeifContainer(head) || !isMovie(head))
1722
+ return [];
1723
+ const patches = [];
1724
+ let i = 0;
1725
+ while (i + 8 <= length) {
1726
+ const header = await readAt(file, i, Math.min(16, length - i));
1727
+ const type = latin1(header, 4, 8);
1728
+ for (let k = 4; k < 8; k++) {
1729
+ const c = byte(header, k);
1730
+ if (c < 0x20 || c > 0x7e)
1731
+ throw new LocationUnreadableError('Movie: box type');
1732
+ }
1733
+ const d = view(header);
1734
+ let size = d.getUint32(0);
1735
+ let headerSize = 8;
1736
+ if (size === 1) {
1737
+ // The file is cut off in the middle of a 64-bit size: nothing more.
1738
+ if (header.length < 16)
1739
+ break;
1740
+ // As two 32-bit halves.
1741
+ size = d.getUint32(8) * 0x100000000 + d.getUint32(12);
1742
+ headerSize = 16;
1743
+ }
1744
+ else if (size === 0) {
1745
+ size = length - i;
1746
+ }
1747
+ if (size < headerSize)
1748
+ throw new LocationUnreadableError(`Movie: box ${type} size`);
1749
+ // The last box may be cut off by the end of the file.
1750
+ if (size > length - i)
1751
+ size = length - i;
1752
+ if (BOXES_WITH_LOCATION.has(type)) {
1753
+ if (size > maxBoxBytes)
1754
+ throw new LocationUnreadableError(`Movie: ${type} too large`);
1755
+ const box = await readAt(file, i, size);
1756
+ const stripped = stripMovieLocation(box);
1757
+ if (stripped !== null) {
1758
+ const patch = diffPatch(i, box, stripped);
1759
+ if (patch !== null)
1760
+ patches.push(patch);
1761
+ }
1762
+ }
1763
+ i += size;
1764
+ }
1765
+ return patches;
1766
+ }
1767
+ finally {
1768
+ await file.close();
1769
+ }
1770
+ }
1771
+ /**
1772
+ * Copies the file at [src] to a new file at [dest] (created with mode 0600,
1773
+ * and never over an existing file), chunk by chunk, with [patches] written
1774
+ * over it as it's read: a copy without the location, never in memory
1775
+ * whole. [src] isn't changed. If the copy fails, the partial [dest] is
1776
+ * removed.
1777
+ */
1778
+ export async function copyWithPatches(src, dest, patches) {
1779
+ const out = await open(dest, 'wx', 0o600);
1780
+ let done = false;
1781
+ try {
1782
+ let offset = 0;
1783
+ for await (const chunk of createReadStream(src)) {
1784
+ const bytes = patches.length === 0 ? chunk : patchChunk(chunk, offset, patches);
1785
+ await writeAll(out, bytes);
1786
+ offset += chunk.length;
1787
+ }
1788
+ if (patches.some((p) => p.offset + p.bytes.length > offset)) {
1789
+ throw new RangeError('patch past the end of the file');
1790
+ }
1791
+ done = true;
1792
+ }
1793
+ finally {
1794
+ await out.close();
1795
+ if (!done)
1796
+ await rm(dest, { force: true });
1797
+ }
1798
+ }
1799
+ async function writeAll(file, bytes) {
1800
+ let written = 0;
1801
+ while (written < bytes.length) {
1802
+ const { bytesWritten } = await file.write(bytes, written, bytes.length - written);
1803
+ written += bytesWritten;
1804
+ }
1805
+ }
1806
+ /**
1807
+ * [chunk], which starts at [start] in its file, with the parts of
1808
+ * [patches] that fall in it written over it: the same array when none
1809
+ * does, else a copy.
1810
+ */
1811
+ export function patchChunk(chunk, start, patches) {
1812
+ const end = start + chunk.length;
1813
+ let out = null;
1814
+ for (const p of patches) {
1815
+ const pEnd = p.offset + p.bytes.length;
1816
+ if (pEnd <= start || p.offset >= end)
1817
+ continue;
1818
+ out ??= copyOf(chunk);
1819
+ const from = p.offset > start ? p.offset : start;
1820
+ const to = pEnd < end ? pEnd : end;
1821
+ out.set(p.bytes.subarray(from - p.offset, to - p.offset), from - start);
1822
+ }
1823
+ return out ?? chunk;
1824
+ }
1825
+ /**
1826
+ * The one range where [before] and [after] (the same length) differ, as a
1827
+ * patch at [offset] plus its start; null when they're the same.
1828
+ */
1829
+ export function diffPatch(offset, before, after) {
1830
+ if (before.length !== after.length)
1831
+ throw new RangeError('diffPatch: lengths differ');
1832
+ let first = 0;
1833
+ while (first < before.length && before[first] === after[first])
1834
+ first++;
1835
+ if (first === before.length)
1836
+ return null;
1837
+ let last = before.length - 1;
1838
+ while (last > first && before[last] === after[last])
1839
+ last--;
1840
+ return { offset: offset + first, bytes: copyOf(after.subarray(first, last + 1)) };
1841
+ }
1842
+ /** [count] bytes of [file] from [at]; LocationUnreadableError if the file is shorter. */
1843
+ async function readAt(file, at, count) {
1844
+ const bytes = new Uint8Array(count);
1845
+ let got = 0;
1846
+ while (got < count) {
1847
+ const { bytesRead } = await file.read(bytes, got, count - got, at + got);
1848
+ if (bytesRead === 0)
1849
+ break;
1850
+ got += bytesRead;
1851
+ }
1852
+ if (got < count)
1853
+ throw new LocationUnreadableError('Movie: truncated');
1854
+ return bytes;
1855
+ }