たくさんの自由帳

HDR 動画から UltraHDR 画像を作成するアプリ

投稿日 : | 0 日前

文字数(だいたい) : 20022

どうもこんばんわ。D.C.5 Sweet Happiness 攻略しました。全年齢版やったから好きな順番で攻略するよ~~~
全年齢の時でめっちゃ良かったから楽しみ~~

感想

!?!?!??!!!!?!?!

感想

感想

妙にリアルな数字わらった

感想

歌ってるのかわいい、無印のグランドエンディング曲!!

感想

感想

感想

かこちゃん、他のヒロインの時でも面白い!!やろう!!、ほかルートでも安心や

感想

うなぎ...

感想

!!!

感想

ついにどこのオシャレなのかが、、、!、

感想

全年齢やったから、あかりちゃんルート2回目だけど、さすが、めにょあ、わかってた

感想

うお~~~~~
私だけかもしれないけど、あかりちゃん、声が良くなったような気がする。無印やってしばらく経ってるせいかもしれないけど!!!

感想

感想

感想

?!?!?!?

感想

ここすき

感想

ここもすき

感想

これがサクラの国だから!?!?

感想

感想

えちえちシーンのせいでより一層お別れが辛くなってる説ある!!

感想

感想

わたしもこれ、です、もっと学園生活を見たかった、、、

あいかちゃん!!!かわいすぎ。とにかくこれだけは見てほしい、

感想

!??!?!!!

感想

読むんだ、、、さすがえちえち版(?)
(この後のお話が神)

感想

!!!!!!!!!!!!!

感想

ぴ、ぴとーかわいい

感想

抵抗しちゃだめ!

感想

!!!ここ新規シナリオな気がする、わくわく
む~~ん

感想

あとここの、パタパタの声がかわいすぎる!!!!!のでやろう!

感想

え?!?!?!!!

感想

あいかちゃん!!!のえちえちシーンが良かったので神ゲーです、やろう
めちゃめちゃよかった、全年齢からここまで来てくれた甲斐がある。。

あと、えちえちシーン、早々に済ませる気だな~って思ってたけど、最後に一回あった。うれc

本題

UltraHDRって既存の写真に明るさのデータ(?)を持たせてより明るく表示できる技術なのですが、皆さん使ってますか?
10-bit HDR動画の静止画版みたいな技術ですけど、、

え、Google Pixel じゃないから撮影できないって?
端末が古いから無理?

つくった

10-bit HDR動画からUltraHDRを撮影するアプリを作りました。
UltraHDR撮影はに対応していなくても、これを使えば10-bit HDR 動画撮影に対応していれば、その動画のフレームを取り出してUltraHDR画像を生成します。

例えばXperiaだとXperia 1 VIIしかUltraHDR撮影に対応していませんが、
HDR 動画撮影機能は少なくともXperia 1 Vから搭載されているので、このアプリを使えば、1 VでもUltraHDRが作れるって寸法。

ただ、もうすこし古くなると、必要になるOpenGL ESの機能が実装されて無くて使えないような。。。
このアプリのソースコードもあります。

番外編 UltraHDR から 10-bit HDR 動画を作る

この逆の例は普通にあります。しかも2通りの例があります、!!
私がやるのはこの逆!!!

まさかの二通り出揃うとはびっくりした。これ誰もやらないのがセオリーだろこれ、、、

UltraHDR を古い端末で撮影するからくり

10-bit HDR 動画っていう、明るさと鮮やかさが特徴の動画があります。
この機能はiPhone 12あたりからスマホでも10-bit HDR撮影ができるようになってきました。

この動画撮影をした後に、動画のフレーム(写真)をUltraHDRの画像を作る処理に渡すことでできます。
UltraHDRに必要な眩しさは10-bit HDR 動画ですでに撮影しているので、10-bit HDR 動画撮影に対応していればUltraHDR保存が使えるわけです。

10-bit HDR 動画から UltraHDR を作成するからくり

10-bit HDR 動画の詳細とAndroid APIに関しては前に書いた記事を見てもらうとして、

ざっくり言うとこんな感じで、
使える色が増えて、それに合わせてガンマカーブ(取り込んだ色をカラーコードにする関数)も進化?している

既存10-bit HDR
色空間BT.709BT.2020
ガンマカーブ標準(?)HLG / PQ / Dolby Vision (スマホは HLG)
RGBA バイト列RGBA すべてで 8ビット (RGBA8888)RGB で 10 ビット、残り 2 ビットが A (RGBA1010102)

話を戻して、なら、なんかいい感じに、動画のフレーム(写真)をUltraHDR画像に出来るのではないかと。
というわけでUltraHDRを作成するライブラリを見てみます。

encoding_senario

抜粋、
エンコードのシナリオ1番、必要なものは、、RGBA1010102の画像ファイルのみ!!!!!!!!!!!
おおお!10-bit HDR 動画からどうにかしてフレームをゲットして、libultrahdrに渡せば良さそうですね、、、、

えっlibultrahdr、ビルドしないとダメなの?

Android の API にはない

libultrahdrのライブラリ自体はP010 or rgba1010102 or rgbaf16のどれか1つさえあれば、UltraHDR画像が作れるのですが、
AndroidAPIは一味違います(不穏な)

たしかに、Kotlin/Java から UltraHDR を作る関数は存在するのですが、
それがこれ、JPEG_Rがそうなのですが、

コンストラクタでrgba1010102の画像バイト配列を渡した後、SDR画像rgba8888画像が必要!!
なんでシナリオ1がないの!!!!

え、C++書く必要ありますか。そうですか、、

Android 16 に先を越された

一応触れておくか...

Android 16の新機能を探している皆さんに朗報。
このバージョンからHDR 動画・HDR 写真が含まれたスクリーンショットを撮影した時、UltraHDRのような眩しいままでスクリーンショットが撮影できます。
ということは、今回作ったアプリを使わなくても、HDR動画を全画面にしてスクリーンショットを取れば、静止画でも眩しいんですけどね。。。。

価値を絞り出すとすると、スクリーンショットはpng画像なので、UltraHDRとはまた別の謎の技術を使っています。
UltraHDRのサポートを謳っていても、そのHDR スクリーンショット PNGまでサポートしているかは不明なので、今のところ眩しい静止画はUltraHDRがいいのかな、

試した限り、HDR スクリーンショット PNG 画像AndroidBitmap#getGainMapでは取得できてそう、、、
これのAPIを使わない他のアプリとかはしらない、

