9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WebTransportでも4K配信がしたい! 〜序章 : まずはストリーム編〜

Last updated at Posted at 2021-11-29

4Kのエンコードは重い!

今を生きている皆さんこんにちは。
そろそろアドベントカレンダーの季節ですが、そんなことは気にせずガンガン書いていきます!

さて、WebRTCで出来なくてWebTransportに期待されていることの一つに4K配信などの高画質配信があるのではないでしょうか?
WebRTCではブラウザの制限により1.5Mbps - 5Mbpsという制限があるため、フルHDでさえ"もやっと"する画質でした。

2021/12/1 データグラムで割とリアルタイムになりました! (ただしフルHD)

動くには動いたが、、、(数秒程度でカックカク)

とりあえずスクショです。左が配信用画面で右が視聴用画面です。(数秒遅れで数秒だけ再生されて、取得できないフレームがあるためデコーダがエラーを吐く)
Chrome側でバッファリングしているような挙動になるので、タイムスタンプを比較してオンタイムにMediaStreamに流してやらないといけないのかもしれません。
改善点や正しく実装できているか怪しいところも多いので、今回はWebCodecsとの繋ぎ込みとして記事を書いておきます。

今回作ったもの

家に4Kのウェブカメラがなかったので、動画をVideoタグに埋め込み、配信ページと視聴ページに分けて作りました。

ソースコードをgithubにアップしておきました。
(簡単に図にするとただこれだけのことです)

おさらい : WebTransportって何?

WebTransportとはブラウザで低レベルな通信をするためのAPIです。
HTTP/3を利用し、それはQUICの上で動きます。
UDPのように高速で信頼性の低いデータグラムと、TCPとUDPのいいとこ取りをしたストリームがあり、今回はいいとこ取りのストリームを使います。
WebRTCがP2Pなのに対してこっちはサーバー・クライアントモデルです。

今まで書いた記事

プログラム解説

さて、まだまだ課題は多そうですがWebTransport + WebCodecsという一番ホットな組み合わせを作れたということでまとめていきます。

QuicTransportからの差分

QuicTransportでビデオチャットネタは2,3記事くらいあったので、そこから新しくなったところをまず書いておきます。

  • Videoの読み取りは、VideoTrackReader から MediaTrackProcessor になった
  • Audioのエンコーダ・デコーダが実装されいてる
  • 出力するところは MediaTrackGenerator を使う

WebCodecsとはなんぞや?

WebCodescとはブラウザにおいて動画・音声フレームをエンコードしたりデコードしたりするためのAPIです。
Chrome M94にてリリースされました。

WebCodecs APIにはエンコード・デコードのためのインターフェースと、データモデルが定義されています。
入出力のためのAPIはまた別のようです。

以下はWebCodecsとデバイスとデータの流れを示したものです。

ウェブカメラや動画ファイルからは一度デコードされてしまいます。
また動画フォーマットは今はvp8しか実装されていません。
今後のハードウェアサポートやUVC(ウェブカメラのストリーム)周りの改良が期待されるところです。

全体像

用途に応じてWebTransportのコネクションを使い分けます。
具体的にはHTTP/3のパスを用途ごとに分けて接続し、サーバー側で処理を分けます。
こうすることでコネクションの種類ごとに何のデータが通信されるか、処理を分けることができます。

データの取り扱いですが、動画は1フレーム、音声は1dataごとにWebTransportのストリームを一つ消費して送信します。
ストリームを使えばQUIC側でパケットの並び替えや再送などをやってくれるためです。(データグラムより楽)

フロントエンド側

フロントエンド側はエンコード処理を行うためにWebWorkerを使います。
これにより重たいエンコード処理を別スレッドに分けてパフォーマンスを良くすることができます。(わけないとUIが反応しづらくなります)

WebWorkerは一つのjsファイルを別スレッドで起動し、postMessage()でデータをやり取りします。
WebWorkerを起動するには次のようにします。

// workerを読み込む
streamWorker = new Worker("./stream_worker.js");

// worker からのメッセージはログに出力する
streamWorker.addEventListener('message', function(e) {
  addToEventLog(e.data);
}, false);

// エンコード・デコード・ストップなどのコマンドを送る
streamWorker.postMessage({ type: "connect", url, /* ストリーム情報など */}, [/* 所有権を渡すパラメータ */]);
streamWorker.postMessage({ type: "stop" });

