たくさんの自由帳

Android で動画からフレーム(画像)を高速で取り出す処理を自作する

投稿日 : | 0 日前

文字数(だいたい) : 18533

目次

どうもこんばんわ。
FLIP*FLOP 〜INNOCENCE OVERCLOCK〜 攻略しました。

Imgur

めっちゃ あまあまなシナリオ + かわいいヒロイン がいる神ゲーです。ほんとにあまあまでした
おじいちゃん何者なの((?))続編で明かされるんかな

Imgur

Imgur

ボクっ娘だ!

Imgur

元気いっぱいイオちゃんかわいい!!

Imgur

サブヒロインも可愛いけど攻略できない、そんな・・・

Imgur

Imgur

というわけでOPCD、開け、、ます!
OP曲めっちゃいい!

Imgur

本題

Androidで動画の1コマ1コマを画像として取り出す処理をAndroidでやりたい。
30 fps なら 1秒間に 30 枚画像が切り替わるわけですが、その1枚1枚を切り出したい。

特に、30fps なら 30枚、連続したフレームを取り出すのに特化した処理を自作したい。
理由は後述しますが遅い!

欲しい要件

  • 指定位置のフレーム(動画の指定位置を画像にする)
  • 動画の再生時間が増加する方向に対して連続でフレームを取り出す際には、高速であってほしい
    • 巻き戻しは遅くなって仕方がない
    • 高速で取れるハズな理由も後述します
    • 後述しますが既知のAndroidの解決策では遅い

動画のサムネイル画像が一回ぽっきりで欲しいとかで、動画のフレームを取得するなら別に遅くてもなんともならないと思うのですが、、、
時間が増加する方向に向かって映像のフレームを取り出す分には、高速にフレームを取り出せる気がするんですよね。以下擬似コード

// 時間が増加する分には速く取れるように作れそう。
val bitmap1 = getVideoFrameBitmap(ms = 1_000)
val bitmap2 = getVideoFrameBitmap(ms = 1_100)
val bitmap3 = getVideoFrameBitmap(ms = 1_200)
val bitmap4 = getVideoFrameBitmap(ms = 1_300)
val bitmap5 = getVideoFrameBitmap(ms = 1_400)

既にあるやつじゃだめなの?

ライブラリを使わずに、Androidで完結させたい場合は多分以下のパターンがある

MediaMetadataRetriever

高レベルAPIですね。
ffprobe的な使い方から、動画のフレームを取ったりも出来ます。

getFrameAtTime

https://developer.android.com/reference/android/media/MediaMetadataRetriever#getFrameAtTime(long,%20int)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
 
    val context = LocalContext.current
    val bitmap = remember { mutableStateOf<ImageBitmap?>(null) }
 
    val videoPicker = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia(),
        onResult = { uri ->
            // MediaMetadataRetriever は File からも作れます
            uri ?: return@rememberLauncherForActivityResult
            val mediaMetadataRetriever = MediaMetadataRetriever().apply {
                setDataSource(context, uri)
            }
            // Bitmap を取り出す
            // 引数の単位は Ms ではなく Us です
            bitmap.value = mediaMetadataRetriever.getFrameAtTime(13_000_000, MediaMetadataRetriever.OPTION_CLOSEST)?.asImageBitmap()
            // もう使わないなら
            mediaMetadataRetriever.release()
        }
    )
 
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {
 
            if (bitmap.value != null) {
                Image(
                    modifier = Modifier.fillMaxWidth(),
                    bitmap = bitmap.value!!,
                    contentDescription = null
                )
            }
 
            Button(onClick = { videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }) {
                Text(text = "取り出す")
            }
 
        }
    }
}

すごく簡単に使える。
動画を渡して時間とオプションを指定するだけで、Bitmapとして取れる。
オプションですが

  • 動画の時間に近いフレームを取り出す(高速)
    • MediaMetadataRetriever.OPTION_PREVIOUS_SYNC
      • 高速な代わりに、指定の時間よりも前のフレームになる
    • MediaMetadataRetriever.OPTION_NEXT_SYNC
      • 高速な代わりに、指定の時間よりも前か後のフレームになる
    • MediaMetadataRetriever.OPTION_CLOSEST_SYNC
      • 高速な代わりに、指定の時間よりも後のフレームになる
  • 動画の時間を厳密にしたフレームを取り出す(めちゃ遅い)
    • MediaMetadataRetriever.OPTION_CLOSEST

フレームを正確に欲しい場合は、MediaMetadataRetriever.OPTION_CLOSESTを使うしか無いと思うんですが、結構遅いのでちょっと使えないかな。

// 時間が巻き戻らなければ速くフレーム返して欲しい
// これだけでも 1 秒くらいはかかっちゃう
mediaMetadataRetriever.getFrameAtTime(1_000_000L, MediaMetadataRetriever.OPTION_CLOSEST)
mediaMetadataRetriever.getFrameAtTime(1_100_000L, MediaMetadataRetriever.OPTION_CLOSEST)
mediaMetadataRetriever.getFrameAtTime(1_200_000L, MediaMetadataRetriever.OPTION_CLOSEST)

getFrameAtIndex

https://developer.android.com/reference/android/media/MediaMetadataRetriever#getFrameAtIndex(int,%20android.media.MediaMetadataRetriever.BitmapParams)

こっちのほうがgetFrameAtTimeよりは高速らしいですが、あんまり速くない気がする。
引数には時間ではなく、フレームの番号を渡す必要があります。30fpsなら、1秒間に30枚あるので、、、

全部で何フレームあるかは、METADATA_KEY_VIDEO_FRAME_COUNTで取れるらしいので、欲しい時間のフレーム番号を計算で出せば良さそう。
が、これも私が試した限りあんまり速くないので別に・・・

MediaMetadataRetriever は並列処理できない可能性

MediaMetadataRetrieverが何やってるのかあんまりわからないのですが、
なんだかgetFrameAtIndex / getFrameAtTimeが遅いからって並列にしても対して速くないです。

まず直列パターン。

// 3枚取り出す
val time = measureTimeMillis {
    repeat(3) {
        val timeUs = it * 1_000_000L
        val mediaMetadataRetriever = MediaMetadataRetriever().apply {
            setDataSource(context, uri)
        }
        // Bitmap を取り出す
        // 引数の単位は Ms ではなく Us です
        mediaMetadataRetriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST)?.asImageBitmap()
        // もう使わないなら
        mediaMetadataRetriever.release()
    }
}
println("time = $time")

次に並列のパターン。

scope.launch {
    // 3枚取り出す
    val time = measureTimeMillis {
        (0 until 3).map {
            launch {
                val timeUs = it * 1_000_000L
                val mediaMetadataRetriever = MediaMetadataRetriever().apply {
                    setDataSource(context, uri)
                }
                // Bitmap を取り出す
                // 引数の単位は Ms ではなく Us です
                mediaMetadataRetriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST)?.asImageBitmap()
                // もう使わないなら
                mediaMetadataRetriever.release()
            }
        }.joinAll()
    }
    println("time = $time ms")
}

