在Android上使用FFmpeg压缩视频

前几天项目需要压缩视频,Github上找了许多库,要么就是太大,要么就是质量不高,其实我只需要压缩视频,最好的方案还是定制编译一个 FFmpegAndroid 用。

本项目使用 FFmpeglibx264(一个第三方的视频编码器) 来编译出可以在 Android 上使用的动态库

一、下载源码

创建一个叫 FFmpegAndroid 的目录,下载 libx264源码ffmpeg源码,然后在 FFmpegAndroid 文件夹下建立一个 bulid 文件夹,用于存放编译脚本和输出

--- FFmpegAndroid
 |-- ffmpeg
 |-- x264
 |-- build

二、编译 FFmpeg

编译 x264 编码器

先在 build 文件夹下建立 setting.sh, 用于申明一些公用的环境变量,比如 $NDK$CPU...

setting.sh

# ndk 环境
NDK=$HOME/Library/Android/sdk/ndk-bundle
SYSROOT=$NDK/platforms/android-14/arch-arm/
TOOLCHAIN=$NDK/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64
# cpu 架构平台,若要编译 x86 则指定 x86
CPU=armv7-a

然后建立 libx264 的编译脚本 build_x264.shlibx264 是一个开源的H.264编码器,据说是最好的视频有损编码器。ffmpeg 默认不自带,但是支持 x264 作为第三方编码器编译。

build_x264.sh

./config 内的# 注释必须在运行的时候去掉

#!/bin/bash

# 引入需要的环境变量
. setting.sh

# 输出下看看对不对,可以去掉,这里调试用
echo "use toolchain: $TOOLCHAIN"
echo "use system root: $SYSROOT"

# 输出文件的前缀,也就是指定最后静态库输出到那里
PREFIX=$(pwd)/lib/x264/$CPU
# 优化参数
OPTIMIZE_CFLAGS="-mfloat-abi=softfp -mfpu=vfp -marm -march=$CPU "
ADDI_CFLAGS=""
ADDI_LDFLAGS=""

# 因为当前目录在 build 目录,需要切换到 x264 去执行 config
cd ../x264
function build_x264
{
./configure \
    --prefix=$PREFIX \
    # 不编译动态库
    --disable-shared \
    --disable-asm \
    # 编译静态库
    --enable-static \
    --enable-pic \
    --enable-strip \
    --host=arm-linux-androideabi \
    --cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \
    --sysroot=$SYSROOT \
    --extra-cflags="-Os -fpic $ADDI_CFLAGS $OPTIMIZE_CFLAGS" \
    --extra-ldflags="$ADDI_LDFLAGS" \
    $ADDITIONAL_CONFIGURE_FLAG
make clean
make -j4
make install
}

# 执行编译指令
build_x264

写完之后就可以编译 x264 库了,编译之前还有一点要注意的是,默认编译出来的文件后缀并不是 *.so,这 Android 是识别不了的,需要对 x264 源码里面的 config 做如下修改:

echo "SOSUFFIX=so" >> config.mak
echo "SONAME=libx264.so.$API" >> config.mak
echo "SOFLAGS=-shared -Wl,-soname,\$(SONAME) $SOFLAGS" >> config.mak

修改成

echo "SOSUFFIX=so" >> config.mak
echo "SONAME=libx264-$API.so" >> config.mak
echo "SOFLAGS=-shared -Wl,-soname,\$(SONAME) $SOFLAGS" >> config.mak

别忘了给 build_x264.shsetting.sh 赋予可执行权限 (chmod +x build_x264.sh setting.sh)

修改完后就可以执行脚本命令了

./build_x264.sh

等待一段时间后,build 文件夹目录下应该有个 lib 目录(build 脚本里面 prefix 指定的目录),里面存放了 x264 的静态库

这里为什么编译成静态库而不是动态库呢?静态库可以把内容编译到待会儿要编译 ffmpeg 的so库里去,不需要单独加载 libx264.so 了,如果你硬要编译成动态库也可以,加载 ffmpeg.so 的时候加载 libx264.so 就可以

