そうだ、表情筋選手権しよう。

こちらは「[ライブ配信/ビデオ通話SDK（Agora）を使用したサービスのアイデアを大募集！【PR】V-CUBE Advent Calendar 2021](https://qiita.com/advent-calendar/2021/agora)」 13日目の記事になります。

参加条件の`①こんなライブ配信があったら面白いのでは？`を考えて居たはずが
いつの間にか`②AgoraSDKを触ってみて、感想を投稿下さい！`を書き始めていました。

## はじめに

先日私が所属する会社でリモート忘年会のコンテンツ募集がありました。
そこから色々あり参加表明する形になったので、
何かしら**同時参加型**で楽しめるコンテンツを作れないかボンヤリ思っていました。

同時参加型なのでwebRTC[^1]を使った何か、、、
ただ実装期間も数日しかない中、なるべく実装工数が少ない方法を探していたところ
協賛にあった「[Agora](https://jp.vcube.com/service/agora)」というビデオ通話SDKを使えば簡単にできそうだと感じて制作に乗り出しました。

## Agoraとは

- `Agora（agora io sdk）は、スマートフォン・PCアプリやWebサイトに、カスタマイズしたビデオ・音声通話やライブ配信をかんたんに実装できるSDK`[^2]
    - Android, iOS/Mac, Windows, Web, Electron... など様々なプラットフォームと開発言語に対応[^3]
- 料金はビデオ通話(HD画質)で`$3.99 per/1,000 minutes`となる[^4]
    - トライアルもあり、無料時間として10,000分提供されている[^5]

## 作ったもの

一言にするなら**「表情ビンゴゲーム」**です。

挑戦者はwebカメラを通して真ん中のパネルに自身の表情を映します。
そして表情映像をAIが分析して、各表情に対応した8つのパネルが埋まっていきます。
すべて揃えるとタイマーストップして勝者が決まる、そんなシンプルなゲームです。

もちろん他の人がプレイ風景を見れないと面白くないので
画面共有機能用の映像ストリームを`Agora`で開くことで、
`閲覧者用ページ`で最大4人の挑戦者映像を見える形にしました。

**文字にしても良く分からない**と思うので、実際のプレイ風景を元に概要図を書きました↓

モザイクが付いていない人は、
**今回快く顔出しOK**してくれた同じチームメンバーの [masatomasato1224](https://qiita.com/masatomasato1224)さんです。ありがとう。

![all_conv.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6799/aabe2813-e4b3-a444-82ef-1fbbd9685c07.png)

webカメラの映像が中心に映っており、各パネルにはそれぞれ達成するべき表情が設定されています。
そしてAIが満足するスコアを出した時に、各パネルが埋まるようになっています。

*↓ 実際に動いている様子*
![mm_conv.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6799/56a54954-c60a-df0b-fc63-5a84e619b0f9.gif)

[配信機能を除いたゲームデモを用意](https://agora-rm.d9fvybk2nkccq.amplifyapp.com/)したので良かったら遊んでみてください。
ただ何も制御せずにAgoraを利用すると御顔が**最大100万人**[^6]に配信されてしまう恐れがあるので、
デモゲームでは配信機能を取り除きました。

- デモサイト(配信機能なし)
  - 【URL】 https://agora-rm.d9fvybk2nkccq.amplifyapp.com/
  - 【ID】 agora
  - 【PASS】 agorawebsdk

*今回は検証・実装の手間を省くためにchromeブラウザのみ動作保証しております。*
*GoogleAnalyticsでPV取得する以外、データ取得・通信してないのでご安心ください。*

## 使った技術・環境

- [Agora Web SDK API](https://docs.agora.io/en/Video/API%20Reference/web_ng/index.html)
- [Next.js](https://nextjs.org/)
- [face-api.js](https://github.com/justadudewhohacks/face-api.js/)
- [Amplify](https://aws.amazon.com/jp/amplify/)

表情判定を外部サービスに任せたくなかったので`face-api.js`を用いクライアント処理。

また費用と実装コストを抑えるために`Amplify`を使い、`Next.js`で開発しました。

今回主軸の映像配信について何より実装コストを考え`Agora`を選択したという形です。

## 実装解説

今回実装したコードは[githubに上がってます](https://github.com/TakeshiOnishi/ordinary_person_with_8_faces)ので忘年会等で使ってみてください。

また今回自分が担当したのは画面配信部分と顔認識および描画部分で、
「開始ボタン・タイマー」については[masatomasato1224](https://qiita.com/masatomasato1224)が実装してくれました。
顔出し協力含めありがとう🌝

また`.ts`なのに型定義されてない部分もありますが、そのあたりは手抜きです。

ここからが解説です。
構成要素を大きく分けると`挑戦者画面`と`閲覧者画面`の2つになるので順番に説明していきます。

### 挑戦者画面

配信者画面には9つのパネル(CellComponent)とパネルをまとめたゲームボード(BoardComponent)、
そしてそれらの画面を配信する機能を実装しました。

*基本的な機能はほとんど `Board.tsx` に含めています。*

#### カメラ初期化と顔認識準備

画像認識用モデルをロードした後、webカメラをアクティブにします。
準備ができたら真ん中のパネルにカメラストリームを接続します。

```typescript
const startCam = async () :Promise<void> => {
  await faceapi.nets.ssdMobilenetv1.loadFromUri("/models");    // モデルロード①
  await faceapi.nets.faceExpressionNet.loadFromUri("/models"); // モデルロード②
  await navigator.mediaDevices
    .getUserMedia({ // Webカメラ取得
      video: true,
      audio: false,
    })
    .then((stream) => {
      faceVideoElm.current.srcObject = stream; //真ん中パネルにstream設定
      faceVideoElm.current.play();  // Webカメラ映像取り込み
    })
    .catch((errorMsg) => {
      console.log(errorMsg);
    });
  join();  // 後述のAgora初期化処理
};
```

#### 表情判定とスナップショット

表情判定については [face-api.js](https://github.com/justadudewhohacks/face-api.js)のドキュメントにあるような基本機能しか使っていないです。

```typescript
const expressionResult:WithFaceExpressions<WithFaceDetection<{}>> = await faceapi
  .detectSingleFace(faceVideoElm.current) // 顔判定
  .withFaceExpressions(); // ラベル情報と共に取り出す
```

判定結果が一定しきい値を超えた場合のみ`Ref`を通して各パネルに描画していきます。

```typescript
  const drawCaptureFace = (squareIndex:number) => {
    const captureTmpCanvas = document.createElement("canvas"); // 映像を保持する2次キャンバス
    const captureWidth = faceVideoElm.current.clientWidth;
    const captureHeight = faceVideoElm.current.clientHeight;

    captureTmpCanvas.width = captureWidth;
    captureTmpCanvas.height = captureHeight;
    captureTmpCanvas
      .getContext("2d")
      .drawImage(faceVideoElm.current, 0, 0, captureWidth, captureHeight); // 2次キャンバスに映像描画

    cellRefs.current[squareIndex].current.updateSetImage( // 該当セルのimgSrc更新
      captureTmpCanvas.toDataURL() // 2次キャンバスに映像をdataURLに変換
    );
  };
```

以上でゲームの基本要素は完成です。

#### いざ映像配信

アカウント作成 → SDKを用いた実装 → 配信テストの流れで説明していきます。

##### まずはAgora利用の事前準備

事前に必要な`Agoraの利用申請`や`AgoraWebSDKの準備`は
[公式ガイド](https://jp.vcube.com/service/agora/developer/tutorial/video/web.html)に超丁寧にあるので、そちらを参考にすれば特に困らないかと思います。

##### AgoraWebSDKを用いた実装

AgoraWebSDKをロードして初期化します。

```typescript
import AgoraRTC from 'agora-rtc-sdk-ng'
...
const agoraClient = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
```

あとは[公式APIリファレンス](https://docs.agora.io/en/Video/API%20Reference/web_ng/index.html)を見ながら必要なパラメータを設定していきます。

```typescript
let localTracks = {
  videoTrack: null
};
const options = {
  appid: process.env.NEXT_PUBLIC_AGORA_APP_ID,          // アプリID。Agoraのダッシュボードに表示されてるもの。
  channel: process.env.NEXT_PUBLIC_AGORA_CHANNEL_NAME,  // チャンネル名。このチャンネルに対して動画配信される。
  uid: null,                                            // NULLでOK
  token: process.env.NEXT_PUBLIC_AGORA_TEMP_TOKEN       // Agoraのダッシュボードから作成するtoken (有効期限24時間)
};
```

上記トークンについては管理画面から下記手順で作成[^7]します。 

|赤枠リンクを選択|チャンネル名を書いて`GenerateTempToken`|
|---|---|
|![pic3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6799/2f556fc5-a886-72ef-ae47-2d9efd760c2c.png)|![pic4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6799/783e860a-7ff6-2a6c-4d19-1002ab951644.png)
|

そして今回の配信方法となる画面共有メソッドを指定して、先程のオプション値を設定。 
**なんとこれだけ！** 超お手軽ですね。

```javascript
[ options.uid, localTracks.videoTrack ] = await Promise.all([
  agoraClient.join(options.appid, options.channel, options.token || null),
  AgoraRTC.createScreenVideoTrack({}, "disable") // 画面共有取得
]);
localTracks.videoTrack.play("local-player");
await agoraClient.publish(Object.values(localTracks));
```

今回はゲームボードを共有したかったので
画面共有用メソッド`createScreenVideoTrack`を利用しましたが、[他にも色々ある](https://docs.agora.io/en/Video/API%20Reference/web_ng/index.html#local-audio-and-video-methods)ようです。

以上で「顔認識してパネルが埋まるゲーム」を**プレイしてる画面を配信する**準備ができました。

残りはこの挑戦者画面が**並んだ画面を見る閲覧者画面**の作成です。

### 閲覧者画面

動きとしては誰も配信していない時はLoading画面が出ており、
1人でも配信開始すると画面へ順番に動画が出てきて、誰もいなくなるとLoading画面に再度戻る仕様です。

*基本的な機能はほとんど`Viewer.tsx`に含めています。*

↓**閲覧者画面再掲** 
![mm_conv.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6799/56a54954-c60a-df0b-fc63-5a84e619b0f9.gif)

#### 映像を受信する

まずは配信と同様にSDK初期化します。

実は送受信コードのベース部分はほとんど[公式デモのコード](https://github.com/AgoraIO/API-Examples-Web/blob/fccc2a9d205d3a60294e8d060929b066f8930bde/Demo/shareTheScreen/shareTheScreen.js)を使っています。資料が豊富。

```typescript
import AgoraRTC from 'agora-rtc-sdk-ng'
...
const agoraClient = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
```

あとは受信チャンネル名を指定して`publish`と`unpublish`イベントを監視するメソッドを作成します。

```typescript
agoraClient.on("user-published", handleUserPublished);
agoraClient.on("user-unpublished", handleUserUnpublished);
[ options.uid ] = await Promise.all([
  agoraClient.join(options.appid, options.channel, options.token || null)
]);
```

チャンネルに対して`publish`を検知したら該当メディア情報を取得し、特定IDを持つ要素に描画します。

```typescript
const handleUserPublished = (user, mediaType) => {
  setRemoteUsers((remoteUsers) => [...remoteUsers, user.uid]); // 挑戦者IDリストを管理する内部state. Agoraとは直接関係しない
  subscribe(user, mediaType);
}

const subscribe = async (user, mediaType) => {
  await agoraClient.subscribe(user, mediaType);
  if (mediaType === 'video') {
    user.videoTrack.play(`player-${user.uid}`); // ここで <div id="{`player-${remoteUser}`}" />に対して描画している
  }
}

```

また配信が終了したら不要Component削除をしたいので下記処理でreactの内部stateを更新しています。

```typescript
const handleUserUnpublished = (user) => {
  // 挑戦者IDリストを管理する内部state. Agoraとは直接関係しない
  setRemoteUsers((remoteUsers) => remoteUsers.filter(remoteUser => remoteUser !== user.uid))
}
```

これで閲覧者はページにアクセスして放置しておくだけで、勝手に表情筋デスマッチが開催されます。

## デプロイ

今回はなるべくメイン開発ライン以外は楽をしたかったので[Amplify](https://aws.amazon.com/jp/amplify/)を使いました。
**社内のイベントなのでBASIC認証を手軽に掛けたかった**のも理由です。

Amplifyの利用方法については非常に簡単でここで解説しなくとも、
公式チュートリアル[AWSでReactアプリケーションの構築](https://aws.amazon.com/jp/getting-started/hands-on/build-react-app-amplify-graphql/)を見たほうが早いかと思うので割愛します。
上記リンクの①まで進めれば大体今回の要件を満たせます。

後はAmplifyから払い出されたURLにアクセスするだけでゲーム開始です。

## 余談: Agoraの管理画面について

管理画面ではトークンの発行以外にも、API使用状況も簡単にわかる様になっているので安心ですね。

![pic2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6799/66613625-b06b-488f-5bed-800a0bde2887.png)

上記は今回トライアル用に使ったアカウントの画面ですが
`12/7(火)`から触り始めて`12/9(木)`には実装を終え`12/10(金)`に本番投入してるので
如何に導入しやすいかがわかるかと思います。

ただトライアルが気前よく`10,000分`[^5]なので実際にはAPI利用状況画面をほぼ見ていないです↓

![pic0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6799/e6fa7851-9079-2398-6426-c01840dd5e0e.png)

## 感想

ブラウザ上で遊べるコンテンツを作ったとして、**どうやって複数人で同時に楽しめるか**が悩みでしたが
`Agora`を利用することでバックエンドを気にせず簡単に作成することができました。

また個人的には`アカウントの作成敷居が低い`、`トライアル使用量が多い`、`ドキュメントが豊富`という点が助かりました。

触りやすいだけでなく同時閲覧人数もかなり余裕があり[^6]、
実際のサービス利用としても十分に選択肢として上がってくる性能という印象でした。

また今回作ったシステムでは**共有するコンテンツ自体**を変更すれば何でもできるので、
他の4人対戦コンテンツ配信するときにも活用でき、何気に用途は広いという感想を持ちました。

あとは例のごとく顔を利用した検証が大変で、深夜の共同デバッグでは**狂気じみた**ものがありました🌝

**楽しかったです。**

## 参考文献・関連資料

- [Agoraトライアル方法](https://jp.vcube.com/service/agora/developer/tutorial)
- [Agora Web SDK API Reference](https://docs.agora.io/en/Video/API%20Reference/web_ng/index.html)
- [face-api.js](https://github.com/justadudewhohacks/face-api.js)
- [Amplify](https://aws.amazon.com/jp/amplify/)


[^1]: 厳密にはAgoraはwebRTC互換
[^2]: 公式サイト引用 https://jp.vcube.com/service/agora
[^3]: 公式サイト参照 https://jp.vcube.com/service/agora/developer/sdk  2021/12/13時点
[^4]: 公式サイト参照 https://www.agora.io/en/pricing/ 2021/12/13時点
[^5]: 2021/12/07登録時点
[^6]: [最大100万人に届けられる。そう、Agoraならね。](https://vcube-support.zendesk.com/hc/ja/articles/360034192792-%E6%9C%80%E5%A4%A7%E4%BD%95%E4%BA%BA%E3%81%BE%E3%81%A7%E5%90%8C%E6%99%82%E6%8E%A5%E7%B6%9A%E6%95%B0%E3%81%8C%E5%8F%AF%E8%83%BD%E3%81%A7%E3%81%99%E3%81%8B-) 2021/12/14時点
[^7]: 今回はテストモードで配信していますが、productionモードで配信する際は随時トークンの生成処理も実装する必要があります。またテストモードではトークン有効期限が24時間なので注意が必要です。