まず直列パターンの結果ですが、

  • Pixel 6 Pro
    • time = 1090 ms
    • time = 1082 ms
  • Pixel 8 Pro
    • time = 744 ms
    • time = 702 ms

そしてコレが並列パターンの結果。

  • Pixel 6 Pro
    • time = 1087 ms
    • time = 983 ms
  • Pixel 8 Pro
    • time = 691 ms
    • time = 653 ms

若干早いけど誤差なのでは・・・?
MediaMetadataRetriever、もしかして内部でインスタンスを共通にしてて切り替えて使ってる?

うーん、おそい!

MediaPlayer + ImageReader

ネタバレすると、これは自作した後に気付いたんですが、これも結局あんまり早くない

動画プレイヤーのMediaPlayerの出力先にImageReaderを使う方法。
MediaPlayerの出力先に普通は、SurfaceViewとか、TextureViewとかを渡しますが、ImageReaderを渡すと画像データとして取ることが出来ます。
SurfaceView / TextureViewが画面に表示する物だとしたら、ImageReaderは画像データにしてくれるものでしょうか。MediaRecorderは動画にしてくれるやつです。
(まあTextureViewをキャプチャするのと対して変わらんと思うけど、、、ImageReaderとか言う適役がいるので)

で、で、で、、、MediaPlayer#seekToして、ImageReaderで動画のフレームを画像にすれば高速に取れるのではないかと。
でもだめだったよ。これもあんまり早くない。

https://developer.android.com/reference/android/media/MediaPlayer#seekTo(long,%20int)

並列はこれで達成できるかもしれない。
ただ、MediaPlayer#seekToMediaMetadataRetrieverのときと同じく、正確性を求めるなら速度が遅くなるみたい。
次のフレームをseekToで指定しても速くなかった。うん。高レベルAPIだし、そりゃそうなるか。

そういえば、MediaPlayer、これ動画を再生するものなので、連続してフレームをBitmapにするとかなら得意なんじゃないだろうか。
動くか分からんけどこんなの。

// TODO 動くかわからない。多分動かない
val imageReader = ImageReader.newInstance(videoWidth, videoHeight, ImageFormat.YUV_420_888, 2)
imageReader.setOnImageAvailableListener({
    // 毎フレームごとに呼ばれるんじゃないかな
    // 連続して取り出す分には出来そう
    val currentPosition = mediaPlayer.currentPosition
    val image = imageReader.acquireLatestImage()
    val bitmap = image.toBitmap() // toBitmap は自分で書いて...
}, null)
 
val mediaPlayer = MediaPlayer().apply {
    setSurface(imageReader.surface)
    setDataSource(videoFile.path)
    prepare()
    seekTo(1_000)
    start() // 再生
}

ただ、連続してフレームがとれるというか、毎フレームBitmapを生成することになるので、Bitmapをどっかに置いておかないといけない。 一旦画像にして保存してもいいけど、、、できれば指定した時間のフレームだけ欲しいし、その次のフレームが欲しかったらすぐ返して欲しい。

そもそも連続したフレームだったら高速に取り出せるんですか?

動画のフレーム(画像)の話

それには動画がどうやって動画を圧縮しているかの話と、キーフレームの話が必要で、しますね。

前も話した気がするけどこのサイトGoogleで見つからない事が多いのでまた書きますね。
SSGでも動く全文検索検討するかあ~)

動画というのは、画像が切り替わっている用に見えますが、考えてみてください。30fpsだと1秒間に30枚の画像を保存しているのかと。 してないですね。仮に作ったとしても動画ファイルはそんなに大きくなりません。でも30fpsなら30枚分あるはずの画像はどこに行ってしまったのか・・・

小さく出来る理由ですが、前回のフレーム(画像)からの差分のみを動画ファイルへ保存するんですね。
動画というのはほとんど変わらない部分も含まれているわけで、それらは前のフレームを参照してねとすれば、動画ファイルは小さく出来ます。
前のフレームに依存する代わりにファイルを小さく出来ました。

Imgur

ただ、すべてのフレームを前のフレームに依存させてしまうと、今度は巻き戻しができなくなってしまいます。
ドロイドくん3つのフレームを表示させたい場合、フレーム単体では表示できないので、それよりも前(上の絵では最初)に戻る必要があります。

でも毎回最初に戻っていてはシークがとんでもなく遅くなってしまうので、定期的にキーフレームという、前のフレームに依存しない完全な状態の画像を差し込んでいます。
1秒に一回くらいとかですかね。これなら、大幅に戻ったりする必要がなくなるのでシークも早くなります。

Imgur

もちろん、動画のコーデックはこれ以外の技術を使って動画のファイルサイズを縮小していますが、今回の高速でフレームを取り出す話しには多分関係ないので飛ばします。

なぜ既知の解決策が遅いのか

シークしているからでしょう。
MediaMetadataRetrieverには4つのオプションがあるといいました。

  • OPTION_PREVIOUS_SYNC (高速)
    • 指定位置より後ろのキーフレームを取る
  • OPTION_NEXT_SYNC (高速)
    • 指定位置より先のキーフレームを取る
  • OPTION_CLOSEST_SYNC (高速)
    • 指定位置に一番近いキーフレームを取る
  • OPTION_CLOSEST (低速)
    • 指定位置のフレームを取り出す

↑のフレームの話を聞いたら、OPTION_CLOSESTがなんで遅くて、それ以外がなんで早いか。分かる気がしませんか?
OPTION_PREVIOUS_SYNC / OPTION_NEXT_SYNC / OPTION_CLOSEST_SYNCはキーフレームを探すのに対して(フレーム単体で画像になっている)、
OPTION_CLOSESTはキーフレームからの差分までも見る必要があるため、キーフレームまで移動した後指定時間になるまで進める必要があり、時間がかかるわけです。

Imgur

そして、OPTION_CLOSESTの場合、おそらく毎回キーフレームまで戻っている?ために遅くなっている?
MediaMetadataRetrieverMediaPlayerも多分そう。

Imgur

なぜ高速に取り出せると思っているのか

キーフレームまで戻るから遅いのでは。巻き戻すわけじゃないから戻らないように時前で書けばいいのでは???

Imgur

絶対戻らないという前提があれば、連続したフレームを取り出すのも早いんじゃないかという話です。

// 毎フレーム、巻き戻ししなければ速く取得できる処理が作れるのではないか。
getVideoFrameBitmap(ms = 16)
getVideoFrameBitmap(ms = 33)
getVideoFrameBitmap(ms = 66)
getVideoFrameBitmap(ms = 99)

というわけで、今回は動画のフレームをBitmapとして取り出す処理。(MediaMetadataRetriever#getFrameAtTimeの代替)、
かつキーフレームまで戻らない仕様を込めて自前で作ってみようと思います。

(ちなみに)
MediaMetadataRetrieverは指定した時間が、前回のフレームの次のフレームだったとしても、OPTION_CLOSEST指定している限りキーフレームまで戻っているのが悪いと言われると微妙。)
(次のフレームなら効率が悪いと思いますが、前回のフレームよりも前に戻る場合は、キーフレームまで戻るこの方法が必要なのでまあ仕方ないところがある。)

