🛠️

WebRTCの通信をMPEG-DASHで効率よく配信する方法

2022/12/16に公開

この記事は、 NTT Communications Advent Calendar 2022 17 日目の記事です。


近年、従来の HTTP ベースの映像配信プロトコルに対してより低遅延な WebRTC ベースの配信プロトコルが登場するなどして、映像配信技術の選択肢が増えてきています。
WebRTC ベースの配信プロトコルは非常に低遅延である特徴を持っている一方で追いかけ再生ができないなどの制約もあります。
そのため、要件によっては WebRTC ベースの配信と、HTTP ベースの配信を併用するケースもあるでしょう。
本記事ではできるだけ少ない処理量で効率よく WebRTC の通信を HTTP ベースの配信プロトコルである MPEG-DASH に変換して配信する方法を紹介します。

なぜ MPEG-DASH

HTTP ベースの配信プロトコルは HLS のシェアが大きいです。しかし HLS は利用できるコーデックとコンテナに制限があるので、多種多様なコーデックを利用できる WebRTC の実力を最大限活かすことができません。

一方で MPEG-DASH は利用できるコーデックに制限がなく、コンテナも MP4 だけでなく、WEBM も利用できます。
個人的に WEBM の方が仕組みをよく知っているので WEBM が使えると助かります。

ワークフロー

MPEG-DASH による配信と視聴の主な流れは次のようになります。

配信

視聴

この流れに従って WebRTC の通信を MPEG-DASH に変換するサンプルアプリを作りながら詳細について見ていきます。

文中のサンプルコードは一部を省略しています。

動作する完全なサンプルアプリはこちらにアップロードしています。

https://github.com/shinyoshiaki/webrtc-dash-example/tree/article

RTP を取り出す

最初に、ブラウザとサーバ間を WebRTC で通信してサーバ側で RTP を取り出します。

サンプルコード

サーバ

server/main.ts
import {
  RTCPeerConnection,
} from "werift";
import { Server } from "ws";

const signalingServer = new Server({ port: signalingServerPort });
signalingServer.on("connection", async (socket) => {
  const { audio, video } = await recorder();

  const pc = new RTCPeerConnection();

  pc.addTransceiver("audio", { direction: "recvonly" }).onTrack.subscribe(
    (track) => {
      // 音声のRTPを受け取る
      track.onReceiveRtp.subscribe((rtp) => {
        audio.input(rtp);
      });
    }
  );

  pc.addTransceiver("video", { direction: "recvonly" }).onTrack.subscribe(
    (track, transceiver) => {
      // 映像のRTPを受け取る
      track.onReceiveRtp.subscribe((rtp) => {
        video.input(rtp);
      });
      // 5秒ごとにキーフレームを要求する
      setInterval(() => {
        transceiver.receiver.sendRtcpPLI(track.ssrc!);
      }, 5_000);
    }
  );

  const sdp = await pc.setLocalDescription(await pc.createOffer());
  socket.send(JSON.stringify(sdp));

  socket.on("message", (data: any) => {
    const obj = JSON.parse(data);
    if (obj.sdp) {
      pc.setRemoteDescription(obj);
    }
  });
});

ブラウザ

client/main.tsx
const socket = new WebSocket("ws://localhost:8888");
await new Promise((r) => (socket.onopen = r));

const offer = await new Promise<any>(
  (r) => (socket.onmessage = (ev) => r(JSON.parse(ev.data)))
);

const peer = new RTCPeerConnection({});

const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});
const [audio] = stream.getAudioTracks();
const [video] = stream.getVideoTracks();
peer.addTrack(audio);
peer.addTrack(video);

await peer.setRemoteDescription(offer);
const answer = await peer.createAnswer();
await peer.setLocalDescription(answer);
socket.send(JSON.stringify(peer.localDescription));

次に MPEG-DASH のマニュフェストファイルである MPD ファイルを作ります

MPD ファイルを作る

MPEG-DASH は「マニュフェストファイル」と「セグメントファイル」によって構成されています。

セグメントファイルは分割されたメディアファイルの断片で、マニュフェストファイルは映像の情報やセグメントファイルのアクセス方法について記述したファイルです。

MPEG-DASH ではマニュフェストファイルに XML によって記述される 「MPD」 を採用しています。
Dash.js などの MPEG-DASH の再生プレイヤーはこの MPD ファイルをエントリポイントとして映像を再生します。