WorkerでのWebTransportの処理

コネクションを張り、受信ストリームを設定し、ストリームごとの受信処理を書きます。

WebTransport 配信側概要

まずは配信側です。

接続する -> 1フレーム読み込む -> エンコードする -> 送信する
という順番です。
非同期処理だらけですが、基本的にはエンコードされたデータの順番がおかしくなることはなさそうです。(保証されているかは不明)

type: "connect"でコネクションを開始させ、エンコード処理と配信を開始します。
type: "stop"で配信を停止してコネクションをクローズします。

let wt_video = null;
let wt_audio = null;

self.addEventListener('message', async (e) => {
  const type = e.data.type;

  if (type === "connect") {
    stopped = false;
    const {media: {video, audio}, url} = e.data;

    wt_video = new WebTransport(url + '/video/stream');
    wt_audio = new WebTransport(url + '/audio/stream');
    await wt_video.ready;
    await wt_audio.ready;
    wt_video.closed.then(() => {
        self.postMessage('video Connection closed normally.');
      })
      .catch(() => {
        self.postMessage('video Connection closed abruptl.');
      });
    wt_audio.closed.then(() => {
        self.postMessage('audio Connection closed normally.');
      })
      .catch(() => {
        self.postMessage('audio Connection closed abruptl.');
      });
    // 送信のみなのでストリームの受け入れは不要

    // エンコード処理と送信する処理
    streamVideo(video);
    streamAudio(audio);
    return;
  }
  // コネクションを閉じる
  if (type === "stop") {
    stopped = true;
    wt_video.close();
    wt_audio.close();
  }

}, false)

// 動画をフレームごとに送信する。
async function streamVideo(video) {
  // エンコーダを準備する
  ...
  enoder = new VideoEncoder({
    output: (chunk) => {
      // エンコードされた時のコールバック

      ... // 圧縮されたデータに必要なメタデータをつける

      // フレームを送信する
      sendBinaryData(wt_video, payload);
     ...
    });

  // 動画データを読み込む
  while(true) {
    // stopしたら抜ける
    if (stopped) {
      ...
      return;
    ]
    // 1フレーム読み込む
    ...
    // エンコードする
    encoder.encode(...); // 上記の VideoEncoder()に渡した outputコールバックが呼ばれる
    ...
  }
}
// 音声も同様
...

// バイナリデータを送信する
async function sendBinaryData(transport, data) {
  let stream = await transport.createUnidirectionalStream();
  let writer = stream.getWriter();
  await writer.write(data);
  await writer.close();
}

WebTransport 視聴側概要

次に視聴側です。
ストリームで動画・音声データを受信するようにします。

接続する -> ストリームを待ち受ける -> ストリームから受信する -> デコードする -> VideoTrackに追加する
という順番です。
後ほど詳細は述べますが、受信にタイムラグがあったり、0バイトしか読み出せないことがあったり、Chromeが大量のエラーをプロンプトのほうに吐き出したりと不安定です。
実装上の問題なのかこちらの使い方が悪いのかは追って検証していきます。

// コネクションなどは(このjs内の)グローバル変数に持たせておく
let stopped = false;
let wait_keyframe = true;
let wt_video = null, frameWriter = null;
let wt_audio = null, audioWriter = null;

self.addEventListener('message', async (e) => {
  if (type === "connect") {
    // WebTransportを接続する処理は上記と同じ
    ...
    streamVideo(video); // 動画を受信してデコードする
    streamAudio(audio); // 音声を受信してデコードする

    return;
  }
  ...
})

// ビデオを取得してデコードする
async function streamVideo(video) {
  // デコーダの準備
  let decoder = new VideoDecoder({
    output: (frame) => {
      // デコードされた時のコールバック
      // フレームをvideoタグのVideoTrackに書き込む (videoタグに表示される)
      frameWriter.write(frame);
    }
  })
  ...

    // ストリームを受け付ける
    acceptUnidirectionalStreams(wt_video, async (payload) => {
      // 受信したデータのコールバック

      // データからメタデータを取り出す
      ...

      // key_frameが来るまで待つ
      ...
      // デコードする
      if (!wait_keyframe) {
        decoder.decode(chunk); // デコーダーの output コールバックが呼ばれる
      }
    })
}

WebTransport ストリーム受信処理

データ受信の処理です。
ログを見る限りでは50Bから10KBほどのデータとして取得できますが、(ChromeのQUIC層である程度結合される)
一度に全データを取得できるわけではないので自分でデータを全て結合します。(その割に数秒程度バッファリングされてまとめて処理が走るのが謎)

ストリームを受け付ける処理はリファレンスとほぼ同じです。
echoサンプルではリアルタイムにストリーム入力を受け付けてくれますが、
今回の動画配信だと、サーバーからデータを受け取っても1から10秒くらいは処理を開始してくれません。

// ストリームを受け付ける
async function acceptUnidirectionalStreams(transport, onstream) {
  let reader = transport.incomingUnidirectionalStreams.getReader();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        self.postMessage('Done accepting unidirectional streams!');
        return;
      }
      let stream = value;
      readFromIncomingStream(stream, onstream);
    }
  } catch (e) {
    self.postMessage('Error while accepting streams: ' + e);
  }
}

