diff --git a/image-convert-scale-bun/.gitignore b/image-convert-scale-bun/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/image-convert-scale-bun/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/image-convert-scale-bun/bun.lock b/image-convert-scale-bun/bun.lock new file mode 100644 index 0000000..9d356a9 --- /dev/null +++ b/image-convert-scale-bun/bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "image-convert-scale", + "devDependencies": { + "@types/bun": "latest" + } + } + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.8.0", "https://registry.npmmirror.com/@types/node/-/node-25.8.0.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + + "bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="] + } +} diff --git a/image-convert-scale-bun/index.ts b/image-convert-scale-bun/index.ts new file mode 100644 index 0000000..f5e1e1c --- /dev/null +++ b/image-convert-scale-bun/index.ts @@ -0,0 +1,203 @@ +import { parseArgs } from "util"; + +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 + -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" }, + 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, +) { + 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 = await 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 = await image.resize(targetWidth, targetHeight, { fit: "fill" }); + } else if (targetWidth) { + processed = await image.resize(targetWidth); + } else if (targetHeight) { + const scale = targetHeight / originalHeight; + const scaledWidth = Math.round(originalWidth * scale); + processed = await image.resize(scaledWidth, targetHeight, { fit: "fill" }); + } else { + processed = image; + } + + let outputImg: Image; + if (format === "jpeg") { + outputImg = await processed.jpeg({ quality: 90 }); + } else { + outputImg = await processed.png(); + } + + await Bun.write(outputPath, await outputImg.buffer()); + + const outMeta = await (await Bun.file(outputPath).image()).metadata(); + const outputSize = (await 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; +} + +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 = values.width ? parseInt(String(values.width), 10) : undefined; + const targetHeight = values.height ? parseInt(String(values.height), 10) : undefined; + const targetSize = values.size ? parseInt(String(values.size), 10) : undefined; + + 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 (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); + if (ok) successCount++; + } + + console.log(`\n📊 Done: ${successCount}/${positionals.length} image(s) processed.`); + + if (successCount === 0) { + process.exit(1); + } +} + +main(); diff --git a/image-convert-scale-bun/package.json b/image-convert-scale-bun/package.json new file mode 100644 index 0000000..ce0d40c --- /dev/null +++ b/image-convert-scale-bun/package.json @@ -0,0 +1,12 @@ +{ + "name": "image-convert-scale", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "convert": "bun run index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/image-convert-scale-bun/requirement.md b/image-convert-scale-bun/requirement.md new file mode 100644 index 0000000..457e3ae --- /dev/null +++ b/image-convert-scale-bun/requirement.md @@ -0,0 +1,7 @@ +1. this is a bun project +2. read document https://bun.com/docs/runtime/image +3. load images including (heic,jpeg, png and other formats) +4. convert to jpeg or png file(user can assign the format, default jpeg) +5. user can 等比例 scale image, user can assign width or height +6. user can assign one or multiple images one time + diff --git a/image-convert-scale-bun/tsconfig.json b/image-convert-scale-bun/tsconfig.json new file mode 100644 index 0000000..e151f35 --- /dev/null +++ b/image-convert-scale-bun/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["**/*.ts"] +}