Reactで、画面を録画してダウンロードする方法

​​React​MediaRecorder,Screen Capture API,TypeScript,カスタムフック

ブラウザだけで画面を録画するには、navigator.mediaDevices.getDisplayMedia() で利用者に画面を選んで共有してもらい、受け取った映像を MediaRecorder で録画します。止めたときにたまったデータを1つの Blob にまとめ、URL.createObjectURL() で作った URL を <a download> に渡せば、ファイルとして保存できます。React では、録画中かどうかと、できた動画の URL を、カスタムフックで持つと扱いやすくなります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドしました。画面を選ぶダイアログはヘッドレスのブラウザでは操作できないため、Xvfb の仮想の画面(1280×800、ウィンドウマネージャーなし)でウィンドウありの Chromium を開き、ダイアログのボタンと「共有を停止」は、xdotool でマウスを動かして押しています。共有したのは「画面全体」だけで、タブやウィンドウの共有、音声の録音、数分を超える長い録画は試していません。Firefox、Safari、Google Chrome と Microsoft Edge の製品版、Windows と macOS、スマートフォン、サーバーレンダリングも試していません。

「画面の録画」の見出しと「画面の録画を始める」ボタンがあるページ。ボタンを押すと、Chromium の「共有する情報を選択する」ダイアログが開く。「画面全体」のタブを選び、画面のサムネイルを選んで「共有」を押すと、ボタンが「録画を止める(0秒)」に変わって秒数が増え、画面の下に「画面を共有しています」のバーと「共有を停止」ボタンが出る。ボタンを押して止めると、録画した動画のプレーヤーと「録画をダウンロード(87 KB、video/mp4;codecs=avc1)」のリンクが出る。下の帯に、何回目の操作かが出ている
録画を始める、画面全体を選んで共有する、録画を止める、の3回の操作。変わった表示をピンクにする強調と、下の帯・クリックの輪は、録画用に足したもの

録画を始めて止めるフック

ts
import { useCallback, useEffect, useRef, useState } from 'react';

// 前から順に、ブラウザが録画できる形式を選ぶ
const MIME_TYPES = ['video/mp4;codecs=avc1', 'video/webm;codecs=vp9', 'video/webm'];

export function pickMimeType(): string {
  return MIME_TYPES.find((t) => MediaRecorder.isTypeSupported(t)) ?? '';
}

export const isScreenRecordingSupported =
  typeof navigator !== 'undefined' &&
  typeof navigator.mediaDevices?.getDisplayMedia === 'function' &&
  typeof MediaRecorder !== 'undefined';

export type Recording = { url: string; type: string; size: number };

export function useScreenRecorder() {
  const [recording, setRecording] = useState(false);
  const [result, setResult] = useState<Recording | null>(null);
  const [error, setError] = useState<string | null>(null);
  const recorderRef = useRef<MediaRecorder | null>(null);
  const urlRef = useRef<string | null>(null);

  const start = useCallback(async () => {
    if (recorderRef.current) return;
    setError(null);

    let stream: MediaStream;
    try {
      stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
    } catch (e) {
      // 画面を選ぶダイアログで「キャンセル」を押すと NotAllowedError になる
      if (e instanceof DOMException && e.name === 'NotAllowedError') {
        setError('画面の共有がキャンセルされました');
      } else {
        setError(`録画を始められませんでした(${e instanceof Error ? e.message : String(e)})`);
      }
      return;
    }

    let recorder: MediaRecorder;
    try {
      const mimeType = pickMimeType();
      recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
    } catch (e) {
      stream.getTracks().forEach((t) => t.stop());
      setError(`録画を始められませんでした(${e instanceof Error ? e.message : String(e)})`);
      return;
    }

    const chunks: Blob[] = [];
    recorder.ondataavailable = (e) => {
      if (e.data.size > 0) chunks.push(e.data);
    };
    // 「録画を止める」のほか、ブラウザの「共有を停止」で映像が終わったときも呼ばれる
    recorder.onstop = () => {
      stream.getTracks().forEach((t) => t.stop());
      const blob = new Blob(chunks, { type: recorder.mimeType });
      if (urlRef.current) URL.revokeObjectURL(urlRef.current);
      urlRef.current = URL.createObjectURL(blob);
      recorderRef.current = null;
      setRecording(false);
      setResult({ url: urlRef.current, type: blob.type, size: blob.size });
    };

    recorder.start();
    recorderRef.current = recorder;
    setRecording(true);
  }, []);

  const stop = useCallback(() => {
    const recorder = recorderRef.current;
    if (recorder && recorder.state !== 'inactive') recorder.stop();
  }, []);

  // 画面から取り除かれたら、録画と画面の共有を止め、作った URL を手放す
  useEffect(() => () => {
    const recorder = recorderRef.current;
    if (recorder) {
      recorder.onstop = null;
      if (recorder.state !== 'inactive') recorder.stop();
      recorder.stream.getTracks().forEach((t) => t.stop());
    }
    if (urlRef.current) URL.revokeObjectURL(urlRef.current);
  }, []);

  return { supported: isScreenRecordingSupported, recording, result, error, start, stop };
}

