はじめに
Webアプリケーションで画像をグレースケールに変換する方法は、大きく分けてクライアントサイドとサーバーサイドの2つがあります。
Next.js(App Router)では両方のアプローチを柔軟に使い分けることができます。本記事では、それぞれの実装方法・メリット・デメリットを整理し、どのケースでどちらを選ぶべきかを解説します。
私自身、Grayscale Image というブラウザベースの変換ツールを開発・運用しており、その実装経験をもとにまとめました。
クライアントサイド:Canvas API による変換
基本実装
ブラウザの Canvas API を使えば、サーバーへの通信なしに画像をピクセル単位で操作できます。
// components/GrayscaleConverter.tsx
'use client';
import { useRef, useState, useCallback } from 'react';
export function GrayscaleConverter() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [converted, setConverted] = useState(false);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const img = new Image();
img.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// BT.709 Luminosity 方式
for (let i = 0; i < data.length; i += 4) {
const gray = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
data[i] = data[i + 1] = data[i + 2] = gray;
}
ctx.putImageData(imageData, 0, 0);
setConverted(true);
};
img.src = URL.createObjectURL(file);
},
[]
);
return (
<div>
<input type="file" accept="image/*" onChange={handleFileChange} />
<canvas ref={canvasRef} />
{converted && <p>変換完了</p>}
</div>
);
}
Web Worker で UI をブロックしない
画像が大きい場合、メインスレッドでピクセル操作を行うと UI がフリーズします。Web Worker + OffscreenCanvas を使えばこの問題を回避できます。
// workers/grayscale.worker.ts
self.onmessage = (e: MessageEvent<ImageData>) => {
const imageData = e.data;
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
data[i] = data[i + 1] = data[i + 2] = gray;
}
// Transferable Objects でゼロコピー転送
self.postMessage(imageData, [imageData.data.buffer]);
};
// hooks/useGrayscaleWorker.ts
'use client';
import { useRef, useCallback } from 'react';
export function useGrayscaleWorker() {
const workerRef = useRef<Worker | null>(null);
const convert = useCallback((imageData: ImageData): Promise<ImageData> => {
return new Promise((resolve) => {
if (!workerRef.current) {
workerRef.current = new Worker(
new URL('../workers/grayscale.worker.ts', import.meta.url)
);
}
workerRef.current.onmessage = (e) => resolve(e.data);
workerRef.current.postMessage(imageData, [imageData.data.buffer]);
});
}, []);
return { convert };
}
ポイント: Transferable Objects を使うと、ArrayBuffer のコピーが発生せず、4000×3000px の画像でも転送オーバーヘッドはほぼゼロになります。
サーバーサイド:Sharp による変換
Route Handler での実装
Next.js の Route Handler(App Router)と Sharp を使えば、サーバー側で高速に変換処理を行えます。
// app/api/grayscale/route.ts
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('image') as File;
if (!file) {
return NextResponse.json({ error: 'No image provided' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const grayscaleBuffer = await sharp(buffer)
.grayscale()
.toBuffer();
return new NextResponse(grayscaleBuffer, {
headers: {
'Content-Type': 'image/png',
'Content-Disposition': 'attachment; filename="grayscale.png"',
},
});
}
Server Action での実装
React 19 の Server Actions を使えば、フォームから直接サーバー処理を呼び出せます。
// app/actions/grayscale.ts
'use server';
import sharp from 'sharp';
export async function convertToGrayscale(formData: FormData) {
const file = formData.get('image') as File;
if (!file) throw new Error('No image provided');
const buffer = Buffer.from(await file.arrayBuffer());
const result = await sharp(buffer)
.grayscale()
.png()
.toBuffer();
return `data:image/png;base64,${result.toString('base64')}`;
}
// components/ServerGrayscale.tsx
'use client';
import { useState } from 'react';
import { convertToGrayscale } from '@/app/actions/grayscale';
export function ServerGrayscale() {
const [result, setResult] = useState<string | null>(null);
const handleSubmit = async (formData: FormData) => {
const dataUrl = await convertToGrayscale(formData);
setResult(dataUrl);
};
return (
<form action={handleSubmit}>
<input type="file" name="image" accept="image/*" />
<button type="submit">変換</button>
{result && <img src={result} alt="Grayscale result" />}
</form>
);
}
比較表
| 観点 | クライアントサイド (Canvas) | サーバーサイド (Sharp) |
|---|---|---|
| 処理速度 | 画像サイズに依存(大きいと遅い) | 高速(C++ネイティブバインディング) |
| サーバー負荷 | なし | CPU・メモリを消費 |
| プライバシー | 画像がサーバーに送信されない | アップロードが必要 |
| 対応フォーマット | ブラウザが対応する形式のみ | AVIF, WebP, TIFF 等も対応 |
| バッチ処理 | 困難 | 容易 |
| オフライン対応 | 可能 | 不可 |
| 初期ロード | JSバンドルサイズ増加 | サーバー依存のみ |
どちらを選ぶべきか
クライアントサイドが適するケース
- プライバシー重視: ユーザーの画像をサーバーに送信したくない場合
- リアルタイムプレビュー: スライダーでパラメータを調整しながら変換する UI
- オフライン対応: PWA やオフラインファーストのアプリ
- サーバーコスト削減: 処理をすべてユーザーのブラウザに委ねる
サーバーサイドが適するケース
- 高画質・大容量画像: 10MB以上の RAW 画像など
- バッチ変換: 複数画像の一括処理
- フォーマット変換: WebP → AVIF などの形式変換を同時に行う
- APIとして提供: 外部サービスから呼び出す場合
ハイブリッドアプローチ
実際のプロダクトでは、プレビューはクライアントサイド、最終出力はサーバーサイドという組み合わせが効果的です。
// プレビュー: Canvas で即時表示
const previewGrayscale = (canvas: HTMLCanvasElement) => {
// Canvas API でリアルタイム変換(低解像度)
};
// ダウンロード: Server Action で高品質出力
const downloadGrayscale = async (file: File) => {
// Sharp で元解像度のまま変換
};
パフォーマンス計測
実際に 4000×3000px(12MP)の画像で計測した結果:
| 方式 | 処理時間 |
|---|---|
| Canvas(メインスレッド) | ~320ms |
| Canvas(Web Worker) | ~280ms + 転送 ~2ms |
| Sharp(サーバー) | ~45ms |
| Sharp + ネットワーク往復 | ~45ms + RTT |
Sharp のネイティブ処理は圧倒的に速いですが、ネットワーク往復を含めるとクライアントサイドの方が体感速度では優位になる場合もあります。
まとめ
Next.js App Router では、'use client' と 'use server' を活用して、画像のグレースケール変換をクライアント・サーバーどちらでも実装できます。
- プライバシーとレスポンスを重視するなら → Canvas API(クライアント)
- 処理性能とフォーマット対応を重視するなら → Sharp(サーバー)
- 最適解は両方を組み合わせたハイブリッド構成
ブラウザ上で手軽にグレースケール変換を試したい方は、grayscale image converter で各アルゴリズムの違いを比較できますので、ぜひ参考にしてみてください。