どうもこんばんわ。
最近はPixel Watch
を付けて寝てますが、これなら起きられそうです。
ぶるぶる震えてくれる。
本題
前作った、この動画からBitmap
を高速に取り出すあれ、連続で取得しない場合にめちゃくちゃ遅いので、改善しようと思います。
https://takusan.negitoro.dev/posts/android_get_video_frame_mediacodec/
ランダムアクセスというんでしょうか、最初から順番に見ていく分には速いんですけどね。。
どういうこと
シークがめちゃくちゃ遅い。遅すぎ
原因というか、一番時間かかってるのが、この動画からBitmap
を取り出す作業です、記事を書いた時点(修正前)で数秒かかります。
これを改善したのがこれ。確かに速いはずなんですが、なんか分かりにくくて草。
今回は改善するために必要な、キーフレームの位置を取得するお話です。
シークは難しい
そもそも、指定した時間の動画のフレーム取得が難しくて、
1 の場所のフレームが欲しい場合、一番近い 2 のキーフレームまで戻らないといけないんですよね。
1 の場所のフレームは不完全なフレームでキーフレームから変化している箇所のみが記録されているため、完全なフレームにするためには一番近いキーフレーム(2 の地点)まで戻り、 1 の位置まで待つ必要があります。
すべてがキーフレームなら戻る必要なく最高なのですが、すべてキーフレームにすると動画サイズがべらぼうに大きくなってしまいます。ので定期的にキーフレームを入れるようにしたらしい。
もちろんすべてキーフレームな動画ファイルもあります。Apple ProRes
とかいうやつですね。
すべてのフレームがキーフレームなのでくっそ容量がでかいと思う。が、代わりに先述の理由によりどの位置にシークしようとキーフレームしか無いので最速。
シークが遅い理由
で、この自前で作った動画フレームをBitmap
にしてくれるやつ、実は連続アクセス前提で作ったので、動画編集中とかは向いてないんですね。
現状は、欲しい時間のフレームが来る前に、キーフレームが来た場合のみシークをしています。
(つまり、いま1
の位置にいて、4
のフレームが欲しい場合でも、まず2
のキーフレームまでデコードが進み(無駄)、そこで3
のキーフレームへシークする判断になる)
https://github.com/takusan23/AkariDroid/blob/master/akari-core/src/main/java/io/github/takusan23/akaricore/video/VideoFrameBitmapExtractor.kt
キーフレームが来るまでは、欲しいフレームが連続したフレームの可能性があるのでシークしない。これにより連続アクセスは速いですが、シークがとてもおそい。
シークする際に、連続したフレームじゃないよ!ってフラグを渡してあげればいいのですが、うーん。
シークの判断を早くする
これはシークするかの判断に、次のキーフレームが来るまで待っているのが悪い。
キーフレームが来る前にシークすれば速い。。。のですが、Android
のMediaExtractor(コンテナフォーマットからデータを取り出すやつ)
に、キーフレームがどこに入っているかを問い合わせるAPI
がない。
さっきの図を使いまわしますが、このキーフレームの再生位置を取得する方法があればいいんですけど、、、
ありました!!!!
コンテナフォーマットからキーフレームの時間を取り出すやつ!!!。これで即時シークが必要かの判定ができます!やっと本題です。
https://developer.android.com/reference/android/media/MediaParser
本来はMediaExtractor
の代替らしいですが、Android 11
以降じゃないと使えないので、引き続きMediaExtractor
は必要かな。
で、今回の記事はMediaParser
を使って動画のキーフレームの位置を取得してみようという話です。
前フリが長すぎた。
環境
MediaCodec
タグつけたけど今回は出てきません。(???)
なまえ | あたい |
---|
端末 | Pixel 8 Pro |
Android Studio | Android Studio Jellyfish 2023.3.1 Patch 1 |
適当に UI を作る
動画ファイルを選ぶボタンと、キーフレーム一覧を出すためのリストを。
Scaffold
とかは別に関係ないので、ボタンとテキストを表示するリストがあればいいんじゃないでしょうか。
というのも、MediaParser
にデータを渡すためのSeekableInputReader(MediaParser.InputReader)
の実装がない。Android
はインターフェースだけ作って実装はしていないらしい。
ただ、InputStream
っぽいインターフェースをしているので多分難しくない(InputStream
に似せるなら用意してほしかった)。
MediaParserKeyFrameDetector.kt
を適当に作りました。まずはSeekableInputReader(MediaParser.InputReader)
の実装を。こんな感じですかね。
一点、シークするメソッドseekToPosition()
が厄介です。
これは指定した位置にInputStream
の読み取り位置をセットする実装を書けばいいのですが、InputStream
の読み取り位置をセットするにはInputStream#mark()
とInputStream#reset()
に対応していないといけないんですよね。
で、で、で、動画をPhotoPicker
で選んで、貰ったUri
で作るInputStream
は残念ながら、mark()
とreset()
出来ません。多分InputStream
の作り直しを要します。
というわけで、private val onCreateInputStream: () -> InputStream
て感じで、InputStream
を返す関数を引数に取るようにしました。作り直しが必要になったらonCreateInputStream
が呼ばれる感じですね。
MediaParser.OutputConsumer
をまず作ります。
取り出したデータはこのコールバック関数で受け取る形です。
ただ、今回はキーフレームがとこにあるか以外のデータ(実際の映像データとか)には興味がないので、適当に捨てています。
捨てている箇所がonSampleDataFound
ですね。一応キーフレームの位置の情報さえ貰えれば終わりでいいので、isFoundSeekMap
フラグを立てています。
コメントを読んでもよく分からなかったので、サンプル通りにしてみました。
onSampleDataFound
でInputReader#read
しないとなんかダメみたいです。先述の通り読み取った後のデータには興味がないので、適当にtempByteArray
へ上書きさせています。
MediaParser.create
の第2引数は可変長引数で、とりあえず有り得そうなコンテナフォーマットを指定しています。
https://developer.android.com/reference/android/media/MediaParser#create(android.media.MediaParser.OutputConsumer,%20java.lang.String[])
あとはデータが無くなるか、isFoundSeekMap
のフラグが立つまでwhile
でMediaParser#advance
を呼び続けます。
advance()
の中でよしなにInputReader
のread
とか、seekToPosition
が呼び出されるわけですね。
最後、キーフレームの位置が分かったら問いただしています。
キーフレームの位置が分かるというか、時間を渡すとキーフレームの位置を返してくれる、の方が正しいですね、適当に 1 秒間隔で問いただしています。
流石にキーフレームが 1 秒未満の間隔に、、、ならないよね?
1 秒間に複数のキーフレームがある動画があったため修正しました。そのため 1 ミリ秒間隔で問いただしています。
ちなみに表示される時間の単位はマイクロ秒です。1 秒 == 1_000 ミリ秒 == 1_000_000 マイクロ秒
UI 側から読んで完成
はい!
使ってみる
ほとんどの人からすれば、動画のキーフレームの位置なんて超絶どうでもいいと思うので、、、、はい。
一応ffprobe get keyframe position
とかで調べて出てきたコマンドを叩いた結果と、今作ったアプリで同じ時間が出てきたので、多分合ってる!
ソースコード
https://github.com/takusan23/AndroidMediaParserKeyFrameListSample
以上です。おつかれさまでした。