gazou-compressor.jp

レスポンシブ画像の自動ブレイクポイント生成:DPR/レイアウト幅から最小セットを算出(Next.js/srcset)

srcset/sizesは幅の設計が9割です。レイアウト幅×DPRから“必要最小限”の幅を導出し、SharpのエンコードAPIで供給すれば、LCPとキャッシュの両立が図れます。入力の正規化はHEIC→Webを参照。

方針(TL;DR)
  • 代表DPR(1/1.5/2/3)をカバーする幅集合を自動生成。
  • 幅の差が小さい候補は間引く(過剰生成の抑制)。
  • 配信はSWR+Vary: Acceptで安全運用(配信戦略)。

1. ブレイクポイントを自動算出

// scripts/derive-breakpoints.ts — “必要最小限”の幅を自動算出
// 依存: npm i -D tsx
// 入力: 想定レイアウト幅(px)の分布、目標カバレッジ(例: 95%)、最大DPR(例: 3)
type Opts = { layoutWidths: number[]; dprs?: number[]; coverage?: number; minStep?: number; minWidth?: number; maxWidth?: number; };

export function deriveBreakpoints({
  layoutWidths, dprs = [1, 1.5, 2, 3], coverage = 0.95, minStep = 80, minWidth = 320, maxWidth = 1920,
}: Opts) {
  // 1) レイアウト幅のヒストグラムを近似(ここでは単純に昇順/ユニーク化)
  const widths = Array.from(new Set(layoutWidths.filter(w => w >= minWidth && w <= maxWidth).sort((a,b)=>a-b)));
  // 2) DPRを掛けて“有効ピクセル幅”の候補を集める
  const candidates = new Set<number>();
  for (const w of widths) for (const d of dprs) candidates.add(Math.round(w * d));
  // 3) 小さな差は丸めて間引く(minStep)
  const sorted = Array.from(candidates).sort((a,b)=>a-b);
  const pruned:number[] = [];
  for (const v of sorted) {
    if (pruned.length === 0 || v - pruned[pruned.length-1] >= minStep) pruned.push(v);
  }
  // 4) 過剰を絞る(カバレッジが落ちない範囲で間引く)
  // 実務ではPSNR/SSIMを参照するが、ここでは単純化して“幅比”で近似
  const kept:number[] = [];
  const ratio = 1.25; // 連続幅が25%未満ならどちらかを省く
  for (const v of pruned) {
    if (kept.length === 0 || v / kept[kept.length-1] > ratio) kept.push(v);
  }
  return kept;
}

// 使い方(例)
if (require.main === module) {
  const widths = [320, 360, 375, 414, 640, 768, 834, 1024, 1280, 1366, 1440, 1680, 1920];
  console.log(deriveBreakpoints({ layoutWidths: widths }));
}

2. srcset/sizesを自動生成

// components/AutoSrcset.tsx — ブレイクポイントからsrcset/sizesを構築
import React from "react";

type Props = {
  src: string; // 変換APIの元画像URL
  alt: string;
  widths: number[];
  sizes: string; // 例: "(min-width:1024px) 800px, 92vw"
  format?: "avif" | "webp" | "jpeg";
};

export default function AutoSrcset({ src, alt, widths, sizes, format = "avif" }: Props) {
  const srcset = widths.map((w) => `${process.env.NEXT_PUBLIC_BASE}/api/encode?url=${encodeURIComponent(src)}&w=${w}&format=${format} ${w}w`).join(", ");
  return <img src={`${process.env.NEXT_PUBLIC_BASE}/api/encode?url=${encodeURIComponent(src)}&w=${widths[0]}&format=${format}`} alt={alt} srcSet={srcset} sizes={sizes} loading="lazy" />;
}

3. エンコードAPI(AVIF/WebP/JPEG)

// app/api/encode/route.ts — Sharpで幅ごとにエンコード(AVIF/WebP/JPEG)
// 依存: npm i sharp
import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
export const runtime = "nodejs"; export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const url = searchParams.get("url");
  const w = Number(searchParams.get("w") ?? 800);
  const format = (searchParams.get("format") ?? "avif").toLowerCase();
  if (!url) return NextResponse.json({ error: "url is required" }, { status: 400 });

  const r = await fetch(url, { cache: "force-cache" });
  if (!r.ok) return NextResponse.json({ error: "fetch failed" }, { status: 502 });
  let img = sharp(Buffer.from(await r.arrayBuffer())).rotate().toColourspace("srgb").resize(w, null, { withoutEnlargement: true });

  let body:Buffer, type="image/avif";
  if (format==="webp") { body = await img.webp({ quality: 82 }).toBuffer(); type="image/webp"; }
  else if (format==="jpeg"||format==="jpg") { body = await img.jpeg({ quality: 80, mozjpeg: true, progressive: true }).toBuffer(); type="image/jpeg"; }
  else { body = await img.avif({ quality: 55, effort: 4, chromaSubsampling: "4:2:0" }).toBuffer(); }

  return new NextResponse(body, { headers: {
    "Content-Type": type,
    "Cache-Control": "public, max-age=60, s-maxage=86400, stale-while-revalidate=604800",
    "Vary": "Accept",
  }});
}

4. 公開前チェック

公開:2025-09-06 / 監修:gazou-compressor.jp 編集部

gazou-compressor.jp 編集部

画像圧縮・変換・背景除去などの実践テクニックと、Webで“速く・軽く・崩さない”ためのノウハウを発信しています。

関連記事

トピック/更新日の近いコンテンツ