たくさんの自由帳

Android で HDR 動画の撮影、動画編集アプリを作る

投稿日 : | 0 日前

文字数(だいたい) : 30366

目次

どうもこんばんわ。
D.C.5 Plus Happinessを攻略しました、今年1楽しみゲーム(の割に数ヶ月やれてなかったのは許して)。すごく良かった。
前にやった D.C.5 は全年齢版、今回のはそれにえちえちシーンが追加された版です。
ついにこの先が、全年齢版とかたかだかキスで恋仲を確かめて終わりだし。とか言ってる私が救われた、先が見れて嬉しいよ

Imgur

ちなみに5ってついてるけど別に前作知らなくても遊べます!!!やろう。
面白かったらD.C.4やると良いと思います。

心なしか色っぽいみずはちゃんのパッケのやつ!!!

Imgur

(せっかくなので一番高いやつ買ってみたよ~~)

Imgur

もうすでにD.C.5の全年齢版をやってるのでえちえちシーンだけ見ても良いんだけど、、、見たいお話あるから2周目やります!!
2周目だから気付けるところがあって良い。あーこれかー的な。あ~~~~

Imgur

!?!??!
まってえちえちシーン後付けじゃない!?、本編のシナリオに組み込まれてる。
なんなら本編のシナリオも影響ない範囲で少しだけ変わってそう?全年齢版で見たことないお話が!!?!?

かこちゃん!!
あとあんまり関係ないけど後ろ姿がD.C.やってるな~って感じでいい

Imgur

Imgur

ここすき

Imgur

Imgur

かこちゃん、他の子のルートでいい味してるんだよな。

Imgur

Imgur

姉には辛辣なのおもろい

Imgur

ゆきねえ!!
1周目だと見落としちゃいそうだけど、お互い心配してるのね、

Imgur

Imgur

自信満々な雪姉いい、それはそうと一回お話を知ってるので、要所要所にあるのが引っかかって辛いわね。
で一番最後は解決したからめにょあがいるのか。(2週目でわかった)
あと豪華版についてくる雪姉の歌うED曲のカバーが良い!。

Imgur

Imgur

めにょあ!!
かこちゃんがいい味、姉妹だ。

Imgur

Imgur

かわいい

Imgur

タジタジめにょあ

Imgur

あとすでにキャラ崩壊きてないか?
いまからキャラ崩壊めにょあが楽しみすぎる!!!!!

Imgur

みずはちゃん!!!、キャラデザがよすぎる、かわいい。

Imgur

Imgur

Imgur

それはそうとパン食い競争好きすぎるみずはちゃんかわいい。

Imgur

Imgur

Imgur

そんな子がいかがわしい知識を得たら。。。最強。最強だった
グイグイ来るみずはちゃん、つよい

Imgur

Imgur

!?!?!?

Imgur

じゃあ一番楽しみのあかりちゃんルート見ますか!!!。
まって声かわいいんだけど!?

Imgur

ときたま引っかかるのも2週目でわかった、あーね。

Imgur

それはそうとこの子のルートは唯一(!!)一緒に住んでるわけじゃないから丁寧に?書かれている感じがした。
いい!!!律儀だ

Imgur

Imgur

かわいい!かわいい
(この辺からsurfaceが壊れてスクショに手間取ってる)

Imgur

Imgur

まあ何でも良いのでとにかくこの子のルートにある、何が好きですか?の部分と、チケットいらないですよ?のお話を見てきてください、
まじでこのシナリオいい!!!!!!!

Imgur

!?!?!??

Imgur

Imgur

お話が良すぎる、キャラデザ、声全部いい!!、ぜひ!!!おすすめです
じゃあ私は全年齢版との差分探すから終わるね。

本題

終わりません。

この記事に貼り付けられている動画は、多分眩しいので暗闇で開いている方がいたらは動画を見ないほうがいいかもしれません、、、

HDR (はいだいなみっくれんじ)の動画って見たことありますか?あのディスプレイの明るさを貫通して眩しい動画
YouTube とかでHDRって調べると見れると思います、あと周りのSDRの部分が暗くなるあれ。

また、最近の端末だとスマホでもHDRの動画が撮影できます。
とくにPixelシリーズだと画面輝度がかなり明るいので結構わかりやすいと思う。てか眩しい。

これは東京タワーとスカイツリーが見えるってやつ。HDR動画です。Chromeだとデコードできるんかな。
(とっさに撮ったのでブレッブレ、音はないです)

サンプルとしてソースコードとともにGitHubに上げておきました。(ファイル地味に大きくてごめんGitHub
https://github.com/takusan23/AndroidMediaCodecHdrVideoCanvasOverlay/blob/master/sample/10_bit_hdr_hevc.mp4

他にもあります、これはCamera2 APIを叩いて取ってみました。こちらです。
別に叩かずとも標準のカメラアプリをHDR動画撮影有効で撮影すればいいだけです。

流石にデカすぎるのでYouTubeに上げたものを貼ります。
画質設定から HDR と書かれているものを選んでください。

眩しいのが思った以上に嫌われてて笑ったけど、今回はAndroidHDR 動画の撮影と、動画編集の話をします。

HDRの対になる単語がSDR (すたんだーどだいなみっくれんじ)で、今までのMediaCodecシリーズで扱ってきたものはすべてSDRになります。
SDRの時はあんまり意識しなくても使えましたが、HDRの場合そうは行かないので、HDR周りのAPIを叩くために必要な知識みたいなのも書いていきたい。

自作アプリの対応状況

サンタさんです(大遅刻)

Imgur

動画を扱う自作アプリでもHDRの動画を扱えるようにアップデートしました。
この成果としてこの記事があるわけですね。

HDR対応によりOpenGL ESの力を借りないといけなくなってしまい、
前に記事で書いた逆再生の動画を作る話と、動画編集アプリを作った話、それぞれ書いたときとは大きく変わったソースコードになってしまいました、あの頃の記事で書いたコードではもう無い。。。

ま、まあOpenGLとかいう複雑を取ってもなお速度向上が得れたので良かった。

2つの意味で使われる HDR

さて、まずはHDRについてなんですが、まさかのここから話さないといけないという。

HDRという単語、カメラの世界では動画撮影以外にも静止画撮影時にも使われます。
しかし、この2つは大きく違うものなので注意しましょう。今回の記事は動画のHDRの話です。

動画の HDR

https://www.eizo.co.jp/eizolibrary/color_management/hdr/

今回の話はこっちです。動画の方です。
従来のSDR動画と比べて、HDRで撮影するとより多くの明るさと色を使って撮影できる。

詳しくは後で話しますが、
SDRで使っている色はBT.709 (Rec.709)の範囲でしたが、HDRではBT.2020 (Rec.2020)という更に広い色の範囲を使うため、より鮮やかになるわけです。
また、SDRの時はRGBをそれぞれ8 ビット(0xff)で保存していたのに対して、HDRだと10 ビットで保存されます。
眩しく見えるのは8 ビット以上を表現できる様になったからでしょうか(多分...)。

今回の記事では、写真のHDRとの差別化のため10 ビット HDRと書き直すことにします。

写真の HDR

本題から外れてしまうので手短に。クソややこしい。
これは、複数回カメラの露出を変えて撮影し、最終的に 1 枚の写真に合成することで、暗い部分が明るく取れるってやつです(逆光に強いとか)。

はい。。撮影方法が違うだけで、使っている色は SDR の頃から変わっていません。

なので、HDR静止画撮影を有効にして撮影したとしても別にHDRらしく明るく表示されたりするわけではありません。
これが動画のHDRと、静止画撮影のHDRは全く別と言われている理由です。

もしCamera2 APIでこの静止画撮影の HDRを有効にしたい場合は、Camera2 API 拡張機能を使うのが多分正解です。
CameraExtensionCharacteristics.EXTENSION_HDR
https://developer.android.com/reference/android/hardware/camera2/CameraExtensionCharacteristics

Ultra HDR

これらのHDRとか言うくせに蓋を開けたらSDRのそれとは違い、動画のHDRのように明るい写真を取れるような技術があります。Pixel 8シリーズから追加されたUltra HDRですね。
SDRで撮影した写真とともに、HDRらしく明るさのためのゲインマップが保存されています。

HDR ざっくり入門

さて、いい加減コード書けやって話ですが、HDRに関連する用語の整理を先にします。。
HLG / PQ / ST 2084 / BT.2020 / Dolby Vision、このあたり何って?

https://developer.android.com/media/camera/camera2/hdr-video-capture

HDR の種類

HDRといっても4つぐらい種類があります。
カメラアプリだとHDRの ON/OFF くらいしかないのですが、例えば動画編集をするとなるとこの辺を意識する必要があります。

名前HLGHDR10HDR10+ドルビービジョン (Dolby Vision profile 8.4)
特徴SDR ディスプレイでも見れる標準?HDR10 にメタデータを入れたものprofile 8.4 は HLG と互換性あり。本物はガンマカーブが PQ
映像コーデックH.265 (HEVC)H.265 (HEVC)H.265 (HEVC)HLG と互換性あり
色空間 (色域、カラースペース)BT.2020 (Rec.2020)BT.2020 (Rec.2020)BT.2020 (Rec.2020)HLG と互換性あり
ガンマカーブ(伝達関数、EOTF)HLGPQ (ST 2084)PQ (ST 2084)HLG と互換性あり

ややこしいね。
また、HDR形式の変換もそう簡単には出来ないようです(HLGPQ (ST 2084)の変換)。

Androidでは、HDR動画撮影に対応している場合、少なくともHLG形式のHDRに対応している必要があるそうで、
今回の記事では HLG 形式の 10 ビット HDR 動画を扱います。 動画撮影も動画編集もHLG形式を使います。

世の Android 端末が採用している HDR 形式調査

どうやら、Android 端末によってカメラアプリの HDR の形式が違うようです。
というわけでヨドバシで色々見てみました。

PixelXperiaHLG
Pixelは記載ないですが、Google フォトを見る限りHLGです。Pixel60fps10 ビット HDR撮影できるようにしてほしい。

Imgur

Galaxy一部の XiaomiHDR10+

Imgur

Imgur

Oppo一部の Xiaomi、あとAQUOS R9 pro (!?)ドルビービジョンでした。
あと Android 関係ないけどiPhoneもこれ。(iPhoneはデフォルトでHDR動画撮影が有効らしい、強気だ)
が、が、が、後述しますが、ドルビービジョンはドルビービジョンでもDolby Vision profile 8.4ってやつな気がします。

Imgur

Imgur

Imgur

Android開発のお仕事の人は大変だろうね~、HDR3つあって(笑)
仕事ですからね。仕方ないですね。

色空間

https://www.eizo.co.jp/eizolibrary/color_management/hdr/index2.html

これは表示できる色の範囲のことです。
10 ビット HDRでは、BT.709よりも更に広い範囲を扱えるようにしたBT.2020を利用します。

BT.709Rec.709BT.2020Rec.2020って別名がついています。
開発時のドキュメントでRec.の方で書かれてたとしても、同じですのでびっくりしないでください。

ガンマカーブ

HLGPQ (ST 2084)のことですね。多分伝達関数とかのがあってそうな気がする。
、、、で、ガンマカーブって何?って話なんですが、説明できる気がしないので、ここでは2つのガンマカーブの違いだけを話します。

HLGは、Wikipedia曰く途中までSDRのガンマカーブとほぼ同じだそうで、SDRとの互換性が高い。
PQ (ST 2084)HDR - SDRのような互換性がないものの、Wikipedia曰く人の視覚に合わせて作ったそうな。

映像コーデック

HDRを扱える動画コーデックは、HEVC (H.265)VP9AV1あたりがあります。

この中で使えるものを、手元のAndroid端末で試したところ、HEVCAV1エンコーダー10 ビット HDR動画をエンコードすることが出来てそうでした。
ただ、AV1はハードウェアエンコーダーがほぼ無いため、現状HEVCを選ぶしか無さそう。VP9HDRいけるってどっかで見たけどAndroidだとダメそう?

覚えないとだめなの?

はい。
というのも、動画ファイル(コンテナフォーマット)には色空間とガンマカーブの種類を保存しているらしく、HLG / HDR10 / HDR10+ / ドルビービジョンかどうかは、
取得した色空間とガンマカーブから求める必要があります。
動画ファイルから一発でHLG / HDR10等を取得できるわけじゃないので注意です。

これはMediaCodec(エンコーダー)にも言えることで、同様にHLG / HDR10を指定するAPIではなく、代わりに色空間ガンマカーブを指定するAPIになっているので、
自分がエンコードしたいHDRの種類の色空間(まあこれはどれを選んでも BT.2020 ですが)、ガンマカーブを指定する必要があります。

Google フォトHLGとかHDR10とか表示していて、そういう一発で取得できるAPIあるもんだと思ってたら普通に違った。

ドルビービジョン←これ本当に何

そもそもこれ、会社の名前がついているあたり自由に使えない可能性がある。
まあこれはH.265 (HEVC)にも言えるんですが、、、

さて、これで説明を終わろうと思ったんですが、ガチで厄介なやつがいました。ドルビービジョンです。
iPhoneをはじめ、XiaomiOppo、あとはAQUOS R9 proもこれでした。

Wikipediaを読むあたり、ガンマカーブはPQを使ってるらしいのですが、撮影したデータを見るとなぜかHLGなんですよね。
ん~~~~????

というわけでもう少し調べると、どうやらiPhoneとかXiaomiとかが謳ってるドルビービジョンドルビービジョンの中でもDolby Vision profile 8.4とかいうやつらしく、
これはガンマカーブがPQではなくHLGらしい。それに加えて、ドルビービジョン用のメタデータか何かが入っている。

ただ、そのドルビービジョンのメタデータとやらを取っ払えばHLG形式のHDRとして解釈していいらしい。。。
現にドルビービジョンが再生できない端末に対してはGoogleフォト上ではHLGと解釈されてそうですね。多分HEVCのデコーダーに流せばデコードできるってことなのかな。

じゃあHLGと互換があると思い込んでHEVCのデコーダーを用意すると、多分今度は本物のドルビービジョン(言い方があれ、ガンマがPQの方)が無理な気がするんだけど。
まじでなにこれ?

あと開発者向けドキュメントあります。なぜかC++だけど、、
https://professionalsupport.dolby.com/s/application-development-android?language=en_US

HDR に詳しくなりたいよ~~

Appleの動画。冒頭はHDRの概要なのでAndroid関係なく動画に詳しくなれそう。
日本語の字幕があります。Firefoxなら字幕もPinPできるはず。

https://developer.apple.com/jp/videos/play/wwdc2020/10010/

付録 HDR の動画に対応していないので、アプリ内で弾きたい

SDRの動画しか対応していないアプリを作っている人向け。
取り急ぎフォトピッカーHDRの動画を選んでしまった場合にエラーを出したいときに使ってください。

/**
 * HDR かどうか判定する
 *
 * @param context [Context]
 * @param uri フォトピッカー等で選んで
 * @return true で HDR(HLG / HDR10 / HDR10+)
 */
private fun isHdrVideo(context: Context, uri: Uri): Boolean {
    return MediaMetadataRetriever().use { mediaMetadataRetriever ->
        // Uri を MediaMetadataRetriever に渡す
        context.contentResolver.openFileDescriptor(uri, "r")?.use {
            mediaMetadataRetriever.setDataSource(it.fileDescriptor)
        }
        // 色空間、ガンマカーブを取得する
        val colorSpace = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD)?.toInt()
        val colorTransfer = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER)?.toInt()
        // HDR なら色空間が BT.2020、ガンマカーブが HLG か PQ であるはず
        colorSpace == MediaFormat.COLOR_STANDARD_BT2020 && (colorTransfer == MediaFormat.COLOR_TRANSFER_HLG || colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084)
    }
}
 
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScreen() {
    val isHdr = remember { mutableStateOf(false) }
    val context = LocalContext.current
 
    val videoPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
        uri ?: return@rememberLauncherForActivityResult
        isHdr.value = isHdrVideo(context, uri)
    }
 
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(text = "HDR 判定くん") }
            )
        }
    ) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Button(onClick = { videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }) {
                Text(text = "動画を選ぶ")
            }
            Text(text = if (isHdr.value) "HDR 動画です" else "HDR 動画ではありません")
        }
    }
}