至此,x264编码器编译完毕

编译 FFmpeg

同样在 build 文件夹下建立编译脚本 build_ffmpeg.sh,编译 ffmpeg 比编译 x264 略微麻烦点,首先肯定不能全功能编译,那还不如直接去网上找一个编译好的,要自己定制哪些组件需要,哪些组件不需要

FFmpeg它主要含有以下几个核心库:

  • libavcodec-提供了更加全面的编解码实现的合集
  • libavformat-提供了更加全面的音视频容器格式的封装和解析以及所支持的协议
  • libavutil-提供了一些公共函数
  • libavfilter-提供音视频的过滤器,如视频加水印、音频变声等
  • libavdevice-提供支持众多设备数据的输入与输出,如读取摄像头数据、屏幕录制
  • libswresample,libavresample-提供音频的重采样工具
  • libswscale-提供对视频图像进行色彩转换、缩放以及像素格式转换,如图像的YUV转换
  • libpostproc-多媒体后处理器

如果不修改什么配置,直接编译的话,我发现 libavcodec.so 有 7.8MB,我可以在这方面下手,指定 decoderencoder,因为我需要的是视频压缩,所以编码器(encoder)我就只需要 x264(视频编码) 和 aac(音频编码),至于解码器,挑几个常用的就可以了

查看编码器和解码器种类,可以通过 ./config --list-decoders 或 ./config --list-encoers 命令实现(ffmpeg目录下)
./config 内的# 注释必须在运行的时候去掉

#!/bin/bash

# 导入环境变量
. setting.sh

# 输出,调试用
echo "use toolchain: $TOOLCHAIN"
echo "use system root: $SYSROOT"

# x264库所在的位置,ffmpeg 需要链接 x264
LIB_DIR=$(pwd)/lib;

# ffmpeg编译输出前缀
PREFIX=$LIB_DIR/ffmpeg/$CPU
# x264的头文件地址
INC="$LIB_DIR/x264/$CPU/include"
# x264的静态库地址
LIB="$LIB_DIR/x264/$CPU/lib"
# 输出调试
echo "include dir: $INC"
echo "lib dir: $LIB"
# 编译优化参数
FF_EXTRA_CFLAGS="-march=$CPU -mfpu=vfpv3-d16 -mfloat-abi=softfp -mthumb" 
# 编译优化参数,-I$INC 指定 x264 头文件路径
FF_CFLAGS="-O3 -Wall -pipe \
-ffast-math \
-fstrict-aliasing -Werror=strict-aliasing \
-Wno-psabi -Wa,--noexecstack \
-DANDROID  \
-I$INC"

cd ../ffmpeg
function build_arm
{
./configure \
    # 这里需要启动生成动态库
    --enable-shared \
    # 静态库就不生成了
    --disable-static \
    --disable-ffmpeg \
    --disable-ffplay \
    --disable-ffprobe \
    --disable-ffserver \
    --disable-symver \
    # 禁用全部的编码
    --disable-encoders \
    # 启用 x264 这个库
    --enable-libx264 \
    # 启用 x264 编码
    --enable-encoder=libx264 \
    # 启用 aac 音频编码
    --enable-encoder=aac \
    # 启用几个图片编码,由于生成视频预览
    --enable-encoder=mjpeg \
    --enable-encoder=png \
    # 禁用全部的解码器
    --disable-decoders \
    # 启用几个常用的解码
    --enable-decoder=aac \
    --enable-decoder=aac_latm \
    --enable-decoder=h264 \
    --enable-decoder=mpeg4 \
    --enable-decoder=mjpeg \
    --enable-decoder=png \
    --disable-demuxers \
    --enable-demuxer=image2 \
    --enable-demuxer=h264 \
    --enable-demuxer=aac \
    --enable-demuxer=avi \
    --enable-demuxer=mpc \
    --enable-demuxer=mov \
    --disable-parsers \
    --enable-parser=aac \
    --enable-parser=ac3 \
    --enable-parser=h264 \
    # 这几个库应该需要,没怎么测试,反正很小就加上了
    --enable-avresample \
    --enable-small \
    --enable-avfilter \
    # 这两个是链接 x264 静态库需要
    --enable-gpl \
    --enable-yasm \
    # 编译输出前缀
    --prefix=$PREFIX \
    --cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \
    --target-os=linux \
    --arch=arm \
    --enable-cross-compile \
    --sysroot=$SYSROOT \
    --extra-cflags="$FF_CFLAGS $FF_EXTRA_CFLAGS" \
    # 指定 x264 静态库位置
    --extra-ldflags="-Wl,-L$LIB"
make clean
make -j16
make install
}

