【Node.js】画像をWebP変換&サムネイル生成する方法

個人サイトの運営といえば、やっぱり画像フォーマットとの戦いですよね。
いかにサイズを小さくしつつ、画質の劣化を抑えるか…。

最初は TinyPNG という、ブラウザで PNG 画像を変換してくれるサービスを利用していました。

ところが調べてみると、どうやら WebP というフォーマットがサイト運営にとても向いているらしい、という情報が Google 検索でたくさん出てきます。

この記事を書いた当時は TinyPNG も CLIP STUDIO PAINT も WebP に対応していなかったため、ローカルで一括変換する方法を探しました。

WebP 変換スクリプト

この記事を参考に環境を構築し、AI に相談しながら自分好みにカスタマイズしたスクリプトを作りました。
src フォルダに入れた PNG 画像が、dest フォルダに WebP 形式で出力されます。

import c from 'ansi-colors';
import log from 'fancy-log';
import fs from 'fs';
import globule from 'globule';
import sharp from 'sharp';

class ImageFormatConverter {
  constructor(options = {}) {
    this.srcBase = options.srcBase || 'src';
    this.destBase = options.destBase || 'dest';
    this.includeExtensionName = options.includeExtensionName || false;
    this.formats = options.formats || [
      {
        type: 'webp',
        quality: 80,
      },
    ];
    this.srcImages = `${this.srcBase}/**/*.{jpg,jpeg,png}`;
    this.init();
  }

  init = async () => {
    const imagePathList = this.findImagePaths();
    await this.convertImages(imagePathList);
  };

  /**
   * globパターンで指定した画像パスを配列化して返す
   * @return { array } 画像パスの配列
   */
  findImagePaths = () => {
    return globule.find({
      src: [this.srcImages],
    });
  };

  /**
   * 画像を変換する
   * @param { string } imagePath 画像パス
   * @param { object } format 画像形式と圧縮品質
   */
  convertImageFormat = async (imagePath, format) => {
    const reg = /\\\\/(.*)\\\\.(jpe?g|png)$/i;
    const [, imageName, imageExtension] = imagePath.match(reg);
    const imageFileName = this.includeExtensionName ? `${imageName}.${imageExtension}` : imageName;
    const destPath = `${this.destBase}/${imageFileName}.${format.type}`;
    await sharp(imagePath)
      .toFormat(format.type, { quality: format.quality })
      .toFile(destPath)
      .then((info) => {
        log(`Converted ${c.blue(imagePath)} to ${c.yellow(format.type.toUpperCase())} ${c.green(destPath)}`);
      })
      .catch((err) => {
        log(c.red(`Error converting image to ${c.yellow(format.type.toUpperCase())}\\\\n${err}`));
      });
  };

  /**
   * 配列内の画像パスのファイルを変換する
   * @param { array } imagePathList 画像パスの配列
   */
  convertImages = async (imagePathList) => {
    if (imagePathList.length === 0) {
      log(c.red('No images found to convert'));
      return;
    }
    for (const imagePath of imagePathList) {
      const reg = new RegExp(`^${this.srcBase}/(.*/)?`);
      const path = imagePath.match(reg)[1] || '';
      const destDir = `${this.destBase}/${path}`;
      if (!fs.existsSync(destDir)) {
        try {
          fs.mkdirSync(destDir, { recursive: true });
          log(`Created directory ${c.green(destDir)}`);
        } catch (err) {
          log(`Failed to create directory ${c.green(destDir)}\\\\n${err}`);
        }
      }
      const conversionPromises = this.formats.map((format) => this.convertImageFormat(imagePath, format));
      await Promise.all(conversionPromises);
    }
  };
}
const imageFormatConverter = new ImageFormatConverter();

リサイズとサムネイル生成を同時に行うスクリプトの作成

私は B5 サイズで絵や漫画を描き、PNG で原寸保存しています。
そのため、サイト掲載用には横幅 800px、ギャラリー表示用には正方形 200px の WebP 画像を自動で出力できるようにカスタマイズしました。

AI に聞いた結果できあがったこのスクリプトを imageProcessor.js という名前で保存しています。

import c from "ansi-colors";
import log from "fancy-log";
import fs from "fs";
import path from "path";
import globule from "globule";
import sharp from "sharp";

class ImageProcessor {
  constructor(options = {}) {
    this.srcBase = options.srcBase || "src";
    this.destBase = "dest";
    this.includeExtensionName = options.includeExtensionName || false;
    this.formats = options.formats || [
      {
        type: "webp",
        quality: 80,
      },
    ];
    this.miniFolder = "mini";
    this.srcImages = `${this.srcBase}/**/*.{jpg,jpeg,png,webp,gif}`;
    this.mainWidth = 800;
    this.miniWidth = 200;
    this.init();
  }

