【Node.js】sharp でサクッと「AVIF」「WebP」生成

次世代画像形式といわれる「AVIF」と「WebP」。
WEBサイトに組み込むことで、表示速度を大幅に向上させることができます。

とっても魅力的な「AVIF」と「WebP」ですが、画像を生成するのが課題となります。
対応しているグラフィックソフトがほとんどないんです…

利用する場合は、プロジェクトごとに自動生成の仕組みを導入することになるかと思います。
でも、自動生成の仕組みが導入されていないプロジェクトでも使いたい…:tired_face:

というわけで、Node.js の画像処理モジュール sharp を使って、サクッと「AVIF」と「WebP」を生成できるようにしてみました！

## 仕様

事前に「JPEG」または「PNG」で書き出した画像を、「AVIF」と「WebP」に変換します。

* 変換前画像
  * `src` ディレクトリに格納（デフォルト）
    * ディレクトリ名は変更可能（オプション）
    * 変換対象の画像は変更可能（オプション）
  * 配下にディレクトリを含む場合でも再帰的に変換
* 変換後画像
  * `dist` ディレクトリに生成（デフォルト）
    * ディレクトリ名は変更可能（オプション）
    * ディレクトリが存在しない場合は自動で生成
  * 変換元画像と同一ディレクトリ（ `src` ）に生成可能（オプション）
  * ファイル名
    * 変換前画像の拡張子を含むことが可能（オプション）
  * 画像形式と品質
    * AVIF: 80 と WebP: 80（デフォルト）
    * 画像形式をどちらか一方だけにすることが可能（オプション）
    * 書き出し品質を変更可能
  * すでに同一ファイル名の画像が存在する場合は上書き

## ファイル構造

デフォルト設定では以下のような構造になります。

```
image-format-converter
├── dist/
│   ├── hoge.avif
│   ├── hoge.webp
│   ├── fuga.avif
│   ├── fuga.webp
│   ├── piyo.avif
│   └── piyo.webp
├── node_modules
├── src/
│   ├── hoge.jpg
│   ├── fuga.jpg
│   └── piyo.jpg
├── _image-format-converter.bat
├── image-format-converter.js
├── package.json
└── package-lock.json
```

## 準備

### package.json の作成

```json:package.json
{
  "name": "image-format-converter",
  "version": "1.0.0",
  "license": "UNLICENSED",
  "private": true,
  "scripts": {
    "image-format-converter": "node ./image-format-converter.js"
  },
  "type": "module"
}
```

### node_module のインストール

必要な `node_module` は以下となります。

| モジュール名 | 役割                     |
|-------------|--------------------------|
| ansi-colors | ログメッセージに色をつける |
| fancy-log   | 時刻付きのログを表示 |
| globule     | ファイルのパスを取得 |
| sharp       | 画像を処理する |

```powershell
npm i ansi-colors fancy-log globule sharp -D
```

### npm-scripts の作成