build_arm

这次编译不用静态库的原因是,静态库链接是有顺序要求的,这里模块太多,我也不知道哪个模块依赖哪个模块,所以直接上动态库

脚本写完后,就可以 run 了,编译时间有点久,可以学学我的某个同学,一编译就起来泡泡妹子,有说有笑。

编译完成后你的目录应该是下面那个样子:

--- FFmpegAndroid
    |-- ffmpeg
    |-- x264
    |-- build
        |-- build_ffmpeg.sh
        |-- build_x264.sh
        |-- lib
            |-- ffmpeg/armv7-a
                |-- include (ffmpeg so库的头文件)
                |-- lib (ffmpeg so库)
                    |-- libavcodec-57.so
                    |-- libavdevice-57.so
                    |-- libavcodec-57.so
                    |-- libavfilter-6.so
                    |-- libavformat-57.so
                    |-- libavresample-3.so
                    |-- libavutil-55.so
                    |-- libpostproc-54.so
                    |-- libresample-2.so
                    |-- libswscale-4.so
            |-- x264 (x264的静态库和头文件)

后面的版本号不一样没关系,这由 ffmpeg 版本决定的

库编译完了,这些 so 库就是在 Android 可用的动态库,接下来就可以准备 JNI 编程了

三、在 Android 里使用 FFmpeg

前面已经把 FFmpeg 各个核心库编译出来了,但是我肯定不会在里面直接用核心库内的函数来用,ffmpeg 本来是一个在 pc 端的命令,命令里面可以填写各种参数,比如 ffmpeg -i a.mp4 -c:v x264 -c:a aac b.mp4,就是把 a.mp4 用 x264(视频)、aac(音频) 编码成 b.mp4

ffmpeg 是由 ffmpeg.c 编译出来的,想要在 Android 里面用 ffmpeg 命令,只要修改 ffmpeg.c 里面的 main 函数,比如修改成 int run_ffmpeg_command(int args, char **argv),然后用 JNI 暴露给 java 调用,就可以在 Android 使用 ffmpeg 命令了

在 FFmpegAndroid 建立一个 Android 工程,然后新建一个 ffmpeg 的 lib module
对于 NDK 开发,AndroidStudio 2.2 以后就有较好的支持,直接修改支持库的 build.gradle 文件

apply plugin: 'com.android.library'

android {
    ...

    defaultConfig {
        ...
        // 启用 c++ 支持
        externalNativeBuild {
            cmake {
                cppFlags "-std=c++11"
            }
            ndk {
                abiFilters "armeabi-v7a"
            }
        }
    }

    ...

    // 指定 CMakeList 文件
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }
}

这样 lib module 就支持 c++ 了,方便吧!比以前的 Android.mk 不知道方便多少

然后在模块的 src/main 下面新建一个 cpp 目录,用于存放 c++ 代码,从ffmpeg拷贝以下文件:

cmdutils_common_opts.h
cmdutils.c
cmdutils.h
config.h
ffmpeg_filter.c
ffmpeg_opt.c
ffmpeg-lib.c
ffmpeg.c
ffmpeg.h

