ライブ配信システムの構築

nginx と rtmp_module を使用するライブ配信システムの基本的な構成と設定方法について解説します。

この文書は基本的な理解を目的とするため、セキュリティリスクなどを考慮していません。インターネット上に公開するシステムを構築する場合、別途、認証やファイアウォール等のセキュリティ対策を十分に実施してください。

## 構成

- 配信サーバ(nginx + rtmp_module)
- 配信クライアント(OBS Studio)
- 視聴クライアント(Chrome、video.js)

## 通信プロトコルについて

このシステムにおけるサーバ-クラアントの接続は

1. 配信クライアント-配信サーバ
2. 視聴クライアント-配信サーバ

の2つの経路があります。
1 の経路ではストリーミング通信プロトコルとして rtmp(ポート 1935) を使用します。
2 の経路ではストリーミング通信プロトコルとして HLS および MPEG-DASH を扱います。

HLS (HTTP Live Streaming) は Apple が開発したストリーミング規格で、MPEG-DASH (Dynamic Adaptive Streaming Over HTTP) はよりオープンな標準化団体である MPEG によって規定されました。この2つが現在の主要なストリーミングプロトコルです。HLS および MPEG-DASH はいずれも HTTP/HTTPS の上で動作するため、使用するポート番号も 80/443 のみとなります。

## 設定パラメタなど

サーバ-クライアント接続におけるキーやURLを適当に決めておきます。

### 配信設定

|項目|サブ項目|値|
|---|---|---|
|rtmpサーバ||rtmp://(serverIP):1935/live1|
|ストリームキー||live-ch1|
|HLS|hls_path|/var/www/html/live/hls|
|〃|hls_fragment|1s|
|〃|hls_playlist_length|10s|
|〃|hls_type|live|
|MPEG-DASH|dash_path|/var/www/html/live/dash|
|〃|dash_fragment|1s|
|〃|dash_playlist_length|10s|

### 視聴ページ

|項目|値|
|---|---|
|視聴ページ|http://(serverIP)/live/live.html|

### 使用ポート

|アクション|対象|
|---|---|
|許可|80/tcp、1935/tcp|

## サーバ設定

必要なコンポーネントをインストールします。
以下は ubuntu の例です。

```bash
# apt install nginx libnginx-mod-rtmp
```

nginx.conf に配信用の設定を追加します。

```/etc/nginx/nginx.conf
rtmp {
    server {
        listen 1935;
        access_log /var/log/nginx/rtmp_access.log;
         application live1 {
            live on;
            wait_video on;

            hls on;
            hls_path /var/www/html/hls;
            hls_fragment 1s;
            hls_playlist_length 10s;
            hls_type live;

            # dash on;
            # dash_path /var/www/html/dash;
            # dash_fragment 1s;
            # dash_playlist_length 10s;
        }
    }
}
```

上記は HLS 形式で配信する設定例です。
MPEG-DASH を使用する場合は上記 hls_* をコメントアウトして、dash_* のコメントを解除してください。
両方をコメントアウトすると2種類の形式で同時配信されます。

必要に応じて /var/www/html/hls、/var/www/html/dash などのディレクトリを作成し、nginx をリロードします。

必要ならポート解放設定なども行います。

## 配信クライアント(OBS)設定

配信用クライアントPC に OBS Studio をインストールし、webカメラを使用できる状態にしておきます。
インストール手順は省略します。

OBS Studio を起動し、以下を設定します。

|項目|サブ項目|値|
|---|---|---|
|設定-配信|サービス|カスタム|
|〃|サーバー|rtmp://(serverIP):1935/live1|
|〃|ストリームキー|live-ch1|
|設定-出力|エンコーダ設定-キーフレーム間隔|1s|

キーフレーム間隔は「出力モード」を「詳細」に切り替えないと設定欄が表示されないので注意してください。

OBS ではキーフレーム間隔の初期値が (0=自動) となっています。このまま配信を行うと、配信から視聴まで 30 秒程度の遅延が発生します。1 秒とすることで遅延を数秒程度まで抑えられます。キーフレームは画質に影響するようですが、今回は低遅延を優先して 1 秒に設定します。

必要があれば、解像度、ビットレート、CPU使用のプリセットなども設定します。

## 視聴ページの説明

視聴ページはサーバと異なり UI が関係するため実装に幅があります。以下に想定する仕様とサンプル実装を示し、それを元に簡単に説明します。

### 仕様

ライブラリとして video.js を使用します。
視聴者の操作は最小限にして、操作の分かりにくさや誤操作を防止することを目標とします。
具体的には以下の想定で実装します。

- 配信が始まったら自動的に再生を開始する
- 配信が終了したら自動的に再生を停止する
- 誤操作を防止するため、再生中以外はプレイヤーを非表示として代わりに代替画像を表示する
- ブラウザの仕様で音声は自動再生されないため、ミュート解除ボタンを用意する

### ページ例

画面例と実際のコード例です。

![client page](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3761798/11ee189a-51d0-4bf1-9e09-2680779f9ed5.jpeg)

