image-guard 5.4.2 → 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,9 @@ 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.
127
+
128
+ * `--no-cache` processes all images, including those recorded as already processed (see [“Record of Processed Images”](#record-of-processed-images)).
123
129
 
124
130
  * `--help`/`-h` lists the arguments and options.
125
131
 
@@ -144,13 +150,25 @@ Image Guard is a Node script that uses [sharp](https://www.npmjs.com/package/sha
144
150
 
145
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.
146
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
+
147
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.
148
160
 
161
+ ### Record of Processed Images
162
+
163
+ To keep repeat runs fast, Image Guard remembers which images it has already processed (compressed, or found not to shrink any further) by their content, and skips them in later runs. This also protects JPG and GIF images from being re-encoded over and over. An image is processed again as soon as its content changes, and all images are when the compression settings or the underlying image libraries change (as through an update).
164
+
165
+ The record is kept in node_modules/.cache/image-guard of the project being processed, or—where there is no node_modules folder, as is common with `npx`—in the per-user cache folder (~/Library/Caches/image-guard on macOS, %LOCALAPPDATA%\image-guard\Cache on Windows, and ~/.cache/image-guard elsewhere). The `IMAGE_GUARD_CACHE_DIR` environment variable sets a different folder—for example, to persist the record in CI. Deleting the folder, or using `--no-cache`, makes Image Guard process all images again.
166
+
149
167
  ## Why Use Image Guard?
150
168
 
151
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.
152
170
 
153
- 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.)
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.
154
172
 
155
173
  ## What Does Image Guard _Not_ Do?
156
174
 
@@ -168,7 +186,7 @@ To check how a change affects speed, memory, and compression, run the benchmark
168
186
  npm run benchmark -- ~/Projects
169
187
  ```
170
188
 
171
- The benchmark copies a deterministic sample of the images (honoring .gitignore) to a temporary folder, runs Image Guard on fresh copies of it several times, and reports the median time, peak memory, and bytes saved. Your images are not modified.
189
+ The benchmark copies a deterministic sample of the images (honoring .gitignore) to a temporary folder, runs Image Guard on fresh copies of it several times (each without a record of processed images), and reports the median time, peak memory, and bytes saved. Your images are not modified.
172
190
 
173
191
  * `--runs <n>` sets the number of runs (default: 3).
174
192
  * `--sample <n>` sets the approximate sample size (default: 300). Each format is sampled in proportion to its share, with at least 10 files per format where available.
@@ -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.4.2"
63
+ "version": "5.6.0"
64
64
  }
package/src/cache.js ADDED
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { styleStderr } from './utils.js'
5
+
6
+ // Records made with other settings or encoder versions are deleted once unused for this long
7
+ const MAX_AGE_STALE = 30 * 24 * 60 * 60 * 1000
8
+
9
+ // Matches record files only, as the folder may be shared (see `IMAGE_GUARD_CACHE_DIR`)
10
+ const PATTERN_RECORD = /^[0-9a-f]{16}\.txt$/
11
+
12
+ // The nearest project’s node_modules/.cache, else the per-user cache folder
13
+ export function findDirCache(dirStart) {
14
+ if (process.env.IMAGE_GUARD_CACHE_DIR) return path.resolve(process.env.IMAGE_GUARD_CACHE_DIR)
15
+ for (let current = path.resolve(dirStart); ; current = path.dirname(current)) {
16
+ if (fs.existsSync(path.join(current, 'package.json'))) {
17
+ const dirModules = path.join(current, 'node_modules')
18
+ if (fs.existsSync(dirModules)) return path.join(dirModules, '.cache', 'image-guard')
19
+ break
20
+ }
21
+ if (path.dirname(current) === current) break
22
+ }
23
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Caches', 'image-guard')
24
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'image-guard', 'Cache')
25
+ return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'image-guard')
26
+ }
27
+
28
+ // Remembers images that compression can’t shrink any further (as format and
29
+ // content hash); the fingerprint keeps records apart per compression settings
30
+ // and encoder version, so changing either processes all images again
31
+ export function createCache(dirStart, fingerprint) {
32
+ const dirCache = findDirCache(dirStart)
33
+ const fileRecord = path.join(dirCache, `${fingerprint}.txt`)
34
+ const added = []
35
+ let entries = new Set()
36
+ let hits = 0
37
+
38
+ try {
39
+ entries = new Set(fs.readFileSync(fileRecord, 'utf8').split('\n').filter(Boolean))
40
+ } catch (err) {
41
+ if (err.code !== 'ENOENT') console.warn(styleStderr('yellow', `Could not read the record of processed images (${err.message})`))
42
+ }
43
+
44
+ return {
45
+ has(entry) {
46
+ const found = entries.has(entry)
47
+ if (found) hits++
48
+ return found
49
+ },
50
+ add(entry) {
51
+ if (entries.has(entry)) return
52
+ entries.add(entry)
53
+ added.push(entry)
54
+ },
55
+ get hits() {
56
+ return hits
57
+ },
58
+ save() {
59
+ if (added.length === 0) {
60
+ // Keeps runs with other settings from deleting a record that’s still in use
61
+ if (hits > 0) {
62
+ try {
63
+ const now = new Date()
64
+ fs.utimesSync(fileRecord, now, now)
65
+ } catch {
66
+ // Best-effort—at worst, the images are processed once more
67
+ }
68
+ }
69
+ return
70
+ }
71
+ try {
72
+ fs.mkdirSync(dirCache, { recursive: true })
73
+ // Appending (rather than rewriting) keeps what concurrent runs add
74
+ fs.appendFileSync(fileRecord, added.map(entry => `${entry}\n`).join(''))
75
+ } catch (err) {
76
+ console.warn(styleStderr('yellow', `Could not save the record of processed images (${err.message})`))
77
+ return
78
+ }
79
+ let names = []
80
+ try {
81
+ names = fs.readdirSync(dirCache)
82
+ } catch {
83
+ // Nothing to clean up if the folder can’t be read
84
+ }
85
+ for (const name of names) {
86
+ const file = path.join(dirCache, name)
87
+ if (file === fileRecord || !PATTERN_RECORD.test(name)) continue
88
+ try {
89
+ if (Date.now() - fs.statSync(file).mtimeMs > MAX_AGE_STALE) fs.rmSync(file)
90
+ } catch (err) {
91
+ // Another run may have deleted it already
92
+ if (err.code !== 'ENOENT') console.warn(styleStderr('yellow', `Could not delete outdated record ${file} (${err.message})`))
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import fsSync from 'node:fs'
7
7
  import os from 'node:os'
8
8
  import path from 'node:path'
9
9
  import sharp from 'sharp'
10
+ import { createCache } from './cache.js'
10
11
  import { createGitignoreFilter } from './gitignore.js'
11
12
  import { styleStderr, utils } from './utils.js'
12
13
 
@@ -31,6 +32,7 @@ export async function runImageGuard() {
31
32
  quiet: { type: 'boolean', short: 'q', default: false },
32
33
  dry: { type: 'boolean', short: 'd', default: false },
33
34
  staged: { type: 'boolean', default: false },
35
+ 'no-cache': { type: 'boolean', default: false },
34
36
  help: { type: 'boolean', short: 'h', default: false },
35
37
  version: { type: 'boolean', short: 'V', default: false }
36
38
  }
@@ -59,6 +61,7 @@ Options:
59
61
  -q, --quiet Print only the final summary
60
62
  -d, --dry Show what would change without writing any files
61
63
  --staged Process only images staged in Git (not combinable with a directory)
64
+ --no-cache Process images even if recorded as already processed
62
65
  -h, --help Show this help
63
66
  -V, --version Show the version number`)
64
67
  return
@@ -89,11 +92,20 @@ Options:
89
92
  throw setupError(`Not a directory: ${dir}`)
90
93
  }
