配信ボタンを押す人間は、もういらない — YouTube/Twitch無人配信の作り方
配信ソフトを使って YouTube や Twitch にライブを流すのは簡単です。自分でその中身を組もうとすると、途端に用語の霧に入ります。RTMP、HLS、WebRTC、SRT、ffmpeg。どれがどこで使われているのか。
この記事は、AI アバターが決まった時刻に無人でライブ配信を始め、コメントに音声で応答し、尺が来たら締めの挨拶をして自動で終わる——というシステムを作る過程でつまずいたこと、その原因、解決をまとめたものです。人間がやることは番組を登録しておくことだけで、当日は誰も配信画面を開きません。
やっていることを並べるとこうなります。
番組の開始時刻になる
→ 配信枠(broadcast)を API で作成
→ RTMP の受け口(stream)に bind
→ GPU の Pod を起動して映像を送出
→ ライブへ遷移
→ 尺が終わったら締めの挨拶
→ 配信を終了、Pod を破棄
→ アーカイブ(VOD)が残る
これを YouTube と Twitch の両方へ、無人で回します。まず全体の見取り図を描き、それから各論(同時配信、API 自動化、自動開始が発火しない罠、終了処理、遅延)に降りていきます。
なお、プラットフォームの対応プロトコルや API 仕様は変わるので、実装前に各社の最新ドキュメントを確認してください。ここでは役割分担の構造と、実際にはまった話をします。
大前提: ライブ配信は3つの区間に分かれている
[1. 制作] → [2. 送出] → [3. 配信]
映像と音を作る プラットフォームへ届ける 視聴者へ届ける
OBS / ブラウザ / カメラ RTMP など HLS など
~ローカル あなた → 事業者 事業者 → 視聴者
この 3 つはまったく別の技術で、要求も違います。混乱の多くは、この区間の区別が曖昧なまま用語を並べることから来ていると思います。
| 区間 | 主な要求 | よく使われるもの |
|---|---|---|
| 1. 制作 | 自由度、リアルタイム性 | OBS、ブラウザ、カメラ、ffmpeg |
| 2. 送出(ingest) | 確実に届く、事業者が受けられる | RTMP(ほか SRT、WHIP など) |
| 3. 配信(delivery) | スケール、CDN で配れる | HLS 系 |
**選べるのは 1 と 2 だけです。**3 はプラットフォームの領域で、こちらからは触れません。ここが後述する「遅延をどうしても縮められない」理由になります。
なぜ今も RTMP で送るのか
RTMP は 2000 年代の技術です。それでも送出の標準であり続けている理由は、単純に受け手が対応しているからです。
- 主要プラットフォームが受け口として提供している(
rtmp://の URL とストリームキー) - 配信ソフトもライブラリも、実装が出揃っている
- 「TCP で繋いでパケットを流す」以上のことをしないので、動かすのが簡単
送出プロトコルには SRT や RIST のような、より新しく損失耐性のある選択肢もあり、WebRTC ベースの送出(WHIP)も出てきています。ただし受け手が対応していなければ意味がないので、実務では「対応している中でいちばん枯れたもの」が選ばれます。それが RTMP です。
ひとつ強く注意しておきたいのは、RTMP は失敗を教えてくれないことです。無効なストリームキーで接続しても受理され、データは黙って捨てられます。送信側からは成功と区別がつきません。だから受信側の状態を外から確認する必要があります。これは実際にはまりました(後述の外形確認の話につながります)。
WebRTC はどこに居るのか
WebRTC は「1 秒未満の双方向通信」のための技術です。ビデオ会議のためのもの、と言い換えてもいい。
**大規模なライブ配信の主経路ではありません。**視聴者 1 人ごとにピア接続を張る構造がスケールしにくいことと、CDN で配る仕組みと噛み合わないことが理由です(配信専業のサービスは WebRTC ベースの低遅延配信を提供していますが、YouTube や Twitch のような一般向け配信の主経路ではありません)。
では使わないのかというと、内部経路で使いました。
[サーバ] AI の音声生成
↓ WebRTC(低遅延・双方向)
[ブラウザ] アバターが口を動かして再生
↓ 画面と音をキャプチャ
[ffmpeg] エンコード
↓ RTMP
[プラットフォーム] → HLS → 視聴者
**制作の内側で WebRTC を使い、送出は RTMP。**WebRTC の低遅延性はサーバとブラウザの間で活き、そこから先はスケールする仕組みに乗せ替える、という構成です。
「WebRTC か RTMP か」ではなく、区間ごとに向いたものを使うというのが実際の姿でした。
ffmpeg は何をしているのか
ffmpeg は「動画を変換するツール」と説明されがちですが、配信パイプラインでは 3 つの役割を同時に担っています。
1. エンコード
生の映像・音声を H.264 / AAC のような配信可能な形式に圧縮します。CPU で最も重い処理で、我々の環境では同時配信数のボトルネックがここでした。ハードウェアエンコーダ(NVENC など)に逃がせる場合は逃がします。
2. mux(多重化)
映像と音声を 1 本のストリームにまとめます。ここで A/V 同期が決まります。映像と音声が別経路で到着する構成では、どちらの時刻を基準にするかを自分で決める必要があります。
3. 分配(tee)
エンコード済みのパケットを複数の出力先に複製します。YouTube と Twitch への同時配信は、**これ 1 つで済みます。**エンコードは 1 回だけなので、送り先が増えても CPU 負荷はほとんど増えません。この分配が、次の同時配信の話の核になります。
YouTube と Twitch へ同時配信する — ffmpeg 1本で足りる
AI アバターの配信を YouTube と Twitch の両方に同時に出したい。素直に考えると、レンダラーを 2 つ起動して、それぞれのプラットフォームに送る構成になります。GPU の消費もエンコードの負荷も 2 倍です。
**その必要はありません。**ffmpeg の tee マルチプレクサで、エンコード済みのパケットを複数の出力先に複製できます。
ページ(映像 + 音声)
→ ffmpeg(エンコードは 1 回だけ)
→ tee ─┬→ rtmp://a.rtmp.youtube.com/live2/<key>
└→ rtmp://<ingest>.twitch.tv/app/<key>
コマンドはこうなります。
ffmpeg <入力の指定> \
-c:v libx264 -c:a aac <エンコード設定> \
-f tee -map 0:v -map 1:a \
"[f=flv:onfail=ignore]rtmp://a.rtmp.youtube.com/live2/KEY1|[f=flv:onfail=ignore]rtmp://INGEST.twitch.tv/app/KEY2"
ポイントは 2 つです。
1. エンコードは 1 回
tee はエンコード済みのストリームを複製するので、CPU で最も重い H.264 エンコードが 1 回で済みます。我々の構成ではボトルネックが GPU ではなく CPU だったので、ここが効きます。プラットフォームを増やしても CPU 負荷はほとんど増えません。
裏返すと、**両方に同じ品質・同じビットレートで送ることになります。**プラットフォームごとに解像度を変えたいなら、この方式は使えません。我々は 720p30 で統一しているので問題ありませんでした。
2. onfail=ignore で片肺運転を許す
これが重要です。既定では、出力先の 1 つが失敗すると ffmpeg 全体が止まります。つまり Twitch 側の接続が切れると YouTube 配信も道連れになります。
onfail=ignore を付けると、失敗した出力先を切り離して、残りを継続します。片方のプラットフォームで問題が起きても、もう片方の視聴者には影響しません。配信では「全部落ちる」より「片方だけ落ちる」ほうが常に望ましいので、ここは必ず指定しています。
プラットフォーム差は「映像」ではなく「周辺」に出る
同じ映像を送っていても、周辺の扱いは違います。
| YouTube | Twitch | |
|---|---|---|
| 配信枠 | API で事前に作成し、明示的にライブへ遷移させる | 枠の概念がなく、キーへ送出した時点でライブ |
| チャット取得 | Data API(API キーで可) | 匿名 IRC(アプリ登録すら不要) |
| 投げ銭・サブスク | チャットのメッセージとして流れてくる | EventSub(OAuth が必要) |
| メタ情報の設定 | 枠の作成時に指定 | Helix API で設定(OAuth が必要) |
映像の送出は共通化できても、周辺の制御はプラットフォームごとに書くことになります。我々は「イベントを内部の共通形式に正規化してから、1 本の応答ロジックに流す」構造にして、この差を送出層とイベント収集層に閉じ込めました。応答ロジックはプラットフォームを知らない状態になります。
なお、Twitch は枠の概念が無いぶん、アバター側が接続していないとそもそもライブにならないという性質があります。YouTube のように「枠はできたが映像が来ない」という中途半端な状態が存在しない代わりに、失敗が「何も起きない」形で現れます。以降の枠まわりの話は、この非対称な YouTube 側の作り込みが中心になります。
YouTube の配信枠を、人間が一度も画面を触らずに作る
YouTube 側は「枠を作ってライブへ遷移させる」制御を自分で書く必要があります。まず整理すべきは資格情報の境界です。
最初に整理すべきは「API キーで足りるか、OAuth が要るか」
ここを最初に理解しておかないと、無駄に時間を使います。YouTube Data API v3 は、操作によって必要な資格情報が違います。
| やりたいこと | 必要なもの |
|---|---|
| 公開データの取得、ライブチャットの購読 | API キーでよい |
| 配信枠の作成・bind・遷移・終了 | OAuth(チャンネル所有者として認可) |
| 配信後のアーカイブ状態の確認 | OAuth(API キーでは 403) |
我々は最初、視聴者コメントを読む機能だけを実装したので API キーで足りていました。そのあと配信枠の自動生成に手を伸ばした段階で、OAuth が必要になるという段差にぶつかっています。実務的には、チャンネルに対して書き込む操作は全部 OAuth、と覚えておけば外しません。
OAuth では、事前にリフレッシュトークンを 1 回取得しておき、それをサーバに持たせてアクセストークンを更新し続ける形になります。ここは対話的な操作が 1 度だけ必要で、「人間が一度も触らない」の唯一の例外が初回の認可です。
枠を作る
liveBroadcasts.insert で配信枠を作ります。ここで指定した値が、配信の性格を決めます。押さえておきたいのは次のあたりです。
- 公開範囲: 検証中は限定公開にしておく。公開に切り替えるのは人間の判断
- 自動終了: 有効にしておくと、ストリームが途切れたとき YouTube 側で枠が閉じる。後始末が減る
- 自動開始: 使わない判断をしました(発火条件に落とし穴がある。次節で詳しく書きます)
- 低遅延設定(latencyPreference): 対話型の配信では視聴遅延が体感に直結するので、縮められるだけ縮める
RTMP の受け口(stream)は毎回作らず使い回しにして、その ID を API から解決する形にしました。ストリームキーを設定に埋め込まなくて済むので、キーのローテーションが楽になります。
AI 生成コンテンツの開示フラグ
これは実装というより責任の話です。
YouTube には、合成メディア(AI で生成された、現実と誤認されうる内容)を含む場合にそれを申告するフラグがあります。配信枠を作る時点で指定できます。我々の配信は AI が声も受け答えも生成しているので、このフラグを立てて作成しています。
自動化されたシステムだと、こういう申告は「実装するかどうか」の選択肢に見えてしまいます。ただ、人間が手で配信を作るときにチェックを入れるものは、自動化しても入れるのが当たり前であるべきです。自動化はプラットフォームとの約束を免除しません。この種のフラグは今後増えていくと思うので、枠を作るコードの近くに「申告項目」というまとまりを作っておくと、追加しやすくて良いと思います。
仕様どおりに叩いているのに配信が始まらない
枠を作り、レンダラーが RTMP で映像を送り、enableAutoStart を有効にしておけば、ストリームが届いた時点で自動的に配信が始まる——はずでした。
始まりませんでした。映像は届いている(ストリームの状態は active)。枠も存在する。でも配信状態は ready のまま動かない。
原因はこれでした。
enableAutoStartは、「すでにactiveになっているストリームに、後から bind した枠」では発火しない。
なぜその順序になっていたか
我々の構成では、ストリーム(RTMP の受け口とキー)は使い回し、配信枠だけを番組ごとに作っていました。処理の順序はこうです。
1. liveBroadcasts.insert で枠を作る
2. liveBroadcasts.bind で既存のストリームに紐付ける
3. レンダラーの Pod を起動し、RTMP 送出を始める
ここで前の番組の名残やテストで、ストリームがすでに active になっている状態があり得ます。その状態で 2 の bind をすると、YouTube 側から見ると「もう流れているストリームに、あとから枠がくっついた」形になります。
自動開始は「ストリームが非アクティブ → アクティブに変わった」という遷移を見ていると考えられ、すでにアクティブなものに bind しても、その遷移が観測されません。だから発火しない。
視聴 URL が何度も変わってしまい、そのたびに共有し直す羽目になりました。原因が分かるまで「枠の作り方が悪いのでは」と insert のパラメータばかり触っていたのですが、問題はイベントの順序でした。
対処: 自動開始に頼らず、自分で遷移させる
自動開始を捨てました。
enableAutoStart = False
monitorStream = 無効
そのうえで、スケジューラが状態を見て明示的に liveBroadcasts.transition を叩く方式に変えました。
ストリームが active になったのを確認
→ transition(broadcastStatus='live') を叩く
→ 失敗したらリトライ
リトライが必要なのは、ストリームが active に見えてから YouTube 側の準備が整うまでにラグがあるためです。1 回目が弾かれても、少し待って叩き直せば通ります。
「イベントに反応してもらう」より「状態を見て自分で叩く」ほうが確実、というのがここでの結論でした。前者は相手の内部状態に依存しますが、後者は自分で観測できます。外部サービスの「自動でやってくれる機能」は発火条件が明文化されていないことが多く、自動に任せるか自分で叩くかは、単なる好みではなく信頼性の設計判断でした。
ついでに踏んだもの
bind が 403 を返す。liveBroadcasts.bind が 403 を返すことがありました。恒久的な権限エラーではなく、タイミングによる一時的なものです。段階的に待つリトライを入れて解決しています。
20 秒待って再試行 → 40 秒 → 60 秒(最大 4 回)
このドメインでは「403 = 権限が無い = 諦める」という反射が通用しませんでした。一時的な失敗を恒久的な失敗と読むと、直せるものを直さずに諦めることになります。
**無音のストリームは音声ゼロとして扱われる。**障害から復帰したとき、状態のスナップショットを復元する実装にしていたため、復帰後は挨拶をスキップして無音のままになることがありました。このとき YouTube 側は音声がまったく無いストリームとして扱います。ライブ配信としては望ましくない状態です。「音を出すべきかどうか」をアプリケーションの都合だけで決めていると、プラットフォームから見た健全性を損なうことがある、という学びでした。
終わらせる方に手間をかける
自動配信で見落としがちなのが終了処理です。始めるのは動機があるので実装しますが、終わるほうは動機が弱く、そして止まらないと課金が続きます。
我々が入れたものを挙げます。
- 尺による正常終了: 番組の長さが来たら、締めの挨拶をしてから終了。挨拶が流れきる猶予を取ってから枠を閉じる
- ライブ化のタイムアウト: 開始処理をしても一定時間ライブにならなければ、失敗として扱い、枠を閉じて Pod を破棄する。これが無いと、映像が出ないまま GPU の課金だけが続く
- 終了後の残存確認: 配信終了後に、GPU の Pod が本当に 0 台になっているかを確認する
3 つ目は運用のチェック項目として毎回やっています。**「終わったはず」を信じない。**実際、検証中に Pod が残ったまま気づかない、という危険を何度か回避しました。自動配信は「始める」より「確実に終わる」ほうが設計として難しい、というのが実感です。始まらない障害はすぐ気づきますが、終わらない障害は請求書で気づきます。
正直に書いておくと、配信後のアーカイブ(VOD)の状態確認だけは API キーでは 403 になり、OAuth 化するか警告のまま運用するかの判断を保留しています。読み取りに見える操作でも、自分のチャンネルの非公開情報に触るなら OAuth、ということですね。
視聴遅延の15〜30秒はどこで生まれるか
対話型の配信を作ると、視聴遅延が体験を直撃します。内訳を測ったらこうでした。
| 区間 | 時間 | こちらで削れるか |
|---|---|---|
| 制作(生成・エンコード) | 数秒 | 削れる |
| 送出(RTMP) | 1 秒未満 | ほぼ無関係 |
| 配信(視聴者に届くまで) | 15〜30 秒 | ほぼ削れない |
**支配項は区間 3、つまりプラットフォームの領域です。**HLS 系の配信はセグメント(数秒の細切れファイル)を作って CDN で配る仕組みなので、構造的に数セグメントぶんの遅延が乗ります。スケールと引き換えの遅延です。
低遅延モード(前述の latencyPreference 相当)を有効にすると縮みますが、ゼロにはなりません。視聴遅延はこちらの実装ではどうにもならない部分なので、設定で縮められるところは全部縮めておくのが正しい。ただし自分の実装をいくら速くしても、この壁は越えられません。
我々はここで方針を変えて、「速くする」のではなく「無反応の時間を縮める」方向に切り替えました。コメントを検知した瞬間に、事前に合成しておいた相槌を返す。総時間は 1 秒も変わりませんが、体験はまったく別物になります。視聴遅延は削れない前提で UX を設計する、というのが結論です。
通し検証で確認したこと
実装しただけでは信用できないので、実際の番組を 1 本流して確認しました。
- 両プラットフォームが同時にライブになる
- コメントへの応答が両方に流れる
- 尺が来たら締めの挨拶をして、両方とも終了する
- GPU の Pod が自動で破棄され、残 0 台になる
ここで 1 つ引っかかったのが外形確認の方法でした。Twitch のライブ状態を外から取るのに公開エンドポイントを使っていたのですが、レスポンスがキャッシュされるため、配信直後に 1 回叩くと offline が返ります。これで「Twitch 側が失敗した」と誤判定しかけました。
**開始も終了も、状態が落ち着くまでポーリングする。**単発チェックは信用しない、というのがここでの教訓です。RTMP が失敗を教えてくれない以上、受信側の状態を外から、しかも一度ではなく確認し続けるしかありません。
自動化するときの勘所
無人で配信を回すために効いた設計判断を、最後にまとめておきます。
- **区間で分けて考える。**ライブ配信は制作 / 送出 / 配信の 3 区間。選べるのは前の 2 つだけ
- **エンコードは 1 回、分配は tee。**プラットフォームを増やしても負荷は増えない(
onfail=ignore必須)。ただし全出力先が同じ品質になる - **チャンネルへの書き込みは OAuth。**読み取りは API キー。ここを最初に整理する
- **AI 生成コンテンツの開示フラグは立てる。**自動化はプラットフォームとの約束を免除しない
- イベントに反応してもらうより、状態を見て自分で叩く。
enableAutoStartは「active なストリームに後から bind した枠」では発火しない。transitionを明示的に叩くほうが確実 - **一時的な失敗を恒久的な失敗と読まない。**bind の 403 は段階的な待ちでリトライ(20→40→60 秒、最大 4 回)
- **配信の寿命(開始・継続・終了)を管理する層を独立させる。**終わらないと課金が続く。ライブ化のタイムアウトと終了後の残存確認を入れる
- **状態はクライアント(ブラウザ)に置かない。**ページは落ちる前提で、使い捨てにする
- **受信側の状態を外から、繰り返し確認する。**送信側のログは成功を意味しない。外形確認はキャッシュを考慮してポーリングする
- **視聴遅延 15〜30 秒はプラットフォーム側の構造。**縮められない前提で UX を組む
用語が入り乱れて見えるのは、別々の区間の話を同じ平面に並べているからでした。区間で分けると、選択肢はかなり素直に決まります。そして無人配信でいちばん難しいのは映像を出すことではなく、確実に終わらせることと、相手(プラットフォーム)の内部状態に依存しない形で仕組みを組むことでした。始まらない障害はすぐ気づきますが、終わらない障害と黙って捨てられる送出は、放っておくと請求書と誤判定で気づくことになります。