Files
tools/image-convert-scale-bun/index.ts
T

224 lines
8.1 KiB
TypeScript
Executable File

#!/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] <image1> [image2] ...
Convert and scale images to JPEG or PNG format.
Arguments:
<image> ... Input image file(s) (heic, jpeg, png, webp, gif, bmp, avif, tiff)
Options:
-f, --format <format> Output format: jpeg or png (default: jpeg)
-w, --width <number> Scale image to this width (height auto-calculated)
-h, --height <number> Scale image to this height (width auto-calculated)
-s, --size <number> Scale image so the larger dimension matches this size
-q, --quality <number> Output image quality, range (0, 100] (default: 95)
-o, --output <dir> 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