Imgur

もしくは、SDR動画の場合は色空間がBT.709なはずなので、SDRかどうかを判定してもいいかもしれません。

/** SDR かどうか判定する */
private fun isSdrVideo(context: Context, uri: Uri): Boolean {
    return MediaMetadataRetriever().use { mediaMetadataRetriever ->
        // Uri を MediaMetadataRetriever に渡す
        context.contentResolver.openFileDescriptor(uri, "r")?.use {
            mediaMetadataRetriever.setDataSource(it.fileDescriptor)
        }
        // 色空間を取得する
        val colorSpace = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD)?.toInt()
        // SDR なら BT.709 のはず
        colorSpace == MediaFormat.COLOR_STANDARD_BT709
    }
}

環境

なまえあたい
Android StudioAndroid Studio Ladybug 2024.2.1 Patch 3
minSdk33
たんまつPixel 8 Pro / Xperia 1 V (Camera2 APIHDR撮影のアプリは動きません)

Xperia 1 Vも最大4K 120fpsHDR動画が撮影できるのですが、Camera2 API には開放していないらしく標準カメラアプリ以外ではHDR撮影ができません。Androidあるある。
後継機では直ってるといいなあこれ

Camera2 API で HDR 動画撮影をやってみる

動画撮影だけならOpenGL ESはでてきません。安心!

Imgur

CameraX は

CameraXだとすぐ作れるらしいです!!!。
未だに使ったことがなくわからないですが、見た感じフラグを指定するだけなのかなって思ってたり。

https://android-developers.googleblog.com/2023/06/camerax-13-is-now-in-beta.html

付録 Camera2 API で対応している HDR の種類を取得する

getCameraCharacteristicsで、CameraCharacteristics.REQUEST_AVAILABLE_DYNAMIC_RANGE_PROFILESを取得すると、Camera2 API側で対応しているHDRの種類が取得できます。
https://developer.android.com/media/camera/camera2/hdr-video-capture?hl=ja#check_for_hdr_support

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        val cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
        // バックカメラ
        val backCameraId = cameraManager.cameraIdList.first { cameraId ->
            cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
        }
        val characteristic = cameraManager.getCameraCharacteristics(backCameraId)
        val capabilities = characteristic[CameraCharacteristics.REQUEST_AVAILABLE_DYNAMIC_RANGE_PROFILES]?.supportedProfiles ?: emptySet()
        val isHlg = DynamicRangeProfiles.HLG10 in capabilities
        val isHdr10 = DynamicRangeProfiles.HDR10 in capabilities
        val isHdr10plus = DynamicRangeProfiles.HDR10_PLUS in capabilities
        println("isHlg = $isHlg / isHdr10 = $isHdr10 / isHdr10plus = $isHdr10plus")
    }
}

Pixel 8 Pro の場合はHLGのみ対応でした。

isHlg = true / isHdr10 = false / isHdr10plus = false

Xperia 1 V はCamera2 APIHDRが利用できないので全部falseです。。。

AndroidManifest

適当なJetpack Composeのプロジェクトを作ったら2つの権限を追加してください。カメラとマイクです。

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

権限チェック画面とカメラ画面

動画撮影ということで、カメラと録音権限がない場合は先に要求する画面に遷移するようにしました。
本当はnavigation composeあたりで画面切り替えするべきですが、まあサンプルなので、、、

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            val context = LocalContext.current
            val isPermissionGranted = remember {
                mutableStateOf(REQUIRED_PERMISSION.all { permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED })
            }
 
            AndroidCamera2HdrTheme {
                // 権限があればカメラ画面へ
                if (isPermissionGranted.value) {
                    MainScreen()
                } else {
                    PermissionScreen(onGranted = { isPermissionGranted.value = true })
                }
            }
        }
    }
}
 
/** 必要な権限 */
private val REQUIRED_PERMISSION = listOf(android.Manifest.permission.CAMERA, android.Manifest.permission.RECORD_AUDIO)
 
/** 権限ください画面 */
@Composable
private fun PermissionScreen(onGranted: () -> Unit) {
    val permissionRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { it ->
        if (it.all { it.value /* == true */ }) {
            onGranted()
        }
    }
 
    Scaffold {
        Column(Modifier.padding(it)) {
            Button(onClick = { permissionRequest.launch(REQUIRED_PERMISSION.toTypedArray()) }) {
                Text(text = "権限を付与してください")
            }
        }
    }
}
 
/** カメラ画面 */
@Composable
private fun MainScreen() {
    // このあとすぐ
}

カメラ操作用クラス

を作ります、Camera2 APIは複雑で、Jetpack ComposeUIといっしょに書くわけには行かないので、、、
まあこの辺を自力で書くくらいならCameraX使えばいいのではと思った。

@SuppressLint("MissingPermission")
class CameraController(private val context: Context) {
    // このあとすぐ
}

プレビュー

先にコードを貼ります。どーーーん。

class CameraController(private val context: Context) {
    private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
    private val cameraExecutor = Executors.newSingleThreadExecutor()
    private val scope = CoroutineScope(Dispatchers.Default + Job())
 
    private val _isRecording = MutableStateFlow(false)
    private var mediaRecorder: MediaRecorder? = null
    private var saveVideoFile: File? = null
 
    /** 今の処理キャンセル用 */
    private var currentJob: Job? = null
 
    /** Surface の生成を通知する Flow */
    private val _surfaceFlow = MutableStateFlow<Surface?>(null)
 
    /** カメラを開いて、状態を通知する Flow */
    private val backCameraDeviceFlow = callbackFlow {
        var _device: CameraDevice? = null
        // openCamera は複数回コールバック関数を呼び出すので flow にする必要がある
        cameraManager.openCamera(getBackCameraId(), cameraExecutor, object : StateCallback() {
            override fun onOpened(camera: CameraDevice) {
                _device = camera
                trySend(camera)
            }
 
            override fun onDisconnected(camera: CameraDevice) {
                // TODO エラー処理
                _device?.close()
                _device = null
                trySend(null)
            }
 
            override fun onError(camera: CameraDevice, error: Int) {
                // TODO エラー処理
                _device?.close()
                _device = null
                trySend(null)
            }
        })
        // キャンセル時
        awaitClose { _device?.close() }
    }.stateIn(
        scope = scope,
        started = SharingStarted.Eagerly,
        initialValue = null
    )
 
    /** 初回時、録画終了後に呼び出す */
    fun prepare() {
        scope.launch {
            currentJob?.cancelAndJoin()
 
            // MediaRecorder を作る
            initMediaRecorder()
 
            currentJob = launch {
                // SurfaceView の生存に合わせる
                // アプリ切り替えで SurfaceView は再生成されるので、常に監視する必要がある
                _surfaceFlow.collectLatest { previewSurface ->
                    previewSurface ?: return@collectLatest
 
                    // 外カメラが開かれるのを待つ
                    val cameraDevice = backCameraDeviceFlow.filterNotNull().first()
 
                    // カメラ出力先。プレビューと MediaRecorder
                    val outputSurfaceList = listOfNotNull(previewSurface, mediaRecorder?.surface)
 
                    // CaptureRequest をつくる
                    val captureRequest = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
                        // HDR はここじゃない
                        outputSurfaceList.forEach { surface -> addTarget(surface) }
                        // FPS とかズームとか設定するなら
                        // set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(VIDEO_FPS, VIDEO_FPS))
                    }
 
                    // CaptureSession をつくる
                    val captureSession = cameraDevice.awaitCameraSessionConfiguration(outputSurfaceList = outputSurfaceList) ?: return@collectLatest
 
                    // プレビュー開始
                    captureSession.setRepeatingRequest(captureRequest.build(), null, null)
                }
            }
        }
    }
 
    /** Surface の生成コールバックで呼び出す */
    fun createSurface(surface: Surface) {
        _surfaceFlow.value = surface
    }
 
    /** Surface の破棄コールバックで呼び出す */
    fun destroySurface() {
        _surfaceFlow.value = null
    }
 
    /** MediaRecorder と仮のファイルを作る */
    private fun initMediaRecorder() {
        // 一時的に getExternalFilesDir に保存する
        saveVideoFile = context.getExternalFilesDir(null)!!.resolve("${System.currentTimeMillis()}.mp4")
        mediaRecorder = MediaRecorder(context).apply {
            setAudioSource(MediaRecorder.AudioSource.MIC)
            setVideoSource(MediaRecorder.VideoSource.SURFACE)
            setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
            setVideoEncoder(MediaRecorder.VideoEncoder.HEVC) // 10 ビット HDR 動画の場合は HEVC にする
            setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
            setAudioChannels(2)
            setVideoEncodingBitRate(20_000_000)
            setVideoFrameRate(60)
            setVideoSize(VIDEO_WIDTH, VIDEO_HEIGHT)
            setAudioEncodingBitRate(192_000)
            setAudioSamplingRate(48_000)
            setOutputFile(saveVideoFile)
            prepare()
        }
    }
 
    /** CameraDevice#createCaptureSession をサスペンド関数にしたもの */
    private suspend fun CameraDevice.awaitCameraSessionConfiguration(outputSurfaceList: List<Surface>): CameraCaptureSession? = suspendCoroutine { continuation ->
        // OutputConfiguration を作る
        val outputConfigurationList = outputSurfaceList
            .map { surface -> OutputConfiguration(surface) }
            .onEach { outputConfig ->
                // 10 ビット HDR 動画撮影を有効にする
                // HDR 動画撮影に対応している場合、少なくとも HLG 形式の HDR に対応していることが保証されているので HLG
                if (isSupportedTenBitHdr()) {
                    outputConfig.dynamicRangeProfile = DynamicRangeProfiles.HLG10
                }
            }
        val sessionConfiguration = SessionConfiguration(SessionConfiguration.SESSION_REGULAR, outputConfigurationList, cameraExecutor, object : CameraCaptureSession.StateCallback() {
            override fun onConfigured(captureSession: CameraCaptureSession) {
                continuation.resume(captureSession)
            }
 
            override fun onConfigureFailed(p0: CameraCaptureSession) {
                // TODO エラー処理
                continuation.resume(null)
            }
        })
        createCaptureSession(sessionConfiguration)
    }
 
    /** 10 ビット HDR 動画撮影に対応している場合は true */
    private fun isSupportedTenBitHdr(): Boolean {
        val characteristic = cameraManager.getCameraCharacteristics(getBackCameraId())
        val capabilities = characteristic[CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES]
        return capabilities?.contains(CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_DYNAMIC_RANGE_TEN_BIT) == true
    }
 
    /** バックカメラの ID を返す */
    private fun getBackCameraId(): String = cameraManager
        .cameraIdList
        .first { cameraId -> cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK }
 
    companion object {
 
        // フル HD
        const val VIDEO_WIDTH = 1920
        const val VIDEO_HEIGHT = 1080
        const val VIDEO_FPS = 60
 
    }
}

backCameraDeviceFlowってのがカメラを開いたあとの状態を通知するコールバックをFlowにしたものになります。
コールバックが複数回呼ばれるのでFlowにする必要があります。
stateIn()HotFlowに変換していますが、これはアプリ利用中は常にCameraDeviceを使い回すためです。撮影が終わってプレビューに戻る際もCameraDeviceは同じものを使うため。

SurfaceViewJetpack Compose側で作ってもらうため、こちらは受け取り口を用意し、StateFlowに入れています。
このFlowを収集し、null以外になったらプレビューを開始しています。

collectLatest { }のなかでプレビューを始める処理をやっています。
本家のドキュメントではMediaCodecで録画しろって書かれてますが、MediaRecorderでもHDR動画が録画できます。(HLGしか見てないケド)
Surfaceを2箇所渡す必要があるのを忘れないようにしてください。やらないとこれ : Each request must have at least one Surface target

Camera2 APIHDR動画撮影する場合はOutputConfigurationHDRの形式をセットする形になります。
今回はHLGで。また、端末の標準カメラアプリが対応していても、Camera2 APIでサポートしているかはまた別の話なので、isSupportedTenBitHdr()関数で確認しています。

10 ビットに対応しているかは、getCameraCharacteristics()CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIESを取得し、CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_DYNAMIC_RANGE_TEN_BITが中にあればいいはずです。

今回はHLGを使うのでこれ以上は見ていないですが(先述の通り最低限HLG形式はサポートされる保証がある)、HLGではなくHDR10ドルビービジョン???で撮影がしたい場合なんかは
CameraCharacteristics.REQUEST_AVAILABLE_DYNAMIC_RANGE_PROFILESを使うと対応しているHDRの形式を取得できるそうです。

val availableProfiles = characteristic[CameraCharacteristics.REQUEST_AVAILABLE_DYNAMIC_RANGE_PROFILES]?.supportedProfiles
val isHdr10Support = availableProfiles?.contains(DynamicRangeProfiles.HDR10) == true

録画部分

といってもあとはMediaRecorderの開始と終了を呼ぶところを追加するだけなんですけどね。
MediaRecorder()で撮影したデータはgetExternalFilesDir()にあって、そのままでは写真アプリ空見れないのでMediaStoreに登録して移動させています。

/** 録画中か */
val isRecording = _isRecording.asStateFlow()
 
/** 撮影開始 */
fun startRecord() {
    mediaRecorder?.start()
    _isRecording.value = true
}
 
