Android 仿抖音之使用OpenGL实现抖音视频录制

前言

在之前写了仿抖音的第一步,就是使用OpenGL显示摄像头数据,今天这篇就是在之前的基础上来录制视频,并且对之前的代码的结构进行了简单的整理,然后进行了仿抖音的视频录制。

工程结构整理

在仿抖音的第一步中封装了ScreenFilter类来实现渲染屏幕的操作,我们都知道在抖音的视频录制过程中,可以添加很多的效果进行显示,比如说磨皮、美颜、大眼以及滤镜等效果,如果把这些效果都放在ScreenFilter中,就需要使用很多的if else来进行判断是否开启效果,显而易见,这样的会显得项目结构不是很美好,我们可以将每种效果都写成一个Filter,并且在ScreenFilter之前的效果,都可以不用显示到屏幕当中去,所以可以使用FBO来实现这个需求,不懂 FBO的可以翻看上一篇的博客FBO的使用

但是这里有一个问题,就是在摄像头画面经过FBO缓冲,我们再从FBO中绘制到屏幕上去,这里的ScreenFilter获取的纹理是来自于FBO中的纹理,也就是OpenGL ES中的,所以不再需要额外扩展的纹理类型了,可以直接使用sampler2D类型,也就意味着ScrennFilter,

  1. 开启效果:使用sampler2D
  2. 未开启效果:使用samplerExternalOES

那么就需要ScreenFilter使用if else去判断,很麻烦,所以我们可以不管摄像头是否开启效果都先将摄像头数据写到FBO中,这样的话,ScreenFilter的采样数据始终都可以是sampler2D了。也就是下面这种结构:

结构1.jpg

需求

长按按钮进行视频的录制,视频有5种速度的录制,极慢、慢、正常、快、以及极快,抬起手指时候停止录制,并将视频保存以MP4格式保存在sdcard中。
(抖音的视频录制在录制完成以后显示的时候都是正常速度,这里我为了看到效果,保存下来的时候是用当前选择的速度进行显示的)。

分析需求

想要录制视频,就需要对视频进行编码,摄像头采集到的视频数据一般为AVC格式的,这里我们需要将AVC格式的数据,编码成h.264的,然后再封装为MP4格式的数据。对于速度的控制,可以在写出到MP4文件格式之前,修改它的时间戳,就可以了。

实现需求
MediaCodec

MediaCodec是Android4.1.2(API 16)提供的一套编解码的API,之前试过使用FFmpeg来进行编码,效果不如这个,这个也比较简单,这次视频录制就使用它来进行编码。MediaCodec使用很简单,它存在一个输入缓冲区和一个输出缓冲区,我们把要编码的数据塞到输入缓冲区,它就可以进行编码了,然后从输出缓冲区取编码后的数据就可以了。

还有一种方式可以告知MediaCodec需要编码的数据,

 /**
     * Requests a Surface to use as the input to an encoder, in place of input buffers.  需要一个Surface作为需要编码的数据,来替代需要输入的数据
     */
    @NonNull
    public native final Surface createInputSurface();

这个接口是用来创建一个Surface的,Surface是用来干啥的呢,就是用来"画画"的,也就是说我们只要在这个Surface上画出我们需要的图像,MediaCodec就会自动帮我们编码这个Surface上面的图像数据,我们可以直接从输出缓冲区中获取到编码后的数据。之前的时候我们是使用OpenGL绘画显示到屏幕上去,我们可以同时将这个画面绘制到MediaCodec#createInputSurface() 中去,这样就可以了。

那怎么样才能绘制到MediaCodec的Surface当中去呢,我们知道录制视频是在一个线程中,显示图像(GLSurfaceView)是在另一个GLThread线程中进行的,所以这两者的EGL环境也不同,但是两者又共享上下文资源,录制现场中画面的绘制需要用到显示线程中的texture等,那么这个线程就需要我们做这些:

  1. 配置录制使用的EGL环境(可以参照GLSurfaceView怎么配置的)
  2. 将显示的图像绘制到MediaCodec中的Surface中
  3. 编码(h.264)与复用(mp4)的工作

代码实现

