Android MediaRecorder 录制音视频总结

243 阅读8分钟

背景

最近接了个需求,需要对录制音视频做审核的功能升级,历史逻辑是调用系统的录制功能,该需求有很多定制功能,系统录制已无法满足诉求,所以需要我们自己去录制音视频。为了方便,本次使用了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录制音视频的过程。

相机采集

下面是相机采集的全流程。

  1. 创建一个外部的纹理ID,这需要再GL环境中执行,本次图方便,没有使用EGL去创建环境,直接使用GlSurfaceView,在onSurfaceCreated中执行即可。

       val textures = IntArray(1)
       GLES20.glGenTextures(1, textures, 0)
       mExternalTextureId = textures[0]
       ```
    
    
  2. 根据步骤1的纹理ID创建一个SurfaceTexture,并监听是否有新的帧用来刷新预览和录制视频。SurfaceTexture在我之前的文章已经提过很多次了,这里就不多加赘述了。

    mSurfaceTexture = SurfaceTexture(mExternalTextureId)
    mSurfaceTexture.setOnFrameAvailableListener {
    glSurfaceView.requestRender()
    } 
    ```
    
  3. 将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
}

矩阵应用

  1. 先计算出最终的矩阵。
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中

  1. 创建FBO自身的id
  2. 根据id绑定到FBO上
  3. 创建FBO 的纹理id
  4. 设置FBO的参数,并将FBO的ID跟纹理ID关联
  5. 解绑FBO
  6. SurfaceTexture收到新数据
  7. 绑定FBO纹理ID
  8. 渲染数据,自动会写到FBO中。在同一个gl context下可以通过步骤三创建的纹理id进行二次渲染。
  9. 解绑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的。