const sharp = require('sharp'); const fs = require('fs').promises; const path = require('path'); const ffmpeg = require('fluent-ffmpeg'); // 支持的图片格式 const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp']; const AUDIO_EXTENSIONS = ['.mp3']; // 压缩配置 const COMPRESSION_OPTIONS = { jpeg: { quality: 60, // 降低质量以获得更高压缩率 mozjpeg: true, // 使用 mozjpeg 压缩 chromaSubsampling: '4:2:0' // 更激进的色度采样 }, png: { quality: 60, // 降低质量 compressionLevel: 9, // 最高压缩级别 palette: true, // 使用调色板模式 effort: 10, // 最大压缩效果 colors: 128 // 减少调色板颜色数量以增加压缩率 }, webp: { quality: 60, // 降低质量 effort: 6, // 最高压缩效果 lossless: false, // 有损压缩 nearLossless: false // 关闭近无损压缩 } }; // 格式化文件大小 function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; } async function processDirectory(inputDir) { try { const items = await fs.readdir(inputDir); let totalOriginalSize = 0; let totalCompressedSize = 0; for (const item of items) { const filePath = path.join(inputDir, item); const stats = await fs.stat(filePath); if (stats.isDirectory()) { await processDirectory(filePath); } else if (stats.isFile()) { const ext = path.extname(item).toLowerCase(); const originalSize = stats.size; if (IMAGE_EXTENSIONS.includes(ext)) { await compressImage(filePath); } else if (AUDIO_EXTENSIONS.includes(ext)) { await compressAudio(filePath); } else { continue; } const compressedStats = await fs.stat(filePath); const compressedSize = compressedStats.size; totalOriginalSize += originalSize; totalCompressedSize += compressedSize; const savedSize = originalSize - compressedSize; const savingPercentage = ((savedSize / originalSize) * 100).toFixed(2); console.log(`已压缩: ${filePath}`); console.log(` 原始大小: ${formatFileSize(originalSize)}`); console.log(` 压缩后大小: ${formatFileSize(compressedSize)}`); console.log(` 节省: ${formatFileSize(savedSize)} (${savingPercentage}%)`); console.log('----------------------------------------'); } } if (totalOriginalSize > 0) { const totalSaved = totalOriginalSize - totalCompressedSize; const totalSavingPercentage = ((totalSaved / totalOriginalSize) * 100).toFixed(2); console.log(`\n当前目录 ${inputDir} 总计:`); console.log(` 原始总大小: ${formatFileSize(totalOriginalSize)}`); console.log(` 压缩后总大小: ${formatFileSize(totalCompressedSize)}`); console.log(` 总共节省: ${formatFileSize(totalSaved)} (${totalSavingPercentage}%)\n`); } } catch (error) { console.error('处理出错:', error); } } async function compressImage(filePath) { const ext = path.extname(filePath).toLowerCase(); const tempPath = filePath + '.temp'; const image = sharp(filePath); try { // 获取图片信息 const metadata = await image.metadata(); // 根据图片大小和类型选择压缩策略 switch (ext) { case '.jpg': case '.jpeg': await image .jpeg(COMPRESSION_OPTIONS.jpeg) .toFile(tempPath); break; case '.png': // 如果PNG是透明的,保持PNG格式 if (metadata.hasAlpha) { await image .png(COMPRESSION_OPTIONS.png) .toFile(tempPath); } else { // 如果PNG不是透明的,转换为JPEG可能会得到更好的压缩效果 await image .jpeg(COMPRESSION_OPTIONS.jpeg) .toFile(tempPath); } break; case '.webp': await image .webp(COMPRESSION_OPTIONS.webp) .toFile(tempPath); break; } // 检查压缩后的文件大小 const originalStats = await fs.stat(filePath); const compressedStats = await fs.stat(tempPath); // 只有当压缩后的文件更小时才替换 if (compressedStats.size < originalStats.size) { await fs.unlink(filePath); await fs.rename(tempPath, filePath); console.log(` 压缩成功: ${formatFileSize(originalStats.size)} -> ${formatFileSize(compressedStats.size)}`); } else { // 如果压缩后反而更大,则删除临时文件保留原文件 await fs.unlink(tempPath); console.log(` 跳过压缩: 原始大小 ${formatFileSize(originalStats.size)}, 压缩后 ${formatFileSize(compressedStats.size)}`); console.log(` 原因: 压缩后文件更大,已保留原文件`); } } catch (error) { try { await fs.unlink(tempPath); } catch (e) { // 忽略临时文件删除失败的错误 } throw error; } } async function compressAudio(filePath) { const tempPath = filePath + '.temp'; return new Promise((resolve, reject) => { ffmpeg(filePath) .toFormat('mp3') .audioBitrate(64) // 降低到64kbps以获得最大压缩 .audioChannels(1) // 转换为单声道 .audioFrequency(22050) // 降低采样率 .audioCodec('libmp3lame') // 使用LAME编码器 .addOptions([ '-q:a 9', // 最高压缩质量 '-compression_level 9' // 最高压缩级别 ]) .on('end', async () => { try { // 检查压缩后的文件大小 const originalStats = await fs.stat(filePath); const compressedStats = await fs.stat(tempPath); // 只有当压缩后的文件更小时才替换 if (compressedStats.size < originalStats.size) { await fs.unlink(filePath); await fs.rename(tempPath, filePath); console.log(` 压缩成功: ${formatFileSize(originalStats.size)} -> ${formatFileSize(compressedStats.size)}`); } else { await fs.unlink(tempPath); console.log(` 跳过压缩: 原始大小 ${formatFileSize(originalStats.size)}, 压缩后 ${formatFileSize(compressedStats.size)}`); console.log(` 原因: 压缩后文件更大,已保留原文件`); } resolve(); } catch (error) { reject(error); } }) .on('error', (err) => { reject(err); }) .save(tempPath); }); } // 设置输入目录 const INPUT_DIR = './assets'; // 运行脚本 (async () => { console.log('开始处理图片压缩...'); await processDirectory(INPUT_DIR); console.log('图片压缩完成!'); })();