100km ウォークに出てきた(途中失格編)
はじめに
5/18 ~ 5/19で開催されたTokyo Extreme Walk 100に参加してきた。
Tokyo Xtreme Walk 100 東京エクストリームウォーク100|朝日新聞
今まで50kmぐらいを歩くのは何度かやっていけることがわかったので、 できるかわからないラインである100kmが目標だった時にどうなるかを知りたかったというのと、 会社の人に誘ってもらい決心がついたのが参加理由。
事前準備
100kmを目標にするのは初だったので、以前と違ってちゃんと準備をした。 主に以下。
- 痛み止めゲルを用意
- 靴下を用意
- 足裏の水脹れ対策のワセリンを用意
ただ、準備が足りなかったかもしれないことを後で思い知るのであった。。。。
当日
出発地点の小田原城に8時すぎぐらいに到着した。参加チェックであったり、アイテムをもらったりした。 そして8時40分すぎに出発。
最初のチェックポイントは24km地点。ここまでは元気だったので同伴者と話しながら進んでいたら一瞬でついた。 カレーパンと水をもらい、15分ほど休憩して出発。
次のチェックポイントは35km地点。 海岸沿いを歩くのだが、日陰がない上にところどころに砂だまりができており辛い。 ゴールが見えているが進んでも進んでもつかない上に、速い人たちにどんどん追い越されていくのも精神にくる。 この時点でだいたい15時過ぎぐらいになっていた。 おにぎり、飲み物、羊羹をもらって、20分ほど休んで出発する。 この時点で少し水ぶくれが現れ始めていたので、近くのコンビニでキズパワーパッドを貼って対策した。
ここからが辛さの始まりであった。キズパッドを貼ってはいるものの足裏の水が移動し痛いし、右足の方も水脹れができてきた。 そしてなんやかんや朝からカレーパンとおにぎり、羊羹のみしか食べていないのでシンプルにカロリーが足りなくなり、 微妙に過呼吸的な症状が出始めた。こんだけ辛くてもまだ半分も行っていないことにショックをうけ、ゴールできないことが頭をよぎり始める。 しかし、以下のように考え方を変えることで、ペースを上げることに成功した。
- 足の裏の痛みが出ないように他に負担をかけて歩いていたが、それだとペースが上がらない上に結局疲れが増えるだけなので、ある程度の痛みを受け入れてペースを保つ。痛みは基本的に慣れる。
- 無理、リタイアするという考えを持たない。ゴールに行けた時の最高な気分を想像し、楽しく行く。
だいたい50kmぐらいまでは体力勝負で、それ以降は精神勝負なのだなということがわかった。 なんやかんやで56km地点の第3チェックポイントまで到着したが、その時点で関門時間の22時を過ぎてしまっていた。。。ショック。 ちゃんと関門時間を事前に意識できていればとか、出発時間を後30分早めていればとか、途中でご飯食べるために3,40分ぐらいコンビニで休憩してしまったことや、こうすれば間に合っていたのにとか思うことはあったが、第3の段階で足裏の痛みがだいぶ辛かったので、 もし間に合っていたとしてもゴールには全然間に合っていなかった可能性が高い。。。
反省
今回の主な反省点は以下だった。
- 足裏の水脹れ
- しりずれ
- 第2~第3でしばらくメンタルがやられていたこと
特に今回は足裏の痛みでメンタルも体力もやられがちだったので、次回は対策したい。 今回もワセリン塗るとかバンドエイド貼るとかはしたが、本来は痛みが出る前に対策をするべきだった。 いろいろ調べてみたが、靴をいいやつにするとかテーピングをすればいいんだろうか? あとは辛くなってもペースを下げないようにするとかも意識が必要そう。
まとめ
体力的には行けた気がするが、精神的要因で負けてしまった感が半端ないのでリベンジしたい。 今回は痛みが結構あったが、他の人の感想を見るに、自分と同様の痛みを感じているがそれを乗り越えて100kmを達成しているっぽい。 今回も、気合さえ続けばいけたのではないかという気がする(痛みに14時間以上耐えるのはだいぶきついが、不可能ではない)。 次出る時は、しっかり痛みの対策はしつつ、強く精神を保っていこうと思った。 そして、今後、今回のように精神で追い詰められることがあっても負けないようにしたい。
2022
はじめに
これは2022年の振り返りです。 今年は主に以下のイベントがありました。
- 業務委託終了
- 大学院を卒業する
- 大きい会社に入社し、大規模家族向けサービスに関わる
- マッチングアプリをガチではじめる
生活スタイル共に考え方が変わることが多かった一年だったので、その都度思っていたことを書いていこうと思います。
趣味を対外的にすることおよび人生の目標について
退職した直後は、自分を追うものが研究しか無くなったので、何をすべきか、人生の目標は何かみたいなことを考えていた。
この時よく考えていたのは、継続して熱量を持って取り組めるかどうかが結果のためには大事ということである。人生をかけるならばもちろんだが、単なる趣味だとしても、たとえすぐにお金にならなくても、10年続けられれば大きな差別化になる。なので自分の趣味の中で長く続けて行きたいものを、なんらかの形で外的に露出するためのプラットフォームを作ろうと考えた。趣味の中で自分が長く続けて行きたいと思っていたことは以下である。
- 本や音楽、映像などから気に入った文章や場面を記録していくこと。
- 気に入った作品の作者の方のバックグラウンドを知ること。
その時点までは、気に入った場面を記録して以下のbotに反映するなどをしていた。
参加型名文bot (@LovelyQuoteBot) / Twitter
これをWebサイト化、アフィ化などすることで、より元作品へリーチしてもらえる&ワシもお金がもらえて嬉しいというものである。
ということで出来たのが以下であった。
とてもつまらない。ひどい。作者さんたちに申し訳ない。当初は何度も見返したいものになるだろうと思っていたのだが、全然であった。 人にも見てもらったが、自分の感じている文章への感動というのはやはり文脈の理解あってのものも多いのだと実感させられた(文章単体で美しいものもあるが、文脈の理解の有無は大きい)。 あと、無駄にGraphQLを採用したせいで詰まりまくり、開発にとても時間がかかってしまった。 技術の勉強をしたいなら稼ぐためのプロジェクトでするべきではない、勉強用のプロジェクトや副業とかで勉強すべき、というのも学びになった。
しかし、以前として文章などを記録していくのは好きであり、サイトの改善は行なっていく予定。
ビールの記録
また、文章だけでなく、ビールの記録も行なっていた。ビールをよく飲む割には前に飲んだものを忘れてしまっており、単なる消費で済ませてしまうのは非常に勿体無いと考えたのが主な理由である。
検索性や継続性を考慮して一旦 notion にて管理することとした。こちらも継続できており、レビュー数は100を超えた。 しかし、世の中にはこんなのよりもっと面白い形でビールを紹介してる人もいるということを友達に教えてもらった(よく考えたらいて当たり前だが全然頭になかった)。
https://www.instagram.com/beer_joy_/?hl=ja
自分の汚い机の上で撮った写真とは美しさが違いすぎる。飲みたいと思わせる力がレベチ。しかしこれは見せ方的な問題でもあると思うので、既存の適当な写真や情報でどうにかできないかというのは検討して行きたい。
IT技術による現実世界の拡張的なこと
人生を通したIT活動の大義として何をするべきかということを考えていた。自分がITに興味を持った理由の一つとしてロックマンエグゼがある。ロックマンエグゼといえばAIのようなイメージもあり、学生時代はそれに関連して機械学習をしていたが、やってみた結果、結局自分が憧れていたのは人工知能と喋ることではなくて、高度にIT化された効率的かつ価値拡張された社会だったということに気づいた。この20年のIT界はそれによってWeb上に新たなコンテンツを複数生み出してきたが、一方で現実世界の仕組みはまだまだ物理的で非効率的なものも多い。そして、Webの世界と現実世界はかなり別個として成長してきてしまっている印象があった。なのでそれをさらにいい感じに融合できるのではないかと考えていた。Googleの検索結果や広告が位置やその時現実世界で行なっている物理的行動によって変わるとか、道端に存在している情報的なもの(チラシとか看板とか)にWeb上の概念が紐づいていて効果を測定できるとかそんなことだ。
ただ、考えていただけで結局具体的に何もしていない。その目標に向かってできる最小でお金になることは何かとかを考えていたが、結局思い浮かばなかった。そもそもこれを書いていてそんなことを考えていたのを思い出したので、今年はなんかできたらいいな〜。
読んでいた本としては以下などがある。
ポケモンGOのユーザーがいかに熱狂的だったか、また副社長をされている方の人生が波瀾万丈過ぎて、先を考えずに飛び込むことで得られるパワーというのは重要だなと思わされた。あとは純粋にPokeGoの人を誘致する広告という形態がすごく面白いなと思った。社内の事業立案研修ではこれを対抗馬として考えたりした。
Web3が流行っていたのでP2Pのメリットを知るために読んだ。治安を保ったりシステムを最新に保つためには結局中央集権的仕組みを取り込む必要がありそうで、適材適所だなという所感。何か最強のユースケースがあるんだろうか。
入社後について
入社後の1, 2ヶ月ほどは割と同期と遊ぶことが多かった(最近でもたまに遊ぶ)。話していて思ったのは、みんな人間力が高すぎるということである。すごい。自分は学生時代後期の就労によって社会不適合者およびコミュ障をある程度脱出して人間としてまともになったと思っていたのだが、同期の人たちに比べたら全くであった。会社の理念がFor Communicationということもあり、コミュ力が高い上にそもそも人と話すことが好きな人が多い。自分は必要な時以外に話すのが苦手なタイプなので、研修中は結構圧倒されていた。中学、高校、大学と周りの人の雰囲気が変わっていっているのは感じたが、新卒入社が最もいい意味でのフィルタリングを感じたかもしれない。自分が現在の会社に入社した理由の一つとして人柄という点があるが、それをいきなり感じさせられた。入社1ヶ月後に同期で50km歩くというイカれイベントに3, 4割ぐらいの人が参加したのは今思えば異常である。
部署配属後について
部署配属後はサーバーサイドおよびネイティブアプリのエンジニアをしていた。今までの仕事と比較して感動したこと、新鮮に感じたこととしては以下などがある。
- アプリケーション開発のみに集中できるような環境になっている。
- ちゃんとしたスクラム開発になっている。
- エンジニア含めてUXレビューがあり、そこでは細かいが鋭い指摘などもたくさん飛んでいてすごい(文言やクリエイティブとかまで)。
- 自分以外の人は大体実ユーザーなので、意見に体験が伴っていて強い。自分も家族内で使い始めているが、子供がいるわけではないのでむずさを少し感じている。
- 自分は家族が多いこともあり、家族内インスタとしてみんな楽しく使えているのでそれはそれでいいのかもしれない(お金にならなくて申し訳ないが...)
- 新規で何かをゴリゴリ設計して作るよりは、既存の設計の上でどう最適に実装するかを考えることが多い。コードベースを読み解く能力が今までよりより重要。
- 自分がガッツリ作った機能がないので、どうポジションを取るかみたいなのに結構悩んでいる。
- 何かを導入するとしても規模が大きいので、しっかりメリットデメリットを整理して、かつチーム全体の合意をとって一緒にやってもらうという流れになるので、整理力、巻き込み力が重要
- 自分以外は全員10個以上年上なのでとても大人な方が多い。とても尊敬している。ただ近い年の人がいないので寂しくなることはある。
- 初めて評価というのを受けた。自分のやったことをちゃんとアピールするというのは大事だというのと、やるべきことをやっているかをシビアに評価されるのは重要だなと思った。
仕事に勉強としては以下を読んだりしていた。技術系(ネイティブアプリ、rails)は結局仕事ドリブンで勉強した方が効率がいいなとなり、最初の概念的なものだけ学ぶ程度で終わった。rails 周りはそれで済んでいるが、ネイティブ周りはやはりガッツリやらないとわからないなと思ったので、今年は何か作ってみようと思う。
スクラムガイド https://scrumguides.org/docs/scrumguide/v2020/2020-Scrum-Guide-Japanese.pdf
Documentation | Android Developers
マッチングアプリをガチではじめてみる
一昨年は作っていたくせに全くやっていなかったが、以下の理由から始めてみた。
- 周りの遊んでいた友人が彼女ができたり結婚できたりしており、話の3割ぐらいがその話になるが全くついていけない。時間が経つと関われなくなるんだろうなと感じた。
- 部署の人、外部の人と話すが、大体そういう話が出る。理研の人とすらそういう話になった。年齢という最強の手札を使えるうちに頑張るべきである。
- 人間としての成長のために他人との関わりを自分から求めるというのは重要だと思った(今まで友人すら自分から作ったことがないため)
- 勧められて読んだフロムの愛するということに人を愛する能力は才能ではなく鍛えるものと書いてあってなるほどとなった
最近
プライベートでもコードを書いたりすることが多かったが、年末年始に全然コードを書かなかったら、すごく時間に余裕ができることに気づいた。なので今まで諦めていた別にキャリアのためにならないけどやってみたかったこと(絵やギター)を始めてみている。これらをやることで別の道が見えてくるんではないかと思っているが、やはり目的なしにやってもどこへ向かっているのかわからず不安になる。しかし、何か自分で納得するラインまでは続けてみたいと思う。
仕事においては、今までは自分がやった感を感じるのにこだわり過ぎていたかもしれない。周りの人たちが優秀なので設計なども結局人の意見になることが多く、俺必要なのか...?みたいになっていたが、むしろそういう他の人を頼ればすぐ終わるものは頼ってすぐ終わらせ、自分の思う新たな価値を見つけてそちらに取り組み、その中で出てくる未知な部分でオーナーシップを持つべきなんではないかということを思った。 なので今年は頼れるだけ人を頼って早く終わらせる、自分の思う新たな価値に色々取り組んでいってそこでオーナーシップをもつ、みたいなことを頑張ってみようかなと考えている。
2021
はじめに
「2022年の振り返り」の間違いと思いましたか? 2023年に書かれてはいますが、これは間違いなく、2021年の振り返りです。本当は昨年の初めに、一人アドベントカレンダーの最終として書こうとしていたのですが、途中で力尽きてしまったので、今更ながら残しておきます。 色々考えさせられるようなことがあったのですが、大体忘れてしまったので、今でも覚えている自分の中の教訓的なものを残しておこうと思います。
逃げられる人間は逃げる
2021年はスタートアップにお世話になっていた。 明確にすべき事業も無いぐらいのフェーズ&規模感。 いくつかサービスの立ち上げに関わらせてもらったが、最終的に僕は、もううまく行く気がしないので、新規には関わりません、という様な感じで逃げた。他の人が会社や学校を辞めるようなリスクを取っている中、僕は修士課程を無事に終えようとしていたし、新卒での就職先もあった。そんな中で以下の記事を見つけた。以下の記事を書いている人達は逃げられた側だが、僕は完全に逃げた側であった。ある程度日常生活を犠牲にしていたものの、それ以上の対価や経験を得られたことを感謝していたし、できる限り頑張るつもりであった。しかし、逃げた。これは僕が、何も不可逆的なものを捧げていなかったからだと。だから今後、もし自分が逃げることを許されないような環境に身を置くならば、何か失えないものを捧げるべきだし、他の人が逃げても大丈夫なように心を備えようと思ったのだった。
ぼくは村作りビジネスをやめる。そう決断するにいたった全経緯と教訓について
起業失敗の話。起業を志す皆さんに敗残者からお伝えしたいこと | ニュー アキンド センター
継続的でない成功はどんなに大きくても単なる運である
ある程度大きい仕事をもらえ、それが順調にいき、大きいお金をもらえた時期があった。完全に他の人のおかげとはわかっていたものの、ある程度自分が頑張ったと思える部分もあったので、多少の自信を持ってしまった。しかしその後、結局自分がいたからと行って事業がうまくいくわけは1mmもないし、今の仕事でバリューを発揮できているわけでもない。なのに、あの時の成功体験が自分にしょぼい自己肯定感を持たせてしまっている。人間、ある程度何かに取り組んでいれば運によって救われるタイミングが来たりする。2回波に乗れれば実力と言っていいだろうが、1回ならばただの運である。もし何度か乗れたとしても、過去の成功体験に囚われては行けない。運をつかむのでなく、引きづり込めるようになってからが本当の自分の実力と言えると思うし、そうあれるよう努力していきたい。
このサービスに賭ける、という様なことはしない
基本的にサービスは失敗する。失敗するというよりは、当初見ていた目標が、目指すものではなくなったりする。 なので、最初に決めたコンセプトに従って、これがダメだったら終わる、などとするのは良くないし、もし違うと思ったならまた別のものに変更すべき。作ったら作ったで愛着が湧いてしまうが、とにかく微妙なものに固執すべきではない。
適切な目的が決まったなら大体のことは終わっている
研究で言われたことだが、他のことにも言えると思う。 目標が適切なら必然とやるべきことが決まるし、それを達成したいという欲も尽きない。 だから焦って中途半端な目的を設定してはいけない。目的に沿って何か行動をするのは、着実に何かが進んでいる快感があるので、つい安易な目的を設定してしまいがちだと思うが、これは完全な間違いであると思う。かと言って何も行動しないのは良くないので、
GCP Video Intelligenceを用いた不適切動画の検知 with Cloud Functions
はじめに
これは2021年振り返りカレンダーの10日目の記事です.
前回はTwilio Sync + Reactを用いてリアルタイム通信を実装するという内容でした.
今回はGCPに戻って,Video Intelligenceを使って不適切な動画を弾く内容について書いていこうと思います.
ユーザが動画などのコンテンツを自由に投稿できるサービスはよくありますが,ユーザ数が一定以上いると, ポルノ画像など他のユーザに害を与えるものをアップロードする人が間違いなく現れます. そのため今回は,そのような不適切コンテンツを検知し,アラートしてくれる簡易的なシステムを実装します.
超大きいサービスだと検出したいケースが複雑でこうはいかないでしょうが, 初期段階のサービスでは役に立つんではないかと思います.
仕様
Cloud Storageの特定のバケットに新たな動画がアップロードされた時,Video Inteligenceで その動画が不適切かどうか(ポルノ要素を含むかどうか)を判定し,結果をslackに通知するとします.
動画の判定処理や通知には,Cloud Functions(typescript)をもちいます.
実装
Video Intelligenceの不適切コンテンツ検出(セーフサーチ)の仕様について
ドキュメントは以下になっています.
使い方の要点をまとめると以下の感じです.
- 判定したいビデオを,GCS上のURI(gs://~~)で指定する.
- 動画のURLを直で与えることはできない.
- 判定結果としては,ビデオが数フレーム毎に分割され,それぞれに6段階で判定が行われる(VERY LIKELY 〜 VELY UNLIKELY).
- 判定の段階はこちら.Likelihood | Cloud Video Intelligence API Documentation | Google Cloud
- 判定には動画のビジュアル部分のみが利用され,音声は関係ない.
- 各フレームの判定結果をもとに自分で適切な閾値を実装し,ビデオ全体の判定結果を出す.
Video Intelligenceを用いた判定処理
次は実際に,Cloud Functionにのせる用のコードを書いていきます.
GCSの特定のバケットにオブジェクトが追加された時に処理を走らせるため, Cloud FunctionsのGCSトリガーを用います.
また,Video Intelligenceでは動画全体で1つの判定結果が返ってくるのではなく, 複数あるフレームの単位で判定結果が返ってくるので,それを1つの判定結果に落とし込む必要があります. 今回は以下のようなフローで判定を行うこととしました.
- 全フレームのうち,1つでもVERY LIKELYの箇所があるなら,不適切と判定.
- 全フレームのうち,40%以上がPOSSIBLEなら,不適切と判定.
- 上記に当てはまらないなら,適切な動画と判定.
実際の実装は以下のような感じです.
import videoIntelligence, { protos } from "@google-cloud/video-intelligence"; const pornoFrameRatioThreshold = 0.4; export const safeSearch = functions .region(REGION) .storage.bucket(functions.config().storage.bucket) .object() .onFinalize(async (object) => { // GCSの新規オブジェクトのパスを取得 const filePath = object.name; // パスのバリデーション if (!filePath) { functions.logger.error("Filepath is null."); return; } if (!validateVideoEdit(object)) { return; } // オブジェクトが入っているバケット名を取得 const bucket = admin.storage().bucket(object.bucket); const userUID = filePath.split("/")[0]; const request = { inputUri: `gs://${bucket.name}/${filePath}`, features: [ protos.google.cloud.videointelligence.v1.Feature .EXPLICIT_CONTENT_DETECTION, ], }; // Video Intelligenceでセーフサーチをする const client = new videoIntelligence.VideoIntelligenceServiceClient(); const [operation] = await client.annotateVideo(request); const [operationResult] = await operation.promise(); // 結果の取り出し const annotations = operationResult.annotationResults ? operationResult.annotationResults[0] : undefined; // 判定されたフレームの数を取得 const frameCount = annotations?.explicitAnnotation?.frames?.length; // フレーム全体を見て判定 const pornographyPossibleCount = annotations?.explicitAnnotation?.frames?.filter( (v) => v.pornographyLikelihood == protos.google.cloud.videointelligence.v1.Likelihood.POSSIBLE ).length; if (typeof pornographyPossibleCount === "undefined" || !frameCount) { functions.logger.error("Video Annotation Failed."); return; } const isPossiblyPorno = annotations?.explicitAnnotation?.frames?.some((v) => v?.pornographyLikelihood ? v?.pornographyLikelihood > protos.google.cloud.videointelligence.v1.Likelihood.POSSIBLE : false ) || pornographyPossibleCount / frameCount >= pornoFrameRatioThreshold; // 結果出力 console.log(isPossiblyPorno); });
結果をSlackへ通知
不適切な動画がサービスに投稿されたらすぐにお知らせしてほしいので, slackに通知を送ることとします.
前述したコードの最後の判定結果 isPossiblyPorno を用いた以下の感じの処理を挟みます.
export const safeSearch = functions .region(REGION) .storage.bucket(functions.config().storage.bucket) .object() .onFinalize(async (object) => { // 前述の判定処理 // 不適切判定がTrueなら通知 if (isPossiblyPorno) { // slack通知のためのトークンなどを環境変数から取得 const SLACK_TOKEN: string = functions.config().slack.oauth_token; const SLACK_INSPECTION_CHANNEL: string = functions.config().slack.inspection_channel; const slackClient = new WebClient(SLACK_TOKEN); const STORAGE_PUBLIC_HOST: string = functions.config().storage.public_host; const videoUrl: string = new URL( path.join(bucket.name, filePath), STORAGE_PUBLIC_HOST ).href; try { await slackClient.chat.postMessage({ channel: SLACK_INSPECTION_CHANNEL, blocks: [ { type: "section", text: { type: "mrkdwn", text:"<!channel>\n危険な動画が投稿された可能性があります!!!", }, }, { type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "ビデオを確認", }, url: videoUrl, }, ], }, ], }); } catch (error) { functions.logger.error(`Sending a slack message failed`); } } return; }
これで実装が完了です.
実サービスに投入しての,精度とかに関する所感
実際に,定期的に動画がアップロードされる実サービスに導入してみた所感が以下のような感じです. アップロード数は大体日に50~100ぐらい?
- 稀にアップロードされる完全にアウトな動画(陰部丸出しの自撮り,AVなど)は,ほぼLIKELY以上となっており,全部弾けていた.数千上がってきて2つぐらいあった.
- 水着や男性の上半身裸などにもLIKELYぐらいで反応するので,その辺は人手の追加チェックが必要(サービスの仕様によると思う).大体日に3, 4件ぐらいは反応していた.
- 手だけ映っているような,セーフだが肌色が多めな画像にも時々反応していた.
ということで,人目で見て明らかにアウトなものは漏れなく検出できましたが,セーフなものも数%ぐらいは検出してしまうので,人手による追加チェックは必須だなという印象でした(AIの判断結果を100%信用するというのもあんまりなさそうな気はしますが).
ちなみに処理時間は,動画の長さにもよりますが大体30秒~1分ぐらい.時々Cloud Functionsの実行時間のデフォルトlimitである1分を超えてしまっていたので,その辺は実際に導入して調整する感じになると思います.
その他細かい知見など
- Video Intelligenceは処理回数,処理時間に対してrate limitがあり,動画数個ですぐ引っかかるので,最初からあげておいた方が良い.
- 公式的には判定できるのはポルノ動画かどうかのみになっており,バイオレンス動画かどうかなどは判定できない模様.ただ,銃撃戦とかの動画をあげたら反応したので,全くというわけでもないのかもしれない(たまたまかもです).
- 今回は使わなかったが,人物検出とかロゴ認識とか色々あるので,初期サービスで人間的処理の自動化を簡単にでも導入するにはとても便利だと思いました.
最後に
GCPのVideo Intelligenceを用いて,GCSに上がった動画に自動でセーフサーチを行う内容について書きました. もしこれを自分で1からやることを考えると,モデルの学習や管理,MLサーバのホスティングなど大量の作業が必要になるので, こんな感じでML処理をラップしてくれるサービスはありえんほど便利だなと思いました. 一方,サービスの要件によってどの程度の精度が必要か,目的のタスクを解けるかはかなり違ってきそうなので, その辺の検証を行うのは大事だなと実感しました(今回だとどんぐらいの誤検知は許容するかなど).
次回はアプリの方に戻って,強制アップデートの実装について書いていこうと思います.
Twilio Syncを用いたリアルタイム通信 with React
はじめに
これは2021年振り返りカレンダーの9日目の記事です.
前回はTwilio Programmable Videoで作ったビデオ通話アプリのUXを向上させるという内容でした.
今回は,Twilioが提供する,ブラウザやアプリで簡単にリアルタイム通信を実装できるサービス,Twilio Syncについて解説していきます.
Twilio Syncでは以下の種類のオブジェクトの更新をリアルタイムに共有させることができます.
自分はReactアプリで,ブラウザ間でJSONオブジェクトを共有させるのを実サービスに導入していたため, どんな感じでやったかとかを書こうと思います.
ちなみに選定理由は以下の感じです.
- Twilio Programmable Videoをサービスで用いていて,同じTwilioで都合が良かったので.
- 時間がなかった上にWebsocketサーバの実装が未経験で,できればマネージドサービス使いたいと思ったため.
- 共有オブジェクトの更新は低頻度かつ利用箇所が一部だけだったため,料金的にも問題ないだろうという感じ.
- バックエンドサーバにCloud Runを用いていて,サーバレスでWebsocket厳しそうだったので(今はゴリゴリ対応してます Using WebSockets | Cloud Run Documentation | Google Cloud)
仕様
フロントエンドはReact,バックエンドはgoで書いてるとします. また,Syncで扱えるもののうち,Documentsを利用します.
実装
処理は以下のような手順になります.
- twilioコンソールからTwilio Syncを利用するためのService SIDなどを取ってくる.
- クライアント側でAPIからSync用のトークンを取得する(オブジェクトごとにユニーク).
- 取得したトークンをもとにSyncオブジェクトに接続.
- オブジェクトの更新を送ったり受け取ったりする.
- オブジェクトを削除.
1個ずつ解説していきます.
Syncサービスの作成と,サービスSIDの取得
参考
Sync用トークンの生成
まず,Syncオブジェクトに接続するためのトークンを生成します. 生成には公式が出しているgoのパッケージを用います.
twilio周りの処理をラップしたパッケージのコードは以下のような感じです.
import ( "context" "net/url" "time" "github.com/twilio/twilio-go" "github.com/twilio/twilio-go/client/jwt" ) type Client struct { service *twilio.Client accountSID string apiKeySID string apiKeySecret string } func New(accountSID, apiKeySID, apiKeySecret, syncServiceSID string) *Client { return &Client{ service: nil, accountSID: accountSID, apiKeySID: apiKeySID, apiKeySecret: apiKeySecret, syncServiceSID: syncServiceSID, } } func (c *Client) NewSyncToken(identity string, ttl time.Duration) (string, error) { params := jwt.AccessTokenParams{ AccountSid: c.accountSID, SigningKeySid: c.apiKeySID, Secret: c.apiKeySecret, Identity: identity, Ttl: c.Ttl, } jwtToken := jwt.CreateAccessToken(params) syncGrant := &jwt.SyncGrant{ ServiceSid: c.syncServiceSID, } jwtToken.AddGrant(syncGrant) return jwtToken.ToJwt() }
トークンを生成するNewSyncTokenメソッドには,オブジェクトのユニーク名を表す Identityと有効期間を表すttlを渡します.
例えばユーザが集合するルームで共有するオブジェクトを 生成するならば,そのルームのIDとともに生成する感じになります.
また,ttlはルームの存続時間より少し長く設定する形になると思います.
上記のパッケージを以下のような感じで呼び出してtokenをクライアントに返します.
func f() { TWILIO_ACCOUNT_SID := "hogehogehoge" TWILIO_API_KEY_SID := "fugaugafuga" TWILIO_API_KEY_SECRET := "piyopiyopiyo" TWILIO_SYNC_SERVICE_SID := "paupau" twl := twilio.New(TWILIO_ACCOUNT_SID, TWILIO_API_KEY_SID, TWILIO_API_KEY_SECRET, TWILIO_SYNC_SERVICE_SID) token, err := twl.NewSyncToken("room:1", 4 * time.Hour) }
クライアント側の実装
クライアント側では,SyncオブジェクトをReact hooksのsetStateと同じように利用するために, 以下のようなState Providerを用意しました.
import React, { createContext, ReactNode, useState, useRef } from "react"; import SyncClient from "twilio-sync"; export const SyncContext = createContext<ISyncContext>(null); interface SyncProviderProps { fetchSyncToken: () => Promise<string>; sessionID: string; ttl: number; children: ReactNode; } // 共有するオブジェクトの型 export type SyncState = { shareObj: ShareObj; }; export type ShareObj = { text: string; }; const defaultSyncState = { shareObj: { text: "initial text", }, }; export interface ISyncContext { syncState: SyncState; // 共有する状態 initSyncState: () => void; // オブジェクトの初期化を行う deleteSyncState: () => void; // オブジェクトを削除する setSyncState: (syncState: SyncState) => void; // 共有オブジェクトを更新(他のユーザにも更新が共有される) createSyncClient: (token: string) => void; // syncオブジェクトへの接続 } export const SyncProvider = ({ fetchSyncToken, sessionID, ttl, children, }: SyncProviderProps): JSX.Element => { const client = useRef<SyncClient>(); const [syncState, setSyncStateLocal] = useState<SyncState>({ syncObj: { text: "init", }, }); // Syncクライアントの初期化 const createSyncClient = async (token: string) => { client.current = new SyncClient(token, { logLevel: "info" }); // Syncオブジェクトへの接続状態が変化した時の動作を定義. client.current.on("connectionStateChanged", (status) => { if (status === "connected") { loadState(); } }); // トークンの時間切れが近い時の処理.トークンを再fetchしたりする. client.current.on("tokenAboutToExpire", async () => { const token = await fetchSyncToken(); if (!token) return; refreshSyncClient(token); }); // トークンが時間切れになった時. client.current.on("tokenExpired", async () => { const token = await fetchSyncToken(); if (!token) return; refreshSyncClient(token); }); }; const refreshSyncClient = (token: string) => { client.current.updateToken(token); }; // Syncオブジェクトをデフォルト状態にする. const initSyncState = () => { setSyncState(defaultSyncState); }; // Syncオブジェクトを削除.誰もオブジェクトを参照しなくなった時などに利用. const deleteSyncState = () => { client.current.document(sessionID).then(async (doc) => { doc.removeDocument(); }); }; // Sync オブジェクトを更新する.この更新がユーザ間に共有される. const updateSyncState = (syncState: SyncState): void => { if (!client.current) { return; } client.current.document(sessionID).then((doc) => { doc.set(syncState, { ttl: ttl }); }); }; // 接続成功時などに,すでにあるオブジェクトの内容を取得してくる const loadState = () => { if (!client.current) { return; } client.current.document(sessionID).then((doc) => { setSyncStateLocal(doc.data as SyncState); doc.on("updated", (data) => { setSyncStateLocal(data.data as SyncState); }); }); }; // 実装者が使うsetSate. const setSyncState = (syncState: SyncState) => { setSyncStateLocal(Object.assign({}, syncState)); updateSyncState(syncState); }; return ( <SyncContext.Provider value={{ syncState, setSyncState, createSyncClient, initSyncState, deleteSyncState, }} > {children} </SyncContext.Provider> ); };
このProviderを利用して,前述した以下の手順を実現していきます.
Syncオブジェクトへの接続
共有Stateを利用したいAppコンポーネントを,SyncProviderでラップします.
SyncProviderには,トークンのフェッチを行うためのasync関数と,SyncオブジェクトのユニークID,ttlを渡します.
ここで初期化の際にSync用のトークンを直で渡さないのは,Syncオブジェクトへの接続が不安定になった際に トークンの再フェッチを行うので,そことトークン取得処理を共通化させたいためです.
<SyncProvider fetchSyncToken={async () => { const { response, error } = await getToken(); if (error) return; return response.data.sync_token; }} sessionID={`room:${RoomID}`} ttl={(ROOM_MINUTE} > <div> <App /> </div> </SyncProvider>
Syncオブジェクトの取得と更新
SyncProvider以下のコンポーネントでは,以下のような形でSyncオブジェクトの取得と更新を行います.
syncStateで共有されたSyncオブジェクトを取得し,setSyncStateで更新します.
SyncProvider内で面倒な処理をラップしているので,通常のstateと似たような感じで扱うことができます (更新の衝突などは起きるので,動作確認やテストはしっかりしないといけませんが...).
import useSyncContext from "~/app/hooks/useSyncContext"; const App = (): JSX.Element => { const { syncState, setSyncState } = useSyncContext(); return ( <div> <input type="text" onChange={e => setSyncState(e.target.value)} value={syncState.text}> </div> ); }
Syncオブジェクトの初期化・削除
最後に,共有オブジェクトを利用しているルームなどが終了した場合,Syncオブジェクトも同様に削除します.
削除もしくは初期化を行うかしないと,再度同じルームに接続した時に,前に操作してた時の状態が残っているという, 不気味な感じのことが起きてしまいます(仕様によりますが).
初期化は以下のような感じで行います.initSyncStateを呼び出すだけです.
オブジェクトを初期化するか,削除するかは,仕様によって選択する感じになると思います. 単純に削除してしまうと,他のユーザが取得するオブジェクトが急にnullだったかになってしまうので, その辺のハンドリングをうまくやる必要があります.
import useSyncContext from "~/app/hooks/useSyncContext"; const App = (): JSX.Element => { const { syncState, initSyncState } = useSyncContext(); return ( <div> <button type="text" onClick={}> leave room </button> </div> ); }
その他細かい知見など
- Twilio Syncベストプラクティスをとりま読んでみる(Best Practices for Building with Sync SDKs | Twilio).
- 共有オブジェクトの更新と読み込みの処理が連なっていると無限更新ループが走ってしまうので注意.
- Provider内でローカルオブジェクトとSyncオブジェクトを分けているのはこれに対応するためだった...はず?(書いたの1年ぐらい前なので忘れちゃいました...)
- オブジェクトのリアルタイム共有はバグおきやすいのでデバッグのケースが多い.
- 削除後の参照とか,初回アクセス時のオブジェクトがない場合どうするかとか.
- Syncオブジェクトの削除は他のユーザに影響があるので,基本は初期化の方がいい気がする.
- オブジェクトは時間が経つと自動で消えてくれるらしい.TTLの間は持つが,消える時間は厳密に決まってない模様.
最後に
Twilio Syncを用いて単純なリアルタイム通信を実装してみました. ゲームなど複雑なものをこれでやるのはかなり厳しそうですが,SaaSなどの細かい1機能として リアルタイム通信導入したいみたいな場合には実装も楽ですごく便利なんじゃないかと思います(利用箇所が多いなら 自分でwebsocketサーバ立てた方が良さそうですが).
今回紹介したSyncProviderは,自分がそこまでreactやリアルタイム通信に詳しくないこともあり もっと良くできそうな箇所は多々ありそうですが,ReactにすぐにSyncを導入するためのサンプルとして 使ってもらえたら嬉しいです.
次回はGCPのVideo Intelligenceを用いた不適切画像の検知について書こうと思います.
Twilio Programmable Videoを用いたビデオ通話の実装 with React(UX編)
はじめに
これは2021年振り返りカレンダーの8日目の記事です.
前回はTwilio Programmable VideoとReactを用いてビデオ通話を実装するという話でした.
今回は,実装したTwilioのビデオ通話の質をどんな感じで調整するかについて書いていきます.
仕様
前回同様,フロントエンドはReact (typescript),バックエンドはgoとします. また,ビデオ通話は最大でも十数人程度で行うとします(ライブ配信みたいに 数百,数千人とかは想定していない).
参考
Developing High Quality Video Applications | Twilio qiita.com
パフォーマンスチューニング
パフォーマンスチューニングについては,Twilioエバンジェリストの方が書いてくださっている以下の記事が とても参考になります.というか自分が書くのは以下の記事の劣化版です. 一応自分がやった部分とかを書いていこうと思います.
Default Connection Optionsの設定
Twilio Video GroupRoomでは,room作成の際に接続に関するパラメータの設定を行います. 以下のような感じです.
const defaultConnectionOptions: ConnectOptions = { bandwidthProfile: { video: { mode: "collaboration", trackSwitchOffMode: "detected", dominantSpeakerPriority: "standard", maxTracks: 10, }, }, dominantSpeaker: true, networkQuality: { local: 1, remote: 1 }, maxAudioBitrate: 16000, preferredVideoCodecs: [{ codec: "VP8", simulcast: true }], region: "jp1", };
Regionの設定
Video Regions and Global Low Latency | Twilio
GroupRoom方式では,Twilio側のサーバを経由してビデオ通話を行います. その際,サーバのロケーションをユーザに最も近い場所に設定しておくことで 通話の品質を向上することができます.
Regionには,ユーザ個別に関わるSignaling Regionと, ルームのユーザ全体に関わるMedia Regionの2つがあります.
connection optionではSignaling Regionを設定します.
例えば,日本のユーザの場合はregionを"jp1"にすることで レイテンシが上がり,通話の速度や品質が向上します.
"gll"を設定した場合は,ユーザにとって最もレイテンシが低いregionが選択されるとのことなので, 複数国にわたってサービスを展開している場合などはそうするのが良さそうです.
また,Media RegionはTwilioのコンソールより設定できます.

こちらのRegionは通話に参加しているユーザからの平均レイテンシが最も低くなるように設定します. 日本国内のみならば,Signalingと同様に"jp1"にするのが良さそうです.
また,グローバルサービスではユーザのRegionが複数あるので,全てに対して 平均的に近い場所を選ぶのが難しいです. そのような場合は"gll"に設定します. ただ,"gll"と言ってもSignalingの時とは挙動が違い,最初にRoomに接続したユーザのRegionに 設定されるようです(SFUサーバは全ユーザに共通するので).
Bandwidth Profile
Using Network Bandwidth Profile API for Programmable Video | Twilio
mode
mode にはcollaboration, grid, presentationがあり,デフォルトはcollaborationになっています.
それぞれを簡単に解説すると,
- collaborationは一部の人のトラックを他より優先して表示するものです.優先表示する人については,喋っている人(dominant speaker)を自動認識させたり,プログラム側で設定することもできます(優先側はpriorityをhigh, 他の人はlowにする).授業や,小・中規模のミーティングなど,全員いて欲しいが喋る人を優先したい場合に向いているでしょう.
- gridは全員のトラックを均等に配信するものです(全員のpriorityをstandardにする).向いてるケースは友達同士のお話や,ミーティングなどみんなおんなじ感じで出てて欲しい場合でしょうか.
- presentationは一部の人のトラックに帯域を全て割り振るというものです.帯域が余った場合は他の参加者のトラックにも使われます.collaborationとの違いは,優先されている人以外が表示されるかどうかです.公演のような聴衆がたくさんいるケースに向いてると思います.
自分の場合は,先生と生徒がいる授業サービスをやっていたので,collaborationに設定しました.
trackSwitchOffMode
Using Network Bandwidth Profile API for Programmable Video | Twilio
trackSwitchOffModeでは,参加者の下り帯域幅が足りなくなった時の挙動をpredicted, detected, disabledから指定できます (デフォルトはpredicted). それぞれ以下のような挙動をします.
- predictedでは,実際に帯域が足りなくなるのを自動で予測し,一部のトラックをオフにします(audioとpriorityがhighのもの以外).喋ってる人以外は最悪見えなくても問題ないケースで用います.
detectedでは,実際に帯域が足りなくなった時にのみトラックをオフにします.不必要なトラックオフを防げる一方で,ビデオが止まったり,音声が途切れる現象がユーザ側に出てしまいます.継続して表示することが重要になるケース(授業など通話に課金が関わるケース)で有用なんじゃないかと思います.
disabledでは,トラックオフを行いません.そのため,停止したビデオがずっと表示されることが起こり得ます.公式的には,よっぽどの理由がなければ使わない方がいいとのこと.
自分の場合は通話に対して課金が発生するサービスをやっていたので,お金を払っているユーザに不利益が出ないよう,detectedにしていました.
dominantSpeaker & dominantSpeakerPriority
Detecting the Dominant Speaker | Twilio
dominant speakerとは,先ほども少し出てきましたが,Room中で声を発しているユーザのことを指します. この設定をtrueにしておくと,trackに動的に変化するdominant speakerかの情報が付与されるので,それを用いて 優先表示したりできます.ZoomやMeetなどのように,話してる人を大きく表示したい時に使えます.
dominantSpeakerPriorityは,名前の通りdominant speakerのpriorityを設定するものであり, 例えばこれをhighにしておけば,喋ってる人のトラックは絶対オフにならないようにできます.
自分の場合は,生徒と教師のように必ずしも喋ってる人が重要ではないという感じだったので,dominantSpeakerPriorityはstandard にしてました(そもそもこの機能onにしなくてよかったかも...).
networkQuality
Twilioではユーザのネットワーク品質を得るための,Network Quality APIが用意されています. 通話サービスだと通信強度を表すバーなどが出ていますが,あれのために利用します.
Using the Network Quality API | Twilio
optionsにおけるnetworkQualityでは, ローカルとリモートのネットワーク状況をどの程度詳細に取るかを指定します.
0〜3(none, minimal, moderate, detailed)の範囲でレベルがあるようですが, 自分は公式で推奨されていた1 (minimal) にしていて,高いレベルにしたときに 取れるデータをどう使うかはよくわかっていないです. 一応まとめておくと,
- minimalでは参加者にNQレベルをレポートする.
- moderateではNQレベル以外にNetworkQualityStatsを提供する.NQSは音声,動画の受信・送信状況より構成される.
- detailedでは,NQSを,音声・動画の送受信状況に加え,NetworkQualityMediaStatsを持っている.NQMSは帯域やレイテンシなどの情報を含む.
minimalで,一般的な通信状況を表すバーは出せるのですが,検証を行う場合などにより詳細なメトリックが欲しい場合は このレベルを上げたりするのかも?
preferredVideoCodecs
こちらにおいては,通信を行う際のコーデックと,Simulcastを有効かするかの指定をします.
今回はcollaborationモードがSimulcastでしか使えないということで,VP8 Simulcastとしましたが, 前述の記事によると,コーデックは以下のような基準で選ぶと良いそうです.
パフォーマンスチューニングの視点でみると、利用するデバイスの多くがモバイル端末であり、H.264による恩恵が受けられそうな場合や、H.264端末しか利用できないデバイスが必須の場合ではH.264を積極的に採用し、それ以外のケースではVP8で通信できるようにしておくとよいでしょう。特にVP8には、オプションとしてVP8 Simulcastが利用できるため、複数の参加者が利用するビデオ会議でパフォーマンスを出しやすくなります。 by Twilio Videoのパフォーマンスチューニング - Qiita
コーデックはデバイスやブラウザによって対応・非対応があるため,詳細は以下を参照.
Simulcastは,ネットワークの下りが遅いユーザがいた時,それに合わせてpublishする データのクオリティを下げなくても良いよう,各ユーザが複数の品質のトラックをpublish し,最適なものをsubscribeさせる仕組みです.詳細は以下など.
Working with VP8 Simulcast | Twilio
WebRTC Simulcast コトハジメ · GitHub
maxAudioBitrate
maxAudioBitrateについては,公式によると,音声として人の話し声が主の場合は,帯域制限のために 16kbps程度にし,それ以外の場合は設定しない(制限なし)のが推奨らしいです. 音楽などを流す場合などは特に制限をかけない方がいいそうです.
Developing High Quality Video Applications | Twilio
動画のサイズ
動画の品質と通信品質はトレードオフなので,用途に合わせて適宜調整します. サンプルコードとしては以下あたりで設定します.
twilio-video-app-react/constants.ts at master · twilio/twilio-video-app-react · GitHub
調整のための考え方は以下が参考になります.Twilio側で,コーデックと解像度に応じて必要な 帯域を示してくれているので,ユーザのネットワーク環境に応じて適切な動画解像度を設定する感じになります.
Twilio Videoのパフォーマンスチューニング - Qiita
また,VP8 Simulcastを用いる場合は,以下のリンク先の表のように,3段階で異なる解像度の映像を生成し, ユーザによって最適なものを流してくれるので,あまりネット環境が悪いユーザに合わせて解像度を決めなくてよくなります.
Working with VP8 Simulcast | Twilio
ネットワーク状況の表示
ユーザのネットワーク状況が悪いと,アプリが正常だったとしてもユーザ側に不具合が出てるように見え,クレームが入ったりします. そのため,前述したNetwork Quality APIを使って,適切なネットワーク状況かをユーザ側に常に提示するように します. 例えば,接続前のチュートリアルなどで,ネットワーク状況のバーが2本以下だと動作が不安定になります,などの注意書きをしておきす.
サンプルの実装の以下が参考になります.
エラーハンドリング
ビデオ通話においては,以下のような様々なエラーが発生し,それに応じてユーザに適切な対応をしてもらう必要があります.
- カメラなどのデバイスにアクセスできない
- 権限がない -> 許可してもらう
- 他のアプリケーションに使われている -> 他のアプリ停止をお願いします
- そもそもカメラついてない -> 使えへんよ!
- ネットワーク環境が悪い
- ユーザ側のネットワークが重い -> 場所移動してもらう
- アプリケーション側が死亡 -> ごめんなさい
- トークンの有効期限が切れている
こちら側に非がないエラーもあるのが大変です. そのため,吐かれたエラーに応じて,ユーザにしてもらうべき動作を提示する通知を行うのが良いです.
自分は以下のようなutil関数を作り,エラー通知を行なっていました.
const getErrorContent = ( hasAudio: boolean, hasVideo: boolean, error?: Error ): string => { switch (true) { // This error is emitted when the user or the user's system has denied permission to use the media devices case error?.name === "NotAllowedError": if (error?.message === "Permission denied by system") { // Chrome only return "OSによりマイク、カメラへのアクセスがブロックされました。設定の見直しを行ってください。"; } else { return "ユーザによってデバイスへのアクセスが拒否されました。ブラウザにデバイスへのアクセス権限を与えてください。"; } // This error is emitted when input devices are not connected or disabled in the OS settings case error?.name === "NotFoundError": return "マイク、カメラが見つかりませんでした。デバイスが接続,有効化されていることを確認してください。"; case error?.name === "NotReadableError": return "マイク、カメラを取得できませんでした。デバイスを他のアプリケーションで利用している場合は終了させてください。"; case error?.name === "AbortError": return "マイク、カメラを取得できませんでした。デバイスを他のアプリケーションで利用している場合は終了させてください。また、それでも起動しない場合は、ブラウザの再起動を行ってください。"; case !hasAudio && !hasVideo: return "カメラ及びマイクが検出されませんでした"; case !hasVideo: return "カメラが検出されませんでした"; case !hasAudio: return "マイクが検出されませんでした"; } }; export default getErrorContent;
以下のように呼び出します.
const { hasAudioInputDevices, hasVideoInputDevices } = useDevices();
getAudioAndVideoTracks().catch((error) => {
notify.error(
`${getErrorContent(
hasAudioInputDevices,
hasVideoInputDevices,
error
)}`
);
自分は特に,Windows,Linuxで発生する,他のアプリがカメラを使っていると出るエラーに 苦しめられました(Macだと起きないので).できたと思って一旦先方に出したら, エラー出て動きませんと来て,なんで!?となり,実はZoom繋ぎながら触ってたからだったという ことがありました.
参考はサンプルのこの辺です.
twilio-video-app-react/MediaErrorSnackbar.tsx at master · twilio/twilio-video-app-react · GitHub
その他細かい知見など
- ネットワーク状況とかのテストは,ブラウザの検証機能についてるのでやっていた.
- パフォーマンスチューニングをちゃんとやるならばユーザタイプごとにpublishする動画サイズを変えた方がいいのだろうが,そこまでできなかった.
- Twilioのjs sdkだと,VP8 Simulcastはfirefoxに対応していない.
- Twilioでは,コンソールから,過去のRoomの参加者の通話・切断履歴やデバイス,OSなどがみれるので それをもとにエラーが起きてないか,ネットワーク状況の下限はどのぐらいかとかを 観察することができます(切断を繰り返してるケースとかがたまにある).
- また,ビデオの録画もできるので,エラーの詳細やユーザ調査も可能です. Understanding Video Recordings and Compositions | Twilio
Roomの履歴の実例

最後に
Twilioでビデオ通話を作るときに,パフォーマンスやUXのために気をつけたことについて書きました. パフォーマンス周りはほとんど記事まんま(劣化版)みたいな感じですが,体験記として みてもらえると嬉しいです.あとWebRTCとか動画まわりについて知らないことばかりだったので記事を読んでいてとても 勉強になりました.
その他,ネットワーク周りやエラーハンドリング周りについては,開発側の非とユーザ側の環境要因を 分離するのが難しく,結構悩まされました.例えユーザ起因であっても,エラーが出た時の利用者側ストレスは 半端ないと思いますし,クレームも受けたりしたので,そこら辺の対応処理は今後も気をつけていきたいと思いました(原因と対応方法を詳細に表示するだけで全然違うことを理解した).
次回は,Twilioが提供する,複数ブラウザ間でリアルタイムでの状態同期(jsオブジェクトなど)のためのSDKである Twilio Syncの利用方法について書いていこうと思います.
Twilio Programmable Videoを用いたビデオ通話の実装 with React(通話編)
はじめに
これは2021年振り返りカレンダーの7日目の記事です.
前回はsqldefをCloud Build上で動かし,マイグレーションの自動化を行うという内容でした.
今まではCI/CD周りの内容を主に書いてきましたが,今回はうって変わって ビデオ通話機能についてです.
ビデオ通話機能を実現するためのSDK, SaaSとして有名なものとしては以下があります.
- Twilio
- SendGridとかでも有名
- Agora
- clubhouseで使われてたやつ
- 数百,数千人規模の接続も可能なのが強み?
- SkyWay
- 国産
- Zoom SDK (New!)
今回はとてもしっかりしたReactサンプルがあるTwilio Programmable Videoを採用することとしました.
Google Meetに近いものがすぐに立ち上がりますし,コードもすごいしっかりしています. これをベースとして独自のビデオ通話を作る時のポイントを書いていきます.
Twilioの料金回り(グループ通話まわり)
- 初期費用なし
- ユーザ1人,1分につき0.44円
- グループ通話は最大50人まで(相談可能ぽい?)
詳しくは以下(回者じゃないです).
仕様
フロントエンドはReact (typescript),バックエンドはgoとします. また,ビデオ通話は最大でも十数人程度で行うとします(ライブ配信みたいに 数百,数千人とかは想定していない).
画面は一旦,twilioのサンプルみたいな形を想定します.
実装
プロジェクトの設定
以下を参考にプロジェクトを作成し,APIキーの取得を行います.
ビデオ通話のための最小構成
Twilioでは1対1での通話を行うためのGo Room,P2Pで複数人通話を行うP2P Room,WebRTC SFUで複数人 通話を行うGroup Roomがあります.
Understanding Video Rooms | Twilio
今回はGroup Roomを用います.
通話開始から終了までの流れは以下の感じになっています.
クライアント側でユーザがカメラやマイクを確認するための準備画面を出す.
クライアントは,サーバから識別子とともに生成したRoom接続用のTokenを取得する.
クライアントは,取得したTokenをもとに通話Roomへの接続を行う.
他ユーザが接続してきたらそれを表示.適宜画面共有など.
通話終了.
通話参加のためのトークンを返すAPI
ユーザが通話用ルームに入るためには,識別子をもとに生成したトークンが必要です. 今回はgoのtwilioクライアントとして,twilio-goを用います.
まだ開発中なようですが,公式のやつも出てました.
GitHub - twilio/twilio-go: A Go package for communicating with the Twilio API.
twilio周りの処理をラップしたパッケージのコードは以下のような感じです.
package twilio import ( "context" "net/url" "time" "github.com/kevinburke/twilio-go" "github.com/kevinburke/twilio-go/token" ) type Client struct { service *twilio.Client accountSID string apiKeySID string apiKeySecret string } func New(accountSID, apiKeySID, apiKeySecret, syncServiceSID string) *Client { return &Client{ service: nil, accountSID: accountSID, apiKeySID: apiKeySID, apiKeySecret: apiKeySecret, syncServiceSID: syncServiceSID, } } func (c *Client) NewVideoToken(identity, roomName string, ttl time.Duration) (string, error) { twToken := token.New( c.accountSID, c.apiKeySID, c.apiKeySecret, identity, ttl, ) videoGrant := token.NewVideoGrant(roomName) twToken.AddGrant(videoGrant) return twToken.JWT() } func (c *Client) NewRoom(ctx context.Context, authToken, roomName string) (*twilio.Room, error) { client := twilio.NewVideoClient(c.accountSID, authToken, nil) param := url.Values{} param.Add("uniqueName", roomName) return client.Rooms.Create(ctx, param) }
上記のパッケージを以下のような感じで呼び出してtokenをクライアントに返します.
ユーザ名にはユーザ固有のidなどを入れてユニークにするとともに, クライアント側で表示するための名前も入れましょう.
また,ルーム名もユニークになるようにします.例えば1つのミーティングに対して ルームを作るなら,ミーティングIDなどをもとにすると良いです.
最後にトークンの有効時間(TTL)ですが,こちらは想定される通話時間よりも少し長いぐらいに 設定するのが良いです.短めに設定してしまうと,通話中にネットが不安定になるなどして再接続を 試みる際にそれが失敗してしまいます.
func f() { TWILIO_ACCOUNT_SID := "hogehogehoge" TWILIO_API_KEY_SID := "fugaugafuga" TWILIO_API_KEY_SECRET := "piyopiyopiyo" TWILIO_SYNC_SERVICE_SID := "paupau" twl := twilio.New(accountSID, apiKeySID, apiKeySecret, syncServiceSID) token, err := twl.NewVideoToken("user:1:けんた", "room_abc", 4 * time.Hour) }
クライアント側の実装
クライアント側の実装は前述したサンプルに準拠させます(1から自分で作るにはかなりの修練と時間が必要そう). 特にhooksとcomponentsをほぼコピーしてきて魔改造します.
肝になる部分について解説していきます.
通話関連の状態を管理する VideoProvider
まず,VideoProviderです.
twilio-video-app-react/index.tsx at master · twilio/twilio-video-app-react · GitHub
これは通話関連のstateなどを持つProviderです.以下のような変数,関数を提供しています.
- 通話ルームの実態 room
- クライアントのローカルトラック(ビデオやマイクなど)
- roomへの接続用関数 connect
- roomへ接続属中かを表す状態変数 isConnecting
- 画面共有のオンオフに利用する toggleScreenShare
- その他諸々...
通話用のコンポーネントはVideoProviderの下におき,上記のような 関数,変数を参照しながら通話画面の制御を行なっていきます.
const Video: FC = () => ( <VideoProvider options={defaultConnectionOptions} onError={() => { console.log("error"); }} > <VideoApp /> </VideoProvider> );
ビデオ,音声などの確認画面
次は,通話ルーム接続前の確認画面です. このステップではクライアントのローカルトラックを 画面上に出すだけです.
実装は以下のような感じになります.
VideoProviderが持っているlocal tracksからビデオを探し,それをVideoTrackコンポーネントで表示しています. また,ユーザがキャンセルを行なった場合はlocal tracksをremoveし,前の画面に戻ります. ユーザが入室ボタンを押した場合は,取得したトークンをconnectに渡し,ルームへの接続を行います.
const PreJoinRoom: FC = () => { const { connect, isConnecting, localTracks, removeLocalAudioTrack, removeLocalVideoTrack, } = useVideoContext(); const handleClickEnter = async () => { let lessonTokens: LessonTokens; const { response: tokenRes, error: tokenErr, } = await apiGet(`/token`, {}); const token = tokenRes.data; } connect(token); }; const handleCancel = () => { removeLocalAudioTrack(); removeLocalVideoTrack(); }; const videoTrack = localTracks.find((track) => track.name.includes("camera") ) as LocalVideoTrack; const audioTrack = localTracks.find( (track) => track.kind === "audio" ) as LocalAudioTrack; return ( <div> <VideoTrack track={videoTrack} isLocal /> <div css={homeBar}> <button css={enterButton(isConnecting)} onClick={handleClickEnter} disabled={isConnecting} > {isConnecting ? "接続中" : "入室"} </button> <button css={cancelButton} onClick={handleCancel}> "戻る" </button> </div> </div> ); }; export default PreJoinRoom;
ルームへの接続
前述した準備画面で,connect関数を用いてルームへの接続を行いました. connectが成功すると,VideoProvider中にあるroomStateが disconnectedからconnectedに変化するので,それをもとに表示する画面の切り替えを行います. 以下のような感じです.
また,接続が不安定になった場合などは,roomStateがreconnectingになるので, その場合はユーザに通知を行うなどすると親切です.
const VideoApp: FC = () => { const history = useHistory(); const roomState = useRoomState(); const { getAudioAndVideoTracks, localTracks } = useVideoContext(); const { hasAudioInputDevices, hasVideoInputDevices } = useDevices(); useEffect(() => { getAudioAndVideoTracks().catch((error) => { console.log(error); notify.error( `${getErrorContent( hasAudioInputDevices, hasVideoInputDevices, error )} ${error}` ); history.push("/"); }); }, [getAudioAndVideoTracks]); useEffect(() => { if (roomState === "reconnecting") { notify.error( "接続が中断されました。ルームへ再接続中です。", "video_reconnecting" ); } }, [roomState]); // roomStateをもとに画面を切り替える return ( <div css={{ height: "100%", width: "100%", background: colors.black }}> {localTracks.length > 0 && (roomState === "disconnected" ? <PreJoinRoom /> : <Room />)} </div> ); };
ビデオ通話画面
通話画面については内容が多いかつ仕様によってかなり異なると思うので,サンプル中のRoomコンポーネントを起点として,コードを 読んで勉強するのが一番良い気がします. それとMenuBarあたり.
twilio-video-app-react/Room.tsx at master · twilio/twilio-video-app-react · GitHub
twilio-video-app-react/MenuBar.tsx at master · twilio/twilio-video-app-react · GitHub
よく出てくる用語をまとめると以下の感じです.
Participant
ビデオ通話への参加者.Main Participantは大きく表示される参加者.
Track
各ユーザが通話のために使うメディア?のこと.ビデオやオーディオなど.入力デバイスごとに存在する感じぽい. 特にローカルユーザのものに関してはlocalTrackと呼ぶ.
Publication
ユーザが通話のために配信するもののこと.Trackの他,オンオフの状態,優先度なども持っている.リモートと ローカルで異なる.
そして,Roomを起点とするコンポーネントの階層とそれぞれの役割は以下のような感じになっています.
Room
├── MainParticipant(メインの参加者.喋ってる人など.)
| └── MainParticipantInfo(メインの参加者の名前や,ビデオ,ミュートなどの状態の表示を行う)
| └── ParticipantTracks(publicationのうち,ビデオ,共有スクリーンなど対応してるもののみを取り出し下に渡す)
| └── Publication(参加者とそのトラックの状態をもとにVideoかAudioかなどを切り替える)
| └── VideoTrack, AudioTrack(ビデオ or 音声の用コンポーネント)
└── ParticipantList(メイン以外の参加者)
└── Participant(メイン以外の参加者)
└── ParticipantInfo(メイン以外の参加者の名前や,ビデオ,ミュートなどの状態の表示を行う)
└── ParticipantTracks
└── 以下Main下と同様なので省略
上記のものを読んで依存関係や使い方などを理解した後,自分の要件に合う形で実装するのがいいと思います.
画面共有
画面共有は,VideoProviderが持つ toggleScreenShareを呼び出すことで行います(めちゃ簡単). これを呼ぶと,呼び出したクライアントのpublicationがスクリーンになるので,それを他ユーザが受け取るという感じです.
サンプルだと以下のあたりのコードが参考になります.
twilio-video-app-react/MenuBar.tsx at master · twilio/twilio-video-app-react · GitHub
通話の終了
通話を終了するには,VideoProviderが持つroomのdisconnect関数を呼び出します.
実際のコードはEndCallButtonなどが参考になります.
twilio-video-app-react/EndCallButton.tsx at master · twilio/twilio-video-app-react · GitHub
これで一旦,通話の開始から終了までの実装が完了しました. 他にも配信設定やルーム作成のタイミング,通信状態の表示,エラーハンドリングなどたくさんあるのですが, 次回に回そうと思います.
その他細かい知見など
- クライアント側でユーザをグループごとに分けたい場合(教師と生徒,スピーカーと聴衆など)は,トークン作成の際のidentityにグループの識別子を入れ,それをもとに制御する.
- トークンの有効期限が切れてもユーザは強制的にルームから退出させられたりしないので,強制退出を行いたい場合はクライアント側で再起動をタイマーで仕込むのが良いと思う.
- Windows, Linuxだと,他のアプリケーション(Zoom, Meetその他諸々)でビデオカメラやオーディオなどを使っている場合,トラックの取得部分でNotReadableが起きて失敗する.Macだと起きないので気づかなかった...
- WebRCT上の問題らしい.https://github.com/twilio/twilio-video.js/issues/325
- 動作確認の際は他の通話アプリは落とすように注意.
- ユーザ側にもその旨の通知をした方が良い.
- Mac以外でブラウザ2つ立ち上げてテストができなかったのでだるい.
最後に
twilio programmable videoを用いてビデオ通話を実装してみました. 自分はtwilioを触るまでWebRTCに関わったことがなかったのですが, これのおかげでそこそこ短いスパンでプロダクション用の通話機能を実装することができました(まだWebRTC詳細について理解できてない部分も多いですが...). よくできたReactサンプルの存在が何よりの助けになった気がします. また,やはりマネージドなSaaSは強い. それと,独自でWebRTC使って実装してる人すげぇ...ってなりました.いつかちゃんと勉強してみたいです.
課金形態は人数及び時間による従量課金なので,ライブ配信みたいな無料通話が発生するケースには向いてないかもですが, レッスンのような通話単位でお金が発生するケースにおいては非常に便利だと思います.
次回は配信設定やエラーハンドリングなど, 通話のクオリティやUXを向上させるための部分について書いていこうと思います.