/** 撮影終了 */
fun stopRecord() {
    scope.launch {
        // 処理を止める
        currentJob?.cancelAndJoin()
        // 録画停止
        mediaRecorder?.stop()
        mediaRecorder?.release()
        _isRecording.value = false
 
        // 動画データを動画フォルダへ移動
        val contentResolver = context.contentResolver
        val contentValues = contentValuesOf(
            MediaStore.Images.Media.DISPLAY_NAME to saveVideoFile!!.name,
            MediaStore.Images.Media.RELATIVE_PATH to "${Environment.DIRECTORY_MOVIES}/AndroidCamera2HdrVideo"
        )
        val uri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)!!
        saveVideoFile!!.inputStream().use { inputStream ->
            contentResolver.openOutputStream(uri)?.use { outputStream ->
                inputStream.copyTo(outputStream)
            }
        }
        saveVideoFile?.delete()
 
        // MediaRecorder は使い捨てなので、準備からやり直す
        prepare()
    }
}
 
/** 終了時に呼ぶ */
fun destroy() {
    scope.cancel()
    cameraExecutor.shutdown()
}

UI 部分

こっちは特にないかな、SurfaceView録画開始、終了ボタンをおいただけ。
注意点としては横画面しか対応できていない点です。縦動画は直してないので撮れません、、、

SurfaceHolder#setFixedSizeを呼ぶことでCamera2 APIプレビューが歪むという初見殺しも対応できます。
https://developer.android.com/reference/android/hardware/camera2/CameraDevice#createCaptureSession(android.hardware.camera2.params.SessionConfiguration)

/** カメラ画面 */
@Composable
private fun MainScreen() {
    val context = LocalContext.current
    val cameraController = remember { CameraController(context) }
    val isRecording = cameraController.isRecording.collectAsState()
 
    DisposableEffect(key1 = Unit) {
        // プレビュー開始
        cameraController.prepare()
        // 使わなくなったら破棄
        onDispose { cameraController.destroy() }
    }
 
    // TODO 横画面しか対応できていない
    Box(modifier = Modifier.fillMaxSize()) {
        AndroidView(
            modifier = Modifier
                .align(Alignment.Center)
                .aspectRatio(CameraController.VIDEO_WIDTH / CameraController.VIDEO_HEIGHT.toFloat()),
            factory = {
                SurfaceView(context).apply {
                    holder.addCallback(object : SurfaceHolder.Callback {
                        override fun surfaceCreated(holder: SurfaceHolder) {
                            // 解像度を合わせておく
                            holder.setFixedSize(CameraController.VIDEO_WIDTH, CameraController.VIDEO_HEIGHT)
                            cameraController.createSurface(holder.surface)
                        }
 
                        override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                            // do nothing
                        }
 
                        override fun surfaceDestroyed(holder: SurfaceHolder) {
                            cameraController.destroySurface()
                        }
                    })
                }
            }
        )
 
        Box(
            modifier = Modifier
                .padding(bottom = 30.dp)
                .align(Alignment.BottomCenter)
                .clip(CircleShape)
                .size(80.dp)
                // 録画中は色を変える
                .background(if (isRecording.value) Color.Red else Color.Gray)
                .clickable { if (isRecording.value) cameraController.stopRecord() else cameraController.startRecord() }
        )
    }
}

これで動くんじゃないでしょうか?

Camera2 API 製の HDR 動画

東京駅です、

撮影風景です。スマホでスマホ取ってる意味不明な人

Imgur

Imgur

ちゃんとCamera2 API10 ビット HDRの撮影ができてそうです。HDR表示です。

Imgur

Camera2 API HDR のソースコード

はい。
camera2_hdrブランチがそうです、

https://github.com/takusan23/AndroidCamera2ApiHdrVideo

質問 プレビューが HDR じゃない

作って気付きました。撮影中は眩しくないのですが、実際に再生してみると眩しく表示されます。
なぜかプレビューのSurfaceViewにはHDRが適用されてない?

camera2のサンプルアプリを動かしてみると眩しいのですが、私もこれやりたいなー思って読み進めていくと、
OpenGL ESのフラグメントシェーダーとか書いてて あ~ って感じに。

https://github.com/android/camera-samples/blob/a07d5f1667b1c022dac2538d1f553df20016d89c/Camera2Video/app/src/main/java/com/example/android/camera2/video/HardwarePipeline.kt#L155

CameraXHDR動画撮影に対応して随分たった後に、プレビューがHDRに対応したのも、もしかしれこれ???
このあとすぐ!

質問 カメラ映像に文字を入れたりしたい

どうやら静止画撮影にはウォーターマークを付ける機能があるそうです、これ動画にも欲しくないですか?

ちなみにこれもOpenGL ESで上からCanvasで書いたBitmapを描画するなどの方法が必要です。
このあとすぐ!

HDR と OpenGL ES

さて、おそらくプレビューもHDRにしたい場合や(なんでプレビュー映し出すのにOpenGL ESを経由する必要があるのかはマジで不明)、
そもそもカメラ映像を加工したいという場合は、OpenGL ESを使う必要があります。

OpenGL ES で HDR(HLG) 表示をするためには

OpenGL ESのセットアップ(EGLとかいう)で、以下の点を直します(?)

  • GLES 3を使うようにする
    • HDR(HLG)の表示にはOpenGL ES 3系を使う必要があるそうです。
    • GLES 2から移行の場合は、シェーダーの書き直しが必要かもしれないし、バージョンディレクティブを書くという方法もあるはず。
      • 今なら生成 AIに書き直してもらえそう、WebGLあたりで知見あるでしょAI
  • RGBA8888RGBA1010102にする
    • EGL_RED_SIZEEGL_GREEN_SIZEEGL_BLUE_SIZE10
    • EGL_ALPHA_SIZE2
  • eglCreateWindowSurface時のint 配列の中に[EGL_GL_COLORSPACE_KHR, EGL_GL_COLORSPACE_BT2020_HLG_EXT]を足す
    • が、そもそもBT2020_HLG_EXTが使えるかを判定する必要がありそうです
    • なお上記の定数は自分で定義しないといけません、、
      • Android SDKの中には無いっぽい?
  • フラグメントシェーダーをちょっと直す(実は直さなくても動いてそう

これでSurfaceView / MediaRecorder / MediaCodec10 ビット HDR映像が扱えるようになります。うわ眩しい!

Camera2 API + OpenGL でカメラ映像のプレビューと動画撮影

OpenGL ESにすれば多分プレビューもHDRで表示されます、ついでにOpenGL ESで描画するならってことで上に文字を重ねます。。

Imgur

OpenGL ES 周りを揃えていく

いつものAOSPから借りてきているInputSurface.java10 ビット HDRに対応させます。
ありざいす

https://cs.android.com/android/platform/superproject/main/+/main:cts/tests/tests/media/common/src/android/media/cts/InputSurface.java

InputSurface.kt

10 bit HDR対応版です。
引数で10 ビット HDR有効時は、GLES 3にしてRGBA1010102にして[EGL_GL_COLORSPACE_KHR, EGL_GL_COLORSPACE_BT2020_HLG_EXT]します。

isAvailableExtension()を使うことでOpenGL側がHLGに対応しているか見れます。camera2のサンプルアプリがそうやってた。
https://github.com/android/camera-samples/blob/a07d5f1667b1c022dac2538d1f553df20016d89c/Camera2Video/app/src/main/java/com/example/android/camera2/video/HardwarePipeline.kt#L561-L568

OpenGlRendererは後で作ります。
その他はいつも通りです

/**
 * [OpenGlRenderer] で描画する際に OpenGL ES の設定が必要で、引数の Surface に対して、EGL 周りの設定をしてくれるやつ。
 * HDR 有効時は EGL 1.4 、GLES 3.0 でセットアップする。[OpenGlRenderer] は GL スレッドから呼び出すこと。
 *
 * @param outputSurface 出力先 [Surface]
 * @param isEnableTenBitHdr 10-bit HDR を利用する場合は true
 */
class InputSurface(
    private val outputSurface: Surface,
    private val isEnableTenBitHdr: Boolean
) {
    private var mEGLDisplay = EGL14.EGL_NO_DISPLAY
    private var mEGLContext = EGL14.EGL_NO_CONTEXT
    private var mEGLSurface = EGL14.EGL_NO_SURFACE
 
    init {
        // 10-bit HDR のためには HLG の表示が必要。
        // それには OpenGL ES 3.0 でセットアップし、10Bit に設定する必要がある。
        if (isEnableTenBitHdr) {
            eglSetupForTenBitHdr()
        } else {
            eglSetupForSdr()
        }
    }
 
    /** 10-bit HDR version. Prepares EGL. We want a GLES 3.0 context and a surface that supports recording. */
    private fun eglSetupForTenBitHdr() {
        mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
        if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) {
            throw RuntimeException("unable to get EGL14 display")
        }
        val version = IntArray(2)
        if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) {
            throw RuntimeException("unable to initialize EGL14")
        }
        // Configure EGL for recording and OpenGL ES 3.0.
        val attribList = intArrayOf(
            EGL14.EGL_RENDERABLE_TYPE, EGLExt.EGL_OPENGL_ES3_BIT_KHR,
            EGL14.EGL_RED_SIZE, 10,
            EGL14.EGL_GREEN_SIZE, 10,
            EGL14.EGL_BLUE_SIZE, 10,
            EGL14.EGL_ALPHA_SIZE, 2,
            EGL14.EGL_SURFACE_TYPE, (EGL14.EGL_WINDOW_BIT or EGL14.EGL_PBUFFER_BIT),
            // EGL_RECORDABLE_ANDROID, 1, // RGBA1010102 だと使えないし多分いらない
            EGL14.EGL_NONE
        )
        val configs = arrayOfNulls<EGLConfig>(1)
        val numConfigs = IntArray(1)
        EGL14.eglChooseConfig(mEGLDisplay, attribList, 0, configs, 0, configs.size, numConfigs, 0)
        checkEglError("eglCreateContext RGBA1010102 ES3")
 
        // Configure context for OpenGL ES 3.0.
        val attrib_list = intArrayOf(
            EGL14.EGL_CONTEXT_CLIENT_VERSION, 3,
            EGL14.EGL_NONE
        )
        mEGLContext = EGL14.eglCreateContext(
            mEGLDisplay, configs[0], EGL14.EGL_NO_CONTEXT,
            attrib_list, 0
        )
        checkEglError("eglCreateContext")
 
        // Create a window surface, and attach it to the Surface we received.
        // EGL_GL_COLORSPACE_BT2020_HLG_EXT を使うことで OpenGL ES で HDR 表示が可能になる(HLG 形式)
        // TODO 10-bit HDR(BT2020 / HLG)に対応していない端末で有効にした場合にエラーになる。とりあえず対応していない場合は何もしない
        val surfaceAttribs = if (isAvailableExtension("EGL_EXT_gl_colorspace_bt2020_hlg")) {
            intArrayOf(
                EGL_GL_COLORSPACE_KHR, EGL_GL_COLORSPACE_BT2020_HLG_EXT,
                EGL14.EGL_NONE
            )
        } else {
            intArrayOf(
                EGL14.EGL_NONE
            )
        }
        mEGLSurface = EGL14.eglCreateWindowSurface(mEGLDisplay, configs[0], outputSurface, surfaceAttribs, 0)
        checkEglError("eglCreateWindowSurface")
    }
 
    /** Prepares EGL. We want a GLES 3.0 context and a surface that supports recording. */
    private fun eglSetupForSdr() {
        mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
        if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) {
            throw RuntimeException("unable to get EGL14 display")
        }
        val version = IntArray(2)
        if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) {
            throw RuntimeException("unable to initialize EGL14")
        }
        // Configure EGL for recording and OpenGL ES 3.0.
        val attribList = intArrayOf(
            EGL14.EGL_RED_SIZE, 8,
            EGL14.EGL_GREEN_SIZE, 8,
            EGL14.EGL_BLUE_SIZE, 8,
            EGL14.EGL_ALPHA_SIZE, 8,
            EGL14.EGL_RENDERABLE_TYPE, EGLExt.EGL_OPENGL_ES3_BIT_KHR,
            EGL_RECORDABLE_ANDROID, 1,
            EGL14.EGL_NONE
        )
        val configs = arrayOfNulls<EGLConfig>(1)
        val numConfigs = IntArray(1)
        EGL14.eglChooseConfig(mEGLDisplay, attribList, 0, configs, 0, configs.size, numConfigs, 0)
        checkEglError("eglCreateContext RGB888 ES3")
 
        // Configure context for OpenGL ES 3.0.
        val attrib_list = intArrayOf(
            EGL14.EGL_CONTEXT_CLIENT_VERSION, 3,
            EGL14.EGL_NONE
        )
        mEGLContext = EGL14.eglCreateContext(
            mEGLDisplay, configs[0], EGL14.EGL_NO_CONTEXT,
            attrib_list, 0
        )
        checkEglError("eglCreateContext")
 
        // Create a window surface, and attach it to the Surface we received.
        val surfaceAttribs = intArrayOf(
            EGL14.EGL_NONE
        )
        mEGLSurface = EGL14.eglCreateWindowSurface(mEGLDisplay, configs[0], outputSurface, surfaceAttribs, 0)
        checkEglError("eglCreateWindowSurface")
    }
 
    /** Discards all resources held by this class, notably the EGL context. */
    fun destroy() {
        if (mEGLDisplay != EGL14.EGL_NO_DISPLAY) {
            EGL14.eglMakeCurrent(mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
            EGL14.eglDestroySurface(mEGLDisplay, mEGLSurface)
            EGL14.eglDestroyContext(mEGLDisplay, mEGLContext)
            EGL14.eglReleaseThread()
            EGL14.eglTerminate(mEGLDisplay)
        }
        mEGLDisplay = EGL14.EGL_NO_DISPLAY
        mEGLContext = EGL14.EGL_NO_CONTEXT
        mEGLSurface = EGL14.EGL_NO_SURFACE
    }
 
    /**
     * Makes our EGL context and surface current.
     */
    fun makeCurrent() {
        EGL14.eglMakeCurrent(mEGLDisplay, mEGLSurface, mEGLSurface, mEGLContext)
        checkEglError("eglMakeCurrent")
    }
 
    /**
     * Calls eglSwapBuffers.  Use this to "publish" the current frame.
     */
    fun swapBuffers(): Boolean {
        val result = EGL14.eglSwapBuffers(mEGLDisplay, mEGLSurface)
        checkEglError("eglSwapBuffers")
        return result
    }
 
    /**
     * Sends the presentation time stamp to EGL.  Time is expressed in nanoseconds.
     */
    fun setPresentationTime(nsecs: Long) {
        EGLExt.eglPresentationTimeANDROID(mEGLDisplay, mEGLSurface, nsecs)
        checkEglError("eglPresentationTimeANDROID")
    }
 
    /**
     * Checks for EGL errors.  Throws an exception if one is found.
     */
    private fun checkEglError(msg: String) {
        val error = EGL14.eglGetError()
        if (error != EGL14.EGL_SUCCESS) {
            throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}")
        }
    }
 
    /**
     * OpenGL ES の拡張機能をサポートしているか。
     * 例えば 10-bit HDR を描画する機能は新し目の Android にしか無いため
     *
     * @param extensionName "EGL_EXT_gl_colorspace_bt2020_hlg" など
     * @return 拡張機能をサポートしている場合は true
     */
    private fun isAvailableExtension(extensionName: String): Boolean {
        val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
        val eglExtensions = EGL14.eglQueryString(display, EGL10.EGL_EXTENSIONS)
        return eglExtensions != null && eglExtensions.contains(extensionName)
    }
 
    companion object {
        private const val EGL_RECORDABLE_ANDROID = 0x3142
 
        // HDR 表示に必要
        private const val EGL_GL_COLORSPACE_KHR = 0x309D
        private const val EGL_GL_COLORSPACE_BT2020_HLG_EXT = 0x3540
    }
}

