Camera+MediaCodec+ffmpeg实现视频录制

目录

架构设计

原设计架构:
调用系统Action属性打开手机相机,进行视频录制操作;
使用ProjectApplication.activity.startActivityForResult()回调方式获取录制视频文件;
利用callback机制回传获取录制视频文件到界面,进行数据上传和UI更新操作。

原设计架构存在问题:上传视频后存在颜色失真的问题,这个现象的原因是Camera录制的NV21图像预览数据,没有进行视频编码和封装,直接转为mp4格式文件存储,并不是手机支持的MediaCodec编解码颜色格式,则上传平台后存在颜色失真的现象。

新架构设计:
使用Camera框架采集视频像素数据;
使用MediaCodec将原生YUV数据转码为H264格式;
使用ffmpeg命令将H264文件封装为mp4文件。
利用callback机制将封装MP4文件回传到界面,进行数据上传和UI更新操作。

Camera

Android5.0之前使用android.hardware包下的Camera类进行拍照、录视频等功能。

Camera实现视频数据采集

public class DataCollectVideoDialog extends BaseDialog implements View.OnClickListener, Camera.PreviewCallback {

    @BindView(R.id.record_video_sfv)
    SurfaceView surfaceView;
    @BindView(R.id.btn_start_video)
    Button startBtn;
    @BindView(R.id.btn_stop_video)
    Button stopBtn;

    private SurfaceHolder holder;
    private android.hardware.Camera camera;
    private DataCollectCallBack callBack;
    private static int yuvqueuesize = 10;
    private static ArrayBlockingQueue<byte[]> YUVQueue = new ArrayBlockingQueue<byte[]>(yuvqueuesize);
    private MediaUtil mediaUtil;


    public DataCollectVideoDialog(Context context, DataCollectCallBack callBack) {
        super(context, R.style.FullScreenDialogTheme);
        this.callBack = callBack;

    }

