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 +24 -6
- package/bin/image-guard.sh +10 -1
- package/package.json +3 -3
- package/src/cache.js +97 -0
- package/src/index.js +54 -14
- package/src/utils.js +198 -61
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,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.
|
|
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.
|
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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
283
|
+
logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
|
|
284
|
+
|
|
285
|
+
if (dry || !replacementSucceeded) {
|
|
286
|
+
return noChange()
|
|
152
287
|
}
|
|
153
288
|
|
|
154
|
-
return
|
|
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
|
-
|
|
171
|
-
console.error(styleStderr('red', `Error compressing ${filename}:`), err)
|
|
306
|
+
return noChange()
|
|
172
307
|
}
|
|
173
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
280
|
-
console.error(styleStderr('red', `Error converting ${filename}:`), err)
|
|
417
|
+
return noChange()
|
|
281
418
|
}
|
|
282
|
-
|
|
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 }
|