Reactで、カメラの映像を表示して写真を撮る方法

​​React​Canvas API,Playwright,TypeScript

ブラウザでカメラの映像を表示するには、navigator.mediaDevices.getUserMedia() で映像のストリームを受け取り、<video> の srcObject に入れます。写真を撮るときは、その瞬間の <video> を <canvas> に描いて、toBlob() で JPEG にします。

React で書くときに気をつけたいのは、カメラを止める処理です。useEffect の片付け(cleanup)で止めるだけでは足りない場合があり、開発中の StrictMode ではカメラが使われたままになりました。そのしくみと直し方も、検証の結果とあわせて書きます。

補足

検証環境は 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 です。この環境には本物のカメラがないので、Chromium の偽のカメラ(起動オプションで、ffmpeg で作った 640×360 の動画を流すもの)を使い、カメラの許可を求める画面も起動オプションで自動的に「許可」または「ブロック」にしました。実際のカメラ、スマートフォンのブラウザ、Firefox と Safari では試していません。前面と背面のカメラが実際に切り替わるかどうかも確かめていません。

カメラの映像が左右反転して表示され、撮影ボタンを2回押す。押すたびに、下に反転していない写真と「640×360、16KB 保存する」が表示される。右上に撮影の回数が出る
撮影ボタンを2回押して写真を撮るまで(カーソルと回数の表示は GIF 用に重ねたもの)

カメラの映像を映して撮影するコンポーネント

CameraCapture は、カメラの映像、撮影ボタン、カメラの切り替えと停止のボタン、撮った写真を表示します。

tsx
import { useEffect, useRef, useState } from 'react';

type Facing = 'user' | 'environment';
type Photo = { url: string; blob: Blob; width: number; height: number };

// getUserMedia() が失敗したときの例外を、画面に出す文にする
function toMessage(err: unknown) {
  const name = err instanceof DOMException ? err.name : '';
  switch (name) {
    case 'NotAllowedError': return 'カメラの使用が許可されていません。ブラウザの設定で許可してください。';
    case 'NotFoundError': return 'カメラが見つかりません。';
    case 'NotReadableError': return 'カメラを使えません。ほかのアプリが使っていないか確かめてください。';
    case 'OverconstrainedError': return '条件に合うカメラがありません。';
    default: return `カメラを開けませんでした(${name || String(err)})`;
  }
}

export function CameraCapture() {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [on, setOn] = useState(true);
  const [facing, setFacing] = useState<Facing>('user');
  const [ready, setReady] = useState(false);
  const [error, setError] = useState('');
  const [photo, setPhoto] = useState<Photo | null>(null);

  useEffect(() => {
    if (!on) return;
    let cancelled = false;
    let stream: MediaStream | null = null;
    setError('');

    (async () => {
      // HTTPS(または localhost)でないページでは navigator.mediaDevices がない
      if (!navigator.mediaDevices?.getUserMedia) {
        setError('このページではカメラを使えません(HTTPS で開いてください)。');
        return;
      }
      try {
        const s = await navigator.mediaDevices.getUserMedia({
          video: { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } },
          audio: false,
        });
        if (cancelled) {
          // 待っている間に片付けが済んでいたら、遅れて届いたカメラをすぐ止める
          s.getTracks().forEach((t) => t.stop());
          return;
        }
        stream = s;
        if (videoRef.current) videoRef.current.srcObject = s;
      } catch (err) {
        if (!cancelled) setError(toMessage(err));
      }
    })();

    return () => {
      cancelled = true;
      stream?.getTracks().forEach((t) => t.stop()); // トラックを止めて、カメラを使い終える
      if (videoRef.current) videoRef.current.srcObject = null;
      setReady(false);
    };
  }, [on, facing]);

  // 撮った写真の URL は、次の写真に替わるときと画面から消えるときに解放する
  useEffect(() => () => { if (photo) URL.revokeObjectURL(photo.url); }, [photo]);

  function takePhoto() {
    const video = videoRef.current;
    if (!video || !video.videoWidth) return;
    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth; // 表示の大きさではなく、映像そのものの大きさ
    canvas.height = video.videoHeight;
    canvas.getContext('2d')!.drawImage(video, 0, 0);
    canvas.toBlob((blob) => {
      if (!blob) return setError('写真を画像にできませんでした。');
      setPhoto({ url: URL.createObjectURL(blob), blob, width: canvas.width, height: canvas.height });
    }, 'image/jpeg', 0.9);
  }

  return (
    <div className="camera">
      {on && (
        <video
          ref={videoRef}
          autoPlay
          playsInline
          muted
          onLoadedMetadata={() => setReady(true)}
          // 前面のカメラは鏡のように左右を反転して見せる(写真は反転しない)
          style={{ transform: facing === 'user' ? 'scaleX(-1)' : undefined }}
        />
      )}
      <p role="alert">{error}</p>
      <div className="buttons">
        <button type="button" onClick={takePhoto} disabled={!on || !ready}>撮影</button>
        <button type="button" onClick={() => setFacing((f) => (f === 'user' ? 'environment' : 'user'))} disabled={!on}>
          カメラを切り替える
        </button>
        <button type="button" onClick={() => setOn((v) => !v)}>{on ? 'カメラを止める' : 'カメラを使う'}</button>
      </div>
      {photo && (
        <figure>
          <img src={photo.url} width={photo.width} height={photo.height} alt="撮った写真" />
          <figcaption>
            {photo.width}×{photo.height}、{Math.round(photo.blob.size / 1024)}KB
            {' '}<a href={photo.url} download="photo.jpg">保存する</a>
          </figcaption>
        </figure>
      )}
    </div>
  );
}

