前言

如今的视频类app可谓是如日中天,火的不行。比如美拍、快手、VUE、火山小视频、抖音小视频等等。而这类视频的最基础和核心的功能就是视频录制和视频编辑功能。包括了手机视频录制、美白、加滤镜、加水印、给本地视频美白、加水印、加滤镜、视频裁剪、视频拼接和加bgm等等一系列音视频的核心操作。 而本系列的文章,就是作者在视频编辑器开发上的一些个人心得,希望能帮助到大家,另外因个人水平有限,难免有不足之处,还希望大家不惜赐教。
      本系列的文章,计划包括以下几部分:
      1、android视频编辑器之视频录制、断点续录、对焦等
      6、android视频编辑器之通过OpenGL做本地视频拼接
      7、android视频编辑器之音视频裁剪、增加背景音乐等
      通过这一系列的文章,大家就能自己开发出一个具有目前市面上完整功能的视频类app最核心功能的视频编辑器了(当然,如果作者能按计划全部写完的话。。。捂脸)。主要涉及到的核心知识点有Android音视频编解码、OpenGL开发、音视频的基础知识等等。整个过程会忽略掉一些基础知识,只会讲解一些比较核心的技术点。所有代码都会上传到github上面。有兴趣的童鞋,可以从文章末尾进行下载。
      文章中也借鉴和学习了很多其他小伙伴们分享的知识。 每篇文章我都会贴出不完全的相关连接,非常感谢小伙伴们的分享。
       Opengl入门详解
      

方案选择

     
      关于android平台的视频录制,首先我们要确定我们的需求,录制音视频本地保存为mp4文件。实现音视频录制的方案有很多,比如原生的sdk,通过Camera进行数据采集,注意:是Android.hardware包下的Camera而不是Android.graphics包下的,后者是用于矩阵变换的一个类,而前者才是通过硬件采集摄像头的数据,并且返回到java层。然后使用SurfaceView进行画面预览,使用MediaCodec对数据进行编解码,最后通过MediaMuxer将音视频混合打包成为一个mp4文件。当然也可以直接使用MediaRecorder类进行录制,该类是封装好了的视频录制类,但是不利于功能扩展,比如如果我们想在录制的视频上加上我们自己的logo,也就是常说的加水印,或者是录制一会儿 然后暂停 然后继续录制的功能,也就是断点续录的话 就不是那么容易实现了。而本篇文章,作为后面系列的基础,我们就不讲解常规的视频录制的方案了,有兴趣的可以查看本文前面附上的一些链接。因为我们后期会涉及到给视频加滤镜、加水印、加美颜等功能,所以就不能使用常规的视频录制方案了,而是采用Camera + OpengGL + MediaCodec +进MediaMuxer行视频录制。

视频预览

      为了实现录制的效果,首先我们得实现摄像头数据预览的功能。
Camera的使用
      android在5.0的版本加载了hardware.camera2包对android平台的视频录制功能进行了增强,但是为了兼容低版本,我们将使用的是Camera类,而不是5.0之后加入的新类。对新api感兴趣的童鞋,可以自行查阅相关资料。

GLSurfaceView的作用
       其实视频的预览大致流程就是,从Camera中拿到当前摄像头返回的数据,然后显示在屏幕上,我们这里是采用的GLSurfaceView类进行图像的显示。GLSurfaceView类有一个Renderer接口,这个Renderer其实就是GLSurfaceView中很重要的一个监听器,你可以把他看成是GLSurfaceView的生命周期的回调。有三个回调函数:
 @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
       
    }
    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {

    }
    @Override
    public void onDrawFrame(GL10 gl) {
      
    }
      onSurfaceCreated:我们主要在这里面做一些初始化的工作
      onSurfaceChanged:就是当surface大小发生变化的时候,会回调,我们主要会在这里面做一些更改相关设置的工作
      onDrawFrame:这个就是返回当前帧的数据,我们对帧数据进行处理,主要就是在这里面进行的
       所以,我们的大致流程就是按照这三个回调方法来进行的:
      
