WebCodecsのVideoFrameで動画サムネイルを作る|Canvas保存と解放忘れ対策

動画からサムネイル作成。VideoFrameの入門 Web標準API
カテゴリー
Web標準API
公開日
2026.09.26

はじめに

動画編集や解析ではフレーム単位の処理が必要です。WebCodecs APIのVideoFrameは、生の動画フレームを表し、Canvasへ描画したりWorkerへ転送したりできます。

この記事では、ユーザーが選んだ動画を<video>でデコードし、指定秒のVideoFrameを作ってWebPサムネイルにします。コンテナを直接解析する本格的なVideoDecoder入門ではありません。

処理の流れ

  1. 利用者が動画ファイルを選ぶ
  2. ブラウザ標準のvideo要素が動画を再生できる形へデコードする
  3. 指定した時刻まで移動する
  4. その瞬間をVideoFrameとして取り出す
  5. Canvasへ描画し、WebP画像として保存する

コーデックは映像を圧縮・展開する方式、コンテナは映像や音声などをまとめる入れ物です。MP4という拡張子だけでは、すべてのブラウザで同じコーデックを再生できるとは限りません。

必要環境と起動

Node.js 22.12以上とnpm、VideoFrameに対応したブラウザを使います。新しいフォルダーに以下のindex.htmlを保存し、この後の3つのJavaScriptブロックを掲載順に1つのapp.jsへ保存してください。

そのフォルダーでnpx --yes vite@7.3.6 --host 127.0.0.1を実行し、端末に表示されたURLを開きます。初回はnpmからViteを取得します。サーバーの終了はCtrl+Cです。最初はブラウザで再生できる短いMP4またはWebMを選んでください。動画はこのサンプルではサーバーへ送信されません。

HTML

<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>動画サムネイル</title>
<style>body{max-width:760px;margin:24px auto;padding:16px;font-family:sans-serif}video,canvas{display:block;max-width:100%;margin:16px 0}button,input{margin:8px 0}</style>
<h1>動画サムネイル</h1>
<label>動画 <input id="videoFile" type="file" accept="video/*"></label>
<label>秒 <input id="time" type="number" min="0" step="0.1" value="1"></label>
<button id="capture">切り出す</button>
<button id="cancel" disabled>中断</button>
<p id="status" role="status">動画を選んでください</p>
<video id="preview" preload="auto" muted playsinline></video>
<canvas id="canvas"></canvas>
<a id="download" hidden>画像を保存</a>
<script type="module" src="./app.js"></script>
</html>

動画を読み込む

const input = document.querySelector("#videoFile");
const video = document.querySelector("#preview");
const button = document.querySelector("#capture");
const cancel = document.querySelector("#cancel");
const time = document.querySelector("#time");
const status = document.querySelector("#status");
const link = document.querySelector("#download");
let videoUrl;
let imageUrl;
let controller;

function clearDownload() {
  if (imageUrl) URL.revokeObjectURL(imageUrl);
  imageUrl = undefined;
  link.removeAttribute("href");
  link.hidden = true;
}

input.addEventListener("change", () => {
  clearDownload();
  video.pause();
  video.removeAttribute("src");
  video.load();
  if (videoUrl) URL.revokeObjectURL(videoUrl);
  const file = input.files[0];
  videoUrl = undefined;
  status.textContent = "動画を選んでください";
  if (!file) return;
  videoUrl = URL.createObjectURL(file);
  video.src = videoUrl;
  status.textContent = "秒数を指定して切り出してください";
});
video.addEventListener("error", () => {
  status.textContent = "動画を読めません。別の形式を試してください";
});
cancel.addEventListener("click", () => controller?.abort());

function waitForMedia(event, ready, signal, start = () => {}) {
  return new Promise((resolve, reject) => {
    let timer;
    const cleanup = () => {
      clearTimeout(timer);
      video.removeEventListener(event, done);
      video.removeEventListener("error", fail);
      signal.removeEventListener("abort", abort);
    };
    const finish = (error) => { cleanup(); error ? reject(error) : resolve(); };
    const done = () => { if (ready()) finish(); };
    const fail = () => finish(new Error("動画を読めません"));
    const abort = () => finish(signal.reason);
    if (signal.aborted) return abort();
    if (video.error) return fail();
    video.addEventListener(event, done);
    video.addEventListener("error", fail);
    signal.addEventListener("abort", abort, { once: true });
    timer = setTimeout(() => finish(new Error("動画の待機がタイムアウトしました")), 15000);
    try { start(); done(); } catch (error) { finish(error); }
  });
}

VideoFrameをCanvasへ描画する

