前回の第10回:切断・再接続とエラーハンドリングで、サーバーの再起動や経路の切断から自動で戻る仕組みを作りました。連載の最終回となる今回は、1対1の通話を 3〜4人の少人数通話 に広げます。あわせて、人数が増えたときに検討する SFU という選択肢と、この連載のサンプルを本番で使うまでに残っている作業を整理します。
「1対1で動いたなら、人数の上限を増やせば10人でも使えるんじゃないの?」
結論から言うと、今回のメッシュ構成は4人程度までが現実的です。メッシュは参加者全員と個別につながる方式で、追加のサーバーが要らない代わりに、人数が増えるほど各端末が送る映像の本数と通信量が増えていきます。実際に測ると、送信量は「人数−1」に比例して増えました。それ以上の人数や、サーバー側での録画・配信が必要なら、SFU(映像を中継するサーバー)を前提に設計と見積もりを見直すことになります。
今回作るもの:最大4人のメッシュ通話
- ルームの定員を2名から4名にする
- 参加者ごとに RTCPeerConnection を持ち、全員とつながる(メッシュ構成)
- 参加者の映像をグリッド(格子状)に並べ、各枠に名前と接続状態を表示する
- チャット・ミュート表示・画面共有を全員向けに対応させる。録画は2人で通話中のときだけにする
追加・変更するファイル
| ファイル | 役割 |
|---|---|
| code/app/composables/usePeerConnection.ts | 1対1用の処理を、相手1人分の接続を作る createPeerLink() に作り替え |
| code/app/composables/useMeshRoom.ts | 参加者ごとの接続の管理(新規) |
| code/app/components/VideoGrid.vue | 映像のグリッド表示(新規) |
| code/shared/types/signaling.ts / code/server/routes/_ws.ts / code/server/utils/signaling.ts | offer/answer・ICE 候補に宛先(to)を追加し、宛先の1人にだけ送る |
| code/server/utils/rooms.ts | 定員を4名に |
| code/app/composables/useDataChannel.ts ほか | チャット・状態表示・ルームページを複数の相手に対応 |
仕組み:メッシュ・SFU・MCU の違い
複数人のビデオ通話には、大きく3つの構成があります。
| 構成 | 仕組み | 各端末の送信 | サーバー | 向いている場面 |
|---|---|---|---|---|
| メッシュ | 全員が全員と直接つながる | 人数−1本 | シグナリング(と TURN)だけ | 少人数。追加のサーバーを置きたくない |
| SFU | 各端末は1本送り、サーバーが他の参加者へ転送する | 1本(品質違いを複数送る方式もある) | 映像を中継するサーバーが必要 | 多人数、サーバー録画、配信 |
| MCU | サーバーが全員の映像を合成して1本にして配る | 1本 | 映像の合成を行う高性能なサーバーが必要 | 受信側の端末の負荷を下げたい場合など |
メッシュでは、4人なら1人あたり3本の接続を持ち、映像・音声を3本送って3本受け取ります。ルーム全体の接続数は「人数×(人数−1)÷2」で、4人なら6本、6人なら15本と、人数の2乗に近い形で増えます。送信は端末の上り回線とCPU(映像の圧縮)を使うため、スマホや上りの細い回線から先に苦しくなります。
実装1:シグナリングに「宛先」を付ける
1対1では、offer/answer と ICE 候補をルームの「自分以外」に送れば、相手に届いていました。3人以上では、Aさんが Bさん向けに作った offer が Cさんにも届いてしまうため、宛先(to)を付けてサーバーがその1人にだけ送るようにします。
code/shared/types/signaling.ts(変更部分)
// 第11回: 3人以上では「誰宛てか」が必要なので to(宛先のピアID)を追加。サーバーは宛先にだけ送る
| { type: 'description'; to: PeerId; description: SessionDescription }
| { type: 'ice-candidate'; to: PeerId; candidate: IceCandidate | null }
| { type: 'media-state'; state: MediaState } // 第5回(ルーム全員に送る)
code/server/routes/_ws.ts(変更部分)
// 第11回: 宛先が同じルームにいる場合だけ、その1人に送る(別のルームの人には送らせない)
const target = connectedPeers.get(msg.to)
if (!target || getRoomOf(msg.to) !== roomId) {
sendTo(peer, { type: 'error', code: 'unknown-peer', message: '宛先の参加者が見つかりません' })
return
}
if (msg.type === 'description') sendTo(target, { type: 'description', from: peer.id, description: msg.description })
else sendTo(target, { type: 'ice-candidate', from: peer.id, candidate: msg.candidate })
接続中のピアを ID で引けるよう、open で Map に登録し、close で削除しています。宛先が同じルームにいるかを必ず確認するのがポイントです。確認しないと、ほかのルームの参加者の ID を知っている人が、そのルームに offer を送り込めてしまいます。定員は server/utils/rooms.ts の ROOM_CAPACITY を 4 にするだけです。
実装2:相手1人分の接続を createPeerLink に切り出す
第3回から育ててきた usePeerConnection は、「相手は1人」という前提で、接続を1つだけ持っていました。中身(Perfect negotiation、ICE restart、DataChannel、映像の差し替え)はそのままに、相手1人分の接続を作る関数 createPeerLink() に作り替えます。
code/app/composables/usePeerConnection.ts(抜粋)
export function createPeerLink(peerId: PeerId, polite: boolean, options: PeerLinkOptions) {
const { send, getLocalStream, getRtcConfig, getVideoOverride } = options
const remoteStream = shallowRef<MediaStream | null>(null)
const connectionState = ref<RTCPeerConnectionState>('new')
const routeType = ref<RTCIceCandidateType | null>(null)
const chatChannel = shallowRef<RTCDataChannel | null>(null)
const pc = new RTCPeerConnection(getRtcConfig())
function sendDescription(description: RTCSessionDescription | null) {
if (description) send({ type: 'description', to: peerId, description: description.toJSON() })
}
// …(addLocalTracks、negotiationneeded、icecandidate、ICE restart、handleSignal は第3〜10回と同じ。
// 送信時に to: peerId を付けるところだけが変わった)
return { peerId, remoteStream, connectionState, routeType, chatChannel, handleSignal, replaceVideoTrack, close }
}
実装3:useMeshRoom で全員とつながる
useMeshRoom は、参加者ごとの接続(PeerLink)を一覧で持ち、シグナリングのメッセージを該当する接続に振り分けます。
code/app/composables/useMeshRoom.ts(抜粋)
signaling.onMessage((msg) => {
switch (msg.type) {
case 'joined':
// 入室(再接続で入り直した場合も含む)。以前の接続は捨てて、先にいた全員とつなぐ。
// 後から入った自分は全員に対して polite(自分から offer を送る側)
closeAll()
msg.peers.forEach(peerId => add(peerId, true))
break
case 'peer-joined':
// 後から入ってきた人とは、先にいた自分が impolite(相手の offer を待つ側)
add(msg.peerId, false)
break
case 'peer-left':
remove(msg.peerId)
break
case 'description':
case 'ice-candidate': {
// 念のため、peer-joined より先に届いた場合も接続を用意する
const link = find(msg.from) ?? add(msg.from, false)
void link.handleSignal(msg)
break
}
}
})
/** 第7回: 送る映像を全員分まとめて差し替える(画面共有の開始・終了) */
async function replaceVideoTrack(track: MediaStreamTrack | null) {
videoOverride = track
await Promise.all(links.value.map(link => link.replaceVideoTrack(track)))
}
第4回で決めた「後から入った側が offer を送る」ルールを、そのまま人数分に広げています。新しく入った人は、先にいた全員に offer を送り、先にいた人たちはそれを待って応じます。これで、どの2人の組み合わせでも offer が衝突しにくくなります。
チャット・状態表示・録画の扱い
- チャット:相手ごとの DataChannel をまとめて扱い、全員に同じ内容を送ります。送信者名(名前入力は省略し「参加者 1a2b」のようにピアIDの先頭4文字)を表示し、参加者の出入りがあっても履歴は残すようにしました。
- ミュート・画面共有の表示:相手の状態を、ピアIDごとの一覧で持つように変えました。
- 録画:3人以上の映像を1ファイルにまとめるにはブラウザ内での合成が必要で、端末の負荷がさらに増えます。録画は2人で通話中のときだけ有効にし、3人目が入ったら自動で止めて保存します。
実装4:VideoGrid で映像を並べる
code/app/components/VideoGrid.vue(template の抜粋)
<div class="grid" :style="{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }">
<div v-for="tile in tiles" :key="tile.peerId" class="grid__tile" :data-state="tile.connectionState">
<RemoteVideo :stream="tile.stream" :media-state="tile.mediaState" />
<span class="grid__caption">{{ caption(tile) }}</span>
</div>
<div class="grid__tile grid__tile--self">
<LocalPreview :stream="localStream" :video-off="localVideoOff" :mirror="localMirror" />
<span class="grid__caption">自分</span>
</div>
</div>
相手の枠は RemoteVideo、自分の枠は LocalPreview をそのまま使い、各枠に名前と接続状態を表示します。画面上部の ConnectionStatus は「接続しました(自分を含めて4人で通話中)」のように全体をまとめて表示します。
動作確認:人数による通信量と負荷の増え方
Playwright で Chromium の独立した4つのウィンドウ(テスト用のカメラ・マイク)を同じルームに順番に入れ、次の点を確認しました。
| 確認したこと | 結果 |
|---|---|
| 2人・3人・4人での接続 | 全員が「接続しました」になり、各自のグリッドに相手の人数分の映像が並ぶ |
| 5人目 | 「このルームは定員(4名)に達しています」と表示され、入室できない |
| チャット | 1人の送信が残り3人に届き、送信者名が表示される |
| ミュート・画面共有 | 該当する人の枠にだけ「ミュート中」「画面を共有中」が出る。画面は全員に届く |
| 退出・タブを閉じる | 残った人のグリッドから、その人の枠だけが消える |
| 録画ボタン | 2人のときだけ有効、3人以上では無効 |
人数ごとに、10秒間の送受信量と、ブラウザ全体(全ウィンドウの合計)の CPU 使用率を測った結果が次の表です。CPU は1コアを100%とした値で、テスト用カメラ(640×480)の映像生成も含みます。
| 人数 | 1人あたりの送信(映像の送信本数) | 1人あたりの送信量 | ブラウザ全体の CPU |
|---|---|---|---|
| 2人 | 1本 | 約490kbps | 約89% |
| 3人 | 2本 | 約990kbps | 約180% |
| 4人 | 3本 | 約1,450kbps | 約307% |
1人あたりの送信量は「人数−1」にほぼ比例し、CPU も人数以上のペースで増えました。ただし、これは同じ PC の中で、低解像度のテスト映像を使った参考値です。実際のカメラ映像(高解像度・動きが多い)や、スマホ、インターネット越しの通信では、数値も上限の人数も大きく変わります。別々の端末・回線での実測、Firefox・Safari の混在、スマホでの発熱や電池の減り方は、この環境では確認していません。npm run build と npm run typecheck は通っています。
SFU という選択肢:何が変わるか
人数が増える、サーバーで録画したい、多くの人に配信したい、といった要件がある場合は、SFU を検討します。SFU は各参加者から受け取った映像を、合成せずに必要な相手へ転送するサーバーで、各端末の送信は人数によらず1本で済みます。品質の違う映像を複数送り、受け手の回線に合わせてサーバーが選ぶ仕組み(サイマルキャスト)を組み合わせることも一般的です。
SFU の導入方法は、大きく2つに分かれます。
| 方式 | 概要 | 主な検討事項 |
|---|---|---|
| OSS を自前で運用 | オープンソースの SFU をサーバーに構築して運用する | サーバーの構築・監視・障害対応・アップデートを担う体制、帯域の確保、ライセンスの確認 |
| マネージドなサービスを利用 | 事業者が運用する SFU を API で利用する | 利用量に応じた費用、提供地域やデータの扱い、サービスへの依存(乗り換えのしやすさ) |
OSS の例としては、mediasoup、Janus(汎用の WebRTC サーバーで、ビデオ会議用の SFU プラグインを持つ)、Jitsi Videobridge、LiveKit などがあり、いずれも公式のリポジトリで SFU(またはその機能を持つサーバー)と説明されています。これらは例として挙げたもので、順位や推奨ではありません。機能・ライセンス・サポートの状況は変わるため、採用時は必ず公式の情報で確認してください。
どちらの方式でも、SFU を入れると次の点が変わります。
- シグナリングの作りが変わる:全員と offer/answer を交換するのではなく、SFU と接続して「誰の映像を受け取るか」をやりとりする形になり、多くの場合 SFU 側の SDK やプロトコルに合わせます。
- 映像がサーバーを通る:通信量に応じた費用と、映像データの扱い(どの地域のサーバーを通るか等)の検討が必要です。
- サーバー録画や配信がしやすくなる:映像がサーバーに集まるため、サーバー側で録画・合成する設計がとりやすくなります。
連載のまとめ:全12回の振り返り
| 回 | 記事 | 作ったもの |
|---|---|---|
| 第0回 | Nuxt 4でビデオチャットの土台を作る:なぜHTTPSが必要なのか | プロジェクトの土台、Secure Context の確認 |
| 第1回 | カメラとマイクを取得してプレビューし、デバイスを選べるようにする | getUserMedia、デバイス選択、エラー表示 |
| 第2回 | Nitro の WebSocket でシグナリングサーバーを作る | ルーム単位の中継サーバー |
| 第3回 | RTCPeerConnection で1対1通話をつなぐ(offer/answer/ICE) | Perfect negotiation による1対1通話 |
| 第4回 | ルーム作成と招待リンク、入室前プレビュー | ルームID、招待リンク、定員チェック |
| 第5回 | ミュートとカメラオフ | track.enabled と状態の通知 |
| 第6回 | STUN/TURN と本番デプロイ:「社外とつながらない」を解消する | 期限付き TURN 認証情報、HTTPS/WSS 配下へのデプロイ |
| 第7回 | 画面共有 | getDisplayMedia と replaceTrack |
| 第8回 | DataChannel でテキストチャット | P2P のテキストチャット |
| 第9回 | MediaRecorder で録画する前に考えること | ローカル録画、了承の確認と録画中表示 |
| 第10回 | 切断・再接続とエラーハンドリング | WebSocket の再接続、ICE restart、状態表示 |
| 第11回 | 少人数通話へ:メッシュ実装と SFU という選択肢(本記事) | 最大4人のメッシュ通話 |
本番運用までに残っている作業
サンプルは「仕組みを理解するための最小構成」です。業務で使うには、少なくとも次の作業が残っています。
| 分野 | 残っている作業 |
|---|---|
| 認証・アクセス制御 | ログインの仕組みと、ルームに入れる人の制限(現在は URL を知っていれば誰でも入れる)。/api/ice-servers をログイン済みの人だけに限定し、呼び出し回数を制限する(現在は誰でも TURN の認証情報を取得できる)。WebSocket の接続時(upgrade)にも同じ認証を行う |
| エラー表示・再接続 | 第10回で WebSocket の再接続と失敗時の表示は追加済み。回線断に早く気付くためのハートビート、TURN 認証情報の期限切れ後の再取得(ICE restart 時)、最初から接続できない場合の文言の改善が残っている |
| 監視・運用 | 接続の成功率・切断の回数・TURN 経由の割合と通信量、サーバーの CPU・メモリ・WebSocket 接続数の監視。問い合わせ調査用のログ(IP アドレス等を含むため保存範囲と期間を決める) |
| スケール | ルームの情報を1台のサーバーのメモリに持っているため、サーバーを複数台にするには、ルーム情報とメッセージの中継を共有する仕組み(外部のデータストアや pub/sub)か、同じルームを同じサーバーに振り分ける仕組みが必要。TURN の冗長化。人数の上限を超える場合は SFU |
| 品質・端末 | 実機(スマホ・Safari・Firefox)での確認、回線の切り替えテスト、表示名の入力、アクセシビリティ |
| データの扱い | 録画・チャットの保存要否、利用規約・プライバシーポリシーへの記載 |
発注者向けメモ:人数・録画・配信の要件を最初に固める
ビデオ通話の見積もりで一番大きく効くのは、同時に何人が参加するかと、サーバー側で録画・配信が必要かです。4人程度までならメッシュで追加のサーバーなしに作れますが、それを超える人数や、サーバー録画・配信が要件に入ると SFU が前提になり、サーバー費用、運用体制、サービスへの依存といった検討事項が加わります。後から人数を増やすと、今回のようにシグナリングや接続の作りを変える必要が出るため、最初に決めておくことをおすすめします。
- ☐ 1つのルームの最大人数(普段の人数と、まれに起きる最大の人数)が決まっているか
- ☐ サーバー側での録画、多人数への配信(視聴だけの参加者)が必要か
- ☐ 参加者の端末(PC だけか、スマホが多いか)と回線(社内 Wi-Fi、モバイル回線)が想定されているか
- ☐ SFU を使う場合、自前運用とマネージドサービスのどちらにするか、その判断材料(運用体制・費用の考え方・データの扱い)が整理されているか
- ☐ 上の「本番運用までに残っている作業」のうち、どこまでを初期リリースに含めるか
開発会社への質問例:
- 「最大何人までを想定した構成ですか?その人数を超えたら何が起きますか?」
- 「スマホの参加者が多い場合、何人まで快適に使えるかをどう確認しますか?」
- 「将来 SFU に切り替える場合、どの部分の作り直しが必要になりますか?」
- 「SFU を使う場合、自前運用とマネージドサービスで、運用の体制と費用の考え方はどう変わりますか?」
まとめ
- メッシュは全員と個別につながる構成。追加のサーバーは不要だが、送信量と端末の負荷が「人数−1」に比例して増える
- 3人以上では offer/answer・ICE 候補に宛先を付け、サーバーは同じルームの宛先にだけ送る
- 1対1の接続処理を「相手1人分の接続」に切り出せば、これまでの機能をほぼそのまま人数分に広げられる
- 多人数・サーバー録画・配信が要件なら SFU を検討する。OSS の自前運用とマネージドサービスがあり、どちらもサーバー費用・運用・依存の検討が加わる
- 本番運用には、認証・監視・スケールなどの作業が残る。人数と録画・配信の要件を最初に固めるのが、見積もりのぶれを小さくする近道
全12回の連載をお読みいただき、ありがとうございました。サンプルコードは、1回ごとの差分で追えるようにしています。自社でビデオ通話機能を検討する際の、たたき台や開発会社との会話の材料として使っていただければ幸いです。
この連載の記事一覧
この記事は連載「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 という選択肢(この記事)