CameraController类对camera进行控制
      Camera的使用过程,网上已经有很多资料了,这里就不在过多的介绍了。但是 有几个地方需要注意一下,首先就是你设置的视频尺寸摄像头并不一定支持,所以我们要选取摄像头支持的,跟我们预设的相同或者相近的尺寸,主要代码如下
     mCamera = Camera.open(cameraId);
        if (mCamera != null){
            /**选择当前设备允许的预览尺寸*/
            Camera.Parameters param = mCamera.getParameters();
            preSize = getPropPreviewSize(param.getSupportedPreviewSizes(), mConfig.rate,
                    mConfig.minPreviewWidth);
            picSize = getPropPictureSize(param.getSupportedPictureSizes(),mConfig.rate,
                    mConfig.minPictureWidth);
            param.setPictureSize(picSize.width, picSize.height);
            param.setPreviewSize(preSize.width,preSize.height);

            mCamera.setParameters(param);
    }

    private Camera.Size getPropPictureSize(List<Camera.Size> list, float th, int minWidth){
        Collections.sort(list, sizeComparator);
        int i = 0;
        for(Camera.Size s:list){
            if((s.height >= minWidth) && equalRate(s, th)){
                break;
            }
            i++;
        }
        if(i == list.size()){
            i = 0;
        }
        return list.get(i);
    }
    private Camera.Size getPropPreviewSize(List<Camera.Size> list, float th, int minWidth){
        Collections.sort(list, sizeComparator);

        int i = 0;
        for(Camera.Size s:list){
            if((s.height >= minWidth) && equalRate(s, th)){
                break;
            }
            i++;
        }
        if(i == list.size()){
            i = 0;
        }
        return list.get(i);
    }
    private static boolean equalRate(Camera.Size s, float rate){
        float r = (float)(s.width)/(float)(s.height);
        if(Math.abs(r - rate) <= 0.03) {
            return true;
        }else{
            return false;
        }
    }
    private Comparator<Camera.Size> sizeComparator=new Comparator<Camera.Size>(){
        public int compare(Camera.Size lhs, Camera.Size rhs) {
            if(lhs.height == rhs.height){
                return 0;
            }else if(lhs.height > rhs.height){
                return 1;
            }else{
                return -1;
            }
        }
    };
     这个代码还是相当简单,这里就不过多介绍了,网上也有很多不同的但是类似功能的适配方法,大家可以多了解下,相互对照。
     第二个就是,摄像头取数据的坐标系和屏幕显示的坐标系不太相同,简单的说就是,不管是前置还是后置摄像头,我们都需要对摄像头取的数据进行一些坐标系旋转操作,才能正常的显示到屏幕上,不然的话就会出现画面扭曲的情况。因为我们是采用的OpengGL进行视频录制的,所以我们会有一系列的AFilter来进行shader的加载和画面的渲染工作,所以我们将摄像头数据的旋转也放到这个里面来做。这部分后面再说, CameraController类主要就是Camera的一个包装类,还会包括一些视频尺寸控制等代码,具体的请下载完整demo,进行查看。

AFilter的作用   
       我们在这个项目中,我们使用了AFilter来完成加载shader、绘制图像、清除数据等,主要代码包括如下:
加载asset中的shader
public static int uLoadShader(int shaderType,String source){
        int shader= GLES20.glCreateShader(shaderType);
        if(0!=shader){
            GLES20.glShaderSource(shader,source);
            GLES20.glCompileShader(shader);
            int[] compiled=new int[1];
            GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS,compiled,0);
            if(compiled[0]==0){
                glError(1,"Could not compile shader:"+shaderType);
                glError(1,"GLES20 Error:"+ GLES20.glGetShaderInfoLog(shader));
                GLES20.glDeleteShader(shader);
                shader=0;
            }
        }
        return shader;
    }

      Buffer的初始化
/**
     * Buffer初始化
     */
    protected void initBuffer(){
        ByteBuffer a= ByteBuffer.allocateDirect(32);
        a.order(ByteOrder.nativeOrder());
        mVerBuffer=a.asFloatBuffer();
        mVerBuffer.put(pos);
        mVerBuffer.position(0);
        ByteBuffer b= ByteBuffer.allocateDirect(32);
        b.order(ByteOrder.nativeOrder());
        mTexBuffer=b.asFloatBuffer();
        mTexBuffer.put(coord);
        mTexBuffer.position(0);
    }

      绑定默认的纹理