```live.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ライブ配信システム</title>
  <link href="css/video-js.min.css" rel="stylesheet">
  <style type="text/css">
body {
  font-family: sans-serif;
}
.header {
  border: 1px solid #0095d9;
  background-color: #0095d9;
}
.title {
  color: white;
  font-size: 1.5rem;
  text-align: center;
  margin: 10px;
  padding: 0;
}
.content {
  width: 100%;
  margin: 1rem auto;
}
.video-area {
  display: flex;
  justify-content: center;
  align-items: center;
}
.unmute-msg {
  text-align: center;
  margin: 0.5rem 0 0 0;
}
.unmute-btn {
  text-align: center;
  margin: 0.5rem 0 0 0;
}
#videoPlayer {
  border: 1px solid black;
  display: none;
}
#poster {
}
  </style>
</head>
<body>

  <div class="header">
    <p class="title">ライブ配信サンプル</p>
  </div>

  <div id="contentArea" class="content">
    <div id="videoParent" class="video-area">
      <img id="poster" src="./image/poster.png">
      <video-js
          id="videoPlayer"
          class="vjs-default-skin"
          muted>
      </video-js>
    </div>
    <div class="unmute-box">
    <p class="unmute-msg">ライブ配信は無音状態で開始されます。<br>下のボタンを押して音声再生を有効化してください。</p>
    <p class="unmute-btn">
      <button id="unmuteBtn" onclick="unmute()">視聴開始</button>
    </p>
    </div>
  </div>

<script src="js/video.min.js"></script>
<script>

  /**
   * コンテンツURL
   */
  const contentUrl = `http://${new URL(location.href).hostname}/hls/livetest.m3u8`;  // HLS
  //const contentUrl = `http://${new URL(location.href).hostname}/dash/livetest.mpd`;  // mpegDASH
  const contentSource = getContentSource(contentUrl);
  /**
   * videojs プレイヤーオブジェクト
   */
  var player = videojs('videoPlayer');
  /**
   * 前回の再生時間
   */
  var prevPlayTime = 0;
  /**
   * 停止状態の継続回数
   */
  var continueSuspended = 0;
  /**
   * インターバルタイマID
   */
  var intervalId = 0;
  /**
   * 再生遅延タイマID
   */
  var playDelayTimerId = 0;
  /**
   * 映像サイズ
   */
  var videoWidth = 0;
  var videoHeight = 0;
  /**
   * 明示的なミュート解除
   */
  var unmuted = false;
  /**
   * 再生中か
   */
  var isPlaying = false;

  /**
   * video-js タグを作成して videoParent 配下に挿入します。
   */
  function createVideoTag()
  {
    // tag = '<video-js id="videoPlayer" class="vjs-default-skin" muted></video-js>';
    let tag = document.createElement('video-js');
    tag.id = 'videoPlayer';
    tag.className = 'vjs-default-skin';
    if (! unmuted) {
      tag.setAttribute('muted', '');
    }

    let parent = document.getElementById('videoParent');
    parent.appendChild(tag);
  }

  /**
   * video タグを作成して再生を開始する
   * @param {boolean} 明示的な再生指示
   */
  async function playVideo(force = false)
  {
    console.log('playVideo()');
    if (! force) {
      if (! await isEnableUrl(contentUrl)) {
        return;
      }
    }

    // ポスター画像からプレイヤーへの切り替え
    document.getElementById("poster").style.display = 'none';
    document.getElementById("videoPlayer").style.display = 'block';

    // 再生開始
    player.src(contentSource);
    player.play();
    player.controls(true);
    continueSuspended = 0;

    isPlaying = true;

    playDelayTimerId = setInterval(() => {
      // contentSource を設定しても直後は映像サイズを取得できないため、
      // 取得できるまでチェック
      if (player.videoWidth() != 0) {
        videoWidth = player.videoWidth();
        videoHeight = player.videoHeight();
        clearInterval(playDelayTimerId);
        resizePlayer();
      }
    }, 300);
  }

  /**
   * ミュート解除
   * @param {boolean} 明示的なミュート解除
   * Chrome の仕様でユーザアクションなしでの音声再生ができないため、
   * ミュート状態で動画再生を開始し、ユーザアクションでミュート解除する
   * 合わせてミュート解除ボタンを非表示にする
   */
  function unmute(force = true)
  {
    if (force) {
      player.muted(false);
    }
    document.getElementsByClassName("unmute-box")[0].style.display = 'none';
    unmuted = true;
  }
  
  /**
   * プレイヤーを停止してリセットする(表示を poster 画像に戻す)
   * 停止ボタンのクリックイベントとしても機能する
   * 暫定: プレイヤーの停止は動作が安定しないため、代わりにページのリロードを行う
   */
  function stopVideo()
  {
    console.log('stopVideo()');
    // location.reload();
    document.getElementById("poster").style.display = 'block';
    document.getElementById("videoPlayer").style.display = 'none';
    player.dispose();

    isPlaying = false;

    // 次回用に player と関連タグを再作成する
    createVideoTag();
    player = videojs('videoPlayer');
  }
  
  /**
   * 初期化処理
   * インターバルタイマ(2秒間隔)を設定する
   */
  window.onload = () => {
    intervalId = setInterval(intervalFunc, 2000);
  }
  
  /**
   * ブラウザリサイズ時の処理
   */
  window.onresize = () => {
    resizePlayer();
  }

  /**
   * プレイヤーサイズを調整する
   */
  function resizePlayer()
  {
    if (videoWidth == 0 || videoHeight == 0) {
      return;
    }
    const contentElm = document.getElementById("contentArea");
    const contentStyle = window.getComputedStyle(contentElm);
    const contentWidth = parseInt(contentStyle.width.replace('px', ''), 10);

    let width = 0;
    if (videoWidth < contentWidth) {
      width = videoWidth;
    }
    else {
      width = contentWidth;
    }
    player.width(width);
  }

  /**
   * 定期処理(タイマ関数)
   * 一定時間毎に配信の有効性を確認し、必要に応じてプレイヤーの開始/停止を行う
   */
  async function intervalFunc()
  {
    if (! isPlaying) {
      // 未再生時、動画ファイルURLを検出したら配信開始とみなし再生を開始する
      if (await isEnableUrl(contentUrl)) {
        playVideo(true);
      }
    }
      else {
      // 再生中、かつ、一時停止中でない場合に、
      //   1. 2回連続で再生時間が停滞している、かつ、
      //   2. 動画ファイルURLを検出できない
      // の条件を満たす場合、
      // 配信終了とみなしてプレイヤーをリセットする
      if (! player.paused()) {
        if (prevPlayTime == player.currentTime()) {
          continueSuspended++;
          if (continueSuspended >= 2) {
            if (! await isEnableUrl(contentUrl)) {
              stopVideo();
              return;
            }
          }
        }
        else {
          prevPlayTime = player.currentTime()
          continueSuspended = 0;
        }
        // プレイヤーからミュート解除した場合もボタンの非表示は必要
        if (! unmuted) {
          if (! player.muted()) {
            unmute(false);
          }
        }
      }
    }
  }

  /**
   * 指定されたURLのコンテンツの有無を確認する。
   * @param {string} url ストリーミングのインデックスファイル
   */
  async function isEnableUrl(url)
  {
    try {
      const response = await fetch(url, {cache: "no-store"})
      if (response.status >= 200 && response.status < 300) {
        return true;
      }
    }
    catch(e) {
    }
    return false;
  }

  /**
   * 指定されたURLの拡張子を判定し、type 情報を付加した contentSource を返す。
   * @param {string} url ストリーミングのインデックスファイル
   *
   * 拡張子は m3u8 または mpd であること。
   * ex)
   * {type: 'application/x-mpegURL', src: 'http://example.com/hls/xx.m3u8'}
   * {type: 'application/dash+xml', src: 'http://example.com/dash/xx.mpd'}
   */
  function getContentSource(url)
  {
    const ext = url.split('.').pop();
    if (ext == 'm3u8') {
      return {type: 'application/x-mpegURL', src: url};
    }
    else if (ext == 'mpd') {
      return {type: 'application/dash+xml', src: url};
    }
    return {type: '', src: url};
  }

