image-guard 5.1.2 → 5.2.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.
@@ -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
@@ -1,4 +1,4 @@
1
- # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference
1
+ # https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference
2
2
 
3
3
  version: 2
4
4
  updates:
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
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.0] - 2026-02-17
8
+
9
+ ### Added
10
+
11
+ * Added opt-in HEIC/HEIF-to-AVIF conversion via `--heic-to-avif` (with `--keep-heic` to preserve originals)
12
+ * Introduced changelog
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/image-guard.svg)](https://www.npmjs.com/package/image-guard) [![Build status](https://github.com/j9t/image-guard/workflows/Tests/badge.svg)](https://github.com/j9t/image-guard/actions) [![Socket](https://badge.socket.dev/npm/package/image-guard)](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 two additional file formats—WebP and AVIF—, 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.)
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 code base, run Image Guard once by calling
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 compresses those files—that approach makes Image Guard more efficient in operation.
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 lossless AVIF. The conversion decodes HEIC/HEIF pixel data and re-encodes it as lossless AVIF, then runs the result through Image Guard’s compression pipeline. Original HEIC/HEIF files are deleted after conversion by default.
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
  ![Screenshot of Image Guard in operation.](https://raw.githubusercontent.com/j9t/image-guard/master/media/output.png)
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 compressed images can then be committed to the underlying repository.
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 AVIF encoding. 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
@@ -389,4 +389,132 @@ 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
+ assert.ok(fs.statSync(avifFile).size > 0, 'AVIF file should not be empty')
419
+
420
+ // Original HEIC should be deleted
421
+ assert.strictEqual(fs.existsSync(heicFile), false, 'Original HEIC should be deleted')
422
+
423
+ fs.rmSync(tempDir, { recursive: true, force: true })
424
+ })
425
+
426
+ test('`--keep-heic` preserves original HEIC file', () => {
427
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-heic-'))
428
+ const tempTestFolder = path.join(tempDir, 'test')
429
+ copyFiles(testFolder, tempTestFolder)
430
+
431
+ const heicFile = path.join(tempTestFolder, 'test.heic')
432
+ assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC test fixture must exist')
433
+
434
+ const originalCwd = process.cwd()
435
+ try {
436
+ process.chdir(tempDir)
437
+ execSync(`node "${imageGuardScript}" --heic-to-avif --keep-heic`, { stdio: 'pipe' })
438
+ } finally {
439
+ process.chdir(originalCwd)
440
+ }
441
+
442
+ // Both files should exist
443
+ assert.strictEqual(fs.existsSync(path.join(tempTestFolder, 'test.avif')), true, 'AVIF file should be created')
444
+ assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC file should be preserved with `--keep-heic`')
445
+
446
+ fs.rmSync(tempDir, { recursive: true, force: true })
447
+ })
448
+
449
+ test('Dry run does not convert HEIC files', () => {
450
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-dry-'))
451
+ const tempTestFolder = path.join(tempDir, 'test')
452
+ copyFiles(testFolder, tempTestFolder)
453
+
454
+ const heicFile = path.join(tempTestFolder, 'test.heic')
455
+ const heicBefore = fs.statSync(heicFile)
456
+ const filesBefore = fs.readdirSync(tempTestFolder).sort()
457
+
458
+ const originalCwd = process.cwd()
459
+ try {
460
+ process.chdir(tempDir)
461
+ execSync(`node "${imageGuardScript}" --heic-to-avif --dry`, { stdio: 'pipe' })
462
+ } finally {
463
+ process.chdir(originalCwd)
464
+ }
465
+
466
+ // HEIC should be unchanged
467
+ const heicAfter = fs.statSync(heicFile)
468
+ assert.strictEqual(heicAfter.size, heicBefore.size)
469
+ assert.strictEqual(heicAfter.mtime.getTime(), heicBefore.mtime.getTime())
470
+
471
+ // No files should be added or removed
472
+ const filesAfter = fs.readdirSync(tempTestFolder).sort()
473
+ assert.deepStrictEqual(filesAfter, filesBefore)
474
+
475
+ fs.rmSync(tempDir, { recursive: true, force: true })
476
+ })
477
+
478
+ test('HEIC files are ignored without `--heic-to-avif` flag', () => {
479
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-noflag-'))
480
+ const tempTestFolder = path.join(tempDir, 'test')
481
+ copyFiles(testFolder, tempTestFolder)
482
+
483
+ const heicFile = path.join(tempTestFolder, 'test.heic')
484
+ const heicBefore = fs.statSync(heicFile)
485
+
486
+ const originalCwd = process.cwd()
487
+ try {
488
+ process.chdir(tempDir)
489
+ execSync(`node "${imageGuardScript}"`, { stdio: 'pipe' })
490
+ } finally {
491
+ process.chdir(originalCwd)
492
+ }
493
+
494
+ // HEIC should be untouched
495
+ const heicAfter = fs.statSync(heicFile)
496
+ assert.strictEqual(heicAfter.size, heicBefore.size)
497
+ assert.strictEqual(heicAfter.mtime.getTime(), heicBefore.mtime.getTime())
498
+
499
+ fs.rmSync(tempDir, { recursive: true, force: true })
500
+ })
501
+
502
+ test('`--keep-heic` without `--heic-to-avif` warns', () => {
503
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-warn-'))
504
+ const tempTestFolder = path.join(tempDir, 'test')
505
+ copyFiles(testFolder, tempTestFolder)
506
+
507
+ const originalCwd = process.cwd()
508
+ let output = ''
509
+ try {
510
+ process.chdir(tempDir)
511
+ output = execSync(`node "${imageGuardScript}" --keep-heic 2>&1`, { encoding: 'utf8' })
512
+ } finally {
513
+ process.chdir(originalCwd)
514
+ }
515
+
516
+ assert.match(output, /`--keep-heic` has no effect without `--heic-to-avif`/)
517
+
518
+ fs.rmSync(tempDir, { recursive: true, force: true })
519
+ })
392
520
  })
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": "^9.39.2"
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.2"
49
+ "version": "5.2.0"
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
- console.info(styleText(['bold'], `\nDefensive base compression completed. You saved ${utils.sizeReadable(savedKB)}.`))
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
- console.info(styleText(['bold'], 'There were no images to compress.'))
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: ${fileTypes.join(', ')})\n`)
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 compress = async (files, dry) => {
75
- if (files.length === 0) {
76
- summary(false)
77
- return
78
- }
79
-
80
- const desiredFileConcurrency = Math.min(os.cpus().length, 4)
81
- // Tune libvips threads to avoid oversubscription
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
- savedKB += r.value
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', 'Compression task failed:'), reason)
108
+ console.error(styleText('red', 'Task failed:'), reason)
107
109
  }
108
110
  }
111
+ return { hadFailures, addedKB }
112
+ }
109
113
 
110
- if (hadFailures) {
111
- process.exitCode = 1
112
- summary(false)
113
- return
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
- summary(true)
120
+ const { hadFailures, addedKB } = processResults(results)
121
+ savedKB += addedKB
122
+ return hadFailures
117
123
  }
118
124
 
119
- const getFilePattern = (ignore) => {
120
- const patterns = []
125
+ const convert = async (files, dry, keepOriginal) => {
126
+ if (files.length === 0) return false
121
127
 
122
- // Rely on `caseSensitiveMatch: false` instead of duplicating upper/lower-case
123
- for (const fileType of fileTypes) {
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 ignoreList = (ignore || '')
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
- for (const p of ignoreList) {
133
- patterns.push(p.startsWith('!') ? p : `!${p}`)
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 patterns = getFilePattern(argv.ignore)
149
- let files = []
150
- let compressionFiles = []
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
- // Filter by allowed extensions
160
- const allowedExts = new Set(fileTypes)
161
- const byExt = stagedFiles.filter(f => allowedExts.has(path.extname(f).slice(1).toLowerCase()))
162
- // Apply `--ignore` using the same glob semantics as non-staged by delegating to globby
163
- const ignoreList = (argv.ignore || '')
164
- .split(',')
165
- .map(s => s.trim())
166
- .filter(Boolean)
167
- .map(p => (p.startsWith('!') ? p : `!${p}`))
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
- // Ensure non-zero exit and failure summary on staged path errors
187
- process.exitCode = 1
188
- summary(false)
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
- files = await findFiles(patterns)
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,89 @@ 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 lossless AVIF (pass the decoded view directly to avoid extra-byte issues)
236
+ await sharp(data, { raw: { width, height, channels: 4 } })
237
+ .toFormat('avif', { effort: 5, lossless: true })
238
+ .toFile(avifPath)
239
+
240
+ const fileSizeAfter = await size(avifPath)
241
+
242
+ logMessage(`Converted ${filename} → ${path.basename(avifPath)} (${sizeReadable(fileSizeBefore)} → ${sizeReadable(fileSizeAfter)})`, dry, 'cyan', quiet)
243
+
244
+ // Compress the resulting AVIF through the standard compression pipeline
245
+ const saved = await compression(avifPath, dry, quiet)
246
+
247
+ // Delete original HEIC/HEIF file unless --keep-heic is set (deferred until after compression succeeds)
248
+ if (!keepOriginal) {
249
+ await retryFileOperation(() => fs.promises.unlink(filename))
250
+ }
251
+
252
+ // Return compression savings only (conversion size change is informational)
253
+ return saved
254
+
255
+ } catch (err) {
256
+ // Clean up partial AVIF if it was created
257
+ try {
258
+ await fs.promises.unlink(avifPath)
259
+ } catch (cleanupErr) {
260
+ if (cleanupErr.code !== 'ENOENT') {
261
+ console.warn(styleText('yellow', `Failed to clean up ${avifPath}:`), cleanupErr)
262
+ }
263
+ }
264
+
265
+ if (err.message && (
266
+ err.message.includes('not a HEIC image') ||
267
+ err.message.includes('HEIF image not found')
268
+ )) {
269
+ logMessage(`Skipped ${filename} (corrupt or unsupported HEIC/HEIF file)`, dry, 'yellow', quiet)
270
+ } else {
271
+ console.error(styleText('red', `Error converting ${filename}:`), err)
272
+ }
273
+ return 0
274
+ }
275
+ }
276
+
193
277
  const size = async (file) => {
194
278
  const stats = await fs.promises.stat(file)
195
279
  return stats.size
@@ -197,4 +281,4 @@ const size = async (file) => {
197
281
 
198
282
  const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB`
199
283
 
200
- export const utils = { compression, sizeReadable }
284
+ export const utils = { compression, conversion, sizeReadable }