从底层分析视频录制那点事

源码下载

这是一篇从 视频源 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监听预览数据回传, 代码以下java

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数据格式android

这里给你们讲解一下YUV数据格式

与RGB相似,YUV也是一种颜色编码方法,它将亮度信息(Y)与色彩信息(UV)分离,没有UV信息同样能够显示完整的图像,只不过是黑白的,而且,YUV不像RGB那样要求三个独立的视频信号同时传输,因此用YUV方式传送占用极少的频宽.git

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

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中的一种
bash

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

Camera返回nv21的YUV数据(这是原始数据),经过libyuv库nv21转nv12,而后使用MediaCidec把nv12转成h264,最后使用ffmpeg把h264转成mp4
这就是所有流程了.其中还包括对YUV图像的编辑操做,下面的开始每步详解ide

首先初始化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 数据
     * <p>
     * 执行顺序为:缩放->旋转->镜像
     *
     * @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<Boolean>() {
            @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<String>() {
            @Override
            public String doInBackground()throws Exception{
                //h264转ts
                ArrayList<String> tsList = new ArrayList<>();
                for (int x=0; x<segmentList.size(); x++){
                    String tsPath = LanSongFileUtil.DEFAULT_DIR+System.currentTimeMillis()+".ts";
                    mVideoEditor.h264ToTs(segmentList.get(x), tsPath);
                    tsList.add(tsPath);
                }
                //合成音频
                String aacPath = mVideoEditor.executePcmEncodeAac(syntPcm(), RecordUtil.sampleRateInHz, RecordUtil.channelCount);
                //合成视频
                String mp4Path = mVideoEditor.executeConvertTsToMp4(tsList.toArray(new String[]{}));
                //音视频混合
                mp4Path = mVideoEditor.executeVideoMergeAudio(mp4Path, aacPath);
                return mp4Path;
            }
            @Override
            public void onFinish(String result) {
                closeProgressDialog();
                Intent intent = new Intent(mContext, EditVideoActivity.class);
                intent.putExtra(INTENT_PATH, result);
                startActivityForResult(intent, REQUEST_CODE_KEY);
            }
            @Override
            public void onError(Throwable e) {
                e.printStackTrace();
                closeProgressDialog();
                Toast.makeText(getApplicationContext(), "视频编辑失败", Toast.LENGTH_SHORT).show();
            }
        });
    }
复制代码

下面讲一下拍照逻辑

先得到摄像头方向, 区分前置和后置, 前置的话还要镜像图片, 而后使用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;
    }
复制代码

至此所有录制逻辑就讲解完了,接下来还有对视频缩放裁剪,添加水印图片,加速等等代码 在以前的文章中有讲解, 你们有兴趣能够看下, 但愿对你有所帮助.

相关文章
相关标签/搜索