TextureRendererSurfaceTexture.kt

名前が終わってる。
SurfaceTextureってのがOpenGL ESのテクスチャにカメラ映像とか動画のデコード結果を使えるようにするやつ。テクスチャの名の通り。
が、ちょっとそのままだと使いにくいので、ラップしています。Surface()SurfaceTextureいれるのずっと不思議なAPI設計したよなあって思ってる。

10-bit HDRの対応としては特にはないです。SurfaceTextureSDRでもHDRでも特に設定することなく使えるそうです。

あと、他のプロジェクトから持ってきた都合上、なんか余計なもの(attachGldetachGl)がありますが、このアプリではいらないですね、、、
コピーしてきた元がこうなってた都合上です。詳しくはTextureRendererで。

/**
 * [SurfaceTexture]をラップしたもの、ちょっと使いにくいので
 *
 * @param initTexName [OpenGlRenderer.generateTextureId]
 */
class TextureRendererSurfaceTexture(private val initTexName: Int) {
 
    private val surfaceTexture = SurfaceTexture(initTexName)
    private val _isAvailableFrameFlow = MutableStateFlow(false)
 
    /** [SurfaceTexture.detachFromGLContext]したら false */
    private var isAttach = true
 
    /** [SurfaceTexture]へ映像を渡す[Surface] */
    val surface = Surface(surfaceTexture) // Surface に SurfaceTexture を渡すというよくわからない API 設計
 
    init {
        surfaceTexture.setOnFrameAvailableListener {
            // StateFlow はスレッドセーフが約束されているので
            _isAvailableFrameFlow.value = true
        }
    }
 
    /**
     * [SurfaceTexture.setDefaultBufferSize] を呼び出す
     * Camera2 API の解像度、SurfaceTexture の場合はここで決定する
     */
    fun setTextureSize(width: Int, height: Int) {
        surfaceTexture.setDefaultBufferSize(width, height)
    }
 
    /**
     * GL コンテキストを切り替え、テクスチャ ID の変更を行う。GL スレッドから呼び出すこと。
     * [OpenGlRenderer]を作り直しする場合など。
     *
     * @param texName テクスチャ
     */
    fun attachGl(texName: Int) {
        // 余計に呼び出さないようにする
        if (!isAttach) {
            surfaceTexture.attachToGLContext(texName)
            isAttach = true
        }
    }
 
    /**
     * GL コンテキストから切り離す。GL スレッドから呼び出すこと。
     * [OpenGlRenderer]を作り直しする場合など。
     */
    fun detachGl() {
        if (isAttach) {
            surfaceTexture.detachFromGLContext()
            isAttach = false
        }
    }
 
    /** テクスチャが更新されていれば、[SurfaceTexture.updateTexImage]を呼び出す */
    fun checkAndUpdateTexImage() {
        val isAvailable = _isAvailableFrameFlow.value
        if (isAvailable) {
            _isAvailableFrameFlow.value = false
            surfaceTexture.updateTexImage()
        }
    }
 
    /** [SurfaceTexture.getTransformMatrix]を呼ぶ */
    fun getTransformMatrix(mtx: FloatArray) {
        surfaceTexture.getTransformMatrix(mtx)
    }
 
    /**
     * 破棄する
     * GL スレッドから呼び出すこと(テクスチャを破棄したい)
     * TODO テクスチャを明示的に破棄すべきか
     */
    fun destroy() {
        val textures = intArrayOf(initTexName)
        GLES20.glDeleteTextures(1, textures, 0)
        surface.release()
        surfaceTexture.release()
    }
}

TextureRenderer.kt

これが実際にOpenGL ESフラグメントシェーダーを書いてUniform 変数を探して描画している部分になります。
SurfaceTextureの映像を描画するのと、Canvasで書いたものを描画する機能があります。

Canvasでも描画できるようにしたので、これで上に文字を重ねることが出来ます!やった!

10-bit HDRの対応箇所としては、フラグメントシェーダーかな。それ以外はSDRのときと同じでいいはずです。
有効時はFRAGMENT_SHADER_10BIT_HDRの方を使うようにしています。
が、これまで通りFRAGMENT_SHADERの方を使ってもHDR表示されていそうでよくわかりません。今回はサンプル通りやろうと思います。

詳しい説明はコードの後に、それ以外はコード内のコメント見て頑張って・・・

/**
 * [OpenGlRenderer]から実際の描画処理を持ってきたもの。OpenGL ES のセットアップは[InputSurface]でやる。
 * カメラ映像や動画デコーダーの映像を描画したり、Canvas で書いたものを OpenGL ES へ転写したりする。
 *
 * @param width 映像の幅
 * @param height 映像の高さ
 * @param isEnableTenBitHdr 10-bit HDR を利用する場合は true
 */
class TextureRenderer(
    private val width: Int,
    private val height: Int,
    private val isEnableTenBitHdr: Boolean
) {
 
    private val mTriangleVertices = ByteBuffer.allocateDirect(mTriangleVerticesData.size * FLOAT_SIZE_BYTES).order(ByteOrder.nativeOrder()).asFloatBuffer()
    private val mMVPMatrix = FloatArray(16)
    private val mSTMatrix = FloatArray(16)
    private var mProgram = 0
    private var muMVPMatrixHandle = 0
    private var muSTMatrixHandle = 0
    private var maPositionHandle = 0
    private var maTextureHandle = 0
 
    // Uniform 変数のハンドル
    private var sSurfaceTextureHandle = 0
    private var sCanvasTextureHandle = 0
    private var iDrawModeHandle = 0
 
    // テクスチャ ID
    private var surfaceTextureTextureId = 0
    private var canvasTextureTextureId = 0
 
    // Canvas 描画のため Bitmap
    private val canvasBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    private val canvas = Canvas(canvasBitmap)
 
    init {
        mTriangleVertices.put(mTriangleVerticesData).position(0)
    }
 
    /**
     * Canvas に書く。
     * GL スレッドから呼び出すこと。
     */
    fun drawCanvas(draw: Canvas.() -> Unit) {
        // 前回のを消す
        canvas.drawColor(0, PorterDuff.Mode.CLEAR)
        // 書く
        draw(canvas)
 
        // 多分いる
        GLES20.glActiveTexture(GLES20.GL_TEXTURE1)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, canvasTextureTextureId)
 
        // テクスチャを転送
        // texImage2D、引数違いがいるので注意
        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, canvasBitmap, 0)
        checkGlError("GLUtils.texImage2D")
 
        // glError 1282 の原因とかになる
        GLES20.glUseProgram(mProgram)
        checkGlError("glUseProgram")
 
        // テクスチャの ID をわたす
        GLES20.glUniform1i(sSurfaceTextureHandle, 0) // GLES20.GL_TEXTURE0
        GLES20.glUniform1i(sCanvasTextureHandle, 1) // GLES20.GL_TEXTURE1
        // モード切替
        GLES20.glUniform1i(iDrawModeHandle, FRAGMENT_SHADER_DRAW_MODE_CANVAS_BITMAP)
        checkGlError("glUniform1i sSurfaceTextureHandle sCanvasTextureHandle iDrawModeHandle")
 
        // そのほかの値を渡す
        mTriangleVertices.position(TRIANGLE_VERTICES_DATA_POS_OFFSET)
        GLES20.glVertexAttribPointer(maPositionHandle, 3, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices)
        checkGlError("glVertexAttribPointer maPosition")
        GLES20.glEnableVertexAttribArray(maPositionHandle)
        checkGlError("glEnableVertexAttribArray maPositionHandle")
        mTriangleVertices.position(TRIANGLE_VERTICES_DATA_UV_OFFSET)
        GLES20.glVertexAttribPointer(maTextureHandle, 2, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices)
        checkGlError("glVertexAttribPointer maTextureHandle")
        GLES20.glEnableVertexAttribArray(maTextureHandle)
        checkGlError("glEnableVertexAttribArray maTextureHandle")
 
        // 行列を戻す
        Matrix.setIdentityM(mSTMatrix, 0)
        Matrix.setIdentityM(mMVPMatrix, 0)
 
        // 描画する
        GLES20.glUniformMatrix4fv(muSTMatrixHandle, 1, false, mSTMatrix, 0)
        GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, mMVPMatrix, 0)
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
        checkGlError("glDrawArrays")
        GLES20.glFinish()
    }
 
    /**
     * SurfaceTexture を描画する。
     * GL スレッドから呼び出すこと。
     *
     * @param surfaceTexture 描画する[SurfaceTexture]
     * @param onTransform 位置や回転を適用するための行列を作るための関数
     */
    fun drawSurfaceTexture(
        surfaceTexture: TextureRendererSurfaceTexture,
        onTransform: ((mvpMatrix: FloatArray) -> Unit)? = null
    ) {
        // attachGlContext の前に呼ぶ必要あり。多分
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, surfaceTextureTextureId)
 
        // 映像を OpenGL ES で使う準備
        surfaceTexture.detachGl()
        surfaceTexture.attachGl(surfaceTextureTextureId)
        // 映像が来ていればテクスチャ更新
        surfaceTexture.checkAndUpdateTexImage()
        surfaceTexture.getTransformMatrix(mSTMatrix)
 
        // glError 1282 の原因とかになる
        GLES20.glUseProgram(mProgram)
        checkGlError("glUseProgram")
 
        // テクスチャの ID をわたす
        GLES20.glUniform1i(sSurfaceTextureHandle, 0) // GLES20.GL_TEXTURE0
        GLES20.glUniform1i(sCanvasTextureHandle, 1) // GLES20.GL_TEXTURE1
        // モード切替
        GLES20.glUniform1i(iDrawModeHandle, FRAGMENT_SHADER_DRAW_MODE_SURFACE_TEXTURE)
        checkGlError("glUniform1i sSurfaceTextureHandle sCanvasTextureHandle iDrawModeHandle")
 
        // そのほかの値を渡す
        mTriangleVertices.position(TRIANGLE_VERTICES_DATA_POS_OFFSET)
        GLES20.glVertexAttribPointer(maPositionHandle, 3, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices)
        checkGlError("glVertexAttribPointer maPosition")
        GLES20.glEnableVertexAttribArray(maPositionHandle)
        checkGlError("glEnableVertexAttribArray maPositionHandle")
        mTriangleVertices.position(TRIANGLE_VERTICES_DATA_UV_OFFSET)
        GLES20.glVertexAttribPointer(maTextureHandle, 2, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices)
        checkGlError("glVertexAttribPointer maTextureHandle")
        GLES20.glEnableVertexAttribArray(maTextureHandle)
        checkGlError("glEnableVertexAttribArray maTextureHandle")
 
        // 行列を適用したい場合
        Matrix.setIdentityM(mMVPMatrix, 0)
        if (onTransform != null) {
            onTransform(mMVPMatrix)
        }
 
        // 描画する
        GLES20.glUniformMatrix4fv(muSTMatrixHandle, 1, false, mSTMatrix, 0)
        GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, mMVPMatrix, 0)
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
        checkGlError("glDrawArrays")
        GLES20.glFinish()
    }
 
    /**
     * バーテックスシェーダ、フラグメントシェーダーをコンパイルする。
     * GL スレッドから呼び出すこと。
     */
    fun prepareShader() {
        mProgram = createProgram(
            vertexSource = VERTEX_SHADER,
            // TODO HLG だろうと samplerExternalOES から HDR のフレームが取れてそう
            fragmentSource = if (isEnableTenBitHdr) FRAGMENT_SHADER_10BIT_HDR else FRAGMENT_SHADER
        )
        if (mProgram == 0) {
            throw RuntimeException("failed creating program")
        }
        maPositionHandle = GLES20.glGetAttribLocation(mProgram, "aPosition")
        checkGlError("glGetAttribLocation aPosition")
        if (maPositionHandle == -1) {
            throw RuntimeException("Could not get attrib location for aPosition")
        }
        maTextureHandle = GLES20.glGetAttribLocation(mProgram, "aTextureCoord")
        checkGlError("glGetAttribLocation aTextureCoord")
        if (maTextureHandle == -1) {
            throw RuntimeException("Could not get attrib location for aTextureCoord")
        }
        muMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix")
        checkGlError("glGetUniformLocation uMVPMatrix")
        if (muMVPMatrixHandle == -1) {
            throw RuntimeException("Could not get attrib location for uMVPMatrix")
        }
        muSTMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uSTMatrix")
        checkGlError("glGetUniformLocation uSTMatrix")
        if (muSTMatrixHandle == -1) {
            throw RuntimeException("Could not get attrib location for uSTMatrix")
        }
        sSurfaceTextureHandle = GLES20.glGetUniformLocation(mProgram, "sSurfaceTexture")
        checkGlError("glGetUniformLocation sSurfaceTexture")
        if (sSurfaceTextureHandle == -1) {
            throw RuntimeException("Could not get attrib location for sSurfaceTexture")
        }
        sCanvasTextureHandle = GLES20.glGetUniformLocation(mProgram, "sCanvasTexture")
        checkGlError("glGetUniformLocation sCanvasTexture")
        if (sCanvasTextureHandle == -1) {
            throw RuntimeException("Could not get attrib location for sCanvasTexture")
        }
        iDrawModeHandle = GLES20.glGetUniformLocation(mProgram, "iDrawMode")
        checkGlError("glGetUniformLocation iDrawMode")
        if (iDrawModeHandle == -1) {
            throw RuntimeException("Could not get attrib location for iDrawMode")
        }
 
        // テクスチャ ID を払い出してもらう
        // SurfaceTexture / Canvas Bitmap 用
        val textures = IntArray(2)
        GLES20.glGenTextures(2, textures, 0)
 
        surfaceTextureTextureId = textures[0]
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, surfaceTextureTextureId)
        checkGlError("glBindTexture cameraTextureId")
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST.toFloat())
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
        checkGlError("glTexParameter")
 
        canvasTextureTextureId = textures[1]
        GLES20.glActiveTexture(GLES20.GL_TEXTURE1)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, canvasTextureTextureId)
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
        checkGlError("glTexParameter")
 
        // アルファブレンディング
        // Canvas で書いた際に、透明な部分は透明になるように
        GLES20.glEnable(GLES20.GL_BLEND)
        GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
        checkGlError("glEnable GLES20.GL_BLEND")
    }
 
    /** テクスチャ ID を払い出す。[SurfaceTexture]を作成するのに必要なので。 */
    fun generateTextureId(): Int {
        val textures = IntArray(1)
        GLES20.glGenTextures(1, textures, 0)
        return textures.first()
    }
 
    /**
     * 描画前に呼び出す。
     * GL スレッドから呼び出すこと。
     */
    fun prepareDraw() {
        // クリア?多分必要
        GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
 
        // drawCanvas / drawSurfaceTexture どっちも呼び出さない場合 glUseProgram 誰もしないので
        GLES20.glUseProgram(mProgram)
        checkGlError("glUseProgram")
    }
 
    private fun checkGlError(op: String) {
        val error = GLES20.glGetError()
        if (error != GLES20.GL_NO_ERROR) {
            throw RuntimeException("$op: glError $error")
        }
    }
 
    /**
     * GLSL(フラグメントシェーダー・バーテックスシェーダー)をコンパイルして、OpenGL ES とリンクする
     *
     * @throws RuntimeException 構文エラーの場合に投げる
     * @throws RuntimeException それ以外
     * @return 0 以外で成功
     */
    private fun createProgram(vertexSource: String, fragmentSource: String): Int {
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
        if (vertexShader == 0) {
            return 0
        }
        val pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
        if (pixelShader == 0) {
            return 0
        }
        var program = GLES20.glCreateProgram()
        checkGlError("glCreateProgram")
        if (program == 0) {
            return 0
        }
        GLES20.glAttachShader(program, vertexShader)
        checkGlError("glAttachShader")
        GLES20.glAttachShader(program, pixelShader)
        checkGlError("glAttachShader")
        GLES20.glLinkProgram(program)
        val linkStatus = IntArray(1)
        GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
        if (linkStatus[0] != GLES20.GL_TRUE) {
            GLES20.glDeleteProgram(program)
            program = 0
        }
        return program
    }
 
    /**
     * GLSL(フラグメントシェーダー・バーテックスシェーダー)のコンパイルをする
     *
     * @throws RuntimeException 構文エラーの場合に投げる
     * @throws RuntimeException それ以外
     * @return 0 以外で成功
     */
    private fun loadShader(shaderType: Int, source: String): Int {
        val shader = GLES20.glCreateShader(shaderType)
        checkGlError("glCreateShader type=$shaderType")
        GLES20.glShaderSource(shader, source)
        GLES20.glCompileShader(shader)
        val compiled = IntArray(1)
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
        if (compiled[0] == 0) {
            // 失敗したら例外を投げる。その際に構文エラーのメッセージを取得する
            val syntaxErrorMessage = GLES20.glGetShaderInfoLog(shader)
            GLES20.glDeleteShader(shader)
            throw RuntimeException(syntaxErrorMessage)
            // ここで return 0 しても例外を投げるので意味がない
            // shader = 0
        }
        return shader
    }
 
    companion object {
        private const val FLOAT_SIZE_BYTES = 4
        private const val TRIANGLE_VERTICES_DATA_STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
        private const val TRIANGLE_VERTICES_DATA_POS_OFFSET = 0
        private const val TRIANGLE_VERTICES_DATA_UV_OFFSET = 3
 
        private val mTriangleVerticesData = floatArrayOf(
            -1.0f, -1.0f, 0f, 0f, 0f,
            1.0f, -1.0f, 0f, 1f, 0f,
            -1.0f, 1.0f, 0f, 0f, 1f,
            1.0f, 1.0f, 0f, 1f, 1f
        )
 
        private const val VERTEX_SHADER = """#version 300 es
in vec4 aPosition;
in vec4 aTextureCoord;
 
uniform mat4 uMVPMatrix;
uniform mat4 uSTMatrix;
 
out vec2 vTextureCoord;
 
void main() {
  gl_Position = uMVPMatrix * aPosition;
  vTextureCoord = (uSTMatrix * aTextureCoord).xy;
}
"""
 
        // iDrawMode に渡す定数
        private const val FRAGMENT_SHADER_DRAW_MODE_SURFACE_TEXTURE = 1
        private const val FRAGMENT_SHADER_DRAW_MODE_CANVAS_BITMAP = 2
 
        /** 10-bit HDR の時に使うフラグメントシェーダー */
        private const val FRAGMENT_SHADER_10BIT_HDR = """#version 300 es
#extension GL_EXT_YUV_target : require
precision mediump float;
 
in vec2 vTextureCoord;
uniform sampler2D sCanvasTexture;
uniform __samplerExternal2DY2YEXT sSurfaceTexture;
 
// 何を描画するか
// 1 SurfaceTexture(カメラや動画のデコード映像)
// 2 Bitmap(テキストや画像を描画した Canvas)
uniform int iDrawMode;
 
// 出力色
out vec4 FragColor;
 
// https://github.com/android/camera-samples/blob/a07d5f1667b1c022dac2538d1f553df20016d89c/Camera2Video/app/src/main/java/com/example/android/camera2/video/HardwarePipeline.kt#L107
vec3 yuvToRgb(vec3 yuv) {
  const vec3 yuvOffset = vec3(0.0625, 0.5, 0.5);
  const mat3 yuvToRgbColorTransform = mat3(
    1.1689f, 1.1689f, 1.1689f,
    0.0000f, -0.1881f, 2.1502f,
    1.6853f, -0.6530f, 0.0000f
  );
  return clamp(yuvToRgbColorTransform * (yuv - yuvOffset), 0.0, 1.0);
}
 
void main() {   
  vec4 outColor = vec4(0.0, 0.0, 0.0, 1.0);
 
  if (iDrawMode == 1) {
    outColor.rgb = yuvToRgb(texture(sSurfaceTexture, vTextureCoord).rgb);
  } else if (iDrawMode == 2) {
    // テクスチャ座標なので Y を反転
    outColor = texture(sCanvasTexture, vec2(vTextureCoord.x, 1.0 - vTextureCoord.y));
  }
 
  FragColor = outColor;
}
"""
 
        /** SDR のときに使うフラグメントシェーダー */
        private const val FRAGMENT_SHADER = """#version 300 es
#extension GL_OES_EGL_image_external_essl3 : require
precision mediump float;
 
in vec2 vTextureCoord;
uniform sampler2D sCanvasTexture;
uniform samplerExternalOES sSurfaceTexture;
 
// 何を描画するか
// 1 SurfaceTexture(カメラや動画のデコード映像)
// 2 Bitmap(テキストや画像を描画した Canvas)
uniform int iDrawMode;
 
// 出力色
out vec4 FragColor;
 
void main() {   
  vec4 outColor = vec4(0.0, 0.0, 0.0, 1.0);
 
  if (iDrawMode == 1) {
    outColor = texture(sSurfaceTexture, vTextureCoord);
  } else if (iDrawMode == 2) {
    // テクスチャ座標なので Y を反転
    outColor = texture(sCanvasTexture, vec2(vTextureCoord.x, 1.0 - vTextureCoord.y));
  }
 
  FragColor = outColor;
}
"""
    }
}