あと、画面録画のMediaProjection APIはまだSDRっぽい?
iOSHDRの画面録画に対応したらしいのでAndroidも来てほしい

UltraHDR 画像のために 10-bit HDR 動画からフレームを取り出す流れ

  • UltraHDRC++ライブラリ、libultrahdrを呼び出す処理を書く
  • OpenGL ESを用意する(EGL)際に、RGBそれぞれが10ビット使えるようにする
  • MediaCodec (ビデオデコーダー)で動画のデコーダーを作る、出力先は↑で作ったOpenGL ES
  • OpenGL ESに描画した動画フレームをglReadPixelsで取得
  • glReadPixelsの結果をlibultrahdrC++で書かれた関数に渡す
  • 帰ってきた写真をMediaStoreに保存

流れ1

OpenGL を回避できないのですか?

MediaCodecの出力先に、画像キャプチャ担当のImageReaderクラスが存在します。
HDRのピクセルのバイト配列を取得できて、libultrahdrライブラリ自体が要求する方式を考慮すると、P010ImageReaderを設定する必要があるのですが、
これが Snapdragon 端末ではエラーになってしまいます

Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x76a4d3d000 in tid 8806 (aframeextractor), pid 8806 (aframeextractor)
Cmdline: io.github.takusan23.androidhdrp010orrgbaframeextractor
pid: 8806, tid: 8806, name: aframeextractor  >>> io.github.takusan23.androidhdrp010orrgbaframeextractor <<<
      #09 pc 0000000000004f88  /data/app/~~Fk18EqBBBYz0MJ_HrpBaHA==/io.github.takusan23.androidhdrp010orrgbaframeextractor-Ipx8f0RO4XwTcwrlBpAWYQ==/base.apk (io.github.takusan23.androidhdrp010orrgbaframeextractor.MainActivityKt.MainScreen$getDataFromImage+0)
      #14 pc 0000000000005068  /data/app/~~Fk18EqBBBYz0MJ_HrpBaHA==/io.github.takusan23.androidhdrp010orrgbaframeextractor-Ipx8f0RO4XwTcwrlBpAWYQ==/base.apk (io.github.takusan23.androidhdrp010orrgbaframeextractor.MainActivityKt.access$MainScreen$getDataFromImage+0)
      #19 pc 0000000000004c38  /data/app/~~Fk18EqBBBYz0MJ_HrpBaHA==/io.github.takusan23.androidhdrp010orrgbaframeextractor-Ipx8f0RO4XwTcwrlBpAWYQ==/base.apk (io.github.takusan23.androidhdrp010orrgbaframeextractor.MainActivityKt$MainScreen$startYuvP010$1.invokeSuspend+0)

なにこれ、もうAndroid開発のユーザーランドからは直せない雰囲気なんだけど。
PixelはなんかImageReader + P010を実装してたっぽい?Snapdragonで使えないなら採用しません、、

で、一方、なぜかOpenGL ESRGB10ビット問題なく動き(眩しい!!)
glReadPixelsRGB10ビットになるように指定すると、大体の端末で動くし、
それをlibultrahdrに入れるとUltraHDR画像ができてしまった、、

というわけで、今回はこれで行きます。

環境

パソコンWindows 10 Pro
Android StudioNarwhal Feature Drop 2025.1.2
端末Pixel 8 Pro / Xperia 1 V
言語Kotlin / C++ (ちょっとだけ!!!)

今回、libultrahdrライブラリをAndroid向けにビルドするのにWSL2を利用します。
理由はC++の環境作っても今後使う機会ないはず、、消したい、、よく使うAndroid Studioとはわけが違って、、

この都合上、WindowsWSL2を使うか、Linuxマシンを用意しないとこの記事通りでは作れないと思います。
もちろん他のOS、それこそもちろんWindowsでもビルドできる方法があります。

ビルドが済んだらさっさと消したい。サンドボックス的な使い方にWSL2がドンピシャ。

作っていく

libultrahdr をビルドする

今回はビルドにLinuxマシンを使います。他のOSでもビルドできるので、以下参照。

今回は先述の通りWSL2Ubuntuを使います。ので用意してください。
もう私のWindowsマシンにはインストールされているので、どういう手順だったか忘れてしまったのですが、、

一回も使ったことない場合はwsl --installを叩けばいいっぽい?

Ubuntuがインストールされると、usernamepasswordを決めるように言われるので、よしなに入力してください。
wsl1

おわったら、いつものコマンドを叩きます。以下2つ。

sudo apt update
sudo apt upgrade

sudoなので、さっきのパスワードを入力するように言われるはず。入れてください。
2番目はYを答える。時間かかるのでしばらく待つ!!

必要なライブラリを入れる

で、本当はC 言語のコンパイラーを入れれば良いのですが、なんか面倒なので、
全部入りパッケージをapt installしようと思います。多分gccclangを手動で入れるで良い。。
というわけで、全部入りのbuild-essentialを入れます。

sudo apt install build-essential

これもY/n聞かれるのでY
これでgccが一緒に入ってくるはず。

次にlibultrahdrに必要なライブラリを用意します。
叩くコマンドはこれです。

sudo apt install cmake pkg-config libjpeg-dev ninja-build

最後に、Android NDKを入れます。
お作法なのか、/optに配置するのが良いみたいなので、先に/optに移動してから取得します。sudoがいるけど、、

cd /opt
sudo wget https://dl.google.com/android/repository/android-ndk-r26d-linux.zip
sudo unzip android-ndk-r26d-linux.zip

もしunzipが存在しないコマンドだよって帰ってきたらaptから入れて、その後にもう一回unzipコマンドを叩いて。

sudo apt install unzip

これでビルドに必要な素材が揃いました。

libultrahdr ソースコードを持ってくる

このコマンドを叩くことで、ホームディレクトリのフォルダにソースコードが出来る

cd ~
git clone https://github.com/google/libultrahdr.git
cd libultrahdr
mkdir build_directory
cd build_directory

そしたら、以下のコマンドを叩きます。
引数ですが、

DCMAKE_TOOLCHAIN_FILEはそのままでよさそう、DUHDR_BUILD_DEPSもそのまま、DANDROID_PLATFORMもそのままでいいはず。
DUHDR_ANDROID_NDK_PATHはさっき展開したNDKの位置(/optに入れたならそのままで)、
DANDROID_ABIは、armeabi-v7aarm64-v8ax86x86_64のどれかです。

