前回の第8回:DataChannel でテキストチャットで、サーバーを通さないテキストチャットを追加しました。今回は、ビデオ通話でよく要望される「録画」を扱います。
「打ち合わせを録画して後で見返したい。ブラウザだけで録画ってできるの?それとも専用のサーバーが必要?」
結論から言うと、自分のパソコンに保存するだけなら、ブラウザ標準の MediaRecorder で録画できます。ただし、録画機能で本当に手間がかかるのは技術よりも、「相手に伝えて了承を得る」「録画中であることを全員が分かる」「ファイルをどこに、いつまで、誰が見られる状態で置くか」といった運用の部分です。今回は、ブラウザ内で完結する録画を作りながら、録画機能を発注する前に決めておきたいことを整理します。
今回作るもの:了承の確認と「録画中」表示つきのローカル録画
- 通話中に「録画する」ボタンを押すと、まず「相手に伝えて了承を得ましたか」という確認を表示する
- 確認後に録画を開始し、自分の画面に「● 録画中 00:12(1.3 MB)」、相手の画面に「● 相手が録画中」と表示する
- 録画するのは、相手の映像と、相手と自分の音声を混ぜたもの
- 「録画を停止して保存」、自分の退出、相手の退出のいずれかで録画を止め、ファイルをダウンロードする
- ブラウザが録画できる形式を
MediaRecorder.isTypeSupported()で判定する。サーバーには一切保存しない
追加・変更するファイル
| ファイル | 役割 |
|---|---|
| code/app/composables/useRecorder.ts | 形式の判定、音声の合成、録画・保存(新規) |
| code/app/components/RecordButton.vue | 録画ボタン・了承の確認・録画中表示(新規) |
| code/shared/types/signaling.ts / code/server/utils/signaling.ts | 状態(MediaState)に recording を追加 |
| code/app/components/RemoteVideo.vue | 「相手が録画中」の表示 |
| code/app/pages/room/[id].vue | 上記の組み込み、退出時の自動停止 |
仕組み:MediaRecorder と「録画できる形式」
MediaRecorder は、MediaStream(映像・音声のトラックの集まり)をファイルの形に記録するブラウザの API です。主な使い方は次のとおりです。
| API | 役割 |
|---|---|
MediaRecorder.isTypeSupported(type) | その形式(MIME タイプ)で録画できるかを true / false で返す |
new MediaRecorder(stream, { mimeType }) | 録画する器を作る |
start(timeslice) | 録画開始。timeslice(ミリ秒)ごとにデータを小分けにして渡す |
dataavailable イベント | 小分けのデータ(Blob)が届く |
stop() と stop イベント | 録画終了。エラーやストリームの終了で止まった場合も stop イベントが来る |
📰 出典:MDN「MediaRecorder」
録画できる形式はブラウザによって違います。MDN の互換性データ(執筆時点の2026年9月に確認)では、MediaRecorder 自体は Chrome・Edge・Firefox・Safari(PC・iOS とも)で使えるとされていますが、どの形式で書き出せるかは別の話です。そこで、候補を優先順に並べて isTypeSupported() で最初に通ったものを使います。
📰 出典:MDN「MediaRecorder: isTypeSupported() static method」
筆者の検証環境(Chromium)での結果は次のとおりでした。ほかのブラウザでは結果が変わるため、対応ブラウザごとに確認が必要です。
| 形式 | Chromium での結果 |
|---|---|
| video/webm;codecs=vp9,opus | true(今回はこれが選ばれた) |
| video/webm;codecs=vp8,opus | true |
| video/webm | true |
| video/mp4;codecs=avc1,mp4a.40.2 | false |
| video/mp4 | true |
なお仕様では、空文字を渡すと常に true(形式をブラウザ任せにする)とされています。
実装1:useRecorder で録画する
code/app/composables/useRecorder.ts(抜粋)
/** 優先順に試す形式。ブラウザによって録画できる形式が違うため isTypeSupported で確かめる */
const MIME_CANDIDATES = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
'video/mp4;codecs=avc1,mp4a.40.2',
'video/mp4',
]
export function useRecorder(getSources: () => RecordSources) {
/** 使える形式(空文字=ブラウザ任せ、null=MediaRecorder 自体が無い) */
const mimeType = ref<string | null>(null)
onMounted(() => {
if (typeof MediaRecorder === 'undefined') return
mimeType.value = MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) ?? ''
})
/** 録画を始める。AudioContext を使うため、ボタンのクリックなどユーザー操作から呼ぶ */
function start(): boolean {
const { remote, local } = getSources()
const videoTrack = remote?.getVideoTracks()[0]
if (!remote || !videoTrack) {
error.value = '相手とつながってから録画してください'
return false
}
// 2人分の音声を1本に混ぜる(音声トラックが複数あるストリームの扱いはブラウザによって異なるため、1本にまとめる)
const ctx = new AudioContext()
const mixed = ctx.createMediaStreamDestination()
for (const stream of [remote, local]) {
const tracks = stream?.getAudioTracks() ?? []
if (tracks.length > 0) ctx.createMediaStreamSource(new MediaStream(tracks)).connect(mixed)
}
// 録画専用のストリームを組み立てる。録画中に対象ストリームのトラックが増減すると
// 仕様上エラーで止まるため、相手のストリームをそのまま渡さない
const recordStream = new MediaStream([videoTrack, ...mixed.stream.getAudioTracks()])
const chunks: Blob[] = []
const rec = new MediaRecorder(recordStream, mimeType.value ? { mimeType: mimeType.value } : undefined)
rec.addEventListener('dataavailable', (e) => {
if (e.data.size === 0) return
chunks.push(e.data)
bytes.value += e.data.size
})
// stop() を呼んだ時も、エラーで止まった時も stop イベントが来る。ここで保存と後片付けをする
rec.addEventListener('stop', () => {
if (chunks.length > 0) download(new Blob(chunks, { type: rec.mimeType }), rec.mimeType)
cleanup()
})
rec.start(1000) // 1秒ごとに dataavailable(長時間でも途中までのデータを失いにくくする)
// …(状態の更新、経過時間のタイマー。new MediaRecorder の例外処理は省略)
return true
}
ポイントは3つです。
- 音声は Web Audio で1本に混ぜる:相手の音声トラックと自分のマイクのトラックを、AudioContext(ブラウザ内で音声を加工する仕組み)で1本にまとめてから録画します。自分がミュート中(第5回の
track.enabled = false)の間は、自分の音声は無音として録音されます。 - 録画専用のストリームを作る:W3C の仕様では、録画中に対象のストリームのトラックが追加・削除されると、録画は
InvalidModificationErrorで止まるとされています。相手のストリームを直接渡さず、「相手の映像トラック+混ぜた音声トラック」の新しいストリームを作っておけば、途中で構成が変わりません。第7回の画面共有で相手が映像を差し替えても、受信側のトラックは同じなので録画は続きます。 - 保存は stop イベントでまとめて:自分で止めた場合もエラーで止まった場合も stop イベントが来るので、ここで Blob(ファイルのデータ)を作ってダウンロードさせます。
📰 出典:W3C「MediaStream Recording」
ダウンロードは、Blob から一時的な URL を作り、download 属性付きのリンクをクリックする方法です。サーバーには送らず、利用者のパソコンの「ダウンロード」フォルダに recording-日付-時刻.webm として保存されます。
code/app/composables/useRecorder.ts(ダウンロード部分)
function download(blob: Blob, type: string) {
const ext = type.startsWith('video/mp4') ? 'mp4' : 'webm'
// …(stamp = 20260912-151000 のような日時の文字列を作る。省略)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `recording-${stamp}.${ext}`
a.click()
// すぐ解放するとダウンロードが始まらないことがあるため、少し待つ
setTimeout(() => URL.revokeObjectURL(url), 10_000)
}
実装2:了承の確認と「録画中」の表示
録画ボタンは、押してすぐ録画を始めるのではなく、確認の手順を1つ挟みます。
code/app/components/RecordButton.vue(template の抜粋)
<div v-else-if="confirming" class="record__confirm" role="dialog" aria-label="録画の確認">
<p>
録画を始める前に、<strong>相手に録画することと、その目的・保存方法を伝え、了承を得てください</strong>。
録画中は相手の画面にも「録画中」と表示されます。録画ファイルはこのパソコンにだけ保存されます。
</p>
<div class="record__actions">
<button type="button" class="record__primary" @click="confirmStart">
了承を得たので録画を開始する
</button>
<button type="button" @click="confirming = false">
やめる
</button>
</div>
</div>
この確認は、あくまで「録画する人に手順を思い出してもらう」ためのものです。相手が同意したことをシステムが記録・証明する仕組みではありません。同意の記録が必要な業務では、相手側に「同意する」ボタンを出して、その結果を保存するといった別の設計が必要になります。
相手への通知は、第5回で作った状態の通知(media-state)に recording を追加して送ります。
code/shared/types/signaling.ts(変更部分)
export type MediaState = {
audio: boolean
video: boolean
screen: boolean
/** 第9回: 録画中か(相手の画面に「録画中」と表示するため) */
recording: boolean
}
サーバー側の検証(server/utils/signaling.ts の isMediaState と、中継する値の詰め直し)にも recording を追加しています。受け取った側は、RemoteVideo に「● 相手が録画中」のバッジを出します。
code/app/components/RemoteVideo.vue(追加部分)
<span v-if="mediaState.recording" class="remote__badge remote__badge--rec" role="status">● 相手が録画中</span>
ルームページでは、録画の状態を送信する状態に加え、相手がいなくなったとき・自分が退出するときに録画を止めて保存します。
code/app/pages/room/[id].vue(追加部分)
const recorder = useRecorder(() => ({ remote: remoteStream.value, local: localStream.value }))
const { remoteState } = useRemoteMediaState(
signaling,
() => ({ audio: audioEnabled.value, video: videoEnabled.value, screen: isSharing.value, recording: recorder.isRecording.value }),
remotePeerId,
)
// 第9回: 相手が退出した(相手の映像が無くなった)ら録画を止めて、そこまでを保存する
watch(remoteStream, (stream) => {
if (!stream) recorder.stop()
})
function leave() {
recorder.stop() // 録画中なら止めて保存する
// …(第7回までの退出処理)
}
動作確認の方法
npm run devで起動し、PC の2つのウィンドウで同じルームに入る- 片方で「録画する」→ 確認画面 →「了承を得たので録画を開始する」。もう片方に「● 相手が録画中」が出ることを確認する
- しばらく話してから「録画を停止して保存」→ ファイルがダウンロードされ、再生できることを確認する
- 録画中に相手が退出したとき、自分が退出したときも、ファイルが保存されることを確認する
- Chrome・Firefox・Safari で「形式:」の表示(isTypeSupported で選ばれた形式)を記録しておく
筆者の環境では、Playwright で Chromium を操作し、テスト用のカメラ・マイクで次の点を確認しました。
| 確認したこと | 結果 |
|---|---|
| 相手がいない間 | 「録画する」ボタンは無効 |
| 確認画面 | 「やめる」で閉じる。了承ボタンを押すまで相手に録画中は表示されない |
| 録画中の表示 | 自分側に経過時間とデータ量、相手側に「相手が録画中」。停止すると消える |
| 停止して保存 | webm(VP9 映像・Opus 音声)がダウンロードされ、Chromium で再生でき、音声も入っている |
| 相手の退出・自分の退出 | どちらも自動で停止し、そこまでのファイルが保存される |
| 相手がミュートした状態での録画 | エラーにならず保存される |
| 録画中に相手が画面共有を開始・停止 | 録画は止まらず続き、停止後に保存される |
一方、Chromium で作った webm の1本は、読み込み直後の再生時間が「不明(Infinity)」になりました。最後まで読み込むと正しい長さ(約5秒)になりますが、プレイヤーによってはシーク(再生位置の移動)がしにくいことがあります。社内配布などで扱いやすい形式が必要な場合は、変換の工程を検討してください。Firefox・Safari・スマホでの形式と再生、2人の声が正しく混ざって聞こえるかの聞き分け、長時間録画でのメモリ使用量は、この環境では確認していません。npm run build と npm run typecheck は通っています。
つまずきやすい点とセキュリティ上の注意
- 録画データはメモリにたまる:今回の方法では、停止するまでデータをブラウザのメモリに持ちます。長時間の録画ではメモリを圧迫し、タブが落ちるとそれまでの録画は失われます。長時間の録画が要件なら、途中で分割保存する、サーバー側で録画する、といった設計が必要です。
- 録画できるのは「自分のブラウザに届いた映像」だけ:相手の回線が悪く画質が落ちていれば、録画も同じ画質です。
- 録画を止められる・隠せる:ブラウザの録画はその人のパソコンで行われるため、画面の「録画中」表示は「このアプリで録画している」ことしか示しません。OS の画面録画など、アプリの外での録画は検知できません。
- ファイルの行き先は利用者任せ:ダウンロードしたファイルは、利用者のパソコンに残ります。紛失・持ち出し・共有のルールは、システムではなく運用で決める必要があります。
同意と通知について(一般的な注意)
録画には、参加者の顔や声、画面に映った資料などが含まれます。個人情報として扱う必要が出る場合もあり、利用目的の伝え方や保存・削除の方法など、検討すべき点が多くあります。一般的には、録画することと目的を事前に伝える、録画中であることを全員に分かるように表示する、保存期間と閲覧できる人を決めておく、といった配慮が求められます。具体的に何が必要かは、業種や用途、契約によって異なるため、社内の法務担当や弁護士などの専門家に確認してください。個人情報保護法の法令・ガイドラインは、個人情報保護委員会のサイトで公開されています。
発注者向けメモ:録画は「保存・管理」が本体
「録画ボタンを付けてほしい」という要望は、今回のように各自のパソコンに保存するだけなら比較的小さな追加で済みます。一方で、サーバーに録画を保存して共有したい、参加者全員分の映像を1本にまとめたい、録画を自動で残したいとなると、第11回で扱う SFU(映像を中継するサーバー)などのサーバー側の仕組み、保存領域、アクセス権限、保存期間の管理が必要になり、開発と運用の規模が大きく変わります。
- ☐ 誰が録画できるか(全員か、主催者だけか)が決まっているか
- ☐ 録画の保存先(各自のパソコンか、サーバーか)と、保存期間・削除の方法が決まっているか
- ☐ 録画することを参加者にどう伝え、了承をどう確認するか(記録が必要か)が決まっているか
- ☐ 録画中であることが、全参加者に分かる表示になっているか
- ☐ 対応するブラウザで、録画できる形式と再生できる環境を確認するか
- ☐ 録画データの取り扱いについて、社内の法務担当や専門家に確認する予定があるか
開発会社への質問例:
- 「録画ファイルはどこに保存されますか?サーバーに保存される場合、誰が見られて、いつ消えますか?」
- 「録画中であることは、相手の画面にどう表示されますか?」
- 「長時間(例えば2時間)の録画で、ブラウザが落ちたときはどうなりますか?」
- 「Safari やスマホでも録画できますか?できたファイルはどのソフトで再生できますか?」
まとめと次回予告
- ブラウザ標準の MediaRecorder で、サーバーなしのローカル録画ができる
- 録画できる形式はブラウザ次第なので、
isTypeSupported()で候補から選ぶ - 音声は Web Audio で1本に混ぜ、録画専用のストリームを作ると途中で止まりにくい
- 了承の確認と「録画中」の表示はアプリで用意できるが、同意の記録やファイル管理は運用・別設計の領域
- サーバー録画や全員分の合成が要件なら、SFU などを前提に見積もりが変わる
次回(第10回)は「切断・再接続とエラーハンドリング」です。Wi-Fi の切り替えやサーバーの再起動で通話が切れたときに、自動で復帰する仕組みを作ります。
この連載の記事一覧
この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【NuxtとWebRTCで作るビデオチャット 第0回】Nuxt 4でビデオチャットの土台を作る:なぜHTTPSが必要なのか
- 【NuxtとWebRTCで作るビデオチャット 第1回】カメラとマイクを取得してプレビューし、デバイスを選べるようにする
- 【NuxtとWebRTCで作るビデオチャット 第2回】Nitro の WebSocket でシグナリングサーバーを作る
- 【NuxtとWebRTCで作るビデオチャット 第3回】RTCPeerConnection で1対1通話をつなぐ(offer/answer/ICE)
- 【NuxtとWebRTCで作るビデオチャット 第4回】ルーム作成と招待リンク、入室前プレビュー
- 【NuxtとWebRTCで作るビデオチャット 第5回】ミュートとカメラオフ
- 【NuxtとWebRTCで作るビデオチャット 第6回】STUN/TURN と本番デプロイ:「社外とつながらない」を解消する
- 【NuxtとWebRTCで作るビデオチャット 第7回】画面共有
- 【NuxtとWebRTCで作るビデオチャット 第8回】DataChannel でテキストチャット
- 【NuxtとWebRTCで作るビデオチャット 第9回】MediaRecorder で録画する前に考えること(この記事)
- 【NuxtとWebRTCで作るビデオチャット 第10回】切断・再接続とエラーハンドリング
- 【NuxtとWebRTCで作るビデオチャット 第11回】少人数通話へ:メッシュ実装と SFU という選択肢










コメント
コメント一覧 (2件)
[…] 前回の第9回:MediaRecorder で録画する前に考えることで、ブラウザ内で完結する録画を作りました。今回は、実際の運用で必ず起きる「通話が切れた」への対応です。 […]
[…] MediaRecorder で録画する前に考えること […]