91
94
 
95
+ // Remembers images that can’t be compressed further, so later runs can skip them
96
+ const cache = argv['no-cache'] ? undefined : createCache(argv.staged ? '.' : dir, utils.fingerprint)
97
+
98
+ const noteCached = () => {
99
+ const hits = cache?.hits ?? 0
100
+ if (hits === 0) return ''
101
+ return hits === 1 ? ' 1 image was skipped as already processed.' : ` ${hits} images were skipped as already processed.`
102
+ }
103
+
92
104
  // Share status
93
105
  const summary = (run, includesConversion = false) => {
94
106
  if (run) {
95
107
  const action = includesConversion ? 'compression and conversion' : 'compression'
96
- console.info(styleText(['bold'], `\nDefensive base ${action} completed. You saved ${utils.sizeReadable(savedKB)}.`))
108
+ console.info(styleText(['bold'], `\nDefensive base ${action} completed. You saved ${utils.sizeReadable(savedKB)}.${noteCached()}`))
97
109
  } else {
98
110
  const what = includesConversion ? 'images to compress or convert' : 'images to compress'
99
111
  console.info(styleText(['bold'], `There were no ${what}.`))
@@ -163,28 +175,35 @@ Options:
163
175
  }
164
176
  const limit = createLimiter(desiredFileConcurrency)
165
177
 
166
- 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) => {
167
182
  let hadFailures = false
168
183
  let addedKB = 0
169
- for (const r of results) {
184
+ results.forEach((r, i) => {
170
185
  if (r.status === 'fulfilled') {
171
- addedKB += r.value
186
+ addedKB += r.value.saved
187
+ if (r.value.changed.length) changesBySource.set(files[i], r.value.changed)
172
188
  } else {
173
189
  hadFailures = true
174
- const reason = r.reason && r.reason.message ? r.reason.message : String(r.reason)
175
- 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
+ }
176
195
  }
177
- }
196
+ })
178
197
  return { hadFailures, addedKB }
