15
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-10-02

次世代画像形式といわれる「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 の作成

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 画像を処理する
npm i ansi-colors fancy-log globule sharp -D

npm-scripts の作成

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パターン
srcDir以下を指定
array srcDir内のすべての画像
['/**/*.{jpg,jpeg,png}']
includeExtensionName ファイル名に変換前画像の拡張子を含む boolean false
formats 変換する画像形式と品質 object AVIF: 50
WebP: 80
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)

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

_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 ファイルのあるディレクトリ移動し、以下コマンドを実行します。

npm run image-format-converter
15
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?