Android 音视频入门 (四)- 记录一次MediaCodec + MediaMuxer的使用

一. 功能简介

调用Android Camera组件,获取预览时的byte[]数组,以后渲染到Activity的TextureView中,同时采用MediaCodec进行AVC(即H264)编码,使用MediaMuxer进行打包,生成MP4文件。java

二. 架构设计

整个功能模块分为以下几个子功能:数组

  1. 相机组件的使用(权限申请、预览画面的获取、尺寸设置等等,暂时不包括对焦,由于主要是编码功能)
  2. TextureView的使用(将预览画面渲染到屏幕上)
  3. MediaCodec的使用(MediaFormat的选择、bufferQueue等等)
  4. MediaMuxer的使用(混合器,混合H264视频码流和音频码流,音频码流暂时还没加入,后期有时间再加入)

三. 相机组件

这里采用的是Android.Hardware.Camera类,注意区分Android.graphic.CameraAndroid.Hardware.Camera2,前者是用于3D图形绘制的工具,然后者是新的Camera操做类,这里选择的是第一代的Camera。markdown

首先最重要的一件事就是在清单中,申请权限。网络

拿到权限后,咱们须要对 Camera进行初始化:架构

主要是初始化:cameraId和outputSizes属性,前者是相机的ID,后者是相机输出的画幅尺寸。app

private fun initCamera() { 
	//初始化相机的一些参数 
    val instanceOfCameraUtil = CameraUtils.getInstance(this).apply {
        this@CameraActivity.cameraManager = this.cameraManager!!        
        cameraId = this.getCameraId(false)!! //默认使用后置相机 
        //获取指定相机的输出尺寸列表 
        outPutSizes = this.getCameraOutputSizes(cameraId, SurfaceTexture::class.java)!!.get(0)    
    }
}
复制代码

假定此时,你的Layout文件中,已经还有一个TextureView(id:textureView),咱们须要声明一个TextureView.SurfaceTextureListeneride

private val mSurfaceTextureListener = object : TextureView.SurfaceTextureListener {    
    override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
        
    }    
    
    override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
        
    }    
    
    override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
        return false    
    }
    
    override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {        		
        openCameraPreview(surface, width, height)         
    }
}
复制代码

咱们须要关注的是第四个重写方法,该方法将在TextureView可用时,被回调,这时,咱们就能够根据该方法来构建预览画面了,这部分的代码在网络上不少的帖子中都有作过叙述。须要注意的是,这里并不包含画面对焦等等功能,若是有须要能够自行百度一下。函数

四. 预览画面的构建

一开始个人设想是构建一个手机竖屏视频全屏播放器,那么(横纵)尺寸必定是:1080 * 1920。这样一来,咱们输入编码器的长宽分别是:1080 * 1920,可是,咱们在setPreviewCallback得到的照片数据:byte[]数组中,咱们的照片是横着摆放的,这样一来,尺寸就变成了:1920 * 1080。这个数据直接送入编码器会致使画面的异常:工具

1618745491334.png

因此,这个一维的byte[]数组中存放的nv21数据,咱们须要将它对应的位置给旋转90度,这就是rotateYUV420Degree90方法(方法参考文末的【附】)oop

private fun openCameraPreview(surfaceTexture: SurfaceTexture, width: Int, height: Int) {        
    //初始化预览尺寸,这些属性必须等到Texture可用后再回调,不然会出问题。 
    mPreviewSize = Size(1080, 1920)        //初始化编码器,强制声明成1080*1920,也能够根据这的长宽来定,1080P是一个比较通用的尺寸,可是放到全面屏中的全屏TextureView可能会致使画面拉伸等等问题,须要另外去解决。
       		
    mTextureView.setAspectRation(mPreviewSize.width, mPreviewSize.height);       
    mCameraDevice = Camera.open(0)        
    mCameraDevice.setDisplayOrientation(90)        
    /** * 得到捕获的视频信息。 */        
    mCameraDevice.parameters = mCameraDevice.parameters.apply {           
        this!!.setPreviewSize(mPreviewSize.height, mPreviewSize.width)            
        this.setPictureSize(mPreviewSize.height, mPreviewSize.width)            
        this.previewFormat = CAMERA_COLOR_FORMAT               
    }        
    /** * Camera做为生产者,生产的图像数据,交给SurfaceTexture处理。 * 或者是进一步渲染 * 或者是显示,这里设置的PreviewTexture天然是显示。 * 这里的surfaceTexture其实是当咱们‘预览’TextureView可用的时候,被回调的这个回调函数中提供了一个钩子:surfaceTexture * 这个surfaceTexure将会做为显示的载体,直接被显示出来。 */        
    mCameraDevice.setPreviewTexture(surfaceTexture)        
    mCameraDevice.setPreviewCallback { data, camera ->            
       //注意:照片的宽高是反着的,曰,而不是日 
       if (::mHandler.isInitialized) {                
          mHandler.post {                    
          //把横版视频分辨率:1920 * 1080 转换成竖版: 1080 * 1920 
          val verticalData = ImageFormatUtils.rotateYUV420Degree90(data, mPreviewSize.height,mPreviewSize.width)                    
              onFrameAvailable(verticalData)                
          }            
       }        
    }        
    mCameraDevice.startPreview()    
}
复制代码

