该文章已在公众号「aserbaocool」发布。如需转载请联系作者。
如果你有学Android 音视频,相机开发的想法,那么这篇文章可以作为一篇不错的参考文章。当然本文为付费文章,收费5元,如果对你有用,文末赞赏缴费即可。如果没有学习音视频,相机的欲望,赶快走,赶快走,不要有一丝停留,因为这篇文章确实枯燥无味且毫无快感可言。
请考虑3s赶快决定去留。
3
2
1
我再扯两句:
这篇是在学习相机音视频开发的时候写的一篇总结。由于涉及的知识点比较多,所以其中部分知识点仅起引导作用。当然,微信有很多链接不能链接,如果要点击链接的话请到原文:https://gitbook.cn/new/gitchat/activity/5aeb03e3af08a333483d71c1 去查看:
ok,枯燥无味正式开始:
昨天发Chat之前还仔细看了时间计划表,时间是5.24提交文章,时间在计划之内,所以才提交的。结果很快通过审核。意料之外的是今天上午11:20就提示我预订人数已经达标了。感谢大家对我的认可,非常感谢。结果我点进去一看,文章提交时间到居然提前到5.18了提交了,人数达标提前一周完成,所以文章提前了一周提交(有点小情绪,平台没有提前告诉我这个,不过工作人员的解答还是缓和了我的暴脾气,好评)。最后到我这压力就大了。因为这篇文章计划写的内容覆盖面是很广泛的,涵盖相机开发的大部分知识,而且我对自己写作要求:内容尽量精炼,不能泛泛而谈。所以时间上来说很紧凑了。当然,如果文章各方面大家有看不顺眼的地方,希望大家帮忙指出批评,一定虚心接受,积极改正。如果今后有机会见面,请你喝茶。项目地址AndroidCamera
1. 从打开一个摄像头说起
当然,这个对大部分人来说都是没什么问题的,但是该篇文章还得照顾大部分初次接触Camera开发的小伙伴,所以请容许我在此多啰嗦一下,如果你有接触过Camera的开发,此部分可以跳过,直接看下一部分。
a. 使用Camera的步骤:
说下Camera的操作步骤,后面给出实例,请结合代码理解分析:
- 获取一个Camera实例,通过open方法,Camera.open(0),0是后置摄像头,1表示前置摄像头。
- 设置Camera的参数,比如聚焦,是否开闪光灯,预览高宽,修改Camera的默认参数:mCamera.getParameters()
通过初始化SurfaceHolder去setPreviewDisplay(SurfaceHolder),没有surface,Camera不能开始预览。 - 调用startPreview方法开始更新预览到surface,在拍照之前,startPreview必须调用,预览必须开启。
- 当你想开始拍照时,使用takePicture(Camera.ShutterCallback, Camera.PictureCallback, Camera.PictureCallback, Camera.PictureCallback), 等待回调提供真实的图像数据
当拍完一张照片时,预览(preview)将会停止,当你想要拍更多的照片时,须要再一次调用startPreview方法 - 当调用stopPreview方法时,将停止更新预览的surface
- 当调用release方法时,将马上释放camera
b.使用SurfaceView预览显示Camera数据
如果你初次开发相机,请按照上面的步骤观看下面代码,如果你已经知道了,请直接过滤掉此基础部分。如果想了解更多预览方式,你可以看我的另一篇文章通过SurfaceView,TextureView,GlSurfaceView显示相机预览。
public class CameraSurfaceViewShowActivity extends AppCompatActivity implements SurfaceHolder.Callback {
@BindView(R.id.mSurface)
SurfaceView mSurfaceView;
public SurfaceHolder mHolder;
private Camera mCamera;
private Camera.Parameters mParameters;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_base_camera);
ButterKnife.bind(this);
mHolder = mSurfaceView.getHolder();
mHolder.addCallback(this);
mHolder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
try {
// Open the Camera in preview mode
mCamera = Camera.open(0);
mCamera.setDisplayOrientation(90);
mCamera.setPreviewDisplay(holder);
mCamera.startPreview();
} catch (IOException e) {
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
mCamera.autoFocus(new Camera.AutoFocusCallback() {
@Override
public void onAutoFocus(boolean success, Camera camera) {
if (success) {
mParameters = mCamera.getParameters();
mParameters.setPictureFormat(PixelFormat.JPEG); //图片输出格式
// mParameters.setFlashMode(Camera.Parameters.FLASH_MODE_TORCH);//预览持续发光
mParameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);//持续对焦模式
mCamera.setParameters(mParameters);
mCamera.startPreview();
mCamera.cancelAutoFocus();
}
}
});
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
if (mCamera != null) {
mCamera.stopPreview();
mCamera.release();
mCamera = null;
}
}
@OnClick(R.id.btn_change)
public void onViewClicked() {
// PropertyValuesHolder valuesHolder2 = PropertyValuesHolder.ofFloat("rotationX", 0.0f, 360.0f, 0.0F);
PropertyValuesHolder valuesHolder = PropertyValuesHolder.ofFloat("rotationY", 0.0f, 360.0f, 0.0F);
PropertyValuesHolder valuesHolder1 = PropertyValuesHolder.ofFloat("scaleX", 1.0f, 0.5f,1.0f);
PropertyValuesHolder valuesHolder3 = PropertyValuesHolder.ofFloat("scaleY", 1.0f, 0.5f,1.0f);
ObjectAnimator objectAnimator = ObjectAnimator.ofPropertyValuesHolder(mSurfaceView, valuesHolder,valuesHolder1,valuesHolder3);
objectAnimator.setDuration(5000).start();
}
}
c. 效果展示
当然,为了使效果好看一点点,我添加了一丢丢效果,效果如下:
好了,到这里为止,我们的简单Camera预览结束。
2. 使用OpenGl ES预览相机数据
OpenGL ES (OpenGL for Embedded Systems) 是OpenGl的子集,针对手机、PDA和游戏主机等嵌入式设备而设计。(我不会偷偷告诉你我是百度滴)
关于OpenGl ES如何绘制一个简单基本图形,下面会做一个简单的讲解,如果你想对OpenGL ES有更深层次的了解,可以看下我写的关于一篇OpenGL绘制简单三角形的文章Android openGl开发详解(一)——简单图形的基本绘制,
1. 使用OpenGl ES绘制相机数据必备的基本知识
1. 关于OpenGl ES渲染流程了解下:
首先我们必须明确我们要做的是将相机数据显示到设备屏幕上,所有的操作都是为此目的服务的。所以我们必须要了解OpenGl ES是如何进行渲染的。(如果下面提到的术语你没有概念,或者模棱两可,请看再看一遍Android openGl开发详解(一)——简单图形的基本绘制)
下面是基本步骤:
- 布局文件中添加GlSurfaceView,并为其指定渲染器Renderer。
- 设置画布大小,清除画布内容,创建纹理对象,并指定OpenGl ES操作纹理ID。(下面会讲到)
- 加载顶点着色器(vertex shader)和片元着色器(fragment shader)。
- 创建OpenGl ES程序,创建program对象,连接顶点和片元着色器,链接program对象。
- 打开相机,设置预览布局,开启预览,并通过glUseProgram()方法将程序添加到OpenGl ES环境中,获取着色器句柄,通过glVertexAttribPointer()传入绘制数据并启用顶点位置句柄。
- 在onDrawFrame方法中更新缓冲区帧数据并通过glDrawArrays绘制到GlSurfaceView上。
- 操作完成后资源释放,需要注意的是使用GlsurfaceView的时候需要注意onResume()和onPause()的调用。
上面步骤基本可以将Camera的预览数据通过OpenGl ES的方式显示到了GlSurfaceView上。当然,我们先来看下效果图,再给出源码部分。让大家看一下效果(因为时间原因,请原谅我拿了之前的图)
这部分源码会在项目中给出,同时在通过SurfaceView,TextureView,GlSurfaceView显示相机预览也有给出,所以,在这里就不贴源码了。
2. 了解下EGL
What?EGL?什么东西?可能很多初学的还不是特别了解EGL是什么?如果你使用过OpenGL ES进行渲染,不知道你有没有想过谁为OpenGl ES提供渲染界面?换个方式问?你们知道OpenGL ES渲染的数据到底去哪了么?(请原谅我问得这么生硬) 当然,到GLSurfaceView,GlSurfaceView为其提供了渲染界面,这还用说!
其实OpenGL ES的渲染是在独立线程中,他是通过EGL接口来实现和硬件设备的连接。EGL为OpenGl EG 提供上下文及窗口管理,注意:OpenGl ES所有的命令必须在上下文中进行。所以EGL是OpenGL ES开发必不可少需要了解的知识。但是为什么我们上面的开发中都没有用到EGL呢?这里说明下:因为在Android开发环境中,GlSurfaceView中已经帮我们配置好了EGL了。
当然,EGL的作用及流程图从官方偷来给大家看一波:
关于EGL的知识内容很多,不想增加本文篇幅,重新写一篇博客专门介绍EGL,有兴趣点这里Android 自定义相机开发(三) —— 了解下EGL。
3. 了解下OpenGl ES中的纹理
OpenGl 中的纹理可以用来表示图像,照片,视频画面等数据,在视频渲染中,我们只需要处理二维的纹理,每个二维的纹理都由许多小的纹理元素组成,我们可以将其看成小块的数据。我们可以简单将纹理理解成电视墙瓷砖,我们要做一面电视墙,需要由多个小瓷砖磡成,最终成型的才是完美的电视墙。我暂时是这么理解滴。使用纹理,最直接的方式是直接从给一个图像文件加载数据。这里我们得稍微注意下,OpenGl的二维纹理坐标和我们的手机屏幕坐标还是有一定的区别。
OpenGl的纹理坐标的原点是在左下角,而计算机的纹理坐标在左上角。尤其是我们在添加贴纸的时候需要注意下y值的转换。这里顺便说下OpenGl ES绘制相机数据的时候纹理坐标的变换问题,下次如果使用OpenGl 处理相机数据遇到镜像或者上下颠倒可以对照下图片上所说的规则:
下面我们来讲解下OpenGl纹理使用的步骤:
- 首先我们需要创建一个纹理对象,通过glGenTextures()方法获取到纹理对象ID,接下来我们就可以操作纹理了对象,但是我们需要告诉OpenGl 我们操作的是哪个纹理,所以我们需要通过glBindTexture()告诉OpenGl操作纹理的ID,当纹理绑定之后,我们还需要为这个纹理对象设置一些参数(纹理的过滤方式),当我们需要将纹理对象渲染到物体表面时,我们需要通过纹理对象的纹理过滤器通过glTexParameterf()方法来指明,最后当我们操作当前纹理完成之后,我们可以通过调用一次GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0)对纹理进行解绑。
private int createTextureID() {
int[] tex = new int[1];
//第一个参数表示创建几个纹理对象,并将创建好的纹理对象放置到第二个参数中去,第二个参数里面存放的是纹理ID(纹理索引),第三个偏移值,通常填0即可。
GLES20.glGenTextures(1, tex, 0);
//纹理绑定
GLES20.glBindTexture(GL_TEXTURE_2D, tex[0]);
//设置缩小过滤方式为GL_LINEAR(双线性过滤,目前最主要的过滤方式),当然还有GL_NEAREST(容易出现锯齿效果)和MIP贴图(占用更多内存)
GLES20.glTexParameterf(GL_TEXTURE_2D,
GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR);
//设置放大过滤为GL_LINEAR,同上
GLES20.glTexParameterf(GL_TEXTURE_2D,
GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
//设置纹理的S方向范围,控制纹理贴纸的范围在(0,1)之内,大于1的设置为1,小于0的设置为0。
GLES20.glTexParameterf(GL_TEXTURE_2D,
GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE);
//设置纹理的T方向范围,同上
GLES20.glTexParameterf(GL_TEXTURE_2D,
GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE);
//解除纹理绑定
GLES20.glBindTexture(GL_TEXTURE_2D, 0);
return tex[0];
}
这里我们稍微提一下,如果是相机数据处理,我们使用GLES11Ext.GL_TEXTURE_EXTERNAL_OES,如果是处理贴纸图片,我们使用GLES20.GL_TEXTURE_2D。因为相机输出的数据类型是YUV420P格式的,使用GLES11Ext.GL_TEXTURE_EXTERNAL_OES扩展纹理可以实现自动将YUV420P转RGB,我们就不需要在存储成MP4的时候再进行数据转换了。
- 如果我们要给当前纹理添加PNG素材,我们需要对PNG这种图片压缩格式进行解码操作。最终传递RGBA数据格式数据到OpenGl 中纹理中,当然,OpenGL还提供了三个指定函数来指定纹理glTexImage1D(), glTexImage2D(), glTexImage3D().。我们运用到的主要2D版本,glTexImage2D();
void glTexImage2D( int target,
int level,
int internalformat,
int width,
int height,
int border,
int format,
int type,
java.nio.Buffer pixels);
简单参数说明 :
target:常数GL_TEXTURE_2D。
level: 表示多级分辨率的纹理图像的级数,若只有一种分辨率,则level设为0。
internalformat:表示用哪些颜色用于调整和混合,通常用GLES20.GL_RGBA。
border:字面意思理解应该是边界,边框的意思,通常写0.
width/height:纹理的宽/高。
format/type :一个是纹理映射格式(通常填写GLES20.GL_RGBA),一个是数据类型(通常填写GLES20.GL_UNSIGNED_BYTE)。
pixels:纹理图像数据。
当然,Android中最常用是使用方式是直接通过texImage2D()方法可以直接将Bitmap数据作为参数传入,方法如下:
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, mBitmap, 0);
- 接来下就如上面OpenGl ES渲染流程所提到的,将纹理绘制到屏幕上。
3. 一起了解下使用MediaCodec实现相机录制
上面我们将相机的预览显示讲完了,接下里我们讲如何将录制视频。就目前来说,Android的录制方式就要有下面三中:
- 使用MediaRecord进行录制。(这个不讲解)
- 使用MediaCodec进行录制(我们讲这种) 。
- 使用FFMpeg+x264/openh264。(软编码的方式,后面出专门的文章讲解到这部分)。
1. 什么是MediaCodec?
MediaCodec官方文档地址
MediaCodec是一个多媒体编解码处理类,可用于访问Android底层的多媒体编解码器。例如,编码器/解码器组件。它是Android底层多媒体支持基础架构的一部分(通常与MediaExtractor, MediaSync, MediaMuxer, MediaCrypto, MediaDrm, Image, Surface, 以及AudioTrack一起使用)。请原谅我后面那一段是从官网搬过来的,知道它是用来处理音视频的That's enough.
2. MediaCodec的操作原理?
MediaCodec到底是如何将数据进行处理生成.mp4文件的呢?我们先看下图(在官方图片上进行了部分改动和标记):
既然上面我们提到MediaCodec是一个编码器处理类,从图上看我们可以知道,他就是2的输入的数据进行处理,然后输出到3中去保存。每个编码器都包含一组输入和输出缓存,中间的两条从Codec出发又返回Codec的虚线就代表两组缓存。当编码器启动后,两组缓存便存在。由编码器发送空缓存给输入区(提供数据区),输入区将输入缓存填充满,再返回给编码器进行编码,编码完成之后将数据进行输出,输出之后将缓冲区返回给编码器。
如果你是个吃货你可以这样理解:Codec是榨汁机,在榨汁之前准备两个杯子。一个杯子(输入缓存)用来装苹果一直往榨汁机里面倒,倒完了继续回去装苹果。另一个杯子(输出缓存)用来装榨出来的苹果汁,无论你将果汁放到哪里去(放一个大瓶子里面或者喝掉),杯子空了你就还回来继续接果汁,知道将榨汁机里面的果汁接完为止。
对,就这么简单,八九不离十的样子,反正我也不知道我说得对不对?
4. MediaCodec的使用步骤:
- 创建MediaFormat,并设置相关属性,MediaFormat.KEY_COLOR_FORMAT(颜色格式),KEY_BIT_RATE(比特率),KEY_FRAME_RATE(帧速),KEY_I_FRAME_INTERVAL(关键帧间隔,0表示要求所有的都是关键帧,负值表示除第一帧外无关键帧)。
温馨提示: 没有设置以上前三个属性你可以能会出现以下错误:
Process: com.aserbao.androidcustomcamera, PID: 18501
android.media.MediaCodec$CodecException: Error 0x80001001
at android.media.MediaCodec.native_configure(Native Method)
at android.media.MediaCodec.configure(MediaCodec.java:1909)
……
- 创建一个MediaCodec的编码器,并配置格式。
- 创建一个MediaMuxer来合成视频。
- 通过dequeueInput/OutputBuffer()获取输入输出缓冲区。
- 通过getInputBuffers获取输入队列,然后通过queueInputBuffer把原始YUV数据送入编码器。
- 通过dequeueOutputBuffer方法获取当前编解码状态,根据不同的状态进行处理。
- 再然后在输出队列端同样通过dequeueOutputBuffer获取输出的h264流。
- 处理完输出数据之后,需要通过releaseOutputBuffer把输出buffer还给系统,重新放到输出队列中。
- 使用MediaMuxer混合。
温馨提示:下面实例是通过直接在mediacodec的输入surface上进行绘制,所以不会有上述输入队列的操作。关于MediaCodec的很多细节,官方已经讲得很详细了,这里不过多阐述。
官方地址:MediaCodec
MediaCodec中文文档
MediaCodec同步缓存处理方式(来自官方实例,还有异步缓存处理及同步数组的处理方式这里不做多讲解,如果有兴趣到官方查看),配合上面的步骤看会理解更多,如果还是不明白建议查看下面实例之后再回头来看步骤和实例:
MediaCodec codec = MediaCodec.createByCodecName(name);
codec.configure(format, …);
MediaFormat outputFormat = codec.getOutputFormat(); // option B
codec.start();
for (;;) {
int inputBufferId = codec.dequeueInputBuffer(timeoutUs);
if (inputBufferId >= 0) {
ByteBuffer inputBuffer = codec.getInputBuffer(…);
// fill inputBuffer with valid data
…
codec.queueInputBuffer(inputBufferId, …);
}
int outputBufferId = codec.dequeueOutputBuffer(…);
if (outputBufferId >= 0) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
// bufferFormat is identical to outputFormat
// outputBuffer is ready to be processed or rendered.
…
codec.releaseOutputBuffer(outputBufferId, …);
} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// Subsequent data will conform to new format.
// Can ignore if using getOutputFormat(outputBufferId)
outputFormat = codec.getOutputFormat(); // option B
}
}
codec.stop();
codec.release();
5. 讲个实例,使用MediaCodec录制一段绘制到Surface上的数据
如果你之前没有使用过MediaCodec录制过视频,这个实例建议你看一下,如果你非常了解了,请跳过。效果图如下:
[图片上传失败...(image-a132c7-1571365256676)]
难得给下代码,当然,项目中会有更多关于MediaCodec的实例,最后会给出:
public class PrimaryMediaCodecActivity extends BaseActivity {
private static final String TAG = "PrimaryMediaCodecActivi";
private static final String MIME_TYPE = "video/avc";
private static final int WIDTH = 1280;
private static final int HEIGHT = 720;
private static final int BIT_RATE = 4000000;
private static final int FRAMES_PER_SECOND = 4;
private static final int IFRAME_INTERVAL = 5;
private static final int NUM_FRAMES = 4 * 100;
private static final int START_RECORDING = 0;
private static final int STOP_RECORDING = 1;
@BindView(R.id.btn_recording)
Button mBtnRecording;
@BindView(R.id.btn_watch)
Button mBtnWatch;
@BindView(R.id.primary_mc_tv)
TextView mPrimaryMcTv;
public MediaCodec.BufferInfo mBufferInfo;
public MediaCodec mEncoder;
@BindView(R.id.primary_vv)
VideoView mPrimaryVv;
private Surface mInputSurface;
public MediaMuxer mMuxer;
private boolean mMuxerStarted;
private int mTrackIndex;
private long mFakePts;
private boolean isRecording;
private int cuurFrame = 0;
private MyHanlder mMyHanlder = new MyHanlder(this);
public File mOutputFile;
@OnClick({R.id.btn_recording, R.id.btn_watch})
public void onViewClicked(View view) {
switch (view.getId()) {
case R.id.btn_recording:
if (mBtnRecording.getText().equals("开始录制")) {
try {
mOutputFile = new File(Environment.getExternalStorageDirectory().getAbsolutePath(), System.currentTimeMillis() + ".mp4");
startRecording(mOutputFile);
mPrimaryMcTv.setText("文件保存路径为:" + mOutputFile.toString());
mBtnRecording.setText("停止录制");
isRecording = true;
} catch (IOException e) {
e.printStackTrace();
mBtnRecording.setText("出现异常了,请查明原因");
}
} else if (mBtnRecording.getText().equals("停止录制")) {
mBtnRecording.setText("开始录制");
stopRecording();
}
break;
case R.id.btn_watch:
String absolutePath = mOutputFile.getAbsolutePath();
if (!TextUtils.isEmpty(absolutePath)) {
if(mBtnWatch.getText().equals("查看视频")) {
mBtnWatch.setText("删除视频");
mPrimaryVv.setVideoPath(absolutePath);
mPrimaryVv.start();
}else if(mBtnWatch.getText().equals("删除视频")){
if (mOutputFile.exists()){
mOutputFile.delete();
mBtnWatch.setText("查看视频");
}
}
}else{
Toast.makeText(this, "请先录制", Toast.LENGTH_SHORT).show();
}
break;
}
}
private static class MyHanlder extends Handler {
private WeakReference<PrimaryMediaCodecActivity> mPrimaryMediaCodecActivityWeakReference;
public MyHanlder(PrimaryMediaCodecActivity activity) {
mPrimaryMediaCodecActivityWeakReference = new WeakReference<PrimaryMediaCodecActivity>(activity);
}
@Override
public void handleMessage(Message msg) {
PrimaryMediaCodecActivity activity = mPrimaryMediaCodecActivityWeakReference.get();
if (activity != null) {
switch (msg.what) {
case START_RECORDING:
activity.drainEncoder(false);
activity.generateFrame(activity.cuurFrame);
Log.e(TAG, "handleMessage: " + activity.cuurFrame);
if (activity.cuurFrame < NUM_FRAMES) {
this.sendEmptyMessage(START_RECORDING);
} else {
activity.drainEncoder(true);
activity.mBtnRecording.setText("开始录制");
activity.releaseEncoder();
}
activity.cuurFrame++;
break;
case STOP_RECORDING:
Log.e(TAG, "handleMessage: STOP_RECORDING");
activity.drainEncoder(true);
activity.mBtnRecording.setText("开始录制");
activity.releaseEncoder();
break;
}
}
}
}
@Override
protected int setLayoutId() {
return R.layout.activity_primary_media_codec;
}
private void startRecording(File outputFile) throws IOException {
cuurFrame = 0;
prepareEncoder(outputFile);
mMyHanlder.sendEmptyMessage(START_RECORDING);
}
private void stopRecording() {
mMyHanlder.removeMessages(START_RECORDING);
mMyHanlder.sendEmptyMessage(STOP_RECORDING);
}
/**
* 准备视频编码器,muxer,和一个输入表面。
*/
private void prepareEncoder(File outputFile) throws IOException {
mBufferInfo = new MediaCodec.BufferInfo();
MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, WIDTH, HEIGHT);
//1. 设置一些属性。没有指定其中的一些可能会导致MediaCodec.configure()调用抛出一个无用的异常。
format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);//比特率(比特率越高,音视频质量越高,编码文件越大)
format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAMES_PER_SECOND);//设置帧速
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL);//设置关键帧间隔时间
//2.创建一个MediaCodec编码器,并配置格式。获取一个我们可以用于输入的表面,并将其封装到处理EGL工作的类中。
mEncoder = MediaCodec.createEncoderByType(MIME_TYPE);
mEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
mInputSurface = mEncoder.createInputSurface();
mEncoder.start();
//3. 创建一个MediaMuxer。我们不能在这里添加视频跟踪和开始合成,因为我们的MediaFormat里面没有缓冲数据。
// 只有在编码器开始处理数据后才能从编码器获得这些数据。我们实际上对多路复用音频没有兴趣。我们只是想要
// 将从MediaCodec获得的原始H.264基本流转换为.mp4文件。
mMuxer = new MediaMuxer(outputFile.toString(), MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
mMuxerStarted = false;
mTrackIndex = -1;
}
private void drainEncoder(boolean endOfStream) {
final int TIMEOUT_USEC = 10000;
if (endOfStream) {
mEncoder.signalEndOfInputStream();//在输入信号end-of-stream。相当于提交一个空缓冲区。视频编码完结
}
ByteBuffer[] encoderOutputBuffers = mEncoder.getOutputBuffers();
while (true) {
int encoderStatus = mEncoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {//没有可以输出的数据使用时
if (!endOfStream) {
break; // out of while
}
} else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
//输出缓冲区已经更改,客户端必须引用新的
encoderOutputBuffers = mEncoder.getOutputBuffers();
} else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
//输出格式发生了变化,后续数据将使用新的数据格式。
if (mMuxerStarted) {
throw new RuntimeException("format changed twice");
}
MediaFormat newFormat = mEncoder.getOutputFormat();
mTrackIndex = mMuxer.addTrack(newFormat);
mMuxer.start();
mMuxerStarted = true;
} else if (encoderStatus < 0) {
} else {
ByteBuffer encodedData = encoderOutputBuffers[encoderStatus];
if (encodedData == null) {
throw new RuntimeException("encoderOutputBuffer " + encoderStatus +
" was null");
}
if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
//当我们得到的时候,编解码器的配置数据被拉出来,并给了muxer。这时候可以忽略。不做处理
mBufferInfo.size = 0;
}
if (mBufferInfo.size != 0) {
if (!mMuxerStarted) {
throw new RuntimeException("muxer hasn't started");
}
//调整ByteBuffer值以匹配BufferInfo。
encodedData.position(mBufferInfo.offset);
encodedData.limit(mBufferInfo.offset + mBufferInfo.size);
mBufferInfo.presentationTimeUs = mFakePts;
mFakePts += 1000000L / FRAMES_PER_SECOND;
mMuxer.writeSampleData(mTrackIndex, encodedData, mBufferInfo);
}
mEncoder.releaseOutputBuffer(encoderStatus, false);
if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
if (!endOfStream) {
Log.e(TAG, "意外结束");
} else {
Log.e(TAG, "正常结束");
}
isRecording = false;
break;
}
}
}
}
private void generateFrame(int frameNum) {
Canvas canvas = mInputSurface.lockCanvas(null);
try {
int width = canvas.getWidth();
int height = canvas.getHeight();
float sliceWidth = width / 8;
Paint paint = new Paint();
for (int i = 0; i < 8; i++) {
int color = 0xff000000;
if ((i & 0x01) != 0) {
color |= 0x00ff0000;
}
if ((i & 0x02) != 0) {
color |= 0x0000ff00;
}
if ((i & 0x04) != 0) {
color |= 0x000000ff;
}
paint.setColor(color);
canvas.drawRect(sliceWidth * i, 0, sliceWidth * (i + 1), height, paint);
}
paint.setColor(0x80808080);
float sliceHeight = height / 8;
int frameMod = frameNum % 8;
canvas.drawRect(0, sliceHeight * frameMod, width, sliceHeight * (frameMod + 1), paint);
paint.setTextSize(50);
paint.setColor(0xffffffff);
for (int i = 0; i < 8; i++) {
if(i % 2 == 0){
canvas.drawText("aserbao", i * sliceWidth, sliceHeight * (frameMod + 1), paint);
}else{
canvas.drawText("aserbao", i * sliceWidth, sliceHeight * frameMod, paint);
}
}
} finally {
mInputSurface.unlockCanvasAndPost(canvas);
}
}
private void releaseEncoder() {
if (mEncoder != null) {
mEncoder.stop();
mEncoder.release();
mEncoder = null;
}
if (mInputSurface != null) {
mInputSurface.release();
mInputSurface = null;
}
if (mMuxer != null) {
mMuxer.stop();
mMuxer.release();
mMuxer = null;
}
}
}
4. 了解下音频录制
Android下的音频录制主要分两种:
- AudioRecord(基于字节流录音) (我们主要讲这个)。
- MediaRecorder(基于文件录音) :
虽然我们这里只讲第一种,在这里还是讲下优缺点:
使用AudioRecord录音
优点:可以对语音进行实时处理,比如变音,降噪,增益……,灵活性比较大。
缺点:就是输出的格式是PCM,你录制出来不能用播放器播放,需要用到AudioTrack来处理。使用 MediaRecorder:
优点:高度封装,操作简单,支持编码,压缩,少量的音频格式文件,灵活性差。
缺点:没法对音频进行实时处理。
1. AudioRecord的工作流程
- 创建AudioRecord实例,配置参数,初始化内部的音频缓冲区。
/**
*@param audioSource 音频采集的输入源,常用的值包括:DEFAULT(默认),VOICE_RECOGNITION(用于语音识别,等同于DEFAULT),MIC(由手机麦克风输入)等等,通常我们使用MIC
*@param sampleRateInHz 采样率,注意,目前44100Hz是唯一可以保证兼容所有Android手机的采样率。
*@param channelConfig 这个参数是用来配置“数据位宽”的,可选的值也是以常量的形式定义在 AudioFormat 类中,常用的是 ENCODING_PCM_16BIT(16bit),ENCODING_PCM_8BIT(8bit),注意,16BIT是可以保证兼容所有Android手机的。
*@param bufferSizeInBytes 它配置的是 AudioRecord 内部的音频缓冲区的大小,该缓冲区的值不能低于一帧“音频帧”(Frame)的大小,一帧音频帧的大小计算如下:int size = 采样率 x 位宽 x 采样时间(取值2.5ms ~ 120ms) x 通道数.
*/
public AudioRecord(int audioSource, int sampleRateInHz, int channelConfig, int audioFormat,
int bufferSizeInBytes)
上面提到的采样时间这里说一下,每个手机厂商设置的可能都不一样,我们设置的采样时间越短,声音的延时就越小。我们可以通过getMinBufferSize()方法来确定我们需要输入的bufferSizeInBytes值,官方说明是说小于getMinBufferSize()的值就会初始化失败。
- 开始采集音频。
这个比较简单:
AudioRecord.startRecording();//开始采集
AudioRecord.stop();//停止采集
……
AudioRecord.read(byte[] audioData, int offsetInBytes, int sizeInBytes);//读取数据
- 开启线程,将数据保存为pcm文件。
- 停止采集,资源释放。
关于AudioRecord录制的音频的例子就不在这里贴出来了,之后项目中会接入录音变音,降噪,增益等功能。都会在代码中给出。
5. 了解下音视频混合
前面讲到了视频和音频的录制,那么如何将他们混合呢?
同样就我所知目前有两种方法:
- 使用MediaMuxer进行混合。(我们将下这种,也是市面上最常用的)。
- 使用FFmpeg进行混合。(目前不讲,后面添加背景音乐会提到)
1. 了解下MediaMuxer
MediaMuxer官方文档地址
MediaMuxer最多仅支持一个视频track,一个音频的track.如果你想做混音怎么办?用ffmpeg进行混合吧。(目前还在研究FFMPEG这一块,欢迎大家一块来讨论。哈哈哈……),目前MediaMuxer支持MP4、Webm和3GP文件作为输出。视频编码的主要格式用H.264(AVC),音频用AAC编码(关于音频你用其他的在IOS端压根就识别不出来,我就踩过这个坑!)。
2. MediaMuxer的工作流程
- 创建MediaMuxer对象。
- 添加媒体通道,并将MediaFormat添加到MediaMuxer中去。
- 通过start()开始混合。
- writeSampleData()方法向mp4文件中写入数据。
- stop()混合关闭并进行资源释放。
官方实例:
MediaMuxer muxer = new MediaMuxer("temp.mp4", OutputFormat.MUXER_OUTPUT_MPEG_4);
// More often, the MediaFormat will be retrieved from MediaCodec.getOutputFormat()
// or MediaExtractor.getTrackFormat().
MediaFormat audioFormat = new MediaFormat(...);
MediaFormat videoFormat = new MediaFormat(...);
int audioTrackIndex = muxer.addTrack(audioFormat);
int videoTrackIndex = muxer.addTrack(videoFormat);
ByteBuffer inputBuffer = ByteBuffer.allocate(bufferSize);
boolean finished = false;
BufferInfo bufferInfo = new BufferInfo();
muxer.start();
while(!finished) {
// getInputBuffer() will fill the inputBuffer with one frame of encoded
// sample from either MediaCodec or MediaExtractor, set isAudioSample to
// true when the sample is audio data, set up all the fields of bufferInfo,
// and return true if there are no more samples.
finished = getInputBuffer(inputBuffer, isAudioSample, bufferInfo);
if (!finished) {
int currentTrackIndex = isAudioSample ? audioTrackIndex : videoTrackIndex;
muxer.writeSampleData(currentTrackIndex, inputBuffer, bufferInfo);
}
};
muxer.stop();
muxer.release();
好了,综上所述知识,已经实现了从预览到录制完成的讲解。
6. 了解下多段视频拼接合成
多段视频合成这里提供两种方案:
- 使用MediaCodec,MediaExtractor,MediaMuxer.(讲思路)。
- 使用mp4parser合成视频。(将使用)。
- 使用FFMpeg来实现。(音视频这一块找它就没错了,基本没有它实现不了的)。
下面我们主要来讲下两种方式的使用,第一种我们讲思路,第二种讲如何使用?第三个暂时不讲。
1. 讲下如何使用Android原生实现视频合成。
只讲思路及实现步骤,代码在项目中之后给出,目前我还没写进去,原谅我最近偷懒一波。大体思路如下:
- 我们通过MediaExtractor将媒体文件分解并找到轨道及帧数据。
- 将分解后的数据填充到MediaCodec的缓冲区中去。
- 通过MediaMuxer将MediaCodec中的数据和找到的音轨进行混合。
- 遍历第二个视频文件。
差不多就是这样滴,因为这个我是看别人是这么做的,我偷懒用了mp4parser,所以仅能给个位提供思路了,今后有时间再了解下。
2. 讲下如何使用mp4parser合成多个视频
上面有提到我现在使用的就是这个,他是开源滴,来来来,点这里给你们传送门。虽然上面对于使用方法都说得很清楚了,虽然我的项目中也会有源代码,但是我还是要把这部分写出来:
/**
* 对Mp4文件集合进行追加合并(按照顺序一个一个拼接起来)
* @param mp4PathList [输入]Mp4文件路径的集合(支持m4a)(不支持wav)
* @param outPutPath [输出]结果文件全部名称包含后缀(比如.mp4)
* @throws IOException 格式不支持等情况抛出异常
*/
public String mergeVideo(List<String> paths, String filePath) {
long begin = System.currentTimeMillis();
List<Movie> movies = new ArrayList<>();
String filePath = "";
if(paths.size() == 1){
return paths.get(0);
}
try {
for (int i = 0; i < paths.size(); i++) {
if(paths != null && paths.get(i) != null) {
Movie movie = MovieCreator.build(paths.get(i));//视频消息实体类
movies.add(movie);
}
}
List<Track> videoTracks = new ArrayList<>();
List<Track> audioTracks = new ArrayList<>();
for (Movie movie : movies) {
for (Track track : movie.getTracks()) {
if ("vide".equals(track.getHandler())) {
videoTracks.add(track);//从Movie对象中取出视频通道
}
if ("soun".equals(track.getHandler())) {
audioTracks.add(track);//Movie对象中得到的音频轨道
}
}
}
Movie result = new Movie();
if (videoTracks.size() > 0) {
// 将所有视频通道追加合并
result.addTrack(new AppendTrack(videoTracks.toArray(new Track[videoTracks.size()])));
}
if (audioTracks.size() > 0) {
// 将所有音频通道追加合并
result.addTrack(new AppendTrack(audioTracks.toArray(new Track[audioTracks.size()])));
}
Container container = new DefaultMp4Builder().build(result);
filePath = getRecorderPath();
FileChannel fc = new RandomAccessFile(String.format(filePath), "rw").getChannel();//合成并输出到指定文件中
container.writeContainer(fc);
fc.close();
} catch (Exception e) {
e.printStackTrace();
return paths.get(0);
}
long end = System.currentTimeMillis();
return filePath;
}
7. 了解下如何获取视频帧?
先看下我们要实现什么功能,如下:
简单分析下,我们现在需要将整个视频的部分帧拿出在下面显示出来,并且添加上面的动态贴纸显示。
1. 如何拿出视频帧?
Android平台下主要有两种拿视频帧的方法:
- 使用ThumbnailUtils,一般用来拿去视频缩略图。
- 使用MediaMetadataRetriever的getFrameAtTime()拿视频帧(我们用的这种方式)。
MediaMetadataRetriever mediaMetadata = new MediaMetadataRetriever();
mediaMetadata.setDataSource(mContext, Uri.parse(mInputVideoPath));
mVideoRotation = mediaMetadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
mVideoWidth = Integer.parseInt(mediaMetadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));
mVideoHeight = Integer.parseInt(mediaMetadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT));
mVideoDuration = Integer.parseInt(mediaMetadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
int frameTime = 1000 * 1000;//帧间隔
int frame = mVideoDuration * 1000 / frameTime;//帧总数
mAsyncTask = new AsyncTask<Void, Void, Boolean>() {
@Override
protected Boolean doInBackground(Void... params) {
myHandler.sendEmptyMessage(ClEAR_BITMAP);
for (int x = 0; x < frame; x++) {
//拿到帧图像
Bitmap bitmap = mediaMetadata.getFrameAtTime(frameTime * x, MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
}
mediaMetadata.release();//释放别忘记
return true;
}
@Override
protected void onPostExecute(Boolean result) {
myHandler.sendEmptyMessage(SUBMIT);//所有帧拿完了
}
拿完所有帧,好了,好了,下一个话题。
2. 如何分解Gif图?
看到上面的等撩了么?
先说下为什么要将Gif图进行分解操作,因为我在添加动态贴纸的时候是在OpenGl Es的OnDraw方法中通过每次动态修改纹理来达到动态贴纸的效果的。所以必须要将Gif图分解成每帧的形式。怎么将Gif图解析出来呢?Google出来一个工具类GifDecoder!当然,后面我去找了Glide的源码,分析其内部Gif图的显示流程,发现其实原理是一样的。Glide StandardGifDecoder当然,关于Glide的Gif图解析内容还是蛮多的,这里不做分析(没有太过深入研究),今后有时间看能不能写一篇文章专门分析。
当然,关于GifDecoder的代码,这里就不贴出来了,会在项目中给出!当然,现在项目中还没有,因为文章写完,我这个项目肯定写不完的,最近事太多,忙着开产品讨论会,尽量在讨论之前5月25号之前能将项目写完。所以这里还请各位多谅解下。
7. 了解下FFmpeg
参考文章:1.FFmpeg官网2. 官方项目地址github3. [FFmpeg]ffmpeg各类参数说明与使用示例
如果你有接触到音视频开发这一块,肯定听说过FFmpeg这个庞然大物。为什么说庞然大物?因为我最近在学习这个,越学越觉得自己无知。哎,不多说了,我要加班恶补FFMpeg了。
1. 了解下什么是FFmpeg
FFmpeg是一个自由软件,可以运行音频和视频多种格式的录影、转换、流功能[2],包含了libavcodec——这是一个用于多个项目中音频和视频的解码器库,以及libavformat——一个音频与视频格式转换库。(来源wiki),简单点可以将FFmpeg理解成音视频处理软件。可以通过输入命令的方式对视频进行任何操作。没错,是任何(一点都不夸张)!
2. 如何在Android下使用FFmpeg
对于FFmpeg,我只想说,我还是个小白,希望各位大大不要在这个问题上抓着我严刑拷打。众所周知的,FFmpge是C实现的,所以生成so文件再调用吧!怎么办?我不会呀?这时候就要去找前人种的树了。这里给一个我参考使用的FFmpeg文件库导入EpMedia,哎,乘凉,感谢这位大大!
当然,如果想了解下FFmpeg的编译,可以看下Android最简单的基于FFmpeg的例子(一)---编译FFmpeg类库](http://www.ihubin.com/blog/android-ffmpeg-demo-1/)
如何使用?
//请记住这个cmd,输入命令cmd,我们就等着行了
EpEditor.execCmd(cmd, 0, new OnEditorListener() {
@Override
public void onSuccess() {
}
@Override
public void onFailure() {
}
@Override
public void onProgress(float v) {
}
});
下面是在我的应用中使用到的一些命令:
1. 视频加减速命令:
设置变速值为speed(范围为0.5-2之间);参数值:setpts= 1/speed;atempo=speed
减速:speed = 0.5;
ffmpeg -i /sdcard/WeiXinRecordedDemo/1515059397193/mergeVideo.mp4 -filter_complex [0:v]setpts=2.000000*PTS[v];[0:a]atempo=0.500000[a] -map [v] -map [a] -y /sdcard/WeiXinRecordedDemo/1515059397193/speedVideo.mp4
加速:speed = 2;
ffmpeg -i /sdcard/WeiXinRecordedDemo/1515118254029/mergeVideo.mp4 -filter_complex [0:v]setpts=0.500000*PTS[v];[0:a]atempo=2.000000[a] -map [v] -map [a] -y /sdcard/WeiXinRecordedDemo/1515118254029/speedVideo.mp4
2. 视频剪切命令:
ffmpeg -i /sdcard/WeiXinRecordedDemo/1515060907399/finish.mp4 -vcodec copy -acodec copy -ss 00:00:00 -t 00:00:01 /sdcard/WeiXinRecordedDemo/1515060907399/1515060998134.mp4
3. 视频压缩命令:
String path = "/storage/emulated/0/ych/123.mp4";
String currentOutputVideoPath = "/storage/emulated/0/ych/video/123456.mp4";
String commands ="-y -i " + path + " -strict-2 -vcodec libx264 -preset ultrafast " +
"-crf 24 -acodec aac -ar 44100 -ac 2 -b:a 96k -s 640x480 -aspect 16:9 " + currentOutputVideoPath;
4.给视频添加背景音乐
ffmpeg -y -i /storage/emulated/0/DCIM/Camera/VID_20180104_121113.mp4 -i /storage/emulated/0/ych/music/A Little Kiss.mp3 -filter_complex [0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,volume=1.0[a0];[1:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,volume=0.5[a1];[a0][a1]amix=inputs=2:duration=first[aout] -map [aout] -ac 2 -c:v copy -map 0:v:0 /storage/emulated/0/ych/music/1515468589128.mp4
5. 加字幕
命令:
ffmpeg -i <input> -filter_complex subtitles=filename=<SubtitleName>-y <output>
说明:利用libass来为视频嵌入字幕,字幕是直接嵌入到视频里的硬字幕。
6. 加水印
String mCommands ="-y -i "+ videoPath + " -i " + imagePath + " -filter_complex [0:v]scale=iw:ih[outv0];[1:0]scale=240.0:84.0[outv1];[outv0][outv1]overlay=main_w-overlay_w-10:main_h-overlay_h-10 -preset ultrafast " + outVideoPath;
说明:imagePath为图片路径,overlay=100:100意义为overlay=x:y,在(x,y)坐标处開始加入水印。scale 为图片的缩放比例
左上角:overlay=10:10
右上角:overlay=main_w-overlay_w-10:10
左下角:overlay=10:main_h-overlay_h-10
右下角:overlay=main_w-overlay_w-10:main_h-overlay_h-10
7. 旋转
视频旋转也可以参考使用OpenCV和FastCV,当然前两种是在线处理,如果是视频录制完成,我们可以通过mp4parser进行离线处理。参考博客Android进阶之视频录制播放常见问题
命令:
ffmpeg -i <input> -filter_complex transpose=X -y <output>
说明:transpose=1为顺时针旋转90°,transpose=2逆时针旋转90°。
8. 参考链接及项目
在音视频开发的路上,感谢下面的文章及项目的作者,感谢他们的无私奉献,在前面种好大树,让我们后来者乘凉。
拍摄录制功能:1. grafika 2. WeiXinRecordedDemo
OpenGL 系列:1. 关于OpenGl的学习:AndroidOpenGLDemo LearnOpenGL-CN 2. 关于滤镜的话:android-gpuimage-plus-masterandroid-gpuimage
关于FFmpeg 1.FFmpeg官网2. 官方项目地址github3. [FFmpeg]ffmpeg各类参数说明与使用示例1. ffmpeg-android-java
贴纸 1. StickerView
9. 结束语
到这里文章基本上结束了,最后想和各位说的是,实在抱歉,确实最近时间有点紧,每天来公司大部分时间在讨论产品,剩下的一小部分时间不是在路上,就是在吃饭睡觉了。每天能抽半个小时写就很不错了。值得庆幸的是,最终它还是完成了,希望通过本文能给大家带来一些实质性的帮助。本来想多写一点,尽量写详细点,但是精力有限,后面的关于滤镜,美颜,变声,及人脸识别部分的之后会再重新整理。最后,项目地址AndroidCamera,项目还没写完,抱歉,后面完善。
10. 广告
请注意,以下内容将全都是广告:
- aserbao的简书
- aserbao的csdn
- 我的同名微信公众号aserbao,分享音视频技术及Android开发小技巧,每周五更新,喜欢的朋友关注下。