841-biborokuWebフロントエンドの備忘録

Node.jsライブラリsharpを使って画像の圧縮とwebp生成を自動化する

画像の圧縮とWebP画像の生成を自動化させるモジュールjsを作成しました。

当初はimageminを使用していましたが、クオリティの設定や元ファイルの状態によっては圧縮されないファイルがあり(※)、sharpを使用することにしました。

※これ以上圧縮できない場合に生成されない(・?)


オリジナル画像があるフォルダ(src\/imagesとする)と圧縮した画像・WebP生成した画像を入れるフォルダ(\/public\/imagesとする)を用意する。

タスク

  • 📁\src\imagesのjpg/pngを📁\public\imagesへ圧縮して格納する
  • 📁public/imagesに圧縮されたjpg/pngが格納されたらWebP画像を生成する
  • gif/svgは変換せず📁public/imagesへ複製
  • 📁src/images内のフォルダ・画像を削除したら📁public/images内も削除する
  • フォルダを監視して実行する

使用したライブラリ

npm i fs fs-extra path sharp chokidar

convertImages.mjs

import fs from 'fs';
import fsExtra from "fs-extra"; //ファイルがあっても削除できる
import path from 'path';
import sharp from 'sharp';
import chokidar from 'chokidar';

const srcDir = './src/images/';
const publicDir = './public/images/';
const allowedExtensions = ['.jpg', '.png'];  //対象拡張子

//画像変換処理の設定
const sharpOptions = {
  'jpg' : [
    'jpg',
    {
      quality: 90,
      progressive: true
    }
  ],
  'png' : [
    'png',
    {
      quality: 80,
    }
  ]
}

//ディレイクトリ置換
const replaceFilePath = (beforeDir, afterDir, filePath) => path.join(afterDir, path.relative(beforeDir, filePath));

//webpへの拡張子置換
const replaceWebpFilePath = filePath => filePath.replace(/\.(jpg|png)$/i, '.webp');

//jpg,pngは圧縮処理、それ以外の拡張子はコピー
function optimizeImage(srcPath, destPath) {
  const extname = path.extname(srcPath).toLowerCase();

  if (allowedExtensions.includes(extname)) {
    const convertFormat = /\.png$/i.test(srcPath) ? sharpOptions.png : sharpOptions.jpg;
    sharp(srcPath)
      .toFormat(convertFormat[0])
      .toFile(destPath, (err) => {
        if (err) {
          console.error('Error processing image:', err);
        } else {
          console.log('convert iamge:', srcPath);
        }
      });
  } else {
    fs.copyFile(srcPath, destPath, (err) => {
      if (err) {
        console.error('Error copying image:', err);
      } else {
        console.log('Copied image:', srcPath);
      }
    });
  }
}

//jpg,pngをwebp変換
function convertWebp(destPath){
  const replaceExtension = replaceWebpFilePath(destPath);
  const webpFilePath = path.join(publicDir, path.relative(publicDir, replaceExtension))
  sharp(destPath)
    .webp({ quality: 85 })
    .toFile(webpFilePath)
}

//ディレクトリの監視と画像圧縮・変換の実行
const convertImages = async () => {
  // src/imagesディレクトリの監視を開始
  const srcWatcher = chokidar.watch(srcDir, {
    ignored: /(^|[\/\\])\../, // 隠しファイルを無視
    persistent: true
  });

  srcWatcher
    .on('all', (event, filePath) => {
      const targetFilePath = replaceFilePath(srcDir, publicDir, filePath);
      if( event === 'add' || event === 'change' ){
        optimizeImage(filePath, targetFilePath);
      } else if( event === 'addDir' ){
        fsExtra.ensureDirSync(targetFilePath);
      } else if( event === 'unlinkDir' ){  //フォルダ名を変更・削除したとき
        fsExtra.removeSync(targetFilePath);
      } else if( event === 'unlink' ){  //ファイルを削除したとき
        fsExtra.removeSync(targetFilePath);
      }
    })

  // public/imagesディレクトリの監視を開始
  const publicWatcher = chokidar.watch(publicDir, {
    ignored: /(^|[\/\\])\../, // 隠しファイルを無視
    persistent: true
  });

  publicWatcher
  .on('all', (event, filePath) => {
    const extension = path.extname(filePath).toLowerCase();
    if (allowedExtensions.includes(extension)) {
      if( event === 'add' || event === 'change' ){
        convertWebp(filePath);
      } else if( event === 'unlink' ){
        const removeFilePath = replaceWebpFilePath(filePath);
        if( !fs.existsSync(removeFilePath) ) return;
        fs.unlink(removeFilePath, (err) => {
          if (err) throw err;
          console.log('remove:', removeFilePath);
        })
      }
    }
  })
}

