205 lines
7.7 KiB
JavaScript
205 lines
7.7 KiB
JavaScript
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('图片压缩完成!');
|
||
})();
|