Squooshが開発終了したので、sharpを使って画像を一括最適化、ついでにSVGにも対応した

# Squoosh開発終了？ globが通らない？

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

https://qiita.com/bananacoffee/items/1f36854d6fbc0b6e809a

いままで上記のスクリプトでlibsqooshを使って画像を変換していたが、

- Node18以降に対応していない
- 正直遅い（特にPNGの変換）
- [開発が終了してしまった](https://github.com/GoogleChromeLabs/squoosh/issues/1304)

ことを受け、話題の[sharp](https://sharp.pixelplumbing.com/)に乗り換えてみた。
また、最近はSVGファイルを使うことが多くなったのでこちらにも対応することにした

さらに、**globを最新にしたところ、Windowsでエラーが出るようになってしまった**。
どうやら、ディレクトリの区切り文字がOS依存になり、スラッシュからバックスラッシュ（円マーク）になってしまった模様。
これも合わせて修正したい。



## 要件

- 前述のスクリプトをベースにsharpに書き換える
- svgファイルはSVGOで最適化する
- svgzも出力可能にする
- **globの仕様が9.xで変わってしまった**ためこちらにも対応する


## 利用パッケージ

```sh
# 前述のスクリプトから以降する場合
npm remove @squoosh/lib
npm install --save-dev sharp svgo zlib

# 新規に作成する場合
npm install --save-dev sharp glob path fs-extra svgo zlib commander
```


## スクリプト（convertImage.mjs）

```javascript
/**
 * 画像一括変換スクリプト
 * 参考： https://sharp.pixelplumbing.com/
 *
 * インストール
 * npm install --save-dev sharp glob path fs-extra svgo zlib commander
 *
 * 実行(例)
 * node convertImage.mjs -i ./dev/_assets/images -o ./public_html/static/images -w -t
 *
 * ヘルプ
 * node convertImage.mjs -h
 */
import sharp from "sharp";
import {globSync} from "glob";
import path from "path";
import fse from "fs-extra";
import { Command } from "commander";
import { optimize, loadConfig } from "svgo";
import zlib from "zlib";

// 引数設定
const program = new Command();
program
  .requiredOption("-i, --input <string>", "ソースディレクトリ（必須）")
  .requiredOption("-o, --out <string>", "出力先ディレクトリ（必須）")
  .option("-m, --minify", "画像の最適化を行う（同一拡張子での変換）", false)
  .option("-w, --webp", "webp化を行う", false)
  .option("-a, --webp-suffix-add", "webp化の際、拡張子を書き換え（false）するか追加（true）するか", false)
  .option("-v, --svg", "svgの最適化を行う", false)
  .option("-z, --svgz", "svgzを出力する", false)
  .option("-n, --nosvg", "svgzを出力した場合、svgは出力しない", false)
  .option("-t, --truncate", "出力先のディレクトリを空にする", false)
  .parse();

/**
 * 設定項目ここから
 */
// 変換対象拡張子とエンコーダーの設定
const GET_ENCODER_FROM_EXTENSION = {
  jpg: "jpg",
  jpeg: "jpg",
  png: "png"
};

// 変換オプション（参考： https://sharp.pixelplumbing.com/api-output）
const ENCODER_OPTIONS = {
  png: {
    compressionLevel: 9,
    adaptiveFiltering: true,
    progressive: true
  },
  jpg: {
    quality: 90
  },
  webp: {
    png: {
      lossless: true
    },
    jpg: {
      quality: 90
    }
  }
};

// SVGを認識する拡張子
const SVG_EXTENSION = "svg";

/**
 * 設定項目ここまで
 */

// オプション項目読み取り
const Options = program.opts();
const IMAGE_DIR = Options.input;
const OUTPUT_DIR = Options.out;
const DO_OPTIMIZE = Options.minify;
const DO_OPTIMIZE_SVG = Options.svg;
const ENCODE_WEBP = Options.webp;
const WEBP_SUFFIX_ADD = Options.webpSuffixAdd;
const ENCODE_SVGZ = Options.svgz;
const NO_SVG = ENCODE_SVGZ && Options.nosvg;
const TRUNCATE_BEFORE = Options.truncate;
const svgoConfig = await loadConfig(); // svgo.config.jsから設定を取得

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

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

// 変数初期化
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).toLowerCase();
    // ソースパスと出力パスを取得
    const sourcePath = path.join(IMAGE_DIR, imagePath);
    const destinationPath = path.join(OUTPUT_DIR, imagePath);

    // destinationPathのディレクトリがなければ作成
    await fse.ensureDir(path.dirname(destinationPath))

    // 拡張子からエンコーダーを取得
    const encoder = GET_ENCODER_FROM_EXTENSION[fileExtension];
    // SVGかどうか
    const isSvg = fileExtension === "svg";

    // 変数の初期化
    let action = "";
    let isCopy = !encoder && !isSvg;
    let encodeOptions = {};
    let binaryData = "";

    if (encoder !== "") {
      // エンコーダーの設定
      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 if (isSvg) {
      // SVGの処理
      binaryData = fse.readFileSync(sourcePath);
      if (DO_OPTIMIZE_SVG) {
        binaryData = optimize(binaryData, svgoConfig);
        binaryData = binaryData.data;
      }
      if (!NO_SVG) {
        await fse.outputFile(destinationPath, binaryData);
        action += "optimized";
      }
      if (ENCODE_SVGZ) {
        await zlib.gzip(binaryData, async (__, svgzData) => {
          await fse.outputFile(destinationPath + "z", svgzData);
        });
        if (action !== "") {
          action += " and ";
        }
        action += "encoded to svgz";
      }
    } else {
      // 最適化を行う
      if (DO_OPTIMIZE) {
        // encoder と encodeOptions を指定して最適化
        await sharp(sourcePath)
          .toFormat(encoder, ENCODER_OPTIONS[encoder])
          .toFile(destinationPath);
        action += "optimized";
      }
      if (ENCODE_WEBP) {
        // webp と encodeOptions を指定して最適化
        const destinationPathWebp = WEBP_SUFFIX_ADD
          ? destinationPath + ".webp"
          : destinationPath.slice(0, fileExtension.length * -1) + "webp";

        await sharp(sourcePath)
          .webp(ENCODER_OPTIONS["webp"][encoder])
          .toFile(destinationPathWebp);
        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", ")");
```


## ポイント

1. 引数設定やOPTIONの変更
    - CPU数に関する設定を削除
    - OPTIONSの設定がSquooshとsharpで微妙に違うので注意。なお、設定値はお好みで変更すること。
    - SVGに関する設定を追加
    - SVGのOPTIONは長くなるので `svgo.config.js` に記載することにした。
2. globのwindows対策
    - Windowsでglob9.x以降を使うとディレクトリ記号がスラッシュ`/`から`\`マークになってしまうため、`file = "./" + file.replace(/\\/g, "/");`を追加している。
3. ディレクトリの作成
    - Squooshはディレクトリを勝手に作成してくれたが、sharpは出来ないようなので、予めディレクトリを作成するコードを追記している
4. 変換部分は大分簡略化出来た。
    - バッファとかいらなくなったのでパス指定で変換できるようになった。

## 利用方法

### 設定項目（SVG以外）

ここをいじると変換対象の拡張子とlibsquooshのオプション設定を変更できる
`GET_ENCODER_FROM_EXTENSION` に含まれていない(svg以外の)拡張子は出力ディレクトリへのコピーのみ行われる。
正直 SVG_EXTENSION はいらない気がするが、念のため書いておいた。
webpはjpegから変換する場合とpngから変換する場合で分けられるようにした。

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

/**
 * 設定項目ここから
 */
// 変換対象拡張子とエンコーダーの設定（参考： https://squoosh.app/）
const GET_ENCODER_FROM_EXTENSION = {
  jpg: "jpg",
  jpeg: "jpg",
  png: "png"
};
    
// 変換オプション（参考： https://sharp.pixelplumbing.com/api-output）
const ENCODER_OPTIONS = {
  png: {
    compressionLevel: 9,
    adaptiveFiltering: true,
    progressive: true
  },
  jpg: {
    quality: 90
  },
  webp: {
    png: {
      lossless: true
    },
    jpg: {
      quality: 90
    }
  }
};
// SVGを認識する拡張子
const SVG_EXTENSION = "svg";
/**
 * 設定項目ここまで
 */
```

### 設定項目（SVG）

svgo.config.js が存在すればそれを利用、なければデフォルトで動作する。
詳しくは以下の**Configuration**と**Built-in plugins**を参照

https://github.com/svg/svgo

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

```sh
node convertImage.mjs -h

Options:
  -i, --input <string>   ソースディレクトリ（必須）
  -o, --out <string>     出力先ディレクトリ（必須）
  -m, --minify           画像の最適化を行う（同一拡張子での変換） (default: false)
  -w, --webp             webp化を行う (default: false)
  -a, --webp-suffix-add  webp化の際、拡張子を書き換え（false）するか追加（true）するか (default: false)
  -v, --svg              svgの最適化を行う (default: false)
  -z, --svgz             svgzを出力する (default: false)
  -n, --nosvg            svgzを出力した場合、svgは出力しない (default: false)
  -t, --truncate         出力先のディレクトリを空にする (default: false)
  -h, --help             display help for command
```

### 実行

#### 変換元データ

```sh
> ls -al ./src/assets/images
-rwxrwxrwx 1 user user 49375  4月 10 12:27 sample01.jpg
-rwxrwxrwx 1 user user 14739 10月 27 16:56 sample02.png
-rwxrwxrwx 1 user user  1792  4月 10 12:27 sample03.svg
-rwxrwxrwx 1 user user 70627  4月 11 15:51 sample04.html

> ls -al ./src/assets/images/sub
-rwxrwxrwx 1 user user  255303  3月  7  2022 sample11.PNG
-rwxrwxrwx 1 user user 1198601  3月  7  2022 sample12.jpeg
-rwxrwxrwx 1 user user  346045  3月 16  2021 sample13.PNG
```

#### 1. jpg,pngをwebp変換し、SVGを最適化する

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

```sh
> node convertImage.mjs -i ./src/images -o ./public/assets/images -w -t -v

// 出力結果
[ 1 / 7 ] ./sample01.jpg is encoded to webp ( 45 ms )
[ 2 / 7 ] ./sample02.png is encoded to webp ( 59 ms )
[ 3 / 7 ] ./sample03.svg is optimized ( 4 ms )
[ 4 / 7 ] ./sample04.html is copied ( 6 ms )
[ 5 / 7 ] ./sub/sample13.PNG is encoded to webp ( 176 ms )
[ 6 / 7 ] ./sub/sample11.PNG is encoded to webp ( 46 ms )
[ 7 / 7 ] ./sub/sample12.jpeg is encoded to webp ( 24 ms )
done! ( total: 360 ms )

> ls -al ./public/assets/images
-rwxrwxrwx 1 user user 12146  4月 11 16:51 sample01.webp
-rwxrwxrwx 1 user user  4724  4月 11 16:51 sample02.webp
-rwxrwxrwx 1 user user  1448  4月 11 16:51 sample03.svg
-rwxrwxrwx 1 user user 70627  4月 11 15:51 sample04.html

ls -al ./public/assets/images/sub
-rwxrwxrwx 1 user user 145486  4月 11 16:51 sample11.webp
-rwxrwxrwx 1 user user 315570  4月 11 16:51 sample12.webp
-rwxrwxrwx 1 user user 206952  4月 11 16:51 sample13.webp
```

#### 2. jpg,pngの最適化を行い、かつ、元拡張子.webp の形でwebp変換、svgはsvgzファイルも出力する

IEや旧デバイス対応なら

```sh
> node convertImage.mjs -i ./src/images -o ./public/assets/images -m -w -t -v -z

// 出力結果
[ 1 / 7 ] ./sample01.jpg is optimized and encoded to webp ( 80 ms )
[ 2 / 7 ] ./sample03.svg is optimized and encoded to svgz ( 63 ms )
[ 3 / 7 ] ./sample02.png is optimized and encoded to webp ( 5 ms )
[ 4 / 7 ] ./sample04.html is copied ( 15 ms )
[ 5 / 7 ] ./sub/sample13.PNG is optimized and encoded to webp ( 334 ms )
[ 6 / 7 ] ./sub/sample12.jpeg is optimized and encoded to webp ( 16 ms )
[ 7 / 7 ] ./sub/sample11.PNG is optimized and encoded to webp ( 63 ms )

> ls -al ./public/assets/images
-rwxrwxrwx 1 user user 16964  4月 11 16:59 sample01.jpg
-rwxrwxrwx 1 user user 12146  4月 11 16:59 sample01.webp
-rwxrwxrwx 1 user user 15487  4月 11 16:59 sample02.png
-rwxrwxrwx 1 user user  4724  4月 11 16:59 sample02.webp
-rwxrwxrwx 1 user user  1448  4月 11 16:59 sample03.svg
-rwxrwxrwx 1 user user   794  4月 11 16:59 sample03.svgz
-rwxrwxrwx 1 user user 70627  4月 11 15:51 sample04.html

> ls -al ./public/assets/images/sub
-rwxrwxrwx 1 user user 257940  4月 11 16:59 sample11.PNG
-rwxrwxrwx 1 user user 145486  4月 11 16:59 sample11.webp
-rwxrwxrwx 1 user user 423161  4月 11 16:59 sample12.jpeg
-rwxrwxrwx 1 user user 315570  4月 11 16:59 sample12.webp
-rwxrwxrwx 1 user user 365373  4月 11 16:59 sample13.PNG
-rwxrwxrwx 1 user user 206952  4月 11 16:59 sample13.webp
```

## 利用シーン

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

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

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

## 感想

**変換がめちゃくちゃ早い**

Squooshやimageminでは特にPNGの変換がとても遅いのだが、sharpはなにか設定を間違えてるんじゃないかと思うくらい早い。
具体的にはいままで160秒くらいかかっていた変換処理が20秒足らずで終わるようになった。
いままでデプロイのボトルネックが画像だったのでこれは嬉しい。
[astro-compress](https://www.npmjs.com/package/astro-compress)で実感していたけれど、実際に比較したことはなかったので感動した。