ABI (CPU アーキテクチャ)は多分全パターンでやらないと、エミュレータだとライブラリが存在しない!!ってエラーになってしまう。。
とりあえずARM 64Bit の arm64-v8aをビルドしてみましょう。

cmake -G Ninja -DCMAKE_TOOLCHAIN_FILE=../cmake/toolchains/android.cmake -DUHDR_ANDROID_NDK_PATH=/opt/android-ndk-r26d/ -DUHDR_BUILD_DEPS=1 -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-23 ../

これで叩くと、ビルドの準備がされます。
ここでC言語のコンパイラーが存在しないと失敗します、がbuild-essentialの中に入ってるので問題なく動くはず、

wsl2

ちなみに、失敗してうまくいかない場合は、
build_directoryを一回消して、作り直して、さっきのcmakeのコマンドを叩くとよいです。

画像のようにcmakeがうまくいったら、以下のコマンドを叩きビルドします。

ninja

これが成功すると、今いるbuild_directorylibuhdr.soって名前のファイルができているはずです!!!
おめでとう!

wsl3

これをどこかWindowsマシンにコピーして、残りのCPUアーキテクチャの分だけ繰り返します
WSLはエクスプローラーから見れます。仮想マシンと違ってすごい便利です

wsl4

ヘッダーファイルをコピーしておく

