@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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ CrewHaus Factory
2
+ Copyright 2026 Max Meier
3
+
4
+ This product includes software developed by Max Meier
5
+ (operating as StudioMax — https://studiomax.io) and contributors.
6
+
7
+ The CrewHaus name and logo are trademarks of Max Meier. See
8
+ https://github.com/crewhaus/factory/blob/main/TRADEMARK.md for the
9
+ use-of-marks policy.
package/README.md CHANGED
@@ -14,8 +14,9 @@ not.
14
14
 
15
15
  ```yaml
16
16
  tools:
17
- - all-media # every tool below
18
- - -MediaProbe # ...except the one that shells out
17
+ - all-media # every tool below, plus ReadImage and ImageGenerate
18
+ - -mediaProbe # ...except the one that shells out
19
+ - -imageGenerate # ...and the one that bills a remote model
19
20
  ```
20
21
 
21
22
  | Tool | What it does |
@@ -104,10 +105,18 @@ reported in `backEdges`.
104
105
 
105
106
  **They will not re-encode a photograph.** `ExifStrip` rewrites the segment
106
107
  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.
108
+ bit-for-bit what it was. It walks the whole file: metadata segments between
109
+ the scans of a progressive JPEG are dropped too, and so is everything after
110
+ the end-of-image marker (an appended preview, second view, HDR gain map or
111
+ motion-photo video, each of which can carry its own EXIF and location). The
112
+ result says how many trailing bytes went and what they looked like. A file
113
+ that breaks off after its first scan (a partial download) is stripped up to
114
+ the break and the rest kept as it was, as 0.7.0 did; a cut-off metadata
115
+ segment there is dropped, and unframed bytes that look like metadata are
116
+ refused rather than written into a file that claims to be clean. It keeps
117
+ the ICC colour profile by default, because a profile is not metadata about
118
+ the photographer: it is what tells a display how to interpret the colours,
119
+ and dropping it visibly shifts the image.
111
120
 
112
121
  **They will not guess.** A 16-bit or interlaced PNG is refused by name
113
122
  rather than truncated or de-interlaced badly. A payload past a QR code's
@@ -140,8 +149,9 @@ too. No EAN-8, and no 2- or 5-digit add-ons. Bars only — the
140
149
  human-readable digits underneath would need a font.
141
150
 
142
151
  **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.
152
+ both byte orders. Not MakerNotes, IFD1 thumbnails, or EXIF in a TIFF or HEIC.
153
+ XMP (Extended XMP reassembled) and a Photoshop `APP13` block are searched for
154
+ a location, not parsed field by field.
145
155
 
146
156
  **Subtitles.** SRT and WebVTT, through a BOM and either line ending, with
147
157
  cue ids and cue settings preserved. Not WebVTT chapter or metadata tracks.
@@ -154,3 +164,19 @@ A photograph taken on a phone usually records where it was taken.
154
164
  `ExifRead` reports that as `hasGps: true` with a `privacyWarning`, rather
155
165
  than burying the coordinates in a field list, because publishing the file
156
166
  publishes the location. `ExifStrip` is the tool that removes it.
167
+
168
+ `ExifRead` reads the whole file, not just its first megabyte, and counts
169
+ GPS found anywhere: before the first scan, between scans, in a JPEG appended
170
+ after the main one, or in an EXIF block sitting in bytes no walk accounts
171
+ for (a video, data it does not recognise, an image that would not walk). It
172
+ reports `trailingBytes`, `interScanMetadataSegments` and every EXIF block
173
+ found outside the main image's own. When it cannot tell and found no GPS
174
+ elsewhere, `hasGps` is `null` with the reason in `gpsUndetermined`, never
175
+ `false`: an EXIF block that does not parse, more appended images than it
176
+ walks, bytes after the image that no walk accounts for (padding aside), a
177
+ file that breaks off after its first scan, a file past 64 MiB, which is
178
+ read that far and answered from what was read, or a metadata segment it does
179
+ not read (a C2PA manifest in `APP11`, a vendor `APPn`), which it names. GPS
180
+ written only in XMP, or in the EXIF or XMP a Photoshop `APP13` block keeps,
181
+ is a definite `true`, listed in `gpsInOtherMetadata`; JFIF, ICC, MPF, Adobe
182
+ and Ducky segments and an IPTC record have no place for coordinates.
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ import { renderDiagram } from "./lib/diagram";
33
33
  import { parseProbeJson } from "./lib/ffprobe";
34
34
  import { HEADER_FORMATS, detectKind, kindsForExtension, knownKinds, readImageHeader, } from "./lib/identify";
35
35
  import { cropImage, diffImages, differenceHash, fitDimensions, hammingDistance, imageStats, resizeImage, } from "./lib/imageops";
36
- import { findExifSegment, parseExif, readJpegSegments, stripJpegMetadata } from "./lib/jpeg";
36
+ import { inspectJpegMetadata, stripJpegMetadata } from "./lib/jpeg";
37
37
  import { DEFAULT_DECODE_LIMITS, decodePng, encodePng, } from "./lib/png";
38
38
  import { ECC_LEVELS, MAX_VERSION, byteCapacity, encodeQr, symbolToText, } from "./lib/qr";
39
39
  import { renderModuleGrid, renderModuleStrip } from "./lib/raster";
@@ -111,6 +111,9 @@ function readWhole(toolName, rel, maxBytes = MAX_FILE_BYTES) {
111
111
  const stats = statSync(path.real);
112
112
  if (stats.isDirectory())
113
113
  throw new ToolInputError(`"${rel}" is a directory, not a file`);
114
+ // Opening a FIFO blocks until something writes to it; a device may never end.
115
+ if (!stats.isFile())
116
+ throw new ToolInputError(`"${rel}" is not a regular file`);
114
117
  if (stats.size > maxBytes) {
115
118
  throw new ToolInputError(`"${rel}" is ${stats.size} bytes, over the ${maxBytes}-byte limit for this tool`);
116
119
  }
@@ -139,6 +142,8 @@ function readHead(toolName, rel, window = HEADER_WINDOW_BYTES) {
139
142
  const stats = statSync(path.real);
140
143
  if (stats.isDirectory())
141
144
  throw new ToolInputError(`"${rel}" is a directory, not a file`);
145
+ if (!stats.isFile())
146
+ throw new ToolInputError(`"${rel}" is not a regular file`);
142
147
  const want = Math.min(window, stats.size);
143
148
  const fd = openSync(path.real, "r");
144
149
  try {
@@ -321,40 +326,127 @@ export const pngRead = buildTool({
321
326
  });
322
327
  export const exifRead = buildTool({
323
328
  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.",
329
+ 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. The whole file is read (a file past 64 MiB up to that point), so EXIF between scans or in an image appended after the main one (a preview, a gain map) counts too, and so does GPS written in XMP; when it cannot tell, hasGps is null with the reason.",
325
330
  inputSchema: z.object({ path: pathField.describe("a JPEG inside the workspace") }),
326
331
  readOnly: true,
327
332
  concurrencySafe: true,
328
333
  execute: async (input) => attempt(() => {
329
- const { path, bytes } = readHead("ExifRead", input.path);
334
+ // The whole file, not a head window: GPS can sit between scans or in
335
+ // an appended image, and "no GPS" must not be said about bytes that
336
+ // were never read. A file past the read limit is read as far as the
337
+ // limit and answered from that, with GPS undetermined unless found.
338
+ const { path, bytes, size } = readHead("ExifRead", input.path, MAX_FILE_BYTES);
339
+ const partial = bytes.length < size;
330
340
  const kind = detectKind(bytes);
331
341
  if (kind?.kind !== "jpeg") {
332
342
  throw new ToolInputError(`"${input.path}" is ${kind === undefined ? "not a recognised format" : kind.kind}; EXIF reading here covers JPEG only`);
333
343
  }
334
- const { segments } = readJpegSegments(bytes);
335
- const segment = findExifSegment(segments);
336
- if (segment === undefined) {
344
+ const report = inspectJpegMetadata(bytes);
345
+ // The main image's own EXIF: never a block found after it.
346
+ const primary = report.exifBlocks.find((b) => b.where === "before the first scan" || b.where === "between scans");
347
+ const withGps = report.exifBlocks.filter((b) => b.exif?.hasGps === true);
348
+ const unreadableBlocks = report.exifBlocks.filter((b) => b.exif === null);
349
+ // XMP and Photoshop resources are read for a location; a segment this
350
+ // reader does not parse (a C2PA manifest in APP11, a vendor APPn) may
351
+ // hold one, so it leaves "no GPS" undetermined, as an unparsed EXIF
352
+ // block does.
353
+ const locatedElsewhere = report.otherMetadata.filter((m) => m.location === true);
354
+ const notRead = report.otherMetadata.filter((m) => m.location === null);
355
+ // GPS found anywhere is a definite yes. No GPS is a definite no only
356
+ // when every byte was read, every EXIF block parsed, and every byte
357
+ // after the image accounted for by an appended image that was walked.
358
+ const unknowns = [];
359
+ if (partial) {
360
+ unknowns.push(`only the first ${bytes.length} of the file's ${size} bytes were read, the most this tool reads`);
361
+ }
362
+ if (report.breaksOffAt !== undefined) {
363
+ // A walk that broke off has no trailer: its unaccounted bytes are
364
+ // the part after the break.
365
+ unknowns.push(`the file breaks off at offset ${report.breaksOffAt.offset} (${report.breaksOffAt.reason}), and the ${bytes.length - report.breaksOffAt.offset} bytes from there could not be walked`);
366
+ }
367
+ else if (report.unaccountedBytes > 0) {
368
+ unknowns.push(`${report.unaccountedBytes} bytes after the image that no walk accounts for (${report.trailingKind ?? "unrecognised data"}) were searched for EXIF blocks but not parsed, and may carry a location`);
369
+ }
370
+ if (report.embeddedImagesNotInspected > 0) {
371
+ unknowns.push(`${report.embeddedImagesNotInspected} more appended images were not walked`);
372
+ }
373
+ if (unreadableBlocks.length > 0)
374
+ unknowns.push("an EXIF block in the file could not be parsed");
375
+ if (notRead.length > 0) {
376
+ const named = notRead
377
+ .slice(0, 8)
378
+ .map((m) => `${m.segment} at offset ${m.offset}`)
379
+ .join(", ");
380
+ const more = notRead.length > 8 ? ` and ${notRead.length - 8} more` : "";
381
+ unknowns.push(`metadata this reader does not parse may carry a location: ${named}${more}`);
382
+ }
383
+ const found = withGps.length > 0 || locatedElsewhere.length > 0;
384
+ const undetermined = !found && unknowns.length > 0;
385
+ const hasGps = found ? true : undetermined ? null : false;
386
+ const where = {
387
+ ...(partial ? { bytesRead: bytes.length, fileBytes: size } : {}),
388
+ trailingBytes: report.trailingBytes,
389
+ ...(report.trailingKind === undefined ? {} : { trailingKind: report.trailingKind }),
390
+ embeddedImages: report.embeddedImages,
391
+ interScanMetadataSegments: report.interScanMetadataSegments,
392
+ ...(report.eoiMissing && !partial ? { eoiMissing: true } : {}),
393
+ ...(report.breaksOffAt === undefined ? {} : { breaksOffAt: report.breaksOffAt.offset }),
394
+ ...(report.unaccountedBytes > 0 ? { unaccountedBytes: report.unaccountedBytes } : {}),
395
+ ...(report.exifBlocks.length > 1 ||
396
+ (report.exifBlocks.length === 1 && primary === undefined)
397
+ ? {
398
+ exifBlocks: report.exifBlocks.map((b) => ({
399
+ where: b.where,
400
+ offset: b.offset,
401
+ hasGps: b.exif?.hasGps ?? null,
402
+ ...(b.exif?.gps === undefined ? {} : { gps: b.exif.gps }),
403
+ ...(b.unreadable === undefined ? {} : { unreadable: b.unreadable }),
404
+ })),
405
+ }
406
+ : {}),
407
+ ...(locatedElsewhere.length > 0
408
+ ? {
409
+ gpsInOtherMetadata: locatedElsewhere.map((m) => ({
410
+ segment: m.segment,
411
+ where: m.where,
412
+ offset: m.offset,
413
+ })),
414
+ }
415
+ : {}),
416
+ ...(undetermined ? { gpsUndetermined: unknowns.join("; ") } : {}),
417
+ ...(report.trailingKind?.startsWith("an appended video") === true
418
+ ? {
419
+ videoNote: "the appended video is not read here and may carry its own location; ExifStrip removes it",
420
+ }
421
+ : {}),
422
+ };
423
+ const privacy = hasGps === true
424
+ ? {
425
+ privacyWarning: "this file records where it was taken; publishing it publishes that location. ExifStrip removes it.",
426
+ }
427
+ : {};
428
+ if (primary === undefined || primary.exif === null) {
337
429
  return json({
338
430
  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(),
431
+ hasExif: report.exifBlocks.length > 0,
432
+ hasGps,
433
+ ...(report.exifBlocks.length === 0
434
+ ? {
435
+ note: "no APP1 EXIF segment; the file carries no EXIF metadata this reader can see",
436
+ }
437
+ : {}),
438
+ metadataSegments: report.metadataSegments,
439
+ ...where,
440
+ ...privacy,
346
441
  });
347
442
  }
348
- const exif = parseExif(segment.payload);
349
443
  return json({
350
444
  path: path.rel,
351
445
  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
- : {}),
446
+ ...primary.exif,
447
+ hasGps,
448
+ ...where,
449
+ ...privacy,
358
450
  });
359
451
  }),
360
452
  });
@@ -417,6 +509,7 @@ const colorTypeField = z
417
509
  .describe("`auto` (the default) picks the smallest type that is lossless for these pixels");
418
510
  export const pngWrite = buildTool({
419
511
  name: "PngWrite",
512
+ operativeArgs: [{ field: "path", kind: "path" }],
420
513
  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
514
  inputSchema: z.object({
422
515
  path: pathField.describe("where to write the PNG, inside the workspace"),
@@ -456,6 +549,10 @@ export const pngWrite = buildTool({
456
549
  });
457
550
  export const imageResize = buildTool({
458
551
  name: "ImageResize",
552
+ operativeArgs: [
553
+ { field: "path", kind: "path", reads: true },
554
+ { field: "output", kind: "path" },
555
+ ],
459
556
  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
557
  inputSchema: z.object({
461
558
  path: pathField.describe("the source PNG"),
@@ -503,6 +600,10 @@ export const imageResize = buildTool({
503
600
  });
504
601
  export const imageCrop = buildTool({
505
602
  name: "ImageCrop",
603
+ operativeArgs: [
604
+ { field: "path", kind: "path", reads: true },
605
+ { field: "output", kind: "path" },
606
+ ],
506
607
  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
608
  inputSchema: z.object({
508
609
  path: pathField.describe("the source PNG"),
@@ -533,7 +634,11 @@ export const imageCrop = buildTool({
533
634
  });
534
635
  export const exifStrip = buildTool({
535
636
  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.",
637
+ operativeArgs: [
638
+ { field: "path", kind: "path", reads: true },
639
+ { field: "output", kind: "path" },
640
+ ],
641
+ 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. Metadata between scans is removed too, and so is anything appended after the image (a preview, gain map or motion-photo video, each of which can carry its own location).",
537
642
  inputSchema: z.object({
538
643
  path: pathField.describe("the source JPEG"),
539
644
  output: pathField.describe("where to write the stripped JPEG"),
@@ -563,7 +668,17 @@ export const exifStrip = buildTool({
563
668
  bytesAfter: result.bytes.length,
564
669
  bytesRemoved: result.bytesRemoved,
565
670
  removed: result.removed,
566
- note: "the entropy-coded scan data was copied verbatim; the picture is unchanged",
671
+ trailingBytesRemoved: result.trailingBytesRemoved,
672
+ ...(result.trailingKind === undefined ? {} : { trailingKind: result.trailingKind }),
673
+ ...(result.eoiMissing ? { eoiMissing: true } : {}),
674
+ ...(result.breaksOffAt === undefined ? {} : { breaksOffAt: result.breaksOffAt.offset }),
675
+ note: result.breaksOffAt !== undefined
676
+ ? `the file breaks off at offset ${result.breaksOffAt.offset} (${result.breaksOffAt.reason}); the scan data up to there was copied verbatim, and the cut-off part after it was ${result.removed.some((r) => r.name.endsWith("(cut off)")) ? "a metadata segment, and was dropped" : "kept as it was"}`
677
+ : result.trailingBytesRemoved > 0
678
+ ? `the main image's scan data was copied verbatim, so that picture is unchanged; the ${result.trailingBytesRemoved} bytes after its end-of-image marker were dropped with any metadata in them: ${result.trailingKind}`
679
+ : result.eoiMissing
680
+ ? "the scan data was copied verbatim; the file has no end-of-image marker, so it was walked to its end and kept as it was"
681
+ : "the entropy-coded scan data was copied verbatim; the picture is unchanged",
567
682
  });
568
683
  }),
569
684
  });
@@ -571,6 +686,7 @@ export const exifStrip = buildTool({
571
686
  // Codes
572
687
  export const qrEncode = buildTool({
573
688
  name: "QrEncode",
689
+ operativeArgs: [{ field: "path", kind: "path" }],
574
690
  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
691
  inputSchema: z.object({
576
692
  text: z.string().min(1).describe("the payload; encoded as UTF-8 in byte mode"),
@@ -647,6 +763,7 @@ export const qrEncode = buildTool({
647
763
  });
648
764
  export const barcodeEncode = buildTool({
649
765
  name: "BarcodeEncode",
766
+ operativeArgs: [{ field: "path", kind: "path" }],
650
767
  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
768
  inputSchema: z.object({
652
769
  symbology: z.enum(["code128", "ean13"]),
@@ -736,6 +853,7 @@ function emitSvg(toolName, svg, rel, overwrite, extra) {
736
853
  }
737
854
  export const chartRender = buildTool({
738
855
  name: "ChartRender",
856
+ operativeArgs: [{ field: "path", kind: "path" }],
739
857
  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
858
  inputSchema: z.object({
741
859
  type: z.enum(["bar", "line", "scatter", "pie"]),
@@ -833,6 +951,7 @@ export const sparklineRender = buildTool({
833
951
  });
834
952
  export const diagramRender = buildTool({
835
953
  name: "DiagramRender",
954
+ operativeArgs: [{ field: "path", kind: "path" }],
836
955
  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
956
  inputSchema: z.object({
838
957
  nodes: z
@@ -993,6 +1112,7 @@ export const subtitleParse = buildTool({
993
1112
  });
994
1113
  export const subtitleWrite = buildTool({
995
1114
  name: "SubtitleWrite",
1115
+ operativeArgs: [{ field: "path", kind: "path" }],
996
1116
  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
1117
  inputSchema: z.object({
998
1118
  cues: z
@@ -1050,6 +1170,7 @@ export const subtitleWrite = buildTool({
1050
1170
  });
1051
1171
  export const mediaProbe = buildTool({
1052
1172
  name: "MediaProbe",
1173
+ operativeArgs: [{ field: "path", kind: "path" }],
1053
1174
  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
1175
  inputSchema: z.object({
1055
1176
  path: pathField.describe("an audio or video file inside the workspace"),
@@ -1062,10 +1183,12 @@ export const mediaProbe = buildTool({
1062
1183
  .describe(`milliseconds before ffprobe is killed; default ${DEFAULT_PROCESS_TIMEOUT_MS}`),
1063
1184
  }),
1064
1185
  // 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.
1186
+ // permission engine auto-allows in `plan` mode, and this tool keeps a plan
1187
+ // from spawning it. (Not every spawning tool makes that call: the git
1188
+ // readers and system probes are read-only because the program they run is
1189
+ // fixed by the tool, not supplied by the workspace — the rule, and the list
1190
+ // it allows, are in apps/cli/src/flag-rules.test.ts.) `destructive` stays
1191
+ // false, because nothing this runs can change a file.
1069
1192
  // Spawning a process crosses a boundary the runtime cannot re-classify
1070
1193
  // afterwards, so this declares both the policy and the capability.
1071
1194
  scope: "external",
@@ -4,6 +4,8 @@ export type Rgb = {
4
4
  readonly b: number;
5
5
  readonly a: number;
6
6
  };
7
+ export declare const RGB_FN: RegExp;
8
+ export declare const HSL_FN: RegExp;
7
9
  /** Colour keywords this package accepts, sorted. */
8
10
  export declare function colorKeywords(): ReadonlyArray<string>;
9
11
  /**
package/dist/lib/color.js CHANGED
@@ -11,8 +11,19 @@ const HEX3 = /^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i;
11
11
  const HEX4 = /^#?([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])$/i;
12
12
  const HEX6 = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
13
13
  const HEX8 = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
14
- const RGB_FN = /^rgba?\(\s*([^)]+)\)$/i;
15
- const HSL_FN = /^hsla?\(\s*([^)]+)\)$/i;
14
+ /*
15
+ * `rgb(…)` and `hsl(…)`, alpha forms included. The capture is the text
16
+ * between the parentheses without its leading whitespace, or its last
17
+ * character when it is all whitespace.
18
+ *
19
+ * These were `\(\s*([^)]+)\)$`, which captures the same. Because the capture
20
+ * could start with whitespace too, a failed match gave a run of whitespace
21
+ * back one character at a time and re-read the rest of the run from each:
22
+ * quadratic in the run, on a colour the model wrote. The capture now starts
23
+ * at a character that is not whitespace, or is a single whitespace character.
24
+ */
25
+ export const RGB_FN = /^rgba?\(\s*([^)\s][^)]*|\s)\)$/i;
26
+ export const HSL_FN = /^hsla?\(\s*([^)\s][^)]*|\s)\)$/i;
16
27
  /**
17
28
  * The 16 colour keywords every CSS level has had, lowercase. The full
18
29
  * 148-name list is deliberately not carried here: a caller who means
@@ -77,7 +88,10 @@ function alphaValue(text) {
77
88
  */
78
89
  export function parseColor(input) {
79
90
  const text = input.trim();
80
- const keyword = KEYWORDS[text.toLowerCase()];
91
+ // Own entries only: `constructor` read Object, and the recursion below
92
+ // failed with "input.trim is not a function" instead of "not a colour".
93
+ const name = text.toLowerCase();
94
+ const keyword = Object.hasOwn(KEYWORDS, name) ? KEYWORDS[name] : undefined;
81
95
  if (keyword !== undefined)
82
96
  return parseColor(keyword);
83
97
  const hex8 = HEX8.exec(text);