データを読み込む処理です。
ここでも1/30の確率で0バイトのデータ読み取りになってしまいます。
逆に受信できたデータは、サイズを照合する限りでは送信側やサーバーのサイズと一致しています。
なので上記の問題と合わせてChromeの実装上の問題なのかもしれません。(アクティブでないタブの受信処理は後回しにされる??)

// データを読み込む
async function readFromIncomingStream(stream, onstream) {
  let reader = stream.getReader();
  let payload = new Uint8Array();
  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      // ここではvalueはundefinedになる

      // 1/30くらいの確率で0バイトのデータになることがある?
      if (payload.byteLength == 0) {
        console.log("invalud payload");
        return;
      }
      // 細かい処理はコールバックでやる
      onstream(payload.buffer);
      return;
    }
    // データを結合する
    buffer = new Uint8Array(payload.byteLength + value.byteLength);
    buffer.set(payload, 0);
    buffer.set(new Uint8Array(value), payload.byteLength);
    payload = buffer;
  }
}

エンコードされたデータ構造について

WebTransportで転送するのはEncodedVideoChunkEncodedAudioChunkというデータになります。
動画なら1フレーム、音声なら1ブロックずつエンコードされてきます。
そのため、これらのデータをリアルタイムに送受信できれば良さそうです。

ちなみに、動画にはキーフレームかデルタ(差分)というのもがあり、データ容量を抑えるためにキーフレームからの差分をデータとして扱います。
最近ではあまりみなくなりましたが、破損した動画データを再生すると、動きのある部分がブロックノイズのようになるのをみたことがあると思います。
それはこのキーフレームと差分データによるものです。(キーフレームは一定間隔で送られてくるので、それを受信すると直ります。データ量は10倍くらい差があります)

付随するデータとして (動画のみ)type (key or delta)timestampdurationが必要になります。
そのため、これらのデータを一緒に送信する必要があります。

今回は単純にフレームデータの先頭にくっつけて送ることにします。