然后在 CMakeList.txt 里面配置这些文件,好让 AndroidStudio 认识它们

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.4.1)

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
            ffmpeg-lib

            # Sets the library as a shared library.
            SHARED

            # Provides a relative path to your source file(s).
             src/main/cpp/cmdutils.c
             src/main/cpp/ffmpeg.c
             src/main/cpp/ffmpeg_filter.c
             src/main/cpp/ffmpeg_opt.c
            # 此文件是用于暴露 ffmpeg.c 的 main 函数用
             src/main/cpp/ffmpeg-lib.c)

set(FFMPEG_LIB_DIR /Users/qigengxin/Documents/Github/FFmpegAndroid/build/lib/ffmpeg/armv7-a/lib)

add_library(
    avcodec
    SHARED
    IMPORTED
)
set_target_properties(
    avcodec
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libavcodec-57.so
)

add_library(
    avdevice
    SHARED
    IMPORTED
)
set_target_properties(
    avdevice
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libavdevice-57.so
)

add_library(
    avfilter
    SHARED
    IMPORTED
)
set_target_properties(
    avfilter
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libavfilter-6.so
)

add_library(
    avformat
    SHARED
    IMPORTED
)
set_target_properties(
    avformat
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libavformat-57.so
)

add_library(
    avresample
    SHARED
    IMPORTED
)
set_target_properties(
    avresample
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libavresample-3.so
)

add_library(
    avutil
    SHARED
    IMPORTED
)
set_target_properties(
    avutil
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libavutil-55.so
)

add_library(
    postproc
    SHARED
    IMPORTED
)
set_target_properties(
    postproc
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libpostproc-54.so
)

add_library(
    swresample
    SHARED
    IMPORTED
)
set_target_properties(
    swresample
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libswresample-2.so
)

add_library(
    swscale
    SHARED
    IMPORTED
)
set_target_properties(
    swscale
    PROPERTIES IMPORTED_LOCATION
    ${FFMPEG_LIB_DIR}/libswscale-4.so
)

include_directories(
    ../../ffmpeg
)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
            log-lib

            # Specifies the name of the NDK library that
            # you want CMake to locate.
            log )

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
            ffmpeg-lib
            avcodec
            avutil
            avfilter
            swscale
            swresample
            avresample
            postproc
            avformat
            avdevice

            # Links the target library to the log library
            # included in the NDK.
            ${log-lib} )

刷新下 gradle,就可以写 c++ 代码了。先看下 ffmpeg.c 这个文件,原先的指令其实调用的就是 main 函数,我们先把 main 函数改成自己自定义的函数 run_ffmpeg_command:

int run_ffmpeg_command(int argc, char **argv){
    ...
}

改了以后,我们就可以调用 run_ffmpeg_command 然后传入参数,相当于在 pc 执行 ffmpeg 命令。不过现在还不能执行,这是个坑点,仔细看 run_ffmpeg_command 函数,在程序结束的时候,或者中途出现错误的时候,都会调用 exit_program(int),这个函数:

int run_ffmpeg_command(int argc, char **argv){
    ...

    /* parse options and open all input/output files */
    ret = ffmpeg_parse_options(argc, argv);
    if (ret < 0){
        exit_program(1);
    }

    ...

    if (nb_output_files <= 0 && nb_input_files == 0) {
        show_usage();
        av_log(NULL, AV_LOG_WARNING, "Use -h to get full help or, even better, run 'man %s'\n", program_name);
        exit_program(1);
    }

    exit_program(received_nb_signals ? 255 : main_return_code);
    return main_return_code;
}

exit_program(int) 函数是什么,跳过去看一下发现里面就是清理资源然后 exit(int),这里就要注意这个 exit 函数了,除非我们是多进程方式调用 run_ffmpeg_command,如果我们在 app 的进程调用,执行了 exit 就会结束 app 的进程!

这不是我想看到的,最好的方法是另开一个进程调用,但是这样就涉及到了进程间的通信问题,麻烦,不想写!反正只是跑一个压缩指令嘛,直接改 ffmpeg.c,首先把 exit(int) 函数给注释掉,然后返回一个 code,run_ffmpeg_command 函数里面只要涉及到 exit_program(int) 函数调用的地方都写成 return exit_program(int),不过要注意,有如下几个坑点:

