Reactで、アップロード前に動画のサムネイル画像を作る方法

​​React​Canvas API,Playwright,TypeScript

ユーザーが選んだ動画ファイルは、サーバーへ送る前に、ブラウザの中でサムネイル画像にできます。画面に出さない <video> に読み込み、currentTime で場面へ移動して、移動が終わったら <canvas> に描き、toBlob() で JPEG にします。サーバー側で動画を処理する仕組みがなくても、選んだ場面の画像を動画と一緒に送れます。

ここでは、動画の 10%・50%・90% の位置から候補を3枚作り、ユーザーが選んだ1枚を動画と一緒に FormData で送るコンポーネントを作ります。移動が終わる前に描くと前の場面が写ることや、読み込めない形式の動画の扱いも、検証の結果とあわせて書きます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。動画は ffmpeg で作った10秒の検証用ファイル(H.264 の MP4 と MOV、VP9 の WebM、HEVC の MP4、回転の情報を付けた MP4)で、Playwright でファイルを選んで確かめました。Firefox と Safari、スマートフォンのブラウザ、実際にスマートフォンで撮った動画、大きな動画(数百 MB 以上)は試していません。回転の情報を付けた動画の向きを確かめたのも、検証用の MP4 を Chromium で読み込んだときだけです。

動画を選ぶと 1.0秒・5.0秒・9.0秒の3枚の候補が並び、9.0秒の候補を押すと枠が付き、送信すると受け取った動画とサムネイルの名前・形式・大きさが表示される。ファイルを選ぶボタンの「Choose File」は Chromium の表示
動画を選び、候補から1枚を選んで送信するまで(カーソルと操作の表示は GIF 用に重ねたもの)

場面を切り出して JPEG にする関数

切り出しの処理は、React に依存しない関数にまとめました。createThumbnails() は、動画ファイルと「長さに対する割合」の配列を受け取り、それぞれの位置の場面を横幅 320px 以下の JPEG にして返します。

ts
export type Thumbnail = { time: number; blob: Blob; width: number; height: number };

// video で type のイベントが起きるまで待つ。読み込めない動画なら error で失敗にする
function waitFor(video: HTMLVideoElement, type: 'loadedmetadata' | 'seeked') {
  return new Promise<void>((resolve, reject) => {
    const done = () => { cleanup(); resolve(); };
    const fail = () => { cleanup(); reject(new Error(`動画を読み込めません(${video.error?.message || 'error'})`)); };
    const cleanup = () => {
      video.removeEventListener(type, done);
      video.removeEventListener('error', fail);
    };
    video.addEventListener(type, done);
    video.addEventListener('error', fail);
  });
}

function toBlob(canvas: HTMLCanvasElement) {
  return new Promise<Blob>((resolve, reject) => {
    canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('画像にできませんでした'))), 'image/jpeg', 0.8);
  });
}

// 動画ファイルの、長さに対する割合(0〜1)の位置の場面を、横幅 maxWidth 以下の JPEG にする
export async function createThumbnails(file: File, ratios: number[], maxWidth = 320): Promise<Thumbnail[]> {
  const src = URL.createObjectURL(file);
  const video = document.createElement('video');
  video.muted = true;
  video.preload = 'auto';
  try {
    const loaded = waitFor(video, 'loadedmetadata');
    video.src = src;
    await loaded;
    if (!video.videoWidth) throw new Error('映像が入っていないか、このブラウザでは映像を表示できません');

    const scale = Math.min(1, maxWidth / video.videoWidth);
    const canvas = document.createElement('canvas');
    canvas.width = Math.round(video.videoWidth * scale);
    canvas.height = Math.round(video.videoHeight * scale);
    const ctx = canvas.getContext('2d')!;

    const thumbs: Thumbnail[] = [];
    // 1つの video で順に移動するので、1枚ずつ待つ(同時に currentTime を変えない)
    for (const ratio of ratios) {
      const time = video.duration * ratio;
      const seeked = waitFor(video, 'seeked');
      video.currentTime = time;
      await seeked; // 移動が終わってから描く
      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
      thumbs.push({ time, blob: await toBlob(canvas), width: canvas.width, height: canvas.height });
    }
    return thumbs;
  } finally {
    video.removeAttribute('src');
    video.load();
    URL.revokeObjectURL(src);
  }
}