convertImages();

📝参考:

sharp公式ドキュメント

フォルダの監視にchokidarを使う

npm i chokidar

監視フォルダを指定すると指定フォルダ内でフォルダ・ファイルの生成や削除などを感知してくれ、イベント名とファイルパスが取得できます。

今回使用したイベントは以下の通り。

  • add / change 追加・変更
  • unlink 削除
  • addDir フォルダの追加
  • unlinkDir フォルダの削除


だいたい使用するファイル名に変更した状態でフォルダに入れることが多いのですがフォルダは都度、監視フォルダ内で生成する予定です。

フォルダを作成するとaddDirイベントがはしり、publicにフォルダを作成する仕様にしています。

フォルダを生成した瞬間フォルダ名を確定させていなくても「新しいフォルダー」が生成されてしまう・・🤔?


問題ありませんでした。

フォルダの削除でunlinkDirがはしり、publicフォルダも連動させています。

フォルダ名を確定させていない状態で「新しいフォルダー」が生成され、フォルダ名を変更し確定すると、unlinkDir→addDirのイベントが発生したので「新しいフォルダー」が削除され、確定したフォルダ名のフォルダが生成されました!


GitHub:chokidar

Next.jsの実行時に画像の圧縮・生成も実行させる

複数のnpm-scriptを実行させるためにnpm-run-allをインストール

npm i npm-run-all

package.jsonのscriptを変更

変更前

"scripts": {
  "dev": "next dev",
~
}

変更後

"scripts": {
  "dev": "npm-run-all -p dev:*",
  "dev:next": "next dev",
  "dev:optimize-images": "node convertImages.mjs",
~
}

npm run devを実行すると両方まとめて実行してくれます。


📝参考:

npm-scripts の 順次・並列実行(npm-run-all)

無駄な処理が多かった失敗版

import fs from "fs";
import fsExtra from "fs-extra"; //ファイルがあっても削除できる
import sharp from "sharp";
import path from "path";
import chokidar from "chokidar";

//元画像と変換後画像のディレクトリ
const inputDir = "./src/images";
const outputDir = "./public/images";

//ディレクトリ・ファイル名の置換
const replaceSrcToPublic = path => path.replace(/src/g, "public");
const replacePublicToSrc = path => path.replace(/public/g, "src");
const fileRenameWebp = fileName => fileName.replace(/\.(jpg|jpeg|png)$/i, '.webp');

//画像変換処理の設定
const sharpOptions = {
  'jpg' : [
    'jpg',
    {
      quality: 75,
      progressive: true
    }
  ],
  'png' : [
    'png',
    {
      quality: 70,
    }
  ]
}

//ファイルパスをWindowsからLinux/macOSの形式に変換する関数
const convertFilePathsToUnixStyle = paths => {
  return paths.map((filePath) => {
    const extname = path.extname(filePath).toLowerCase();
    if( ['.jpg', '.jpeg', '.png', '.gif', '.svg'].includes(extname) ){
      return filePath.replace(/\\/g, "/");
    }
  });
}

const optimizeImages = async(filePaths) => {
  try {
    const inputFiles = convertFilePathsToUnixStyle(filePaths);
    for (const file of inputFiles) {
      const outputPath = replaceSrcToPublic(file);
      const convertFormat = /\.png$/i.test(file) ? sharpOptions.png : sharpOptions.jpg;
      //TODO: svgとgifはただファイルコピーしたいだけ

      await sharp(file)
        .toFormat(convertFormat[0])
        .toFile(outputPath)
        .catch(error => {
          console.error(`Error processing ${file}:`, error);
        });
      // sharpモジュールを終了してリソースを開放
      // sharp.cache(false); // キャッシュを無効化
      // sharp.simd(false); // SIMDを無効化
      // sharp.concurrency(0); // 並行処理を無効化
    }
    console.log('img conversion completed!');
  } catch (error) {
    console.error('Error converting to 圧縮:', error);
    throw error;
  }
}

const createWebpImages = async(filePaths) => {
  try {
    const beforeImageFiles = convertFilePathsToUnixStyle(filePaths);
    for (const beforeFile of beforeImageFiles) {
      const fileNameWebp = fileRenameWebp(beforeFile)
      await sharp(beforeFile)
      .webp({
        quality: 60,
        lossless: true,
      })
      .toFile(fileNameWebp)
      .catch(error => {
        console.error(`Error processing ${beforeFile}:`, error);
      });
      console.log(`create Webp -> ${fileNameWebp}`);
    }
    console.log('create WebP completed!');
  } catch (error) {
    console.error('Error creating to WebP:', error);
    throw error;
  }
}