つくる

前置きが長過ぎる

環境

Android StudioAndroid Studio Hedgehog 2023.1.1 Patch 2
端末Pixel 8 Pro / Xperia 1 V
言語Koltin / OpenGL

一応MediaCodecの出力先をImageReaderにするだけで動くので、MediaCodec系といっしょに使われるOpenGLとかは要らないはずですが
OpenGLを一枚噛ませるとさせておくとより安心です(嘘です。なんか間違えたのかGoogle Pixel以外で落ちました。OpenGLを噛ませないと動きません。落ちた話は後半でします。)

→ 2024/05/30 追記もあります。ImageReader何もわからない。

今回の作戦

前回の位置から、巻き戻っていない場合は、コンテナから次のデータを取り出してデコーダーに渡すようにします。
これをするため、フレームが取得し終わってもMediaCodec / MediaExtractorはそのままにしておく必要があります(待機状態というのでしょうか・・)

Imgur

MediaCodec とゆかいな仲間たち

  • MediaCodec
    • エンコード済のデータをデコードしたりする
      • AVCを生データに
      • AACPCM
  • MediaExtractor
    • mp4 / webm等のコンテナフォーマットから、パラメーターや実際のデータを取り出すやつ
    • デコーダーに渡すときに使う
  • ImageReader
    • SurfaceViewが画面に表示するやつなら、これは静止画に変換するやつ
  • Surface
    • 映像データを運ぶパイプみたいなやつです
    • このパイプみたいなやつがいるおかげで、私たちは映像データをバイト配列でやり取りする必要がなくなります
  • OpenGL

OpenGL 周りを AOSP から借りてくる

何やってるか私もわからないのでAOSPから借りてくることにします。
私がやったのはKotlin化くらいです。

Imgur

VideoFrameBitmapExtractor.kt

適当にクラスを作って、以下の関数を用意します。
それぞれの中身はこれから書きます。

newSingleThreadContextがなんで必要かは前書いたのでそっち見て
https://takusan.negitoro.dev/posts/android_14_media_projection_partial/#録画部分に組み込む話と-kotlin-coroutine-の話

まあ言うと
newSingleThreadContextってやつを使うことで、常に同じスレッドで処理してくれるDispatcherを作れます。これをwithContextとかで使えばいい。
あ、でも複数のVideoFrameBitmapExtractor()のインスタンスを作って使う場合は、openGlRenderDispatcherをそれぞれ作らないといけないので、companion objectに置いたらダメですね。

/**
 * [MediaCodec]と[MediaExtractor]、[ImageReader]を使って高速に動画からフレームを取り出す
 */
class VideoFrameBitmapExtractor {
 
    /** MediaCodec デコーダー */
    private var decodeMediaCodec: MediaCodec? = null
 
    /** Extractor */
    private var mediaExtractor: MediaExtractor? = null
 
    /** 映像デコーダーから Bitmap として取り出すための ImageReader */
    private var imageReader: ImageReader? = null
 
    /** 最後の[getVideoFrameBitmap]で取得したフレームの位置 */
    private var latestDecodePositionMs = 0L
 
    /** 前回のシーク位置 */
    private var prevSeekToMs = -1L
 
    /** 前回[getImageReaderBitmap]で作成した Bitmap */
    private var prevBitmap: Bitmap? = null
 
    /**
     * デコーダーを初期化する
     *
     * @param uri 動画ファイル
     */
    suspend fun prepareDecoder(
        context: Context,
        uri: Uri,
    ) = withContext(Dispatchers.IO) {
        // todo
    }
 
    /**
     * 指定位置の動画のフレームを取得して、[Bitmap]で返す
     *
     * @param seekToMs シーク位置
     * @return Bitmap
     */
    suspend fun getVideoFrameBitmap(
        seekToMs: Long
    ): Bitmap = withContext(Dispatchers.Default) {
        // TODO
    }
 
    /** 破棄する */
    fun destroy() {
        decodeMediaCodec?.release()
        mediaExtractor?.release()
        imageReader?.close()
        inputSurface?.release()
    }
 
    companion object {
        /** MediaCodec タイムアウト */
        private const val TIMEOUT_US = 10_000L
 
        /** OpenGL 用に用意した描画用スレッド。Kotlin coroutines では Dispatcher を切り替えて使う */
        @OptIn(DelicateCoroutinesApi::class)
        private val openGlRenderDispatcher = newSingleThreadContext("openGlRenderDispatcher")
    }
}

初期化する処理

prepareDecoder関数の中身です。
ContextUriJetpack Composeで作るUI側で貰えるので

trackIndex = ...の部分は、mp4 / webmから映像トラックを探してselectTrackします。
音声トラックと映像トラックで2つしか無いと思いますが。

ImageReaderですが、MediaCodecで使う場合はImageFormat.YUV_420_888じゃないとだめっぽいです。

/**
 * デコーダーを初期化する
 *
 * @param uri 動画ファイル
 */
suspend fun prepareDecoder(
    context: Context,
    uri: Uri,
) = withContext(Dispatchers.IO) {
    // コンテナからメタデータを取り出す
    val mediaExtractor = MediaExtractor().apply {
        context.contentResolver.openFileDescriptor(uri, "r")?.use {
            setDataSource(it.fileDescriptor)
        }
    }
    this@VideoFrameBitmapExtractor.mediaExtractor = mediaExtractor
 
    // 映像トラックを探して指定する。音声と映像で2️個入ってるので
    val trackIndex = (0 until mediaExtractor.trackCount)
        .map { index -> mediaExtractor.getTrackFormat(index) }
        .indexOfFirst { mediaFormat -> mediaFormat.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true }
    mediaExtractor.selectTrack(trackIndex)
 
    // デコーダーの用意
    val mediaFormat = mediaExtractor.getTrackFormat(trackIndex)
    val codecName = mediaFormat.getString(MediaFormat.KEY_MIME)!!
    val videoHeight = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT)
    val videoWidth = mediaFormat.getInteger(MediaFormat.KEY_WIDTH)
 
    // Surface 経由で Bitmap が取れる ImageReader つくる
    imageReader = ImageReader.newInstance(videoWidth, videoHeight, PixelFormat.RGBA_8888, 2)
 
    // 描画用スレッドに切り替える
    withContext(openGlRenderDispatcher) {
        // MediaCodec と ImageReader の間に OpenGL を経由させる
        // 経由させないと、Google Pixel 以外(Snapdragon 端末とか)で動かなかった
        this@VideoFrameBitmapExtractor.inputSurface = InputSurface(
            surface = imageReader!!.surface,
            textureRenderer = TextureRenderer()
        )
        inputSurface!!.makeCurrent()
        inputSurface!!.createRender()
    }
 
    // 映像デコーダー起動
    // デコード結果を ImageReader に流す
    decodeMediaCodec = MediaCodec.createDecoderByType(codecName).apply {
        configure(mediaFormat, inputSurface!!.drawSurface, null, 0)
    }
    decodeMediaCodec!!.start()
}

