目录
架构设计
原设计架构:
调用系统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 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从打开到预览的基础流程比较
- camera api 1
- 检查权限android.permission.CAMERA
- 获取摄像头信息,Camera.getCameraInfo
- 获得句柄 Camera.open (cameraId),并打开相机
- 设置分辨率和预览格式,Camera.Parameters
- 设置实时预览,setPreviewDisplay()
- 开启预览,startPreview()
- 设置预览回调,Camera.PreviewCallback
- 获取预览数据,onPreviewFrame()
- 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 功能设置的比较
- camera api 1
- 图像原始数据byte[]实时获取,Camera.PreviewCallback中onPreviewFrame(byte[],Camera)
- Camera图像预览尺寸大小设置,Camera.Parameters.setPreviewSize(width, height)
- 将来获取到的图片的格式设置,Camera.Parameters…setPictureFormat(ImageFormat.JPEG);
- 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的使用会经历配置、启动、数据处理、停止、释放几个过程
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的排列的不同。
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;
}