image-guard 5.4.1 → 5.5.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
@@ -121,6 +121,8 @@ Image Guard takes an optional path to the directory to process—`npx image-guar
121
121
 
122
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.
123
123
 
124
+ * `--no-cache` processes all images, including those recorded as already processed (see [“Record of Processed Images”](#record-of-processed-images)).
125
+
124
126
  * `--help`/`-h` lists the arguments and options.
125
127
 
126
128
  * `--version`/`-V` prints the version number.
@@ -146,11 +148,17 @@ Automated compression works by monitoring whether a given [change list](https://
146
148
 
147
149
  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
150
 
151
+ ### Record of Processed Images
152
+
153
+ 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).
154
+
155
+ 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.
156
+
149
157
  ## Why Use Image Guard?
150
158
 
151
159
  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
160
 
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.)
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.)
154
162
 
155
163
  ## What Does Image Guard _Not_ Do?
156
164
 
@@ -158,11 +166,24 @@ Image Guard is no substitute for image fine-tuning and micro-optimization. That
158
166
 
159
167
  That is, micro-optimization still needs to be taken care of through other means, whether manually or through tools. Image Guard just solves the problem that images are checked in or go live that are not compressed _at all_.
160
168
 
161
- ## What’s Next?
169
+ ## Working on Image Guard
170
+
171
+ ### Benchmarking
172
+
173
+ To check how a change affects speed, memory, and compression, run the benchmark on any local folder with images:
174
+
175
+ ```shell
176
+ npm run benchmark -- ~/Projects
177
+ ```
178
+
179
+ 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.
162
180
 
163
- There are a few ideas, like adding light SVG support, or ensuring compatibility with projects in which the project’s .git folder is not at the same level as its package.json (currently, automatic mode doesn’t work in these cases).
181
+ * `--runs <n>` sets the number of runs (default: 3).
182
+ * `--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.
183
+ * `--save` saves the result as the baseline (benchmark/baseline.json, ignored by Git), which later runs on the same sample compare against.
184
+ * `--profile` additionally compresses each image on its own, in dry mode, and reports the time per format and the slowest files.
164
185
 
165
- Feedback is appreciated: Please [file an issue](https://github.com/j9t/image-guard/issues) or send a pull request. Thank you!
186
+ To compare a branch with `main`, run the benchmark with `--save` on `main`, then without it on the branch. Differences of around 10% can be noise; use more runs, and repeat a run on `main`, before relying on smaller ones.
166
187
 
167
188
  ## License
168
189
 
package/package.json CHANGED
@@ -6,16 +6,20 @@
6
6
  "imagemin-merlin": "bin/image-guard.sh"
7
7
  },
8
8
  "dependencies": {
9
- "globby": "^16.2.4",
10
9
  "heic-decode": "^2.1.0",
11
- "sharp": "^0.35.4",
12
- "simple-git": "^3.36.0"
10
+ "ignore": "^7.0.12",
11
+ "sharp": "^0.35.5",
12
+ "simple-git": "^4.0.2",
13
+ "tinyglobby": "^0.2.17"
13
14
  },
14
15
  "description": "Near-lossless image compressor that’s easy to use and easy to automate",
15
16
  "devDependencies": {
16
17
  "@eslint/js": "^10.0.1",
17
- "eslint": "^10.11.0",
18
- "globals": "^17.12.0"
18
+ "eslint": "^10.12.0",
19
+ "globals": "^17.13.0"
20
+ },
21
+ "engines": {
22
+ "node": ">=22.13"
19
23
  },
20
24
  "files": [
21
25
  "bin/image-guard.js",
@@ -49,11 +53,12 @@
49
53
  "url": "git+https://github.com/j9t/image-guard.git"
50
54
  },
51
55
  "scripts": {
56
+ "benchmark": "node benchmark/benchmark.js",
52
57
  "lint": "eslint .",
53
58
  "lint:fix": "eslint . --fix",
54
59
  "prepare": "git config core.hooksPath .githooks || true",
55
- "test": "node --test bin/image-guard.test.js"
60
+ "test": "node --test bin/image-guard.test.js benchmark/benchmark.test.js"
56
61
  },
57
62
  "type": "module",