五. 编码器的声明

鉴于各类设备DSP芯片的区别,各类设备支持的色彩格式等等参数也有不一样,在这里我就使用在小米10上高通865可用的色彩格式之一:COLOR_FormatYUV420SemiPlanar,即NV21,接下来,咱们初始化MediaCodecMediaMuxer。具体支持的格式须要真正运行时动态地去判断、获取。

若是设备的DSP芯片比较差,支持的格式也更少,硬解码是没法使用的,所以也应该适时地引入手段进行软件解码(FFmpeg等等)。这里仅例举MediaCodec的使用。格式必须配套,不配套的话会致使:色彩和位置之间的误差、偏色、花屏等等各类问题。

private val MEDIA_TYPE = MediaFormat.MIMETYPE_VIDEO_AVCprivate 
val MEDIACODEC_COLOR_FORMAT = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar//接受的编NV21

private fun initEncoder() {    
    val supportedColorFormat = ImageFormatUtils.getSupportColorFormat()//获取支持的色彩格式 
    try {        
        mMediaCodec = MediaCodec.createEncoderByType(MEDIA_TYPE)        
        mMediaFormat = MediaFormat.createVideoFormat(MEDIA_TYPE,mPreviewSize.width,mPreviewSize.height).apply {            				setInteger(MediaFormat.KEY_COLOR_FORMAT, MEDIACODEC_COLOR_FORMAT)//设置输入的颜色 I420,咱们要先转换NV21成I420 
			setInteger(MediaFormat.KEY_BIT_RATE, 10000000)            
             setInteger(MediaFormat.KEY_FRAME_RATE, 30)           
             setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)        
                                                                                                              }        
        mMediaCodec.configure(mMediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
        //布置混合器 
        val fileName = this.obbDir.absolutePath + "/" + System.currentTimeMillis() + ".mp4"        		    mMuxer = MediaMuxer(fileName, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)        				} catch (e: Exception) {        
        e.printStackTrace()        
        return    
    }
}
复制代码

若是到Muxer没有出现错误,那么说明Codec和Muxer都构建成功了。

通常通用的色彩格式是:I420,在这里使用的应该是COLOR_FormatYUV420Flexible这个变量。须要在数据编码前,将Nv21转换为I420的编码,若是不转换,使用主流的播放器也没有太大的问题。

六. 数据的记录

咱们须要开一个新的线程来做编码的记录,咱们在Camera的预览界面拿到一帧数据后咱们经过子线程的Handler,为其POST一个任务。

//编码线程
private lateinit var mHandler: Handler
private lateinit var mWorkerThread: HandlerThread
private fun startEncoder() {    
	isEncoding = true    //开始编码 
	mMediaCodec.start()    //构建链接器。 
	mWorkerThread = HandlerThread("WorkerThread-Encoder")    
	mWorkerThread.start()    
	mHandler = Handler(mWorkerThread.looper)
}
复制代码

注意,咱们并不在此处就开启Muxer,咱们会在子线程中接受数据的时候的某个状态开始进行混合。

mCameraDevice.setPreviewCallback { data, camera ->    
	if (::mHandler.isInitialized) {        
            mHandler.post {            
            //把横版视频分辨率:1920 * 1080 转换成竖版: 1080 * 1920 
            val verticalData = ImageFormatUtils.rotateYUV420Degree90(data, mPreviewSize.height, mPreviewSize.width)            
            onFrameAvailable(verticalData)        
           }    
	}
 }
复制代码

我在查询Camera支持的分辨率的时候,发现全部的分辨率都是横版的分辨率,即:1920*1080版本的,可是咱们MediaCodec最初设定的分辨率是竖版的,这里也是一个坑。

onFrameAvailable()方法中,咱们不断地插入一个byte数组,这个数组中是相机实时传来的预览画面,咱们对这个画面进行编码便可。编码完成后,将编码出来的画面接入到Muxer中:

