image-guard 5.1.2 → 5.2.1
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/.github/FUNDING.yml +2 -2
- package/.github/dependabot.yml +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +11 -6
- package/bin/image-guard.test.js +133 -0
- package/package.json +5 -2
- package/src/index.js +119 -76
- package/src/utils.js +82 -1
package/.github/FUNDING.yml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
buy_me_a_coffee: meiert
|
|
1
|
+
# buy_me_a_coffee: meiert
|
|
2
2
|
github: j9t
|
|
3
|
-
liberapay: j9t
|
|
3
|
+
# liberapay: j9t
|
package/.github/dependabot.yml
CHANGED
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Starting with version 5.2.0, all notable changes to Image Guard are documented in this file, which is (mostly) AI-generated and (always) human-edited. Dependency updates may or may not be called out specifically.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [5.2.1] - 2026-02-17
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
* HEIC-to-AVIF conversion now uses lossy encoding (quality 80) instead of lossless, producing files smaller than the HEIC originals
|
|
12
|
+
* Removed redundant lossless compression pass after HEIC conversion
|
|
13
|
+
* Documented Display P3 to sRGB color space mapping during HEIC conversion
|
|
14
|
+
|
|
15
|
+
## [5.2.0] - 2026-02-17
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
* Added opt-in HEIC/HEIF-to-AVIF conversion via `--heic-to-avif` (with `--keep-heic` to preserve originals)
|
|
20
|
+
* Introduced changelog
|
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/image-guard) [](https://github.com/j9t/image-guard/actions) [](https://socket.dev/npm/package/image-guard)
|
|
4
4
|
|
|
5
|
-
(This project was based on [sum.cumo’s imagemin-merlin](https://github.com/sumcumo/imagemin-merlin). [Changes are documented](https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master), and include this README. Image Guard supports
|
|
5
|
+
(This project was based on [sum.cumo’s imagemin-merlin](https://github.com/sumcumo/imagemin-merlin). [Changes are documented](https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master), and include this README. Image Guard supports additional file formats—WebP, AVIF, and HEIC/HEIF—, comes with improved code and documentation, and is being maintained. For this reason, it’s [not based on any Imagemin packages](https://meiert.com/blog/image-guard-4/) anymore.)
|
|
6
6
|
|
|
7
|
-
Image Guard takes care of near-lossless compression of your images, to help you avoid bloat in your repositories. It makes it convenient and as safe as possible to automatically compress PNG, JPG, GIF, WebP, and AVIF images.
|
|
7
|
+
Image Guard takes care of near-lossless compression of your images, to help you avoid bloat in your repositories. It makes it convenient and as safe as possible to automatically compress PNG, JPG, GIF, WebP, and AVIF images. It can also convert HEIC/HEIF files to AVIF (opt-in via `--heic-to-avif`).
|
|
8
8
|
|
|
9
9
|
It’s convenient because setup is simple. Run it right away—done. Or install, run, add hook—done.
|
|
10
10
|
|
|
@@ -48,7 +48,7 @@ Install Image Guard in your project:
|
|
|
48
48
|
npm i -D image-guard
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
To compress images already in the
|
|
51
|
+
To compress images already in the codebase, run Image Guard once by calling
|
|
52
52
|
|
|
53
53
|
```console
|
|
54
54
|
npx image-guard
|
|
@@ -109,7 +109,11 @@ npm pkg set scripts.postprepare="grep -qxF 'npx image-guard --staged' .husky/pre
|
|
|
109
109
|
|
|
110
110
|
* `--ignore` allows you to specify paths to be ignored (as in `--ignore=example,test`). Multiple paths must be separated by commas. The option supports glob patterns (e.g., `assets/**`, `**/*.png`); matching is case‑insensitive and honors `.gitignore`.
|
|
111
111
|
|
|
112
|
-
* `--staged` (recommended with automated use) triggers a mode that watches PNG, JPG, GIF, WebP, and AVIF files in `git diff` and only
|
|
112
|
+
* `--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) in `git diff` and only processes those files—that approach makes Image Guard more efficient in operation.
|
|
113
|
+
|
|
114
|
+
* `--heic-to-avif` converts HEIC/HEIF files to AVIF. Because HEIC sources are already lossy-compressed (HEVC), the conversion uses lossy AVIF encoding (quality 80) to produce files smaller than the originals at near-identical visual quality. Original HEIC/HEIF files are deleted after conversion by default. (Note: The conversion maps Display P3 colors—common on iPhones—to sRGB, which may result in slightly less vivid colors.)
|
|
115
|
+
|
|
116
|
+
- `--keep-heic` (used with `--heic-to-avif`) preserves the original HEIC/HEIF files instead of deleting them after conversion.
|
|
113
117
|
|
|
114
118
|
* `--quiet` suppresses per‑file logs and prints only the final summary (plus errors). This reduces console noise and speeds up runs in CI and Git hooks.
|
|
115
119
|
|
|
@@ -134,6 +138,7 @@ Roughly like this:
|
|
|
134
138
|

|
|
135
139
|
|
|
136
140
|
* Green: The image file has been compressed.
|
|
141
|
+
* Cyan: The image file has been converted (HEIC/HEIF to AVIF).
|
|
137
142
|
* White (light gray): The image file has not been changed.
|
|
138
143
|
* Blue: The image file had already been compressed more aggressively than the new result, and was therefore skipped, too.
|
|
139
144
|
|
|
@@ -143,7 +148,7 @@ Tip: Use `--quiet` to suppress these per‑file lines and keep only the final su
|
|
|
143
148
|
|
|
144
149
|
Image Guard is a Node script that uses [sharp](https://www.npmjs.com/package/sharp) under the hood.
|
|
145
150
|
|
|
146
|
-
Automated compression works by monitoring whether a given [change list](https://webglossary.info/terms/change-list/) includes any PNGs, JPGs, GIFs, WebPs, or AVIFs. It’s initiated by a Git hook. Only those images are compressed where there is an improvement. The
|
|
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.
|
|
147
152
|
|
|
148
153
|
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.
|
|
149
154
|
|
|
@@ -167,7 +172,7 @@ Feedback is appreciated: Please [file an issue](https://github.com/j9t/image-gua
|
|
|
167
172
|
|
|
168
173
|
## License
|
|
169
174
|
|
|
170
|
-
Copyright 2019 [sum.cumo GmbH](https://web.archive.org/web/20191208211414/https://www.sumcumo.com/)
|
|
175
|
+
Copyright 2019 [sum.cumo GmbH](https://web.archive.org/web/20191208211414/https://www.sumcumo.com/)<br>
|
|
171
176
|
Copyright 2022 [Jens Oliver Meiert](https://meiert.com/)
|
|
172
177
|
|
|
173
178
|
Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
package/bin/image-guard.test.js
CHANGED
|
@@ -389,4 +389,137 @@ describe('Image Guard', () => {
|
|
|
389
389
|
// Cleanup
|
|
390
390
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
391
391
|
})
|
|
392
|
+
|
|
393
|
+
test('Convert HEIC to AVIF with `--heic-to-avif`', () => {
|
|
394
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-'))
|
|
395
|
+
const tempTestFolder = path.join(tempDir, 'test')
|
|
396
|
+
copyFiles(testFolder, tempTestFolder)
|
|
397
|
+
|
|
398
|
+
// Ensure HEIC test fixture exists
|
|
399
|
+
const heicFile = path.join(tempTestFolder, 'test.heic')
|
|
400
|
+
assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC test fixture must exist')
|
|
401
|
+
|
|
402
|
+
// Remove pre-existing AVIF so we can verify the conversion actually creates it
|
|
403
|
+
const avifFile = path.join(tempTestFolder, 'test.avif')
|
|
404
|
+
if (fs.existsSync(avifFile)) {
|
|
405
|
+
fs.unlinkSync(avifFile)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const originalCwd = process.cwd()
|
|
409
|
+
try {
|
|
410
|
+
process.chdir(tempDir)
|
|
411
|
+
execSync(`node "${imageGuardScript}" --heic-to-avif`, { stdio: 'pipe' })
|
|
412
|
+
} finally {
|
|
413
|
+
process.chdir(originalCwd)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// AVIF should be created by the conversion
|
|
417
|
+
assert.strictEqual(fs.existsSync(avifFile), true, 'AVIF file should be created')
|
|
418
|
+
const avifSize = fs.statSync(avifFile).size
|
|
419
|
+
assert.ok(avifSize > 0, 'AVIF file should not be empty')
|
|
420
|
+
|
|
421
|
+
// Lossy AVIF should not be larger than the HEIC source
|
|
422
|
+
const heicSize = fs.statSync(path.join(testFolder, 'test.heic')).size
|
|
423
|
+
assert.ok(avifSize <= heicSize, `AVIF (${avifSize}) should not be larger than HEIC (${heicSize})`)
|
|
424
|
+
|
|
425
|
+
// Original HEIC should be deleted
|
|
426
|
+
assert.strictEqual(fs.existsSync(heicFile), false, 'Original HEIC should be deleted')
|
|
427
|
+
|
|
428
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('`--keep-heic` preserves original HEIC file', () => {
|
|
432
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-heic-'))
|
|
433
|
+
const tempTestFolder = path.join(tempDir, 'test')
|
|
434
|
+
copyFiles(testFolder, tempTestFolder)
|
|
435
|
+
|
|
436
|
+
const heicFile = path.join(tempTestFolder, 'test.heic')
|
|
437
|
+
assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC test fixture must exist')
|
|
438
|
+
|
|
439
|
+
const originalCwd = process.cwd()
|
|
440
|
+
try {
|
|
441
|
+
process.chdir(tempDir)
|
|
442
|
+
execSync(`node "${imageGuardScript}" --heic-to-avif --keep-heic`, { stdio: 'pipe' })
|
|
443
|
+
} finally {
|
|
444
|
+
process.chdir(originalCwd)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Both files should exist
|
|
448
|
+
assert.strictEqual(fs.existsSync(path.join(tempTestFolder, 'test.avif')), true, 'AVIF file should be created')
|
|
449
|
+
assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC file should be preserved with `--keep-heic`')
|
|
450
|
+
|
|
451
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('Dry run does not convert HEIC files', () => {
|
|
455
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-dry-'))
|
|
456
|
+
const tempTestFolder = path.join(tempDir, 'test')
|
|
457
|
+
copyFiles(testFolder, tempTestFolder)
|
|
458
|
+
|
|
459
|
+
const heicFile = path.join(tempTestFolder, 'test.heic')
|
|
460
|
+
const heicBefore = fs.statSync(heicFile)
|
|
461
|
+
const filesBefore = fs.readdirSync(tempTestFolder).sort()
|
|
462
|
+
|
|
463
|
+
const originalCwd = process.cwd()
|
|
464
|
+
try {
|
|
465
|
+
process.chdir(tempDir)
|
|
466
|
+
execSync(`node "${imageGuardScript}" --heic-to-avif --dry`, { stdio: 'pipe' })
|
|
467
|
+
} finally {
|
|
468
|
+
process.chdir(originalCwd)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// HEIC should be unchanged
|
|
472
|
+
const heicAfter = fs.statSync(heicFile)
|
|
473
|
+
assert.strictEqual(heicAfter.size, heicBefore.size)
|
|
474
|
+
assert.strictEqual(heicAfter.mtime.getTime(), heicBefore.mtime.getTime())
|
|
475
|
+
|
|
476
|
+
// No files should be added or removed
|
|
477
|
+
const filesAfter = fs.readdirSync(tempTestFolder).sort()
|
|
478
|
+
assert.deepStrictEqual(filesAfter, filesBefore)
|
|
479
|
+
|
|
480
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('HEIC files are ignored without `--heic-to-avif` flag', () => {
|
|
484
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-noflag-'))
|
|
485
|
+
const tempTestFolder = path.join(tempDir, 'test')
|
|
486
|
+
copyFiles(testFolder, tempTestFolder)
|
|
487
|
+
|
|
488
|
+
const heicFile = path.join(tempTestFolder, 'test.heic')
|
|
489
|
+
const heicBefore = fs.statSync(heicFile)
|
|
490
|
+
|
|
491
|
+
const originalCwd = process.cwd()
|
|
492
|
+
try {
|
|
493
|
+
process.chdir(tempDir)
|
|
494
|
+
execSync(`node "${imageGuardScript}"`, { stdio: 'pipe' })
|
|
495
|
+
} finally {
|
|
496
|
+
process.chdir(originalCwd)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// HEIC should be untouched
|
|
500
|
+
const heicAfter = fs.statSync(heicFile)
|
|
501
|
+
assert.strictEqual(heicAfter.size, heicBefore.size)
|
|
502
|
+
assert.strictEqual(heicAfter.mtime.getTime(), heicBefore.mtime.getTime())
|
|
503
|
+
|
|
504
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('`--keep-heic` without `--heic-to-avif` warns', () => {
|
|
508
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-warn-'))
|
|
509
|
+
const tempTestFolder = path.join(tempDir, 'test')
|
|
510
|
+
copyFiles(testFolder, tempTestFolder)
|
|
511
|
+
|
|
512
|
+
const originalCwd = process.cwd()
|
|
513
|
+
let output = ''
|
|
514
|
+
try {
|
|
515
|
+
process.chdir(tempDir)
|
|
516
|
+
output = execSync(`node "${imageGuardScript}" --keep-heic 2>&1`, { encoding: 'utf8' })
|
|
517
|
+
} finally {
|
|
518
|
+
process.chdir(originalCwd)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
assert.match(output, /`--keep-heic` has no effect without `--heic-to-avif`/)
|
|
522
|
+
|
|
523
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
524
|
+
})
|
|
392
525
|
})
|
package/package.json
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
},
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"globby": "^16.1.0",
|
|
10
|
+
"heic-decode": "^2.1.0",
|
|
10
11
|
"sharp": "^0.34.5",
|
|
11
12
|
"simple-git": "^3.30.0"
|
|
12
13
|
},
|
|
13
14
|
"description": "Near-lossless image compressor that’s easy to use and easy to automate",
|
|
14
15
|
"devDependencies": {
|
|
15
|
-
"eslint": "^
|
|
16
|
+
"eslint": "^10.0.0"
|
|
16
17
|
},
|
|
17
18
|
"funding": "https://github.com/j9t/image-guard?sponsor=1",
|
|
18
19
|
"homepage": "https://github.com/j9t/image-guard",
|
|
@@ -22,6 +23,8 @@
|
|
|
22
23
|
"compression",
|
|
23
24
|
"gif",
|
|
24
25
|
"graphics",
|
|
26
|
+
"heic",
|
|
27
|
+
"heif",
|
|
25
28
|
"images",
|
|
26
29
|
"jpeg",
|
|
27
30
|
"jpg",
|
|
@@ -43,5 +46,5 @@
|
|
|
43
46
|
"test": "node --test bin/image-guard.test.js"
|
|
44
47
|
},
|
|
45
48
|
"type": "module",
|
|
46
|
-
"version": "5.1
|
|
49
|
+
"version": "5.2.1"
|
|
47
50
|
}
|
package/src/index.js
CHANGED
|
@@ -11,26 +11,38 @@ import { utils } from './utils.js'
|
|
|
11
11
|
// Files to be compressed
|
|
12
12
|
export const fileTypes = ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp'];
|
|
13
13
|
|
|
14
|
+
// Files to be converted (require explicit opt-in)
|
|
15
|
+
export const convertTypes = ['heic', 'heif'];
|
|
16
|
+
|
|
14
17
|
export async function runImageGuard() {
|
|
15
18
|
const options = {
|
|
16
19
|
dry: { type: 'boolean', default: false },
|
|
20
|
+
'heic-to-avif': { type: 'boolean', default: false },
|
|
17
21
|
ignore: { type: 'string', multiple: false, default: '' },
|
|
22
|
+
'keep-heic': { type: 'boolean', default: false },
|
|
18
23
|
staged: { type: 'boolean', default: false },
|
|
19
24
|
quiet: { type: 'boolean', default: false }
|
|
20
25
|
}
|
|
21
26
|
const { values: argv } = parseArgs({ options })
|
|
22
27
|
|
|
23
28
|
// Share status
|
|
24
|
-
const summary = (run) => {
|
|
29
|
+
const summary = (run, includesConversion = false) => {
|
|
25
30
|
if (run) {
|
|
26
|
-
|
|
31
|
+
const action = includesConversion ? 'compression and conversion' : 'compression'
|
|
32
|
+
console.info(styleText(['bold'], `\nDefensive base ${action} completed. You saved ${utils.sizeReadable(savedKB)}.`))
|
|
27
33
|
} else {
|
|
28
|
-
|
|
34
|
+
const what = includesConversion ? 'images to compress or convert' : 'images to compress'
|
|
35
|
+
console.info(styleText(['bold'], `There were no ${what}.`))
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
if (argv['keep-heic'] && !argv['heic-to-avif']) {
|
|
40
|
+
console.warn(styleText('yellow', '`--keep-heic` has no effect without `--heic-to-avif`'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const allTypes = argv['heic-to-avif'] ? [...fileTypes, ...convertTypes] : fileTypes
|
|
32
44
|
if (!argv.quiet) {
|
|
33
|
-
console.log(`(Search pattern: ${
|
|
45
|
+
console.log(`(Search pattern: ${allTypes.join(', ')})\n`)
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
let savedKB = 0
|
|
@@ -71,66 +83,74 @@ export async function runImageGuard() {
|
|
|
71
83
|
})
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const perTaskThreads = Math.max(1, Math.floor(os.cpus().length / Math.max(1, desiredFileConcurrency)))
|
|
83
|
-
try {
|
|
84
|
-
sharp.concurrency(perTaskThreads)
|
|
85
|
-
} catch {
|
|
86
|
-
// Best-effort; ignore if not supported (could log in debug mode)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const limit = createLimiter(desiredFileConcurrency)
|
|
90
|
-
const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet)))
|
|
91
|
-
const results = await Promise.allSettled(tasks)
|
|
86
|
+
const desiredFileConcurrency = Math.min(os.cpus().length, 4)
|
|
87
|
+
const perTaskThreads = Math.max(1, Math.floor(os.cpus().length / Math.max(1, desiredFileConcurrency)))
|
|
88
|
+
try {
|
|
89
|
+
sharp.concurrency(perTaskThreads)
|
|
90
|
+
} catch {
|
|
91
|
+
// Best-effort; ignore if not supported
|
|
92
|
+
}
|
|
93
|
+
const limit = createLimiter(desiredFileConcurrency)
|
|
92
94
|
|
|
95
|
+
const processResults = (results) => {
|
|
93
96
|
let hadFailures = false
|
|
97
|
+
let addedKB = 0
|
|
94
98
|
for (const r of results) {
|
|
95
99
|
if (r.status === 'fulfilled') {
|
|
96
100
|
if (typeof r.value === 'number') {
|
|
97
|
-
|
|
101
|
+
addedKB += r.value
|
|
98
102
|
} else {
|
|
99
|
-
// Treat non-numeric fulfillment as a failure signal
|
|
100
103
|
hadFailures = true
|
|
101
104
|
}
|
|
102
105
|
} else {
|
|
103
106
|
hadFailures = true
|
|
104
|
-
// Log the underlying reason to aid troubleshooting
|
|
105
107
|
const reason = r.reason && r.reason.message ? r.reason.message : String(r.reason)
|
|
106
|
-
console.error(styleText('red', '
|
|
108
|
+
console.error(styleText('red', 'Task failed:'), reason)
|
|
107
109
|
}
|
|
108
110
|
}
|
|
111
|
+
return { hadFailures, addedKB }
|
|
112
|
+
}
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
const compress = async (files, dry) => {
|
|
115
|
+
if (files.length === 0) return false
|
|
116
|
+
|
|
117
|
+
const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet)))
|
|
118
|
+
const results = await Promise.allSettled(tasks)
|
|
115
119
|
|
|
116
|
-
|
|
120
|
+
const { hadFailures, addedKB } = processResults(results)
|
|
121
|
+
savedKB += addedKB
|
|
122
|
+
return hadFailures
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
const
|
|
120
|
-
|
|
125
|
+
const convert = async (files, dry, keepOriginal) => {
|
|
126
|
+
if (files.length === 0) return false
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
patterns.push(`**/*.${fileType}`)
|
|
125
|
-
}
|
|
128
|
+
const tasks = files.map(file => limit(() => utils.conversion(file, dry, keepOriginal, argv.quiet)))
|
|
129
|
+
const results = await Promise.allSettled(tasks)
|
|
126
130
|
|
|
127
|
-
const
|
|
131
|
+
const { hadFailures, addedKB } = processResults(results)
|
|
132
|
+
savedKB += addedKB
|
|
133
|
+
return hadFailures
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const getIgnorePatterns = (ignore) => {
|
|
137
|
+
return (ignore || '')
|
|
128
138
|
.split(',')
|
|
129
139
|
.map(s => s.trim())
|
|
130
140
|
.filter(Boolean)
|
|
141
|
+
.map(p => (p.startsWith('!') ? p : `!${p}`))
|
|
142
|
+
}
|
|
131
143
|
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
const getFilePattern = (ignore, types = fileTypes) => {
|
|
145
|
+
const patterns = []
|
|
146
|
+
|
|
147
|
+
// Rely on `caseSensitiveMatch: false` instead of duplicating upper/lower-case
|
|
148
|
+
for (const fileType of types) {
|
|
149
|
+
patterns.push(`**/*.${fileType}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const p of getIgnorePatterns(ignore)) {
|
|
153
|
+
patterns.push(p)
|
|
134
154
|
}
|
|
135
155
|
|
|
136
156
|
return patterns
|
|
@@ -145,51 +165,74 @@ export async function runImageGuard() {
|
|
|
145
165
|
})
|
|
146
166
|
}
|
|
147
167
|
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
const filterStagedFiles = async (stagedFiles, types) => {
|
|
169
|
+
const allowedExts = new Set(types)
|
|
170
|
+
const byExt = stagedFiles.filter(f => allowedExts.has(path.extname(f).slice(1).toLowerCase()))
|
|
171
|
+
const ignoreList = getIgnorePatterns(argv.ignore)
|
|
172
|
+
|
|
173
|
+
if (ignoreList.length > 0) {
|
|
174
|
+
const escapedPaths = byExt.map(p => convertPathToPattern(p))
|
|
175
|
+
return globby([...escapedPaths, ...ignoreList], {
|
|
176
|
+
gitignore: true,
|
|
177
|
+
expandDirectories: false,
|
|
178
|
+
onlyFiles: true,
|
|
179
|
+
caseSensitiveMatch: false
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return byExt
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const doConversion = argv['heic-to-avif']
|
|
186
|
+
let hadFailures = false
|
|
187
|
+
let totalFiles = 0
|
|
151
188
|
|
|
152
|
-
// Search for staged files
|
|
153
189
|
if (argv.staged) {
|
|
154
190
|
const git = simpleGit()
|
|
155
191
|
try {
|
|
156
|
-
// Get staged file paths directly from Git
|
|
157
192
|
const diffOutput = await git.raw(['diff', '--name-only', '--cached', '--diff-filter=ACMRT'])
|
|
158
193
|
const stagedFiles = diffOutput.split('\n').map(s => s.trim()).filter(Boolean)
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (ignoreList.length > 0) {
|
|
170
|
-
// Use globby to filter the staged list with identical options; avoid repo-wide scan
|
|
171
|
-
// Pass the staged file paths as include patterns and the ignores as negatives
|
|
172
|
-
const escapedPaths = byExt.map(p => convertPathToPattern(p))
|
|
173
|
-
const filtered = await globby([...escapedPaths, ...ignoreList], {
|
|
174
|
-
gitignore: true,
|
|
175
|
-
expandDirectories: false,
|
|
176
|
-
onlyFiles: true,
|
|
177
|
-
caseSensitiveMatch: false
|
|
178
|
-
})
|
|
179
|
-
compressionFiles = filtered
|
|
180
|
-
} else {
|
|
181
|
-
compressionFiles = byExt
|
|
194
|
+
|
|
195
|
+
const compressionFiles = await filterStagedFiles(stagedFiles, fileTypes)
|
|
196
|
+
totalFiles += compressionFiles.length
|
|
197
|
+
hadFailures = await compress(compressionFiles, argv.dry)
|
|
198
|
+
|
|
199
|
+
if (doConversion) {
|
|
200
|
+
const conversionFiles = await filterStagedFiles(stagedFiles, convertTypes)
|
|
201
|
+
totalFiles += conversionFiles.length
|
|
202
|
+
const convFailed = await convert(conversionFiles, argv.dry, argv['keep-heic'])
|
|
203
|
+
hadFailures = hadFailures || convFailed
|
|
182
204
|
}
|
|
183
|
-
await compress(compressionFiles, argv.dry)
|
|
184
205
|
} catch (err) {
|
|
185
206
|
console.error(err)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
207
|
+
hadFailures = true
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
const patterns = getFilePattern(argv.ignore)
|
|
211
|
+
const compressionFiles = await findFiles(patterns)
|
|
212
|
+
totalFiles += compressionFiles.length
|
|
213
|
+
hadFailures = await compress(compressionFiles, argv.dry)
|
|
214
|
+
|
|
215
|
+
if (doConversion) {
|
|
216
|
+
const convPatterns = getFilePattern(argv.ignore, convertTypes)
|
|
217
|
+
const conversionFiles = await findFiles(convPatterns)
|
|
218
|
+
totalFiles += conversionFiles.length
|
|
219
|
+
const convFailed = await convert(conversionFiles, argv.dry, argv['keep-heic'])
|
|
220
|
+
hadFailures = hadFailures || convFailed
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (hadFailures) {
|
|
225
|
+
process.exitCode = 1
|
|
226
|
+
if (totalFiles > 0) {
|
|
227
|
+
const action = doConversion ? 'compression and conversion' : 'compression'
|
|
228
|
+
const savings = savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''
|
|
229
|
+
console.info(styleText(['bold'], `\nDefensive base ${action} partially completed (some tasks failed).${savings}`))
|
|
230
|
+
} else {
|
|
231
|
+
summary(false, doConversion)
|
|
189
232
|
}
|
|
233
|
+
} else if (totalFiles > 0) {
|
|
234
|
+
summary(true, doConversion)
|
|
190
235
|
} else {
|
|
191
|
-
|
|
192
|
-
compressionFiles = files
|
|
193
|
-
await compress(compressionFiles, argv.dry)
|
|
236
|
+
summary(false, doConversion)
|
|
194
237
|
}
|
|
195
238
|
}
|
package/src/utils.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'fs'
|
|
|
4
4
|
import path from 'path'
|
|
5
5
|
import sharp from 'sharp'
|
|
6
6
|
import { styleText } from 'node:util'
|
|
7
|
+
import decode from 'heic-decode'
|
|
7
8
|
|
|
8
9
|
const logMessage = (message, dry, color = 'yellow', quiet = false) => {
|
|
9
10
|
if (quiet) return
|
|
@@ -190,6 +191,86 @@ const compression = async (filename, dry, quiet = false) => {
|
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
const conversion = async (filename, dry, keepOriginal, quiet = false) => {
|
|
195
|
+
const fileSizeBefore = await size(filename)
|
|
196
|
+
|
|
197
|
+
if (fileSizeBefore === 0) {
|
|
198
|
+
logMessage(`Skipped ${filename} (${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
|
|
199
|
+
return 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const maxFileSize = 100 * 1024 * 1024 // 100 MB
|
|
203
|
+
|
|
204
|
+
if (fileSizeBefore > maxFileSize) {
|
|
205
|
+
logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
|
|
206
|
+
return 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!/\.hei[cf]$/i.test(filename)) {
|
|
210
|
+
logMessage(`Skipped ${filename} (not a HEIC/HEIF file)`, dry, 'yellow', quiet)
|
|
211
|
+
return 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const avifPath = filename.replace(/\.hei[cf]$/i, '.avif')
|
|
215
|
+
|
|
216
|
+
// Avoid overwriting an existing AVIF file
|
|
217
|
+
try {
|
|
218
|
+
await fs.promises.access(avifPath)
|
|
219
|
+
logMessage(`Skipped ${filename} (${path.basename(avifPath)} already exists)`, dry, 'yellow', quiet)
|
|
220
|
+
return 0
|
|
221
|
+
} catch {
|
|
222
|
+
// File does not exist—proceed with conversion
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
// Decode HEIC/HEIF to raw pixel data
|
|
227
|
+
const inputBuffer = await fs.promises.readFile(filename)
|
|
228
|
+
const { width, height, data } = await decode({ buffer: inputBuffer })
|
|
229
|
+
|
|
230
|
+
if (dry) {
|
|
231
|
+
logMessage(`Converted ${filename} → ${path.basename(avifPath)}`, dry, 'cyan', quiet)
|
|
232
|
+
return 0
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Encode as lossy AVIF—HEIC sources are already lossy (HEVC), so lossless
|
|
236
|
+
// re-encoding would inflate file sizes without any quality benefit
|
|
237
|
+
await sharp(data, { raw: { width, height, channels: 4 } })
|
|
238
|
+
.toFormat('avif', { quality: 80, effort: 5 })
|
|
239
|
+
.toFile(avifPath)
|
|
240
|
+
|
|
241
|
+
const fileSizeAfter = await size(avifPath)
|
|
242
|
+
|
|
243
|
+
logMessage(`Converted ${filename} → ${path.basename(avifPath)} (${sizeReadable(fileSizeBefore)} → ${sizeReadable(fileSizeAfter)})`, dry, 'cyan', quiet)
|
|
244
|
+
|
|
245
|
+
// Delete original HEIC/HEIF file unless `--keep-heic` is set
|
|
246
|
+
if (!keepOriginal) {
|
|
247
|
+
await retryFileOperation(() => fs.promises.unlink(filename))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
|
|
251
|
+
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// Clean up partial AVIF if it was created
|
|
254
|
+
try {
|
|
255
|
+
await fs.promises.unlink(avifPath)
|
|
256
|
+
} catch (cleanupErr) {
|
|
257
|
+
if (cleanupErr.code !== 'ENOENT') {
|
|
258
|
+
console.warn(styleText('yellow', `Failed to clean up ${avifPath}:`), cleanupErr)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (err.message && (
|
|
263
|
+
err.message.includes('not a HEIC image') ||
|
|
264
|
+
err.message.includes('HEIF image not found')
|
|
265
|
+
)) {
|
|
266
|
+
logMessage(`Skipped ${filename} (corrupt or unsupported HEIC/HEIF file)`, dry, 'yellow', quiet)
|
|
267
|
+
} else {
|
|
268
|
+
console.error(styleText('red', `Error converting ${filename}:`), err)
|
|
269
|
+
}
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
193
274
|
const size = async (file) => {
|
|
194
275
|
const stats = await fs.promises.stat(file)
|
|
195
276
|
return stats.size
|
|
@@ -197,4 +278,4 @@ const size = async (file) => {
|
|
|
197
278
|
|
|
198
279
|
const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB`
|
|
199
280
|
|
|
200
|
-
export const utils = { compression, sizeReadable }
|
|
281
|
+
export const utils = { compression, conversion, sizeReadable }
|