Webカメラの映像を取得して表示する

WebView の getUserMedia() でカメラの映像を video 要素に映し、カメラの選択と静止画の取り出しまで行う。macOS の Info.plist の説明文とエンタイトルメント、OS ごとの許可の違いも示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約9分 hw-011
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 映像を video 要素に映す
  4. カメラを選ぶ・切り替える
  5. 静止画を取り出す
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

ビデオ通話、バーコードの読み取り、証明写真の撮影など、カメラの映像を画面に出すなら WebView 標準の getUserMedia() で取得して <video> に映します。Tauri のプラグインや Rust のコードは要らず、capability の追加も不要です。その代わり OS ごとのカメラの許可に合わせた準備が要り、特に macOS は Info.plist に使う理由を書かないとカメラを使えません。マイクの録音は マイクから音声を録音する で扱います。

前提条件

getUserMedia() は Tauri のコマンドを通らないので、権限は core:default のままで構いません。

macOS では、カメラを使う理由を src-tauri/Info.plist に書きます。許可ダイアログにこの文が表示されます。このファイルはビルド時に既定の Info.plist にマージされ、tauri dev の実行ファイルにも埋め込まれます(tauri dev で読まれるのはこの場所のファイルだけです)。音声も取るなら NSMicrophoneUsageDescription も並べます。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSCameraUsageDescription</key>
  <string>書類に添付する写真を撮るためにカメラを使います</string>
</dict>
</plist>

署名して配布する場合は bundle.macOS.hardenedRuntime が既定で true なので、エンタイトルメントのファイル(例では src-tauri/Entitlements.plist)に com.apple.security.device.camera を入れ、bundle.macOS.entitlements で指定します。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.security.device.camera</key>
  <true/>
</dict>
</plist>
{
  "bundle": {
    "macOS": {
      "entitlements": "./Entitlements.plist"
    }
  }
}

OS ごとに必要な準備は次のとおりです。

OS準備初回の確認
Windows設定の「プライバシーとセキュリティ」→「カメラ」で、デスクトップアプリからのアクセスをオンWebView の確認が出ることがある
macOSInfo.plist の説明文、署名するならエンタイトルメントOS のダイアログ(説明文が表示される)
Linux既定のままでは使えない場合がある―

1. フロントエンドから実装する (TypeScript)

映像を video 要素に映す

muted を付けた <video> は、ユーザーの操作なしで再生を始められます。

<video id="preview" autoplay playsinline muted></video>
<select id="camera"></select>

getUserMedia() の width / height に ideal を付けると「できればこの解像度」という指定になり、対応していないカメラでも近い値で開けます。exact にすると、合わないときに失敗します。使い終わったらトラックを stop() します。止めないとカメラを使ったままになり、カメラのランプも消えません。

const video = document.querySelector<HTMLVideoElement>('#preview')!;
let stream: MediaStream | null = null;

// カメラを止めて解放する
export function stopCamera(): void {
  stream?.getTracks().forEach((t) => t.stop());
  stream = null;
  video.srcObject = null;
}

// deviceId を省略すると既定のカメラ。戻り値で実際の解像度が分かる
export async function startCamera(deviceId?: string): Promise<MediaTrackSettings> {
  if (!navigator.mediaDevices?.getUserMedia) throw new Error('getUserMedia が使えません');
  stopCamera(); // 切り替えるときは先に止める(同じカメラを二重に開けない機種がある)
  stream = await navigator.mediaDevices.getUserMedia({
    video: {
      deviceId: deviceId ? { exact: deviceId } : undefined,
      width: { ideal: 1280 },
      height: { ideal: 720 },
    },
    audio: false,
  });
  video.srcObject = stream;
  await video.play();
  return stream.getVideoTracks()[0].getSettings();
}

// 失敗の理由(DOMException の name)を画面向けの文にする
export function describeCameraError(e: unknown): string {
  const name = typeof e === 'object' && e !== null && 'name' in e ? String(e.name) : '';
  switch (name) {
    case 'NotAllowedError': return 'カメラの使用が許可されていません';
    case 'NotFoundError': return 'カメラが見つかりません';
    case 'NotReadableError': return 'カメラを開けません。ほかのアプリが使っている可能性があります';
    case 'OverconstrainedError': return '指定した条件に合うカメラがありません';
    default: return String(e);
  }
}

カメラを選ぶ・切り替える

enumerateDevices() は、カメラの使用を許可される前に呼ぶと名前(label)が空になり、台数も正しく分からないことがあります。最初に既定のカメラで startCamera() してから一覧を作ります。選んだカメラは deviceId で保存し、次回はそれで開きます。外されていると exact の指定で失敗するので、既定のカメラに戻します。

