@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/dist/index.js ADDED
@@ -0,0 +1,1132 @@
1
+ /**
2
+ * `@crewhaus/tool-media` — deterministic media tools.
3
+ *
4
+ * Image headers, PNG encoding and decoding, screenshot comparison, EXIF,
5
+ * QR codes and barcodes, charts, sparklines and diagrams, subtitles, and a
6
+ * probe for audio and video. Every format reader and writer here is
7
+ * hand-written against its published specification: there is no image
8
+ * library in this package, and nothing is pulled in at runtime.
9
+ *
10
+ * Two properties hold across the whole surface.
11
+ *
12
+ * **Determinism.** The same inputs against the same files produce the same
13
+ * bytes, on any machine. Nothing reads the clock, nothing uses unseeded
14
+ * randomness, listings are sorted, and text is formatted without a locale.
15
+ * The single exception is `MediaProbe`, which shells out to `ffprobe` and
16
+ * is therefore only as deterministic as the `ffprobe` on the machine — its
17
+ * description says so.
18
+ *
19
+ * **Containment.** Every caller-supplied path goes through
20
+ * `resolveSafe`, which refuses anything resolving outside the workspace
21
+ * root, symlinks included. Every file read is size-checked before it is
22
+ * read, every decode is capped before it allocates, and the one tool that
23
+ * spawns a process bounds its runtime and its output.
24
+ */
25
+ import { closeSync, openSync, readSync, statSync, writeFileSync } from "node:fs";
26
+ import { buildTool } from "@crewhaus/tool-builder";
27
+ import { z } from "zod";
28
+ import { encodeCode128, encodeEan13 } from "./lib/barcode";
29
+ import { MediaFormatError, toHex } from "./lib/bytes";
30
+ import { renderChart } from "./lib/chart";
31
+ import { colorKeywords, contrastReport, parseColor, relativeLuminance, rgbToHsl, toHexString, } from "./lib/color";
32
+ import { renderDiagram } from "./lib/diagram";
33
+ import { parseProbeJson } from "./lib/ffprobe";
34
+ import { HEADER_FORMATS, detectKind, kindsForExtension, knownKinds, readImageHeader, } from "./lib/identify";
35
+ import { cropImage, diffImages, differenceHash, fitDimensions, hammingDistance, imageStats, resizeImage, } from "./lib/imageops";
36
+ import { findExifSegment, parseExif, readJpegSegments, stripJpegMetadata } from "./lib/jpeg";
37
+ import { DEFAULT_DECODE_LIMITS, decodePng, encodePng, } from "./lib/png";
38
+ import { ECC_LEVELS, MAX_VERSION, byteCapacity, encodeQr, symbolToText, } from "./lib/qr";
39
+ import { renderModuleGrid, renderModuleStrip } from "./lib/raster";
40
+ import { sparklineBlocks, sparklineSvg } from "./lib/sparkline";
41
+ import { parseSubtitles, writeSubtitles } from "./lib/subtitle";
42
+ import { ToolPermissionError, resolveSafe } from "./paths";
43
+ import { DEFAULT_PROCESS_TIMEOUT_MS, MAX_PROCESS_TIMEOUT_MS, describeFailure, runProcess, } from "./proc";
44
+ /** Compact JSON — the reader is a model, and every byte returned is context. */
45
+ const json = (value) => JSON.stringify(value);
46
+ /** Largest file any tool here reads whole. */
47
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
48
+ /** How much of a file a header reader looks at. Headers live near the front. */
49
+ const HEADER_WINDOW_BYTES = 1024 * 1024;
50
+ /** Largest image any tool here decodes, in pixels. */
51
+ const MAX_DECODE_PIXELS = 16_000_000;
52
+ /** Largest base64 pixel payload `PngWrite` accepts, before decoding it. */
53
+ const MAX_PIXEL_BASE64_CHARS = 90_000_000;
54
+ /** Largest base64 blob `PngRead` will hand back rather than refuse. */
55
+ const DEFAULT_MAX_RETURNED_BASE64 = 1_400_000;
56
+ /**
57
+ * A caller mistake this package states in its own words: a wrong argument
58
+ * combination, a file that is too big, a destination that already exists.
59
+ * Distinct from a plain `Error` so `explain` can tell a message written FOR
60
+ * the caller apart from a bug's message, which was written for nobody.
61
+ */
62
+ class ToolInputError extends Error {
63
+ name = "ToolInputError";
64
+ }
65
+ /**
66
+ * Turn an expected failure into a readable sentence. A malformed file, a
67
+ * path outside the workspace or a format this package does not read is an
68
+ * ANSWER, not a crash: the model is told what went wrong and can act on it.
69
+ *
70
+ * Anything NOT in that list is a bug in this package, and returning its
71
+ * message would hand the caller `undefined is not an object` where a result
72
+ * belongs — indistinguishable from content. Those are rethrown.
73
+ */
74
+ function explain(err) {
75
+ if (err instanceof MediaFormatError ||
76
+ err instanceof ToolPermissionError ||
77
+ err instanceof ToolInputError) {
78
+ return err.message;
79
+ }
80
+ const code = err.code;
81
+ if (code === "ENOENT")
82
+ return `no such file: ${err.path ?? ""}`;
83
+ if (code === "EACCES")
84
+ return `permission denied: ${err.path ?? ""}`;
85
+ if (code === "EISDIR")
86
+ return "that path is a directory, not a file";
87
+ if (code === "EPERM" || code === "EROFS") {
88
+ return `cannot write there: ${err.path ?? ""}`;
89
+ }
90
+ return null;
91
+ }
92
+ /** Run a tool body, converting the expected failures into a readable string. */
93
+ async function attempt(body) {
94
+ try {
95
+ return await body();
96
+ }
97
+ catch (err) {
98
+ const message = explain(err);
99
+ if (message === null)
100
+ throw err;
101
+ return message;
102
+ }
103
+ }
104
+ /**
105
+ * Read a whole file after checking its size, so an enormous file is refused
106
+ * rather than read and then rejected. The `stat` is on the RESOLVED path,
107
+ * which is the same path the read uses.
108
+ */
109
+ function readWhole(toolName, rel, maxBytes = MAX_FILE_BYTES) {
110
+ const path = resolveSafe(toolName, rel);
111
+ const stats = statSync(path.real);
112
+ if (stats.isDirectory())
113
+ throw new ToolInputError(`"${rel}" is a directory, not a file`);
114
+ if (stats.size > maxBytes) {
115
+ throw new ToolInputError(`"${rel}" is ${stats.size} bytes, over the ${maxBytes}-byte limit for this tool`);
116
+ }
117
+ const fd = openSync(path.real, "r");
118
+ try {
119
+ const buffer = new Uint8Array(stats.size);
120
+ let read = 0;
121
+ while (read < stats.size) {
122
+ const n = readSync(fd, buffer, read, stats.size - read, read);
123
+ if (n === 0)
124
+ break;
125
+ read += n;
126
+ }
127
+ return { path, bytes: buffer.subarray(0, read), size: stats.size };
128
+ }
129
+ finally {
130
+ closeSync(fd);
131
+ }
132
+ }
133
+ /**
134
+ * Read only the first `window` bytes. A header reader never needs more, and
135
+ * reading the head of a 4 GB video must not cost 4 GB.
136
+ */
137
+ function readHead(toolName, rel, window = HEADER_WINDOW_BYTES) {
138
+ const path = resolveSafe(toolName, rel);
139
+ const stats = statSync(path.real);
140
+ if (stats.isDirectory())
141
+ throw new ToolInputError(`"${rel}" is a directory, not a file`);
142
+ const want = Math.min(window, stats.size);
143
+ const fd = openSync(path.real, "r");
144
+ try {
145
+ const buffer = new Uint8Array(want);
146
+ let read = 0;
147
+ while (read < want) {
148
+ const n = readSync(fd, buffer, read, want - read, read);
149
+ if (n === 0)
150
+ break;
151
+ read += n;
152
+ }
153
+ return { path, bytes: buffer.subarray(0, read), size: stats.size };
154
+ }
155
+ finally {
156
+ closeSync(fd);
157
+ }
158
+ }
159
+ /** Write bytes to a contained path, refusing to clobber unless told to. */
160
+ function writeSafe(toolName, rel, data, overwrite) {
161
+ const path = resolveSafe(toolName, rel);
162
+ if (!overwrite) {
163
+ let exists = true;
164
+ try {
165
+ statSync(path.real);
166
+ }
167
+ catch (err) {
168
+ if (err.code !== "ENOENT")
169
+ throw err;
170
+ exists = false;
171
+ }
172
+ if (exists)
173
+ throw new ToolInputError(`"${rel}" already exists; pass overwrite to replace it`);
174
+ }
175
+ writeFileSync(path.real, data);
176
+ return path;
177
+ }
178
+ /**
179
+ * Decode a PNG from disk with this package's caps applied. The format is
180
+ * checked from the MAGIC BYTES first, so a JPEG named `.png` gets told what
181
+ * it actually is rather than "the signature does not match".
182
+ */
183
+ function loadPng(toolName, rel, maxPixels = MAX_DECODE_PIXELS) {
184
+ const { path, bytes } = readWhole(toolName, rel);
185
+ const kind = detectKind(bytes);
186
+ if (kind?.kind !== "png") {
187
+ throw new ToolInputError(`"${rel}" is ${kind === undefined ? "not a recognised format" : kind.kind}; this tool decodes PNG only (use ImageInfo for the header facts of other formats)`);
188
+ }
189
+ return { path, image: decodePng(bytes, { maxPixels }) };
190
+ }
191
+ const pathField = z.string().min(1);
192
+ const overwriteField = z.boolean().optional().describe("replace the destination if it exists");
193
+ // ---------------------------------------------------------------------------
194
+ // Inspection
195
+ export const imageInfo = buildTool({
196
+ name: "ImageInfo",
197
+ description: "Read an image's dimensions, bit depth, colour model and whether it has an alpha channel, from the file header alone. Use before deciding how to handle an image, or to check a rendered asset came out the size it should have, without decoding a single pixel.",
198
+ inputSchema: z.object({
199
+ path: pathField.describe("a PNG, JPEG, GIF, WebP or BMP inside the workspace"),
200
+ }),
201
+ readOnly: true,
202
+ concurrencySafe: true,
203
+ execute: async (input) => attempt(() => {
204
+ const { path, bytes, size } = readHead("ImageInfo", input.path);
205
+ const header = readImageHeader(bytes);
206
+ return json({
207
+ path: path.rel,
208
+ fileBytes: size,
209
+ ...header,
210
+ megapixels: Math.round((header.width * header.height) / 10_000) / 100,
211
+ // A header may declare a zero dimension. Reporting `null` says the
212
+ // ratio is undefined; the arithmetic would give Infinity or NaN,
213
+ // both of which JSON renders as a bare `null` that reads like a bug.
214
+ aspectRatio: header.height === 0 ? null : Math.round((header.width / header.height) * 1000) / 1000,
215
+ });
216
+ }),
217
+ });
218
+ export const imageKind = buildTool({
219
+ name: "ImageKind",
220
+ description: "Identify a file by its magic bytes and say whether the extension agrees with the content. Use when a file may be mislabelled — a .jpg that is really a PNG, or an upload with no extension at all — before handing it to something that trusts the name.",
221
+ inputSchema: z.object({
222
+ path: pathField.describe("any file inside the workspace"),
223
+ }),
224
+ readOnly: true,
225
+ concurrencySafe: true,
226
+ execute: async (input) => attempt(() => {
227
+ const { path, bytes, size } = readHead("ImageKind", input.path, 4096);
228
+ const kind = detectKind(bytes);
229
+ const dot = path.rel.lastIndexOf(".");
230
+ const extension = dot > 0 ? path.rel.slice(dot + 1).toLowerCase() : "";
231
+ const expected = extension === "" ? [] : kindsForExtension(extension);
232
+ const base = {
233
+ path: path.rel,
234
+ fileBytes: size,
235
+ extension: extension === "" ? null : extension,
236
+ magic: toHex(bytes.subarray(0, 8)),
237
+ };
238
+ if (kind === undefined) {
239
+ return json({
240
+ ...base,
241
+ kind: null,
242
+ recognised: false,
243
+ note: `not one of the ${knownKinds().length} formats this tool recognises; the leading bytes are reported so a caller can look them up`,
244
+ });
245
+ }
246
+ const agrees = expected.length === 0 ? null : expected.includes(kind.kind);
247
+ return json({
248
+ ...base,
249
+ kind: kind.kind,
250
+ recognised: true,
251
+ mediaType: kind.mediaType,
252
+ family: kind.family,
253
+ expectedExtensions: kind.extensions,
254
+ extensionAgrees: agrees,
255
+ ...(agrees === false
256
+ ? {
257
+ warning: `the extension ".${extension}" names ${expected.join(" or ")}, but the content is ${kind.kind}`,
258
+ }
259
+ : {}),
260
+ });
261
+ }),
262
+ });
263
+ export const pngRead = buildTool({
264
+ name: "PngRead",
265
+ description: "Decode a PNG to raw RGBA pixels, or to per-channel statistics when the pixels would be too large to return. Use to inspect the actual colours in a rendered image, or to get pixels to transform and hand back to PngWrite.",
266
+ inputSchema: z.object({
267
+ path: pathField,
268
+ region: z
269
+ .object({
270
+ x: z.number().int().min(0),
271
+ y: z.number().int().min(0),
272
+ width: z.number().int().positive(),
273
+ height: z.number().int().positive(),
274
+ })
275
+ .optional()
276
+ .describe("decode the whole image, then return only this rectangle"),
277
+ statsOnly: z
278
+ .boolean()
279
+ .optional()
280
+ .describe("return per-channel means and extremes instead of the pixels"),
281
+ maxBase64Chars: z
282
+ .number()
283
+ .int()
284
+ .positive()
285
+ .max(20_000_000)
286
+ .optional()
287
+ .describe(`cap on the returned pixel blob; default ${DEFAULT_MAX_RETURNED_BASE64}`),
288
+ }),
289
+ readOnly: true,
290
+ concurrencySafe: true,
291
+ execute: async (input) => attempt(() => {
292
+ const { path, image: decoded } = loadPng("PngRead", input.path, DEFAULT_DECODE_LIMITS.maxPixels);
293
+ const image = input.region === undefined
294
+ ? decoded
295
+ : cropImage(decoded, input.region.x, input.region.y, input.region.width, input.region.height);
296
+ const meta = {
297
+ path: path.rel,
298
+ width: image.width,
299
+ height: image.height,
300
+ fullWidth: decoded.width,
301
+ fullHeight: decoded.height,
302
+ sourceColorType: decoded.sourceColorType,
303
+ sourceBitDepth: decoded.sourceBitDepth,
304
+ hasAlpha: decoded.hadAlpha,
305
+ pixelFormat: "rgba8",
306
+ };
307
+ if (input.statsOnly === true) {
308
+ return json({ ...meta, stats: imageStats(image) });
309
+ }
310
+ const limit = input.maxBase64Chars ?? DEFAULT_MAX_RETURNED_BASE64;
311
+ const base64Chars = Math.ceil(image.data.length / 3) * 4;
312
+ if (base64Chars > limit) {
313
+ return json({
314
+ ...meta,
315
+ pixels: null,
316
+ refused: `${image.width}x${image.height} RGBA is ${base64Chars} base64 characters, over the ${limit} cap — pass a smaller \`region\`, or \`statsOnly\``,
317
+ });
318
+ }
319
+ return json({ ...meta, pixels: Buffer.from(image.data).toString("base64") });
320
+ }),
321
+ });
322
+ export const exifRead = buildTool({
323
+ name: "ExifRead",
324
+ description: "Read a JPEG's EXIF metadata: capture time, camera, lens, exposure, orientation, and GPS coordinates when the file carries them. Use before publishing or sharing a photograph, because `hasGps: true` means the file is carrying the location it was taken at.",
325
+ inputSchema: z.object({ path: pathField.describe("a JPEG inside the workspace") }),
326
+ readOnly: true,
327
+ concurrencySafe: true,
328
+ execute: async (input) => attempt(() => {
329
+ const { path, bytes } = readHead("ExifRead", input.path);
330
+ const kind = detectKind(bytes);
331
+ if (kind?.kind !== "jpeg") {
332
+ throw new ToolInputError(`"${input.path}" is ${kind === undefined ? "not a recognised format" : kind.kind}; EXIF reading here covers JPEG only`);
333
+ }
334
+ const { segments } = readJpegSegments(bytes);
335
+ const segment = findExifSegment(segments);
336
+ if (segment === undefined) {
337
+ return json({
338
+ path: path.rel,
339
+ hasExif: false,
340
+ hasGps: false,
341
+ note: "no APP1 EXIF segment; the file carries no EXIF metadata this reader can see",
342
+ metadataSegments: segments
343
+ .filter((s) => s.marker >= 0xe0 && s.marker <= 0xef)
344
+ .map((s) => s.name)
345
+ .sort(),
346
+ });
347
+ }
348
+ const exif = parseExif(segment.payload);
349
+ return json({
350
+ path: path.rel,
351
+ hasExif: true,
352
+ ...exif,
353
+ ...(exif.hasGps
354
+ ? {
355
+ privacyWarning: "this file records where it was taken; publishing it publishes that location. ExifStrip removes it.",
356
+ }
357
+ : {}),
358
+ });
359
+ }),
360
+ });
361
+ export const imageDiff = buildTool({
362
+ name: "ImageDiff",
363
+ description: "Compare two PNGs pixel by pixel and by perceptual hash, returning the fraction of pixels that differ and a bounding box around the change. Use as the screenshot-regression check: the bounding box says WHERE a render moved, and the hash distance says whether it is the same picture at all.",
364
+ inputSchema: z.object({
365
+ a: pathField.describe("the baseline PNG"),
366
+ b: pathField.describe("the PNG to compare against it"),
367
+ threshold: z
368
+ .number()
369
+ .int()
370
+ .min(0)
371
+ .max(255)
372
+ .optional()
373
+ .describe("per-channel tolerance before a pixel counts as different; default 0 (exact)"),
374
+ }),
375
+ readOnly: true,
376
+ concurrencySafe: true,
377
+ execute: async (input) => attempt(() => {
378
+ const a = loadPng("ImageDiff", input.a);
379
+ const b = loadPng("ImageDiff", input.b);
380
+ const hashA = differenceHash(a.image);
381
+ const hashB = differenceHash(b.image);
382
+ const perceptual = {
383
+ hashA,
384
+ hashB,
385
+ hashDistance: hammingDistance(hashA, hashB),
386
+ hashBits: 64,
387
+ };
388
+ if (a.image.width !== b.image.width || a.image.height !== b.image.height) {
389
+ return json({
390
+ a: a.path.rel,
391
+ b: b.path.rel,
392
+ identical: false,
393
+ sizeMismatch: {
394
+ a: { width: a.image.width, height: a.image.height },
395
+ b: { width: b.image.width, height: b.image.height },
396
+ },
397
+ note: "different dimensions, so there is no per-pixel comparison; the perceptual hashes are still comparable",
398
+ ...perceptual,
399
+ });
400
+ }
401
+ const diff = diffImages(a.image, b.image, input.threshold ?? 0);
402
+ return json({
403
+ a: a.path.rel,
404
+ b: b.path.rel,
405
+ identical: diff.differingPixels === 0,
406
+ threshold: input.threshold ?? 0,
407
+ ...diff,
408
+ ...perceptual,
409
+ });
410
+ }),
411
+ });
412
+ // ---------------------------------------------------------------------------
413
+ // Writing images
414
+ const colorTypeField = z
415
+ .enum(["auto", "rgba", "rgb", "gray", "gray-alpha"])
416
+ .optional()
417
+ .describe("`auto` (the default) picks the smallest type that is lossless for these pixels");
418
+ export const pngWrite = buildTool({
419
+ name: "PngWrite",
420
+ description: "Encode raw RGBA pixels as a valid PNG file, choosing the smallest lossless colour type by default. Use to write out an image a tool or a script has computed pixel by pixel, without adding an image library to the project.",
421
+ inputSchema: z.object({
422
+ path: pathField.describe("where to write the PNG, inside the workspace"),
423
+ width: z.number().int().positive().max(20_000),
424
+ height: z.number().int().positive().max(20_000),
425
+ pixels: z.string().describe("base64 of exactly width * height * 4 bytes, as R, G, B, A"),
426
+ colorType: colorTypeField,
427
+ overwrite: overwriteField,
428
+ }),
429
+ destructive: true,
430
+ execute: async (input) => attempt(() => {
431
+ if (input.width * input.height > MAX_DECODE_PIXELS) {
432
+ throw new ToolInputError(`${input.width}x${input.height} is over the ${MAX_DECODE_PIXELS}-pixel cap`);
433
+ }
434
+ if (input.pixels.length > MAX_PIXEL_BASE64_CHARS) {
435
+ throw new ToolInputError(`the pixel blob is ${input.pixels.length} characters, over the ${MAX_PIXEL_BASE64_CHARS} cap`);
436
+ }
437
+ const data = new Uint8Array(Buffer.from(input.pixels, "base64"));
438
+ const expected = input.width * input.height * 4;
439
+ if (data.length !== expected) {
440
+ throw new ToolInputError(`the pixel blob decodes to ${data.length} bytes, but ${input.width}x${input.height} RGBA needs ${expected}`);
441
+ }
442
+ const image = { width: input.width, height: input.height, data };
443
+ const bytes = encodePng(image, {
444
+ colorType: (input.colorType ?? "auto"),
445
+ });
446
+ const path = writeSafe("PngWrite", input.path, bytes, input.overwrite === true);
447
+ return json({
448
+ path: path.rel,
449
+ bytes: bytes.length,
450
+ width: input.width,
451
+ height: input.height,
452
+ colorType: input.colorType ?? "auto",
453
+ filter: "per-scanline, minimum sum of absolute differences",
454
+ });
455
+ }),
456
+ });
457
+ export const imageResize = buildTool({
458
+ name: "ImageResize",
459
+ description: "Resize a PNG to a new size and write it out as a PNG, stating the resampling method used. Use to produce a thumbnail or a fixed-width asset; pass only a width or only a height to keep the aspect ratio.",
460
+ inputSchema: z.object({
461
+ path: pathField.describe("the source PNG"),
462
+ output: pathField.describe("where to write the resized PNG"),
463
+ width: z.number().int().positive().max(20_000).optional(),
464
+ height: z.number().int().positive().max(20_000).optional(),
465
+ method: z
466
+ .enum(["nearest", "bilinear"])
467
+ .optional()
468
+ .describe("`bilinear` (default) for photographs, `nearest` to preserve exact colours"),
469
+ colorType: colorTypeField,
470
+ overwrite: overwriteField,
471
+ }),
472
+ destructive: true,
473
+ execute: async (input) => attempt(() => {
474
+ if (input.width === undefined && input.height === undefined) {
475
+ throw new ToolInputError("give a width, a height, or both");
476
+ }
477
+ const { path, image } = loadPng("ImageResize", input.path);
478
+ const target = fitDimensions(image, {
479
+ ...(input.width !== undefined ? { width: input.width } : {}),
480
+ ...(input.height !== undefined ? { height: input.height } : {}),
481
+ });
482
+ if (target.width * target.height > MAX_DECODE_PIXELS) {
483
+ throw new ToolInputError(`${target.width}x${target.height} is over the ${MAX_DECODE_PIXELS}-pixel cap`);
484
+ }
485
+ const method = input.method ?? "bilinear";
486
+ const resized = resizeImage(image, target.width, target.height, method);
487
+ const bytes = encodePng(resized, {
488
+ colorType: (input.colorType ?? "auto"),
489
+ });
490
+ const out = writeSafe("ImageResize", input.output, bytes, input.overwrite === true);
491
+ return json({
492
+ source: path.rel,
493
+ path: out.rel,
494
+ from: { width: image.width, height: image.height },
495
+ to: target,
496
+ method,
497
+ resampling: method === "bilinear"
498
+ ? "bilinear interpolation of the four neighbouring pixels; no area averaging, so shrinking by a large factor will alias"
499
+ : "nearest neighbour; exact source colours, visible stair-stepping on photographs",
500
+ bytes: bytes.length,
501
+ });
502
+ }),
503
+ });
504
+ export const imageCrop = buildTool({
505
+ name: "ImageCrop",
506
+ description: "Cut a rectangle out of a PNG and write it out as a PNG. Use to isolate a region of a screenshot — the part a diff flagged, or one panel of a wider capture — before comparing or reading it.",
507
+ inputSchema: z.object({
508
+ path: pathField.describe("the source PNG"),
509
+ output: pathField.describe("where to write the cropped PNG"),
510
+ x: z.number().int().min(0),
511
+ y: z.number().int().min(0),
512
+ width: z.number().int().positive(),
513
+ height: z.number().int().positive(),
514
+ colorType: colorTypeField,
515
+ overwrite: overwriteField,
516
+ }),
517
+ destructive: true,
518
+ execute: async (input) => attempt(() => {
519
+ const { path, image } = loadPng("ImageCrop", input.path);
520
+ const cropped = cropImage(image, input.x, input.y, input.width, input.height);
521
+ const bytes = encodePng(cropped, {
522
+ colorType: (input.colorType ?? "auto"),
523
+ });
524
+ const out = writeSafe("ImageCrop", input.output, bytes, input.overwrite === true);
525
+ return json({
526
+ source: path.rel,
527
+ path: out.rel,
528
+ from: { width: image.width, height: image.height },
529
+ region: { x: input.x, y: input.y, width: input.width, height: input.height },
530
+ bytes: bytes.length,
531
+ });
532
+ }),
533
+ });
534
+ export const exifStrip = buildTool({
535
+ name: "ExifStrip",
536
+ description: "Write a copy of a JPEG with its metadata segments removed, keeping the image data byte for byte. Use before publishing a photograph, to drop the GPS coordinates, camera serial and capture time without re-encoding and losing quality.",
537
+ inputSchema: z.object({
538
+ path: pathField.describe("the source JPEG"),
539
+ output: pathField.describe("where to write the stripped JPEG"),
540
+ keepIccProfile: z
541
+ .boolean()
542
+ .optional()
543
+ .describe("keep the APP2 colour profile; default true, because dropping it shifts colours"),
544
+ keepJfif: z.boolean().optional().describe("keep the APP0 JFIF header; default true"),
545
+ overwrite: overwriteField,
546
+ }),
547
+ destructive: true,
548
+ execute: async (input) => attempt(() => {
549
+ const { path, bytes } = readWhole("ExifStrip", input.path);
550
+ const kind = detectKind(bytes);
551
+ if (kind?.kind !== "jpeg") {
552
+ throw new ToolInputError(`"${input.path}" is ${kind === undefined ? "not a recognised format" : kind.kind}; this tool rewrites JPEG only`);
553
+ }
554
+ const result = stripJpegMetadata(bytes, {
555
+ keepIccProfile: input.keepIccProfile ?? true,
556
+ keepJfif: input.keepJfif ?? true,
557
+ });
558
+ const out = writeSafe("ExifStrip", input.output, result.bytes, input.overwrite === true);
559
+ return json({
560
+ source: path.rel,
561
+ path: out.rel,
562
+ bytesBefore: bytes.length,
563
+ bytesAfter: result.bytes.length,
564
+ bytesRemoved: result.bytesRemoved,
565
+ removed: result.removed,
566
+ note: "the entropy-coded scan data was copied verbatim; the picture is unchanged",
567
+ });
568
+ }),
569
+ });
570
+ // ---------------------------------------------------------------------------
571
+ // Codes
572
+ export const qrEncode = buildTool({
573
+ name: "QrEncode",
574
+ description: "Generate a QR code from text, as a PNG file or as a text matrix, with a chosen error-correction level. Use to turn a URL, a Wi-Fi string or a payload into a scannable code without a service call; versions 1 to 10 are supported, which is up to 271 bytes at level L.",
575
+ inputSchema: z.object({
576
+ text: z.string().min(1).describe("the payload; encoded as UTF-8 in byte mode"),
577
+ format: z
578
+ .enum(["png", "text"])
579
+ .optional()
580
+ .describe("`png` (default, needs `path`) or `text` for a matrix of characters"),
581
+ path: pathField.optional().describe("where to write the PNG; required when format is png"),
582
+ ecc: z
583
+ .enum(["L", "M", "Q", "H"])
584
+ .optional()
585
+ .describe("error correction: L ~7%, M ~15% (default), Q ~25%, H ~30%"),
586
+ version: z.number().int().min(1).max(MAX_VERSION).optional().describe("force a symbol version"),
587
+ mask: z
588
+ .number()
589
+ .int()
590
+ .min(0)
591
+ .max(7)
592
+ .optional()
593
+ .describe("force a mask; default is the lowest-penalty one"),
594
+ scale: z.number().int().min(1).max(40).optional().describe("pixels per module; default 8"),
595
+ quietZone: z
596
+ .number()
597
+ .int()
598
+ .min(0)
599
+ .max(16)
600
+ .optional()
601
+ .describe("modules of margin; default 4, which is the minimum the standard requires"),
602
+ dark: z.string().optional().describe("module colour; default black"),
603
+ light: z.string().optional().describe("background colour; default white"),
604
+ overwrite: overwriteField,
605
+ }),
606
+ destructive: true,
607
+ execute: async (input) => attempt(() => {
608
+ const ecc = (input.ecc ?? "M");
609
+ const symbol = encodeQr(input.text, {
610
+ ecc,
611
+ ...(input.version !== undefined ? { version: input.version } : {}),
612
+ ...(input.mask !== undefined ? { mask: input.mask } : {}),
613
+ });
614
+ const quietZone = input.quietZone ?? 4;
615
+ const common = {
616
+ version: symbol.version,
617
+ ecc: symbol.ecc,
618
+ mask: symbol.mask,
619
+ modules: symbol.size,
620
+ quietZone,
621
+ mode: "byte (UTF-8)",
622
+ payloadBytes: new TextEncoder().encode(input.text).length,
623
+ capacityBytes: byteCapacity(symbol.version, ecc),
624
+ };
625
+ if ((input.format ?? "png") === "text") {
626
+ return json({
627
+ ...common,
628
+ matrix: symbolToText(symbol, "██", " ", quietZone),
629
+ legend: "two characters per module so the code is square in a monospace font",
630
+ });
631
+ }
632
+ if (input.path === undefined) {
633
+ throw new ToolInputError("format `png` needs a `path` to write to");
634
+ }
635
+ const image = renderModuleGrid(symbol.modules, input.scale ?? 8, quietZone, parseColor(input.dark ?? "#000000"), parseColor(input.light ?? "#ffffff"));
636
+ const bytes = encodePng(image);
637
+ const out = writeSafe("QrEncode", input.path, bytes, input.overwrite === true);
638
+ return json({
639
+ ...common,
640
+ path: out.rel,
641
+ width: image.width,
642
+ height: image.height,
643
+ scale: input.scale ?? 8,
644
+ bytes: bytes.length,
645
+ });
646
+ }),
647
+ });
648
+ export const barcodeEncode = buildTool({
649
+ name: "BarcodeEncode",
650
+ description: "Generate a Code 128 or EAN-13 barcode, as a PNG file or as the raw module pattern, computing the check digit. Use for a label, a product code or an asset tag; EAN-13 accepts twelve digits and computes the thirteenth, or thirteen and verifies it.",
651
+ inputSchema: z.object({
652
+ symbology: z.enum(["code128", "ean13"]),
653
+ value: z.string().min(1).describe("Code 128: ASCII 32-126. EAN-13: 12 or 13 digits."),
654
+ format: z
655
+ .enum(["png", "modules"])
656
+ .optional()
657
+ .describe("`png` (default, needs `path`) or `modules` for the bar/space string"),
658
+ path: pathField.optional().describe("where to write the PNG; required when format is png"),
659
+ scale: z.number().int().min(1).max(20).optional().describe("pixels per module; default 3"),
660
+ height: z
661
+ .number()
662
+ .int()
663
+ .min(8)
664
+ .max(1000)
665
+ .optional()
666
+ .describe("bar height in pixels; default 80"),
667
+ quietZone: z
668
+ .number()
669
+ .int()
670
+ .min(0)
671
+ .max(40)
672
+ .optional()
673
+ .describe("modules of margin; default 10 for Code 128 and 11 for EAN-13, the standards' minimums"),
674
+ overwrite: overwriteField,
675
+ }),
676
+ destructive: true,
677
+ execute: async (input) => attempt(() => {
678
+ const isEan = input.symbology === "ean13";
679
+ const encoded = isEan ? encodeEan13(input.value) : encodeCode128(input.value);
680
+ const quietZone = input.quietZone ?? (isEan ? 11 : 10);
681
+ const common = {
682
+ symbology: input.symbology,
683
+ moduleCount: encoded.modules.length,
684
+ quietZone,
685
+ ...(isEan
686
+ ? {
687
+ digits: encoded.digits,
688
+ checkDigit: encoded.checkDigit,
689
+ }
690
+ : {
691
+ value: input.value,
692
+ codeSet: encoded.values[0] === 105 ? "C" : "B",
693
+ checkCharacter: encoded.values.at(-2) ?? null,
694
+ }),
695
+ };
696
+ if ((input.format ?? "png") === "modules") {
697
+ return json({ ...common, modules: encoded.modules });
698
+ }
699
+ if (input.path === undefined) {
700
+ throw new ToolInputError("format `png` needs a `path` to write to");
701
+ }
702
+ const scale = input.scale ?? 3;
703
+ const height = input.height ?? 80;
704
+ const image = renderModuleStrip(encoded.modules, scale, height, quietZone, parseColor("#000000"), parseColor("#ffffff"));
705
+ const bytes = encodePng(image);
706
+ const out = writeSafe("BarcodeEncode", input.path, bytes, input.overwrite === true);
707
+ return json({
708
+ ...common,
709
+ path: out.rel,
710
+ width: image.width,
711
+ height: image.height,
712
+ scale,
713
+ bytes: bytes.length,
714
+ note: "bars only; no human-readable digits are drawn, because that would need a font",
715
+ });
716
+ }),
717
+ });
718
+ // ---------------------------------------------------------------------------
719
+ // Drawing
720
+ const seriesField = z
721
+ .array(z.object({
722
+ name: z.string(),
723
+ values: z.array(z.number()).optional().describe("bar charts: one value per category"),
724
+ points: z
725
+ .array(z.tuple([z.number(), z.number()]))
726
+ .optional()
727
+ .describe("line and scatter charts: [x, y] pairs"),
728
+ }))
729
+ .optional();
730
+ /** Write an SVG to `path`, or hand it back inline when there is no path. */
731
+ function emitSvg(toolName, svg, rel, overwrite, extra) {
732
+ if (rel === undefined)
733
+ return json({ ...extra, format: "svg", svg });
734
+ const out = writeSafe(toolName, rel, svg, overwrite);
735
+ return json({ ...extra, format: "svg", path: out.rel, bytes: Buffer.byteLength(svg, "utf8") });
736
+ }
737
+ export const chartRender = buildTool({
738
+ name: "ChartRender",
739
+ description: "Render bar, line, scatter or pie data as an SVG chart with axes, ticks, labels and a legend. Use to turn a query result or a metric series into a picture for a report; the layout is fixed arithmetic, so the same data always produces the same bytes.",
740
+ inputSchema: z.object({
741
+ type: z.enum(["bar", "line", "scatter", "pie"]),
742
+ title: z.string().optional(),
743
+ width: z.number().int().min(120).max(4000).optional().describe("default 640"),
744
+ height: z.number().int().min(80).max(4000).optional().describe("default 400"),
745
+ categories: z.array(z.string()).optional().describe("bar charts: the x-axis labels"),
746
+ series: seriesField,
747
+ slices: z
748
+ .array(z.object({ label: z.string(), value: z.number().min(0) }))
749
+ .optional()
750
+ .describe("pie charts: the slices"),
751
+ xLabel: z.string().optional(),
752
+ yLabel: z.string().optional(),
753
+ colors: z.array(z.string()).optional().describe("series colours; defaults to a fixed palette"),
754
+ background: z.string().optional().describe("`none` for a transparent chart; default white"),
755
+ legend: z.boolean().optional().describe("default: shown when there is more than one series"),
756
+ stacked: z
757
+ .boolean()
758
+ .optional()
759
+ .describe("bar charts: stack the series instead of grouping them"),
760
+ path: pathField.optional().describe("write the SVG here; omit to get it back inline"),
761
+ overwrite: overwriteField,
762
+ }),
763
+ destructive: true,
764
+ execute: async (input) => attempt(() => {
765
+ const spec = {
766
+ type: input.type,
767
+ width: input.width ?? 640,
768
+ height: input.height ?? 400,
769
+ ...(input.title !== undefined ? { title: input.title } : {}),
770
+ ...(input.categories !== undefined ? { categories: input.categories } : {}),
771
+ ...(input.series !== undefined ? { series: input.series } : {}),
772
+ ...(input.slices !== undefined ? { slices: input.slices } : {}),
773
+ ...(input.xLabel !== undefined ? { xLabel: input.xLabel } : {}),
774
+ ...(input.yLabel !== undefined ? { yLabel: input.yLabel } : {}),
775
+ ...(input.colors !== undefined ? { colors: input.colors } : {}),
776
+ ...(input.background !== undefined ? { background: input.background } : {}),
777
+ ...(input.legend !== undefined ? { legend: input.legend } : {}),
778
+ ...(input.stacked !== undefined ? { stacked: input.stacked } : {}),
779
+ };
780
+ const svg = renderChart(spec);
781
+ return emitSvg("ChartRender", svg, input.path, input.overwrite === true, {
782
+ type: input.type,
783
+ width: spec.width,
784
+ height: spec.height,
785
+ note: "SVG only: rasterising the axis labels would need a font, and this package ships none",
786
+ });
787
+ }),
788
+ });
789
+ export const sparklineRender = buildTool({
790
+ name: "SparklineRender",
791
+ description: "Render a series as a one-line sparkline, either as a small SVG or as Unicode block characters. Use to put a trend into a digest line, a status table or a commit message, where a full chart would not fit.",
792
+ inputSchema: z.object({
793
+ values: z.array(z.number()).min(1).max(2000),
794
+ format: z.enum(["svg", "blocks"]).optional().describe("`blocks` (default) or `svg`"),
795
+ min: z.number().optional().describe("force the low end of the range; values below it clip"),
796
+ max: z.number().optional().describe("force the high end of the range; values above it clip"),
797
+ width: z.number().int().min(8).max(2000).optional().describe("svg only; default 120"),
798
+ height: z.number().int().min(4).max(400).optional().describe("svg only; default 24"),
799
+ stroke: z.string().optional().describe("svg only; the line colour"),
800
+ fill: z.string().optional().describe("svg only; fill the area under the line"),
801
+ markLast: z.boolean().optional().describe("svg only; put a dot on the final point"),
802
+ }),
803
+ readOnly: true,
804
+ concurrencySafe: true,
805
+ execute: async (input) => attempt(() => {
806
+ const range = {
807
+ ...(input.min !== undefined ? { min: input.min } : {}),
808
+ ...(input.max !== undefined ? { max: input.max } : {}),
809
+ };
810
+ const summary = {
811
+ count: input.values.length,
812
+ first: input.values[0],
813
+ last: input.values.at(-1),
814
+ min: Math.min(...input.values),
815
+ max: Math.max(...input.values),
816
+ };
817
+ if ((input.format ?? "blocks") === "blocks") {
818
+ return json({
819
+ ...summary,
820
+ format: "blocks",
821
+ sparkline: sparklineBlocks(input.values, range),
822
+ });
823
+ }
824
+ const svg = sparklineSvg(input.values, {
825
+ ...(input.width !== undefined ? { width: input.width } : {}),
826
+ ...(input.height !== undefined ? { height: input.height } : {}),
827
+ ...(input.stroke !== undefined ? { stroke: input.stroke } : {}),
828
+ ...(input.fill !== undefined ? { fill: input.fill } : {}),
829
+ ...(input.markLast !== undefined ? { markLast: input.markLast } : {}),
830
+ }, range);
831
+ return json({ ...summary, format: "svg", svg });
832
+ }),
833
+ });
834
+ export const diagramRender = buildTool({
835
+ name: "DiagramRender",
836
+ description: "Render a node and edge list as a box-and-arrow SVG diagram with a layered layout. Use for a pipeline, a state machine or a service sketch; nodes are placed by layer and declaration order, so the same graph always draws the same, and edges that close a cycle are drawn dashed and reported.",
837
+ inputSchema: z.object({
838
+ nodes: z
839
+ .array(z.object({ id: z.string().min(1), label: z.string().optional() }))
840
+ .min(1)
841
+ .max(200),
842
+ edges: z
843
+ .array(z.object({ from: z.string().min(1), to: z.string().min(1), label: z.string().optional() }))
844
+ .max(600),
845
+ direction: z
846
+ .enum(["TB", "LR"])
847
+ .optional()
848
+ .describe("`TB` top to bottom (default) or `LR` left to right"),
849
+ title: z.string().optional(),
850
+ background: z.string().optional(),
851
+ nodeFill: z.string().optional(),
852
+ nodeStroke: z.string().optional(),
853
+ path: pathField.optional().describe("write the SVG here; omit to get it back inline"),
854
+ overwrite: overwriteField,
855
+ }),
856
+ destructive: true,
857
+ execute: async (input) => attempt(() => {
858
+ const result = renderDiagram({
859
+ nodes: input.nodes,
860
+ edges: input.edges,
861
+ ...(input.direction !== undefined ? { direction: input.direction } : {}),
862
+ ...(input.title !== undefined ? { title: input.title } : {}),
863
+ ...(input.background !== undefined ? { background: input.background } : {}),
864
+ ...(input.nodeFill !== undefined ? { nodeFill: input.nodeFill } : {}),
865
+ ...(input.nodeStroke !== undefined ? { nodeStroke: input.nodeStroke } : {}),
866
+ });
867
+ return emitSvg("DiagramRender", result.svg, input.path, input.overwrite === true, {
868
+ width: result.width,
869
+ height: result.height,
870
+ nodes: input.nodes.length,
871
+ edges: input.edges.length,
872
+ layerCount: Math.max(...result.layers.map((l) => l.layer)) + 1,
873
+ layers: result.layers,
874
+ backEdges: result.backEdges,
875
+ });
876
+ }),
877
+ });
878
+ // ---------------------------------------------------------------------------
879
+ // Colour
880
+ export const colorConvert = buildTool({
881
+ name: "ColorConvert",
882
+ description: "Convert colours between hex, RGB and HSL, reporting each form plus the relative luminance. Use to move a palette between a stylesheet and a design token file, or to normalise colours written in three different notations.",
883
+ inputSchema: z.object({
884
+ colors: z
885
+ .array(z.string().min(1))
886
+ .min(1)
887
+ .max(256)
888
+ .describe("hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba(), hsl()/hsla(), or a basic keyword"),
889
+ }),
890
+ readOnly: true,
891
+ concurrencySafe: true,
892
+ execute: async (input) => attempt(() => {
893
+ const results = input.colors.map((text) => {
894
+ try {
895
+ const rgb = parseColor(text);
896
+ const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
897
+ return {
898
+ input: text,
899
+ hex: toHexString(rgb),
900
+ rgb: { r: rgb.r, g: rgb.g, b: rgb.b },
901
+ alpha: Math.round((rgb.a / 255) * 1000) / 1000,
902
+ rgbCss: rgb.a === 255
903
+ ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`
904
+ : `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.round((rgb.a / 255) * 1000) / 1000})`,
905
+ hsl,
906
+ hslCss: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`,
907
+ relativeLuminance: Math.round(relativeLuminance(rgb.r, rgb.g, rgb.b) * 10000) / 10000,
908
+ };
909
+ }
910
+ catch (err) {
911
+ return { input: text, error: err.message };
912
+ }
913
+ });
914
+ return json({ count: results.length, keywords: colorKeywords(), colors: results });
915
+ }),
916
+ });
917
+ export const colorContrast = buildTool({
918
+ name: "ColorContrast",
919
+ description: "Compute the WCAG contrast ratio between foreground and background colours and report which conformance levels each pair clears. Use as the accessibility check before shipping a palette: it names AA and AAA for normal text, large text and UI components separately.",
920
+ inputSchema: z.object({
921
+ pairs: z
922
+ .array(z.object({
923
+ foreground: z.string().min(1),
924
+ background: z.string().min(1),
925
+ label: z.string().optional(),
926
+ }))
927
+ .min(1)
928
+ .max(200),
929
+ }),
930
+ readOnly: true,
931
+ concurrencySafe: true,
932
+ execute: async (input) => attempt(() => {
933
+ const results = input.pairs.map((pair) => {
934
+ try {
935
+ const report = contrastReport(parseColor(pair.foreground), parseColor(pair.background));
936
+ return {
937
+ ...(pair.label !== undefined ? { label: pair.label } : {}),
938
+ input: { foreground: pair.foreground, background: pair.background },
939
+ ...report,
940
+ };
941
+ }
942
+ catch (err) {
943
+ return {
944
+ ...(pair.label !== undefined ? { label: pair.label } : {}),
945
+ input: { foreground: pair.foreground, background: pair.background },
946
+ error: err.message,
947
+ };
948
+ }
949
+ });
950
+ const failing = results.filter((r) => "verdict" in r && r.verdict === "fail").length;
951
+ return json({
952
+ standard: "WCAG 2.x; AA is 4.5:1 for normal text and 3:1 for large text and UI components, AAA is 7:1 and 4.5:1",
953
+ count: results.length,
954
+ failingNormalText: results.filter((r) => "normalTextAA" in r && !r.normalTextAA).length,
955
+ failingEverything: failing,
956
+ pairs: results,
957
+ });
958
+ }),
959
+ });
960
+ // ---------------------------------------------------------------------------
961
+ // Subtitles and probing
962
+ export const subtitleParse = buildTool({
963
+ name: "SubtitleParse",
964
+ description: "Parse an SRT or WebVTT subtitle file into cues with start and end times in milliseconds. Use to search a transcript, count speaking time, or pull the text of a video without watching it; the format is detected from the file when not given.",
965
+ inputSchema: z.object({
966
+ path: pathField.describe("a .srt or .vtt file inside the workspace"),
967
+ format: z.enum(["srt", "vtt"]).optional().describe("override the detected format"),
968
+ maxCues: z.number().int().positive().max(20_000).optional().describe("default 2000"),
969
+ textOnly: z.boolean().optional().describe("return just the concatenated text, without timings"),
970
+ }),
971
+ readOnly: true,
972
+ concurrencySafe: true,
973
+ execute: async (input) => attempt(() => {
974
+ const { path, bytes } = readWhole("SubtitleParse", input.path, 32 * 1024 * 1024);
975
+ const result = parseSubtitles(new TextDecoder("utf-8").decode(bytes), input.format);
976
+ const limit = input.maxCues ?? 2000;
977
+ const cues = result.cues.slice(0, limit);
978
+ const totalMs = result.cues.reduce((sum, c) => sum + (c.endMs - c.startMs), 0);
979
+ const summary = {
980
+ path: path.rel,
981
+ format: result.format,
982
+ cueCount: result.cues.length,
983
+ returned: cues.length,
984
+ truncated: result.cues.length > cues.length,
985
+ totalCueDurationMs: totalMs,
986
+ skipped: result.skipped,
987
+ };
988
+ if (input.textOnly === true) {
989
+ return json({ ...summary, text: cues.map((c) => c.text).join("\n") });
990
+ }
991
+ return json({ ...summary, cues });
992
+ }),
993
+ });
994
+ export const subtitleWrite = buildTool({
995
+ name: "SubtitleWrite",
996
+ description: "Write cues out as SRT or WebVTT, optionally shifting every time and re-wrapping the text. Use to fix a track that runs early or late, to convert between the two formats, or to re-wrap long lines to a readable width.",
997
+ inputSchema: z.object({
998
+ cues: z
999
+ .array(z.object({
1000
+ startMs: z.number().int().min(0),
1001
+ endMs: z.number().int().min(0),
1002
+ text: z.string(),
1003
+ id: z.string().optional(),
1004
+ settings: z.string().optional().describe("WebVTT cue settings, kept verbatim"),
1005
+ }))
1006
+ .min(1)
1007
+ .max(20_000),
1008
+ format: z.enum(["srt", "vtt"]),
1009
+ path: pathField.optional().describe("write here; omit to get the document back inline"),
1010
+ shiftMs: z
1011
+ .number()
1012
+ .int()
1013
+ .optional()
1014
+ .describe("added to every cue; a cue pushed before zero is dropped and counted"),
1015
+ wrapColumns: z.number().int().min(10).max(200).optional().describe("re-wrap each cue's text"),
1016
+ header: z.string().optional().describe("WebVTT only: text appended to the WEBVTT line"),
1017
+ overwrite: overwriteField,
1018
+ }),
1019
+ destructive: true,
1020
+ execute: async (input) => attempt(() => {
1021
+ const cues = input.cues.map((cue, i) => ({
1022
+ index: i + 1,
1023
+ startMs: cue.startMs,
1024
+ endMs: cue.endMs,
1025
+ text: cue.text,
1026
+ ...(cue.id !== undefined ? { id: cue.id } : {}),
1027
+ ...(cue.settings !== undefined ? { settings: cue.settings } : {}),
1028
+ }));
1029
+ for (const cue of cues) {
1030
+ if (cue.endMs < cue.startMs) {
1031
+ throw new ToolInputError(`cue ${cue.index} ends at ${cue.endMs}ms, before its start at ${cue.startMs}ms`);
1032
+ }
1033
+ }
1034
+ const result = writeSubtitles(cues, input.format, {
1035
+ ...(input.shiftMs !== undefined ? { shiftMs: input.shiftMs } : {}),
1036
+ ...(input.wrapColumns !== undefined ? { wrapColumns: input.wrapColumns } : {}),
1037
+ ...(input.header !== undefined ? { header: input.header } : {}),
1038
+ });
1039
+ const summary = {
1040
+ format: input.format,
1041
+ cueCount: result.cueCount,
1042
+ droppedBeforeZero: result.dropped,
1043
+ shiftMs: input.shiftMs ?? 0,
1044
+ };
1045
+ if (input.path === undefined)
1046
+ return json({ ...summary, text: result.text });
1047
+ const out = writeSafe("SubtitleWrite", input.path, result.text, input.overwrite === true);
1048
+ return json({ ...summary, path: out.rel, bytes: Buffer.byteLength(result.text, "utf8") });
1049
+ }),
1050
+ });
1051
+ export const mediaProbe = buildTool({
1052
+ name: "MediaProbe",
1053
+ description: "Probe an audio or video file with ffprobe and return its duration, streams, codecs, resolution and bitrate as structured data. Use to learn what a media file actually contains before transcoding or embedding it; ffprobe must be installed, and the tool says so plainly when it is not.",
1054
+ inputSchema: z.object({
1055
+ path: pathField.describe("an audio or video file inside the workspace"),
1056
+ timeout: z
1057
+ .number()
1058
+ .int()
1059
+ .min(1000)
1060
+ .max(MAX_PROCESS_TIMEOUT_MS)
1061
+ .optional()
1062
+ .describe(`milliseconds before ffprobe is killed; default ${DEFAULT_PROCESS_TIMEOUT_MS}`),
1063
+ }),
1064
+ // NOT `readOnly`. ffprobe only reads, but `readOnly` is what the
1065
+ // permission engine auto-allows in `plan` mode, and a plan must not spawn
1066
+ // anything: every other process-spawning tool in this repo (tool-bash,
1067
+ // tool-proc) makes the same call. `destructive` stays false, because
1068
+ // nothing this runs can change a file.
1069
+ // Spawning a process crosses a boundary the runtime cannot re-classify
1070
+ // afterwards, so this declares both the policy and the capability.
1071
+ scope: "external",
1072
+ ioCapability: "process",
1073
+ execute: async (input, ctx) => attempt(async () => {
1074
+ const path = resolveSafe("MediaProbe", input.path);
1075
+ const stats = statSync(path.real);
1076
+ if (stats.isDirectory())
1077
+ throw new ToolInputError(`"${input.path}" is a directory, not a file`);
1078
+ const argv = [
1079
+ "ffprobe",
1080
+ "-v",
1081
+ "error",
1082
+ "-print_format",
1083
+ "json",
1084
+ "-show_format",
1085
+ "-show_streams",
1086
+ // `--` then the path: a file name starting with a dash is a file
1087
+ // name, never an option.
1088
+ "--",
1089
+ path.real,
1090
+ ];
1091
+ const result = await runProcess(argv, {
1092
+ cwd: process.cwd(),
1093
+ timeoutMs: input.timeout ?? DEFAULT_PROCESS_TIMEOUT_MS,
1094
+ ...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
1095
+ });
1096
+ if (result.missing || result.code === 127) {
1097
+ return `ffprobe is not installed on this machine, so ${path.rel} cannot be probed here. Install ffmpeg, or use ImageInfo if this is a still image.`;
1098
+ }
1099
+ if (result.code !== 0 || result.timedOut)
1100
+ return describeFailure(argv, result);
1101
+ const probe = parseProbeJson(result.stdout);
1102
+ return json({
1103
+ path: path.rel,
1104
+ fileBytes: stats.size,
1105
+ ...probe,
1106
+ note: "reported by the ffprobe on this machine; a different ffprobe build may name codecs differently",
1107
+ });
1108
+ }),
1109
+ });
1110
+ /** Every tool this package registers, in the order a catalog should list them. */
1111
+ export const MEDIA_TOOLS = Object.freeze([
1112
+ barcodeEncode,
1113
+ chartRender,
1114
+ colorContrast,
1115
+ colorConvert,
1116
+ diagramRender,
1117
+ exifRead,
1118
+ exifStrip,
1119
+ imageCrop,
1120
+ imageDiff,
1121
+ imageInfo,
1122
+ imageKind,
1123
+ imageResize,
1124
+ mediaProbe,
1125
+ pngRead,
1126
+ pngWrite,
1127
+ qrEncode,
1128
+ sparklineRender,
1129
+ subtitleParse,
1130
+ subtitleWrite,
1131
+ ]);
1132
+ export { HEADER_FORMATS, ECC_LEVELS, knownKinds };