Android 相机开发,音视频入门教程全解

该文章已在公众号「aserbaocool」发布。如需转载请联系作者。

如果你有学Android 音视频,相机开发的想法,那么这篇文章可以作为一篇不错的参考文章。当然本文为付费文章,收费5元,如果对你有用,文末赞赏缴费即可。如果没有学习音视频,相机的欲望,赶快走,赶快走,不要有一丝停留,因为这篇文章确实枯燥无味且毫无快感可言。

请考虑3s赶快决定去留。

3

2

1

我再扯两句:

这篇是在学习相机音视频开发的时候写的一篇总结。由于涉及的知识点比较多,所以其中部分知识点仅起引导作用。当然,微信有很多链接不能链接,如果要点击链接的话请到原文:https://gitbook.cn/new/gitchat/activity/5aeb03e3af08a333483d71c1 去查看:

ok,枯燥无味正式开始:

昨天发Chat之前还仔细看了时间计划表,时间是5.24提交文章,时间在计划之内,所以才提交的。结果很快通过审核。意料之外的是今天上午11:20就提示我预订人数已经达标了。感谢大家对我的认可,非常感谢。结果我点进去一看,文章提交时间到居然提前到5.18了提交了,人数达标提前一周完成,所以文章提前了一周提交(有点小情绪,平台没有提前告诉我这个,不过工作人员的解答还是缓和了我的暴脾气,好评)。最后到我这压力就大了。因为这篇文章计划写的内容覆盖面是很广泛的,涵盖相机开发的大部分知识,而且我对自己写作要求:内容尽量精炼,不能泛泛而谈。所以时间上来说很紧凑了。当然,如果文章各方面大家有看不顺眼的地方,希望大家帮忙指出批评,一定虚心接受,积极改正。如果今后有机会见面,请你喝茶。项目地址AndroidCamera

Android 相机 camera

1. 从打开一个摄像头说起

当然,这个对大部分人来说都是没什么问题的,但是该篇文章还得照顾大部分初次接触Camera开发的小伙伴,所以请容许我在此多啰嗦一下,如果你有接触过Camera的开发,此部分可以跳过,直接看下一部分。

a. 使用Camera的步骤:

说下Camera的操作步骤,后面给出实例,请结合代码理解分析:

  1. 获取一个Camera实例,通过open方法,Camera.open(0),0是后置摄像头,1表示前置摄像头。
  2. 设置Camera的参数,比如聚焦,是否开闪光灯,预览高宽,修改Camera的默认参数:mCamera.getParameters()
    通过初始化SurfaceHolder去setPreviewDisplay(SurfaceHolder),没有surface,Camera不能开始预览。
  3. 调用startPreview方法开始更新预览到surface,在拍照之前,startPreview必须调用,预览必须开启。
  4. 当你想开始拍照时,使用takePicture(Camera.ShutterCallback, Camera.PictureCallback, Camera.PictureCallback, Camera.PictureCallback), 等待回调提供真实的图像数据
    当拍完一张照片时,预览(preview)将会停止,当你想要拍更多的照片时,须要再一次调用startPreview方法
  5. 当调用stopPreview方法时,将停止更新预览的surface
  6. 当调用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和游戏主机等嵌入式设备而设计。(我不会偷偷告诉你我是百度滴)

image

关于OpenGl ES如何绘制一个简单基本图形,下面会做一个简单的讲解,如果你想对OpenGL ES有更深层次的了解,可以看下我写的关于一篇OpenGL绘制简单三角形的文章Android openGl开发详解(一)——简单图形的基本绘制

1. 使用OpenGl ES绘制相机数据必备的基本知识

1. 关于OpenGl ES渲染流程了解下:

首先我们必须明确我们要做的是将相机数据显示到设备屏幕上,所有的操作都是为此目的服务的。所以我们必须要了解OpenGl ES是如何进行渲染的。(如果下面提到的术语你没有概念,或者模棱两可,请看再看一遍Android openGl开发详解(一)——简单图形的基本绘制)
下面是基本步骤:

  1. 布局文件中添加GlSurfaceView,并为其指定渲染器Renderer。
  2. 设置画布大小,清除画布内容,创建纹理对象,并指定OpenGl ES操作纹理ID。(下面会讲到)
  3. 加载顶点着色器(vertex shader)和片元着色器(fragment shader)。
  4. 创建OpenGl ES程序,创建program对象,连接顶点和片元着色器,链接program对象。
  5. 打开相机,设置预览布局,开启预览,并通过glUseProgram()方法将程序添加到OpenGl ES环境中,获取着色器句柄,通过glVertexAttribPointer()传入绘制数据并启用顶点位置句柄。
  6. 在onDrawFrame方法中更新缓冲区帧数据并通过glDrawArrays绘制到GlSurfaceView上。
  7. 操作完成后资源释放,需要注意的是使用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纹理使用的步骤:

  1. 首先我们需要创建一个纹理对象,通过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的时候再进行数据转换了。

  1. 如果我们要给当前纹理添加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);
  1. 接来下就如上面OpenGl ES渲染流程所提到的,将纹理绘制到屏幕上。

3. 一起了解下使用MediaCodec实现相机录制

