前回、TapoカメラをブラウザでPiP視聴できるようにした話を書いた。今回はその続きで、映像をCloudflare R2に録画して後から見返せるようにした話。予算は極力かけたくなかったので、無料枠でどこまでやれるかを最初に詰めた。
Cloudflare上でffmpegは動かせない
最初に考えたのは「Cloudflare Workerでffmpegを動かして録画すればいいのでは」だったが、これは調べてすぐに詰んだ。標準のCloudflare Workers(V8 isolate)はネイティブバイナリやサブプロセスの実行ができない。ffmpegのような任意のDockerイメージを動かせるCloudflare Containersという製品はあるが、公式ドキュメントに「included monthly usage as part of the $5 USD per month Workers Paid plan」と明記されている通り、Workers Paidプラン($5/月〜)が必須で無料プランには存在しない。
なので方針を変えて、ffmpegは今動いているMac/Pi側で走らせ、できたファイルをR2にS3互換APIで直接アップロードする形にした。Cloudflare側はR2バケットに徹してもらう。
[ go2rtc のRTSPリストリーム ]
│ RTSP
▼
[ recorder (ffmpeg) ]
再エンコード(400kbps)+1分チャンク化
│ PutObject (S3互換API)
▼
[ R2バケット: tapo-recordings ]
1日で自動失効(ライフサイクルルール)
▲
│ ListObjectsV2 / 署名付きURL発行
│
[ recordings-server ]
│ 302 redirect (presigned URL)
▼
[ ブラウザ(一覧・再生ページ) ]
映像バイトはR2から直接ブラウザへ無料枠10GBに収まるビットレートを計算する
R2の無料枠は月10GBのストレージ(アカウント単位)、Class A operations(PutObjectなど)月100万回、Class B operations(GetObjectなど)月1000万回、egressは常に無料。ストレージだけ気をつければ良さそうに見えたが、実際にカメラのRTSPセッションのSDPを見ると b=AS:4096 (映像だけで最大4096kbps=4Mbps)とあった。これをそのまま録画すると、
4Mbps ÷ 8 × 86400秒 ÷ 1,000,000 ≈ 42.9 GB/日無料枠の10GBを 5〜6時間で使い切る 計算になる。1日どころではなかった。
そこで、録画用にffmpegで再エンコードして映像400kbps・640px幅・音声64kbpsまで落とすことにした(ストリームコピーではなく明示的にトランスコード)。実際にバケットの使用量を計測すると1日あたり約5GB程度で、無料枠10GBの半分程度に収まっている。
古いチャンクの削除はR2のライフサイクルルールに任せる
1分ごとのチャンクに切って、古くなったら消す設計にしたが、削除ロジックを自分のプログラムに書く必要は無かった。R2バケット自体にライフサイクルルールを設定するだけで、指定日数を過ぎたオブジェクトを自動で消してくれる。
npx wrangler r2 bucket create tapo-recordings
npx wrangler r2 bucket lifecycle add tapo-recordings expire-old-chunks --expire-days 1これで録画プログラム側は「アップロードしたら終わり」で良くなり、削除タイミングのバグを自分で作り込む余地が減った。
HLSで1本の連続シークにする
1分チャンクを1本の連続したシークバーで自由に行き来できるようにしたかったので、HLS(.m3u8+hls.js)を採用し、セグメント形式も.ts(MPEG-TS。HLSはこの形式のセグメントを前提にしている)にした。
- チャンクの長さが不正確だとシーク位置がズレる:
#EXTINFにチャンクの公称の長さ(60秒)をそのまま書いていたが、実際のチャンクはキーフレーム境界の都合で60〜75秒とばらつく。ffprobeで実測してからプレイリストに書くようにして解消した - 再起動をまたぐとタイムスタンプが不連続になる: ffmpegのタイムスタンプは1プロセスの中でしか連続しない。デバッグ中に何度も再起動していたら、後半にシークすると再生時間の計算が丸ごとおかしくなる不具合が起きた。チャンクごとに「どのffmpegプロセス由来か」を記録し、プロセスが変わった箇所や前後のチャンクの時間に不自然な隙間がある箇所へ
#EXT-X-DISCONTINUITYタグを挿入することで直した - hls.jsはXHRでセグメントを取りに行くのでCORSが要る:
<video src>への直接読み込みと違い、hls.jsはプログラム的にセグメントを取得しに行くため、R2バケット側にCORS設定(wrangler r2 bucket cors set)が必要だった
閲覧はCloudflare Workerを増やさず、署名付きURLで直接再生
録画を見返すために一覧・再生ページも作った。ここでも「Cloudflare Workerを1つ立ててR2をプロキシする」という選択肢はあったが、WorkerはデフォルトでインターネットからアクセスできるURLを持つので、追加の認証を組み込まない限り録画データが実質公開されることになる。今回は自宅の映像なので、それは避けたかった。
代わりに、今動いているMac/Pi側に軽量なGoの HTTPサーバーを1つ追加し、
- R2の
ListObjectsV2でチャンク一覧を取得し、日付ごとのHLSプレイリスト(.m3u8)をその場で組み立てて返す - 各セグメントはR2への署名付きURL(数時間有効)を指す。映像バイトは自分のサーバーを経由せず、ブラウザとR2の間で直接やりとりされる
という形にした。バケット自体は非公開のままで、Cloudflare側に新しいインターネット向けの窓口を増やさずに済んだ。
おまけ: シークバーのホバープレビュー
シークバーにカーソルを乗せるとその位置のサムネイルが出るようにもした。ネイティブの<video controls>のシークバーはホバー位置をJSから取得できないので、ここは自前のシークバーを実装している。録画側では各チャンクの中間フレームをffmpegで1枚抜いてR2にアップロードし、チャンクの累積時間とサムネイルURLの対応表をJSONで返すエンドポイントを用意した。タッチ操作でも(スワイプでプレビュー、指を離してシーク)使えるようにしてある。
ライブ視聴ページと録画ページは見た目(全画面動画+下部の半透明コントロールバー)を揃えて、お互いに行き来できるリンクを付けた。両方ともPiP対応。
まとめ
別サーバーが必要ではあるがシンプルな構成に収まった気がする。ビットレートの計算も、SDPのb=AS:4096という値やコンテナ形式の選択をそのまま採用せず、設計し直したことで無料枠に収めることができた。