たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 19749
目次
本題
前面と背面が同時に撮影できる Android アプリを探してるんだけど検索妨害するのやめない?
追記 2024/10/29
ざっくり概要
令和最新版なのに CameraX 使わないんですか
つくる
プロジェクトをつくる
AndroidManifest
カメラ権限ください画面
AOSP からコードをお借りしてくる
InputSurface クラス
TextureRender を元にしたクラス
createShader 関数
draw 関数
isAvailableBackCameraFrame、isAvailableFrontCameraFrame 関数
updateBackCameraTexture、updateFrontCameraTexture 関数
setSurfaceTextureSize 関数
カメラを管理するクラスを作る
newSingleThreadContext なんで2個あるの
カメラを開く
カメラID
openCamera
openCameraFlow を呼び出す
createCaptureSession
OpenGL ES 周りを書く
OpenGlDrawPair
createOpenGlDrawPair
renderOpenGl
プレビューを OpenGL ES で描画する用意
prepare 関数
プレビューを描画する
繋ぎこみをしてプレビューを映す
CameraScreen に設置する
静止画撮影を追加
ImageReader から画像を取る
静止画撮影用 Camera2 API
動画撮影も付ける
引数追加
MediaRecorder の用意
録画開始処理、終了処理
そーすこーど
おわりに
おまけ
おわりに2
おわりに3
おわりに4
どうもこんばんわ。
アスカさんはなびかない 攻略しました。アスカさんめっちゃかわいんだけど!?!?!?
!!!
ここの話いい!
ちゃんとなびいてない!
丁寧に書かれていてよかったと思います。とくになびくまで!!
それから声優さんの声がめっちゃよかった、また出てくれないかな
おすすめです、かわいかったです
前面と背面のカメラを同時に利用して一つのView(SurfaceView)にカメラ映像を表示させようというやつです。
これの令和最新版です。もうちょっときれいなコード、マシな動作を目指します。
SurfaceTextureのコールバックが暫定対応感ある(Mutex()使えばいい)newSingleThreadContextがOpenGL ESで使えそう細かい説明は去年書いたやつに任せるとして、でもコードはほぼ書き直しです。
はい。
CameraXが追いついてきました。
もう私みたいにOpenGL ESのシェーダーやら何やら書くこと無く、2つのカメラ映像を重ねた状態でSurfaceViewで表示したり、MediaCodecで録画できるらしいです。
悔しい...ですよね?
もうCameraXを使えばいいと思いました。
前面背面それぞれプレビュー用のSurfaceViewを持つと、見る分には良いのですが、静止画、動画撮影が出来ないんですよね。
なので、どうにかして一つのSurfaceに合成する必要があります。
(Surfaceファミリー。画面に表示するSurfaceView / TextureView、録画するMediaRecorder / MediaCodec、静止画撮影のImageReader)
Surfaceってのが、なんか映像を渡すパイプみたいなやつ。これのおかげで私たちは映像データをバイト配列で扱わなくて済む。
(ブラウザ JavaScriptのMediaStreamが一番近そう。詳しくないけど。)
で、その一つのSurfaceに2つのカメラ映像を合成する方法がおそらくOpenGL ESを使うしか無い。OpenGL ESを使えば、合成処理がCPUじゃなくてGPUで処理されるので、プレビューも動画撮影も難なくこなせるはずです。OpenGL ESめっちゃ難しいけど、AOSPコピペする気でいるので。。。
OpenGL ESへカメラ映像を渡す方法ですが、SurfaceTextureクラスを使います。
これで、後述するフラグメントシェーダーからテクスチャとしてカメラ映像を利用できます。texture2D()で使うことが出来ます。WebGLでも<video>がテクスチャとして使えますが、そんな感じです。
逆にOpenGL ESで描画した内容をSurfaceViewやMediaRecorderで使う方法ですが、これもAOSPで使われているInputSurfaceクラスを使います。
私はカメラ周りの用意と、フラグメントシェーダーで2つの映像を重ねて描画する処理を書くのと、録画とプレビューの繋ぎこみ。くらいしかやっていないことがわかりますね。
あの記事を書いた後くらいに、CameraXでも同時に前面背面カメラを利用できるようになったそうです。(未検証)

