Android音视频开发笔记(二)--ffmpeg命令行的使用&相机预览

2,704 阅读9分钟

在上一篇文章中,我们介绍了一些音视频的基础知识,并且编译了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视图中会多出两块

在cpp目录下,Android Studio为我们自动生成了一个native-lib.cpp文件,相当于一个hello wrold。 这里我们主要看一下CMakeList.txt文件里的内容。我们这里只做一下简单的介绍。

在build.gradle文件中也有一些变化

CMakeList.txt的文件路径

移植编译好的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拷贝进来。 完整目录结构如下:

  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目录下

  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环境,还会向大家介绍如何给摄像头预览数据添加简单的以及高级一些的实时滤镜渲染效果,敬请期待!