MPD ファイルの構造は次のような記事が参考になります。

MPD のオフィシャルな仕様は「ISO_IEC 23009-1_2022 ed.5 」で定義されています。

今回は次のような MPD ファイルを用意しました。
一旦 JSON で書いてからxmlbuilder2というライブラリで XML に変換しています。

server/mpd.ts
{
  ...toAttributes({
    "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
    xmlns: "urn:mpeg:dash:schema:mpd:2011",
    "xsi:schemaLocation": "urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd",
    profiles:
      "urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash-if-simple",
    type: "dynamic",
    availabilityStartTime: this.availabilityStartTime,
    publishTime: this.publishTime,
    minimumUpdatePeriod: `PT${this.minimumUpdatePeriod}S`,
    minBufferTime: `PT${this.minBufferTime}S`,
  }),
  Period: {
    ...toAttributes({ start: "PT0S", id: "1" }),
    AdaptationSet: {
      ...toAttributes({ mimeType: "video/webm" }),
      ContentComponent: {
        ...toAttributes({ contentType: "video", id: 1 }),
      },
      Representation: {
        ...toAttributes({
          id: "1",
          width: this.width,
          height: this.height,
          codecs: this.codecs.join(","),
        }),
        SegmentTemplate: {
          ...toAttributes({
            timescale: 1000,
            initialization: this.initialization,
            media: this.media,
            presentationTimeOffset: 0,
          }),
          SegmentTimeline: {
            S: this.segmentationTimeLine.map((s) => ({
              ...toAttributes({ d: s.d, t: s.t }),
            })),
          },
        },
      },
    },
  },
  UTCTiming: {
    ...toAttributes({
      schemeIdUri: "urn:mpeg:dash:utc:http-iso:2014",
      value: "https://time.akamai.com/?iso&ms",
    }),
  },
}

このファイルの注目点を上から順番に見ていくと

1 つ目は attribute の type と minimumUpdatePeriod です。
type には dynamic が指定されており、この MPD ファイルはライブ配信することを示しています。
minimumUpdatePeriod は MPD ファイルが更新される周期を示しています。
MPD ファイルには映像のセグメント情報が順次追加され、ファイル自体が更新されます。

2 つ目の注目点は Period.AdaptionSet.Representation.SegmentTemplate.SegmentTimeline です。
MPEG-DASH with Webm では Segment をキーフレーム毎に作る必要があります。
キーフレームの間隔が一定なら SegmentTimeline を使わないシンプルな構成を採用できます。
しかし WebRTC はキーフレームの間隔が一定ではないので、サンプルコードのようにセグメント毎にそのセグメントの再生時間を設定する必要があります。


今回は、かなりシンプルな配信構成を採用しており、画質選択機能には対応していません。
画質選択機能は WebRTC の Simulcast 機能を活用すれば実現可能だと思われます。その際の注意点について列挙します。

  • WEBM コンテナに Audio と Video の両方を入れるのではなく、別々の WEBM コンテナで扱う
  • Period.AdaptionSetを Audio と Video の 2 つ分用意する
  • Period.AdaptionSet.Representationを Simulcast のレイヤ分用意する

また、Simulcast は設定したレイヤの数に対して実際に送信されるレイヤの数が常にイコールと限らない(通信状況によっては送信するレイヤの数が制限される)ので、それについても考慮する必要があります。

RTP から WEBM へ

このサンプルアプリでは WEBM を MPEG-DASH のセグメントファイルのコンテナに採用しています。一般的には MP4 がコンテナに使われることが多いそうです。

MPEG-DASH で使う WEBM ファイルは一般的な WEBM ファイルと構造が異なります。

DASH 用の WEBM の構造の詳細は YouTube のガイドラインがとても参考になります。
https://developers.google.com/youtube/v3/live/guides/encoding-with-dash#webm-segments

主なルールを列挙します。

  • WEBM ファイルを WEBM の Segment(DASH のセグメントのことではありません)のヘッダー部とそれ以降の複数のクラスターに分け、それぞれをファイルとして保存し、拡張子を.webm とする必要があります。
  • ヘッダー部のファイル名を MPD ファイルのPeriod.AdaptationSet.Representation.SegmentTemplateinitializationattribute に入力する必要があります。
  • MPD ファイルのPeriod.AdaptationSet.Representation.SegmentTemplatemediaattribute が「media$Time$.webm」になっているため、クラスターのファイル名にはタイムスタンプを入れる必要があります。

