🖼️

複数画像を一括リサイズできるWebツールを作った話

に公開

はじめに

ブログ記事やSNS投稿のために画像をリサイズしたいとき、わざわざPhotoshopやGIMPを開くのは面倒ですよね。オンラインの画像リサイズツールもたくさんありますが、サーバーに画像をアップロードするのがちょっと気になる、、、そんな悩みを解決するために、ブラウザだけで完結する画像リサイズツールを作成しました。

この記事では、なぜブラウザベースの実装を選んだのか、どのように設計・実装したのか、そして開発中に学んだことを共有します。

今回作成した画像リサイズツールhttps://tools.easegis.jp/ja/tools/image/image-resizer

開発の背景と目的

なぜブラウザベースなのか

最初はバックエンドでImageMagickを使った実装も検討したんですが、以下の理由でフロントエンド完結型を選びました。

  1. プライバシーの確保
    サーバーに画像をアップロードせず、ローカルで処理が完結する

  2. サーバーコストゼロ
    GitHub Pagesで無料ホスティングが可能

  3. 高速な処理
    ネットワーク遅延がなく、Canvas APIによるGPU加速で高速

  4. スケーラビリティ
    ユーザーごとにブラウザで処理するため、サーバー負荷を気にする必要がない

設計思想

アーキテクチャの選定

このツールは以下の技術スタックで構築しています。

Next.js 16 (App Router)
React 19 + TypeScript (strict mode)
Tailwind CSS v4
Canvas API (画像処理のコア)
JSZip (ZIP一括ダウンロード)

Next.jsを選んだ理由は、静的サイト生成(SSG)によって高速な配信が可能で、かつReactのコンポーネントベースの開発がしやすいからです。

状態管理の設計

状態管理はシンプルにuseStateuseRefで管理しています。Reduxなどの大規模な状態管理ライブラリは使いませんでした。

interface ImageData {
  id: number
  originalUrl: string
  resizedUrl?: string
  width: number
  height: number
  isResized: boolean
}

const [images, setImages] = useState<ImageData[]>([])
const [selectedImageId, setSelectedImageId] = useState<number | null>(null)
const [targetWidth, setTargetWidth] = useState(800)
const [targetHeight, setTargetHeight] = useState(600)
const [maintainAspect, setMaintainAspect] = useState(true)

この設計により、画像の追加・削除・リサイズ・選択といった操作がシンプルに管理できます。

実装の詳細

Canvas APIによる画像リサイズ

画像リサイズの中核となるのがCanvas APIです。

const resizeImage = (
  img: HTMLImageElement,
  width: number,
  height: number
): string => {
  const canvas = canvasRef.current
  if (!canvas) return ''

  const ctx = canvas.getContext('2d')
  if (!ctx) return ''

  canvas.width = width
  canvas.height = height

  ctx.drawImage(img, 0, 0, width, height)

  return canvas.toDataURL('image/png')
}

Canvas APIのdrawImageメソッドは、元画像を指定したサイズに描画してくれます。その後、toDataURLでData URL形式(base64エンコード)に変換することで、ブラウザ内で完結した画像データとして扱えるようになります。

アスペクト比の維持

リサイズ時にアスペクト比を維持する機能も実装しました。

const calculateDimensions = (
  originalWidth: number,
  originalHeight: number,
  targetWidth: number,
  targetHeight: number,
  maintainAspect: boolean
) => {
  if (!maintainAspect) {
    return { width: targetWidth, height: targetHeight }
  }

  const aspectRatio = originalWidth / originalHeight

  let newWidth = targetWidth
  let newHeight = targetHeight

  if (targetWidth / targetHeight > aspectRatio) {
    newWidth = Math.round(targetHeight * aspectRatio)
  } else {
    newHeight = Math.round(targetWidth / aspectRatio)
  }

  return { width: newWidth, height: newHeight }
}

ターゲットの幅と高さに対して、元画像のアスペクト比を保ちながら最適なサイズを計算します。これにより、画像が歪まずに美しくリサイズされます。

クリップボード操作の実装

現代のWebアプリケーションらしく、クリップボードからの画像ペーストと、リサイズ後の画像をクリップボードにコピーする機能も実装しました。

const copyFromDataUrl = async (dataUrl: string) => {
  try {
    const response = await fetch(dataUrl)
    const blob = await response.blob()

    await navigator.clipboard.write([
      new ClipboardItem({ [blob.type]: blob })
    ])

    return true
  } catch (error) {
    console.error('Failed to copy to clipboard:', error)
    return false
  }
}

