Claude Codeで画像最適化を自動化:WebP変換と遅延読み込みで表示を速くする
数MBの画像をうっかり本番に流した僕が、Claude CodeにWebP/AVIF変換・遅延読み込み・CIサイズ検査を任せて表示を速くした手順を、コピペできるコード付きで解説。
ある朝、ブログの表示が妙に重い。原因を探ったら、前日に貼った1枚のスクリーンショットが3.8MBもありました。1920pxのPNGをそのままアップしていたんです。
スマホで開いたら、画像が出るまで体感で2秒。読者は待ってくれません。
そこで僕は「画像を見るたびに手で圧縮する」のをやめて、Claude Codeに変換の仕組みごと作らせることにしました。WebPやAVIFへの変換、画面サイズに合わせた配信、そして「重すぎる画像はそもそも公開させない」門番まで。この記事は、その作り方をまるごと共有します。
この記事の要点
- 画像最適化は「あとで圧縮」では必ず抜け漏れが出る。変換→配信→検査をパイプラインにして自動化するのが正解
sharpで1枚の元画像からAVIF / WebP / JPEGを複数サイズ生成し、<picture>でブラウザに選ばせる- ヒーロー画像だけ優先読み込み、それ以外は遅延読み込み。ここを混ぜると逆に遅くなる
- CIで「○○KBを超えたらビルドを落とす」検査を入れると、巨大画像の事故がゼロになる
- Claude Codeには「圧縮して」ではなく入力・出力・作らない範囲を渡すと一発で通る
Claude Codeの基本操作に不安があるなら、先にClaude Code入門ガイドに目を通しておくと、このあとの依頼例がすっと入ってきます。画像以外の速度改善もまとめて見直したい人は、Claude Codeでパフォーマンス最適化を進める方法が地続きで役立ちます。
なぜ「あとで圧縮」は破綻するのか
最初に正直な話をします。僕は長いこと、画像最適化を「気が向いたときにまとめてやる作業」だと思っていました。
これが続かない。トップのヒーロー画像、記事内のスクリーンショット、商品一覧のサムネイル。数が増えるほど、どれを圧縮したか分からなくなります。そして忙しい日に限って、巨大なPNGをそのまま貼ってしまう。冒頭の3.8MBがまさにそれでした。
しかも画像はLargest Contentful Paint(LCP)——ページの中でいちばん大きな要素が表示されるまでの時間——に直撃します。LCPはGoogleが見ている速度指標のひとつなので、重い画像はSEOにも効いてきます。気分の問題ではなく、数字の問題なんですね。
だから「人間が頑張る」前提をやめます。画像を置いたら毎回同じ品質で勝手に変換され、おかしなサイズはCIが止める。この状態を作れば、僕がうっかり者でも事故りません。Claude Codeには「画像を圧縮して」とふわっと頼むのではなく、役割を3つに分けて依頼します。
| 担当 | やること | やらないこと |
|---|---|---|
| 変換スクリプト | 元画像から派生画像を作る | 表示や配信のことは考えない |
| 表示コンポーネント | 最適な画像候補をブラウザに渡す | 変換はしない |
| 検証スクリプト | 重すぎる画像でビルドを止める | 画像は作らない |
役割を割ると、レビューで見るべき差分がはっきりします。これは画像に限らず、Claude Codeに仕事を任せるときの基本姿勢です。詳しくはClaude Codeのワークフロー自動化でも触れています。
最初に決める品質基準(ここで手を抜くと後悔する)
実装の前に、品質の物差しを決めます。圧縮率だけで判断すると、必ずどこかでやりすぎます。
スクリーンショット、写真、図解では「許される劣化」がまるで違うからです。風景写真はAVIFで強めに圧縮しても気づかれませんが、UIのスクリーンショットは同じ設定だと文字がにじんで読めなくなる。僕はこの違いを、Claude Codeに渡す前提条件として表にしています。
| 用途 | 目標 | 注意点 |
|---|---|---|
| ヒーロー画像 | 1280px以上、AVIF/WebP優先、JPEGフォールバック | LCP対象なのでpriority扱いにする |
| 記事内スクリーンショット | 640px/960px中心、文字が読める品質 | AVIFの品質を下げすぎるとUI文字がにじむ |
| ギャラリー/一覧 | 320px/640pxを多用 | 遅延読み込みで初期表示を軽くする |
| OGP/SNS用 | JPEGまたはPNGを残す | クローラーがAVIFを読まないケースに備える |
候補を出す順番はAVIF → WebP → JPEGが現実的です。対応形式はsharp公式ドキュメントで確認できます。HTML側はMDNのレスポンシブ画像ガイドの考え方に合わせて、srcsetとsizesを必ずセットで扱うのがコツです。片方だけだと効きません。
sharpで派生画像を生成する(変換の心臓部)
まず変換スクリプトです。Node.js 18.17以上を前提に、public/images/originalに置いたJPEG/PNGからpublic/images/optimizedへ派生画像を吐き出します。
依存はこれだけ。
npm install -D sharp glob tsx
スクリプト本体です。長く見えますが、やっていることは「元画像を読む→幅と形式を総当たりで変換→manifest.jsonにどの画像を作ったか記録する」だけです。
// scripts/optimize-images.ts
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
import { glob } from "glob";
import sharp from "sharp";
const inputDir = process.argv[2] ?? "public/images/original";
const outputDir = process.argv[3] ?? "public/images/optimized";
const widths = [320, 640, 960, 1280, 1920] as const;
const formats = ["avif", "webp", "jpeg"] as const;
// 品質値: AVIFは低めでも保つ。写真とUIで後から調整する前提
const quality = { avif: 52, webp: 76, jpeg: 82 } as const;
type ImageFormat = (typeof formats)[number];
type ManifestEntry = {
src: string;
width: number;
format: string;
bytes: number;
};
const manifest: Record<string, ManifestEntry[]> = {};
function slugFromPath(filePath: string) {
const relative = path.relative(inputDir, filePath);
return relative
.replace(path.extname(relative), "")
.split(path.sep)
.join("-")
.replace(/[^a-zA-Z0-9_-]/g, "-")
.toLowerCase();
}
function extension(format: ImageFormat) {
return format === "jpeg" ? "jpg" : format;
}
async function buildVariant(filePath: string, slug: string, width: number, format: ImageFormat) {
let image = sharp(filePath).rotate().resize({ width, withoutEnlargement: true });
if (format === "avif") image = image.avif({ quality: quality.avif, effort: 4 });
if (format === "webp") image = image.webp({ quality: quality.webp, effort: 4 });
if (format === "jpeg") image = image.jpeg({ quality: quality.jpeg, mozjpeg: true });
const fileName = `${slug}-${width}w.${extension(format)}`;
const target = path.join(outputDir, fileName);
const info = await image.toFile(target);
return {
src: `/images/optimized/${fileName}`,
width: info.width,
format: extension(format),
bytes: info.size,
};
}
async function optimizeOne(filePath: string) {
const metadata = await sharp(filePath).metadata();
const sourceWidth = metadata.width ?? widths[widths.length - 1];
// 元画像より大きい幅は作らない(水増しを防ぐ)
const targetWidths: number[] = widths.filter((width) => width <= sourceWidth);
if (!targetWidths.includes(sourceWidth)) targetWidths.push(sourceWidth);
targetWidths.sort((a, b) => a - b);
const slug = slugFromPath(filePath);
manifest[slug] = [];
for (const width of targetWidths) {
for (const format of formats) {
manifest[slug].push(await buildVariant(filePath, slug, width, format));
}
}
console.log(`optimized ${slug}: ${manifest[slug].length} files`);
}
async function main() {
await mkdir(outputDir, { recursive: true });
const pattern = `${inputDir.replace(/\\/g, "/")}/**/*.{jpg,jpeg,png}`;
const files = await glob(pattern, { nodir: true });
for (const filePath of files) {
await optimizeOne(filePath);
}
await writeFile(
path.join(outputDir, "manifest.json"),
JSON.stringify(manifest, null, 2),
);
console.log(`done: ${files.length} source images`);
}
void main().catch((error) => {
console.error(error);
process.exit(1);
});
このスクリプトのキモは、元画像より大きい幅を無理に作らないことです。withoutEnlargementに頼るだけだと、ファイル名は1280wなのに中身は900px、という気持ち悪い状態が生まれます。targetWidthsを明示的に組み立てておくと、レビューで「なぜこの幅だけ無いの?」と聞かれても即答できます。
レスポンシブ画像コンポーネントで配信する
次に、生成した画像をブラウザへ渡すコンポーネントです。ブラウザは<source>の並び順とsizesを見て、いまの表示幅とデバイスのピクセル密度に合う画像を自分で選びます。
ここでsizesが雑だと、せっかく小さい画像を用意しても、ブラウザは「画面いっぱいに表示される」と勘違いして大きい候補を取りに行きます。小さい画像が無駄になるんですね。
// src/components/OptimizedImage.tsx
import type { ImgHTMLAttributes } from "react";
type OptimizedImageProps = Omit<
ImgHTMLAttributes<HTMLImageElement>,
"src" | "srcSet" | "sizes" | "width" | "height" | "loading"
> & {
slug: string;
alt: string;
width: number;
height: number;
widths?: number[];
sizes?: string;
priority?: boolean;
};
function srcSet(slug: string, widths: number[], extension: "avif" | "webp" | "jpg") {
return widths
.map((width) => `/images/optimized/${slug}-${width}w.${extension} ${width}w`)
.join(", ");
}
export function OptimizedImage({
slug,
alt,
width,
height,
widths = [320, 640, 960, 1280],
sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 960px",
priority = false,
className,
...imgProps
}: OptimizedImageProps) {
const fallbackWidth = widths.includes(960) ? 960 : widths[Math.floor(widths.length / 2)];
const priorityProps = priority
? ({ fetchPriority: "high" } as ImgHTMLAttributes<HTMLImageElement>)
: {};
return (
<picture className={className}>
<source type="image/avif" srcSet={srcSet(slug, widths, "avif")} sizes={sizes} />
<source type="image/webp" srcSet={srcSet(slug, widths, "webp")} sizes={sizes} />
<img
src={`/images/optimized/${slug}-${fallbackWidth}w.jpg`}
srcSet={srcSet(slug, widths, "jpg")}
sizes={sizes}
width={width}
height={height}
alt={alt}
loading={priority ? "eager" : "lazy"}
decoding={priority ? "sync" : "async"}
{...priorityProps}
{...imgProps}
/>
</picture>
);
}
ヒーロー画像だけはpriorityをtrueにします。記事中の画像まで全部eagerにすると、初期表示でネットワークが渋滞して、肝心のファーストビューが遅れます。逆に、画面外の画像はloading="lazy"で後回しにする。この出し分けが効くんです。
最初に見える大きな要素を改善対象にする考え方は、web.devのLCP解説が分かりやすいです。遅延読み込みそのものをもっと丁寧に詰めたい人は、Claude Codeで画像の遅延読み込みを安全に実装するで、CLS(レイアウトのガタつき)対策まで掘り下げています。
CIで巨大画像を止める(門番を置く)
最後に門番です。変換された画像が大きくなりすぎたら、ビルドそのものを落とします。
人間のレビューだけに頼ると、必ず破綻します。冒頭の3.8MB事故も、僕が目視チェックを過信した結果でした。機械でわかることは機械に止めさせる。これがいちばん確実です。
// scripts/check-image-budget.mjs
import { readFile } from "node:fs/promises";
const manifestUrl = new URL("../public/images/optimized/manifest.json", import.meta.url);
const manifest = JSON.parse(await readFile(manifestUrl, "utf8"));
// 上限はデフォルト240KB。環境変数で上書きできる
const maxBytes = Number(process.env.IMAGE_BUDGET_BYTES ?? 240_000);
const failures = [];
for (const [slug, entries] of Object.entries(manifest)) {
for (const entry of entries) {
const isLargeCandidate = entry.width >= 1280 && ["avif", "webp", "jpg"].includes(entry.format);
if (isLargeCandidate && entry.bytes > maxBytes) {
failures.push(`${slug} ${entry.width}w.${entry.format}: ${entry.bytes} bytes`);
}
}
}
if (failures.length > 0) {
console.error(`Image budget exceeded. Limit: ${maxBytes} bytes`);
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("Image budget check passed.");
package.jsonにコマンドを2つ足しておけば、npm run images:buildで変換、npm run images:checkで検査が回ります。
{
"scripts": {
"images:build": "tsx scripts/optimize-images.ts",
"images:check": "node scripts/check-image-budget.mjs"
}
}
検査ロジックは単純ですが、運用ではびっくりするほど効きます。もっと厳しくするなら、ヒーロー画像は200KB、記事内スクリーンショットは300KB、サムネイルは80KBというように、用途別の予算をmanifestへ持たせます。
こんな場面で効く(3つの実例)
1. 技術ブログ
記事のスクリーンショットはPNGのまま放置されがちです。UI文字が読める範囲でWebP化すると、転送量がぐっと下がります。Claude Codeには「本文幅は最大960px、スマホは100vw、スクリーンショットの文字を優先」と伝えると、sizesまで含めた提案が返ってきます。
2. SaaSのランディングページ
ファーストビューのヒーロー画像はLCPに直撃します。ここではAVIF/WebP化に加えて、widthとheightを必ず指定して、画像が遅れて出るときのレイアウトのガタつき(CLS)を防ぎます。Claude Codeには「ヒーローだけpriority、それ以外はlazy」と明示するのがポイントです。
3. ECやポートフォリオのギャラリー
同じ商品写真をカード・詳細・OGPで使い回すと、幅の候補と命名規則が崩れやすい。manifest.jsonを作っておくと、どの派生画像が存在するかをテストや管理画面から確認できます。画像をたくさん並べるなら、Claude Codeで画像ギャラリーを作るも合わせてどうぞ。
Claude Codeへの上手な頼み方
実務では、こう制約を先に渡すと手戻りが激減します。「いい感じに最適化して」は、まず外します。
public/images/original にある jpg/png を sharp で最適化するスクリプトを作ってください。
出力先は public/images/optimized です。
幅は 320, 640, 960, 1280, 1920px、形式は avif, webp, jpg。
元画像より大きい幅は作らないでください。
manifest.json に src, width, format, bytes を保存してください。
package scripts に images:build と images:check を追加してください。
既存の他ファイルは触らず、テスト可能な最小差分にしてください。
この依頼文は「どのファイルを作るか」「何を作らないか」「どう検証するか」が全部明確です。Claude Codeは便利ですが、画像品質の最終ジャッジまでは自動で正解を出せません。レビュー担当が比較できるよう、出力ファイル名・幅・サイズを残す設計にしておくのが大事です。
そして、一度に「Next.js/Astro対応、画像アップロード、CDN、管理画面、テスト全部やって」と丸投げしないこと。差分が巨大になってレビュー不能になります。変換スクリプト→表示コンポーネント→CI検証の順で、小さく頼むのが結局いちばん速いです。
よくある質問
Q. AVIFだけ作れば十分では?
やめておいたほうがいいです。僕も最初「AVIFだけで十分」と思って失敗しました。古いブラウザやSNSのクローラーはAVIFを読めないことがあります。AVIF → WebP → JPEGの順でフォールバックを用意して、<picture>にブラウザを選ばせるのが安全です。
Q. AVIFの品質はどこまで下げていい? 写真とUIで分けてください。風景写真なら50台でも気づかれませんが、コード画面やUIスクリーンショットを同じ値で潰すと文字がにじみます。僕は写真をAVIF 50前後、UI系はWebP/JPEGも見ながら少し高めにしています。
Q. loading="lazy"を全画像に付ければ速くなる?
逆効果です。ファーストビューのメイン画像まで遅延読み込みにすると、LCPが悪化します。画面に最初から見える画像はeager+priority、画面外だけlazyが正解です。
Q. sizesは省略してもいい?
省略すると、ブラウザは「画像がビューポート幅いっぱいに表示される」と仮定しがちです。カード内の小さな画像でも大きい候補を取りに行くので、srcsetの効果が薄れます。面倒でもsizesはセットで書きましょう。
Q. WordPressや既存CMSでも使える? 変換スクリプトとCI検査の考え方はそのまま流用できます。表示部分は環境に合わせて差し替えてください。CMS側が自動でWebPを吐く場合でも、「重すぎる画像を止める門番」は別途あると事故が減ります。
実際に試した結果
僕の検証では、1920pxのPNGスクリーンショットをそのまま配信していたページで、記事内画像の転送量が半分以下になりました。3.8MB事故のページは、最終的に数百KBまで落ちました。
一方で、欲張ってAVIF品質を45まで下げたときは、コード画面の文字がつぶれて読者にとってはむしろ改悪でした。最終的に落ち着いたのは、写真はAVIF 50台、UIスクリーンショットはWebP/JPEGも確認しながら少し高めという運用です。数字は一発で決まらない、というのが正直な実感です。
次の一手はシンプルです。いきなり全部に適用せず、1カテゴリだけ対象にしてnpm run images:buildとnpm run images:checkをCIに入れてみてください。うまく回ったら、Claude Codeのワークフロー自動化と組み合わせて、画像追加時のチェックをプルリクエストに織り込むのがおすすめです。賢いAIを探すより、転んでもケガしない門番を先に置く。これが、うっかり者の僕がたどり着いた結論です。
手を動かす教材や、自分のサイトに合わせた相談が欲しくなったら、教材一覧をのぞいてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。