diginext-img-magic-cli
Version:
README.md
358 lines (357 loc) ⢠14.9 kB
JavaScript
import yargs from "yargs";
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import { queue } from "async";
import { getFileExtension, getFileNameWithoutExtension } from "diginext-utils/string";
import getAllFiles from "./plugins/getAllFiles";
import sharp from "sharp";
import isImageSupported from "./plugins/isImageSupported";
const { version } = require("../package.json");
class ImageConverter {
constructor(options) {
this.outputDirWebp = "";
this.outputDirThumb = "";
this.results = [];
this.processedCount = 0;
this.options = options;
this.setupOutputDirectories();
}
setupOutputDirectories() {
const normalizedDir = this.options.dir.replace(/\\/g, "/");
const folderName = getFileNameWithoutExtension(normalizedDir);
const parentDir = path.dirname(normalizedDir);
this.outputDirWebp = path.join(parentDir, `${folderName}-webp`);
this.outputDirThumb = path.join(parentDir, `${folderName}-thumb-webp`);
}
async ensureDirectoryExists(dirPath) {
try {
if (fsSync.existsSync(dirPath)) {
await fs.rm(dirPath, { recursive: true });
}
await fs.mkdir(dirPath, { recursive: true });
}
catch (error) {
throw new Error(`Failed to create directory ${dirPath}: ${error}`);
}
}
async validateDirectory() {
try {
const stats = await fs.lstat(this.options.dir);
if (!stats.isDirectory()) {
throw new Error("Path is not a directory");
}
}
catch (error) {
throw new Error(`Invalid directory: ${this.options.dir}`);
}
}
checkForDuplicateNames(files) {
const nameMap = new Map();
files.forEach((file) => {
if (isImageSupported(file)) {
const nameWithoutExt = getFileNameWithoutExtension(file);
const relativePath = path.dirname(file.replace(this.options.dir, ""));
const key = path.join(relativePath, nameWithoutExt);
if (!nameMap.has(key)) {
nameMap.set(key, []);
}
nameMap.get(key).push(file);
}
});
const duplicates = [];
nameMap.forEach((files, name) => {
if (files.length > 1) {
duplicates.push(...files);
console.warn(`ā ļø Duplicate names found for: ${name}`);
console.warn(` Files: ${files.join(", ")}`);
}
});
return duplicates;
}
async convertSingleImage(task) {
var _a;
const { inputPath, index, total } = task;
const startTime = performance.now();
const startMemory = process.memoryUsage().heapUsed;
try {
// console.log(`šø [${index}/${total}] Processing: ${path.basename(inputPath)}`);
const normalizedPath = inputPath.replace(/\\/g, "/");
const fileExt = getFileExtension(normalizedPath);
const isSupported = isImageSupported(normalizedPath);
// Handle non-image files
if (!isSupported) {
const outputPath = normalizedPath.replace(this.options.dir, this.outputDirWebp);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.copyFile(normalizedPath, outputPath);
const endTime = performance.now();
const endMemory = process.memoryUsage().heapUsed;
return {
success: true,
input: inputPath,
output: outputPath,
metrics: {
durationMs: endTime - startTime,
memoryUsedMB: (endMemory - startMemory) / 1024 / 1024,
},
};
}
// Convert image to WebP
const webpOutputPath = normalizedPath.replace(this.options.dir, this.outputDirWebp).replace(`.${fileExt}`, ".webp");
await fs.mkdir(path.dirname(webpOutputPath), { recursive: true });
await sharp(normalizedPath).webp({ quality: (_a = this.options.quality) !== null && _a !== void 0 ? _a : 90 }).toFile(webpOutputPath);
const endTime = performance.now();
const endMemory = process.memoryUsage().heapUsed;
return {
success: true,
input: inputPath,
output: webpOutputPath,
metrics: {
durationMs: endTime - startTime,
memoryUsedMB: (endMemory - startMemory) / 1024 / 1024,
},
};
}
catch (error) {
const endTime = performance.now();
const endMemory = process.memoryUsage().heapUsed;
return {
success: false,
input: inputPath,
error: error instanceof Error ? error.message : String(error),
metrics: {
durationMs: endTime - startTime,
memoryUsedMB: (endMemory - startMemory) / 1024 / 1024,
},
};
}
}
createWorker() {
return async (task) => {
try {
const result = await this.convertSingleImage(task);
this.results.push(result);
this.processedCount++;
if (!result.success) {
console.error(`ā Failed to convert ${task.inputPath}: ${result.error}`);
}
else {
console.log(`ā [${this.processedCount}/${task.total}] Completed: ${path.basename(task.inputPath)}`);
}
}
catch (error) {
const errorResult = {
success: false,
input: task.inputPath,
error: error instanceof Error ? error.message : String(error),
};
this.results.push(errorResult);
this.processedCount++;
console.error(`ā Worker error for ${task.inputPath}:`, error);
}
};
}
async processWithQueue(files) {
return new Promise((resolve, reject) => {
const concurrency = this.options.concurrency || 10;
console.log(`š Starting conversion of ${files.length} files with concurrency: ${concurrency}`);
// Create the queue with our worker function
const q = queue(this.createWorker(), concurrency);
// Set up queue event handlers
q.error((error, task) => {
console.error("Queue error occurred:", error);
console.error("Failed task:", task);
});
q.drain(() => {
console.log("\nšÆ All tasks completed!");
resolve(this.results);
});
// Add progress monitoring
// let lastProgress = 0;
// q.saturated(() => {
// console.log("š„ Queue is running at full capacity");
// });
q.empty(() => {
console.log("š Queue is empty, waiting for workers to finish...");
});
// Create tasks and add them to the queue
const tasks = files.map((file, index) => ({
inputPath: file,
index: index + 1,
total: files.length,
}));
// Add all tasks to the queue
q.push(tasks, (error) => {
if (error) {
console.error("Task completion error:", error);
}
});
// Handle the case where there are no files
if (files.length === 0) {
resolve([]);
}
});
}
async cleanup() {
try {
if (fsSync.existsSync(".temp")) {
await fs.rm(".temp", { recursive: true });
}
}
catch (error) {
console.warn("ā ļø Warning: Failed to cleanup temp directory:", error);
}
}
printSummary(results, totalDurationMs) {
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
// Calculate metrics
const totalProcessingTime = results.reduce((sum, r) => { var _a; return sum + (((_a = r.metrics) === null || _a === void 0 ? void 0 : _a.durationMs) || 0); }, 0);
const avgTimePerImage = results.length > 0 ? totalProcessingTime / results.length : 0;
const maxMemoryUsed = Math.max(...results.map((r) => { var _a; return ((_a = r.metrics) === null || _a === void 0 ? void 0 : _a.memoryUsedMB) || 0; }), 0);
const currentMemory = process.memoryUsage();
console.log("\n" + "=".repeat(50));
console.log("š CONVERSION SUMMARY");
console.log("=".repeat(50));
console.log(`ā Successful: ${successful}`);
console.log(`ā Failed: ${failed}`);
console.log(`š Output directory: ${this.outputDirWebp}`);
if (this.options.thumb) {
console.log(`š¼ļø Thumbnail directory: ${this.outputDirThumb}`);
}
console.log("\n" + "-".repeat(50));
console.log("ā±ļø PERFORMANCE METRICS");
console.log("-".repeat(50));
console.log(`ā±ļø Total wall time: ${(totalDurationMs / 1000).toFixed(2)}s`);
console.log(`ā±ļø Total processing time: ${(totalProcessingTime / 1000).toFixed(2)}s`);
console.log(`ā±ļø Avg time per image: ${avgTimePerImage.toFixed(0)}ms`);
console.log(`š¾ Peak heap memory: ${(currentMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`);
console.log(`š¾ Heap total: ${(currentMemory.heapTotal / 1024 / 1024).toFixed(2)} MB`);
console.log(`š¾ RSS (total process): ${(currentMemory.rss / 1024 / 1024).toFixed(2)} MB`);
if (failed > 0) {
console.log("\nā Failed conversions:");
results.filter((r) => !r.success).forEach((r) => console.log(` ⢠${path.basename(r.input)}: ${r.error}`));
}
console.log("=".repeat(50));
}
async convert() {
const conversionStartTime = performance.now();
try {
// Reset state
this.results = [];
this.processedCount = 0;
// Validate input directory
await this.validateDirectory();
// Get all files
const files = getAllFiles(this.options.dir, undefined, this.options.ignore);
if (files.length === 0) {
console.log("š No files found in the specified directory.");
return;
}
// Check for duplicate names
const duplicates = this.checkForDuplicateNames(files);
if (duplicates.length > 0) {
throw new Error("Cannot proceed: Found files with duplicate names but different extensions.");
}
// Setup output directories
await this.ensureDirectoryExists(this.outputDirWebp);
if (this.options.thumb) {
await this.ensureDirectoryExists(this.outputDirThumb);
}
// Process files using async queue
const results = await this.processWithQueue(files);
// Cleanup and summary
await this.cleanup();
const totalDurationMs = performance.now() - conversionStartTime;
this.printSummary(results, totalDurationMs);
}
catch (error) {
console.error("š„ Conversion failed:", error instanceof Error ? error.message : String(error));
throw error;
}
}
}
async function parseArguments() {
const argv = await yargs(process.argv.slice(2))
.usage("Usage: $0 [options]")
.example('$0 --dir "./images" --thumb --maxsize 2048 --concurrency 5', "Convert images with 5 concurrent workers")
.option("dir", {
alias: "d",
describe: "Source directory containing images",
type: "string",
demandOption: true,
})
.option("thumb", {
alias: "t",
describe: "Generate thumbnails",
type: "boolean",
default: false,
})
.option("maxsize", {
alias: "m",
describe: "Maximum size for converted images",
type: "number",
default: 4096,
})
.option("quality", {
alias: "q",
describe: "WebP quality (1-100)",
type: "number",
default: 90,
})
.option("concurrency", {
alias: "c",
describe: "Number of concurrent workers",
type: "number",
default: 12,
})
.option("ignore", {
alias: "i",
describe: "Folder names to ignore (can specify multiple)",
type: "string",
array: true,
default: [],
})
.help("h")
.alias("h", "help")
.epilog(`Version: ${version}`)
.parseAsync();
return {
dir: argv.dir,
thumb: argv.thumb,
maxsize: argv.maxsize,
quality: argv.quality,
concurrency: argv.concurrency,
ignore: argv.ignore,
};
}
async function main() {
try {
console.log(`šØ Image Converter v${version}`);
console.log("=".repeat(30));
const options = await parseArguments();
console.log(`š Source: ${options.dir}`);
console.log(`š Max size: ${options.maxsize}px`);
console.log(`šÆ Quality: ${options.quality}`);
console.log(`š¼ļø Generate thumbnails: ${options.thumb ? "Yes" : "No"}`);
console.log(`ā” Concurrency: ${options.concurrency}`);
if (options.ignore && options.ignore.length > 0) {
console.log(`š« Ignoring folders: ${options.ignore.join(", ")}`);
}
console.log("=".repeat(30));
const converter = new ImageConverter(options);
await converter.convert();
console.log("š All done!");
}
catch (error) {
console.error("š„ Application error:", error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Handle unhandled promise rejections
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
process.exit(1);
});
// Run the application
main();