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 +25 -4
- package/package.json +12 -7
- package/src/cache.js +97 -0
- package/src/gitignore.js +50 -0
- package/src/index.js +35 -13
- package/src/utils.js +65 -46
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
12
|
-
"
|
|
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.
|
|
18
|
-
"globals": "^17.
|
|
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.
|
|
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
|
+
}
|
package/src/gitignore.js
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
151
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
|
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 }
|