映像からフレームを取り出す処理

前回フレームを取り出した再生位置よりも前の位置のを取り出す処理と、後の位置のを取り出す処理で2つ処理を分けたほうが良さそう。

前回より前の位置にあるフレームを取り出す

|-------◯----|

前回取り出したフレームの位置よりも前にある場合は、もうこれは仕方ないので、一旦キーフレームまで戻って、指定時間になるまでコンテナから取り出してデコードを続けます。
とりあえず前のキーフレームまでシークして待てばいいので、後の位置よりも簡単ですね。

一点、現時点の再生位置よりも巻き戻すシークの場合MediaCodec#flushしないとだめっぽい?
試した感じ、flush()呼ばないと巻き戻らないんだよね。
flush()を呼ぶ場合、MediaCodec#dequeueInputBufferで取ったバッファのインデックスを、MediaCodec#queueInputBufferに渡してMediaCodecに返却してからflushを呼ぶようにしましょうね。
MediaCodec#dequeueInputBufferを呼びっぱなしにしてflushすると怒られます)

(クソながMediaCodecのドキュメントついに役に立つのか!)
https://developer.android.com/reference/android/media/MediaCodec#for-decoders-that-do-not-support-adaptive-playback-including-when-not-decoding-onto-a-surface

あとはwhileで欲しい時間のフレームが来るまで繰り返すだけです。
readSampleDataで取り出してqueueInputBufferでデコーダーに詰める。デコードできたかどうかはdequeueOutputBufferを呼び出して、データが来ていればSurfaceに描画です。
単位がMsじゃなくてUsなので注意。

/**
 * 今の再生位置よりも前の位置にシークして、指定した時間のフレームまでデコードする。
 * 指定した時間のフレームがキーフレームじゃない場合は、キーフレームまでさらに巻き戻すので、ちょっと時間がかかります。
 *
 * @param seekToMs シーク位置
 */
private suspend fun awaitSeekToPrevDecode(
    seekToMs: Long
) = withContext(Dispatchers.Default) {
    val decodeMediaCodec = decodeMediaCodec!!
    val mediaExtractor = mediaExtractor!!
    val inputSurface = inputSurface!!
 
    // シークする。SEEK_TO_PREVIOUS_SYNC なので、シーク位置にキーフレームがない場合はキーフレームがある場所まで戻る
    mediaExtractor.seekTo(seekToMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
    // エンコードサれたデータを順番通りに送るわけではない(隣接したデータじゃない)ので flush する
    decodeMediaCodec.flush()
 
    // デコーダーに渡す
    var isRunning = true
    val bufferInfo = MediaCodec.BufferInfo()
    while (isRunning) {
        // キャンセル時
        if (!isActive) break
 
        // コンテナフォーマットからサンプルを取り出し、デコーダーに渡す
        // while で繰り返しているのは、シーク位置がキーフレームのため戻った場合に、狙った時間のフレームが表示されるまで繰り返しデコーダーに渡すため
        val inputBufferIndex = decodeMediaCodec.dequeueInputBuffer(TIMEOUT_US)
        if (inputBufferIndex >= 0) {
            val inputBuffer = decodeMediaCodec.getInputBuffer(inputBufferIndex)!!
            // デコーダーへ流す
            val size = mediaExtractor.readSampleData(inputBuffer, 0)
            decodeMediaCodec.queueInputBuffer(inputBufferIndex, 0, size, mediaExtractor.sampleTime, 0)
            // 狙ったフレームになるまでデータを進める
            mediaExtractor.advance()
        }
 
        // デコーダーから映像を受け取る部分
        var isDecoderOutputAvailable = true
        while (isDecoderOutputAvailable) {
            // デコード結果が来ているか
            val outputBufferIndex = decodeMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
            when {
                outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
                    // もう無い時
                    isDecoderOutputAvailable = false
                }
 
                outputBufferIndex >= 0 -> {
                    // ImageReader ( Surface ) に描画する
                    val doRender = bufferInfo.size != 0
                    decodeMediaCodec.releaseOutputBuffer(outputBufferIndex, doRender)
                    // OpenGL で描画して、ImageReader で撮影する
                    // OpenGL 描画用スレッドに切り替えてから、swapBuffers とかやる
                    withContext(openGlRenderDispatcher) {
                        if (doRender) {
                            var errorWait = false
                            try {
                                inputSurface.awaitNewImage()
                            } catch (e: Exception) {
                                errorWait = true
                            }
                            if (!errorWait) {
                                inputSurface.drawImage()
                                inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
                                inputSurface.swapBuffers()
                            }
                        }
                    }
                    // 欲しいフレームの時間に到達した場合、ループを抜ける
                    val presentationTimeMs = bufferInfo.presentationTimeUs / 1000
                    if (seekToMs <= presentationTimeMs) {
                        isRunning = false
                        latestDecodePositionMs = presentationTimeMs
                    }
                }
            }
        }
    }
}

前回より後の位置にあるフレームを取り出す

2024/03/28 追記。コード間違えてた、対応しないと無限ループに陥ります。詳しくは後述

|-------◯----|

さて、MediaMetadataRetriever#getFrameAtTimeにはない、巻き戻さなければキーフレームまで戻らないを実装していきます。
が、が、が、巻き戻さなければなんですが、これだと前回よりもかけ離れた先にある場所へシークするのが遅くなってしまいます。連続したフレームの取得なら早くなりますが、
遠い場所へシークする場合は近くのキーフレームまでシークしたほうが早いです。(これがないと前回からの差分を全部取り出すので効率が悪い)

というわけで、欲しい位置のフレームの取得よりも先に、キーフレームが出現した場合は一気に近い位置までシークするような処理を書きました。
(前回よりも数フレーム先のフレームなら、キーフレームまでシークせずに取り出せるので高速ですが、次次...あれ先にキーフレームが来ちゃうの?ってくらい離れていると逆に一気にシークした方が良い)

