Scripts
convert-to-webp
Converte imagens JPG e PNG para formato WebP recursivamente com processamento paralelo.
Como usar (TypeScript)
- 1.Instale o Bun: https://bun.sh
- 2.Baixe o script para a pasta do seu projeto (mesmo diretório do package.json)
- 3.Execute: bun {scriptPath}
1#!/usr/bin/env bun
2
3import { $ } from "bun";
4import { readdirSync, statSync, existsSync } from "fs";
5import { join, relative } from "path";
6import { cpus } from "os";
7
8interface ConversionResult {
9 file: string;
10 originalSize: number;
11 webpSize: number;
12 saved: number;
13 savedPercent: number;
14 status: 'success' | 'error' | 'skipped';
15 error?: string;
16}
17
18interface ConversionStats {
19 total: number;
20 converted: number;
21 skipped: number;
22 failed: number;
23 originalSize: number;
24 webpSize: number;
25}
26
27const IGNORED_DIRS = [
28 'node_modules',
29 '.git',
30 '.next',
31 '.nuxt',
32 '.svelte-kit',
33 'dist',
34 'build',
35 'out',
36 '.cache',
37 '.temp',
38 '.tmp',
39 'coverage',
40 '.nyc_output',
41 '__pycache__',
42 '.pytest_cache',
43 'vendor',
44 '.venv',
45 'venv',
46 '.env',
47];
48
49const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
50
51function matchesPattern(dirName: string, pattern: string): boolean {
52 if (dirName === pattern) return true;
53 if (pattern.startsWith('.') && dirName.startsWith(pattern)) return true;
54 return false;
55}
56
57function shouldIgnoreDirectory(dirName: string): boolean {
58 return IGNORED_DIRS.some(pattern => matchesPattern(dirName, pattern));
59}
60
61function isWebpUpToDate(imagePath: string): boolean {
62 const webpPath = imagePath.replace(/\.(png|jpe?g)$/i, '.webp');
63 if (!existsSync(webpPath)) return false;
64
65 const originalStat = statSync(imagePath);
66 const webpStat = statSync(webpPath);
67 return webpStat.mtimeMs > originalStat.mtimeMs;
68}
69
70async function findImages(
71 dir: string,
72 rootDir: string,
73 depth: number = 0
74): Promise<string[]> {
75 const images: string[] = [];
76
77 try {
78 const entries = readdirSync(dir, { withFileTypes: true });
79
80 for (const entry of entries) {
81 const fullPath = join(dir, entry.name);
82 const relativePath = relative(rootDir, fullPath);
83
84 if (entry.isDirectory()) {
85 if (shouldIgnoreDirectory(entry.name)) {
86 if (depth === 0) {
87 console.log(`⏭️ Skipping: ${relativePath}`);
88 }
89 continue;
90 }
91
92 const subImages = await findImages(fullPath, rootDir, depth + 1);
93 images.push(...subImages);
94 } else if (entry.isFile()) {
95 const ext = entry.name.toLowerCase();
96 const hasImageExt = IMAGE_EXTENSIONS.some(imgExt => ext.endsWith(imgExt));
97
98 if (hasImageExt && !isWebpUpToDate(fullPath)) {
99 images.push(fullPath);
100 }
101 }
102 }
103 } catch (error) {
104 console.error(`Error reading directory ${dir}:`, error);
105 }
106
107 return images;
108}
109
110async function convertToWebp(
111 imagePath: string,
112 rootDir: string,
113 quality: number = 85
114): Promise<ConversionResult> {
115 const webpPath = imagePath.replace(/\.(png|jpe?g)$/i, '.webp');
116 const relativePath = relative(rootDir, imagePath);
117
118 try {
119 const originalSize = statSync(imagePath).size;
120
121 if (existsSync(webpPath)) {
122 const webpSize = statSync(webpPath).size;
123 return {
124 file: relativePath,
125 originalSize,
126 webpSize,
127 saved: originalSize - webpSize,
128 savedPercent: ((originalSize - webpSize) / originalSize) * 100,
129 status: 'skipped',
130 };
131 }
132
133 await $`ffmpeg -i ${imagePath} -c:v libwebp -quality ${quality} -y ${webpPath}`.quiet();
134
135 const webpSize = statSync(webpPath).size;
136 const saved = originalSize - webpSize;
137 const savedPercent = (saved / originalSize) * 100;
138
139 return {
140 file: relativePath,
141 originalSize,
142 webpSize,
143 saved,
144 savedPercent,
145 status: 'success',
146 };
147 } catch (error) {
148 return {
149 file: relativePath,
150 originalSize: 0,
151 webpSize: 0,
152 saved: 0,
153 savedPercent: 0,
154 status: 'error',
155 error: error instanceof Error ? error.message : String(error),
156 };
157 }
158}
159
160async function processImages(
161 images: string[],
162 rootDir: string,
163 quality: number,
164 concurrency: number
165): Promise<ConversionResult[]> {
166 const results: ConversionResult[] = [];
167 let completed = 0;
168 let activeWorkers = 0;
169 let currentIndex = 0;
170
171 const loggers = {
172 success: (result: ConversionResult) => {
173 const sign = result.savedPercent > 0 ? '↓' : '↑';
174 console.log(`\n✓ ${result.file} ${sign} ${Math.abs(result.savedPercent).toFixed(1)}%`);
175 },
176 error: (result: ConversionResult) => {
177 console.log(`\n✗ ${result.file} - ${result.error}`);
178 },
179 skipped: () => {},
180 } as const;
181
182 const progressInterval = setInterval(() => {
183 const percent = ((completed / images.length) * 100).toFixed(1);
184 process.stdout.write(`\r🔄 Progress: ${completed}/${images.length} (${percent}%) - Active workers: ${activeWorkers}`);
185 }, 100);
186
187 const processNext = async (): Promise<void> => {
188 while (currentIndex < images.length) {
189 const index = currentIndex++;
190 const image = images[index];
191
192 activeWorkers++;
193 const result = await convertToWebp(image, rootDir, quality);
194 activeWorkers--;
195
196 results.push(result);
197 completed++;
198
199 loggers[result.status]?.(result);
200 }
201 };
202
203 const workers = Array(concurrency).fill(null).map(() => processNext());
204 await Promise.all(workers);
205
206 clearInterval(progressInterval);
207 console.log('\n'); // Clear progress line
208
209 return results;
210}
211
212function displayStats(results: ConversionResult[], rootDir: string): void {
213 const stats: ConversionStats = results.reduce(
214 (acc, result) => {
215 acc.total++;
216 if (result.status === 'success') {
217 acc.converted++;
218 acc.originalSize += result.originalSize;
219 acc.webpSize += result.webpSize;
220 } else if (result.status === 'skipped') {
221 acc.skipped++;
222 acc.originalSize += result.originalSize;
223 acc.webpSize += result.webpSize;
224 } else {
225 acc.failed++;
226 }
227 return acc;
228 },
229 { total: 0, converted: 0, skipped: 0, failed: 0, originalSize: 0, webpSize: 0 }
230 );
231
232 const totalSaved = stats.originalSize - stats.webpSize;
233 const totalSavedPercent = stats.originalSize > 0
234 ? (totalSaved / stats.originalSize) * 100
235 : 0;
236
237 console.log('\n📊 Conversion Summary');
238 console.log('═'.repeat(70));
239 console.log(`Directory: ${rootDir}`);
240 console.log(`Total images: ${stats.total}`);
241 console.log(`✓ Converted: ${stats.converted}`);
242 console.log(`⏭️ Skipped: ${stats.skipped}`);
243 console.log(`✗ Failed: ${stats.failed}`);
244 console.log('─'.repeat(70));
245 console.log(`Original size: ${(stats.originalSize / 1024 / 1024).toFixed(2)} MB`);
246 console.log(`WebP size: ${(stats.webpSize / 1024 / 1024).toFixed(2)} MB`);
247 console.log(`Total saved: ${(totalSaved / 1024 / 1024).toFixed(2)} MB (${totalSavedPercent.toFixed(1)}%)`);
248 console.log('═'.repeat(70));
249
250 const successfulResults = results.filter(r => r.status === 'success' && r.saved > 0);
251 if (successfulResults.length > 0) {
252 console.log('\n🏆 Top 10 Biggest Savings:\n');
253 const topSavings = successfulResults
254 .sort((a, b) => b.saved - a.saved)
255 .slice(0, 10);
256
257 topSavings.forEach((result, index) => {
258 console.log(`${index + 1}. ${result.file}`);
259 console.log(` Saved: ${(result.saved / 1024).toFixed(1)} KB (${result.savedPercent.toFixed(1)}%)\n`);
260 });
261 }
262
263 const failures = results.filter(r => r.status === 'error');
264 if (failures.length > 0) {
265 console.log('\n❌ Failed Conversions:\n');
266 failures.forEach(result => {
267 console.log(` ${result.file}`);
268 console.log(` Error: ${result.error}\n`);
269 });
270 }
271}
272
273async function main() {
274 const args = process.argv.slice(2);
275 const targetDir = args[0] || process.cwd();
276 const quality = parseInt(args[1]) || 85;
277 const numCores = cpus().length;
278
279 console.log('🖼️ Image to WebP Converter');
280 console.log('═'.repeat(70));
281 console.log(`Target directory: ${targetDir}`);
282 console.log(`Quality: ${quality}`);
283 console.log(`CPU cores: ${numCores}`);
284 console.log(`Parallel workers: ${numCores}`);
285 console.log('═'.repeat(70));
286
287 if (!existsSync(targetDir)) {
288 console.error(`❌ Error: Directory does not exist: ${targetDir}`);
289 process.exit(1);
290 }
291
292 console.log('\n🔍 Scanning for images...\n');
293 const startTime = Date.now();
294 const images = await findImages(targetDir, targetDir);
295 const scanTime = ((Date.now() - startTime) / 1000).toFixed(2);
296
297 console.log(`\n✓ Found ${images.length} images in ${scanTime}s`);
298
299 if (images.length === 0) {
300 console.log('\nNo images to convert. Exiting.');
301 return;
302 }
303
304 console.log(`\n🔄 Converting with ${numCores} parallel workers...\n`);
305 const convertStartTime = Date.now();
306 const results = await processImages(images, targetDir, quality, numCores);
307 const convertTime = ((Date.now() - convertStartTime) / 1000).toFixed(2);
308
309 console.log(`\n✓ Conversion completed in ${convertTime}s`);
310
311 displayStats(results, targetDir);
312
313 console.log('\n✅ Done! Original files preserved.\n');
314}
315
316main().catch(error => {
317 console.error('\n❌ Fatal error:', error.message || error);
318 console.error('Check the target directory and ffmpeg installation, then try again.');
319 process.exit(1);
320});
321