#!/usr/bin/env bun import { existsSync, statSync, readdirSync, unlinkSync, renameSync } from "fs"; import { basename, dirname, extname, join, resolve } from "path"; import { spawn } from "child_process"; type Compressor = "sips" | "cwebp" | "imagemagick" | "sharp"; type Format = "webp" | "png" | "jpeg"; interface Options { input: string; output?: string; format: Format; quality: number; keep: boolean; recursive: boolean; json: boolean; } interface Result { input: string; output: string; inputSize: number; outputSize: number; ratio: number; compressor: Compressor; } const SUPPORTED_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff"]; async function commandExists(cmd: string): Promise { try { const proc = spawn("which", [cmd], { stdio: "pipe" }); return new Promise((res) => { proc.on("close", (code) => res(code === 0)); proc.on("error", () => res(false)); }); } catch { return false; } } async function detectCompressor(format: Format): Promise { if (format === "webp") { if (await commandExists("cwebp")) return "cwebp"; if (await commandExists("convert")) return "imagemagick"; return "sharp"; } if (process.platform === "darwin") return "sips"; if (await commandExists("convert")) return "imagemagick"; return "sharp"; } function runCmd(cmd: string, args: string[]): Promise<{ code: number; stderr: string }> { return new Promise((res) => { const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] }); let stderr = ""; proc.stderr?.on("data", (d) => (stderr += d.toString())); proc.on("close", (code) => res({ code: code ?? 1, stderr })); proc.on("error", (e) => res({ code: 1, stderr: e.message })); }); } async function compressWithSips(input: string, output: string, format: Format, quality: number): Promise { const fmt = format === "jpeg" ? "jpeg" : format; const args = ["-s", "format", fmt, "-s", "formatOptions", String(quality), input, "--out", output]; const { code, stderr } = await runCmd("sips", args); if (code !== 0) throw new Error(`sips failed: ${stderr}`); } async function compressWithCwebp(input: string, output: string, quality: number): Promise { const args = ["-q", String(quality), input, "-o", output]; const { code, stderr } = await runCmd("cwebp", args); if (code !== 0) throw new Error(`cwebp failed: ${stderr}`); } async function compressWithImagemagick(input: string, output: string, quality: number): Promise { const args = [input, "-quality", String(quality), output]; const { code, stderr } = await runCmd("convert", args); if (code !== 0) throw new Error(`convert failed: ${stderr}`); } async function compressWithSharp(input: string, output: string, format: Format, quality: number): Promise { const sharp = (await import("sharp")).default; let pipeline = sharp(input); if (format === "webp") pipeline = pipeline.webp({ quality }); else if (format === "png") pipeline = pipeline.png({ quality }); else if (format === "jpeg") pipeline = pipeline.jpeg({ quality }); await pipeline.toFile(output); } async function compress( compressor: Compressor, input: string, output: string, format: Format, quality: number ): Promise { switch (compressor) { case "sips": await compressWithSips(input, output, format, quality); break; case "cwebp": if (format !== "webp") { await compressWithSharp(input, output, format, quality); } else { await compressWithCwebp(input, output, quality); } break; case "imagemagick": await compressWithImagemagick(input, output, quality); break; case "sharp": await compressWithSharp(input, output, format, quality); break; } } function getOutputPath(input: string, format: Format, keep: boolean, customOutput?: string): string { if (customOutput) return resolve(customOutput); const dir = dirname(input); const base = basename(input, extname(input)); const ext = format === "jpeg" ? ".jpg" : `.${format}`; if (keep && extname(input).toLowerCase() === ext) { return join(dir, `${base}-compressed${ext}`); } return join(dir, `${base}${ext}`); } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } async function processFile( compressor: Compressor, input: string, opts: Options ): Promise { const absInput = resolve(input); const inputSize = statSync(absInput).size; const output = getOutputPath(absInput, opts.format, opts.keep, opts.output); const tempOutput = output + ".tmp"; await compress(compressor, absInput, tempOutput, opts.format, opts.quality); const outputSize = statSync(tempOutput).size; if (!opts.keep && absInput !== output) { const ext = extname(absInput); const base = absInput.slice(0, -ext.length); renameSync(absInput, `${base}_original${ext}`); } renameSync(tempOutput, output); return { input: absInput, output, inputSize, outputSize, ratio: outputSize / inputSize, compressor, }; } function collectFiles(dir: string, recursive: boolean): string[] { const files: string[] = []; const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory() && recursive) { files.push(...collectFiles(full, recursive)); } else if (entry.isFile() && SUPPORTED_EXTS.includes(extname(entry.name).toLowerCase())) { files.push(full); } } return files; } function printHelp() { console.log(`Usage: bun main.ts [options] Options: -o, --output Output path -f, --format Output format: webp, png, jpeg (default: webp) -q, --quality Quality 0-100 (default: 80) -k, --keep Keep original file -r, --recursive Process directories recursively --json JSON output -h, --help Show help`); } function parseArgs(args: string[]): Options | null { const opts: Options = { input: "", format: "webp", quality: 80, keep: false, recursive: false, json: false, }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "-h" || arg === "--help") { printHelp(); process.exit(0); } else if (arg === "-o" || arg === "--output") { opts.output = args[++i]; } else if (arg === "-f" || arg === "--format") { const fmt = args[++i]?.toLowerCase(); if (fmt === "webp" || fmt === "png" || fmt === "jpeg" || fmt === "jpg") { opts.format = fmt === "jpg" ? "jpeg" : (fmt as Format); } else { console.error(`Invalid format: ${fmt}`); return null; } } else if (arg === "-q" || arg === "--quality") { const q = parseInt(args[++i], 10); if (isNaN(q) || q < 0 || q > 100) { console.error(`Invalid quality: ${args[i]}`); return null; } opts.quality = q; } else if (arg === "-k" || arg === "--keep") { opts.keep = true; } else if (arg === "-r" || arg === "--recursive") { opts.recursive = true; } else if (arg === "--json") { opts.json = true; } else if (!arg.startsWith("-") && !opts.input) { opts.input = arg; } } if (!opts.input) { console.error("Error: Input file or directory required"); printHelp(); return null; } return opts; } async function main() { const args = process.argv.slice(2); const opts = parseArgs(args); if (!opts) process.exit(1); const input = resolve(opts.input); if (!existsSync(input)) { console.error(`Error: ${input} not found`); process.exit(1); } const compressor = await detectCompressor(opts.format); const isDir = statSync(input).isDirectory(); if (isDir) { const files = collectFiles(input, opts.recursive); if (files.length === 0) { console.error("No supported images found"); process.exit(1); } const results: Result[] = []; for (const file of files) { try { const r = await processFile(compressor, file, { ...opts, output: undefined }); results.push(r); if (!opts.json) { const reduction = Math.round((1 - r.ratio) * 100); console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`); } } catch (e) { if (!opts.json) console.error(`Error processing ${file}: ${(e as Error).message}`); } } if (opts.json) { const totalInput = results.reduce((s, r) => s + r.inputSize, 0); const totalOutput = results.reduce((s, r) => s + r.outputSize, 0); console.log( JSON.stringify({ files: results, summary: { totalFiles: results.length, totalInputSize: totalInput, totalOutputSize: totalOutput, ratio: totalInput > 0 ? totalOutput / totalInput : 0, compressor, }, }, null, 2) ); } else { const totalInput = results.reduce((s, r) => s + r.inputSize, 0); const totalOutput = results.reduce((s, r) => s + r.outputSize, 0); const reduction = Math.round((1 - totalOutput / totalInput) * 100); console.log(`\nProcessed ${results.length} files: ${formatSize(totalInput)} → ${formatSize(totalOutput)} (${reduction}% reduction)`); } } else { try { const r = await processFile(compressor, input, opts); if (opts.json) { console.log(JSON.stringify(r, null, 2)); } else { const reduction = Math.round((1 - r.ratio) * 100); console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`); } } catch (e) { console.error(`Error: ${(e as Error).message}`); process.exit(1); } } } main();