修改 ffmpeg.c 坑点一

调试的时候发现 return exit_program(int); 语句并不会结束当前函数并返回,而是继续往下执行了,当时一脸楞逼,我艹!!这是什么鬼??为什么我 return 了没有用?找了半天后才发现是 exit_program(int) 这个函数声明的锅!看下面这个函数的声明:

/**
 * Wraps exit with a program-specific cleanup routine.
 */
int exit_program(int ret) av_noreturn;

函数后面有个奇怪的 av_noreturn 声明,网上查了一下才知道,这个是给编译器的注解,这货的锅,去掉就好了。

修改 ffmpeg.c 坑点二

其实 exit_program(int) 这个函数不只是在 run_ffmpeg_command 里面调用,其它各种函数里面都有,如果都要修改的话必须一层一层的 return (C语言里面没有异常啊),很麻烦,但是如果没有改好的话就很容易 crash,这是个要解决的问题,首先 run_ffmpeg_command 里面的 exit_program 都要改成 return 方式

然后因为最终目的是压缩视频,参数集是固定的,所以不用考虑编码不支持,或参数匹配不到的情况,只需要考虑文件读写的问题,就是输入文件不存在的时候,或者输出路径不合法的时候,不能让程序异常退出,而是返回错误码,这个需要改 ffmpeg_opt.c 这个文件
ffmpeg_opt.c

static int open_files(OptionGroupList *l, const char *inout, int (*open_file)(OptionsContext*, const char*)){
    ...
}

static int open_input_file(OptionsContext *o, const char *filename){
    ...
}

static int open_outout_file(OptionsContext *o, const char *filename){
    ...
}

static int init_output_filter(OutputFilter *ofilter, OptionsContext *o, AVFormatContext *oc){
    ...
}

目前我项目中就只改了这几个函数内的 exit_program,测试可行,也可以参考本项目的代码,链接在文末

最后就是暴露 run_ffmpeg_command 方法给 java 调用了,这个和普通的 JNI 编程一样,建一个 native 的方法,创建 cpp 代码。。。没啥东西,直接上代码

FFmpegNativeBridge

public class FFmpegNativeBridge {

    static {
        System.loadLibrary("ffmpeg-lib");
    }

    /**
     * 执行指令
     * @param command
     * @return 命令返回结果
     */
    public static native int runCommand(String[] command);
}

ffmpeg-lib.c

#include <jni.h>
#include "ffmpeg.h"

JNIEXPORT jint JNICALL
Java_org_voiddog_ffmpeg_FFmpegNativeBridge_runCommand(JNIEnv *env, jclass type,
                                                      jobjectArray command) {
    int argc = (*env)->GetArrayLength(env, command);
    char *argv[argc];
    jstring jsArray[argc];
    int i;
    for (i = 0; i < argc; i++) {
        jsArray[i] = (jstring) (*env)->GetObjectArrayElement(env, command, i);
        argv[i] = (char *) (*env)->GetStringUTFChars(env, jsArray[i], 0);
    }
    int ret = run_ffmpeg_command(argc,argv);
    for (i = 0; i < argc; ++i) {
        (*env)->ReleaseStringUTFChars(env, jsArray[i], argv[i]);
    }
    return ret;
}

运行前先需要把 ffmpeg 编译出来的一堆 so 库放到 jniLibs 内,不然运行的时候会出现动态库无法加载的异常。最后就可以在 Android 内用 ffmpeg 的命令了:

 int ret = FFmpegNativeBridge.runCommand(new String[]{"ffmpeg",
                    "-i", "/storage/emulated/0/DCIM/Camera/VID_20170527_175421.mp4",
                    "-y",
                    "-c:v", "libx264",
                    "-c:a", "aac",
                    "-vf", "scale=480:-2",
                    "-preset", "ultrafast",
                    "-crf", "28",
                    "-b:a", "128k",
                    "/storage/emulated/0/Download/a.mp4"});

关于这些参数,可以去查 FFmpeg官网,本项目源码地址Github

推荐阅读更多精彩内容