87
61

More than 3 years have passed since last update.

WebTransportとWebCodecsを組み合わせてビデオチャットを実装してみる

Last updated at Posted at 2020-12-10

この記事は?

こんにちは。NTTコミュニケーションズのyuki uchidaです。普段はSkyWayというWebRTCプラットフォームの開発やWebRTCリサーチャー(見習い)をしています。

この記事は NTTコミュニケーションズ Advent Calendar 2020 10日目の記事です。昨日はy-i さんの記事、 「グループ内プログラミングコンテストをオンライン上で開いた件」でした。

前回、 NTT コミュニケーションズのアドベントカレンダー3日目の記事として、「WebSocketの次の技術!?WebTransportについての解説とチュートリアル」という記事を投稿しました。
WebTransportが出てきた背景や、双方向通信の歴史について解説しているため、興味があれば是非ご覧ください。

今回の記事は、そのWebTransportWebCodecsを組み合わせてビデオチャットを実装する応用編になります。

対象読者

  • WebTransportの応用先について興味がある人
  • 映像や音声のリアルタイム通信に興味がある人
  • ブラウザで映像や音声を扱うことに興味がある人

WebTransportとは

WebTransportに関しては前回の記事でも解説した通り、WebSocketの次の技術ではないかと期待されている技術です。WebSocketのパフォーマンス問題を解消しただけではなくWebSocketでは対応できていなかったユースケースにも対応した双方向通信フレームワークです。

(WebTransport).jpg

WebSocketやWebRTCとの違いが分かりやすい様に、表でまとめると以下のようになります。

Untitled.png

サバクラかつUDPlikeなユースケースは、今まではWebRTCを応用したりして、なんとか対応していました。しかし、元々P2Pを前提に仕様が決まっているため、応用しようとしても、柔軟性がなかったり、複雑である点から、サービス提供者は「正直キツい・・・」と思いながら開発していました。

例えば、Zoom社はWebRTCを丸ごと使うのではなく、WebRTCの一部分(WebRTC datachannel)と自作コーデックを組み合わせることでZoomを提供している様です。(かなりの力技ですね)

このような決断をしたZoomも、WebTransportとWebCodecsには注目している様です。
こちらの動画を見ると、Zoomが何故WebTransportやWebCodecsに注目しているかがわかります。(英語ですが)

WebTransportは、「サバクラかつUDPlikeなユースケースにfitする技術がない」という穴を埋めてくれる様な技術です。
また、WebSocketのパフォーマンス問題も解消しているため、WebSocketが対応していたユースケースにもWebTransportは利用できます。(将来的にWebSocketの代替になるかも?)

Untitled 1.png

2020年12月現在は、WebTransportはまだGoogle ChromeのOrigin Trial(お試し機能)としてしか利用できませんが、映像配信やクラウドゲーミング・IoTなど多方面から期待されている技術となっています。

詳しくは前回の記事 をご覧ください。

WebCodecsとは

WebCodecsとは、ブラウザが内部で実装しているH264やVP8といったコーデックを用いてエンコード・デコードを行えるAPIで、2020年12月現在、Google ChromeでOrigin Trialとして利用可能です。

Origin Trialなので、正式提供されたAPIではないのでご注意ください。

WebCodecsはWebTransportと一緒に使われる事を想定しており、WebTransportについても知っておくと理解が深まります。

WebCodecsがでてきた理由を理解するため、WebTransportのユースケースから見ていきましょう。

WebTransportが輝くユースケース

WebTransportがデータを転送するトランスポート部分として素晴らしいという事を前回の記事 で述べました。(TCPlikeなユースケース・UDPlikeなユースケース両方に対応できます!)

WebTransportは、いろんなユースケースで活躍してくれる技術ですが、最も価値を発揮するユースケースは、常に低遅延を維持し続ける必要があるアプリケーションです。(UDPlikeなユースケース)

その例としては、映像配信クラウドゲーミングなどがあげられます。

これらはデータの少しの遅れがサービスとしての価値に直結してしまいます。ライブ配信などで映像が固まったり、ゲームの映像が遅れてきたりするとユーザーとしては使いたくなくなってしまいますよね。

このようなケースではWebTransportが活躍するでしょう。

つまり、WebTransportの登場で一番最初に適用されると考えられるのは、映像や音声を送受信するケースです。(他のケースはWebSocketやWebRTCが使えるためです。今後、WebTransportがどのくらいパフォーマンスが出るかにもよりますが、すでにある技術で十分な場合もありますし、すぐに代替することはないでしょう)

WebCodecs登場の背景

WebTransportは、映像や音声を送受信する様なケースで輝きます。

しかし、今までWebブラウザには、その映像や音声のコーデックにアクセスしてエンコードやデコードを行う様なAPIは提供されていませんでした。送受信する技術としてWebTransportが出てきたのに、そのWebTransportで一番送信したい映像や音声のデータを自由に扱う機能がない状態です。

音声や映像を扱えるブラウザのAPIとしては、以下のものが挙げられます。

  • audio/video tag
  • WebAudio
  • WebRTC
  • Media Recorder API
  • MSE

これらのAPIは内部で映像や音声のエンコード・デコードをしてくれています。しかし、特定のユースケースに沿った使い方しかできないため、非常に柔軟性が低いAPI(高レベルなAPI)です。

例えば、WebRTCであれば「映像や音声のエンコードやデコードを自動で行い、 P2Pで接続した対向にデータを転送する」といった抽象度の高い高レベルなAPIになっています。

