オンラインカンファレンス向け事前収録システムを作った #iosdc

9/19〜9/21にiOSDC Japan 2020を主催しました。今年の開催は初のオンライン開催で、レギュラートーク(20分・40分)はすべて事前収録とし、当日は編集済の動画を配信する方法を採りました。

このエントリではiOSDC Japan 2020のために構築した事前収録システムについてその構成やハマりどころを解説します。

TL;DR

2017年から開発・メンテナンスしているカンファレンス運営支援システムの fortee に収録 & 編集機能を実装しました。1

いくつかのサービスのAPIやWebhookを使って以下の様なことをしています。

収録予約

  • スタッフは fortee に「レコーディングスロット」を作る 2 3
  • スピーカーは fortee で都合の良い時間のレコーディングスロットを予約する (①)

収録開始

  • レコーディングスロットの開始時刻になったら、fortee はZoomにミーティングを作成し、ミーティングにYouTube Liveへのストリーミング設定をする (②)
  • スピーカーはZoomに接続し、YouTube Liveで画面共有やマイクの設定が正しくできているかを確認、トークを収録する (③)
  • ミーティング中は画面共有とスピーカー顔カメラを個別にZoomクラウド録画する

収録終了

  • レコーディングスロットの終了時刻になるかスピーカーがミーティングから退出したら fortee はZoomミーティングとYouTube Liveを終了する

編集

  • スタッフはクラウド録画をダウンロードして、切り出しするIN点/OUT点を fortee に登録する (④)
  • 変換サーバで編集処理が実行される (⑤, ⑥)
    • Zoomからクラウド録画をダウンロード
    • 画面共有動画、スピーカー顔カメラ動画をフレーム画像(トークタイトルやスピーカー名、スポンサーロゴなどを載せた画像)にはめこみ合成
    • IN点/OUT点の間を切り出す
    • カバー画像から無音の動画を生成して切り出した動画の先頭に追加
    • 出来上がった動画をDropboxの共有フォルダにアップロード
  • Dropboxにアップされた動画をスタッフが確認して編集完了 (⑦)

今回のヒットはクラウド録画を使うところでした。

クラウド録画を使うことでPCの数など物理的な制約にかかわらず並行して収録でき、またクラウド録画はすべて同一の動画フォーマットで記録されるため、動画フォーマット決め打ちで編集処理できました。4

ここから先はもっと細かいことに興味がある方向けのドキュメントです。同じ様なシステムを作りたい方にしか役に立たない気がしますが、お好きな方は先にお進みください!

関連システムとの連携

今回のシステムはZoom, YouTube Live, Dropboxと連携しています。この中でもZoomとYouTube Liveについて特長を解説します。

Zoomとの連携

ZoomのAPI操作 – JWTの使用

ZoomのAPIはoAuth 2.0で得られるCredentialまたはJWT形式のAPI Key/API Secretを使って実行できます。

特定システムからAPIを実行する様なケースではJWTを使用するのがZoom的にも想定されている様ですので、今回はJWTを使用しました。5

Webhook

Zoomはイベント発生時にWebhookで連携システムに通知することができます。WebhookのエンドポイントURLは前述のAPI Key/API Secretを作成する際に指定します。

ZoomのWebhookは3秒以内に200または204を返す必要があり、返せないと5分後に再度同じWebhookが実行されます。

これを知らないと「なぜ同じWebhookが2回来るんだ…」と悩んだり、うっかり2回目の実行がエラーになって500を返し、無限にリトライすることになったりします。

参考: Webhooks

Zoomのアカウント

Zoomはアカウントにユーザを追加することができます。主アカウントと追加したユーザにはそれぞれ有料ライセンスを追加することができます。

今回は主アカウントと追加の1ユーザを有料化し、2つのミーティングを並行して実行可能にしました。これらのユーザアカウントはAPI Key/API Secretを共有します。

有料アカウントはクラウド録画の容量を1GB持っていますが、今回は合計2GBでは不足するため500GBを追加しました。6

YouTube Live との連携

YouTube Liveはスピーカーから画面共有やマイクの設定が正しくできているかを確認するため、および万が一クラウド録画が失敗した時のバックアップ録画のために使用しています。

YouTube LiveのAPI操作

YouTube LiveのAPIはGoogleサービス関連API共通のoAuth経由で得られるCredentialを使って実行します。

fortee は以前からアップロードしたYouTube動画にタイトルや説明を付与するためのYouTube用oAuth連携や、スタッフ間でのファイル共有のためのGoogleドライブ用oAuth連携の機能があったので、それに使用していたプロジェクトを使用しました。

ライブ配信とストリームキー

YouTube Liveはライブ配信の設定値としてストリームキーを持ちます。Zoomにはストリームキーと(おそらく全ライブ配信で共通の)ストリームURLを指定することになります。

