@crewhaus/tool-media 0.7.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,236 @@
1
+ /**
2
+ * Pixel operations on decoded RGBA: resampling, cropping, comparison and a
3
+ * perceptual hash.
4
+ *
5
+ * Everything here is integer-deterministic. Resampling rounds with
6
+ * `Math.round` on a value computed in the same order every time, so two
7
+ * runs on the same pixels produce byte-identical output — which is the
8
+ * whole point of a screenshot-regression primitive.
9
+ */
10
+ import { MediaFormatError } from "./bytes";
11
+ import { luma } from "./png";
12
+ /** A blank image, fully transparent. */
13
+ export function blankImage(width, height) {
14
+ return { width, height, data: new Uint8Array(width * height * 4) };
15
+ }
16
+ /**
17
+ * Resize to exactly `width` x `height`.
18
+ *
19
+ * `nearest` preserves exact colours (right for pixel art, palettes and QR
20
+ * codes); `bilinear` averages the four neighbours (right for photographs,
21
+ * and the only one of the two that does not alias badly when shrinking by
22
+ * a large factor — though neither does the area-averaging a proper
23
+ * downscaler would, which is stated rather than hidden).
24
+ */
25
+ export function resizeImage(image, width, height, method) {
26
+ if (width <= 0 || height <= 0) {
27
+ throw new MediaFormatError(`cannot resize to ${width}x${height}`);
28
+ }
29
+ const out = new Uint8Array(width * height * 4);
30
+ const src = image.data;
31
+ const sw = image.width;
32
+ const sh = image.height;
33
+ const xRatio = sw / width;
34
+ const yRatio = sh / height;
35
+ if (method === "nearest") {
36
+ for (let y = 0; y < height; y++) {
37
+ const sy = Math.min(sh - 1, Math.floor((y + 0.5) * yRatio));
38
+ for (let x = 0; x < width; x++) {
39
+ const sx = Math.min(sw - 1, Math.floor((x + 0.5) * xRatio));
40
+ const s = (sy * sw + sx) * 4;
41
+ const d = (y * width + x) * 4;
42
+ out[d] = src[s];
43
+ out[d + 1] = src[s + 1];
44
+ out[d + 2] = src[s + 2];
45
+ out[d + 3] = src[s + 3];
46
+ }
47
+ }
48
+ return { width, height, data: out };
49
+ }
50
+ for (let y = 0; y < height; y++) {
51
+ const fy = Math.min(sh - 1, Math.max(0, (y + 0.5) * yRatio - 0.5));
52
+ const y0 = Math.floor(fy);
53
+ const y1 = Math.min(sh - 1, y0 + 1);
54
+ const wy = fy - y0;
55
+ for (let x = 0; x < width; x++) {
56
+ const fx = Math.min(sw - 1, Math.max(0, (x + 0.5) * xRatio - 0.5));
57
+ const x0 = Math.floor(fx);
58
+ const x1 = Math.min(sw - 1, x0 + 1);
59
+ const wx = fx - x0;
60
+ const p00 = (y0 * sw + x0) * 4;
61
+ const p01 = (y0 * sw + x1) * 4;
62
+ const p10 = (y1 * sw + x0) * 4;
63
+ const p11 = (y1 * sw + x1) * 4;
64
+ const d = (y * width + x) * 4;
65
+ for (let c = 0; c < 4; c++) {
66
+ const top = src[p00 + c] * (1 - wx) + src[p01 + c] * wx;
67
+ const bottom = src[p10 + c] * (1 - wx) + src[p11 + c] * wx;
68
+ out[d + c] = Math.round(top * (1 - wy) + bottom * wy);
69
+ }
70
+ }
71
+ }
72
+ return { width, height, data: out };
73
+ }
74
+ /** The size that fits `width`/`height` while preserving the aspect ratio. */
75
+ export function fitDimensions(source, wanted) {
76
+ const { width, height } = wanted;
77
+ if (width !== undefined && height !== undefined)
78
+ return { width, height };
79
+ if (width !== undefined) {
80
+ return { width, height: Math.max(1, Math.round((width * source.height) / source.width)) };
81
+ }
82
+ if (height !== undefined) {
83
+ return { width: Math.max(1, Math.round((height * source.width) / source.height)), height };
84
+ }
85
+ return { width: source.width, height: source.height };
86
+ }
87
+ /** Cut a rectangle out. The rectangle must lie entirely inside the image. */
88
+ export function cropImage(image, x, y, width, height) {
89
+ if (width <= 0 || height <= 0)
90
+ throw new MediaFormatError(`cannot crop to ${width}x${height}`);
91
+ if (x < 0 || y < 0 || x + width > image.width || y + height > image.height) {
92
+ throw new MediaFormatError(`crop ${width}x${height}+${x}+${y} is not inside the ${image.width}x${image.height} image`);
93
+ }
94
+ const out = new Uint8Array(width * height * 4);
95
+ for (let row = 0; row < height; row++) {
96
+ const from = ((y + row) * image.width + x) * 4;
97
+ out.set(image.data.subarray(from, from + width * 4), row * width * 4);
98
+ }
99
+ return { width, height, data: out };
100
+ }
101
+ /** Round to six decimal places, so a fraction is stable across runs. */
102
+ function round6(value) {
103
+ return Math.round(value * 1e6) / 1e6;
104
+ }
105
+ /**
106
+ * Compare two same-sized images. A pixel counts as differing when any of its
107
+ * four channels differs by more than `threshold` (0 means exact equality).
108
+ */
109
+ export function diffImages(a, b, threshold) {
110
+ if (a.width !== b.width || a.height !== b.height) {
111
+ throw new MediaFormatError(`cannot compare a ${a.width}x${a.height} image with a ${b.width}x${b.height} one`);
112
+ }
113
+ let differing = 0;
114
+ let maxDelta = 0;
115
+ let deltaSum = 0;
116
+ let minX = a.width;
117
+ let minY = a.height;
118
+ let maxX = -1;
119
+ let maxY = -1;
120
+ const total = a.width * a.height;
121
+ for (let i = 0; i < total; i++) {
122
+ const o = i * 4;
123
+ let pixelMax = 0;
124
+ for (let c = 0; c < 4; c++) {
125
+ const delta = Math.abs(a.data[o + c] - b.data[o + c]);
126
+ deltaSum += delta;
127
+ if (delta > pixelMax)
128
+ pixelMax = delta;
129
+ }
130
+ if (pixelMax > maxDelta)
131
+ maxDelta = pixelMax;
132
+ if (pixelMax > threshold) {
133
+ differing++;
134
+ const x = i % a.width;
135
+ const y = (i - x) / a.width;
136
+ if (x < minX)
137
+ minX = x;
138
+ if (x > maxX)
139
+ maxX = x;
140
+ if (y < minY)
141
+ minY = y;
142
+ if (y > maxY)
143
+ maxY = y;
144
+ }
145
+ }
146
+ return {
147
+ width: a.width,
148
+ height: a.height,
149
+ totalPixels: total,
150
+ differingPixels: differing,
151
+ fraction: round6(differing / total),
152
+ maxChannelDelta: maxDelta,
153
+ meanChannelDelta: round6(deltaSum / (total * 4)),
154
+ boundingBox: maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
155
+ };
156
+ }
157
+ /**
158
+ * A 64-bit difference hash: reduce to 9x8 greyscale, then emit one bit per
159
+ * adjacent horizontal pair saying whether the left sample is brighter.
160
+ * Robust to scaling and mild compression, which is what makes it the right
161
+ * "are these the same picture?" signal when the pixels are not identical.
162
+ *
163
+ * Returned as 16 lowercase hex digits, most significant bit first.
164
+ */
165
+ export function differenceHash(image) {
166
+ const small = resizeImage(image, 9, 8, "bilinear");
167
+ let hex = "";
168
+ for (let y = 0; y < 8; y++) {
169
+ let rowBits = 0;
170
+ for (let x = 0; x < 8; x++) {
171
+ const l = (y * 9 + x) * 4;
172
+ const r = (y * 9 + x + 1) * 4;
173
+ const left = luma(small.data[l], small.data[l + 1], small.data[l + 2]);
174
+ const right = luma(small.data[r], small.data[r + 1], small.data[r + 2]);
175
+ rowBits = (rowBits << 1) | (left > right ? 1 : 0);
176
+ }
177
+ hex += rowBits.toString(16).padStart(2, "0");
178
+ }
179
+ return hex;
180
+ }
181
+ /** Bits that differ between two hex hashes of equal length. */
182
+ export function hammingDistance(a, b) {
183
+ if (a.length !== b.length) {
184
+ throw new MediaFormatError(`cannot compare a ${a.length}-digit hash with a ${b.length}-digit one`);
185
+ }
186
+ let distance = 0;
187
+ for (let i = 0; i < a.length; i++) {
188
+ let x = Number.parseInt(a[i], 16) ^ Number.parseInt(b[i], 16);
189
+ while (x > 0) {
190
+ distance += x & 1;
191
+ x >>= 1;
192
+ }
193
+ }
194
+ return distance;
195
+ }
196
+ /** Per-channel mean and extremes, for `PngRead`'s statistics mode. */
197
+ export function imageStats(image) {
198
+ const sums = [0, 0, 0, 0];
199
+ const mins = [255, 255, 255, 255];
200
+ const maxs = [0, 0, 0, 0];
201
+ let opaque = 0;
202
+ const distinct = new Set();
203
+ const CAP = 4096;
204
+ let capped = false;
205
+ const total = image.width * image.height;
206
+ for (let i = 0; i < total; i++) {
207
+ const o = i * 4;
208
+ for (let c = 0; c < 4; c++) {
209
+ const v = image.data[o + c];
210
+ sums[c] = sums[c] + v;
211
+ if (v < mins[c])
212
+ mins[c] = v;
213
+ if (v > maxs[c])
214
+ maxs[c] = v;
215
+ }
216
+ if (image.data[o + 3] === 255)
217
+ opaque++;
218
+ if (!capped) {
219
+ const key = (image.data[o] << 16) |
220
+ (image.data[o + 1] << 8) |
221
+ image.data[o + 2];
222
+ distinct.add(key);
223
+ if (distinct.size >= CAP)
224
+ capped = true;
225
+ }
226
+ }
227
+ const at = (i) => round6(sums[i] / total);
228
+ return {
229
+ mean: { r: at(0), g: at(1), b: at(2), a: at(3) },
230
+ min: { r: mins[0], g: mins[1], b: mins[2], a: mins[3] },
231
+ max: { r: maxs[0], g: maxs[1], b: maxs[2], a: maxs[3] },
232
+ opaquePixels: opaque,
233
+ distinctColorsCapped: distinct.size,
234
+ capped,
235
+ };
236
+ }
@@ -0,0 +1,81 @@
1
+ /** One marker segment, located in the original file. */
2
+ export type JpegSegment = {
3
+ /** The marker byte after the `0xFF`, e.g. `0xE1` for `APP1`. */
4
+ readonly marker: number;
5
+ /** Conventional name: `"APP1"`, `"SOF0"`, `"DQT"`, … */
6
+ readonly name: string;
7
+ /** Offset of the `0xFF` that starts the segment. */
8
+ readonly offset: number;
9
+ /** Total bytes including the marker and the two length bytes. */
10
+ readonly length: number;
11
+ /** The payload, excluding the marker and the length field. */
12
+ readonly payload: Uint8Array;
13
+ };
14
+ /**
15
+ * Every marker segment from `SOI` up to and including `SOS`, plus the
16
+ * offset at which the entropy-coded scan data begins. Everything from that
17
+ * offset to the end of the file is opaque and is copied verbatim by
18
+ * `stripJpegMetadata` rather than parsed.
19
+ */
20
+ export declare function readJpegSegments(bytes: Uint8Array): {
21
+ segments: JpegSegment[];
22
+ scanStart: number;
23
+ };
24
+ export type GpsFix = {
25
+ readonly latitude: number;
26
+ readonly longitude: number;
27
+ readonly altitudeMeters?: number;
28
+ /** `YYYY:MM:DD HH:MM:SS` as the tags record it, if both parts are present. */
29
+ readonly timestampUtc?: string;
30
+ };
31
+ export type ExifData = {
32
+ readonly byteOrder: "little-endian" | "big-endian";
33
+ readonly make?: string;
34
+ readonly model?: string;
35
+ readonly lens?: string;
36
+ readonly software?: string;
37
+ readonly orientation?: number;
38
+ readonly orientationDescription?: string;
39
+ readonly dateTimeOriginal?: string;
40
+ readonly dateTimeDigitized?: string;
41
+ readonly dateTime?: string;
42
+ readonly exposureTimeSeconds?: number;
43
+ readonly fNumber?: number;
44
+ readonly isoSpeed?: number;
45
+ readonly focalLengthMm?: number;
46
+ readonly focalLength35mm?: number;
47
+ readonly pixelWidth?: number;
48
+ readonly pixelHeight?: number;
49
+ readonly hasGps: boolean;
50
+ readonly gps?: GpsFix;
51
+ /** Tags present that this reader recognises but did not fit above. */
52
+ readonly otherTagCount: number;
53
+ };
54
+ /** Parse an `APP1` payload that begins `Exif\0\0`. */
55
+ export declare function parseExif(payload: Uint8Array): ExifData;
56
+ /** Find the `APP1` segment carrying EXIF, if there is one. */
57
+ export declare function findExifSegment(segments: ReadonlyArray<JpegSegment>): JpegSegment | undefined;
58
+ export type StripOptions = {
59
+ /**
60
+ * Keep the `APP2` ICC colour profile. On by default: a profile is not
61
+ * metadata about the photographer, it is what tells a display how to
62
+ * interpret the colours, and dropping it visibly shifts the image.
63
+ */
64
+ readonly keepIccProfile?: boolean;
65
+ /** Keep the `APP0` JFIF header. On by default; it carries no identity. */
66
+ readonly keepJfif?: boolean;
67
+ };
68
+ export type StripResult = {
69
+ readonly bytes: Uint8Array;
70
+ readonly removed: ReadonlyArray<{
71
+ name: string;
72
+ bytes: number;
73
+ }>;
74
+ readonly bytesRemoved: number;
75
+ };
76
+ /**
77
+ * A JPEG with its metadata segments dropped. The scan data is copied
78
+ * verbatim — nothing is re-encoded, so the image is bit-for-bit the same
79
+ * picture, just without the record of who took it and where.
80
+ */
81
+ export declare function stripJpegMetadata(bytes: Uint8Array, options?: StripOptions): StripResult;
@@ -0,0 +1,388 @@
1
+ /**
2
+ * JPEG at the segment level: walking the marker segments, reading the EXIF
3
+ * that hides in one of them, and writing a copy without the metadata.
4
+ *
5
+ * ## What this reads
6
+ *
7
+ * The TIFF structure inside an `APP1` segment whose payload begins
8
+ * `Exif\0\0`: IFD0, the Exif sub-IFD it points at, and the GPS sub-IFD.
9
+ * Both byte orders. Rationals, ASCII, shorts and longs. That covers the
10
+ * capture time, camera, lens, exposure and — the one that matters most for
11
+ * anything about to be published — the location.
12
+ *
13
+ * ## What this does not read
14
+ *
15
+ * MakerNotes (vendor-private, undocumented, and different per camera),
16
+ * IFD1 thumbnails, XMP, IPTC, and EXIF carried in a TIFF or HEIC rather
17
+ * than a JPEG. Each would be a separate parser; none is approximated.
18
+ */
19
+ import { ByteReader, MediaFormatError, asciiAt, startsWith } from "./bytes";
20
+ function markerName(marker) {
21
+ if (marker >= 0xe0 && marker <= 0xef)
22
+ return `APP${marker - 0xe0}`;
23
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
24
+ return `SOF${marker - 0xc0}`;
25
+ }
26
+ switch (marker) {
27
+ case 0xc4:
28
+ return "DHT";
29
+ case 0xcc:
30
+ return "DAC";
31
+ case 0xd8:
32
+ return "SOI";
33
+ case 0xd9:
34
+ return "EOI";
35
+ case 0xda:
36
+ return "SOS";
37
+ case 0xdb:
38
+ return "DQT";
39
+ case 0xdd:
40
+ return "DRI";
41
+ case 0xfe:
42
+ return "COM";
43
+ default:
44
+ return `FF${marker.toString(16).toUpperCase().padStart(2, "0")}`;
45
+ }
46
+ }
47
+ /**
48
+ * Every marker segment from `SOI` up to and including `SOS`, plus the
49
+ * offset at which the entropy-coded scan data begins. Everything from that
50
+ * offset to the end of the file is opaque and is copied verbatim by
51
+ * `stripJpegMetadata` rather than parsed.
52
+ */
53
+ export function readJpegSegments(bytes) {
54
+ if (!startsWith(bytes, [0xff, 0xd8])) {
55
+ throw new MediaFormatError("not a JPEG: the file does not start with FFD8 (SOI)");
56
+ }
57
+ const segments = [];
58
+ const reader = new ByteReader(bytes);
59
+ reader.seek(2);
60
+ while (reader.remaining >= 2) {
61
+ const start = reader.offset;
62
+ let marker = reader.u8();
63
+ if (marker !== 0xff) {
64
+ throw new MediaFormatError(`expected a marker at offset ${start}, found 0x${marker.toString(16)}`);
65
+ }
66
+ marker = reader.u8();
67
+ while (marker === 0xff && reader.remaining > 0)
68
+ marker = reader.u8(); // fill bytes
69
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
70
+ segments.push({
71
+ marker,
72
+ name: markerName(marker),
73
+ offset: start,
74
+ length: reader.offset - start,
75
+ payload: new Uint8Array(0),
76
+ });
77
+ continue;
78
+ }
79
+ if (marker === 0xd9) {
80
+ segments.push({ marker, name: "EOI", offset: start, length: 2, payload: new Uint8Array(0) });
81
+ return { segments, scanStart: reader.offset };
82
+ }
83
+ const length = reader.u16be();
84
+ if (length < 2) {
85
+ throw new MediaFormatError(`segment ${markerName(marker)} declares a length of ${length}`);
86
+ }
87
+ const payload = reader.take(length - 2);
88
+ segments.push({
89
+ marker,
90
+ name: markerName(marker),
91
+ offset: start,
92
+ length: reader.offset - start,
93
+ payload,
94
+ });
95
+ if (marker === 0xda)
96
+ return { segments, scanStart: reader.offset };
97
+ }
98
+ throw new MediaFormatError("the file ends before the start-of-scan marker");
99
+ }
100
+ // --- EXIF ----------------------------------------------------------------
101
+ const TYPE_SIZES = {
102
+ 1: 1,
103
+ 2: 1,
104
+ 3: 2,
105
+ 4: 4,
106
+ 5: 8,
107
+ 6: 1,
108
+ 7: 1,
109
+ 8: 2,
110
+ 9: 4,
111
+ 10: 8,
112
+ 11: 4,
113
+ 12: 8,
114
+ };
115
+ /** Cap on entries per IFD, so a corrupt count cannot drive a long loop. */
116
+ const MAX_IFD_ENTRIES = 512;
117
+ class TiffReader {
118
+ view;
119
+ little;
120
+ bytes;
121
+ constructor(bytes, little) {
122
+ this.bytes = bytes;
123
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
124
+ this.little = little;
125
+ }
126
+ u16(at) {
127
+ if (at + 2 > this.bytes.length)
128
+ throw new MediaFormatError(`TIFF read past the end at ${at}`);
129
+ return this.view.getUint16(at, this.little);
130
+ }
131
+ u32(at) {
132
+ if (at + 4 > this.bytes.length)
133
+ throw new MediaFormatError(`TIFF read past the end at ${at}`);
134
+ return this.view.getUint32(at, this.little);
135
+ }
136
+ i32(at) {
137
+ if (at + 4 > this.bytes.length)
138
+ throw new MediaFormatError(`TIFF read past the end at ${at}`);
139
+ return this.view.getInt32(at, this.little);
140
+ }
141
+ }
142
+ function readTagValue(tiff, type, count, valueAt) {
143
+ const size = TYPE_SIZES[type];
144
+ if (size === undefined)
145
+ throw new MediaFormatError(`TIFF type ${type} is not defined`);
146
+ const total = size * count;
147
+ const at = total <= 4 ? valueAt : tiff.u32(valueAt);
148
+ if (at + total > tiff.bytes.length) {
149
+ throw new MediaFormatError(`tag value of ${total} bytes at ${at} runs past the EXIF block`);
150
+ }
151
+ if (type === 2) {
152
+ let out = "";
153
+ for (let i = 0; i < count; i++) {
154
+ const byte = tiff.bytes[at + i];
155
+ if (byte === 0)
156
+ break;
157
+ out += String.fromCharCode(byte);
158
+ }
159
+ return out.trim();
160
+ }
161
+ const values = [];
162
+ for (let i = 0; i < count; i++) {
163
+ const p = at + i * size;
164
+ switch (type) {
165
+ case 1:
166
+ case 7:
167
+ values.push(tiff.bytes[p]);
168
+ break;
169
+ case 3:
170
+ values.push(tiff.u16(p));
171
+ break;
172
+ case 4:
173
+ values.push(tiff.u32(p));
174
+ break;
175
+ case 5: {
176
+ const denominator = tiff.u32(p + 4);
177
+ values.push(denominator === 0 ? 0 : tiff.u32(p) / denominator);
178
+ break;
179
+ }
180
+ case 9:
181
+ values.push(tiff.i32(p));
182
+ break;
183
+ case 10: {
184
+ const denominator = tiff.i32(p + 4);
185
+ values.push(denominator === 0 ? 0 : tiff.i32(p) / denominator);
186
+ break;
187
+ }
188
+ case 6:
189
+ values.push(tiff.view.getInt8(p));
190
+ break;
191
+ case 8:
192
+ values.push(tiff.view.getInt16(p, tiff.little));
193
+ break;
194
+ case 11:
195
+ values.push(tiff.view.getFloat32(p, tiff.little));
196
+ break;
197
+ case 12:
198
+ values.push(tiff.view.getFloat64(p, tiff.little));
199
+ break;
200
+ default:
201
+ break;
202
+ }
203
+ }
204
+ return values.length === 1 ? values[0] : values;
205
+ }
206
+ function readIfd(tiff, at) {
207
+ const out = new Map();
208
+ const count = tiff.u16(at);
209
+ if (count > MAX_IFD_ENTRIES) {
210
+ throw new MediaFormatError(`an IFD claims ${count} entries, over the ${MAX_IFD_ENTRIES} cap`);
211
+ }
212
+ for (let i = 0; i < count; i++) {
213
+ const entry = at + 2 + i * 12;
214
+ if (entry + 12 > tiff.bytes.length)
215
+ break;
216
+ const tag = tiff.u16(entry);
217
+ const type = tiff.u16(entry + 2);
218
+ const length = tiff.u32(entry + 4);
219
+ if (length > 1_000_000)
220
+ continue; // a count this large is corruption
221
+ try {
222
+ out.set(tag, readTagValue(tiff, type, length, entry + 8));
223
+ }
224
+ catch {
225
+ // One unreadable tag should not lose the rest of the block.
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+ const ORIENTATIONS = {
231
+ 1: "normal",
232
+ 2: "mirrored horizontally",
233
+ 3: "rotated 180 degrees",
234
+ 4: "mirrored vertically",
235
+ 5: "mirrored horizontally then rotated 90 degrees counter-clockwise",
236
+ 6: "rotated 90 degrees clockwise",
237
+ 7: "mirrored horizontally then rotated 90 degrees clockwise",
238
+ 8: "rotated 90 degrees counter-clockwise",
239
+ };
240
+ function dms(value) {
241
+ if (!Array.isArray(value) || value.length < 3)
242
+ return undefined;
243
+ const [d, m, s] = value;
244
+ if (d === undefined || m === undefined || s === undefined)
245
+ return undefined;
246
+ return d + m / 60 + s / 3600;
247
+ }
248
+ function str(value) {
249
+ if (typeof value !== "string")
250
+ return undefined;
251
+ return value === "" ? undefined : value;
252
+ }
253
+ function n(value) {
254
+ if (typeof value !== "number")
255
+ return undefined;
256
+ return Number.isFinite(value) ? value : undefined;
257
+ }
258
+ /** Parse an `APP1` payload that begins `Exif\0\0`. */
259
+ export function parseExif(payload) {
260
+ if (!asciiAt(payload, 0, "Exif") || payload[4] !== 0x00 || payload[5] !== 0x00) {
261
+ throw new MediaFormatError('the APP1 payload does not begin with the "Exif\\0\\0" identifier');
262
+ }
263
+ const tiffBytes = payload.subarray(6);
264
+ const order = (tiffBytes[0] << 8) | tiffBytes[1];
265
+ const little = order === 0x4949;
266
+ if (!little && order !== 0x4d4d) {
267
+ throw new MediaFormatError("the TIFF header carries neither the II nor the MM byte-order mark");
268
+ }
269
+ const tiff = new TiffReader(tiffBytes, little);
270
+ if (tiff.u16(2) !== 42)
271
+ throw new MediaFormatError("the TIFF header is missing its 42 magic number");
272
+ const ifd0 = readIfd(tiff, tiff.u32(4));
273
+ const exifOffset = ifd0.get(0x8769);
274
+ const exif = typeof exifOffset === "number" ? readIfd(tiff, exifOffset) : new Map();
275
+ const gpsOffset = ifd0.get(0x8825);
276
+ const gpsIfd = typeof gpsOffset === "number" ? readIfd(tiff, gpsOffset) : new Map();
277
+ let gps;
278
+ const latitude = dms(gpsIfd.get(0x0002));
279
+ const longitude = dms(gpsIfd.get(0x0004));
280
+ if (latitude !== undefined && longitude !== undefined) {
281
+ const latRef = str(gpsIfd.get(0x0001)) ?? "N";
282
+ const lonRef = str(gpsIfd.get(0x0003)) ?? "E";
283
+ const altitude = n(gpsIfd.get(0x0006));
284
+ const altitudeRef = n(gpsIfd.get(0x0005)) ?? 0;
285
+ const time = gpsIfd.get(0x0007);
286
+ const date = str(gpsIfd.get(0x001d));
287
+ let timestampUtc;
288
+ if (date !== undefined && Array.isArray(time) && time.length >= 3) {
289
+ const pad = (v) => String(Math.floor(v)).padStart(2, "0");
290
+ timestampUtc = `${date} ${pad(time[0])}:${pad(time[1])}:${pad(time[2])}`;
291
+ }
292
+ gps = {
293
+ latitude: Math.round((latRef.toUpperCase().startsWith("S") ? -latitude : latitude) * 1e6) / 1e6,
294
+ longitude: Math.round((lonRef.toUpperCase().startsWith("W") ? -longitude : longitude) * 1e6) / 1e6,
295
+ ...(altitude !== undefined
296
+ ? { altitudeMeters: Math.round((altitudeRef === 1 ? -altitude : altitude) * 100) / 100 }
297
+ : {}),
298
+ ...(timestampUtc !== undefined ? { timestampUtc } : {}),
299
+ };
300
+ }
301
+ const orientation = n(ifd0.get(0x0112));
302
+ const known = new Set([0x010f, 0x0110, 0x0112, 0x0131, 0x0132, 0x8769, 0x8825]);
303
+ const otherTagCount = [...ifd0.keys()].filter((t) => !known.has(t)).length + exif.size + gpsIfd.size;
304
+ return {
305
+ byteOrder: little ? "little-endian" : "big-endian",
306
+ ...(str(ifd0.get(0x010f)) !== undefined ? { make: str(ifd0.get(0x010f)) } : {}),
307
+ ...(str(ifd0.get(0x0110)) !== undefined ? { model: str(ifd0.get(0x0110)) } : {}),
308
+ ...(str(exif.get(0xa434)) !== undefined ? { lens: str(exif.get(0xa434)) } : {}),
309
+ ...(str(ifd0.get(0x0131)) !== undefined ? { software: str(ifd0.get(0x0131)) } : {}),
310
+ ...(orientation !== undefined
311
+ ? {
312
+ orientation,
313
+ orientationDescription: ORIENTATIONS[orientation] ?? `unknown (${orientation})`,
314
+ }
315
+ : {}),
316
+ ...(str(exif.get(0x9003)) !== undefined
317
+ ? { dateTimeOriginal: str(exif.get(0x9003)) }
318
+ : {}),
319
+ ...(str(exif.get(0x9004)) !== undefined
320
+ ? { dateTimeDigitized: str(exif.get(0x9004)) }
321
+ : {}),
322
+ ...(str(ifd0.get(0x0132)) !== undefined ? { dateTime: str(ifd0.get(0x0132)) } : {}),
323
+ ...(n(exif.get(0x829a)) !== undefined
324
+ ? { exposureTimeSeconds: n(exif.get(0x829a)) }
325
+ : {}),
326
+ ...(n(exif.get(0x829d)) !== undefined ? { fNumber: n(exif.get(0x829d)) } : {}),
327
+ ...(n(exif.get(0x8827)) !== undefined ? { isoSpeed: n(exif.get(0x8827)) } : {}),
328
+ ...(n(exif.get(0x920a)) !== undefined ? { focalLengthMm: n(exif.get(0x920a)) } : {}),
329
+ ...(n(exif.get(0xa405)) !== undefined
330
+ ? { focalLength35mm: n(exif.get(0xa405)) }
331
+ : {}),
332
+ ...(n(exif.get(0xa002)) !== undefined ? { pixelWidth: n(exif.get(0xa002)) } : {}),
333
+ ...(n(exif.get(0xa003)) !== undefined ? { pixelHeight: n(exif.get(0xa003)) } : {}),
334
+ hasGps: gps !== undefined,
335
+ ...(gps !== undefined ? { gps } : {}),
336
+ otherTagCount,
337
+ };
338
+ }
339
+ /** Find the `APP1` segment carrying EXIF, if there is one. */
340
+ export function findExifSegment(segments) {
341
+ return segments.find((s) => s.marker === 0xe1 && asciiAt(s.payload, 0, "Exif"));
342
+ }
343
+ /** A segment name that `stripJpegMetadata` removes, given these options. */
344
+ function isMetadata(segment, options) {
345
+ const { marker, payload } = segment;
346
+ if (marker === 0xfe)
347
+ return true; // COM
348
+ if (marker === 0xe0)
349
+ return options.keepJfif === false;
350
+ if (marker === 0xe2) {
351
+ // APP2 is the ICC profile in practice; anything else there is metadata.
352
+ const isIcc = asciiAt(payload, 0, "ICC_PROFILE");
353
+ return isIcc ? options.keepIccProfile === false : true;
354
+ }
355
+ // Every other APPn: EXIF and XMP (APP1), Meta (APP3), IPTC/Photoshop
356
+ // (APP13), Ducky (APP12), and the rest.
357
+ return marker >= 0xe1 && marker <= 0xef;
358
+ }
359
+ /**
360
+ * A JPEG with its metadata segments dropped. The scan data is copied
361
+ * verbatim — nothing is re-encoded, so the image is bit-for-bit the same
362
+ * picture, just without the record of who took it and where.
363
+ */
364
+ export function stripJpegMetadata(bytes, options = {}) {
365
+ const { segments, scanStart } = readJpegSegments(bytes);
366
+ const kept = [new Uint8Array([0xff, 0xd8])];
367
+ const removed = [];
368
+ for (const segment of segments) {
369
+ if (segment.marker === 0xd8)
370
+ continue;
371
+ if (isMetadata(segment, options)) {
372
+ removed.push({ name: segment.name, bytes: segment.length });
373
+ continue;
374
+ }
375
+ kept.push(bytes.subarray(segment.offset, segment.offset + segment.length));
376
+ }
377
+ kept.push(bytes.subarray(scanStart));
378
+ let total = 0;
379
+ for (const part of kept)
380
+ total += part.length;
381
+ const out = new Uint8Array(total);
382
+ let at = 0;
383
+ for (const part of kept) {
384
+ out.set(part, at);
385
+ at += part.length;
386
+ }
387
+ return { bytes: out, removed, bytesRemoved: bytes.length - out.length };
388
+ }