Android音視訊開發筆記(二)--ffmpeg命令列的使用&相機預覽

元氣彈發表於2019-02-14

在上一篇文章中,我們介紹了一些音視訊的基礎知識,並且編譯了Android平臺的ffmpeg。那麼在這篇文章中,我們將介紹如何將我們編譯好的ffmpeg庫接入到我們的Android專案中,並介紹移植ffmpeg強大的命令列工具到Android App裡。另外我們會介紹如何使用OpenGL ES來渲染我們相機的實時預覽畫面。閒話少說,上乾貨

建立專案

  1. 第一步,我們開啟我們熟悉的Android Studio(2.2版本後,Android Studio支援了CMake的方式來管理我們的c/c++程式碼)。

    首先我們需要確定NDK的版本,儘量和ffmpeg編譯時使用的版本一致

與建立其他 Android Studio 專案類似,不過還需要額外幾個步驟

(1).在嚮導的 Configure your new project 部分,選中 Include C++ Support 核取方塊。
(2).點選 Next。
(3).正常填寫所有其他欄位並完成嚮導接下來的幾個部分
(4).在嚮導的 Customize C++ Support 部分,您可以使用下列選項自定義專案: 
    1). C++ Standard:使用下拉選單選擇您希望使用哪種 C++ 標準。選擇 Toolchain Default 會使用預設的 CMake 設定。
    2). Exceptions Support:如果您希望啟用對 C++ 異常處理的支援,請選中此核取方塊。如果啟用此核取方塊,Android Studio 會將 -fexceptions 標誌新增到模組級 build.gradle 檔案的 cppFlags 中,Gradle 會將其傳遞到 CMake。
    3). Runtime Type Information Support:如果您希望支援 RTTI,請選中此核取方塊。如果啟用此核取方塊,Android Studio 會將 -frtti 標誌新增到模組級 build.gradle 檔案的 cppFlags 中,Gradle 會將其傳遞到 CMake。
(5). 點選finish
複製程式碼

在點選完成後,我們會發現Android檢視中會多出兩塊

Android音視訊開發筆記(二)--ffmpeg命令列的使用&相機預覽

在cpp目錄下,Android Studio為我們自動生成了一個native-lib.cpp檔案,相當於一個hello wrold。 這裡我們主要看一下CMakeList.txt檔案裡的內容。我們這裡只做一下簡單的介紹。

Android音視訊開發筆記(二)--ffmpeg命令列的使用&相機預覽

在build.gradle檔案中也有一些變化

Android音視訊開發筆記(二)--ffmpeg命令列的使用&相機預覽
CMakeList.txt的檔案路徑
Android音視訊開發筆記(二)--ffmpeg命令列的使用&相機預覽

