Use Cases (更新: 2026/6/2)

Claude Codeで画像処理を自動化する実践ガイド: Sharp、Canvas、WebP/AVIF、アップロード検証

Claude Codeで画像処理を安全に実装する。Sharp、Canvas、検証、EXIF削除、WebP/AVIFを実例で解説。

Claude Codeで画像処理を自動化する実践ガイド: Sharp、Canvas、WebP/AVIF、アップロード検証

画像処理は、Claude Codeに「いい感じに圧縮して」と頼むだけでは事故りやすい領域です。見た目は小さな便利機能でも、実際にはアップロード検証、ファイル名、EXIF削除、変換フォーマット、CPU負荷、非同期ジョブ、プライバシー、テストまで含むバックエンド機能です。

特に商品画像、プロフィール画像、記事のアイキャッチ、教材のスクリーンショットを扱うサイトでは、画像が重いだけでLCPが悪化し、広告やCTAまで見られなくなります。逆に圧縮しすぎると、購入前に確認したい商品の質感や、手順記事のUI文字が読めなくなります。

この記事では、Claude Codeに実装させる前に渡すべき設計と、そのままコピペして検証できるTypeScriptコードをまとめます。公式仕様はClaude Code公式ドキュメント、サーバー側処理はSharpのresize APISharpの出力API、ブラウザ側はMDN File APICanvas toBlob、アップロード安全性はOWASP File Upload Cheat Sheetを基準にします。重いブラウザ処理を逃がす話は、内部記事のClaude CodeでWeb Workerを実装する方法も合わせて読むと判断しやすいです。

先に決める設計

最初に決めるのはライブラリではなく、どこで何を処理するかです。クライアント側、サーバー側、バックグラウンドジョブは役割が違います。

場所向いている処理避けたい処理
ブラウザプレビュー、軽い縮小、送信前の容量削減信頼できる検証、AVIF大量変換、秘密情報の判断
サーバー同期処理MIME検証、寸法確認、EXIF削除、小さなサムネイル何十枚もの一括変換、重いAVIF生成
バックグラウンドジョブ商品画像セット、CMSの一括再生成、古い画像の移行ユーザーを待たせるアップロード応答

実務では「ブラウザで軽く小さくし、サーバーで必ず検証し直し、重い派生画像はジョブに逃がす」が安全です。ブラウザのaccept="image/*"file.typeはUXのヒントであって、セキュリティ境界ではありません。悪意あるファイルや壊れたファイルを止める責任はサーバーにあります。

処理フローは次のように分けます。

flowchart LR
  Browser["Browser preview / optional resize"]
  Upload["Upload endpoint"]
  Validate["Magic bytes, size, dimensions"]
  Store["Private raw storage"]
  Job["Background variants"]
  Public["Public WebP/JPEG/AVIF"]
  Browser --> Upload
  Upload --> Validate
  Validate --> Store
  Store --> Job
  Job --> Public

Claude Codeへの依頼では、この境界を明文化します。たとえば「サーバーでMIMEとmagic bytesを検証する」「元ファイル名を公開URLに使わない」「.withMetadata()を呼ばない」「AVIFはオプションにする」と書くと、生成コードの危険な省略が減ります。

3つ以上の実用ユースケース

1つ目はECやマーケットプレイスの商品画像です。出品者はスマホの高解像度写真をそのまま送ります。必要なのは、カード用の小さい画像、商品詳細の大きい画像、SNS共有用の画像です。ここでは画質を落としすぎると購入判断に響くため、WebPを基本にしつつ、AVIFは変換コストと効果を測ってから追加します。

2つ目はプロフィール画像やチームメンバー写真です。ここで重要なのは、丸く切り抜ける正方形サムネイル、EXIFの位置情報削除、安全なファイル名です。ユーザー名や元ファイル名をURLに出すと、メールアドレス、氏名、社内プロジェクト名が漏れることがあります。

3つ目はブログ、ヘルプセンター、教材のスクリーンショットです。手順記事では文字が読めることが最優先です。雑に圧縮すると「ボタン名が読めない」「赤枠の注釈がにじむ」という失敗になります。記事制作ではClaude CodeでPDF生成を実装する方法のように、画像とドキュメント生成が同じ運用に入ることも多いです。

4つ目はSaaSの管理画面での添付ファイルです。請求書、本人確認、サポート添付などは公開画像ではありません。保存先、アクセス制御、削除期限、監査ログまで考えます。公開用のpublic/uploadsに置くコードをそのまま流用してはいけません。

セットアップ