58
- "version": "5.4.1"
63
+ "version": "5.5.0"
59
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
+ }
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import ignore from 'ignore'
4
+
5
+ function findGitRoot(dir) {
6
+ for (let current = dir; ; current = path.dirname(current)) {
7
+ if (fs.existsSync(path.join(current, '.git'))) return current
8
+ if (path.dirname(current) === current) return undefined
9
+ }
10
+ }
11
+
12
+ // Returns a predicate for absolute paths under `cwd` that applies .gitignore
13
+ // files from the repository root (or `cwd` outside a repository) down to each
14
+ // path, as Git does: Deeper rules take precedence, and nothing inside an
15
+ // ignored directory can be re-included
16
+ export function createGitignoreFilter(cwd) {
17
+ const base = path.resolve(cwd)
18
+ const top = findGitRoot(base) ?? base
19
+ const rulesByDir = new Map()
20
+
21
+ const getRules = (dir) => {
22
+ if (!rulesByDir.has(dir)) {
23
+ let rules
24
+ try {
25
+ rules = ignore().add(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'))
26
+ } catch (err) {
27
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
28
+ }
29
+ rulesByDir.set(dir, rules)
30
+ }
31
+ return rulesByDir.get(dir)
32
+ }
33
+
34
+ return (file) => {
35
+ const segments = path.relative(top, file).split(path.sep)
36
+ for (let i = 1; i <= segments.length; i++) {
37
+ const suffix = i < segments.length ? '/' : ''
38
+ let ignored = false
39
+ for (let j = 0; j < i; j++) {
40
+ const rules = getRules(path.join(top, ...segments.slice(0, j)))
41
+ if (!rules) continue
42
+ const result = rules.test(segments.slice(j, i).join('/') + suffix)
43
+ if (result.ignored) ignored = true
44
+ else if (result.unignored) ignored = false
45
+ }
46
+ if (ignored) return true
47
+ }
48
+ return false
49
+ }
50
+ }
package/src/index.js CHANGED
@@ -1,12 +1,14 @@
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 { globby, convertPathToPattern } from 'globby'
4
- import simpleGit from 'simple-git'
3
+ import { glob, convertPathToPattern } from 'tinyglobby'
4
+ import { simpleGit } from 'simple-git'
5
5
  import { parseArgs, styleText } from 'node:util'
6
6
  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'
11
+ import { createGitignoreFilter } from './gitignore.js'
10
12
  import { styleStderr, utils } from './utils.js'
11
13
 
12
14
  // Files to be compressed
@@ -30,6 +32,7 @@ export async function runImageGuard() {
30
32
  quiet: { type: 'boolean', short: 'q', default: false },
31
33
  dry: { type: 'boolean', short: 'd', default: false },
32
34
  staged: { type: 'boolean', default: false },
35
+ 'no-cache': { type: 'boolean', default: false },
33
36
  help: { type: 'boolean', short: 'h', default: false },
34
37
  version: { type: 'boolean', short: 'V', default: false }
35
38
  }
@@ -58,6 +61,7 @@ Options:
58
61
  -q, --quiet Print only the final summary
59
62
  -d, --dry Show what would change without writing any files
60
63
  --staged Process only images staged in Git (not combinable with a directory)
64
+ --no-cache Process images even if recorded as already processed
61
65
  -h, --help Show this help
62
66
  -V, --version Show the version number`)
63
67
  return
@@ -88,11 +92,20 @@ Options:
88
92
  throw setupError(`Not a directory: ${dir}`)
89
93
  }
90
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
+
91
104
  // Share status
92
105
  const summary = (run, includesConversion = false) => {
93
106
  if (run) {
94
107
  const action = includesConversion ? 'compression and conversion' : 'compression'
95
- 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()}`))
96
109
  } else {
97
110
  const what = includesConversion ? 'images to compress or convert' : 'images to compress'
98
111
  console.info(styleText(['bold'], `There were no ${what}.`))
@@ -147,8 +160,14 @@ Options:
147
160
  })
148
161
  }
149
162
 
