@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 +156 -0
- package/dist/fixtures.d.ts +81 -0
- package/dist/fixtures.js +385 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +1132 -0
- package/dist/lib/barcode.d.ts +43 -0
- package/dist/lib/barcode.js +310 -0
- package/dist/lib/bytes.d.ts +45 -0
- package/dist/lib/bytes.js +162 -0
- package/dist/lib/chart.d.ts +32 -0
- package/dist/lib/chart.js +290 -0
- package/dist/lib/color.d.ts +53 -0
- package/dist/lib/color.js +267 -0
- package/dist/lib/diagram.d.ts +48 -0
- package/dist/lib/diagram.js +256 -0
- package/dist/lib/ffprobe.d.ts +36 -0
- package/dist/lib/ffprobe.js +154 -0
- package/dist/lib/identify.d.ts +39 -0
- package/dist/lib/identify.js +519 -0
- package/dist/lib/imageops.d.ts +86 -0
- package/dist/lib/imageops.js +236 -0
- package/dist/lib/jpeg.d.ts +81 -0
- package/dist/lib/jpeg.js +388 -0
- package/dist/lib/png.d.ts +59 -0
- package/dist/lib/png.js +466 -0
- package/dist/lib/qr.d.ts +70 -0
- package/dist/lib/qr.js +544 -0
- package/dist/lib/raster.d.ts +18 -0
- package/dist/lib/raster.js +82 -0
- package/dist/lib/sparkline.d.ts +21 -0
- package/dist/lib/sparkline.js +93 -0
- package/dist/lib/subtitle.d.ts +62 -0
- package/dist/lib/subtitle.js +233 -0
- package/dist/lib/svg.d.ts +76 -0
- package/dist/lib/svg.js +168 -0
- package/dist/paths.d.ts +53 -0
- package/dist/paths.js +161 -0
- package/dist/proc.d.ts +39 -0
- package/dist/proc.js +158 -0
- package/package.json +45 -0
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;
|
package/dist/fixtures.js
ADDED
|
@@ -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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|