UNPKG

@j9t/imagemin-guard

Version:

Ensure losslessly compressed PNG, JPG, GIF, WebP, and AVIF images (suitable for manual and automatic compression)

200 lines (171 loc) 6.95 kB
// This file, which had been forked from imagemin-merlin, was modified for imagemin-guard: https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master import fs from 'fs' import path from 'path' import sharp from 'sharp' import { styleText } from 'node:util' const logMessage = (message, dry, color = 'yellow', quiet = false) => { if (quiet) return const prefix = dry ? 'Dry run: ' : '' console.info(styleText(color, `${prefix}${message}`)) } // Retry file operations to handle file locking issues const retryFileOperation = async (operation, maxRetries = 5, delayMs = 100) => { for (let i = 0; i < maxRetries; i++) { try { return await operation() } catch (err) { if ((err.code === 'EPERM' || err.code === 'UNKNOWN') && i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delayMs * (i + 1))) } else { throw err } } } } const compression = async (filename, dry, quiet = false) => { const filenameBackup = `${filename}.bak` const fileSizeBefore = await size(filename) // Track whether original file was successfully replaced let replacementSucceeded = false if (fileSizeBefore === 0) { logMessage(`Skipped ${filename} (${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet) return 0 } const maxFileSize = 100 * 1024 * 1024 // 100 MB if (fileSizeBefore > maxFileSize) { logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet) return 0 } // Place temp file next to the original to maximize same-device atomic rename const tempFilePath = path.join( path.dirname(filename), `.imagemin-guard-${Date.now()}-${Math.random().toString(36).slice(2)}-${path.basename(filename)}` ) // Track whether the temporary file has been “consumed” (renamed into place or explicitly deleted after copy) let tempConsumed = false try { const ext = path.extname(filename).slice(1).toLowerCase() if (!ext) { throw new Error(`Cannot determine file type for ${filename}—no extension found`) } const outputFormat = ext === 'jpg' ? 'jpeg' : ext // sharp uses “jpeg” instead of “jpg” // Compression configuration for each format const formatConfigs = { png: { options: { pages: -1 }, 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 }, gif: { options: { pages: -1 }, settings: { reuse: true, // Preserve original palette for lossless quality (default) effort: 10, // Maximum compression effort dither: 0, // No dithering = lossless quality interFrameMaxError: 0, // No transparency errors = lossless (default) interPaletteMaxError: 0, // Perfect palette match = lossless colors: 256 // Full palette available (default) } }, webp: { options: { pages: -1 }, settings: { animated: true, lossless: true } }, avif: { options: {}, settings: { effort: 5, lossless: true } // Temporarily specifying effort, too, as per https://github.com/lovell/sharp/issues/4370#issuecomment-2798848572 } } // Apply format-specific compression or use default const config = formatConfigs[outputFormat] if (config) { await sharp(filename, config.options) .toFormat(outputFormat, config.settings) .toFile(tempFilePath) } else { // Fallback for any other supported formats (like JPG) await sharp(filename) .toFormat(outputFormat, { quality: 100 }) .toFile(tempFilePath) } const fileSizeAfter = await size(tempFilePath) let color = 'white' let status = 'Skipped' let details = 'already compressed' if (fileSizeAfter < fileSizeBefore) { color = 'green' status = 'Compressed' details = `${sizeReadable(fileSizeBefore)}${sizeReadable(fileSizeAfter)}` if (!dry) { // Only now create a backup and replace the original await retryFileOperation(() => fs.promises.copyFile(filename, filenameBackup)) // Prefer atomic rename when possible try { await retryFileOperation(() => fs.promises.rename(tempFilePath, filename)) // Temp file was renamed (consumed) tempConsumed = true replacementSucceeded = true } catch { // Fallback to copy when rename across devices isn’t possible await retryFileOperation(() => fs.promises.copyFile(tempFilePath, filename)) await retryFileOperation(() => fs.promises.unlink(tempFilePath)) // Temp file explicitly removed after copy tempConsumed = true replacementSucceeded = true } } } else if (fileSizeAfter > fileSizeBefore) { color = 'blue' status = 'Skipped' details = 'already compressed more aggressively' } logMessage(`${status} ${filename} (${details})`, dry, color, quiet) if (dry) { await retryFileOperation(() => fs.promises.unlink(tempFilePath)) return 0 } // Clean up temp file only when it wasn’t consumed if (!tempConsumed) { try { await retryFileOperation(() => fs.promises.unlink(tempFilePath)) } catch (err) { if (err.code !== 'ENOENT') throw err } } if (fileSizeAfter === 0) { console.error(styleText('red', `Error compressing ${filename}: Compressed file size is 0`)) } return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0 } catch (err) { // Check if this is a file corruption error if (err.message && ( err.message.includes('corrupt header') || err.message.includes('Unexpected end of') || err.message.includes('Invalid') || err.message.includes('gifload:') || err.message.includes('pngload:') || err.message.includes('jpegload:') )) { logMessage(`Skipped ${filename} (corrupt file)`, dry, 'yellow', quiet) } else { console.error(styleText('red', `Error compressing ${filename}:`), err) } return 0 } finally { // If backup created (i.e., only in improvement path), try to remove it if (!dry && replacementSucceeded) { try { await retryFileOperation(() => fs.promises.unlink(filenameBackup)) } catch (err) { if (err.code !== 'ENOENT') { console.warn(styleText('yellow', `Failed to delete backup file ${filenameBackup}:`), err) } } } } } const size = async (file) => { const stats = await fs.promises.stat(file) return stats.size } const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB` export const utils = { compression, sizeReadable }