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