移植編譯好的libffmpeg.so到專案中

  1. 指定編譯的cpu架構

    我們開啟module下的build.gradle目錄,在defaultConfig節點下新增:

     ndk {
         abiFilters 'armeabi-v7a'
     }
    複製程式碼

    因為目前絕大多數Android裝置都是使用arm架構,極少有使用x86架構的,所以我們這裡直接遮蔽x86。由於arm64-v8a是向下相容的,所以我們只需指定armeabi-v7a即可

  2. 拷貝相應原始檔

    接下來我們在cpp目錄下建立一個thirdparty資料夾,然後在thirdparty目錄下建立ffmpeg目錄,將我們編譯好的標頭檔案拷貝進來,之後再在thirdparty目錄下建立prebuilt資料夾,在此目錄下,建立一個armeabi-v7a目錄,將我們編譯出的libffmpeg.so拷貝進來。 完整目錄結構如下:

    Android音視訊開發筆記(二)--ffmpeg命令列的使用&相機預覽

  3. cmake的配置

    在CMakeList.txt中是可以指定檔案路徑的,就是定義指定檔案路徑作為變數。個人認為,jni的相關程式碼最好和核心程式碼分開的好,所以我們在src/main/目錄下建立一個jni資料夾,在這個裡面專門存放我們的jni程式碼(不知道jni是什麼的朋友,這系列的文章可能不太適合你,可以先去自行補課)。

     cmake_minimum_required(VERSION 3.4.1)
     #指定核心業務原始碼路徑
     set(PATH_TO_VIDEOSTUDIO ${CMAKE_SOURCE_DIR}/src/main/cpp)
     #指定jni相關程式碼原始碼路徑
     set(PATH_TO_JNI_LAYER ${CMAKE_SOURCE_DIR}/src/main/jni)
     #指定第三方庫標頭檔案路徑
     set(PATH_TO_THIRDPARTY ${PATH_TO_VIDEOSTUDIO}/thirdparty)
     #指定第三方庫檔案路徑
     set(PATH_TO_PRE_BUILT ${PATH_TO_THIRDPARTY}/prebuilt/${ANDROID_ABI})
    複製程式碼

    其中CMAKE_SOURCE_DIR是內建變數,指的是CMakeList.txt所在目錄;ANDROID_ABI也是內建變數,對應我們gradle中配置的cpu架構。

  4. 呼叫ffmpeg

    在jni目錄下建立一個VideoStudio.cpp的c++原始檔(也可以隨自己的喜好來起原始檔名稱)。內容如下:

     #include <cstdlib>
     #include <cstring>
     #include <jni.h>
     #ifdef __cplusplus
     extern "C" {
     #endif
     #include "libavformat/avformat.h"
     #include "libavcodec/avcodec.h"
     #ifdef __cplusplus
     }
     #endif
     
     // java檔案對應的全類名
     #define JNI_REG_CLASS "com/xxxx/xxxx/VideoStudio"
     
     JNIEXPORT jstring JNICALL showFFmpegInfo(JNIEnv *env, jobject) {
         char *info = (char *) malloc(40000);
         memset(info, 0, 40000);
         av_register_all();
         AVCodec *c_temp = av_codec_next(NULL);
         while (c_temp != NULL) {
             if (c_temp->decode != NULL) {
                 strcat(info, "[Decoder]");
             } else {
                 strcat(info, "[Encoder]");
             }
             switch (c_temp->type) {
                 case AVMEDIA_TYPE_VIDEO:
                     strcat(info, "[Video]");
                     break;
                 case AVMEDIA_TYPE_AUDIO:
                     strcat(info, "[Audio]");
                     break;
                 default:
                     strcat(info, "[Other]");
                     break;
             }
             sprintf(info, "%s %10s\n", info, c_temp->name);
             c_temp = c_temp->next;
         }
         puts(info);
         jstring result = env->NewStringUTF(info);
         free(info);
         return result;
     }
    
     const JNINativeMethod g_methods[] = {
             "showFFmpegInfo", "()Ljava/lang/String;", (void *) showFFmpegInfo
     };
     
     JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
         JNIEnv *env = NULL;
         jclass clazz = NULL;
         if (vm->GetEnv((void **) &env, JNI_VERSION_1_4) != JNI_OK)
             return JNI_ERR;
         clazz = env->FindClass(JNI_REG_CLASS);
         if (clazz == NULL)
             return JNI_ERR;
         if (env->RegisterNatives(clazz, g_methods, NELEM(g_methods)) != JNI_OK)
             return JNI_ERR;
         return JNI_VERSION_1_4;
     }
     
     JNIEXPORT void JNICALL JNI_OnUnload(JavaVM *vm, void *) {
         JNIEnv *env = NULL;
         jclass clazz = NULL;
         if (vm->GetEnv((void **) &env, JNI_VERSION_1_4) != JNI_OK)
             return;
         clazz = env->FindClass(JNI_REG_CLASS);
         if (clazz == NULL)
             return;
         env->UnregisterNatives(clazz);
     }
    複製程式碼

    我這裡是使用JNI_OnLoad的方式來做的JNI連線,當然也可以採用"Java_全類名_showFFmpegInfo"的方式。對應的,我們需要建立對應的VideoStudio.java檔案,以及編寫對應的native方法。

    這裡需要注意的是,我們需要在CMakeList.txt中配置我們的jni相關程式碼的原始檔路徑。

     # ffmpeg標頭檔案路徑
     include_directories(BEFORE ${PATH_TO_THIRDPARTY}/ffmpeg/include)
     # jni相關程式碼路徑
     file(GLOB FILES_JNI_LAYER "${PATH_TO_JNI_LAYER}/*.cpp")
     add_library(
                 video-studio 
                 SHARED
                 ${FILES_JNI_LAYER})
     add_library(ffmpeg SHARED IMPORTED)
     set_target_properties(
                 ffmpeg
                 PROPERTIES IMPORTED_LOCATION
                 ${PATH_TO_PRE_BUILT}/libffmpeg.so)
     
     target_link_libraries( # Specifies the target library.
                 video-studio
                 ffmpeg
                 log)
    複製程式碼

