从底层分析一对一直播软件源码开发中,视频录制那点事

这是一篇从 视频源 YUV数据编辑 音视频合成 这些方面来讲解一对一直播软件源码开发中视频录制的文章。

录制框架:
Camera(视频源) + Libyuv(编辑YUV图像数据) + MediaCodec(编辑h264数据) + AudioRecord(录制音频数据) + ffmpeg(多段音视频合成,输出mp4)

一对一直播软件源码开发中视频录制的主要代码全在RecordUtil类中,主要用到的库和类:

1.使用Camera作为视频源
2.使用MediaCodec进行视频编码
3.使用AudioRecord录制音频
4.使用google的libyuv编辑YUV数据(如旋转,缩放,镜像,nv21转nv12)
5.使用ffmpeg进行 h264转ts,合成多段音视频,音视频混合功能,以此实现分段录制
6.抓取一帧图片,使用libyuv转成bitmap,实现拍照功能

先说下一对一直播软件源码中视频录制流程

首先初始化Camera对象,我封装成CameraHelp了, 主要是设置预览Size,前后摄像头,旋转角度,对焦等等, 最主要的是setPreviewCallback监听预览数据回传, 代码如下

     public void openCamera(Activity activity, int cameraId, SurfaceHolder surfaceHolder){
        try {
            this.cameraId = cameraId;
            mCamera = Camera.open(cameraId);
            displayOrientation = getCameraDisplayOrientation(activity, cameraId);
            mCamera.setDisplayOrientation(displayOrientation);
            mCamera.setPreviewDisplay(surfaceHolder);
            mCamera.setPreviewCallback(previewCallback);

            Camera.Parameters parameters = mCamera.getParameters();
            previewSize = getPreviewSize();
            parameters.setPreviewSize(previewSize[0], previewSize[1]);
            parameters.setFocusMode(getAutoFocus());
            parameters.setPictureFormat(ImageFormat.JPEG);
            parameters.setPreviewFormat(ImageFormat.NV21);

            mCamera.setParameters(parameters);
            mCamera.startPreview();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
     private void initMediaRecorder() {
        mCameraHelp.setPreviewCallback(new Camera.PreviewCallback() {
            @Override
            public void onPreviewFrame(byte[] data, Camera camera) {
                //在此对视频数据进行处理
            }
        });
    }

onPreviewFrame会不断被回调,大概一秒钟30次,也就是说我们在一对一直播软件源码中录制的视频最多1秒钟30帧,传过来的byte数组是nv21格式的YUV420图像数据

简单来说,就是一对一直播软件源码Camera返回的YUV数据不能直接用,需要转换, 而且为了提高编辑速度 也需要转换一下YUV数据格式

这里给大家讲解一下YUV数据格式

与RGB类似,YUV也是一种颜色编码方法,它将一对一直播软件源码亮度信息(Y)与色彩信息(UV)分离,没有UV信息一样可以显示完整的图像,只不过是黑白的,并且,YUV不像RGB那样要求三个独立的视频信号同时传输,所以用YUV方式传送占用极少的频宽.

YUV格式有两大类:planar和packed.
对于planar的YUV格式,先连续存储所有像素点的Y,紧接着存储所有像素点的U,随后是所有像素点的V.
对于packed的YUV格式,每个像素点的Y,U,V是连续交错存储的.

YUV里分 YUV444, YUV422和YUV420
YUV 4:4:4采样,每一个Y对应一组UV分量
YUV 4:2:2采样,每两个Y共用一组UV分量
YUV 4:2:0采样,每四个Y共用一组UV分量

YUV420就是下区分NV21, NV12和I420
NV21:YYYYYYYY VU VU, 先Y然后VU交错存储
NV12:YYYYYYYY UV UV, 先Y然后UV交错存储
I420: YYYYYYYY UU VV, 先Y然后是U最后是V

android的一对一直播软件源码Cardme返回的就是nv21就是属于YUV420SP中的一种

我这里使用的YUV转换流程: nv21 -> nv12 -> h264 -> mp4

一对一直播软件源码中Camera返回nv21的YUV数据(这是原始数据),通过libyuv库nv21转nv12,然后使用MediaCidec把nv12转成h264,最后使用ffmpeg把h264转成mp4

这就是全部流程了.其中还包括对YUV图像的编辑操作,下面的开始每步详解

首先初始化so库

    LanSoEditor.initSDK(this, null);
    LanSongFileUtil.setFileDir("/sdcard/WeiXinRecorded/"+System.currentTimeMillis()+"/");
    LibyuvUtil.loadLibrary();

第一步nv21转nv12, 我之前是使用java代码对byte数据直接操作, 效率低下, 所以换成libyuv库

1.先把nv21转成I420 这样方便对一对一直播软件源码数据进行编辑操作, libyuv是封装好的,直接使用就可以了

    //将 NV21 转 I420
    public static native void convertNV21ToI420(byte[] src, byte[] dst, int width, int height);

2.然后是图像旋转缩放镜像

/**
     * 压缩 I420 数据
     * 

* 执行顺序为:缩放->旋转->镜像 * * @param src 原始数据 * @param srcWidth 原始宽度 * @param srcHeight 原始高度 * @param dst 输出数据 * @param dstWidth 输出宽度 * @param dstHeight 输出高度 * @param degree 旋转(90, 180, 270) * @param isMirror 镜像(镜像在旋转之后) */ public static native void compressI420(byte[] src, int srcWidth, int srcHeight, byte[] dst, int dstWidth, int dstHeight, int degree, boolean isMirror);

我们通过一对一直播软件源码Camera得到的YUV数据都是横向的, 所以我们需要旋转一下, 在前面初始化Camera时我们已经得到这个参数了, 一般来说后置摄像头是90度, 前置摄像头是270度(前置的还需要镜像一下YUV数据), 如果你要压缩的话, 也可以传入压缩后的宽高.

3.最后是把I420转成NV12, 下一步交给MediaCodec

    /**
     * 将 I420 转 NV12
     */
    public static native void convertI420ToNV12(byte[] src, byte[] dst, int width, int height);

第二步是MediaCodec NV12转h264

1.先看看之前初始化的MediaCodec

private void initVideoMediaCodec()throws Exception{
        MediaFormat mediaFormat;
        if(rotation==90 || rotation==270){
            //设置视频宽高
            mediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, videoHeight, videoWidth);
        }else{
            mediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, videoWidth, videoHeight);
        }
        //图像数据格式 YUV420
        mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar);
        //码率
        mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, videoWidth*videoHeight*3);
        //每秒30帧
        mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate);
        //1秒一个关键帧
        mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
        videoMediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
        videoMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        videoMediaCodec.start();
    }