async function captureAt(seconds, signal) {
  if (!("VideoFrame" in window)) throw new Error("WebCodecs非対応です");
  if (!videoUrl) throw new Error("先に動画を選んでください");
  if (!Number.isFinite(seconds) || seconds < 0) throw new RangeError("0以上の秒数を入力してください");
  await waitForMedia("loadeddata", () => video.readyState >= 2, signal);
  if (!Number.isFinite(video.duration) || video.duration <= 0) throw new Error("長さが確定した動画を選んでください");
  if (seconds >= video.duration) throw new RangeError("動画の長さより小さい秒数を入力してください");
  video.pause();

  if (video.currentTime !== seconds) {
    await waitForMedia("seeked", () => !video.seeking && video.readyState >= 2,
      signal, () => { video.currentTime = seconds; });
  }
  signal.throwIfAborted();
  const frame = new VideoFrame(video);
  try {
    const canvas = document.querySelector("#canvas");
    canvas.width = frame.displayWidth;
    canvas.height = frame.displayHeight;
    canvas.getContext("2d").drawImage(frame, 0, 0);

    const blob = await new Promise((resolve, reject) => {
      canvas.toBlob(
        (blob) => blob ? resolve(blob) : reject(new Error("画像化に失敗しました")),
        "image/webp",
        0.85,
      );
    });
    signal.throwIfAborted();
    return blob;
  } finally {
    frame.close();
  }
}

VideoFrameはGPUやデコーダー資源を保持することがあります。使い終えたらclose()します。大量フレームで解放を忘れると、メモリ圧迫やデコード停止につながります。

保存ボタン

button.addEventListener("click", async () => {
  if (controller) return;
  controller = new AbortController();
  button.disabled = input.disabled = time.disabled = true;
  cancel.disabled = false;
  clearDownload();
  status.textContent = "処理中…";
  try {
    const seconds = time.valueAsNumber;
    const blob = await captureAt(seconds, controller.signal);
    const extension = { "image/webp": "webp", "image/png": "png" }[blob.type];
    if (!extension) throw new Error("予期しない画像形式です");
    imageUrl = URL.createObjectURL(blob);
    link.href = imageUrl;
    link.download = `thumbnail-${seconds}.${extension}`;
    link.textContent = `${extension.toUpperCase()}を保存`;
    link.hidden = false;
    status.textContent = "サムネイルを作成しました";
  } catch (error) {
    status.textContent = error.name === "AbortError" ? "中断しました" : error.message;
  } finally {
    controller = undefined;
    button.disabled = input.disabled = time.disabled = false;
    cancel.disabled = true;
  }
});

処理中はファイルと秒数の変更・二重実行を止めます。待機には15秒の上限があり、中断時もPromiseをrejectしてイベントリスナーを片付けます。Canvasの画像化自体は中断できないため、その処理中に中断した場合は完了後に結果を破棄します。

toBlob()は要求した形式に非対応ならPNGを返すため、実際のblob.typeから拡張子とリンク名を決めます。この例の待機上限は動画読み込み・シークに対するもので、全処理時間やメモリ消費量の保証ではありません。大容量・高解像度の動画を大量処理する用途は対象外です。

制約と本格的なデコード

WebCodecsのVideoDecoderはMP4やWebMというコンテナを自動で分解してくれません。encoded chunkへ分け、codec configurationとtimestampを渡すdemux処理が別途必要です。単発サムネイルなら<video>によるデコードの方が短く、連続フレーム解析ならdemuxer+VideoDecoder+Workerを検討します。

クロスオリジン動画はCORS設定がないとCanvasがtaintedになり、画像保存できません。ローカルファイルでも、ブラウザが動画codecを再生できなければ処理できません。

動作確認

  • 0秒、中間、終端直前を切り出せる
  • 空欄、負数、duration以上を拒否する
  • 非対応形式でエラーを表示する
  • 縦動画で幅・高さが正しい
  • 処理中は二重実行できず、中断後は再実行できる
  • 繰り返した後も保存リンクが更新され、不要になったObject URLが解放される
  • 実際のMIMEに応じてWebPまたはPNGの拡張子になる

成功するとCanvasに指定時刻の映像が表示され、「WebPを保存」リンクが有効になります。保存画像を開き、動画の向きと縦横比が一致することも確認します。

よくあるトラブル

  • 動画を選んでも再生できない:ブラウザが動画内のコーデックへ対応しているか確認する
  • 指定秒と違う画像になる:seekedを待ってからVideoFrameを作成する
  • RangeErrorになる:指定秒が0未満または動画時間を超えていないか確認する
  • 保存画像が真っ黒になる:動画の読み込み・seek完了とCanvasサイズを確認する
  • 繰り返すと重くなる:作成したVideoFrameをfinallyで必ずclose()する
  • 外部URLの動画を保存できない:配信元のCORS設定が必要になる

関連記事

まとめ

VideoFrame(video)は既存の動画要素からフレーム処理へ入る分かりやすい入口です。seek完了を待ち、表示寸法を使い、必ずclose()してください。直接デコードではdemuxerが別に必要です。

参考リンク

コメント

タイトルとURLをコピーしました