Camera2录制视频(二):MediaCodeC+OpenGL视频编码

5,898 阅读8分钟

原文地址
原创文章,未经作者允许不得转载

秋风清,秋月明
叶叶梧桐槛外声
难教归梦成

MediaLearn

欢迎大家关注我的项目MediaLearn,这是一个以学习分享音视频知识为目的建立的项目,目前仅局限于Android平台,后续会逐渐扩展。
对音视频领域知识感兴趣的朋友,欢迎一起来学习!!!

在上一篇文章Camera2录制视频(一):音频的录制及编码,主要分享了使用Camera2搭配MediaCodeC和MediaMuxer进行视频录制中的音频录制部分。那么在这篇文章中呢,就着手分析使用MediaCodeC完成视频的录制编码和MediaMuxer完成Mux视频合成模块。有关使用MediaCodeC硬编码对视频编解码的相关视频,我之前也有分享,想看的朋友们可以点击以下传送门回顾。

MediaCodeC硬编码将图片集编码为视频Mp4文件MediaCodeC编码视频
MediaCodeC将视频完整解码,并存储为图片文件。使用两种不同的方式,硬编码解码视频
MediaCodeC解码视频指定帧硬编码解码指定帧

概述

项目中使用的摄像头API为Camera2

在文章开始之前,依然是老规矩,我们从结果导向,梳理流程。看看在视频录制这个阶段,流程是如何运作的,数据在这其中发生了什么变化。

流程梳理

当设备的摄像头在运转时,sensor【传感器】会将光信号转为电信号,再转为数字信号。sensor会输出四种格式的图片格式:YUV、RGB、RAW RGB DATA、JPEG。YUV是最常用的一种格式,YUV输出的数据中亮度信号是无损的,RGB会有一定的损耗会丢掉一些原始信息。而RAW DATA是最原始的信息,但是存储空间会变大,而且需要一些特定软件才能打开。
在废弃的摄像头API——Camera中,默认的预览回调数据格式就是NV21。而在Camera2中,函数只提供了Surface作为桥接对象。若是想获取YUV、或者JPEG和RAW_SENSOR的话,可以使用ImageReader提供的Surface,再通过监听获取图像信息。
不管是Camera还是Camera2,都是支持设置Surface的。通过Surface,我们可以将Camera拿到的数据直接输送到GPU通过OpenGL来渲染处理。这样可以不用再CPU中处理Camera帧数据,从而节省大量时间。好了,接下来我用一张流程图,来展示MediaCodeC如何编码Camera帧数据的。

  • 1、因为我们需要的是H264数据,所以需要给MediaCodeC配置Mime为video/avc
  • 2、MediaCodeC配置好之后,通过createInputSurface创建出一个作输入的Input—Surface
  • 3、将Input-surface作为参数,配置Android平台EGL环境的windowSurface
  • 4、创建OpenGL的program程序,得到一个可用的纹理ID,从而构建出一个SurfaceTexture。这个对象可以提供给已经废弃的CameraAPI,也可以构建出一个Surface提供给Camera2API。

通过以上操作,我们就可以把Camera采集的数据直接传入到GPU,不用在CPU中费力的处理一番。

代码实现

在上一篇文章我提到过,会将整个视频录制中涉及到的各个功能模块化,以供后续复用。那么在视频的录制编码这块,我将它分装为了一个Runnable——VideoRecorderVideoRecorder的内部职责为,封装了MediaCodeC+OpenGL编码的流程。对外提供OpenGL纹理的Surface,和硬编码编码后的ByteBuffer、以及BufferInfo和其他视频帧相关的信息。

// MediaCodeC配置
codec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)

val s = codec.createInputSurface()
val surfaceTexture = encodeCore.buildEGLSurface(s)
inputSurface = Surface(surfaceTexture)
// 构建一个搭载了OpenGL纹理的Surface,然后回调出去
readySurface.invoke(inputSurface)
// 开始编码
codec.start()

// 计时
val startTime = System.nanoTime()

// 使用数组来保持视频录制线程和音频录制线程以及Mux线程的同步
while (isRecording.isNotEmpty()) {
    // 编码数据
    drainEncoder(false)
    frameCount++
    // OpenGL 绘制
    encodeCore.draw()
    val curFrameTime = System.nanoTime() - startTime
    encodeCore.swapData(curFrameTime)
}
// 发送编码结束信号
drainEncoder(true)

以上伪代码代表了视频编码的全部流程,首先我们需要配置一个合适的MediaCodeC,通过MediaCodeC的codec.createInputSurface函数得到一个Surface对象,前文我称之为InputSurface。然后配置EGL环境,构建OpenGLProgram。【有关OpenGL相关的代码,我都封装到了SurfaceEncodeCore这个类里面。SurfaceEncodeCore的内部职责主要是:构建EGL环境,配置OpenGL程序,绘制纹理】。
OpenGL本身是不负责窗口管理和上下文环境管理的,这个功能由各自平台提供。Android里负责为OpenGL提供窗口管理和上下文环境管理的就是EGL。在EGL里,是使用EGLSurface将输出渲染到设备屏幕。而创建EGLSurface有两种方式,一种是创建一个可实际显示的Surface,通过eglCreateWindowSurface函数,而这个函数需要一个Surface作为参数。另一个是通过eglCreatePbufferSurface创建一个离屏Surface。至此,MediaCodeC的输入渠道就搭建完毕,这个渠道会在录制期间,不停接受Camera回调的数据,并通过OpenGLProgram处理。我们只需要从MediaCodeC源源不断地提取出已经编码好的H264码流,对外回调视频帧数据即可。
函数drainEncoder的实现为:

