Image and Video compressor in NodeJs

First things first

Before I begin to explain how I created this package, I would like to give a big shoutout to sharp and fluent-ffmpeg because this package wouldn't be possible without these awesome libraries.

While I was compressing all my photos from my recent motorbike trip to RARA lake, I did not want to use shady-looking online third-party sites to compress my pictures. So, I came up with a solution.

Since I was getting a little rusty using Node.js, I decided it was the perfect time to reignite my knowledge.

Also, before moving forward with an in-depth explanation of the code provided, I want to acknowledge that I'm fully aware that this implementation isn't yet in its most optimal form and holds room for substantial improvements. I'm currently working on refactoring this code to enhance the overall performance and efficiency of the package.

My project has the following structure:

image-and-video-compressor
├── cli.js
├── formats.json
├── package.json
├── src
│   ├── image.js
│   ├── index.js
│   └── video.js
└── utils
    └── fns.js

package.json

This file contains metadata and configuration information about the Node.js package. It specifies details such as the author, license, version, keywords, and dependencies of the package. It also includes scripts for running specific tasks related to image and video optimization. Additionally, it defines the entry point of the package and sets up command-line execution.

{
  "author": "Rohan Poudel",
  "name": "image-and-video-compressor",
  "license": "MIT",
  "version": "1.0.8",
  "keywords": [
    "image",
    "video",
    "compressor",
    "nodejs"
  ],
  "description": "Compress Image and Video using Node.js",
  "bin": {
    "imgvidcompress": "./cli.js"
  },
  "scripts": {
    "optimise:image": "node ./cli.js optimise:image",
    "optimise:video": "node ./cli.js optimise:video",
  },
  "main": "cli.js",
  "dependencies": {
    "fluent-ffmpeg": "^2.1.2",
    "sharp": "^0.33.2",
    "yargs": "^17.7.2"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/rohanpoudel2/image_video_compressor.git"
  },
  "bugs": {
    "url": "https://github.com/rohanpoudel2/image_video_compressor/issues"
  },
  "homepage": "https://github.com/rohanpoudel2/image_video_compressor#readme"
}

formats.json

This JSON file stores lists of supported image and video formats. It categorizes formats into ImageFormats and VideoFormats arrays.

{
  "ImageFormats": {
    ".jpg": "jpeg",
    ".jpeg": "jpeg",
    ".png": "png",
    ".webp": "webp",
    ".avif": "avif",
    ".svg": "svg",
    ".tiff": "tiff",
    ".gif": "gif"
  },
  "VideoFormats": [".mp4", ".mov", ".avi", ".mkv", ".webm"]
}

cli.js

This file serves as the command-line interface (CLI) for the image and video compressor package. It utilizes the yargs library for parsing command-line arguments and executing appropriate actions based on the provided commands.

#!/usr/bin/env node
const path = require("path");
const { startImageProcess, startVideoProcess } = require(
  path.join(__dirname, "src")
);
const Formats = require(path.join(__dirname, "formats.json"));
const { processExtension } = require(path.join(__dirname, "utils/fns.js"));
const argv = require("yargs")
  .command("optimise:image", "Optimise images", {
    loadFolder: {
      describe: "Path to the Image folder",
      demandOption: true,
      type: "string",
    },
    quality: {
      describe: "Quality parameter (between 10 and 100)",
      default: 20,
      type: "number",
      coerce: (quality) => {
        if (quality < 10) {
          throw new Error("Quality cannot be less than 10.");
        }
        if (quality > 100) {
          throw new Error("Quality cannot be more than 100.");
        }
        return quality;
      },
    },
    output: {
      describe: "Image output format",
      type: "string",
      default: ".webp",
      coerce: (output) => {
        if (!Object.keys(Formats.ImageFormats).includes(output)) {
          throw new Error(
            `Supported formats: \n Images: ${Object.keys(Formats.ImageFormats).join(", ")}`
          );
        }
        return output.trim();
      },
    },
  })
  .command("optimise:video", "Optimise videos", {
    loadFolder: {
      describe: "Path to the Video folder",
      demandOption: true,
      type: "string",
    },
    quality: {
      describe: "Quality parameter (between 10 and 100)",
      default: 20,
      type: "number",
      coerce: (quality) => {
        if (quality < 10) {
          throw new Error("Quality cannot be less than 10.");
        }
        if (quality > 100) {
          throw new Error("Quality cannot be more than 100.");
        }
        return quality;
      },
    },
    output: {
      describe: "Video output format",
      type: "string",
      default: ".mp4",
      coerce: (output) => {
        if (!Formats.VideoFormats.includes(output)) {
          throw new Error(
            `Supported formats: \n Videos: ${Formats.VideoFormats.join(", ")} `
          );
        }
        return output.trim();
      },
    },
  })
  .scriptName("imgvidcompress")
  .usage("Usage: imgvidcompress <command> [options]")
  .example(
    'imgvidcompress optimise:image --loadFolder="/path/to/images" --quality=80 --output=".webp"'
  )
  .example(
    'imgvidcompress optimise:video --loadFolder="/path/to/videos" --quality=50 --output=".mp4"'
  )
  .epilogue("For more information, visit: https://www.rohanpoudel.com.np")
  .help().argv;

