@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,519 @@
1
+ /**
2
+ * Identifying a file by what it IS, and reading the handful of facts every
3
+ * image format puts in its header.
4
+ *
5
+ * Nothing here decodes an image. Every format below states its dimensions,
6
+ * its sample depth and whether it carries an alpha channel within the first
7
+ * few dozen bytes, so a caller can learn all of that from a slice of the
8
+ * file rather than from a megabyte of pixels. The one exception is GIF
9
+ * transparency, which lives in a Graphic Control Extension after the header
10
+ * — that scan is bounded and gives up rather than reading the whole file.
11
+ */
12
+ import { ByteReader, MediaFormatError, asciiAt, startsWith } from "./bytes";
13
+ const eq = (offset, text) => (b) => asciiAt(b, offset, text);
14
+ const magic = (prefix) => (b) => startsWith(b, prefix);
15
+ /** True for an ISO base-media file whose `ftyp` brand is in `brands`. */
16
+ function ftypBrand(bytes, brands) {
17
+ if (!asciiAt(bytes, 4, "ftyp"))
18
+ return false;
19
+ for (const brand of brands) {
20
+ if (asciiAt(bytes, 8, brand))
21
+ return true;
22
+ }
23
+ // Compatible brands follow the major brand and its 4-byte minor version.
24
+ for (let at = 16; at + 4 <= Math.min(bytes.length, 64); at += 4) {
25
+ for (const brand of brands) {
26
+ if (asciiAt(bytes, at, brand))
27
+ return true;
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Ordered: the first match wins, so more specific signatures come first
34
+ * (AVIF and HEIC before the generic MP4 they share a container with).
35
+ */
36
+ const SIGNATURES = [
37
+ {
38
+ kind: "png",
39
+ mediaType: "image/png",
40
+ extensions: ["png"],
41
+ family: "image",
42
+ match: magic([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
43
+ },
44
+ {
45
+ kind: "jpeg",
46
+ mediaType: "image/jpeg",
47
+ extensions: ["jpg", "jpeg", "jpe"],
48
+ family: "image",
49
+ match: magic([0xff, 0xd8, 0xff]),
50
+ },
51
+ {
52
+ kind: "gif",
53
+ mediaType: "image/gif",
54
+ extensions: ["gif"],
55
+ family: "image",
56
+ match: (b) => asciiAt(b, 0, "GIF87a") || asciiAt(b, 0, "GIF89a"),
57
+ },
58
+ {
59
+ kind: "webp",
60
+ mediaType: "image/webp",
61
+ extensions: ["webp"],
62
+ family: "image",
63
+ match: (b) => asciiAt(b, 0, "RIFF") && asciiAt(b, 8, "WEBP"),
64
+ },
65
+ {
66
+ kind: "bmp",
67
+ mediaType: "image/bmp",
68
+ extensions: ["bmp", "dib"],
69
+ family: "image",
70
+ match: eq(0, "BM"),
71
+ },
72
+ {
73
+ kind: "tiff",
74
+ mediaType: "image/tiff",
75
+ extensions: ["tif", "tiff"],
76
+ family: "image",
77
+ match: (b) => magic([0x49, 0x49, 0x2a, 0x00])(b) || magic([0x4d, 0x4d, 0x00, 0x2a])(b),
78
+ },
79
+ {
80
+ kind: "ico",
81
+ mediaType: "image/x-icon",
82
+ extensions: ["ico"],
83
+ family: "image",
84
+ match: magic([0x00, 0x00, 0x01, 0x00]),
85
+ },
86
+ {
87
+ kind: "avif",
88
+ mediaType: "image/avif",
89
+ extensions: ["avif"],
90
+ family: "image",
91
+ match: (b) => ftypBrand(b, ["avif", "avis"]),
92
+ },
93
+ {
94
+ kind: "heic",
95
+ mediaType: "image/heic",
96
+ extensions: ["heic", "heif"],
97
+ family: "image",
98
+ match: (b) => ftypBrand(b, ["heic", "heix", "mif1", "msf1"]),
99
+ },
100
+ {
101
+ kind: "mp4",
102
+ mediaType: "video/mp4",
103
+ extensions: ["mp4", "m4v", "m4a"],
104
+ family: "video",
105
+ match: (b) => ftypBrand(b, ["isom", "mp41", "mp42", "M4V ", "M4A ", "dash", "iso2"]),
106
+ },
107
+ {
108
+ kind: "matroska",
109
+ mediaType: "video/webm",
110
+ extensions: ["webm", "mkv"],
111
+ family: "video",
112
+ match: magic([0x1a, 0x45, 0xdf, 0xa3]),
113
+ },
114
+ {
115
+ kind: "quicktime",
116
+ mediaType: "video/quicktime",
117
+ extensions: ["mov"],
118
+ family: "video",
119
+ match: (b) => ftypBrand(b, ["qt "]),
120
+ },
121
+ {
122
+ kind: "ogg",
123
+ mediaType: "application/ogg",
124
+ extensions: ["ogg", "oga", "ogv", "opus"],
125
+ family: "audio",
126
+ match: eq(0, "OggS"),
127
+ },
128
+ {
129
+ kind: "wav",
130
+ mediaType: "audio/wav",
131
+ extensions: ["wav"],
132
+ family: "audio",
133
+ match: (b) => asciiAt(b, 0, "RIFF") && asciiAt(b, 8, "WAVE"),
134
+ },
135
+ {
136
+ kind: "flac",
137
+ mediaType: "audio/flac",
138
+ extensions: ["flac"],
139
+ family: "audio",
140
+ match: eq(0, "fLaC"),
141
+ },
142
+ {
143
+ kind: "mp3",
144
+ mediaType: "audio/mpeg",
145
+ extensions: ["mp3"],
146
+ family: "audio",
147
+ // An ID3v2 tag, or a bare MPEG audio frame sync with a valid layer.
148
+ match: (b) => asciiAt(b, 0, "ID3") ||
149
+ (b.length > 1 &&
150
+ b[0] === 0xff &&
151
+ (b[1] & 0xe0) === 0xe0 &&
152
+ ((b[1] >> 1) & 0x03) !== 0),
153
+ },
154
+ {
155
+ kind: "pdf",
156
+ mediaType: "application/pdf",
157
+ extensions: ["pdf"],
158
+ family: "document",
159
+ match: eq(0, "%PDF-"),
160
+ },
161
+ {
162
+ kind: "zip",
163
+ mediaType: "application/zip",
164
+ extensions: ["zip"],
165
+ family: "archive",
166
+ match: magic([0x50, 0x4b, 0x03, 0x04]),
167
+ },
168
+ {
169
+ kind: "gzip",
170
+ mediaType: "application/gzip",
171
+ extensions: ["gz"],
172
+ family: "archive",
173
+ match: magic([0x1f, 0x8b]),
174
+ },
175
+ {
176
+ kind: "woff2",
177
+ mediaType: "font/woff2",
178
+ extensions: ["woff2"],
179
+ family: "font",
180
+ match: eq(0, "wOF2"),
181
+ },
182
+ ];
183
+ /** Leading whitespace and an optional UTF-8 BOM, for the text sniffs. */
184
+ function textHead(bytes, limit = 512) {
185
+ let start = 0;
186
+ if (startsWith(bytes, [0xef, 0xbb, 0xbf]))
187
+ start = 3;
188
+ const slice = bytes.subarray(start, Math.min(bytes.length, start + limit));
189
+ return new TextDecoder("utf-8", { fatal: false }).decode(slice).trimStart();
190
+ }
191
+ const SVG = {
192
+ kind: "svg",
193
+ mediaType: "image/svg+xml",
194
+ extensions: ["svg"],
195
+ family: "image",
196
+ };
197
+ /**
198
+ * Name the format from the bytes. Returns `undefined` when nothing matches,
199
+ * which is the honest answer — "unknown" beats a guess from the extension.
200
+ */
201
+ export function detectKind(bytes) {
202
+ for (const signature of SIGNATURES) {
203
+ if (signature.match(bytes)) {
204
+ const { match: _match, ...kind } = signature;
205
+ return kind;
206
+ }
207
+ }
208
+ // SVG has no magic number; it is XML, so it is sniffed last and only from
209
+ // a leading `<svg` or an XML declaration followed by one.
210
+ const head = textHead(bytes);
211
+ if (head.startsWith("<svg") || (head.startsWith("<?xml") && head.includes("<svg")))
212
+ return SVG;
213
+ return undefined;
214
+ }
215
+ /** Every format `detectKind` can name, sorted, for a tool to report. */
216
+ export function knownKinds() {
217
+ return [...SIGNATURES.map((s) => s.kind), SVG.kind].sort();
218
+ }
219
+ /** Look up the kinds an extension conventionally names. */
220
+ export function kindsForExtension(extension) {
221
+ const ext = extension.replace(/^\./, "").toLowerCase();
222
+ const out = [];
223
+ for (const signature of [...SIGNATURES, SVG]) {
224
+ if (signature.extensions.includes(ext))
225
+ out.push(signature.kind);
226
+ }
227
+ return out.sort();
228
+ }
229
+ const PNG_COLOR_TYPES = {
230
+ 0: "grayscale",
231
+ 2: "rgb",
232
+ 3: "palette",
233
+ 4: "grayscale-alpha",
234
+ 6: "rgba",
235
+ };
236
+ function pngHeader(bytes) {
237
+ const reader = new ByteReader(bytes);
238
+ reader.seek(8);
239
+ reader.u32be();
240
+ reader.ascii(4);
241
+ const width = reader.u32be();
242
+ const height = reader.u32be();
243
+ const bitDepth = reader.u8();
244
+ const colorType = reader.u8();
245
+ reader.u8();
246
+ reader.u8();
247
+ const interlace = reader.u8();
248
+ reader.skip(4); // the IHDR chunk's CRC
249
+ // `tRNS` gives an otherwise opaque colour type transparency; look for it
250
+ // in the chunks that precede the first IDAT.
251
+ let hasTrns = false;
252
+ try {
253
+ while (reader.remaining >= 8) {
254
+ const length = reader.u32be();
255
+ const type = reader.ascii(4);
256
+ if (type === "IDAT" || type === "IEND")
257
+ break;
258
+ if (type === "tRNS") {
259
+ hasTrns = true;
260
+ break;
261
+ }
262
+ reader.skip(length + 4);
263
+ }
264
+ }
265
+ catch {
266
+ // A header slice that stops mid-chunk simply tells us nothing more.
267
+ }
268
+ const notes = [];
269
+ if (interlace === 1)
270
+ notes.push("Adam7 interlaced");
271
+ if (hasTrns)
272
+ notes.push("carries a tRNS transparency chunk");
273
+ return {
274
+ format: "png",
275
+ width,
276
+ height,
277
+ bitDepth,
278
+ colorType: PNG_COLOR_TYPES[colorType] ?? `unknown(${colorType})`,
279
+ hasAlpha: colorType === 4 || colorType === 6 || hasTrns,
280
+ ...(notes.length > 0 ? { notes } : {}),
281
+ };
282
+ }
283
+ const JPEG_PROGRESSIVE = new Set([0xc2, 0xc6, 0xca, 0xce]);
284
+ function jpegHeader(bytes) {
285
+ const reader = new ByteReader(bytes);
286
+ reader.seek(2);
287
+ while (reader.remaining >= 4) {
288
+ let marker = reader.u8();
289
+ if (marker !== 0xff) {
290
+ // Re-synchronise: fill bytes of 0xFF are legal between segments.
291
+ continue;
292
+ }
293
+ while (marker === 0xff && reader.remaining > 0)
294
+ marker = reader.u8();
295
+ // Standalone markers carry no length.
296
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
297
+ continue;
298
+ if (marker === 0xd9 || marker === 0xda)
299
+ break;
300
+ const length = reader.u16be();
301
+ if (length < 2)
302
+ throw new MediaFormatError(`JPEG segment FF${marker.toString(16)} has length ${length}`);
303
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
304
+ if (isSof) {
305
+ const precision = reader.u8();
306
+ const height = reader.u16be();
307
+ const width = reader.u16be();
308
+ const components = reader.u8();
309
+ const colorType = components === 1
310
+ ? "grayscale"
311
+ : components === 3
312
+ ? "ycbcr"
313
+ : components === 4
314
+ ? "ycck-or-cmyk"
315
+ : `${components}-component`;
316
+ const notes = [];
317
+ if (JPEG_PROGRESSIVE.has(marker))
318
+ notes.push("progressive");
319
+ return {
320
+ format: "jpeg",
321
+ width,
322
+ height,
323
+ bitDepth: precision,
324
+ colorType,
325
+ // Baseline JPEG has no alpha channel at all.
326
+ hasAlpha: false,
327
+ ...(notes.length > 0 ? { notes } : {}),
328
+ };
329
+ }
330
+ reader.skip(length - 2);
331
+ }
332
+ throw new MediaFormatError("no JPEG frame header (SOFn) found before the scan data");
333
+ }
334
+ function gifHeader(bytes) {
335
+ const reader = new ByteReader(bytes);
336
+ const version = reader.ascii(6);
337
+ const width = reader.u16le();
338
+ const height = reader.u16le();
339
+ const packed = reader.u8();
340
+ const bitsPerColor = ((packed >> 4) & 0x07) + 1;
341
+ const hasGlobalTable = (packed & 0x80) !== 0;
342
+ // Transparency is declared per-frame in a Graphic Control Extension, so
343
+ // scan for the first one. The scan is bounded by the slice it was given.
344
+ let hasAlpha = false;
345
+ for (let i = 13; i + 3 < bytes.length; i++) {
346
+ if (bytes[i] === 0x21 && bytes[i + 1] === 0xf9 && bytes[i + 2] === 0x04) {
347
+ if ((bytes[i + 3] & 0x01) !== 0) {
348
+ hasAlpha = true;
349
+ break;
350
+ }
351
+ }
352
+ }
353
+ return {
354
+ format: "gif",
355
+ width,
356
+ height,
357
+ bitDepth: bitsPerColor,
358
+ colorType: "palette",
359
+ hasAlpha,
360
+ notes: [
361
+ version,
362
+ hasGlobalTable ? "global colour table" : "no global colour table",
363
+ "alpha is a single fully transparent palette index, not a channel",
364
+ ],
365
+ };
366
+ }
367
+ function webpHeader(bytes) {
368
+ const reader = new ByteReader(bytes);
369
+ reader.seek(12);
370
+ const fourcc = reader.ascii(4);
371
+ const length = reader.u32le();
372
+ switch (fourcc) {
373
+ case "VP8 ": {
374
+ if (length < 10)
375
+ throw new MediaFormatError("WebP VP8 chunk is too short for a frame header");
376
+ reader.skip(3); // frame tag
377
+ const sync = reader.take(3);
378
+ if (sync[0] !== 0x9d || sync[1] !== 0x01 || sync[2] !== 0x2a) {
379
+ throw new MediaFormatError("WebP VP8 frame is missing its 9D 01 2A start code");
380
+ }
381
+ const width = reader.u16le() & 0x3fff;
382
+ const height = reader.u16le() & 0x3fff;
383
+ return {
384
+ format: "webp",
385
+ width,
386
+ height,
387
+ colorType: "ycbcr",
388
+ hasAlpha: false,
389
+ notes: ["lossy (VP8)"],
390
+ };
391
+ }
392
+ case "VP8L": {
393
+ const signature = reader.u8();
394
+ if (signature !== 0x2f)
395
+ throw new MediaFormatError("WebP VP8L chunk is missing its 0x2F signature");
396
+ const bits = reader.u32le();
397
+ const width = (bits & 0x3fff) + 1;
398
+ const height = ((bits >>> 14) & 0x3fff) + 1;
399
+ const hasAlpha = ((bits >>> 28) & 0x01) === 1;
400
+ return {
401
+ format: "webp",
402
+ width,
403
+ height,
404
+ colorType: "rgba",
405
+ hasAlpha,
406
+ notes: ["lossless (VP8L)"],
407
+ };
408
+ }
409
+ case "VP8X": {
410
+ const flags = reader.u8();
411
+ reader.skip(3); // reserved
412
+ const width = (reader.u8() | (reader.u8() << 8) | (reader.u8() << 16)) + 1;
413
+ const height = (reader.u8() | (reader.u8() << 8) | (reader.u8() << 16)) + 1;
414
+ const notes = ["extended (VP8X)"];
415
+ if ((flags & 0x02) !== 0)
416
+ notes.push("animated");
417
+ if ((flags & 0x08) !== 0)
418
+ notes.push("has an ICC profile");
419
+ if ((flags & 0x04) !== 0)
420
+ notes.push("has EXIF metadata");
421
+ return {
422
+ format: "webp",
423
+ width,
424
+ height,
425
+ colorType: "rgba",
426
+ hasAlpha: (flags & 0x10) !== 0,
427
+ notes,
428
+ };
429
+ }
430
+ default:
431
+ throw new MediaFormatError(`WebP body starts with an unexpected "${fourcc}" chunk`);
432
+ }
433
+ }
434
+ function bmpHeader(bytes) {
435
+ const reader = new ByteReader(bytes);
436
+ reader.seek(14);
437
+ const dibSize = reader.u32le();
438
+ if (dibSize === 12) {
439
+ // BITMAPCOREHEADER: 16-bit unsigned dimensions.
440
+ const width = reader.u16le();
441
+ const height = reader.u16le();
442
+ reader.u16le(); // planes
443
+ const bitCount = reader.u16le();
444
+ return {
445
+ format: "bmp",
446
+ width,
447
+ height,
448
+ bitDepth: bitCount,
449
+ colorType: bitCount <= 8 ? "palette" : "rgb",
450
+ hasAlpha: false,
451
+ notes: ["BITMAPCOREHEADER"],
452
+ };
453
+ }
454
+ if (dibSize < 40)
455
+ throw new MediaFormatError(`BMP DIB header is ${dibSize} bytes, too short to read`);
456
+ const width = reader.i32le();
457
+ const signedHeight = reader.i32le();
458
+ reader.u16le(); // planes
459
+ const bitCount = reader.u16le();
460
+ const compression = reader.u32le();
461
+ let hasAlpha = false;
462
+ if (dibSize >= 108) {
463
+ // BITMAPV4HEADER and later state the channel masks; an alpha mask of
464
+ // zero means the 32nd bit is padding, not transparency.
465
+ reader.skip(20); // image size, x/y ppm, colours used, colours important
466
+ reader.u32le(); // red mask
467
+ reader.u32le(); // green mask
468
+ reader.u32le(); // blue mask
469
+ hasAlpha = reader.u32le() !== 0;
470
+ }
471
+ else if (bitCount === 32 && compression === 6) {
472
+ // BI_ALPHABITFIELDS.
473
+ hasAlpha = true;
474
+ }
475
+ const notes = [`DIB header ${dibSize} bytes`];
476
+ if (signedHeight < 0)
477
+ notes.push("top-down row order");
478
+ if (bitCount === 32 && !hasAlpha)
479
+ notes.push("32bpp with no declared alpha mask: the 4th byte is padding");
480
+ return {
481
+ format: "bmp",
482
+ width: Math.abs(width),
483
+ height: Math.abs(signedHeight),
484
+ bitDepth: bitCount,
485
+ colorType: bitCount <= 8 ? "palette" : "rgb",
486
+ hasAlpha,
487
+ notes,
488
+ };
489
+ }
490
+ /** The formats `readImageHeader` understands, in the order it tries them. */
491
+ export const HEADER_FORMATS = Object.freeze([
492
+ "png",
493
+ "jpeg",
494
+ "gif",
495
+ "webp",
496
+ "bmp",
497
+ ]);
498
+ /**
499
+ * Dimensions and colour facts from a file header alone. Throws
500
+ * `MediaFormatError` when the bytes are not one of `HEADER_FORMATS`, or
501
+ * when they are but the header is malformed or truncated.
502
+ */
503
+ export function readImageHeader(bytes) {
504
+ const detected = detectKind(bytes);
505
+ switch (detected?.kind) {
506
+ case "png":
507
+ return pngHeader(bytes);
508
+ case "jpeg":
509
+ return jpegHeader(bytes);
510
+ case "gif":
511
+ return gifHeader(bytes);
512
+ case "webp":
513
+ return webpHeader(bytes);
514
+ case "bmp":
515
+ return bmpHeader(bytes);
516
+ default:
517
+ throw new MediaFormatError(`header reading covers ${HEADER_FORMATS.join(", ")}; these bytes are ${detected === undefined ? "not a recognised format" : detected.kind}`);
518
+ }
519
+ }
@@ -0,0 +1,86 @@
1
+ import { type RgbaImage } from "./png";
2
+ export type Resampling = "nearest" | "bilinear";
3
+ /** A blank image, fully transparent. */
4
+ export declare function blankImage(width: number, height: number): RgbaImage;
5
+ /**
6
+ * Resize to exactly `width` x `height`.
7
+ *
8
+ * `nearest` preserves exact colours (right for pixel art, palettes and QR
9
+ * codes); `bilinear` averages the four neighbours (right for photographs,
10
+ * and the only one of the two that does not alias badly when shrinking by
11
+ * a large factor — though neither does the area-averaging a proper
12
+ * downscaler would, which is stated rather than hidden).
13
+ */
14
+ export declare function resizeImage(image: RgbaImage, width: number, height: number, method: Resampling): RgbaImage;
15
+ /** The size that fits `width`/`height` while preserving the aspect ratio. */
16
+ export declare function fitDimensions(source: {
17
+ readonly width: number;
18
+ readonly height: number;
19
+ }, wanted: {
20
+ readonly width?: number;
21
+ readonly height?: number;
22
+ }): {
23
+ width: number;
24
+ height: number;
25
+ };
26
+ /** Cut a rectangle out. The rectangle must lie entirely inside the image. */
27
+ export declare function cropImage(image: RgbaImage, x: number, y: number, width: number, height: number): RgbaImage;
28
+ export type DiffResult = {
29
+ readonly width: number;
30
+ readonly height: number;
31
+ readonly totalPixels: number;
32
+ readonly differingPixels: number;
33
+ /** `differingPixels / totalPixels`, rounded to six places. */
34
+ readonly fraction: number;
35
+ /** Largest single-channel absolute difference anywhere. */
36
+ readonly maxChannelDelta: number;
37
+ /** Mean absolute channel difference across every channel of every pixel. */
38
+ readonly meanChannelDelta: number;
39
+ /** Tight box around every differing pixel, or `null` when none differ. */
40
+ readonly boundingBox: {
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ } | null;
46
+ };
47
+ /**
48
+ * Compare two same-sized images. A pixel counts as differing when any of its
49
+ * four channels differs by more than `threshold` (0 means exact equality).
50
+ */
51
+ export declare function diffImages(a: RgbaImage, b: RgbaImage, threshold: number): DiffResult;
52
+ /**
53
+ * A 64-bit difference hash: reduce to 9x8 greyscale, then emit one bit per
54
+ * adjacent horizontal pair saying whether the left sample is brighter.
55
+ * Robust to scaling and mild compression, which is what makes it the right
56
+ * "are these the same picture?" signal when the pixels are not identical.
57
+ *
58
+ * Returned as 16 lowercase hex digits, most significant bit first.
59
+ */
60
+ export declare function differenceHash(image: RgbaImage): string;
61
+ /** Bits that differ between two hex hashes of equal length. */
62
+ export declare function hammingDistance(a: string, b: string): number;
63
+ /** Per-channel mean and extremes, for `PngRead`'s statistics mode. */
64
+ export declare function imageStats(image: RgbaImage): {
65
+ mean: {
66
+ r: number;
67
+ g: number;
68
+ b: number;
69
+ a: number;
70
+ };
71
+ min: {
72
+ r: number;
73
+ g: number;
74
+ b: number;
75
+ a: number;
76
+ };
77
+ max: {
78
+ r: number;
79
+ g: number;
80
+ b: number;
81
+ a: number;
82
+ };
83
+ opaquePixels: number;
84
+ distinctColorsCapped: number;
85
+ capped: boolean;
86
+ };