カメラを開く処理は、on(カメラを使うかどうか)と facing(前面か背面か)に合わせて useEffect の中で行います。受け取ったストリームは、videoRef で取った <video> の srcObject に入れます。ストリームは URL ではないので、src ではなく srcObject を使います。

<video> には autoPlay・playsInline・muted を付けました。検証環境では、play() を呼ばなくても映像が再生され、0.5秒で currentTime が 0.07 から 0.53 に進みました。playsinline は、全画面にせず要素の中で再生させるための属性です。スマートフォンのブラウザでの効き方は試していません。

撮影ボタンは、loadedmetadata が発生して映像の大きさが分かるまで押せないようにしています。takePhoto() でも videoWidth が 0 のときは何もしません。

片付けでカメラを止めないと、使われたまま残る

カメラは、ストリームの中のトラックごとに stop() を呼んで止めます。止めたトラックの readyState は "live" から "ended" に変わります。上のコンポーネントでは、useEffect の片付けで止めています。

ただし、getUserMedia() は Promise を返すので、ストリームが届く前に片付けが走ることがあります。比べるために、届いたかどうかを確かめない次の書き方も試しました。

tsx
useEffect(() => {
  let stream: MediaStream | null = null;
  navigator.mediaDevices.getUserMedia({ video: true }).then((s) => {
    stream = s;
    if (videoRef.current) videoRef.current.srcObject = s;
  });
  return () => stream?.getTracks().forEach((t) => t.stop());
}, []);

開発用ビルドの StrictMode では、公式リファレンスにある StrictMode の説明のとおり、Effect の実行と片付けがもう1回ずつ余分に行われます。そのため getUserMedia() が2回呼ばれます。検証環境で、作られたトラックの readyState を調べた結果は次のとおりです。

書き方 表示したあと コンポーネントを外したあと
届いたかどうかを確かめない書き方 live, live live, ended
CameraCapture(cancelled で確かめる) ended, live ended, ended

確かめない書き方では、1回目の片付けはストリームが届く前に走るので、そのとき stream はまだ null です。あとから届いた1つ目のトラックはどこからも止められず、コンポーネントを外したあとも live のまま残りました。カメラを使い終えたつもりでも、このトラックはカメラを使い続けています。

CameraCapture では、片付けで cancelled を true にし、遅れて届いたストリームはその場で止めています。公式リファレンスにある useEffect でデータを取得する例で、ignore を使って古い結果を捨てているのと同じ考え方です。

この確かめ方は StrictMode のためだけのものではありません。ストリームが届く前に on や facing が変わったときも、前の getUserMedia() の結果が片付けのあとに届くことがあります(ボタンをすばやく続けて押す操作は試していません)。検証環境では、止める・もう一度使う・切り替える、と操作したあとのトラックは ended, ended, live で、使っているのは最後の1つだけでした。コンポーネントを外すと3つとも ended になりました。

写真は、映像そのものの大きさで描く

