Advanced (更新: 2026/6/2)

Claude Codeで画像最適化パイプラインを作る実践ガイド

Claude CodeでWebP/AVIF変換、レスポンシブ画像、CI検証まで自動化する実践手順。

Claude Codeで画像最適化パイプラインを作る実践ガイド

画像最適化は「あとで圧縮する」だけでは安定しません。トップページのヒーロー画像、記事内のスクリーンショット、商品一覧のサムネイルが増えるほど、手作業では抜け漏れが出ます。しかも画像はLargest Contentful Paint、つまりページ内で最も大きな要素が表示されるまでの時間に直結します。

この記事では、Claude Codeに実装の足場を作らせながら、sharpでWebP/AVIF/JPEGを生成し、picture要素で配信し、CIでサイズ予算を守るところまでをひとつのパイプラインにします。Masaが小さな技術ブログで試したときは、最初に「AVIFだけ作れば十分」と考えて失敗しました。古いブラウザやSNSクローラー向けのフォールバック、sizesの指定、ヒーロー画像の優先読み込みまで含めて初めて、安定した改善になります。

Claude Codeの基本操作に不安がある場合は、先にClaude Code入門ガイドを確認してください。画像以外の速度改善もまとめて見直すなら、Claude Codeでパフォーマンス最適化を進める方法も役に立ちます。

全体像

狙いは、画像をアップロードするたびに毎回同じ品質で変換され、レビュー時に問題を検出できる状態を作ることです。Claude Codeには「画像を圧縮して」ではなく、次のように役割を分けて依頼します。

flowchart LR
  A["original images"] --> B["sharp conversion"]
  B --> C["AVIF / WebP / JPEG variants"]
  C --> D["OptimizedImage component"]
  D --> E["browser chooses best source"]
  C --> F["manifest.json"]
  F --> G["CI size budget check"]

この流れにすると、実装者が見るべき差分がはっきりします。変換スクリプトは「入力から派生画像を作る」、コンポーネントは「適切な画像候補をブラウザに渡す」、検証スクリプトは「重すぎる画像を止める」ことだけに集中できます。

最初に決める品質基準

最適化は圧縮率だけで判断しないほうが安全です。スクリーンショット、写真、図解では許容できる劣化が違います。私は次の基準をClaude Codeへの前提として渡しています。

用途目標注意点
ヒーロー画像1280px以上、AVIF/WebP優先、JPEGフォールバックLCP対象なのでpriority扱いにする
記事内スクリーンショット640px/960px中心、文字が読める品質AVIFの品質を下げすぎるとUI文字がにじむ
ギャラリー/一覧320px/640pxを多用遅延読み込みで初期表示を軽くする
OGP/SNS用JPEGまたはPNGを残すクローラーがAVIFを読まないケースに備える

2026年6月時点で実装するなら、sharp公式ドキュメントの対応形式を確認し、AVIF -> WebP -> JPEGの順で候補を出すのが現実的です。HTML側はMDNのレスポンシブ画像ガイドの考え方に合わせ、srcsetsizesを必ずセットで扱います。

実装1: sharpで派生画像を生成する

まず、変換スクリプトを作ります。Node.js 18.17以上を前提に、public/images/originalへ置いたJPEG/PNGからpublic/images/optimizedへ派生画像を出力します。

npm install -D sharp glob tsx
// 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;
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を明示的に作ると、レビュー時にも挙動を説明しやすくなります。

実装2: レスポンシブ画像コンポーネントを作る

次に、生成した画像をブラウザへ渡すコンポーネントを用意します。ブラウザは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>
  );
}

ヒーロー画像だけはprioritytrueにします。記事中の画像まで全部eagerにすると、初期表示のネットワークが詰まります。web.devのLCP解説でも、最初に見える大きな要素を改善対象として扱う考え方が重要です。

実装3: CIで画像サイズを止める

最後に、変換された画像が大きくなりすぎたらビルドを落とします。人間のレビューだけに頼ると、1枚の差し替えで数MBの画像が混ざります。

// 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"));
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.");
{
  "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化に加えて、widthheightを必ず指定し、レイアウトシフトを防ぎます。Claude Codeには「ヒーローだけpriority、それ以外はlazy」と明示します。

3つ目はECやポートフォリオのギャラリーです。同じ商品写真をカード、詳細、OGPで使い回す場合、幅の候補と命名規則が崩れやすくなります。manifestを作っておくと、どの派生画像が存在するかをテストや管理画面から確認できます。

よくある落とし穴

AVIFの品質を下げすぎると、写真は軽く見えてもUIスクリーンショットの文字が読みにくくなります。写真と図解を同じ品質値で処理しないほうが無難です。

sizesを未指定にすると、ブラウザは画像がビューポート幅いっぱいに表示されると仮定しやすくなります。カード内の小さな画像でも大きい候補を取りに行くため、srcsetの効果が薄れます。

loading="lazy"をすべての画像へ付けるのも危険です。ファーストビューのメイン画像まで遅延読み込みにすると、LCPが悪化します。逆に、記事下部の画像をeagerにすると初期通信を圧迫します。

Claude Codeへ一度に「Next.js/Astro対応、画像アップロード、CDN、管理画面、テスト全部」と依頼すると、差分が大きくなりすぎます。まず変換スクリプト、次に表示コンポーネント、最後にCI検証という順番で依頼するほうがレビューできます。

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は便利ですが、画像品質の判断までは自動で正解を選べません。レビュー担当者が比較できるように、出力ファイル名、幅、サイズを残す設計にしておくのが大事です。

実際に試した結果と次の一手

Masaの検証では、1920pxのPNGスクリーンショットをそのまま配信していたページで、記事内画像の転送量が半分以下になりました。一方で、AVIF品質を45まで下げたときはコード画面の文字がつぶれ、読者にとっては悪化でした。最終的には写真はAVIF 50台、UIスクリーンショットはWebP/JPEGも確認しながら少し高めにする運用が安定しました。

次は、実際のリポジトリで1カテゴリだけ対象にしてnpm run images:buildnpm run images:checkをCIへ入れてください。うまくいったら、Claude Codeのワークフロー自動化と組み合わせて、画像追加時のチェックをプルリクエストに組み込むのがおすすめです。

#Claude Code #画像最適化 #WebP #AVIF #パフォーマンス
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。