JavaScriptで画像リサイズ

Web WorkerとOffscreenCanvasを使用


概要

- `input[type="file"]` で選んだ画像を 長辺が最大 `1600px` になるようにリサイズして、画面に表示する最小構成のサンプル - `createImageBitmap` が使える環境では高速に、未対応でも `` ロードでフォールバック) - ポイント - 長辺が `1600px` を超える場合だけ縮小(アスペクト比維持) - `canvas.toBlob()` で生成した `Blob` を `` に表示(`dataURL` より低メモリ) - 元が `PNG` なら `PNG`、それ以外は `JPEG` で出力(品質は `0.9`) - 補足 - `EXIF` の回転情報(`Orientation`) はこの簡易版では反映していない - スマホ撮影 `JPEG` で縦横が合わない場合は、`EXIF` を読んで回転させる処理(例:`exifreader` 等のライブラリで角度を取得し、`ctx.rotate()` で補正)を追加 - 表示だけなら `` をそのまま `DOM` に挿入しても良いがが、上記は再利用しやすいように `` に出力 - 画質と容量のバランスを調整したい場合は、`quality`(`JPEG` のみ)や `MAX_LONG_EDGE` を変更 - `Web Worker` + `OffscreenCanvas` を使えば、デコード〜リサイズ〜エンコード(`JPEG/PNG`) をワーカースレッドで行い、UIスレッドのカクつきを抑えることが可能 - `OffscreenCanvas` は、 `canvas` をDOMが無い環境でも描画でき、`canvas` の描画処理をメインスレッド以外で実行できる仕組み - iPhone(iOS Safari)でも iOS `16.4` 以降なら `OffscreenCanvas` が使えるため、この構成が有効 - サンプルコードの構成 - 画像を選択 → Workerでリサイズ → 画面プレビュー - さらに、リサイズ後のファイルを `
` の `input[type=file]` にセット(`DataTransfer`)して、通常の `multipart/form-data` で送信

デモ

画像を長辺1600pxにリサイズして表示

ここに画像をドラッグ&ドロップしてもOK

リサイズ後のプレビュー

コード

HTML

<label>
  画像を選択: <input id="fileInput" type="file" accept="image/*">
</label>

<div class="dropzone" id="dropzone" title="ここに画像ファイルをドラッグ&ドロップ">
  ここに画像をドラッグ&ドロップしてもOK
</div>

<p class="info" id="info"></p>
<img id="preview" alt="リサイズ後のプレビュー" />

<form id="uploadForm" action="/upload" method="post" enctype="multipart/form-data">
  <!-- CSRFトークンが必要なフレームワークは適宜追加 -->
  <input type="file" name="image" id="resizedFile" hidden>
  <input type="hidden" name="width" id="metaW">
  <input type="hidden" name="height" id="metaH">
  <input type="hidden" name="original_name" id="metaOrig">
  <!-- <button id="submitBtn" type="submit" disabled>この内容でフォーム送信</button> -->
</form>

JavaScript

const MAX_LONG_EDGE = 1600;
const fileInput  = document.getElementById('fileInput');
const preview    = document.getElementById('preview');
const infoEl     = document.getElementById('info');
const resizedInp = document.getElementById('resizedFile');
const metaW      = document.getElementById('metaW');
const metaH      = document.getElementById('metaH');
const metaOrig   = document.getElementById('metaOrig');

let currentObjectURL = null;

/** ① Worker本体を「普通の関数」として定義(外部スコープに依存しないことが大事) */
function workerMain() {
  self.onmessage = async (e) => {
    const { file, maxEdge } = e.data;
    const outType = (file.type && file.type.includes('png')) ? 'image/png' : 'image/jpeg';
    const quality = (outType === 'image/jpeg') ? 0.9 : 1.0;

    try {
      if (typeof OffscreenCanvas !== 'undefined' && 'createImageBitmap' in self) {
        const bitmap = await createImageBitmap(file);
        const width  = bitmap.width;
        const height = bitmap.height;
        const longEdge = Math.max(width, height);
        const scale  = longEdge > maxEdge ? (maxEdge / longEdge) : 1;
        const outW   = Math.round(width * scale);
        const outH   = Math.round(height * scale);

        const canvas = new OffscreenCanvas(outW, outH);
        const ctx = canvas.getContext('2d', { alpha: true });
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        ctx.drawImage(bitmap, 0, 0, outW, outH);
        bitmap.close?.();

        const blob = await canvas.convertToBlob({ type: outType, quality });
        self.postMessage({ ok: true, blob, outW, outH, outType });
        return;
      }
      // フォールバック指示(メインスレッドで実行してもらう)
      infoEl.textContent = 'メインスレッドで処理中...';
      self.postMessage({ ok: false, needFallback: true, reason: 'No OffscreenCanvas or createImageBitmap' });
    } catch (err) {
      self.postMessage({ ok: false, error: err?.message || String(err) });
    }
  };
}

