Scripts

convert-to-webp

Converte imagens JPG e PNG para formato WebP recursivamente com processamento paralelo.

Como usar (TypeScript)

  1. 1.Instale o Bun: https://bun.sh
  2. 2.Baixe o script para a pasta do seu projeto (mesmo diretório do package.json)
  3. 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