//inputDir内の画像を圧縮する
const createOptimizeImages = (filePath) => {
  let batchFileList = [];
  let batchTimeout = null;
  const createImgPath = replaceSrcToPublic(filePath);
  const imgSrcMs = fs.statSync(filePath).birthtimeMs;
  const imgPublicMs = fs.existsSync(createImgPath) && fs.statSync(createImgPath).birthtimeMs;
  if( imgSrcMs < imgPublicMs ) return

  batchFileList.push(filePath);
  if (!batchTimeout) {
    batchTimeout = setTimeout(async () => {
      try {
        await optimizeImages(batchFileList);
      } catch (error) {
        console.error('Error optimizing images:', error);
      }
      batchFileList = [];
      batchTimeout = null;
    }, 2000);
  }
}

//inputDirのフォルダ・ファイルを削除したらoutputDirのファイルを自動で削除する
const removeFile = (filePath) => {
  const relativePath = path.relative(inputDir, filePath);
  const destFilePath = path.join(outputDir, relativePath);
  if ( fs.existsSync(destFilePath) ) {
    const stats = fs.statSync(destFilePath);
    if (stats.isDirectory()) {  //フォルダか判定
      fsExtra.removeSync(destFilePath, (err) => {
        if (err) throw err;
        console.log(`${destFilePath}を削除しました`);
      });
    } else {
      const destWebpFilePath = fileRenameWebp(destFilePath);
      fs.unlink(destFilePath, (err) => {
        if (err) throw err;
        console.log(`${destFilePath}を削除しました`);
      });
      if ( destFilePath === destWebpFilePath || !fs.existsSync(destWebpFilePath) ) return;
      fs.unlink(destWebpFilePath, (err) => {
        if (err) throw err;
        console.log(`${destWebpFilePath}を削除しました`);
      });
    }
  }
}

//inputDirにフォルダがある場合outputDirにフォルダ作成
const createAddDir = (filePath) => {
  if( fs.statSync(filePath).isFile() ) return;
  return fsExtra.ensureDirSync(replaceSrcToPublic(filePath))
}

// optimizeImgs関数を呼び出すことでファイルの監視と処理を行う関数
const watchOptimizeImgs = async () => {
  console.log('Start watching images...');

  // let batchTimeout = null;
  // let batchFileList = [];
  let batchTimeout2 = null;
  let batchFileList2 = [];

  // inputDirを監視
  chokidar.watch(inputDir).on('all', (event, filePath) => {
    switch (event) {
      case "add":
        createOptimizeImages(filePath);
        break;
      case "addDir":
        createAddDir(filePath);
        break;
      // case "unlink":
      // case "unlinkDir":
      //   removeFile(filePath);
      //   break;
      default:
        break;
    }
  });
  chokidar.watch(outputDir).on('add', (event, filePath) => {
    createWebpImages(filePath);
  })
  chokidar.watch(outputDir).on('all', (event, filePath) => {
    const createImgPath = replacePublicToSrc(filePath);
    if( event === 'add' && /\.(jpg|jpeg|png)$/i.test(createImgPath) ){
      console.log(createImgPath);
      //ファイルがあって日付が新しい場合だけwebp生成する
      if( fs.existsSync(createImgPath) && fs.statSync(filePath).birthtimeMs > fs.statSync(createImgPath).birthtimeMs ){
        batchFileList2.push(filePath);
        // 一定時間経過後にバッチ処理を実行
        if (!batchTimeout2) {
          batchTimeout2 = setTimeout(async () => {
            try {
              await createWebpImages(batchFileList2);
            } catch (error) {
              console.error('Error optimizing images:', error);
            }
            // バッチ処理用のリストをクリア
            batchFileList2 = [];
            batchTimeout2 = null;
          }, 5000); // 5秒間のディレイを設定
        }
      }
    }
    //publicにだけあるファイルはpublicから削除する
    if( event === 'add' && /\.(jpg|jpeg|png|svg|gif)$/i.test(filePath) ){
      if (!fs.existsSync(createImgPath)) {
        fs.unlink(filePath, (err) => {
          if (err) throw err;
          console.log(`${filePath}を削除しました`);
        });
        //webpも消すように処理する
      }
    }
  });
}

watchOptimizeImgs();