start() は、まず getDisplayMedia() を呼びます。ブラウザが、共有する画面を選ぶダイアログを出し、利用者が選んで「共有」を押すと、その画面の映像が入った MediaStream が返ってきます。検証環境で「画面全体」を選んだとき、映像の設定は displaySurface: 'monitor'、幅 1280、高さ 800、フレームレート 30 でした。

次に、その MediaStream を MediaRecorder に渡して start() を呼ぶと、録画が始まります。録画したデータは dataavailable のイベントで届くので、配列 chunks にためておきます。stop() を呼ぶと、最後の dataavailable のあとに stop のイベントが来ます。onstop の中で chunks を1つの Blob にまとめ、URL.createObjectURL() で URL を作って state に入れます。

onstop では、stream.getTracks() のすべてに stop() を呼んで、画面の共有も終わらせます。検証環境では、録画を止めたあとに画面の下の「画面を共有しています」のバーが消えました。

MediaRecorder と作った URL は、画面に出す値ではないので ref に持ちます。録画中かどうか(recording)、できた動画(result)、エラーの文(error)だけを state にしています。

ダウンロードのリンクを出すコンポーネント

tsx
import { useEffect, useState } from 'react';
import { useScreenRecorder } from './useScreenRecorder';

function fileName(type: string): string {
  const d = new Date();
  const p = (n: number) => String(n).padStart(2, '0');
  const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
  const ext = type.startsWith('video/mp4') ? 'mp4' : 'webm';
  return `screen-${stamp}.${ext}`;
}

export function ScreenRecorder() {
  const { supported, recording, result, error, start, stop } = useScreenRecorder();
  const [seconds, setSeconds] = useState(0);

  // 録画している間だけ、経過秒数を数える
  useEffect(() => {
    if (!recording) return;
    setSeconds(0);
    const id = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => clearInterval(id);
  }, [recording]);

  if (!supported) {
    return <p>このブラウザでは画面を録画できません。</p>;
  }

  return (
    <div className="recorder">
      {recording ? (
        <button type="button" onClick={stop}>
          録画を止める({seconds}秒)
        </button>
      ) : (
        <button type="button" onClick={start}>
          画面の録画を始める
        </button>
      )}
      {error && <p className="error" role="alert">{error}</p>}
      {result && !recording && (
        <div className="result">
          <video src={result.url} controls width={320} />
          <p>
            <a href={result.url} download={fileName(result.type)}>
              録画をダウンロード
            </a>
            ({(result.size / 1024).toFixed(0)} KB、{result.type})
          </p>
        </div>
      )}
    </div>
  );
}

<a> の download 属性に名前を入れておくと、リンクを押したときにその名前で保存されます。検証環境でリンクを押すと、screen-20261106-032755.mp4 という名前でダウンロードが始まりました。拡張子は、録画の形式(result.type)が video/mp4 で始まるなら .mp4、そうでなければ .webm にしています。

同じ URL を <video> の src にも渡して、保存する前に中身を確かめられるようにしました。ダウンロードしたファイルを ffprobe で調べると、映像は H.264、解像度は 1280×800 で、長さは 3.82秒でした。<video> で読んだ長さも同じ 3.82秒です。

経過秒数は、recording が true の間だけ setInterval で数えています。検証では、共有を始めて約1.5秒後は「録画を止める(1秒)」、そこからさらに約2秒後には「録画を止める(3秒)」と表示されました。

録画の形式は isTypeSupported() で選ぶ

録画できる形式はブラウザによって違います。MediaRecorder.isTypeSupported() に MIME タイプを渡すと、そのブラウザが録画できる形式かどうかを true か false で返します。フックの pickMimeType() は、MIME_TYPES の前から順に調べて、最初に true になったものを使います。検証環境の Chromium 153 で調べた結果は次のとおりです。

