#!/usr/bin/env runts -- --runtime-bun import {parseArgs} from "util"; import {Image} from "bun"; const SUPPORTED_INPUT_FORMATS = [".heic", ".jpeg", ".jpg", ".png", ".webp", ".gif", ".bmp", ".avif", ".tiff", ".tif"]; const OUTPUT_FORMATS = ["jpeg", "png"] as const; type OutputFormat = (typeof OUTPUT_FORMATS)[number]; function printUsage() { console.log(`Usage: bun run index.ts [options] [image2] ... Convert and scale images to JPEG or PNG format. Arguments: ... Input image file(s) (heic, jpeg, png, webp, gif, bmp, avif, tiff) Options: -f, --format Output format: jpeg or png (default: jpeg) -w, --width Scale image to this width (height auto-calculated) -h, --height Scale image to this height (width auto-calculated) -s, --size Scale image so the larger dimension matches this size -q, --quality Output image quality, range (0, 100] (default: 95) -o, --output Output directory (default: same as input) --help Show this help message Examples: bun run index.ts photo.heic bun run index.ts photo.jpg --format png bun run index.ts photo.jpg --width 800 bun run index.ts photo.jpg --height 600 bun run index.ts a.jpg b.heic c.png --format png --width 1920 bun run index.ts photo.jpg --output ./converted --format jpeg --width 1280`); } function parseCliArgs(args: string[]) { const {values, positionals} = parseArgs({ args, options: { format: {type: "string", short: "f", default: "jpeg"}, width: {type: "string", short: "w"}, height: {type: "string", short: "h"}, size: {type: "string", short: "s"}, quality: {type: "string", short: "q"}, output: {type: "string", short: "o"}, help: {type: "boolean"}, }, strict: false, allowPositionals: true, }); return {values, positionals}; } function getOutputPath(inputPath: string, format: OutputFormat, outputDir?: string): string { const base = inputPath.split("/").pop()!; const nameWithoutExt = base.replace(/\.[^.]+$/, ""); const ext = format === "jpeg" ? "jpg" : "png"; const outputName = `${nameWithoutExt}_converted.${ext}`; if (outputDir) { return `${outputDir.replace(/\/$/, "")}/${outputName}`; } const dir = inputPath.includes("/") ? inputPath.substring(0, inputPath.lastIndexOf("/")) : "."; return `${dir}/${outputName}`; } async function processImage( inputPath: string, outputPath: string, format: OutputFormat, targetWidth?: number, targetHeight?: number, targetSize?: number, targetQuality?: number, ) { const inputFile = Bun.file(inputPath); if (!(await inputFile.exists())) { console.error(`❌ File not found: ${inputPath}`); return false; } const ext = "." + inputFile.name?.split(".").pop()?.toLowerCase(); if (!SUPPORTED_INPUT_FORMATS.includes(ext)) { console.error(`❌ Unsupported format: ${ext} (${inputPath})`); return false; } const image = inputFile.image(); const meta = await image.metadata(); const originalWidth = meta.width; const originalHeight = meta.height; // Resolve --size into width/height (scale by larger dimension, no upscaling) if (targetSize) { const largerDim = Math.max(originalWidth, originalHeight); if (largerDim <= targetSize) { console.warn(`⚠️ ${inputPath} already smaller than ${targetSize}px (${originalWidth}x${originalHeight}), skipping scale`); targetSize = undefined; } else if (originalWidth >= originalHeight) { targetWidth = targetSize; } else { targetHeight = targetSize; } } let processed: Image; if (targetWidth && targetHeight) { processed = image.resize(targetWidth, targetHeight, {fit: "fill"}); } else if (targetWidth) { processed = image.resize(targetWidth); } else if (targetHeight) { const scale = targetHeight / originalHeight; const scaledWidth = Math.round(originalWidth * scale); processed = image.resize(scaledWidth, targetHeight, {fit: "fill"}); } else { processed = image; } let outputImg: Image; if (format === "jpeg") { outputImg = processed.jpeg({quality: targetQuality ?? 90}); } else { outputImg = processed.png(); } await Bun.write(outputPath, await outputImg.buffer()); const outMeta = await Bun.file(outputPath).image().metadata(); const outputSize = Bun.file(outputPath).size.toLocaleString(); const scaled = outMeta.width !== originalWidth || outMeta.height !== originalHeight; const sizeInfo = scaled ? `${originalWidth}x${originalHeight} -> ${outMeta.width}x${outMeta.height}` : `${originalWidth}x${originalHeight}`; console.log(`✅ ${inputPath} -> ${outputPath} (${sizeInfo}, ${outputSize} bytes)`); return true; } function parseNumber(val: any): number | undefined { return val ? parseInt(val, 10) : undefined; } async function main() { const args = Bun.argv.slice(2); if (args.length === 0 || args.includes("--help")) { printUsage(); process.exit(0); } const {values, positionals} = parseCliArgs(args); if (positionals.length === 0) { console.error("❌ Error: No input files specified.\n"); printUsage(); process.exit(1); } const format = String(values.format).toLowerCase() as OutputFormat; if (!OUTPUT_FORMATS.includes(format)) { console.error(`❌ Error: Invalid format "${format}". Must be one of: ${OUTPUT_FORMATS.join(", ")}\n`); process.exit(1); } const targetWidth = parseNumber(values.width); const targetHeight = parseNumber(values.height); const targetSize = parseNumber(values.size); const targetQuality = parseNumber(values.quality); if (targetWidth !== undefined && (isNaN(targetWidth) || targetWidth <= 0)) { console.error(`❌ Error: Invalid width "${values.width}". Must be a positive integer.\n`); process.exit(1); } if (targetHeight !== undefined && (isNaN(targetHeight) || targetHeight <= 0)) { console.error(`❌ Error: Invalid height "${values.height}". Must be a positive integer.\n`); process.exit(1); } if (targetSize !== undefined && (isNaN(targetSize) || targetSize <= 0)) { console.error(`❌ Error: Invalid size "${values.size}". Must be a positive integer.\n`); process.exit(1); } if (targetQuality !== undefined && (isNaN(targetQuality) || targetQuality <= 0 || targetQuality > 100)) { console.error(`❌ Error: Invalid quality "${values.quality}". Must be in (0, 100].\n`); process.exit(1); } if (targetWidth && targetHeight) { console.log(`⚠️ Both width and height specified — using explicit resize (${targetWidth}x${targetHeight})`); } else if (targetWidth) { console.log(`📐 Scaling to width: ${targetWidth}px (height auto-calculated)`); } else if (targetHeight) { console.log(`📐 Scaling to height: ${targetHeight}px (width auto-calculated)`); } else if (targetSize) { console.log(`📐 Scaling by size: ${targetSize}px (larger dimension)`); } console.log(`🔄 Converting ${positionals.length} image(s) to ${format.toUpperCase()}...\n`); let successCount = 0; for (const inputPath of positionals) { const outputDir = values.output ? String(values.output) : undefined; const outputPath = getOutputPath(inputPath, format, outputDir); const ok = await processImage(inputPath, outputPath, format, targetWidth, targetHeight, targetSize, targetQuality); if (ok) successCount++; } console.log(`\n📊 Done: ${successCount}/${positionals.length} image(s) processed.`); if (successCount === 0) { process.exit(1); } } main().catch((err) => { console.error(err); }) // @SCRIPT-SIGNATURE-V1: yk-r1.ES256.20260520T011157+08:00.MEYCIQDFQkz8F4jyBvFIw7WJ // 2f4gfh3q33lLn+WakUPoPRYLvAIhALaeRDnnrQuGxU9anH1lroQ5cIqbxsp6lLoV1xj3xFgV