private fun onFrameAvailable(_data: ByteArray?) {
        if (!isEncoding) {
            return;
        }
        //(可选NV21->I420),而后送入解码器
        val data: ByteArray = _data!!

        var index = 0
        try {
            index = mMediaCodec.dequeueInputBuffer(0)
        } catch (e: Exception) {
            e.printStackTrace()
            return
        }
        if (index >= 0) {
            val inputBuffer = mMediaCodec.getInputBuffer(index)
            inputBuffer!!.clear()
            inputBuffer.put(data, 0, data.size)
            mMediaCodec.queueInputBuffer(
                    index,
                    0,
                    data.size,
                    System.nanoTime() / 1000,
                    0)
        }
        while (true) {
            val bufferInfo = MediaCodec.BufferInfo()
            val encoderStatus = mMediaCodec.dequeueOutputBuffer(bufferInfo, 10_000)
            if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
                break//稍后再试
            } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                //输出的格式发生了改变,此处开启混合器
                val newFormat = mMediaCodec.outputFormat
                mVideoTrack = mMuxer!!.addTrack(newFormat)
                mMuxer!!.start()
            } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                //
            } else {
                //正常编码则得到缓冲区下标
                val encodedDat = mMediaCodec.getOutputBuffer(encoderStatus)
                if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
                    bufferInfo.size = 0
                }
                if (bufferInfo.size != 0) {
                    //设置从XX地方开始读取数据
                    encodedDat!!.position(bufferInfo.offset)
                    //设置读数据总长度
                    encodedDat.limit(bufferInfo.offset + bufferInfo.size)
                    //写出MP4
                    if (!isEncoding) {
                        return
                    }
                    mMuxer!!.writeSampleData(mVideoTrack, encodedDat, bufferInfo)

                }
                //释放缓冲区
                mMediaCodec.releaseOutputBuffer(encoderStatus, false)
            }
        }
    }
复制代码

这个方法是在子线程中执行的。

七. 生成文件

private fun pauseRecord() {    
    +send//显示发送按钮 
    record.isRunning = false    
    Timer.cancel()//取消计时 
    showBackOrCancel()    
    if (isEncoding) {        
        stopEncoder()    
    }
}

private fun stopEncoder() {
    isEncoding = false
    Toast(this.obbDir.absolutePath + "\\下")
    try {
        mMuxer?.stop()
        mMuxer?.release()
        //中止
        mMediaCodec.stop()
        mMediaCodec.release()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

复制代码

这样一来,咱们在存储目录中的Android/obb/包名/下就有生成的文件了。

八. 总结

整体来讲仍是挺简陋的,好比没有根据具体的设备动态地去判断录制的尺寸、录制的色彩格式选择等等,相机相关的功能闪光灯、对焦也未加入。

MediaCodec自己是编解码器,和FFmpeg不一样,它会优先进行硬件解码,效率高,功耗低,可是缺点就是,兼容性、可扩展性相对于软件解码来讲会更低。有一部分的播放软件,将硬解仍是软解的选择权交给了用户,这样既能够兼顾到扩展性,又能够兼顾到功耗。

最终实现的效果(没对焦):

QQ图片20210418195548.jpg

附. 一些相关的方法:

1. 横屏Nv21->竖屏Nv21的排列:

public byte[] rotateYUV420Degree90(byte[] data, int imageWidth, int imageHeight) {
        byte[] yuv = new byte[imageWidth * imageHeight * 3 / 2];
        // Rotate the Y luma
        int i = 0;
        for (int x = 0; x < imageWidth; x++) {
            for (int y = imageHeight - 1; y >= 0; y--) {
                yuv[i] = data[y * imageWidth + x];
                i++;
            }
        }
        // Rotate the U and V color components
        i = imageWidth * imageHeight * 3 / 2 - 1;
        for (int x = imageWidth - 1; x > 0; x = x - 2) {
            for (int y = 0; y < imageHeight / 2; y++) {
                yuv[i] = data[(imageWidth * imageHeight) + (y * imageWidth) + (x - 1)];
                i--;
                yuv[i] = data[(imageWidth * imageHeight) + (y * imageWidth) + x];
                i--;
            }
        }
        return yuv;
    }
复制代码

2. 查询设备支持的色彩格式

public static int getSupportColorFormat() {
    int numCodecs = MediaCodecList.getCodecCount();
    MediaCodecInfo codecInfo = null;
    for (int i = 0; i < numCodecs && codecInfo == null; i++) {
        MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
        if (!info.isEncoder()) {
            continue;
        }
        String[] types = info.getSupportedTypes();
        boolean found = false;
        for (int j = 0; j < types.length && !found; j++) {
            if (types[j].equals("video/avc")) {
                Log.d("TAG:", "found");
                found = true;
            }
        }
        if (!found)
            continue;
        codecInfo = info;
    }
    Log.e("TAG", "Found " + codecInfo.getName() + " supporting " + "video/avc");
    // Find a color profile that the codec supports
    MediaCodecInfo.CodecCapabilities capabilities = codecInfo.getCapabilitiesForType("video/avc");
    Log.e("TAG",
            "length-" + capabilities.colorFormats.length + "==" + Arrays.toString(capabilities.colorFormats));
    for (int i = 0; i < capabilities.colorFormats.length; i++) {
        Log.d(TAG, "TAG MediaCodecInfo COLOR FORMAT :" + capabilities.colorFormats[i]);
        if ((capabilities.colorFormats[i] == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) || (capabilities.colorFormats[i] == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar)) {
            return capabilities.colorFormats[i];
        }
    }
    return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible;
}
复制代码