Web production note

 【更新日 :

libSquooshで画像を一括圧縮、リサイズ、WebP変換する

Category:
開発環境

手元に検証したメモがあるので記事として公開しますが、libSquooshのプロジェクトは保守されなくなったそうなので、新規プロジェクトではsharpなど別のライブラリを導入した方が良さそうです。
ウェブアプリ版はサポートが続いているようです。

Project no longer maintained
Unfortunately, due to a few people leaving the team, and staffing issues resulting from the current economic climate (ugh), this package is no longer actively maintained. I know that sucks, but there simply isn’t the time & people to work on this. If anyone from the community wants to fork it, you have my blessing. The squoosh.app web app will continue to be supported and improved.

(DeepL翻訳)
プロジェクトは保守されなくなりました
残念ながら、数名がチームを離れ、また現在の経済情勢からくるスタッフの問題 (ugh) により、このパッケージは積極的にメンテナンスされなくなりました。残念なことですが、これに取り組む時間と人がいないのです。もしコミュニティからフォークしたい人がいれば、私はそれを祝福します。squoosh.appウェブアプリは引き続きサポートされ、改善されていきます。

https://www.npmjs.com/package/@squoosh/lib

Googleが開発した画像圧縮ウェブアプリ「Squoosh」のNode.js版である「libSquoosh」を試してみた備忘録です。

先に構築内容をまとめて記載しています。
後半は内容の補足などです。

ベースコード

ベースコードは主に以下の記事を参考にさせていただいています。

この記事のlibSquooshでできること

  • 画像の圧縮
  • 3倍の画像を2倍へリサイズ
  • 3倍の画像を等倍へリサイズ
  • WebP変換
  • 上記4処理を分割実行する
  • 上記4処理を一括実行する
  • jpg、png、webpの3種類に対応

初期設定(ファイルのインストール)

構築したいディレクトリへ移動

作業を始める前にlibSquooshを構築したいディレクトリまで移動してください。

Windowsでの移動

cd C:¥Users¥.....¥libSquooshを構築したいプロジェクトディレクトリ

Macでの移動

cd /Users/....../libSquooshを構築したいプロジェクトディレクトリ

macなら該当フォルダを右クリック→「フォルダに新規ターミナルタブ」を選択してターミナルを立ち上げるとスムーズです。

libSquooshをインストール

以下のコマンドを実行してlibSquooshをインストールします。

npm install -D @squoosh/lib

libSquooshの設定用JSファイルを作成

開発ディレクトリに各処理の設定を記載するJSファイル(config.mjs)と、4つの処理をそれぞれ記載するJSファイル(convert.mjs、optimise.mjs、resize@1x.mjs、resize@2x.mjs)計5つのファイルを作成します。

config.mjs (各処理の設定を記載するファイル)

config.mjs

/*
  libSquoosh
  https://github.com/GoogleChromeLabs/squoosh/tree/dev/libsquoosh

  参考
  https://ics.media/entry/220204/
  https://openbase.com/js/@squoosh/lib
*/
import { ImagePool } from "@squoosh/lib";
import { cpus } from "os";
import { existsSync, readdirSync, readFileSync, mkdirSync } from "fs";
import { writeFile } from "fs/promises";
const imagePool = new ImagePool(cpus().length);


/*
  除外リスト
*/
const ignore = ['sample1.jpg'];


/*
  パス設定
*/
//画像フォルダ
const IMAGE_DIR = "./images";

//出力先フォルダ
const OUTPUT_DIR = "./dist";


/*
  画像圧縮オプション
*/

//共通 前処理オプション
const preprocessOptions = {
  //減色処理
  quant: {
    // 色数。最大256
    numColors: 256,
    // ディザ。0〜1で設定
    dither: .9,
  },
};

// JPGの圧縮オプション
const jpgEncodeOptions = {
  mozjpeg: {
    quality: 70,
    baseline: false,
    arithmetic: false,
    progressive: true,
    optimize_coding: true,
    smoothing: 0,
    color_space: 3 /*YCbCr*/,
    quant_table: 3,
    trellis_multipass: false,
    trellis_opt_zero: false,
    trellis_opt_table: false,
    trellis_loops: 1,
    auto_subsample: true,
    chroma_subsample: 2,
    separate_chroma_quality: false,
    chroma_quality: 75,
  }
}

// PNGの圧縮オプション
const pngEncodeOptions = {
  oxipng: {
    /*
      Oxipng
      https://github.com/shssoichiro/oxipng
    */
    level: 4, //処理速度優先の場合は1〜2を指定
  }
}

// WebPの圧縮オプション
const webpEncodeOptions = {
  webp: {
    quality: 70,
    target_size: 0,
    target_PSNR: 0,
    method: 4,
    sns_strength: 50,
    filter_strength: 60,
    filter_sharpness: 0,
    filter_type: 1,
    partitions: 0,
    segments: 4,
    pass: 1,
    show_compressed: 0,
    preprocessing: 0,
    autofilter: 0,
    partition_limit: 0,
    alpha_compression: 1,
    alpha_filtering: 1,
    alpha_quality: 0, //透明箇所のクオリティを上げる場合は100
    lossless: 0,
    exact: 0,
    image_hint: 0,
    emulate_jpeg_size: 0,
    thread_level: 0,
    low_memory: 0,
    near_lossless: 100,
    use_delta_palette: 0,
    use_sharp_yuv: 0,
  }
}