MediaRecorder.java
视频编码类

 public void start(float speed) throws IOException {
        mSpeed = speed;
        /**
         *     配置MediaCodec编码器
         */
        //类型
        MediaFormat mediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, mWidth, mHeight);
        //参数配置
        //1500kbs 码率
        mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, 1500_00);
        //帧率
        mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 20);
        //关键帧间隔
        mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 20);
        //颜色格式
        //从Surface当中获取的
        mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
        //编码器
        mMediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
        //将参数配置给编码器
        mMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

        //交给虚拟屏幕 通过OpenGL 将预览的纹理 会知道这一个虚拟屏幕中
        //这样MediaCodec就会自动编码mInputSurface当中的图像了
        mInputSurface = mMediaCodec.createInputSurface();
        /**
         * H264
         * 封装成MP4文件写出去
         */
        mMediaMuxer = new MediaMuxer(mPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        /**
         * 配置EGL环境
         * 在GLSurfaceView中启动了一个GLThread子线程,去配置EGL环境,这里
         * 我们也启动一个子线程去配置EGL环境
         */
        HandlerThread handlerThread = new HandlerThread("VideoCodec");
        handlerThread.start();
        Looper looper = handlerThread.getLooper();

        //用于其他线程 通知子线程
        mHandler = new Handler(looper);
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                //创建egl环境(虚拟设备)
                mEglBase = new EGLBase(mContext, mWidth, mHeight, mInputSurface, mEglContext);
                //启动编码器
                mMediaCodec.start();
                isStart = true;

            }
        });
 }
 /**
     * 传递 纹理进来
     * 相当于调用一次就有一个新的图像需要编码
     */
    public void encodeFrame(final int textureId, final long timestamp) {
        if (!isStart) {
            return;
        }
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                //把图像画到虚拟屏幕
                mEglBase.draw(textureId, timestamp);
                //从编码器的输出缓冲区获取编码后的数据就ok了
                getCodec(false);
            }
        });
    }
 /**
     * 获取编码后 的数据
     *
     * @param endOfStream 标记是否结束录制
     */
    private void getCodec(boolean endOfStream) {
        //不录了, 给mediacodec一个标记
        if (endOfStream) {
            mMediaCodec.signalEndOfInputStream();
        }
        //输出缓冲区
        MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
        // 希望将已经编码完的数据都 获取到 然后写出到mp4文件
        while (true) {
            //等待10 ms
            int status = mMediaCodec.dequeueOutputBuffer(bufferInfo, 10_000);
            //让我们重试  1、需要更多数据  2、可能还没编码为完(需要更多时间)
            if (status == MediaCodec.INFO_TRY_AGAIN_LATER) {
                // 如果是停止 我继续循环
                // 继续循环 就表示不会接收到新的等待编码的图像
                // 相当于保证mediacodec中所有的待编码的数据都编码完成了,不断地重试 取出编码器中的编码好的数据
                // 标记不是停止 ,我们退出 ,下一轮接收到更多数据再来取输出编码后的数据
                if (!endOfStream) {
                    //不写这个 会卡太久了,没有必要 你还是在继续录制的,还能调用这个方法的!
                    break;
                }
                //否则继续
            } else if (status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                //开始编码 就会调用一次
                MediaFormat outputFormat = mMediaCodec.getOutputFormat();
                //配置封装器
                // 增加一路指定格式的媒体流 视频
                index = mMediaMuxer.addTrack(outputFormat);
                mMediaMuxer.start();
            } else if (status == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                //忽略
            } else {
                //成功 取出一个有效的输出
                ByteBuffer outputBuffer = mMediaCodec.getOutputBuffer(status);
                //如果获取的ByteBuffer 是配置信息 ,不需要写出到mp4
                if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
                    bufferInfo.size = 0;
                }

                if (bufferInfo.size != 0) {
                    bufferInfo.presentationTimeUs = (long) (bufferInfo.presentationTimeUs / mSpeed);
                    //写到mp4
                    //根据偏移定位
                    outputBuffer.position(bufferInfo.offset);
                    //ByteBuffer 可读写总长度
                    outputBuffer.limit(bufferInfo.offset + bufferInfo.size);
                    //写出
                    mMediaMuxer.writeSampleData(index, outputBuffer, bufferInfo);
                }
                //输出缓冲区 我们就使用完了,可以回收了,让mediacodec继续使用
                mMediaCodec.releaseOutputBuffer(status, false);
                //结束
                if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                    break;
                }
            }
        }
    }

