背景
最近接了个需求,需要对录制音视频做审核的功能升级,历史逻辑是调用系统的录制功能,该需求有很多定制功能,系统录制已无法满足诉求,所以需要我们自己去录制音视频。为了方便,本次使用了MediaRecorder+Camera2进行录制音视频,但在后期发现单纯使用 MediaRecorder+Camera2 预览没有问题,但录制的视频无法直接做镜像处理,除非使用FFMPEG等工具后期处理。于是又引进了OpenGL,通过 OpenGL 将相机原始数据镜像处理到FBO中,再将FBO渲染到SurfaceView的Surface跟MediaRecorder的Surface中。
在使用OpenGL渲染的过程中也遇到了各种各样的问题,通过这篇文章总结记录一下。
方案选型
业界录制音视频的方案非常多,由于该场景功能简单,对性能要求不高,同时开发联调时间就一周左右,故技术选型上直接采用了 MediaRecorder,MediaRecorder的优点是封装程度高,使用简单,兼容性强。
刚开始跟预想的一样,很快就完成了该功能的开发,并用两款手机自测通过,然后我就去干其他事情了。
在快提测的时候,我发现了一个bug,前置摄像头录制的视频无镜像,左右画面是反的,预览没有问题。刚开始还没意识到问题的棘手程度,觉得MediaRecorder应该调个api就能实现镜像了。但事与愿违,不存在这样的一个api。
用豆包、chatgpt、deepseek搜索了一圈,给出了各种奇奇怪怪的解决方案,最终经测试有的是针对预览的,有的api Android压根不存在。最终得出了一个结论,MediaRecorder默认情况都是使用相机的原始数据,想简单的实现 MediaRecorder 的录制镜像是不可能的, 如果要实现录制镜像,必须手动处理原始数据,然后再将数据吐给MediaRecorder的Surface。
音视频录制
接下来详细介绍下使用MediaRecorder录制音视频的过程。
相机采集
下面是相机采集的全流程。
-
创建一个外部的纹理ID,这需要再GL环境中执行,本次图方便,没有使用EGL去创建环境,直接使用GlSurfaceView,在onSurfaceCreated中执行即可。
val textures = IntArray(1) GLES20.glGenTextures(1, textures, 0) mExternalTextureId = textures[0] ``` -
根据步骤1的纹理ID创建一个SurfaceTexture,并监听是否有新的帧用来刷新预览和录制视频。SurfaceTexture在我之前的文章已经提过很多次了,这里就不多加赘述了。
mSurfaceTexture = SurfaceTexture(mExternalTextureId) mSurfaceTexture.setOnFrameAvailableListener { glSurfaceView.requestRender() } ``` -
将SurfaceTexture包装成Surface,并使用Camera2打开相机,相机的数据流会根据SurfaceTexture 刷新到步骤1创建的纹理id上。
fun openCamera() { try { manager.openCamera(mCameraId, object : CameraDevice.StateCallback() { override fun onOpened(@NonNull camera: CameraDevice) { mCameraDevice = camera createCameraPreviewSession(camera) } ... }, null) } catch (e: CameraAccessException) { e.printStackTrace() } } private fun createCameraPreviewSession(c) { try { val surfaceTexture = view.getSurfaceTexture() surfaceTexture?.setDefaultBufferSize(mPreviewSize.getWidth(), mPreviewSize.getHeight()) val previewSurface = Surface(surfaceTexture) val outputSurfaces: MutableList<Surface> = ArrayList() outputSurfaces.add(previewSurface) // 创建CaptureRequest val builder = camera.createCaptureRequest( CameraDevice.TEMPLATE_PREVIEW ) builder.addTarget(previewSurface) // 创建CaptureSession camera.createCaptureSession(outputSurfaces) } catch (e: CameraAccessException) { e.printStackTrace() } }
相机数据处理
相机采集到数据之后,就需要加工成最终要呈现的画面,这期间需要进行一系列的换算。
相机的数据处理采用 OpenGl 标准的MVP矩阵变换。
最终坐标 = P(Projection) * V(View) * M(Model)*原始坐标,这个计算的顺序不能乱,可以使用结合律,不能使用交换律。
投影矩阵(Projection)
该场景属于2D画面,我们使用正交矩阵即可,如果不使用投影矩阵,相机的画面会把拉伸变形。
```
// 计算新的正交矩阵
val sWhView: Float = viewWidth.toFloat() / viewHeight
val sWhImg: Float = cameraWidth / cameraHeight
if (sWhImg > sWhView) {
Matrix.orthoM(mProjectMatrix, 0, -sWhView / sWhImg, sWhView / sWhImg, -1f, 1f, 1f, 3f
)
} else {
Matrix.orthoM(
mProjectMatrix, 0, -1f, 1f, -sWhImg / sWhView, sWhImg / sWhView, 1f, 3f
)
}
```
视图矩阵(View)
视图矩阵用于定义相机的位置和方向,它会把世界坐标系中的物体转换到相机坐标系。也就是确定从哪个位置、以什么角度去观察场景。2d场景比较简单,就是放在z轴上的某一点正着方向去瞅即可。
Matrix.setLookAtM(mViewMatrix, 0, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f)
模型矩阵(M)
我们这次重点要处理的旋转跟镜像就属于模型矩阵。也有很多老司机喜欢把这里的变换放在片段着色器中,但根据规范而言,放在这里是最合适的。
Camera吐出的数据前后摄像头的旋转角度不一样,如果手机保持竖直方向不变的话,后置需要旋转270度,前置旋转90。 当横屏的时候,这个算法就会出现问题。如果想要更严谨精确的角度可以通过再下面的方法计算旋转角度。
if (currentCameraId == Camera.CameraInfo.CAMERA_FACING_BACK) {
Matrix.rotateM(mModelMatrix,270,0f,0f,1f)
} else {
Matrix.rotateM(mModelMatrix,0,90f,0f,0f,1f)
Matrix.scaleM(mModelMatrix,0,1.0f,-1.0f,1.0f)
}
// 精确计算角度
private fun getDeviceOrientation(): Int {
val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
val display = windowManager.defaultDisplay
val rotation: Int = display.getRotation()
var degrees = 0
when (rotation) {
Surface.ROTATION_0 -> degrees = 0
Surface.ROTATION_90 -> degrees = 90
Surface.ROTATION_180 -> degrees = 180
Surface.ROTATION_270 -> degrees = 270
}
return degrees
}
private fun calculateOrientation(): Int {
val deviceOrientation: Int = getDeviceOrientation()
val info = Camera.CameraInfo()
Camera.getCameraInfo(currentCameraId, info)
val result = if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
// 前置摄像头
val result = (info.orientation + deviceOrientation) % 360
(360 - result) % 360
} else {
// 后置摄像头
(info.orientation - deviceOrientation + 360) % 360
}
ALogger.i(TAG,"calculateRecordingOrientation,result:${result}")
return result
}
矩阵应用
- 先计算出最终的矩阵。
Matrix.multiplyMM(mMVPMatrix, 0, mViewMatrix, 0, mModelMatrix, 0)
//计算变换矩阵
Matrix.multiplyMM(mMVPMatrix, 0, mProjectMatrix, 0, mMVPMatrix, 0)
2. 在着色器中使用
顶点着色器
uniform mat4 uMVPMatrix;
attribute vec4 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTexCoord = aTexCoord;
}
片段着色器
```
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTexCoord;
uniform samplerExternalOES uTexture;
void main(){
gl_FragColor = texture2D(uTexture, vTexCoord);
}
```
渲染到FBO中
- 创建FBO自身的id
- 根据id绑定到FBO上
- 创建FBO 的纹理id
- 设置FBO的参数,并将FBO的ID跟纹理ID关联
- 解绑FBO
- SurfaceTexture收到新数据
- 绑定FBO纹理ID
- 渲染数据,自动会写到FBO中。在同一个gl context下可以通过步骤三创建的纹理id进行二次渲染。
- 解绑FBO
private fun createFBO(width: Int, height: Int) {
val fboIds = IntArray(1)
GLES20.glGenFramebuffers(1, fboIds, 0)
fboId = fboIds[0]
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId)
val textureIds = IntArray(1)
GLES20.glGenTextures(1, textureIds, 0)
fboTextureId = textureIds[0]
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId)
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, fboTextureId, 0)
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
}
FBO渲染到预览流&录制流
一份数据如果要渲染到两个Surface中,那就需要两个gl环境。
预览流Surface处理
本次预览使用了GLSurfaceView,EGL环境都是默认创建好的。直接获取对应的EGLDisplay、EGLContext、EGLSurface即可。获取这三个对象的目的是将渲染数据写入预览Surface中。
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
mSharedContext = EGL14.eglGetCurrentContext()
mEglDisplay = EGL14.eglGetCurrentDisplay()
mEglSurface = EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW)
}
录制流Surface处理
录制流的Surface由MediaRecorder 创建。
mediaRecorder?.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setVideoSource(MediaRecorder.VideoSource.SURFACE)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setVideoEncoder(MediaRecorder.VideoEncoder.H264)
setVideoFrameRate(30)
setVideoSize(width, height)
setVideoEncodingBitRate(bitRate)
setAudioSamplingRate(44100)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioEncodingBitRate(128000)
...
}
录制流需要手动创建EGL环境,然后根据encodeSurface 创建EGLDisplay、EGLContext、EGLSurface。
由于两个Surface需要使用同一个FBO对象,所以需要共享
recordEglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
...
recordEglContext = EGL14.eglCreateContext(otherEglDisplay, eglConfig, mSharedContext, contextAttribs, 0)
...
val surfaceAttribs = intArrayOf(
EGL14.EGL_NONE
)
recordEglSurface = EGL14.eglCreateWindowSurface(otherEglDisplay, eglConfig, mEncoderSurface, surfaceAttribs, 0)
FBO写入双Surface
GLSurfaceVIew会自动刷新数据,录制需要手动调用eglSwapBuffers去刷新。
override fun onDrawFrame(gl: GL10) {
mSurfaceTexture?.updateTexImage()
fboDrawer.drawFBO(mMVPMatrix)
previewDrawer.drawElement()
EGL14.eglMakeCurrent(otherEglDisplay, otherEglSurface, otherEglSurface, otherEglContext)
encodeDrawer.drawElement()
EGL14.eglSwapBuffers(otherEglDisplay, otherEglSurface)
EGL14.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mSharedContext)
}
相关问题记录
问题一:使用FBO画面上下颠倒,不使用FBO却是正常的
这里主要还是纹理坐标映射出了问题导致的。
顶点的坐标体系跟纹理的坐标体系不一样,直觉上会很反人类。顶点的坐标系是笛卡尔坐标系,纹理的坐标系是
其范围是从 (0, 0) 到 (1, 1)。在这个坐标系中:
- 原点
(0, 0):代表纹理图像的左下角。 (1, 0):代表纹理图像的右下角。(0, 1):代表纹理图像的左上角。(1, 1):代表纹理图像的右上角。
但是如果按照这个坐标系去设置纹理,最终结果是贴图会上下颠倒 , 这里是因为大多数图像格式(如 PNG、JPEG)的像素数据在内存中是从 左上角 开始存储的(即第一行是图像的顶部行)。由于FBO跟预览录制用的是同一个片段着色器二,故FBO的图像是正常的,预览跟录制再次翻转后出现了问题。
正确的做法是FBO使用片段着色器二,预览跟录制使用片段着色器一
private val VERTEX_COORDS = floatArrayOf(
-1.0f, -1.0f, // 左下
1.0f, -1.0f, // 右下
-1.0f, 1.0f, // 左上
1.0f, 1.0f // 右上
)
// 片段着色器一,按照纹理坐标一一对应
private val TEX_COORDS = floatArrayOf( // S, T (对应纹理的 X, Y)
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.0f, 1.0f, // 左上角
1.0f, 1.0f // 右上角
)
// 片段着色器二,上下反转
private val TEX_COORDS_NORMAL = floatArrayOf( // S, T (对应纹理的 X, Y)
0.0f, 1.0f, // 左下角
1.0f, 1.0f, // 右下角
0.0f, 0.0f, // 左上角
1.0f, 0.0f // 右上角
)
问题二:GlSurfaceView中使用同一个glcontext切换不同的EGLSurface录制会失效
录制的surface不能直接使用GLSurfaceView的EGLContext,需要创建一个新的EGLContext共享GLSurfaceView的EGLContext才行,否则 EGL14.eglSwapBuffers(recordEglDisplay,recordEglSurface)最终还是会渲染到GLSurafceView的Surface中,不会渲染到MediaRecorder的surface中。
问题三:创建的 EGLSurface 使用无效
这是一个低级错误,创建跟使用不在同一个线程,opengl的使用一定要确保线程是ok的。