// (続き)
const select = document.querySelector<HTMLSelectElement>('#camera')!;
const KEY = 'camera-id';

export async function fillCameraList(): Promise<void> {
  const cams = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'videoinput');
  select.replaceChildren(...cams.map((c, i) => new Option(c.label || `カメラ ${i + 1}`, c.deviceId)));
  const current = stream?.getVideoTracks()[0]?.getSettings().deviceId;
  if (current) select.value = current;
}

select.addEventListener('change', async () => {
  localStorage.setItem(KEY, select.value);
  await startCamera(select.value);
});
// USB カメラの抜き差しで一覧を作り直す
navigator.mediaDevices.addEventListener('devicechange', () => void fillCameraList());

// 起動時: 前回のカメラ、だめなら既定のカメラで開く
export async function initCamera(): Promise<void> {
  const saved = localStorage.getItem(KEY) ?? undefined;
  try {
    await startCamera(saved);
  } catch (e) {
    if (!saved) throw e;
    await startCamera(); // 前回のカメラが外されている
  }
  await fillCameraList();
}

静止画を取り出す

今の 1 コマを canvas に描いて PNG にします。canvas の大きさは表示サイズではなく videoWidth / videoHeight(実際の解像度)に合わせます。自分の顔を映すときは CSS の transform: scaleX(-1) で鏡像にすることが多いですが、CSS は保存する画像には効かないので、そろえたいなら描くときにも反転します。

// (続き)
export async function captureFrame(mirror = false): Promise<Uint8Array> {
  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  const ctx = canvas.getContext('2d');
  if (!ctx || canvas.width === 0) throw new Error('映像がまだ届いていません');
  if (mirror) {
    ctx.translate(canvas.width, 0);
    ctx.scale(-1, 1);
  }
  ctx.drawImage(video, 0, 0);
  const blob = await new Promise<Blob>((resolve, reject) =>
    canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob に失敗しました'))), 'image/png'),
  );
  return new Uint8Array(await blob.arrayBuffer());
}

できたバイト列は バイナリデータをファイルに保存する の writeFile() で保存し、保存先をユーザーに選ばせるなら ファイルを保存する場所を選ばせる と組み合わせます。撮った写真を紙に出すなら、<img> に表示して window.print() するのが手軽です(プリンターの一覧を取得して印刷する)。

動作確認

npm run tauri dev で起動して initCamera() を呼ぶと、プレビューに映像が出てカメラのランプが点きます。macOS では初回に OS の許可ダイアログが出ます。コンソールで await startCamera() の戻り値を見ると、実際に開いた解像度(width / height)が分かります。stopCamera() を呼ぶとランプが消えます。

よくあるエラーと対処法

  • NotAllowedError: OS か WebView で拒否されています。Windows はプライバシーの設定を確かめます。macOS は一度拒否すると二度とダイアログが出ないので、システム設定の「プライバシーとセキュリティ」→「カメラ」で許可し直します。Info.plist の説明文が無いときも使えません。
  • navigator.mediaDevices が undefined: getUserMedia() は安全な場所(セキュアコンテキスト)から読み込んだページでしか使えません。開発時に devUrl を http://192.168.x.x:1420 のような IP アドレスにしていると起きます。localhost なら使えます。
  • NotReadableError: ほかのアプリがカメラを使っているか、切り替え時に前のストリームを止めていません。
  • OverconstrainedError: exact で指定したカメラや解像度がありません。ideal にするか、既定のカメラで開き直します。
  • カメラの名前が空・1 台しか出ない: 許可を得る前に enumerateDevices() を呼んでいます。

OS ごとの違いと注意点

  • Windows: OS のプライバシー設定でデスクトップアプリのカメラがオフだと使えません。初回などに WebView の確認が出ることがあります。
  • macOS: Info.plist の説明文が必須で、署名するならエンタイトルメントも要ります(前提条件を参照)。
  • Linux: WebView の設定によっては、既定のままでは getUserMedia() が使えない場合があります。配布先で動くかを早めに確かめます。
  • ウィンドウを隠す: ウィンドウを隠す・トレイに格納するだけではカメラは解放されません。見えないあいだは stopCamera() で止めます。
  • Rust で映像を処理したい: 画像認識などを Rust で行うなら、WebView で取った静止画をバイト列のまま送るのが簡単です(バイナリデータをファイルに保存する の Rust の例)。
  • マイク: audio: true で音声も取れます。録音して保存する方法と、Rust の cpal との使い分けは マイクから音声を録音する を参照してください。出力先のスピーカーを選ぶなら 音声の出力スピーカーを切り替える です。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する