😽

Cloudflare Calls のサンプルアプリ Orange Meets

に公開

Cloudflare Calls はリアルタイムのオーディオ/ビデオ/データ アプリケーション用のインフラストラクチャを提供するサービスです。スケーリングやリージョンを気にすることなく、リアルタイム アプリケーションの構築をCloudflareのインフラストラクチャを活用して行うことができます。選択的転送ユニット (WebRTC SFU)、ブロードキャスト用のファンアウト配信システム (WebRTC CDN)等が可能として提供されます。

WebRTC と SFU

WebRTC(Web Real-Time Communication)は、ブラウザ間でプラグインなしでリアルタイムの音声、映像、データ通信を実現する技術です。これにより、ビデオ通話やファイル共有などのアプリケーションが可能になります。
SFU(Selective Forwarding Unit)は、WebRTCのセッションでメディアストリームを転送するサーバーで、ストリームのデコードやエンコードは行いません。参加者ごとに必要なストリームのみを送信し、帯域幅の使用を最適化します。主に多人数のビデオ会議で使用されます。

SFUが存在しない場合クライアントは通信を行うピア毎にセッションを張ることとなり、クライアント(参加者)が増えるごとにセッション数はうなぎのぼりに増えていきます。これを纏めるのがSFUです。クライアントは1つのノードとセッションを張ればよく、またCloudflareのインフラを活用することで通信が高速化し安定化します。

Cloudflare Calls と 一般的なSFUの違い

通常SFUは、どこか1か所に存在し、そのスケーラビリティや地域依存性が問題となります。このためクライアントの場所にとっては遅延が大きいクライアントと遅延が小さいクライアントが存在します。
Cloudflare Callsは世界中に点在しているCloudflareのエッジで分散してSFUが動作するため、クライアントとSFU間の遅延を最小化でき、同時にスケーラビリティの問題も解決します。

さっそくやってみる

まずはCloudflareのマネージメントコンソールでCallsをアクティベート化します。
左ペインからCallsをクリックします。

CreateServerless SFU Appを選択します。

もう一つの選択肢である、TURN Appというのは、NAT環境などでWebRTC通信が妨げられる場合、そのリレーサーバの機能を果たすものです。クライアントがCloudflareエッジとトンネリングを確立することで通信を実現させます。
適当な名前を入れてCreateをクリックします。

App IDApp Tokenをコピーしておきます。(ただしこの記事では使いません。クリライアントがSFUへの通信を行う際に必要となるため、皆さんがCallsを活用したアプリケーションの開発に必要となります)

以下の表示されるソースをコピーしてindex.htmlとして保存します。