150
- const desiredFileConcurrency = Math.min(os.cpus().length, 4)
151
- const perTaskThreads = Math.max(1, Math.floor(os.cpus().length / Math.max(1, desiredFileConcurrency)))
163
+ // Capped to bound memory, as each file in flight holds its decoded pixels
164
+ const desiredFileConcurrency = Math.min(os.availableParallelism(), 8)
165
+ const perTaskThreads = Math.max(1, Math.floor(os.availableParallelism() / desiredFileConcurrency))
166
+
167
+ // sharp runs on libuv’s thread pool (4 threads by default), which caps files
168
+ // in flight regardless of the limiter; this must happen before the first
169
+ // asynchronous I/O and respects a user setting
170
+ process.env.UV_THREADPOOL_SIZE ??= String(desiredFileConcurrency)
152
171
  try {
153
172
  sharp.concurrency(perTaskThreads)
154
173
  } catch {
@@ -174,7 +193,7 @@ Options:
174
193
  const compress = async (files, dry) => {
175
194
  if (files.length === 0) return false
176
195
 
177
- const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet)))
196
+ const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet, cache)))
178
197
  const results = await Promise.allSettled(tasks)
179
198
 
180
199
  const { hadFailures, addedKB } = processResults(results)
@@ -194,7 +213,7 @@ Options:
194
213
  }
195
214
 
196
215
  // Plain patterns also get a “/**” variant so directories are excluded with
197
- // their contents: “dir/” resolves only where globby can stat it, and a bare
216
+ // their contents: “dir/” resolves only where tinyglobby can stat it, and a bare
198
217
  // “dir” only covers what the walk descends into