179
198
  }
180
199
 
181
200
  const compress = async (files, dry) => {
182
201
  if (files.length === 0) return false
183
202
 
184
- const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet)))
203
+ const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet, cache)))
185
204
  const results = await Promise.allSettled(tasks)
186
205
 
187
- const { hadFailures, addedKB } = processResults(results)
206
+ const { hadFailures, addedKB } = processResults(results, files)
188
207
  savedKB += addedKB
189
208
  return hadFailures
190
209
  }
@@ -195,7 +214,7 @@ Options:
195
214
  const tasks = files.map(file => limit(() => utils.conversion(file, dry, keepOriginal, argv.quiet)))
196
215
  const results = await Promise.allSettled(tasks)
197
216
 
198
- const { hadFailures, addedKB } = processResults(results)
217
+ const { hadFailures, addedKB } = processResults(results, files)
199
218
  savedKB += addedKB
200
219
  return hadFailures
201
220
  }
@@ -263,10 +282,14 @@ Options:
263
282
  let totalFiles = 0
264
283
 
265
284
  if (argv.staged) {
266
- const git = simpleGit()
267
285
  try {
268
- const diffOutput = await git.raw(['diff', '--name-only', '--cached', '--diff-filter=ACMRT'])
269
- 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([]))
270
293
 
271
294
  // Collect compression and conversion candidates in parallel
272
295
  const [compressionFiles, conversionFiles] = await Promise.all([
@@ -281,6 +304,21 @@ Options:
281
304
  convert(conversionFiles, argv.dry, argv['keep-heic'])
282
305
  ])
283
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
+ }
284
322
  } catch (err) {
285
323
  console.error(err)
286
324
  hadFailures = true
@@ -309,11 +347,13 @@ Options:
309
347
  }
310
348
  }
311
349
 