(() => {
  const { _ } = argv;
  const command = _[0];

  const loadFolder = argv.loadFolder.replace(/["']/g, "");
  const quality = parseInt(argv.quality.toString().replace(/["']/g, ""));
  const output = processExtension(argv.output);

  switch (command) {
    case "optimise:image": {
      startImageProcess(loadFolder, quality, output);
      break;
    }
    case "optimise:video": {
      startVideoProcess(loadFolder, quality, output);
      break;
    }
    default: {
      console.error(
        'Invalid command. Use "optimise:image" or "optimise:video"'
      );
      process.exit(1);
    }
  }
})();

utils/fns.js

This module exports a function processExtension that processes file extensions. It ensures that file extensions consistently include a dot (".") at the beginning.

const fs = require("fs");
const path = require("path");

let processCount = 0;
global.totalFiles = 0;

const loadFiles = (loadFolder, filterFunction) => {
  return new Promise((resolve, reject) => {
    if (!fs.existsSync(loadFolder)) {
      fs.mkdirSync(loadFolder);
      reject(`Add files to this path: ${loadFolder}`);
    }
    fs.readdir(loadFolder, (err, files) => {
      if (err) {
        reject(err);
      } else {
        const filteredFiles = files
          .map((fileName) => path.join(loadFolder, fileName))
          .filter(filterFunction);
        resolve(filteredFiles);
      }
    });
  });
};

const createOptimiseFolder = (optimiseFolder) => {
  if (!fs.existsSync(optimiseFolder)) {
    fs.mkdirSync(optimiseFolder);
  }
};

const cleanLogsPrint = (msg) => {
  process.stdout.clearLine();
  process.stdout.cursorTo(0);
  process.stdout.write(msg);
};

const logProgress = (
  progress = null,
  type = "",
  optimiseFolder = "",
  completed = false
) => {
  processCount++;
  const percentDone = parseInt(
    progress || type === "Video"
      ? progress
      : Math.floor((processCount / global.totalFiles) * 100)
  );
  cleanLogsPrint(`⌛️ Optimising: ${percentDone}%`);
  if (completed && percentDone == 100) {
    cleanLogsPrint(
      `\n ${type} optimised and saved at: ${optimiseFolder.split("/").slice(0, -1).join("/")}`
    );
  }
};

const lastOrOnlyOne = (arr, i) => i === arr.length - 1 || arr.length === 1;

const processExtension = (ext) => (ext.includes(".") ? ext : `.${ext}`);

module.exports = {
  loadFiles,
  createOptimiseFolder,
  logProgress,
  lastOrOnlyOne,
  processExtension,
  cleanLogsPrint,
};

src/index.js

This file contains functions for starting the image and video compression processes. It utilizes child processes (forks) to execute separate image and video compression scripts (image.js and video.js). These functions communicate with the child processes through message passing.

const path = require("path");
const { fork } = require("child_process");
const { cleanLogsPrint } = require("../utils/fns");

module.exports = {
  startImageProcess: (loadFolder, quality, output) => {
    const optimiseFolder = path.join(loadFolder, "optimised_images");

    const imageProcess = fork(path.join(__dirname, "image.js"));
    imageProcess.send({ loadFolder, optimiseFolder, quality, output });

    imageProcess.on("message", (message) =>
      cleanLogsPrint(`\n ${message.text}`)
    );
  },
  startVideoProcess: (loadFolder, quality, output) => {
    const optimiseFolder = path.join(loadFolder, "optimised_videos");

    const videoProcess = fork(path.join(__dirname, "video.js"));
    videoProcess.send({ loadFolder, optimiseFolder, quality, output });

    videoProcess.on("message", (message) =>
      cleanLogsPrint(`\n ${message.text}`)
    );
  },
};

src/image.js

This file handles the optimization of images. It listens for messages from the parent process containing details about the image optimization task. It then processes each image file found in the specified directory, compresses it using the sharp library, and saves the optimized image to the output directory.

const path = require("path");
const sharp = require("sharp");
const {
  loadFiles,
  createOptimiseFolder,
  logProgress,
  lastOrOnlyOne,
} = require("../utils/fns");

const Formats = require(path.join(__dirname, "../formats.json")).ImageFormats;

process.on("message", (payload) => {
  const { loadFolder, optimiseFolder, quality, output } = payload;
  optimiseImages(loadFolder, optimiseFolder, quality, output);
});

const isImage = (fileName) =>
  Object.keys(Formats).includes(path.extname(fileName).toLowerCase());

const processImage = (
  imagePath,
  optimiseFolder,
  quality,
  output,
  completed
) => {
  return new Promise((resolve, reject) => {
    createOptimiseFolder(optimiseFolder);

    const optimisedPath = path.join(
      optimiseFolder,
      path.basename(imagePath, path.extname(imagePath)) + output
    );
    let image = sharp(imagePath);

    const formatMethod = Formats[output];
    if (!formatMethod) {
      return reject(new Error(`Unsupported output format: ${output}`));
    }

    image = image[formatMethod]({ quality });

    image.toFile(optimisedPath, (err) => {
      if (err) {
        console.error(`Error processing image ${imagePath}: ${err}`);
        reject(err);
      } else {
        logProgress(null, "Image", optimisedPath, completed);
        resolve();
      }
    });
  });
};

const optimiseImages = (loadFolder, optimiseFolder, quality, output) => {
  loadFiles(loadFolder, isImage)
    .then((imagesPath) => {
      if (imagesPath) {
        global.totalFiles = imagesPath.length;
        return Promise.all(
          imagesPath.map((imagePath, i) =>
            processImage(
              imagePath,
              optimiseFolder,
              quality,
              output,
              lastOrOnlyOne(imagesPath, i)
            )
          )
        );
      }
    })
    .then(() => {
      process.send({ text: "Image optimisation completed 😄" });
      process.exit();
    })
    .catch((err) => {
      console.error(err);
      process.send({ text: "Image optimisation error 😵‍💫" });
      process.exit();
    });
};

src/video.js

This script handles the optimization of videos. Similar to image.js, it listens for messages from the parent process containing details about the video optimization task. It processes each video file found in the specified directory, compresses it using the fluent-ffmpeg library, and saves the optimized video to the output directory.

const path = require("path");
const ffmpeg = require("fluent-ffmpeg");
const {
  loadFiles,
  createOptimiseFolder,
  logProgress,
  lastOrOnlyOne,
} = require("../utils/fns");

const EXTENSION = require(path.join(__dirname, "../formats.json")).VideoFormats;

process.on("message", (payload) => {
  const { loadFolder, optimiseFolder, quality, output } = payload;
  optimiseVideo(loadFolder, optimiseFolder, quality, output);
});

const isVideo = (fileName) =>
  EXTENSION.includes(path.extname(fileName).toLowerCase());

const processVideo = (
  videoPath,
  optimiseFolder,
  quality,
  output,
  completed
) => {
  return new Promise((resolve, reject) => {
    createOptimiseFolder(optimiseFolder);
    const optimisedPath = path.join(
      optimiseFolder,
      path.basename(videoPath, path.extname(videoPath)) + output
    );
    const adjustedQuality = 100 - quality;

    ffmpeg(videoPath)
      .fps(30)
      .videoCodec("libx264")
      .outputOptions([`-crf ${adjustedQuality}`, "-preset fast"])
      .on("end", () => {
        logProgress(null, "Video", optimisedPath, completed);
        resolve();
      })
      .on("error", (err) => {
        console.error(`Error processing video ${videoPath}: ${err}`);
        reject(err);
      })
      .on("progress", (progress) => {
        const percentage = progress.percent ? progress.percent : 0;
        logProgress(percentage, "Video", optimisedPath, false);
      })
      .save(optimisedPath);
  });
};

const optimiseVideo = (loadFolder, optimiseFolder, quality, output) => {
  loadFiles(loadFolder, isVideo)
    .then((videosPath) => {
      if (videosPath) {
        global.totalFiles = videosPath.length;
        return Promise.all(
          videosPath.map((videoPath, i) =>
            processVideo(
              videoPath,
              optimiseFolder,
              quality,
              output,
              lastOrOnlyOne(videosPath, i)
            )
          )
        );
      }
    })
    .then(() => {
      process.send({ text: "Video optimisation completed 😄" });
      process.exit();
    })
    .catch((err) => {
      console.error(err);
      process.send({ text: "Video optimisation error 😵‍💫" });
      process.exit();
    });
};

Credits

This package wouldn't be possible without the following awesome libraries:

  • sharp - High-performance Node.js image processing.
  • fluent-ffmpeg - A fluent API to FFMPEG.

License

This project is licensed under the MIT License - see the LICENSE file for details.

GitHub Repo URL

Npm URL

Created by - Rohan Poudel