10-bit HDR のフラグメントシェーダー

フラグメントシェーダーだけ抜粋しました。
GLSL 3.0を使うぞというバージョンディレクティブと、GL_EXT_YUV_targetを使うそうです。

SurfaceTextureのテクスチャの部分、SDRの時はsamplerExternalOESを使っていましたが、HDRだと__samplerExternal2DY2YEXTとかいうやつを使うようです。よくわからない。
texture()で読み出したあと、yuvToRgb()に入れていますが、これもよく分からず、Camera2 APIのサンプルコードからまんま借りてきたものになります。何この定数。。。

iDrawModeUniform 変数にいれる数字によってSurfaceTexture / Canvas(Bitmap)どっちを描画するかが決められるようにしました。
映像の上に文字を重ねる時は、SurfaceTextureを描画した後にCanvasで描画したBitmapを描画させます。クリアされるまでは残るのでこれでいいはず。
あと、これも後でまた言及しますがそのままSDR画像を重ねると普通に眩しいです。

#version 300 es
#extension GL_EXT_YUV_target : require
precision mediump float;
 
in vec2 vTextureCoord;
uniform sampler2D sCanvasTexture;
uniform __samplerExternal2DY2YEXT sSurfaceTexture;
 
// 何を描画するか
// 1 SurfaceTexture(カメラや動画のデコード映像)
// 2 Bitmap(テキストや画像を描画した Canvas)
uniform int iDrawMode;
 
// 出力色
out vec4 FragColor;
 
// https://github.com/android/camera-samples/blob/a07d5f1667b1c022dac2538d1f553df20016d89c/Camera2Video/app/src/main/java/com/example/android/camera2/video/HardwarePipeline.kt#L107
vec3 yuvToRgb(vec3 yuv) {
  const vec3 yuvOffset = vec3(0.0625, 0.5, 0.5);
  const mat3 yuvToRgbColorTransform = mat3(
    1.1689f, 1.1689f, 1.1689f,
    0.0000f, -0.1881f, 2.1502f,
    1.6853f, -0.6530f, 0.0000f
  );
  return clamp(yuvToRgbColorTransform * (yuv - yuvOffset), 0.0, 1.0);
}
 
void main() {   
  vec4 outColor = vec4(0.0, 0.0, 0.0, 1.0);
 
  if (iDrawMode == 1) {
    outColor.rgb = yuvToRgb(texture(sSurfaceTexture, vTextureCoord).rgb);
  } else if (iDrawMode == 2) {
    // テクスチャ座標なので Y を反転
    outColor = texture(sCanvasTexture, vec2(vTextureCoord.x, 1.0 - vTextureCoord.y));
  }
 
  FragColor = outColor;
}

SurfaceTexture のテクスチャ ID の話

そういえば、SurfaceTextureの作成にはテクスチャ IDが必要なのですが、private var surfaceTextureTextureIdといった感じで隠されちゃっています。
SurfaceTexture()の作成のために公開するべきなのではという話ですが、これは描画時にSurfaceTexture()を切り替えできるようにするためです。
が、これから作るアプリでは SurfaceTexture は一つで事足りるため(外カメラの映像一つだけなので)明らかにオーバースペックです。

コピー元のコードではSurfaceTextureを複数持って、描画のたびにテクスチャIDだけ切り替えることで複数の映像を扱うアプリを作ってましたが、今回は使わないのでどうでもいいです。
https://github.com/takusan23/KomaDroid/blob/0bc67b6ad94774d696e5e472e0e5b9001255478a/app/src/main/java/io/github/takusan23/komadroid/KomaDroidCameraManager.kt#L723-L747

OpenGlRenderer.kt

さて、今まで作ったInputSurfaceTextureRendererSurfaceTextureTextureRendererをつなぎ合わせて使えるようにするクラスを作ります。
ばらばらで使いにくいってのもそうなんですが、OpenGL ESはスレッドを意識する必要があって、
makeCurrent()を呼び出したスレッドからしか描画(というかOpenGL ESの関数呼び出し)が出来ないのでスレッドを気にせず使えるようにしたいですよね。

というわけでスレッドといえばKotlin Coroutinesですね、新しく作ったスレッドでコルーチンが処理できるようにnewSingleThreadContext()を使います。
これとwithContext()すれば、このクラス利用側はスレッドの意識をしなくてもすみます。

それ以外はTextureRendererInputSurfaceで作った処理を呼び出している感じで特にはないです。。。

/**
 * OpenGL ES を利用して[TextureRendererSurfaceTexture]や Canvas の中身を描画するクラス。
 * カメラ映像に Canvas で書いた文字を重ねたり出来ます。
 *
 * @param outputSurface 描画した内容の出力先 Surface。SurfaceView、MediaRecorder、MediaCodec など
 * @param width
 * @param height 高さ
 * @param isEnableTenBitHdr 10-bit HDR を有効にする場合は true
 */
class OpenGlRenderer(
    outputSurface: Surface,
    width: Int,
    height: Int,
    isEnableTenBitHdr: Boolean
) {
 
    /** OpenGL 描画用スレッドの Kotlin Coroutine Dispatcher */
    @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
    private val openGlRelatedThreadDispatcher = newSingleThreadContext("openGlRelatedThreadDispatcher")
 
    private val inputSurface = InputSurface(outputSurface, isEnableTenBitHdr)
    private val textureRenderer = TextureRenderer(width, height, isEnableTenBitHdr)
 
    /** OpenGL ES の用意をし、フラグメントシェーダー等をコンパイルする */
    suspend fun prepare() {
        withContext(openGlRelatedThreadDispatcher) {
            inputSurface.makeCurrent()
            textureRenderer.prepareShader()
        }
    }
 
    /** テクスチャ ID を払い出す。SurfaceTexture 作成に使うので */
    suspend fun generateTextureId(): Int {
        return withContext(openGlRelatedThreadDispatcher) {
            textureRenderer.generateTextureId()
        }
    }
 
    /** 描画する */
    suspend fun drawLoop(drawTexture: suspend TextureRenderer.() -> DrawContinuesData) {
        withContext(openGlRelatedThreadDispatcher) {
            while (true) {
                yield()
 
                // 描画する
                textureRenderer.prepareDraw()
                val continuesData = drawTexture(textureRenderer)
 
                // presentationTime が多分必要。swapBuffers して Surface に流す
                inputSurface.setPresentationTime(continuesData.currentTimeNanoSeconds)
                inputSurface.swapBuffers()
 
                // 続行するか
                if (!continuesData.isAvailableNext) break
            }
        }
    }
 
    /** 破棄する */
    suspend fun destroy() {
        // try-finally で呼び出されるため NonCancellable 必須
        withContext(openGlRelatedThreadDispatcher + NonCancellable) {
            inputSurface.destroy()
        }
        openGlRelatedThreadDispatcher.close()
    }
 
    /**
     * 描画を続行するかのデータ
     *
     * @param isAvailableNext 次も描画できる場合は true
     * @param currentTimeNanoSeconds プレビューのときは使われてない(?)、MediaRecorder の場合は[System.nanoTime]、MediaCodec の場合は今のフレームの時間を入れてください。
     */
    data class DrawContinuesData(
        var isAvailableNext: Boolean,
        var currentTimeNanoSeconds: Long = System.nanoTime()
    )
}

HDR の映像を OpenGL で見る

先程のカメラアプリに組み込んでみようと思います。
といっても、CameraControllerprepare()を少し変えるだけなんですが。
(OpenGLのセットアップ、映像出力先をSurfaceTexture、描画のメインループを追加)

prepare()関数の書き換えたあと全てと、追加したdrawFrame()拡張関数がこちらです。
せっかくなのでCanvasで文字を書いてカメラ映像に重ねてみます。

