0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数画像をブラウザだけで一括変換してZIPで返す——JSZip×Canvasと「逐次処理」を選んだ理由

0
Posted at

「フォルダの中の PNG と JPEG が混ざった画像を、全部 WebP に変換したい」「100枚まとめて 50% に縮小したい」——画像の一括変換は地味によくある作業だ。1枚ずつ変換ツールにかけるのは面倒だし、まとめて処理できるデスクトップアプリを入れるのも大げさ。かといってオンラインの一括変換サービスは、大量の画像をサーバーにアップロードすることになる。

そこで、複数画像をブラウザ内で一括変換し、結果を ZIP にまとめてダウンロードできるツールをぱんだツールズの1機能として作った。1枚もサーバーに送らない。

実装の核は Canvas で1枚ずつ変換して JSZip でまとめるだけ。だが「複数枚を扱う」となると、単発変換ツールにはない設計判断がいくつか出てくる。この記事ではそのあたり——特になぜ並列ではなく逐次処理にしたか——を中心に書く。

全体の流れ:Canvas で変換 → JSZip でまとめる

使ったライブラリは ZIP 生成の JSZip 1つだけ。画像変換そのものは Canvas API なのでライブラリ不要。

"jszip": "^3.10.1"

1枚あたりの変換は単発の画像変換ツールと同じで、Image に読んで Canvas に描いて toBlob でエンコードする。リサイズ(%指定 / px指定 / 変更なし)とフォーマット(JPEG/PNG/WebP)を引数で受ける関数に切り出した。

async function convertImage(
  file: File, format: OutputFormat, quality: number,
  resizeMode: ResizeMode, pxWidth: number, pxHeight: number,
  keepAspect: boolean, percent: number
): Promise<Blob> {
  const url = URL.createObjectURL(file)
  try {
    const img = new Image()
    await new Promise<void>((resolve, reject) => {
      img.onload = () => resolve()
      img.onerror = () => reject(new Error('画像の読み込みに失敗しました'))
      img.src = url
    })

    let targetW = img.naturalWidth
    let targetH = img.naturalHeight
    if (resizeMode === 'percent' && percent > 0) {
      targetW = Math.round(img.naturalWidth * percent / 100)
      targetH = Math.round(img.naturalHeight * percent / 100)
    } else if (resizeMode === 'px') {
      // px指定。幅だけ+アスペクト維持なら高さを自動計算 など
    }
    targetW = Math.max(1, targetW)
    targetH = Math.max(1, targetH)

    const canvas = document.createElement('canvas')
    canvas.width = targetW
    canvas.height = targetH
    const ctx = canvas.getContext('2d')
    if (!ctx) throw new Error('Canvas の初期化に失敗しました')
    if (format === 'jpeg') {        // JPEGは透過を表現できないので白で埋める
      ctx.fillStyle = '#ffffff'
      ctx.fillRect(0, 0, targetW, targetH)
    }
    ctx.drawImage(img, 0, 0, targetW, targetH)

    const qualityValue = format === 'png' ? undefined : quality / 100
    return await new Promise<Blob>((resolve, reject) => {
      canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('画像変換に失敗しました'))), mime, qualityValue)
    })
  } finally {
    URL.revokeObjectURL(url)   // 成功・失敗どちらでも解放
  }
}

ポイントは try/finallyURL.revokeObjectURL を必ず通すこと。一括変換だと数十〜数百回 createObjectURL を呼ぶので、解放漏れがそのままメモリ圧迫につながる。finally に置けば、変換が失敗して例外が飛んでも確実に解放される。

JPEG のときだけ Canvas を白で塗ってから描くのも単発ツールと同じ理由で、透過 PNG をそのまま JPEG にすると透過部分が黒くなるのを防ぐため。PNG は quality を渡さない(undefined)。

なぜ並列(Promise.all)ではなく逐次処理なのか

ここが一括処理ならではの判断。画像を N 枚変換するとき、素直に書くと Promise.all で全部並列に投げたくなる。

// やっていない書き方
const blobs = await Promise.all(items.map((item) => convertImage(item.file, ...)))

でも実装では for ループで1枚ずつ順番に変換している。

const zip = new JSZip()
let successCount = 0

for (let i = 0; i < items.length; i++) {
  const item = items[i]
  setItems((prev) => prev.map((it) => it.id === item.id ? { ...it, status: 'processing' } : it))
  try {
    const blob = await convertImage(item.file, outputFormat, quality, resizeMode, w, h, keepAspect, pct)
    const baseName = item.file.name.replace(/\.[^.]+$/, '')
    zip.file(`${baseName}.${ext}`, blob)
    setItems((prev) => prev.map((it) => it.id === item.id ? { ...it, status: 'done' } : it))
    successCount++
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : '変換に失敗しました'
    setItems((prev) => prev.map((it) => it.id === item.id ? { ...it, status: 'error', errorMsg: msg } : it))
  }
}

逐次にした理由は3つ。