一系列的配置完成後,應該就可以成功呼叫了,不出意外的話,是可以成功遍歷出ffmpeg開啟的所有的編碼/解碼器了。

新增命令列工具支援

ffmpeg有強大的命令列工具,可以完成一些常見的音視訊功能,比如視訊的裁剪、轉碼、圖片轉視訊、視訊轉圖片、視訊水印新增等等。當然高階定製化的功能,還是需要我們開發者自己來寫程式碼實現。

  1. 原始檔及標頭檔案的拷貝

    開啟我們下載的ffmpeg的原始檔目錄,找到config.h,拷貝到我們cpp/thirdparty/ffmpeg/include/目錄下,然後,在cpp/目錄下新建cmd_line目錄,在ffmpeg原始碼目錄下找到cmdutils.c cmdutils.h ffmpeg.c ffmpeg.h ffmpeg_filter.c ffmpeg_hw.c ffmpeg_opt.c 拷貝到我們的cmd_line目錄下

    Android音視訊開發筆記(二)--ffmpeg命令列的使用&相機預覽

  2. 稍作修改

    找到ffmpeg.c檔案,將其內部的main函式改為你喜歡的名字,這裡我把它改為ffmpeg_exec

    修改前:

     int main(int argc, char **argv) {
         ...
     }
    複製程式碼

    修改後:

     int ffmpeg_exec(int argc, char **argv) {
         ...
     }
    複製程式碼

    相應的,我們需要在ffmpeg.h中新增函式的宣告。

    找到cmdutils.c,找到exit_program函式,因為每次執行完這裡會退出程式,在app中的表現就像閃退一樣。所以,我們稍加修改:

    修改前:

     void exit_program(int ret)
     {
         if (program_exit)
             program_exit(ret);
    
         exit(ret);
     }
    複製程式碼

    修改後:

     int exit_program(int ret)
     {
         if (program_exit)
             program_exit(ret);
     //    exit(ret);
         return ret;
     }
    複製程式碼

    相應的,我們也需要在cmdutils.h中修改對應的函式宣告。

    最後還有一點,為了避免第二次呼叫命令列崩潰,我們還需要的我們ffmpeg.c中我們修改過的ffmpeg_exec函式return之前加上這幾行:

     nb_filtergraphs = 0;
     progress_avio = NULL;
    
     input_streams = NULL;
     nb_input_streams = 0;
     input_files = NULL;
     nb_input_files = 0;
    
     output_streams = NULL;
     nb_output_streams = 0;
     output_files = NULL;
     nb_output_files = 0;
    複製程式碼
  3. 呼叫

    在我們的jni程式碼,VideoStudio.cpp中,新增函式:

     JNIEXPORT jint JNICALL executeFFmpegCmd(JNIEnv *env, jobject, jobjectArray commands) {
         int argc = env->GetArrayLength(commands);
         char **argv = (char **) malloc(sizeof(char *) * argc);
         for (int i = 0; i < argc; i++) {
             jstring string = (jstring) env->GetObjectArrayElement(commands, i);
             const char *tmp = env->GetStringUTFChars(string, 0);
             argv[i] = (char *) malloc(sizeof(char) * 1024);
             strcpy(argv[i], tmp);
         }
         try {
             ffmpeg_exec(argc, argv);
         } catch (int i) {
             LOGE("ffmpeg_exec error: %d", i);
         }
         for (int i = 0; i < argc; i++) {
             free(argv[i]);
         }
         free(argv);
         return 0;
     }
    複製程式碼

    在g_methods陣列常量中新增:

     const JNINativeMethod g_methods[] = {
         "showFFmpegInfo", "()Ljava/lang/String;", (void *) showFFmpegInfo,
         "executeFFmpegCmd", "([Ljava/lang/String;)I", (void *) executeFFmpegCmd
     };
    複製程式碼

    在java中的呼叫:

     public int executeFFmpegCmd(String cmd) {
         String[] argv = cmd.split(" ");
         return VideoStudio.executeFFmpegCmd(argv);
     }
    複製程式碼

    到這裡,我們在Android App中呼叫ffmpeg命令列的整合工作已經完成了!