C++を書くときにincludeするためのファイル!!!(#includeなんか懐かしくて草)
が、別にこのファイルは適当にgit cloneして取得してもいいです。が、せっかくWSL2の環境にclone済みのがあるので、ここから拝借します。

ultrahdr_api.hってファイルです。これをコピーしておいてください。

wsl5

おわり

もうWSL2は使わないのでWindowsから消しちゃっていいです。
PowerShellwsl --unregister Ubuntuでエンターすると消えます。Cドライブ返してくれ

wsl6

Android Studio でプロジェクトを作る

Native C++ではなくJetpack Composeのやつを選んでいいです。
というのも、前書いた通りC++コードを後から追加するほうがJetpack Composeよりも楽だからです。

プロジェクトができたら、FileAdd C++ to Moduleを選んで、C++が書けるように準備します。
ダイアログでなにか聞かれてもOKを選んで。

Add C++ to Module 1

Add C++ to Module 2

画像は前回の記事の再利用です、、

libultrahdr ライブラリをアプリに入れる

app/src/mainjniLibsフォルダを作り、その中にそれぞれのABIのフォルダを作り、その中にそのABIでビルドした共有ライブラリを配置します。
それとは別に、jniLibsincludeフォルダを作り、その中にはヘッダーファイルを入れます、

jniLibs

何言ってるか分からんと思うので、スクショを貼るとこうです。

次に、cppフォルダの中にあるCMakeLists.txtを開いて、以下を足します。
これするとC++コードから#includeできて、ライブラリもリンクされるようになってるはず。

add_library(libuhdr SHARED IMPORTED)

set_target_properties(
        libuhdr

        PROPERTIES IMPORTED_LOCATION

        ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libuhdr.so
)

include_directories(${CMAKE_SOURCE_DIR}/../jniLibs/include)

target_link_libraries(${CMAKE_PROJECT_NAME} libuhdr)

libultrahdr ライブラリを使って UltraHDR を作成する C++

まず、Kotlin側でUltraHDR画像を作る関数を置くためのクラスを作りました。

関数はexternal funで宣言してください。
文字列の引数は、入力ファイルパスと、出力先ファイルパスで、あと縦と横は画像の縦と横です。

object LibUltraHdr {

    // 実装は C++ へ
    external fun createUltraHdr(width: Int, height: Int, rgba1010102PixelPath: String, resultPath: String)

    // C++ コードをロードする
    // 実装が書かれている C++ ファイル名
    init {
        System.loadLibrary("hdrvideotoultrahdr")
    }
}

関数の部分が真っ赤になってると思うので、マウスオーバーしてCreate JNI function ...を押します。

c++1

System.loadLibraryに渡した名前と同じファイル名に生成されればOKです!
ここにC++コードを書いていきます。が、ぶっちゃけサンプルのパクリなので詳しい文法とかは理解できてません、、

とりあえず全部張ります。
関数名Java_io_github_takusan23_hdrvideotoultrahdr_LibUltraHdr_createUltraHdrは各自違うはず。

#include <jni.h>
#include <malloc.h>
#include <iostream>
#include <fstream>
#include <android/log.h>
#include "ultrahdr_api.h"

#define READ_BYTES(DESC, ADDR, LEN)                                                             \
 DESC.read(static_cast<char*>(ADDR), (LEN));                                                   \
 if (DESC.gcount() != (LEN)) {                                                                 \
   std::cerr << "failed to read : " << (LEN) << " bytes, read : " << DESC.gcount() << " bytes" \
             << std::endl;                                                                     \
   return false;                                                                               \
 }

static bool loadFile(const char *filename, uhdr_raw_image_t *handle) {
    std::ifstream ifd(filename, std::ios::binary);
    if (ifd.good()) {
        if (handle->fmt == UHDR_IMG_FMT_24bppYCbCrP010) {
            const size_t bpp = 2;
            READ_BYTES(ifd, handle->planes[UHDR_PLANE_Y], bpp * handle->w * handle->h)
            READ_BYTES(ifd, handle->planes[UHDR_PLANE_UV],
                       bpp * (handle->w / 2) * (handle->h / 2) * 2)
            return true;
        } else if (handle->fmt == UHDR_IMG_FMT_32bppRGBA1010102 ||
                   handle->fmt == UHDR_IMG_FMT_32bppRGBA8888) {
            const size_t bpp = 4;
            READ_BYTES(ifd, handle->planes[UHDR_PLANE_PACKED], bpp * handle->w * handle->h)
            return true;
        } else if (handle->fmt == UHDR_IMG_FMT_64bppRGBAHalfFloat) {
            const size_t bpp = 8;
            READ_BYTES(ifd, handle->planes[UHDR_PLANE_PACKED], bpp * handle->w * handle->h)
            return true;
        } else if (handle->fmt == UHDR_IMG_FMT_12bppYCbCr420) {
            READ_BYTES(ifd, handle->planes[UHDR_PLANE_Y], (size_t) handle->w * handle->h)
            READ_BYTES(ifd, handle->planes[UHDR_PLANE_U],
                       (size_t) (handle->w / 2) * (handle->h / 2))
            READ_BYTES(ifd, handle->planes[UHDR_PLANE_V],
                       (size_t) (handle->w / 2) * (handle->h / 2))
            return true;
        }
        return false;
    }
    return false;
}

static bool writeFile(const char *filename, void *&result, size_t length) {
    std::ofstream ofd(filename, std::ios::binary);
    if (ofd.is_open()) {
        ofd.write(static_cast<char *>(result), length);
        return true;
    }
    std::cerr << "unable to write to file : " << filename << std::endl;
    return false;
}

extern "C"
JNIEXPORT void JNICALL
Java_io_github_takusan23_hdrvideotoultrahdr_LibUltraHdr_createUltraHdr(JNIEnv *env, jobject thiz,
                                                                       jint width, jint height,
                                                                       jstring rgba1010102_pixel_path,
                                                                       jstring result_path) {
    // pointer
    const char *native_rgba1010102_path = env->GetStringUTFChars(rgba1010102_pixel_path, 0);
    const char *native_ultra_hdr_path = env->GetStringUTFChars(result_path, 0);

    // Load rgba1010102
    const size_t bpp = 4;
    uhdr_raw_image_t mRawRgba1010102Image{};
    mRawRgba1010102Image.fmt = UHDR_IMG_FMT_32bppRGBA1010102;
    mRawRgba1010102Image.cg = UHDR_CG_DISPLAY_P3;
    mRawRgba1010102Image.ct = UHDR_CT_HLG;
    mRawRgba1010102Image.range = UHDR_CR_FULL_RANGE;
    mRawRgba1010102Image.w = width;
    mRawRgba1010102Image.h = height;
    mRawRgba1010102Image.planes[UHDR_PLANE_PACKED] = malloc(bpp * width * height);
    mRawRgba1010102Image.planes[UHDR_PLANE_UV] = nullptr;
    mRawRgba1010102Image.planes[UHDR_PLANE_V] = nullptr;
    mRawRgba1010102Image.stride[UHDR_PLANE_PACKED] = width;
    mRawRgba1010102Image.stride[UHDR_PLANE_UV] = 0;
    mRawRgba1010102Image.stride[UHDR_PLANE_V] = 0;
    loadFile(native_rgba1010102_path, &mRawRgba1010102Image);

    // https://github.com/google/libultrahdr/blob/6db3a83ee2b1f79850f3f597172289808dc6a331/examples/ultrahdr_app.cpp#L776-L781
    uhdr_codec_private_t *handle = uhdr_create_encoder();
    uhdr_enc_set_raw_image(handle, &mRawRgba1010102Image, UHDR_HDR_IMG);
    uhdr_enc_set_quality(handle, 95, UHDR_BASE_IMG);
    uhdr_enc_set_quality(handle, 95, UHDR_GAIN_MAP_IMG);
    uhdr_enc_set_using_multi_channel_gainmap(handle, true);
    uhdr_enc_set_gainmap_scale_factor(handle, 1);
    uhdr_enc_set_gainmap_gamma(handle, 1.f);
    uhdr_enc_set_preset(handle, UHDR_USAGE_BEST_QUALITY);
    uhdr_encode(handle);

    auto output = uhdr_get_encoded_stream(handle);

    // for decoding
    uhdr_compressed_image_t mUhdrImage{};
    mUhdrImage.data = malloc(output->data_sz);
    memcpy(mUhdrImage.data, output->data, output->data_sz);
    mUhdrImage.capacity = mUhdrImage.data_sz = output->data_sz;
    mUhdrImage.cg = output->cg;
    mUhdrImage.ct = output->ct;
    mUhdrImage.range = output->range;
    uhdr_release_encoder(handle);

    writeFile(native_ultra_hdr_path, mUhdrImage.data, mUhdrImage.data_sz);
    free(mUhdrImage.data);
}

多分無駄があるような気もしますが、、とりあえずこれで動くので!!
はい!!C++編終わり!!

OpenGL ES 周りを用意していく

Camera2API HDR 動画撮影の記事で触れたそれです。今回もこれを使います、
その記事もAOSPが使ってるInputSurfaceをお借りしているんですが。。

要はOpenGL ESのセットアップ(EGL)で、GLES 3を使うようにして、RGB10ビットにして、EGL_GL_COLORSPACE_BT2020_HLG_EXTを渡せば良さそう。
とりあえず以下のコードをひらすらコピペしていってください、、

先述の記事との違いは、
HDRのみにして、CanvasOpenGL ESに転写する処理も今回は使わないので消して、glReadPixels出来るようにしました。

そういえば、AndroidにもOpenGLESSurfaceViewを合体させたクラスがすでにあるんですが、
HDR行けない気がする、、、

付録 Media3

書いたあとに気づいた、Media3 (ExoPlayer)HDR動画からフレームをRGBA1010102形式でフレームを取り出す機能があるみたいです!!(使ったことなく不明)
OpenGLMediaCodecもどうしてもやりたくない場合は調べても良いかも。この記事ではコピペで動くようにしてますが、が、が、まあやりたくはないでしょうね。

InputSurface.kt

先述のCamera2 API HDR動画撮影の記事から拝借。
これがEGLまわりで、

/**
 * [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 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 に設定する必要がある。
        eglSetupForTenBitHdr()
    }

    /** 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

これが、OpenGL ESのテクスチャとして、カメラ映像や動画デコーダーの出力結果が使えるようになるやつ。詳しくは前述の記事で、、、!

/**
 * [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]を呼び出す */
    suspend fun awaitUpdateTexImage() {
        // フラグが来たら折る
        _isAvailableFrameFlow.first { it /* == true */ }
        _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で描画している箇所になります。
詳しくは先述の(ry

awaitUpdateTexImage()は新しいフレームが来るまで待つ関数で、ただそもそもフレームが変化しない事があるので、(30fpsの動画を60fpsのペースで取ったら変化しないので)
それはタイムアウト付きで、永遠に待ったりはしないように。

というのも、MediaCodec周りをいじってたときに、フレームが更新されたときのみテクスチャを更新したら、なんか、気付かないくらいだけど、カクカクしてる気がする。

エンコードするとフレーム落ちする事がある問題を修正 · takusan23/AkariDroid@e3d08ef

https://github.com/takusan23/AkariDroid/commit/e3d08ef7e46c8cc36b55740c30d12bf55eef8078

/**
 * [OpenGlRenderer]から実際の描画処理を持ってきたもの。OpenGL ES のセットアップは[InputSurface]でやる。
 */
class TextureRenderer() {

    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

    // テクスチャ ID
    private var surfaceTextureTextureId = 0

    init {
        mTriangleVertices.put(mTriangleVerticesData).position(0)
    }

    /**
     * SurfaceTexture を描画する。
     * GL スレッドから呼び出すこと。
     *
     * @param surfaceTexture 描画する[SurfaceTexture]
     * @param onTransform 位置や回転を適用するための行列を作るための関数
     */
    suspend 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)
        // 映像が来るまで待つ、500ms に来なかったら更新しない
        withTimeoutOrNull(500) {
            surfaceTexture.awaitUpdateTexImage()
        }
        surfaceTexture.getTransformMatrix(mSTMatrix)

        // glError 1282 の原因とかになる
        GLES20.glUseProgram(mProgram)
        checkGlError("glUseProgram")

        // テクスチャの ID をわたす
        GLES20.glUniform1i(sSurfaceTextureHandle, 0) // GLES20.GL_TEXTURE0

        // そのほかの値を渡す
        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 = FRAGMENT_SHADER_10BIT_HDR
        )
        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")
        }

        // テクスチャ ID を払い出してもらう
        val textures = IntArray(1)
        GLES20.glGenTextures(1, 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")
    }

    /** テクスチャ 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)

        GLES20.glUseProgram(mProgram)
        checkGlError("glUseProgram")
    }

    /**
     * glReadPixels を呼び出す。
     * [drawEnd]の後、[AkariGraphicsInputSurface.swapBuffers]の前に呼び出す必要がありそう?
     * (swapBuffers の後だと真っ暗だった)
     *
     * @return [android.opengl.GLES30.glReadPixels] の結果
     */
    fun glReadPixels(width: Int, height: Int): ByteArray {
        // RGBA で 4バイト使う
        val byteArray = ByteArray(4 * height * width)
        val byteBuffer = ByteBuffer.wrap(byteArray)
        // OpenGL ES の EGL で RGB 10 ビット、Alpha 2 ビット使っているので GL_UNSIGNED_INT_2_10_10_10_REV
        GLES30.glReadPixels(0, 0, width, height, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_INT_2_10_10_10_REV, byteBuffer)
        return byteArray
    }

    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;
}
"""

        /** 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 __samplerExternal2DY2YEXT sSurfaceTexture;

// 出力色
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);

  outColor.rgb = yuvToRgb(texture(sSurfaceTexture, vTextureCoord).rgb);

  FragColor = outColor;
}
"""

    }
}