CameraX | Jetpack | Android Developers
https://developer.android.com/jetpack/androidx/releases/camera
CameraXも同時にカメラを開けるようになったらしい、前回記事書いた時はダメだったのですごい!
ただ、この後に出てくるコードでプレビューを作ってるのですが、プレビューは多分SurfaceView?にあたるものを2個重ねてるだけっぽい?
静止画撮影や録画はどうすればいいのかまでは話してくれなかった。
多分撮影、録画したい場合は結局OpenGL ESとかを書かないといけない雰囲気がして、
そうなるとCamera2 API叩くのと変わらないというか、CameraX入れても享受出来る機能あんまりなさそうなんだけどどうなんだろう?。
あんまりCameraXの機能使いたい!とかないんだよな今回。PreviewViewが便利そうだけどOpenGL ESで描画したら結局使えなさそう。
| なまえ | あたい |
|---|---|
| 端末 | Pixel 8 Pro / Xperia 1 V |
| Android | プリインストールの時点で 11 以降 |
| minSdk | 30 |
| 言語 | Kotlin / OpenGL ES |
カメラ映像を一つのSurfaceViewに描画するためOpenGL ESを使います。
ががが、相変わらずAOSPのコードをコピーすることにするので、そんなに難しくないはず。
今回も今回とてKotlin コルーチンが大活躍です。
コルーチンがいたるところに出てくるので多分難しい。私もよく分からない。カメラ周りはコールバック多すぎる。
また、Android 11以降で、カメラの前後同時利用が出来るようになりましたが!!
同時利用のためにはハードウェア側も対応している必要がおそらくあり、アップデートでAndroid 11にした場合はおそらく対応していません。
Android 11以降が初めから搭載された端末の場合は多分使えます。
minSdkを30に(Android 11)。Jetpack Composeを使っても使わなくてもいいです。MainActivityにはカメラ映像を出すためのSurfaceViewがあれば最低限良いのですから。
名前ですがいい感じのを付けてください。今回は自分側のカメラ映像が小窓で映るので→こまどろいど
カメラ権限と、動画撮影でマイクを使うならマイク権限も。
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />先に権限をもらう画面を作りますか。
よくある、初回起動時に必要な権限を一気に要求するタイプの嫌なアプリになってしまう。が、カメラアプリでカメラ権限無いのは問題だしこれはこれでいいか。。。
まずは権限を確認するユーティリティクラスを
object PermissionTool {
/** 必要な権限 */
val REQUIRED_PERMISSION_LIST = arrayOf(
android.Manifest.permission.CAMERA,
android.Manifest.permission.RECORD_AUDIO
)
/** 権限があるか */
fun isGrantedPermission(context: Context): Boolean = REQUIRED_PERMISSION_LIST
.map { permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED }
.all { it }
}次に権限ください画面を
/** 権限ください画面 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PermissionScreen(onGranted: () -> Unit) {
val permissionRequest = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = {
// 権限付与された
if (it.all { it.value }) {
onGranted()
}
}
)
Scaffold(
topBar = { TopAppBar(title = { Text(text = "権限ください") }) }
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Text(text = "権限ください")
Button(onClick = {
permissionRequest.launch(PermissionTool.REQUIRED_PERMISSION_LIST)
}) { Text(text = "権限を付与") }
}
}
}カメラ画面も作ってしまいます。CameraScreen.kt
権限を貰った後は、ここに映像が描画されるようにします。
/** カメラ画面 */
@Composable
fun CameraScreen() {
}最後にMainActivityから呼び出して出るはず。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KomaDroidTheme {
CameraOrPermissionScreen()
}
}
}
}
// 権限画面かカメラ画面
@Composable
private fun CameraOrPermissionScreen() {
val context = LocalContext.current
// 権限ない場合は権限ください画面
val isGrantedPermission = remember { mutableStateOf(PermissionTool.isGrantedPermission(context)) }
if (!isGrantedPermission.value) {
PermissionScreen(
onGranted = { isGrantedPermission.value = true }
)
} else {
CameraScreen()
}
}次はCamera 2 APIでカメラの用意、、、の前にOpenGL ES周りを終わらせてしまいます。
難易度爆上がりコピーしよう
まず1つ目がこちら、InputSurfaceクラス。
これはAOSPのをコピペして私がKotlin化をしたものです。
一応分かっている範囲で説明をすると、Androidには、GLSurfaceViewっていう、AndroidでOpenGL ESで描画した内容を表示する、SurfaceViewを継承したViewがあります。OpenGL ESはメインスレッド以外で描画するので、バックグラウンドスレッドでも描画できるSurfaceViewをもと作ってます。
ただ、初めから用意されているのはSurfaceViewだけで、SurfaceViewのお友達であるTextureViewや、SurfaceViewのように表示はしないけど、代わりに録画を行うMediaRecorderなどにはそれぞれGLTextureView、GLMediaRecorderみたいなクラスがありません。存在しない!!!
そこでこのクラスです。SurfaceViewを継承したGLSurfaceViewがやっていることを多分やってくれています。
これでTextureViewやMediaRecorderでもOpenGL ESが使えるわけです。多分。
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.takusan23.komadroid.gl
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLExt
import android.view.Surface
/**
* SurfaceView / MediaRecorder / MediaCodec で描画する際に OpenGL ES の設定が必要だが、EGL 周りの設定をしてくれるやつ。
*
* @param outputSurface 出力先 [Surface]
*/
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 {
eglSetup()
}
/**
* Prepares EGL. We want a GLES 2.0 context and a surface that supports recording.
*/
private fun eglSetup() {
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 2.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, EGL14.EGL_OPENGL_ES2_BIT,
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+recordable ES2")
// Configure context for OpenGL ES 2.0.
val attrib_list = intArrayOf(
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
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)}")
}
}
companion object {
private const val EGL_RECORDABLE_ANDROID = 0x3142
}
}さっきはOpenGL ESが他のTextureViewとかでも使えるようにするためのクラスを作りました。
が、OpenGL ESの設定だけで、実際にカメラ映像を描画するためのクラスがありませんでした。それがこちらです。
説明出来るところはするけど、まずはコードを。こちらです。
/**
* 前面背面カメラを、OpenGL ES を使い、同時に重ねて描画する。
* OpenGL 用スレッドで呼び出してください。
*/
class KomaDroidCameraTextureRenderer {
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 sFrontCameraTextureHandle = 0
private var sBackCameraTextureHandle = 0
private var iDrawFrontCameraTextureHandle = 0
// スレッドセーフに Bool 扱うため Mutex と CoroutineScope
private val frameMutex = Mutex()
private val scope = CoroutineScope(Dispatchers.Default + Job())
// カメラ映像が来ているか。カメラ映像が描画ループの速度よりも遅いので
private var isAvailableFrontCameraFrame = false
private var isAvailableBackCameraFrame = false
// カメラ映像は SurfaceTexture を経由してフラグメントシェーダーでテクスチャとして使える
private var frontCameraTextureId = -1
private var backCameraTextureId = -1
// SurfaceTexture。カメラ映像をテクスチャとして使えるやつ
private var frontCameraSurfaceTexture: SurfaceTexture? = null
private var backCameraSurfaceTexture: SurfaceTexture? = null
// カメラ映像を流す Surface。SurfaceTexture として使われます
var frontCameraInputSurface: Surface? = null
private set
var backCameraInputSurface: Surface? = null
private set
init {
mTriangleVertices.put(mTriangleVerticesData).position(0)
}
/** バーテックスシェーダ、フラグメントシェーダーをコンパイルする。多分 GL スレッドから呼び出してください */
fun createShader() {
mProgram = createProgram(VERTEX_SHADER, 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")
}
sFrontCameraTextureHandle = GLES20.glGetUniformLocation(mProgram, "sFrontCameraTexture")
checkGlError("glGetUniformLocation sFrontCameraTexture")
if (sFrontCameraTextureHandle == -1) {
throw RuntimeException("Could not get attrib location for sFrontCameraTexture")
}
sBackCameraTextureHandle = GLES20.glGetUniformLocation(mProgram, "sBackCameraTexture")
checkGlError("glGetUniformLocation sBackCameraTexture")
if (sBackCameraTextureHandle == -1) {
throw RuntimeException("Could not get attrib location for sBackCameraTexture")
}
iDrawFrontCameraTextureHandle = GLES20.glGetUniformLocation(mProgram, "iDrawFrontCameraTexture")
checkGlError("glGetUniformLocation iDrawFrontCameraTexture")
if (iDrawFrontCameraTextureHandle == -1) {
throw RuntimeException("Could not get attrib location for iDrawFrontCameraTexture")
}
// テクスチャ ID を払い出してもらう
// 前面カメラの映像、背面カメラの映像で2個分
val textures = IntArray(2)
GLES20.glGenTextures(2, textures, 0)
// 1個目はフロントカメラ映像
frontCameraTextureId = textures[0]
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, frontCameraTextureId)
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")
// 2個目はバックカメラ映像
backCameraTextureId = textures[1]
GLES20.glActiveTexture(GLES20.GL_TEXTURE1)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, backCameraTextureId)
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")
// glGenTextures で作ったテクスチャは SurfaceTexture で使う
// カメラ映像は Surface 経由で受け取る
frontCameraSurfaceTexture = SurfaceTexture(frontCameraTextureId)
frontCameraInputSurface = Surface(frontCameraSurfaceTexture)
backCameraSurfaceTexture = SurfaceTexture(backCameraTextureId)
backCameraInputSurface = Surface(backCameraSurfaceTexture)
// 新しいフレームが使える場合に呼ばれるイベントリスナー
// 他のスレッドからも書き換わるので Mutex() する
frontCameraSurfaceTexture?.setOnFrameAvailableListener {
scope.launch {
frameMutex.withLock {
isAvailableFrontCameraFrame = true
}
}
}
backCameraSurfaceTexture?.setOnFrameAvailableListener {
scope.launch {
frameMutex.withLock {
isAvailableBackCameraFrame = true
}
}
}
}
/** SurfaceTexture のサイズを設定する */
fun setSurfaceTextureSize(width: Int, height: Int) {
frontCameraSurfaceTexture?.setDefaultBufferSize(width, height)
backCameraSurfaceTexture?.setDefaultBufferSize(width, height)
}
/** 新しいフロントカメラの映像が来ているか */
suspend fun isAvailableFrontCameraFrame() = frameMutex.withLock {
if (isAvailableFrontCameraFrame) {
isAvailableFrontCameraFrame = false
true
} else {
false
}
}
/** 新しいバックカメラの映像が来ているか */
suspend fun isAvailableBackCameraFrame() = frameMutex.withLock {
if (isAvailableBackCameraFrame) {
isAvailableBackCameraFrame = false
true
} else {
false
}
}
/** フロントカメラ映像のテクスチャを更新する */
fun updateFrontCameraTexture() {
if (frontCameraSurfaceTexture?.isReleased == false) {
frontCameraSurfaceTexture?.updateTexImage()
}
}
/** バックカメラ映像のテクスチャを更新する */
fun updateBackCameraTexture() {
if (backCameraSurfaceTexture?.isReleased == false) {
backCameraSurfaceTexture?.updateTexImage()
}
}
/** 描画する。GL スレッドから呼び出してください */
fun draw() {
// Snapdragon だと glClear が無いと映像が乱れる
// Google Pixel だと何も起きないのに、、、
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
// 描画する
checkGlError("draw() start")
GLES20.glUseProgram(mProgram)
checkGlError("glUseProgram")
// テクスチャの ID をわたす
GLES20.glUniform1i(sFrontCameraTextureHandle, 0) // GLES20.GL_TEXTURE0 なので 0
GLES20.glUniform1i(sBackCameraTextureHandle, 1) // GLES20.GL_TEXTURE1 なので 1
checkGlError("glUniform1i sFrontCameraTextureHandle sBackCameraTextureHandle")
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")
// --- まずバックカメラ映像を描画する ---
GLES20.glUniform1i(iDrawFrontCameraTextureHandle, 0)
checkGlError("glUniform1i iDrawFrontCameraTextureHandle")
// mMVPMatrix リセット
Matrix.setIdentityM(mMVPMatrix, 0)
backCameraSurfaceTexture?.getTransformMatrix(mSTMatrix)
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.glUniform1i(iDrawFrontCameraTextureHandle, 1)
checkGlError("glUniform1i iDrawFrontCameraTextureHandle")
// mMVPMatrix リセット
Matrix.setIdentityM(mMVPMatrix, 0)
// 右上に移動させる
// Matrix.translateM(mMVPMatrix, 0, 1f - 0.3f, 1f - 0.3f, 1f)
// 右下に移動なら
Matrix.translateM(mMVPMatrix, 0, 1f - 0.3f, -1f + 0.3f, 1f)
// 半分ぐらいにする
Matrix.scaleM(mMVPMatrix, 0, 0.3f, 0.3f, 1f)
// 描画する
frontCameraSurfaceTexture?.getTransformMatrix(mSTMatrix)
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()
}
/** 破棄時に呼び出す */
fun destroy() {
scope.cancel()
frontCameraSurfaceTexture?.release()
frontCameraInputSurface?.release()
backCameraSurfaceTexture?.release()
backCameraInputSurface?.release()
}
private fun checkGlError(op: String) {
val error = GLES20.glGetError()
if (error != GLES20.GL_NO_ERROR) {
throw RuntimeException("$op: glError $error")
}
}
/**
* GLSL(フラグメントシェーダー・バーテックスシェーダー)をコンパイルして、OpenGL ES とリンクする
*
* @throws GlslSyntaxErrorException 構文エラーの場合に投げる
* @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 GlslSyntaxErrorException 構文エラーの場合に投げる
* @throws RuntimeException それ以外
* @return 0 以外で成功
*/
private fun loadShader(shaderType: Int, source: String): Int {
var 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) {
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 = """
uniform mat4 uMVPMatrix;
uniform mat4 uSTMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
}
"""
private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sFrontCameraTexture;
uniform samplerExternalOES sBackCameraTexture;
// sFrontCameraTexture を描画する場合は 1。
// sBackCameraTexture は 0。
uniform int iDrawFrontCameraTexture;
void main() {
// 出力色
vec4 outColor = vec4(0., 0., 0., 1.);
// どっちを描画するのか
if (bool(iDrawFrontCameraTexture)) {
// フロントカメラ(自撮り)
vec4 cameraColor = texture2D(sFrontCameraTexture, vTextureCoord);
outColor = cameraColor;
} else {
// バックカメラ(外側)
vec4 cameraColor = texture2D(sBackCameraTexture, vTextureCoord);
outColor = cameraColor;
}
// 出力
gl_FragColor = outColor;
}
"""
}
}まずバーテックスシェーダとフラグメントシェーダーをコンパイルしています。
バーテックスシェーダーがどこに描画するか、フラグメントシェーダーが何色で色を塗るかですね。
原画家さんとグラフィッカーさんかな、
実際のバーテックスシェーダーとフラグメントシェーダーがどこにあるかですが、companion objectにあるVERTEX_SHADERとFRAGMENT_SHADERです。C言語みたいなやつ。GPU側で動くのでC言語みたいになっちゃいます(??)
バーテックスシェーダーの方はよくわかりません、これで画面いっぱいに描画するぜってことらしいです。
フラグメントシェーダーが色を付けてるところで、ここでカメラ映像のテクスチャから対応する座標の色を取り出して、gl_FragColorに渡してる感じです。texture2D関数が引数にテクスチャと位置(vec2)を取ります。if (bool(....))を使うことで、前面カメラの映像を描画するか、背面カメラの映像を描画するか分岐してる感じですね。
OpenGL ES(というかGPU)がなぜ速いかは、多分GPUのコア数を活かしてフラグメントシェーダーを並列で動かしてるからなんじゃないかなと。
ディスプレイの各ピクセルの色を決めるのにフラグメントシェーダーを並列で動かす。
シェーダーはこのへんで。
次はglGetAttribLocationやglGetUniformLocationが続きます。これはバーテックスシェーダー、フラグメントシェーダーへ値を渡したいときに使うやつです。GPUで動くのでCPU側で作った値は送らないといけない。変数へのアドレスみたいなのがもらえるので、glUniform1iとかを使って値を渡します。
今回はこれを使って前面カメラ、背面カメラどっちを描画するかとかをCPU側で指定した後、GPUで描画するようにしています。
最後にglGenTextures。これは画像を使いますよというやつです。2個分です。前面カメラと背面カメラ用。
最後はSurfaceTexture / Surfaceを作り、glGenTexturesで作ったテクスチャがカメラ映像になるようにします。
どーでもいいけど、Surface()のコンストラクタにSurfaceTextureのインスタンスを入れるという、なんだかよく分からないAPI設計ですよね。TextureView使ったことあれば謎に思った人が何人かいそう。
描画する処理があります。glUseProgramで、コンパイルしたシェーダーを使いますよと宣言し、
バーテックスシェーダー、フラグメントシェーダーで使ってる変数をセットします。
そのあと、まずは背面カメラの映像を描画します。そのためにフラグを切り替えます。glDrawArraysで描画。
次にフラグを前面カメラを描画するように切り替えます。
また行列操作をします。今度は小さくして、右下に配置します。
もう一回glDrawArraysすることで、背景カメラ映像の右上に前面カメラの映像が描画されるようになるはず。
OpenGL ES周りですが、記事を書いた後しばらくしてこれを使えば良いんじゃないかと思ったので、、、SurfaceTexture(カメラ映像をOpenGL ESのテクスチャとして使えるやつ)のコールバックが描画に対して速すぎるせいで、映像の更新通知がおかしくなり描画出来なくなる。とか言ってましたが。。。SurfaceTexture.OnFrameAvailableListener stops being called
I am implementing the SurfaceTexture.OnFrameAvailableListener interface in my app so I can use the video frames as an OpenGL texture. All is setup like it should and it works perfectly however
https://stackoverflow.com/questions/14185661/surfacetexture-onframeavailablelistener-stops-being-called
あれは私が複数スレッドでbooleanを書き換えていたのが原因。です。OpenGL ES描画用スレッドでフラグを折って、SurfaceTextureのコールバックでフラグを立てていた(多分メインスレッド)のが悪い。
synchronized(か同等の解決策を)して、booleanの書き換えがスレッドセーフになるようにすれば良かったのです。はい。Kotlin コルーチンではsynchronizedは動きませんが、スレッドセーフにアクセスできる代替案があります。Mutex()です。
Shared mutable state and concurrency | Kotlin
https://kotlinlang.org/docs/shared-mutable-state-and-concurrency.html
これを使えば複数のコルーチン(スレッド)から変数を操作したとしてもスレッドセーフになるはずです!
前回のInt増やしたり減らしたりするよりも良さそう感ある。
isReleased == falseをおまじない程度に入れてあります。
いらないなら消して良いはず。
Camera2 APIの解像度がこのSurfaceTexture#setDefaultBufferSizeで決めると書いてあるので。
(カメラ映像をOpenGL ESのテクスチャとして使えるSurfaceTextureを出力先にする場合、動画撮影のMediaRecorderとかはまた別)
詳しくはこのへん↓
縦持ちだとしても、横だと考えて解像度を入れる必要があるそうです。
縦だから1280x720を720x1280にする必要はない、横のまま入れて縦で使えば勝手に縦になる(?)
ちなみに利用可能な解像度は以下のように取得できます。getOutputSizesに入れればいいらしい。
val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val frontCameraId = cameraManager
.cameraIdList
.first { cameraId -> cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT }
cameraManager
.getCameraCharacteristics(frontCameraId)
.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
?.getOutputSizes(SurfaceTexture::class.java)
?.forEach {
println("${it.width}x${it.height}")
}別に1280x720みたいな、16:9以外にも正方形とかが選べたはずで、もし自撮りカメラを正方形で描画したい場合は、ここと、OpenGL ES の行列を調整してみてください。
おそらくMatrix.scaleM(mMVPMatrix, 0, 1.7f, 1f, 1f)みたいなのをやればいいはずです(?)
Camera2 APIを触るとまずぶつかるのがこのプレビューで、アスペクト比が歪んでぐちゃぐちゃになるのがセオリーですが、
今回はここで出力サイズを決めて、かつSurfaceViewも16:9になるようにしているのでぐちゃぐちゃにはなりません。多分。。。
KomaDroidCameraManagerを作りました。
カメラを管理するクラスです、今更ですが名前は何でもいいです。Contextを使うので取っておいてください。
プレビュー表示用のSurfaceView、OpenGL ES用のスレッドのためのnewSingleThreadContext(2個ある理由は後述します)、
前面背面カメラのCameraDevice、あとはコルーチン使いたいのでコルーチンスコープとかあります。
ついでに用意した材料を破棄する関数も用意しておきましょう、Jetpack Compose側から使わなくなった際に呼び出します。
/** カメラを開いたり、プレビュー用の SurfaceView を作ったり、静止画撮影したりする */
@OptIn(ExperimentalCoroutinesApi::class)
class KomaDroidCameraManager(private val context: Context) {
private val scope = CoroutineScope(Dispatchers.Default + Job())
private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private val cameraExecutor = Executors.newSingleThreadExecutor()
/** 今のタスク(というか動画撮影)キャンセル用 */
private var currentJob: Job? = null
/** プレビュー用 OpenGL ES のスレッド */
private val previewGlThreadDispatcher = newSingleThreadContext("PreviewGlThread")
/** 録画用 OpenGL ES のスレッド */
private val recordGlThreadDispatcher = newSingleThreadContext("RecordGlThread")
/** 静止画撮影用[ImageReader] */
private var imageReader: ImageReader? = null
/** 録画用[OpenGlDrawPair] */
private var recordOpenGlDrawPair: OpenGlDrawPair? = null
/** 出力先 Surface */
val surfaceView = SurfaceView(context)
/** 破棄時に呼び出す。Activity の onDestroy とかで呼んでください。 */
fun destroy() {
scope.cancel()
recordOpenGlDrawPair?.textureRenderer?.destroy()
recordOpenGlDrawPair?.inputSurface?.destroy()
previewGlThreadDispatcher.close()
recordGlThreadDispatcher.close()
}
companion object {
/** 横 */
const val CAMERA_RESOLUTION_WIDTH = 720
/** 縦 */
const val CAMERA_RESOLUTION_HEIGHT = 1280
}
}今回Mutex()と同じくらい大活躍するのがこちら、newSingleThreadContextです。
これは、新しくJavaのスレッドを作りDispatcherを返してくれます。withContext() { }やCoroutineScope.launch() { }の時に、このDispatcherを渡すと、処理されるスレッドがさっき作ったJavaのスレッドになるというやつです。
Dispatchers.IOとかDispatchers.Defaultのように、メインスレッド以外のスレッドで処理されるDispatcherがいくつかあるのに、
わざわざ新しく作るのはなぜ?と思いますよね。というわけで以下のコード。Androidはあんまり関係ないですが、
fun main() {
// テスト用なので runBlocking しています
runBlocking {
(0 until 100).map { i ->
launch(Dispatchers.Default) {
// どの Java のスレッドで処理されたかを見る
println("Index = $i / CurrentThread = ${Thread.currentThread().name}")
}
}.joinAll() // 100個終わるのを待つ
}
}出力結果がこちらです。Indexがぐちゃぐちゃなのは並列で処理したから仕方ないとして、Dispatchers.Defaultを指定するとJavaのスレッドが複数存在していることがわかります。
これはドキュメントにも書いてあって、少なくとも2つのスレッドが存在して、そのどちらかで処理されるらしいです。
Index = 90 / CurrentThread = DefaultDispatcher-worker-1
Index = 91 / CurrentThread = DefaultDispatcher-worker-1
Index = 92 / CurrentThread = DefaultDispatcher-worker-1
Index = 93 / CurrentThread = DefaultDispatcher-worker-1
Index = 95 / CurrentThread = DefaultDispatcher-worker-3
Index = 98 / CurrentThread = DefaultDispatcher-worker-3
Index = 99 / CurrentThread = DefaultDispatcher-worker-3
Index = 94 / CurrentThread = DefaultDispatcher-worker-4
Index = 97 / CurrentThread = DefaultDispatcher-worker-2
Index = 96 / CurrentThread = DefaultDispatcher-worker-1で、これの何が問題かと言うと、OpenGL ESはコンテキストがスレッドに紐ついてるんですよね。OpenGL ESの関数、glDrawArrays()とかを見てみると分かるんですが、この手の関数が全部staticなんですよね。状態を持っていない。
staticなのにどうやって自分が描画すべきOpenGL ES(EGL)が分かるのかと言うと、makeCurrent()を呼び出したスレッドに紐ついてるコンテキストに対して描画をする。から。
つまりmakeCurrent()していないスレッドでOpenGL ESの関数を呼び出しても期待通りにはならない。スレッドに紐ついてるので。
スレッドに紐ついてるので、↑のコルーチンの結果のような、起動する度にスレッドが変わると描画できなくなってしまうので、これだと困るわけです。
そこでnewSingleThreadContextです。新しくスレッドを作って、そのスレッドだけで処理を行うDispatcher。
fun main() {
// テスト用なので runBlocking しています
val singleThreadDispatcher = newSingleThreadContext("SingleThreadDispatcher")
runBlocking {
(0 until 100).map { i ->
launch(singleThreadDispatcher) {
// どの Java のスレッドで処理されたかを見る
println("Index = $i / CurrentThread = ${Thread.currentThread().name}")
}
}.joinAll() // 100個終わるのを待つ
}
}Index = 90 / CurrentThread = SingleThreadDispatcher
Index = 91 / CurrentThread = SingleThreadDispatcher
Index = 92 / CurrentThread = SingleThreadDispatcher
Index = 93 / CurrentThread = SingleThreadDispatcher
Index = 94 / CurrentThread = SingleThreadDispatcher
Index = 95 / CurrentThread = SingleThreadDispatcher
Index = 96 / CurrentThread = SingleThreadDispatcher
Index = 97 / CurrentThread = SingleThreadDispatcher
Index = 98 / CurrentThread = SingleThreadDispatcher
Index = 99 / CurrentThread = SingleThreadDispatcherこれで作ったDispatcherを指定してwithContextやlaunchすれば、同じスレッドで処理できることが約束されているので、OpenGL ESも怖くない!!!!!
以上!newSingleThreadContextでした。
まずはカメラのIDを取得するところから。
最近のスマホにはカメラが複数ついてますが、開発的に見ると一つのカメラとしてみることが出来ます(古い Android でそれが使えるかはわからない)
多分個別で広角だけ!とかも出来るんじゃないかなあ、、、
一つのカメラとしてみるので、超広角・広角・望遠の切り替えはCamera2 APIのズームする関数で自動的に選ばれる。だったはず。
/** フロントカメラの ID を返す */
private fun getFrontCameraId(): String = cameraManager
.cameraIdList
.first { cameraId -> cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT }
/** バックカメラの ID を返す */
private fun getBackCameraId(): String = cameraManager
.cameraIdList
.first { cameraId -> cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK }次にカメラを開く処理です。onOpened以外にもコールバックがあり、また、状態によって複数回コールバック関数が呼ばれるため、suspendCancellableCoroutineじゃなくてFlowにする必要があります。複数回返せるやつ。Flowで、カメラが使える時はCameraDevice、使えない場合はnullをFlow経由でもらいます。
それとopenCamera、コールバックだけじゃなくて、関数自体も例外を投げる場合があり、多分try-catchしないとダメです。
お気持ち程度にCameraDeviceをcloseしています。必要かは分からない。
/**
* カメラを開く
* 開くのに成功したら[CameraDevice]を流します。失敗したら null を流します。
*
* @param cameraId 起動したいカメラ
*/
@SuppressLint("MissingPermission")
private fun openCameraFlow(cameraId: String) = callbackFlow {
var _cameraDevice: CameraDevice? = null
cameraManager.openCamera(cameraId, cameraExecutor, object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
_cameraDevice = camera
trySend(camera)
}
override fun onDisconnected(camera: CameraDevice) {
_cameraDevice = camera
camera.close()
trySend(null)
}
override fun onError(camera: CameraDevice, error: Int) {
_cameraDevice = camera
camera.close()
trySend(null)
}
})
awaitClose { _cameraDevice?.close() }
}openCameraを呼び出します。Flowなので、どこかで購読している必要があるのですが、
今回は普通にcollect { }するのではなく、ホットフローに変換して常に動かしておこうかなと。
openCameraはcallbackFlow { }なので、末端でcollect()されるまでcallbackFlow { }は動かない(ブロック内の処理が実行されない)のですが。
プレビュー、写真撮影、動画撮影で同じCameraDeviceを使いまわしたいので、どこかでcollect()しないといけません。
この収集されるまで動かない、収集される度に起動するタイプのFlowをコールドフローとかいいますね。
ただ、今回のこのような一回だけしかFlowを作れない場合(openCameraは一回呼び出して後は使い回す)や、
高コストでcollect()の度に起動されると困る場合(インターネット通信が伴う等)の対処法があります。
それが常に動かしておくタイプのFlow、ホットフローに変換する技です。
stateIn()かsharedIn()を使えばいいのですが、今回はstateIn()を使います。stateInだとStateFlowに出来ます。これはSharedFlowと違い、常に最新の値を持っていてくれます。AndroidのLiveDataのそれと同じです。ちなみにFlowはKotlinで書かれてるのでnull安全です。
最新の値、つまりここではCameraDeviceを保持してもらうことで、プレビュー、静止画撮影、動画撮影で同じCameraDeviceを使い回せるわけです。
最新の値を持つ関係で、初期値を渡す必要があります。まあnullで。
/** 前面カメラ */
private val frontCameraFlow = openCameraFlow(getFrontCameraId()).stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = null
)
/** 背面カメラ */
private val backCameraFlow = openCameraFlow(getBackCameraId()).stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = null
)もちろん、どこか、init { }とかでcollectして、収集されたCameraDeviceをフィールドに保持するとかでもいいのですが、こっちのが綺麗にかけそう。
// これでもいいけど、stateIn で変換するのが良さそう
var cameraDevice: CameraDevice? = null
openCameraFlow(getFrontCameraId()).collect { cameraDevice = it }最後に、キャプチャーセッション?がこれまた非同期なので、コルーチンで書けるようにします。
多分こっちは一回だけonConfiguredかonConfigureFailedのどっちかが呼ばれる、、はず。
/**
* [SessionConfiguration]が非同期なので、コルーチンで出来るように
*
* @param outputSurfaceList 出力先[Surface]
*/
private suspend fun CameraDevice.awaitCameraSessionConfiguration(
outputSurfaceList: List<Surface>
) = suspendCancellableCoroutine { continuation ->
// OutputConfiguration を作る
val outputConfigurationList = outputSurfaceList.map { surface -> OutputConfiguration(surface) }
val backCameraSessionConfiguration = SessionConfiguration(SessionConfiguration.SESSION_REGULAR, outputConfigurationList, cameraExecutor, object : CameraCaptureSession.StateCallback() {
override fun onConfigured(captureSession: CameraCaptureSession) {
continuation.resume(captureSession)
}
override fun onConfigureFailed(p0: CameraCaptureSession) {
continuation.resume(null)
}
})
createCaptureSession(backCameraSessionConfiguration)
}次はAOSPからお借りしてきたInputSurface、KomaDroidCameraTextureRendererを持つだけのクラスをまず作ります。InputSurface、TextureRendererをただデータクラスにいれるだけです。扱いがちょっと楽になると言うか、引数取るときが楽になる程度です
/**
* OpenGL ES 描画のための2点セット。
* [InputSurface]、[KomaDroidCameraTextureRenderer]を持っているだけ。
*/
private data class OpenGlDrawPair(
val inputSurface: InputSurface,
val textureRenderer: KomaDroidCameraTextureRenderer
)次はこのOpenGlDrawPairを作る処理です。
引数はプレビューならSurfaceViewのSurfaceView#holder#surface、静止画撮影ならImageReader#surfaceですね。
OpenGL ES周りはOpenGL ES用に作ったスレッド(Kotlin コルーチンだとDispatcher)内で呼び出すように。必須です。
プレビュー用のOpenGL ESならpreviewGlThreadDispatcher、録画用のOpenGL ESならrecordGlThreadDispatcher。
/**
* [surface]を受け取って、[OpenGlDrawPair]を作る
* この関数は[previewGlThreadDispatcher]や[recordGlThreadDispatcher]等、OpenGL 用スレッドの中で呼び出す必要があります。
*
* @param surface 描画先
* @return [OpenGlDrawPair]
*/
private fun createOpenGlDrawPair(surface: Surface): OpenGlDrawPair {
val inputSurface = InputSurface(surface)
val textureRenderer = KomaDroidCameraTextureRenderer()
// スレッド切り替え済みなはずなので
inputSurface.makeCurrent()
textureRenderer.createShader()
// カメラ映像の解像度
// 縦持ちだとしても、横のまま入れればいいらしい
// https://developer.android.com/reference/android/hardware/camera2/CameraDevice#createCaptureSession(android.hardware.camera2.params.SessionConfiguration)
textureRenderer.setSurfaceTextureSize(width = CAMERA_RESOLUTION_HEIGHT, height = CAMERA_RESOLUTION_WIDTH)
return OpenGlDrawPair(inputSurface, textureRenderer)
}そしたら、静止画撮影のImageReaderとImageReaderに対してOpenGL ESで描画できるように初期化するやつを作ります。
繰り返しになりますが、createOpenGlDrawPairを録画用のDispatcherで呼び出すの、忘れないで。
/** 静止画モードの初期化 */
private suspend fun initPictureMode() {
imageReader = ImageReader.newInstance(
CAMERA_RESOLUTION_WIDTH,
CAMERA_RESOLUTION_HEIGHT,
PixelFormat.RGBA_8888,
2
)
// 描画を OpenGL に、プレビューと同じ
recordOpenGlDrawPair = withContext(recordGlThreadDispatcher) {
createOpenGlDrawPair(surface = imageReader!!.surface)
}
}次はOpenGL ESで描画する処理です。AOSPからコピペしてきたdraw()とかswapBuffers()とかを呼び出します。
これもスレッドはちゃんと意識しないとダメです。それ用のスレッドに切り替えてあげましょう。
/**
* [OpenGlDrawPair]を使って描画する。
* スレッド注意です!!!。[previewGlThreadDispatcher]や[recordGlThreadDispatcher]から呼び出す必要があります。
*
* @param drawPair プレビューとか
*/
private suspend fun renderOpenGl(drawPair: OpenGlDrawPair) {
if (drawPair.textureRenderer.isAvailableFrontCameraFrame() || drawPair.textureRenderer.isAvailableBackCameraFrame()) {
// カメラ映像テクスチャを更新して、描画
drawPair.textureRenderer.updateFrontCameraTexture()
drawPair.textureRenderer.updateBackCameraTexture()
drawPair.textureRenderer.draw()
drawPair.inputSurface.swapBuffers()
}
}プレビューを作る準備です。
やることはSurfaceViewでOpenGL ESが使えるようにする。(静止画撮影のImageReaderのそれと同じ)
なんですけど、SurfaceViewはコールバックを待たないと使えない。
生成コールバック、破棄コールバックに応じてOpenGL ES周りの生成と破棄をしなくちゃいけなくて、ImageReader#surfaceみたいなすぐ使えるわけじゃなくて厳しい。
これもまずはコールバックをFlowに変換するところから始めましょう。
コールバックでSurfaceViewの用意ができたら、今度はcreateOpenGlDrawPairを呼び出してプレビューのOpenGL ES用意もします。
また、カメラを開く関数と同様に、stateInしています。stateInが何なのかはopenCameraFlow関数作るところでちらっと話したのでそっちで。
これは全体でプレビューOpenGL ESを一回作って使い回すというのもあるのですが、それよりもSurfaceViewのこのコールバックがいつ呼ばれるか分からない。
分からないのでとにかく作ったら速攻コールバックを追加してFlowで監視することにします。SurfaceViewが用意済みの場合だとaddCallbackしてもsurfaceCreated()呼んでくれなさそうな雰囲気なのでスピード勝負。
コールバック自体は多分addViewとかでViewに追加されたら呼ばれそうではあります。
/**
* [SurfaceView]へ OpenGL で描画できるやつ。
* ただ、[SurfaceView]は生成と破棄の非同期コールバックを待つ必要があるため、このような[Flow]を使う羽目になっている。
* これは[OpenGlDrawPair]の生成までしかやっていないので、破棄は使う側で頼みました。
*
* また、[stateIn]でホットフローに変換し、[SurfaceView]のコールバックがいつ呼ばれても大丈夫にする。
* [callbackFlow]はコールドフローで、collect するまで動かない、いつコールバックが呼ばれるかわからないため、今回はホットフローに変換している。
*/
private val previewOpenGlDrawPairFlow = callbackFlow {
val callback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
trySend(holder)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// do nothing
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
trySend(null)
}
}
surfaceView.holder.addCallback(callback)
awaitClose { surfaceView.holder.removeCallback(callback) }
}.map { holder ->
// OpenGL ES のセットアップ
val surface = holder?.surface
if (surface != null) {
withContext(previewGlThreadDispatcher) {
createOpenGlDrawPair(surface)
}
} else null
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = null
)を書きます。
この中に、プレビュー、静止画撮影、動画撮影で共通する処理を書きます。
とりあえずはinitPictureMode()を呼ぶだけで。
/** 用意をする */
fun prepare() {
scope.launch {
// 静止画撮影の用意
initPictureMode()
}
}次はOpenGL ESでカメラのプレビューを描画する処理を書きます。OpenGL ESの描画する関数とかをprepare()内に書きます。これはプレビュー、静止画撮影、動画撮影で共通なので。
ここでやってるのはプレビューの描画と破棄だけですね。
静止画撮影のImageReaderとか、動画撮影のMediaRecorderに対して描画・破棄する処理はまた別に書こうかなと。
プレビュー用OpenGlDrawPairをもらいます。これをrenderOpenGlへ渡して、while()で繰り返し描画します。
これがプレビューの描画。スレッド注意です。
ところで、collectLatest { }これは何なんだ。という話ですが、
collect { }と違って、Flowで次の値が来たときに、既存の処理に対してキャンセルしてくれます。
普通にcollect { }すると、値の数だけwhileが起動してしまうことになります。今回は最新のOpenGlDrawPairを使って描画したい!!!
そこで、collectLatest { }に置き換える。こうすると、前回の値で起動したcollectLatest { }のブロックに対してキャンセルをしてくれます。
キャンセルするとisActiveがfalseになるので、以下のコードではfinallyへ進みます。これで最新の値でのみ動くwhileループが作れます。
あとは新しいOpenGlDrawPairが来たら、古い方は破棄しないといけないので、finallyで破棄したかったってのもあります。whileで描画→新しいOpenGlDrawPairが来る→キャンセルが投げられる→finallyで古いOpenGL ES周りが破棄される→新しいOpenGlDrawPairで描画ループが始まる。
あと一点、キャンセル投げられた後はwithContext() { }が使えません。withContextに関してはNonCancellableを引数に渡すことで、キャンセル後も動かす必要のある処理(リソース開放、クリーンアップ処理)が出来ます。
が、キャンセル命令を無視して処理することになるので、最小限にするべきです。
/** 用意をする */
fun prepare() {
scope.launch {
// 静止画撮影の用意
initPictureMode()
// プレビュー Surface で OpenGL ES の描画と破棄を行う。OpenGL ES の用意は map { } でやっている。
// 新しい値が来たら、既存の OpenGlDrawPair は破棄するので、collectLatest でキャンセルを投げてもらうようにする。
// また、録画用(静止画撮影、動画撮影)も別のところで描画
launch {
previewOpenGlDrawPairFlow.collectLatest { previewOpenGlDrawPair ->
previewOpenGlDrawPair ?: return@collectLatest
try {
// OpenGL ES の描画のためのメインループ
withContext(previewGlThreadDispatcher) {
while (isActive) {
renderOpenGl(previewOpenGlDrawPair)
}
}
} finally {
// 終了時は OpenGL ES の破棄
withContext(NonCancellable + previewGlThreadDispatcher) {
previewOpenGlDrawPair.textureRenderer.destroy()
previewOpenGlDrawPair.inputSurface.destroy()
}
}
}
}
}
}ここまで、プレビューの描画ループとかは完成しましたが、まだカメラの処理と、OpenGL ESの処理の繋ぎこみをしていないので、何も映りません。
繋ぎ込んでいきます。
前面カメラ、背面カメラ、プレビュー用OpenGL ESのやつ、全部非同期。非同期はしんどいのでFlowです。Flowにしたおかげで強力な関数が使えます。combine()です。
combine()のお友達が何個がありますが、今回はこれ。Flowを取る可変長引数と変換する関数で出来ています。
引数に渡したFlowの、それぞれ最後の値を、変換する関数の引数として呼び出し、返り値をFlowで流してくれます。
どれか一つのFlowに値が来ると、そのたびに変換する関数を呼んでくれます。値が変化していない(来ていない)Flowに関しては最後の値を使います。
Flowを変換して一つのFlowに出来るよってわかれば。今回は3つのFlowの値を受け取ってTripleに変換してFlowに流しています。
特にnullチェックはFlowの中ではやってないのでcollect { }のところで見ています。全部 null 以外になるまで進みません。
あとはCamera2 APIを叩いて前面カメラ、背面カメラの映像をOpenGL ESへ流しています。
プレビュー描画はprepare()のwhileでやっているので、ここでは特に無いです。
/** プレビューを始める */
private fun startPreview() {
scope.launch {
// キャンセルして、コルーチンが終わるのを待つ
currentJob?.cancelAndJoin()
currentJob = launch {
// カメラを開けるか
// 全部非同期なので、Flow にした後、複数の Flow を一つにしてすべての準備ができるのを待つ。
combine(
frontCameraFlow,
backCameraFlow,
previewOpenGlDrawPairFlow
) { a, b, c -> Triple(a, b, c) }.collect { (frontCamera, backCamera, previewOpenGlDrawPair) ->
// フロントカメラ、バックカメラ、プレビューの OpenGL ES がすべて準備完了になるまで待つ
frontCamera ?: return@collect
backCamera ?: return@collect
previewOpenGlDrawPair ?: return@collect
val recordOpenGlDrawPair = recordOpenGlDrawPair ?: return@collect
// フロントカメラの設定
// 出力先
val frontCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.frontCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.frontCameraInputSurface
)
val frontCameraCaptureRequest = frontCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
frontCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val frontCameraCaptureSession = frontCamera.awaitCameraSessionConfiguration(frontCameraOutputList)
frontCameraCaptureSession?.setRepeatingRequest(frontCameraCaptureRequest, null, null)
// バックカメラの設定
val backCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.backCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.backCameraInputSurface
)
val backCameraCaptureRequest = backCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
backCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val backCameraCaptureSession = backCamera.awaitCameraSessionConfiguration(backCameraOutputList)
backCameraCaptureSession?.setRepeatingRequest(backCameraCaptureRequest, null, null)
}
}
}
}あとは起動したらまずプレビューに遷移するように、prepare()で呼び出します。
/** 用意をする */
fun prepare() {
scope.launch {
// 以下省略...
// プレビューを開始する
startPreview()
}
}空っぽのCameraScreenに手を入れます。SurfaceView、作っただけで画面に追加してないので追加します。CameraScreenでKomaDroidCameraManagerのインスタンスを作って、AndroidViewでSurfaceViewを追加します。
/** カメラ画面 */
@Composable
fun CameraScreen() {
val context = LocalContext.current
val cameraManager = remember { KomaDroidCameraManager(context) }
// カメラを開く、Composable が破棄されたら破棄する
DisposableEffect(key1 = Unit) {
cameraManager.prepare()
onDispose { cameraManager.destroy() }
}
Box(modifier = Modifier.fillMaxSize()) {
// OpenGL ES を描画する SurfaceView
// アスペクト比
AndroidView(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.aspectRatio(KomaDroidCameraManager.CAMERA_RESOLUTION_WIDTH / KomaDroidCameraManager.CAMERA_RESOLUTION_HEIGHT.toFloat()),
factory = { cameraManager.surfaceView }
)
Button(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 50.dp),
onClick = { /* TODO この後すぐ */ }
) { Text(text = "写真撮影") }
}
}そして実行してみる、、、、
どうでしょう???プレビューでた?
まずは静止画撮影のImageReaderから画像を取り出して保存する処理を。ImageReaderのモードがRGBA_8888なのでこれで動きますが、JPEGとかはまた別の処理だと思います。
この辺は前回の記事と同じですね。謎の余白を消す処理も健在。
/** [ImageReader]から写真を取り出して、端末のギャラリーに登録する拡張関数。 */
private suspend fun ImageReader.saveJpegImage() = withContext(Dispatchers.IO) {
val image = acquireLatestImage()
val width = image.width
val height = image.height
val planes = image.planes
val buffer = planes[0].buffer
// なぜか ImageReader のサイズに加えて、何故か Padding が入っていることを考慮する必要がある
val pixelStride = planes[0].pixelStride
val rowStride = planes[0].rowStride
val rowPadding = rowStride - pixelStride * width
// Bitmap 作成
val readBitmap = Bitmap.createBitmap(width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888)
readBitmap.copyPixelsFromBuffer(buffer)
// 余分な Padding を消す
val editBitmap = Bitmap.createBitmap(readBitmap, 0, 0, CAMERA_RESOLUTION_WIDTH, CAMERA_RESOLUTION_HEIGHT)
readBitmap.recycle()
// ギャラリーに登録する
val contentResolver = context.contentResolver
val contentValues = contentValuesOf(
MediaStore.Images.Media.DISPLAY_NAME to "${System.currentTimeMillis()}.jpg",
MediaStore.Images.Media.RELATIVE_PATH to "${Environment.DIRECTORY_PICTURES}/KomaDroid"
)
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) ?: return@withContext
contentResolver.openOutputStream(uri)?.use { outputStream ->
editBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
}
// 開放
editBitmap.recycle()
image.close()
}静止画撮影の場合は、カメラのセットアップが若干変わって、CameraDevice.TEMPLATE_STILL_CAPTUREと、CameraCaptureSession#captureを呼び出します。どちらも一回ポッキリ撮影するのに最適化したモード(らしい)です。
プレビューと違って、静止画撮影は短時間で終わるので、combine()でFlowを監視したりはしません!
プレビューの場合は長い時間使われるので、プレビュー OpenGLやカメラが使えなくなったら再起動出来るようcollectしてましたが、すぐ終わるのでfirst()で取って終わりにします。
/**
* 静止画撮影する
* 静止画撮影用に[CameraDevice.TEMPLATE_STILL_CAPTURE]と[CameraCaptureSession.capture]が使われます。
*/
fun takePicture() {
scope.launch {
// キャンセルして、コルーチンが終わるのを待つ
currentJob?.cancelAndJoin()
currentJob = launch {
// 用意が揃うまで待つ
val frontCamera = frontCameraFlow.filterNotNull().first()
val backCamera = backCameraFlow.filterNotNull().first()
val previewOpenGlDrawPair = previewOpenGlDrawPairFlow.filterNotNull().first()
val recordOpenGlDrawPair = recordOpenGlDrawPair!!
// フロントカメラの設定
// 出力先
val frontCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.frontCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.frontCameraInputSurface
)
val frontCameraCaptureRequest = frontCamera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
frontCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val frontCameraCaptureSession = frontCamera.awaitCameraSessionConfiguration(frontCameraOutputList)
frontCameraCaptureSession?.capture(frontCameraCaptureRequest, null, null)
// バックカメラの設定
val backCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.backCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.backCameraInputSurface
)
val backCameraCaptureRequest = backCamera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
backCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val backCameraCaptureSession = backCamera.awaitCameraSessionConfiguration(backCameraOutputList)
backCameraCaptureSession?.capture(backCameraCaptureRequest, null, null)
// ImageReader に描画する
withContext(recordGlThreadDispatcher) {
renderOpenGl(recordOpenGlDrawPair)
}
// ImageReader で取り出す
imageReader?.saveJpegImage()
// 撮影したらプレビューに戻す
withContext(Dispatchers.Main) {
Toast.makeText(context, "撮影しました", Toast.LENGTH_SHORT).show()
}
startPreview()
}
}
}あとはJetpack Compose側で、ボタンを押したときにtakePicture()を呼べば完成。
保存先は写真フォルダ。DCIMじゃなくてPicturesっぽいです(何が違うのかよく分からない)。Google フォトとかで見れるはず。
Button(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 50.dp),
onClick = { cameraManager.takePicture() } // ←ここ
) { Text(text = "写真撮影") }ちなみに盾持ちなら問題ないですが、横で撮影すると回転状態になってしまいます・・・Bitmapを回転すれば良いのでしょうが、、、面倒なので今回は無しで。
Camera2 APIとImageReaderの組み合わせなら、CaptureRequest.JPEG_ORIENTATIONで回転できるらしい(使ったこと無い)ですが、
今回はOpenGL ESで描画した内容をImageReaderで撮影しているのでその方法は使えないと思います。愚直にBitmapを回転させないとダメそう。
KomaDroidCameraManagerに録画機能も付けます。
同じクラスに静止画撮影と動画撮影が混在する(しかもどちらかしか使えない)のでなんとかしたほうがいいですが。今回は動くところ最優先なのでやりません!
クラスのコンストラクタ引数に、どっちのモードで使うかを決めます。
それ用のenumも作りました
class KomaDroidCameraManager(
private val context: Context,
private val mode: CaptureMode // これ
) {
/** 静止画撮影 or 録画 */
enum class CaptureMode {
/** 静止画撮影 */
PICTURE,
/** 録画 */
VIDEO
}
}つぎに、ImageReaderと同じように、MediaRecorderを作ります。
録画用ですね。静止画撮影とクラスを分けなかったせいでぐちゃぐちゃになってきました。あと別にMediaCodecでも動くと思いますがクソ難しくなると思います。
/** 静止画撮影用[ImageReader] */
private var imageReader: ImageReader? = null
// 下2つを足す
/** 録画用の[MediaRecorder] */
private var mediaRecorder: MediaRecorder? = null
/** 録画保存先 */
private var saveVideoFile: File? = null次に、MediaRecorderを初期化する関数を書きます。ImageReaderのやつをMediaRecorderにしただけ。出力先ファイルを初期化時に決めないといけないので、一時的にgetExternalFilesDirに保存するようにしています。
録画終了時に端末の動画フォルダへ移動させます。
映像コーデックがH.264なので高めのビットレートで(これでもまだ低いかも。1280x720なのでまあこれでも。)
別にH.265 (HEVC)とかでも良いのよ、使っても大丈夫なら。VP9は使っても問題ないはず。AV1はPixel 8シリーズにしかハードウェアエンコーダーが無いからまだ厳しい!
/** 録画モードの初期化 */
private suspend fun initVideoMode() {
mediaRecorder = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) else MediaRecorder()).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setVideoSource(MediaRecorder.VideoSource.SURFACE)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setVideoEncoder(MediaRecorder.VideoEncoder.H264)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioChannels(2)
setVideoEncodingBitRate(3_000_000) // H.264 なので高めに
setVideoFrameRate(30)
setVideoSize(CAMERA_RESOLUTION_WIDTH, CAMERA_RESOLUTION_HEIGHT)
setAudioEncodingBitRate(128_000)
setAudioSamplingRate(44_100)
// 一時的に getExternalFilesDir に保存する
saveVideoFile = File(context.getExternalFilesDir(null), "${System.currentTimeMillis()}.mp4")
setOutputFile(saveVideoFile!!)
prepare()
}
// 描画を OpenGL に、プレビューと同じ
recordOpenGlDrawPair = withContext(recordGlThreadDispatcher) {
createOpenGlDrawPair(surface = mediaRecorder!!.surface)
}
}次に、prepare()関数を書き直してモード別に初期化処理を分岐させます。initVideoMode()が増えただけ、
/** 用意をする */
fun prepare() {
scope.launch {
// モードに応じて初期化を分岐
when (mode) {
CaptureMode.PICTURE -> initPictureMode()
CaptureMode.VIDEO -> initVideoMode()
}
// プレビュー Surface で OpenGL ES の描画と破棄を行う。OpenGL ES の用意は map { } でやっている。
// 新しい値が来たら、既存の OpenGlDrawPair は破棄するので、collectLatest でキャンセルを投げてもらうようにする。
// また、録画用(静止画撮影、動画撮影)も別のところで描画
launch {
previewOpenGlDrawPairFlow.collectLatest { previewOpenGlDrawPair ->
previewOpenGlDrawPair ?: return@collectLatest
try {
// OpenGL ES の描画のためのメインループ
withContext(previewGlThreadDispatcher) {
while (isActive) {
renderOpenGl(previewOpenGlDrawPair)
}
}
} finally {
// 終了時は OpenGL ES の破棄
withContext(NonCancellable + previewGlThreadDispatcher) {
previewOpenGlDrawPair.textureRenderer.destroy()
previewOpenGlDrawPair.inputSurface.destroy()
}
}
}
}
// プレビューを開始する
startPreview()
}
}次、録画開始処理と終了処理を書きます。
一応動画撮影に向いたCameraDevice.TEMPLATE_RECORDにしてみました。
プレビューと大体同じで、違うのはMediaRecorder#startしている点ですね。finallyで録画終了処理をしています。cancel()されたときか、collectLatestから新しい値が来たときですかね。collectLatestなので、カメラ開けなくなった、プレビューがだめになったとかでnullが来たとしても、録画中の処理に対してキャンセルを投げてくれるので、finallyで保存されるんじゃないかなーと。
コルーチンがキャンセルされた場合、新しくコルーチンが作れないのでwithContextとNonCancellableを使う必要があります、
が、これも先述しましたが、終了処理のみで使うに留めておいてね、むやみやたらに使うべきじゃないです。多分。
MediaRecorderを作り直して、startPreviewを呼んでプレビューへ戻してあげます。stopしたら作り直さないといけないので。関数にしておいてよかったMediaRecorderの初期化処理。
イマイチな点としては、動画フォルダへ動画ファイルが移動し終わるまでプレビューに戻らない点ですね。面倒なのでやりませんが。
/**
* 動画撮影をする
* 静止画撮影用に[CameraDevice.TEMPLATE_RECORD]と[CameraCaptureSession.setRepeatingRequest]が使われます。
*/
fun startRecordVideo() {
scope.launch {
// キャンセルして、コルーチンが終わるのを待つ
currentJob?.cancelAndJoin()
currentJob = launch {
// カメラを開けるか
// 全部非同期なのでコールバックを待つ
combine(
frontCameraFlow,
backCameraFlow,
previewOpenGlDrawPairFlow
) { a, b, c -> Triple(a, b, c) }.collectLatest { (frontCamera, backCamera, previewOpenGlDrawPair) ->
// フロントカメラ、バックカメラ、プレビューの OpenGL ES がすべて準備完了になるまで待つ
frontCamera ?: return@collectLatest
backCamera ?: return@collectLatest
previewOpenGlDrawPair ?: return@collectLatest
val recordOpenGlDrawPair = recordOpenGlDrawPair ?: return@collectLatest
// フロントカメラの設定
// 出力先
val frontCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.frontCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.frontCameraInputSurface
)
val frontCameraCaptureRequest = frontCamera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
frontCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val frontCameraCaptureSession = frontCamera.awaitCameraSessionConfiguration(frontCameraOutputList)
frontCameraCaptureSession?.setRepeatingRequest(frontCameraCaptureRequest, null, null)
// バックカメラの設定
val backCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.backCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.backCameraInputSurface
)
val backCameraCaptureRequest = backCamera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
backCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val backCameraCaptureSession = backCamera.awaitCameraSessionConfiguration(backCameraOutputList)
backCameraCaptureSession?.setRepeatingRequest(backCameraCaptureRequest, null, null)
// 録画開始
mediaRecorder?.start()
try {
// MediaRecorder に OpenGL ES で描画
// 録画中はループするのでこれ以降の処理には進まない
withContext(recordGlThreadDispatcher) {
while (isActive) {
renderOpenGl(recordOpenGlDrawPair)
}
}
} finally {
// 録画終了処理
// stopRecordVideo を呼び出したときか、collectLatest から新しい値が来た時
// キャンセルされた後、普通ならコルーチンが起動できない。
// NonCancellable を付けることで起動できるが、今回のように終了処理のみで使いましょうね
withContext(NonCancellable) {
mediaRecorder?.stop()
mediaRecorder?.release()
// 動画ファイルを動画フォルダへコピーさせ、ファイルを消す
withContext(Dispatchers.IO) {
val contentResolver = context.contentResolver
val contentValues = contentValuesOf(
MediaStore.Images.Media.DISPLAY_NAME to saveVideoFile!!.name,
MediaStore.Images.Media.RELATIVE_PATH to "${Environment.DIRECTORY_MOVIES}/KomaDroid"
)
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 は stop したら使えないので、MediaRecorder を作り直してからプレビューに戻す
initVideoMode()
startPreview()
}
}
}
}
}
}
/** [startRecordVideo]を終了する */
fun stopRecordVideo() {
// startRecordVideo の finally に進みます
currentJob?.cancel()
}最後にJetpack Compose側から呼び出して終わりです。
静止画撮影と動画撮影でKomaDroidCameraManagerを分けたので、画面も分けることにしました。その結果がこちらです。
切り替えボタンも追加しました。Material3のSegmentedButtonです。アニメーションされて綺麗。
切り替えボタンを押すと、それぞれの画面へ切り替わります。KomaDroidCameraManagerも再生成されます。
録画中か知るすべを用意しそこねたので雑に画面の方においておきました。
本当はKomaDroidCameraManagerが提供すべきですね。
録画画面の方は録画中に応じてstartRecordVideo / stopRecordVideoを分岐させます。
それ以外は静止画撮影と同じレイアウトですね。
/** カメラ画面 */
@Composable
fun CameraScreen() {
val context = LocalContext.current
// 静止画撮影 or 動画撮影
val currentMode = remember { mutableStateOf(KomaDroidCameraManager.CaptureMode.PICTURE) }
Box(modifier = Modifier.fillMaxSize()) {
// 静止画モード・動画撮影モード
when (currentMode.value) {
KomaDroidCameraManager.CaptureMode.PICTURE -> PictureModeScreen()
KomaDroidCameraManager.CaptureMode.VIDEO -> VideoModeScreen()
}
// 切り替えボタン
SwitchModeButton(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
currentMode = currentMode.value,
onSelect = { currentMode.value = it }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SwitchModeButton(
modifier: Modifier = Modifier,
currentMode: KomaDroidCameraManager.CaptureMode,
onSelect: (KomaDroidCameraManager.CaptureMode) -> Unit
) {
SingleChoiceSegmentedButtonRow(modifier = modifier) {
KomaDroidCameraManager.CaptureMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == currentMode,
onClick = { onSelect(mode) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = KomaDroidCameraManager.CaptureMode.entries.size
)
) {
Text(text = mode.name)
}
}
}
}
@Composable
private fun PictureModeScreen() {
val context = LocalContext.current
val cameraManager = remember { KomaDroidCameraManager(context, KomaDroidCameraManager.CaptureMode.PICTURE) }
// カメラを開く、Composable が破棄されたら破棄する
DisposableEffect(key1 = Unit) {
cameraManager.prepare()
onDispose { cameraManager.destroy() }
}
Box(modifier = Modifier.fillMaxSize()) {
// OpenGL ES を描画する SurfaceView
// アスペクト比
AndroidView(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.aspectRatio(KomaDroidCameraManager.CAMERA_RESOLUTION_WIDTH / KomaDroidCameraManager.CAMERA_RESOLUTION_HEIGHT.toFloat()),
factory = { cameraManager.surfaceView }
)
Button(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 50.dp),
onClick = { cameraManager.takePicture() }
) { Text(text = "写真撮影") }
}
}
@Composable
private fun VideoModeScreen() {
val context = LocalContext.current
val cameraManager = remember { KomaDroidCameraManager(context, KomaDroidCameraManager.CaptureMode.VIDEO) }
// 仮でここに置かせて
val isRecording = remember { mutableStateOf(false) }
// カメラを開く、Composable が破棄されたら破棄する
DisposableEffect(key1 = Unit) {
cameraManager.prepare()
onDispose { cameraManager.destroy() }
}
Box(modifier = Modifier.fillMaxSize()) {
// OpenGL ES を描画する SurfaceView
// アスペクト比
AndroidView(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.aspectRatio(KomaDroidCameraManager.CAMERA_RESOLUTION_WIDTH / KomaDroidCameraManager.CAMERA_RESOLUTION_HEIGHT.toFloat()),
factory = { cameraManager.surfaceView }
)
Button(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 50.dp),
onClick = {
if (isRecording.value) {
cameraManager.stopRecordVideo()
} else {
cameraManager.startRecordVideo()
}
isRecording.value = !isRecording.value
}
) {
Text(text = if (isRecording.value) "録画終了" else "録画開始")
}
}
}こんな感じに切り替えボタンが出て、切り替えた後に録画ボタンを押せば撮影されるはず。
保存先は動画フォルダです。これもGoogle フォトとかで見れるはず。
Material3のSegmentedButton、いい感じ
ブランチ名blog_codeのがそうです。ブログ記述時時点のコードがあります多分。
カメラアプリ開発はとてもとても大変。OpenGL ES周りの扱いががががが、割とOpenGL ES周りだけ結構書き直してる。
OpenGL ES周りの難しい部分を隠すことで、OpenGL ESの上に構築してGPUの性能を享受しつつ、他のアプリでも難しくないAPIを公開しよう。って考えてみた。仮です。SurfaceTexture(カメラ映像、動画のフレームデコード結果)を描画しつつ、Canvasで文字とかも書ける。さらにはエフェクトだって適用できる、みたいな。
もしまともに使えそうならまた記事にしようかな。
val previewSurface = viewBinding.previewSurface.holder.surface
// OpenGL ES の上に構築された、映像を加工するやつ
val akariGraphicsProcessor = AkariGraphicsProcessor(
outputSurface = previewSurface.surface,
width = CAMERA_RESOLUTION_WIDTH,
height = CAMERA_RESOLUTION_HEIGHT
)
akariGraphicsProcessor.prepare()
// カメラ映像を OpenGL のテクスチャとして利用できる SurfaceTexture を作る
val frontCameraTexture = akariGraphicsProcessor.genTextureId { texId -> AkariSurfaceTexture(texId) }
val backCameraTexture = akariGraphicsProcessor.genTextureId { texId -> AkariSurfaceTexture(texId) }
// エフェクト
val blurEffect = akariGraphicsProcessor.genEffect { AkariEffectFragmentShader(Shaders.BLUR) }
// カメラのセットアップ
// 省略...
frontCameraCaptureSession?.setRepeatingRequest(...)
backCameraCaptureSession?.setRepeatingRequest(...)
try {
// 描画のループ
akariGraphicsProcessor.drawLoop {
// カメラ映像描画
drawSurfaceTexture(backCameraTexture) { mvpMatrix ->
// do nothing
}
drawSurfaceTexture(frontCameraTexture) { mvpMatrix ->
// 小さくする
Matrix.scaleM(mvpMatrix, 0, 0.3f, 0.3f, 0.3f)
}
// Canvas も重ねる
drawCanvas {
drawText("Camera Preview", 100f, 100f, paint)
}
// エフェクト
textureRenderer.applyEffect(blurEffect)
}
} finally {
akariGraphicsProcessor.destroy()
}というわけで、OpenGL ESで描画したいとか、同時にカメラを開く(これはCameraXで出来るらしい)とか、静止画撮影対象がOpenGL ES、とかのマニアックな使い方をしない場合は、
大人しくCameraXを頼ったほうが良いはずです。使ったこと無いので何もわからないですが。
プレビューもクソ大変だし、Camera2 APIもコールバックばっかでしんどいし。
プレビュー出すまでにどれだけのコールバックが必要なんだろう、数えたくないけど。
ちなみに、SurfaceViewでOpenGL ESを使いたいだけならGLSurfaceViewってのがあるのでそれでいいと思います。
ただ、今回は静止画撮影、動画撮影と同じ描画処理を使い回したかったのでに使っていません。
SurfaceView + OpenGLES = GLSurfaceViewは存在しますが、ImageReader + OpenGLES = GLImageReaderやMediaRecorder + OpenGLES = GLMediaRecorderは存在しないので、それらと繋ぐ部分は結局必要。プレビューだけGLSurfaceViewにする旨味は多分無い。
Google Pixelだと以下のコードが落ちます。逆にSnapdragonだと問題なく動いて悩んだ。Snapdragonが優秀説ある?他のSoCのスマホがなくて試せない。
// SurfaceView と OpenGL ES 用スレッドを作る
val openGlDispatcher = newSingleThreadContext("OpenGlThread")
val surfaceView = SurfaceView(this)
// SurfaceView の生成コールバックを待つ
surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// Surface が出来たら OpenGL ES の作成(どっちかというと EGL の作成)と破棄を2回やる
lifecycleScope.launch {
// InputSurface.java
// https://cs.android.com/android/platform/superproject/main/+/main:cts/tests/mediapc/src/android/mediapc/cts/InputSurface.java
val inputSurface1 = InputSurface(holder.surface)
withContext(openGlDispatcher) {
inputSurface1.makeCurrent()
}
inputSurface1.release()
// 同じ Surface で、違う EGL のセットアップをする。inputSurface1 は破棄済み
val inputSurface2 = InputSurface(holder.surface)
withContext(openGlDispatcher) {
inputSurface2.makeCurrent()
}
inputSurface2.release()
println("おわり")
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// do nothing
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// do nothing
}
})
// 画面に置く
setContentView(surfaceView)スタックトレース
FATAL EXCEPTION: main
Process: io.github.takusan23.opengldrivererror, PID: 31834
java.lang.RuntimeException: eglCreateWindowSurface: EGL error: 0x3003
at io.github.takusan23.opengldrivererror.InputSurface.checkEglError(InputSurface.java:29)
at io.github.takusan23.opengldrivererror.InputSurface.createEGLSurface(InputSurface.java:88)
at io.github.takusan23.opengldrivererror.InputSurface.eglSetup(InputSurface.java:76)
at io.github.takusan23.opengldrivererror.InputSurface.<init>(InputSurface.java:120)
at io.github.takusan23.opengldrivererror.MainActivity$onCreate$1$surfaceCreated$1.invokeSuspend(MainActivity.kt:35)ちなみに直せます、記事ほぼ書き終わった今気付いた。InputSurface#releaseもOpenGL ES用スレッドから呼び出せば良いです。じゃあSnapdragonで動いたのはなんで???
// Surface が出来たら OpenGL ES の作成(どっちかというと EGL の作成)と破棄を2回やる
lifecycleScope.launch {
// InputSurface.java
// https://cs.android.com/android/platform/superproject/main/+/main:cts/tests/mediapc/src/android/mediapc/cts/InputSurface.java
val inputSurface1 = InputSurface(holder.surface)
withContext(openGlDispatcher) {
inputSurface1.makeCurrent()
inputSurface1.release()
}
// 同じ Surface で、違う EGL のセットアップをする。inputSurface1 は破棄済み
val inputSurface2 = InputSurface(holder.surface)
withContext(openGlDispatcher) {
inputSurface2.makeCurrent()
inputSurface2.release()
}
println("おわり")
}