    @Override
    protected int setContentView() {
        return R.layout.dialog_data_collect_video;
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    protected void bindView() {
        super.bindView();
        supportAvcCodec();
        init();
    }

	// 对View控件初始化
    private void init() {
        getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        holder = surfaceView.getHolder();
        startBtn.setOnClickListener(this);
        stopBtn.setOnClickListener(this);
        stopBtn.setVisibility(View.GONE);
        ((Toolbar) view.findViewById(R.id.record_video_toolbar)).setNavigationOnClickListener(v -> cancelOn());
        setOnKeyListener(((dialog, keyCode, event) -> {
            if (keyCode == KeyEvent.KEYCODE_BACK && keyCode == KeyEvent.ACTION_DOWN) {
                cancelOn();
            }
            return false;
        }));
        show();
    }

	// 检查当前是否支持video编码
    private boolean supportAvcCodec() {
        if (Build.VERSION.SDK_INT >= 18) {
            for (int j = MediaCodecList.getCodecCount() - 1; j >= 0; j--) {
                MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(j);
                String[] types = codecInfo.getSupportedTypes();
                for (int i = 0; i < types.length; i++) {
                    if (types[i].equalsIgnoreCase("video/avc")) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

	// 初始化Camera设置
    private void openCamera() {
        try {
            camera = Camera.open(android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK);
            Camera.Parameters parameters = camera.getParameters();
            Camera.Size previewSize = parameters.getPreviewSize();
            int width = previewSize.width;
            int height = previewSize.height;
            // 设置预览格式(也就是每一帧的视频格式)YUV420下的NV21
            parameters.setPreviewFormat(ImageFormat.NV21);
            // 设置预览图像分辨率
            parameters.setPreviewSize(width, height);
            camera.setParameters(parameters);
            camera.setDisplayOrientation(90);
            camera.setPreviewDisplay(holder);
        } catch (IOException e) {
            e.printStackTrace();
        }
        // 设置监听获取视频流的每一帧
        camera.setPreviewCallback(this);
        
        // 调用startPreview()用以更新preview的surface
        camera.startPreview();
    }

    @Override
    public void onPreviewFrame(byte[] data, android.hardware.Camera camera) {
        putYUVData(data, data.length);
    }

    public void putYUVData(byte[] buffer, int length) {
        if (YUVQueue.size() >= 10) {
            YUVQueue.poll();
        }
        YUVQueue.add(buffer);
    }

    private void releaseCamera() {
        if (null != camera) {
            camera.setPreviewCallback(null);
            camera.stopPreview();
            camera.release();
            camera = null;
            MediaUtil.getInstance().stopEncoder(outputFile -> {
                callBack.collectRescueVideoCallBack(outputFile);
            });
        }
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.btn_start_video:
                openCamera();
                // 调用MediaCodec对视频像素YUV数据编码
                mediaUtil = MediaUtil.getInstance();
                mediaUtil.startEncoderThread(YUVQueue);
                stopBtn.setVisibility(View.VISIBLE);
                startBtn.setVisibility(View.GONE);
                break;
            case R.id.btn_stop_video:
                releaseCamera();
                cancelOn();
                break;
        }
    }
}

Camera2优点

Camera2将该程序包将摄像头设备建模为管道,该管道接收用于捕获单个帧的输入请求,根据请求捕获单个图像,然后输出一个请求结果元数据包以及该请求的一组输出图像缓冲区。
这些请求将按顺序处理,并且多个请求可以一次运行。
Camera+MediaCodec+ffmpeg实现视频录制
Camera v2 API的优点:
重新设计 Android Camera API 的目的在于大幅提高应用对于 Android 设备上的相机子系统的控制能力,同时重新组织 API,提高其效率和可维护性。
在CaptureRequest中设置不同的Surface用于接收不同的图片数据,最后从不同的Surface中获取到图片数据和包含拍照相关信息的CaptureResult。
Camera v2 API,这些API不仅大幅提高了Android系统拍照的功能,还能支持RAW照片输出,甚至允许程序调整相机的对焦模式、曝光模式、快门等。
在创建会话,设置ImageReader监听,都需要传递一个Handler对象,这个Handler对象决定着这些会话、监听的回调方法会被在哪个线程中调用,如果传递的是NULL,那么回调会调用在当前线程。

Camera2 使用实例

应用实例参考以下文章:
Android Camera2 API和拍照与录像过程

API1和API2 Camera从打开到预览的基础流程比较

  1. camera api 1
  • 检查权限android.permission.CAMERA
  • 获取摄像头信息,Camera.getCameraInfo
  • 获得句柄 Camera.open (cameraId),并打开相机
  • 设置分辨率和预览格式,Camera.Parameters
  • 设置实时预览,setPreviewDisplay()
  • 开启预览,startPreview()
  • 设置预览回调,Camera.PreviewCallback
  • 获取预览数据,onPreviewFrame()
  1. camare api 2
  • 检查权限android.permission.CAMERA
  • 获取CameraManager
  • 获取摄像头信息,mCameraManager.getCameraCharacteristics
  • 打开摄像头,mCameraManager.openCamera, 传入id和StateCallback
  • 获取camera状态,CameraDevice.StateCallback.onOpened (onError/onDisconnected/onClosed),获取CameraDevice实例
  • 创建Session:CameraDevice.createCaptureSession(surfaces, CameraCaptureSession.StateCallback, mRespondHandler);
  • 获取session实例,CameraCaptureSession.StateCallback. onConfigured (onConfigureFailed/onClosed)
  • 创建BuildCaptureRequest,拿到CaptureRequest builder = mCameraDevice.createCaptureRequest(msg.arg1),使用builder去设置camera属性
  • 调用setRepeatingRequest开启预览,mSession.setRepeatingRequest(builder.build(), mCaptureCallback, null);
  • 反馈捕获结果CameraCaptureSession.CaptureCallback.onCaptureCompleted

API1和API2 Camera 功能设置的比较

  1. camera api 1
  • 图像原始数据byte[]实时获取,Camera.PreviewCallback中onPreviewFrame(byte[],Camera)
  • Camera图像预览尺寸大小设置,Camera.Parameters.setPreviewSize(width, height)
  • 将来获取到的图片的格式设置,Camera.Parameters…setPictureFormat(ImageFormat.JPEG);
  1. camare api 2
  • 图像原始数据byte[]实时获取,设置ImageReader.setOnImageAvailableListener监听,在onImageAvailable(ImageReader)通过回调传递的ImageReader.acquireLatestImage()方法获取到一个Image对象,然后Image.getPlanes()[0].getBuffer()返回了一个ByteBuffer对象,最后new byte[buffer.remaining()]即可得到原始图像的byte[]。
  • Camera图像预览尺寸大小设置,TextureView. getSurfaceTexture()拿到SurfaceTexture()对象,再通过setDefaultBufferSize(width, height)进行设置。
  • 将来获取到的图片的格式设置,ImageReader.newInstance(width, height,ImageFormat.YUV_420_888, MAX_IMAGES);

MediaCodec

MediaCodec类Android提供的用于访问低层多媒体编/解码器接口,能够编解码诸如H.264、H.265、AAC、3gp等常见的音视频格式。

MediaCodec编码过程

在整个编解码过程中,MediaCodec的使用会经历配置、启动、数据处理、停止、释放几个过程
Camera+MediaCodec+ffmpeg实现视频录制

MediaCodec处理具体的视频流方法

getInputBuffers:获取需要编码数据的输入流队列,返回的是一个ByteBuffer数组
queueInputBuffer:输入流入队列
dequeueInputBuffer:从输入流队列中取数据进行编码操作
getOutputBuffers:获取编解码之后的数据输出流队列,返回的是一个ByteBuffer数组
dequeueOutputBuffer:从输出队列中取出编码操作之后的数据
releaseOutputBuffer:处理完成,释放ByteBuffer数据

创建编/解码器

MediaCodec主要提供了createEncoderByType(String type)、createDecoderByType(String type)两个方法来创建编解码器,它们均需要传入一个MIME类型多媒体格式。常见的MIME类型多媒体格式如下:
● “video/x-vnd.on2.vp8” - VP8 video (i.e. video in .webm)
● “video/x-vnd.on2.vp9” - VP9 video (i.e. video in .webm)
● “video/avc” - H.264/AVC video
● “video/mp4v-es” - MPEG4 video
● “video/3gpp” - H.263 video
● “audio/3gpp” - AMR narrowband audio
● “audio/amr-wb” - AMR wideband audio
● “audio/mpeg” - MPEG1/2 audio layer III
● “audio/mp4a-latm” - AAC audio (note, this is raw AAC packets, not packaged in LATM!)
● “audio/vorbis” - vorbis audio
● “audio/g711-alaw” - G.711 alaw audio
● “audio/g711-mlaw” - G.711 ulaw audio

MediaCodec还提供了一个createByCodecName (String name)方法,支持使用组件的具体名称来创建编解码器。官方是建议最好是配合MediaCodecList使用,因为MediaCodecList记录了所有可用的编解码器。

private boolean supportAvcCodec() {
        if (Build.VERSION.SDK_INT >= 18) {
            for (int j = MediaCodecList.getCodecCount() - 1; j >= 0; j--) {
                MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(j);
                String[] types = codecInfo.getSupportedTypes();
                for (int i = 0; i < types.length; i++) {
                    if (types[i].equalsIgnoreCase("video/avc")) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

 mediaCodec = MediaCodec.createEncoderByType("video/avc");

配置、启动编/解码器

编解码器配置使用的是MediaCodec的configure方法,该方法首先对MediaFormat存储的数据map进行提取,然后调用本地方法native-configure实现对编解码器的配置工作。

configure方法参数
format为MediaFormat的实例,它使用”key-value”键值对的形式存储多媒体数据格式信息;
surface用于指明解码器的数据源来自于该surface;
crypto用于指定一个MediaCrypto对象,以便对媒体数据进行安全解密;
flags指明配置的是编码器(CONFIGURE_FLAG_ENCODE)

MediaFormat.KEY_COLOR_FORMAT
Camera预览采集的图像流通常为NV21或YV12,那么编码器需要指定相应的颜色格式,否则编码得到的数据可能会出现花屏、叠影、颜色失真等现象。
MediaCodecInfo.CodecCapabilities.存储了编码器所有支持的颜色格式
原始数据 编码器
NV12(YUV420sp) ———> COLOR_FormatYUV420PackedSemiPlanar
NV21 ———-> COLOR_FormatYUV420SemiPlanar
YV12(I420) ———-> COLOR_FormatYUV420Planar

mediaCodec = MediaCodec.createEncoderByType("video/avc");
            // height和width一般都是照相机的height和width。
            MediaFormat mediaFormat = MediaFormat.createVideoFormat("video/avc", width, height);
            // 描述平均位速率(以位/秒为单位)的键。 关联的值是一个整数
            mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, width * height * 5);
            // 描述视频格式的帧速率(以帧/秒为单位)的键。帧率,一般在15至30之内,太小容易造成视频卡顿。
            mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate);
            // 色彩格式,具体查看相关API,不同设备支持的色彩格式不尽相同
            mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar);
            // 关键帧间隔时间,单位是秒
            mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
            mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

数据处理

public void startEncoderThread(ArrayBlockingQueue<byte[]> YUVQueue) {
        new Thread(() -> {
            isRecordVideo = true;
            byte[] input = null;
            long pts = 0;
            long generateIndex = 0;
            while (isRecordVideo) {
                if (YUVQueue.size() > 0) {
                    input = YUVQueue.poll();
                    byte[] yuv420sp = new byte[width * height * 3 / 2];
                    // 编码器编码格式只支持NV12,所以需要将NV21->NV12
                    NV21ToNV12(input, yuv420sp, width, height);
                    input = yuv420sp;
                }
                if (input != null) {
                    try {
                        ByteBuffer[] inputBuffers = mediaCodec.getInputBuffers();
                        // 返回要用有效数据填充的输入缓冲区的索引,-1表示超时无限等待
                        int inputBufferIndex = mediaCodec.dequeueInputBuffer(-1);
                        if (inputBufferIndex >= 0) {
                            pts = computePresentationTime(generateIndex);
                            ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
                            inputBuffer.clear();
                            inputBuffer.put(input);
                            mediaCodec.queueInputBuffer(inputBufferIndex, 0, input.length, pts, 0);
                            generateIndex += 1;
                        }


                        ByteBuffer[] outputBuffers = mediaCodec.getOutputBuffers();
                        MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
                        int outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC);
                        while (outputBufferIndex >= 0) {
                            Log.d("dataCollect", "-=-=- encoderH264: Get H264 Buffer Success!" +
                                    "  flag = " + bufferInfo.flags + ",  pts = " + bufferInfo.presentationTimeUs);
                            ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
                            byte[] outData = new byte[bufferInfo.size];
                            outputBuffer.get(outData);

                            if (bufferInfo.flags == 2) {
                                configBytes = new byte[bufferInfo.size];
                                configBytes = outData;
                            } else if (bufferInfo.flags == 1) {
                                byte[] keyframe = new byte[bufferInfo.size + configBytes.length];
                                System.arraycopy(configBytes, 0, keyframe, 0, configBytes.length);
                                System.arraycopy(outData, 0, keyframe, configBytes.length, outData.length);
                                outputStream.write(keyframe, 0, keyframe.length);
                            } else {
                                outputStream.write(outData, 0, outData.length);
                            }
                            mediaCodec.releaseOutputBuffer(outputBufferIndex, false);
                            outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC);
                        }

                    } catch (Throwable t) {
                        t.printStackTrace();
                    }
                } else {
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }

    /**
     * Generates the presentation time for frame N, in microseconds.
     */
    private long computePresentationTime(long frameIndex) {
        return 132 + frameIndex * 1000000 / frameRate;
    }

NV21,I420都是Camera预览图像像素数据格式;NV12是MediaCodec支持编解码数据输入格式。

NV21 数据格式

NV21 图像格式数据排列 : 以 4 × 4 像素的图片为例 , 其有 16 个 Y 数据 , UV 数据只有 4 组 , 共 8 个 ;

byte[] data = {
	y1 , y2 , y3 , y4 ,
	y5 , y6 , y7 , y8 ,
	y9 , y10, y11, y12,
	y13, y14, y15, y16,
	v1 , u1 , v2 , u2 , 
	v3 , u3 , v4 , u4 , 
}

灰度数据 y1 , y2 , y5 , y6 使用的是 v1 , u1 色彩数据 ;
灰度数据 y3 , y4 , y7 , y8 使用的是 v2 , u2 色彩数据 ;
灰度数据 y9 , y10, y13, y14 使用的是 v3 , u3 色彩数据 ;
灰度数据 y11, y12, y15, y16 使用的是 v4 , u5 色彩数据 ;

NV12数据格式

NV21 和 NV12都是属于YUV420, 所以Y的排列是一样的,唯一的区别就是UV的排列的不同。
Camera+MediaCodec+ffmpeg实现视频录制

I420 数据格式

以 4 × 4 像素的图片为例 , 其有 16 个 Y 数据 , UV 数据只有 4 组 , 共 8 个 ;

byte[] data = {
	y1 , y2 , y3 , y4 ,
	y5 , y6 , y7 , y8 ,
	y9 , y10, y11, y12,
	y13, y14, y15, y16,
	u1 , u2 , u3 , u4 , 
	v1 , v2 , v3 , v4
}

灰度数据 y1 , y2 , y5 , y6 使用的是 v1 , u1 色彩数据 ;
灰度数据 y3 , y4 , y7 , y8 使用的是 v2 , u2 色彩数据 ;
灰度数据 y9 , y10, y13, y14 使用的是 v3 , u3 色彩数据 ;
灰度数据 y11, y12, y15, y16 使用的是 v4 , u5 色彩数据 ;

NV21 格式与 I420 格式对比

数据量 : 相同像素点数的图像 , 其数据大小是相同的 ;
Y 灰度值排列 : 其灰度值排列方式是相同的 , 都是在 1 ~ 16 位置依次排列 16 个像素点数 ;
UV 色彩值排列 : 其色彩值排列是不同的 ,

  • NV21 格式中 , UV 色彩值是交替排序的 , v1 , u1 , v2 , u2 , v3 , u3 , v4 , u4 ;
  • I420 格式中 , UV 色彩值是 4 个 u 先排列 , 然后排 4 个 v 数据 , u1 , u2 , u3 , u4 , v1 , v2 , v3 , v4 ;

ffmpeg封装MP4

在停止录像之后,MediaCodec完成NV21数据硬编码为H264文件,将H264文件使用ffmpeg命令封装为MP4文件。

private static String outputPath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/vid_" + Utils.getTimeStr() + ".mp4";

public void stopEncoder(EncodeListener listener) {
        try {
            mediaCodec.stop();
            mediaCodec.release();
            outputStream.flush();
            outputStream.close();
            FFmpegCmd.exec(FFmpegCmdUtil.getH264ToMp4CmdArray(file, "vid_" + Utils.getTimeStr() + ".mp4"));
            File outputFile = new File(outputPath);
            listener.callback(outputFile);
            isRecordVideo = false;
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

// 构造ffmpeg命令
private static String h264ToMp4Cmd = "ffmpeg -i test.h264 -vcodec copy -f mp4 output.mp4";
public static String[] getH264ToMp4CmdArray(File inputFile,String outputPath) {
        String[] split = h264ToMp4Cmd.split(" ");
        split[2] = inputFile.getName();
        split[split.length - 1] = outputPath;
        return split;
    }
上一篇:Android硬编码知识点总结


下一篇:java-mediacodec ExtractMpegFramesTest示例不匹配