/**
     * 绑定默认纹理
     */
    protected void onBindTexture(){
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0+textureType);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,getTextureId());
        GLES20.glUniform1i(mHTexture,textureType);
    }

     每次绘制前需要清理画布
/**
     * 清理画布
     */
    protected void onClear(){
        GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
    }

      这些代码在不同的filter中其实都是公用的,所以我们通过一个抽象类,来进行管理。
      上面我们说了摄像头的数据需要进行旋转,所以我们通过一个ShowAFilter来进行画面的旋转操作,核心代码如下,通过传入是前置还是后置摄像头的flag来进行画面旋转
public void setFlag(int flag) {
        super.setFlag(flag);
        float[] coord;
        if(getFlag()==1){    //前置摄像头 顺时针旋转90,并上下颠倒
            coord=new float[]{
                    1.0f, 1.0f,
                    0.0f, 1.0f,
                    1.0f, 0.0f,
                    0.0f, 0.0f,
            };
        }else{               //后置摄像头 顺时针旋转90度
            coord=new float[]{
                    0.0f, 1.0f,
                    1.0f, 1.0f,
                    0.0f, 0.0f,
                    1.0f, 0.0f,
            };
        }
        mTexBuffer.clear();
        mTexBuffer.put(coord);
        mTexBuffer.position(0);
    }

      摄像头和AFilter我们都已经准备好了,下一步,就是我们需要把Camera取的数据显示在GLSurfaceView上面了,也就是需要将AFilter、 CameraController和GLSurfaceView联系起来。然后,因为我们后续会涉及到很多不同AFilter的管理,所以我们创建一个CameraDraw类,来管理AFilter。让其实现GLSurfaceView.Renderer接口,便于管理。

CameraDraw类
      首先实现GLSurfaceView.Renderer接口
    public class CameraDrawer implements GLSurfaceView.Renderer
       然后,在类的构造函数中,进行AFilter的初始化
   public CameraDrawer(Resources resources){
        //初始化一个滤镜 也可以叫控制器
        showFilter = new ShowFilter(resources);      
   }
      在 onSurfaceCreated中,进行SurfaceTextured的创建,并且和AFilter进行绑定
 @Override
    public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
        textureID = createTextureID();
        mSurfaceTextrue = new SurfaceTexture(textureID);
        showFilter.create();
        showFilter.setTextureId(textureID);   
       
    }
     在onSurfaceChanged中,进行一些参数的更改和纹理的重新绑定
    @Override
    public void onSurfaceChanged(GL10 gl10, int i, int i1) {
        width = i;
        height = i1;
        /**创建一个帧染缓冲区对象*/
        GLES20.glGenFramebuffers(1,fFrame,0);
        /**根据纹理数量 返回的纹理索引*/
        GLES20.glGenTextures(1, fTexture, 0);
        /**将生产的纹理名称和对应纹理进行绑定*/
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fTexture[0]);
        /**根据指定的参数 生产一个2D的纹理 调用该函数前  必须调用glBindTexture以指定要操作的纹理*/
        GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, mPreviewWidth, mPreviewHeight,
                0,  GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null);
        useTexParameter();
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0);
    }
      在 onDrawFrame中进行图像的绘制工作。
@Override
    public void onDrawFrame(GL10 gl10) {
        /**更新界面中的数据*/
        mSurfaceTextrue.updateTexImage();

        /**绘制显示的filter*/
        GLES20.glViewport(0,0,width,height);
        showFilter.draw();
    }
     CameraDraw目前所做的主要工作就是这样,然后我们将CameraController、CameraDraw和自定义的CameraView控件进行绑定,就可以实现摄像头数据预览了。