具体的には以下のようなHTMLであらかじめApp IDApp Tokenが埋め込まれています。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- This adapter normalizes cross-browser differences in WebRTC APIs. Currently necessary in order to support Firefox. -->
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"
      integrity="sha512-l40eBFtXx+ve5RryIELC3y6/OM6Nu89mLGQd7fg1C93tN6XrkC3supb+/YiD/Y+B8P37kdJjtG1MT1kOO2VzxA=="
      crossorigin="anonymous"
      referrerpolicy="no-referrer"
    ></script>
  </head>


  <body>
    <div class="grid">
      <h1>Calls Echo Demo</h1>
      <div>
        <h2>Local stream</h2>
        <video id="local-video" autoplay muted></video>
      </div>
      <div>
        <h2>Remote echo stream</h2>
        <video id="remote-video" autoplay></video>
      </div>
    </div>


    <script type="module">
      // This is a class the defines the Calls API interactions.
      // It's not an SDK but a example of how Calls API can be used.


      // This is the App Id provided by the dashboard that identifies this Calls Application.
      const appId = 'xxxxx';
      // DO NOT USE YOUR SECRET IN THE BROWSER FOR PRODUCTION. It should be kept and used server-side.
      const appSecret = 'xxxxx';


      class CallsApp {
        constructor(appId, basePath = 'https://rtc.live.cloudflare.com/v1') {
          this.prefixPath = `${basePath}/apps/${appId}`;
        }


        async sendRequest(url, body, method = 'POST') {
          const request = {
            method: method,
            mode: 'cors',
            headers: {
              'content-type': 'application/json',
              Authorization: `Bearer ${appSecret}`
            },
            body: JSON.stringify(body)
          };
          const response = await fetch(url, request);
          const result = await response.json();
          return result;
        }


        checkErrors(result, tracksCount = 0) {
          if (result.errorCode) {
            throw new Error(result.errorDescription);
          }
          for (let i = 0; i < tracksCount; i++) {
            if (result.tracks[i].errorCode) {
              throw new Error(
                `tracks[${i}]: ${result.tracks[i].errorDescription}`
              );
            }
          }
        }


        // newSession sends the initial offer and creates a session
        async newSession(offerSDP) {
          const url = `${this.prefixPath}/sessions/new`;
          const body = {
            sessionDescription: {
              type: 'offer',
              sdp: offerSDP
            }
          };
          const result = await this.sendRequest(url, body);
          this.checkErrors(result);
          this.sessionId = result.sessionId;
          return result;
        }


        // newTracks shares local tracks or gets tracks
        async newTracks(trackObjects, offerSDP = null) {
          const url = `${this.prefixPath}/sessions/${this.sessionId}/tracks/new`;
          const body = {
            sessionDescription: {
              type: 'offer',
              sdp: offerSDP
            },
            tracks: trackObjects
          };
          if (!offerSDP) {
            delete body['sessionDescription'];
          }
          const result = await this.sendRequest(url, body);
          this.checkErrors(result, trackObjects.length);
          return result;
        }


        // sendAnswerSDP sends an answer SDP if a renegotiation is required
        async sendAnswerSDP(answer) {
          const url = `${this.prefixPath}/sessions/${this.sessionId}/renegotiate`;
          const body = {
            sessionDescription: {
              type: 'answer',
              sdp: answer
            }
          };
          const result = await this.sendRequest(url, body, 'PUT');
          this.checkErrors(result);
        }
      }


      // Use Cloudflare's STUN server
      self.pc = new RTCPeerConnection({
        iceServers: [
          {
            urls: 'stun:stun.cloudflare.com:3478'
          }
        ],
        bundlePolicy: 'max-bundle'
      });


      // In order to successfully establish a peer connection, we need at least one track to publish.
      // In this case, we create two: video & audio
      const localStream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true
      });


      // Get the local video element in the HTML and set the source to show local stream
      const localVideoElement = document.getElementById('local-video');
      localVideoElement.srcObject = localStream;


      // Add sendonly trancievers to the PeerConnection
      self.transceivers = localStream.getTracks().map(track =>
        self.pc.addTransceiver(track, {
          direction: 'sendonly'
        })
      );


      // Create a instance of CallsApp (defined below). Please note that this is not an official SDK but just a demo showing the HTML API.
      self.app = new CallsApp(appId);


      // Send the first offer and create a session. The returned sessionId is required to retrieve any track published by this peer
      await self.pc.setLocalDescription(await self.pc.createOffer());
      const newSessionResult = await self.app.newSession(
        self.pc.localDescription.sdp
      );
      await self.pc.setRemoteDescription(
        new RTCSessionDescription(newSessionResult.sessionDescription)
      );


      // Make the peer connection was established
      await new Promise((resolve, reject) => {
        self.pc.addEventListener('iceconnectionstatechange', ev => {
          if (ev.target.iceConnectionState === 'connected') {
            resolve();
          }
          setTimeout(reject, 5000, 'connect timeout');
        });
      });


      // We associate a trackName to a transceiver identified by a mid (media ID). This way the track
      // is remotely reachable by the tuple (sessionId, trackName)
      let trackObjects = self.transceivers.map(transceiver => {
        return {
          location: 'local',
          mid: transceiver.mid,
          trackName: transceiver.sender.track.id
        };
      });


      // Get local description, create a new track, set remote description with the response
      await self.pc.setLocalDescription(await self.pc.createOffer());
      const newLocalTracksResult = await self.app.newTracks(
        trackObjects,
        self.pc.localDescription.sdp
      );
      await self.pc.setRemoteDescription(
        new RTCSessionDescription(newLocalTracksResult.sessionDescription)
      );


      // At this point in code, we are successfully sending local stream to Cloudflare Calls.
      // Now, we will pull the same stream from Cloudflare Calls.


      // Update trackObjects to reference the tracks as "remote"
      trackObjects = trackObjects.map(trackObject => {
        return {
          location: 'remote',
          sessionId: self.app.sessionId,
          trackName: trackObject.trackName
        };
      });


      // Prepare to receive the tracks before asking for them
      const remoteTracksPromise = new Promise(resolve => {
        let tracks = [];
        self.pc.ontrack = event => {
          tracks.push(event.track);
          console.debug(`Got track mid=${event.track.mid}`);
          if (tracks.length >= 2) {
            // remote video & audio are ready
            resolve(tracks);
          }
        };
      });


      // Calls API request to ask for the tracks
      const newRemoteTracksResult = await self.app.newTracks(trackObjects);
      if (newRemoteTracksResult.requiresImmediateRenegotiation) {
        switch (newRemoteTracksResult.sessionDescription.type) {
          case 'offer':
            // We let Cloudflare know we're ready to receive the tracks
            await self.pc.setRemoteDescription(
              new RTCSessionDescription(
                newRemoteTracksResult.sessionDescription
              )
            );
            await self.pc.setLocalDescription(await self.pc.createAnswer());
            await self.app.sendAnswerSDP(self.pc.localDescription.sdp);
            break;
          case 'answer':
            throw new Error('An offer SDP was expected');
        }
      }


      // Once started receiving the tracks (video & audio) send the data to the video tag
      const remoteTracks = await remoteTracksPromise;
      const remoteVideoElement = document.getElementById('remote-video');
      const remoteStream = new MediaStream();
      remoteStream.addTrack(remoteTracks[0]);
      remoteStream.addTrack(remoteTracks[1]);
      remoteVideoElement.srcObject = remoteStream;
    </script>
    <style>
      /* Styles are safe to ignore, just here for demo */


      html {
        color-scheme: light dark;
        font-family:
          system-ui,
          -apple-system,
          BlinkMacSystemFont,
          'Segoe UI',
          Roboto,
          Oxygen,
          Ubuntu,
          Cantarell,
          'Open Sans',
          'Helvetica Neue',
          sans-serif;
        background: white;
        color: black;
      }
      body,
      h1,
      h2 {
        margin: 0;
      }
      h1,
      h2 {
        font-weight: 400;
      }
      h1 {
        font-size: 1.5rem;
        grid-column: 1 / -1;
      }
      h2 {
        font-size: 1rem;
        margin-bottom: 0.5rem;
      }
      video {
        width: 100%;
      }
      .grid {
        display: grid;
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        gap: 1rem;
      }


      @media (max-width: 500px) {
        .grid {
          grid-template-columns: minmax(0, 1fr);
        }
      }
    </style>
  </body>