高レベルなAPIはカスタマイズしたり、複雑な処理をしなくても動くので非常に扱うのが簡単なのですが、今回の様なユースケースの場合には、「エンコード・デコードだけ行う」ような、低レベルなAPIが欲しくなります。

高レベルなAPIの代表として、WebRTCを図示してみます。

WebCodecs.jpg

このように抽象度が高いAPIだと、「エンコード・デコードだけ任せて、送受信の部分はWebTransportでやりたいなぁ」と思った時に困ります。

WebRTCやその他のAPIは、高度に抽象化されているため、その内部のエンコード・デコードの処理だけを取り出して単体で使うことはできません。

コーデックの各種機能は、このように内包されて使えなかったという背景があるため、ブラウザのコーデック にアクセスしエンコードやデコードが行える低レベルなAPIとしてWebCodecsが提供される様になりました。(2020年12月現在 Google ChromeでOrigin Trial中)

このWebCodecsの提供により、エンコードやデコードを自由にできる様になりました。また、WebTransportに転送をお願いする前に追加の処理を挟み込んだりすることも可能で、非常に柔軟性の高いシステムを構成可能です。

WebCodecs2.jpg

以上が、WebCodecs登場の背景です。

WebTransportで最も実現したいユースケース(映像配信クラウドゲーミング)を考えると、WebCodecsが同時期に登場してきた理由がわかりますね。このような理由から、WebTransportWebCodecsは合わせて語られることが多いです。

余談ですが、WebTransport(Quictransport)のOriginTrialの期間がWebCodecsの登場に合わせて伸びるということもありました。Google Chromeとしてもやはりセットで考えている様です。

現在はWebTransport(QuicTransport)とWebCodecsは両方ともGoogle ChromeでOrigin Trialとして利用可能です。

WebTransportをWebCodecsを組み合わせたビデオチャットアプリケーション

本記事では、このWebCodecsを用いてエンコードしたデータをWebTransportで送信します。(受信側もWebCodecs APIを用いてデコードを行います)

以下の様なアプリケーションが完成物です。githubはこちら

output.gif

仕組みとしては単純で、WebCodecsで映像をエンコードし、QuicTransportサーバーに向けてdatagramで送信します。QuicTransportサーバーは受け取った映像をそのままオウム返しするアプリケーションです。

*) 現在、GoogleChromeでは、WebTransportのliteな実装であるQuicTransportが利用可能なので、本記事ではこのQuicTransportを利用します。

WebCodecs 1.jpg

) *本記事では、前回のWebTransport記事 と被る部分もありますがご了承ください**

1. ChromeM87以降をダウンロードする

QuicTransport、WebCodecsはEdgeな技術であるため、最近のGoogle Chromeでなければ動きません。

左上のメニューバーから「Google Chromeについて」を選択し、現在のバージョンを確認してください。バージョン87以降になっていれば問題ありません。それ以前のものになっている場合はupdateしましょう。

これは、QuicTransportはM84から、WebCodecsはM86からOrigin Trialが始まったためです。M86でも問題ありませんが、私の端末ではM86だと動作が不安定(M87だと安定)だったため、M87以降をお勧めします。M85以前だとWebCodecsが動きません。

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124611/34165596-dc8f-a391-ae3e-d965d72145a9.png

2. Origin Trialに登録し、Tokenを取得する

以下のリンクのActive Trialsのタブから、QuicTransportとWebCodecsを探し、REGISTERボタンを押しましょう

https://developers.chrome.com/origintrials/#/trials/active

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124611/f9f7008d-0101-97c6-16e5-f6d9d0e8badc.png

以下のように、WebOriginの項目に <http://localhost:8000> を入力、Expected Usageの項目に 0 to 10,000 を入力しましょう。(どちらも任意のものなので参考までに。)

その後、利用規約に同意してREGISTERボタンを押せば登録完了です。

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124611/da2da220-59ba-6088-6b72-a5834b2d1f85.png

QuicTransportとWebCodecsの両方を登録するのを忘れずに!どちらも手順は同じです。

3. QuicTransportサーバー用の秘密鍵・公開鍵を作成する

以下のコマンドをコピペして certificate.keycertificate.pem を用意しましょう

openssl req -newkey rsa:2048 -nodes -keyout certificate.key -x509 -out certificate.pem -subj '/CN=Test Certificate' -addext "subjectAltName = DNS:localhost"

では次に、以下のコマンドで署名を行います。(出力される文字列をメモしておきましょう)

openssl x509 -pubkey -noout -in certificate.pem | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | base64

4. Google Chromeを引数付きで起動する

Google Chromeに、「QuicTransportを利用できるようにするフラグ」と「WebCodecsを利用できる様にするフラグ」と「その際に出るエラーを無視するためのフラグ」をつけて起動します。その際、メモした文字列を使用してください。

open -a "Google Chrome" --args --origin-to-force-quic-on=localhost:4433 --enable-blink-features=WebCodecs --ignore-certificate-errors-spki-list="<3.QuicTransportサーバー用の秘密鍵・公開鍵を作成するでメモした文字列>"

起動すると、Google Chromeのメニューバーの下に以下のような注意書きが出るのを確認しましょう。もしこの注意書きが出なかった場合には一度Google Chromeを完全に終了してから再度上記のコマンドを入力して注意書きが出るか確認してください。

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124611/6d11b4e3-2568-1c72-425d-b0a07b89828b.png

5. HTML/JSで接続処理

まずは、HTML/JSでQuicTransportと接続する処理を書いていきます。