/** 初回時、録画終了後に呼び出す */
fun prepare() {
    scope.launch {
        currentJob?.cancelAndJoin()
 
        // MediaRecorder を作る
        initMediaRecorder()
        // MediaRecorder 用 OpenGlRenderer と SurfaceTexture を作る
        val (recordOpenGlRenderer, recordSurfaceTexture) = createOpenGlRendererAndSurfaceTexture(mediaRecorder!!.surface)
 
        currentJob = launch {
            // SurfaceView の生存に合わせる
            // アプリ切り替えで SurfaceView は再生成されるので、常に監視する必要がある
            _surfaceFlow.collectLatest { previewSurface ->
                previewSurface ?: return@collectLatest
 
                // プレビュー 用 OpenGlRenderer と SurfaceTexture を作る
                val (previewOpenGlRenderer, previewSurfaceTexture) = createOpenGlRendererAndSurfaceTexture(previewSurface)
 
                // 外カメラが開かれるのを待つ
                val cameraDevice = backCameraDeviceFlow.filterNotNull().first()
 
                // カメラ出力先。それぞれの SurfaceTexture
                val outputSurfaceList = listOfNotNull(previewSurfaceTexture.surface, recordSurfaceTexture.surface)
 
                // CaptureRequest をつくる
                val captureRequest = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
                    // HDR はここじゃない
                    outputSurfaceList.forEach { surface -> addTarget(surface) }
                    // FPS とかズームとか設定するなら
                    set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(VIDEO_FPS, VIDEO_FPS))
                }
 
                // CaptureSession をつくる
                val captureSession = cameraDevice.awaitCameraSessionConfiguration(outputSurfaceList = outputSurfaceList) ?: return@collectLatest
 
                // カメラ映像を流し始める
                captureSession.setRepeatingRequest(captureRequest.build(), null, null)
 
                coroutineScope {
                    // プレビューを描画する
                    launch {
                        try {
                            // メインループ
                            val drawContinuesData = OpenGlRenderer.DrawContinuesData(true, 0)
                            previewOpenGlRenderer.drawLoop {
                                drawFrame(previewSurfaceTexture)
                                drawContinuesData
                            }
                        } finally {
                            // コルーチンキャンセル時
                            previewSurfaceTexture.destroy()
                            previewOpenGlRenderer.destroy()
                        }
                    }
                    // MediaRecorder のを描画する
                    launch {
                        try {
                            // メインループ
                            val drawContinuesData = OpenGlRenderer.DrawContinuesData(true, 0)
                            recordOpenGlRenderer.drawLoop {
                                drawFrame(recordSurfaceTexture)
                                // MediaRecorder は setPresentationTime の指定が必要(そう)
                                drawContinuesData.currentTimeNanoSeconds = System.nanoTime()
                                drawContinuesData
                            }
                        } finally {
                            // コルーチンキャンセル時
                            recordSurfaceTexture.destroy()
                            recordOpenGlRenderer.destroy()
                        }
                    }
                }
            }
        }
    }
}
 
private val textPaint = Paint().apply {
    color = Color.WHITE
    textSize = 50f
    isAntiAlias = true
}
 
/** プレビューと録画の描画を共通化するための関数 */
private fun TextureRenderer.drawFrame(surfaceTexture: TextureRendererSurfaceTexture) {
    drawSurfaceTexture(surfaceTexture) { mvpMatrix ->
        // 回転する
        // TODO 常に横画面で使う想定のため、条件分岐がありません。縦持ちでも使いたい場合は if (isLandscape) { } をやってください
        Matrix.rotateM(mvpMatrix, 0, 90f, 0f, 0f, 1f)
    }
    drawCanvas {
        drawText("撮影:${Build.MODEL} コーデック:HEVC ダイナミックレンジ:HDR(BT.2020, HLG)", 100f, 1000f, textPaint)
    }
}
 
/** [OpenGlRenderer]と[TextureRendererSurfaceTexture]を作る */
@SuppressLint("Recycle")
private suspend fun createOpenGlRendererAndSurfaceTexture(surface: Surface): Pair<OpenGlRenderer, TextureRendererSurfaceTexture> {
    // OpenGL ES で描画する OpenGlRenderer を作る
    val openGlRenderer = OpenGlRenderer(surface, VIDEO_WIDTH, VIDEO_HEIGHT, isSupportedTenBitHdr())
    openGlRenderer.prepare()
    // カメラ映像を OpenGL ES へ渡す SurfaceTexture を作る
    val surfaceTexture = TextureRendererSurfaceTexture(openGlRenderer.generateTextureId())
    // カメラ映像の解像度を設定
    surfaceTexture.setTextureSize(VIDEO_WIDTH, VIDEO_HEIGHT)
    return openGlRenderer to surfaceTexture
}

動かしてみる

どうでしょうか?プレビューもHDRになりましたか?文字も出ているはず。。
(機種名によっては長くて見切れちゃってるかも。drawText()の座標を直してみてください)

ドコモの5G SA見てきました。このアンテナがそうなのかは知らない(えっ)

相変わらずスマホでスマホ取ってる意味不明な人

Imgur

質問 文字がやけに眩しい

HDRに無理やりSDRの画像を入れたからですかね、何かしら明るさを調整してあげたほうがいいかもしれません。てかそうです。

Media3 Transformerすでに対策済みらしく、若干暗く描画する対応をしているそうです。
https://medium.com/google-exoplayer/media3-1-4-0-whats-new-ba1c9c17ee1a

OpenGL で HDR と文字入れソースコード

ブランチはopenglです。git checkoutしてください。

https://github.com/takusan23/AndroidCamera2ApiHdrVideo

10 ビット HDR 動画編集

標準カメラアプリだと対応してるけど、Camera2 API経由だとHDR対応してないよ~~~って人向け。
ちなみに動画編集やるってなった場合でも、さっき作ったOpenGL ES周りは転用できるので、あとはMediaCodec でデコーダー、エンコーダーを作ればほぼ終わり、、、、
いやそれが一番たいへん(今回はコピペできるよう貼っておきます)。

MediaCodec と OpenGL で HDR 動画編集をするためには

これもSDRのときと同じようにMediaCodec + OpenGLでやれば良いです。
OpenGLの設定はHDRカメラのそれと同じものを使えばいいです。動画編集だとカメラ映像ではなく、動画ファイルから動画トラックを取り出しデコーダーに入れて出てきた映像を使えばいいのですが、これも特に変えることなく動くと思います。

カメラの動画撮影と違い、こちらは確実にMediaRecorderではなくMediaCodecの方を使わないといけないので若干面倒ですね(フレームごと取り出してやらないといけないので...)。
(でもコピペで動きます)

Imgur

付録 エンコーダーが 10 ビット HDR に対応しているか見る

わかりにくいですが、対応しているプロファイルを見ると良さそうです。

HEVC以外はmedia3ライブラリを参考にしました、ありざいす
https://github.com/androidx/media/blob/76088cd6af7f263aba238b7a48d64bd4f060cb8b/libraries/transformer/src/main/java/androidx/media3/transformer/EncoderUtil.java#L130

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
 
 
    println("HEVC = ${getSupportedHdrEncodingByHevc()}")
    println("VP9 = ${getSupportedHdrEncodingByVp9()}")
    println("AV1 = ${getSupportedHdrEncodingByAv1()}")
}
 
/** HEVC エンコーダーが対応している HDR の種類を返す */
private fun getSupportedHdrEncodingByHevc(): SupportHdrEncoding {
    val hevcEncoderList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
        .codecInfos
        .filter { !it.isAlias }
        .filter { it.isEncoder && MediaFormat.MIMETYPE_VIDEO_HEVC in it.supportedTypes }
    // ハードウェアエンコーダーで、ない場合はソフトウェア
    val hevcEncoder = hevcEncoderList.firstOrNull { it.isHardwareAccelerated } ?: hevcEncoderList.first()
    // 対応しているか見る
    val profileList = hevcEncoder.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_HEVC).profileLevels.map { it.profile }
    return SupportHdrEncoding(
        hlg = MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10 in profileList,
        hdr10 = MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10 in profileList,
        hdr10plus = MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10Plus in profileList
    )
}
 
/** AV1 エンコーダーが対応している HDR の種類を返す */
private fun getSupportedHdrEncodingByAv1(): SupportHdrEncoding {
    val hevcEncoderList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
        .codecInfos
        .filter { !it.isAlias }
        .filter { it.isEncoder && MediaFormat.MIMETYPE_VIDEO_AV1 in it.supportedTypes }
    // ハードウェアエンコーダーで、ない場合はソフトウェア
    val hevcEncoder = hevcEncoderList.firstOrNull { it.isHardwareAccelerated } ?: hevcEncoderList.first()
    // 対応しているか見る
    val profileList = hevcEncoder.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AV1).profileLevels.map { it.profile }
    return SupportHdrEncoding(
        hlg = MediaCodecInfo.CodecProfileLevel.AV1ProfileMain10 in profileList,
        hdr10 = MediaCodecInfo.CodecProfileLevel.AV1ProfileMain10HDR10 in profileList,
        hdr10plus = MediaCodecInfo.CodecProfileLevel.AV1ProfileMain10HDR10Plus in profileList
    )
}
 
/** VP9 エンコーダーが対応している HDR の種類を返す */
private fun getSupportedHdrEncodingByVp9(): SupportHdrEncoding {
    val hevcEncoderList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
        .codecInfos
        .filter { !it.isAlias }
        .filter { it.isEncoder && MediaFormat.MIMETYPE_VIDEO_VP9 in it.supportedTypes }
    // ハードウェアエンコーダーで、ない場合はソフトウェア
    val hevcEncoder = hevcEncoderList.firstOrNull { it.isHardwareAccelerated } ?: hevcEncoderList.first()
    // 対応しているか見る
    val profileList = hevcEncoder.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_VP9).profileLevels.map { it.profile }
    // HLG / PQ 兼用らしい
    val isHlgOrPqAvailable = MediaCodecInfo.CodecProfileLevel.VP9Profile2HDR in profileList || MediaCodecInfo.CodecProfileLevel.VP9Profile3HDR in profileList
    val isHdr10plus = MediaCodecInfo.CodecProfileLevel.VP9Profile2HDR10Plus in profileList || MediaCodecInfo.CodecProfileLevel.VP9Profile3HDR10Plus in profileList
    return SupportHdrEncoding(
        hlg = isHlgOrPqAvailable,
        hdr10 = isHlgOrPqAvailable,
        hdr10plus = isHdr10plus
    )
}
 
data class SupportHdrEncoding(
    val hlg: Boolean,
    val hdr10: Boolean,
    val hdr10plus: Boolean
)

HDR エンコードのエンコーダー対応状況を見てみる

HLGHDR10HDR10+それぞれで見てみます。

Pixel 8 Pro
みたところHEVCVP9AV1すべてが対応している?

HEVC = SupportHdrEncoding(hlg=true, hdr10=true, hdr10plus=true)
VP9 = SupportHdrEncoding(hlg=true, hdr10=true, hdr10plus=true)
AV1 = SupportHdrEncoding(hlg=true, hdr10=true, hdr10plus=true)

Xperia 1 V
HEVCがすべて行けそう、VP9は全部だめ、AV1HLGのみ。なおAV1はソフトウェアエンコーダーなので遅い。

HEVC = SupportHdrEncoding(hlg=true, hdr10=true, hdr10plus=true)
VP9 = SupportHdrEncoding(hlg=false, hdr10=false, hdr10plus=false)
AV1 = SupportHdrEncoding(hlg=true, hdr10=false, hdr10plus=false)

ただ、冒頭の通りで、私がミスっているのか VP9 で HDR の映像をエンコードすることは出来ませんでした。。。
ここで対応してるって返しているの、何?

HDR 動画撮影で使った OpenGL 周りのコードを持ってくる

この4つです。

Imgur

MediaCodec でデコーダーを作る

動画ファイルから映像トラックを取り出し渡して1枚1枚フレームを取り出して、Surfaceに描画するやつを作ります。コピペで動きます。
デコーダーを用意する関数、次の時間のフレームを取り出す関数、リソース開放する関数しか無いです。

次の時間のフレームを取得する関数しか無いのは、動画編集で一方向(0→動画時間)へしか動画を再生しないためです。
真面目に動画のプレイヤーをMediaCodecで作る場合は、これでは時間が減る方向にシークできないので、前の時間にシークできる関数も作る必要があります。が、別に動画プレイヤーを作りたいわけじゃないので、増える方向のみでいいわけです。

/** MediaCodec を使って動画をデコードする */
class VideoDecoder {
 
    private var decodeMediaCodec: MediaCodec? = null
    private var mediaExtractor: MediaExtractor? = null
 
    /**
     * デコードの準備をする
     *
     * @param context [Context]
     * @param uri PhotoPicker 等で選んだやつ
     * @param outputSurface デコードした映像の出力先
     */
    fun prepare(context: Context, uri: Uri, outputSurface: Surface) {
        val mediaExtractor = MediaExtractor().apply {
            context.contentResolver.openFileDescriptor(uri, "r")?.use {
                setDataSource(it.fileDescriptor)
            }
        }
        this.mediaExtractor = mediaExtractor
 
        // 動画トラックを探す
        val (trackIndex, mediaFormat) = (0 until mediaExtractor.trackCount)
            .map { mediaExtractor.getTrackFormat(it) }
            .withIndex()
            .first { it.value.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true }
        mediaExtractor.selectTrack(trackIndex)
 
        // MediaCodec を作る
        val codecName = mediaFormat.getString(MediaFormat.KEY_MIME)!!
        decodeMediaCodec = MediaCodec.createDecoderByType(codecName).apply {
            configure(mediaFormat, outputSurface, null, 0)
        }
        decodeMediaCodec?.start()
    }
 
    /** 破棄する */
    fun destroy() {
        decodeMediaCodec?.stop()
        decodeMediaCodec?.release()
        mediaExtractor?.release()
    }
 
