ブラウザから直接カメラを起動して写真を撮るWebアプリを作っているフロントエンドエンジニアに向けた内容です。getUserMediaというカメラ・マイクにアクセスするためのブラウザAPIを使う場面で、開発環境では動くのに本番で動かないという落とし穴を整理します。
題材にするのは、Hacktoberfest(10月に開催されるオープンソース貢献を促すイベント)向けに公開されたNatureSnap AIというアプリです。屋外で見つけた葉や花、昆虫をスマホのカメラで撮影すると、Googleのオープンウェイトモデル(重みが公開され誰でも利用・改変できるAIモデル)であるGemmaが種類を推定し、観察ミッションを提示する仕組みを持っています。このアプリはWebRTC(ブラウザ間でリアルタイムに映像・音声をやり取りする技術)のカメラ機能と、カメラ切り替え、ファイルアップロード時の画像圧縮を組み合わせています。この構成は多くのWebカメラアプリに共通する設計で、同じ落とし穴にはまりやすいポイントが詰まっています。
何が起きるか
開発中のPCブラウザでは問題なく動くカメラ機能が、スマホの実機やSafariで急に動かなくなる現象です。
具体的には「カメラへのアクセスが拒否されました」というエラーが出たり、映像が真っ黒のまま固まったりします。
NatureSnap AIのようにライブカメラ撮影とファイルアップロードの両方を用意しているアプリでは、撮影機能だけが死んでアップロード機能は生き残るという中途半端な壊れ方をすることもあります。ユーザーから見ると「カメラボタンを押しても何も起きない」というだけの不具合に見えますが、原因はブラウザのセキュリティポリシーに起因することがほとんどです。
なぜ起きるか
原因は大きく3段階に分けて考えると整理しやすくなります。
1つ目は通信プロトコルの問題です。getUserMediaはセキュアコンテキスト(HTTPSまたはlocalhostでの通信)でしか動作しません。開発中はlocalhostなので気づかず、本番でHTTP配信のままデプロイすると突然動かなくなります。
2つ目はブラウザごとの権限モデルの違いです。Safariはカメラ・マイクへのアクセス許可の扱いがChromeより厳格で、iframeの中からの呼び出しにはallow属性で明示的にcamera権限を渡す必要があります。たとえばアプリをiframeで埋め込んで配信している場合、親ページ側で許可していてもiframe側の属性指定が抜けていると映像が取得できません。
3つ目はPermissions Policy(旧Feature Policy、ブラウザ機能の利用可否をHTTPヘッダーで制御する仕組み)の設定です。CDNやリバースプロキシの設定でcamera=()のようにカメラ利用を明示的に禁止するヘッダーが付与されていると、アプリ側のコードが正しくてもブラウザレベルで拒否されます。Cloudflareやnginxの設定を流用した際に、意図せずこのヘッダーが残っているケースがあります。
これら3段階が独立して存在するため、1つ直してもまだ動かない、ということが起こりやすい構造になっています。
自分のプロジェクトが該当するか確認する方法
まず配信プロトコルを確認します。
curl -I https://your-app-domain.example.com | grep -i content-security-policy
curl -I https://your-app-domain.example.com | grep -i permissions-policyPermissions-Policyヘッダーにcamera=()やmicrophone=()が含まれていないかをチェックします。何も表示されなければヘッダー自体が未設定で、ブラウザのデフォルト挙動に従います。
次にHTTPS化を確認します。ブラウザのアドレスバーで鍵マークが表示されているか、開発者ツールのNetworkタブでプロトコルがhttp/2やh3になっているかを見ます。HTTPのままであれば、getUserMediaは呼び出し自体が失敗します。
iframe埋め込みをしている場合は、HTMLソースを確認します。
grep -r "iframe" ./src --include="*.html" --include="*.tsx" --include="*.jsx"該当箇所のallow属性にcamera; microphoneが含まれているかを目視で確認します。含まれていなければここが原因です。
ブラウザ側の対応状況はCan I useのgetUserMediaのページで確認できます。特にSafariのiOS版は権限ダイアログの挙動がバージョンごとに変わることがあるため、ターゲットにしているiOSバージョンでの挙動を事前に把握しておくと安心です。
対策の手順
1. HTTPS配信を徹底する
VercelやNetlifyなど主要なホスティングサービスはデフォルトでHTTPSが有効ですが、独自ドメインを設定した際に証明書の発行が反映されるまでタイムラグが生じることがあります。デプロイ後は必ずcurlでステータスとプロトコルを確認します。
2. iframe配信ならallow属性を明示する
<iframe
src="https://your-app-domain.example.com"
allow="camera; microphone; geolocation"
></iframe>NatureSnap AIのように位置情報(Optional Coarse Location)も使う場合は、geolocationも併記しておきます。
3. Permissions Policyヘッダーを明示的に許可する
nginxであれば以下のように設定します。
add_header Permissions-Policy "camera=(self), microphone=(self)";CDNやプロキシ側の設定テンプレートを流用した場合は、デフォルト値がcamera=()になっていないかを必ず見直します。
4. getUserMediaのエラーハンドリングを分岐させる
エラーの種類によってユーザーへの案内文を変えると、問い合わせ対応が減ります。
navigator.mediaDevices.getUserMedia({ video: true })
.catch((err) => {
if (err.name === "NotAllowedError") {
// 権限拒否: 設定アプリでの許可手順を案内
} else if (err.name === "NotFoundError") {
// カメラデバイスなし: ファイルアップロードへ誘導
}
});NatureSnap AIがライブカメラとファイルアップロードの両方を用意しているのは、まさにこの分岐の受け皿として理にかなった設計です。カメラが使えない環境でもアップロード経由で同じ体験を提供できます。
5. 代替手段としてのcaptureタグ
カメラAPIの実装コストを下げたい場合、HTML標準のinput要素にcapture属性を付ける方法もあります。
<input type="file" accept="image/*" capture="environment">これはWebRTCほど柔軟な制御はできませんが、モバイルブラウザでネイティブのカメラアプリを起動できるため、権限モデルの差異に悩まされにくい選択肢です。プロトタイプ段階や、カメラ切り替え・リアルタイムプレビューが不要な用途であれば十分検討に値します。
まとめ
WebRTCのカメラ機能がSafariや実機で動かない原因は、HTTPS化・iframeのallow属性・Permissions Policyヘッダーの3つに集約されます。
まず本番URLに対してcurlでヘッダーを確認し、次にiframe埋め込みの有無とallow属性を目視でチェックする、という順番で切り分けると原因箇所を絞り込みやすくなります。
カメラ機能が必須でない用途であれば、input要素のcapture属性という選択肢も用意しておくと、権限まわりのトラブルシューティングに時間を取られすぎずに済みます。
デプロイ前のチェックリストにこの3項目を加えておくと、リリース後の「カメラが動かない」という問い合わせを事前に減らせます。