/*
  画像変換処理
*/
async function imageConversion (options) {
  options = Object.assign({
    type: 'optimise', //圧縮:optimise、WebP変換:convert、リサイズ:resize
    resize: {
      isWebp: true,//リサイズ処理時にwebpを生成するか
      divisor: 1/3,//リサイズ処理で用いる除数
      sizeName: '@1x',//リサイズ処理で用いる画像名の末尾に追加する名称
    }
  }, options);
  const { type,resize } = options;
  const {divisor, sizeName, isWebp} = resize;

  // 画像フォルダ内のJPGとPNGを抽出
  const imageFileList = readdirSync(IMAGE_DIR).filter((file) => {
    //除外ファイルの場合は抽出しない
    const isIgnore = ignore.some(fileName => new RegExp(fileName).test(file));
    if(isIgnore) {
      return false;
    }
    const regex = /\.(jpe?g|png|webp)$/i;
    return regex.test(file);
  });

  // 抽出したファイルをimagePool内にセットし、ファイル名とimagePoolの配列を作成
  const imagePoolList = imageFileList.map((fileName) => {
    const imageFile = readFileSync(`${IMAGE_DIR}/${fileName}`);
    const image = imagePool.ingestImage(imageFile);
    return { name: fileName, image };
  });

  const encodeImage = async ({ image, name }) => {
    let options;
    let checkWebp = /\.(webp)$/i.test(name);
    if(type === 'convert' || checkWebp) {
      options = webpEncodeOptions;

    } else if (/\.(jpe?g)$/i.test(name)) {
      options = jpgEncodeOptions;

    } else if (/\.(png)$/i.test(name)) {
      options = pngEncodeOptions;
    }

    if(isWebp && !checkWebp && type === 'resize') {
      options = Object.assign(options, webpEncodeOptions);
    }

    await image.encode(options);
  }

  // 前処理を実行
  await Promise.all(
    imagePoolList.map(async (item) => {
      const { image } = item;

      // リサイズなどを処理するためにデコードする
      let imageInfo = await image.decoded;

      //リサイズ処理
      if(type === 'resize') {
        const width = imageInfo.bitmap.width;
        preprocessOptions.resize = {
          width: width * divisor
        }
        await image.preprocess(preprocessOptions);

        //リサイズ後メイン処理を実行する
        //(リサイズ後のencodeはリサイズ処理のみで圧縮されないためここで1回実行しておく)
        await image.decoded;
        delete preprocessOptions.resize;
        await image.preprocess(preprocessOptions);
        encodeImage(item);

      } else {
        await image.preprocess(preprocessOptions);
      }
    })
  );

  //画像圧縮処理
  await Promise.all(
    imagePoolList.map(async (item) => {
      encodeImage(item);
    })
  );

  // 圧縮したデータを出力する
  // 出力先フォルダがなければ作成
  if (!existsSync(OUTPUT_DIR)) {
    mkdirSync(OUTPUT_DIR);
  }
  for (const item of imagePoolList) {
    let {
      name,
      image: { encodedWith },
    } = item;

    // 圧縮したデータを格納する変数
    let data;

    let fileName = name;

    let getSizeName = '';
    if(type === 'resize') {
      getSizeName = sizeName;
    }

    // JPGならMozJPEGで圧縮したデータを取得
    if (encodedWith.mozjpeg) {
      data = await encodedWith.mozjpeg;
    }

    // PNGならOxiPNGで圧縮したデータを取得
    if (encodedWith.oxipng) {
      data = await encodedWith.oxipng;
    }

    // WebPのデータを取得
    if(encodedWith.webp) {
      data = await encodedWith.webp;
      //ファイル名を.で分割して末尾の拡張子を削除
      let nameSplit = name.split('.');
      nameSplit.pop();
      fileName = nameSplit.join('') + getSizeName + '.webp';
    }

    //リサイズ処理時のリネーム
    if(type === 'resize') {
      //ファイル名を.で分割して拡張子を取得
      let nameSplit = name.split('.');
      let extType = nameSplit[nameSplit.length-1];
      //末尾の拡張子を削除
      nameSplit.pop();
      //ファイル名をテキストに戻して末尾に名称追加
      fileName = nameSplit.join('') + getSizeName + '.';

      await writeFile(`${OUTPUT_DIR}/${fileName + extType}`, data.binary);

      //webp生成
      if(isWebp) {
        data = await encodedWith.webp;
        await writeFile(`${OUTPUT_DIR}/${fileName}webp`, data.binary);
      }
      continue;
    }

    await writeFile(`${OUTPUT_DIR}/${fileName}`, data.binary);
  }

  // imagePoolを閉じる
  await imagePool.close();
}
export { imageConversion };

