UNPKG

@j9t/imagemin-guard

Version:

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

137 lines (111 loc) 4.41 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 { execFile } from 'child_process' import fs from 'fs' import gifsicle from 'gifsicle' import os from 'os' import path from 'path' import sharp from 'sharp' import { styleText } from 'node:util' import util from '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` 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(), 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” // @@ Refactor for better maintainability and configurability if (outputFormat === 'png') { await sharp(filename, { pages: -1 }) .png({ 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 .toFile(tempFilePath) } else if (outputFormat === 'gif') { const execFileAsync = util.promisify(execFile) try { await execFileAsync(gifsicle, ['-O3', filename, '-o', tempFilePath], { stdio: ['ignore', 'ignore', 'ignore'] }) } catch (err) { logMessage(`Skipped ${filename} (appears corrupt)`, dry) return 0 } } else if (outputFormat === 'webp') { await sharp(filename, { pages: -1 }) .webp({ animated: true, lossless: true }) .toFile(tempFilePath) } else if (outputFormat === 'avif') { await sharp(filename) // Temporarily specifying effort, too, as per https://github.com/lovell/sharp/issues/4370#issuecomment-2798848572 .avif({ effort: 5, lossless: true }) .toFile(tempFilePath) } else { 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) { fs.unlinkSync(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) { console.error(styleText('red', `Error compressing ${filename}:`), error) await fs.promises.rename(filenameBackup, filename) return 0 } finally { 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 }