1. 程式人生 > >Android 仿抖音之使用OpenGL實現抖音視訊錄製

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);
    }

原始碼下載