```js:image-format-converter.js
import c from 'ansi-colors'
import log from 'fancy-log'
import fs from 'fs'
import globule from 'globule'
import sharp from 'sharp'

class ImageFormatConverter {
  #defaults = {
    srcDir: 'src',
    distDir: 'dist',
    src: ['/**/*.{jpg,jpeg,png}'],
    includeExtensionName: true,
    formats: [
      { type: 'webp', quality: 80 },
      { type: 'avif', quality: 50 }
    ]
  }

  #options

  constructor(options = {}) {
    this.#options = { ...this.#defaults, ...options }
    this.#init()
  }

  async #init() {
    const imagePathList = this.#findImagePaths()
    await this.#convertImages(imagePathList)
  }

  /**
   * globパターンで指定した画像パスを配列化して返す
   * @return { array } 画像パスの配列
   */
  #findImagePaths() {
    const patterns = this.#options.src.map(
      (src) => `${this.#options.srcDir}${src}`
    )
    return globule.find({ src: patterns })
  }

  /**
   * 画像を変換する
   * @param { string } imagePath 画像パス
   * @param { object } format 画像形式と圧縮品質
   */
  async #convertImageFormat(imagePath, format) {
    const reg = /\/(.*)\.(jpe?g|png)$/i
    const [, imageName, imageExtension] = imagePath.match(reg)
    const imageFileName = this.#options.includeExtensionName
      ? `${imageName}.${imageExtension}`
      : imageName
    const distPath = `${this.#options.distDir}/${imageFileName}.${format.type}`
    try {
      await sharp(imagePath)
        .toFormat(format.type, { quality: format.quality })
        .toFile(distPath)
      log(
        `Converted ${c.blue(imagePath)} to ${c.yellow(
          format.type.toUpperCase()
        )} ${c.green(distPath)}`
      )
    } catch (error) {
      log(
        c.red(
          `Error converting image to ${c.yellow(
            format.type.toUpperCase()
          )}\n${error}`
        )
      )
    }
  }

  /**
   * 配列内の画像パスのファイルを変換する
   * @param { array } imagePathList 画像パスの配列
   */
  async #convertImages(imagePathList) {
    if (imagePathList.length === 0) {
      log(c.red('No images found to convert'))
      return
    }
    for (const imagePath of imagePathList) {
      await this.#createDistDir(imagePath)
      const conversionPromises = this.#options.formats.map((format) =>
        this.#convertImageFormat(imagePath, format)
      )
      await Promise.all(conversionPromises)
    }
  }

  /**
   * 画像を格納するディレクトリが無い場合は作成する
   * @param { string } imagePath 画像を格納するディレクトリパス
   */
  async #createDistDir(imagePath) {
    const reg = new RegExp(`^${this.#options.srcDir}/(.*/)?`)
    const path = imagePath.match(reg)[1] || ''
    const distDir = `${this.#options.distDir}/${path}`
    if (!fs.existsSync(distDir)) {
      try {
        fs.mkdirSync(distDir, { recursive: true })
        log(`Created directory ${c.green(distDir)}`)
      } catch (error) {
        log(c.red(`Failed to create directory ${c.yellow(distDir)}\n${error}`))
        throw error
      }
    }
  }
}
const imageFormatConverter = new ImageFormatConverter()
```

#### オプション

インスタンス作成時、以下のオプション設定が可能です。

| オプション             | 内容                           | 型      | デフォルト |
|----------------------|--------------------------------|---------|-------------|
| srcDir               | 変換前画像の格納ディレクトリ名      | string  | 'src' |
| distDir              | 変換後画像の格納ディレクトリ名      | string  | 'dist' |
| src                  | 変換する画像のglobパターン<br>`srcDir`以下を指定 | array | `srcDir`内のすべての画像<br>['/**/*.{jpg,jpeg,png}'] |
| includeExtensionName | ファイル名に変換前画像の拡張子を含む | boolean | false |
| formats              | 変換する画像形式と品質             | object  | AVIF: 50 <br> WebP: 80 |

```js:image-format-converter.js
const imageFormatConverter = new ImageFormatConverter({
  // 変換前画像の格納ディレクトリ変更
  srcDir: 'hoge',
  // 変換後画像の格納ディレクトリ変更
  distDir: 'hoge',
  // 変換する画像を変更（srcDir以下のパスを指定）
  src: ['/assets/img/**/*.{jpg,jpeg,png}'],
  // ファイル名に変換前画像の拡張子を含む
  includeExtensionName: true,
  // AVIFだけ生成する
  formats: [
    {
      type: 'avif',
      quality: 60 // 品質を変更
    },
  ]
})
```

### バッチファイル の作成（Windows）

:::note warn
Windows のみとなります。
npm-scripts の実行を簡単にするためにバッチファイルを作成します。
:::

```powershell:_image-format-converter.bat
@echo off
PowerShell -command npm run image-format-converter
pause
```

## npm-scripts の実行

### Windows

バッチファイル（ `_image-format-converter.bat` ）をダブルクリックでOK:ok_hand:

### Mac

ターミナルで `image-format-converter.js` ファイルのあるディレクトリ移動し、以下コマンドを実行します。

```powershell
npm run image-format-converter
```