199
218
  const getIgnorePatterns = (ignore) => {
200
219
  return (ignore || '')
@@ -223,14 +242,14 @@ Options:
223
242
  // lookup are relative to the searched directory, not to the shell’s—and the
224
243
  // results are rejoined for display and file access
225
244
  const findFiles = async (patterns, options = {}) => {
226
- const files = await globby(patterns, {
245
+ const files = await glob(patterns, {
227
246
  cwd: dir,
228
- gitignore: true,
229
247
  onlyFiles: true,
230
248
  caseSensitiveMatch: false,
231
249
  ...options
232
250
  })
233
- return files.map(file => path.join(dir, file))
251
+ const isGitignored = createGitignoreFilter(dir)
252
+ return files.map(file => path.join(dir, file)).filter(file => !isGitignored(path.resolve(file)))
234
253
  }
235
254
 
236
255
  const filterStagedFiles = async (stagedFiles, types) => {
@@ -240,12 +259,13 @@ Options:
240
259
 
241
260
  if (ignoreList.length > 0) {
242
261
  const escapedPaths = byExt.map(p => convertPathToPattern(p))
243
- return globby([...escapedPaths, ...ignoreList], {
244
- gitignore: true,
262
+ const files = await glob([...escapedPaths, ...ignoreList], {
245
263
  expandDirectories: false,
246
264
  onlyFiles: true,
247
265
  caseSensitiveMatch: false
248
266
  })
267
+ const isGitignored = createGitignoreFilter('.')
268
+ return files.filter(file => !isGitignored(path.resolve(file)))
249
269
  }
250
270
  return byExt
251
271
  }
@@ -301,11 +321,13 @@ Options:
301
321
  }
302
322
  }
303
323
 
324
+ if (!argv.dry) cache?.save()
325
+
304
326
  if (hadFailures) {
305
327
  process.exitCode = 1
306
328
  if (totalFiles > 0) {
307
329
  const action = doConversion ? 'compression and conversion' : 'compression'
308
- const savings = savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''
330
+ const savings = `${savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''}${noteCached()}`
309
331
  console.info(styleText(['bold'], `\nDefensive base ${action} partially completed (some tasks failed).${savings}`))
310
332
  } else {
311
333
  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'
@@ -8,6 +9,42 @@ import decode from 'heic-decode'
8
9
 
9
10
  const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
10
11
 
12
+ // Compression configuration for each format (as sharp names them)
13
+ const formatConfigs = {
14
+ jpeg: {
15
+ options: {},
16
+ settings: { quality: 100 }
17
+ },
18
+ 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
+ },
22
+ gif: {
23
+ options: { pages: -1 },
24
+ settings: {
25
+ reuse: true, // Preserve original palette for lossless quality (default)
26
+ effort: 10, // Maximum compression effort
27
+ dither: 0, // No dithering = lossless quality
28
+ interFrameMaxError: 0, // No transparency errors = lossless (default)
29
+ interPaletteMaxError: 0, // Perfect palette match = lossless
30
+ colors: 256 // Full palette available (default)
31
+ }
32
+ },
33
+ webp: {
34
+ options: { pages: -1 },
35
+ settings: { animated: true, lossless: true }
36
+ },
37
+ avif: {
38
+ options: {},
39
+ settings: { effort: 5, lossless: true } // Temporarily specifying effort, too, as per https://github.com/lovell/sharp/issues/4370#issuecomment-2798848572
40
+ }
41
+ }
42
+
43
+ export const hash = (data) => createHash('sha256').update(data).digest('hex')
44
+
45
+ // 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)
47
+
11
48
  // `styleText` detects color on `process.stdout` by default, so anything bound
12
49
  // for STDERR has to name that stream—otherwise a redirected STDERR collects
13
50
  // escape codes whenever STDOUT happens to be a terminal
@@ -34,7 +71,7 @@ const retryFileOperation = async (operation, maxRetries = 5, delayMs = 100) => {
34
71
  }
35
72
  }
36
73
 
37
- const compression = async (filename, dry, quiet = false) => {
74
+ const compression = async (filename, dry, quiet = false, cache) => {
38
75
  const filenameBackup = `${filename}.bak`
39
76
  const fileSizeBefore = await size(filename)
40
77
  // Track whether original file was successfully replaced
@@ -66,48 +103,28 @@ const compression = async (filename, dry, quiet = false) => {
66
103
  }
67
104
 
68
105
  const outputFormat = ext === 'jpg' ? 'jpeg' : ext // sharp uses “jpeg” instead of “jpg”
69
-
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
- }
106
+ const config = formatConfigs[outputFormat]
107
+ if (!config) {
108
+ throw new Error(`Unsupported file type for ${filename}`)
95
109
  }
96
110
 
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)
111
+ // The format follows the extension
112
+ const toEntry = (data) => `${outputFormat}:${hash(data)}`
113
+
114
+ const input = await fs.readFile(filename)
115
+ const entryInput = cache ? toEntry(input) : undefined
116
+ if (cache?.has(entryInput)) {
117
+ logMessage(`Skipped ${filename} (already processed)`, dry, 'white', quiet)
118
+ return 0
108
119
  }
109
120
 
110
- const fileSizeAfter = await size(tempFilePath)
121
+ const output = await sharp(input, config.options)
122
+ .toFormat(outputFormat, config.settings)
123
+ .toBuffer()
124
+ const fileSizeAfter = output.length
125
+ if (fileSizeAfter === 0) {
126
+ throw new Error('Compressed file size is 0')
127
+ }
111
128
 
112
129
  let color = 'white'
113
130
  let status = 'Skipped'
@@ -120,6 +137,7 @@ const compression = async (filename, dry, quiet = false) => {
120
137
  if (!dry) {
121
138
  // Only now create a backup and replace the original
122
139
  await retryFileOperation(() => fs.copyFile(filename, filenameBackup))
140
+ await fs.writeFile(tempFilePath, output)
123
141
  // Prefer atomic rename when possible
124
142
  try {
125
143
  await retryFileOperation(() => fs.rename(tempFilePath, filename))
@@ -141,14 +159,15 @@ const compression = async (filename, dry, quiet = false) => {
141
159
  details = 'already compressed more effectively'
142
160
  }
143
161
 
162
+ // Records the file as it now is, so compressed results aren’t re-encoded again either
163
+ if (!dry) {
164
+ cache?.add(fileSizeAfter < fileSizeBefore ? toEntry(output) : entryInput)
165
+ }
166
+
144
167
  logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
145
168
 
146
169
  if (dry) {
147
- return 0 // Temp file cleaned up in finally
148
- }
149
-
150
- if (fileSizeAfter === 0) {
151
- console.error(styleStderr('red', `Error compressing ${filename}: Compressed file size is 0`))
170
+ return 0
152
171
  }
153
172
 
154
173
  return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
@@ -174,7 +193,7 @@ const compression = async (filename, dry, quiet = false) => {
174
193
 
175
194
  } finally {
176
195
 
177
- // Clean up temp file if it wasn’t consumed (covers dry-run, error, and no-improvement paths)
196
+ // Clean up temp file if it wasn’t consumed (covers error paths)
178
197
  if (!tempConsumed) {
179
198
  try {
180
199
  await retryFileOperation(() => fs.unlink(tempFilePath))
@@ -290,4 +309,4 @@ const size = async (file) => {
290
309
 
291
310
  const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB` // eslint-disable-line no-irregular-whitespace
292
311
 
293
- export const utils = { compression, conversion, sizeReadable }
312
+ export const utils = { compression, conversion, sizeReadable, fingerprint }