</html>

ブラウザでindex.htmlを開くと以下の通りマイクとカメラへのアクセスが求められますので許可します。

以下のようにechoとしてLocal,Remote双方の環境にブラウザから読み取れる映像と音声が出てきます。

Remote 環境の設定

このindex.htmlを今度は別々の環境で動作するように改造する必要がありますが、裏側で動作している2つのプロトコル、SDPとICEの情報を交換する基盤を作らないといけません。SDP(Session Description Protocol)は、通信の設定情報(メディア形式や通信方法)を記述するためのプロトコルで、WebRTCでの接続確立に使われます。ICE(Interactive Connectivity Establishment)は、NAT(ネットワークアドレス変換)を越えてピア間の接続を確立するためのプロセスです。これは一般的にシグナリグンサーバと言われるもので行われます。
シグナリングサーバは、接続先情報やSDP(Session Description Protocol)メッセージの交換、ICE(Interactive Connectivity Establishment)候補のやり取りを行います。具体的には、SDPを通じてメディアの設定(コーデック、解像度など)を交渉し、ICEでネットワーク経路(NAT越え)の候補を交換して、最終的にWebRTCピア間の直接接続を確立させます。

少し私の手に余るため、Cloudflare がもともと用意してくれているサンプルアプリを使います。
https://github.com/cloudflare/orange?tab=readme-ov-file
以下のようにCloudflare基盤を使って簡単なビデオ会議アプリを試すことが出来ます。

Discussion