    /**
     * 次の時間のフレームを取得する
     *
     * @param seekToMs 欲しいフレームの時間
     * @return 次のフレームがない場合は null。そうじゃない場合は動画フレームの時間
     */
    suspend fun seekNextFrame(seekToMs: Long): Long? {
        val decodeMediaCodec = decodeMediaCodec!!
        val mediaExtractor = mediaExtractor!!
 
        // advance() で false を返したことがある場合、もうデータがない。getSampleTime も -1 になる。
        if (mediaExtractor.sampleTime == -1L) {
            return null
        }
 
        var isRunning = true
        val bufferInfo = MediaCodec.BufferInfo()
        var returnValue: Long? = null
        while (isRunning) {
 
            // キャンセル時
            yield()
 
            // コンテナフォーマットからサンプルを取り出し、デコーダーに渡す
            // シークしないことで、連続してフレームを取得する場合にキーフレームまで戻る必要がなくなり、早くなる
            val inputBufferIndex = decodeMediaCodec.dequeueInputBuffer(TIMEOUT_US)
            if (0 <= inputBufferIndex) {
                // デコーダーへ流す
                val inputBuffer = decodeMediaCodec.getInputBuffer(inputBufferIndex)!!
                val size = mediaExtractor.readSampleData(inputBuffer, 0)
                decodeMediaCodec.queueInputBuffer(inputBufferIndex, 0, size, mediaExtractor.sampleTime, 0)
            }
 
            // デコーダーから映像を受け取る部分
            var isDecoderOutputAvailable = true
            while (isDecoderOutputAvailable) {
 
                // キャンセル時
                yield()
 
                // デコード結果が来ているか
                val outputBufferIndex = decodeMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
                when {
                    outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
                        // リトライが必要
                        isDecoderOutputAvailable = false
                    }
 
                    0 <= outputBufferIndex -> {
                        // ImageReader ( Surface ) に描画する
                        val doRender = bufferInfo.size != 0
                        decodeMediaCodec.releaseOutputBuffer(outputBufferIndex, doRender)
                        if (doRender) {
                            // 欲しいフレームの時間に到達した場合、ループを抜ける
                            val presentationTimeMs = bufferInfo.presentationTimeUs / 1000
                            if (seekToMs <= presentationTimeMs) {
                                isRunning = false
                                isDecoderOutputAvailable = false
                                returnValue = presentationTimeMs
                            }
                        }
                    }
                }
            }
 
            // 次に進める。デコーダーにデータを入れた事を確認してから。
            // advance() が false の場合はもうデータがないので、break
            if (0 <= inputBufferIndex) {
                val isEndOfFile = !mediaExtractor.advance()
                if (isEndOfFile) {
                    // return で false(フレームが取得できない旨)を返す
                    returnValue = null
                    break
                }
            }
        }
 
        return returnValue
    }
 
    companion object {
        /** MediaCodec タイムアウト */
        private const val TIMEOUT_US = 0L
    }
}

MediaCodec でエンコーダーを作る

デコードの逆。Surfaceで入力されてきた映像をエンコーダーに渡してエンコードして動画ファイルに書き込む。
HDRの情報(色空間、ガンマカーブ、プロファイル)が渡せるようになってます。MediaRecorderの時はなんか勝手にHLGが使われたのですが、MediaCodecの場合は明示的に渡さないとだめだった気がします。多分。

/** MediaCodec を使って動画をエンコードする */
class VideoEncoder {
    private var encodeMediaCodec: MediaCodec? = null
    private var mediaMuxer: MediaMuxer? = null
 
    /**
     * MediaCodec エンコーダーの準備をする
     *
     * @param videoFilePath 保存先
     * @param codecName コーデック名
     * @param bitRate ビットレート
     * @param frameRate フレームレート
     * @param keyframeInterval キーフレームの間隔
     * @param outputVideoWidth 動画の高さ
     * @param outputVideoHeight 動画の幅
     * @param tenBitHdrParametersOrNullSdr SDR 動画の場合は null。HDR でエンコードする場合は[TenBitHdrParameters]を埋めてください
     */
    fun prepare(
        videoFilePath: String,
        outputVideoWidth: Int = 1280,
        outputVideoHeight: Int = 720,
        frameRate: Int = 30,
        bitRate: Int = 1_000_000,
        keyframeInterval: Int = 1,
        codecName: String = MediaFormat.MIMETYPE_VIDEO_HEVC,
        tenBitHdrParametersOrNullSdr: TenBitHdrParameters? = null
    ) {
        // エンコーダーにセットするMediaFormat
        // コーデックが指定されていればそっちを使う
        val videoMediaFormat = MediaFormat.createVideoFormat(codecName, outputVideoWidth, outputVideoHeight).apply {
            setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
            setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
            setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, keyframeInterval)
            setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
 
            // 10-bit HDR のパラメーターをセット
            if (tenBitHdrParametersOrNullSdr != null) {
                setInteger(MediaFormat.KEY_PROFILE, tenBitHdrParametersOrNullSdr.codecProfile)
                setInteger(MediaFormat.KEY_COLOR_STANDARD, tenBitHdrParametersOrNullSdr.colorStandard)
                setInteger(MediaFormat.KEY_COLOR_TRANSFER, tenBitHdrParametersOrNullSdr.colorTransfer)
                setFeatureEnabled(MediaCodecInfo.CodecCapabilities.FEATURE_HdrEditing, true)
            }
        }
 
        // マルチプレクサ
        mediaMuxer = MediaMuxer(videoFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
 
        encodeMediaCodec = MediaCodec.createEncoderByType(codecName).apply {
            configure(videoMediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
        }
    }
 
    /** エンコーダーの入力になる[Surface]を取得する */
    fun getInputSurface(): Surface = encodeMediaCodec!!.createInputSurface()
 
    /** エンコーダーを開始する */
    suspend fun start() {
        val encodeMediaCodec = encodeMediaCodec ?: return
        val mediaMuxer = mediaMuxer ?: return
 
        var videoTrackIndex = -1
        val bufferInfo = MediaCodec.BufferInfo()
        encodeMediaCodec.start()
 
        try {
            while (true) {
                // yield() で 占有しないよう
                yield()
 
                // Surface経由でデータを貰って保存する
                val encoderStatus = encodeMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
                if (0 <= encoderStatus) {
                    if (bufferInfo.size > 0) {
                        if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
                            val encodedData = encodeMediaCodec.getOutputBuffer(encoderStatus)!!
                            mediaMuxer.writeSampleData(videoTrackIndex, encodedData, bufferInfo)
                        }
                    }
                    encodeMediaCodec.releaseOutputBuffer(encoderStatus, false)
                } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                    // MediaMuxerへ映像トラックを追加するのはこのタイミングで行う
                    // このタイミングでやると固有のパラメーターがセットされたMediaFormatが手に入る(csd-0 とか)
                    // 映像がぶっ壊れている場合(緑で塗りつぶされてるとか)は多分このあたりが怪しい
                    val newFormat = encodeMediaCodec.outputFormat
                    videoTrackIndex = mediaMuxer.addTrack(newFormat)
                    mediaMuxer.start()
                }
                if (encoderStatus != MediaCodec.INFO_TRY_AGAIN_LATER) {
                    continue
                }
            }
        } finally {
            // エンコーダー終了
            encodeMediaCodec.signalEndOfInputStream()
            encodeMediaCodec.stop()
            encodeMediaCodec.release()
            // コンテナフォーマットに書き込む処理も終了
            mediaMuxer.stop()
            mediaMuxer.release()
        }
    }
 
    /**
     * 10-bit HDR の動画を作成するためのパラメーター。
     * 色空間とガンマカーブを指定してください。
     *
     * HLG 形式の HDR の場合は[MediaFormat.COLOR_STANDARD_BT2020]と[MediaFormat.COLOR_TRANSFER_HLG]と[MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10]。
     * デフォルト引数は HLG。
     *
     * 定数自体は Android 7 からありますが、10-bit HDR の動画編集が(MediaCodec が?) 13 以上なので。
     *
     * @param colorStandard 色空間
     * @param colorTransfer ガンマカーブ
     * @param codecProfile コーデックのプロファイル
     */
    data class TenBitHdrParameters(
        val colorStandard: Int = MediaFormat.COLOR_STANDARD_BT2020,
        val colorTransfer: Int = MediaFormat.COLOR_TRANSFER_HLG,
        val codecProfile: Int = MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10
    )
 
    companion object {
        /** タイムアウト */
        private const val TIMEOUT_US = 0L
    }
}

音声トラックを入れ直す処理を作る

動画編集すると、映像トラックと音声トラックは別々になっているので、一つの動画ファイル(コンテナフォーマット)に映像トラックと音声トラックを一つにまとめる処理を書きます。
OpenGLを使って編集した動画には音声トラックが無いので、これを使い元の動画データから音声データだけ入れ直します。

object MediaMuxerTool {
 
    /** それぞれ音声トラック、映像トラックを取り出して、2つのトラックを一つの mp4 にする */
    @SuppressLint("WrongConstant")
    suspend fun mixAvTrack(
        context: Context,
        audioTrackUri: Uri,
        videoTrackFilePath: String,
        resultFilePath: String
    ) {
        // 出力先
        val mediaMuxer = MediaMuxer(resultFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
 
        // 音声は元の動画データから
        // 映像は作った仮ファイルから
        val (audioExtractor, audioFormat) = createMediaExtractor(context, audioTrackUri, "audio/")
        val (videoExtractor, videoFormat) = createMediaExtractor(videoTrackFilePath, "video/")
 
        // MediaMuxer に追加して開始
        val audioTrackIndex = mediaMuxer.addTrack(audioFormat)
        val videoTrackIndex = mediaMuxer.addTrack(videoFormat)
        mediaMuxer.start()
 
        // MediaExtractor からトラックを取り出して、MediaMuxer に入れ直す処理
        listOf(
            audioExtractor to audioTrackIndex,
            videoExtractor to videoTrackIndex
        ).forEach { (extractor, trackIndex) ->
            val byteBuffer = ByteBuffer.allocate(1024 * 4096)
            val bufferInfo = MediaCodec.BufferInfo()
            // データが無くなるまで回す
            while (true) {
                yield()
                // データを読み出す
                val offset = byteBuffer.arrayOffset()
                bufferInfo.size = extractor.readSampleData(byteBuffer, offset)
                // もう無い場合
                if (bufferInfo.size < 0) break
                // 書き込む
                bufferInfo.presentationTimeUs = extractor.sampleTime
                bufferInfo.flags = extractor.sampleFlags // Lintがキレるけど黙らせる
                mediaMuxer.writeSampleData(trackIndex, byteBuffer, bufferInfo)
                // 次のデータに進める
                extractor.advance()
            }
            // あとしまつ
            extractor.release()
        }
        // 後始末
        mediaMuxer.stop()
        mediaMuxer.release()
    }
 
    /** [Uri]から[MediaExtractor]を作る */
    private fun createMediaExtractor(context: Context, uri: Uri, mimeType: String): Pair<MediaExtractor, MediaFormat> {
        val mediaExtractor = MediaExtractor().apply {
            context.contentResolver.openFileDescriptor(uri, "r")?.use {
                setDataSource(it.fileDescriptor)
            }
        }
        val (trackIndex, mediaFormat) = (0 until mediaExtractor.trackCount)
            .map { index -> mediaExtractor.getTrackFormat(index) }
            .withIndex()
            .first { it.value.getString(MediaFormat.KEY_MIME)?.startsWith(mimeType) == true }
        mediaExtractor.selectTrack(trackIndex)
        return mediaExtractor to mediaFormat
    }
 
    /** [filePath]から[MediaExtractor]を作る */
    private fun createMediaExtractor(filePath: String, mimeType: String): Pair<MediaExtractor, MediaFormat> {
        val mediaExtractor = MediaExtractor().apply {
            setDataSource(filePath)
        }
        val (trackIndex, mediaFormat) = (0 until mediaExtractor.trackCount)
            .map { index -> mediaExtractor.getTrackFormat(index) }
            .withIndex()
            .first { it.value.getString(MediaFormat.KEY_MIME)?.startsWith(mimeType) == true }
        mediaExtractor.selectTrack(trackIndex)
        return mediaExtractor to mediaFormat
    }
}

適当に UI を作る

動画を選ぶボタンと、開始ボタンを用意しました。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            AndroidMediaCodecHdrVideoCanvasOverlayTheme {
                HomeScreen()
            }
        }
    }
}
 
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeScreen() {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
 
    val videoUri = remember { mutableStateOf<Uri?>(null) }
    val overlayText = remember { mutableStateOf("10 ビット HDR 動画の編集テスト") }
    val videoPicker = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia(),
        onResult = { videoUri.value = it }
    )
 
    fun start() {
        scope.launch {
            // このあとすぐ
        }
    }
 
    Scaffold(
        topBar = { TopAppBar(title = { Text(text = stringResource(R.string.app_name)) }) },
        modifier = Modifier.fillMaxSize()
    ) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
 
            Button(onClick = { videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }) {
                Text(text = "動画を選ぶ")
            }
 
            OutlinedTextField(
                value = overlayText.value,
                onValueChange = { overlayText.value = it },
                label = { Text(text = "映像の上に重ねる文字") }
            )
 
            if (videoUri.value != null) {
                Button(onClick = { start() }) {
                    Text(text = "動画編集を始める")
                }
            }
        }
    }
}

組み合わせる

さっきのstart()関数の中身です。
動画編集で使うOpenGLのやつや、OpenGLで描画したものをエンコードして動画ファイルに書き込むエンコーダーの初期設定が続きます。
それらの初期設定には動画の高さ等が必要なので、MediaMetadataRetrieverを使ってます。
のんきにuse { }を使っていますが、AutoClosable インターフェースを実装してるのはAndroid 10以降なので古いバージョンでも使いたい場合はclose()を呼んでください。

縦動画の場合は動画のwidth / heightが逆になるので、rotationの値を見るようにしてください。
また、今回は10-bit HDR動画を扱うので、色空間(colorStandart)とガンマカーブ(colorTransfer)を取っています。これらはMediaCodecで渡せる形(MediaFormatクラスの定数)で返してくれるので、
取り出したら特に変換とかはせず、そのまま渡せば良いです。

プロファイルですがHDRの種類が、HLGならHEVCProfileMain10HDR10ならHEVCProfileMain10HDR10HDR10+ならHEVCProfileMain10HDR10Plusを指定すればいいはずです。

OpenGLで描画するやつを作り、OpenGLで映像データをテクスチャとして使えるSurfaceTextureも作ります。
そのSurfaceTextureに映像を流すためのデコーダーも作り、一通り揃いました。

エンコーダーを起動し、OpenGLで描画するためのメインループを呼びます。
ここで次の映像フレームがなくなるまで、デコーダーから映像フレームを取り出し、OpenGLで描画しその上から、Canvasを使って文字を書いて同じくOpenGLで描画。
MediaCodecに今のフレームの時間を伝える必要が多分あるのでsetPresentationTime()でフレームの時間も渡しています。これを動画が終わるまで続けます。

描画ループが終了するまで(動画フレームを最後まで取り出した)、join()で一時停止し、戻ってきたらエンコーダー側もcancel()させ動画編集を終わらせます。
ただ、このままだと映像の上に文字が重なった映像しかなく、音がなくなってしまいました。音を元の動画データから入れ直す作業が最後に必要です。

元の動画データから音声トラックを入れ直してきたらMediaStoreに保存して完了です。
ちなみに必要な時間は動画時間と同じくらいになるんじゃないかなと思います。そんなに大きく時間がかかるわけじゃ無さそう。