YouTube Liveは1アカウントで複数のライブ配信を同時に実施できるのですが、ストリームキーはそれぞれのライブ配信にユニークに使用する必要があります。

今回はZoomユーザの数ぶんだけストリームキーを作成して、Zoomユーザと1:1対応する様に使いました。

当初、YouTube Liveが持っている「ストリームが途絶えたら自動でライブ配信を終了」という設定に頼った設計をしていたのですが、レコーディングスロット終了時にライブ配信が終了しないという現象に悩まされました。

この現象が発生すると次のレコーディングスロットが開始しても1つ前のライブ配信にストリームが流れてしまいます。すると新しく始まったレコーディングスロット用に作ったライブ配信にストリームが流れず、画面共有やマイクの設定が確認できなくなってしまいます。

全ての収録で同じライブ配信を使えばこの問題は発生しないのですが、誰にも見られない状態で落ち着いて収録して欲しく、レコーディングスロットごとにライブ配信を作成するのにこだわって試行錯誤しました。

最終的には「ライブ配信を作る」「ライブ配信にストリームキーをバインドする」「ライブ配信からストリームキーをアンバインドする」「ライブ配信を終了する」と1つずつまごころを込めて設定することで安定して個別のライブ配信にストリームを流すことができる様になりました。

処理の詳細

ここからはステップごとの処理の詳細を解説します。

収録開始処理

スピーカーが予約したレコーディングスロットの開始時刻になった時に fortee がする一連の処理です。fortee はこの一連の処理後に、スピーカーにZoomとYouTube LiveのURLを表示します。

ミーティング作成

ミーティングの作成にはCreate a Meeting APIを使用します。

このAPIはURLで /users/{userId}/meetings の形でユーザIDを指定します。複数ユーザを使用する場合はここでミーティングのホストになるユーザIDを指定することになります。

リクエストボディはこんな感じ。

