libSquooshを使って画像一括変換

:::note warn
2023年4月11日追記
本記事は **Node.JS16以下、かつ、glob 8.x以下** でないと動作しません。
Node.JS18以上、またはglob 9.x以上を利用する場合は以下を参照ください。

**sharpを使って画像を一括最適化、ついでにSVGにも対応した**
https://qiita.com/bananacoffee/items/d7a4b5cb4afff7efd162
:::

## これ、Webpackである必要なくない？

以前、

https://qiita.com/bananacoffee/items/8c610dc59d34a85ec010

という記事でwebpackを用いて画像変換を行うプログラムを作成したが、

- 画像変換をするだけなのにwebpackさんに出張ってもらう必要なくない？
- ImageMinimizerを経由するため変換効率が悪い

という考えと問題に至り、Node.js + libSquooshだけで変換を行えるようにしてみた。

### 利用パッケージ

```sh
npm install --save-dev @squoosh/lib glob path fs-extra commander
```

### 変換プログラム（convertImage.mjs）
```js
/**
 * 画像一括変換スクリプト
 * 参考： https://github.com/GoogleChromeLabs/squoosh/tree/dev/libsquoosh
 *
 * 実行(例)
 * node convertImage.mjs -i ./dev/_assets/images -o ./public_html/static/images -w -t
 *
 * ヘルプ
 * node convertImage.mjs -h
 */
import { ImagePool } from "@squoosh/lib";
import { cpus } from "os";
import glob from "glob";
import path from "path";
import fse from "fs-extra";
import { Command } from "commander";

// 引数設定
const program = new Command();
program
  .requiredOption("-i, --input <type>", "ソースディレクトリ（必須）")
  .requiredOption("-o, --out <type>", "出力先ディレクトリ（必須）")
  .option("-m, --minify", "画像の最適化を行う（同一拡張子での変換）", false)
  .option("-w, --webp", "webp化を行う", false)
  .option(
    "-a, --webp-suffix-add",
    "webp化を行った際の拡張子の処理（true:拡張子の後ろに追加、false:既存の拡張子を置換）",
    false
  )
  .option("-t, --truncate", "変換前に出力先のディレクトリを空にする", false)
  .option(
    "-s, --size <n>",
    "最大利用CPU(worker)数（0 = CPU数）メモリ不足になるようなら減らす",
    parseInt,
    0
  )
  .action(Options => {
    if (isNaN(Options.size)) {
      console.error("最大利用CPU(worker)数は数値で入力してください");
      process.exit(1);
    }
  })
  .parse();

/**
 * 設定項目ここから
 */
// 変換対象拡張子とエンコーダーの設定（参考： https://squoosh.app/）
const GET_ENCODER_FROM_EXTENSION = {
  jpg: "mozjpeg",
  jpeg: "mozjpeg",
  png: "oxipng"
};
// 変換オプション（参考： https://squoosh.app/）
const ENCODER_OPTIONS = {
  oxipng: {
    level: 3,
    interlace: false
  },
  mozjpeg: {
    quality: 90
  },
  webp: {
    lossless: 1
  }
};
/**
 * 設定項目ここまで
 */

// オプション項目読み取り
const Options = program.opts();
const IMAGE_DIR = Options.input;
const OUTPUT_DIR = Options.out;
const MAX_WORKER_SIZE = Options.size;
const DO_OPTIMIZE = Options.minify;
const ENCODE_WEBP = Options.webp;
const WEBP_SUFFIX_ADD = Options.webpSuffixAdd;
const TRUNCATE_BEFORE = Options.truncate;

// ソースディレクトリからファイル一覧を取得
let imageFileList = [];
glob.sync(IMAGE_DIR + "/**/*.*").map(function(file) {
  imageFileList.push(file.replace(IMAGE_DIR, "."));
});

// 出力先ディレクトリを空にする
if (TRUNCATE_BEFORE) {
  fse.emptyDirSync(OUTPUT_DIR);
}

// Worker Sizeの算出
let numberOfWorkers = cpus().length;
if (MAX_WORKER_SIZE > 0 && MAX_WORKER_SIZE < numberOfWorkers) {
  numberOfWorkers = MAX_WORKER_SIZE;
}
const imagePool = new ImagePool(numberOfWorkers);
console.info("Set", numberOfWorkers, "Workers");

// 変数初期化
const ts_start = Date.now();
let ts_worker_start = Date.now();
let ts_worker_end;
let targetFileNum = imageFileList.length;
let encodedFileNum = 1;

await Promise.all(
  imageFileList.map(async imagePath => {
    const fileExtension = path.extname(imagePath).substring(1);
    const sourcePath = path.join(IMAGE_DIR, imagePath);
    const destinationPath = path.join(OUTPUT_DIR, imagePath);

    const encoder = GET_ENCODER_FROM_EXTENSION[fileExtension];
    let action = "";
    let isCopy = !encoder;
    let encodeOptions = {};

    if (!isCopy) {
      if (DO_OPTIMIZE) {
        encodeOptions[encoder] = ENCODER_OPTIONS[encoder];
      }
      if (ENCODE_WEBP) {
        encodeOptions["webp"] = ENCODER_OPTIONS["webp"];
      }
      if (Object.keys(encodeOptions).length === 0) {
        isCopy = true;
      }
    }

    if (isCopy) {
      // エンコード対象外
      await fse.copy(sourcePath, destinationPath);
      action = "copied";
    } else {
      const rawImageFile = await fse.readFile(sourcePath);
      const ingestedImage = imagePool.ingestImage(rawImageFile);
      await ingestedImage.encode(encodeOptions);
      if (DO_OPTIMIZE) {
        const encodedImage = await ingestedImage.encodedWith[encoder];
        await fse.outputFile(destinationPath, encodedImage.binary);
        action += "optimized";
      }
      if (ENCODE_WEBP) {
        const encodedImageWebp = await ingestedImage.encodedWith.webp;
        const destinationPathWebp = WEBP_SUFFIX_ADD
          ? destinationPath + ".webp"
          : destinationPath.slice(0, fileExtension.length * -1) + "webp";
        await fse.outputFile(destinationPathWebp, encodedImageWebp.binary);
        if (action !== "") {
          action += " and ";
        }
        action += "encoded to webp";
      }
    }

    // 変換結果表示
    ts_worker_end = Date.now();
    console.info(
      "[",
      encodedFileNum++,
      "/",
      targetFileNum,
      "]",
      imagePath,
      "is",
      action,
      "(",
      ts_worker_end - ts_worker_start,
      "ms",
      ")"
    );
    ts_worker_start = ts_worker_end;
  })
);

// 結果表示
console.info("done!", "(", "total:", ts_worker_end - ts_start, "ms", ")");
await imagePool.close();
```