HTMLでボタンや映像を表示するためのvideo要素などを準備しましょう。

HTMLの準備

まずはHTMLを用意します。単純にボタンと映像を写すためのvideo要素とcanvas要素があればOKです。

image.png

「2. Origin Trialに登録し、Tokenを取得する」 で取得したTokenを以下のHTMLの
<meta http-equiv="origin-trial" content="<QUIC TRANSPOERT の ORIGIN TRIALのTOKEN>" />
の部分を置き換えてください。これによってQuicTransportがそのページで利用可能になります。また、同様にWebCodecsも書き換えを行いましょう。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta http-equiv="origin-trial" content="<QUIC TRANSPOERT の ORIGIN TRIALのTOKEN>" />
    <meta http-equiv="origin-trial" content="<WEBCODECS の ORIGIN TRIALのTOKEN>" />
    <style>
      .box {
        display: flex;
        flex-direction: row;
      }
      .viewer {
        width: 720px;
        height: 620px;
        text-align: center;
        border: 1px solid gray;
        margin: 3px;
      }
    </style>
  </head>
  <body>
    <main>
      <hr />
      <div>
        WebCodecs supported:
        <span id="web-codecs-supported">detecting...</span>
      </div>
      <input
        type="button"
        id="prepareConnectionButton"
        value="prepareConnection"
        onclick="prepareConnection()"
      />
      <textarea
        id="QuicTransportID"
        type="text"
        name="QuicTransportID"
        value="?????"
        cols="50"
        readonly
      ></textarea>
      <div>
        <button id="getUserMediaButton">getUserMedia</button>
      </div>
      <div>
        <button id="sendVideoWithDatagramButton">sendVideoWithDatagram</button>
        <button id="sendKeyFrameRequestButton">sendKeyFrameRequest</button>
      </div>
      <div class="box">
        <div class="encoder viewer">
          <h2>encoder</h2>
          <video autoplay></video>
          <div>
            timestamp: <span class="timestamp"></span><br />
            type: <span class="type"></span><br />
            byteLength: <span class="byte-length"></span>
          </div>
        </div>
        <div class="decoder viewer">
          <h2>decoder</h2>
          <canvas></canvas>
        </div>
      </div>
    </main>
  </body>
  <script>
    const checkSupported = () => {
      return !!window.VideoEncoder;
    };
    const supported = checkSupported();
    if (!supported) {
      alert(
        [
          'Your browser does not support WebCodecs.',
          'use Chrome with M87 and enable `#enable-experimental-web-platform-features`',
          'for experiencing this experimental web app.',
        ].join(' ')
      );
    }
    document.querySelector('#web-codecs-supported').innerHTML = supported ? 'yes' : 'no';
  </script>
  <script src="./cbor.js"></script>
  <script src="./script.js"></script>
</html>

QuicTransportServerと接続する関数

まずは、QuicServerと接続を確立する部分です。

接続したいQuicTransportServerのURLを取得して、 new WebTransport(url) するだけです。
URLは、 quic-transport://localhost:4433/ のような形になります。先頭が quic-transport というプロトコルの文字列になってるのに注意してください。

async function prepareConnection() {
  // QuicTransportServerと接続するためのURLです。
  // `quic-transport://localhost:4433/webcodecs_webtransport` をデフォルトにしています。
  const url = 'quic-transport://localhost:4433/webcodecs_webtransport';
  var transport = new QuicTransport(url);
  console.log(`initializing QuicTransport Instance`);
  transport.closed
    .then(() => {
      console.log(`The QUIC connection to ${url} closed gracefully`);
    })
    .catch((error) => {
      console.error(`the QUIC connection to ${url} closed due to ${error}`);
    });
  // QuicTransportServerとのコネクションを開始!!!
  await transport.ready;
  console.log("startReceivingDatagram");
  startReceivingDatagram(transport); // datagramを受け取る準備です。後ほど定義します
  console.log("startReceivingStream");
  startReceivingStream(transport); // streamを受け取るための準備です。後ほど定義します
  globalThis.currentTransport = transport
  globalThis.streamNumber = 1;
}

streamを受けて処理する関数

では次にstreamを受け取る処理を書いていきます。
QuicTransportServerに接続している自分のコネクションIDを知るために用意しています。

6. QuicTransportServerの処理を書くでコネクションIDをサーバーから教えてくれる処理を記述するので、ここでは受け手側の処理のみ書いておきます。

async function startReceivingStream(transport) {
  let reader = transport.incomingUnidirectionalStreams.getReader();
  while (true) {
    let result = await reader.read();
    if (result.done) {
      console.log("Done accepting unidirectional streams!");
      return;
    }
    let stream = result.value;
    let number = globalThis.streamNumber++;
    readDataFromStream(stream, number);
  }
}

async function readDataFromStream(stream, number) {
  let decoder = new TextDecoderStream('utf-8');
  let reader = stream.readable.pipeThrough(decoder).getReader();
  while (true) {
    let result = await reader.read();
    if (result.done) {
      console.log('Stream #' + number + ' closed');
      return;
    }
    // 受け取った中身を解釈する
    // 今回のアプリケーションでは、quic_transport_id=で始まっている場合、自分のquic_transport_idとするように設計しています
    if (result.value.startsWith('quic_transport_id=')) {
      const index = result.value.indexOf('=');
      document.getElementById('QuicTransportID').value = result.value.slice(index + 1);
    } else {
      console.log(result);
    }
  }
}

以下のGIFのように、コネクションが成立すると、右側に自分のコネクションIDを表示できるように準備しておきます。