なんか手こずったけどなんとかなりました(MediaExtractor#getSampleTimeBufferInfo#getPresentationTimeUs()って微妙に違うのか・・)。

それ以外は↑のコードと大体一緒なので説明は省略で。

/**
 * 今の再生位置よりも後の位置にシークして、指定した時間のフレームまでデコードする。
 *
 * また高速化のため、まず[seekToMs]へシークするのではなく、次のキーフレームまでデータをデコーダーへ渡します。
 * この間に[seekToMs]のフレームがあればシークしません。
 * これにより、キーフレームまで戻る必要がなくなり、連続してフレームを取得する場合は高速に取得できます。
 *
 * @param seekToMs シーク位置
 */
private suspend fun awaitSeekToNextDecode(
    seekToMs: Long
) = withContext(Dispatchers.Default) {
    val decodeMediaCodec = decodeMediaCodec!!
    val mediaExtractor = mediaExtractor!!
    val inputSurface = inputSurface!!
 
    var isRunning = isActive
    val bufferInfo = MediaCodec.BufferInfo()
    while (isRunning) {
        // キャンセル時
        if (!isActive) break
 
        // コンテナフォーマットからサンプルを取り出し、デコーダーに渡す
        // シークしないことで、連続してフレームを取得する場合にキーフレームまで戻る必要がなくなり、早くなる
        val inputBufferIndex = decodeMediaCodec.dequeueInputBuffer(TIMEOUT_US)
        if (inputBufferIndex >= 0) {
            // デコーダーへ流す
            val inputBuffer = decodeMediaCodec.getInputBuffer(inputBufferIndex)!!
            val size = mediaExtractor.readSampleData(inputBuffer, 0)
            decodeMediaCodec.queueInputBuffer(inputBufferIndex, 0, size, mediaExtractor.sampleTime, 0)
        }
 
        // デコーダーから映像を受け取る部分
        var isDecoderOutputAvailable = true
        while (isDecoderOutputAvailable) {
            // デコード結果が来ているか
            val outputBufferIndex = decodeMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
            when {
                outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
                    // もう無い時
                    isDecoderOutputAvailable = false
                }
 
                outputBufferIndex >= 0 -> {
                    // ImageReader ( Surface ) に描画する
                    val doRender = bufferInfo.size != 0
                    decodeMediaCodec.releaseOutputBuffer(outputBufferIndex, doRender)
                    // OpenGL で描画して、ImageReader で撮影する
                    // OpenGL 描画用スレッドに切り替えてから、swapBuffers とかやる
                    withContext(openGlRenderDispatcher) {
                        if (doRender) {
                            var errorWait = false
                            try {
                                inputSurface.awaitNewImage()
                            } catch (e: Exception) {
                                errorWait = true
                            }
                            if (!errorWait) {
                                inputSurface.drawImage()
                                inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
                                inputSurface.swapBuffers()
                            }
                        }
                    }
                    // 欲しいフレームの時間に到達した場合、ループを抜ける
                    val presentationTimeMs = bufferInfo.presentationTimeUs / 1000
                    if (seekToMs <= presentationTimeMs) {
                        isRunning = false
                        latestDecodePositionMs = presentationTimeMs
                    }
                }
            }
        }
 
        // 次に進める
        mediaExtractor.advance()
 
        // 欲しいフレームが前回の呼び出しと連続していないときの処理
        // 例えば、前回の取得位置よりもさらに数秒以上先にシークした場合、指定位置になるまで待ってたら遅くなるので、数秒先にあるキーフレームまでシークする
        // で、このシークが必要かどうかの判定がこれ。数秒先をリクエストした結果、欲しいフレームが来るよりも先にキーフレームが来てしまった
        // この場合は一気にシーク位置に一番近いキーフレームまで進める
        // ただし、キーフレームが来ているサンプルの時間を比べて、欲しいフレームの位置の方が大きくなっていることを確認してから。
        // デコーダーの時間 presentationTimeUs と、MediaExtractor の sampleTime は同じじゃない?らしく、sampleTime の方がデコーダーの時間より早くなるので注意
        val isKeyFrame = mediaExtractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
        val currentSampleTimeMs = mediaExtractor.sampleTime / 1000
        if (isKeyFrame && currentSampleTimeMs < seekToMs) {
            mediaExtractor.seekTo(seekToMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
        }
    }
}

追記

MediaExtractorからもうデータが取れないときの対応が必要です。
これしないと、最後まで取得しようとした時に多分無限ループになります。

なので、もうデータがない場合は null を返すように修正する必要があります。
このコミット参照。
https://github.com/takusan23/AndroidVideoFrameFastNextExtractor/commit/bceea63d9bb12616eea65a261d8c309900c9c0ff#diff-c7c817c9f4104122158d59c5d4aa6a97ba894f79f99e73d15eee88b892502ebe

説明すると、

getVideoFrameBitmapBitmapnullableにする。

     suspend fun getVideoFrameBitmap(
         seekToMs: Long
-    ): Bitmap = withContext(Dispatchers.Default) {
+    ): Bitmap? = withContext(Dispatchers.Default) {

getVideoFrameBitmapelseでフレームがないならgetImageReaderBitmapを呼ばないように。

             else -> {
                 // 巻き戻しでも無く、フレームを取り出す必要がある
                 awaitSeekToNextDecode(seekToMs)
-                getImageReaderBitmap()
+                // 巻き戻しでも無く、フレームを取り出す必要がある
+                val hasData = awaitSeekToNextDecode(seekToMs)
+                if (hasData) getImageReaderBitmap() else null
             }

awaitSeekToNextDecodeBooleanを返せるようにします。trueならフレームがある(getImageReaderBitmapが呼び出せる)、falseなら無いです。

     private suspend fun awaitSeekToNextDecode(
         seekToMs: Long
-    ) = withContext(Dispatchers.Default) {
+    ): Boolean = withContext(Dispatchers.Default) {

awaitSeekToNextDecodeのループ直前で、MediaExtractor#getSampleTime()を呼び出して、-1(もうデータがない)場合は何もせず、false を返します。

         val decodeMediaCodec = decodeMediaCodec!!
         val mediaExtractor = mediaExtractor!!
         val inputSurface = inputSurface!!
 
+        // advance() で false を返したことがある場合、もうデータがない。getSampleTime も -1 になる。
+        if (mediaExtractor.sampleTime == -1L) {
+            return@withContext false
+        }
+
         var isRunning = isActive
         val bufferInfo = MediaCodec.BufferInfo()

MediaExtractor#advance()の返り値を見て、もうデータがない場合(false)は、ループを抜けるようにします。

 
-            // 次に進める
-            mediaExtractor.advance()
+            // 次に進める。advance() が false の場合はもうデータがないので、break する。
+            val isEndOfFile = !mediaExtractor.advance()
+            if (isEndOfFile) {
+                break
+            }

これで、無限ループは回避出来るはず。
nullableになった関係で、呼び出し箇所も修正が必要かもです。

-                bitmap.value = videoFrameBitmapExtractor.getVideoFrameBitmap(currentPositionMs.value).asImageBitmap()
+                bitmap.value = videoFrameBitmapExtractor.getVideoFrameBitmap(currentPositionMs.value)?.asImageBitmap()

ImageReader から Bitmap を取り出す処理

acquireLatestImageして、Bufferとって、Bitmapにしています。

/** [imageReader]から[Bitmap]を取り出す */
private suspend fun getImageReaderBitmap(): Bitmap = withContext(Dispatchers.Default) {
    val image = imageReader!!.acquireLatestImage()
    val width = image.width
    val height = image.height
    val buffer = image.planes.first().buffer
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    bitmap.copyPixelsFromBuffer(buffer)
    prevBitmap = bitmap
    // Image を close する
    image.close()
    return@withContext bitmap
}

組み合わせる

awaitSeekToNextDecodeとかawaitSeekToPrevDecodeとかgetImageReaderBitmapを組み合わせて、動画のフレームを取り出す関数を完成させます。

あ~
シーク不要の部分で、なんで前回の Bitmapを返しているかなんですが、動画フレームの枚数よりも多くのフレームをリクエストしてきた時に前回のフレームを返すためのものです。
どういうことかと言うと、30fpsなら1秒間に30枚までならフレームを取り出せますが、1秒間に60枚取ろうとするとフレームの枚数よりも多くのフレームを要求することになり、余計にデコードが進んでいってしまい、ズレていってしまいます。
30fpsなら33ミリ秒毎に取り出す処理なら問題ないけど、16ミリ秒毎に取り出すと、フレームの枚数よりも多くのフレームを取るから壊れちゃう。)

フレームの枚数よりも多くのフレームを要求してきても壊れないように、最後取得したフレームの位置を見て、もし最後取得したフレームよりも前の位置だったら前回のBitmapを返すようにしました。

/**
 * 指定位置の動画のフレームを取得して、[Bitmap]で返す
 *
 * @param seekToMs シーク位置
 * @return Bitmap
 */
suspend fun getVideoFrameBitmap(
    seekToMs: Long
): Bitmap = withContext(Dispatchers.Default) {
    val videoFrameBitmap = when {
        // 現在の再生位置よりも戻る方向に(巻き戻し)した場合
        seekToMs < prevSeekToMs -> {
            awaitSeekToPrevDecode(seekToMs)
            getImageReaderBitmap()
        }
 
        // シーク不要
        // 例えば 30fps なら 33ms 毎なら新しい Bitmap を返す必要があるが、 16ms 毎に要求されたら Bitmap 変化しないので
        // つまり映像のフレームレートよりも高頻度で Bitmap が要求されたら、前回取得した Bitmap がそのまま使い回せる
        seekToMs < latestDecodePositionMs && prevBitmap != null -> {
            prevBitmap!!
        }
 
        else -> {
            // 巻き戻しでも無く、フレームを取り出す必要がある
            awaitSeekToNextDecode(seekToMs)
            getImageReaderBitmap()
        }
    }
    prevSeekToMs = seekToMs
    return@withContext videoFrameBitmap
}

Jetpack Compose で作った UI 側で呼び出して使う

ボタンと画像を表示するやつをおいて、ボタンを押したら動画を選ぶやつを開いて、選んだら↑の処理を呼び出す。
これで一通り出来たかな。ボタンを押して動画を選べば出てきます。

fun VideoFrameBitmapExtractorScreen() {
    val scope = rememberCoroutineScope()
    val context = LocalContext.current
    val bitmap = remember { mutableStateOf<ImageBitmap?>(null) }
 
    // フレームを取り出すやつと取り出した位置
    val currentPositionMs = remember { mutableStateOf(0L) }
    val videoFrameBitmapExtractor = remember { VideoFrameBitmapExtractor() }
 
    val videoPicker = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia(),
        onResult = { uri ->
            uri ?: return@rememberLauncherForActivityResult
            scope.launch {
                videoFrameBitmapExtractor.prepareDecoder(context, uri)
                currentPositionMs.value = 1000
                bitmap.value = videoFrameBitmapExtractor.getVideoFrameBitmap(currentPositionMs.value).asImageBitmap()
            }
        }
    )
 
    // 破棄時
    DisposableEffect(key1 = Unit) {
        onDispose { videoFrameBitmapExtractor.destroy() }
    }
 
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = "VideoFrameBitmapExtractorScreen") })
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {
 
            if (bitmap.value != null) {
                Image(
                    modifier = Modifier.fillMaxWidth(),
                    bitmap = bitmap.value!!,
                    contentDescription = null
                )
            }
 
            Text(text = "currentPositionMs = ${currentPositionMs.value}")
 
            Button(onClick = {
                videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
            }) { Text(text = "取り出す") }
 
            Button(onClick = {
                scope.launch {
                    currentPositionMs.value += 16
                    bitmap.value = videoFrameBitmapExtractor.getVideoFrameBitmap(currentPositionMs.value).asImageBitmap()
                }
            }) { Text(text = "16ms 進める") }
 
        }
    }
 
}