1つめ:メモリのピークを抑えるため。 並列にすると N 枚分の Image・Canvas・ImageData が同時にメモリに乗る。高解像度画像を100枚並列でデコードすると、デバイスによってはブラウザが落ちる。逐次なら同時に展開されるのは原則1枚分で、ピークメモリが枚数に依存しにくい。一括変換は「大量・大サイズ」を相手にするので、ここが効く。

2つめ:進捗を1枚ずつ出せるから。 ループの各反復で対象アイテムを processingdone(or error)に更新しているので、サムネイルにスピナーやチェックを出せる。ボタンのラベルも 変換中… 3/10 のように現在位置を表示できる。Promise.all だと「全部終わるまで何も分からない」状態になり、大量処理ほど不安になる。

{isProcessing
  ? processingItem
    ? `変換中… ${items.indexOf(processingItem) + 1}/${items.length}`
    : 'ZIPを生成中…'
  : `一括変換してZIPでダウンロード(${items.length}枚)`}

3つめ:エラーを1枚ずつ隔離できるから。 try/catch をループの中に置いているので、ある画像の変換が失敗しても、そのアイテムだけ error 状態にして次へ進める。Promise.all は1つでも reject すると全体が落ちる。壊れた1枚のせいで残り99枚の変換結果が無駄になるのは避けたい。successCount で成功枚数を数え、1枚でも成功していれば ZIP を作る。

Canvas のデコードは基本 CPU バウンドなので、並列にしても劇的に速くなるわけではない。むしろメモリリスクと UX を取って逐次にした、という判断。

JSZip で固めてダウンロードする

変換した Blob は zip.file(ファイル名, Blob) でどんどん追加していく。ファイル名は元の拡張子だけ差し替える。

const baseName = item.file.name.replace(/\.[^.]+$/, '')
zip.file(`${baseName}.${ext}`, blob)

全部追加し終わったら generateAsync で ZIP の Blob を生成し、<a> 要素を作ってプログラム的にクリックしてダウンロードさせる。

if (successCount > 0) {
  const zipBlob = await zip.generateAsync({ type: 'blob' })
  const url = URL.createObjectURL(zipBlob)
  const a = document.createElement('a')
  a.href = url
  a.download = `converted_images_${ext}.zip`
  a.click()
  URL.revokeObjectURL(url)
} else {
  setGlobalError('変換できた画像がありませんでした')
}

generateAsync は名前のとおり非同期で、内部で圧縮を進める。JSZip は Blob を直接 file() に渡せるので、Canvas の toBlob で得た Blob をそのまま放り込めるのが楽。ダウンロード後の revokeObjectURL も忘れずに。

なお画像(JPEG/PNG/WebP)は既に圧縮済みなので、ZIP の圧縮で容量はほとんど減らない。ここでの ZIP は「圧縮」というより複数ファイルを1つにまとめて1回のダウンロードで済ませるための器、という位置づけ。

大量の Object URL とプレビューの後始末

一括ツールはプレビュー用にも createObjectURL を使う。選択した全画像のサムネイルを出すため、枚数分の Object URL が生まれる。これを解放し忘れると、ページを開いている間ずっとメモリに残る。

生成した URL を useRef に持った Set に貯めておき、アンマウント時にまとめて解放する。

const previewUrlsRef = useRef<Set<string>>(new Set())

// ファイル追加時
const previewUrl = URL.createObjectURL(f)
previewUrlsRef.current.add(previewUrl)

// アンマウント時
useEffect(() => {
  const urls = previewUrlsRef.current
  return () => { urls.forEach((u) => URL.revokeObjectURL(u)) }
}, [])

加えて、同じファイルを二重に追加しないよう、ファイル名 + ファイルサイズ をキーにした重複チェックも入れている。ドラッグ&ドロップとファイル選択を何度も繰り返すと、うっかり同じ画像を足しがちなので。

const existingSlugs = new Set(prev.map((i) => i.file.name + i.file.size))
const newItems = files.filter((f) => !existingSlugs.has(f.name + f.size)).map(/* ... */)

まとめ

ブラウザ完結の画像一括変換は、「Canvas で1枚変換する処理」を JSZip でまとめるだけ、と思いきや、複数枚を相手にすると設計判断が増える。

  • 1枚の変換は try/finally で Object URL を必ず解放する
  • N枚の処理は Promise.all ではなく for で逐次。理由はメモリピーク抑制・進捗表示・エラー隔離の3点
  • try/catch をループ内に置き、壊れた1枚で全体を巻き込まない(successCount で判定)
  • JSZip は file(name, Blob)generateAsync({ type: 'blob' })。画像は圧縮済みなので ZIP は「まとめる器」
  • プレビュー用 Object URL は Set に貯めて一括解放、名前+サイズ で重複除外

「とりあえず全部並列」が常に正解ではない好例だった。大量・大サイズを扱うブラウザ処理では、逐次の方が安定して、しかも UX も良くなる。

ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?