流れは次のとおりです。

  1. URL.createObjectURL() で、選ばれたファイルを指す URL を作り、画面に出さない <video> の src に入れます。
  2. loadedmetadata を待ちます。ここで長さ(duration)と映像の大きさ(videoWidth・videoHeight)が分かります。
  3. 割合ごとに currentTime を入れて、seeked を待ってから drawImage() で canvas に描きます。
  4. toBlob() に 'image/jpeg' と画質 0.8 を渡して、JPEG の Blob にします。

最後に finally で src を外し、URL を URL.revokeObjectURL() で解放します。MDN では、使い終わった URL はこのメソッドで解放するよう説明しています。解放しないと、ブラウザがファイルへの参照を持ち続けます。

toBlob() は、画像を作れなかったときにコールバックへ null を渡します。そのときは失敗として扱います。

移動が終わる前に描くと、前の場面が写る

currentTime に値を入れても、その場ですぐ場面が変わるわけではありません。移動が終わったことは seeked イベントで分かります。検証用の動画(0〜1秒が黒、1〜4秒が水色、4〜7秒がピンク、7〜10秒が黄色)で、描くタイミングを変えて、canvas に何が写るかを調べました。

描いたタイミング 写ったもの
loadedmetadata の直後(readyState が 1) 何も描かれない(透明)
loadeddata(いまの位置の場面を読み込んだ)のあと、0秒の位置で currentTime = 5 を入れた直後 黒(0秒の場面)
そのあと seeked を待ってから ピンク(5秒の場面)
続けて currentTime = 9 を入れ、setTimeout(0) のあと ピンク(前の5秒の場面)
そのあと seeked を待ってから 黄色(9秒の場面)

setTimeout(0) で少し待っても、移動はまだ終わっておらず(seeking が true)、前の場面が写りました。決まった時間だけ待つのではなく、seeked を待つのが確実です。MDN の drawImage() の注意書きにも、video を描くには readyState が 1 より大きい必要があると書かれています。

seeked は、いまと同じ時刻を currentTime に入れたときにも届きました(9秒の位置で 9 を入れて確かめました)。検証環境の Chromium では、同じ割合が2回並んでも、待ちが終わらなくなることはありませんでした。

1つの <video> で場面を移動するので、createThumbnails() では1枚ずつ、seeked を待って描き終えてから次の位置へ移動しています。

先頭の0秒を選ぶと、この検証用の動画では黒い画像になりました。先頭が黒い動画や、フェードインで始まる動画では、0秒では内容の分からない画像になります。この記事で 10% の位置から候補を作っているのは、そのためです。

候補を選んで、動画と一緒に送るコンポーネント

VideoThumbnailPicker は、ファイルを選ぶ欄、3枚の候補、送信ボタンを表示します。候補を押すと selected が変わり、送信するときにその1枚の Blob を FormData に入れます。

tsx
import { useEffect, useRef, useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import { createThumbnails } from './videoThumbnail';
import type { Thumbnail } from './videoThumbnail';

const RATIOS = [0.1, 0.5, 0.9]; // 動画の 10%・50%・90% の位置

export function VideoThumbnailPicker() {
  const [file, setFile] = useState<File | null>(null);
  const [thumbs, setThumbs] = useState<(Thumbnail & { url: string })[]>([]);
  const [selected, setSelected] = useState(0);
  const [status, setStatus] = useState('');
  const latestFile = useRef<File | null>(null);

  // URL はファイルを選んだときに作り、片付け(cleanup)で解放する
  useEffect(() => () => thumbs.forEach((t) => URL.revokeObjectURL(t.url)), [thumbs]);

  async function handleChange(e: ChangeEvent<HTMLInputElement>) {
    const f = e.target.files?.[0] ?? null;
    latestFile.current = f;
    setFile(f);
    setThumbs([]);
    setSelected(0);
    if (!f) return setStatus('');
    setStatus('サムネイルを作っています…');
    try {
      const result = await createThumbnails(f, RATIOS);
      if (latestFile.current !== f) return; // 途中で別のファイルが選ばれた
      setThumbs(result.map((t) => ({ ...t, url: URL.createObjectURL(t.blob) })));
      setStatus('');
    } catch (err) {
      if (latestFile.current !== f) return;
      setStatus(err instanceof Error ? err.message : String(err));
    }
  }

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const thumb = thumbs[selected];
    if (!file || !thumb) return;
    const body = new FormData();
    body.append('video', file);
    body.append('thumbnail', thumb.blob, 'thumbnail.jpg');
    setStatus('送信しています…');
    const res = await fetch('/upload', { method: 'POST', body });
    setStatus(res.ok ? `送信しました:${await res.text()}` : `送信に失敗しました(${res.status})`);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        動画を選ぶ <input type="file" accept="video/*" onChange={handleChange} />
      </label>
      {thumbs.length > 0 && (
        <div className="thumbs" role="group" aria-label="サムネイルの候補">
          {thumbs.map((t, i) => (
            <button
              key={t.time}
              type="button"
              aria-pressed={i === selected}
              onClick={() => setSelected(i)}
            >
              <img src={t.url} width={t.width} height={t.height} alt={`${t.time.toFixed(1)}秒の場面`} />
              <span>{t.time.toFixed(1)}秒</span>
            </button>
          ))}
        </div>
      )}
      <p role="status">{status}</p>
      <button type="submit" disabled={!thumbs.length}>この画像で送信</button>
    </form>
  );
}