上面我们将相机的预览显示讲完了,接下里我们讲如何将录制视频。就目前来说,Android的录制方式就要有下面三中:

  1. 使用MediaRecord进行录制。(这个不讲解)
  2. 使用MediaCodec进行录制(我们讲这种) 。
  3. 使用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的使用步骤:

  1. 创建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)
                      ……
  1. 创建一个MediaCodec的编码器,并配置格式。
  2. 创建一个MediaMuxer来合成视频。
  3. 通过dequeueInput/OutputBuffer()获取输入输出缓冲区。
  4. 通过getInputBuffers获取输入队列,然后通过queueInputBuffer把原始YUV数据送入编码器。
  5. 通过dequeueOutputBuffer方法获取当前编解码状态,根据不同的状态进行处理。
  6. 再然后在输出队列端同样通过dequeueOutputBuffer获取输出的h264流。
  7. 处理完输出数据之后,需要通过releaseOutputBuffer把输出buffer还给系统,重新放到输出队列中。
  8. 使用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下的音频录制主要分两种:

  1. AudioRecord(基于字节流录音) (我们主要讲这个)。
  2. MediaRecorder(基于文件录音) :

虽然我们这里只讲第一种,在这里还是讲下优缺点:

  1. 使用AudioRecord录音
    优点:可以对语音进行实时处理,比如变音,降噪,增益……,灵活性比较大。
    缺点:就是输出的格式是PCM,你录制出来不能用播放器播放,需要用到AudioTrack来处理。

  2. 使用 MediaRecorder:
    优点:高度封装,操作简单,支持编码,压缩,少量的音频格式文件,灵活性差。
    缺点:没法对音频进行实时处理。

1. AudioRecord的工作流程

  1. 创建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()的值就会初始化失败。

  1. 开始采集音频。
    这个比较简单:
AudioRecord.startRecording();//开始采集
AudioRecord.stop();//停止采集
……
AudioRecord.read(byte[] audioData, int offsetInBytes, int sizeInBytes);//读取数据
  1. 开启线程,将数据保存为pcm文件。
  2. 停止采集,资源释放。

关于AudioRecord录制的音频的例子就不在这里贴出来了,之后项目中会接入录音变音,降噪,增益等功能。都会在代码中给出。

5. 了解下音视频混合

前面讲到了视频和音频的录制,那么如何将他们混合呢?
同样就我所知目前有两种方法:

  1. 使用MediaMuxer进行混合。(我们将下这种,也是市面上最常用的)。
  2. 使用FFmpeg进行混合。(目前不讲,后面添加背景音乐会提到)

1. 了解下MediaMuxer

MediaMuxer官方文档地址
MediaMuxer最多仅支持一个视频track,一个音频的track.如果你想做混音怎么办?用ffmpeg进行混合吧。(目前还在研究FFMPEG这一块,欢迎大家一块来讨论。哈哈哈……),目前MediaMuxer支持MP4、Webm和3GP文件作为输出。视频编码的主要格式用H.264(AVC),音频用AAC编码(关于音频你用其他的在IOS端压根就识别不出来,我就踩过这个坑!)。

2. MediaMuxer的工作流程

  1. 创建MediaMuxer对象。
  2. 添加媒体通道,并将MediaFormat添加到MediaMuxer中去。
  3. 通过start()开始混合。
  4. writeSampleData()方法向mp4文件中写入数据。
  5. 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. 了解下多段视频拼接合成

多段视频合成这里提供两种方案:

  1. 使用MediaCodec,MediaExtractor,MediaMuxer.(讲思路)。
  2. 使用mp4parser合成视频。(将使用)。
  3. 使用FFMpeg来实现。(音视频这一块找它就没错了,基本没有它实现不了的)。

下面我们主要来讲下两种方式的使用,第一种我们讲思路,第二种讲如何使用?第三个暂时不讲。

1. 讲下如何使用Android原生实现视频合成。

只讲思路及实现步骤,代码在项目中之后给出,目前我还没写进去,原谅我最近偷懒一波。大体思路如下:

  1. 我们通过MediaExtractor将媒体文件分解并找到轨道及帧数据。
  2. 将分解后的数据填充到MediaCodec的缓冲区中去。
  3. 通过MediaMuxer将MediaCodec中的数据和找到的音轨进行混合。
  4. 遍历第二个视频文件。

差不多就是这样滴,因为这个我是看别人是这么做的,我偷懒用了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平台下主要有两种拿视频帧的方法:

  1. 使用ThumbnailUtils,一般用来拿去视频缩略图。
  2. 使用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. 参考学习对象(排名无先后)
    雷霄骅 湖广午王 逆流的鱼yuiop小码哥_WS
    感谢四位老哥的博客,给予了我很大帮助。

  2. 拍摄录制功能:1. grafika 2. WeiXinRecordedDemo

  3. OpenGL 系列:1. 关于OpenGl的学习:AndroidOpenGLDemo LearnOpenGL-CN 2. 关于滤镜的话:android-gpuimage-plus-masterandroid-gpuimage

  4. 关于FFmpeg 1.FFmpeg官网2. 官方项目地址github3. [FFmpeg]ffmpeg各类参数说明与使用示例1. ffmpeg-android-java

  5. 贴纸 1. StickerView

9. 结束语

到这里文章基本上结束了,最后想和各位说的是,实在抱歉,确实最近时间有点紧,每天来公司大部分时间在讨论产品,剩下的一小部分时间不是在路上,就是在吃饭睡觉了。每天能抽半个小时写就很不错了。值得庆幸的是,最终它还是完成了,希望通过本文能给大家带来一些实质性的帮助。本来想多写一点,尽量写详细点,但是精力有限,后面的关于滤镜,美颜,变声,及人脸识别部分的之后会再重新整理。最后,项目地址AndroidCamera,项目还没写完,抱歉,后面完善。

10. 广告

请注意,以下内容将全都是广告:

  1. aserbao的简书
  2. aserbao的csdn
  3. 我的同名微信公众号aserbao,分享音视频技术及Android开发小技巧,每周五更新,喜欢的朋友关注下。
这里写图片描述

推荐阅读更多精彩内容