EGLBase.java
该类是封装了EGL配置 以及使用OpenGL 对createInputSurface进行绘制

/**
     * @param context
     * @param width
     * @param height
     * @param surface    MediaCodec创建的surface 我们需要将其贴到我们的虚拟屏幕上去
     * @param eglContext GLThread的EGL上下文
     */
    public EGLBase(Context context, int width, int height, Surface surface, EGLContext eglContext) {
        //配置EGL环境
        createEGL(eglContext);
        //把Surface贴到  mEglDisplay ,发生关系
        int[] attrib_list = {
                EGL14.EGL_NONE
        };
        // 绘制线程中的图像 就是往这个mEglSurface 上面去画
        mEglSurface = EGL14.eglCreateWindowSurface(mEglDisplay, mEglConfig, surface, attrib_list, 0);
        // 绑定当前线程的显示设备及上下文, 之后操作opengl,就是在这个虚拟显示上操作
        if (!EGL14.eglMakeCurrent(mEglDisplay,mEglSurface,mEglSurface,mEglContext)) {
            throw  new RuntimeException("eglMakeCurrent 失败!");

        }
        //像虚拟屏幕画
        mScreenFilter = new ScreenFilter(context);
        mScreenFilter.onReady(width,height);
    }

createEGL()方法主要是配置EGL环境,这块内容参照GLSurfaceView里面的写法

 private void createEGL(EGLContext eglContext) {
        //创建虚拟显示器
        mEglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
        if (mEglDisplay == EGL14.EGL_NO_DISPLAY) {
            throw new RuntimeException("eglDisplay failed");
        }

        //初始化显示器
        int[] version = new int[2];
        //major:主版本,minor 子版本
        if (!EGL14.eglInitialize(mEglDisplay, version, 0, version, 1)) {
            throw new RuntimeException("eglInitialize failed");
        }

        //egl 根据我们配置的属性 选择一个配置
        int[] arrtib_list = {
                EGL14.EGL_RED_SIZE, 8, // 缓冲区中 红分量 位数
                EGL14.EGL_GREEN_SIZE, 8,// 缓冲区中 绿分量 位数
                EGL14.EGL_BLUE_SIZE, 8, 
                EGL14.EGL_ALPHA_SIZE, 8,
                EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, //egl版本 2
                EGL14.EGL_NONE
        };

        EGLConfig[] configs = new EGLConfig[1];
        int[] num_config=new int[1];
        if (!EGL14.eglChooseConfig(mEglDisplay,arrtib_list,0,configs,0,configs.length,num_config,0)){
            throw  new IllegalArgumentException("eglChooseConfig#2 failed");
        }

        mEglConfig = configs[0];
        int[] ctx_arrtib_list={
                EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, //egl版本 2
                EGL14.EGL_NONE
        };
        //创建EGL上下文
        //第三个参数是share_context,传绘制线程也就是GLThread线程中的Eglcontext,达到资源共享
        mEglContext = EGL14.eglCreateContext(mEglDisplay,mEglConfig,eglContext,ctx_arrtib_list,0);
        if (mEglContext == EGL14.EGL_NO_CONTEXT){
            throw  new RuntimeException("EGL Context Error");
        }
    }
 /**
     *
     * @param textureId 纹理id 代表一个图片
     * @param timestamp 时间戳
     */
    public void draw(int textureId,long timestamp){
        // 绑定当前线程的显示设备及上下文, 之后操作opengl,就是在这个虚拟显示上操作
        if (!EGL14.eglMakeCurrent(mEglDisplay,mEglSurface,mEglSurface,mEglContext)) {
            throw  new RuntimeException("eglMakeCurrent 失败!");
        }
        //画画 画到虚拟屏幕上
        mScreenFilter.onDrawFrame(textureId);
        //刷新eglsurface的时间戳
        EGLExt.eglPresentationTimeANDROID(mEglDisplay,mEglSurface,timestamp);

        //交换数据
        //EGL的工作模式是双缓存模式, 内部有两个frame buffer (fb)
        //当EGL将一个fb  显示屏幕上,另一个就在后台等待opengl进行交换
        EGL14.eglSwapBuffers(mEglDisplay,mEglSurface);
    }

源码下载

猜你喜欢

转载自blog.csdn.net/YuQing_Cat/article/details/83824825