UNPKG

@j9t/imagemin-guard

Version:

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

165 lines (137 loc) 5.3 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 os from 'os' import path from 'path' import sharp from 'sharp' import { styleText } from 'node:util' const logMessage = (message, dry, color = 'yellow') => { const prefix = dry ? 'Dry run: ' : '' console.info(styleText(color, `${prefix}${message}`)) } const compression = async (filename, dry) => { const filenameBackup = `${filename}.bak` if (!dry) { try { await fs.promises.copyFile(filename, filenameBackup) } catch (error) { console.error(styleText('red', `Error creating backup for ${filename}:`), error) return 0 } } const fileSizeBefore = await size(filename) if (fileSizeBefore === 0) { logMessage(`Skipped ${filename} (${sizeReadable(fileSizeBefore)})`, dry) return 0 } const maxFileSize = 100 * 1024 * 1024 // 100 MB if (fileSizeBefore > maxFileSize) { logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry) return 0 } const tempFilePath = path.join(os.tmpdir(), `imagemin-${Date.now()}-${Math.random().toString(36).slice(2)}-${path.basename(filename)}`) 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) { await fs.promises.copyFile(tempFilePath, filename) } } else if (fileSizeAfter > fileSizeBefore) { color = 'blue' status = 'Skipped' details = 'already compressed more aggressively' } logMessage(`${status} ${filename} (${details})`, dry, color) if (dry) { await fs.promises.unlink(tempFilePath) return 0 } await fs.promises.unlink(tempFilePath) if (fileSizeAfter === 0) { console.error(styleText('red', `Error compressing ${filename}: Compressed file size is 0`)) } return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0 } catch (error) { // Check if this is a file corruption error if (error.message && ( error.message.includes('corrupt header') || error.message.includes('Unexpected end of') || error.message.includes('Invalid') || error.message.includes('gifload:') || error.message.includes('pngload:') || error.message.includes('jpegload:') )) { logMessage(`Skipped ${filename} (corrupt file)`, dry, 'yellow') } else { console.error(styleText('red', `Error compressing ${filename}:`), error) } if (!dry) { await fs.promises.rename(filenameBackup, filename) } return 0 } finally { if (!dry) { try { await fs.promises.unlink(filenameBackup) } catch (error) { if (error.code !== 'ENOENT') { console.warn(styleText('yellow', `Failed to delete backup file ${filenameBackup}:`), error) } } } } } 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 }