以下はNode.js 20以上を前提にした最小構成です。Next.js、Express、Hono、AstroのAPIルートにも移植できます。

npm i sharp file-type p-limit
npm i -D tsx typescript @types/node
mkdir -p src public/uploads

テストをコマンドに入れる場合は、次のようにします。

{
  "scripts": {
    "test:images": "node --import tsx --test src/**/*.test.ts"
  }
}

アップロード検証と安全なファイル名

最初のコードは、アップロードされた画像を信用しないための境界です。拡張子ではなくmagic bytesを見て、Sharpで寸法を読み、上限を超えたものやアニメーション系の複数ページ画像を拒否します。

// src/image-policy.ts
import { randomUUID } from "node:crypto";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";

const MAX_BYTES = 6 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;

const EXTENSION_BY_MIME = {
  "image/jpeg": ".jpg",
  "image/png": ".png",
  "image/webp": ".webp",
  "image/avif": ".avif",
} as const;

export type MimeType = keyof typeof EXTENSION_BY_MIME;

export type ImageUploadInfo = {
  mime: MimeType;
  extension: string;
  width: number;
  height: number;
  bytes: number;
  originalName: string;
};

function isAllowedMime(mime: string): mime is MimeType {
  return mime in EXTENSION_BY_MIME;
}

export async function assertImageUpload(
  buffer: Buffer,
  originalName = "upload",
): Promise<ImageUploadInfo> {
  if (buffer.byteLength === 0) {
    throw new Error("Empty file");
  }

  if (buffer.byteLength > MAX_BYTES) {
    throw new Error("Image must be 6 MB or smaller");
  }

  const detected = await fileTypeFromBuffer(buffer);

  if (!detected || !isAllowedMime(detected.mime)) {
    throw new Error("Unsupported image type");
  }

  const metadata = await sharp(buffer, { failOn: "error" }).metadata();

  if (!metadata.width || !metadata.height) {
    throw new Error("Image dimensions could not be read");
  }

  if (metadata.pages && metadata.pages > 1) {
    throw new Error("Animated images are not allowed here");
  }

  const pixels = metadata.width * metadata.height;

  if (pixels > MAX_PIXELS) {
    throw new Error("Image dimensions are too large");
  }

  return {
    mime: detected.mime,
    extension: EXTENSION_BY_MIME[detected.mime],
    width: metadata.width,
    height: metadata.height,
    bytes: buffer.byteLength,
    originalName,
  };
}

export function safeImageName(mime: MimeType): string {
  return `${randomUUID()}${EXTENSION_BY_MIME[mime]}`;
}

ここで元ファイル名を保存しないのは、単なる見た目の問題ではありません。yamada-contract-final.pngのようなファイル名が公開URLに残ると、個人名や案件名が漏れます。DBに表示用の元名を残す場合も、公開URLはランダムIDにします。

Sharpでリサイズ、圧縮、EXIF削除

SharpはNode.jsの画像処理で現実的な選択肢です。rotate()でEXIFの向きを反映してから出力し、withMetadata()を呼ばなければ、Sharpは通常メタデータを出力に残しません。つまり「位置情報を消したい」場合は、削除メソッドを探すより、余計なメタデータ保持をしないことが重要です。

// src/optimize-image.ts
import { mkdir } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";

type Variant = {
  kind: "thumb" | "card" | "hero";
  width: number;
  height?: number;
};

const VARIANTS: Variant[] = [
  { kind: "thumb", width: 320, height: 320 },
  { kind: "card", width: 640 },
  { kind: "hero", width: 1280 },
];

export type OptimizedImage = {
  src: string;
  width: number;
  height: number;
  bytes: number;
  format: "webp" | "avif";
};

export async function optimizeImage(
  buffer: Buffer,
  outputDir: string,
  baseName: string,
  makeAvif = false,
): Promise<OptimizedImage[]> {
  await mkdir(outputDir, { recursive: true });

  const results: OptimizedImage[] = [];

  for (const variant of VARIANTS) {
    const resized = sharp(buffer)
      .rotate()
      .resize({
        width: variant.width,
        height: variant.height,
        fit: variant.height ? "cover" : "inside",
        withoutEnlargement: true,
      });

    const webpName = `${baseName}-${variant.kind}.webp`;
    const webpInfo = await resized
      .clone()
      .webp({ quality: 78, effort: 4 })
      .toFile(path.join(outputDir, webpName));

    results.push({
      src: `/uploads/${webpName}`,
      width: webpInfo.width,
      height: webpInfo.height,
      bytes: webpInfo.size,
      format: "webp",
    });

    if (makeAvif) {
      const avifName = `${baseName}-${variant.kind}.avif`;
      const avifInfo = await resized
        .clone()
        .avif({ quality: 45, effort: 4 })
        .toFile(path.join(outputDir, avifName));

      results.push({
        src: `/uploads/${avifName}`,
        width: avifInfo.width,
        height: avifInfo.height,
        bytes: avifInfo.size,
        format: "avif",
      });
    }
  }

  return results;
}