350
+ if (!argv.dry) cache?.save()
351
+
312
352
  if (hadFailures) {
313
353
  process.exitCode = 1
314
354
  if (totalFiles > 0) {
315
355
  const action = doConversion ? 'compression and conversion' : 'compression'
316
- const savings = savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''
356
+ const savings = `${savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''}${noteCached()}`
317
357
  console.info(styleText(['bold'], `\nDefensive base ${action} partially completed (some tasks failed).${savings}`))
318
358
  } else {
319
359
  summary(false, doConversion)
package/src/utils.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // This file, which had been forked from imagemin-merlin, was modified for image-guard: https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master
2
2
 
3
+ import { createHash } from 'node:crypto'
3
4
  import { promises as fs } from 'node:fs'
4
5
  import path from 'node:path'
5
6
  import sharp from 'sharp'
@@ -7,6 +8,153 @@ import { styleText } from 'node:util'
7
8
  import decode from 'heic-decode'
8
9
 
9
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
13
+
14
+ // Compression configuration for each format (as sharp names them)
15
+ const formatConfigs = {
16
+ jpeg: {
17
+ options: {},
18
+ settings: { quality: 100 }
19
+ },
20
+ png: {
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 }
28
+ },
29
+ gif: {
30
+ options: { pages: -1 },
31
+ settings: {
32
+ reuse: true, // Preserve original palette for lossless quality (default)
33
+ effort: 10, // Maximum compression effort
34
+ dither: 0, // No dithering = lossless quality
35
+ interFrameMaxError: 0, // No transparency errors = lossless (default)
36
+ interPaletteMaxError: 0, // Perfect palette match = lossless
37
+ colors: 256 // Full palette available (default)
38
+ }
39
+ },
40
+ webp: {
41
+ options: { pages: -1 },
42
+ settings: { animated: true, lossless: true }
43
+ },
44
+ avif: {
45
+ options: {},
46
+ settings: { effort: 5, lossless: true } // Temporarily specifying effort, too, as per https://github.com/lovell/sharp/issues/4370#issuecomment-2798848572
47
+ }
48
+ }
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
+
154
+ export const hash = (data) => createHash('sha256').update(data).digest('hex')
155
+
156
+ // Identifies what compression results depend on, to tell when recorded results no longer apply
157
+ const fingerprint = hash(JSON.stringify({ formatConfigs, MAX_PIXELS_QUANTIZED, pipeline: [encode, createPipeline, isSrgbProfile, isVisiblySame].map(String), versions: sharp.versions })).slice(0, 16)
10
158
 
11
159
  // `styleText` detects color on `process.stdout` by default, so anything bound
12
160
  // for STDERR has to name that stream—otherwise a redirected STDERR collects
@@ -34,7 +182,10 @@ const retryFileOperation = async (operation, maxRetries = 5, delayMs = 100) => {
34
182
  }
35
183
  }
36
184
 