OpenGlRenderer.kt

さて、今まで作ったInputSurfaceTextureRendererSurfaceTextureTextureRendererをつなぎ合わせて使えるようにするクラスを作ります。
OpenGL ESはコンテキストをスレッドに紐付けている(自分かどうかの識別にスレッドを使ってる)。ので、マルチスレッドとかはないです。
Kotlin CoroutineswithContextを使うことで、スレッドを切り替えるのが容易になったため、このようなスレッドに依存するような処理が便利だった。

詳しい話は先述の記事(ry

/**
 * OpenGL ES を利用して[TextureRendererSurfaceTexture]を描画するクラス。
 *
 * @param width 動画の横
 * @param height 動画の縦
 * @param outputSurface 描画した内容の出力先 Surface。SurfaceView、MediaRecorder、MediaCodec など
 */
class OpenGlRenderer(
    private val width: Int,
    private val height: Int,
    outputSurface: Surface
) {

    /** OpenGL 描画用スレッドの Kotlin Coroutine Dispatcher */
    @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
    private val openGlRelatedThreadDispatcher = newSingleThreadContext("openGlRelatedThreadDispatcher")

    private val inputSurface = InputSurface(outputSurface)
    private val textureRenderer = TextureRenderer()

    /** OpenGL ES の用意をし、フラグメントシェーダー等をコンパイルする */
    suspend fun prepare() {
        withContext(openGlRelatedThreadDispatcher) {
            inputSurface.makeCurrent()
            textureRenderer.prepareShader()
            GLES20.glViewport(0, 0, width, height)
        }
    }

    /** テクスチャ ID を払い出す。SurfaceTexture 作成に使うので */
    suspend fun generateTextureId(): Int {
        return withContext(openGlRelatedThreadDispatcher) {
            textureRenderer.generateTextureId()
        }
    }

    /**
     * 一回だけ描画し、glReadPixels の結果を返す。
     * 返り値の ByteArray は、[Bitmap.createBitmap]して、[Bitmap.copyPixelsFromBuffer] に渡すと Bitmap にできます。
     *
     * @param width
     * @param height 高さ
     * @param draw このブロックは GL スレッドから呼び出されます
     * @return [draw]した内容を Bitmap にしたもの
     */
    suspend fun drawOneshotAndGlReadPixels(width: Int, height: Int, draw: suspend TextureRenderer.() -> Unit): ByteArray {
        return withContext(openGlRelatedThreadDispatcher) {
            textureRenderer.prepareDraw()
            draw(textureRenderer)
            val byteArray = textureRenderer.glReadPixels(width, height)
            inputSurface.swapBuffers()
            byteArray
        }
    }

    /** 破棄する */
    suspend fun destroy() {
        // try-finally で呼び出されるため NonCancellable 必須
        withContext(openGlRelatedThreadDispatcher + NonCancellable) {
            inputSurface.destroy()
        }
        openGlRelatedThreadDispatcher.close()
    }

}

MediaCodec デコーダー

mp4からHEVCの映像データをデコーダーに渡すことで、出力結果をOpenGL ESで描画することができます。

↑パクってましたが、Issue で間違い指摘されたので、大急ぎでこの記事のコードも修正

指定した時間のデータをMediaExtractorで取り出してMediaCodecに渡しているだけ、
ちなみにOpenGL ESなしの、ImageReaderを使う場合でも結局ここのコードは書かないといけない、

余計なコードが入ってるんだけどもう消す気もなく、、

/** MediaCodec を使って動画をデコードする */
class VideoDecoder {

    private var decodeMediaCodec: MediaCodec? = null
    private var mediaExtractor: MediaExtractor? = null

    /** 最後の[seekTo]で取得したフレームの位置 */
    private var latestDecodePositionMs = 0L

    /** 前回のシーク位置 */
    private var prevSeekToMs = -1L

    /**
     * デコードの準備をする
     *
     * @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()
    }

    /**
     * シークする。
     * これを連続で呼び出しフレームを連続で取り出し再生させる。
     *
     * @param seekToMs 動画フレームの時間
     * @return [SeekResult]
     */
    suspend fun seekTo(seekToMs: Long): SeekResult {
        val isSuccessDecodeFrame = when {
            // 現在の再生位置よりも戻る方向に(巻き戻し)した場合
            seekToMs < prevSeekToMs -> {
                latestDecodePositionMs = prevSeekTo(seekToMs)
                SeekResult(
                    isSuccessful = true,
                    isNewFrame = true
                )
            }

            // シーク不要
            // 例えば 30fps なら 33ms 毎なら新しい Bitmap を返す必要があるが、 16ms 毎に要求されたら Bitmap 変化しないので
            // つまり映像のフレームレートよりも高頻度で Bitmap が要求されたら、前回取得した Bitmap がそのまま使い回せる
            seekToMs < latestDecodePositionMs -> {
                // do nothing
                SeekResult(
                    isSuccessful = true,
                    isNewFrame = false
                )
            }

            else -> {
                // 巻き戻しでも無く、フレームを取り出す必要がある
                val framePositionMsOrNull = nextSeekTo(seekToMs)
                if (framePositionMsOrNull != null) {
                    latestDecodePositionMs = framePositionMsOrNull
                }
                SeekResult(
                    isSuccessful = framePositionMsOrNull != null,
                    isNewFrame = true
                )
            }
        }
        prevSeekToMs = seekToMs
        return isSuccessDecodeFrame
    }

    /** 破棄する */
    fun destroy() {
        decodeMediaCodec?.stop()
        decodeMediaCodec?.release()
        mediaExtractor?.release()
    }

    /**
     * 前回の時間よりも次のフレームを取り出す。
     * シークするとキーフレームまで戻ってしまうので、極力シークを避けるようにしています。
     *
     * @param seekToMs 欲しいフレームの時間
     * @return 次のフレームがない場合は null。そうじゃない場合は動画フレームの時間
     */
    private suspend fun nextSeekTo(seekToMs: Long): Long? {
        val decodeMediaCodec = decodeMediaCodec!!
        val mediaExtractor = mediaExtractor!!

        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)
                // データが有ればデコーダーへ、もうデータがなければ終了シグナルを送る
                if (0 <= size) {
                    decodeMediaCodec.queueInputBuffer(inputBufferIndex, 0, size, mediaExtractor.sampleTime, 0)
                    mediaExtractor.advance()

                    // シーク先が、果てしなく遠い場所になっているときの対応
                    // 極力シークしないことで、高速にフレームを取り出せるようにしている(シークすると戻る必要が出てくるので)
                    // が、動画編集で、動画素材の最後をプレビューする際に、一切シークがないと遅くなってしまう
                    // なので、果てしなく遠い、つまり、連続で取り出して先にキーフレームが来た場合、その場合は、近場のキーフレームにシークしたほうが速いので
                    val isAvailable = mediaExtractor.sampleTime != -1L
                    val isKeyframe = mediaExtractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
                    val extractTimeMs = mediaExtractor.sampleTime / 1000L
                    if (isAvailable && isKeyframe && extractTimeMs < seekToMs) {
                        mediaExtractor.seekTo(seekToMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
                        decodeMediaCodec.flush()
                    }

                } else {
                    decodeMediaCodec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                }
            }

            // デコーダーから映像を受け取る部分
            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 (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
                            isRunning = false
                            isDecoderOutputAvailable = false
                            returnValue = null
                        }

                        if (doRender) {
                            // 欲しいフレームの時間に到達した場合、ループを抜ける
                            val presentationTimeMs = bufferInfo.presentationTimeUs / 1000
                            if (seekToMs <= presentationTimeMs) {
                                isRunning = false
                                isDecoderOutputAvailable = false
                                returnValue = presentationTimeMs
                            }
                        }
                    }
                }
            }
        }

        return returnValue
    }

    /**
     * 前回の時間よりも前のフレームを取り出す。
     * キーフレームまで戻るため[nextSeekTo]より時間がかかります。
     *
     * @param seekToMs 欲しいフレームの時間
     * @return フレームの時間
     */
    private suspend fun prevSeekTo(seekToMs: Long): Long {
        val decodeMediaCodec = decodeMediaCodec!!
        val mediaExtractor = mediaExtractor!!

        // シークする。SEEK_TO_PREVIOUS_SYNC なので、シーク位置にキーフレームがない場合はキーフレームがある場所まで戻る
        // エンコードサれたデータを順番通りに送るわけではない(隣接したデータじゃない)ので flush する
        mediaExtractor.seekTo(seekToMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
        decodeMediaCodec.flush()

        // デコーダーに渡す
        var isRunning = true
        val bufferInfo = MediaCodec.BufferInfo()
        var returnValue = 0L
        while (isRunning) {
            // キャンセル時
            yield()

            // コンテナフォーマットからサンプルを取り出し、デコーダーに渡す
            // while で繰り返しているのは、シーク位置がキーフレームのため戻った場合に、狙った時間のフレームが表示されるまで繰り返しデコーダーに渡すため
            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)
                // 狙ったフレームになるまでデータを進める
                mediaExtractor.advance()
            }

            // デコーダーから映像を受け取る部分
            var isDecoderOutputAvailable = true
            while (isDecoderOutputAvailable) {
                // デコード結果が来ているか
                val outputBufferIndex = decodeMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
                when {
                    outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
                        // リトライが必要
                        isDecoderOutputAvailable = false
                    }

                    0 <= outputBufferIndex -> {
                        // Surface へ描画
                        val doRender = bufferInfo.size != 0
                        decodeMediaCodec.releaseOutputBuffer(outputBufferIndex, doRender)
                        // 欲しいフレームの時間に到達した場合、ループを抜ける
                        val presentationTimeMs = bufferInfo.presentationTimeUs / 1000
                        if (doRender) {
                            if (seekToMs <= presentationTimeMs) {
                                isRunning = false
                                isDecoderOutputAvailable = false
                                returnValue = presentationTimeMs
                            }
                        }
                    }
                }
            }

            // もうない場合
            if (mediaExtractor.sampleTime == -1L) break
        }

        return returnValue
    }

    /**
     * [seekTo]の結果
     *
     * @param isSuccessful フレームが取得できた場合は true。もう無い場合などは false。
     * @param isNewFrame フレームが更新された場合は true。つまり動画の fps よりも早くフレームを取り出した場合、前回のフレームが使われることがあるため、その場合は false。
     */
    data class SeekResult internal constructor(
        val isSuccessful: Boolean,
        val isNewFrame: Boolean
    )

    companion object {
        /** MediaCodec タイムアウト */
        private const val TIMEOUT_US = 0L
    }
}

