Last active
November 25, 2025 14:11
-
-
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!)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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