使用OpenGL ES預覽相機畫面

>> 我們知道,相機Camera類(這裡我們只介紹Camera1的API,感興趣的同學可以自行嘗試Camera2)是可以指定SurfaceHolder和SurfaceTexture作為預覽載體來預覽相機畫面的。
那為什麼我們要使用OpenGL ES來做這件事呢?前面我們介紹過,OpenGL ES是搭載在Android系統中一個強大的三維(二維也可以)影象渲染庫,在音視訊開發工作中,我們可以使用OpenGL ES在實時的相機預覽畫面新增實時濾鏡渲染,磨皮美白也可以做。
另外我們也可以在預覽畫面上新增任意我們想渲染的元素。這些是直接使用SurfaceView/TextureView做不到的(給SurfaceView和TextureView新增OpenGL ES支援的不要來槓,這裡是說直接使用)。
複製程式碼

這部分內容需要有一定的OpenGL ES入門知識才能看懂,不瞭解的同學,如果感興趣的話可以去移動端濾鏡開發(二)初識OpenGl裡補一下課

OpenGL在使用時,是需要一條專門繫結了OpenGL上下文環境的執行緒。 Android系統為我們提供了一個整合好OpenGL ES環境的View,它就是GLSurfaceView,它繼承自SurfaceView,我們可以直接在GLSurfaceView提供的OpenGL環境中直接做OpenGL ES API呼叫。當然我們也可以使用EGL介面來建立自己的OpenGL環境(GLSurfaceView其實就是一個自帶單獨執行緒、由EGL建立好環境的這麼一個View)

GLSurfaceView也暴露了介面,讓我們可以自己制定渲染載體:

setEGLWindowSurfaceFactory(new EGLWindowSurfaceFactory() {
    @Override
            public EGLSurface createWindowSurface(EGL10 egl, EGLDisplay display,
                                                  EGLConfig config, Object nativeWindow) {
                return egl.eglCreateWindowSurface(display, config, mSurface, null);
            }

            @Override
            public void destroySurface(EGL10 egl, EGLDisplay display, EGLSurface surface) {
                egl.eglDestroySurface(display, surface);
            }
});
複製程式碼

所以我們可以利用GLSurfaceView的環境,讓Camera資料渲染到我們想要的載體上(SurfaceView/TextureView)。

建立好環境後,接下來就是渲染了,設定給Camera的SurfaceTexture我們可以自己建立Android系統的一個特殊的OES紋理來構建SurfaceTexture,當然建立紋理的動作需要在OpenGL環境中

public int createOESTexture() {
    int[] textures = new int[1];
    GLES20.glGenTextures(1, textures, 0);
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0]);
    // 放大和縮小都使用雙線性過濾
    GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER,
            GLES20.GL_LINEAR);
    GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER,
            GLES20.GL_LINEAR);
    // GL_CLAMP_TO_EDGE 表示OpenGL只畫圖片一次,剩下的部分將使用圖片最後一行畫素重複
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S,
            GLES20.GL_CLAMP_TO_EDGE);
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T,
            GLES20.GL_CLAMP_TO_EDGE);
    return textures[0];
}
複製程式碼

