- カテゴリー
- Web標準API
- 公開日
- 2026.09.26
Contents
はじめに
動画編集や解析ではフレーム単位の処理が必要です。WebCodecs APIのVideoFrameは、生の動画フレームを表し、Canvasへ描画したりWorkerへ転送したりできます。
この記事では、ユーザーが選んだ動画を<video>でデコードし、指定秒のVideoFrameを作ってWebPサムネイルにします。コンテナを直接解析する本格的なVideoDecoder入門ではありません。
処理の流れ
- 利用者が動画ファイルを選ぶ
- ブラウザ標準の
video要素が動画を再生できる形へデコードする - 指定した時刻まで移動する
- その瞬間を
VideoFrameとして取り出す - 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が別に必要です。


コメント