fun start() {
    scope.launch(Dispatchers.Default) {
        val inputUri = videoUri.value ?: return@launch
        val tempVideoTrackFile = context.getExternalFilesDir(null)?.resolve("temp_video.mp4") ?: return@launch
 
        // 動画のメタデータを取得
        val videoWidth: Int
        val videoHeight: Int
        val fps: Int
        val colorStandard: Int
        val colorTransfer: Int
        MediaMetadataRetriever().use { mediaMetadataRetriever ->
            context.contentResolver.openFileDescriptor(inputUri, "r")?.use {
                mediaMetadataRetriever.setDataSource(it.fileDescriptor)
            }
            // FPS / 色空間 / ガンマカーブ
            fps = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: 30
            colorStandard = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD)?.toIntOrNull() ?: MediaFormat.COLOR_STANDARD_BT2020
            colorTransfer = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER)?.toIntOrNull() ?: MediaFormat.COLOR_TRANSFER_HLG
            // 動画の縦横サイズは ROTATION を見る必要あり
            val __videoWidth = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 1280
            val __videoHeight = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 720
            val rotation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
            // 縦動画の場合、縦横が入れ替わるので
            val (_videoWidth, _videoHeight) = when (rotation) {
                90, 270 -> __videoHeight to __videoWidth
                else -> __videoWidth to __videoHeight
            }
            videoWidth = _videoWidth
            videoHeight = _videoHeight
        }
 
        // 動画のエンコーダー
        val videoEncoder = VideoEncoder().apply {
            prepare(
                videoFilePath = tempVideoTrackFile.path,
                outputVideoWidth = videoWidth,
                outputVideoHeight = videoHeight,
                frameRate = fps,
                bitRate = 20_000_000,
                keyframeInterval = 1,
                codecName = MediaFormat.MIMETYPE_VIDEO_HEVC,
                tenBitHdrParametersOrNullSdr = VideoEncoder.TenBitHdrParameters(
                    colorStandard = colorStandard,
                    colorTransfer = colorTransfer,
                    codecProfile = when (colorTransfer) {
                        MediaFormat.COLOR_TRANSFER_HLG -> MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10
                        MediaFormat.COLOR_TRANSFER_ST2084 -> MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10
                        else -> -1 // ココには来ないはず
                    }
                )
            )
        }
 
        // OpenGL で描画するクラス
        val openGlRenderer = OpenGlRenderer(
            outputSurface = videoEncoder.getInputSurface(),
            width = videoWidth,
            height = videoHeight,
            // BT.2020 でかつ、HLG か ST2084
            isEnableTenBitHdr = (colorStandard == MediaFormat.COLOR_STANDARD_BT2020 && (colorTransfer == MediaFormat.COLOR_TRANSFER_HLG || colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084))
        ).apply { prepare() }
 
        // 映像を流す SurfaceTexture を作る
        val surfaceTexture = TextureRendererSurfaceTexture(openGlRenderer.generateTextureId())
 
        // 動画のデコーダーを作る
        val videoDecoder = VideoDecoder().apply {
            prepare(
                context = context,
                uri = inputUri,
                outputSurface = surfaceTexture.surface
            )
        }
 
        try {
            coroutineScope {
                // エンコーダー
                val encoderJob = launch { videoEncoder.start() }
 
                // OpenGL のメインループ
                val openGlJob = launch {
                    val continuesData = OpenGlRenderer.DrawContinuesData(true, 0)
                    val frameMs = 1_000 / fps
                    val textPaint = Paint().apply {
                        color = Color.WHITE
                        textSize = 100f
                        isAntiAlias = true
                    }
                    var currentPositionMs = 0L
 
                    // isAvailableNext が false までループする
                    openGlRenderer.drawLoop {
                        // フレームを取得。次のフレームがない場合は null
                        val hasNextFrame = videoDecoder.seekNextFrame(currentPositionMs) != null
                        currentPositionMs += frameMs
 
                        // 描画する
                        drawSurfaceTexture(surfaceTexture)
                        drawCanvas { drawText(overlayText.value, 100f, 200f, textPaint) }
 
                        // ループ続行情報
                        continuesData.isAvailableNext = hasNextFrame
                        continuesData.currentTimeNanoSeconds = currentPositionMs * 1_000_000L // nanoseconds に変換する
                        continuesData
                    }
                }
 
                // 終わったらエンコーダーも終わり
                openGlJob.join()
                encoderJob.cancelAndJoin()
            }
        } finally {
            surfaceTexture.destroy()
            openGlRenderer.destroy()
            videoDecoder.destroy()
        }
 
        // 音声トラックがないので(音がないので)、元の動画データから入れ直す
        val resultFile = context.getExternalFilesDir(null)?.resolve("result_${System.currentTimeMillis()}.mp4")!!
        MediaMuxerTool.mixAvTrack(
            context = context,
            audioTrackUri = inputUri,
            videoTrackFilePath = tempVideoTrackFile.path,
            resultFilePath = resultFile.path
        )
 
        // 端末の動画フォルダに入れる
        val contentResolver = context.contentResolver
        val contentValues = contentValuesOf(
            MediaStore.Images.Media.DISPLAY_NAME to resultFile.name,
            MediaStore.Images.Media.RELATIVE_PATH to "${Environment.DIRECTORY_MOVIES}/AndroidMediaCodecHdrVideoCanvasOverlay"
        )
        val uri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)!!
        resultFile.inputStream().use { inputStream ->
            contentResolver.openOutputStream(uri)?.use { outputStream ->
                inputStream.copyTo(outputStream)
            }
        }
 
        // コピーしたので消す
        tempVideoTrackFile.delete()
        resultFile.delete()
 
        // おわり
        withContext(Dispatchers.Main) {
            Toast.makeText(context, "おわり", Toast.LENGTH_SHORT).show()
        }
    }
}

10 ビット動画の編集

動かしてみます。

Imgur

できた動画はこちらです
ちゃんと10 ビット HDRの動画の上に文字が出ています。しかもHDRを保持したまま動画編集したので眩しさや色の鮮やかさは残っています。

HDR 動画編集 ソースコード

どうぞ

https://github.com/takusan23/AndroidMediaCodecHdrVideoCanvasOverlay

10 ビット HDR の動画をできる限り SDR にしたい

https://developer.android.com/media/media3/transformer/tone-mapping

HDRからSDRの変換がしたい場合、多分自分で作るよりもMedia3 Transformerライブラリでやったほうがいいと思います・・・
このためだけにOpenGLだったりMediaCodecやりたくないでしょ。

このHDRからSDRにするやつ、トーンマッピングとかいう名前がついているそうで、Android 13以降であれば、
MediaCodecのデコーダー設定時のMediaFormatsetInteger(MediaFormat.KEY_COLOR_TRANSFER_REQUEST, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)するだけ。
あとは出力先SurfaceからSDRに変換された映像が流れてきます。

ちなみに上記を使わず何もしない状態でSDRにするとかなり白っぽくなります。

それと、この標準トーンマッピングすべての端末で利用できるわけではないらしく、
手元のXperia 1 Vでは対応してなかった、、、

Android 組み込みのトーンマッピングを使ってみる

これはさっき作った動画編集のプログラムを少し変えるだけで出来ます。

  • デコーダーのMediaFormatに一つ追加する
    • setInteger(MediaFormat.KEY_COLOR_TRANSFER_REQUEST, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
  • エンコーダーにはcolorSpacecolorTransformの値をSDR(BT.709)にする(か、そもそも指定しない
  • OpenGL ESの設定でHLGを利用しない(SDRのときと同じInputSurfaceの処理をする)

まずはデコーダーのprepare 関数に引数を一つ追加します。

/**
 * デコードの準備をする
 *
 * @param context [Context]
 * @param uri PhotoPicker 等で選んだやつ
 * @param outputSurface デコードした映像の出力先
 * @param toToneMapSdr SDR にトーンマッピングする場合は true
 */
fun prepare(
    context: Context,
    uri: Uri,
    outputSurface: Surface,
    toToneMapSdr: Boolean = false
) {
    val mediaExtractor = MediaExtractor().apply {
        context.contentResolver.openFileDescriptor(uri, "r")?.use {
            setDataSource(it.fileDescriptor)
        }
    }
    this.mediaExtractor = mediaExtractor
 
    // 動画トラックを探す
    val (trackIndex, mediaFormat) = (0 until mediaExtractor.trackCount)
        .map { mediaExtractor.getTrackFormat(it) }
        .withIndex()
        .first { it.value.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true }
    mediaExtractor.selectTrack(trackIndex)
 
    // SDR に変換する場合は、MediaFormat にオプション指定
    if (toToneMapSdr) {
        mediaFormat.setInteger(MediaFormat.KEY_COLOR_TRANSFER_REQUEST, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
    }
 
    // MediaCodec を作る
    val codecName = mediaFormat.getString(MediaFormat.KEY_MIME)!!
    decodeMediaCodec = MediaCodec.createDecoderByType(codecName).apply {
        configure(mediaFormat, outputSurface, null, 0)
    }
    decodeMediaCodec?.start()
}

次にエンコーダーにはSDRを、OpenGLにはHLGを使わないようにし、デコーダーにはtoToneMapSdrtrueにしてあげます。

// 省略...
 
// 動画のエンコーダー
val videoEncoder = VideoEncoder().apply {
    prepare(
        videoFilePath = tempVideoTrackFile.path,
        outputVideoWidth = videoWidth,
        outputVideoHeight = videoHeight,
        frameRate = fps,
        bitRate = 20_000_000,
        keyframeInterval = 1,
        codecName = MediaFormat.MIMETYPE_VIDEO_HEVC,
        tenBitHdrParametersOrNullSdr = null // SDR にする
    )
}
 
// OpenGL で描画するクラス
val openGlRenderer = OpenGlRenderer(
    outputSurface = videoEncoder.getInputSurface(),
    width = videoWidth,
    height = videoHeight,
    isEnableTenBitHdr = false // SDR にする
).apply { prepare() }
 
// 映像を流す SurfaceTexture を作る
val surfaceTexture = TextureRendererSurfaceTexture(openGlRenderer.generateTextureId())
 
// 動画のデコーダーを作る
val videoDecoder = VideoDecoder().apply {
    prepare(
        context = context,
        uri = inputUri,
        outputSurface = surfaceTexture.surface,
        toToneMapSdr = true // SDR にする
    )
}
 
// 省略...

あとは動画編集のプログラムそのままで使えます。

結果

結構近い色になってると思う

ちなみにこれをしないでSDRにするとまじでかすれた色になります。。。

HLG 互換のドルビービジョンをドルビービジョン非対応端末でデコードする

そもそもドルビービジョンの会社にライセンス料を払わないといけないのか、HLGとしてデコードする分にはお金がかからないのかとかその辺知らないので、、、
ここでは技術面のみ。

https://professionalsupport.dolby.com/s/article/Transcoding-Dolby-Vision-profile-8-4-to-HLG-on-Android?language=en_US

多分ドルビービジョンはドルビービジョン用のデコーダーが存在する気がして、対応端末であれば、これまでのコードで特段対応することなくデコードできるんじゃないかなと思います。
(すいません、手元にドルビービジョンがデコードできる端末がなくこれで動くかは分からないです。)

一方、非対応端末でドルビービジョンの動画をいれると、ドルビービジョンのデコーダーなんてねえよ。うるせえよ。的な例外が投げられると思います。
ですが、冒頭の通りiPhoneXiaomi等スマホに入ってるドルビービジョンHLGと互換性があるのでHEVCデコーダーでデコードできるはず。

その修正を入れてみます。
VideoDecoderクラスのprepare関数で、ドルビービジョンのコーデックが要求されていて、かつ端末にドルビービジョンのデコーダーがない場合は、
HEVCのデコーダーを作るようにしました。デコーダーの有無は上記のURLから持ってきました。。

/**
 * デコードの準備をする
 *
 * @param context [Context]
 * @param uri PhotoPicker 等で選んだやつ
 * @param outputSurface デコードした映像の出力先
 * @param toToneMapSdr SDR にトーンマッピングする場合は true
 */
fun prepare(
    context: Context,
    uri: Uri,
    outputSurface: Surface,
    toToneMapSdr: Boolean = false
) {
    
    // ... 以下省略 ...
 
    // MediaCodec を作る
    var codecName = mediaFormat.getString(MediaFormat.KEY_MIME)!!
 
    // ドルビービジョンの動画で、ドルビービジョンのデコーダーがない場合は HEVC としてデコードさせる
    // ただ、本物のドルビービジョンは HLG と互換性がないので、HLG と互換性があるドルビービジョンのみ対応できる
    if (codecName == MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION && !hasDolbyVisionDecoder()) {
        codecName = MediaFormat.MIMETYPE_VIDEO_HEVC
    }
 
    decodeMediaCodec = MediaCodec.createDecoderByType(codecName).apply {
        configure(mediaFormat, outputSurface, null, 0)
    }
    decodeMediaCodec?.start()
}
 
/** ドルビービジョンのデコーダーが存在するか */
private fun hasDolbyVisionDecoder(): Boolean {
    val format = MediaFormat()
    format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
 
    val mediaCodecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
    val decoder = mediaCodecList.findDecoderForFormat(format)
 
    // true if a Dolby Vision decoder is found, false otherwise.
    return decoder != null
}

でも、多分本物のドルビービジョン(PQの方、何て言うのか知らない)がこれだと多分動かない、、、
まー本物はそれ用のデコーダーが存在するんじゃないんかな(しらんけど)

ソースコード(再掲)

https://github.com/takusan23/AndroidMediaCodecHdrVideoCanvasOverlay

すぺしゃるさんくす

おまけ

MediaRecorderstop()が反応しなくなったり、MediaCodecがいきなり失敗するようになったら、端末の再起動を試してみるとよいです。

おわりに

しばらくはこの10 ビット HDRで撮影しようと思った。
(と思ったけどPixel30fpsでしか10 ビット HDR撮れないんだよな。どっちを取るか。)

私が見ている分には、HDRSDRにトーンマッピングしても気付ける自信あんまりないし(えっ)

以上です、おつかれさまでした。8888888

おわりに2

Google フォト10 ビット HDRの動画のカットと回転をやろうとしたら対応してなくて、自作動画編集アプリでちょうど対応しておいて良かった。。。
(回転機能はないので大急ぎでつけた)

おわりに3

YouTubeHDRの動画アップしたのに画質一覧にはSDRしかないんですけど?

Imgur

って思って調べてみるとHDRの画質が用意されるには結構時間がかかるそう
「YouTube HDR 時間かかる」とかいうふわっふわな検索ワードですら引っかかる。

そういえばHDRのメタデータがなんとかってやつは多分、MediaRecorderもしくはMediaCodec + MediaMuxerあたりが勝手にmp4に書き込んでおいてくれているはずなので、
メタデータが付いていないってことは無さそう。

数時間後に反映された、AndroidAPIはちゃんとYouTube側が分かるメタデータをつけてくれたようです。

Imgur

ところで、Pixel 8 Pro、カタログスペックでは10 ビット HDR撮影だと60fps無理なんだけど、なんかCamera2 APIを叩くと60fpsになってる?
YouTubeにアップして気付いた。ただフレームを水増ししただけの30fpsの可能性もある。