@crewhaus/tool-media 0.7.0 → 0.8.1
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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +34 -8
- package/dist/index.js +149 -26
- package/dist/lib/color.d.ts +2 -0
- package/dist/lib/color.js +17 -3
- package/dist/lib/jpeg.d.ts +141 -3
- package/dist/lib/jpeg.js +527 -10
- package/dist/lib/subtitle.d.ts +13 -0
- package/dist/lib/subtitle.js +27 -7
- package/dist/paths.d.ts +21 -2
- package/dist/paths.js +72 -13
- package/package.json +10 -6
- package/dist/fixtures.d.ts +0 -81
- package/dist/fixtures.js +0 -385
package/dist/lib/jpeg.js
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* ## What this does not read
|
|
14
14
|
*
|
|
15
15
|
* MakerNotes (vendor-private, undocumented, and different per camera),
|
|
16
|
-
* IFD1 thumbnails,
|
|
17
|
-
*
|
|
16
|
+
* IFD1 thumbnails, and EXIF carried in a TIFF or HEIC rather than a JPEG.
|
|
17
|
+
* Each would be a separate parser; none is approximated. XMP and Photoshop
|
|
18
|
+
* resource blocks are searched for a location (see `otherMetadataOf`), not
|
|
19
|
+
* parsed, and any other APPn segment is reported as not read.
|
|
18
20
|
*/
|
|
19
21
|
import { ByteReader, MediaFormatError, asciiAt, startsWith } from "./bytes";
|
|
20
22
|
function markerName(marker) {
|
|
@@ -97,6 +99,167 @@ export function readJpegSegments(bytes) {
|
|
|
97
99
|
}
|
|
98
100
|
throw new MediaFormatError("the file ends before the start-of-scan marker");
|
|
99
101
|
}
|
|
102
|
+
/** More marker segments than any real JPEG has; past it the file is refused. */
|
|
103
|
+
const MAX_JPEG_SEGMENTS = 65_536;
|
|
104
|
+
/**
|
|
105
|
+
* Walk the WHOLE codestream: every marker segment, including those between
|
|
106
|
+
* the scans of a progressive or multi-scan file, to EOI.
|
|
107
|
+
*
|
|
108
|
+
* `readJpegSegments` stops at the first start-of-scan, which is all a header
|
|
109
|
+
* reader needs, and 0.7.0's ExifStrip copied everything after that point
|
|
110
|
+
* verbatim. But an APPn or COM segment may sit between scans, and a camera
|
|
111
|
+
* appends whole images after EOI (MPF previews, HDR gain maps, stereo pairs)
|
|
112
|
+
* and phones append motion-photo video; each can carry its own EXIF and GPS.
|
|
113
|
+
*
|
|
114
|
+
* Inside a scan, `0xFF` followed by `0x00` (stuffing), `0xD0`–`0xD7` (restart
|
|
115
|
+
* markers) or another `0xFF` (fill) belongs to the scan; any other `0xFF xx`
|
|
116
|
+
* is the next marker. The walk is linear: the scan search is a native
|
|
117
|
+
* `indexOf` from where the last one stopped.
|
|
118
|
+
*
|
|
119
|
+
* A broken header (before the first scan) is refused. Past the first scan
|
|
120
|
+
* the picture is already there, and a file cut short there — a partial
|
|
121
|
+
* download, a truncated progressive JPEG — is still a photo: the walk stops
|
|
122
|
+
* at the break and says where (`stopped`), rather than refusing a file
|
|
123
|
+
* 0.7.0 read, which only ever looked as far as the first scan.
|
|
124
|
+
*/
|
|
125
|
+
export function walkJpeg(bytes) {
|
|
126
|
+
if (!startsWith(bytes, [0xff, 0xd8])) {
|
|
127
|
+
throw new MediaFormatError("not a JPEG: the file does not start with FFD8 (SOI)");
|
|
128
|
+
}
|
|
129
|
+
const parts = [];
|
|
130
|
+
const segments = [];
|
|
131
|
+
const push = (segment) => {
|
|
132
|
+
if (segments.length >= MAX_JPEG_SEGMENTS) {
|
|
133
|
+
throw new MediaFormatError(`the file has more than ${MAX_JPEG_SEGMENTS} marker segments`);
|
|
134
|
+
}
|
|
135
|
+
segments.push(segment);
|
|
136
|
+
parts.push({ type: "segment", segment });
|
|
137
|
+
};
|
|
138
|
+
let pos = 2;
|
|
139
|
+
let sawScan = false;
|
|
140
|
+
// Past the first scan a framing error ends the walk; before it, the file.
|
|
141
|
+
const broken = (offset, reason) => {
|
|
142
|
+
if (!sawScan)
|
|
143
|
+
throw new MediaFormatError(reason);
|
|
144
|
+
return { parts, segments, eoiEnd: null, trailing: 0, stopped: { offset, reason } };
|
|
145
|
+
};
|
|
146
|
+
while (pos < bytes.length) {
|
|
147
|
+
const start = pos;
|
|
148
|
+
if (bytes[pos] !== 0xff) {
|
|
149
|
+
return broken(start, `expected a marker at offset ${start}, found 0x${bytes[pos].toString(16)}`);
|
|
150
|
+
}
|
|
151
|
+
pos++;
|
|
152
|
+
while (pos < bytes.length && bytes[pos] === 0xff)
|
|
153
|
+
pos++; // fill bytes
|
|
154
|
+
if (pos >= bytes.length)
|
|
155
|
+
break; // a lone trailing 0xFF: nothing follows
|
|
156
|
+
const marker = bytes[pos];
|
|
157
|
+
pos++;
|
|
158
|
+
if (marker === 0xd9) {
|
|
159
|
+
push({ marker, name: "EOI", offset: start, length: pos - start, payload: new Uint8Array(0) });
|
|
160
|
+
return { parts, segments, eoiEnd: pos, trailing: bytes.length - pos, stopped: null };
|
|
161
|
+
}
|
|
162
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
163
|
+
push({
|
|
164
|
+
marker,
|
|
165
|
+
name: markerName(marker),
|
|
166
|
+
offset: start,
|
|
167
|
+
length: pos - start,
|
|
168
|
+
payload: new Uint8Array(0),
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (marker === 0xd8) {
|
|
173
|
+
return broken(start, `a second start-of-image marker at offset ${start}`);
|
|
174
|
+
}
|
|
175
|
+
if (pos + 2 > bytes.length) {
|
|
176
|
+
return broken(start, `segment ${markerName(marker)} at ${start} is cut off`);
|
|
177
|
+
}
|
|
178
|
+
const length = (bytes[pos] << 8) | bytes[pos + 1];
|
|
179
|
+
if (length < 2) {
|
|
180
|
+
return broken(start, `segment ${markerName(marker)} declares a length of ${length}`);
|
|
181
|
+
}
|
|
182
|
+
if (pos + length > bytes.length) {
|
|
183
|
+
return broken(start, `segment ${markerName(marker)} at ${start} runs ${pos + length - bytes.length} bytes past the end of the file`);
|
|
184
|
+
}
|
|
185
|
+
push({
|
|
186
|
+
marker,
|
|
187
|
+
name: markerName(marker),
|
|
188
|
+
offset: start,
|
|
189
|
+
length: pos + length - start,
|
|
190
|
+
payload: bytes.subarray(pos + 2, pos + length),
|
|
191
|
+
});
|
|
192
|
+
pos += length;
|
|
193
|
+
if (marker !== 0xda)
|
|
194
|
+
continue;
|
|
195
|
+
sawScan = true;
|
|
196
|
+
// Entropy-coded data, up to the next real marker.
|
|
197
|
+
const dataStart = pos;
|
|
198
|
+
for (;;) {
|
|
199
|
+
const ff = bytes.indexOf(0xff, pos);
|
|
200
|
+
if (ff === -1 || ff + 1 >= bytes.length) {
|
|
201
|
+
pos = bytes.length;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
const next = bytes[ff + 1];
|
|
205
|
+
if (next === 0x00 || (next >= 0xd0 && next <= 0xd7)) {
|
|
206
|
+
pos = ff + 2;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (next === 0xff) {
|
|
210
|
+
pos = ff + 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
pos = ff;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
parts.push({ type: "scan", start: dataStart, end: pos });
|
|
217
|
+
}
|
|
218
|
+
return { parts, segments, eoiEnd: null, trailing: 0, stopped: null };
|
|
219
|
+
}
|
|
220
|
+
/** Whether `marker` opens a segment ExifStrip treats as metadata by default (APPn, COM). */
|
|
221
|
+
function isMetadataMarker(marker) {
|
|
222
|
+
return marker !== undefined && ((marker >= 0xe0 && marker <= 0xef) || marker === 0xfe);
|
|
223
|
+
}
|
|
224
|
+
const EXIF_SIGNATURE = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
|
225
|
+
/** Offsets in `bytes[from, to)` where an `Exif\0\0` block starts, at most `max` of them. */
|
|
226
|
+
function exifSignatures(bytes, from, to, max) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (let i = bytes.indexOf(0x45, from); i !== -1 && i + 6 <= to; i = bytes.indexOf(0x45, i + 1)) {
|
|
229
|
+
if (EXIF_SIGNATURE.every((b, k) => bytes[i + k] === b)) {
|
|
230
|
+
out.push(i);
|
|
231
|
+
if (out.length >= max)
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The first offset in `bytes` that looks like metadata — an APPn or COM
|
|
239
|
+
* marker, or an `Exif\0\0` block — or -1. For bytes a walk could not frame.
|
|
240
|
+
*/
|
|
241
|
+
function metadataHint(bytes) {
|
|
242
|
+
for (let i = bytes.indexOf(0xff); i !== -1 && i + 1 < bytes.length; i = bytes.indexOf(0xff, i + 1)) {
|
|
243
|
+
if (isMetadataMarker(bytes[i + 1]))
|
|
244
|
+
return i;
|
|
245
|
+
}
|
|
246
|
+
return exifSignatures(bytes, 0, bytes.length, 1)[0] ?? -1;
|
|
247
|
+
}
|
|
248
|
+
/** A hint at what the bytes after EOI are, for a person reading the result. */
|
|
249
|
+
export function describeTrailer(trailer) {
|
|
250
|
+
if (trailer.length === 0)
|
|
251
|
+
return "nothing";
|
|
252
|
+
for (let i = trailer.indexOf(0xff); i !== -1 && i + 2 < trailer.length; i = trailer.indexOf(0xff, i + 1)) {
|
|
253
|
+
if (trailer[i + 1] === 0xd8 && trailer[i + 2] === 0xff) {
|
|
254
|
+
return "an appended JPEG (an MPF preview, a second view or an HDR gain map)";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (let i = trailer.indexOf(0x66); i !== -1 && i + 3 < trailer.length; i = trailer.indexOf(0x66, i + 1)) {
|
|
258
|
+
if (asciiAt(trailer, i, "ftyp"))
|
|
259
|
+
return "an appended video (a motion photo)";
|
|
260
|
+
}
|
|
261
|
+
return "unrecognised data";
|
|
262
|
+
}
|
|
100
263
|
// --- EXIF ----------------------------------------------------------------
|
|
101
264
|
const TYPE_SIZES = {
|
|
102
265
|
1: 1,
|
|
@@ -357,24 +520,52 @@ function isMetadata(segment, options) {
|
|
|
357
520
|
return marker >= 0xe1 && marker <= 0xef;
|
|
358
521
|
}
|
|
359
522
|
/**
|
|
360
|
-
* A JPEG with its metadata
|
|
361
|
-
*
|
|
362
|
-
*
|
|
523
|
+
* A JPEG with its metadata dropped: every APPn and COM segment the options
|
|
524
|
+
* do not keep, wherever it sits (before the first scan or between scans),
|
|
525
|
+
* and everything after EOI. The scans' entropy-coded bytes are copied
|
|
526
|
+
* verbatim — nothing is re-encoded, so the primary image is bit-for-bit the
|
|
527
|
+
* same picture, without the record of who took it and where.
|
|
528
|
+
*
|
|
529
|
+
* Dropping the trailer loses an appended preview, gain map or motion-photo
|
|
530
|
+
* video, and the metadata inside each. Nothing in the stripped file points
|
|
531
|
+
* at them any more: the MPF index (APP2) and the XMP (APP1) that did are
|
|
532
|
+
* metadata segments and are removed.
|
|
363
533
|
*/
|
|
364
534
|
export function stripJpegMetadata(bytes, options = {}) {
|
|
365
|
-
const
|
|
535
|
+
const walk = walkJpeg(bytes);
|
|
366
536
|
const kept = [new Uint8Array([0xff, 0xd8])];
|
|
367
537
|
const removed = [];
|
|
368
|
-
for (const
|
|
369
|
-
if (
|
|
538
|
+
for (const part of walk.parts) {
|
|
539
|
+
if (part.type === "scan") {
|
|
540
|
+
kept.push(bytes.subarray(part.start, part.end));
|
|
370
541
|
continue;
|
|
542
|
+
}
|
|
543
|
+
const segment = part.segment;
|
|
371
544
|
if (isMetadata(segment, options)) {
|
|
372
545
|
removed.push({ name: segment.name, bytes: segment.length });
|
|
373
546
|
continue;
|
|
374
547
|
}
|
|
375
548
|
kept.push(bytes.subarray(segment.offset, segment.offset + segment.length));
|
|
376
549
|
}
|
|
377
|
-
|
|
550
|
+
if (walk.stopped !== null) {
|
|
551
|
+
// The file breaks off after the first scan. What follows the break is a
|
|
552
|
+
// cut-off segment or unframed bytes: a cut metadata segment is dropped;
|
|
553
|
+
// anything else is kept as 0.7.0 kept everything after the first scan,
|
|
554
|
+
// unless it holds what may be metadata, which is refused rather than
|
|
555
|
+
// written into a file that claims to be stripped.
|
|
556
|
+
const tail = bytes.subarray(walk.stopped.offset);
|
|
557
|
+
if (tail[0] === 0xff && isMetadataMarker(tail[1])) {
|
|
558
|
+
removed.push({ name: `${markerName(tail[1])} (cut off)`, bytes: tail.length });
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
const hint = metadataHint(tail);
|
|
562
|
+
if (hint !== -1) {
|
|
563
|
+
throw new MediaFormatError(`the file breaks off at offset ${walk.stopped.offset} (${walk.stopped.reason}), and the part after it that could not be walked holds what may be metadata at offset ${walk.stopped.offset + hint}; nothing was written — re-export the file and strip that`);
|
|
564
|
+
}
|
|
565
|
+
kept.push(tail);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const trailerStart = walk.eoiEnd ?? bytes.length;
|
|
378
569
|
let total = 0;
|
|
379
570
|
for (const part of kept)
|
|
380
571
|
total += part.length;
|
|
@@ -384,5 +575,331 @@ export function stripJpegMetadata(bytes, options = {}) {
|
|
|
384
575
|
out.set(part, at);
|
|
385
576
|
at += part.length;
|
|
386
577
|
}
|
|
387
|
-
|
|
578
|
+
const trailing = bytes.length - trailerStart;
|
|
579
|
+
return {
|
|
580
|
+
bytes: out,
|
|
581
|
+
removed,
|
|
582
|
+
bytesRemoved: bytes.length - out.length,
|
|
583
|
+
trailingBytesRemoved: trailing,
|
|
584
|
+
...(trailing > 0 ? { trailingKind: describeTrailer(bytes.subarray(trailerStart)) } : {}),
|
|
585
|
+
eoiMissing: walk.eoiEnd === null,
|
|
586
|
+
...(walk.stopped === null ? {} : { breaksOffAt: walk.stopped }),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** How many appended images one call walks. */
|
|
590
|
+
const MAX_EMBEDDED_IMAGES = 16;
|
|
591
|
+
/** How many EXIF blocks found in unwalked bytes one call parses. */
|
|
592
|
+
const MAX_LOOSE_EXIF_BLOCKS = 16;
|
|
593
|
+
function exifBlocksOf(walk, base, where) {
|
|
594
|
+
const out = [];
|
|
595
|
+
let sawScan = false;
|
|
596
|
+
for (const part of walk.parts) {
|
|
597
|
+
if (part.type === "scan") {
|
|
598
|
+
sawScan = true;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const s = part.segment;
|
|
602
|
+
if (s.marker !== 0xe1 || !asciiAt(s.payload, 0, "Exif"))
|
|
603
|
+
continue;
|
|
604
|
+
let exif = null;
|
|
605
|
+
let unreadable;
|
|
606
|
+
try {
|
|
607
|
+
exif = parseExif(s.payload);
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
unreadable = err.message;
|
|
611
|
+
}
|
|
612
|
+
out.push({
|
|
613
|
+
where: where(sawScan),
|
|
614
|
+
offset: base + s.offset,
|
|
615
|
+
exif,
|
|
616
|
+
...(unreadable === undefined ? {} : { unreadable }),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
const XMP_ID = "http://ns.adobe.com/xap/1.0/\0";
|
|
622
|
+
const XMP_EXTENSION_ID = "http://ns.adobe.com/xmp/extension/\0";
|
|
623
|
+
/** Extended XMP: the identifier, a 32-byte GUID, the full length and this chunk's offset. */
|
|
624
|
+
const XMP_EXTENSION_HEADER = XMP_EXTENSION_ID.length + 32 + 4 + 4;
|
|
625
|
+
/**
|
|
626
|
+
* Does an XMP packet name a location? The GPS properties are
|
|
627
|
+
* `exif:GPSLatitude` and `exif:GPSLongitude`, written as attributes or
|
|
628
|
+
* elements under whatever prefix the packet binds (IPTC's LocationCreated
|
|
629
|
+
* nests the same two; DJI writes `drone-dji:GpsLatitude`), so the property
|
|
630
|
+
* names are searched for, ignoring case. A packet that has either is taken
|
|
631
|
+
* to carry a location: for a file about to be published, a false "yes" costs
|
|
632
|
+
* a strip, and a false "no" publishes where it was taken.
|
|
633
|
+
*/
|
|
634
|
+
function xmpNamesLocation(bytes) {
|
|
635
|
+
let text = "";
|
|
636
|
+
// Latin-1 keeps one character per byte, and the property names are ASCII.
|
|
637
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
638
|
+
text += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
639
|
+
}
|
|
640
|
+
const lower = text.toLowerCase();
|
|
641
|
+
return lower.includes("gpslatitude") || lower.includes("gpslongitude");
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Walk a Photoshop Image Resource Block (APP13 `Photoshop 3.0`) and say
|
|
645
|
+
* whether it names a location: the EXIF (0x0422, 0x0423) and XMP (0x0424)
|
|
646
|
+
* resources Photoshop can keep there are read like their APP1 twins; the
|
|
647
|
+
* IPTC-IIM record (0x0404) has place names but no coordinates. A block that
|
|
648
|
+
* does not walk is not read.
|
|
649
|
+
*/
|
|
650
|
+
function photoshopNamesLocation(payload) {
|
|
651
|
+
const header = "Photoshop 3.0\0";
|
|
652
|
+
let at = header.length;
|
|
653
|
+
let unread = false;
|
|
654
|
+
while (at + 12 <= payload.length) {
|
|
655
|
+
if (!asciiAt(payload, at, "8BIM"))
|
|
656
|
+
return null;
|
|
657
|
+
const id = (payload[at + 4] << 8) | payload[at + 5];
|
|
658
|
+
const nameLength = payload[at + 6];
|
|
659
|
+
// The Pascal-string name, its length byte included, is padded to even.
|
|
660
|
+
const nameEnd = at + 6 + ((nameLength + 2) & ~1);
|
|
661
|
+
if (nameEnd + 4 > payload.length)
|
|
662
|
+
return null;
|
|
663
|
+
const size = ((payload[nameEnd] << 24) >>> 0) +
|
|
664
|
+
(payload[nameEnd + 1] << 16) +
|
|
665
|
+
(payload[nameEnd + 2] << 8) +
|
|
666
|
+
payload[nameEnd + 3];
|
|
667
|
+
const dataStart = nameEnd + 4;
|
|
668
|
+
if (dataStart + size > payload.length)
|
|
669
|
+
return null;
|
|
670
|
+
const data = payload.subarray(dataStart, dataStart + size);
|
|
671
|
+
if (id === 0x0424 && xmpNamesLocation(data))
|
|
672
|
+
return true;
|
|
673
|
+
if (id === 0x0422 || id === 0x0423) {
|
|
674
|
+
const framed = new Uint8Array(6 + data.length);
|
|
675
|
+
framed.set([0x45, 0x78, 0x69, 0x66, 0, 0]);
|
|
676
|
+
framed.set(data, 6);
|
|
677
|
+
try {
|
|
678
|
+
if (parseExif(framed).hasGps)
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
unread = true;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
at = dataStart + size + (size & 1);
|
|
686
|
+
}
|
|
687
|
+
return unread ? null : false;
|
|
688
|
+
}
|
|
689
|
+
/** The first bytes of an identifier, for naming a segment this does not read. */
|
|
690
|
+
function identifierOf(payload) {
|
|
691
|
+
let id = "";
|
|
692
|
+
for (let i = 0; i < Math.min(payload.length, 24); i++) {
|
|
693
|
+
const b = payload[i];
|
|
694
|
+
if (b === 0)
|
|
695
|
+
break;
|
|
696
|
+
if (b < 0x20 || b > 0x7e)
|
|
697
|
+
return id;
|
|
698
|
+
id += String.fromCharCode(b);
|
|
699
|
+
}
|
|
700
|
+
return id;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The APPn segments of one walk other than EXIF, each with whether it names
|
|
704
|
+
* a location. XMP split across Extended XMP chunks is put back together
|
|
705
|
+
* (per GUID, by offset) before it is searched, so a property cut in two by a
|
|
706
|
+
* chunk boundary is still seen.
|
|
707
|
+
*/
|
|
708
|
+
function otherMetadataOf(walk, base, where) {
|
|
709
|
+
const out = [];
|
|
710
|
+
const extended = new Map();
|
|
711
|
+
let sawScan = false;
|
|
712
|
+
for (const part of walk.parts) {
|
|
713
|
+
if (part.type === "scan") {
|
|
714
|
+
sawScan = true;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const s = part.segment;
|
|
718
|
+
if (s.marker < 0xe0 || s.marker > 0xef)
|
|
719
|
+
continue;
|
|
720
|
+
const p = s.payload;
|
|
721
|
+
const at = { where: where(sawScan), offset: base + s.offset };
|
|
722
|
+
if (s.marker === 0xe1 && asciiAt(p, 0, "Exif"))
|
|
723
|
+
continue; // parsed as EXIF
|
|
724
|
+
if (s.marker === 0xe1 && asciiAt(p, 0, XMP_ID)) {
|
|
725
|
+
out.push({ segment: "APP1 XMP", ...at, location: xmpNamesLocation(p) });
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (s.marker === 0xe1 && asciiAt(p, 0, XMP_EXTENSION_ID)) {
|
|
729
|
+
if (p.length < XMP_EXTENSION_HEADER) {
|
|
730
|
+
out.push({ segment: "APP1 extended XMP", ...at, location: null });
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
const guid = String.fromCharCode(...p.subarray(XMP_EXTENSION_ID.length, XMP_EXTENSION_ID.length + 32));
|
|
734
|
+
const o = XMP_EXTENSION_ID.length + 36;
|
|
735
|
+
const chunkOffset = ((p[o] << 24) >>> 0) +
|
|
736
|
+
(p[o + 1] << 16) +
|
|
737
|
+
(p[o + 2] << 8) +
|
|
738
|
+
p[o + 3];
|
|
739
|
+
const entry = extended.get(guid) ?? { ...at, chunks: [] };
|
|
740
|
+
entry.chunks.push([chunkOffset, p.subarray(XMP_EXTENSION_HEADER)]);
|
|
741
|
+
extended.set(guid, entry);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
let location = null;
|
|
745
|
+
let segment = `${s.name} ${identifierOf(p)}`.trim();
|
|
746
|
+
if (s.marker === 0xe0 && asciiAt(p, 0, "JFIF\0"))
|
|
747
|
+
location = false;
|
|
748
|
+
// JFXX carries a thumbnail; one coded as a JPEG (0x10) could carry EXIF.
|
|
749
|
+
else if (s.marker === 0xe0 && asciiAt(p, 0, "JFXX\0"))
|
|
750
|
+
location = p[5] === 0x10 ? null : false;
|
|
751
|
+
else if (s.marker === 0xe2 && asciiAt(p, 0, "ICC_PROFILE\0"))
|
|
752
|
+
location = false;
|
|
753
|
+
// MPF indexes the appended images, which are walked for themselves.
|
|
754
|
+
else if (s.marker === 0xe2 && asciiAt(p, 0, "MPF\0"))
|
|
755
|
+
location = false;
|
|
756
|
+
else if (s.marker === 0xec && asciiAt(p, 0, "Ducky"))
|
|
757
|
+
location = false;
|
|
758
|
+
else if (s.marker === 0xee && asciiAt(p, 0, "Adobe"))
|
|
759
|
+
location = false;
|
|
760
|
+
else if (s.marker === 0xed && asciiAt(p, 0, "Photoshop 3.0\0")) {
|
|
761
|
+
segment = "APP13 Photoshop";
|
|
762
|
+
location = photoshopNamesLocation(p);
|
|
763
|
+
}
|
|
764
|
+
out.push({ segment, ...at, location });
|
|
765
|
+
}
|
|
766
|
+
for (const entry of extended.values()) {
|
|
767
|
+
entry.chunks.sort((a, b) => a[0] - b[0]);
|
|
768
|
+
let total = 0;
|
|
769
|
+
for (const [, chunk] of entry.chunks)
|
|
770
|
+
total += chunk.length;
|
|
771
|
+
const joined = new Uint8Array(total);
|
|
772
|
+
let at = 0;
|
|
773
|
+
for (const [, chunk] of entry.chunks) {
|
|
774
|
+
joined.set(chunk, at);
|
|
775
|
+
at += chunk.length;
|
|
776
|
+
}
|
|
777
|
+
out.push({
|
|
778
|
+
segment: "APP1 extended XMP",
|
|
779
|
+
where: entry.where,
|
|
780
|
+
offset: entry.offset,
|
|
781
|
+
location: xmpNamesLocation(joined),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Every place in a JPEG that can carry metadata: EXIF before and between
|
|
788
|
+
* scans, other APPn/COM segments, and the JPEGs appended after EOI (walked
|
|
789
|
+
* once each, not their own trailers again). Bytes no walk accounts for —
|
|
790
|
+
* after a break, a video, unrecognised data, an appended image that would
|
|
791
|
+
* not walk — are counted and searched for EXIF blocks, which are parsed
|
|
792
|
+
* where found. What this reports is what ExifRead answers from, so it
|
|
793
|
+
* cannot call a file clean on the strength of bytes it never read.
|
|
794
|
+
*/
|
|
795
|
+
export function inspectJpegMetadata(bytes) {
|
|
796
|
+
const walk = walkJpeg(bytes);
|
|
797
|
+
const mainWhere = (sawScan) => sawScan ? "between scans" : "before the first scan";
|
|
798
|
+
const exifBlocks = exifBlocksOf(walk, 0, mainWhere);
|
|
799
|
+
const otherMetadata = otherMetadataOf(walk, 0, mainWhere);
|
|
800
|
+
const interScan = [];
|
|
801
|
+
const before = [];
|
|
802
|
+
let sawScan = false;
|
|
803
|
+
for (const part of walk.parts) {
|
|
804
|
+
if (part.type === "scan") {
|
|
805
|
+
sawScan = true;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const m = part.segment.marker;
|
|
809
|
+
if ((m >= 0xe0 && m <= 0xef) || m === 0xfe) {
|
|
810
|
+
(sawScan ? interScan : before).push(part.segment.name);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
let embeddedImages = 0;
|
|
814
|
+
let attempts = 0;
|
|
815
|
+
let notInspected = 0;
|
|
816
|
+
// Everything from here to the end is outside the main image's walk.
|
|
817
|
+
const outsideFrom = walk.eoiEnd ?? walk.stopped?.offset ?? bytes.length;
|
|
818
|
+
// Ranges an appended image's walk accounts for, in order.
|
|
819
|
+
const covered = [];
|
|
820
|
+
if (walk.eoiEnd !== null && walk.trailing > 0) {
|
|
821
|
+
// Each appended JPEG is walked from its SOI; a candidate inside one
|
|
822
|
+
// already walked (its thumbnail, say) is skipped rather than walked
|
|
823
|
+
// again. Every attempt counts against the cap, a failed one too, so a
|
|
824
|
+
// trailer of false starts cannot make this quadratic.
|
|
825
|
+
let coveredTo = outsideFrom;
|
|
826
|
+
for (let i = bytes.indexOf(0xff, outsideFrom); i !== -1 && i + 2 < bytes.length; i = bytes.indexOf(0xff, i + 1)) {
|
|
827
|
+
if (bytes[i + 1] !== 0xd8 || bytes[i + 2] !== 0xff || i < coveredTo)
|
|
828
|
+
continue;
|
|
829
|
+
if (attempts >= MAX_EMBEDDED_IMAGES) {
|
|
830
|
+
notInspected++;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
attempts++;
|
|
834
|
+
try {
|
|
835
|
+
const inner = walkJpeg(bytes.subarray(i));
|
|
836
|
+
embeddedImages++;
|
|
837
|
+
exifBlocks.push(...exifBlocksOf(inner, i, () => "in an appended image"));
|
|
838
|
+
otherMetadata.push(...otherMetadataOf(inner, i, () => "in an appended image"));
|
|
839
|
+
const end = i + (inner.eoiEnd ?? inner.stopped?.offset ?? bytes.length - i);
|
|
840
|
+
covered.push([i, end]);
|
|
841
|
+
coveredTo = end;
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
// Not a walkable JPEG after all (FF D8 FF inside other data, or an
|
|
845
|
+
// image whose header is broken): its bytes stay unaccounted for.
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// The rest: counted, and searched for EXIF blocks, unless it is padding.
|
|
850
|
+
let unaccounted = 0;
|
|
851
|
+
let signatures = 0;
|
|
852
|
+
let parsedLoose = 0;
|
|
853
|
+
let at = outsideFrom;
|
|
854
|
+
for (const [from, to] of [...covered, [bytes.length, bytes.length]]) {
|
|
855
|
+
if (from > at) {
|
|
856
|
+
let padding = true;
|
|
857
|
+
for (let k = at; k < from; k++) {
|
|
858
|
+
const b = bytes[k];
|
|
859
|
+
if (b !== 0x00 && b !== 0xff) {
|
|
860
|
+
padding = false;
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (!padding) {
|
|
865
|
+
unaccounted += from - at;
|
|
866
|
+
for (const offset of exifSignatures(bytes, at, from, Number.POSITIVE_INFINITY)) {
|
|
867
|
+
signatures++;
|
|
868
|
+
if (parsedLoose >= MAX_LOOSE_EXIF_BLOCKS)
|
|
869
|
+
continue;
|
|
870
|
+
parsedLoose++;
|
|
871
|
+
let exif = null;
|
|
872
|
+
let unreadable;
|
|
873
|
+
try {
|
|
874
|
+
exif = parseExif(bytes.subarray(offset, from));
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
unreadable = err.message;
|
|
878
|
+
}
|
|
879
|
+
exifBlocks.push({
|
|
880
|
+
where: "in bytes no walk accounts for",
|
|
881
|
+
offset,
|
|
882
|
+
exif,
|
|
883
|
+
...(unreadable === undefined ? {} : { unreadable }),
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
at = Math.max(at, to);
|
|
889
|
+
}
|
|
890
|
+
const trailerStart = walk.eoiEnd ?? bytes.length;
|
|
891
|
+
return {
|
|
892
|
+
exifBlocks,
|
|
893
|
+
interScanMetadataSegments: interScan,
|
|
894
|
+
metadataSegments: before.filter((n) => n !== "COM").sort(),
|
|
895
|
+
trailingBytes: walk.trailing,
|
|
896
|
+
...(walk.trailing > 0 ? { trailingKind: describeTrailer(bytes.subarray(trailerStart)) } : {}),
|
|
897
|
+
embeddedImages,
|
|
898
|
+
embeddedImagesNotInspected: notInspected,
|
|
899
|
+
eoiMissing: walk.eoiEnd === null,
|
|
900
|
+
...(walk.stopped === null ? {} : { breaksOffAt: walk.stopped }),
|
|
901
|
+
unaccountedBytes: unaccounted,
|
|
902
|
+
exifSignaturesNotParsed: signatures - parsedLoose,
|
|
903
|
+
otherMetadata,
|
|
904
|
+
};
|
|
388
905
|
}
|
package/dist/lib/subtitle.d.ts
CHANGED
|
@@ -20,6 +20,19 @@ export type ParseResult = {
|
|
|
20
20
|
reason: string;
|
|
21
21
|
}>;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* A timing line's two sides: the text before its first `-->`, and the text
|
|
25
|
+
* after it. Null when the line has no `-->`, or holds a line break: `\r\n`
|
|
26
|
+
* folding leaves U+2028 and U+2029 inside a line.
|
|
27
|
+
*
|
|
28
|
+
* This is what `/^(.*?)-->(.*?)$/` captured, read with one search for the
|
|
29
|
+
* arrow and one for a break. That pattern's lazy start tried every `-->` on
|
|
30
|
+
* the line and ran from each to the break that `$` cannot pass: quadratic in
|
|
31
|
+
* a line of arrows. No pattern here repeats a group either, because
|
|
32
|
+
* JavaScriptCore silently stops matching one that repeats a group a million
|
|
33
|
+
* times or so, and a line can be as long as the file.
|
|
34
|
+
*/
|
|
35
|
+
export declare function timingParts(line: string): readonly [start: string, rest: string] | null;
|
|
23
36
|
/** `HH:MM:SS,mmm`, `MM:SS.mmm` and the other legal spellings, to millis. */
|
|
24
37
|
export declare function parseTimestamp(text: string): number;
|
|
25
38
|
/** Milliseconds to `HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (WebVTT). */
|
package/dist/lib/subtitle.js
CHANGED
|
@@ -22,7 +22,26 @@
|
|
|
22
22
|
* shift is an input, never "now minus something".
|
|
23
23
|
*/
|
|
24
24
|
import { MediaFormatError } from "./bytes";
|
|
25
|
-
|
|
25
|
+
/** The line breaks a regular expression's `.` does not take. */
|
|
26
|
+
const LINE_BREAK = /[\n\r\u2028\u2029]/;
|
|
27
|
+
/**
|
|
28
|
+
* A timing line's two sides: the text before its first `-->`, and the text
|
|
29
|
+
* after it. Null when the line has no `-->`, or holds a line break: `\r\n`
|
|
30
|
+
* folding leaves U+2028 and U+2029 inside a line.
|
|
31
|
+
*
|
|
32
|
+
* This is what `/^(.*?)-->(.*?)$/` captured, read with one search for the
|
|
33
|
+
* arrow and one for a break. That pattern's lazy start tried every `-->` on
|
|
34
|
+
* the line and ran from each to the break that `$` cannot pass: quadratic in
|
|
35
|
+
* a line of arrows. No pattern here repeats a group either, because
|
|
36
|
+
* JavaScriptCore silently stops matching one that repeats a group a million
|
|
37
|
+
* times or so, and a line can be as long as the file.
|
|
38
|
+
*/
|
|
39
|
+
export function timingParts(line) {
|
|
40
|
+
const arrow = line.indexOf("-->");
|
|
41
|
+
if (arrow === -1 || LINE_BREAK.test(line))
|
|
42
|
+
return null;
|
|
43
|
+
return [line.slice(0, arrow), line.slice(arrow + 3)];
|
|
44
|
+
}
|
|
26
45
|
const TIMESTAMP = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/;
|
|
27
46
|
/** `HH:MM:SS,mmm`, `MM:SS.mmm` and the other legal spellings, to millis. */
|
|
28
47
|
export function parseTimestamp(text) {
|
|
@@ -76,17 +95,18 @@ export function parseSubtitles(input, format) {
|
|
|
76
95
|
}
|
|
77
96
|
let at = 0;
|
|
78
97
|
let id;
|
|
79
|
-
|
|
98
|
+
let timing = timingParts(first);
|
|
99
|
+
if (timing === null) {
|
|
80
100
|
id = first.trim();
|
|
81
101
|
at = 1;
|
|
102
|
+
const next = lines[1];
|
|
103
|
+
timing = next === undefined ? null : timingParts(next);
|
|
82
104
|
}
|
|
83
|
-
|
|
84
|
-
if (timingLine === undefined || !TIMING.test(timingLine)) {
|
|
105
|
+
if (timing === null) {
|
|
85
106
|
skipped.push({ block: b, reason: "no timing line" });
|
|
86
107
|
continue;
|
|
87
108
|
}
|
|
88
|
-
const
|
|
89
|
-
const rightSide = match[2].trim();
|
|
109
|
+
const rightSide = timing[1].trim();
|
|
90
110
|
// Cue settings follow the end timestamp, separated by whitespace.
|
|
91
111
|
const spaceAt = rightSide.search(/\s/);
|
|
92
112
|
const endText = spaceAt === -1 ? rightSide : rightSide.slice(0, spaceAt);
|
|
@@ -94,7 +114,7 @@ export function parseSubtitles(input, format) {
|
|
|
94
114
|
let startMs;
|
|
95
115
|
let endMs;
|
|
96
116
|
try {
|
|
97
|
-
startMs = parseTimestamp(
|
|
117
|
+
startMs = parseTimestamp(timing[0]);
|
|
98
118
|
endMs = parseTimestamp(endText);
|
|
99
119
|
}
|
|
100
120
|
catch (err) {
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The longest path this resolves, in UTF-16 code units; a longer one is
|
|
4
|
+
* refused before it is walked. Nothing that long can be opened or created:
|
|
5
|
+
* PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
|
|
6
|
+
* 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
|
|
7
|
+
* UTF-16 one. The limit is twice that because the part of a path that exists
|
|
8
|
+
* is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
|
|
9
|
+
* own: no spelling of the place reaches it, not even one through a symlink
|
|
10
|
+
* that shortens the part that exists.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MAX_PATH_CHARS: number;
|
|
13
|
+
/**
|
|
14
|
+
* Raised when a caller-supplied path resolves outside the workspace root, or
|
|
15
|
+
* is longer than any path can be.
|
|
16
|
+
*/
|
|
3
17
|
export declare class ToolPermissionError extends CrewhausError {
|
|
4
18
|
readonly name = "ToolPermissionError";
|
|
5
19
|
readonly toolName: string;
|
|
6
20
|
readonly path: string;
|
|
7
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Why, when it is not that the location escapes the workspace root: a path
|
|
23
|
+
* longer than any path can be. A caller that rewords the refusal says this.
|
|
24
|
+
*/
|
|
25
|
+
readonly reason: string | undefined;
|
|
26
|
+
constructor(toolName: string, attemptedPath: string, reason?: string);
|
|
8
27
|
}
|
|
9
28
|
/**
|
|
10
29
|
* A validated path, in both forms the tools need.
|