Data URLをBlobに変換して、Clipboard APIで書き込むという流れです。これにより、リサイズした画像をそのまま別のアプリケーションに貼り付けられます。

複数画像の並列処理

複数の画像を効率的にリサイズするために、Promise.allを使って並列処理しています。

const handleResizeAll = async () => {
  setIsResizing(true)

  const resizedImages = await Promise.all(
    images.map(async (img) => {
      const dimensions = calculateDimensions(
        img.width,
        img.height,
        targetWidth,
        targetHeight,
        maintainAspect
      )

      const resizedUrl = await resizeImage(img, dimensions.width, dimensions.height)

      return {
        ...img,
        resizedUrl,
        isResized: true,
      }
    })
  )

  setImages(resizedImages)
  setIsResizing(false)
}

この実装により、10枚の画像をリサイズする場合でも、ほぼ同時に処理が完了します。

ZIP一括ダウンロード

複数の画像をまとめてダウンロードするために、JSZipライブラリを使用しています。

import JSZip from 'jszip'

const downloadAllAsZip = async () => {
  const zip = new JSZip()

  images.forEach((img, index) => {
    if (img.resizedUrl) {
      const base64Data = img.resizedUrl.split(',')[1]
      zip.file(`resized_${index + 1}.png`, base64Data, { base64: true })
    }
  })

  const content = await zip.generateAsync({ type: 'blob' })
  const url = URL.createObjectURL(content)

  const link = document.createElement('a')
  link.href = url
  link.download = 'resized_images.zip'
  link.click()

  URL.revokeObjectURL(url)
}

Data URLからbase64部分を抽出してZIPに追加し、最終的にBlobとしてダウンロードさせています。

パフォーマンス最適化

useRefで隠しCanvasを再利用

毎回新しいCanvas要素を作成するのではなく、useRefで保持した1つのCanvas要素を再利用することで、DOM操作のコストを削減しました。

const canvasRef = useRef<HTMLCanvasElement | null>(null)

useEffect(() => {
  if (!canvasRef.current) {
    canvasRef.current = document.createElement('canvas')
  }
}, [])

メモリ管理

Data URLは文字列としてメモリに保持されるため、大量の画像を扱う場合はメモリ使用量が増えます。今後の改善として、Blob URLを使用することでメモリ効率を向上させることを検討しています。

const blobUrl = URL.createObjectURL(blob)

URL.revokeObjectURL(blobUrl)

開発中に学んだこと

1. Canvas APIの可能性

Canvas APIは単なる描画ツールではなく、画像処理の強力なツールだと実感しました。リサイズだけでなく、フィルター適用やトリミングなども可能で、サーバーレスでの画像処理の可能性が広がりますね。

2. Clipboard APIの制約

Clipboard APIはHTTPS環境でないと動作しないという制約があります。開発環境ではlocalhostでも動作しますが、本番環境ではHTTPSが必須です。

3. ブラウザ互換性への配慮

Canvas APIやClipboard APIは主要なブラウザでサポートされていますが、一部の古いブラウザでは動作しません。エラーハンドリングとユーザーへのフィードバックが重要だと学びました。

if (!navigator.clipboard) {
  alert('お使いのブラウザはクリップボード機能に対応していません')
  return
}

4. UX設計の重要性

技術的な実装だけでなく、ユーザーがどのように操作するかを考えることが大切です。たとえば、リサイズ完了時に「完了」バッジを表示したり、コピー成功時にボタンのテキストを変更したりすることで、ユーザーに安心感を与えられます。

今後の展望

現在実装済みの機能に加えて、以下の機能追加を検討しています。

画像フォーマット変換(PNG/JPEG/WebP)
画像品質の調整(圧縮率の変更)
トリミング機能
フィルター適用(グレースケール、セピアなど)
ドラッグ&ドロップでの並び替え

また、パフォーマンス改善として、Web Workerを使ったバックグラウンド処理も検討しています。

まとめ

ブラウザベースの画像リサイズツールを開発することで、Canvas APIやClipboard APIといったモダンなWeb APIの力を実感できました。サーバーレスでありながら高機能なツールが作れるというのは、フロントエンド技術の進化を感じますね。

個人開発でWebツールを作る際は、ユーザーのプライバシーを守りつつ、手軽に使える設計を心がけることが大切だと学びました。今回の開発を通じて、技術選定の理由や設計の背景を丁寧に考えることの重要性も再認識できました。

ぜひ使ってみてください。

画像リサイズツール: https://tools.easegis.jp/ja/tools/image/image-resizer

Discussion