2.把nv12数据压入一对一直播软件源码数据队列中

            ByteBuffer inputBuffer = videoMediaCodec.getInputBuffer(inputIndex);
            //把要编码的数据添加进去
            inputBuffer.put(nv12);
            //塞到编码序列中, 等待MediaCodec编码
            videoMediaCodec.queueInputBuffer(inputIndex, 0, nv12.length,  System.nanoTime()/1000, 0);

3.然后从一对一直播软件源码输出队列中得到编码后的h264数据

        //读取MediaCodec编码后的数据
        int outputIndex = videoMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC);
        byte[] frameData = null;
        int destPos = 0;
        ByteBuffer outputBuffer = videoMediaCodec.getOutputBuffer(outputIndex);
        byte[] h264 = new byte[bufferInfo.size];
        //这步就是编码后的h264数据了
        outputBuffer.get(h264);
        switch (bufferInfo.flags) {
            case MediaCodec.BUFFER_FLAG_CODEC_CONFIG://视频信息
                configByte = new byte[bufferInfo.size];
                configByte = h264;
                break;
            case MediaCodec.BUFFER_FLAG_KEY_FRAME://关键帧
                videoOut.write(configByte, 0, configByte.length);
                videoOut.write(h264, 0, h264.length);
                break;
            default://正常帧
                videoOut.write(h264, 0, h264.length);
                if(frameData == null) {
                    frameData = new byte[bufferInfo.size];
                }
                System.arraycopy(h264, 0, frameData, destPos, h264.length);
                break;
        }
        videoOut.flush();
        //数据写入本地成功 通知MediaCodec释放data
        videoMediaCodec.releaseOutputBuffer(outputIndex, false);