output2.gif

6. QuicTransportServerの処理を書く

datagramを受け取りオウム返しQuicTransportServerを実装していきます。

今回は googleがgithubにあげているサンプルを少し改修していきます。

https://github.com/GoogleChrome/samples/blob/gh-pages/quictransport/quic_transport_server.py

今回のアプリケーションで、QuicTransportServerがやる処理を以下に挙げます。

  • QuicTransportServerと接続した際に接続したconnectionのIDをstreamで伝える
  • 映像のデータをdatagramで受け取ると、そのデータをdatagramで送り返す

QuicTransportServerと接続した際に、sendDataHandlerというハンドラをアタッチする

ブラウザ側で new WebTransport(url) を行うと、QuicTransportServerでは、そのクライアント情報をチェックし、接続してきたURLのpathによって処理をルーティングします。

今回は、 quic-transport://localhost:4433/webcodecs_webtransport のように、 webcodecs_webtransport というpathに接続するようになっています。そのため以下のコードでは、pathが webcodecs_webtransport のときは sendDataHandlerというハンドラをアタッチしています。

def process_client_indication(self) -> None:
    """
    ProtocolNegotiated/HandshakeCompletedのeventが送られてきた後に送られるClient_indicationを処理する。
    stream_idが2(クライアント開始・単方向)で、handlerがまだ渡されていない(開始していないstreamである)場合にこの関数が呼ばれる。
    (その時はend_stream=Trueで、self.is_closing_or_closed()がFalseになっているはず)
    new QuicTransport(url);の段階で、clientの情報が送られてくる。その情報はまず`quic_event_received()` で処理される
    """
    KEY_ORIGIN = 0
    KEY_PATH = 1
    indication = dict(
        self.parse_client_indication(io.BytesIO(self.client_indication_data))
    )

    origin = urllib.parse.urlparse(indication[KEY_ORIGIN].decode())
    path = urllib.parse.urlparse(indication[KEY_PATH]).decode()
    # Verify that the origin host is allowed to talk to this server.  This
    # is similar to the CORS (Cross-Origin Resource Sharing) mechanism in
    # HTTP.  See <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>.
    if origin.hostname != "localhost":
        raise Exception("Wrong origin specified")
    # Dispatch the incoming connection based on the path specified in the
    if path.path == "/webcodecs_webtransport":
        self.handler = sendDataHandler(self, self._quic)
        print("handler attached!!!!!!!")
    else:
        raise Exception("Unknown path")

受け取ったdatagramを送信する処理は、

self.connection.send_datagram_frame(payload) でオウム返しするだけです。簡単ですね。

def quic_event_received(self, event: QuicEvent) -> None:
    # こっちは入室時にstreamで自分のIDを教えてあげる様な処理。
    if isinstance(event, StreamDataReceived):
        if event.end_stream:
            response_id = event.stream_id
            payload = event.data
            print("end_stream")
            self.connection.send_stream_data(response_id, payload, True)
        else:
            # Stream_idが0x0ならクライアント開始のbidirectional-stream
            if event.stream_id % 4 == 0 and event.end_stream is False:
                payload = event.data
                self.connection.send_stream_data(event.stream_id, payload, False)
            # stream_idが0x2ならクライアント開始のunidirectional-stream。
            if event.stream_id % 4 == 2 and event.end_stream is False:
                payload = event.data
                response_id = self.connection.get_next_available_stream_id(
                    is_unidirectional=True
                )
                self.connection.send_stream_data(response_id, payload, False)
    # 映像はdatagramで受け取る。
    # datagramを受け取った時の処理はこっち!!!
    # ただオウム返しするだけ。
    if isinstance(event, DatagramFrameReceived):
        payload = event.data
        self.connection.send_datagram_frame(payload)

接続した時の addConnections() や切断した時の removeConnections() は以下のようにしてあります。たくさん接続してきてもきちんと管理できるように、 connections_list という辞書を用意しておき、それぞれのconnectionのuuidでユニークなIDを付与しています。

connections_list = {}

def addConnections(new_connection, new_protocol) -> None:
    print(f"quic_transport_server addConnections{connections_list}")
    new_quic_transport_id = str(uuid.uuid4())
    # 入室情報を他の人に教えてあげる(streamで)
    for quic_transport_id, connection_dict in connections_list.items():
        response_id = connection_dict["connection"].get_next_available_stream_id(
            is_unidirectional=True
        )
        payload = str(f"joined={new_quic_transport_id}").encode("ascii")
        connection_dict["connection"].send_stream_data(response_id, payload, True)
        # connection_dict["protocol"].transmit()

    connections_list[new_quic_transport_id] = {
        "connection": new_connection,
        "protocol": new_protocol,
    }
    # 自分のQuicTransportIDを教えてあげる(streamで)
    response_id = new_connection.get_next_available_stream_id(is_unidirectional=True)
    payload = str(f"quic_transport_id={new_quic_transport_id}").encode("ascii")
    new_connection.send_stream_data(response_id, payload, True)
    new_protocol.transmit()
    for quic_transport_id, connection_dict in connections_list.items():
        # if new_connection == connection_dict["connection"]:
        #     continue
        if new_quic_transport_id == quic_transport_id:
            continue
        response_id = new_connection.get_next_available_stream_id(
            is_unidirectional=True
        )
        payload = str(f"joined={quic_transport_id}").encode("ascii")
        new_connection.send_stream_data(response_id, payload, True)
        new_protocol.transmit()
    return new_quic_transport_id

