「フォルダの中の 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/finally で URL.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枚ずつ出せるから。 ループの各反復で対象アイテムを processing → done(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 にも同じ内容を投稿しています。