拡張子は webm であるものの WEBM コンテナとしては不完全なので一般的な動画プレイヤーでは再生できない、DASH 専用のファイルとなります。ただし生成した全ての webm ファイルを順番通りに結合して 1 つのファイルにすれば完全な webm ファイルになるので一般的な動画プレイヤーで再生できます。

サンプルコード

前処理

server/main.ts
const audio = new RtpSourceCallback();
const video = new RtpSourceCallback();

{
  const depacketizer = new DepacketizeCallback("opus");
  audio.pipe(depacketizer.input);
  depacketizer.pipe(avBuffer.inputAudio);
  avBuffer.pipeAudio(webm.inputAudio);
}
{
  const depacketizer = new DepacketizeCallback("vp8", {
    isFinalPacketInSequence: (h) => h.marker,
  });
  video.pipe(depacketizer.input);
  depacketizer.pipe(avBuffer.inputVideo);
  avBuffer.pipeVideo(webm.inputVideo);
}

RTP のパケットをパイプライン処理しています。

最後の avBuffer は audio パケットと video パケットを同一のタイムラインで扱い、それぞれの
パケットのタイムスタンプが前後しないように制御しています。
主要な mpeg-dash プレイヤーである dash.js は webm のクラスター内でタイムスタンプが前後することを厳密に許さないので、このような処理が必要となります。

MPEG-DASH 用の webm ファイルを生成

server/main.ts
async function recorder() {
  let timestamp = 0;
  const queue = new PromiseQueue();
  webm.pipe(async (value) => {
    queue.push(async () => {
      if (value.saveToFile && value.kind) {
        switch (value.kind) {
          case "initial":
            {
              // webmのSegmentのヘッダー部
              await writeFile(dir + "/init.webm", value.saveToFile);
            }
            break;
          case "cluster":
            {
              if (value.previousDuration! > 0) {
                // MPDにクラスターの長さを書き込む
                mpd.segmentationTimeLine.push({
                  d: value.previousDuration!,
                  t: timestamp,
                });
                await writeFile(dir + "/dash.mpd", mpd.build());
                // 一時保存していたクラスターをDASH用にリネームする
                // ファイル名はMPDで定義したとおりにする。
                await rename(
                  dir + "/cluster.webm",
                  dir + `/media${timestamp}.webm`
                );
                timestamp += value.previousDuration!;
              }

              // クラスターを一時保存する
              await writeFile(dir + `/cluster.webm`, value.saveToFile);
            }
            break;
          case "block":
            {
              // 個々のブロックはクラスターの一時保存先に足していく
              await appendFile(dir + `/cluster.webm`, value.saveToFile);
            }
            break;
        }
      }
    });
  });
}

コード中のコメントの通りのことをしています。

MPEG-DASH 配信

これまでに作った MPD ファイルと WEBM ファイル群を HTTP ファイルサーバで公開すれば、Dash.js などの再生プレイヤーから視聴できるようになります。

↑ の再生プレイヤーでしか動作を確認してません

サンプルコード

import { createServer } from "http";

const dashServer = createServer();
dashServer.on("request", async (req, res) => {
  const filePath = dir + req.url;

  const extname = String(path.extname(filePath)).toLowerCase();
  const mimeTypes = { ".mpd": "application/dash+xml", ".webm": "video/webm" };

  if (extname === ".mpd") {
    await writeFile(dir + "/dash.mpd", mpd.build());
  }

  try {
    const file = await readFile(filePath);
    res.writeHead(200, { "Content-Type": mimeTypes[extname] });
    res.end(file);
  } catch (error) {
    res.writeHead(404);
    res.end();
  }
});
dashServer.listen(dashServerPort);

ファイルサーバは HTTPS に対応していないと Dash.js では再生できないので、動作確認目的なら手軽に ngrok などで https のエンドポイントを用意すると良いでしょう。

最後に

WebRTC ベースの配信規格である WHEP[https://www.ietf.org/id/draft-murillo-whep-01.html]の提案書で MPEG-DASH が触れられており、DASH は WebRTC による映像配信に足りないピースを埋める重要な技術であることがわかります。

HLS と違い、使うことのできるコーデックに縛りがないため、WebRTC から DASH へ変換する過程で、映像の再エンコードの必要がなく、処理効率が非常に高いことも魅力的です。

DASH を通して配信技術にも興味が出てきたので今後も追っていこうと思います。

Discussion