Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Compress and convert images to WebP or PNG using automatic tool selection (sips, cwebp, ImageMagick, Sharp).
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/main.ts
1#!/usr/bin/env bun2import { existsSync, statSync, readdirSync, unlinkSync, renameSync } from "fs";3import { basename, dirname, extname, join, resolve } from "path";4import { spawn } from "child_process";56type Compressor = "sips" | "cwebp" | "imagemagick" | "sharp";7type Format = "webp" | "png" | "jpeg";89interface Options {10input: string;11output?: string;12format: Format;13quality: number;14keep: boolean;15recursive: boolean;16json: boolean;17}1819interface Result {20input: string;21output: string;22inputSize: number;23outputSize: number;24ratio: number;25compressor: Compressor;26}2728const SUPPORTED_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff"];2930async function commandExists(cmd: string): Promise<boolean> {31try {32const proc = spawn("which", [cmd], { stdio: "pipe" });33return new Promise((res) => {34proc.on("close", (code) => res(code === 0));35proc.on("error", () => res(false));36});37} catch {38return false;39}40}4142async function detectCompressor(format: Format): Promise<Compressor> {43if (format === "webp") {44if (await commandExists("cwebp")) return "cwebp";45if (await commandExists("convert")) return "imagemagick";46return "sharp";47}48if (process.platform === "darwin") return "sips";49if (await commandExists("convert")) return "imagemagick";50return "sharp";51}5253function runCmd(cmd: string, args: string[]): Promise<{ code: number; stderr: string }> {54return new Promise((res) => {55const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });56let stderr = "";57proc.stderr?.on("data", (d) => (stderr += d.toString()));58proc.on("close", (code) => res({ code: code ?? 1, stderr }));59proc.on("error", (e) => res({ code: 1, stderr: e.message }));60});61}6263async function compressWithSips(input: string, output: string, format: Format, quality: number): Promise<void> {64const fmt = format === "jpeg" ? "jpeg" : format;65const args = ["-s", "format", fmt, "-s", "formatOptions", String(quality), input, "--out", output];66const { code, stderr } = await runCmd("sips", args);67if (code !== 0) throw new Error(`sips failed: ${stderr}`);68}6970async function compressWithCwebp(input: string, output: string, quality: number): Promise<void> {71const args = ["-q", String(quality), input, "-o", output];72const { code, stderr } = await runCmd("cwebp", args);73if (code !== 0) throw new Error(`cwebp failed: ${stderr}`);74}7576async function compressWithImagemagick(input: string, output: string, quality: number): Promise<void> {77const args = [input, "-quality", String(quality), output];78const { code, stderr } = await runCmd("convert", args);79if (code !== 0) throw new Error(`convert failed: ${stderr}`);80}8182async function compressWithSharp(input: string, output: string, format: Format, quality: number): Promise<void> {83const sharp = (await import("sharp")).default;84let pipeline = sharp(input);85if (format === "webp") pipeline = pipeline.webp({ quality });86else if (format === "png") pipeline = pipeline.png({ quality });87else if (format === "jpeg") pipeline = pipeline.jpeg({ quality });88await pipeline.toFile(output);89}9091async function compress(92compressor: Compressor,93input: string,94output: string,95format: Format,96quality: number97): Promise<void> {98switch (compressor) {99case "sips":100await compressWithSips(input, output, format, quality);101break;102case "cwebp":103if (format !== "webp") {104await compressWithSharp(input, output, format, quality);105} else {106await compressWithCwebp(input, output, quality);107}108break;109case "imagemagick":110await compressWithImagemagick(input, output, quality);111break;112case "sharp":113await compressWithSharp(input, output, format, quality);114break;115}116}117118function getOutputPath(input: string, format: Format, keep: boolean, customOutput?: string): string {119if (customOutput) return resolve(customOutput);120const dir = dirname(input);121const base = basename(input, extname(input));122const ext = format === "jpeg" ? ".jpg" : `.${format}`;123if (keep && extname(input).toLowerCase() === ext) {124return join(dir, `${base}-compressed${ext}`);125}126return join(dir, `${base}${ext}`);127}128129function formatSize(bytes: number): string {130if (bytes < 1024) return `${bytes}B`;131if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;132return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;133}134135async function processFile(136compressor: Compressor,137input: string,138opts: Options139): Promise<Result> {140const absInput = resolve(input);141const inputSize = statSync(absInput).size;142const output = getOutputPath(absInput, opts.format, opts.keep, opts.output);143const tempOutput = output + ".tmp";144145await compress(compressor, absInput, tempOutput, opts.format, opts.quality);146147const outputSize = statSync(tempOutput).size;148149if (!opts.keep && absInput !== output) {150const ext = extname(absInput);151const base = absInput.slice(0, -ext.length);152renameSync(absInput, `${base}_original${ext}`);153}154renameSync(tempOutput, output);155156return {157input: absInput,158output,159inputSize,160outputSize,161ratio: outputSize / inputSize,162compressor,163};164}165166function collectFiles(dir: string, recursive: boolean): string[] {167const files: string[] = [];168const entries = readdirSync(dir, { withFileTypes: true });169for (const entry of entries) {170const full = join(dir, entry.name);171if (entry.isDirectory() && recursive) {172files.push(...collectFiles(full, recursive));173} else if (entry.isFile() && SUPPORTED_EXTS.includes(extname(entry.name).toLowerCase())) {174files.push(full);175}176}177return files;178}179180function printHelp() {181console.log(`Usage: bun main.ts <input> [options]182183Options:184-o, --output <path> Output path185-f, --format <fmt> Output format: webp, png, jpeg (default: webp)186-q, --quality <n> Quality 0-100 (default: 80)187-k, --keep Keep original file188-r, --recursive Process directories recursively189--json JSON output190-h, --help Show help`);191}192193function parseArgs(args: string[]): Options | null {194const opts: Options = {195input: "",196format: "webp",197quality: 80,198keep: false,199recursive: false,200json: false,201};202203for (let i = 0; i < args.length; i++) {204const arg = args[i];205if (arg === "-h" || arg === "--help") {206printHelp();207process.exit(0);208} else if (arg === "-o" || arg === "--output") {209opts.output = args[++i];210} else if (arg === "-f" || arg === "--format") {211const fmt = args[++i]?.toLowerCase();212if (fmt === "webp" || fmt === "png" || fmt === "jpeg" || fmt === "jpg") {213opts.format = fmt === "jpg" ? "jpeg" : (fmt as Format);214} else {215console.error(`Invalid format: ${fmt}`);216return null;217}218} else if (arg === "-q" || arg === "--quality") {219const q = parseInt(args[++i], 10);220if (isNaN(q) || q < 0 || q > 100) {221console.error(`Invalid quality: ${args[i]}`);222return null;223}224opts.quality = q;225} else if (arg === "-k" || arg === "--keep") {226opts.keep = true;227} else if (arg === "-r" || arg === "--recursive") {228opts.recursive = true;229} else if (arg === "--json") {230opts.json = true;231} else if (!arg.startsWith("-") && !opts.input) {232opts.input = arg;233}234}235236if (!opts.input) {237console.error("Error: Input file or directory required");238printHelp();239return null;240}241242return opts;243}244245async function main() {246const args = process.argv.slice(2);247const opts = parseArgs(args);248if (!opts) process.exit(1);249250const input = resolve(opts.input);251if (!existsSync(input)) {252console.error(`Error: ${input} not found`);253process.exit(1);254}255256const compressor = await detectCompressor(opts.format);257const isDir = statSync(input).isDirectory();258259if (isDir) {260const files = collectFiles(input, opts.recursive);261if (files.length === 0) {262console.error("No supported images found");263process.exit(1);264}265266const results: Result[] = [];267for (const file of files) {268try {269const r = await processFile(compressor, file, { ...opts, output: undefined });270results.push(r);271if (!opts.json) {272const reduction = Math.round((1 - r.ratio) * 100);273console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`);274}275} catch (e) {276if (!opts.json) console.error(`Error processing ${file}: ${(e as Error).message}`);277}278}279280if (opts.json) {281const totalInput = results.reduce((s, r) => s + r.inputSize, 0);282const totalOutput = results.reduce((s, r) => s + r.outputSize, 0);283console.log(284JSON.stringify({285files: results,286summary: {287totalFiles: results.length,288totalInputSize: totalInput,289totalOutputSize: totalOutput,290ratio: totalInput > 0 ? totalOutput / totalInput : 0,291compressor,292},293}, null, 2)294);295} else {296const totalInput = results.reduce((s, r) => s + r.inputSize, 0);297const totalOutput = results.reduce((s, r) => s + r.outputSize, 0);298const reduction = Math.round((1 - totalOutput / totalInput) * 100);299console.log(`\nProcessed ${results.length} files: ${formatSize(totalInput)} → ${formatSize(totalOutput)} (${reduction}% reduction)`);300}301} else {302try {303const r = await processFile(compressor, input, opts);304if (opts.json) {305console.log(JSON.stringify(r, null, 2));306} else {307const reduction = Math.round((1 - r.ratio) * 100);308console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`);309}310} catch (e) {311console.error(`Error: ${(e as Error).message}`);312process.exit(1);313}314}315}316317main();318