shpck
Version:
Ultra-fast, multi-threaded file compression tool for images, videos, and media files
727 lines (635 loc) • 27.2 kB
JavaScript
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { FileUtils } = require('../utils/fileUtils');
class ImageCompressor {
constructor(options = {}) {
this.options = options;
this.speedOptimized = options.speedOptimized || false;
this.skipOptimizations = options.skipOptimizations || false;
}
async compress(inputFile, options = {}) {
const mergedOptions = { ...this.options, ...options };
let originalSize, outputPath, sharpInstance, metadata;
if (Buffer.isBuffer(inputFile)) {
originalSize = inputFile.length;
sharpInstance = sharp(inputFile);
metadata = await sharpInstance.metadata();
sharpInstance = await this.applyTransformations(sharpInstance, metadata, mergedOptions);
if (sharpInstance._targetSize) {
sharpInstance._originalSize = originalSize;
}
const format = this.determineOutputFormat('fragment.jpg', mergedOptions.format);
let buffer;
switch (format.toLowerCase()) {
case 'jpeg':
case 'jpg':
buffer = await sharpInstance.jpeg({ quality: mergedOptions.quality || 85 }).toBuffer();
break;
case 'png':
buffer = await sharpInstance.png({ quality: mergedOptions.quality || 85 }).toBuffer();
break;
case 'webp':
buffer = await sharpInstance.webp({ quality: mergedOptions.quality || 85 }).toBuffer();
break;
case 'avif':
buffer = await sharpInstance.avif({ quality: mergedOptions.quality || 50 }).toBuffer();
break;
default:
buffer = await sharpInstance.jpeg({ quality: mergedOptions.quality || 85 }).toBuffer();
}
return {
inputFile,
outputFile: null,
originalSize,
compressedSize: buffer.length,
buffer,
reduction: ((originalSize - buffer.length) / originalSize * 100).toFixed(2),
metadata: {
width: metadata.width,
height: metadata.height,
format: metadata.format,
hasAlpha: metadata.hasAlpha
}
};
} else {
const originalStats = await fs.stat(inputFile);
originalSize = originalStats.size;
outputPath = this.generateOutputPath(inputFile, mergedOptions);
sharpInstance = sharp(inputFile);
metadata = await sharpInstance.metadata();
sharpInstance = await this.applyTransformations(sharpInstance, metadata, mergedOptions);
if (sharpInstance._targetSize) {
sharpInstance._originalSize = originalSize;
}
const finalOutputPath = await this.applyCompression(sharpInstance, outputPath, mergedOptions);
const actualOutputPath = finalOutputPath || outputPath;
const compressedStats = await fs.stat(actualOutputPath);
const compressedSize = compressedStats.size;
return {
inputFile,
outputFile: actualOutputPath,
originalSize,
compressedSize,
reduction: ((originalSize - compressedSize) / originalSize * 100).toFixed(2),
metadata: {
width: metadata.width,
height: metadata.height,
format: metadata.format,
hasAlpha: metadata.hasAlpha
}
};
}
}
async applyTransformations(sharpInstance, metadata, options) {
if (options.width || options.height) {
const resizeOptions = {
width: options.width ? parseInt(options.width) : null,
height: options.height ? parseInt(options.height) : null,
fit: 'inside',
withoutEnlargement: true
};
sharpInstance = sharpInstance.resize(resizeOptions);
}
if (options.targetSize) {
const targetBytes = this.parseTargetSize(options.targetSize);
sharpInstance._targetSize = targetBytes;
}
return sharpInstance;
}
async applyCompression(sharpInstance, outputPath, options) {
const format = this.determineOutputFormat(outputPath, options.format);
let quality = parseInt(options.quality) || 85;
quality = Math.max(1, Math.min(100, quality));
if (format.toLowerCase() === 'png') {
if (quality > 95) {
quality = 90;
}
} else if (this.speedOptimized && !options.quality) {
quality = Math.min(quality, 70);
}
let compressionOptions = {};
switch (format.toLowerCase()) {
case 'jpeg':
case 'jpg':
compressionOptions = {
quality,
progressive: this.speedOptimized ? false : (options.progressive || true),
mozjpeg: !this.speedOptimized,
optimiseScans: !this.skipOptimizations,
optimiseCoding: !this.skipOptimizations
};
if (sharpInstance._targetSize) {
return await this.compressToTargetSize(sharpInstance, outputPath, 'jpeg', compressionOptions, sharpInstance._originalSize);
} else {
await sharpInstance.jpeg(compressionOptions).toFile(outputPath);
return outputPath;
}
break;
case 'png':
const pngCompressionLevel = this.speedOptimized ? 6 : 9;
const shouldUsePalette = quality < 90 || this.speedOptimized;
if (!this.options.skip) {
console.log(`🔧 PNG Settings: quality=${quality}, level=${pngCompressionLevel}, palette=${shouldUsePalette}, ultrafast=${this.speedOptimized}`);
}
compressionOptions = {
compressionLevel: pngCompressionLevel,
adaptiveFiltering: !this.skipOptimizations,
palette: shouldUsePalette,
quality: Math.min(quality, 95),
progressive: false
};
if (sharpInstance._targetSize) {
return await this.compressToTargetSize(sharpInstance, outputPath, 'png', compressionOptions, sharpInstance._originalSize);
} else {
await sharpInstance.png(compressionOptions).toFile(outputPath);
return outputPath;
}
break;
case 'webp':
compressionOptions = {
quality,
lossless: false,
effort: this.speedOptimized ? 1 : 4,
nearLossless: false
};
if (sharpInstance._targetSize) {
return await this.compressToTargetSize(sharpInstance, outputPath, 'webp', compressionOptions, sharpInstance._originalSize);
} else {
await sharpInstance.webp(compressionOptions).toFile(outputPath);
return outputPath;
}
break;
case 'avif':
compressionOptions = {
quality,
lossless: false,
effort: this.speedOptimized ? 1 : 4
};
if (sharpInstance._targetSize) {
return await this.compressToTargetSize(sharpInstance, outputPath, 'avif', compressionOptions, sharpInstance._originalSize);
} else {
await sharpInstance.avif(compressionOptions).toFile(outputPath);
return outputPath;
}
break;
default:
await sharpInstance.jpeg({ quality, progressive: true }).toFile(outputPath);
return outputPath;
}
}
async compressToTargetSize(sharpInstance, outputPath, format, baseOptions, originalSize) {
const targetSize = sharpInstance._targetSize;
const maxAttempts = 25;
if (!this.options.skip) {
console.log(`🎯 Target size: ${(targetSize / 1024).toFixed(1)}KB`);
const compressionRatio = (targetSize / originalSize * 100).toFixed(2);
console.log(`📊 Required compression: ${compressionRatio}% of original size`);
console.log(`🔍 Debug: originalSize=${originalSize}, targetSize=${targetSize}, ratio=${(targetSize / originalSize).toFixed(4)}`);
}
const compressionRatio = targetSize / originalSize;
if (compressionRatio < 0.10) {
console.log(`🚀 EXTREME COMPRESSION MODE TRIGGERED! (ratio: ${(compressionRatio * 100).toFixed(2)}%)`);
return await this.extremeCompressionStrategy(sharpInstance, outputPath, format, baseOptions, originalSize, targetSize);
}
if (!this.options.skip) {
console.log(`📝 Using standard compression (ratio: ${(compressionRatio * 100).toFixed(2)}% >= 10%)`);
}
return await this.standardCompressionStrategy(sharpInstance, outputPath, format, baseOptions, originalSize, targetSize, maxAttempts);
}
async extremeCompressionStrategy(sharpInstance, outputPath, format, baseOptions, originalSize, targetSize) {
if (!this.options.skip) {
console.log(`🚀 EXTREME compression mode activated! Parallel processing...`);
}
const strategies = [
{ format: 'png', quality: 1, compressionLevel: 9, palette: true },
{ format: 'jpeg', quality: 1 },
{ format: 'jpeg', quality: 3 },
{ format: 'jpeg', quality: 5 },
{ format: 'jpeg', quality: 8 },
{ format: 'webp', quality: 1, effort: 0 },
{ format: 'webp', quality: 3, effort: 0 },
{ format: 'webp', quality: 5, effort: 0 },
{ format: 'jpeg', quality: 10, resize: 0.1 },
{ format: 'jpeg', quality: 15, resize: 0.15 },
{ format: 'jpeg', quality: 20, resize: 0.2 },
{ format: 'jpeg', quality: 25, resize: 0.25 },
{ format: 'jpeg', quality: 30, resize: 0.3 },
{ format: 'webp', quality: 10, resize: 0.1, effort: 0 },
{ format: 'webp', quality: 15, resize: 0.15, effort: 0 },
{ format: 'webp', quality: 20, resize: 0.2, effort: 0 },
{ format: 'png', quality: 1, resize: 0.1, compressionLevel: 9, palette: true },
{ format: 'png', quality: 1, resize: 0.15, compressionLevel: 9, palette: true },
{ format: 'jpeg', quality: 5, resize: 0.05 },
{ format: 'webp', quality: 5, resize: 0.05, effort: 0 },
];
const keepDims = this.options.keepDimensions || (baseOptions && baseOptions.keepDimensions);
let usedStrategies = strategies;
if (keepDims) {
usedStrategies = strategies.filter(s => !s.resize);
}
const promises = usedStrategies.map(async (strategy, index) => {
try {
let instance = sharpInstance.clone();
if (strategy.resize && !keepDims) {
const metadata = await sharpInstance.metadata();
const newWidth = Math.max(1, Math.floor(metadata.width * strategy.resize));
const newHeight = Math.max(1, Math.floor(metadata.height * strategy.resize));
instance = instance.resize(newWidth, newHeight, {
fit: 'inside',
withoutEnlargement: true,
kernel: 'nearest',
});
}
let buffer;
const startTime = Date.now();
switch (strategy.format) {
case 'png':
buffer = await instance.png({
quality: strategy.quality,
compressionLevel: strategy.compressionLevel,
palette: strategy.palette,
adaptiveFiltering: false,
progressive: false,
colours: strategy.resize ? 16 : 256
}).toBuffer();
break;
case 'jpeg':
buffer = await instance.jpeg({
quality: strategy.quality,
progressive: false,
mozjpeg: false,
optimiseScans: false,
optimiseCoding: false,
trellisQuantisation: false,
overshootDeringing: false,
optimizeScans: false
}).toBuffer();
break;
case 'webp':
buffer = await instance.webp({
quality: strategy.quality,
effort: strategy.effort || 0,
lossless: false,
nearLossless: false,
smartSubsample: false,
preset: 'picture'
}).toBuffer();
break;
}
const processingTime = Date.now() - startTime;
return {
index,
strategy,
buffer,
size: buffer.length,
processingTime,
success: buffer.length <= targetSize
};
} catch (error) {
return {
index,
strategy,
error: error.message,
success: false
};
}
});
if (!this.options.skip) {
console.log(`⚡ Testing ${strategies.length} compression strategies in parallel...`);
console.log(`🎯 Target: ${(targetSize / 1024).toFixed(1)}KB from ${(originalSize / (1024*1024)).toFixed(1)}MB`);
}
const results = await Promise.all(promises);
const successful = results
.filter(r => r.success && !r.error)
.sort((a, b) => a.size - b.size);
const fastest = results
.filter(r => r.success && !r.error)
.sort((a, b) => a.processingTime - b.processingTime)[0];
const bestQuality = results
.filter(r => r.success && !r.error)
.sort((a, b) => b.size - a.size)[0];
if (!this.options.skip) {
console.log(`📊 Parallel results (${results.length} strategies tested):`);
results.forEach((result, i) => {
if (result.error) {
console.log(` ❌ Strategy ${i + 1}: ${result.error}`);
} else {
const size = (result.size / 1024).toFixed(1);
const time = result.processingTime;
const status = result.success ? '✅' : '❌';
const desc = this.getStrategyDescription(result.strategy);
console.log(` ${status} Strategy ${i + 1}: ${size}KB (${time}ms) - ${desc}`);
}
});
if (fastest && !fastest.error) {
console.log(`⚡ Fastest strategy: ${(fastest.size / 1024).toFixed(1)}KB in ${fastest.processingTime}ms`);
}
if (bestQuality && bestQuality !== successful[0]) {
console.log(`🎨 Best quality option: ${(bestQuality.size / 1024).toFixed(1)}KB - ${this.getStrategyDescription(bestQuality.strategy)}`);
}
}
if (successful.length > 0) {
let filteredSuccessful = successful;
let filteredFastest = fastest;
let filteredBestQuality = bestQuality;
if (this.options.format && this.options.format !== 'auto') {
const targetFormat = this.options.format.toLowerCase();
filteredSuccessful = successful.filter(r => r.strategy.format.toLowerCase() === targetFormat);
filteredFastest = results
.filter(r => r.success && !r.error && r.strategy.format.toLowerCase() === targetFormat)
.sort((a, b) => a.processingTime - b.processingTime)[0];
filteredBestQuality = filteredSuccessful.length > 0 ?
filteredSuccessful.sort((a, b) => b.size - a.size)[0] : null;
if (filteredSuccessful.length === 0) {
if (!this.options.skip) {
console.log(`⚠️ No ${targetFormat.toUpperCase()} strategies achieved target. Using best available format.`);
}
filteredSuccessful = successful;
filteredFastest = fastest;
filteredBestQuality = bestQuality;
} else {
if (!this.options.skip) {
console.log(`🎯 Format restricted to ${targetFormat.toUpperCase()} (${filteredSuccessful.length} options available)`);
}
}
}
let chosen;
const strategy = this.options.strategy || 'auto';
switch (strategy) {
case 'size':
chosen = filteredSuccessful[0];
break;
case 'speed':
chosen = filteredFastest || filteredSuccessful[0];
break;
case 'quality':
chosen = filteredBestQuality || filteredSuccessful[0];
break;
case 'auto':
default:
chosen = this.selectBestStrategy(filteredSuccessful, filteredFastest, filteredBestQuality, targetSize);
break;
}
const ext = this.getExtensionForFormat(chosen.strategy.format);
const finalOutputPath = outputPath.replace(/\.[^.]+$/, ext);
await fs.writeFile(finalOutputPath, chosen.buffer);
if (!this.options.skip) {
const compressionAchieved = ((originalSize - chosen.size) / originalSize * 100).toFixed(2);
console.log(`📏 ${(originalSize / (1024*1024)).toFixed(1)}MB → ${(chosen.size / 1024).toFixed(1)}KB (${compressionAchieved}% reduction)`);
const strategyName = strategy === 'auto' ? 'auto-optimized' : strategy;
console.log(`🔧 Strategy used: ${strategyName} - ${this.getStrategyDescription(chosen.strategy)}`);
console.log(`💾 Saved as: ${path.basename(finalOutputPath)}`);
console.log(`⚡ Processing time: ${chosen.processingTime}ms`);
if (strategy !== 'size' && successful[0] !== chosen) {
console.log(`💡 Size strategy would give: ${(successful[0].size / 1024).toFixed(1)}KB`);
}
if (strategy !== 'speed' && fastest !== chosen && fastest) {
console.log(`💡 Speed strategy would give: ${(fastest.size / 1024).toFixed(1)}KB in ${fastest.processingTime}ms`);
}
if (strategy !== 'quality' && bestQuality !== chosen) {
console.log(`💡 Quality strategy would give: ${(bestQuality.size / 1024).toFixed(1)}KB`);
}
}
return finalOutputPath;
}
if (!this.options.skip) {
console.log(`⚠️ No parallel strategy achieved target. Falling back to iterative approach...`);
}
return await this.standardCompressionStrategy(sharpInstance, outputPath, format, baseOptions, originalSize, targetSize, 15);
}
selectBestStrategy(successful, fastest, bestQuality, targetSize) {
if (!successful || successful.length === 0) return null;
const smallest = successful[0];
if (successful.length === 1) {
if (!this.options.skip) {
console.log(`🤖 Auto-strategy: Only one option - choosing smallest`);
}
return smallest;
}
const sizeRange = bestQuality ? (bestQuality.size - smallest.size) / 1024 : 0;
const timeRange = fastest ? (smallest.processingTime - fastest.processingTime) : 0;
const targetSizeKB = targetSize / 1024;
if (fastest === smallest) {
if (!this.options.skip) {
console.log(`🤖 Auto-strategy: Perfect combo - fastest AND smallest!`);
console.log(` 💎 Best of both worlds: ${(smallest.size / 1024).toFixed(1)}KB in ${smallest.processingTime}ms`);
}
return smallest;
}
const qualityUtilization = bestQuality ? (bestQuality.size / targetSize) * 100 : 0;
const sizeUtilization = (smallest.size / targetSize) * 100;
if (bestQuality && qualityUtilization <= 95) {
if (!this.options.skip) {
console.log(`🤖 Auto-strategy: Quality fits budget (${qualityUtilization.toFixed(1)}% of ${targetSizeKB.toFixed(1)}KB) - quality wins!`);
console.log(` 🎨 Better image: ${(bestQuality.size / 1024).toFixed(1)}KB vs Smallest: ${(smallest.size / 1024).toFixed(1)}KB`);
}
return bestQuality;
} else {
if (!this.options.skip) {
if (bestQuality) {
console.log(`🤖 Auto-strategy: Quality too close to budget (${qualityUtilization.toFixed(1)}% > 95%)`);
} else {
console.log(`🤖 Auto-strategy: No quality option available - choosing smallest`);
}
console.log(` 💾 Safe choice: ${(smallest.size / 1024).toFixed(1)}KB of ${targetSizeKB.toFixed(1)}KB budget`);
}
return smallest;
}
if (timeRange > 500 && sizeRange < 0.5 && fastest) {
if (!this.options.skip) {
console.log(`🤖 Auto-strategy: Huge time saving (${timeRange}ms) for tiny size cost - speed wins!`);
console.log(` ⚡ Time saved: ${timeRange}ms, Size cost: +${((fastest.size - smallest.size) / 1024).toFixed(1)}KB`);
}
return fastest;
}
if (!this.options.skip) {
console.log(`🤖 Auto-strategy: When in doubt, choose smallest file!`);
console.log(` 🏆 Winner: ${(smallest.size / 1024).toFixed(1)}KB (size priority)`);
}
return smallest;
}
calculateEfficiencyScore(option, smallest, fastest) {
if (!option || !smallest || !fastest) return 0;
const sizeScore = 1 - ((option.size - smallest.size) / (smallest.size || 1));
const timeScore = fastest.processingTime > 0 ?
1 - ((option.processingTime - fastest.processingTime) / fastest.processingTime) : 1;
return (sizeScore * 0.7) + (timeScore * 0.3);
}
getStrategyDescription(strategy) {
let desc = strategy.format.toUpperCase();
if (strategy.quality) desc += ` Q${strategy.quality}`;
if (strategy.resize) desc += ` ${(strategy.resize * 100)}%size`;
if (strategy.effort) desc += ` E${strategy.effort}`;
return desc;
}
getExtensionForFormat(format) {
switch (format) {
case 'jpeg': return '.jpg';
case 'png': return '.png';
case 'webp': return '.webp';
case 'avif': return '.avif';
default: return '.jpg';
}
}
async standardCompressionStrategy(sharpInstance, outputPath, format, baseOptions, originalSize, targetSize, maxAttempts) {
if (["jpeg", "jpg", "webp", "avif"].includes(format)) {
let minQ = 5;
let maxQ = baseOptions.quality || 85;
let bestBuffer = null;
let bestQ = minQ;
let bestSize = Infinity;
let lastGoodQ = null;
let lastGoodBuffer = null;
let attempts = 0;
const orig = sharpInstance.clone();
while (minQ <= maxQ && attempts < maxAttempts) {
const q = Math.floor((minQ + maxQ) / 2);
let buffer;
let inst = orig.clone();
switch (format) {
case "jpeg":
case "jpg":
buffer = await inst.jpeg({ quality: q, progressive: baseOptions.progressive !== false }).toBuffer();
break;
case "webp":
buffer = await inst.webp({ quality: q, effort: baseOptions.speedOptimized ? 1 : 4 }).toBuffer();
break;
case "avif":
buffer = await inst.avif({ quality: q, effort: baseOptions.speedOptimized ? 1 : 4 }).toBuffer();
break;
}
if (buffer.length <= targetSize) {
lastGoodQ = q;
lastGoodBuffer = buffer;
bestSize = buffer.length;
minQ = q + 1;
} else {
maxQ = q - 1;
}
attempts++;
}
if (lastGoodBuffer) {
await fs.writeFile(outputPath, lastGoodBuffer);
return outputPath;
} else {
let inst = orig.clone();
let buffer;
switch (format) {
case "jpeg":
case "jpg":
buffer = await inst.jpeg({ quality: minQ, progressive: baseOptions.progressive !== false }).toBuffer();
break;
case "webp":
buffer = await inst.webp({ quality: minQ, effort: baseOptions.speedOptimized ? 1 : 4 }).toBuffer();
break;
case "avif":
buffer = await inst.avif({ quality: minQ, effort: baseOptions.speedOptimized ? 1 : 4 }).toBuffer();
break;
}
await fs.writeFile(outputPath, buffer);
return outputPath;
}
}
if (format === "png") {
let bestBuffer = null;
let bestSize = Infinity;
const orig = sharpInstance.clone();
const levels = baseOptions.speedOptimized ? [3, 6] : [6, 9];
const palettes = [true, false];
for (const compressionLevel of levels) {
for (const palette of palettes) {
let inst = orig.clone();
let buffer = await inst.png({
compressionLevel,
palette,
quality: Math.min(baseOptions.quality || 85, 95),
progressive: false
}).toBuffer();
if (buffer.length < bestSize) {
bestSize = buffer.length;
bestBuffer = buffer;
}
if (buffer.length <= targetSize) {
await fs.writeFile(outputPath, buffer);
return outputPath;
}
}
}
await fs.writeFile(outputPath, bestBuffer);
return outputPath;
}
return outputPath;
}
determineOutputFormat(outputPath, formatOption) {
if (formatOption && formatOption !== 'auto') {
return formatOption;
}
const ext = path.extname(outputPath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'jpeg';
case '.png':
return 'png';
case '.webp':
return 'webp';
case '.avif':
return 'avif';
default:
return 'png';
}
}
generateOutputPath(inputFile, options) {
if (options.output) {
if (options.output.endsWith('/') || options.output.endsWith('\\')) {
const basename = path.basename(inputFile, path.extname(inputFile));
const ext = this.getOutputExtension(inputFile, options.format);
const suffix = options.overwrite ? '' : '_compressed';
return path.join(options.output, `${basename}${suffix}${ext}`);
} else {
return options.output;
}
} else {
const dir = path.dirname(inputFile);
const basename = path.basename(inputFile, path.extname(inputFile));
const ext = this.getOutputExtension(inputFile, options.format);
const suffix = options.overwrite ? '' : '_compressed';
return path.join(dir, `${basename}${suffix}${ext}`);
}
}
getOutputExtension(inputFile, formatOption) {
if (formatOption && formatOption !== 'auto') {
switch (formatOption.toLowerCase()) {
case 'jpeg':
case 'jpg':
return '.jpg';
case 'png':
return '.png';
case 'webp':
return '.webp';
case 'avif':
return '.avif';
default:
return path.extname(inputFile);
}
}
return path.extname(inputFile);
}
parseTargetSize(targetSize) {
const units = {
'B': 1,
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024
};
const match = targetSize.toString().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
if (!match) {
throw new Error(`Invalid target size format: ${targetSize}`);
}
const value = parseFloat(match[1]);
const unit = (match[2] || 'B').toUpperCase();
return Math.floor(value * units[unit]);
}
}
module.exports = { ImageCompressor };