在拿到OES紋理ID後,我們就可以作為建構函式引數直接構建SurfaceTexture了

SurfaceTexture surfaceTexture = new SurfaceTexture(textureId);
複製程式碼

我們可以直接使用此紋理ID構建的SurfaceTexture通過Camera的setPreviewTexture方法來指定渲染載體。

有了資料來源之後還不夠,我們需要將紋理貼圖繪製到螢幕上,這個時候我們就需要藉助OpenGL ES的API以及glsl語言來做畫面的渲染。

頂點著色器:

attribute vec4 aPosition;
attribute vec4 aTexCoord;
varying vec2 vTexCoord;
uniform mat4 aMvpMatrix;
uniform mat4 aStMatrix;

void main() {
    gl_Position = aMvpMatrix * aPosition;
    vTexCoord = (aStMatrix * aTexCoord).xy;
}
複製程式碼

片元著色器:

#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTexCoord;
uniform samplerExternalOES sTexture;
void main() {
    gl_FragColor = texture2D(sTexture, vTexCoord);
}
複製程式碼

編譯、連線shader程式

public int createProgram(String vertexSrc, String fragmentSrc) {
    int vertex = loadShader(GLES20.GL_VERTEX_SHADER, vertexSrc);
    int fragment = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSrc);
    int program = GLES20.glCreateProgram();
    GLES20.glAttachShader(program, vertex);
    GLES20.glAttachShader(program, fragment);
    GLES20.glLinkProgram(program);
    int[] linkStatus = new int[1];
    GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
    if (linkStatus[0] != GLES20.GL_TRUE) {
        Log.e(TAG, "Could not link program: ");
        Log.e(TAG, GLES20.glGetProgramInfoLog(program));
        GLES20.glDeleteProgram(program);
        program = 0;
    }
    return program;
}

private int loadShader(int type, String src) {
    int shader = GLES20.glCreateShader(type);
    GLES20.glShaderSource(shader, src);
    GLES20.glCompileShader(shader);
    int[] compileStatus = new int[1];
    GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
    if (compileStatus[0] == 0) {
        Log.e(TAG, "load shader failed, type: " + type);
        Log.e(TAG, " " + GLES20.glGetShaderInfoLog(shader));
        GLES20.glDeleteShader(shader);
        shader = 0;
    }
    return shader;
}
複製程式碼

剩下的就是指定視口和繪製了,需要注意的是當對紋理使用samplerExternalOES取樣器取樣時,應該首先使用getTransformMatrix(float[]) 查詢得到的矩陣來變換紋理座標,每次呼叫updateTexImage的時候,可能會導致變換矩陣發生變化,因此在紋理影象更新時需要重新查詢,改矩陣將傳統2D OpenGL ES紋理座標列向量(s,t,0,1),其中s,t∈[0, 1],變換為紋理中對應的取樣位置。該變換補償了影象流中任何可能導致與傳統OpenGL ES紋理有差異的屬性。例如,從影象的左下角開始取樣,可以通過使用查詢得到的矩陣來變換列向量(0, 0, 0, 1),而從右上角取樣可以通過變換(1, 1, 0, 1)來得到。

專案程式碼已經上傳到github,喜歡的同學喜歡可以貢獻一個start

結語

今天就先寫到這裡,在本篇文章中,介紹瞭如何把ffmpeg整合到我們的Android專案中,還介紹瞭如何在Android App中使用ffmpeg的命令列。最後向大家介紹瞭如何使用OpenGL ES渲染攝像頭預覽資料。在下篇文章中,我們將會介紹如何使用EGL API搭建我們自己的OpenGL環境,還會向大家介紹如何給攝像頭預覽資料新增簡單的以及高階一些的實時濾鏡渲染效果,敬請期待!

相關文章