getUserMedia() には width: { ideal: 1280 }、height: { ideal: 720 } を渡しています。ただし ideal は「できればこの値」という指定で、その大きさが出せないカメラでは、ほかの大きさの映像が届きます。検証環境の偽のカメラは 640×360 の映像しか出せないので、届いた映像も 640×360 でした。

そのため takePhoto() では、canvas の大きさを決め打ちにせず、<video> の videoWidth・videoHeight から決めています。画面の <video> は CSS で横幅 320px に縮めて表示していますが、写真は映像そのものの 640×360 で描かれ、画質 0.9 の JPEG で 16KB でした。この大きさは、2色の塗りに文字だけを描いた検証用の映像での値です。

どうしても特定の大きさが必要なら、min や exact を使います。MDN によると、それを満たすカメラがないときは OverconstrainedError で失敗します。この場合は試していません。

写真の URL は URL.createObjectURL() で作り、次の写真に替わったときに useEffect の片付けで解放しています。検証では、2枚目を撮ったあとに1枚目の URL を読もうとすると失敗し、解放されていることを確かめました。「保存する」のリンクには download 属性を付けたので、押すと photo.jpg という名前で保存されました。

前面カメラの左右反転は、表示だけにしている

前面のカメラの映像をそのまま映すと、鏡とは左右が逆に見えます。鏡と同じ向きに見えるように、facing が 'user' のときだけ <video> に transform: scaleX(-1) を付けています。

CSS の反転は表示だけを変えるもので、drawImage() で描く映像は反転しません。左半分がピンク、右半分が水色の映像を偽のカメラに流して、色を調べました。

調べたもの 左側の色 右側の色
画面に見えている <video> 水色 ピンク
撮った写真 ピンク 水色

GIF でも、上のプレビューは「右」が左側に、下の写真は「左」が左側に写っています。写真を鏡と同じ向きで保存したいときは、描く前に canvas の座標を左右に反転します。

ts
const ctx = canvas.getContext('2d')!;
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(video, 0, 0);

検証用のスクリプトで同じ描き方を試したところ、左側が水色、右側がピンクになり、画面に見えている向きと同じになりました。どちらの向きで保存するかは、作るアプリしだいです。

「カメラを切り替える」を押すと、facingMode に 'environment'(背面)を渡して、もう一度 getUserMedia() を呼びます。背面のカメラでは反転しないので、transform は外れます。facingMode も ideal と同じく「できれば」の指定で、背面のカメラがなければほかのカメラが使われます。背面だけに限るなら facingMode: { exact: 'environment' } と書きます。MDN の getUserMedia() の前面と背面のカメラの例には、切り替える前に今のトラックを stop() しておくとよいと書かれています。CameraCapture では、facing が変わると片付けで前のトラックを止めてから、次のカメラを開きます。

カメラを使えないときの表示

getUserMedia() は、カメラを使えないときに DOMException で失敗します。toMessage() では、その name を見て表示する文を変えています。NotReadableError(OS やハードウェアの問題でカメラを使えないときなど)と OverconstrainedError の文は、MDN の getUserMedia() の例外の一覧をもとに用意したもので、この環境では起こせませんでした。検証環境で、使えない状況を作って確かめた結果は次のとおりです。

状況 起きたこと 画面の表示
カメラの使用をブロックした NotAllowedError で失敗 カメラの使用が許可されていません。ブラウザの設定で許可してください。
カメラがない NotFoundError で失敗 カメラが見つかりません。
HTTPS でも localhost でもない http:// のページ navigator.mediaDevices が undefined このページではカメラを使えません(HTTPS で開いてください)。

カメラの使用をブロックしたときは、撮影ボタンを押せないままでした。

注意

getUserMedia() は、HTTPS か localhost のページでしか使えません。MDN にあるとおり、それ以外のページでは navigator.mediaDevices が undefined になります。開発用のサーバーを http://192.0.2.10:5173 のように IP アドレスやホスト名で開くと、localhost では動いていたページでもカメラを使えません(検証環境では、localhost 以外のホスト名で開いて確かめました)。

MDN によると、この場合に navigator.mediaDevices.getUserMedia() をそのまま呼ぶと TypeError になります。CameraCapture では、先に navigator.mediaDevices?.getUserMedia があるかを確かめています。

​Posted by 86