Claude Codeでsharp画像処理を自動化:リサイズ・切り抜き・形式変換・一括処理
「いい感じに加工して」で事故った僕が、Claude Codeにsharpでのリサイズ・切り抜き・WebP/AVIF変換・一括処理を任せた手順を、動くコード付きで解説。
「アップロードされた商品画像、サムネイル作っといて」
そう頼んだ翌朝、生成されたコードは確かに動いていました。でも、縦長のスマホ写真は上下が切れて商品が見切れ、横倒しのまま保存された画像があり、おまけに一部の写真には撮影場所の位置情報がべったり残ったまま公開フォルダに並んでいたんです。
画像処理は「いい感じに加工して」のひと言で済む領域に見えて、実は地雷だらけ。今日はそこを、僕が踏んだ地雷ごと共有します。
この記事の要点
- 画像処理(サーバー側の加工)で最初に決めるのはライブラリではなく「どのサイズ・形にする」という仕様。これを渡さないとClaude Codeは見切れ画像を量産する
sharpはresize()のfit指定で挙動が激変する。**サムネイルはcover、全体を見せたいならinside**を使い分ける- 切り抜きは中央固定だと顔や商品が切れる。
positionやsharpの注目領域指定で逃げる - 形式変換(WebP/AVIF)と位置情報(EXIF)の扱いはセットで考える。
withMetadata()を呼ばなければメタデータは残らない - 何十枚もの一括処理は同期APIでやらない。
p-limitで並列数を絞り、失敗を記録する
Web表示を速くする話(遅延読み込みやLCP対策)はClaude Codeで画像最適化を自動化に分けてあります。この記事はサーバー側で画像を「加工」するほう——リサイズ、切り抜き、形式変換、一括処理——に絞ります。重い変換をメインスレッドから逃がす話はClaude CodeでWeb Workerを実装する方法も合わせて読むと判断しやすいです。
なぜ「いい感じに加工して」は事故るのか
正直に言うと、僕は最初、画像のリサイズなんて一行で終わると思っていました。
実際は違いました。商品一覧のカード画像、商品詳細の大きい画像、プロフィールの丸いアイコン、SNS共有用のOGP画像。同じ「リサイズ」でも、必要な縦横比も、切り抜き方も、許される画質も全部違うんです。冒頭の見切れ事故は、僕が「サムネイル作って」としか言わず、縦横比をAIに丸投げした結果でした。
しかも画像加工は見た目以上にバックエンドの仕事です。アップロードされたファイルが本当に画像か、寸法は妥当か、向きは正しいか、位置情報は消えているか、CPUを食い潰さないか。ここまで含めて初めて「加工した」と言えます。だからClaude Codeには「画像を加工して」とふわっと頼むのではなく、入力・出力サイズ・切り抜き方・作らない範囲を先に言葉にして渡します。
公式の挙動は手元で確認しつつ、仕様はClaude Code公式ドキュメント、リサイズの細かい挙動はsharpのresize API、出力形式はsharpの出力APIを基準にしています。
先に決める「加工の仕様」
ライブラリ選びより先に、どんな派生画像(variant)を何のために作るかを決めます。ここが曖昧だとClaude Codeは「高画質そうな設定」を選びがちです。
| 用途 | サイズの目安 | 切り抜き方(fit) | 形式 |
|---|---|---|---|
| 一覧のカード | 幅640 | inside(全体を見せる) | WebP |
| 商品詳細のヒーロー | 幅1280 | inside | WebP(AVIFは任意) |
| プロフィール/アイコン | 320×320 | cover(正方形に切る) | WebP |
| OGP共有 | 1200×630 | cover | JPEG/WebP |
fitの選択が一番事故ります。coverは指定した縦横比にぴったり収めるためはみ出した部分を切り捨てます。insideは縦横比を保ったまま枠に収めるので切れない代わりに余白方向は短くなる。サムネイルをinsideで作ると正方形にならず、カード画像をcoverで作ると商品の端が切れます。この一行を間違えると冒頭の見切れ事故になります。
Claude Codeへの依頼では、この表をそのまま渡したうえで「元ファイル名を公開URLに使わない」「withMetadata()を呼ばない」「AVIFはオプションにする」と書き添えます。生成コードの危険な省略がぐっと減ります。
こんな場面で効く(3つ)
1. ECの商品画像セット
出品者はスマホの高解像度写真をそのまま送ってきます。必要なのはカード用・詳細用・SNS用の3サイズ。ここで画質を落としすぎると質感が伝わらず購入をためらわれるので、WebPを基本にしてAVIFは効果を測ってから足します。サイズとfitをvariantごとに固定しておけば、誰がアップしても同じ仕上がりになります。
2. プロフィール/チームメンバー写真
丸く表示するアイコンは、まず正方形に切り抜く(cover)のが基本。そして位置情報(EXIF)を消すこと。スマホ写真には撮影場所が埋め込まれていることがあり、社員の自宅が特定できてしまった、なんて事故も現実に起きます。
3. 記事や教材のスクリーンショット一括変換 手順記事では「ボタン名が読めること」が最優先です。雑に圧縮すると赤枠の注釈がにじみ、UI文字がつぶれます。過去記事の画像を何十枚もまとめて変換するときは、後述の一括処理で並列数を絞りながら回します。記事制作ではClaude CodeでPDF生成を実装する方法のように、画像とドキュメント生成が同じ運用に入ることも多いです。
セットアップ
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
テストをコマンドに入れるなら、package.jsonにこう足します。
{
"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; // 6MBより大きい画像は受け取らない
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("空のファイルです");
}
if (buffer.byteLength > MAX_BYTES) {
throw new Error("画像は6MB以下にしてください");
}
// 拡張子ではなく中身のバイト列で形式を判定する
const detected = await fileTypeFromBuffer(buffer);
if (!detected || !isAllowedMime(detected.mime)) {
throw new Error("対応していない画像形式です");
}
const metadata = await sharp(buffer, { failOn: "error" }).metadata();
if (!metadata.width || !metadata.height) {
throw new Error("画像の寸法を読み取れませんでした");
}
if (metadata.pages && metadata.pages > 1) {
throw new Error("複数ページ(アニメーション)画像はここでは扱えません");
}
const pixels = metadata.width * metadata.height;
if (pixels > MAX_PIXELS) {
throw new Error("画像の寸法が大きすぎます");
}
return {
mime: detected.mime,
extension: EXTENSION_BY_MIME[detected.mime],
width: metadata.width,
height: metadata.height,
bytes: buffer.byteLength,
originalName,
};
}
// 公開ファイル名はランダムIDにして元名を漏らさない
export function safeImageName(mime: MimeType): string {
return `${randomUUID()}${EXTENSION_BY_MIME[mime]}`;
}
元ファイル名を保存しないのは見た目の話ではありません。yamada-contract-final.pngのような名前が公開URLに残ると、個人名や案件名がそのまま漏れます。表示用に元名をDBへ残すのは構いませんが、公開URLは必ずランダムIDにします。
sharpでリサイズ・切り抜き・形式変換
ここが本題です。sharpはNode.jsの画像加工で現実的な第一候補。rotate()でEXIFの向きを反映してから加工し、withMetadata()を呼ばなければ出力にメタデータは残りません。つまり位置情報を消したいときは、削除メソッドを探すより余計なメタデータを保持しないのが正解です。
variantごとにfitを出し分けているのがポイントです。
// 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; // heightを指定したものだけ正方形に切り抜く
};
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() // EXIFの向きを反映(横倒し写真を直す)
.resize({
width: variant.width,
height: variant.height,
// 高さ指定ありは cover で切り抜き、なしは inside で全体を残す
fit: variant.height ? "cover" : "inside",
position: "attention", // 切り抜きは情報量の多い領域を優先
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;
}
position: "attention"は、sharpが画像の中で情報量(エッジや彩度)の多い領域を推測して、そこを残すように切り抜く指定です。中央固定だと顔や商品が画面端にあるときに切れますが、これで多少マシになります。完璧ではないので、人物中心のサービスなら顔検出を別途検討してください。
AVIFは万能ではありません。圧縮率は強い一方でエンコードが重く、画像によってはWebPとの差が小さい。商品一覧の大量変換を同期APIでやるとアップロード画面がタイムアウトします。最初はWebPだけ本番投入し、AVIFは背景ジョブで比較するくらいが堅実です。
Next.jsのアップロードAPIに組み込む
App RouterのPOSTハンドラ例です。ExpressやHonoでも、FileをBufferに変える部分を置き換えれば同じ考え方で使えます。
// 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 フィールドが必要です" },
{ 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(/\.[^.]+$/, "");
// 同期APIでは検証+最小限の派生まで。重い変換はここでやらない
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で配信します。このコードは公開メディア用の最小例です。
何十枚もの一括処理と性能予算
過去画像のまとめ変換、CMSの一括再生成、古い画像の移行。こうした一括処理を素直に全部同期で回すと、CPUとメモリを食い潰してサーバーが不安定になります。p-limitで並列数を絞り、一枚の失敗で全体を止めないようにします。
// 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); // 同時に動かすのは3枚まで(CPU保護)
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側の参照が消える、という失敗もよくあります。
性能予算の目安も決めておきます。アイコンは320×320で80KB以下、カードは幅640で120KB以下、ヒーローは幅1280で250KB以下あたりから。数字は絶対ではありませんが、予算がないとClaude Codeは「高画質そうな設定」を選びがちです。
動かして確かめる(コピペで実行できるテスト)
最低限、検証・安全ファイル名・リサイズ結果・切り抜き寸法・メタデータ削除をテストします。手で1枚見るのではなく、テスト内で小さな画像を生成するとCIに載せやすい。これはnode --testでそのまま走ります。
// 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("画像を検証して3サイズに加工できる", async () => {
// テスト用に1200x800の単色画像をその場で生成する
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);
// 公開名がランダムIDになっているか
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));
// サムネイルが正方形に切り抜けているか&EXIFが残っていないか
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);
});
手動確認では、横向きスマホ写真、縦長の人物写真、透明PNG、巨大画像、壊れた画像、スクリーンショット文字の可読性を見ます。Claude Codeには最後に「画像加工のレビューだけして。fitの選択、EXIF向き、切り抜き位置、ファイル名、CPU、テスト不足を優先」と頼むと見落としが減ります。関連する計測はClaude Codeでアナリティクス実装も参考になります。
よくある質問
Q. sharpとImageMagickはどちらを使うべき? A. Node.jsのサーバーでリサイズ・変換・サムネイル生成をするなら、まずsharpで十分です。速度が速く依存も軽い。複雑な合成や特殊効果が必要になったときに、ImageMagickやCanvasを足すか検討する順番がおすすめです。
Q. リサイズで画像が切れてしまいます。
A. resize()のfitがcoverになっていないか確認してください。全体を残したいならinside、指定の縦横比に切り抜きたいならcoverです。coverのときはpositionで残す領域を調整できます。
Q. スマホ写真が横倒しで保存されます。
A. 出力前に.rotate()を呼べば、EXIFの向き情報を反映して正しい向きに直してくれます。引数なしで呼ぶのがポイントです。
Q. 位置情報(EXIF)を消すにはどうすれば?
A. sharpはwithMetadata()を呼ばなければ、通常メタデータを出力に残しません。つまり「消す」のではなく「持たせない」が正解です。心配なら出力画像のmetadata().exifがundefinedかテストで確認します。
Q. 一括変換でサーバーが固まります。
A. 全部を同期で並列実行しているのが原因です。p-limitで同時実行数を3前後に絞り、重い処理は背景ジョブに逃がしてください。アップロード応答のなかで何十枚も変換しないことです。
実際に試した結果
2026年6月にMasaが小さなNext.js検証環境で試したところ、Claude Codeに最初から「サムネイルはcoverで正方形、カードはinsideで全体、AVIFは任意、元ファイル名は公開しない、withMetadata()は呼ばない」と仕様を渡したときは、差分レビューがかなり短く済みました。逆に「画像のサムネイル作って」だけだと、fitが中央固定で見切れ、rotate()なしで横倒し、EXIFが残ったまま、という冒頭の事故が見事に再現されました。
結局いちばん効いたのは、賢いプロンプトよりサイズと切り抜き方の表を先に渡すことでした。画像処理はセキュリティ・UX・性能が同時に絡むので、実装前の条件整理がそのまま品質に直結します。繰り返し使う依頼の型は商品一覧で整え、チーム導入や画像アップロードを含む実装レビューは研修・相談でまとめて相談できます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。