レイアウトを作る

あとは、Activityに動画を選ぶボタンを付けて、位置を決めてもらって、プレビューを用意して、保存ボタンを押したらUltraHDR画像が作成されるようにしましょう。
まずはレイアウト。ドーン。

SurfaceViewComposeで作る時、今まではAndroidViewを使っていたのですが、
いつの間にかAndroidExternalSurfaceってコンポーネントができていました。しかもコールバックがKotlin Coroutinessuspend funになっててモダン!!

とりあえず押した時のイベントとかは後回しで、レイアウトだけだとこうです

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            HDRVideoToUltraHDRTheme {
                MainScreen()
            }
        }
    }
}

@Composable
private fun MainScreen() {
    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(
            modifier = Modifier.padding(innerPadding),
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {

            Button(onClick = { }) {
                Text("動画選択")
            }

            AndroidExternalSurface(
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1.7f)
            ) {
                onSurface { surface, _, _ ->

                }
            }

            Slider(
                value = 0f,
                onValueChange = {}
            )

            Button(onClick = {  }) {
                Text("UltraHDR にして保存")
            }
        }
    }
}

動画を選ぶ

PhotoPickerを開くように。コールバックの中身はこのあとすぐ!

@Composable
private fun MainScreen() {
    // 動画
    val videoPicker = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia(),
        onResult = {  }
    )

    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(
            modifier = Modifier.padding(innerPadding),
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {

            Button(onClick = {
                videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
            }) {
                Text("動画選択")
            }

// 以下省略 ...

動画情報を取得

動画の時間、縦横サイズをMediaMetadataRetrieverから取り出します。シークできるように。と、縦横はglReadPixelsのときに必要になるので。
MediaCodecとか使っておきながら最後は高レベルAPIで草とかなしで。

ちなみに、MediaMetadataRetriever#use { }Android 10以降のみで、それ以前は手動でクローズする必要があります。

// 動画
val videoDurationMs = remember { mutableLongStateOf(0L) }
val currentPositionMs = remember { mutableLongStateOf(0L) }
val videoWidth = remember { mutableIntStateOf(0) }
val videoHeight = remember { mutableIntStateOf(0) }

val videoPicker = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.PickVisualMedia(),
    onResult = { uri ->
        uri ?: return@rememberLauncherForActivityResult

        // 動画情報
        MediaMetadataRetriever().use { retriever ->
            context.contentResolver.openFileDescriptor(uri, "r")?.use {
                retriever.setDataSource(it.fileDescriptor)
            }
            videoDurationMs.longValue = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
            videoWidth.intValue = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
            videoHeight.intValue = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
        }
    }
)

動画のシークバーを用意する

雑にLongFloatにしてます、Composeのスライダー、FloatRangeなんだよな、、

Slider(
    value = currentPositionMs.longValue.toFloat(),
    valueRange = 0f..videoDurationMs.longValue.toFloat(),
    onValueChange = { currentPositionMs.longValue = it.toLong() }
)

描画を準備

まず値を用意します。
別に値変化を追跡しなくていいのでmutableStateOf()は使ってません。再コンポジションに耐えられればいいのでremember { }だけ。
OpenGlRendererはインスタンス生成を待ってから、デコーダー等の準備をしないとなので、それだけはState<T>です。

あと今更ですがContextCoroutineScopeを。

val context = LocalContext.current
val scope = rememberCoroutineScope()

// OpenGL ES とか
val renderer = remember { mutableStateOf<OpenGlRenderer?>(null) }
var surfaceTexture = remember<TextureRendererSurfaceTexture?> { null }
var videoDecoder = remember<VideoDecoder?> { null }

まずはOpenGL ESの描画。これはAndroidExternalSurfaceのブロック内でSurfaceと、Surface破棄コールバックが取得できます。
さっき作ったクラスたちを呼び出していきます。

そうだ、SurfaceSizeは指定しないとglReadPixelsで黒色の余白ができてしまいます。
それから、OpenGlRendererは内部でglViewportを呼び出すため、動画の縦横サイズが必要になります。
(なんかglViewport無くても動きそうな感あるんだけど、、、、一応)

なので、もう面倒なので、
videoWidthvideoHeight0以外になるのを待ってからAndroidExternalSurfaceを描画しようと思います。2箇所で必要になるならもうこれだ。

            if (videoWidth.intValue != 0 && videoHeight.intValue != 0) {
                AndroidExternalSurface(
                    modifier = Modifier
                        .fillMaxWidth()
                        .aspectRatio(1.7f),
                    surfaceSize = IntSize(videoWidth.intValue, videoHeight.intValue)
                ) {
                    onSurface { surface, _, _ ->
                        // 多分 glViewport を呼ばないといけないので、そのためのサイズ
                        val viewportWidth = snapshotFlow { videoWidth.intValue }.first { it != 0 }
                        val viewportHeight = snapshotFlow { videoHeight.intValue }.first { it != 0 }

                        // OpenGL ES 用意
                        renderer.value = OpenGlRenderer(viewportWidth, viewportHeight, surface).apply { prepare() }
                        // 破棄されたら破棄
                        surface.onDestroyed {
                            scope.launch {
                                renderer.value?.destroy()
                                renderer.value = null
                            }
                        }
                    }
                }
            }

次にVideoDecoderSurfaceTexture
これはPhotoPickerの取得コールバックで作ることにします。これは↑のOpenGLを待った後に呼び出す必要があるため、
こっちもState<T>Flowにしてインスタンスができるのを待ってます。

val videoPicker = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.PickVisualMedia(),
    onResult = { uri ->
        uri ?: return@rememberLauncherForActivityResult

        // 動画情報
        MediaMetadataRetriever().use { retriever ->
            context.contentResolver.openFileDescriptor(uri, "r")?.use {
                retriever.setDataSource(it.fileDescriptor)
            }
            videoDurationMs.longValue = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
            videoWidth.intValue = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
            videoHeight.intValue = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
        }

        scope.launch {
            // OpenGL を待つ
            val processor = snapshotFlow { renderer.value }.filterNotNull().first()

            // デコーダーを用意する
            surfaceTexture = TextureRendererSurfaceTexture(processor.generateTextureId())
            videoDecoder = VideoDecoder().apply {
                prepare(context, uri, surfaceTexture.surface)
            }
        }
    }
)

