image-guard 5.2.1 → 5.2.3
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/CHANGELOG.md +22 -1
- package/README.md +2 -2
- package/bin/image-guard.test.js +37 -9
- package/package.json +1 -1
- package/src/index.js +30 -22
- package/src/utils.js +51 -44
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,32 @@ Starting with version 5.2.0, all notable changes to Image Guard are documented i
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
+
## [5.2.3] - 2026-02-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
* Improved corrupt-file error detection to cover WebP and AVIF formats, normalize message casing before matching, and avoid false positives from the overly broad `Invalid` string match
|
|
12
|
+
* Warned when a `.bak` file is left behind after a failed file replacement
|
|
13
|
+
* Removed a dead code branch in result processing
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
* Moved to compression and conversion running in parallel (non-overlapping file sets), reducing wall-clock time when `--heic-to-avif` is active
|
|
18
|
+
* Reduced directory traversals from two to one in non-staged mode when `--heic-to-avif` is active
|
|
19
|
+
* Extracted shared `MAX_FILE_SIZE` constant; aligned `node:fs` and `node:path` import style in `utils.js`
|
|
20
|
+
* Extended test coverage for corrupt file detection and reporting
|
|
21
|
+
|
|
22
|
+
## [5.2.2] - 2026-02-17
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
* Updated HEIC dry run to report actual file sizes instead of omitting size data
|
|
27
|
+
|
|
7
28
|
## [5.2.1] - 2026-02-17
|
|
8
29
|
|
|
9
30
|
### Changed
|
|
10
31
|
|
|
11
|
-
* HEIC-to-AVIF conversion
|
|
32
|
+
* Adjusted HEIC-to-AVIF conversion to use lossy instead of lossless encoding (quality 80), to produce files smaller than the HEIC originals but still at high quality
|
|
12
33
|
* Removed redundant lossless compression pass after HEIC conversion
|
|
13
34
|
* Documented Display P3 to sRGB color space mapping during HEIC conversion
|
|
14
35
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
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. It can also convert HEIC/HEIF files to AVIF
|
|
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.
|
|
8
8
|
|
|
9
9
|
It’s convenient because setup is simple. Run it right away—done. Or install, run, add hook—done.
|
|
10
10
|
|
|
@@ -140,7 +140,7 @@ Roughly like this:
|
|
|
140
140
|
* Green: The image file has been compressed.
|
|
141
141
|
* Cyan: The image file has been converted (HEIC/HEIF to AVIF).
|
|
142
142
|
* White (light gray): The image file has not been changed.
|
|
143
|
-
* Blue: The image file had already been compressed more
|
|
143
|
+
* Blue: The image file had already been compressed more effectively than the new result, and was therefore skipped, too.
|
|
144
144
|
|
|
145
145
|
Tip: Use `--quiet` to suppress these per‑file lines and keep only the final summary.
|
|
146
146
|
|
package/bin/image-guard.test.js
CHANGED
|
@@ -141,7 +141,7 @@ describe('Image Guard', () => {
|
|
|
141
141
|
assert.strictEqual(allCompressed, true)
|
|
142
142
|
})
|
|
143
143
|
|
|
144
|
-
test('
|
|
144
|
+
test('Ensure files are not modified in dry run', () => {
|
|
145
145
|
const originalStats = fs.readdirSync(testFolderGit).sort().map(file => {
|
|
146
146
|
const filePath = path.join(testFolderGit, file)
|
|
147
147
|
return { file, stats: fs.statSync(filePath) }
|
|
@@ -298,7 +298,7 @@ describe('Image Guard', () => {
|
|
|
298
298
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
299
299
|
})
|
|
300
300
|
|
|
301
|
-
test('
|
|
301
|
+
test('Ensure quiet mode suppresses per-file logs but keeps summary', () => {
|
|
302
302
|
// Prepare isolated temp directory with test images
|
|
303
303
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-quiet-'))
|
|
304
304
|
const tempTestFolder = path.join(tempDir, 'test')
|
|
@@ -320,7 +320,7 @@ describe('Image Guard', () => {
|
|
|
320
320
|
assert.strictEqual(!(/Compressed|Skipped/.test(stdout)), true)
|
|
321
321
|
})
|
|
322
322
|
|
|
323
|
-
test('
|
|
323
|
+
test('Ensure dry and quiet runs leave no artifacts and do not mutate files', () => {
|
|
324
324
|
// Use isolated temp directory
|
|
325
325
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-dry-quiet-'))
|
|
326
326
|
const tempTestFolder = path.join(tempDir, 'test')
|
|
@@ -368,7 +368,7 @@ describe('Image Guard', () => {
|
|
|
368
368
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
369
369
|
})
|
|
370
370
|
|
|
371
|
-
test('
|
|
371
|
+
test('Ensure no .bak files remain after normal compression', () => {
|
|
372
372
|
// Prepare isolated temp directory with test images
|
|
373
373
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-bak-'))
|
|
374
374
|
const tempTestFolder = path.join(tempDir, 'test')
|
|
@@ -428,7 +428,7 @@ describe('Image Guard', () => {
|
|
|
428
428
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
429
429
|
})
|
|
430
430
|
|
|
431
|
-
test('`--keep-heic` preserves original HEIC file', () => {
|
|
431
|
+
test('Ensure `--keep-heic` preserves original HEIC file', () => {
|
|
432
432
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-heic-'))
|
|
433
433
|
const tempTestFolder = path.join(tempDir, 'test')
|
|
434
434
|
copyFiles(testFolder, tempTestFolder)
|
|
@@ -451,19 +451,26 @@ describe('Image Guard', () => {
|
|
|
451
451
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
452
452
|
})
|
|
453
453
|
|
|
454
|
-
test('
|
|
454
|
+
test('Ensure dry run does not convert HEIC files but reports sizes', () => {
|
|
455
455
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-dry-'))
|
|
456
456
|
const tempTestFolder = path.join(tempDir, 'test')
|
|
457
457
|
copyFiles(testFolder, tempTestFolder)
|
|
458
458
|
|
|
459
|
+
// Remove pre-existing AVIF so the conversion path is exercised
|
|
460
|
+
const avifFile = path.join(tempTestFolder, 'test.avif')
|
|
461
|
+
if (fs.existsSync(avifFile)) {
|
|
462
|
+
fs.unlinkSync(avifFile)
|
|
463
|
+
}
|
|
464
|
+
|
|
459
465
|
const heicFile = path.join(tempTestFolder, 'test.heic')
|
|
460
466
|
const heicBefore = fs.statSync(heicFile)
|
|
461
467
|
const filesBefore = fs.readdirSync(tempTestFolder).sort()
|
|
462
468
|
|
|
463
469
|
const originalCwd = process.cwd()
|
|
470
|
+
let output = ''
|
|
464
471
|
try {
|
|
465
472
|
process.chdir(tempDir)
|
|
466
|
-
execSync(`node "${imageGuardScript}" --heic-to-avif --dry`, { stdio: 'pipe' })
|
|
473
|
+
output = execSync(`node "${imageGuardScript}" --heic-to-avif --dry`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
467
474
|
} finally {
|
|
468
475
|
process.chdir(originalCwd)
|
|
469
476
|
}
|
|
@@ -477,10 +484,13 @@ describe('Image Guard', () => {
|
|
|
477
484
|
const filesAfter = fs.readdirSync(tempTestFolder).sort()
|
|
478
485
|
assert.deepStrictEqual(filesAfter, filesBefore)
|
|
479
486
|
|
|
487
|
+
// Dry run should report size data for HEIC conversion
|
|
488
|
+
assert.match(output, /Converted.*test\.heic.*KB.*→.*KB/i, 'Dry run should report HEIC conversion sizes')
|
|
489
|
+
|
|
480
490
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
481
491
|
})
|
|
482
492
|
|
|
483
|
-
test('HEIC files are ignored without `--heic-to-avif` flag', () => {
|
|
493
|
+
test('Ensure HEIC files are ignored without `--heic-to-avif` flag', () => {
|
|
484
494
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-noflag-'))
|
|
485
495
|
const tempTestFolder = path.join(tempDir, 'test')
|
|
486
496
|
copyFiles(testFolder, tempTestFolder)
|
|
@@ -504,7 +514,25 @@ describe('Image Guard', () => {
|
|
|
504
514
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
505
515
|
})
|
|
506
516
|
|
|
507
|
-
test('
|
|
517
|
+
test('Skip and report corrupt files', () => {
|
|
518
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-corrupt-'))
|
|
519
|
+
const tempTestFolder = path.join(tempDir, 'test')
|
|
520
|
+
copyFiles(testFolder, tempTestFolder)
|
|
521
|
+
|
|
522
|
+
const originalCwd = process.cwd()
|
|
523
|
+
let output = ''
|
|
524
|
+
try {
|
|
525
|
+
process.chdir(tempDir)
|
|
526
|
+
output = execSync(`node "${imageGuardScript}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
527
|
+
} finally {
|
|
528
|
+
process.chdir(originalCwd)
|
|
529
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
assert.match(output, /Skipped.*test#corrupt\.gif.*corrupt file/i)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('Ensure `--keep-heic` without `--heic-to-avif` issues warning', () => {
|
|
508
536
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-warn-'))
|
|
509
537
|
const tempTestFolder = path.join(tempDir, 'test')
|
|
510
538
|
copyFiles(testFolder, tempTestFolder)
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -97,11 +97,7 @@ export async function runImageGuard() {
|
|
|
97
97
|
let addedKB = 0
|
|
98
98
|
for (const r of results) {
|
|
99
99
|
if (r.status === 'fulfilled') {
|
|
100
|
-
|
|
101
|
-
addedKB += r.value
|
|
102
|
-
} else {
|
|
103
|
-
hadFailures = true
|
|
104
|
-
}
|
|
100
|
+
addedKB += r.value
|
|
105
101
|
} else {
|
|
106
102
|
hadFailures = true
|
|
107
103
|
const reason = r.reason && r.reason.message ? r.reason.message : String(r.reason)
|
|
@@ -192,32 +188,44 @@ export async function runImageGuard() {
|
|
|
192
188
|
const diffOutput = await git.raw(['diff', '--name-only', '--cached', '--diff-filter=ACMRT'])
|
|
193
189
|
const stagedFiles = diffOutput.split('\n').map(s => s.trim()).filter(Boolean)
|
|
194
190
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
191
|
+
// Collect compression and conversion candidates in parallel
|
|
192
|
+
const [compressionFiles, conversionFiles] = await Promise.all([
|
|
193
|
+
filterStagedFiles(stagedFiles, fileTypes),
|
|
194
|
+
doConversion ? filterStagedFiles(stagedFiles, convertTypes) : Promise.resolve([])
|
|
195
|
+
])
|
|
196
|
+
totalFiles += compressionFiles.length + conversionFiles.length
|
|
197
|
+
|
|
198
|
+
// Run compression and conversion in parallel (non-overlapping file sets)
|
|
199
|
+
const [compFailed, convFailed] = await Promise.all([
|
|
200
|
+
compress(compressionFiles, argv.dry),
|
|
201
|
+
convert(conversionFiles, argv.dry, argv['keep-heic'])
|
|
202
|
+
])
|
|
203
|
+
hadFailures = compFailed || convFailed
|
|
205
204
|
} catch (err) {
|
|
206
205
|
console.error(err)
|
|
207
206
|
hadFailures = true
|
|
208
207
|
}
|
|
209
208
|
} else {
|
|
210
|
-
|
|
211
|
-
const
|
|
209
|
+
// Single directory traversal for all relevant types
|
|
210
|
+
const allFiles = await findFiles(getFilePattern(argv.ignore, allTypes))
|
|
211
|
+
|
|
212
|
+
const compExts = new Set(fileTypes)
|
|
213
|
+
const compressionFiles = allFiles.filter(f => compExts.has(path.extname(f).slice(1).toLowerCase()))
|
|
212
214
|
totalFiles += compressionFiles.length
|
|
213
|
-
hadFailures = await compress(compressionFiles, argv.dry)
|
|
214
215
|
|
|
215
216
|
if (doConversion) {
|
|
216
|
-
const
|
|
217
|
-
const conversionFiles =
|
|
217
|
+
const convExts = new Set(convertTypes)
|
|
218
|
+
const conversionFiles = allFiles.filter(f => convExts.has(path.extname(f).slice(1).toLowerCase()))
|
|
218
219
|
totalFiles += conversionFiles.length
|
|
219
|
-
|
|
220
|
-
|
|
220
|
+
|
|
221
|
+
// Run compression and conversion in parallel (non-overlapping file sets)
|
|
222
|
+
const [compFailed, convFailed] = await Promise.all([
|
|
223
|
+
compress(compressionFiles, argv.dry),
|
|
224
|
+
convert(conversionFiles, argv.dry, argv['keep-heic'])
|
|
225
|
+
])
|
|
226
|
+
hadFailures = compFailed || convFailed
|
|
227
|
+
} else {
|
|
228
|
+
hadFailures = await compress(compressionFiles, argv.dry)
|
|
221
229
|
}
|
|
222
230
|
}
|
|
223
231
|
|
package/src/utils.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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 fs from 'fs'
|
|
4
|
-
import path from 'path'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
5
|
import sharp from 'sharp'
|
|
6
6
|
import { styleText } from 'node:util'
|
|
7
7
|
import decode from 'heic-decode'
|
|
8
8
|
|
|
9
|
+
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
|
|
10
|
+
|
|
9
11
|
const logMessage = (message, dry, color = 'yellow', quiet = false) => {
|
|
10
12
|
if (quiet) return
|
|
11
13
|
const prefix = dry ? 'Dry run: ' : ''
|
|
@@ -38,9 +40,7 @@ const compression = async (filename, dry, quiet = false) => {
|
|
|
38
40
|
return 0
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (fileSizeBefore > maxFileSize) {
|
|
43
|
+
if (fileSizeBefore > MAX_FILE_SIZE) {
|
|
44
44
|
logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
|
|
45
45
|
return 0
|
|
46
46
|
}
|
|
@@ -114,17 +114,17 @@ const compression = async (filename, dry, quiet = false) => {
|
|
|
114
114
|
details = `${sizeReadable(fileSizeBefore)} → ${sizeReadable(fileSizeAfter)}`
|
|
115
115
|
if (!dry) {
|
|
116
116
|
// Only now create a backup and replace the original
|
|
117
|
-
await retryFileOperation(() => fs.
|
|
117
|
+
await retryFileOperation(() => fs.copyFile(filename, filenameBackup))
|
|
118
118
|
// Prefer atomic rename when possible
|
|
119
119
|
try {
|
|
120
|
-
await retryFileOperation(() => fs.
|
|
120
|
+
await retryFileOperation(() => fs.rename(tempFilePath, filename))
|
|
121
121
|
// Temp file was renamed (consumed)
|
|
122
122
|
tempConsumed = true
|
|
123
123
|
replacementSucceeded = true
|
|
124
124
|
} catch {
|
|
125
125
|
// Fallback to copy when rename across devices isn’t possible
|
|
126
|
-
await retryFileOperation(() => fs.
|
|
127
|
-
await retryFileOperation(() => fs.
|
|
126
|
+
await retryFileOperation(() => fs.copyFile(tempFilePath, filename))
|
|
127
|
+
await retryFileOperation(() => fs.unlink(tempFilePath))
|
|
128
128
|
// Temp file explicitly removed after copy
|
|
129
129
|
tempConsumed = true
|
|
130
130
|
replacementSucceeded = true
|
|
@@ -133,23 +133,13 @@ const compression = async (filename, dry, quiet = false) => {
|
|
|
133
133
|
} else if (fileSizeAfter > fileSizeBefore) {
|
|
134
134
|
color = 'blue'
|
|
135
135
|
status = 'Skipped'
|
|
136
|
-
details = 'already compressed more
|
|
136
|
+
details = 'already compressed more effectively'
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
|
|
140
140
|
|
|
141
141
|
if (dry) {
|
|
142
|
-
|
|
143
|
-
return 0
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Clean up temp file only when it wasn’t consumed
|
|
147
|
-
if (!tempConsumed) {
|
|
148
|
-
try {
|
|
149
|
-
await retryFileOperation(() => fs.promises.unlink(tempFilePath))
|
|
150
|
-
} catch (err) {
|
|
151
|
-
if (err.code !== 'ENOENT') throw err
|
|
152
|
-
}
|
|
142
|
+
return 0 // Temp file cleaned up in finally
|
|
153
143
|
}
|
|
154
144
|
|
|
155
145
|
if (fileSizeAfter === 0) {
|
|
@@ -160,15 +150,17 @@ const compression = async (filename, dry, quiet = false) => {
|
|
|
160
150
|
|
|
161
151
|
} catch (err) {
|
|
162
152
|
|
|
163
|
-
// Check if this is a file corruption error
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
153
|
+
// Check if this is a file corruption error (sharp/libvips error messages)
|
|
154
|
+
const msg = err.message ? err.message.toLowerCase() : ''
|
|
155
|
+
if (msg.includes('corrupt header') ||
|
|
156
|
+
msg.includes('unexpected end of') ||
|
|
157
|
+
msg.includes('invalid image') ||
|
|
158
|
+
msg.includes('gifload:') ||
|
|
159
|
+
msg.includes('pngload:') ||
|
|
160
|
+
msg.includes('jpegload:') ||
|
|
161
|
+
msg.includes('webpload:') ||
|
|
162
|
+
msg.includes('avifload:')
|
|
163
|
+
) {
|
|
172
164
|
logMessage(`Skipped ${filename} (corrupt file)`, dry, 'yellow', quiet)
|
|
173
165
|
} else {
|
|
174
166
|
console.error(styleText('red', `Error compressing ${filename}:`), err)
|
|
@@ -177,15 +169,32 @@ const compression = async (filename, dry, quiet = false) => {
|
|
|
177
169
|
|
|
178
170
|
} finally {
|
|
179
171
|
|
|
172
|
+
// Clean up temp file if it wasn’t consumed (covers dry-run, error, and no-improvement paths)
|
|
173
|
+
if (!tempConsumed) {
|
|
174
|
+
try {
|
|
175
|
+
await retryFileOperation(() => fs.unlink(tempFilePath))
|
|
176
|
+
} catch {
|
|
177
|
+
// Best-effort cleanup—ignore all errors to avoid masking the original error
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
180
181
|
// If backup created (i.e., only in improvement path), try to remove it
|
|
181
182
|
if (!dry && replacementSucceeded) {
|
|
182
183
|
try {
|
|
183
|
-
await retryFileOperation(() => fs.
|
|
184
|
+
await retryFileOperation(() => fs.unlink(filenameBackup))
|
|
184
185
|
} catch (err) {
|
|
185
186
|
if (err.code !== 'ENOENT') {
|
|
186
187
|
console.warn(styleText('yellow', `Failed to delete backup file ${filenameBackup}:`), err)
|
|
187
188
|
}
|
|
188
189
|
}
|
|
190
|
+
} else if (!dry && !replacementSucceeded) {
|
|
191
|
+
// If a backup was created but replacement failed, warn so the user can recover it
|
|
192
|
+
try {
|
|
193
|
+
await fs.access(filenameBackup)
|
|
194
|
+
console.warn(styleText('yellow', `Replacement failed for ${filename}; backup preserved at ${filenameBackup}`))
|
|
195
|
+
} catch {
|
|
196
|
+
// No backup exists—nothing to warn about
|
|
197
|
+
}
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
}
|
|
@@ -199,9 +208,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
|
|
|
199
208
|
return 0
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (fileSizeBefore > maxFileSize) {
|
|
211
|
+
if (fileSizeBefore > MAX_FILE_SIZE) {
|
|
205
212
|
logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
|
|
206
213
|
return 0
|
|
207
214
|
}
|
|
@@ -215,7 +222,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
|
|
|
215
222
|
|
|
216
223
|
// Avoid overwriting an existing AVIF file
|
|
217
224
|
try {
|
|
218
|
-
await fs.
|
|
225
|
+
await fs.access(avifPath)
|
|
219
226
|
logMessage(`Skipped ${filename} (${path.basename(avifPath)} already exists)`, dry, 'yellow', quiet)
|
|
220
227
|
return 0
|
|
221
228
|
} catch {
|
|
@@ -224,14 +231,9 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
|
|
|
224
231
|
|
|
225
232
|
try {
|
|
226
233
|
// Decode HEIC/HEIF to raw pixel data
|
|
227
|
-
const inputBuffer = await fs.
|
|
234
|
+
const inputBuffer = await fs.readFile(filename)
|
|
228
235
|
const { width, height, data } = await decode({ buffer: inputBuffer })
|
|
229
236
|
|
|
230
|
-
if (dry) {
|
|
231
|
-
logMessage(`Converted ${filename} → ${path.basename(avifPath)}`, dry, 'cyan', quiet)
|
|
232
|
-
return 0
|
|
233
|
-
}
|
|
234
|
-
|
|
235
237
|
// Encode as lossy AVIF—HEIC sources are already lossy (HEVC), so lossless
|
|
236
238
|
// re-encoding would inflate file sizes without any quality benefit
|
|
237
239
|
await sharp(data, { raw: { width, height, channels: 4 } })
|
|
@@ -242,9 +244,14 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
|
|
|
242
244
|
|
|
243
245
|
logMessage(`Converted ${filename} → ${path.basename(avifPath)} (${sizeReadable(fileSizeBefore)} → ${sizeReadable(fileSizeAfter)})`, dry, 'cyan', quiet)
|
|
244
246
|
|
|
247
|
+
if (dry) {
|
|
248
|
+
await retryFileOperation(() => fs.unlink(avifPath))
|
|
249
|
+
return 0
|
|
250
|
+
}
|
|
251
|
+
|
|
245
252
|
// Delete original HEIC/HEIF file unless `--keep-heic` is set
|
|
246
253
|
if (!keepOriginal) {
|
|
247
|
-
await retryFileOperation(() => fs.
|
|
254
|
+
await retryFileOperation(() => fs.unlink(filename))
|
|
248
255
|
}
|
|
249
256
|
|
|
250
257
|
return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
|
|
@@ -252,7 +259,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
|
|
|
252
259
|
} catch (err) {
|
|
253
260
|
// Clean up partial AVIF if it was created
|
|
254
261
|
try {
|
|
255
|
-
await fs.
|
|
262
|
+
await retryFileOperation(() => fs.unlink(avifPath))
|
|
256
263
|
} catch (cleanupErr) {
|
|
257
264
|
if (cleanupErr.code !== 'ENOENT') {
|
|
258
265
|
console.warn(styleText('yellow', `Failed to clean up ${avifPath}:`), cleanupErr)
|
|
@@ -272,7 +279,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
|
|
|
272
279
|
}
|
|
273
280
|
|
|
274
281
|
const size = async (file) => {
|
|
275
|
-
const stats = await fs.
|
|
282
|
+
const stats = await fs.stat(file)
|
|
276
283
|
return stats.size
|
|
277
284
|
}
|
|
278
285
|
|