'body' => json_encode([
    'topic' => $topic,
    'type' => 2, // 2: scheduled meeting
    'start_time' => (new Time())->i18nFormat('yyyy-MM-ddTHH:mm:ss'),
    'password' => $password,
    'agenda' => $agenda,
    'timezone' => 'Asia/Tokyo',
    'duration' => 3,
    'settings' => [
        'participant_video' => true,
        'join_before_host' => true,
        'auto_recording' => 'cloud',
        'waiting_room' => false,
    ]

前半は特に見るべきところは無く、キモは'join_before_host' => true 'auto_recording' => 'cloud' 'waiting_room' => false あたりです。

それぞれ意味するのは「ホスト参加より前に参加可能」「自動でクラウド録画を開始する」「待機ルームを使わない」です。今回の利用方法だとホストの参加なしに進行し、クラウド録画をしたいのでこの3つの指定は必須です。

今見ると'duration' => 3 が意味不明…これは「Meeting duration (minutes).」なのでレコーディングスロットの長さを渡すべきで、なんでこんな風に書いてるんだろう…。

Create a Meeting API でミーティングを作成すると、レスポンスでミーティングを開始するための start_url と、ミーティングに参加するための join_url が得られます。

ドキュメントによると、まず start_url で参加してから join_url で参加する様に読めるのですが、いきなり join_url で参加してもミーティングは開始されたので、今回の実装では join_url のみ使用しました。

ライブ配信作成 & ストリームをバインド

YouTube Liveの LiveBroadcasts: insert を使用してライブ配信を作成します。

得意注意すべき事項は無いのですが LiveBroadcasts: insert のパラメタ contentDetails.boundStreamId は設定には使えない様で、指定してもストリームはバインドされませんでした。そのため、ライブ配信を作る→ストリームをバインドする(LiveBroadcasts: bind)、と2回APIを実行しています。

URL表示

ZoomとYouTube Liveの準備ができたらスピーカーにZoomのjoin_urlとYouTube Liveのライブ配信URLを表示します。スピーカーはZoomのミーティングに参加し、YouTube Liveのライブ配信を確認します。

ライブ配信開始

前段の処理で fortee システムはスピーカーの参加を待っている状態になります。

スピーカーがZoomミーティングに参加したら fortee はYouTubeへのライブストリーミングを開始します。

ZoomのWebhook

ZoomはWebhookでいくつかのイベントを連携システムに通知できます。今回はミーティングの開始と終了、ミーティングへのユーザの参加と退出、の4つのWebhookを使用しました。

開始処理ではそのうち2つ、ミーティングの開始(meeting.started)とユーザの参加(meeting.participant_joined)を使用しています。

今回の利用方法だとこの2つのWebhookは同時に発生します。fortee では meeting.started で収録に必要な処理をし、 meeting.participant_joined ではスピーカー参加時刻の記録のみしています。

ZoomのWebhookは3秒以内に200または204を返す必要があり、返せないと5分後に同じWebhookがリクエストされます。今回はシステムをPHPで実装しており非同期処理には向いていないためあまり工夫もせず実装していて、ちょいちょい3秒越えしています。APIをいくつかコールすると簡単に超えてしまうので結構厳しいですよね…。5分後のリトライでは処理をスキップして3秒以内に200を返しているので良いのですが、これからシステム構築される方は非同期処理も検討されると良いかと思います。7

ライブストリームの開始

Webhook meeting.started はパラメタとしてZoomのミーティングIDを渡してきます。

fortee はZoomのユーザIDとYouTube Liveのストリームキーを1:1対応させているので、パラメタとして渡されたミーティングIDから対応するストリームキーを探してZoomミーティングに設定します。(Update Live Stream API

上記フローではYouTubeからストリームキー一覧を取得しています。これは fortee ではストリームキーのIDのみを保存していて、ストリームキーが必要になるたびにYouTubeからIDに対応するストリームキーを取得しているためです。

IDに対応するストリームキーは不変なんじゃないかなーとは思うのですが、突然変わっても面倒だし…ということでこんな設計にしています。

Zoomミーティングにストリームキーの設定が完了したら続けてストリームを開始します。(Update Live Stream Status API

収録終了処理

レコーディングスロットの終了時刻になるかスピーカーがZoomミーティングから退席したら収録を終了します。

レコーディングスロットの終了時刻になった場合は fortee から Update Meeting Status API を使ってミーティングを終了します。スピーカーが退席した場合はミーティングが終了し、Webhook meeting.endedmeeting.participant_left が通知されます。

収録終了処理はどちらも同じ処理なのでここではスピーカーがZoomから退出した場合のフローで解説します。

Update Meeting Status API を使ってミーティングを終了した場合、追ってWebhook meeting.ended が通知されますので、処理が重複しない様に注意が必要です。8

Zoomの終了処理

Zoomミーティング終了後にはZoomミーティングのパスワードを変更しています。

スピーカーにはミーティングの join_url を通知しているのですが、このURL、どうもミーティング終了後にも有効で、開くとミーティングが開始してしまうのです。今回のシステムでこれが発生すると致命的なため、ミーティング終了時にパスワードを変更して join_url を無効にしています。(Update a Meeting API9

YouTube Liveの終了処理

YouTube Liveのライブ配信の終了処理はライブストリームのアンバインド(LiveBroadcasts: bind)とライブ配信の停止(LiveBroadcasts: transition)の2段階でしています。10

これは前述のとおりライブ配信の停止だけだとライブストリームが開放されず次のライブ配信開始時に前のライブ配信にストリームが流れてしまうことがあったためです。

ちゃんとライブ配信が停止できていればアンバインドは不要なのではないかなあ、と思いつつ、これでうまく行ってるからこれでいいか〜ということでこんな風にしています。

編集処理

クラウド録画の取得

Zoomのクラウド録画は事前に何を録画するかを設定する様になっています。今回のケースでは画面共有とスピーカー顔カメラをフレームに合成したかったのでそれぞれ録画しています。また、ちゃんと収録ができているかの確認用に画面共有とスピーカー顔カメラが合成されたもの(ギャラリービュー)も録画しています。

何を録画するかの設定はAPIからすることができないので事前にZoomの設定→記録で設定しておく必要があります。

録画された動画はZoomの List All Recordings API で返却されたURLからダウロードすることができます。List All Recordings API からはプレビュー用のURLも返却されるのですが、プレビューは当該ZoomユーザでZoomのWebサイトにログインしていないと表示できなく今回のユースケースに合わなかったので使用しませんでした。

画面共有のクラウド録画解像度はスピーカーのディスプレイ解像度に依存します。Keynoteを16:9で作成しているとスピーカーのディスプレイの中に16:9を最大表示した状態で録画されるのでそこから16:9の動画を切り出して使用しています。

多くのMacBookのディスプレイは19:9または16:10の様です。一方、世の中には17:9という微妙な解像度もあるため、16:9より縦長の場合も横長の場合もケアした計算ロジックにしておく必要があります。11

フレームへの合成と切り出し

フレーム画像は fortee で生成していますが、こちらも特に工夫は無く、テンプレートのpng画像にPHP + GDでトークタイトルや、スピーカーの画像、スピーカー名を書き込んでいます。

フレーム画像

フレームへの合成と切り出しには王道 of 王道ということでPHPから ffmpeg コマンドを実行して実現しています。

ffmpeg 
-i speaker-file.mp4 
-i shared-screen.mp4 
-i frame.png 
-map 0:a:0 
-filter_complex \"
nullsrc=size=1920x1080 [base]; 
[0:v] setpts=PTS-STARTPTS, crop=%d:%d:%d:%d, scale=244x183 [speaker];
[1:v] setpts=PTS-STARTPTS, crop=%d:%d:%d:%d, scale=1506x847 [screen];
[2:v] scale=1920x1080 [frame]; 
[base] [speaker] overlay=shortest=1:x=71:y=873 [tmp1];
[tmp1] [screen] overlay=shortest=1:x=0:y=0 [tmp2];
[tmp2] [frame] overlay=x=1:y=0\" 
-af volume=10dB -y 
-ss from-sec -to to-sec output.mp4

いや〜 ffmpeg すごい & 難しいですね!!

カバー動画の生成と結合

カバー動画はトーク動画の先頭につける10秒の動画で、トークタイトルや、スピーカーの画像、スピーカー名が表示された動画です。これはフレーム画像同様、PHP + GDで静止画を作成し、ffmpeg で動画化しています。

カバー画像
ffmpeg -y -loop 1 -r 25 -i cover.png -vcodec libx264 -pix_fmt yuv420p -t 10 cover.mp4;
ffmpeg -y -i cover.mp4 -f lavfi -i aevalsrc=0 -ar 32000 -shortest cover_audio.mp4;
ffmpeg -y -f concat -safe 0 -i files.txt -c:v copy -c:a copy -map 0:v -map 0:a out.mp4

1行目で cover.png から25fps、10秒の動画を作成しています。2行目でその動画に32kHzの無音の音声トラックを追加しています。動画のフレームレートや音声のサンプリングレートはトーク本体のものに合わせておきます。3行目は作成したカバー動画とトーク動画の結合です。files.txt にはカバー動画とトーク動画のファイル名が書かれています。

今回使用したZoomのクラウド録画は必ず25fps、32kHzになっているのでラクができましたが、動画のフレームレートや音声のサンプリングレートがばらついていると相当苦労しただろうな、と思います。

Dropboxへのアップロード

作成した動画はDropboxにアップロードしています。これは今回のユースケースではたまたま都合が良かっただけで、本質的ではないのですがメモ的に…。

使用したAPIは /upload です。このAPIは「Do not use this to upload a file larger than 150 MB.」ということで、それ以上の場合は分割アップロードになる /upload_session/start を使用しろ、とのことなのですが、今回作った動画は40分トークでも60〜70MBだったので /upload で完結させてしまいました。

DropboxのAPIも例によってApp consoleでAppを作成してのoAuthなのですが、App作成時に操作対象のフォルダをそのAppに限定することができます12。こうするとAPIをミスって他のデータを壊してしまう心配が無く、良く出来ているな〜と感心しました。

余談ですが、DropboxのAPIは全体的に良く出来ていて、パラメタを間違ったりするとエラーメッセージに何が間違っているかを教えてくれたり、エンドポイントを間違うと「このAPIのエンドポイントは〇〇だよ」って教えてくれたりします。ふつうのAPIは「403!」「500!」みたいな感じなのでちょっと感動しました。

という訳で iOSDC Japan 2020 向けに作った事前録画システムのご紹介でした。似たシステムの構築を検討されている方のお役に立てれば!

  1. この機能はまだ一般のユーザの方にお使い頂くには早いかな、とういことで iOSDC Japan 2020 向けにしか有効化していません。有料オプションとしての開放を検討しており、ご希望の方はTwitter DM( @tomzoh )などでご連絡ください。
  2. 準備時間, 収録時間, 猶予時間を指定して作成する。今回は準備時間15分、収録時間20分 or 40分、猶予時間5分で作成した
  3. Zoomの1アカウントに対応する「レコーディングトラック」の中に切れ目無く24時間作成する
  4. 当初はZoomミーティングに参加して待受をするPCをトラック数分並べて録画するつもりでした
  5. oAuth 2.0を使う場合、他の多くのシステム同様にApp MarketplaceにAppを作成することになりますが、ZoomはAppを公開しないとデベロッパアカウント以外のアカウントからそのAppを使用することができず、また、Appの公開には会社の意志決定者のサインが必要、ととてもハードルが高くなっています。
  6. 結果的にはこの追加は過剰で、60トーク分の収録を終えて使用容量は37GBほどでしたので、100GBのプランでも足りました
  7. それはそれでWebhookを受けた処理に失敗しても再試行ができないので微妙ですが…
  8. fortee ではどちらにも同じ終了処理を書いているのですが、今考えるとWebhook meeting.ended にだけ処理を書いた方が良い気がします。
  9. これはミーティングを “Scheduled meeting” として作成しているためかもしれず、”Instant meeting” として作成すればパスワードを変更しなくても join_url は再利用できないのかもしれません。
  10. ライブストリームのアンバインドにはバインドと同じAPIを使用し、パラメタのライブストリームIDを省略することでアンバインド動作になります
  11. 17:9の存在を知らずに変換バッチがコケたりしました…
  12. Dropbox配下の “/アプリ/App名” ディレクトリ配下のみ操作できる様になります