これを好きなところで表示してください。
好きな場所でいいので、今回は検証のためにMainActivity.ktとかで。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
 
        setContent {
            AndroidVideoFrameFastNextExtractorTheme {
                VideoFrameBitmapExtractorScreen()
            }
        }
    }
}

使ってみた

ちゃんとImage()に映像のフレームが写っています。
ちょっとずつだけど進めるを押せば動画も進んでいそう。

Imgur

ベンチマーク

頑張って作ったので、MediaMetadataRetriever#getFrameAtTimeよりも早くないと困るぞ・・・!
今回は正確なフレームが欲しいので、MediaMetadataRetriever#getFrameAtTimeの第2引数には遅いですがMediaMetadataRetriever.OPTION_CLOSESTを指定します。
意地悪ですね・・

3枚フレーム取り出してみる

とりあえず連続して3回取り出してみる。

val totalTimeVideoFrameBitmapExtractor = remember { mutableStateOf(0L) }
val totalTimeMetadataRetriever = remember { mutableStateOf(0L) }
 
fun startVideoFrameBitmapExtractorBenchMark(uri: Uri?) {
    uri ?: return
    scope.launch(Dispatchers.Default) {
        totalTimeVideoFrameBitmapExtractor.value = 0
        totalTimeVideoFrameBitmapExtractor.value = measureTimeMillis {
            VideoFrameBitmapExtractor().apply {
                prepareDecoder(context, uri)
                getVideoFrameBitmap(1_000)
                getVideoFrameBitmap(1_100)
                getVideoFrameBitmap(1_200)
            }
        }
    }
}
 
fun startMediaMetadataRetrieverBenchMark(uri: Uri?) {
    uri ?: return
    scope.launch(Dispatchers.Default) {
        totalTimeMetadataRetriever.value = 0
        totalTimeMetadataRetriever.value = measureTimeMillis {
            MediaMetadataRetriever().apply {
                setDataSource(context, uri)
                getFrameAtTime(1_000_000, MediaMetadataRetriever.OPTION_CLOSEST)
                getFrameAtTime(1_100_000, MediaMetadataRetriever.OPTION_CLOSEST)
                getFrameAtTime(1_200_000, MediaMetadataRetriever.OPTION_CLOSEST)
            }
        }
    }
}

うーん?
Xperiaに関しては自作しないほうが速いぞ・・?
なんならGoogle Pixelの方も若干速いくらいで誤差っちゃ誤差かもしれない

  • Xperia 1 V
    • 自前のVideoFrameBitmapExtractor
      • 787 ms
      • 790 ms
    • MediaMetadataRetriever#getFrameAtTime
      • 346 ms
      • 342 ms
  • Pixel 8 Pro
    • 自前のVideoFrameBitmapExtractor
      • 1104 ms
      • 1291 ms
    • MediaMetadataRetriever#getFrameAtTime
      • 1548 ms
      • 1515 ms
  • Pixel 6 Pro
    • 自前のVideoFrameBitmapExtractor
      • 1127 ms
      • 1072 ms
    • MediaMetadataRetriever#getFrameAtTime
      • 3235 ms
      • 2810 ms