自定义的CameraView控件
      首先,在构造函数中进行OpenGL、CameraController、CameraDraw 的初始化
    private void init() {
        /**初始化OpenGL的相关信息*/
        setEGLContextClientVersion(2);//设置版本
        setRenderer(this);//设置Renderer
        setRenderMode(RENDERMODE_WHEN_DIRTY);//主动调用渲染
        setPreserveEGLContextOnPause(true);//保存Context当pause时
        setCameraDistance(100);//相机距离

        /**初始化Camera的绘制类*/
        mCameraDrawer = new CameraDrawer(getResources());
        /**初始化相机的管理类*/
        mCamera = new CameraController();
     }
      然后,分别在三个生命周期的函数中调用CameraController和CameraDrawer的相关方法,以及打开摄像头
    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        mCameraDrawer.onSurfaceCreated(gl,config);
        if (!isSetParm){
            open(0);
            stickerInit();
        }
        mCameraDrawer.setPreviewSize(dataWidth,dataHeight);
    }
    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        mCameraDrawer.onSurfaceChanged(gl,width,height);
    }
    @Override
    public void onDrawFrame(GL10 gl) {
        if (isSetParm){
            mCameraDrawer.onDrawFrame(gl);
        }
    }
     然后在onFrameAvailable函数中,调用即可
    @Override
    public void onFrameAvailable(SurfaceTexture surfaceTexture) {
        this.requestRender();
    }
 
     我们的视频预览的主体流程就是这样,然后我们可以直接在布局中使用CameraView类即可。
<Image_1>

视频录制和断点录制

       在上面部分,我们实现了通过OpenGL预览视频,下面部分,我们就需要实现录制视频了. 我们使用的opengl录制视频方案,采用的是谷歌的工程师编写的grafika 这个项目,项目链接 在文章开头部分。这个项目包含了很多GLSurfaceView和视频编解码的知识,还是非常值得学习一下的。主要核心的类就是TextureMovieEncoder和VideoEncoderCore类。 采用的是MediaMuxer和MeidaCodec类进行视频的编码和音视频合成。后面我们涉及到音视频编解码、视频拼接、音视频裁剪的时候会详细的介绍一下android里面音视频编解码的相关类的用法。这里就暂时先不深入讲解了。当然音视频的编解码也可以使用FFmpeg进行软编码,但是因为硬编码的速度比软编码要快得多,所以我们这个项目,不会涉及到FFmpeg的使用。
        好了,现在回到我们的从GLSurfaceView读取数据,通过TexureMovieEncoder进行视频的录制和断点续录。
         上面我们说了我们通过AFilter的相关类,进行opengl的相关操作,实现了视频的预览,这里我们还需要一个AFilter类将摄像头的数据交给我们的编码类进行编码。所以初始化的时候 再初始化一个
     drawFilter = new ShowFilter(resources);
        这里需要注意一下,为了显示在屏幕上是正常的,我们进行了旋转的操作。所以,我们在录制的AFilter里面需要加上矩阵翻转的控制。
    OM= MatrixUtils.getOriginalMatrix();
    MatrixUtils.flip(OM,false,true);//矩阵上下翻转
    drawFilter.setMatrix(OM);
     然后同样分别进行drawFilter的create,在onDrawFrame里面讲textureId进行绑定以及绘制。还有就是添加录制控制的相关代码
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fFrame[0]);
        GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
                GLES20.GL_TEXTURE_2D, fTexture[0], 0);
        GLES20.glViewport(0,0,mPreviewWidth,mPreviewHeight);
        drawFilter.setTextureId(fTexture[0]);
        drawFilter.draw();
        //解绑
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER,0);

        if (recordingEnabled){
            /**说明是录制状态*/
            switch (recordingStatus){
                case RECORDING_OFF:
                    videoEncoder = new TextureMovieEncoder();
                    videoEncoder.setPreviewSize(mPreviewWidth,mPreviewHeight);
                    videoEncoder.startRecording(new TextureMovieEncoder.EncoderConfig(
                            savePath, mPreviewWidth, mPreviewHeight,
                            3500000, EGL14.eglGetCurrentContext(),
                            null));
                    recordingStatus = RECORDING_ON;
                    break;
                case RECORDING_ON:
                case RECORDING_PAUSED:
                    break;
                case RECORDING_RESUMED:
                    videoEncoder.updateSharedContext(EGL14.eglGetCurrentContext());
                    videoEncoder.resumeRecording();
                    recordingStatus = RECORDING_ON;
                    break;

                case RECORDING_RESUME:
                    videoEncoder.resumeRecording();
                    recordingStatus=RECORDING_ON;
                    break;
                case RECORDING_PAUSE:
                    videoEncoder.pauseRecording();
                    recordingStatus=RECORDING_PAUSED;

                    break;
                default:
                    throw new RuntimeException("unknown recording status "+recordingStatus);
            }

        }else {
            switch (recordingStatus) {
                case RECORDING_ON:
                case RECORDING_RESUMED:
                case RECORDING_PAUSE:
                case RECORDING_RESUME:
                case RECORDING_PAUSED:
                    videoEncoder.stopRecording();
                    recordingStatus = RECORDING_OFF;
                    break;
                case RECORDING_OFF:
                    break;
                default:
                    throw new RuntimeException("unknown recording status " + recordingStatus);
            }
        }

        if (videoEncoder != null && recordingEnabled && recordingStatus == RECORDING_ON){
            videoEncoder.setTextureId(fTexture[0]);
            videoEncoder.frameAvailable(mSurfaceTextrue);
        }
      上面主要逻辑是,在数据返回的视频判断当前的录制状态,如果是正在录制,就将SurfaceTexture给到VideoEncoder进行数据的编码,如果没有录制,就跳过该帧,这样就可以实现断点续录,即录制 —>暂停录制—>继续录制,而且这样录制出来的是一个整体的视频文件。
       这里就不贴出 TexureMovieEncoder和VideoEncoderCore类的详细代码了。
       这样我们就完成了,摄像头数据的预览和视频的录制功能。然后呢,还有一些额外的功能。