## 利用方法

### 設定項目

ここをいじると変換対象の拡張子とlibsquooshのオプション設定を変更できる
`GET_ENCODER_FROM_EXTENSION` に含まれていない拡張子は出力ディレクトリへのコピーのみ行われる。

```js
// convertImage.mjs
// line44付近から

/**
 * 設定項目ここから
 */
// 変換対象拡張子とエンコーダーの設定（参考： https://squoosh.app/）
const GET_ENCODER_FROM_EXTENSION = {
  jpg: "mozjpeg",
  jpeg: "mozjpeg",
  png: "oxipng"
};
// 変換オプション（参考： https://squoosh.app/）
const ENCODER_OPTIONS = {
  oxipng: {
    level: 3,
    interlace: false
  },
  mozjpeg: {
    quality: 90
  },
  webp: {
    lossless: 1
  }
};
/**
 * 設定項目ここまで
 */
```

### ヘルプを表示（by commander）

```sh
> node convertImage.mjs -h
Usage: convertImage [options]

Options:
  -i, --input <type>     ソースディレクトリ（必須）
  -o, --out <type>       出力先ディレクトリ（必須）
  -m, --minify           画像の最適化を行う（同一拡張子での変換） (default: false)
  -w, --webp             webp化を行う (default: false)
  -a, --webp-suffix-add  webp化を行った際の拡張子の処理（true:拡張子の後ろに追加、false:既存の拡張子を置換） (default: false)
  -t, --truncate         出力先のディレクトリを空にする (default: false)
  -s, --size <n>         最大利用CPU(worker)数（0 = CPU数）メモリ不足になるようなら減らす (default: 0)
  -h, --help             display help for command
```

### 実行

#### 変換元データ

```sh
-rwxrwxrwx 1 user user      27  9月  2 10:19 samplehtml.html
-rwxrwxrwx 1 user user   17542  8月 31 14:53 sampleicon.ico
-rwxrwxrwx 1 user user   66564  8月 31 13:07 sampleillust.png
-rwxrwxrwx 1 user user 1305843  1月 27  2008 samplephoto.jpg
-rwxrwxrwx 1 user user    8820  8月 31 14:53 samplevector.svg
```