  init = async () => {
    const imagePathList = this.findImagePaths();
    await this.processImages(imagePathList);
  };

  findImagePaths = () => {
    return globule.find({
      src: [this.srcImages],
    });
  };

  convertImageFormat = async (imagePath, format) => {
    const reg = /\/(.*)\.(jpe?g|png|webp|gif)$/i;
    const [, imageName, imageExtension] = imagePath.match(reg);
    const imageFileName = this.includeExtensionName ? `${imageName}.${imageExtension}` : imageName;
    const destPath = `${this.destBase}/${imageFileName}.${format.type}`;

    try {
      let sharpOptions = {};
      if (imageExtension.toLowerCase() === "gif") {
        sharpOptions.pages = 1;
      }

      // メインの画像を800pxで出力
      await sharp(imagePath, sharpOptions)
        .resize({
          width: this.mainWidth,
          withoutEnlargement: true,
        })
        .toFormat(format.type, { quality: format.quality })
        .toFile(destPath);

      log(
        `Converted ${c.blue(imagePath)} to ${c.yellow(format.type.toUpperCase())} ${c.green(
          destPath
        )}`
      );

      // WebPの場合、ミニバージョン(200px)も作成
      if (format.type === "webp") {
        await this.createMiniVersion(imagePath, imageFileName);
      }
    } catch (err) {
      log(c.red(`Error converting image to ${c.yellow(format.type.toUpperCase())}\n${err}`));
    }
  };

  createMiniVersion = async (imagePath, imageFileName) => {
    const miniDestPath = path.join(this.destBase, this.miniFolder, `${imageFileName}_th.webp`);

    try {
      fs.mkdirSync(path.dirname(miniDestPath), { recursive: true });
      await sharp(imagePath)
        .resize({
          width: this.miniWidth,
          height: this.miniWidth,
          position: "center",
        })
        .toFormat("webp", { quality: 80 })
        .toFile(miniDestPath);

      log(`Created mini version: ${c.green(miniDestPath)}`);
    } catch (err) {
      log(c.red(`Error creating mini version\n${err}`));
    }
  };

  processImages = async (imagePathList) => {
    if (imagePathList.length === 0) {
      log(c.red("No images found to process"));
      return;
    }

    for (const imagePath of imagePathList) {
      const reg = new RegExp(`^${this.srcBase}/(.*/)?`);
      const path = imagePath.match(reg)[1] || "";
      const destDir = `${this.destBase}/${path}`;

      if (!fs.existsSync(destDir)) {
        try {
          fs.mkdirSync(destDir, { recursive: true });
          log(`Created directory ${c.green(destDir)}`);
        } catch (err) {
          log(`Failed to create directory ${c.green(destDir)}\n${err}`);
        }
      }

      const conversionPromises = this.formats.map((format) =>
        this.convertImageFormat(imagePath, format)
      );
      await Promise.all(conversionPromises);
    }
  };
}

const imageProcessor = new ImageProcessor();

package.json の編集

バッチファイルから簡単に変換できるよう、package.json"scripts" に次のように追記しました。

"imageProcessor": "node imageProcessor.js"

全体はこんな感じです。

{
  "name": "image-format-converter",
  "version": "1.0.0",
  "license": "UNLICENSED",
  "private": true,
  "scripts": {
    "image-format-converter": "node ./image-format-converter.js",
    "imageProcessor": "node imageProcessor.js"
  },
  "type": "module",
  "devDependencies": {
    "ansi-colors": "^4.1.3",
    "fancy-log": "^2.0.0",
    "globule": "^1.3.4",
    "sharp": "^0.33.5"
  }
}

バッチファイルの作成

変換をワンクリックでできるように、フォルダの上のほうに置いておくため !imageProcessor.bat という名前で保存しました。

@echo off
npm run imageProcessor
pause

スクリプトを実行

!imageProcessor.bat をダブルクリックすると、コマンドプロンプトが開き、画像がまとめて変換されます。

また、imageProcessor.js 内の

this.mainWidth = 800;
this.miniWidth = 200;

の数値を変えることで、メイン画像やサムネイルのサイズを自由に変更できます。

私は、簡単なラクガキを 500px や 600px で掲載することが多いので、用途ごとに変換スクリプトやバッチファイルを作り分けています。
現在は「同時変換用」「サムネイル専用」「500px」「600px」「1000px」用のスクリプトをそれぞれ運用しています。

タイトルとURLをコピーしました