key size
type 1byte
timestamp 8byte
duration 8byte
data data.byteLength
配信側
async function streamVideo(video) {
  let encoder = new VideoEncoder({
      output: (chunk) => {
        // データがエンコードされた時
        ...

        // header(17) = type(1byte) + timestamp(8) + duration(8)
        let payload = new ArrayBuffer(17 + chunk.byteLength);
        const view = new DataView(payload);
        view.setUint8(0, (chunk.type === "key" ? 1 : 2));
        view.setBigInt64(1, BigInt(chunk.timestamp)); // 仕様では long long だが実際はNumber
        view.setBigUint64(9, BigInt(chunk.duration)); // 仕様では unsigned long long だが実際はNumber
        chunk.copyTo(new DataView(payload, 17));

        // フレームを送信する
        sendBinaryData(wt_video, payload);

 });
 ...
}

受信する側では逆に必要な情報を取り出します。

受信側
// ビデオを取得してデコードする
async function streamVideo(video) { 
   // デコーダーの準備
   ...
   // 受信した処理のコールバック
   acceptUnidirectionalStreams(wt_video, async (payload) => {

      // payloadからデータを復元する
      // header(17) = type(1byte) + timestamp(8) + duration(8)
      let view = new DataView(payload, 0);
      const type = view.getUint8(0);
      const chunk = new EncodedVideoChunk({
        type: (type === 1 ? 'key' : 'delta'),
        timestamp: Number(view.getBigInt64(1)), // 仕様では long long だが実際はNumber
        duration: Number(view.getBigInt64(9)), // 仕様では unsigned long long だが実際はNumber
        data: new DataView(payload, 17),
      });
      ...
      // デコーダにデータを渡す
      ...
  });
  ...
}

エンコード処理

さて、エンコード処理です。
動画・音声データの読み取りとエンコード処理に分かれます。
ウェブカメラを使う場合であっても getUserMedia()からMediaStreamを取得するのでほぼ同じです。

動画・音声フレーム取得

MediaStreamからVideoTrackもしくはAudioTrackを取得し、MediaStreamTrackProcessorでデータを読み取るためのオブジェクトを生成します。

stream.js
  // 動画を再生したらストリームを開始する。停止したらストリーム配信も止める
  video.onplay = () => {
    const [videoTrack] = document.getElementById('video').captureStream().getVideoTracks();
    const [audioTrack] = document.getElementById('video').captureStream().getAudioTracks();

    const videoProcessor = new MediaStreamTrackProcessor(videoTrack);
    const audioProcessor = new MediaStreamTrackProcessor(audioTrack);

    const frameStream = videoProcessor.readable;
    const audioStream = audioProcessor.readable;
    ...
    // workerに処理を投げる
    streamWorker.postMessage({ type: "connect", url, {video: {stream: frameStrea}, audio: {stream: audioStream}}}, [frameStream, audioStream]);
  };

動画エンコード

この読み取り用オブジェクトからデータを読み取り、エンコーダに渡していきます。

  const frameReader = video.stream.getReader();
  ...
  let encoder = new VideoEncoder({
      output: (chunk) => {
        // 1フレーム送信する (分割・結合はQUICにお任せする)

         ... // データにtypeやtimestamp, durationをつける

        // フレームを送信する
        sendBinaryData(wt_video, payload);
      },
      error: (e) => {
        // エラー処理
      }
    });
  encoder.configure({
    codec: 'vp8', // これしか使えない
    width: video.width,
    height: video.height,
    framerate: 1, // 指定できない
    latencyMode: "realtime", // 特に変わらない
  });

  // データを読み取る
    while(true) {
      // 動画を停止したら処理をやめる
      if (stopped) {
        frameReader.close();
        encoder.close();
        self.postMessage("frame stream stopped.");
        break;
      }

      // WebTransportのストリームの受信と似たような感じ
      const {value, done} = await frameReader.read();
      if (done) {
        self.postMessage("frame stream ended.");
        break;
      }

      var frame = value;
      // 30フレームに一度キーフレームにする
      encoder.encode(frame, {keyFrame: (frameCount % 30 == 0 ? true : false)});
      frame.close();
    }

音声エンコード

音声データの場合もほぼ同様です。

async function streamAudio(audio) {
  const frameReader = audio.stream.getReader();
  self.postMessage('Start audio frame encode.');

  let encoder = new AudioEncoder({
      output: (chunk) => {

        ... // timestamp, durationをバイナリデータに含める

        // フレームを送信する
        sendBinaryData(wt_audio, payload);
      },
      error: (e) => {
        self.postMessage("encoding error. " + e.message)
      }
    });
  encoder.configure({
    codec: 'opus',
    numberOfChannels: 2,
    sampleRate: 48000, // AudioContextからサンプルレートを取得したいがここでは使えない
  });

  // データを取得する
    while(true) {
      // 動画を停止した場合は止める
      if (stopped) {
        frameReader.close();
        encoder.close();
        self.postMessage("frame stream stopped.");
        break;
      }

      // 読み取る
      const {value, done} = await frameReader.read();
      if (done) {
        self.postMessage("frame stream ended.");
        break;
      }
      var frame = value;
      encoder.encode(frame); // エンコードする
      frame.close();
    }
  wt_audio.close();
}

デコードする

デコードの場合はデータ受信 -> デコード ->データ書き込み となるのでやや処理の流れが分かりにくいです。
まずは接続する際にMediaTrackGeneratorでトラックを作成し、これをvideoタグに紐付けます。

動画・音声ストリーム作成

viewer.js
    // WebTransportで接続する
    ...

    // デコードされた動画を受け取るためのストリームを作る
    const videoTrack = new MediaStreamTrackGenerator({ kind: 'video' });
    const audioTrack = new MediaStreamTrackGenerator({ kind: 'audio' });
    const frameStream = videoTrack.writable;
    const audioStream = audioTrack.writable;
    const media = {
      video: {
        stream: frameStream,
      },
      audio: {
        stream: audioStream,
      },
    };
    // workerに受信とデコード処理を投げる
    viewerWorker.postMessage({type: "connect", url, media}, [frameStream, audioStream]);

    // ストリームをビデオタグに設定する
    const stream = new MediaStream();
    stream.addTrack(videoTrack);
    stream.addTrack(audioTrack);
    document.getElementById('video').srcObject = stream;

動画デコード

受信したデータをデコーダに渡し、frameWriterに書き込みます。

    // デコーダーの準備
    frameWriter = video.stream.getWriter();
    let decoder = new VideoDecoder({
        output: (frame) => {
          frameWriter.write(frame); // ここで書き込む
        },
        error: (e) => {
          console.log(e);
        }
      });
    decoder.configure({
      codec: 'vp8', // これしか使えない
      optimizeForLatency: true, // 有効かどうか不明
    });

    // ストリームを受信した時のコールバック
    acceptUnidirectionalStreams(wt_video, async (payload) => {
      ...
      decoder.decode(chunk); // デコードする
    })

音声デコード

音声もほぼ同じです。

async function streamAudio(audio) {

    // デコーダーの準備
    audioWriter = audio.stream.getWriter();
    let frameCount = 0;
    let decodedFrameCount = 0;
    let decoder = new AudioDecoder({
        output: (frame) => {
          audioWriter.write(frame);
          decodedFrameCount++
        },
        error: (e) => {
          console.log(e);
          self.postMessage(e)
        }
      });
    decoder.configure({
      codec: 'opus',
      numberOfChannels: 2,
      sampleRate: 48000, // audioCtx.sampleRate,
    });

    // ストリームを受信した時のコールバック
    acceptUnidirectionalStreams(wt_audio, async (payload) => {
      ... // payloadからchunkを取り出す

      decoder.decode(chunk); // デコードする
    });
}

なお、WebCodecsは8000ピクセルまでのwidthに対応しているようなので、8kも視野に入れているものかと思います。
WebTransportを使わずに別のvideoタグに書き出してみたところ、フルHDの動画ならば問題なくエンコード・デコードできました。

サーバー側

WebTransportが接続されるたびに class WebTransportProtocol(QuicConnectionProtocol): インスタンスが作成されます。

ハンドラの追加

HTTP/3のハンドラに動画・音声のハンドラを追加し、データ受信と配信のためのクラスを追加します。

class WebTransportProtocol(QuicConnectionProtocol):
    def __init__(self, *args, **kwargs) -> None:
       ...
       self._handler = None # pathに応じて ChatHandler, VideoReceiver, VideoSubscriber を使い分ける

    def _handshake_webtransport(self,
        if path == b"/chat":
            assert(self._handler is None)
            self._handler = ChatHandler(stream_id, self._http)
            self._send_response(stream_id, 200)
        elif path == b"/audio/stream": # 音声配信するときにアクセスするパス(サーバーから見れば、音声を受信する)
            assert(self._handler is None)
            self._handler = AudioReceiver(stream_id, self._http)
            self._send_response(stream_id, 200)
        elif path == b"/audio/view":# 音声受信するときにアクセスするパス(サーバーから見れば、音声を配信する)
            assert(self._handler is None)
            self._handler = AudioSubscriber(stream_id, self._http)
            self._send_response(stream_id, 200)
        elif path == b"/video/stream": # 動画配信するときにアクセスするパス
            assert(self._handler is None)
            self._handler = VideoReceiver(stream_id, self._http)
            self._send_response(stream_id, 200)
        elif path == b"/video/view": # 動画視聴するときにアクセスするパス
            assert(self._handler is None)
            self._handler = VideoSubscriber(stream_id, self._http)
            self._send_response(stream_id, 200)

動画・音声を受け取る処理

配信者が動画と音声を配信するための処理を書きます。
サーバーから見ればデータ受信処理になります。
受け取ったデータをそのまま流すだけなので特に難しいことはありません。


# 動画
class VideoReceiver:

    def __init__(self, session_id, http: H3ConnectionWithDatagram) -> None:
        self._session_id = session_id
        self._http = http
        self._buf = defaultdict(bytes)

    def h3_event_received(self, event: H3Event) -> None:
        if isinstance(event, WebTransportStreamDataReceived):
            # 1フレームを1ストリームで送る
            self._buf[event.stream_id] += event.data
            if event.stream_ended:
                broadcast_video(self._buf[event.stream_id])
                self.stream_closed(event.stream_id)

    def stream_closed(self, stream_id) -> None:
        try:
            del self._buf[stream_id]
        except KeyError:
            pass

    def session_closed(self) -> None:
        # ビデオ送信がストップされた
        # 特に何もする必要はない
        return

# 音声
class AudioReceiver:

    def __init__(self, session_id, http: H3ConnectionWithDatagram) -> None:
        self._session_id = session_id
        self._http = http
        self._buf = defaultdict(bytes)

    def h3_event_received(self, event: H3Event) -> None:
        if isinstance(event, WebTransportStreamDataReceived):
            # 1フレームを1ストリームで送る
            self._buf[event.stream_id] += event.data
            if event.stream_ended:
                broadcast_audio(self._buf[event.stream_id])
                self.stream_closed(event.stream_id)

    def stream_closed(self, stream_id) -> None:
        try:
            del self._buf[stream_id]
        except KeyError:
            pass

    def session_closed(self) -> None:
        # 音声送信がストップされた
        # 特に何もする必要はない
        return

動画・音声を配信する処理

サーバーにコネクションリストを持ち、視聴用のパスに接続されたらリストに追加します。
あとは上記の動画・音声データを受信し終わったときに全員に配信します。

# 動画

# video_member = {"connection_id": {'connection': h3_connection, 'session_id': session_id}}
viewers = defaultdict(Any)

def broadcast_video(payload):
    print("send video " +  str(len(payload)))
    for viewer in viewers.values():
        stream_id = viewer['connection'].create_webtransport_stream(
            viewer['session_id'], is_unidirectional=True)
        viewer['connection']._quic.send_stream_data(
            stream_id, payload, end_stream=True)

class VideoSubscriber:
    def __init__(self, session_id, http: H3ConnectionWithDatagram) -> None:
        # viewersに登録する
        print("add viewer.")
        self._connection_id = http._quic.host_cid
        viewers[self._connection_id] = {"connection": http, "session_id": session_id}

    def h3_event_received(self, event: H3Event) -> None:
        # 特に何も受け取らない
        return

    def session_closed(self) -> None:
        # viewersから削除する
        try:
            del viewers[self._connection_id]
        except KeyError:
            pass

# 音声

# listeners = {"connection_id": {'connection': h3_connection, 'session_id': session_id}}
listeners = defaultdict(Any)

def broadcast_audio(payload):
    print("send audio " +  str(len(payload)))
    for viewer in listeners.values():
        stream_id = viewer['connection'].create_webtransport_stream(
            viewer['session_id'], is_unidirectional=True)
        viewer['connection']._quic.send_stream_data(
            stream_id, payload, end_stream=True)

class AudioSubscriber:
    def __init__(self, session_id, http: H3ConnectionWithDatagram) -> None:
        # listenersに登録する
        print("add viewer.")
        self._connection_id = http._quic.host_cid
        listeners[self._connection_id] = {"connection": http, "session_id": session_id}

    def h3_event_received(self, event: H3Event) -> None:
        # 特に何も受け取らない
        return

    def session_closed(self) -> None:
        # listenersから削除する
        try:
            del listeners[self._connection_id]
        except KeyError:
            pass

なお、PythonのサーバーはCPU負荷が高いものの、データが破損することもなくほぼ遅延なく処理を行なっているようです。

スムーズに動画が配信できない。何が原因か

さて、ここまでの処理でエンコード・デコード・配信・受信などの処理ができました。
ただ、冒頭に書いた通り期待通りに動画が再生されることはなく、特にデータ受信が不安定でした。

確認できたこと

  • 負荷が高くて処理が追いつかない
    • $K動画をリアルタイムにエンコードするにはCPUが少し足りない
    • PythonサーバーのCPU負荷が高すぎる (100KBのダミーデータを30フレーム送ると90%程度)
  • 実装が枯れていない
    • ストリームを使っているのにChromeがストリームを読み取るときに空のデータを返すことがある
    • Chromeのストリームの受信バッファが溢れる
  • そもそも考えることが多い
    • 受信側で動画・音声のバッファリングしないといけない?
    • データグラムの場合、RTP的なパケロス対策や誤り訂正などが必要
    • ストリームでもデータ結合やシリアライズが必要
    • 非同期処理周りで何か間違っているかも

どれくらい重たいか

どこの処理が重たいのか、処理を切り出して比較してみました。

4K動画

使用したデータは焚き火を使ってみました。

key value
サイズ 3840 x 2180
fps 29.97
bps 20.80Mbps
フォーマット H264

そもそも4K動画を再生すること自体無理があるのでしょうか?
少しずつ処理を足して確認してみます。

4K動画をvideoタグで再生するだけ

流石にハードウェア支援が効くので全く負荷はありませんでした。
Chrome Helper (GPU) が7.8%使うくらいでした。

フレームを読み取るだけ

デコードされたフレームを読み取る処理はどうでしょうか?
エンコード・送信処理をコメントアウトして試してみます。

タスク CPU使用率
Chrome Helper(Renderer) 8.3%
Chrome Helper(GPU) 7.8%

ほぼ問題ないレベルです。

エンコードするだけ(送信はしない)

VP8はCPUエンコードなので、かなり重たいもののギリギリ30フレーム遅延くらいで処理が間に合っていました。
下のスクリーンショットは4K動画をエンコードだけ行い、送信しなかった場合の30フレームごとのサイズと遅延フレーム数です。

フレームサイズは約60KB、30フレームなので、60KB x 30 = 14.4Mbpsくらいのデータ量です。

遅延フレーム数はおおよそ16 - 20くらいで安定しました。

Read 150 frames. last frame = 1
Read 150 frames. last frame = 22
Read 150 frames. last frame = 16
Read 150 frames. last frame = 16
Read 150 frames. last frame = 16
Read 150 frames. last frame = 21

タスクマネージャーを見ると案の定、CPU負荷が高くなっていました。
スクリーンショット 2021-11-27 22.12.36.png

これにPythonサーバーへの通信処理を加えると、エンコード処理が追いつかずChromeが大量のエラーを出力してフリーズしました。
(last frame = 66, 76 が処理できずに溜まっているフレーム数)

フルHDではどうか?

次に動画をフルHDにしてみました。
遅延フレーム数は1となり、エンコードと送信のCPU負荷は問題ないようです。

ただし、配信開始してから数秒から10秒程度反応せず、そのあと早送りで再生されるような挙動でした。
また、4K配信の時もそうですが1分ほど配信しているとitermの方に下記のエラーが延々と出力されて固まることがありました。

[85146:13571:1127/215410.468886:ERROR:quic_stream.cc(781)] Fin already buffered

Chromeのソースコードを見るとこの辺なので、バッファ周りの処理なのかもしれません。

それではVGAでは?

もっと軽いVGA動画ではどうでしょうか?
試してみたところ、負荷は全く問題ありませんでしたが10秒間隔でバッファリングされたような挙動になり、
期待していたようなスムーズな受信処理ができませんでした。
Chrome側の実装の問題でスケジューリング周りの処理がまだ不安定なだけなら今後の実装である程度スムーズな配信ができるかもしれません。
もし、ブラウザ側である程度バッファリングしてからまとめて処理が走るようであれば、独自にバッファリングしてタイムスタンプを見ながらMediaStreamに流し込む処理を実装する必要がありそうです。

今はローカルでの処理ですし、WebSocketでさえ10秒バッファリングされるようなこともないので、早いところ実装がこなれてくると良いなぁと思います。

WebWorkerだから? とか非同期処理周りなど色々変えてみたりしましたが、
そもそも前回のテキストチャットの時点でリアルタイムではなく数秒のラグがありました。

まとめ

まだデータグラムを試していないので次はそれでやってみます。
ともあれ、エンコーダーが4Kに対応しているのと24Mbpsくらいのダミーデータであれば問題なく送信はできているので
十分に4K配信できるだけのポテンシャルはありそうです。

しかも!
MozillaがRustで実装しているQUICサーバーのNeqoがWebTransport関連のプルリクをマージしたようです!
もし使えるようであれば、いよいよWebTransportサーバーを本格的に実装していけるかもしれません!

WebTransportは元々がクラウドゲーミングの需要から実装が始まっていますが、
自分のパソコンが完全にクラウドになってしまう日も近いのかもしれませんね。

9
5
0

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
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?