Claude Codeで画像をWebP・AVIFに変換する — sharpのビルドと容量上限
sharpで元画像から複数の幅とWebP・AVIFを生成するスクリプトをClaude Codeに書かせ、容量の上限を超えたらStopフックとCIで落とす手順。合成画像で測ったサイズも載せます。
画像をWebPやAVIFに変えるだけなら、スクリプトはClaude Codeが数十秒で書きます。難しいのは、そのあとです。品質の数値をいじるたびに容量が動き、気づけば元の画像より重い出力が混ざります。
そこで、変換スクリプトと一緒に「容量の上限を超えたら失敗する検査」を置きます。検査はStopフックでClaude Codeの手元に差し戻し、同じ検査をCIでも回します。例はNode.jsのsharpで、元画像はsrc/images、出力はpublic/imagesに置く構成です。
先に決めるのは、幅・形式・上限の3つ
依頼文に入れる条件は3つあります。決めずに頼むと、Claude Codeが妥当そうな値を勝手に選び、その値が検査の基準になってしまいます。
| 決めること | この記事の例 | 決める根拠 |
|---|---|---|
| 出力する幅 | この記事の例480・960・1600px | 決める根拠画面での表示幅の実測 |
| 形式と品質 | この記事の例WebP(quality 80)、AVIF(quality 50) | 決める根拠sharpの既定値から始める |
| 容量の上限 | この記事の例幅ごとに40KB・120KB・300KB | 決める根拠既存ページの転送量から逆算 |
品質の既定値は、sharpのドキュメントに載っています。WebPはqualityが80、effortが4(0から6)です。AVIFはqualityが50、effortが4(0から9)で、クロマサブサンプリングは既定で4:4:4になります。既定値のまま始めて、容量が上限に収まらない画像だけ品質を動かす順が安全です。
幅と上限の数字は、サイトによって答えが違います。表の値は、あとの検査スクリプトの動作を示すための例です。
sharpのスクリプトをClaude Codeに書かせる
依頼文に入れる条件
スクリプトの仕様は、依頼文で固定します。出力の名前規則と、拡大しない指定を明記すると、あとの検査が書きやすくなります。
src/images の JPEG/PNG から、sharp で WebP と AVIF を生成する
scripts/build-images.mjs を書いて。
- 幅は 480 / 960 / 1600。元画像より広い幅は作らない
- 出力は public/images/<名前>-<幅>.<拡張子>
- WebP は quality 80、AVIF は quality 50・effort 4
- 生成した各ファイルの幅とバイト数を public/images/manifest.json に書く
- 依存の追加は sharp だけsharpのインストールはNode-API v9互換のランタイム、たとえばNode.js 20.9.0以降が前提です。プリビルドのバイナリが用意された環境なら、npm install sharpだけで入ります。npmのlockfileを複数のOSで共有するときは、公式のインストール手順にクロスプラットフォーム向けの注意があります。
出来上がるスクリプトの形
依頼文のとおりに書かせると、たとえば次の形になります。以下は、そのまま動かして確認したスクリプトです。
import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
const SRC = "src/images";
const OUT = "public/images";
const WIDTHS = [480, 960, 1600];
const FORMATS = {
webp: (s) => s.webp({ quality: 80 }),
avif: (s) => s.avif({ quality: 50, effort: 4 }),
};
await mkdir(OUT, { recursive: true });
const manifest = [];
for (const file of await readdir(SRC)) {
if (!/\.(jpe?g|png)$/i.test(file)) continue;
const name = path.parse(file).name;
const { width: srcWidth } = await sharp(path.join(SRC, file)).metadata();
// 元画像より広い幅は作らない(同じ中身のファイルが増えるだけ)
const widths = WIDTHS.filter((w) => w <= srcWidth);
if (widths.length === 0) widths.push(WIDTHS[0]);
for (const width of widths) {
for (const [ext, encode] of Object.entries(FORMATS)) {
const out = path.join(OUT, `${name}-${width}.${ext}`);
const info = await encode(
sharp(path.join(SRC, file), { autoOrient: true }).resize({
width,
withoutEnlargement: true,
}),
).toFile(out);
manifest.push({ file: out, target: width, bytes: info.size });
}
}
}
await writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
console.log(`generated ${manifest.length} files`);manifestには、実際の幅ではなく、狙った幅targetを書きます。理由は、次の節で触れるwithoutEnlargementの挙動にあります。
出力に効く指定は3か所
レビューでは、次の3点を見ます。
autoOrient: true: sharpは出力時に、EXIFの向き情報を含むメタデータをすべて外します。向きの情報が外れるため、先に反映しておかないと、スマートフォンで撮った写真の向きがずれる場合があります。コンストラクタのautoOrientは、EXIFの向きに合わせて回転・反転させる指定です。withoutEnlargement: true: 対象の幅や高さに元画像が届かないとき、拡大しません。指定がないと、小さい画像が目標の幅まで引き伸ばされます。resize({ width })のように幅だけを渡す: 幅と高さの両方を渡すと、既定のfitはcoverで、はみ出した部分が切り取られます。切り抜かれたくない画像はinsideを指定します。この例は幅だけを渡し、fitを使いません。
合成画像で測ると、AVIFはWebPよりかなり小さくなる
スクリプトの動作確認として、入力に2枚を用意しました。1枚は3200×2000のJPEG(合成したグラデーションに軽いノイズを重ねたもの、3,186,785バイト)、もう1枚は幅400の小さなJPEGです。sharp 0.35.5・Node.js 20.18.0で変換し、下の表は3200×2000の1枚だけの結果です。
| 出力 | WebP(quality 80) | AVIF(quality 50) | AVIF/WebP |
|---|---|---|---|
| 幅480 | WebP(quality 80)7,536 B | AVIF(quality 50)3,879 B | AVIF/WebP約51% |
| 幅960 | WebP(quality 80)66,892 B | AVIF(quality 50)23,247 B | AVIF/WebP約35% |
| 幅1600 | WebP(quality 80)334,736 B | AVIF(quality 50)87,252 B | AVIF/WebP約26% |
3200×2000の画像は3幅×2形式で6ファイル、幅400の画像は480の名前で1幅×2形式の2ファイルになり、合計は8ファイルです。
この数字は、合成画像だけの結果です。写真やスクリーンショットでは、WebPとAVIFの差は違った形で出ます。使える結論は、サイズの傾向ではなく、手順です。幅1600のWebPが300KBの上限を超えたことで、次の節の検査が実際に落ちました。
容量の上限を、検査スクリプトとして置く
上限の検査は、変換スクリプトとは別のファイルに分けます。変換側が上限を知らなくて済み、Claude Codeが変換の品質を調整しても、基準は動きません。
import { readFile } from "node:fs/promises";
// 幅ごとの上限(バイト)。値はプロジェクトに合わせて決める
const BUDGET = { 480: 40_000, 960: 120_000, 1600: 300_000 };
const manifest = JSON.parse(await readFile("public/images/manifest.json", "utf8"));
const over = manifest.filter((m) => m.bytes > BUDGET[m.target]);
for (const m of over) {
console.error(`OVER ${m.file}: ${m.bytes} B > ${BUDGET[m.target]} B`);
}
if (over.length > 0) process.exit(1);
console.log(`ok: ${manifest.length} files within budget`);先ほどの合成画像に対して実行すると、次の出力になり、終了コードは1でした。
node scripts/build-images.mjs
node scripts/check-image-budget.mjsgenerated 8 files
OVER public/images/hero-1600.webp: 334736 B > 300000 Bgenerated 8 filesの内訳は、前の節のとおり3200×2000の画像が6、幅400の画像が2です。幅400の画像は幅480以上を作らず、480の名前で1回だけ出力します。withoutEnlargementを付けても、広い幅を並べて出力すれば同じ中身のファイルが増えてしまうので、スクリプト側で幅を絞っています。
上限を超えたら、直す順番を決めておく
上限を超えたとき、Claude Codeに「何とかして」と頼むと、品質を下げすぎるか、上限を書き換えて通すかのどちらかに寄りがちです。直す順番を、依頼文かCLAUDE.mdに書いておきます。
## 画像の容量
- 上限は scripts/check-image-budget.mjs の BUDGET。変更しない
- 超えたら、次の順で試す: ①quality を5ずつ下げる ②effort を上げる ③その幅の出力を見直す
- 変更のたびに build-images と check-image-budget を実行し、結果を報告する
- quality は既定値から10を超えて下げない(WebP は70、AVIF は40が下限)。それより下げたい画像は、目視で確認してから人が決める2つ目の箇条の③「その幅の出力を見直す」は、人に判断を戻す合図です。品質を落としても収まらない画像は、元画像の構図や幅の設計に原因があるので、機械的な調整では解けません。
落とし穴
上限の数字を、Claude Codeが書き換えて通してしまう
検査が落ちて直そうとするとき、いちばん近道なのはBUDGETを大きくすることです。基準を動かす編集を止めるには、権限のdenyルールが使えます。
{
"permissions": {
"deny": ["Edit(/scripts/check-image-budget.mjs)"]
}
}Editのdenyルールは、組み込みの編集ツールに加えて、Bashの中でClaude Codeが認識できるファイル操作にも効きます。sedなどのファイルコマンドや、> fileのようなリダイレクトの書き込み先も対象で、sed -iでの書き換えは止まります。
止まらないのは、node -eやPythonのスクリプトのように、プロセスが自分でファイルを開いて書き換える経路です。この経路まで塞ぐには、OSレベルで書き込みを制限するサンドボックスが必要になります。
最終の門は、CIの検査と、レビューでcheck-image-budget.mjsの差分を見ることです。上限を本当に変える日は、人間がファイルを直します。
Stopフックで、上限超過のまま完了させない
検査を手で実行してもらう運用だと、忘れた回に上限超過が残ります。Stopフックにすると、Claude Codeが「終わりました」と言う前に検査が走り、落ちれば作業が続きます。
Stopフックが終了コード2を返すと、Claude Codeの停止が止まり、会話が続きます。差し戻しの理由として使われるのは、フックのstderrです。上のスクリプトはエラーをstderrに出すので、そのまま使えます。
#!/bin/bash
input=$(cat)
# 停止フックが起こした再開中は通す(解決しない条件で無限に差し戻さない)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
# 画像かスクリプトに変更があるときだけ検査する
if [ -z "$(git status --porcelain -- src/images scripts)" ]; then
exit 0
fi
node scripts/build-images.mjs >/dev/null 2>&1 || { echo "画像の生成に失敗しました" >&2; exit 2; }
node scripts/check-image-budget.mjs >&2 || exit 2設定は、イベント、マッチャーグループ、ハンドラの3段で書きます。Stopにはマッチャーがないため、hooksの配列だけを置きます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/image-budget.sh"
}
]
}
]
}
}このフックを、上の検査スクリプトに対して実行して確かめた結果は次のとおりです。
stop_hook_activeがfalseで、変更されたファイルがあり、上限を超過: 終了コード2。標準エラーにOVERの行stop_hook_activeがtrue: 終了コード0で通過
stop_hook_activeの分岐が必要な理由
stop_hook_activeは、Claude Codeが前回のStopフックの結果で作業を続けているときにtrueになります。この値を見ずに常に2を返すと、直せない条件のとき、差し戻しが終わりません。JSONのdecision: "block"による差し戻しには、連続8回の上限があります。上限に頼らず、フック側で1回に絞る方が往復が少なく済みます。
この書き方だと、再開後の1回は検査が走りません。再開した作業が上限を直せたかは、次のStopか、CIで確認することになります。再開後も検査したい場合は、stop_hook_activeの分岐を外し、フック側で試行回数を数えます。
PostToolUseではなくStopにする理由
編集のたびに検査したくなりますが、PostToolUseは向きません。PostToolUseは、ツールが成功した直後に実行され、終了コード2でも、すでに実行されたツールは取り消せず、stderrがClaudeに見えるだけです。さらに、BashコマンドやClaude Code外のプロセスがファイルを書き換えた場合は、Edit|Write向けのフックは動きません。画像の変換は、ほとんどBashで走るため、取りこぼしが出ます。完了の直前に1回だけ検査するStopの方が、この作業に合います。
CIで同じ検査を回す
フックは、Claude Codeを使う人の手元でしか効きません。他の人のPRやCIには届かないので、門はCIに置きます。フックとCIに同じスクリプトを使えば、基準が1つで済みます。
name: images
on: pull_request
jobs:
budget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: node scripts/build-images.mjs
- run: node scripts/check-image-budget.mjs構成は2通りです。
- 生成物をコミットしない: 上のワークフローのように、ビルドしてから検査します。
- 生成物をコミットする: まず、コミット済みの
manifest.jsonを、ビルドせずに検査します。そのうえで、build-images.mjsを実行したあとにgit diff --exit-codeを走らせ、manifestと実ファイルがずれていないこと(ビルドの再現性)を確かめます。検査とビルドの再現性の確認は別のステップです。
CIでClaude Code自体を動かす場合は、非対話のclaude -pが使えます。ツールの自動許可は--allowedToolsで絞ります。ただし、容量の検査は決定的な処理なので、モデルを介さずスクリプトのまま回す方が速く、結果も安定します。
画像が崩れていないかは、検査では分からない
容量の上限は、画質を保証しません。上限に収めようとして品質を下げ続けると、グラデーションの帯や、文字まわりのノイズが出ます。
次の確認は人間が行う前提で、手順に入れておきます。
容量が収まったあとの確認
- 1
元画像と並べて見る
上限の近くまで下げた画像を、元画像と等倍で並べます。文字・細い線・グラデーションを見ます。
- 2
透過のある画像を分ける
sharpのWebP出力には
exactオプションがあり、既定では透明な画素の色データを保ちません。透過部分の色が見た目に効くデザインは、個別に確認します。 - 3
HTML側の出し分けを確認する
<picture>のsourceでAVIF、WebP、元の形式の順に並べているか、srcsetとsizesが幅の候補に合っているかを見ます。next/imageへ置き換える場合の見方は、next/image置換の差分レビューにまとめています。
変換でつまずきやすい点
- AVIFが遅い: AVIFの
effortは0から9で、数値を上げるほど遅くなります。CIの時間が延びるときは、effortを下げます。 - アニメーション画像: sharpのAVIF出力は、画像シーケンスに対応していません。アニメーション画像は、AVIFにせず別に扱います。アニメーションWebPを最適化する例は、入力側に
animated: trueを渡す形です。 - ロスレスは別の指定: WebPとAVIFには
lossless: trueの可逆圧縮モードがあります。上限が厳しい画像に使う前に、容量を測ります。 - メタデータが消える: 出力からEXIFが消えるのは既定の動作です。撮影情報を残す必要がある画像は、
keepExif()を使います。EXIFには撮影場所などが入ることがあるため、残す項目は公開前に決めます。 - バンドル側の対策と混ぜない: 画像の容量は、JavaScriptのバンドルとは別の問題です。バンドル側の上限の置き方はバンドルサイズ削減の記事にあります。
まとめ
画像の変換そのものは、Claude Codeに任せて早く済みます。手間をかける場所は、変換の外側です。容量の上限を別ファイルに置き、その編集を止め、StopフックとCIの両方で同じスクリプトを回す。この3点があれば、品質の調整を繰り返す作業を、Claude Codeに安心して渡せます。
上限の数字だけは、サイトの実測から人間が決める値です。この記事の40KB・120KB・300KBは、スクリプトの動きを見せるための例なので、そのまま使わず、既存ページの転送量から決め直してください。
よくある質問
Claude Codeに画像そのものを渡して変換させられますか
画像の変換は、スクリプトで行います。Claude Codeは、画像を読んで内容を説明することはできますが、容量や形式の変換は、sharpのような処理系が担う仕事です。巨大な画像をClaude Codeに読ませる場合のエラーは、「Image was too large」の記事で扱っています。
JPEGを残してWebPとAVIFだけ配信してよいですか
形式ごとの対応状況は、利用者のブラウザによって違います。<picture>でAVIF、WebP、JPEGの順に出し分け、最後に元の形式を置く構成なら、AVIFに対応していない環境でも画像は表示されます。元画像はsrc/imagesに残しておきます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。