webp 変換スクリプト

参考リンク:【Node.js】sharp でサクッと「AVIF」「WebP」生成 - Qiita

この記事を参考にし環境構築し、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();

リサイズとサムネイル生成を同時に行う

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 = 120;
    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の場合、ミニバージョン(120px)も作成
      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}.webp`);

    try {
      fs.mkdirSync(path.dirname(miniDestPath), { recursive: true });
      await sharp(imagePath)
        .resize({
          width: this.miniWidth,
          withoutEnlargement: true,
        })
        .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 をダブルクリックするとコマンドプロンプトが開き画像が変換される。PhotoSwipe 用にサムネイルの比率を変更していないが、たぶん 100x100 とかに設定する方法もあると思う。

imageProcessor.js 内の this.mainWidth = 800; の部分の数値を変えれば、横幅 500px とかにもできると思う。簡単ならくがきとかは 500px とか 300px で載せてるのでその都度変更している。