2023年4月11日追記
本記事は Node.JS16以下、かつ、glob 8.x以下 でないと動作しません。
Node.JS18以上、またはglob 9.x以上を利用する場合は以下を参照ください。
sharpを使って画像を一括最適化、ついでにSVGにも対応した
https://qiita.com/bananacoffee/items/d7a4b5cb4afff7efd162
これ、Webpackである必要なくない?
以前、
という記事でwebpackを用いて画像変換を行うプログラムを作成したが、
- 画像変換をするだけなのにwebpackさんに出張ってもらう必要なくない?
- ImageMinimizerを経由するため変換効率が悪い
という考えと問題に至り、Node.js + libSquooshだけで変換を行えるようにしてみた。
利用パッケージ
npm install --save-dev @squoosh/lib glob path fs-extra commander
変換プログラム(convertImage.mjs)
/**
* 画像一括変換スクリプト
* 参考: 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
に含まれていない拡張子は出力ディレクトリへのコピーのみ行われる。
// 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)
> 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
実行
変換元データ
-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画像を作ってしまうとあとから変換したもので上書きされてしまうので注意
> 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や旧デバイス対応なら
> 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 とか登録しておけばいちいちパスなどを打たなくても良い
{
...,
"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化により更に巨大になってしまう。
# 変換前
-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)化しないほうがいいかもしれない。
※もし詳しい方がいれば教えて欲しいです