def removeConnections(connection, protocol) -> None:
    connections_list.remove({"connection": connection, "protocol": protocol})
    print(
        f"quic_transport_server removeConnections removed :{connection},{protocol}, remain: {connections_list} "
    )

ここまでで、QuicTransportと接続する処理と、QuicTransportサーバー側の処理が完了です。

7. 映像のエンコードとデコード

以下の図で、現状を確認してみましょう。QuicTransportサーバーのオウム返しの処理が実装できたので、次は本記事の肝であるWebCodecsの登場です。

WebCodecs2 1.jpg

映像のEncoderの準備

WebCodecsのEncoderを使う際は、いくつかの手順が必要です。

  • 映像をエンコードするEncoderの生成
  • コーデックのconfigをEncoderに付与する
  • 映像を読み込むReaderの生成
  • Readerが読み込みをスタートさせ、読み込んだ映像のframeをEncoderに渡す

new VideoEncoder({output: function~ , error: function~}) とすることでEncoderを生成可能です。(output の部分に、encodeした後そのデータをどうするかを記述します。)

const videoEncoder = new VideoEncoder({
    // Encodeのoutputとしてでてきたデータをどうするかをここで記述します。
    output: function (chunk) {
        // やりたい処理を記述する
        hogehoge()
    },
    error: function () {
        // error時の処理を記述する
        fugafuga()
    },
  }
);

その後、どのようなコーデックを用いるかを .configure() で決めます。今回はVP8を用います。

await videoEncoder.configure({
  codec: 'vp8',
  width: 640,
  height: 480,
  framerate: 30,
});

これでEncoderの準備ができたので、このEncoderに映像データを受け渡してくれるReaderの準備をします。今回のアプリケーションでは、 getUserMedia() したカメラのデータを encoderVideo という名前の <video>タグ のsrcObjectとして設定しているため、そこから映像を読み込みます。

const [videoTrack] = $encoderVideo.srcObject.getVideoTracks() のような形で映像のtrackが取得できるので、それをReaderを生成する時の引数に与えてあげます。

const videoReader = new VideoTrackReader(videoTrack); で生成完了です。

//..getUserMediaしてvideoタグに映像を流している
const localStream = await navigator.mediaDevices
    .getUserMedia({ video: true, audio: false })
    .catch((err) => {
      throw err;
    });
$encoderVideo.srcObject = localStream;
//..ここまで

const [videoTrack] = $encoderVideo.srcObject.getVideoTracks();
const videoReader = new VideoTrackReader(videoTrack);

Readerが生成できたので、その読み込みをスタートさせます。また、その際には読み込んだデータをEncoderに渡してencodeしたいのその記述も併せて行います。

videoReader.start((frame) => {
  videoEncoder.encode(frame);
});

その際に注意が必要なのが、keyframeの取り扱いです。
エンコードした後の映像には、keyframedeltaframeという二種類のデータがあります。

簡潔にまとめると、keyframeはベースとなるデータで、deltaframeはそのベースからの差分になります。映像を1枚1枚送信するのではなく、「ベースとなるデータを決めて、そこからどう映像が変化したか」を送信することによって、データ量の圧縮を行っています。(他にもいろいろな圧縮処理が行われますが、今回は割愛します)

WebCodecsは、低レベルなAPIのため、keyframeとdeltaframeのどちらを生成するかを決めることができます。今回のアプリケーションでは、以下の様に、30秒に1回keyframeをおくり、残りはdeltaframeを送る様にしました。

let reqKeyFrame = false;
let idx = 0;
const interval = 10 * 30; // 10 sec
console.log('start videoReader');
videoReader.start((frame) => {
  const _reqKeyFrame = reqKeyFrame || !(idx++ % interval);
  videoEncoder.encode(frame, { keyFrame: _reqKeyFrame });
  reqKeyFrame = false;
});

ここまでのEncoderの処理をまとめると以下の様なコードになります

const sendVideoWithDatagram = async () => {
  $sendVideoWithDatagramButton.setAttribute('disabled', 'disabled');

  const [videoTrack] = $encoderVideo.srcObject.getVideoTracks();
  let reqKeyFrame = false;
  let sequenceNumber = 0;
  const videoEncoder = new VideoEncoder({
    output: function (chunk) {
      //chunkを送信する処理
      hoge()
    },
    error: function () {
      console.error(arguments);
    },
  });
  await videoEncoder.configure({
    codec: 'vp8',
    width: 640,
    height: 480,
    framerate: 30,
  });
  console.log('make VideoTrackReader');
  const videoReader = new VideoTrackReader(videoTrack);
  let idx = 0;
  const interval = 10 * 30; // 10 sec
  console.log('start videoReader');
  videoReader.start((frame) => {
    const _reqKeyFrame = reqKeyFrame || !(idx++ % interval);
    videoEncoder.encode(frame, { keyFrame: _reqKeyFrame });
    reqKeyFrame = false;
  });
};
$sendVideoWithDatagramButton.onclick = sendVideoWithDatagram;

映像のDecoderの準備

先ほど映像のEncoderの準備ができたので、受け手側のDecoder側の実装も行っておきましょう。

WebCodecsのDecoderを使う際は、次の手順が必要です。(Readerの準備がない分Encoderよりは少し楽です)

  • 映像をデコードするDecoderの生成
  • コーデックのconfigをEncoderに付与する

Decoderの生成はEncoderとほぼ同じ手順で行うことができます。

以下の様な記述をすることで、Decoderを生成可能です。(output の部分に、encodeした後そのデータをどうするかを記述します。)

