@crewhaus/tool-media 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/paths.js CHANGED
@@ -11,15 +11,45 @@
11
11
  import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { CrewhausError } from "@crewhaus/errors";
14
- /** Raised when a caller-supplied path resolves outside the workspace root. */
14
+ /**
15
+ * The longest path this resolves, in UTF-16 code units; a longer one is
16
+ * refused before it is walked. Nothing that long can be opened or created:
17
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
18
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
19
+ * UTF-16 one. The limit is twice that because the part of a path that exists
20
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
21
+ * own: no spelling of the place reaches it, not even one through a symlink
22
+ * that shortens the part that exists.
23
+ */
24
+ export const MAX_PATH_CHARS = 2 * (process.platform === "darwin" ? 1_024 : process.platform === "win32" ? 32_767 : 4_096);
25
+ /** A path as a refusal quotes it: whole, unless it is past {@link MAX_PATH_CHARS}. */
26
+ function shownPath(given) {
27
+ return given.length > MAX_PATH_CHARS ? `${given.slice(0, 64)}…` : given;
28
+ }
29
+ /** Why a path past {@link MAX_PATH_CHARS} is refused: its length and the limit. */
30
+ function tooLong(abs) {
31
+ return `it is ${abs.length} characters once resolved, over the ${MAX_PATH_CHARS}-character limit: no path that long can be opened or created`;
32
+ }
33
+ /**
34
+ * Raised when a caller-supplied path resolves outside the workspace root, or
35
+ * is longer than any path can be.
36
+ */
15
37
  export class ToolPermissionError extends CrewhausError {
16
38
  name = "ToolPermissionError";
17
39
  toolName;
18
40
  path;
19
- constructor(toolName, attemptedPath) {
20
- super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
41
+ /**
42
+ * Why, when it is not that the location escapes the workspace root: a path
43
+ * longer than any path can be. A caller that rewords the refusal says this.
44
+ */
45
+ reason;
46
+ constructor(toolName, attemptedPath, reason) {
47
+ super("tool", reason === undefined
48
+ ? `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`
49
+ : `tool "${toolName}" rejected path "${shownPath(attemptedPath)}": ${reason}`);
21
50
  this.toolName = toolName;
22
51
  this.path = attemptedPath;
52
+ this.reason = reason;
23
53
  }
24
54
  }
25
55
  /** The workspace root, resolved. Every path is contained within it. */
@@ -59,15 +89,7 @@ function nameExists(p) {
59
89
  function resolveLocation(target, depth = 0) {
60
90
  if (depth > 40)
61
91
  throw new Error(`symlink chain at "${target}" is too long to resolve`);
62
- let probe = target;
63
- const tail = [];
64
- while (!nameExists(probe)) {
65
- tail.unshift(path.basename(probe));
66
- const parent = path.dirname(probe);
67
- if (parent === probe)
68
- break; // reached the filesystem root
69
- probe = parent;
70
- }
92
+ const { probe, tail } = deepestName(target);
71
93
  let probeReal;
72
94
  try {
73
95
  probeReal = realpathSync(probe);
@@ -89,7 +111,41 @@ function resolveLocation(target, depth = 0) {
89
111
  const base = realpathSync(path.dirname(probe));
90
112
  probeReal = resolveLocation(path.resolve(base, link), depth + 1);
91
113
  }
92
- return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
114
+ // One string, not one argument per name: Bun's `path.join` keeps only
115
+ // (argument count mod 65,536) of its arguments.
116
+ return tail.length > 0 ? path.join(probeReal, tail) : probeReal;
117
+ }
118
+ /**
119
+ * `target`, an absolute path, split where its deepest prefix that exists as
120
+ * a NAME ends: that prefix, and the names below it, which do not exist, as
121
+ * one string ("" when `target` itself exists). This is `path.dirname` and
122
+ * `path.basename` worked by index on `target` itself: handing them each
123
+ * shorter prefix in turn copied the rest of the path once per missing name,
124
+ * the square of a count the caller picks.
125
+ */
126
+ function deepestName(target) {
127
+ const top = path.parse(target).root.length;
128
+ const isSep = (i) => target[i] === "/" || target[i] === path.sep;
129
+ let probe = target;
130
+ let start = target.length;
131
+ let stop = -1;
132
+ while (!nameExists(probe)) {
133
+ // `path.basename(probe)` is `target.slice(start, end)`, and
134
+ // `path.dirname(probe)` is `target.slice(0, up)`.
135
+ let end = probe.length;
136
+ while (end > top && isSep(end - 1))
137
+ end--;
138
+ if (stop < 0)
139
+ stop = end;
140
+ start = end;
141
+ while (start > top && !isSep(start - 1))
142
+ start--;
143
+ const up = start > top ? start - 1 : top;
144
+ if (up === probe.length)
145
+ break; // reached the filesystem root
146
+ probe = target.slice(0, up);
147
+ }
148
+ return { probe, tail: stop < 0 ? "" : target.slice(start, stop) };
93
149
  }
94
150
  /**
95
151
  * Resolve `rel` inside the workspace, or throw `ToolPermissionError`.
@@ -110,6 +166,9 @@ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
110
166
  if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
111
167
  throw new ToolPermissionError(toolName, rel);
112
168
  }
169
+ // Longer than any path can be: refused before the walk below reads it.
170
+ if (abs.length > MAX_PATH_CHARS)
171
+ throw new ToolPermissionError(toolName, rel, tooLong(abs));
113
172
  let real;
114
173
  try {
115
174
  const rootReal = realpathSync(rootResolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-media",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic media tools: image inspection and transformation, barcode encoding and decoding, chart and diagram rendering, and audio and video probing",
6
6
  "main": "dist/index.js",
@@ -8,16 +8,17 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "dependencies": {
18
- "@crewhaus/errors": "0.7.0",
19
- "@crewhaus/tool-builder": "0.7.0",
20
- "@crewhaus/tool-catalog": "0.7.0",
19
+ "@crewhaus/errors": "0.8.1",
20
+ "@crewhaus/tool-builder": "0.8.1",
21
+ "@crewhaus/tool-catalog": "0.8.1",
21
22
  "zod": "^3.23.8"
22
23
  },
23
24
  "devDependencies": {
@@ -41,5 +42,8 @@
41
42
  "publishConfig": {
42
43
  "access": "public"
43
44
  },
44
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
46
+ "engines": {
47
+ "bun": ">=1.3.14"
48
+ }
45
49
  }
@@ -1,81 +0,0 @@
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 DELETED
@@ -1,385 +0,0 @@
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
- });