
Twilio Video API + Twilio Function で 1対1 ビデオ通話を実装する
はじめに
本記事では、Twilio Video API を使用して最小限の構成で 1対1 ビデオ通話機能を実装する方法を説明します。Twilio Function でバックエンド処理を行い、Pure JavaScript でフロントエンドを構築することで、シンプルかつ効率的なビデオ通話システムを構築できます。
Twilio とは
Twilio は、開発者向けの通信プラットフォームサービスです。SMS、音声通話、ビデオ通話などの通信機能を API 経由で簡単にアプリケーションに組み込めます。豊富な SDK と充実したドキュメントにより、複雑な通信インフラを自前で構築することなく、高品質な通信機能を実装できます。
Twilio Video とは
Twilio Video は、リアルタイムビデオ通話機能を提供するサービスです。WebRTC 技術をベースとし、ブラウザやモバイルアプリ間でのビデオ・音声通話を実現します。Room という概念で参加者を管理し、複数の参加者が同一の Room に接続することで通話が成立します。
主な特徴は以下の通りです。
- WebRTC ベースの高品質な映像・音声伝送
- クロスプラットフォーム対応 (Web、iOS、Android)
- 自動的なメディアリレーとファイアウォール越え
- レコーディング機能
対象読者
- JavaScript の基本的な文法を理解している
- HTTP/HTTPS の概念を理解している
- REST API の使用経験がある
- ビデオ通話機能をアプリケーションに組み込みたい開発者
参考
構成
- Room 作成: ブラウザから Twilio Function を呼び出し、Video Room を作成
- Access Token 取得: 参加者ごとに Twilio Function から Access Token を取得
- Room 接続: Access Token を使用して Twilio Video Room に接続
- メディアストリーム: WebRTC 経由で参加者間のビデオ・音声を伝送
実装
Twilio Function の作成
Twilio Console で Functions を作成し、Room の作成と Access Token の生成を行います。
Environment Variables の設定
Twilio Console の Functions > Configuration で以下の環境変数を設定します:
TWILIO_ACCOUNT_SID: あなたの Account SID
TWILIO_API_KEY_SID: API Key SID
TWILIO_API_KEY_SECRET: API Key Secret
video-token Function の作成
// /video-token
exports.handler = async function(context, event, callback) {
// CORS対応のレスポンスオブジェクトを作成
const response = new Twilio.Response();
response.appendHeader('Content-Type', 'application/json');
response.appendHeader('Access-Control-Allow-Origin', '*');
response.appendHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
response.appendHeader('Access-Control-Allow-Headers', 'Content-Type');
// OPTIONSメソッド(プリフライトリクエスト)への対応
if (event.httpMethod === 'OPTIONS') {
response.setStatusCode(200);
response.setBody({});
return callback(null, response);
}
try {
const AccessToken = Twilio.jwt.AccessToken;
const VideoGrant = AccessToken.VideoGrant;
// リクエストパラメータの取得
const identity = event.identity;
const roomName = event.roomName;
if (!identity || !roomName) {
response.setStatusCode(400);
response.setBody({
error: 'identity と roomName が必要です'
});
return callback(null, response);
}
// 環境変数の確認
if (!context.ACCOUNT_SID || !context.TWILIO_API_KEY_SID || !context.TWILIO_API_KEY_SECRET) {
response.setStatusCode(500);
response.setBody({
error: '環境変数が正しく設定されていません'
});
return callback(null, response);
}
// Access Token の作成
const token = new AccessToken(
context.ACCOUNT_SID,
context.TWILIO_API_KEY_SID,
context.TWILIO_API_KEY_SECRET,
{ identity: identity }
);
// Video Grant の追加
const videoGrant = new VideoGrant({
room: roomName
});
token.addGrant(videoGrant);
// 成功レスポンス
response.setStatusCode(200);
response.setBody({
token: token.toJwt(),
identity: identity,
roomName: roomName
});
callback(null, response);
} catch (error) {
console.error('Token生成エラー:', error);
response.setStatusCode(500);
response.setBody({
error: 'Token生成に失敗しました',
details: error.message
});
callback(null, response);
}
};
create-room Function の作成
// /create-room
exports.handler = async function(context, event, callback) {
// CORS対応のレスポンスオブジェクトを作成
const response = new Twilio.Response();
response.appendHeader('Content-Type', 'application/json');
response.appendHeader('Access-Control-Allow-Origin', '*');
response.appendHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
response.appendHeader('Access-Control-Allow-Headers', 'Content-Type');
// OPTIONSメソッド(プリフライトリクエスト)への対応
if (event.httpMethod === 'OPTIONS') {
response.setStatusCode(200);
response.setBody({});
return callback(null, response);
}
try {
const client = context.getTwilioClient();
const roomName = event.roomName;
if (!roomName) {
response.setStatusCode(400);
response.setBody({
error: 'roomName が必要です'
});
return callback(null, response);
}
// Room の作成
const room = await client.video.v1.rooms.create({
uniqueName: roomName,
type: 'group'
});
// 成功レスポンス
response.setStatusCode(200);
response.setBody({
roomSid: room.sid,
roomName: room.uniqueName,
status: room.status
});
callback(null, response);
} catch (error) {
console.error('Room作成エラー:', error);
// Room が既に存在する場合
if (error.code === 53113) {
response.setStatusCode(200);
response.setBody({
message: 'Room は既に存在します',
roomName: roomName
});
} else {
response.setStatusCode(500);
response.setBody({
error: 'Room作成に失敗しました',
details: error.message
});
}
callback(null, response);
}
};
作成した Function の Visibility を Public に設定します。
フロントエンド実装
HTML ファイルの作成
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Twilio Video 1対1 通話</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.controls {
margin-bottom: 20px;
}
input, button {
margin: 5px;
padding: 10px;
}
.video-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
video {
width: 300px;
height: 200px;
background-color: #000;
border: 1px solid #ccc;
}
.local-video {
border: 2px solid blue;
}
.remote-video {
border: 2px solid green;
}
</style>
</head>
<body>
<div class="container">
<h1>Twilio Video 1対1 通話</h1>
<div class="controls">
<input type="text" id="roomNameInput" placeholder="Room 名を入力" />
<input type="text" id="identityInput" placeholder="あなたの名前を入力" />
<button id="createRoomBtn">Room 作成</button>
<button id="joinRoomBtn">Room 参加</button>
<button id="leaveRoomBtn" disabled>Room 退出</button>
</div>
<div id="status"></div>
<div class="video-container">
<div>
<h3>あなたの映像</h3>
<video id="localVideo" class="local-video" muted autoplay playsinline></video>
</div>
<div>
<h3>相手の映像</h3>
<video id="remoteVideo" class="remote-video" autoplay playsinline></video>
</div>
</div>
</div>
<script src="https://sdk.twilio.com/js/video/releases/2.28.1/twilio-video.min.js"></script>
<script src="app.js"></script>
</body>
</html>
JavaScript ファイルの作成
// app.js
const TWILIO_FUNCTION_BASE_URL = 'https://your-function-domain.twil.io'; // あなたの Function URL に置き換え
// 注意: 本記事では説明を簡単にするため、Twilio Function の URL を直接コードに記載していますが、本番環境では Vercel の環境変数などの方法で管理してください。
let currentRoom = null;
let localTracks = [];
// DOM 要素の取得
const roomNameInput = document.getElementById('roomNameInput');
const identityInput = document.getElementById('identityInput');
const createRoomBtn = document.getElementById('createRoomBtn');
const joinRoomBtn = document.getElementById('joinRoomBtn');
const leaveRoomBtn = document.getElementById('leaveRoomBtn');
const statusDiv = document.getElementById('status');
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
// イベントリスナーの設定
createRoomBtn.addEventListener('click', createRoom);
joinRoomBtn.addEventListener('click', joinRoom);
leaveRoomBtn.addEventListener('click', leaveRoom);
// ステータス表示関数
function updateStatus(message) {
statusDiv.textContent = message;
console.log(message);
}
// Room 作成
async function createRoom() {
const roomName = roomNameInput.value.trim();
if (!roomName) {
alert('Room 名を入力してください');
return;
}
try {
updateStatus('Room を作成中...');
const response = await fetch(`${TWILIO_FUNCTION_BASE_URL}/create-room`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `roomName=${encodeURIComponent(roomName)}`
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
updateStatus(`Room "${data.roomName}" を作成しました (SID: ${data.roomSid})`);
} catch (error) {
console.error('Room 作成エラー:', error);
updateStatus('Room の作成に失敗しました: ' + error.message);
}
}
// Room 参加
async function joinRoom() {
const roomName = roomNameInput.value.trim();
const identity = identityInput.value.trim();
if (!roomName || !identity) {
alert('Room 名と名前を入力してください');
return;
}
try {
updateStatus('Access Token を取得中...');
// Access Token の取得
const response = await fetch(`${TWILIO_FUNCTION_BASE_URL}/video-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `identity=${encodeURIComponent(identity)}&roomName=${encodeURIComponent(roomName)}`
});
// レスポンスのエラーチェック
if (!response.ok) {
const errorText = await response.text();
console.error('API Error:', response.status, errorText);
throw new Error(`API Error: ${response.status}`);
}
// JSONパース
const data = await response.json();
// tokenの存在チェック
if (!data.token) {
throw new Error('Token が取得できませんでした');
}
updateStatus('ローカルメディアを取得中...');
// ローカルメディアの取得
localTracks = await Twilio.Video.createLocalTracks({
audio: true,
video: true
});
// ローカル映像の表示
const localVideoTrack = localTracks.find(track => track.kind === 'video');
if (localVideoTrack) {
localVideo.srcObject = new MediaStream([localVideoTrack.mediaStreamTrack]);
}
updateStatus('Room に接続中...');
// Room への接続
currentRoom = await Twilio.Video.connect(data.token, {
name: roomName,
tracks: localTracks
});
updateStatus(`Room "${roomName}" に接続しました`);
// UI の更新
createRoomBtn.disabled = true;
joinRoomBtn.disabled = true;
leaveRoomBtn.disabled = false;
// Room イベントの設定
setupRoomEvents(currentRoom);
// 既存の参加者の処理
currentRoom.participants.forEach(participantConnected);
} catch (error) {
console.error('Room 参加エラー:', error);
updateStatus('Room への参加に失敗しました: ' + error.message);
// ローカルトラックのクリーンアップ
localTracks.forEach(track => track.stop());
localTracks = [];
}
}
// Room 退出
function leaveRoom() {
if (currentRoom) {
currentRoom.disconnect();
currentRoom = null;
}
// ローカルトラックの停止
localTracks.forEach(track => track.stop());
localTracks = [];
// 映像の初期化
localVideo.srcObject = null;
remoteVideo.srcObject = null;
// UI の更新
createRoomBtn.disabled = false;
joinRoomBtn.disabled = false;
leaveRoomBtn.disabled = true;
updateStatus('Room から退出しました');
}
// Room イベントの設定
function setupRoomEvents(room) {
// 参加者が接続した時
room.on('participantConnected', participantConnected);
// 参加者が切断した時
room.on('participantDisconnected', participantDisconnected);
// Room から切断された時
room.on('disconnected', (room, error) => {
updateStatus('Room から切断されました');
leaveRoom();
});
}
// 参加者接続時の処理
function participantConnected(participant) {
updateStatus(`${participant.identity} が参加しました`);
// 既存のトラックの処理
participant.tracks.forEach(publication => {
if (publication.isSubscribed) {
trackSubscribed(publication.track);
}
});
// トラック購読イベント
participant.on('trackSubscribed', trackSubscribed);
participant.on('trackUnsubscribed', trackUnsubscribed);
}
// 参加者切断時の処理
function participantDisconnected(participant) {
updateStatus(`${participant.identity} が退出しました`);
// リモート映像をクリア
remoteVideo.srcObject = null;
}
// トラック購読時の処理
function trackSubscribed(track) {
if (track.kind === 'video') {
remoteVideo.srcObject = new MediaStream([track.mediaStreamTrack]);
}
}
// トラック購読解除時の処理
function trackUnsubscribed(track) {
if (track.kind === 'video') {
remoteVideo.srcObject = null;
}
}
// ページ離脱時のクリーンアップ
window.addEventListener('beforeunload', () => {
leaveRoom();
});
Vercel でのデプロイ
ブラウザのセキュリティポリシーにより、カメラ・マイクへのアクセスには HTTPS が必要です。Vercel を使用することで、簡単に HTTPS 対応のアプリケーションをデプロイできます。
GitHub リポジトリの作成
test-twilio-video/
├── index.html
└── app.js
Vercel でのデプロイ手順
GitHub にリポジトリをプッシュ
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/your-username/test-twilio-video.git
git push -u origin main
Vercel でインポート
- vercel.com にアクセスしてサインアップ
- "New Project" をクリック
- GitHub リポジトリを選択
- Framework Preset: Other を選択
- "Deploy" をクリック
- 数分で
https://your-project-name.vercel.app
の URL が発行される
環境変数の設定
app.js
の TWILIO_FUNCTION_BASE_URL
を Twilio Function の実際の URL に更新してください。
const TWILIO_FUNCTION_BASE_URL = 'https://your-function-domain.twil.io';
動作確認
デバイス A での操作 (PC ブラウザ)
- Vercel の URL にアクセス
- Room 名 (例:
test
) を入力 - 名前 (例:
user1
) を入力 - "Room 作成" → "Room 参加" をクリック
- カメラ・マイクの許可を与える
デバイス B での操作 (スマートフォンブラウザ)
- 同じ Vercel URL にアクセス
- 同じ Room 名 (
test
) を入力 - 異なる名前 (例:
user2
) を入力 - "Room 参加" をクリック
- カメラ・マイクの許可を与える
通話確認
- 両デバイスでお互いの映像が表示される
- 音声が双方向で聞こえる
まとめ
本記事では、Twilio Video API を使用して最小限の構成で 1対1 ビデオ通話機能を実装しました。この基本実装をベースに、以下のような拡張案が考えられます。
- エラーハンドリングの強化
- UI/UX の改善
- 画面共有機能
- チャット機能
- 通話録画機能
Twilio Video API の豊富な機能により、用途に応じてさらに高度なビデオ通話システムを構築できます。