最後に描画する関数を書きましょう。
シークした時なんかはここを呼び出す感じです。JetpackComposeだとfun drawよりもval draw = { }派がが多そう。

isSavetrueにすると写真フォルダにUltraHDR画像が保存されます。が、今思った、描画のたびにglReadPixelsしてるのはいまいちな気がしてきた。

やってることは、

  • 動画デコーダーを時間までシークして
  • SurfaceViewに描画+glReadPixelsする
  • libultrahdrで作ったC++関数を呼び出してrgba1010102からUltraHDR画像を作成
  • 写真フォルダに保存。

そういえば、glReadPixelsは上下逆さまの画像になります。
最後にUltraHDRBitmapを逆さまにする方法でもよいですが、onTransform = { }の関数で回転行列が渡されて加工できるように作ったので、(作れるようにしたので!!!)
OpenGLに描画する段階で逆さまにして、glReadPixelsすれば、正しい向きになるようになります。
欠点はプレビューも逆さまになる点です。。

それと、なぜかGoogle フォトで開いたときにUltraHDR表示になりません。
色々試した限り、Bitmapのインスタンスを経由してMediaStoreに保存すると、ちゃんとUltraHDR表示になります(よく分からない)
1行足すだけです。

googlephoto1

googlephoto2

fun draw(isSave: Boolean = false) {
    val processor = renderer.value ?: return
    val surfaceTexture = surfaceTexture ?: return
    val videoDecoder = videoDecoder ?: return

    scope.launch(Dispatchers.Default) {

        // 時間を進める
        videoDecoder.seekTo(seekToMs = currentPositionMs.longValue)

        // 描画する
        val glReadPixelsResult = processor.drawOneshotAndGlReadPixels(
            width = videoWidth.intValue,
            height = videoHeight.intValue
        ) {
            drawSurfaceTexture(
                surfaceTexture = surfaceTexture,
                onTransform = { mvpMatrix ->
                    // glReadPixels すると上下逆さまになるので、OpenGL で描画する時点で反転させておく
                    if (isSave) {
                        Matrix.scaleM(mvpMatrix, 0, 1f, -1f, 1f)
                    }
                }
            )
        }

        // 保存する場合
        if (isSave) {

            // 一時的にファイルに保存
            val rgba1010102File = context.getExternalFilesDir(null)!!
                .resolve("rgba1010102")
                .apply { writeBytes(glReadPixelsResult) }

            // 完成品パスも
            val resultFile = context.getExternalFilesDir(null)!!
                .resolve("ultrahdr_${System.currentTimeMillis()}.jpg")

            // C++ で書いた libultrahdr を呼び出す
            // resultPath に UltraHDR 画像ができる
            LibUltraHdr.createUltraHdr(
                width = videoWidth.intValue,
                height = videoHeight.intValue,
                rgba1010102PixelPath = rgba1010102File.path,
                resultPath = resultFile.path
            )

            // UltraHDR として Google フォトで認識されない問題を修正
            // 一回 Bitmap にして保存すると治る
            val libUltraHdrBitmap = BitmapFactory.decodeFile(resultFile.path)
            // Pictures/HDRVideoToUltraHdr フォルダに保存
            val fileContentValues = contentValuesOf(
                MediaStore.Images.ImageColumns.DISPLAY_NAME to resultFile.name,
                MediaStore.Images.ImageColumns.RELATIVE_PATH to "${Environment.DIRECTORY_PICTURES}/HDRVideoToUltraHdr"
            )
            val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, fileContentValues)!!
            context.contentResolver.openOutputStream(uri)?.use { outputStream ->
                resultFile.inputStream().use { inputStream ->
                    inputStream.copyTo(outputStream)
                }
            }

            // 消す
            rgba1010102File.delete()
            resultFile.delete()
        }
    }
}