#### 1. jpg,pngをwebp変換する

IE非対応ならこれでOK。
ちなみに、同名のjpgとpng画像を作ってしまうとあとから変換したもので上書きされてしまうので注意

```sh
> node convertImage.mjs -i ./dev/_assets/images -o ./public_html/static/images -w -t

// 出力結果
Set 12 Workers
[ 1 / 5 ] ./samplevector.svg is copied ( 188 ms )
[ 2 / 5 ] ./sampleicon.ico is copied ( 3 ms )
[ 3 / 5 ] ./samplehtml.html is copied ( 11 ms )
[ 4 / 5 ] ./sampleillust.png is encoded to webp ( 496 ms )
[ 5 / 5 ] ./samplephoto.jpg is encoded to webp ( 2258 ms )
done! ( total: 2956 ms )

> ls -al ./public_html/static/images
-rwxrwxrwx 1 user user      27  9月  2 10:19 samplehtml.html
-rwxrwxrwx 1 user user   17542  8月 31 14:53 sampleicon.ico
-rwxrwxrwx 1 user user   20004  9月  2 10:53 sampleillust.webp
-rwxrwxrwx 1 user user 2293238  9月  2 10:53 samplephoto.webp
-rwxrwxrwx 1 user user    8820  8月 31 14:53 samplevector.svg
```

#### 2. jpg,pngの最適化を行い、かつ、元拡張子.webp の形でwebp変換する

IEや旧デバイス対応なら

```sh
> node convertImage.mjs -i ./dev/_assets/images -o ./public_html/static/images -w -t -m -a

// 出力結果
Set 12 Workers
[ 1 / 5 ] ./samplevector.svg is copied ( 565 ms )
[ 2 / 5 ] ./sampleicon.ico is copied ( 1 ms )
[ 3 / 5 ] ./samplehtml.html is copied ( 0 ms )
[ 4 / 5 ] ./samplephoto.jpg is optimized and encoded to webp ( 2661 ms )
[ 5 / 5 ] ./sampleillust.png is optimized and encoded to webp ( 2277 ms )
done! ( total: 5504 ms )

> ls -al ./public_html/static/images
-rwxrwxrwx 1 user user      27  9月  2 10:19 samplehtml.html
-rwxrwxrwx 1 user user   17542  8月 31 14:53 sampleicon.ico
-rwxrwxrwx 1 user user   37401  9月  2 10:55 sampleillust.png
-rwxrwxrwx 1 user user   20004  9月  2 10:55 sampleillust.png.webp
-rwxrwxrwx 1 user user 1291952  9月  2 10:55 samplephoto.jpg
-rwxrwxrwx 1 user user 2293238  9月  2 10:55 samplephoto.jpg.webp
-rwxrwxrwx 1 user user    8820  8月 31 14:53 samplevector.svg
```

## 利用シーン

package.json に image とか convertimage とか登録しておけばいちいちパスなどを打たなくても良い

```package.json
{
  ...,
  "scripts": {
    ...,
    "image": "node convertImage.mjs -i ./src/assets/images -o ./public/assets/images -w -t",
  },
  ...
}
```

これで`npm run image` とすれば楽に変換可能。

## SVGについて

SVGはsquooshでは最適化できないため、別途SVGOなどを導入して最適化を行うと良い。
（変換はできるが、ベクタデータの強みを消す理由がない）
なお、私はこれを拡張してSVGも扱えるようにしている（安定したら投稿する予定）

## ちなみに

スマートフォンで撮ったjpeg画像に対してwebp化を行うと、ファイルサイズが増えてしまう
特に**webp化により更に巨大になってしまう**。

```sh 
# 変換前
-rwxrwxrwx 1 user user 723256  8月 26 10:08 pixel6photo.jpg
# 変換後
-rwxrwxrwx 1 user user 1073927  9月  2 10:59 pixel6photo.jpg
-rwxrwxrwx 1 user user 4981940  9月  2 10:59 pixel6photo.jpg.webp
```

理由は分からないが、恐らくスマートフォンやデジカメの画像は十分に最適化されているためだろう。
むやみにjpegを最適（webp）化しないほうがいいかもしれない。

※もし詳しい方がいれば教えて欲しいです