new VideoDecoder({
  output: hoge(),
  errpr: fuga()
})

受け取った映像データをDecodeした後はcanvasに描画したいので描画する処理も含めて記述します

const videoDecoder = new VideoDecoder({
    // Decodeのoutputとしてでてきたデータをどうするかをここで記述します。
    output: function (chunk) {
      // canvas に描画
      const { codedWidth, codedHeight } = chunk;
      $decoderCanvas.width = codedWidth;
      $decoderCanvas.height = codedHeight;
      const imageBitmap = await chunk.createImageBitmap();
      ctx.drawImage(imageBitmap, 0, 0);
    },
    error: function () {
      // error時の処理を記述する
      fugafuga()
    },
  }
);

その後、どのようなコーデックを用いるかを .configure() で決めます。

await videoDecoder.configure({
  codec: 'vp8'
});

これでDecoderの生成が完了です。

8. 映像データのパケット分割・結合

8.1. Encoder側(送信側)でのパケット分割

以下の図で、現状を確認してみましょう。先ほど映像のエンコード・デコード部分が実装できたので、次はエンコードしたデータをQuicTransportで送受信する部分を記述します。

WebCodecs3.jpg

本来、QuicTransportのdatagramで送信するのは writer.write() で可能なので、そこまで難しくはないのですが、エンコードした映像データをそのままQuicTransportで送信しようとすると、容量が大き過ぎて送れないため、少し工夫が必要です。

WebCodecs4.jpg

パケットサイズはTCPの場合、約1500byteまで、UDPは約1200byteまでです。これ以上のデータを送信する際には分割して送信する必要があります。

また、今回はQuicTransportを使うため、採用プロトコルであるQUICでは何byteまで遅れるのかも確認しておきます。

QUICの仕様が定められている以下のdraftでは、1252byteと書かれています。

https://tools.ietf.org/html/draft-ietf-quic-transport-32#section-14

また、この 1500byte,1200byteというのもこのサイズ内であれば問題ないというわけではなく、通信経路にあるルーターによってはそれ以下のサイズで無ければ送信できない場合があります。

送信できるサイズを調べるために、Path MTU Discoveryという技術もあります。

以下のサイトなどが参考になります

参考: https://milestone-of-se.nesuke.com/nw-basic/as-nw-engineer/udp-mtu-mss/

先ほど 「7. 映像のエンコードとデコード」で述べた様に、Encoderを生成する際に output: の部分にやりたい処理を記述します。

const videoEncoder = new VideoEncoder({
    // Encodeのoutputとしてでてきたデータをどうするかをここで記述します。
    output: function (chunk) {
        // やりたい処理を記述する
        hogehoge()
    },
    error: function () {
        // error時の処理を記述する
        fugafuga()
    },
  }
);

今回、エンコードしたデータをUDPで送信する際に、最大byte数を超えない様にしてあげる必要があります。(厳密には Path MTU Discoveryで送信可能なbyte数を見つけるべきだと思うのですが、今回は行いません)

WebCodecs5.jpg

データを1000byteごとに分割して送信しましょう。

serializeChunk() という関数を定義してあげて、その中で分割&シリアライズを行う様にします。

やっていることは単純で data から、1000byteずつデータを取り出しています。


new Uint8Array(data.slice(i, i + 1000))

その際に、辞書型のデータをシリアライズするための技術としてCBORを使用します。これによって辞書型のデータがバイナリ化されます。

ここで、パケット分割して送信する前に、受け手側でそのパケットをきちんと揃えて結合するための工夫を加えています。これらが無ければ、分割したパケットを揃えることができなくなります。揃えて結合するための情報が必要です。

  • sequenceNumber: どのデータを分割したのかわからなくならない様に番号をつけている
  • packetSize: 分割されたパケットがきちんと全部きたか判別するために、元々のサイズを埋め込んでおく
  • packetNumber: 分割したパケットがもともとどういう順番で並んでいたかがわからなくならない様に番号をつけている
const serializeChunk = (chunk, sequenceNumber) => {
const { type, timestamp, duration, data } = chunk;

const encodedPackets = [];
let i = 0;
const packetSize = Math.ceil(data.byteLength / 1000);
while (i < data.byteLength) {
  const packetNumber = parseInt(i / 1000);
  // CBORという技術を使ってシリアライズしてバイナリに変換
  const encoded = new Uint8Array(
    CBOR.encode({
      type,
      timestamp,
      duration,
      sequenceNumber, // どのデータを分割したのかわからなくならない様に番号をつけている
      packetSize,  // 分割されたパケットがきちんと全部きたか判別するために、元々のサイズを埋め込んでおく
      packetNumber, // 分割したパケットがもともとどういう順番で並んでいたかがわからなくならない様に番号をつけている
      // dataを1000byteごとに取り出している
      data: new Uint8Array(data.slice(i, i + 1000)),
    })
  );
  const size = new Uint8Array(4);
  const view = new DataView(size.buffer);
  view.setUint32(0, encoded.length);

  encodedPackets.push(new Uint8Array([...size, ...encoded]));
  i += 1000;
}
return encodedPackets;
};

CBORという技術はRFC7049 で規定されています。解説記事としてはこちらの記事が参考になります。

ライブラリとして、以下のリポジトリのコードをお借りしました。(このcbor.jsをプロジェクト配下に置いてライブラリとして使える様にしましょう)

https://github.com/paroga/cbor-js

ちなみにCBORを使うアイデアはjxckさんの記事を参考にしました。

