@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.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # @crewhaus/tool-media
2
+
3
+ Deterministic media tools. Image headers, PNG encoding and decoding,
4
+ screenshot comparison, EXIF, QR codes and barcodes, charts, sparklines and
5
+ diagrams, subtitles, and a probe for audio and video — without a model call
6
+ and without an image library.
7
+
8
+ Every format reader and writer here is hand-written against its published
9
+ specification, and each one states the exact subset it supports. Where
10
+ something cannot be done faithfully — rasterising a chart label with no
11
+ font, decoding a 16-bit PNG into 8-bit samples, laying out a dense graph —
12
+ the tool says so rather than producing something that looks right and is
13
+ not.
14
+
15
+ ```yaml
16
+ tools:
17
+ - all-media # every tool below
18
+ - -MediaProbe # ...except the one that shells out
19
+ ```
20
+
21
+ | Tool | What it does |
22
+ |---|---|
23
+ | `BarcodeEncode` | Code 128 or EAN-13 to a PNG or a module pattern, check digit computed |
24
+ | `ChartRender` | Bar, line, scatter or pie data to an SVG chart with axes, ticks and a legend |
25
+ | `ColorContrast` | The WCAG contrast ratio for colour pairs, and the levels each one clears |
26
+ | `ColorConvert` | Hex, RGB and HSL in every direction, plus relative luminance |
27
+ | `DiagramRender` | A node and edge list to a box-and-arrow SVG with a layered layout |
28
+ | `ExifRead` | A JPEG's capture time, camera, lens, exposure, orientation — and GPS |
29
+ | `ExifStrip` | A copy of a JPEG without its metadata, the picture untouched |
30
+ | `ImageCrop` | A rectangle out of a PNG, written as a PNG |
31
+ | `ImageDiff` | Two PNGs compared pixel by pixel and by perceptual hash |
32
+ | `ImageInfo` | Dimensions, bit depth, colour model and alpha, from the header alone |
33
+ | `ImageKind` | What a file actually is, and whether its extension agrees |
34
+ | `ImageResize` | A PNG at a new size, with the resampling method stated |
35
+ | `MediaProbe` | `ffprobe`'s view of an audio or video file, structured |
36
+ | `PngRead` | A PNG decoded to RGBA pixels, or to per-channel statistics |
37
+ | `PngWrite` | Raw RGBA pixels encoded as a valid PNG |
38
+ | `QrEncode` | A QR code as a PNG or a text matrix, error correction chosen |
39
+ | `SparklineRender` | A series as a one-line SVG or a run of Unicode blocks |
40
+ | `SubtitleParse` | SRT or WebVTT to cues with millisecond times |
41
+ | `SubtitleWrite` | Cues back out, shifted and re-wrapped, in either format |
42
+
43
+ ## Determinism
44
+
45
+ The same inputs against the same files produce the same bytes, on any
46
+ machine. Nothing reads the clock — a subtitle shift is an argument, never
47
+ "now minus something". Nothing uses unseeded randomness. Listings are
48
+ sorted. Numbers are formatted without a locale, so an axis label does not
49
+ change when `LANG` does. Chart and diagram layout is arithmetic on the data
50
+ plus fixed constants: no measurement, no iteration to convergence. A QR
51
+ code's mask is chosen by the standard's penalty rules, which are a pure
52
+ function of the payload, so the same text always produces the same symbol.
53
+
54
+ Two deliberate consequences. Label widths are **estimated** from a fixed
55
+ per-character advance rather than measured, because measuring would make
56
+ the output depend on which fonts a machine has. And PNG compression runs at
57
+ a pinned level through `node:zlib`, so two renders of the same chart
58
+ compare equal byte for byte.
59
+
60
+ The one exception is `MediaProbe`, which is only as deterministic as the
61
+ `ffprobe` on the machine. Its result says so.
62
+
63
+ ## Containment
64
+
65
+ Every caller-supplied path goes through the same `resolveSafe` that
66
+ `@crewhaus/tool-fs` uses: a lexical check for `..` and absolute escapes,
67
+ then a symlink-aware check, so a link living inside the workspace and
68
+ pointing at `/etc` is refused rather than followed. The workspace root is
69
+ `process.cwd()`.
70
+
71
+ Every read is size-checked before it happens. A header reader reads the
72
+ first megabyte of a file, never the whole of a four-gigabyte video. A PNG's
73
+ dimensions are checked against the pixel cap on `IHDR`, before anything is
74
+ inflated, and the inflate itself is given `maxOutputLength`, so a lying
75
+ stream aborts mid-decompression rather than after the fact.
76
+
77
+ `MediaProbe` is the only tool that leaves the process. It declares
78
+ `scope: "external"` and `ioCapability: "process"`, passes its argument as
79
+ an argv array with a `--` terminator (never a shell string), bounds the run
80
+ with a deadline it cannot outlive, and reads `ffprobe`'s pipes with a byte
81
+ cap and a drain deadline — so a probe that hangs costs a second, not a
82
+ turn. Only an allowlist of `ffprobe`'s fields is carried through; the
83
+ absolute path it echoes back is not one of them.
84
+
85
+ ## What these will not do
86
+
87
+ **They will not rasterise text.** Drawing a label into a bitmap needs a
88
+ font, and this package ships none — inventing glyphs would be worse than
89
+ declining. So `ChartRender`, `DiagramRender` and `SparklineRender` emit
90
+ SVG, which is text and renders wherever there are fonts, and `QrEncode` and
91
+ `BarcodeEncode` emit PNG, because a code is pure geometry.
92
+
93
+ **They will not decode JPEG, GIF, WebP or BMP.** `ImageInfo` reads those
94
+ formats' headers, which is where the dimensions and the colour model live.
95
+ Pixels are PNG only, which is what `PngRead`, `ImageDiff`, `ImageResize`
96
+ and `ImageCrop` say when handed anything else — by name, so a caller knows
97
+ what the file actually turned out to be.
98
+
99
+ **They will not lay out an arbitrary graph.** `DiagramRender` assigns each
100
+ node a layer one past its deepest predecessor and draws layers in order. A
101
+ dense graph will have crossing edges, and this will not untangle them. A
102
+ cycle is broken at one named edge, which is still drawn — dashed — and is
103
+ reported in `backEdges`.
104
+
105
+ **They will not re-encode a photograph.** `ExifStrip` rewrites the segment
106
+ list and copies the entropy-coded scan data verbatim, so the picture is
107
+ bit-for-bit what it was. It keeps the ICC colour profile by default,
108
+ because a profile is not metadata about the photographer: it is what tells
109
+ a display how to interpret the colours, and dropping it visibly shifts the
110
+ image.
111
+
112
+ **They will not guess.** A 16-bit or interlaced PNG is refused by name
113
+ rather than truncated or de-interlaced badly. A payload past a QR code's
114
+ capacity is refused with the capacity. An EAN-13 whose thirteenth digit
115
+ does not check is refused with the digit it should have been, not silently
116
+ corrected. A colour notation this package does not read is named back.
117
+
118
+ ## Formats and their limits
119
+
120
+ **PNG.** Writes 8-bit colour types 0, 2, 4 and 6, with `auto` choosing the
121
+ smallest one that is lossless for the exact pixels given. Reads those plus
122
+ palette (type 3) with `tRNS`, and multiple `IDAT` chunks. Refuses bit
123
+ depths other than 8, and Adam7 interlacing.
124
+
125
+ **QR.** Byte mode, UTF-8, versions 1 to 10 (21×21 to 57×57 modules — 271
126
+ bytes at level L), all four error-correction levels, all eight masks with
127
+ the standard's penalty scoring. No Structured Append, Micro QR, Kanji mode
128
+ or FNC1/GS1 indicators; each of those changes what a scanner does with the
129
+ result, so none is approximated.
130
+
131
+ **Code 128.** One code set for the whole symbol: C when the payload is an
132
+ even number of digits at least four long, otherwise B. Code set A is never
133
+ emitted, so the range is ASCII 32 to 126, and anything outside it is
134
+ refused by character. Switching sets mid-symbol would make a mixed payload
135
+ narrower and nothing else.
136
+
137
+ **EAN-13.** Twelve digits in, the thirteenth computed; thirteen in, the
138
+ thirteenth verified. UPC-A is EAN-13 with a leading zero, so that works
139
+ too. No EAN-8, and no 2- or 5-digit add-ons. Bars only — the
140
+ human-readable digits underneath would need a font.
141
+
142
+ **EXIF.** IFD0, the Exif sub-IFD and the GPS sub-IFD of an `APP1` segment,
143
+ both byte orders. Not MakerNotes, IFD1 thumbnails, XMP, IPTC, or EXIF in a
144
+ TIFF or HEIC.
145
+
146
+ **Subtitles.** SRT and WebVTT, through a BOM and either line ending, with
147
+ cue ids and cue settings preserved. Not WebVTT chapter or metadata tracks.
148
+ Inline markup is left in the cue text verbatim — this neither parses it nor
149
+ strips it.
150
+
151
+ ## A note on `ExifRead`
152
+
153
+ A photograph taken on a phone usually records where it was taken.
154
+ `ExifRead` reports that as `hasGps: true` with a `privacyWarning`, rather
155
+ than burying the coordinates in a field list, because publishing the file
156
+ publishes the location. `ExifStrip` is the tool that removes it.
@@ -0,0 +1,81 @@
1
+ export declare const PNG_SIGNATURE_BYTES: Uint8Array<ArrayBuffer>;
2
+ export type HandPngOptions = {
3
+ readonly width: number;
4
+ readonly height: number;
5
+ /** 0 grey, 2 RGB, 3 palette, 4 grey+alpha, 6 RGBA. */
6
+ readonly colorType: number;
7
+ /** Raw samples, row-major, WITHOUT the per-scanline filter byte. */
8
+ readonly samples: Uint8Array;
9
+ readonly palette?: Uint8Array;
10
+ readonly transparency?: Uint8Array;
11
+ /** Split the IDAT across this many chunks, to exercise concatenation. */
12
+ readonly idatChunks?: number;
13
+ readonly bitDepth?: number;
14
+ readonly interlace?: number;
15
+ };
16
+ /**
17
+ * A PNG assembled by hand: filter type 0 on every scanline, one IHDR, the
18
+ * requested ancillary chunks, and the IDAT stream optionally split. Written
19
+ * independently of `encodePng` so the decoder is tested against something
20
+ * other than this package's own encoder.
21
+ */
22
+ export declare function handWrittenPng(options: HandPngOptions): Uint8Array;
23
+ /** An RGBA sample buffer from a pure function of the coordinates. */
24
+ export declare function rgbaSamples(width: number, height: number, at: (x: number, y: number) => [number, number, number, number]): Uint8Array;
25
+ export type ExifFixture = {
26
+ readonly make?: string;
27
+ readonly model?: string;
28
+ readonly orientation?: number;
29
+ readonly dateTimeOriginal?: string;
30
+ readonly fNumber?: [number, number];
31
+ readonly isoSpeed?: number;
32
+ /** Decimal degrees; converted to the degrees/minutes/seconds the tags use. */
33
+ readonly latitude?: number;
34
+ readonly longitude?: number;
35
+ readonly altitudeMeters?: number;
36
+ };
37
+ /** An `APP1` payload: `Exif\0\0` plus a little-endian TIFF block. */
38
+ export declare function exifApp1Payload(fixture: ExifFixture): Uint8Array;
39
+ export type JpegFixture = {
40
+ readonly width: number;
41
+ readonly height: number;
42
+ readonly components?: number;
43
+ readonly exif?: ExifFixture;
44
+ readonly comment?: string;
45
+ readonly iccProfile?: boolean;
46
+ readonly jfif?: boolean;
47
+ /** Bytes of pretend entropy-coded data; must contain no 0xFF. */
48
+ readonly scanBytes?: number;
49
+ };
50
+ /**
51
+ * A JPEG's SEGMENT STRUCTURE — a real marker sequence with a real SOF0 and
52
+ * whatever metadata segments were asked for, and pretend scan data. It is
53
+ * not a decodable picture, and nothing in this package decodes JPEG; what
54
+ * it exercises is exactly what the readers here look at.
55
+ */
56
+ export declare function sampleJpeg(fixture: JpegFixture): Uint8Array;
57
+ export declare function sampleGif(width: number, height: number, options?: {
58
+ transparent?: boolean;
59
+ version?: "GIF87a" | "GIF89a";
60
+ }): Uint8Array;
61
+ /** A lossless WebP header: dimensions and the alpha flag, nothing decodable. */
62
+ export declare function sampleWebpLossless(width: number, height: number, alpha: boolean): Uint8Array;
63
+ /** An extended WebP header, which is how an animated or alpha WebP starts. */
64
+ export declare function sampleWebpExtended(width: number, height: number, flags?: {
65
+ alpha?: boolean;
66
+ animation?: boolean;
67
+ icc?: boolean;
68
+ }): Uint8Array;
69
+ /** A lossy WebP header, with the VP8 keyframe start code. */
70
+ export declare function sampleWebpLossy(width: number, height: number): Uint8Array;
71
+ export declare function sampleBmp(width: number, height: number, options?: {
72
+ bitCount?: number;
73
+ topDown?: boolean;
74
+ alphaMask?: boolean;
75
+ }): Uint8Array;
76
+ /** A three-cue SRT document, with CRLF line endings and a BOM. */
77
+ export declare const SAMPLE_SRT = "\uFEFF1\r\n00:00:01,000 --> 00:00:03,500\r\nFirst line\r\nsecond line\r\n\r\n2\r\n00:00:04,000 --> 00:00:06,000\r\nA much longer caption that will need re-wrapping to fit\r\n\r\n3\r\n00:01:00,250 --> 00:01:02,000\r\nLast\r\n";
78
+ /** A WebVTT document with a NOTE block, a cue id and cue settings. */
79
+ export declare const SAMPLE_VTT = "WEBVTT - the sample\n\nNOTE this block is not a cue\n\nintro\n00:00:01.000 --> 00:00:03.500 align:start position:10%\nFirst line\n\n00:00:04.000 --> 00:00:06.000\nSecond\n";
80
+ /** ffprobe's JSON for a short H.264 clip with one audio track. */
81
+ export declare const SAMPLE_FFPROBE_JSON: string;
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Binary fixtures, BUILT rather than committed.
3
+ *
4
+ * A checked-in .jpg or .webp is an opaque blob: nobody can see what a test
5
+ * is actually asserting about, and nobody can adjust it. Every fixture here
6
+ * is constructed byte by byte from readable parts, so a test that says "a
7
+ * JPEG whose EXIF puts it in Reykjavik, rotated 90 degrees" has that file
8
+ * written out in front of it.
9
+ *
10
+ * This file ships rather than living under a `.test` extension because the
11
+ * type checker should check it too, and because the PNG builder below is
12
+ * the only independent writer this package has for verifying its own
13
+ * decoder — it writes every scanline with filter type 0, which is a path
14
+ * `encodePng` never takes.
15
+ */
16
+ import { deflateSync } from "node:zlib";
17
+ import { concatBytes, crc32 } from "./lib/bytes";
18
+ function u16le(value) {
19
+ return new Uint8Array([value & 0xff, (value >>> 8) & 0xff]);
20
+ }
21
+ function u32le(value) {
22
+ return new Uint8Array([
23
+ value & 0xff,
24
+ (value >>> 8) & 0xff,
25
+ (value >>> 16) & 0xff,
26
+ (value >>> 24) & 0xff,
27
+ ]);
28
+ }
29
+ function u16be(value) {
30
+ return new Uint8Array([(value >>> 8) & 0xff, value & 0xff]);
31
+ }
32
+ function u32be(value) {
33
+ return new Uint8Array([
34
+ (value >>> 24) & 0xff,
35
+ (value >>> 16) & 0xff,
36
+ (value >>> 8) & 0xff,
37
+ value & 0xff,
38
+ ]);
39
+ }
40
+ function ascii(text) {
41
+ const out = new Uint8Array(text.length);
42
+ for (let i = 0; i < text.length; i++)
43
+ out[i] = text.charCodeAt(i);
44
+ return out;
45
+ }
46
+ // --- PNG -----------------------------------------------------------------
47
+ function pngChunk(type, data) {
48
+ const typeBytes = ascii(type);
49
+ return concatBytes([u32be(data.length), typeBytes, data, u32be(crc32(typeBytes, data))]);
50
+ }
51
+ export const PNG_SIGNATURE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
52
+ const CHANNELS_FOR = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
53
+ /**
54
+ * A PNG assembled by hand: filter type 0 on every scanline, one IHDR, the
55
+ * requested ancillary chunks, and the IDAT stream optionally split. Written
56
+ * independently of `encodePng` so the decoder is tested against something
57
+ * other than this package's own encoder.
58
+ */
59
+ export function handWrittenPng(options) {
60
+ const { width, height, colorType, samples } = options;
61
+ const channels = CHANNELS_FOR[colorType] ?? 1;
62
+ const stride = width * channels;
63
+ const raw = new Uint8Array(height * (stride + 1));
64
+ for (let y = 0; y < height; y++) {
65
+ raw[y * (stride + 1)] = 0; // filter: None
66
+ raw.set(samples.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
67
+ }
68
+ const compressed = deflateSync(raw);
69
+ const idat = new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.byteLength);
70
+ const parts = [
71
+ PNG_SIGNATURE_BYTES,
72
+ pngChunk("IHDR", concatBytes([
73
+ u32be(width),
74
+ u32be(height),
75
+ new Uint8Array([options.bitDepth ?? 8, colorType, 0, 0, options.interlace ?? 0]),
76
+ ])),
77
+ ];
78
+ if (options.palette !== undefined)
79
+ parts.push(pngChunk("PLTE", options.palette));
80
+ if (options.transparency !== undefined)
81
+ parts.push(pngChunk("tRNS", options.transparency));
82
+ const chunks = Math.max(1, options.idatChunks ?? 1);
83
+ const per = Math.ceil(idat.length / chunks);
84
+ for (let i = 0; i < chunks; i++) {
85
+ parts.push(pngChunk("IDAT", idat.subarray(i * per, Math.min(idat.length, (i + 1) * per))));
86
+ }
87
+ parts.push(pngChunk("IEND", new Uint8Array(0)));
88
+ return concatBytes(parts);
89
+ }
90
+ /** An RGBA sample buffer from a pure function of the coordinates. */
91
+ export function rgbaSamples(width, height, at) {
92
+ const out = new Uint8Array(width * height * 4);
93
+ let o = 0;
94
+ for (let y = 0; y < height; y++) {
95
+ for (let x = 0; x < width; x++) {
96
+ const [r, g, b, a] = at(x, y);
97
+ out[o++] = r;
98
+ out[o++] = g;
99
+ out[o++] = b;
100
+ out[o++] = a;
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+ function asciiEntry(tag, value) {
106
+ const bytes = ascii(`${value}\0`);
107
+ return { tag, type: 2, count: bytes.length, bytes };
108
+ }
109
+ function shortEntry(tag, value) {
110
+ return { tag, type: 3, count: 1, bytes: u16le(value) };
111
+ }
112
+ function byteEntry(tag, value) {
113
+ return { tag, type: 1, count: 1, bytes: new Uint8Array([value]) };
114
+ }
115
+ function longEntry(tag, value) {
116
+ return { tag, type: 4, count: 1, bytes: u32le(value) };
117
+ }
118
+ function rationalEntry(tag, values) {
119
+ const bytes = concatBytes(values.flatMap(([n, d]) => [u32le(n), u32le(d)]));
120
+ return { tag, type: 5, count: values.length, bytes };
121
+ }
122
+ /**
123
+ * Serialise one IFD at `ifdAt`, appending any value over four bytes to
124
+ * `heap`, which starts at `heapAt`. Entries are written in ascending tag
125
+ * order, as the TIFF specification requires.
126
+ */
127
+ function writeIfd(entries, ifdAt, heapAt, heap) {
128
+ const sorted = [...entries].sort((a, b) => a.tag - b.tag);
129
+ const parts = [u16le(sorted.length)];
130
+ let heapSize = heap.reduce((sum, part) => sum + part.length, 0);
131
+ for (const entry of sorted) {
132
+ const inline = new Uint8Array(4);
133
+ if (entry.bytes.length <= 4) {
134
+ inline.set(entry.bytes, 0);
135
+ }
136
+ else {
137
+ inline.set(u32le(heapAt + heapSize), 0);
138
+ // Values are word-aligned, which every real writer does.
139
+ const padded = entry.bytes.length % 2 === 0 ? entry.bytes : concatBytes([entry.bytes, new Uint8Array(1)]);
140
+ heap.push(padded);
141
+ heapSize += padded.length;
142
+ }
143
+ parts.push(u16le(entry.tag), u16le(entry.type), u32le(entry.count), inline);
144
+ }
145
+ parts.push(u32le(0)); // no next IFD
146
+ void ifdAt;
147
+ return concatBytes(parts);
148
+ }
149
+ function toDms(value) {
150
+ const abs = Math.abs(value);
151
+ const degrees = Math.floor(abs);
152
+ const minutes = Math.floor((abs - degrees) * 60);
153
+ // Thousandths of a second, so the fixture's coordinates survive the round
154
+ // trip without a floating-point argument about the last decimal.
155
+ const seconds = Math.round((abs - degrees - minutes / 60) * 3600 * 1000);
156
+ return [
157
+ [degrees, 1],
158
+ [minutes, 1],
159
+ [seconds, 1000],
160
+ ];
161
+ }
162
+ /** An `APP1` payload: `Exif\0\0` plus a little-endian TIFF block. */
163
+ export function exifApp1Payload(fixture) {
164
+ const exifEntries = [];
165
+ if (fixture.dateTimeOriginal !== undefined) {
166
+ exifEntries.push(asciiEntry(0x9003, fixture.dateTimeOriginal));
167
+ }
168
+ if (fixture.fNumber !== undefined)
169
+ exifEntries.push(rationalEntry(0x829d, [fixture.fNumber]));
170
+ if (fixture.isoSpeed !== undefined)
171
+ exifEntries.push(shortEntry(0x8827, fixture.isoSpeed));
172
+ const gpsEntries = [];
173
+ if (fixture.latitude !== undefined && fixture.longitude !== undefined) {
174
+ gpsEntries.push(asciiEntry(0x0001, fixture.latitude >= 0 ? "N" : "S"));
175
+ gpsEntries.push(rationalEntry(0x0002, toDms(fixture.latitude)));
176
+ gpsEntries.push(asciiEntry(0x0003, fixture.longitude >= 0 ? "E" : "W"));
177
+ gpsEntries.push(rationalEntry(0x0004, toDms(fixture.longitude)));
178
+ if (fixture.altitudeMeters !== undefined) {
179
+ gpsEntries.push(byteEntry(0x0005, fixture.altitudeMeters >= 0 ? 0 : 1));
180
+ gpsEntries.push(rationalEntry(0x0006, [[Math.round(Math.abs(fixture.altitudeMeters) * 100), 100]]));
181
+ }
182
+ }
183
+ const ifd0Entries = [];
184
+ if (fixture.make !== undefined)
185
+ ifd0Entries.push(asciiEntry(0x010f, fixture.make));
186
+ if (fixture.model !== undefined)
187
+ ifd0Entries.push(asciiEntry(0x0110, fixture.model));
188
+ if (fixture.orientation !== undefined) {
189
+ ifd0Entries.push(shortEntry(0x0112, fixture.orientation));
190
+ }
191
+ const sizeOf = (entries) => 2 + entries.length * 12 + 4;
192
+ const hasExif = exifEntries.length > 0;
193
+ const hasGps = gpsEntries.length > 0;
194
+ // IFD0 gains a pointer entry for each sub-IFD that exists.
195
+ const ifd0Count = ifd0Entries.length + (hasExif ? 1 : 0) + (hasGps ? 1 : 0);
196
+ const ifd0At = 8;
197
+ const ifd0Size = 2 + ifd0Count * 12 + 4;
198
+ const exifAt = ifd0At + ifd0Size;
199
+ const gpsAt = exifAt + (hasExif ? sizeOf(exifEntries) : 0);
200
+ const heapAt = gpsAt + (hasGps ? sizeOf(gpsEntries) : 0);
201
+ if (hasExif)
202
+ ifd0Entries.push(longEntry(0x8769, exifAt));
203
+ if (hasGps)
204
+ ifd0Entries.push(longEntry(0x8825, gpsAt));
205
+ const heap = [];
206
+ const ifd0 = writeIfd(ifd0Entries, ifd0At, heapAt, heap);
207
+ const exif = hasExif ? writeIfd(exifEntries, exifAt, heapAt, heap) : new Uint8Array(0);
208
+ const gps = hasGps ? writeIfd(gpsEntries, gpsAt, heapAt, heap) : new Uint8Array(0);
209
+ const tiff = concatBytes([ascii("II"), u16le(42), u32le(ifd0At), ifd0, exif, gps, ...heap]);
210
+ return concatBytes([ascii("Exif"), new Uint8Array([0, 0]), tiff]);
211
+ }
212
+ function jpegSegment(marker, payload) {
213
+ return concatBytes([new Uint8Array([0xff, marker]), u16be(payload.length + 2), payload]);
214
+ }
215
+ /**
216
+ * A JPEG's SEGMENT STRUCTURE — a real marker sequence with a real SOF0 and
217
+ * whatever metadata segments were asked for, and pretend scan data. It is
218
+ * not a decodable picture, and nothing in this package decodes JPEG; what
219
+ * it exercises is exactly what the readers here look at.
220
+ */
221
+ export function sampleJpeg(fixture) {
222
+ const components = fixture.components ?? 3;
223
+ const parts = [new Uint8Array([0xff, 0xd8])];
224
+ if (fixture.jfif !== false) {
225
+ parts.push(jpegSegment(0xe0, concatBytes([
226
+ ascii("JFIF"),
227
+ new Uint8Array([0, 1, 2, 0]),
228
+ u16be(72),
229
+ u16be(72),
230
+ new Uint8Array([0, 0]),
231
+ ])));
232
+ }
233
+ if (fixture.exif !== undefined)
234
+ parts.push(jpegSegment(0xe1, exifApp1Payload(fixture.exif)));
235
+ if (fixture.iccProfile === true) {
236
+ parts.push(jpegSegment(0xe2, concatBytes([ascii("ICC_PROFILE"), new Uint8Array([0, 1, 1]), new Uint8Array(32).fill(7)])));
237
+ }
238
+ if (fixture.comment !== undefined)
239
+ parts.push(jpegSegment(0xfe, ascii(fixture.comment)));
240
+ const sof = concatBytes([
241
+ new Uint8Array([8]),
242
+ u16be(fixture.height),
243
+ u16be(fixture.width),
244
+ new Uint8Array([components]),
245
+ ...Array.from({ length: components }, (_, i) => new Uint8Array([i + 1, 0x11, 0])),
246
+ ]);
247
+ parts.push(jpegSegment(0xc0, sof));
248
+ const sos = concatBytes([
249
+ new Uint8Array([components]),
250
+ ...Array.from({ length: components }, (_, i) => new Uint8Array([i + 1, 0])),
251
+ new Uint8Array([0, 63, 0]),
252
+ ]);
253
+ parts.push(jpegSegment(0xda, sos));
254
+ parts.push(new Uint8Array(fixture.scanBytes ?? 64).fill(0x42));
255
+ parts.push(new Uint8Array([0xff, 0xd9]));
256
+ return concatBytes(parts);
257
+ }
258
+ // --- GIF, WebP, BMP ------------------------------------------------------
259
+ export function sampleGif(width, height, options = {}) {
260
+ const parts = [
261
+ ascii(options.version ?? "GIF89a"),
262
+ u16le(width),
263
+ u16le(height),
264
+ // Global colour table, 8-bit colour resolution, two entries.
265
+ new Uint8Array([0b1_111_0_000, 0, 0]),
266
+ new Uint8Array([0, 0, 0, 255, 255, 255]),
267
+ ];
268
+ if (options.transparent === true) {
269
+ // Graphic Control Extension with the transparency flag set.
270
+ parts.push(new Uint8Array([0x21, 0xf9, 0x04, 0x01, 0, 0, 0x00, 0x00]));
271
+ }
272
+ parts.push(new Uint8Array([0x2c]), u16le(0), u16le(0), u16le(width), u16le(height), new Uint8Array([0, 2, 2, 0x44, 0x01, 0x00, 0x3b]));
273
+ return concatBytes(parts);
274
+ }
275
+ function riff(fourcc, payload) {
276
+ const chunk = concatBytes([
277
+ ascii(fourcc),
278
+ u32le(payload.length),
279
+ payload,
280
+ payload.length % 2 === 1 ? new Uint8Array(1) : new Uint8Array(0),
281
+ ]);
282
+ return concatBytes([ascii("RIFF"), u32le(4 + chunk.length), ascii("WEBP"), chunk]);
283
+ }
284
+ /** A lossless WebP header: dimensions and the alpha flag, nothing decodable. */
285
+ export function sampleWebpLossless(width, height, alpha) {
286
+ const bits = ((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14) | ((alpha ? 1 : 0) << 28);
287
+ return riff("VP8L", concatBytes([new Uint8Array([0x2f]), u32le(bits >>> 0), new Uint8Array(8)]));
288
+ }
289
+ /** An extended WebP header, which is how an animated or alpha WebP starts. */
290
+ export function sampleWebpExtended(width, height, flags = {}) {
291
+ const flagByte = (flags.alpha === true ? 0x10 : 0) |
292
+ (flags.animation === true ? 0x02 : 0) |
293
+ (flags.icc === true ? 0x08 : 0);
294
+ const triple = (value) => new Uint8Array([value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff]);
295
+ return riff("VP8X", concatBytes([new Uint8Array([flagByte, 0, 0, 0]), triple(width - 1), triple(height - 1)]));
296
+ }
297
+ /** A lossy WebP header, with the VP8 keyframe start code. */
298
+ export function sampleWebpLossy(width, height) {
299
+ return riff("VP8 ", concatBytes([
300
+ new Uint8Array([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]),
301
+ u16le(width),
302
+ u16le(height),
303
+ new Uint8Array(4),
304
+ ]));
305
+ }
306
+ export function sampleBmp(width, height, options = {}) {
307
+ const bitCount = options.bitCount ?? 24;
308
+ const dibSize = options.alphaMask === true ? 108 : 40;
309
+ const header = concatBytes([
310
+ u32le(dibSize),
311
+ u32le(width),
312
+ u32le(options.topDown === true ? -height >>> 0 : height),
313
+ u16le(1),
314
+ u16le(bitCount),
315
+ u32le(options.alphaMask === true ? 3 : 0),
316
+ u32le(0),
317
+ u32le(2835),
318
+ u32le(2835),
319
+ u32le(0),
320
+ u32le(0),
321
+ ...(options.alphaMask === true
322
+ ? [
323
+ u32le(0x00ff0000),
324
+ u32le(0x0000ff00),
325
+ u32le(0x000000ff),
326
+ u32le(0xff000000),
327
+ new Uint8Array(dibSize - 56),
328
+ ]
329
+ : []),
330
+ ]);
331
+ const pixels = new Uint8Array(width * height * (bitCount / 8));
332
+ const fileHeader = concatBytes([
333
+ ascii("BM"),
334
+ u32le(14 + header.length + pixels.length),
335
+ u32le(0),
336
+ u32le(14 + header.length),
337
+ ]);
338
+ return concatBytes([fileHeader, header, pixels]);
339
+ }
340
+ // --- Subtitles -----------------------------------------------------------
341
+ /** A three-cue SRT document, with CRLF line endings and a BOM. */
342
+ export const SAMPLE_SRT = "\uFEFF1\r\n00:00:01,000 --> 00:00:03,500\r\nFirst line\r\nsecond line\r\n\r\n2\r\n00:00:04,000 --> 00:00:06,000\r\nA much longer caption that will need re-wrapping to fit\r\n\r\n3\r\n00:01:00,250 --> 00:01:02,000\r\nLast\r\n";
343
+ /** A WebVTT document with a NOTE block, a cue id and cue settings. */
344
+ export const SAMPLE_VTT = "WEBVTT - the sample\n\nNOTE this block is not a cue\n\nintro\n00:00:01.000 --> 00:00:03.500 align:start position:10%\nFirst line\n\n00:00:04.000 --> 00:00:06.000\nSecond\n";
345
+ /** ffprobe's JSON for a short H.264 clip with one audio track. */
346
+ export const SAMPLE_FFPROBE_JSON = JSON.stringify({
347
+ streams: [
348
+ {
349
+ index: 1,
350
+ codec_name: "aac",
351
+ codec_long_name: "AAC (Advanced Audio Coding)",
352
+ codec_type: "audio",
353
+ sample_rate: "48000",
354
+ channels: 2,
355
+ channel_layout: "stereo",
356
+ bit_rate: "128000",
357
+ duration: "12.032000",
358
+ tags: { language: "eng", title: "Main" },
359
+ },
360
+ {
361
+ index: 0,
362
+ codec_name: "h264",
363
+ codec_long_name: "H.264 / AVC",
364
+ codec_type: "video",
365
+ profile: "High",
366
+ width: 1920,
367
+ height: 1080,
368
+ pix_fmt: "yuv420p",
369
+ r_frame_rate: "30000/1001",
370
+ avg_frame_rate: "30000/1001",
371
+ bit_rate: "4500000",
372
+ duration: "12.000000",
373
+ },
374
+ ],
375
+ format: {
376
+ filename: "/somewhere/private/clip.mp4",
377
+ nb_streams: 2,
378
+ format_name: "mov,mp4,m4a,3gp,3g2,mj2",
379
+ format_long_name: "QuickTime / MOV",
380
+ duration: "12.032000",
381
+ size: "7012345",
382
+ bit_rate: "4661234",
383
+ tags: { major_brand: "isom", title: "Sample clip", encoder: "Lavf60" },
384
+ },
385
+ });
@@ -0,0 +1,25 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ import { HEADER_FORMATS, knownKinds } from "./lib/identify";
3
+ import { ECC_LEVELS } from "./lib/qr";
4
+ export declare const imageInfo: RegisteredTool;
5
+ export declare const imageKind: RegisteredTool;
6
+ export declare const pngRead: RegisteredTool;
7
+ export declare const exifRead: RegisteredTool;
8
+ export declare const imageDiff: RegisteredTool;
9
+ export declare const pngWrite: RegisteredTool;
10
+ export declare const imageResize: RegisteredTool;
11
+ export declare const imageCrop: RegisteredTool;
12
+ export declare const exifStrip: RegisteredTool;
13
+ export declare const qrEncode: RegisteredTool;
14
+ export declare const barcodeEncode: RegisteredTool;
15
+ export declare const chartRender: RegisteredTool;
16
+ export declare const sparklineRender: RegisteredTool;
17
+ export declare const diagramRender: RegisteredTool;
18
+ export declare const colorConvert: RegisteredTool;
19
+ export declare const colorContrast: RegisteredTool;
20
+ export declare const subtitleParse: RegisteredTool;
21
+ export declare const subtitleWrite: RegisteredTool;
22
+ export declare const mediaProbe: RegisteredTool;
23
+ /** Every tool this package registers, in the order a catalog should list them. */
24
+ export declare const MEDIA_TOOLS: ReadonlyArray<RegisteredTool>;
25
+ export { HEADER_FORMATS, ECC_LEVELS, knownKinds };