指定した MIME タイプ isTypeSupported() 実際に録画された形式
video/mp4;codecs=avc1 true MP4(映像は H.264)
video/mp4 true MP4(映像は VP9)
video/webm;codecs=vp9 true WebM(映像は VP9)
video/mp4;codecs=avc1,mp4a.40.2 false NotSupportedError で録画できない

video/mp4 とだけ指定すると、入れ物は MP4 でも、映像は VP9 になりました。H.264 にしたいときは、codecs=avc1 まで書きます。録画できない形式を MediaRecorder に渡すと、new MediaRecorder() の時点で NotSupportedError になります。フックでは、このときも共有を止めてからエラーを出すように、try で囲んでいます。

「共有を停止」で止められても、onstop が呼ばれる

画面を共有している間、ブラウザは「共有を停止」のボタンを出します。利用者がページの「録画を止める」ではなく、こちらを押すこともあります。

検証環境で、ended のイベントで止める処理を書かずに録画し、「共有を停止」を押したところ、映像のトラックの ended、dataavailable、stop の順にイベントが来て、MediaRecorder の state は inactive になりました。MDN の stop イベントのページにも、stop() を呼んだときのほか、録画している MediaStream が終わったときにも stop のイベントが来ると書かれています。そのため、フックでは ended を自分で受け取らず、どちらで止めても onstop の同じ処理で動画を作っています。部品で試すと、「共有を停止」を押したあとにダウンロードのリンクが出て、共有のバーも消えました。

コードからトラックの stop() を呼んだときは、ended のイベントは来ませんでした。MDN の ended イベントのページにも、stop() を呼んで終えたときだけは ended が来ないと書かれています。このときも MediaRecorder の stop のイベントは来て、state は inactive になりました。ended のイベントだけで止める処理を書くと、コードからトラックを止めたときには呼ばれません。

start() に間隔を渡すと、WebM の長さが分からなくなる

MediaRecorder.start() には、dataavailable を出す間隔をミリ秒で渡せます。検証環境で約3.5秒録画すると、start() では dataavailable が止めたときの1回だけ、start(1000) では4回でした。

ただし、WebM で間隔を渡すと、できたファイルに長さが入りませんでした。録画したものを <video> で読んだ長さと、ffprobe で調べた長さは次のとおりです。

形式と start() の呼び方 <video> の長さ ffprobe の長さ
WebM(VP9)、start() 2.48秒 2.48秒
WebM(VP9)、start(1000) Infinity N/A
MP4(H.264)、start(1000) 2.51秒 2.51秒

録画をまとめてダウンロードするだけなら、間隔を渡す必要はありません。フックでも start() を引数なしで呼んでいます。

キャンセルしたときと、録画中に部品を外したとき

画面を選ぶダイアログで「キャンセル」を押すと、getDisplayMedia() は NotAllowedError で失敗します。MDN の getDisplayMedia() のページでも、利用者が許可しなかったときは NotAllowedError とされています。フックはこれをエラーの文「画面の共有がキャンセルされました」に変え、ボタンは「画面の録画を始める」のまま戻ります。検証環境でもそのとおりの表示になりました。

録画中に部品が画面から取り除かれたときは、useEffect のクリーンアップで録画とトラックを止めます。このとき onstop を null にしておくのは、取り除いたあとの部品の state を書き換えないためです。検証環境で録画中に部品を外すと、共有のバーが消えて、画面の共有も終わりました。

もう一度録画したときは、前の URL を URL.revokeObjectURL() で手放してから、新しい URL を作ります。手放した URL は使えなくなり、検証環境で前の URL を fetch() で読むと TypeError: Failed to fetch になりました。

使えるブラウザ

MDN のブラウザの対応表のもとになっているデータによると、getDisplayMedia() はデスクトップの Chrome 72、Edge 79、Firefox 66、Safari 13 から使えます。Android の Chrome と Firefox には対応していません。iOS の Safari は 27 からとされています。

そのため、フックの isScreenRecordingSupported で、getDisplayMedia と MediaRecorder があるかを先に調べます。ないときは、部品は録画のボタンの代わりに「このブラウザでは画面を録画できません。」と表示します。ヘッドレスの Chromium で、ページを開く前に getDisplayMedia を消してから表示すると、ボタンは出ずに、この文だけが出ました。

​Posted by 86