add image-convert-scale-bun
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -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=="]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] <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
|
||||||
|
-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" },
|
||||||
|
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();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user