convert.mjs (WebP変換処理ファイル)

convert.mjs

import { imageConversion } from "./config.mjs";
imageConversion({
  type: 'resize', //圧縮:optimise、WebP変換:convert、リサイズ:resize
  resize: {
    isWebp: true,//リサイズ処理時にwebpを生成するか
    divisor: 2/3,//リサイズ処理で用いる除数
    sizeName: '@2x',//リサイズ処理で用いる画像名の末尾に追加する名称
  }
});

optimise.mjs (画像圧縮処理ファイル)

optimise.mjs

import { imageConversion } from "./config.mjs";
imageConversion({
  type: 'convert' //圧縮:optimise、WebP変換:convert、リサイズ:resize
});

resize@1x.mjs (等倍リサイズ処理ファイル)

resize@1x.mjs

import { imageConversion } from "./config.mjs";
imageConversion({
  type: 'resize', //圧縮:optimise、WebP変換:convert、リサイズ:resize
  resize: {
    isWebp: true,//リサイズ処理時にwebpを生成するか
    divisor: 1/3,//リサイズ処理で用いる除数
    sizeName: '@1x',//リサイズ処理で用いる画像名の末尾に追加する名称
  }
});

resize@2x.mjs (2倍リサイズ処理ファイル)

resize@2x.mjs

import { imageConversion } from "./config.mjs";
imageConversion({
  type: 'resize', //圧縮:optimise、WebP変換:convert、リサイズ:resize
  resize: {
    isWebp: true,//リサイズ処理時にwebpを生成するか
    divisor: 2/3,//リサイズ処理で用いる除数
    sizeName: '@2x',//リサイズ処理で用いる画像名の末尾に追加する名称
  }
});

package.jsonへコマンドを追加

package.json

{
  〜省略〜
  "scripts": {
    "o": "node optimise.mjs",
    "r": "node resize@1x.mjs && node resize@2x.mjs",
    "c": "node convert.mjs",
    "cr": "node convert.mjs && node resize@1x.mjs && node resize@2x.mjs",
    "all": "node optimise.mjs && node convert.mjs && node resize@1x.mjs && node resize@2x.mjs"
  },
  〜省略〜
}

圧縮コマンド

npm run oもしくは node optimise.mjs で画像圧縮を実施します。

npm run o
node optimise.mjs

リサイズコマンド

npm run rもしくは node resize@1x.mjs && node resize@2x.mjs で画像のリサイズを実施します。

npm run r
node resize@1x.mjs && node resize@2x.mjs

WebP変換コマンド

npm run cもしくは node convert.mjs でWebP変換を実施します。

npm run convert
node convert.mjs

リサイズとWebP変換コマンド

npm run crでリサイズとWebP変換を同時に実施します。

npm run cr

一括実行コマンド

npm run allで全ての処理を一括で実施します。

npm run all

ディレクトリ構成

開発ファイルを src/で管理する構成です。

■プロジェクトディレクトリ
 ┣ dist (処理後の画像が出力されるディレクトリ)
 ┣ images (処理する前の画像を格納)
 ┃
 ┣ config.mjs (各処理の設定を記載するファイル)
 ┣ convert.mjs (WebP変換処理ファイル)
 ┣ optimise.mjs (画像圧縮処理ファイル)
 ┣ resize@1x.mjs(等倍リサイズ処理ファイル)
 ┣ resize@1x.mjs(2倍リサイズ処理ファイル)
 ┃
 ┣ package.json (プロジェクトのjsonファイル)
 ┃
 ┗ node_modules (編集不要:自動生成されるコアファイル格納場所)

ファイルの除外オプションについて

config.mjsの上部にあるignore配列に追加したファイル名と一致する画像は対象から除外されます。

config.mjs

〜 省略 〜

/*
  除外リスト
*/
const ignore = ['sample1.jpg'];

〜 省略 〜

oxipngのlevelオプションについて

oxipngのGitHubにそれらしい記載がありましたのでそのまま記載します。

Optimization: -o 1 through -o 6, lower is faster, higher is better compression. The default (-o 2) is sufficiently fast on a modern CPU and provides 30-50% compression gains over an unoptimized PNG. -o 4 is 6 times slower than -o 2 but can provide 5-10% extra compression over -o 2. Using any setting higher than -o 4 is unlikely to give any extra compression gains and is not recommended.

DeepL翻訳

最適化:-o 1から-o 6まで、低いほど速く、高いほど圧縮率が高い。デフォルト (-o 2) は最新の CPU で十分に速く、最適化されていない PNG と比較して 30 ~ 50% の圧縮率を得ることができます。-o 4 は -o 2 の 6 倍の速度だが、-o 2 よりも 5-10% 圧縮率が高くなる。

4は圧縮率が良いようですが速度にやや難があるようなので、速度優先の場合は2くらいの設定にするのが良さそうです。

細かい調整をしたい場合

画像によっては細かく調整したい場合があると思いますが、
その場合は同じオプションで視覚的に調整できるウェブアプリ版の利用をオススメします。