image-guard 5.4.2 → 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 +10 -2
- package/package.json +1 -1
- package/src/cache.js +97 -0
- package/src/index.js +17 -3
- 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
|
|
|
@@ -168,7 +176,7 @@ To check how a change affects speed, memory, and compression, run the benchmark
|
|
|
168
176
|
npm run benchmark -- ~/Projects
|
|
169
177
|
```
|
|
170
178
|
|
|
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.
|
|
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.
|
|
172
180
|
|
|
173
181
|
* `--runs <n>` sets the number of runs (default: 3).
|
|
174
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.
|
package/package.json
CHANGED
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}.`))
|
|
@@ -181,7 +193,7 @@ Options:
|
|
|
181
193
|
const compress = async (files, dry) => {
|
|
182
194
|
if (files.length === 0) return false
|
|
183
195
|
|
|
184
|
-
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)))
|
|
185
197
|
const results = await Promise.allSettled(tasks)
|
|
186
198
|
|
|
187
199
|
const { hadFailures, addedKB } = processResults(results)
|
|
@@ -309,11 +321,13 @@ Options:
|
|
|
309
321
|
}
|
|
310
322
|
}
|
|
311
323
|
|
|
324
|
+
if (!argv.dry) cache?.save()
|
|
325
|
+
|
|
312
326
|
if (hadFailures) {
|
|
313
327
|
process.exitCode = 1
|
|
314
328
|
if (totalFiles > 0) {
|
|
315
329
|
const action = doConversion ? 'compression and conversion' : 'compression'
|
|
316
|
-
const savings = savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''
|
|
330
|
+
const savings = `${savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''}${noteCached()}`
|
|
317
331
|
console.info(styleText(['bold'], `\nDefensive base ${action} partially completed (some tasks failed).${savings}`))
|
|
318
332
|
} else {
|
|
319
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 }
|