fun MediaCodec.handleOutputBuffer(bufferInfo: MediaCodec.BufferInfo, defTimeOut: Long,
                                  formatChanged: () -> Unit = {},
                                  render: (bufferId: Int) -> Unit,
                                  needEnd: Boolean = true) {
    loopOut@ while (true) {
        //  获取可用的输出缓存队列
        val outputBufferId = dequeueOutputBuffer(bufferInfo, defTimeOut)
        Log.d("handleOutputBuffer", "output buffer id : $outputBufferId ")
        if (outputBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) {
            if (needEnd) {
                break@loopOut
            }
        } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
            formatChanged.invoke()
        } else if (outputBufferId >= 0) {
            render.invoke(outputBufferId)
            if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
                break@loopOut
            }
        }
    }
}

private fun drainEncoder(isEnd: Boolean = false) {
        if (isEnd) {
            codec.signalEndOfInputStream()
        }
        codec.handleOutputBuffer(bufferInfo, 2500, {
            if (!isFormatChanged) {
                outputFormatChanged.invoke(codec.outputFormat)
                isFormatChanged = true
            }
        }, {
            val encodedData = codec.getOutputBuffer(it)
            if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
                bufferInfo.size = 0
            }
            if (bufferInfo.size != 0) {
                Log.d(TAG, "buffer info offset ${bufferInfo.offset} time is ${bufferInfo.presentationTimeUs} ")
                encodedData.position(bufferInfo.offset)
                encodedData.limit(bufferInfo.offset + bufferInfo.size)
                Log.d(TAG, "sent " + bufferInfo.size + " bytes to muxer")
                dataCallback.invoke(frameCount, bufferInfo.presentationTimeUs, bufferInfo, encodedData)
            }
            codec.releaseOutputBuffer(it, false)
        }, !isEnd)
    }

这是MediaCodeC处理输出数据的老一套代码了,根据dequeueOutputBuffer返回的ID,确认目前编码器处于何种状态。再分别加以处理,将得到的原始数据对外回调。

混合器Mux模块

至此为止,整个视频录制功能中,视频录制编码模块完成、音频录制编码模块完成,只需要一个Mux模块。将其余两个模块提供的数据,串联起来输出Mp4文件即可。
在Mux模块中,已经没有什么技术含量了,具体工作就是,维护了两个数据队列。一个是视频帧队列,另一个是音频帧队列。Mux模块无限循环地从两个队列中提取队列首端数据。然后比较视频帧数据和音频帧数据中的时间戳大小,将时间小的先行封装即可。具体代码可参考Muxer

mediaMuxer = MediaMuxer(p, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
// 添加视频轨
val videoTrackId = mediaMuxer!!.addTrack(videoTrack)
// 添加音频轨
val audioTrackId = mediaMuxer!!.addTrack(audioTrack)
mediaMuxer!!.start()

while (isRecording.isNotEmpty()) {
    // 从队列中提取首端数据
    val videoFrame = videoQueue.firstSafe
    val audioFrame = audioQueue.firstSafe

    val videoTime = videoFrame?.bufferInfo?.presentationTimeUs ?: -1L
    val audioTime = audioFrame?.bufferInfo?.presentationTimeUs ?: -1L
    
    // 比较音频帧和视频帧的时间戳
    if (videoTime == -1L && audioTime != -1L) {
        writeAudio(audioTrackId)
        } else if (audioTime == -1L && videoTime != -1L) {
            writeVideo(videoTrackId)
        } else if (audioTime != -1L && videoTime != -1L) {
            // 先写小一点的时间戳的数据
            if (audioTime < videoTime) {
                // 封装音频帧数据
                writeAudio(audioTrackId)
            } else {
                // 封装视频帧数据
                writeVideo(videoTrackId)
            }
        } else {
            // do nothing
        }
}

Camera2视频尺寸选择

好了。整个视频录制全部功能已全部整理完毕。接下来我们分析一个视频的尺寸选择问题,以及Camera2中使我迷惑的点————如何选择视频尺寸?
在Android官方文档中,要想获取Camera2摄像头数据,必须依靠Surface。To capture or stream images from a camera device, the application must first create a camera capture session with a set of output Surfaces for use with the camera device, with createCaptureSession(SessionConfiguration).。Camera2会根据你配置的Surface来匹配相应的尺寸,Each Surface has to be pre-configured with an appropriate size and format (if applicable) to match the sizes and formats available from the camera device,每一个Surface都必须提前配置好相应的尺寸,以便去匹配Camera2合适的Size。
Camera2在配置的时候,会返回一个可供选择的尺寸集合,表示当前设备摄像头所支持的所有尺寸。我在测试视频录制时,测试设备返回的尺寸列表如下:

那么OK。既然知道了支持的尺寸,那么我在配置MediaCodeC的时候,设置的宽高从这里面选择就ok了,就不用再进一步的图像处理了。可是实际我试验的结果却不理想,在MediaCodeC设置第一个尺寸的时候,录制的视频画面毫无变形。可选择其他尺寸譬如720 X 960、720 X 1280却变形严重。可当我选择1088 X 1088 或者 960 X 960,这种等宽高尺寸时,录制的画面却毫无变形。对此我也是毫无头绪,因为摸不清楚Surface匹配的尺寸机制问题,在OpenGL绘制的时候,就不知道该如何裁剪,这才是大问题。如果有解决这个问题的朋友,希望能给我提一些建议。
做视频图像处理的时候,我走了一些弯路【手动狗头】。之前我错误的判定了Surface尺寸的来源,导致在视频尺寸这里进行了不正确的逻辑判断。事实上使用SurfaceTexture的setDefaultBufferSize函数可以达到尺寸匹配。但相应的,Camera2返回的size是宽高相反的,所以这里的setDefaultBufferSize的宽高也是相反的才能匹配。

以上