这里要区分视频普通帧,关键帧和视频头信息 mp4会把视频参数信息写在视频头部(比如视频长度,大小,编码格式,音频格式等等), 每隔1秒也会写入一个关键帧

第三步是h264转mp4

    //把h264转成ts文件
    ffmpeg -i input -vcodec copy -vbsf h264_mp4toannexb output 
    //把ts转成mp4 因为是分段录制,这里可以是多个ts文件
    ffmpeg -i concat:input1|input2|input3 -c copy -bsf:a aac_adtstoasc -y output
    
     /**
     * 执行成功,返回0, 失败返回错误码.
     * 解析参数失败 返回1
     * sdk未授权 -1;
     * 解码器错误:69
     * 收到线程的中断信号:255
     * 如硬件编码器错误,则返回:26625---26630
     * @param cmdArray ffmpeg命令的字符串数组, 可参考此文件中的各种方法举例来编写.
     * @return 执行成功, 返回0, 失败返回错误码.
     */
    private native int execute(Object cmdArray);

这步也是比较简单, 一对一直播软件源码通过调用VideoEditor的execute方法, 传入ffmpeg语句, 交给ffmpeg就可以了

以上就是YUV图像数据转化的整个过程, 下面讲讲其中的一些坑和注意事项

1.之前我们设定了视频每秒30帧, 那么每帧的间隔就是1000/30≈33毫秒 也就是说我们需要在33毫秒内处理完这一帧的转换过程, 那么如果超出了33毫秒会怎么样呢?

MediaCodec在一对一直播软件源码编码数据时, 并没有添加每帧的时间戳信息, 也就是视频会以1秒30帧的速度播放, 但比如我们处理一秒的时间是66毫秒, 我们录制1秒的视频最后只有15帧数据,最后出来的视频时间就是0.5秒

最后得出的结论是一对一直播软件源码手机性能越差(处理一帧的时间大于33毫秒), 录制出的视频时间越短.同理,一对一直播软件源码手机性能越高(处理一帧的时间小于33毫秒), 录制出的视频时间越长.比如处理一帧要17秒, 那么一秒的视频就有60帧, 录制出的视频时间就是2秒

2.解决方法我这里有两种, 第一种是使用MediaMuxer对一对一直播软件源码音视频进行封装, 他会在内部同步视频帧时间戳
我使用的是第二种, 针对手机性能不足,一对一直播软件源码编码时间过长的问题, 我使用libyuv替换了java代码对YUV数据进行操作, 大大缩短了编辑时间
针对手机性能高, 一对一直播软件源码编码时间过快, 我在编码YUV数据前, 进行时间戳比对,记录当前总共编辑了多少视频帧, 录制时间多少, 判断是否超出一秒30帧的限制.
3.整个视频帧转换过程, 小米8大概10毫秒左右,低端一点的机型大概20毫秒, 一般都会小于33毫秒,所以使用一对一直播软件源码时间戳对比方式, 来进行视频帧同步.

然后是音频录制

1.首先初始化AudioRecord, 需要传入一对一直播软件源码的麦克风源,采样率,声道,缓存大小

    private void initAudioRecord(){
        audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, sampleRateInHz, channelConfig, AudioFormat.ENCODING_PCM_16BIT, audioBufferSize);
    }