Camera的手动对焦

     不管是视频录制还是拍照的时候,对焦是非常重要的,如果没有对焦功能,那录制出来的视频的效果会非常的差,但是网上的很多讲解android的摄像头对焦功能的文章,其实并不准确,他们实现的功能其实有一些小问题的,主要是涉及到摄像头坐标系和屏幕显示坐标系的变化。手动聚焦,主要是点击屏幕 然后就调用Camera聚焦的相关函数 进行对焦。
     聚焦的主要代码如下,在CameraConroller类里面 
   Camera.Parameters parameters = mCamera.getParameters();
        boolean supportFocus=true;
        boolean supportMetering=true;
        //不支持设置自定义聚焦,则使用自动聚焦,返回
        if (parameters.getMaxNumFocusAreas() <= 0) {
            supportFocus=false;
        }
        if (parameters.getMaxNumMeteringAreas() <= 0){
            supportMetering=false;
        }
        List<Camera.Area> areas = new ArrayList<Camera.Area>();
        List<Camera.Area> areas1 = new ArrayList<Camera.Area>();
        //再次进行转换
        point.x= (int) (((float)point.x)/ MyApplication.screenWidth*2000-1000);
        point.y= (int) (((float)point.y)/MyApplication.screenHeight*2000-1000);

        int left = point.x - 300;
        int top = point.y - 300;
        int right = point.x + 300;
        int bottom = point.y + 300;
        left = left < -1000 ? -1000 : left;
        top = top < -1000 ? -1000 : top;
        right = right > 1000 ? 1000 : right;
        bottom = bottom > 1000 ? 1000 : bottom;
        areas.add(new Camera.Area(new Rect(left, top, right, bottom), 100));
        areas1.add(new Camera.Area(new Rect(left, top, right, bottom), 100));
        if(supportFocus){
            parameters.setFocusAreas(areas);
        }
        if(supportMetering){
            parameters.setMeteringAreas(areas1);
        }

        try {
            mCamera.setParameters(parameters);// 部分手机 会出Exception(红米)
            mCamera.autoFocus(callback);
        } catch (Exception e) {
            e.printStackTrace();
        }
       主要涉及到了一下坐标变换,因为大部分的手机的前置摄像头不支持对焦功能,所以我们不进行前置摄像头的对焦。
      

结语

      到这里的话,本篇文章的主要内容就已经结束了,再次回顾一下,我们其实本篇文章主要涉及到的内容有通过OpenGl预览视频,通过MediaCodec录制视频,以及一些其他的知识点。这里并没有讲解OpenGL的一些基础知识,比如顶点着色器等等,这部分如果要涉及到的话,也是一个很庞大的内容,所以就不会在系列文章中进行介绍了。大家不太清楚的话,请自行查询相关资料。
     本篇仅仅是一个开始,下一篇文章,我们就会在录制视频的时候通过opengl加上水印和美白效果。请大家持续关注。
     因为个人水平有限,难免有错误和不足之处,还望大家能包涵和提醒。谢谢啦!!!

其他
      项目的github地址



Logo

前往低代码交流专区

更多推荐