シリアライズ・デシリアライズに関してはこちらの記事が参考になります。シリアライズすると以下の様にバイナリ化されます。(QuicTransportでは、バイナリを送信するため、この様な変換処理が必要になります)

// シリアライズ
a = JSON.generate({a: 1})
=> "{\"a\":1}"
// デシリアライズ
JSON.parse(a)
=> {"a"=>1}

引用: http://cloudcafe.tech/?p=2639

これで パケット分割&結合するためのデータ付与をしてくれる serializeChunk() が定義できました。

この関数を使って、Encoderの処理を記述していきましょう。

serializeChunk() 関数にデータを与えて分割した後は、それを送信するだけです。

encodedPackets.forEach((encodedPacket) => writer.write(encodedPacket)); で配列になっている分割パケットを送信してあげましょう

// 分割したパケットを受けて側でまとめて結合するために
let sequenceNumber = 0;
const videoEncoder = new VideoEncoder({
  output: function (chunk) {
    // serializeChunkという関数を定義してあげて、その関数内で1000bytesごとに分割する
    const encodedPackets = serializeChunk(chunk, sequenceNumber);
    if (!globalThis.writer) {
      const ws = globalThis.currentTransport.sendDatagrams();
      const writer = ws.getWriter();
      globalThis.writer = writer;
    }
    // serializeChunk関数の返り値はパケット分割されて配列型になっている.
    // それをfor文で繰り返して送信する
    encodedPackets.forEach((encodedPacket) => writer.write(encodedPacket));
    sequenceNumber += 1;
  },
  error: function () {
    console.error(arguments);
  },
});

少し大変でしたが、これで映像データをパケット分割して送信できる様になりました。

8.2. Decoder側(受信側)でのパケット結合

次はパケット分割して送られてきたデータを受け取って結合&デコードする処理をDecoder側に書いていきます。

パケット分割されたデータを受け取った際、ある程度パケットを貯めておいて、分割されたパケットが全て届いたら結合してからDecoderに渡してあげる処理が必要です。

パケット分割されたdatagramとして送られてくるため、 startReceivingDatagram() という関数を定義してあげて、その中でdatagramの読み取りと、分割されたパケットの保存と結合を行います。

まず、datagramの読み取りは以下の様な形でできます。

datagramを読み込んでくれるreader をQuicTransportオブジェクトから生成し、while文で読み込み続けるだけです。

async function startReceivingDatagram() {
    // QuicTransportオブジェクトから、datagram読み取り用のオブジェクトを取得
    const rs = globalThis.currentTransport.receiveDatagrams();
    // datagramの読み取りを行うreaderの生成
    const reader = rs.getReader();
    // 読み取り開始!
    while (true) {
        const { value, done } = await reader.read();
        if (done) {
           return;
        }
    }
}

面倒なのは、ここから分割されたパケットをまとめる処理です。

まずは受け取ったパケットを保持する chunkQueue と、今どのデータまで受け取ったか確認するための nowSequenceNumber 変数を用意します。

async function startReceivingDatagram() {
  const rs = globalThis.currentTransport.receiveDatagrams();
  const reader = rs.getReader();
  // 追加
  let nowSequenceNumber = null;
  const chunkQueue = [];
  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      return;
    }
    }
}

受け取ったデータは元々は以下の様な形でした。これがEncoder側でシリアライズされバイナリになっているので、受け取ったらまずは元の形に戻してあげたいです。(デシリアライズします)

const encoded = new Uint8Array(
    CBOR.encode({
      type,
      timestamp,
      duration,
      sequenceNumber, // どのデータを分割したのかわからなくならない様に番号をつけている
      packetSize,  // 分割されたパケットがきちんと全部きたか判別するために、元々のサイズを埋め込んでおく
      packetNumber, // 分割したパケットがもともとどういう順番で並んでいたかがわからなくならない様に番号をつけている
      // dataを1000byteごとに取り出している
      data: new Uint8Array(data.slice(i, i + 1000)),
    })
  );

デシリアライズするためには、以下の様な形で CBOR.decode() してあげる必要があります。

async function startReceivingDatagram() {
    const rs = globalThis.currentTransport.receiveDatagrams();
    const reader = rs.getReader();
    let nowSequenceNumber = null;
    const chunkQueue = [];
    while (true) {
    const { value, done } = await reader.read();
        // ここから追加の部分。デシリアライズします
        const view = new DataView(value.buffer);
        let buffer = value.slice(4);
    try {
      const chunk = CBOR.decode(buffer.buffer);
    } catch (error) {
      console.log(error);
      continue;
    }
  }
}

次に、分割されたパケットを番号ごとに整列させて、全て受け取った結合してDecoderに渡してあげる様な処理を記述します。

上記のtry文の中に以下の処理を追加します。(結構長い処理です)

  • 番号を見て次の番号の時は現在番号( nowSequenceNumber)を更新
  • 分割されたデータが全部きたら順番通りに並び替えて(ソートして)マージ。そしてDecoderに渡す

Decoderに渡す前に new EncodedVideoChunk(mergedPacket) の様な形で変換しておくことを忘れないでください。

const chunkPacket = new EncodedVideoChunk(mergedPacket);
globalThis.decoder.decode(chunkPacket);

番号を揃えたりソートしたり現在の番号を確認したり、Decoderに渡したりする処理を記述すると、以下の様な形になります。