AVIFは万能ではありません。圧縮率は強い一方で、エンコードが重く、画像によってはWebPとの差が小さいことがあります。商品一覧の大量変換を同期APIでやると、アップロード画面がタイムアウトします。最初はWebPだけを本番投入し、AVIFは背景ジョブでA/B比較するくらいが堅いです。

Next.jsのアップロードAPI例

次の例はApp RouterのPOSTハンドラです。ExpressやHonoでも、FileBufferにする部分を置き換えれば同じ考え方で使えます。

// app/api/images/route.ts
import path from "node:path";
import { NextResponse } from "next/server";
import { assertImageUpload, safeImageName } from "@/src/image-policy";
import { optimizeImage } from "@/src/optimize-image";

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get("image");

  if (!(file instanceof File)) {
    return NextResponse.json(
      { error: "image field is required" },
      { status: 400 },
    );
  }

  const buffer = Buffer.from(await file.arrayBuffer());
  const upload = await assertImageUpload(buffer, file.name);
  const storedName = safeImageName(upload.mime);
  const baseName = storedName.replace(/\.[^.]+$/, "");

  const variants = await optimizeImage(
    buffer,
    path.join(process.cwd(), "public", "uploads"),
    baseName,
    false,
  );

  return NextResponse.json({
    original: {
      width: upload.width,
      height: upload.height,
      bytes: upload.bytes,
    },
    variants,
  });
}

本番で個人情報や審査書類を扱う場合、public/uploadsに置いてはいけません。非公開のオブジェクトストレージに保存し、署名付きURLや認可済みAPIで配信します。この記事のコードは公開メディア用の最小例です。

ブラウザ側で軽く縮小する

スマホ写真をそのまま送ると、ユーザーの通信量もサーバーのCPUも無駄になります。ブラウザでプレビュー用に縮小してから送ると体験は良くなります。ただし、ブラウザ側の変換は補助です。サーバー検証の代わりにはなりません。

// src/resize-in-browser.ts
export async function resizeInBrowser(
  file: File,
  maxSide = 1600,
): Promise<File> {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
  const width = Math.round(bitmap.width * scale);
  const height = Math.round(bitmap.height * scale);

  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;

  const context = canvas.getContext("2d");

  if (!context) {
    throw new Error("Canvas 2D context is not available");
  }

  context.drawImage(bitmap, 0, 0, width, height);
  bitmap.close();

  const blob = await new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (result) => {
        if (result) resolve(result);
        else reject(new Error("Canvas export failed"));
      },
      "image/webp",
      0.82,
    );
  });

  const outputName = file.name.replace(/\.[^.]+$/, ".webp");

  return new File([blob], outputName, {
    type: blob.type || "image/webp",
    lastModified: Date.now(),
  });
}

ブラウザCanvasで再エンコードすると多くのメタデータは落ちますが、プライバシー対策としてそれだけに依存しないでください。EXIF削除はサーバー出力で確認します。また、AVIFをブラウザで作る前提にすると環境差で壊れやすいので、AVIFはサーバー側のオプションに寄せるのが安全です。

背景ジョブと性能予算

画像処理はCPUとメモリを使います。アップロードAPIで全部やると、少しアクセスが増えただけでレスポンスが不安定になります。同期処理では「検証、保存、最小サムネイル」までにし、商品詳細用、OGP用、AVIF版、古い画像の再生成はジョブに逃がします。

目安として、アバターは320x320で80KB以下、カード画像は幅640で120KB以下、ヒーロー画像は幅1280で250KB以下から始めます。数字は絶対ではありませんが、予算がないとClaude Codeは「高画質そうな設定」を選びがちです。

// src/batch-optimize.ts
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import pLimit from "p-limit";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";

export async function batchOptimize(inputDir: string, outputDir: string) {
  const files = await readdir(inputDir);
  const limit = pLimit(3);

  const jobs = files.map((file) =>
    limit(async () => {
      const sourcePath = path.join(inputDir, file);
      const buffer = await readFile(sourcePath);
      const upload = await assertImageUpload(buffer, file);
      const baseName = safeImageName(upload.mime).replace(/\.[^.]+$/, "");
      const variants = await optimizeImage(buffer, outputDir, baseName, true);

      return {
        file,
        variants: variants.length,
      };
    }),
  );

  return Promise.allSettled(jobs);
}