</script>
</body>
</html>
```

### 自動再生

コンテンツ URL への接続性を定期的にポーリングして確認します。未再生状態、かつ、コンテンツ URL に接続可能なら再生を開始します。

このコンテンツ URL とは、視聴ページではなく、～.m3u8 のような配信ストリーミング自体のインデクスファイルを指します。このファイルは配信が開始されると作成され終了すると削除されます。これを監視することで配信中を識別します。

### 自動停止

コンテンツ URL への接続性を定期的にポーリングして確認します。再生中、かつ、コンテンツ URL に接続不可であれば、再生を停止します。

videojs プレイヤーには ended イベントが存在し、配信終了に関しては本来このイベントで処理するようです。ただ、ended イベントは、ストリームの EXT-X-ENDLIST タグを認識して呼び出されるようですが、前述のサーバ設定では、ストリームに該当タグは付加されません。結果、イベントが発生しません。

正しい対応としては、EXT-X-ENDLIST タグを付加するように追加のサーバ設定を行うのが良さそうですが、今回は面倒なのでポーリングで妥協します。

### ミュート解除ボタン

Chrome バージョン 70 以降、セキュリティポリシーの問題で音声の自動再生が許可されません。
再生にはユーザアクションが必要ですが、プレイヤー上でのミュート解除は分かりにくいため、プレイヤーをミュート状態で開始し、別途ミュート解除ボタンを配置してミュート解除させます。

### 再生終了時の処理

配信の開始時にプレイヤーを videojs() で作成し、配信終了時には player.dispose() でプレイヤーを削除します。

ただ、dispose() の削除は player オブジェクトの中身の初期化だけでなく、video-js タグ自体を削除してしまうようです。そのため、同じページで再度動画再生を行う場合、video-js タグを作り直す必要があることに注意が必要です。

ページ内に他の機能がないのであれば、dispose()+タグ再作成を行う代わりにページのリロードで初期化した方がより単純で安全かもしれません。