37
- const compression = async (filename, dry, quiet = false) => {
185
+ // Bytes saved, and the paths written or removed (for staging)
186
+ const noChange = () => ({ saved: 0, changed: [] })
187
+
188
+ const compression = async (filename, dry, quiet = false, cache) => {
38
189
  const filenameBackup = `${filename}.bak`
39
190
  const fileSizeBefore = await size(filename)
40
191
  // Track whether original file was successfully replaced
@@ -42,12 +193,12 @@ const compression = async (filename, dry, quiet = false) => {
42
193
 
43
194
  if (fileSizeBefore === 0) {
44
195
  logMessage(`Skipped ${filename} (${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
45
- return 0
196
+ return noChange()
46
197
  }
47
198
 
48
199
  if (fileSizeBefore > MAX_FILE_SIZE) {
49
200
  logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
50
- return 0
201
+ return noChange()
51
202
  }
52
203
 
53
204
  // Place temp file next to the original to maximize same-device atomic rename
@@ -66,48 +217,30 @@ const compression = async (filename, dry, quiet = false) => {
66
217
  }
67
218
 
68
219
  const outputFormat = ext === 'jpg' ? 'jpeg' : ext // sharp uses “jpeg” instead of “jpg”
220
+ if (!formatConfigs[outputFormat]) {
221
+ throw new Error(`Unsupported file type for ${filename}`)
222
+ }
69
223
 
70
- // Compression configuration for each format
71
- const formatConfigs = {
72
- png: {
73
- options: { pages: -1 },
74
- 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
75
- },
76
- gif: {
77
- options: { pages: -1 },
78
- settings: {
79
- reuse: true, // Preserve original palette for lossless quality (default)
80
- effort: 10, // Maximum compression effort
81
- dither: 0, // No dithering = lossless quality
82
- interFrameMaxError: 0, // No transparency errors = lossless (default)
83
- interPaletteMaxError: 0, // Perfect palette match = lossless
84
- colors: 256 // Full palette available (default)
85
- }
86
- },
87
- webp: {
88
- options: { pages: -1 },
89
- settings: { animated: true, lossless: true }
90
- },
91
- avif: {
92
- options: {},
93
- settings: { effort: 5, lossless: true } // Temporarily specifying effort, too, as per https://github.com/lovell/sharp/issues/4370#issuecomment-2798848572
94
- }
224
+ // The format follows the extension
225
+ const toEntry = (data) => `${outputFormat}:${hash(data)}`
226
+
227
+ const input = await fs.readFile(filename)
228
+ const entryInput = cache ? toEntry(input) : undefined
229
+ if (cache?.has(entryInput)) {
230
+ logMessage(`Skipped ${filename} (already processed)`, dry, 'white', quiet)
231
+ return noChange()
95
232
  }
96
233
 
97
- // Apply format-specific compression or use default
98
- const config = formatConfigs[outputFormat]
99
- if (config) {
100
- await sharp(filename, config.options)
101
- .toFormat(outputFormat, config.settings)
102
- .toFile(tempFilePath)
103
- } else {
104
- // Fallback for any other supported formats (like JPG)
105
- await sharp(filename)
106
- .toFormat(outputFormat, { quality: 100 })
107
- .toFile(tempFilePath)
234
+ if (outputFormat === 'png' && isAnimatedPng(input)) {
235
+ logMessage(`Skipped ${filename} (animated PNG)`, dry, 'yellow', quiet)
236
+ return noChange()
108
237
  }
109
238
 
110
- const fileSizeAfter = await size(tempFilePath)
239
+ const output = await encode(input, outputFormat)
240
+ const fileSizeAfter = output.length
241
+ if (fileSizeAfter === 0) {
242
+ throw new Error('Compressed file size is 0')
243
+ }
111
244
 
112
245
  let color = 'white'
113
246
  let status = 'Skipped'
@@ -120,6 +253,7 @@ const compression = async (filename, dry, quiet = false) => {
120
253
  if (!dry) {
121
254
  // Only now create a backup and replace the original
122
255
  await retryFileOperation(() => fs.copyFile(filename, filenameBackup))
256
+ await fs.writeFile(tempFilePath, output)
123
257
  // Prefer atomic rename when possible
124
258
  try {
125
259
  await retryFileOperation(() => fs.rename(tempFilePath, filename))
@@ -141,17 +275,18 @@ const compression = async (filename, dry, quiet = false) => {
141
275
  details = 'already compressed more effectively'
142
276
  }
143
277
 
144
- logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
145
-
146
- if (dry) {
147
- return 0 // Temp file cleaned up in finally
278
+ // Records the file as it now is, so compressed results aren’t re-encoded again either
279
+ if (!dry) {
280
+ cache?.add(fileSizeAfter < fileSizeBefore ? toEntry(output) : entryInput)
148
281
  }
149
282
 
150
- if (fileSizeAfter === 0) {
151
- console.error(styleStderr('red', `Error compressing ${filename}: Compressed file size is 0`))
283
+ logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
284
+
285
+ if (dry || !replacementSucceeded) {
286
+ return noChange()
152
287
  }
153
288
 
154
- return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
289
+ return { saved: fileSizeBefore - fileSizeAfter, changed: [filename] }
155
290
 
156
291
  } catch (err) {
157
292
 
@@ -160,6 +295,7 @@ const compression = async (filename, dry, quiet = false) => {
160
295
  if (msg.includes('corrupt header') ||
161
296
  msg.includes('unexpected end of') ||
162
297
  msg.includes('invalid image') ||
298
+ msg.includes('unsupported image format') ||
163
299
  msg.includes('gifload:') ||
164
300
  msg.includes('pngload:') ||
165
301
  msg.includes('jpegload:') ||
@@ -167,14 +303,13 @@ const compression = async (filename, dry, quiet = false) => {
167
303
  msg.includes('avifload:')
168
304
  ) {
169
305
  logMessage(`Skipped ${filename} (corrupt file)`, dry, 'yellow', quiet)
170
- } else {
171
- console.error(styleStderr('red', `Error compressing ${filename}:`), err)
306
+ return noChange()
172
307
  }
173
- return 0
308
+ throw new Error(`Error compressing ${filename}`, { cause: err })
174
309
 
175
310
  } finally {
176
311
 
177
- // Clean up temp file if it wasn’t consumed (covers dry-run, error, and no-improvement paths)
312
+ // Clean up temp file if it wasn’t consumed (covers error paths)
178
313
  if (!tempConsumed) {
179
314
  try {
180
315
  await retryFileOperation(() => fs.unlink(tempFilePath))
@@ -210,17 +345,17 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
210
345
 
211
346
  if (fileSizeBefore === 0) {
212
347
  logMessage(`Skipped ${filename} (${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
213
- return 0
348
+ return noChange()
214
349
  }
215
350
 
216
351
  if (fileSizeBefore > MAX_FILE_SIZE) {
217
352
  logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
218
- return 0
353
+ return noChange()
219
354
  }
220
355
 
221
356
  if (!/\.hei[cf]$/i.test(filename)) {
222
357
  logMessage(`Skipped ${filename} (not a HEIC/HEIF file)`, dry, 'yellow', quiet)
223
- return 0
358
+ return noChange()
224
359
  }
225
360
 
226
361
  const avifPath = filename.replace(/\.hei[cf]$/i, '.avif')
@@ -229,7 +364,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
229
364
  try {
230
365
  await fs.access(avifPath)
231
366
  logMessage(`Skipped ${filename} (${path.basename(avifPath)} already exists)`, dry, 'yellow', quiet)
232
- return 0
367
+ return noChange()
233
368
  } catch {
234
369
  // File does not exist—proceed with conversion
235
370
  }
@@ -251,7 +386,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
251
386
 
252
387
  if (dry) {
253
388
  await retryFileOperation(() => fs.unlink(avifPath))
254
- return 0
389
+ return noChange()
255
390
  }
256
391
 
257
392
  // Delete original HEIC/HEIF file unless `--keep-heic` is set
@@ -259,7 +394,10 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
259
394
  await retryFileOperation(() => fs.unlink(filename))
260
395
  }
261
396
 
262
- return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
397
+ return {
398
+ saved: fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0,
399
+ changed: keepOriginal ? [avifPath] : [avifPath, filename]
400
+ }
263
401
 
264
402
  } catch (err) {
265
403
  // Clean up partial AVIF if it was created
@@ -276,10 +414,9 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
276
414
  err.message.includes('HEIF image not found')
277
415
  )) {
278
416
  logMessage(`Skipped ${filename} (corrupt or unsupported HEIC/HEIF file)`, dry, 'yellow', quiet)
279
- } else {
280
- console.error(styleStderr('red', `Error converting ${filename}:`), err)
417
+ return noChange()
281
418
  }
282
- return 0
419
+ throw new Error(`Error converting ${filename}`, { cause: err })
283
420
  }
284
421
  }
285
422
 
@@ -290,4 +427,4 @@ const size = async (file) => {
290
427
 
291
428
  const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB` // eslint-disable-line no-irregular-whitespace
292
429
 
293
- export const utils = { compression, conversion, sizeReadable }
430
+ export const utils = { compression, conversion, sizeReadable, fingerprint }