キューを使うならBullMQ、Cloud Tasks、SQS、Sidekiqなど、既存スタックに合うものを使います。重要なのは「ジョブID、元画像ID、失敗理由、再試行回数、生成済みvariant」を記録することです。画像だけが増え続け、DB側の参照が消える失敗もよくあります。

よくある失敗例

失敗例1: file.typeだけで許可する。ブラウザが送るMIMEは信用できません。サーバーでmagic bytesとSharp metadataを見ます。

失敗例2: 元ファイル名をURLに使う。日本語ファイル名の文字化けだけでなく、個人名や社内情報の漏洩につながります。公開名はランダムIDにします。

失敗例3: EXIFの向きを無視する。スマホ写真が横倒しになります。rotate()で向きを反映してから、出力メタデータは残さないようにします。

失敗例4: すべてAVIFにする。変換が重く、古い配信環境や一部の運用ツールで扱いにくいことがあります。pictureでAVIF、WebP、JPEG/PNGの順にフォールバックさせます。

失敗例5: 画像品質だけを見て、CTAを見ない。画像がきれいでも、商品ページの購入ボタンや記事末尾の導線が遅れて表示されるなら収益は落ちます。関連する計測はClaude Codeでアナリティクス実装も参考になります。

テストで確認する

最低限、アップロード検証、安全ファイル名、リサイズ結果、メタデータ削除をテストします。手動で1枚だけ見るのではなく、小さな画像をテスト内で生成するとCIに載せやすくなります。

// src/image-policy.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import sharp from "sharp";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";

test("validates and optimizes a generated image", async () => {
  const input = await sharp({
    create: {
      width: 1200,
      height: 800,
      channels: 3,
      background: "#38bdf8",
    },
  })
    .jpeg()
    .toBuffer();

  const info = await assertImageUpload(input, "masa-profile.jpg");
  assert.equal(info.mime, "image/jpeg");
  assert.equal(info.width, 1200);

  const safeName = safeImageName(info.mime);
  assert.match(safeName, /^[a-f0-9-]+\.jpg$/);

  const outDir = await mkdtemp(path.join(tmpdir(), "images-"));
  const baseName = safeName.replace(/\.[^.]+$/, "");
  const variants = await optimizeImage(input, outDir, baseName, false);

  assert.equal(variants.length, 3);
  assert.ok(variants.every((item) => item.bytes > 0));

  const thumb = await sharp(
    path.join(outDir, `${baseName}-thumb.webp`),
  ).metadata();

  assert.equal(thumb.width, 320);
  assert.equal(thumb.height, 320);
  assert.equal(thumb.exif, undefined);
});

手動確認では、320px幅のモバイル、低速回線、アップロード失敗、壊れた画像、巨大画像、縦向きスマホ写真、透明PNG、スクリーンショット文字の可読性を見ます。Claude Codeには最後に「画像処理のレビューだけをして。検証、EXIF、ファイル名、CPU、フォールバック、テスト不足を優先」と頼むと、見落としが減ります。

収益導線に組み込む

画像最適化は速度改善だけではありません。商品画像が速く表示される、教材スクリーンショットが読める、プロフィール写真から不要な情報が消える、記事のアイキャッチがSNSで崩れない。これらはすべて信頼とCTAクリックに関係します。

ClaudeCodeLabでは、まず無料チートシートで日常のClaude Code依頼を固め、繰り返し使うプロンプトやCLAUDE.mdの型は商品一覧で整え、チーム導入や画像アップロードを含む実装レビューは研修・相談で扱えます。画像処理のようにセキュリティ、UX、収益導線が同時に絡む機能は、実装前の条件整理が一番効きます。

この記事で紹介した内容を実際に試した結果

2026年6月2日にMasaが小さなNext.js検証環境で試したところ、Claude Codeに最初から「クライアント縮小は補助、サーバー検証が本線、AVIFは任意、元ファイル名を公開しない」と書いた場合は、差分レビューがかなり短くなりました。逆に「画像アップロードを作って」だけだと、file.typeだけの検証、元ファイル名の公開、EXIF向きの無視、同期APIでのAVIF生成が混ざりやすかったです。最初に性能予算と失敗例を渡すことが、画像処理実装の品質を一段上げる近道でした。

#Claude Code #画像処理 #Sharp #WebP #アップロード検証
無料

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

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

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

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

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

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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