Skip to content

Instantly share code, notes, and snippets.

@ocodista
Last active November 25, 2025 14:11
Show Gist options
  • Select an option

  • Save ocodista/7d55f9b62c7b2ee1ca2d216d5a114c17 to your computer and use it in GitHub Desktop.

Select an option

Save ocodista/7d55f9b62c7b2ee1ca2d216d5a114c17 to your computer and use it in GitHub Desktop.
Convert all .jpg/.jpeg/.png images in child folders to .webp (use at your own risk!)
#!/usr/bin/env bun
import { $ } from "bun";
import { readdirSync, statSync, existsSync } from "fs";
import { join, relative } from "path";
import { cpus } from "os";
interface ConversionResult {
file: string;
originalSize: number;
webpSize: number;
saved: number;
savedPercent: number;
status: 'success' | 'error' | 'skipped';
error?: string;
}
interface ConversionStats {
total: number;
converted: number;
skipped: number;
failed: number;
originalSize: number;
webpSize: number;
}
const IGNORED_DIRS = [
'node_modules',
'.git',
'.next',
'.nuxt',
'.svelte-kit',
'dist',
'build',
'out',
'.cache',
'.temp',
'.tmp',
'coverage',
'.nyc_output',
'__pycache__',
'.pytest_cache',
'vendor',
'.venv',
'venv',
'.env',
];
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
function matchesPattern(dirName: string, pattern: string): boolean {
if (dirName === pattern) return true;
if (pattern.startsWith('.') && dirName.startsWith(pattern)) return true;
return false;
}
function shouldIgnoreDirectory(dirName: string): boolean {
return IGNORED_DIRS.some(pattern => matchesPattern(dirName, pattern));
}
function isWebpUpToDate(imagePath: string): boolean {
const webpPath = imagePath.replace(/\.(png|jpe?g)$/i, '.webp');
if (!existsSync(webpPath)) return false;
const originalStat = statSync(imagePath);
const webpStat = statSync(webpPath);
return webpStat.mtimeMs > originalStat.mtimeMs;
}
async function findImages(
dir: string,
rootDir: string,
depth: number = 0
): Promise<string[]> {
const images: string[] = [];
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = relative(rootDir, fullPath);
if (entry.isDirectory()) {
if (shouldIgnoreDirectory(entry.name)) {
if (depth === 0) {
console.log(`⏭️ Skipping: ${relativePath}`);
}
continue;
}
const subImages = await findImages(fullPath, rootDir, depth + 1);
images.push(...subImages);
} else if (entry.isFile()) {
const ext = entry.name.toLowerCase();
const hasImageExt = IMAGE_EXTENSIONS.some(imgExt => ext.endsWith(imgExt));
if (hasImageExt && !isWebpUpToDate(fullPath)) {
images.push(fullPath);
}
}
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error);
}
return images;
}
async function convertToWebp(
imagePath: string,
rootDir: string,
quality: number = 85
): Promise<ConversionResult> {
const webpPath = imagePath.replace(/\.(png|jpe?g)$/i, '.webp');
const relativePath = relative(rootDir, imagePath);
try {
const originalSize = statSync(imagePath).size;
if (existsSync(webpPath)) {
const webpSize = statSync(webpPath).size;
return {
file: relativePath,
originalSize,
webpSize,
saved: originalSize - webpSize,
savedPercent: ((originalSize - webpSize) / originalSize) * 100,
status: 'skipped',
};
}
await $`ffmpeg -i ${imagePath} -c:v libwebp -quality ${quality} -y ${webpPath}`.quiet();
const webpSize = statSync(webpPath).size;
const saved = originalSize - webpSize;
const savedPercent = (saved / originalSize) * 100;
return {
file: relativePath,
originalSize,
webpSize,
saved,
savedPercent,
status: 'success',
};
} catch (error) {
return {
file: relativePath,
originalSize: 0,
webpSize: 0,
saved: 0,
savedPercent: 0,
status: 'error',
error: error instanceof Error ? error.message : String(error),
};
}
}
async function processImages(
images: string[],
rootDir: string,
quality: number,
concurrency: number
): Promise<ConversionResult[]> {
const results: ConversionResult[] = [];
let completed = 0;
let activeWorkers = 0;
let currentIndex = 0;
const loggers = {
success: (result: ConversionResult) => {
const sign = result.savedPercent > 0 ? '↓' : '↑';
console.log(`\n✓ ${result.file} ${sign} ${Math.abs(result.savedPercent).toFixed(1)}%`);
},
error: (result: ConversionResult) => {
console.log(`\n✗ ${result.file} - ${result.error}`);
},
skipped: () => {},
} as const;
const progressInterval = setInterval(() => {
const percent = ((completed / images.length) * 100).toFixed(1);
process.stdout.write(`\r🔄 Progress: ${completed}/${images.length} (${percent}%) - Active workers: ${activeWorkers}`);
}, 100);
const processNext = async (): Promise<void> => {
while (currentIndex < images.length) {
const index = currentIndex++;
const image = images[index];
activeWorkers++;
const result = await convertToWebp(image, rootDir, quality);
activeWorkers--;
results.push(result);
completed++;
loggers[result.status]?.(result);
}
};
const workers = Array(concurrency).fill(null).map(() => processNext());
await Promise.all(workers);
clearInterval(progressInterval);
console.log('\n'); // Clear progress line
return results;
}
function displayStats(results: ConversionResult[], rootDir: string): void {
const stats: ConversionStats = results.reduce(
(acc, result) => {
acc.total++;
if (result.status === 'success') {
acc.converted++;
acc.originalSize += result.originalSize;
acc.webpSize += result.webpSize;
} else if (result.status === 'skipped') {
acc.skipped++;
acc.originalSize += result.originalSize;
acc.webpSize += result.webpSize;
} else {
acc.failed++;
}
return acc;
},
{ total: 0, converted: 0, skipped: 0, failed: 0, originalSize: 0, webpSize: 0 }
);
const totalSaved = stats.originalSize - stats.webpSize;
const totalSavedPercent = stats.originalSize > 0
? (totalSaved / stats.originalSize) * 100
: 0;
console.log('\n📊 Conversion Summary');
console.log('═'.repeat(70));
console.log(`Directory: ${rootDir}`);
console.log(`Total images: ${stats.total}`);
console.log(`✓ Converted: ${stats.converted}`);
console.log(`⏭️ Skipped: ${stats.skipped}`);
console.log(`✗ Failed: ${stats.failed}`);
console.log('─'.repeat(70));
console.log(`Original size: ${(stats.originalSize / 1024 / 1024).toFixed(2)} MB`);
console.log(`WebP size: ${(stats.webpSize / 1024 / 1024).toFixed(2)} MB`);
console.log(`Total saved: ${(totalSaved / 1024 / 1024).toFixed(2)} MB (${totalSavedPercent.toFixed(1)}%)`);
console.log('═'.repeat(70));
const successfulResults = results.filter(r => r.status === 'success' && r.saved > 0);
if (successfulResults.length > 0) {
console.log('\n🏆 Top 10 Biggest Savings:\n');
const topSavings = successfulResults
.sort((a, b) => b.saved - a.saved)
.slice(0, 10);
topSavings.forEach((result, index) => {
console.log(`${index + 1}. ${result.file}`);
console.log(` Saved: ${(result.saved / 1024).toFixed(1)} KB (${result.savedPercent.toFixed(1)}%)\n`);
});
}
const failures = results.filter(r => r.status === 'error');
if (failures.length > 0) {
console.log('\n❌ Failed Conversions:\n');
failures.forEach(result => {
console.log(` ${result.file}`);
console.log(` Error: ${result.error}\n`);
});
}
}
async function main() {
const args = process.argv.slice(2);
const targetDir = args[0] || process.cwd();
const quality = parseInt(args[1]) || 85;
const numCores = cpus().length;
console.log('🖼️ Image to WebP Converter');
console.log('═'.repeat(70));
console.log(`Target directory: ${targetDir}`);
console.log(`Quality: ${quality}`);
console.log(`CPU cores: ${numCores}`);
console.log(`Parallel workers: ${numCores}`);
console.log('═'.repeat(70));
if (!existsSync(targetDir)) {
console.error(`❌ Error: Directory does not exist: ${targetDir}`);
process.exit(1);
}
console.log('\n🔍 Scanning for images...\n');
const startTime = Date.now();
const images = await findImages(targetDir, targetDir);
const scanTime = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`\n✓ Found ${images.length} images in ${scanTime}s`);
if (images.length === 0) {
console.log('\nNo images to convert. Exiting.');
return;
}
console.log(`\n🔄 Converting with ${numCores} parallel workers...\n`);
const convertStartTime = Date.now();
const results = await processImages(images, targetDir, quality, numCores);
const convertTime = ((Date.now() - convertStartTime) / 1000).toFixed(2);
console.log(`\n✓ Conversion completed in ${convertTime}s`);
displayStats(results, targetDir);
console.log('\n✅ Done! Original files preserved.\n');
}
main().catch(error => {
console.error('\n❌ Fatal error:', error.message || error);
console.error('Check the target directory and ffmpeg installation, then try again.');
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment