Androidマンになりたいおじさん

自分の備忘録的なのです。Androidを普及できたらいいなと思ったりしながら書きます

ExoPlayerのDecoder周りのお話

はじめに

こんにちは!!!!さっき任天堂からサイレント爆弾投下されて元気になっているぐみおじです!!!! 信者とかじゃないんですが、任天堂って人を喜ばせるのうまいな〜って思いつつ、見習いたいところですね

ということで、今日はDecoder周りについて少し触れたいと思います

ちなみにAndroidの低レベル層で提供されているMediaCodecについては今回は触れませんx(そのうち深堀りしていきたいですね)

Let's Nintendo

Codecについて

ここでいうCodecとは、動画や音声をEncodeしたり、Decodeしたりすることを指します。(画像なんかでも使われてますね)

生のデータは綺麗だけど、容量デカすぎ!なるべく人間に気付かれないように容量軽くなるように圧縮・変換しちゃおうぜ!的な捉え方で大丈夫です

  • 圧縮・変換 -> Encode
  • Encode済みデータを復元 -> Decode

この圧縮方法が色んなやり方があって、そりゃもう大変大変って感じ。

ExoPlayerでは何をサポートしているかが以下から確認できます! exoplayer.dev

多分皆さんも一度は耳にしたことがあると思います!

映像: H.264, H.265, MPEG-4...etc. 音声: AAC, MP3...etc.

これらのCodecをハードウェアとして提供してたり、ソフトウェアで実現したりって話もあったりします。もちろん性能はハードの方が優秀なんですが、色んなformatをハードで対応するのは正直きつそうという小並感な意見をもってます...

補足: 動画は音声と映像の2つから成り立ってるから、音声用のCodec, 映像用のCodecというふうに扱います。

AndroidのCodecについて

AndroidのCodecってどうなってるんだろ?自分ってどんなのが対応してるんだろ?とか思いませんか?

adb shellで自分の端末にアクセスしてみて、media_codecs.xmlがどこかに配置されてるので探してみてください! -> 下記サイトより: etc/, vendor/etc/, odm/etc/などに基本はあるみたいですよ!

medium.com

androidのcodec情報はxml宣言されてることがまず驚きですよね...。(自分はびっくりしました)

試しに自分のGalaxyS10+のvendo/etc/配下を見てみましょう!!

f:id:qurangumio:20200903234415p:plain
media_codecs

うっ。。。目が痛い

全部は見てられないので、代表的なmedia_codecs.xmlをチラ見してみます。

<MediaCodecs>
    <Settings>
        <Setting name="max-video-encoder-input-buffers" value="11" />
    </Settings>
    <Encoders>
        <!-- Video Hardware  -->
        <MediaCodec name="OMX.qcom.video.encoder.avc" type="video/avc" >
            <Quirk name="requires-allocate-on-input-ports" />
            <Quirk name="requires-allocate-on-output-ports" />
            <Quirk name="requires-loaded-to-idle-after-allocation" />
            <Limit name="size" min="96x96" max="4096x2304" />
            <Limit name="alignment" value="2x2" />
            <Limit name="block-size" value="16x16" />
            <Limit name="blocks-per-second" min="36" max="1958400" />
            <Limit name="bitrate" range="1-160000000" />
            <Limit name="frame-rate" range="1-480" />
            <Limit name="concurrent-instances" max="16" />
            <Limit name="performance-point-4096x2304" value="56" />
            <Limit name="performance-point-4096x2160" value="60" />
            <Limit name="performance-point-3840x2160" value="60" />
            <Limit name="performance-point-1920x1080" value="240" />
            <Limit name="performance-point-1280x720" value="480" />
        </MediaCodec>
        <!-- Video Software -->
        <MediaCodec name="OMX.qcom.video.encoder.h263sw" type="video/3gpp" >
            <Quirk name="requires-allocate-on-input-ports" />
            <Quirk name="requires-allocate-on-output-ports" />
            <Quirk name="requires-loaded-to-idle-after-allocation" />
            <Limit name="size" min="96x96" max="864x480" />
            <Limit name="alignment" value="4x4" />
            <Limit name="block-size" value="16x16" />
            <Limit name="blocks-per-second" min="36" max="48600" />
            <Limit name="bitrate" range="1-2000000" />
            <Limit name="frame-rate" range="1-30" />
            <Limit name="concurrent-instances" max="16" />
            <Limit name="performance-point-720x480" value="30" />
        </MediaCodec>
    </Encoders>
    <Decoders>
       <!-- Video Hardware  -->
        <MediaCodec name="OMX.qcom.video.decoder.avc" type="video/avc" >
            <Quirk name="requires-allocate-on-input-ports" />
            <Quirk name="requires-allocate-on-output-ports" />
            <Limit name="size" min="96x96" max="8192x4320" />
            <Limit name="alignment" value="2x2" />
            <Limit name="block-size" value="16x16" />
            <Limit name="blocks-per-second" min="36" max="3916800" />
            <Limit name="bitrate" range="1-220000000" />
            <Limit name="frame-rate" range="1-480" />
            <Feature name="adaptive-playback" />
            <Limit name="concurrent-instances" max="16" />
            <Limit name="performance-point-4096x2304" value="60" />
            <Limit name="performance-point-4096x2160" value="96" />
            <Limit name="performance-point-3840x2160" value="120" />
            <Limit name="performance-point-1920x1088" range="480" />
            <Limit name="performance-point-1920x1088" range="240" />
            <Limit name="performance-point-1280x720" value="480" />
        </MediaCodec>
        <!-- Video Software -->
        <MediaCodec name="OMX.qti.video.decoder.h263sw" type="video/3gpp" >
            <Quirk name="requires-allocate-on-input-ports" />
            <Quirk name="requires-allocate-on-output-ports" />
            <Limit name="size" min="96x96" max="864x480" />
            <Limit name="alignment" value="4x4" />
            <Limit name="block-size" value="16x16" />
            <Limit name="blocks-per-second" min="36" max="48600" />
            <Limit name="bitrate" range="1-16000000" />
            <Limit name="frame-rate" range="1-30" />
            <Feature name="adaptive-playback" />
            <Limit name="concurrent-instances" max="16" />
            <Limit name="performance-point-720x480" value="30" />
        </MediaCodec>
    </Decoders>

    <!-- JAPAN models only have mpeg2 decoder -->
    <Include href="media_codecs_qcom_video_mpeg2.xml" />
</MediaCodecs>

かなり長いので、一部の抜粋となっています。

EncoderとDecoderがあり、コメントでHardware, Softwareがあり、各Codecには細かな設定が宣言されてることがわかりますね。

androd.media.MediaCodecListを使っても簡単に覗くことも可能ですo

MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.forEach {
      Log.d("MediaCodec", it.name))
}

ExoPlayerのCodecについて

ExoPlayerではMediaCodecRenderでcodecの名前を一意に指定して、MediaCodecを生成して、Decode処理を試みようとします。

try {
 codecInitializingTimestamp = SystemClock.elapsedRealtime();
 TraceUtil.beginSection("createCodec:" + codecName);
 codec = MediaCodec.createByCodecName(codecName);
 TraceUtil.endSection();
 TraceUtil.beginSection("configureCodec");
 configureCodec(codecInfo, codec, inputFormat, crypto, codecOperatingRate);
 TraceUtil.endSection();
 TraceUtil.beginSection("startCodec");
 codec.start();
 TraceUtil.endSection();
 codecInitializedTimestamp = SystemClock.elapsedRealtime();
 getCodecBuffers(codec);
} ・・・

このMediaCodecRenderを継承してるのが、MediaCodecAudio/VideoRendererになっているわけですね。

この内部のgetDecoderInfosでdecoderの情報を取得するようになっているんですが、これを担っているのがMediaCodecSelectorになっています。

MediaCodecSelectorRenderesFactoryで実装したものが指定できます。更にRenderesFactorySimpleExoPlayerを生成するときに指定することができます。

何が嬉しいか

何が嬉しいか話をする前にMediaCodecRenderで選ばれたdecoderInfos達がどのように扱われてるかを知ってる前提で話す必要があるのでそれをさせてください。

取得したdecoderInfosの取り扱い

MediaCodecRender内部であれやこれと処理が進んで、Decodeの準備するかーって段階で maybeInitCodecWithFallback が呼ばれます。

maybeInitCodecWithFallback ではallAvailableCodecInfosとしてMediaCodecSelectorで実装したcodecInfosがMediaAudio/VideoRenderでsortなどの加工がされて流れてきます。

注) ここの加工部分に関しては今回省略しています

この後に以下のような実装がされています

if (enableDecoderFallback) {
 availableCodecInfos.addAll(allAvailableCodecInfos);
} else if (!allAvailableCodecInfos.isEmpty()) {
 availableCodecInfos.add(allAvailableCodecInfos.get(0));
}

このenableDecoderFallbackはdefaultではfalseになっているので、sortされたcodecInfosの一番最初のものが利用されることになります。この時点でcodecInfosは優先度の高い順に並んでます。

このcodecがもしコンテンツと相性がよくなかったり、Codecが対応していないとかで初期化に失敗したら、DecoderInitializationException が投げられ、動画は再生されません。

最適化されたCodec順に並んでて、一番最初に失敗するなんてあるんか???って思うじゃないですか....あるんです。

その大半の理由を占めるのがベンダー依存のhardware codecです。

仮にdecodeに成功しても映像が微妙とかいうパターンもあります。Errorすら検知できないですね、、、

なので、ここでMediaCodecSelectorを使って、MediaCodecSelector.DEFAULTで取得する情報を自分の都合のいいように変えちゃうわけですね。

MediaCodecInfoにはCodecに関しての色んなフィールドがあるので、把握しとくといいですよ!

今回でいうとsoftwareOnlyがあるので、これを使ってsoftwareOnlyなcodecだけを抽出して返してあげればよさそうですねb

decoder fallbackについて

ちなみにenableDecoderFallbackってあったじゃないですか?

あれ一応RenderesFactory.setEnableDecoderFallbackでtrueにできます。

trueにすると、先程のallAvailableCodecInfosに優先度の高い順に並んだcodecがすべて格納されます。なので、最初のcodecがdecodeに失敗しても次の候補でdecodeしてみるという動きに変わるわけですね。

この機能をonにしとけば、何も怖くない!!!とはならないですね

仮にdecodeに成功しても映像が微妙とかいうパターンもあります。Errorすら検知できないですね、、、

こういうパターンはやっぱり弾けないので、、、、

でも個人的な意見ですが、このfallback機能はonにしててもいいと思っています。ExoPlayer的にdefaultでoffにしている理由があるんだろうけど、、、 -> 今度聞いてみます

おまけ

decoder周りで何かを調査したい時にAnalyticsListenerに以下のDecoder周りの有益な情報があるので分析などをしてみたい場合は使ってみるとよいかも(?)ですよ!

  /**
   * Called when an audio or video decoder has been enabled.
   *
   * @param eventTime The event time.
   * @param trackType The track type of the enabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or
   *     {@link C#TRACK_TYPE_VIDEO}.
   * @param decoderCounters The accumulated event counters associated with this decoder.
   */
  default void onDecoderEnabled(EventTime eventTime, int trackType, DecoderCounters decoderCounters) {}

  /**
   * Called when an audio or video decoder has been initialized.
   *
   * @param eventTime The event time.
   * @param trackType The track type of the initialized decoder. Either {@link C#TRACK_TYPE_AUDIO}
   *     or {@link C#TRACK_TYPE_VIDEO}.
   * @param decoderName The decoder that was created.
   * @param initializationDurationMs Time taken to initialize the decoder, in milliseconds.
   */
  default void onDecoderInitialized(EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) {}

  /**
   * Called when an audio or video decoder input format changed.
   *
   * @param eventTime The event time.
   * @param trackType The track type of the decoder whose format changed. Either {@link
   *     C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}.
   * @param format The new input format for the decoder.
   */
  default void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) {}

  /**
   * Called when an audio or video decoder has been disabled.
   *
   * @param eventTime The event time.
   * @param trackType The track type of the disabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or
   *     {@link C#TRACK_TYPE_VIDEO}.
   * @param decoderCounters The accumulated event counters associated with this decoder.
   */
  default void onDecoderDisabled(EventTime eventTime, int trackType, DecoderCounters decoderCounters) {}

さいごに

いかがだったでしょうか! 調べれば、調べるほど、ExoPlayerって本当に実装とか設計が綺麗だなーってなって感動しちゃいます。。。。

Codec周りはまだまだ学ぶことがありふれているので、その辺りを次は学んでいきたいですね

それでは皆さん、よいExo lifeを!!!!!!

暗号化配信について(ExoPlayer + HLS + AES-128)

はじめに

こんにちは!!!!!!最近、暗号周りの勉強をして面白かったので、見返せるように書いてみました!

ここで主に紹介するのはAndroid + HLS + AES-128になりますmm

暗号化配信

最近、当たり前となってきてるストリーミング配信ですが、インターネットを通じて配信されてるので様々な不正利用が考えられます。コンテンツを保護するためにしっかりとコンテンツを暗号化してから、配信しましょう。

暗号化配信の他にも DRM配信がありますが、詳しくは別記事に書いてますmm gumiossan.hatenablog.com

まずは暗号化によく出てきそうなワードを紹介していきます。 暗号化配信では、共通鍵暗号が主に使われるみたいです。

共通鍵暗号

暗号化と復号化の際に使用する鍵が一緒のことを指します。 鍵が漏れたら、誰でも復号されてしまうから、鍵を如何に安全に配布することが重要になってくる。 処理速度が特徴となってるので、大きなサイズの暗号化に向いてます。

共通鍵暗号には大きく以下のような手法があります。

  • ブロック暗号
  • ストリーム暗号

ブロック暗号

平文を一定の大きさ(ブロック)にいくつかまとめて暗号化するやり方。暗号文は平文のサイズと同じになる。

ブロックサイズよりも大きな平文を暗号化するには暗号化モードというのを利用します。

いくつかに区切って処理しているので、処理負荷が小さい。

AES(64, 128, 256)などが代表的なものです。

ストリーム暗号

平文を小さな単位(1bit, 1byte..etc.)で順次処理で暗号化するやり方。

平文と鍵のXOR(排他的論理和)をとって、暗号・復号するのが特徴。

細かい単位で処理しているので、処理速度は速いが効率は悪い。

リアルタイム性が求められる、無線LANの暗号化などにも使われてるみたいです。

安全性はブロック暗号の方が優れているとか...。

IV

IV(initializationn vector) = 初期化ベクトル -> データを解読しにくくするためのランダムなbit列のこと。

例えば、同じコンテンツを同じkeyで何度も暗号化してたら、そのうち暗号文から推測などがされたり、バレる危険性があります。

それを防ぐために同じコンテンツを同じkeyで暗号化はするのですが、IVを合間に挟むことで結果的に出力される暗号文を変える役割を担っています。

ブロック暗号, ストリーム暗号両方で使われていて、両者でIVの実装は異なる。

ブロック暗号IV

後述するCBCモードでIVが主に使われる。

平文と直前の暗号化したブロックを参照し、XORをしたものが次のブロックの暗号文となる。

初めて暗号化する時だけ、直前のブロックはないのでIVが使われる。

ストリーム暗号IV

こっちは単純で疑似乱数生成器で暗号文を作成するのですが、毎回同じseedを使っていると出力される乱数がパターン化される。 なので、(IV + seed)をすることで疑似乱数生成器に偏りをなくすのに使われる。

暗号化モード

ブロック暗号でブロックサイズよりも大きいサイズの平文を暗号化するときに利用するもの。

  • CBC
  • CTR
  • ECB
  • CFB..etc.

色々モードはありますが、配信ではCBC, CTRが主に使われるみたいです。DRMでもこの2つが使われています。

CBCモード

CBC(Cipher Block Chaining) = 暗号ブロック連鎖

先程のブロック暗号IVで紹介したまんまのモードです。

直前の暗号ブロックと平文ブロックにXORをとり、それが次の暗号文となるのを最後まで繰り返します。

一番最初はIVでXORをとります。

つまり、暗号化のときは直前情報がいるから並列処理ができない....(復号化のときはできる)

平文コンテンツがブロックに分けた時にサイズが綺麗に割り切れるとは言い切れません。

なので、最後の方でスカスカになってブロックサイズを埋め合わせたいという時に使われるのがパディングというデータで埋めます。

CTRモード

CTR(CounTeR) = カウンタモード

ブロック暗号なんですが、モードを切り替えることでストリーム暗号として扱えるみたいです。

1ずつインクリメントしていくカウンタを用意し、それを暗号化し、出力される暗号文と平文ブロックでXORをとる。

ストリーム暗号なので、CBCと違いパディングなどの調整は必要ないということですね! こっちでは暗号化も復号化も並列処理ができる...!!!

カウンタは0, 1, 2, 3...nとなっていきますが、それだと結果が固定化しちゃいますよね。 なので、ノンスと言われる使い捨て乱数とカウンタを組み合わせるみたいです! -> IVみたいなもんですね。

HLS + AES-128

HLSの暗号化仕様はAES-128で、CBCやCTRモードのブロック暗号みたいです。

使い方

MediaPlaylistに#EXT-X-KEYを宣言してあげましょう。

サンプル

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:5
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="https://sample.jp/hls/video/key/hoge.key",IV=0123456789ABCDEF0123456789ABCDEF
#EXTINF:5.0000,
otamesi01.ts
#EXTINF:5.0000,
otamesi02.ts
  • METHOD -> AES-128を使うよ!
  • URI -> keyの場所を指してるよ!
  • IV -> 上記で説明していた、IVで、CTRモードなのでノンスとして扱われる(?)

Android

特にクライアントは意識することはないんですが、知っとくだけでも面白いかもしれないですね!

ExoPlayerでは、MediaPlaylistを取得し、そこに記されたsegment file(.ts)を取得してくるのですが、ここの段階でAes128DataSourceを合間に挟んで、内部でURIのkeyとIVを使い、復号化しています。

お気づきかもしれませんが、めちゃくちゃ強度しては弱いです...。キャプチャできる人なら、やりたい放題ということですね...。なので、より強固なDRMを使おうというわけですね!!!

以下は暗号化されたコンテンツをlocalで試したくなったときにどうしたらいいんだろう?ってなったので、その時のメモです!

任意のts fileを用意して、ffmpegとかで暗号化しちゃって、その時使ったkeyをlocal pathとして配置して、MediaPlaylistを作成し、上記のSampleみたいに適切なURIとIVさえ宣言できれば大丈夫そうです!

作成したMediaPlaylistと再生したいts fileをassetsフォルダーを作成し、配置してあげます。

ExoPlayerのDefaultDataSourceFactoryがあり、これはassets配下のhlsをいい感じに読み込んでくれます。 ただのlocalオンリーなら、これでいいのですがURI先が通信発生するのであれば、HttpDataSourceFactoryDefaultDataSourceFactoryの引数に指定してあげます。

あとは再生urlにurl = "asset:///hogehoge.m3u8"という風に指定してあげれば、再生できますb

まとめ

暗号って難しい分野だと個人的には思ってるのですが、これがないとインターネットの秩序は最悪ですね。。。先人に感謝しましょう!

余裕がある人はDRMを使って、動画を守っていきましょう!!

それでは皆さん、よいExo lifeを!!!!!!

DRMで動画を守ってあげたい(ExoPlayer + Widevine)

はじめに

こんにちは!!!!最近初めてOSS(ExoPlayer)にPRが出せて喜んでいるぐみおじです!!!!

今日は動画を不正利用から守るための技術について触れていきます!動画を守れる優しい世界を目指していきましょうしょうb

注: この記事では、Android(ExoPlayer) + DRM(Widevine)にフォーカスをあてていきます!!

なぜ動画を守るか

なんで動画を守る必要があるか?

よく挙げられるのは無断転載とかですかね?誰かが一生懸命時間かけて、利益を得るために作成された動画が第三者にその権利を奪われてしまうとかとか....

そんな悲しい世界を少しでも減らすために動画を不正利用から守っていきましょう!!!

動画を守るには

でわ、どうやって動画を守るのでしょう? たくさんやり方はありますが、セキュリティレベルを上げようと思えば思うほどコストがかかってきます...なので保護対象コンテンツとコストとのトレードオフで選択していきましょう

動画を守る方法にどんなのがある?

たくさんありますが今回は、ストリーミング配信には欠かせないこのご時世かつ、セキュリティレベルが最も高いDRMについてみていきます!!!

余談: めちゃくちゃ余談なんですが、商用のDVD, BDとかを保護するためにCSS, AACS..etc.が使われてるのですが、暗号化keyがインターネットに流出してしまったことで無力になってしまったらしいです....どんな強固なセキュリティもkey一つで裸になるんだな~って。気をつけましょうね!!!

暗号化配信について書いたので、ぜひぜひこちらもmm

gumiossan.hatenablog.com

DRMとは

next scapeさんの記事を見ましょう!!!!

media-streaming.nextscape.net

動画を守る手段の1つで、最もアツいのか〜!くらいの認識で大丈夫です。

DRMを実現するのに重要なCDMについて、少し触れます

CDMとは

Content Decryption Moduleの略。

これはDRMコンテンツを復号化するための役割を持っています。

AndoridのCDMはGoogleに認可されたデバイスに搭載されており、使用するDRMに対応していないと機能しません。

DRM対応したい!CDM搭載している!だからPlayReadyのコンテンツが再生できる!という訳にはいかないということですね、、、

WidevineはGoogleが提供しているので、ほぼ全てのAndroidバイスに搭載されているのと、ライセンス料がかからないので安心してください。

ライセンスサーバーとは

対象のDRMコンテンツを復号化するための情報のやり取りを行うサーバーです。

CDMを搭載していて、対象のDRMに対してのライセンスサーバーに対応していたら、復号化するための情報をやり取りすることができます。

どゆこと????

対象のDRMごとに企業がライセンスサーバーを管理しています。

DRM対応したい!CDM搭載している!だからPlayReadyのコンテンツが再生できる!という訳にはいかないということですね、、、

これの意味はCDMがPlayReadyに対応していないから、そもそもライセンスサーバーとのやり取りが無視されてしまう的なやつです。

なぜWidevineか

AndroidアプリでExoPlayerを使う前提で話しますが、ExoPlayerのドキュメントにサポートしている対応表が載っています

exoplayer.dev

DRMの選択肢にWidevine, ClearKey, PlayReadyと3種類あります。 cenc, cens, cbcs, cbc1はどうやって暗号化するかな〜っていうモードのようなものだと思ってください。 詳しい説明はこの記事ではしませんので、ぜひ調べてみてください!

  • cenc -> CTR モード + Sub-Sample Encryption
  • cbc1 -> CBC モード + Sub-Sample Encryption
  • cens -> CTR モード + Sub-Sample + Patterned Encryption
  • cbcs -> CBC モード + Sub-Sample + Patterned Encryption

ClearKeyはDRMの枠には入りますが、WidevineやPlayReadyと比べると力が弱いです。理由は、JSONでやり取りしているので通信キャプチャなどされてしまうとkey情報などが漏れて、堅牢性が低くなるからです。 そもそもDASHでしか使えないので、HLSで配信している場合は使えません...。

PlayReadyはAndroidTVだけですね。。選択肢から外しましょう。

Widevineを使うのですが、modeはcenc以外はversion7.1からですね...現状のminSdkVerで考えるとまだ難しそうです。

HLSはfmp4の形式しか対応していないので、気をつけましょう!

余談ですが、WidevineはJSONではなく、ProtocolBufferでやり取りが行われますので安心ですね。

最後にDRMのセキュリティレベルについて触れておきましょう。

DRMセキュリティレベル

DRMにはセキュリティレベルみたいな概念があります。

WidevineだとL1 ~ L3という表現が使われ、L1が最もセキュアです。

先程のPlayReadyの横にSL2000みたいなのが添えられてたと思いますが、あれもPlayReadyのセキュリティレベルの表現方法です。

じゃあ最も強いL1を使えばいいんじゃないの?って思うかもしれませんが、TEEという環境がデバイスに用意されている必要があります。 このTEEで復号化などが行われてるかどうかで、レベルが変わってくるのでデバイスに依存するということですね...。

AndroidにはTrusty OSというAndroid OSと同じプロセッサで並行で動作するOSが提供されてます。これがベンダーによって、対応されていたらL1認定ということですかね?(この辺りは深堀りできていません) source.android.google.cn

TEEで処理しないのが最低レベルのL3になります。ディズニーなどの有名な一部のコンテンツは、配信するにはL1しか許さん!みたいな企業もあるので、L3のみの対応だと厳しそうですね....

制限が設けられてないコンテンツはL3で、一部のコンテンツはL1で扱うようにして、対応できない場合は扱いのデバイスでは残念ながら動画を視聴することはできません....ごめんなさいって表示するのがいいんですかね? :thinking_face:

自分の端末のセキュリティレベルとCDMの詳細知りたくない?

DRM infoというアプリで確認することができますので、興味がある方はみてみるといいかもしれません!

自分はGalaxy S10+を使用しています。

ClearKeyとWidevineに対応していて、レベルはL1ということが分かりますね!さすがGalaxy!

f:id:qurangumio:20200621173933j:plain
Galaxy S10+ DRM info

WideVine事前準備

実はめちゃくちゃめんどくさいです...。next scapeさんが準備手順をまとめてくださってるのでこちらを読んでくださいましmm

media-streaming.nextscape.net

要するにプロキシサーバーを建てるには、世界のどこかで行われているCWIPという試験に部署の誰かが2人以上合格しないとスタートラインにすら建てないと.....うわぁ....。

そのためのnext scapeさんのようなコストはかかりますが、提供してくださる企業があるというわけですね。なるほど

Android + DRM

AndroidにはDRM機能を提供するためのDRM frameworkが用意されてます。

source.android.com

DRM frameworkがやってくれること

  • 複雑な DRM オペレーションの処理
  • ライセンスの取得
  • バイスのプロビジョニング
  • DRM コンテンツとライセンスの関連付け
  • DRM コンテンツの復号化などを行うためのシンプルなAPI

今まで説明に出てきた、ライセンスサーバーとのやり取り、CDMへのアクセスetc.をやってくれるわけですね!

ExoPlayerを使う場合は内部でこれをよしなにやってくれます。

ExoPlayer + DRM

それでは実際に動画にWidevineのプロテクトをかけてみて、ExoPlayerで再生していきましょう!

DRMコンテンツの準備

適当な動画を用意 -> こちらのサンプルを拝借

用意した動画にShakaPackagerでWidevineプロテクトをかけて、HLSとDASHを用意してみる。

このExampleのようにそのまんま実行するだけですb

google.github.io

packager \
 in=sample.mp4,stream=audio,output=audio.mp4 \
 in=sample.mp4,stream=video,output=video.mp4 \
 --protection_scheme cenc \
 --enable_widevine_encryption \
 --key_server_url https://license.uat.widevine.com/cenc/getcontentkey/widevine_test \
 --content_id 7465737420636f6e74656e74206964 \
 --signer widevine_test \
 --aes_signing_key 1ae8ccd0e7985cc0b6203a55855a1034afc252980e970ca90e5202689f947ab9 \
 --aes_signing_iv d58ce954203b7c9a9a9d467f59839249 \
 --mpd_output sample.mpd \
 --hls_master_playlist_output sample_master.m3u8

protection_schemeでmode設定できるんですが、cencに指定してます!

生成された動画は自分でhostingしてもいいですし、assets配下に置いて DefaultDataSourceFactory を使ってlocalで再生も可能ですb

何もせずにこのままWidevineコンテンツを再生してみましょう!

f:id:qurangumio:20200621230215g:plain
widevine_play_fail

当たり前ですが、動画にはプロテクトがかかってますし、再生ができるわけないです。ただ、しっかりと守られていることが確認できました!

ExoPlayerでDRMを再生

Exoがよしなにやりすぎて、結構シンプルにできちゃいますw

注: 以下のコードは2020/06/21 現在のExoPlayer 2.11.5を使用しています

localのassetsを読み込むためにDefaultDataSource, ライセンスリクエストするためにHttpDataSourceを用意しています

val httpDataSourceFactory = DefaultHttpDataSourceFactory("exoplayer-sample-app")
val dataSourceFactory = DefaultDataSourceFactory(this, httpDataSourceFactory)

val hlsUri = Uri.parse("asset:///sample_master.m3u8")
val dashUri = Uri.parse("asset:///sample_dash.mpd")

val drmCallback = HttpMediaDrmCallback(
 LICENSE_SERVER_TEST,
 httpDataSourceFactory
)

val drmSessionManager = DefaultDrmSessionManager.Builder().build(drmCallback)

val hlsMediaSource = HlsMediaSource.Factory(dataSourceFactory)
val dashMediaSource = DashMediaSource.Factory(dataSourceFactory)

return hlsMediaSource.setDrmSessionManager(drmSessionManager).createMediaSource(hlsUri)

めちゃくちゃシンプルを極めるなら、これだけで再生できます....すごい

再生してみましょう!再生はできますが、自分の端末でしか確認できません!キャプチャしても見ることはできません....DRMの素晴らしさを感じ取っちゃいましたね....()

登場人物はこんな感じです!

ExoMediaDrm
 -> DRMコンテンツを復号化するための鍵をリクエストするための情報を生成してくれる

FrameworkMediaDrm
 -> ExoMediaDrmを実装したやつ、MediaDrm APIを含むDRM FrameworkをExo内部でwrapしたやつですね!CDMはここに含まれることになります

DrmSessionManager
 -> DrmSessionを管理してくれる(そのまんま)
 -> 大抵のユースケースはDefaultDrmSessionManagerで満たせる
 -> Default値でwidevine指定とかproviderに勝手にUUID生成してsetしてくれるので、buildにdrmcallback渡すだけが一番シンプル

HttpMediaDrmCallback
 -> 基本offlineでDRMを取り扱わない限りは通信しないとだめ
 -> provisioningやkeyのrequest..etc.のDrmSessionからのcallbackを受けて、ライセンスサーバーにpostしてくれる
 -> localもあるけど、今回はテストサーバーにリクエストするからHttpでいきます

何となく今まで説明してきたところに当てはまりそうですね!

DRMコンテンツ再生したい -> プレイリストなどの様々な情報から、CDMに復号化ししたいから、ライセンスサーバーにリクエストするための情報をくれってお願い -> リクエスト情報を元にPlayerがライセンスサーバーにそれをPOSTして、復号化するための情報をレスポンスとして受け取る -> そのレスポンスをまたCDMに流して、DRMコンテンツが復号される -> 再生完了v

つまり、Playerやアプリ側はライセンスサーバーとのやりとりを仲介してるだけで、ほとんどの作業はDRM Framework配下で行われてることがわかりますね!

以下は、まだまだ自分の理解が曖昧なのでこれから深掘っていきたい箇所です!

key rorateしたい場合 -> multiSession(true)にしたらよい

offline対応したい場合 -> setModeを使い、offlineLicenseKeySetIdを設定してあげるとよさそう

clearなコンテンツとdrmコンテンツが混じってる場合 -> decoder切り替えを毎回行うとパフォーマンスが悪くなるので、setUseDrmSessionsForClearContent(true)を使おう

まとめ

色々準備や知識が大変なDRMですが、使えるようになれば強力な武器になるということが伝わりましたかね!?!?

DRMを使って、動画を守っていきましょう!!

それでは皆さん、よいExo lifeを!!!!!!

ExoPlayeのABRはどう実装されてるか

はじめに

おはようこんにちはおやすみなさい!!!!!!!!!!最近、あつ森とゼノブレイドにハマってる、ぐみおじです!!!!!

今回はExoPlayerのABRについて、調べる機会があったので調べたのを記事にしました!

2020/04/10(金) 15:00時点のExoPlayerのversion2.11.4を対象としています

ABRとは

Adaptive bit rateの略

以下、日本デジタル・プロセシング・システムズ株式会社様のサイトより www.dpsj.co.jp

動画コンテンツを視聴環境(デバイスや回線速度など)に合わせて、可能な限り品質高くスムーズに再生できるようにするための動画配信技術

具体的には視聴端末の処理能力や解像度、回線速度などに応じて届ける動画の品質を自動的に上げたり下げたりすることで、視聴者に可能な限り快適な視聴体験を提供する

つまり、各ユーザーの視聴環境に合わせ、高品質な動画をスムーズに提供するための動画配信技術のこと

配信サーバーがABR対応のprotocolに準拠していたら、実現できます 主流なのはHTTPベースの以下の2つです

  • MPEG-DASH
  • HLS etc.

通信環境によって、適切な解像度の動画を提供する際に内部でどうやって見分けているか

ABRを実現するには、各帯域幅に対応した解像度を提供するmaster playlistをサーバーは用意して上げる必要があり、playerはそれらを内部で適切に選択する必要が出てくる

例: master playlist

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1100000,RESOLUTION=852x480
http://example.com/480/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2200000,RESOLUTION=1280x720
http://example.com/720/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
http://example.com/1080/index.m3u8

各playerはこのBANDWIDTH(以下、帯域幅)の値を基準に内部で適切な動画を選択してるということになります

例えば、上のmaster playlistの定義を借りて、僕の一時的な帯域幅が3.2Mbpsだったとき、720のmedia playlistを選択して、再生しようとします

f:id:qurangumio:20200420184502j:plain
abr_image

参考動画

いくつかDroidkaigi2020でtakusembaさんが、共通する部分を動画で話してくださってるので動画で見たい人はこちらへ!!! youtu.be

でわ、ExoPlayerはどう実装しているかを見ていきましょう!!!

ExoPlayerのABR

結論

ユーザの帯域幅 >= master playlistに定義された帯域幅の関係が成り立つ、最も大きなplaylistを選び

現在の解像度より、上がる場合

  • bufferdが安全に切り替えれるくらいの最低限のbuffer(live edge込)を満たしている

現在の解像度より、下がる場合

  • bufferdが今すぐ切り替えないといけないくらいに余裕がない

簡易実装フロー

ここでは簡易的なフローを書いています

更に深ぼった実装詳細は下の方に書いています

初めにplayerがあるmater playlistをダウンロードして、再生を試みようとします

次にchunkをloadするタイミングがあり、そこで適切なmedia playlistを選択するロジックが入ります

ABR適応するときに以下の2つを考慮している

  • ユーザーの帯域幅はmaster playlistに定義された帯域幅を満たしてる?
  • 上記を満たしてるけど、安全に切り替えるために充分にbufferingできてる?

帯域幅について

playerはユーザの帯域幅を知る必要があり、player内で実測値を見積もる実装の必要があります

この見積もった値を使い、master playlistの帯域幅を大きい順から比較していき、それ以下を満たすmedia playlistが選択されます

bufferingについて

帯域幅比較で選ばれたmedia playlistを適応するかどうかの際に以下のparamsが関わってくる

  • 現在再生中のmediaの地点(current)
  • 現在load済みのmediaの地点(loaded)
  • buffer済みのmediaの期間(bufferd = loaded - current)
  • 現在edgeのmediaの地点(live edge)
補足: live edgeとは
playerはserverに動画をリクエストをし、server側で複雑な処理を重ね、最終的にレスポンスとして返ってきます
また、playerはbufferingしないと再生できないため、bufferingすることも含め、再生完了の状態になるまで、何秒かのlatencyが発生します
playerは一番最新のリクエストだけを知っているので、まだレスポンスとして返ってきてはいないが、何も問題がなければ、いずれ返ってくるのは分かっています
そのいずれ返ってくるはずであろう、一番最新のリクエストが返す動画コンテンツの秒数地点のことを指します
生放送や低遅延といった配信では、このlatencyの間隔が短くなるため、edgeが近いという表現を使います

f:id:qurangumio:20200420180004j:plain
buffering_params_image

更新するとき、以下の条件を満たしてると変わらない

  • 高品質に切り替えるくらいの帯域幅はあるけど、安全に切り替えるためのminBuffer(指定可)がbufferedより大きい(minBuffer > buffered)
    • もしくは、Live streamingの場合はedgeが近くなるため、minBufferを満たせないという状況が増え、代わりにedgeを基準にして、currentからedgeまでを全体とし、bufferedが何割(指定可)以下しか占めていない
  • 低品質に切り替えないといけない帯域幅だけど、bufferdが今すぐ切り替える必要がないくらい余裕(maxBuffer)があるとき(指定可)

僕の帯域幅が3.2Mbpsあり、720のmedia playlistを再生していたとします 通信環境が少し悪くなり、1.6Mbpsほどの帯域幅になりました

順番にmaster playlistの帯域幅と比較した結果、480のmedia playlistが取得できることになりました

  • 1080: 5.0Mbps >= 1.6Mbps -> x
  • 720: 2.2Mbps >= 1.6Mbps -> x
  • 480: 1.1Mbps <= 1.6Mbps -> o

720から480の低品質に切り替える必要がありますが、この段階で、bufferingはどれほどたまってるでしょう?

  • current(現在の再生地点): 45s
  • loaded(現在のload済地点): 55s
  • buffered(現在buffer済みの期間): 10s
  • live edge(現在判明しているedgeの地点): 60s

今すぐ切り替える必要がないくらいのbuffer(maxBufferとする)を25s間と設定していたとします(指定可) bufferd < maxBuffer -> x

bufferに余裕がない判定となり、480に切り替わることが確定しました

実装詳細

ここからは、ExoPlayerのABR実装がどのように実現しているかをみていきます

Keyとなってくるのは以下の3つのclassです

  • AdaptiveTrackSelection
  • DefaultBandwidthMeter
  • DefaultBandwidthProvider

簡易フローより

次にchunkをloadするタイミングがあり、そこで適切なmedia playlistを選択するロジックが入ります

これはHlsChunkSource#getNextChunk()内のAdaptiveTrackSelection#updateSelectedTrack()を呼び出しています

AdaptiveTrackSelection

ユーザの帯域幅を推測した結果を用いて、master playlistの帯域幅と比較し、解像度を上げるか、下げるかを内部で判断しているclassです

これはDefaultTrackSelector生成時に constructor内でdefaultで作られます

また、ExoPlayerがDefaultですぐに利用できるように用意してあるだけで、自分でカスタマイズしたTrackSelectionを指定することも可能です

このクラスでABRを実現するために大事なParamsが以下のようにあります

@Nullable private final BandwidthMeter bandwidthMeter;
 -> ユーザの帯域幅を計測して、提供してくれる

private final int minDurationForQualityIncreaseMs; 
 -> default: 10000ms
 -> 高品質に切り替わるのに必要なbufferの最小期間

private final int maxDurationForQualityDecreaseMs;
 -> default: 25000ms
 -> 低品質に切り替わるのに必要なbufferの最大期間

private final float bandwidthFraction;
 -> default: 0.7f
 -> 実計測値の内、使用可能とみなす帯域幅の割合。Estimatorの不正確さを考慮して、1以下がいいらしい

private final float bufferedFractionToLiveEdgeForQualityIncrease;
 -> default: 0.75f
 -> Live streamingの場合はedgeが近くなるため、minBufferを満たせないという状況が増え、代わりにedgeを基準にして、currentからedgeまでを全体とし、bufferedが占めている割合

生成段階の説明は今回の主旨とズレるので、割愛します

HlsChunkSource#getNextChunk()から、updateTrackSelectionsが呼ばれた際の説明をしていきます

やってることとしては、media playlistsが配列表現されており、TrackSelectionは選択したplaylistのselectedIndexとしてフィールドを持ってるので、そのindexを更新してる処理をdetermineIdealSelectedIndexで行ってます

ここで、DefaultBandwidthProviderから帯域幅(以下、effectiveBitrate)をもらい、master playlistの帯域幅がそれ以下だと適用されます

更に言うと、再生速度(playbackSpeed)に邪魔されないようにmaster playlistの帯域幅にplaybackSpeedを掛けた上で比較します

Math.round(trackBitrate * playbackSpeed) <= effectiveBitrate;

この時点で、ユーザに次に返すコンテンツ候補が選ばれた状態です

まだ、候補なだけで適用ではないです

最初の結論のとこにも書いたとおり、条件を満たした場合は解像度が現状維持のままで変わりません

if (selectedFormat.bitrate > currentFormat.bitrate && bufferedDurationUs < minDurationForQualityIncreaseUs(availableDurationUs)) {
  selectedIndex = currentSelectedIndex;
} else if (selectedFormat.bitrate < currentFormat.bitrate && bufferedDurationUs >= maxDurationForQualityDecreaseUs) {
  selectedIndex = currentSelectedIndex;
}

minDurationForQualityIncreaseUs(availableDurationUs)は、live edge込でみてくれてます

これで最終的に適用されるselectedIndexがHlsChunkSourceにいき、Uri selectedPlaylistUrl = playlistUrls[selectedTrackIndex];という形でとってきてくれるわけです

ちなみに内部で、bitrate比較は高い順にみていってるので、最初にtrueとなったindexが選ばれるようになってます

これは親のBaseTrackSeletionの内部でcompareをoverrideしていて、compare(b.bitrate - a.bitrate)になっていることから、高い順に調べてることがわかる

DefaultBandwidthMeter

BandwidthMeterをDefault実装していて、帯域幅を計測してくれるclassです

以下のparamが計測する際に大きく関与します(指定可)

private int slidingWindowMaxWeight;
 -> default: 2000
 -> playerが帯域幅を必要としたタイミング時に、合計でどのくらい過去のサンプリングまでを参考にするかの重みの最高値

ユーザがmedia fileをダウンロードしてくる際にどのくらい時間がかかったかのサンプリングを集めていて、帯域幅を必要とされた時から、一定の過去の区間までの中央値を計測値としています

この過去の区間で、どこまで遡るかを決めるのがslidingWindowMaxWeight(以下maxWeight)で、これはサンプル1つ1つにweightがつけられていて、このweightがtotalを超えるまでを中央値の区間とされます(totalWeight > maxWeight)

f:id:qurangumio:20200420180100j:plain
bandwidthの計測

また、初期bitrate(指定可)は計測されていないので、分かりません

そのため、ExoPlayerは各network, 、国別で予めグループ分けして、決め打ちで定義がされています

色んな国のnetwork事情が見えてくるので、一度覗いてみると面白いと思います

  /** Default initial Wifi bitrate estimate in bits per second. */
  public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI =
      new long[] {5_700_000, 3_500_000, 2_000_000, 1_100_000, 470_000};

  /** Default initial 2G bitrate estimates in bits per second. */
  public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_2G =
      new long[] {200_000, 148_000, 132_000, 115_000, 95_000};

  /** Default initial 3G bitrate estimates in bits per second. */
  public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_3G =
      new long[] {2_200_000, 1_300_000, 970_000, 810_000, 490_000};

  /** Default initial 4G bitrate estimates in bits per second. */
  public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_4G =
      new long[] {5_300_000, 3_200_000, 2_000_000, 1_400_000, 690_000};

日本では以下のように定義されています

ex.) {WIFI, 2G, 3G, 4G}
countryGroupAssignment.put("JP", new int[] {0, 2, 1, 1});

これを各配列に当てはめてみると、日本の初期値はこんな感じになりそうです

{WIFI: 5_700_000, 2G: 132_000, 3G: 1_300_000, 4G: 3_200_000}

ちなみに5Gは現段階では、WIFIと同じになってます この初期値は最初のmedia playlist取得の際に利用されます

いやいや、日本のWIFIが全員5.7Mbpsもあると思ったら、大間違いですよ...ってなりますよね

そのためにsetInitialBitrateEstimate(long initialBitrateEstimate)があり、これで初期bitrateを指定してあげることができます

おまけ程度ですが、setResetOnNetworkTypeChangeをONにすると、networkTypeが変わるたびに初期値に戻ります

DefaultBandwidthProvider

BandwidthProvider interfaceをDefault実装していて、割り当てた帯域幅を提供してるclassです

上記のBandwidthMeterにより、計測された帯域幅にbandwidthFraction(指定可)をかけた帯域幅が、最終的にmaster playlistの帯域幅と比較されます

これはインターネットの不確実性を考慮して、実際の値より小さめに設定するために以下のような計算が行われています

bandwidthMeter.getBitrateEstimate() * bandwidthFraction

さいごに

動画技術って一見難しそうですが、やってることはシンプルなこと多いですよね(たぶん)

こういうのをもっと知っていけたらなと思います!!!これからも学びの気持ちでいきましょう!!!

それでは皆さん、よいExo lifeを!!!!!!

ExoPlayerのBufferingについて

はじめに

こんにちは!!!!いよいよ花粉が今週過ぎ去れば、平穏な日々がやってく....????

ハッッッッッックショイ!!!!!!!!!!!!!!

今日はタイトルの通りの疑問を持ったので、DefaultLoadControl classを少し覗いて紹介したいと思います

ExoPlayerってなんやねん!!!って方は先にこっち

gumiossan.hatenablog.com

DefaultLoadControl



まず、ExoPlayerを生成する際に自分でカスタマイズできる1つにLoadControlがあります

何も指定しなければ、DefaultLoadControlが使用されます



こいつって何してるの???
名前から察するにLoad周りの処理を制御する何かやろ〜くらいの気持ちでみていきましょう!

今回のkeyになってくるのが、以下の4つ

public Builder setBufferDurationsMs(
        int minBufferMs,
        int maxBufferMs,
        int bufferForPlaybackMs,
        int bufferForPlaybackAfterRebufferMs) { ・・・ }
  • minBufferMs
    • Exoが追加のデータを読み込む前のbufferされた最小量 = 最小でこんなけは持ちたいよ!
  • maxBufferMs
    • Exoが再生開始してから、更に停止する前に読み込む最大量 = 最大でこんなけは持てるよ!
  • bufferForPlaybackMs
    • 再生開始する前にどのくらいbufferするかを表す (minBuffer <= bufferForPlayback)
    • これはセグメントを完全にbufferしなくても最低限bufferForPlaybackMsを確保したら、再生開始する
  • bufferForPlaybackAfterRebufferMs
    • 再生後にbufferがなくなって、rebufferingが発生し、その際にbufferするか(minBuffer <= bufferForPlaybackAfterreBuffer)

これらのdefault値は以下のとおりです

  • minBufferMs: 15000
  • maxBufferMs: 50000
  • bufferForPlaybackMs: 2500
  • bufferForPlaybackAfterRebufferMs: 5000

ここで最低限確保したいminBufferMsが、bufferFor~の2つより小さいと意味不明って言われるわけですね minBufferMsが自分が2秒確保してるよ〜って保障したいのに、bufferFor~らが再生開始までに10秒必要やわ〜って言われてると矛盾を感じますよね...(いや、2秒ちゃうんかーいって)

再生開始するまでに10秒もbufferしたら、最低でもminBufferは10以上は確保されてないとおかしいわけです

実際、minBufferMsをbufferFor~より小さくしてみると

minBufferMs cannot be less than bufferForPlaybackMs

.........怒られました。

ここまで言いたいこと

ここまでで分かることをAkamaiさんが紹介してくれてるので、それを見ながら書いていきます!

blogs.akamai.com

単純にstart up time(再生開始時間)を速くしたかったら、bufferForPlaybackMsを限りなく0に近づければいいと!

ただ、それをすると最初に充分なbufferingが行われず、rebufferingがめちゃくちゃ発生するというわけですね…

VODコンテンツなどの終わりが分かっているものなら、maxBufferMsをたくさん伸ばしてあげれば、rebufferingが減って快適な視聴体験を届けれる...?

届けれるけど、maxBufferの値が大きいほど、Exoのメモリが消費されるので、世の中のデバイスの平均的な積まれてるメモリなどは把握しといた方がいいかも…。 更に途中で再生停止して離脱されたら、帯域幅をたくさん無駄に食べてしまい、通信量こんなに使ってないぞー!なんて怒られたり…

以下はExoPlayerのチームが行った実験の紹介なのですが

github.com

Exoで常に大きなbufferを確保しておけば、rebufferingは起きにくくなるので、minBufferとmaxBufferは同じ値にしてあげたらよいと言っています。rebufferingが起きにくい、快適な視聴体験を届けることができて、電池消費量がちょっと増えたくらいって言ってますね。これは今の値じゃ、burst bufferというのが発生し、rebufferingになりやすいから、それが起きないようにmin, max値を一緒にすることを→点滴供給方式って言っていますね(どゆこと???)

まぁ、この辺の詳細はあまり理解できていないので、要勉強します!!!!!!!!!!!!!!!!!!むしろ教えてください!!!!!!!!!

以上から、これらの設定できる4つの値はトレードオフな関係なのが分かりますね….

ExoPlayerはその辺りを全て考慮して、現在のdefault値を定めてるんでしょうね(すばらしい)

あくまでケースバイケースになるので、ユーザに値をいじれるようになってるのもさすがという感じです(すばらしい)

皆さんのプロダクトに合わせた、用法用量を守り、値を調整しましょう(すばらry)

実装覗き見

最後にこの辺りをDefaultLoadControlは結局どういう風にBufferingしようとか、Rebufferingだなとか判断しているのかを見て終わりにしたいと思います!

keyとなりそうなのは、以下の2つ

@Override
public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) {
  boolean targetBufferSizeReached = allocator.getTotalBytesAllocated() >= targetBufferSize;
  long minBufferUs = hasVideo ? minBufferVideoUs : minBufferAudioUs;
  if (playbackSpeed > 1) {
    // The playback speed is faster than real time, so scale up the minimum required media
    // duration to keep enough media buffered for a playout duration of minBufferUs.
    long mediaDurationMinBufferUs =
        Util.getMediaDurationForPlayoutDuration(minBufferUs, playbackSpeed);
    minBufferUs = Math.min(mediaDurationMinBufferUs, maxBufferUs);
  }
  if (bufferedDurationUs < minBufferUs) {
    isBuffering = prioritizeTimeOverSizeThresholds || !targetBufferSizeReached;
  } else if (bufferedDurationUs >= maxBufferUs || targetBufferSizeReached) {
    isBuffering = false;
  } // Else don't change the buffering state
  return isBuffering;
}

@Override
public boolean shouldStartPlayback(long bufferedDurationUs, float playbackSpeed, boolean rebuffering) {
  bufferedDurationUs = Util.getPlayoutDurationForMediaDuration(bufferedDurationUs, playbackSpeed);
  long minBufferDurationUs = rebuffering ? bufferForPlaybackAfterRebufferUs : bufferForPlaybackUs;
  return minBufferDurationUs <= 0
      || bufferedDurationUs >= minBufferDurationUs
      || (!prioritizeTimeOverSizeThresholds
          && allocator.getTotalBytesAllocated() >= targetBufferSize);
}

shouldContinueLoading

これは単純に渡されたbuffere済なdurationがminBufferを満たしていなかったら、bufferingする必要ありますねーくらいの判断をしてます

一応ここで、videoかどうかでminVideo, minAudioってminBufferの分解が行われているのが分かります

shouldStartPlayback

playbackStateがBUFFEINGのときにstateをREADYにして、再生開始していいかを判断してますね

その際にrebufferingかどうかがExoPlayerInternal classのdoSomeWorkでstate READYだけど、renderersReadyOrEndedがfalseになっていると(次のrendererの準備ができていないよ〜って状態)、rebufferingがtrueになり、渡ってきてますね

Rebufferingかどうかで、最小限確保したいdurationを最初に紹介したbufferForPlaybackAfterRebufferにするかどうかが見られてます。 bufferedDurationがminBufferDurationを満たしてたら、OK判定っぽいです。なるほどど

最後に

如何だったでしょうか??? 案外あっさりしているようで、深いな〜って僕は思いました。

もし、mobile/tvごとに帯域環境が異なったり、動画プレイヤー/音楽プレイヤーなど分かれている場合はこの辺りの値をカスタマイズすると効率よくできるかも????

それでは皆さん、よいExo lifeを!!!!!!

【入門】ExoPlayerと仲良くなっていかない?

はじめに

こんにちは!!!!最近花粉で死にかけてます!!!! 12月くらいから、Androidに変わりはないんですが動画周りを扱う専門チームに希望して異動し、日々楽しく過ごしております!!!

Androidももちろんですが、動画単体の記事などをたくさんあげていけたらいいなとおもいます〜〜〜〜

ということで、ExoPlayerと仲良くなるために入門していきましょう

ExoPlayerってなーに?

  • application labelで使えるAndroidで動画や音楽を扱うためのMediaPlayer OSS Library
  • 既存でMediaPlayer APIがあるじゃん
  • ExoPlayerは4.1以降じゃないと使えない!でももう5.0くらいからサポートしてしまえば、よさそうやな?
  • DASH, HLS, SmoothStreaming, 共通暗号化などにも対応してる
  • 優れた設計により、カスタマイズ性, 拡張性が高いということ

こんな親切なサイトまで用意されてる!軽く目を通して見るといいかも? 全く分からんわー!って人はこの記事を読み終わってから、もう一回見に行ってもいいかも!

exoplayer.dev

ExoPlayerのCodelabsが用意されてるので、これを通して、入門していきましょう!!!!

codelabs.developers.google.com

Let's Exo

ExoPlayer導入

みんな大好きGithub

github.com

でわ、AndroidStudioで適当な新規プロジェクトを作っていきましょう!

2020/03/04 現在の一番新しいのにしてみましょう

implementation 'com.google.android.exoplayer:exoplayer-core:2.11.3'
implementation 'com.google.android.exoplayer:exoplayer-dash:2.11.3'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.11.3'

ExoPlayerは基本はcoreが必須でそれ以外は使用用途に合わせて、各moduleを追加していったらいいみたいですね! 試しに追加した、dashはDASHサポートをするためで、UIはプレイヤー再生するためのUI Componentsを提供してくれるみたい!

じゃあ、HLSをサポートしたかったら、exoplayer-hlsとか入れちゃえばいいってことですね!

何が必要かよく分からんし、全部入れちゃえばいいのでは????????

そういうときは

implementation 'com.google.android.exoplayer:exoplayer:2.11.3’

これだけでよきよきです

このままじゃ、うまく動かないのでJava8解禁しましょう

compileOptions {
        targetCompatibility JavaVersion.VERSION_1_8
}

Player View設置

これで準備ができたので、まずはPlayerを表示するためのViewを設置しましょう

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/video_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

さっきui componentを入れといたおかげで、これだけでlayoutは完結しちゃいます...すごい...

実際にcodeでplayerを生成して、video_viewに紐づけていきましょう

private lateinit var playerView: PlayerView
private var mediaPlayer: SimpleExoPlayer? = null

playerView = video_view
initPlayer()

private fun initPlayer() {
    mediaPlayer = SimpleExoPlayer.Builder(this).build()
    playerView.player = mediaPlayer
}

最新verの一番SimpleなPlayerの作り方になります!
Builderにcontextを渡すだでおっけーみたい!

2.11.xでインスタンス生成に大きな変更が出ています。2.11より前のversionでは別途記事を参照くださいmm

このBuilderの引数や、Builder.setHogeHogeで色んなものが渡せるみたいで、基本は渡さなくても全部ExoPlayerが内部で用意してくれているDefaultHogeHogeが使われる形になりそう!
このDefaultHogeHogeを自分で改造したり、都合のいいように拡張してあげて、渡すだけで容易にカスタマイズできそう!

細かい種類の深堀りは後日記事で一つずつ見ていこうと思います!

(2020/03/09現在にその内の1つ、LoadControlについて書きました)

gumiossan.hatenablog.com

現段階でbuildすると何も機能しないThe Player!!っぽいのが表示されましたね???

MediaSourceの作り方

実際に動画を再生するために動画コンテンツデータを表現するMediaSourceを作っていきます!!!

これもSimpleにDefaultDataSourceFactoryを作り、それをProgressive(HLS, Dash..etc.)MediaSourceを作るときに渡してやったら、mp4を再生してみます!(mp3も可)

適当なsample urlをstring resourceとして用意します! 今回は動画に関わったら、嫌というほど見かけるBigBunnyを借りましょう! https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4

指定のuriを渡したら、MediaSourceを返してくれるメソッドでも定義しておきましょう

private fun buildMediaSource(uri: Uri): MediaSource {
    val dataSourceFactory = DefaultDataSourceFactory(this, "exoplayer-sample-app")
    return ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri)
}

ExoPlayerはしっかり面倒みよう

ExoPlayerは何でも出来ちゃう優秀な子なんですが、使用する際にAndroidのLifecycleに合わせて、うまく適切な呼び出しをやってあげないとだめな子です....

この辺はクライアント側で自由にやらせてくれるって意味もありますが、
勝手にやってくれる訳ではないので、適切に書いていきますます

身構えなくてもよいです!playerのインスタンスが空だったら、触らない!createやstartのタイミングでnull checkして、nullならインスタンス生成してあげましょう!

逆にstopやpauseなどでplayerインスタンスが存在する時だけ、不必要になったら関連づいたリソースをreleaseしてあげて、playerインスタンスをnullにsetしてあげたりたり!


private fun releasePlayer() {
    mediaPlayer?.let {
        playWhenReady = it.playWhenReady
        playbackPosition = it.currentPosition
        currentWindow = it.currentWindowIndex
        it.release()
        mediaPlayer = null
    }
}

ExoPlayer周りの値

おいおいおい!しれっと知らん値が出てきてるぞー!ってならないでください(

  • playWhenReadyはplayerが再生するリソースもばっちり読み込んで、問題なくいつでも再生できるよ!ということを返してくれます
  • playbackPositionは現在再生してる動画の位置ですね。これを記憶して、seekTo()とかに渡してあげたら前回の再生位置とか復帰できそうですね!
  • currentWindowは現在再生しているコンテンツの長さを表現するようなものだと今は思っていたらよさそうです!

ExoPlayerはTimeline, Window, Periodという抽象的な概念でmediaというのを表現しています。これは今回深堀りしません、後日書きます

再生してみよう

先程宣言したinitPlayerも少し変えて、実際にuriをparseしてbuildMediaSourceでmediaSourceを再生してみましょう!

作成したmediaSourceををmediaPlayer.prepare()に渡してあげて、動画の準備が出来次第(playWhenReady)再生できます!簡単!すごい!

private fun initPlayer() {
    val uri = Uri.parse(getString(R.string.media_url_mp4))
    val mediaSource = buildMediaSource(uri)

    mediaPlayer = SimpleExoPlayer.Builder(this).build().apply {
        playWhenReady = playWhenReady
        seekTo(currentWindow, playbackPosition)
        prepare(mediaSource, false, false)
    }

    playerView.player = mediaPlayer
}

lifecycleでどのタイミングでinitやreleaseを呼ぶか、currentWindowなどの変数は自分で宣言してください!!!!!

ここまで出来ると無事皆さんもBigBunnyとご対面ですね!!わいわい

プレイリストみたいなことがしたい

でも、今のままじゃ1つの動画しか再生できませんね?? 複数の動画はどうしたらいいのだろう???

複数のMediaSourceをまとめるConcatenatingMediaSourceがあります! これは最近は用語として当たり前になってきた(?)、playlist的なのを作成することを可能にしてくれるはずです!

buildMediaSourceを以下のように変えてみましょう

private fun buildMediaSource(): MediaSource {
    val dataSourceFactory = DefaultDataSourceFactory(this, "exoplayer-sample-app")
    val uri = listOf<Uri>(
        Uri.parse(getString(R.string.media_url_mp4)),
        Uri.parse(getString(R.string.google_sample_url)),
        Uri.parse(getString(R.string.media_url_mp4)),
        Uri.parse(getString(R.string.google_sample_url)),
        Uri.parse(getString(R.string.media_url_mp4)),
        Uri.parse(getString(R.string.google_sample_url))
    )

    val mediaSourceList = mutableListOf<MediaSource>().apply {
        uri.forEach {
            val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(it)
            add(mediaSource)
        }
    }

    return ConcatenatingMediaSource(*mediaSourceList.toTypedArray())
}

複数のMediaSourcesを作成して、それを可変長なConcatenatingMediaSourceに渡しています これだけでプレイリストができちゃいます!wow

工夫次第で自分だけのプレイリストを作ることができるような気がしてきません???あ、しないですか。。はい

ExoPlayerだからこそ、できたこと

次はAdaptive Streamingをやっていきましょう! Adaptive Streamingはざっくりいうと、ユーザーのネット環境などに合わせて柔軟に最適なStreamを勝手に調整してくれるものです(wow

以下の記事を書いたので、詳しくはこちらへ gumiossan.hatenablog.com

つまり、配信サーバー側で各画質のStreamを用意することで、高速wifiに繋いだりすると1080pのFHDのものを再生し、LTEなどに切り替わったら、720pのHDに変わったり、通信環境が最悪になると320pとかに変えてくれるようなことを指します

これらのStreaming規格の代表的なのがDASHやHLSといったものになります

実装難度かなり高そうなことを実現するためにExoPlayerはに内部にTrackSelectorというものがいます

これをSimpleExoPlayerを生成するときにプラスで指定してやりましょう

val trackSelector = DefaultTrackSelector(this).apply {
    setParameters(buildUponParameters().setMaxVideoSizeSd())
}

mediaPlayer = SimpleExoPlayer.Builder(this)
    .setTrackSelector(trackSelector)
    .build()
    .apply {
        playWhenReady = playWhenReady
        seekTo(currentWindow, playbackPosition)
        prepare(mediaSource, false, false)
    }

DefaultTrackSelectorはDefaultでAdaptiveTrackSelectionを生成してくれているので、これで大丈夫ですb

先程のmp4を適当なsample dashのuriに変えてあげて、DashMediaSource.Factoryに変えてあげるだけです!分かりやすくていいですねb

private fun buildMediaSource(): MediaSource {
    val dataSourceFactory = DefaultDataSourceFactory(this, "exoplayer-sample-app")
    val uri = Uri.parse(getString(R.string.media_url_dash))

    return DashMediaSource.Factory(dataSourceFactory).createMediaSource(uri)
}

これでdashコンテンツが再生できるはずですが、いまいちすごさが分からないですね???

Network Link Conditionerという帯域を自由に絞ることができるツールを使って、Wifiから3Gに絞ったときのビデオ品質をみてみたいと思います!
ちょっと以下の画像じゃわかりにくいですが、3Gではそれに見合ったStreamが再生されていて、字幕がすごく見にくいことがわかりますね

f:id:qurangumio:20200306001508p:plain
3G
f:id:qurangumio:20200306001505p:plain
Wifi

でわ、AdaptiveTrackSelection内部で帯域を見積もる役割をしてる、BandWidthMeterのbitrateEstimateをwifiから3Gに切り替えた時にどのくらい値が変わってるかをみてみましょう 動画再生中にWifi -> 3Gに切り替えたら、以下のようにbitrateEstimateがスムーズに切り替わりました

wifi: 1288781 
3G: 339707

教えて、ExoPlayer!

ExoPlayerにはあらゆるユースケースを満たすためののListenerがたくさん存在します それらを少しみていきましょう!

mediaPlayer.add~って入力したら、補完にたくさんそれらしいのが出てきますね??

恐らく、たくさん(?)使いそうなPlayer.EventListenerの以下3つを実装してみましょう!


private val playerEventListener = object : Player.EventListener {
    override fun onIsPlayingChanged(isPlaying: Boolean) {
        val state = if (isPlaying) "STATE_PLAYING" else "STATE_NOT_PLAYING"
        Log.d(TAG, "isPlayingChanged: $state")
    }

    override fun onPlayerError(error: ExoPlaybackException) {
        Log.d(TAG, "Error: ${error.message ?: ""}")
    }

    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
        val stateStr = when(playbackState) {
            SimpleExoPlayer.STATE_IDLE -> "STATE_IDLE"
            SimpleExoPlayer.STATE_BUFFERING -> "STATE_BUFFERING"
            SimpleExoPlayer.STATE_READY -> "STATE_READY"
            SimpleExoPlayer.STATE_ENDED ->  "STATE_ENDED"
            else -> "UNKNOWN"
        }

        Log.d(TAG, "state is $stateStr, playWhenReady is $playWhenReady")
    }
}

とりあえず動かして、色々操作をしながらLog出力とかをみてみると勉強になるかもしれません!

あ、Listener登録はprepareの前にしておきましょう!じゃないと適切なstateが検知できなくなります、、、

アプリ起動 -> state is STATE_BUFFERING, playWhenReady is false
Bufferが溜まってきた -> state is STATE_READY, playWhenReady is false
再生 -> state is STATE_READY, playWhenReady is true && isPlayingChanged: true && isPlayingChanged: STATE_PLAYING
停止 -> state is STATE_READY, playWhenReady is false
シーク操作(蓄えてたbufferをぶっ飛ばします) -> state is STATE_BUFFERING, playWhenReady is false
最後まで見る -> state is STATE_ENDED, playWhenReady is true

再生するプレイリストを意味のわからない文字列に変えて、rebuildしてみましょう
アプリ起動 -> state is STATE_IDLE, playWhenReady is false

再生中にわざとネット切断してみる -> Error: com.google.android.exoplayer2.upstream.HttpDataSource$HttpDataSourceException: Unable to connect to url

ここで有名なExoの図を貼っておきます

f:id:qurangumio:20200306002216p:plain
ExoPlayer State

IDLE -> 何を再生したらいいかわからないよー = 再生できるものがない BUFFERING -> バッファリング中 READY -> 再生可能ですよー! END -> 再生完了しきった!

この辺をみておけば、Playerのstateとかをうまく管理できそうですね! お気づきかもしれませんが、playWhenReady && READYのときは再生中ということになりますね!
前まではこうやって見ていたのですが新しくisPlayingChangedが追加されたので、これをみておけば、再生開始されてるかどうかを検知できます

Errorはそのままのとおりで、Exo内で何かしらErrorを検知したい場合にって感じですね

最後にreleaseのタイミングでしっかり、登録したlistenerはremoveしておきましょう!じゃないとmemory leakしちゃいますからね….

少しだけPlayerViewについて

最後に少しだけPlayerViewをみましょう!


app:use_controller="false"

これをするとプレイヤーのコントローラがみえなくなります
VODならまだしも、LIVEとかだといらないですからね、、、



元に戻して、次は以下を追加してみましょう
基本単位はmsです!

app:show_timeout="10000"
 -> 指定秒数アプリに触れていなかったら、コントローラがhideになります
app:fastforward_increment="30000"
 -> 早送りできます
app:rewind_increment="30000"
 -> 巻き戻しできます



さいごに

割と長くなってしまったので、この記事はここまで!!!!

でも一気に入門できた気になった気がしませんか???そんなことないですか??

実際に手を動かして、読んでくれた人はきっと自分でも気づいていないけど、わかっているかもしれません(?)

最初はふんわりとした理解でいいと思います...ExoPlayerは巨大なので、必要になった箇所を深掘って学んでいけばいいのかな〜〜〜なんて

最後にここまでで書いたコードはあげていますので、ご自由にどぞ!

github.com

なので、ほんとに基本の基本は押さえれていると思います!!!!!保証しませんけどね!!!!!!!
それでは皆さん、よいExo lifeを!!!!!!

【Android】BottomSheetDialogFragmentで少しハマったっていう思い出話と解決策

おはよう、こんにちは、おやすみなさい

ぐみおです

BottomSheetDialogFragmentで少しハマったので、自分のために残します

もし同じようなことで悩まれたら、この記事最高〜〜〜〜って思ってもらう数年後のために残すわけではありません

BottomSheetDialogFragment

下からひょこひょこ出てくるアイツのことです 公式みてね material.io

何にハマったのか

f:id:qurangumio:20191202001217g:plain

??????? これでいいじゃんとか思ったでしょ??? ちがう!

画像サイズが固定で指定してるだけでは、だめなんじ〜〜〜〜〜〜〜〜〜^

Constraintの制約によって、ImageViewをユーザの端末ごとによって動的に表示変えたいんです!!!

じゃあ、すればいいじゃんってね 0dp, 0dpにすればいいじゃんってね

f:id:qurangumio:20191202001647g:plain

画像どこいった? この表示のされ方はおかしいぞ????

なぜ

BottomSheetDialogFragmentの親元で使われているBottomSheet表示するためのdesign_bottom_sheetのFrameLayoutのheightがWRAP_CONTENTになっている

<FrameLayout
        android:id="@+id/design_bottom_sheet"
        style="?attr/bottomSheetStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal|top"
        app:layout_behavior="@string/bottom_sheet_behavior"/>

github.com

は〜〜〜〜〜〜ほんま!は〜〜〜〜ほんま!

は〜ほんまってなりつつもイマイチいい解決が思いつかなかったのですが、とりあえずこうしてみた

class SampleBottomSheetDialogFragment : BottomSheetDialogFragment() {

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return SampleBottomSheetDialog(requireContext())
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val binding = DataBindingUtil.inflate<FragmentSampleBottomSheetDialogBinding>(
            inflater,
            R.layout.fragment_sample_bottom_sheet_dialog,
            container,
            false
        ).apply {
            continueButton.setOnClickListener {
                dismiss()
            }
        }

        return binding.root
    }

    private class SampleBottomSheetDialog(
        context: Context
    ) : BottomSheetDialog(context, 好きなようにstyle作って指定してね) {
        override fun setContentView(view: View) {
            super.setContentView(view)
            val parent = view.parent as? View ?: return

            parent.apply {
                layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
            }

            BottomSheetBehavior.from(parent).run {
                skipCollapsed = true
                state = BottomSheetBehavior.STATE_EXPANDED
            }
        }
    }

    companion object {
        const val TAG = "SampleBottomSheetDialogFragment"
        fun newInstance(): SampleBottomSheetDialogFragment {
            return SampleBottomSheetDialogFragment()
        }
    }
}

Dialogの中にonCreateViewでlayoutがsetされる前に親元のwrap_contentをmatch_parentに書き換えちゃえ作戦ですね.....えいやえいや

override fun onActivityCreated(savedInstanceState: Bundle?)でcontentがdialogにセットされますが、この中でdialogのsetContentViewが呼ばれてるのでBottomSheetDialogを継承したclassのsetContentViewの中に書いちゃいましょう

val parent = view.parent as? View ?: return
parent.apply {
  layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
}

これですね

ほんとにそれでいいんですか!??!?!?は知らないので、もっといい方法あるよって人はこっそり教えて下さい(こっそりですよ)

これを適用したのがこちら

f:id:qurangumio:20191202003307g:plain

よさそう

今回こういう用途として扱いましたが、固定の高さに設定したい場合とかでも使えそうですね

おやすみなさい