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

若是你有学Android 音视频,相机开发的想法,那么这篇文章能够做为一篇不错的参考文章。固然本文为付费文章,收费10元,若是对你有用,文末赞扬缴费便可。若是没有学习音视频,相机的欲望,赶快走,赶快走,不要有一丝停留,由于这篇文章确实枯燥无味且毫无快感可言。若是不知道我讲的是啥,先到Github项目:AndroidCamera去看下效果就知道了。html

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

3……android

2……git

1……github

不走,我再扯两句:canvas

这篇是在学习相机音视频开发的时候写的一篇总结。因为涉及的知识点比较多,因此其中部分知识点仅起引导做用。数组

ok,枯燥无味正式开始:缓存

这篇文章计划写的内容覆盖面是很普遍的,涵盖相机开发的大部分知识,并且我对本身写做要求:内容尽可能精炼,不能泛泛而谈。因此时间上来讲很紧凑了。固然,若是文章各方面你们有看不顺眼的地方,但愿你们帮忙指出批评,必定虚心接受,积极改正。若是从此有机会见面,请您喝茶。bash

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和游戏主机等嵌入式设备而设计。(我不会偷偷告诉你我是百度滴)

关于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录制过视频,这个实例建议你看一下,若是你很是了解了,请跳过。效果图以下:

这里写图片描述

可贵给下代码,固然,项目中会有更多关于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支持MP四、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类库](www.ihubin.com/blog/androi…)

如何使用?

//请记住这个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开发小技巧。

这里写图片描述

相关文章
相关标签/搜索