0から3秒まで連続してフレームを取り出してみる

い、、いや、連続してフレームを取る際に早くなっていればええんや。
こっちが早くなっていれば万々歳

// 0 から 3 秒まで、33 ずつ増やした数字の配列(30fps = 33ms なので)
val BenchMarkFramePositionMsList = (0 until 3_000L step 33)
 
val totalTimeVideoFrameBitmapExtractor = remember { mutableStateOf(0L) }
val totalTimeMetadataRetriever = remember { mutableStateOf(0L) }
 
fun startVideoFrameBitmapExtractorBenchMark(uri: Uri?) {
    uri ?: return
    scope.launch(Dispatchers.Default) {
        totalTimeVideoFrameBitmapExtractor.value = 0
        totalTimeVideoFrameBitmapExtractor.value = measureTimeMillis {
            VideoFrameBitmapExtractor().apply {
                prepareDecoder(context, uri)
                BenchMarkFramePositionMsList.forEach { framePositionMs ->
                    println("framePositionMs = $framePositionMs")
                    getVideoFrameBitmap(framePositionMs)
                }
            }
        }
    }
}
 
fun startMediaMetadataRetrieverBenchMark(uri: Uri?) {
    uri ?: return
    scope.launch(Dispatchers.Default) {
        totalTimeMetadataRetriever.value = 0
        totalTimeMetadataRetriever.value = measureTimeMillis {
            MediaMetadataRetriever().apply {
                setDataSource(context, uri)
                BenchMarkFramePositionMsList.forEach { framePositionMs ->
                    println("framePositionMs = $framePositionMs")
                    getFrameAtTime(framePositionMs * 1000, MediaMetadataRetriever.OPTION_CLOSEST)
                }
            }
        }
    }
}

結果はこちらです。
連続して取得する方はかなり速いです。まあ巻き戻ししなければ速く取れるように作っているのでそれはそうなのですが。
うれしい!ハッピーハッピーハッピー(猫ぴょんぴょん)

  • Xperia 1 V
    • 自前のVideoFrameBitmapExtractor
      • 2264 ms
      • 2608 ms
    • MediaMetadataRetriever#getFrameAtTime
      • 12877 ms
      • 22712 ms
  • Pixel 8 Pro
    • 自前のVideoFrameBitmapExtractor
      • 3108 ms
      • 3814 ms
    • MediaMetadataRetriever#getFrameAtTime
      • 54782 ms
      • 55143 ms
  • Pixel 6 Pro
    • 自前のVideoFrameBitmapExtractor
      • 4539 ms
      • 4407 ms
    • MediaMetadataRetriever#getFrameAtTime
      • 135828 ms
      • 137949 ms

動画からフレームを連続して取り出して保存してみる

連続して取り出して保存する処理を書きました。
↑で書いたBitmap取り出しした後MediaStoreを使って写真フォルダに保存する処理が入ってます。多分保存処理があんまり速度でないんですけど、、、

https://github.com/takusan23/AndroidVideoFrameFastNextExtractor/blob/875cf02a003a6d186f5b0f695d5ee08e9d895360/app/src/main/java/io/github/takusan23/androidvideoframefastnextextractor/ui/screen/VideoFrameExtractAndSaveScreen.kt#L121

Imgur

連続して取り出すのは得意

巻き戻ししなければキーフレームまで戻らないので、次のフレームの取得は早くなります。(コンテナから次のデータ取ってデコーダーに入れてでてくるのを待てば良い)
試した感じかなりいい成績ですよ。

自前↓
Imgur

MediaMetadataRetriever↓
Imgur

自前↓
Imgur

MediaMetadataRetriever↓
Imgur

苦手なのもある

連続して取り出さない場合はMediaMetadataRetrieverの方が早くなることがあります。(1秒で1フレームずつ取り出すとか)
あと巻き戻す場合は完敗だとおもいます。

自前↓
Imgur

MediaMetadataRetriever↓
Imgur

ソースコードです

https://github.com/takusan23/AndroidVideoFrameFastNextExtractor

おまけ

こっから先は知見の共有なので、本編とは関係ないです。

MediaCodec の出力先を OpenGL 無し ImageReader にするのは辞めておいたほうがいい

私が試した限り動かなかった。
あと動画によってはGoogle Pixelでもぶっ壊れているフレームを吐き出してた。。。

Google Pixel 以外で落ちる

Google Pixel で動いていれば他でも動くと思ってた時期が私にもありました
私の名前は ImageReader です。Google Pixel を使ってます(それ以外では動かないので :( )

素直にMediaCodecの出力先をImageReaderにしたら、Google Pixel以外で落ちた。
動かないの書いても無駄ですが一応。MediaCodecで使うImageReaderYUV_420_888にする必要があります。

// Surface 経由で Bitmap が取れる ImageReader つくる
imageReader = ImageReader.newInstance(videoWidth, videoHeight, ImageFormat.YUV_420_888, 2)
// 映像デコーダー起動
// デコード結果を ImageReader に流す
decodeMediaCodec = MediaCodec.createDecoderByType(codecName).apply {
    configure(mediaFormat, imageReader!!.surface, null, 0)
}
decodeMediaCodec!!.start()

ただ、Google Pixel以外の端末(Qualcomm Snapdragon 搭載端末?)だとなんか落ちて、
しかもネイティブの部分(C++ か何かで書かれてる部分)で落ちているのでかなり厳しい雰囲気。

java_vm_ext.cc:591] JNI DETECTED ERROR IN APPLICATION: non-zero capacity for nullptr pointer: 1
java_vm_ext.cc:591]     in call to NewDirectByteBuffer
java_vm_ext.cc:591]     from android.media.ImageReader$SurfaceImage$SurfacePlane[] android.media.ImageReader$SurfaceImage.nativeCreatePlanes(int, int, long)
runtime.cc:691] Runtime aborting...
runtime.cc:691] Dumping all threads without mutator lock held
runtime.cc:691] All threads:
runtime.cc:691] DALVIK THREADS (26):
runtime.cc:691] "main" prio=10 tid=1 Native

というわけで調べたら、デコーダーに渡すMediaFormatで、mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)を指定すれば直るって!

// Surface 経由で Bitmap が取れる ImageReader つくる
imageReader = ImageReader.newInstance(videoWidth, videoHeight, ImageFormat.YUV_420_888, 2)
 
// 映像デコーダー起動
// デコード結果を ImageReader に流す
decodeMediaCodec = MediaCodec.createDecoderByType(codecName).apply {
    // Google Pixel 以外(Snapdragon 搭載機?)でおちるので
    mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
    configure(mediaFormat, imageReader!!.surface, null, 0)
}
decodeMediaCodec!!.start()

取り出したフレームが壊れている