表示用の URL は、ファイルを選んだときの処理(handleChange)の中で、候補ができたあとに URL.createObjectURL(t.blob) で作り、useEffect の片付け(cleanup)で解放しています。公式リファレンスにある useEffect の説明のとおり、片付けの関数は依存配列の値が変わったときに古い値で呼ばれ、コンポーネントが画面から消えるときにも呼ばれます。別の動画を選び直すと、前の3枚の URL が解放されます。

検証では、URL.revokeObjectURL() を呼んだ回数を数えました。1つ目の動画を選んだあとは1回(createThumbnails() の中の動画の URL)、2つ目を選んだあとは合わせて5回でした。増えた4回は、2つ目の動画の URL(1回)と、前の候補3枚の URL(3回)です。StrictMode の開発用ビルドでも回数は同じで、画像も3枚とも表示されました。

latestFile は、サムネイルを作っている途中で別の動画が選ばれたときのためのものです。先に選んだ動画の処理があとから終わっても、そのときの latestFile と違うファイルの結果は捨てるので、画面には最後に選んだ動画の候補が残ります。

送信では、FormData.append() の3つ目の引数にファイル名を渡しています。Blob には元のファイル名がないので、渡さないとファイル名は "blob" になります。受け取った内容をそのまま返す検証用のサーバーに、5.0秒の候補を選んで送ったところ、次のように動画と JPEG の2つが届いていました。

text
送信しました:video=sample.mp4(video/mp4、218587バイト) / thumbnail=thumbnail.jpg(image/jpeg、4310バイト)

1280×720 の動画から、320×180 で 4KB ほどの JPEG ができました。この大きさは、単色に文字だけの検証用の動画での値です。

形式や向きが違う動画で試した結果

同じコンポーネントで、形式の違う動画を選んで確かめました。

動画 結果
H.264 の MP4 1.0秒・5.0秒・9.0秒の3枚ができた(320×180)
VP9 の WebM 同じく3枚できた
H.264 の MOV 同じく3枚できた
HEVC(H.265)の MP4 検証環境の Chromium では読み込めず、error イベントになった
「90度回して表示する」情報を付けた MP4 縦長の3枚ができた(320×569)

検証環境の Chromium では、HEVC の動画は error が届いて、createThumbnails() は失敗になりました。画面には video.error.message を含む次の文が出ます。

text
動画を読み込めません(PipelineStatus::DEMUXER_ERROR_NO_SUPPORTED_STREAMS: FFmpegDemuxer: no supported streams)

拡張子が同じ .mp4 でも、中の映像の形式によっては読み込めないことがあります。error を待って知らせる処理は省かないほうが安全です。video.error.message は、エラーの原因を調べるための詳しい説明です。実際の画面では「この形式の動画には対応していません」のような文に置き換えると親切です。

回転の情報を付けた動画は、元の映像が 1280×720 の横長です。Chromium では videoWidth・videoHeight が 720×1280 になり、サムネイルも縦長に描かれました。<video> で表示したときと同じ向きであることも、画像を並べて確かめました。createThumbnails() は canvas の大きさを videoWidth・videoHeight から決めているので、縦横を入れ替える処理を書く必要はありませんでした。

​Posted by 86