2.然后开启一个子线程, 不断从audioRecord中取音频数据, 直接写入一对一直播软件源码本地就好

    private void startRecordAudio(){
        RxJavaUtil.run(new RxJavaUtil.OnRxAndroidListener() {
            @Override
            public Boolean doInBackground() throws Throwable {
                audioRecord.startRecording();
                while (isRecording.get()) {
                    byte[] data = new byte[audioBufferSize];
                    if (audioRecord.read(data, 0, audioBufferSize) != AudioRecord.ERROR_INVALID_OPERATION) {
                        audioOut.write(data);
                    }
                }
                return true;
            }
            @Override
            public void onFinish(Boolean result) {
            }
            @Override
            public void onError(Throwable e) {
                e.printStackTrace();
            }
        });
    }

3.最后我们得到的音频数据是pcm, 通过ffmpeg转成aac格式就可以使用了(与ts文件合成mp4)

 /**
     * 把pcm格式的音频文件编码成AAC
     * @param srcPach    源pcm文件
     * @param samplerate pcm的采样率
     * @param channel    pcm的通道数
     * @return  输出的m4a合成后的文件
     */
    public String executePcmEncodeAac(String srcPach, int samplerate, int channel) 

最后看一下ffmpeg分段合成音视频的逻辑

流程就是: 先把h264转成ts, 然后合成多个ts, 最后ts转mp4文件(这时是没有音频数据的)
接下来是音频: 多个pcm音频文件合成一个, 再把pcm转成aac, 最后把mp4+aac合成完整的视频

    public void finishVideo(){
        RxJavaUtil.run(new RxJavaUtil.OnRxAndroidListener() {
            @Override
            public String doInBackground()throws Exception{
                //h264转ts
                ArrayList tsList = new ArrayList<>();
                for (int x=0; x

下面讲一下拍照逻辑

先获得摄像头方向, 区分前置和后置, 前置的话还要镜像图片, 然后使用libyuv先把nv21转成i420, 便于编辑图像,然后调用LibyuvUtil.compressI420, 进行旋转,镜像,缩放
最后使用LibyuvUtil.convertI420ToBitmap输出bitmap图片, 保存在一对一直播软件源码本地即可, 使用libyuv进行图像编辑, 相较于使用java代码操作一对一直播软件源码图片, 速度提升了3倍, 可以达到毫秒级.

    public String doInBackground() throws Throwable {
    
        boolean isFrontCamera = mCameraHelp.getCameraId()== Camera.CameraInfo.CAMERA_FACING_FRONT;
        int rotation;
        if(isFrontCamera){
            rotation = 270;
        }else{
            rotation = 90;
        }
    
        byte[] yuvI420 = new byte[nv21.length];
        byte[] tempYuvI420 = new byte[nv21.length];
    
        int videoWidth =  mCameraHelp.getHeight();
        int videoHeight =  mCameraHelp.getWidth();
    
        LibyuvUtil.convertNV21ToI420(nv21, yuvI420, mCameraHelp.getWidth(), mCameraHelp.getHeight());
        LibyuvUtil.compressI420(yuvI420, mCameraHelp.getWidth(), mCameraHelp.getHeight(), tempYuvI420,
                mCameraHelp.getWidth(), mCameraHelp.getHeight(), rotation, isFrontCamera);
    
        Bitmap bitmap = Bitmap.createBitmap(videoWidth, videoHeight, Bitmap.Config.ARGB_8888);
    
        LibyuvUtil.convertI420ToBitmap(tempYuvI420, bitmap, videoWidth, videoHeight);
    
        String photoPath = LanSongFileUtil.DEFAULT_DIR+System.currentTimeMillis()+".jpeg";
        FileOutputStream fos = new FileOutputStream(photoPath);
        bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos);
        fos.close();
    
        return photoPath;
    }

至此一对一直播软件源码全部录制逻辑就讲解完了, 希望对你有所帮助。

你可能感兴趣的:(一对一直播源码)