image-guard 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,11 +8,15 @@ Image Guard takes care of near-lossless compression of your images, to help you
8
8
 
9
9
  It’s convenient because setup is simple. Run it right away—done. Or install, run, add hook—done.
10
10
 
11
- It’s as safe as possible because compression happens losslessly (near-lossless for JPG and GIF images). That allows you to stop worrying about forgetting to compress images, but also about sacrificing too much quality. (You can take care of additional optimizations manually or through other tooling.)
11
+ It’s as safe as possible because compression happens losslessly (near-lossless for PNG, JPG, and GIF images). That allows you to stop worrying about forgetting to compress images, but also about sacrificing too much quality. (You can take care of additional optimizations manually or through other tooling.)
12
12
 
13
13
  ## Installation and Use
14
14
 
15
- (Note available parameters below.)
15
+ On Windows, Image Guard needs Git Bash, which comes with [Git for Windows](https://gitforwindows.org/). Git hooks use it already. For npx and npm scripts (from any shell, including PowerShell), have npm use Git Bash, too:
16
+
17
+ ```shell
18
+ npm config set script-shell "C:\Program Files\Git\bin\bash.exe"
19
+ ```
16
20
 
17
21
  ### Ways to Use Image Guard
18
22
 
@@ -103,7 +107,7 @@ npm pkg set scripts.postprepare="grep -qxF './node_modules/.bin/image-guard --st
103
107
 
104
108
  If you give the hook options like `--ignore`, add them to the `postprepare` script as well—the hook command is the only place to configure Image Guard, and the next install rewrites that line.
105
109
 
106
- **Important:** When you commit images that have not yet been compressed, the automated compression process (triggered by the pre-commit hook) will modify those image files to reduce their size. As a result, after your initial commit attempt, you will see these images appear as changed files in Git. To include the optimized images in your repository, you need to stage and commit them again. In rare cases, if further compression is possible, you may need to repeat this process until no further changes are detected. This workflow is intentional and ensures that only optimally compressed images are committed. Many editors can display diffs for images, helping you review these changes.
110
+ **Important:** When you commit images that have not yet been compressed, the pre-commit hook compresses them and stages the result, so the commit includes the compressed images. Images with changes you haven’t staged are the exception: Image Guard compresses them, too, but leaves them unstaged (and says so), so as not to commit changes you held back. Many editors can display diffs for images, helping you review these changes.
107
111
 
108
112
  ### Parameters
109
113
 
@@ -119,7 +123,7 @@ Image Guard takes an optional path to the directory to process—`npx image-guar
119
123
 
120
124
  * `--dry`/`-d` allows you to run Image Guard in “dry mode.” All changes are shown in the terminal (unless combined with `--quiet`, which leaves just the summary).
121
125
 
122
- * `--staged` (recommended with automated use) triggers a mode that watches PNG, JPG, GIF, WebP, and AVIF files (and HEIC/HEIF, when `--heic-to-avif` is used) from staged changes (`git diff --cached`) and only processes those files—that approach makes Image Guard more efficient in operation. Because the file set comes from Git, `--staged` can’t be combined with a path.
126
+ * `--staged` (recommended with automated use) triggers a mode that watches PNG, JPG, GIF, WebP, and AVIF files (and HEIC/HEIF, when `--heic-to-avif` is used) from staged changes (`git diff --cached`) and only processes those files—that approach makes Image Guard more efficient in operation. It then stages the compressed (and converted) images. Because the file set comes from Git, `--staged` can’t be combined with a path.
123
127
 
124
128
  * `--no-cache` processes all images, including those recorded as already processed (see [“Record of Processed Images”](#record-of-processed-images)).
125
129
 
@@ -146,6 +150,12 @@ Image Guard is a Node script that uses [sharp](https://www.npmjs.com/package/sha
146
150
 
147
151
  Automated compression works by monitoring whether a given [change list](https://webglossary.info/terms/change-list/) includes any PNGs, JPGs, GIFs, WebPs, or AVIFs (and HEIC/HEIF when opted in). It’s initiated by a Git hook. Only those images are compressed where there is an improvement. HEIC conversion uses [heic-decode](https://www.npmjs.com/package/heic-decode) for patent-free decoding and sharp for lossy AVIF encoding (quality 80). The processed images can then be committed to the underlying repository.
148
152
 
153
+ Compression removes metadata, except for what affects how an image looks: its orientation, its color profile (unless that’s sRGB, which browsers assume anyway), CMYK color, and the bit depth of AVIF images.
154
+
155
+ PNG images with more than 256 colors are reduced to 256 colors (with dithering) where that makes them smaller and a comparison with the original shows no visible change. Other PNG images, like most photos, grayscale and 16-bit images, images with smooth gradients or small elements in rare colors, and images over 40 megapixels, are compressed losslessly, keeping their bit depth.
156
+
157
+ Animated PNGs (APNG) are skipped, as sharp would keep only their first frame.
158
+
149
159
  Through this approach, though glossed over here, Image Guard makes up for what’s missing or complicated in other packages, namely easy, near-riskless, automatable, resource-friendly in-repo optimization.
150
160
 
151
161
  ### Record of Processed Images
@@ -158,7 +168,7 @@ The record is kept in node_modules/.cache/image-guard of the project being proce
158
168
 
159
169
  You use Image Guard when you need a simple, automatable, robust solution to compress images in a way that limits unnecessary image payload right from the start, in your repositories, and that reduces the risk that entirely uncompressed images go into production.
160
170
 
161
- As Image Guard compresses near-losslessly, there’s little risk of quality issues from compression. (Lossless compression is not possible for every image format, however, so there’s a risk when excessively iterating over the same images. Doing so may eventually degrade quality. The [record of processed images](#record-of-processed-images) prevents this, unless it’s bypassed or cleared.)
171
+ As Image Guard compresses near-losslessly, there’s little risk of quality issues from compression. Repeat runs don’t degrade quality further, even when the [record of processed images](#record-of-processed-images) is bypassed or cleared.
162
172
 
163
173
  ## What Does Image Guard _Not_ Do?
164
174
 
@@ -12,4 +12,13 @@ if ! command -v node > /dev/null 2>&1; then
12
12
  echo "Image Guard: Node not found. Install Node.js via nvm, Volta, asdf, mise, or Homebrew." >&2
13
13
  exit 1
14
14
  fi
15
- exec node "$(dirname "$0")/../image-guard/bin/image-guard.js" "$@"
15
+ # Follows symlinks (as from node_modules/.bin or a global bin folder) to this script’s own folder
16
+ self="$0"
17
+ while [ -L "$self" ]; do
18
+ target="$(readlink "$self")"
19
+ case "$target" in
20
+ /*) self="$target" ;;
21
+ *) self="$(dirname "$self")/$target" ;;
22
+ esac
23
+ done
24
+ exec node "$(dirname "$self")/image-guard.js" "$@"
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "heic-decode": "^2.1.0",
10
- "ignore": "^7.0.12",
10
+ "ignore": "^7.0.14",
11
11
  "sharp": "^0.35.5",
12
12
  "simple-git": "^4.0.2",
13
13
  "tinyglobby": "^0.2.17"
@@ -15,7 +15,7 @@
15
15
  "description": "Near-lossless image compressor that’s easy to use and easy to automate",
16
16
  "devDependencies": {
17
17
  "@eslint/js": "^10.0.1",
18
- "eslint": "^10.12.0",
18
+ "eslint": "^10.13.0",
19
19
  "globals": "^17.13.0"
20
20
  },
21
21
  "engines": {
@@ -60,5 +60,5 @@
60
60
  "test": "node --test bin/image-guard.test.js benchmark/benchmark.test.js"
61
61
  },
62
62
  "type": "module",
63
- "version": "5.5.0"
63
+ "version": "5.6.0"
64
64
  }
package/src/index.js CHANGED
@@ -175,18 +175,25 @@ Options:
175
175
  }
176
176
  const limit = createLimiter(desiredFileConcurrency)
177
177
 
178
- const processResults = (results) => {
178
+ // Paths written or removed, by the image they came from
179
+ const changesBySource = new Map()
180
+
181
+ const processResults = (results, files) => {
179
182
  let hadFailures = false
180
183
  let addedKB = 0
181
- for (const r of results) {
184
+ results.forEach((r, i) => {
182
185
  if (r.status === 'fulfilled') {
183
- addedKB += r.value
186
+ addedKB += r.value.saved
187
+ if (r.value.changed.length) changesBySource.set(files[i], r.value.changed)
184
188
  } else {
185
189
  hadFailures = true
186
- const reason = r.reason && r.reason.message ? r.reason.message : String(r.reason)
187
- console.error(styleStderr('red', 'Task failed:'), reason)
190
+ if (r.reason?.cause) {
191
+ console.error(styleStderr('red', `${r.reason.message}:`), r.reason.cause)
192
+ } else {
193
+ console.error(styleStderr('red', 'Task failed:'), r.reason?.message ?? String(r.reason))
194
+ }
188
195
  }
189
- }
196
+ })
190
197
  return { hadFailures, addedKB }
191
198
  }
192
199
 
@@ -196,7 +203,7 @@ Options:
196
203
  const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet, cache)))
197
204
  const results = await Promise.allSettled(tasks)
198
205
 
199
- const { hadFailures, addedKB } = processResults(results)
206
+ const { hadFailures, addedKB } = processResults(results, files)
200
207
  savedKB += addedKB
201
208
  return hadFailures
202
209
  }
@@ -207,7 +214,7 @@ Options:
207
214
  const tasks = files.map(file => limit(() => utils.conversion(file, dry, keepOriginal, argv.quiet)))
208
215
  const results = await Promise.allSettled(tasks)
209
216
 
210
- const { hadFailures, addedKB } = processResults(results)
217
+ const { hadFailures, addedKB } = processResults(results, files)
211
218
  savedKB += addedKB
212
219
  return hadFailures
213
220
  }
@@ -275,10 +282,14 @@ Options:
275
282
  let totalFiles = 0
276
283
 
277
284
  if (argv.staged) {
278
- const git = simpleGit()
279
285
  try {
280
- const diffOutput = await git.raw(['diff', '--name-only', '--cached', '--diff-filter=ACMRT'])
281
- const stagedFiles = diffOutput.split('\n').map(s => s.trim()).filter(Boolean)
286
+ // Git reports paths relative to the repository root
287
+ process.chdir((await simpleGit().revparse(['--show-toplevel'])).trim())
288
+ const git = simpleGit()
289
+ // `-z` keeps Git from quoting names with non-ASCII characters
290
+ const listFiles = async (args) => (await git.raw(['diff', '--name-only', '-z', ...args])).split('\0').filter(Boolean)
291
+ const stagedFiles = await listFiles(['--cached', '--diff-filter=ACMRT'])
292
+ const unstagedFiles = new Set(await listFiles([]))
282
293
 
283
294
  // Collect compression and conversion candidates in parallel
284
295
  const [compressionFiles, conversionFiles] = await Promise.all([
@@ -293,6 +304,21 @@ Options:
293
304
  convert(conversionFiles, argv.dry, argv['keep-heic'])
294
305
  ])
295
306
  hadFailures = compFailed || convFailed
307
+
308
+ // Stages the results so the commit includes them, except where that would
309
+ // also stage changes the user left unstaged
310
+ const pathsToStage = []
311
+ for (const [file, changed] of changesBySource) {
312
+ if (unstagedFiles.has(file)) {
313
+ console.warn(styleStderr('yellow', `Didn’t stage ${changed.join(', ')}, as ${file} has unstaged changes`))
314
+ } else {
315
+ pathsToStage.push(...changed)
316
+ }
317
+ }
318
+ // Batched to stay within command-line length limits
319
+ for (let i = 0; i < pathsToStage.length; i += 100) {
320
+ await git.raw(['add', '--all', '--', ...pathsToStage.slice(i, i + 100).map(p => `:(literal)${p}`)])
321
+ }
296
322
  } catch (err) {
297
323
  console.error(err)
298
324
  hadFailures = true
package/src/utils.js CHANGED
@@ -8,6 +8,8 @@ import { styleText } from 'node:util'
8
8
  import decode from 'heic-decode'
9
9
 
10
10
  const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
11
+ // Limits palette reduction, as checking it holds two decoded copies of the image (320 MB at the limit)
12
+ const MAX_PIXELS_QUANTIZED = 40 * 1000 * 1000 // 40 megapixels
11
13
 
12
14
  // Compression configuration for each format (as sharp names them)
13
15
  const formatConfigs = {
@@ -16,8 +18,13 @@ const formatConfigs = {
16
18
  settings: { quality: 100 }
17
19
  },
18
20
  png: {
19
- options: { pages: -1 },
20
- settings: { animated: true, compressionLevel: 9, quality: 100 } // Still waiting for APNG support though (`animated` doesn’t seem to have an effect), https://github.com/lovell/sharp/issues/2375
21
+ options: {},
22
+ settings: { compressionLevel: 9 },
23
+ // Lossless for images that already use a palette
24
+ settingsPalette: { palette: true, quality: 100, dither: 0, effort: 10 },
25
+ // Reduces other images to 256 colors, kept only where that doesn’t visibly change them
26
+ // (effort 7 being as small and as close to the original as 10, in two-thirds of the time)
27
+ settingsQuantized: { palette: true, quality: 100, effort: 7 }
21
28
  },
22
29
  gif: {
23
30
  options: { pages: -1 },
@@ -40,10 +47,114 @@ const formatConfigs = {
40
47
  }
41
48
  }
42
49
 
50
+ // Checks the profile description, as sRGB profiles only add bytes (sRGB being
51
+ // what images without a profile are taken to be)
52
+ const isSrgbProfile = (icc) => {
53
+ try {
54
+ const count = icc.readUInt32BE(128)
55
+ for (let i = 0; i < count; i++) {
56
+ const entry = 132 + i * 12
57
+ if (icc.toString('latin1', entry, entry + 4) !== 'desc') continue
58
+ const offset = icc.readUInt32BE(entry + 4)
59
+ // Removing null bytes covers both ASCII and UTF-16 descriptions
60
+ return icc.toString('latin1', offset, offset + icc.readUInt32BE(entry + 8)).replaceAll('\0', '').includes('sRGB')
61
+ }
62
+ } catch {
63
+ // Malformed profiles are kept
64
+ }
65
+ return false
66
+ }
67
+
68
+ // Keeps what affects how the image looks—orientation, color profile, CMYK, grayscale, and
69
+ // high bit depth—while dropping all other metadata
70
+ const createPipeline = (input, format, metadata, quantize = false) => {
71
+ const { options, settings, settingsPalette, settingsQuantized } = formatConfigs[format]
72
+ const settingsImage = quantize ? settingsQuantized : metadata.isPalette && settingsPalette ? settingsPalette : settings
73
+ let pipeline = sharp(input, options)
74
+ if (metadata.icc && !isSrgbProfile(metadata.icc)) pipeline = pipeline.keepIccProfile()
75
+ if (metadata.orientation > 1) pipeline = pipeline.withExif({ IFD0: { Orientation: String(metadata.orientation) } })
76
+ if (metadata.space === 'cmyk') pipeline = pipeline.pipelineColourspace('cmyk').toColourspace('cmyk')
77
+ if (format === 'png' && !quantize && (metadata.bitsPerSample > 8 || metadata.space === 'b-w')) {
78
+ return pipeline.toColourspace(metadata.space).toFormat(format, settingsImage)
79
+ }
80
+ if (format === 'avif' && metadata.bitsPerSample > 8) {
81
+ return pipeline.toColourspace(metadata.space).toFormat(format, { ...settingsImage, bitdepth: metadata.bitsPerSample })
82
+ }
83
+ return pipeline.toFormat(format, settingsImage)
84
+ }
85
+
86
+ // Compares lightly blurred pixels, so that dithering, which the eye blends, counts less than
87
+ // shifted colors and blotches—overall, and in every 16×16 area, so that small changes, like
88
+ // icons losing their color, count, too; the limits follow a visual review, in which established
89
+ // metrics (SSIMULACRA2, DSSIM, SSIM) predicted visible changes less well
90
+ const isVisiblySame = async (input, output) => {
91
+ const read = (data) => sharp(data).toColourspace('srgb').ensureAlpha().blur(1).raw().toBuffer({ resolveWithObject: true })
92
+ const [{ data: pixelsInput, info: { width, height } }, { data: pixelsOutput }] = await Promise.all([read(input), read(output)])
93
+ // Counts differences by value, as sorting them would hold up other images for longer
94
+ const histogram = new Uint32Array(256)
95
+ // Sums differences in 8×8 cells, of which 2×2 make up an area
96
+ const widthCells = Math.ceil(width / 8)
97
+ const heightCells = Math.ceil(height / 8)
98
+ const cells = new Float64Array(widthCells * heightCells)
99
+ let sum = 0
100
+ for (let y = 0; y < height; y++) {
101
+ for (let x = 0; x < width; x++) {
102
+ const i = (y * width + x) * 4
103
+ // Weighs colors by opacity, as colors of transparent pixels don’t show
104
+ const alphaInput = pixelsInput[i + 3]
105
+ const alphaOutput = pixelsOutput[i + 3]
106
+ let diff = Math.abs(alphaInput - alphaOutput) * 255
107
+ for (let c = i; c < i + 3; c++) {
108
+ const diffColor = Math.abs(pixelsInput[c] * alphaInput - pixelsOutput[c] * alphaOutput)
109
+ if (diffColor > diff) diff = diffColor
110
+ }
111
+ histogram[(diff / 255) | 0]++
112
+ sum += diff / 255
113
+ cells[(y >> 3) * widthCells + (x >> 3)] += diff / 255
114
+ }
115
+ }
116
+ const count = width * height
117
+ // The difference that 99.9% of pixels stay within
118
+ let seen = 0
119
+ let diffTop = 0
120
+ while ((seen += histogram[diffTop]) <= Math.floor(count * 0.999)) diffTop++
121
+ // The largest average difference of an area, with areas overlapping by half
122
+ let diffArea = 0
123
+ for (let y = 0; y < heightCells - 1; y++) {
124
+ for (let x = 0; x < widthCells - 1; x++) {
125
+ const k = y * widthCells + x
126
+ const diffAverage = (cells[k] + cells[k + 1] + cells[k + widthCells] + cells[k + widthCells + 1]) / 256
127
+ if (diffAverage > diffArea) diffArea = diffAverage
128
+ }
129
+ }
130
+ return sum / count <= 3 && diffTop <= 20 && diffArea <= 5.5
131
+ }
132
+
133
+ // Reduces PNG images to 256 colors where that doesn’t visibly change them—once reduced, they
134
+ // use a palette, which later runs keep losslessly—and compresses all other images as configured
135
+ const encode = async (input, format) => {
136
+ const metadata = await sharp(input).metadata()
137
+ // Leaves out grayscale images, as palettes don’t make 8-bit ones smaller, and 16-bit images,
138
+ // whose precision the comparison can’t see
139
+ if (format === 'png' && !metadata.isPalette && metadata.channels > 1 && !(metadata.bitsPerSample > 8) && metadata.width * metadata.height <= MAX_PIXELS_QUANTIZED) {
140
+ const output = await createPipeline(input, format, metadata, true).toBuffer()
141
+ // Gives way to lossless compression where dithering makes images larger, as in some screenshots
142
+ if (output.length < input.length && await isVisiblySame(input, output)) return output
143
+ }
144
+ return createPipeline(input, format, metadata).toBuffer()
145
+ }
146
+
147
+ // Detects APNG images, of which libvips would keep only the first frame
148
+ const isAnimatedPng = (data) => {
149
+ const actl = data.indexOf('acTL')
150
+ const idat = data.indexOf('IDAT')
151
+ return actl !== -1 && (idat === -1 || actl < idat)
152
+ }
153
+
43
154
  export const hash = (data) => createHash('sha256').update(data).digest('hex')
44
155
 
45
156
  // Identifies what compression results depend on, to tell when recorded results no longer apply
46
- const fingerprint = hash(JSON.stringify({ formatConfigs, versions: sharp.versions })).slice(0, 16)
157
+ const fingerprint = hash(JSON.stringify({ formatConfigs, MAX_PIXELS_QUANTIZED, pipeline: [encode, createPipeline, isSrgbProfile, isVisiblySame].map(String), versions: sharp.versions })).slice(0, 16)
47
158
 
48
159
  // `styleText` detects color on `process.stdout` by default, so anything bound
49
160
  // for STDERR has to name that stream—otherwise a redirected STDERR collects
@@ -71,6 +182,9 @@ const retryFileOperation = async (operation, maxRetries = 5, delayMs = 100) => {
71
182
  }
72
183
  }
73
184
 
185
+ // Bytes saved, and the paths written or removed (for staging)
186
+ const noChange = () => ({ saved: 0, changed: [] })
187
+
74
188
  const compression = async (filename, dry, quiet = false, cache) => {
75
189
  const filenameBackup = `${filename}.bak`
76
190
  const fileSizeBefore = await size(filename)
@@ -79,12 +193,12 @@ const compression = async (filename, dry, quiet = false, cache) => {
79
193
 
80
194
  if (fileSizeBefore === 0) {
81
195
  logMessage(`Skipped ${filename} (${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
82
- return 0
196
+ return noChange()
83
197
  }
84
198
 
85
199
  if (fileSizeBefore > MAX_FILE_SIZE) {
86
200
  logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
87
- return 0
201
+ return noChange()
88
202
  }
89
203
 
90
204
  // Place temp file next to the original to maximize same-device atomic rename
@@ -103,8 +217,7 @@ const compression = async (filename, dry, quiet = false, cache) => {
103
217
  }
104
218
 
105
219
  const outputFormat = ext === 'jpg' ? 'jpeg' : ext // sharp uses “jpeg” instead of “jpg”
106
- const config = formatConfigs[outputFormat]
107
- if (!config) {
220
+ if (!formatConfigs[outputFormat]) {
108
221
  throw new Error(`Unsupported file type for ${filename}`)
109
222
  }
110
223
 
@@ -115,12 +228,15 @@ const compression = async (filename, dry, quiet = false, cache) => {
115
228
  const entryInput = cache ? toEntry(input) : undefined
116
229
  if (cache?.has(entryInput)) {
117
230
  logMessage(`Skipped ${filename} (already processed)`, dry, 'white', quiet)
118
- return 0
231
+ return noChange()
119
232
  }
120
233
 
121
- const output = await sharp(input, config.options)
122
- .toFormat(outputFormat, config.settings)
123
- .toBuffer()
234
+ if (outputFormat === 'png' && isAnimatedPng(input)) {
235
+ logMessage(`Skipped ${filename} (animated PNG)`, dry, 'yellow', quiet)
236
+ return noChange()
237
+ }
238
+
239
+ const output = await encode(input, outputFormat)
124
240
  const fileSizeAfter = output.length
125
241
  if (fileSizeAfter === 0) {
126
242
  throw new Error('Compressed file size is 0')
@@ -166,11 +282,11 @@ const compression = async (filename, dry, quiet = false, cache) => {
166
282
 
167
283
  logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
168
284
 
169
- if (dry) {
170
- return 0
285
+ if (dry || !replacementSucceeded) {
286
+ return noChange()
171
287
  }
172
288
 
173
- return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
289
+ return { saved: fileSizeBefore - fileSizeAfter, changed: [filename] }
174
290
 
175
291
  } catch (err) {
176
292
 
@@ -179,6 +295,7 @@ const compression = async (filename, dry, quiet = false, cache) => {
179
295
  if (msg.includes('corrupt header') ||
180
296
  msg.includes('unexpected end of') ||
181
297
  msg.includes('invalid image') ||
298
+ msg.includes('unsupported image format') ||
182
299
  msg.includes('gifload:') ||
183
300
  msg.includes('pngload:') ||
184
301
  msg.includes('jpegload:') ||
@@ -186,10 +303,9 @@ const compression = async (filename, dry, quiet = false, cache) => {
186
303
  msg.includes('avifload:')
187
304
  ) {
188
305
  logMessage(`Skipped ${filename} (corrupt file)`, dry, 'yellow', quiet)
189
- } else {
190
- console.error(styleStderr('red', `Error compressing ${filename}:`), err)
306
+ return noChange()
191
307
  }
192
- return 0
308
+ throw new Error(`Error compressing ${filename}`, { cause: err })
193
309
 
194
310
  } finally {
195
311
 
@@ -229,17 +345,17 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
229
345
 
230
346
  if (fileSizeBefore === 0) {
231
347
  logMessage(`Skipped ${filename} (${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
232
- return 0
348
+ return noChange()
233
349
  }
234
350
 
235
351
  if (fileSizeBefore > MAX_FILE_SIZE) {
236
352
  logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
237
- return 0
353
+ return noChange()
238
354
  }
239
355
 
240
356
  if (!/\.hei[cf]$/i.test(filename)) {
241
357
  logMessage(`Skipped ${filename} (not a HEIC/HEIF file)`, dry, 'yellow', quiet)
242
- return 0
358
+ return noChange()
243
359
  }
244
360
 
245
361
  const avifPath = filename.replace(/\.hei[cf]$/i, '.avif')
@@ -248,7 +364,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
248
364
  try {
249
365
  await fs.access(avifPath)
250
366
  logMessage(`Skipped ${filename} (${path.basename(avifPath)} already exists)`, dry, 'yellow', quiet)
251
- return 0
367
+ return noChange()
252
368
  } catch {
253
369
  // File does not exist—proceed with conversion
254
370
  }
@@ -270,7 +386,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
270
386
 
271
387
  if (dry) {
272
388
  await retryFileOperation(() => fs.unlink(avifPath))
273
- return 0
389
+ return noChange()
274
390
  }
275
391
 
276
392
  // Delete original HEIC/HEIF file unless `--keep-heic` is set
@@ -278,7 +394,10 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
278
394
  await retryFileOperation(() => fs.unlink(filename))
279
395
  }
280
396
 
281
- return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
397
+ return {
398
+ saved: fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0,
399
+ changed: keepOriginal ? [avifPath] : [avifPath, filename]
400
+ }
282
401
 
283
402
  } catch (err) {
284
403
  // Clean up partial AVIF if it was created
@@ -295,10 +414,9 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
295
414
  err.message.includes('HEIF image not found')
296
415
  )) {
297
416
  logMessage(`Skipped ${filename} (corrupt or unsupported HEIC/HEIF file)`, dry, 'yellow', quiet)
298
- } else {
299
- console.error(styleStderr('red', `Error converting ${filename}:`), err)
417
+ return noChange()
300
418
  }
301
- return 0
419
+ throw new Error(`Error converting ${filename}`, { cause: err })
302
420
  }
303
421
  }
304
422