これを、シークしたときと

Slider(
    value = currentPositionMs.longValue.toFloat(),
    valueRange = 0f..videoDurationMs.longValue.toFloat(),
    onValueChange = {
        currentPositionMs.longValue = it.toLong()
        // プレビュー更新
        draw()
    }
)

保存ボタンを押したときはisSave=trueで呼び出して

Button(onClick = { draw(isSave = true) }) {
    Text("UltraHDR にして保存")
}

あと、PhotoPickerで動画を選んで、動画デコーダーを起動したあとも描画するようにしました。このままだとシークバーを動かすまでSurfaceViewにプレビューされないので。。
なおGoogle Pixelが使ってるExsynosGPUだと何故かここで描画されませんSnapdragonなら動くのに謎です(???)

// デコーダーを用意する
scope.launch {
    // 多分 OpenGL ES が初期化済みなはずなので、!!
    surfaceTexture = TextureRendererSurfaceTexture(processor!!.generateTextureId())
    videoDecoder = VideoDecoder().apply {
        prepare(context, uri, surfaceTexture.surface)
    }
    // まず一回
    draw()
}

完成!!

ボタンを押して動画を選んで、シークバーを動かすと時間がプレビューに反映されて、保存を押せばUltraHDRになっているはず!

完成1

完成2

完成3

ちゃんとUltraHDR表記です。

完成4

Xperia 1 V でも動く!

1 VIIから見て2世代前ですが、ちゃんと動きました!!!
この子も10-bit HDR動画撮影が可能(しかも最大4K 120fpsでデータヤバそう)

xperia1v_1

xperia1v_2

xperia1v_3

作品集

まぶしすぎる

create_libultrahdr1

ちゃーはん

create_libultrahdr2

ここ福山雅治のMVのあそこじゃね?!?!(先に曲名言えよ)

create_libultrahdr3

そーすこーど

どうぞ!

おわりに

Xperia 1 V使ってて、新型でたら機種変しよ~~って思ったときにこのアプリを作ろうと思いました。
どーせ新型にUltraHDR撮影機能なんて搭載されないと思い込んでたので、、
思わぬ収穫だった(標準カメラで撮れるに越したことはない)

おわりに2

おわりに3

glReadPixelsがうまくいかないときに試す

  • AndroidSurfaceViewSurfaceHolder.setFixedSizeを呼び出してない
    • AndroidExternalSurfaceの場合はsurfaceSize引数
  • EGLを直接扱っている場合、swapBuffer()よりも前に呼び出す

おわりに4

私の作りが怪しい気がするけど!!!、
Google Pixelが採用してるExynosGPUだけ初回時に更新されないんだけど!?!??!
リークだからあれだけど、Pixel 10からSoCのベンダーが変わるらしい!よ?試してみたいな

それはそれとして、SnapdragonAdreno GPUが優秀すぎると毎回思う。