でもこれもだめで、クラッシュこそしなくなりましたが、
画像がぶっ壊れている。ちゃんと出てくる画像もあるけど大体失敗してる。
ちなみにPixelでも失敗したのでMediaCodecとかImageReader何もわからない。

Imgur
↑ 謎の緑の線

Imgur
↑ 砂嵐のように何も見えない

Imgur
↑ Pixel でもだめだった

Imgur
↑ なぜかこれだけ動いた

解決策があるのか不明ですが、、、結局OpenGLを一枚噛ませたら直ったのでもうそれで。
MediaCodec周りはSurface直指定よりOpenGLを噛ませておくと安牌。なんだろう、SoC (というか GPU ?)の違いなのかな。
あ、ちなみにもしこれがうまく行っても、YUV_420_888Bitmapにするのが面倒そう。

MediaCodecの映像入出力にはSurface以外にByteBufferByteArray)も使えますが、ByteBufferだと色の面倒も自前で調整しないといけない???
スマホのSoC違ったら動かないとか嫌すぎる・・・

それに比べるとOpenGL周りの用意は面倒なものの、AndroidのSurfaceTexture、デコーダーから出てくるこの色?カラーフォーマット?の扱い(YUVとかRGBとか)を勝手に吸収してくれている可能性。
stackoverflowだとその事に関して言及してるんだけど、公式だとどこでSurfaceのカラーフォーマットの話書いてあるんだろうか。

OpenGL ES周りは厳しいけど(AOSPコピペで何がなんだかわからない)、けど、それなりのメリットはありそうです。
あとフラグメントシェーダーで加工できるのもメリットだけど難しそう。

追記:2024/05/30 ImageReader の width と height は決まっている説

ImageReaderのドキュメントには乗ってませんが、width / heightの値は決まった数字以外で動かないっぽい?

https://developer.android.com/reference/android/media/ImageReader#newInstance(int,%20int,%20int,%20int,%20long)

変な解像度videoWidth = 1104 / videoHeight = 2560の場合に出力された映像がぐちゃぐちゃになっちゃった。
調べてもよくわからないので、色々試した感じ、1280x720とかの解像度は動く。けどメジャーじゃない、中途半端な数字では動かない。
MediaCodecの噂では、16の倍数じゃないといけないとかで、16で割れるかのチェックを入れてみたんですけどそれもダメそうで。結局動く値に丸めることにした。

面倒なので縦も横も同じ正方形に、ImageReaderから取り出したBitmapBitmap#scaleで元のサイズに戻すのが、今のところ安定している。。。

// しかし、一部の縦動画(画面録画)を入れるとどうしても乱れてしまう。
// Google Pixel の場合は、縦と横にを 16 で割り切れる数字にすることで修正できたが、Snapdragon は直らなかった。
// ・・・・
// Snapdragon がどうやっても直んないので、別の方法を取る。
// 色々いじってみた結果、Snapdragon も 320 / 480 / 720 / 1280 / 1920 / 2560 / 3840 とかのキリがいい数字は何故か動くので、もうこの値を縦と横に適用する。
// その後元あった Bitmap のサイズに戻す。もう何もわからない。なんだよこれ・・
val originWidth = videoWidth
val originHeight = videoHeight
val maxSize = maxOf(videoWidth, videoHeight)
val imageReaderSize = when {
    maxSize < 320 -> 320
    maxSize < 480 -> 480
    maxSize < 720 -> 720
    maxSize < 1280 -> 1280
    maxSize < 1920 -> 1920
    maxSize < 2560 -> 2560
    maxSize < 3840 -> 3840
    else -> 1920 // 何もなければ適当に Full HD
}
imageReader = ImageReader.newInstance(imageReaderSize, imageReaderSize, PixelFormat.RGBA_8888, 2)

戻すのがこの辺。

val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(buffer)
// アスペクト比を戻す
val resultBitmap = bitmap.scale(originWidth, originHeight)

追記:2024/06/04

やっぱり16の倍数にするだけでいい気がする。

追記:2024/09/16 もうちょっと速くできる

もう少し速く出来るのでその話をします。
TIMEOUT_USの時間を0にすればさらに速くなるはずです。これを書いた時点の私は10_000Lよりも小さくすると映像が崩れてしまうので、最低でもこれくらいは必要だろうと適当に入れてたのですが、そんなことはなく単に私の書いたコードが間違えてました。

映像が崩れてしまう原因ですが、デコーダーに入れて無いのにコンテナ(mp4)のデータを次に読み進めていたのが原因でした。
デコーダーに入れた後にデータを次に読み進める分には正解なのですが、デコーダーに入れてもないのにデータを読み進めたのが悪かったようです。はい私のせい。

タイムアウトが長かったため、タイムアウトよりもデコーダーの用意が先に来ることがほとんどになるためうまく動いていた。しかし、タイムアウトを短くしたことにより、デコーダーが間に合わずリトライが必要になる場合が出てきた。
しかし、現状のコードではデコーダーに入れたかどうか確認せずにコンテナのデータを読み進めていた。確認してないのでデータだけが先に読み進んでしまった。

明らかにキーフレームが欠落した動画フレームが出てきて疑ったらビンゴ。

というわけで修正箇所がこんな感じで、MediaCodec#queueInputBufferをしていることを確認してからMediaExtractor#advanceするように直しました。
あとタイムアウトの定数を0にした。

     companion object {
         /** MediaCodec タイムアウト */
-        private const val TIMEOUT_US = 10_000L
+        private const val TIMEOUT_US = 0L
-            // 次に進める。advance() が false の場合はもうデータがないので、break する。
-            val isEndOfFile = !mediaExtractor.advance()
-            if (isEndOfFile) {
-                break
+            // 次に進める。デコーダーにデータを入れた事を確認してから。
+            // advance() が false の場合はもうデータがないので、break する。
+            if (0 <= inputBufferIndex) {
+                val isEndOfFile = !mediaExtractor.advance()
+                if (isEndOfFile) {
+                    break
+                }
             }

あとここも。
タイムアウトを短くしたことによりlatestDecodePositionMs = presentationTimeMsの部分が2回以上通過しちゃうことがあった。
別プロジェクトでおかしくなってしまったので調査したらこの部分が2回以上呼ばれてたからだった。

                         val presentationTimeMs = bufferInfo.presentationTimeUs / 1000
                         if (seekToMs <= presentationTimeMs) {
                             isRunning = false
+                            isDecoderOutputAvailable = false
                             latestDecodePositionMs = presentationTimeMs
                         }
                     }

これが乱れた動画フレーム。
デコーダーに入れるべきデータがずれてしまったのが多分原因。

Imgur

TIMEOUT_US0になったので、さらに速くなった?リトライが多くなってるとは思うけどコード間違って無ければ問題ないはず。

Imgur

ちなみにこれがTIMEOUT_US10_000Lだった頃。↑が改善後なので速くなった。

Imgur

修正コミットはこちらです。

おわりに

こんな長々と書く予定はありませんでした。
ぜひ試す際はいろんな動画を入れてみるといいと思います、たまに変に動くやついる↑もそれで見つけた