レスポンシブ画像の自動ブレイクポイント生成: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. 公開前チェック
- 最小幅でLCP画像がぼけない。
- 最大幅で過剰生成がない。
- キャッシュキーにAcceptが含まれる。