/** ② 関数を toString() して Blob URL から Worker を生成 */
function createWorkerFromFunction(fn) {
  // 直に IIFE してワーカー側で関数を起動
  const src = `(${fn.toString()})();`;
  const blob = new Blob([src], { type: 'text/javascript' });
  const url  = URL.createObjectURL(blob);
  const worker = new Worker(url);
  // 後で掃除できるようURLを持たせておく(任意)
  worker.__url = url;
  return worker;
}

const worker = createWorkerFromFunction(workerMain);
window.addEventListener('unload', () => {
  try { worker.terminate(); } catch {}
  if (worker.__url) URL.revokeObjectURL(worker.__url);
});

/** 古環境向けフォールバック(メインスレッドでリサイズ) */
async function resizeOnMainThread(file) {
  const img = await new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file);
    const el = new Image();
    el.onload = () => { URL.revokeObjectURL(url); resolve(el); };
    el.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
    el.src = url;
  });
  const width = img.naturalWidth || img.width;
  const height = img.naturalHeight || img.height;
  const longEdge = Math.max(width, height);
  const scale  = longEdge > MAX_LONG_EDGE ? (MAX_LONG_EDGE / longEdge) : 1;
  const outW   = Math.round(width * scale);
  const outH   = Math.round(height * scale);

  const canvas = document.createElement('canvas');
  canvas.width = outW; canvas.height = outH;
  const ctx = canvas.getContext('2d', { alpha: true });
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(img, 0, 0, outW, outH);

  const outType = (file.type && file.type.includes('png')) ? 'image/png' : 'image/jpeg';
  const quality = (outType === 'image/jpeg') ? 0.9 : 1.0;
  const blob = await new Promise(res => canvas.toBlob(res, outType, quality));
  if (!blob) throw new Error('toBlob failed');

  return { blob, outW, outH, outType };
}

fileInput.addEventListener('change', async (e) => {
  const file = e.target.files?.[0];
  if (!file) return;
  infoEl.textContent = '処理中...';

  try {
    const result = await resizeInWorkerOrFallback(file);
    await updatePreviewAndForm(file, result);
  } catch (err) {
    console.error(err);
    infoEl.textContent = '処理に失敗しました。別の画像でお試しください。';
  } finally {
    e.target.value = ''; // 同じファイルでも再選択可
  }
});

function resizeInWorkerOrFallback(file) {
  return new Promise((resolve, reject) => {
    const onMsg = (ev) => {
      worker.removeEventListener('message', onMsg);
      const d = ev.data;
      if (d.ok) {
        resolve({ blob: d.blob, outW: d.outW, outH: d.outH, outType: d.outType });
      } else if (d.needFallback) {
        resizeOnMainThread(file).then(resolve, reject);
      } else {
        reject(new Error(d.error || 'worker failed'));
      }
    };
    worker.addEventListener('message', onMsg);
    worker.postMessage({ file, maxEdge: MAX_LONG_EDGE });
  });
}

async function updatePreviewAndForm(originalFile, { blob, outW, outH, outType }) {
  // プレビュー
  if (currentObjectURL) URL.revokeObjectURL(currentObjectURL);
  currentObjectURL = URL.createObjectURL(blob);
  preview.src = currentObjectURL;

  // 情報
  const kb = (blob.size / 1024).toFixed(1);
  infoEl.textContent = `出力: ${outW}×${outH}, 形式: ${outType}, 約 ${kb} KB`;

  // フォームへセット(DataTransferで file input に入れる)
  const ext  = (outType === 'image/png') ? 'png' : 'jpg';
  const base = (originalFile.name || 'image').replace(/\.[^.]+$/, '');
  const filename = `${base}_resized_${outW}x${outH}.${ext}`;
  const resizedFile = new File([blob], filename, { type: outType });

  let attached = false;
  if (window.DataTransfer) {
    try {
      const dt = new DataTransfer();
      dt.items.add(resizedFile);
      resizedInp.files = dt.files;
      attached = resizedInp.files && resizedInp.files.length > 0;
    } catch {
      console.error('ERROR :: Failed to attach resized file to input');
    }
  }

  metaW.value = String(outW);
  metaH.value = String(outH);
  metaOrig.value = originalFile.name || '';
}