try {
  const chunk = CBOR.decode(buffer.buffer);
  // まだ一度もデータを受け取っていない場合は最初に受け取ったデータのsequenceNumberを使う
  if (nowSequenceNumber == null) {
    nowSequenceNumber = chunk.sequenceNumber;
  }
  if (chunk.sequenceNumber - nowSequenceNumber == 1) {
     // 次の番号のパケットがきた場合には、現在のsequenceNumberを更新する。
     nowSequenceNumber = chunk.sequenceNumber;
  }
  if (nowSequenceNumber == chunk.sequenceNumber) {
    // sequenceNumberが同じ時はqueueに入れてサイズが満たされているかチェックする
    chunkQueue.push(chunk);
    if (chunkQueue.length == chunk.packetSize) {
      // [{type: , timestamp: , data: ,},{},{}]みたいな形になってるので、最初のプロパティ郡と全てのdataを取り出す
      // packetNumberがずれて送られてくるかもしれないのでそれを並び替える
      chunkQueue.sort(function (a, b) {
        if (a.packetNumber < b.packetNumber) return -1;
        if (a.packetNumber > b.packetNumber) return 1;
        return 0;
      });

      const mergedPacket = {};
      mergedPacket.type = chunkQueue[0].type;
      mergedPacket.duration = chunkQueue[0].duration;
      mergedPacket.timestamp = chunkQueue[0].timestamp;
      const mergedData = [];
      chunkQueue.forEach((p) => {
        mergedData.push(...p.data);
      });
      chunkQueue.length = 0; // flush chunk queue.
      mergedPacket.data = new Uint8Array(mergedData);
      // ここではまだsequenceNumberやpacketSizeの情報もあるので何かしらの処理を挟んでからdecode
      // 最終的にEncodedVideochunkにしてからデコードする
      const chunkPacket = new EncodedVideoChunk(mergedPacket);
      globalThis.decoder.decode(chunkPacket);
    } else {
      //全てのパケット分届いていないなら待つ
      continue;
    }
  }
} catch (error) {
  console.log(error);
  continue;
}

QUIC(UDP)で送っている以上、パケットが遅れて届いたり、ロスしたりするのは避けられません。その為に再送リクエストを投げたりする様な処理が必要なのですが、今回は間に合いませんでしたので本記事では割愛させていただきます。(リポジトリの最新では少しずつ進めています。)

普段は全く気にしていませんでしたが、今回の様に低レベルAPIを使ったり、仕組みを自作しようとするとデータのやり取りというのはテクニックの塊であることがわかります・・・。

今まで実装してきた部分を図で再確認しましょう。

Encoder、パケット分割&送信、QuicTransportServerでのオウム返し、受信&パケット結合、Decoderと、たくさん記述しました。これで映像通信が可能になりました。

完成物はgithubにもあげているので参考にしてください

WebCodecs3 1.jpg

注意) 本来ならば、他にも考慮しなければいけないケースはたくさんあります。

  • パケットが遅れてきた時(後のパケットが先に届いてしまった時)
  • パケットがロスした時

などです。もっと多くのケースを考慮してバグが起きない様に仕組みを作る必要があるのですが、今回の記事では軽いパケット分割結合のみを実装しています。

商用サービスで使いたいなら、FEC(前方誤り訂正)バッファの処理パケットの冗長性などの仕組みが必要になるので、かなり大変です。

起動!

「4. Google Chromeを引数付きで起動する」で、Google Chromeの起動は済んでいると思うので、QuicTransportサーバーの起動と、HTTP サーバーの起動を行います。

python quictransport_server.py certificate.pem certificate.key
python -m http.server 8000

http://localhost:8000/ にアクセスしてこんな感じになればOKです!

output.gif

あれ・・・音声は?

実はまだGoogle Chrome の WebCodecsにはAudioEncoderが実装されていないのです・・・(Origin Trial期間中なのでこういうこともあります!!)

使おうとするとこんな感じ↓のエラーが。まだ未実装の様ですね

script.js:150 Uncaught (in promise) ReferenceError: AudioEncoder is not define

その為、今回の記事では音声の送受信は行いません。Google ChromeのWebCodecsにAudio Encoderが実装されたら追記しようかな・・・

まとめ

今回の記事では、WebTransportとWebCodecsを組み合わせてビデオチャットアプリを実装してみました。

WebTransportとWebCodecsの使い道について分かったのではないでしょうか。

今回は音声の送受信は行えませんでしたが、音声の場合、映像とはまた一味違うので、バッファの扱いなどがいろいろ変わってきそうで、まともに使える様なビデオ通話アプリに仕上げるのは難しそうですね・・・。WebRTCなどではこれらが内部でよしなにやってくれていたので苦しんだことはなかったのですが、WebCodecsやWebTransportはまだ仕様策定中&ライブラリが充実していないことから自分で実装する必要があります。

今後について

カスタマイズ性・柔軟性と、抽象化の度合い(低レベルAPIか高レベルAPIか)というのはトレードオフです。
いずれライブラリが充実してきたら柔軟性とカスタマイズ性が両立できるようになると思うので、期待して待ちましょう!!

また、今回パケット分割を実装してみましたが、これらを実装するのはすごく勉強になります。メディアの通信でどの様な処理がされているか気になる方はライブラリを作っちゃうのも良さそうです。
今後は再送リクエストFEC(前方誤り訂正)Audio Redundancyなども実装してみようと思います。
Let's リアルタイム通信スペシャリスト!

以上で、「WebTransportとWebCodecsを組み合わせてビデオチャットを実装してみる」記事は終了です。
明日は @a0x41 さんの記事です。お楽しみに!

87
61
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
87
61