Android 集成 FFmpeg (二) 以命令方式調用 FFmpeg

上一篇文章實現了 FFmpeg 編譯及 Android 端的簡單調用,成功獲取了 FFmpeg 支持的編解碼信息,而在實際使用時,需要調用 FFmpeg 內部函數,或通過命令行方式調用,但后者簡單很多。

怎么讓 FFmpeg 運行命令呢?很簡單,調用 FFmpeg 中執行命令的函數即可,這個函數位于源碼的 ffmpeg.c 文件中:

int main(int argc, char **argv)

我們的目的很簡單:將 FFmpeg 命令傳遞給 main 函數并執行。而這個傳遞過程需要編寫底層代碼實現,在這個底層接口代碼中,接收上層傳遞過來的 FFmpeg 命令 ,然后調用 ffmpeg.c 中的 main 函數執行該命令。

開始集成之前,首先回顧一下 JNI 標準接入步驟:

  1. 編寫帶有 native 方法的 Java 類
  2. 生成該類擴展名為 .h 的頭文件
  3. 創建該頭文件的 C/C++ 文件,實現 native 方法
  4. 將該 C/C++ 文件編譯成動態鏈接庫
  5. 在Java 程序中加載該動態鏈接庫

接下來按照此步驟開始集成,實現 Android 端以命令方式調用 FFmpeg ,這里假設你已經編譯過 FFmpeg 源碼,具體編譯方法可查看本系列第一篇。如果你是新手或對 Android 端集成底層庫不太熟悉,強烈建議先閱讀本系列第一篇 Android 集成 FFmpeg (一)基礎知識及簡單調用

首先新建一個文件夾 ndkBuild 作為工作空間,在 ndkBuild 目錄下新建 jni 文件夾, 作為編譯工作目錄。

1. 編寫帶有 native 方法的 Java 類

package com.jni;

public class FFmpegJni {
   
    public static native int run(String[] commands);
    
}

2. 生成該類擴展名為 .h 的頭文件

在 Android Studio 的 Terminal 中 切換到 java 目錄下,運行 javah 命令生成頭文件:

這里寫圖片描述

可以看到在 java 目錄下生成了頭文件:

這里寫圖片描述

然后將此頭文件剪切到 jni 目錄下。

3. 創建該頭文件的 C/C++ 文件,實現 native 方法

在 jni 目錄下創建對應的 C 文件 com_jni_FFmpegJni.c :

#include "android_log.h"
#include "com_jni_FFmpegJni.h"
#include "ffmpeg.h"

JNIEXPORT jint JNICALL Java_com_jni_FFmpegJni_run(JNIEnv *env, jclass obj, jobjectArray commands) {
    int argc = (*env)->GetArrayLength(env, commands);
    char *argv[argc];
    int i;
    for (i = 0; i < argc; i++) {
        jstring js = (jstring) (*env)->GetObjectArrayElement(env, commands, i);
        argv[i] = (char*) (*env)->GetStringUTFChars(env, js, 0);
    }
    LOGD("----------begin---------");
    return main(argc, argv);
}

函數的主要作用就是將 Java 端傳遞過來的 jobjectArray 類型的 FFmpeg 命令,轉換為 main 函數所需要的參數 argc 和 argv ,然后調用之。為了將日志輸出函數簡化為簡潔的 "LOGD"、 "LOGE",需要在同級目錄下新建 android_log.h 文件:

#ifdef ANDROID
#include <android/log.h>
#ifndef LOG_TAG
#define  MY_TAG   "MYTAG"
#define  AV_TAG   "AVLOG"
#endif
#define LOGE(format, ...)  __android_log_print(ANDROID_LOG_ERROR, MY_TAG, format, ##__VA_ARGS__)
#define LOGD(format, ...)  __android_log_print(ANDROID_LOG_DEBUG,  MY_TAG, format, ##__VA_ARGS__)
#define  XLOGD(...)  __android_log_print(ANDROID_LOG_INFO,AV_TAG,__VA_ARGS__)
#define  XLOGE(...)  __android_log_print(ANDROID_LOG_ERROR,AV_TAG,__VA_ARGS__)
#else
#define LOGE(format, ...)  printf(MY_TAG format "\n", ##__VA_ARGS__)
#define LOGD(format, ...)  printf(MY_TAG format "\n", ##__VA_ARGS__)
#define XLOGE(format, ...)  fprintf(stdout, AV_TAG ": " format "\n", ##__VA_ARGS__)
#define XLOGI(format, ...)  fprintf(stderr, AV_TAG ": " format "\n", ##__VA_ARGS__)
#endif

其中 XLOGD 和 XLOGE 方法是為了將 FFmpeg 內部日志信息自動輸出到 logcat,后面會用到。除 android_log.h 之外,很顯然,還需要添加 ffmpeg.c 、ffmpeg.h 文件,實際上 ffmpeg.c 的 main 函數中還會調用到其他文件,所以需要從源碼中拷貝 ffmpeg.h、ffmpeg.c、ffmpeg_opt.c、ffmpeg_filter.c、cmdutils.c、cmdutils.h 以及 cmdutils_common_opts.h 共 7 個文件到 jni 目錄下。

此時 jni 目錄下應該有以下 10 個文件:

這里寫圖片描述

接下來還要修改 ffmpeg.c 、cmdutils.c 以及 cmdutils.h 三個文件使其適用于 Android 端調用,按功能分為以下三點:

1.日志輸出到 logcat (修改 ffmpeg.c)

在執行命令過程中,FFmpeg 內部的日志系統會輸出很多有用的信息,但是在 Android 的 logcat 中是看不到的,所以需要修改源碼將 FFmpeg 內部日志輸出 logcat 中,方便調試,其實這是十分必要的。修改方法很簡單,只需修改 ffmpeg.c 文件三處:

引入 android_log.h 頭文件:

#include "android_log.h"

修改 log_callback_null 方法為下:(原方法為空)

static void log_callback_null(void *ptr, int level, const char *fmt, va_list vl)
{
    static int print_prefix = 1;
    static int count;
    static char prev[1024];
    char line[1024];
    static int is_atty;
    av_log_format_line(ptr, level, fmt, vl, line, sizeof(line), &print_prefix);
    strcpy(prev, line);
    if (level <= AV_LOG_WARNING){
        XLOGE("%s", line);
    }else{
        XLOGD("%s", line);
    }
}

設置日志回調方法為 log_callback_null:(main 函數開始處)

int main(int argc, char **argv)
{
    av_log_set_callback(log_callback_null);
    int i, ret;
    ......

2.執行命令后清除數據(修改 ffmpeg.c)

由于 Android 端執行一條 FFmpeg 命令后并不需要結束進程,所以需要初始化相關變量,否則執行下一條命令時就會崩潰。首先找到 ffmpeg.c 的 ffmpeg_cleanup 方法,在該方法的末尾添加以下代碼:

    nb_filtergraphs = 0;
    nb_output_files = 0;
    nb_output_streams = 0;
    nb_input_files = 0;
    nb_input_streams = 0;

然后在 main 函數的最后調用 ffmpeg_cleanup 方法,如下:

    ......
    ffmpeg_cleanup(0);
    return main_return_code;
}

3.執行結束后不結束進程(修改 cmdutils.c、cmdutils.h)

FFmpeg 在執行過程中出現異常或執行結束后會自動銷毀進程,而我們在 Android 中調用時,只想讓它作為一個普通的方法,不需要銷毀進程,只需要正常返回就可以了,這就需要修改 cmdutils.c 中的 exit_program 方法,源碼中為:

void exit_program(int ret)
{
    if (program_exit)
        program_exit(ret);

    exit(ret);
}

修改為:

int exit_program(int ret)
{
   return ret;
}

此處修改了方法的返回值類型,所以還需要修改對應頭文件中的方法聲明,即將 cmdutils.h 中的:

void exit_program(int ret) av_noreturn;

修改為:

int exit_program(int ret);

到這里需要修改項都已修改完畢,網上教程實現 FFmpeg 內部日志輸出到 logcat 的并不多,但這一步是十分有必要的。很多教程中需要將 ffmpeg 中的 main 方法名字修改為 "run" 、"exec" 等等,其實完全沒必要,為什么要對方法名這么在意,乃至不惜徒增新手學習的復雜度呢? 我不知道修改的原因和意義所在。 有些教程中需要把 config.h 文件也拷貝到 jni 目錄下,而我并沒有拷貝,那么到底需不需要呢?FFmpeg 的命令數不勝數,我只能說我執行過的命令都不需要拷貝 config.h ,盡管源碼 ffmpeg.c 中就聲明了引入 config.h 文件。

4. 將該 C/C++ 文件編譯成動態鏈接庫

在 jni 目錄下創建 Android.mk 文件 :

LOCAL_PATH:= $(call my-dir)

#編譯好的 FFmpeg 頭文件目錄
INCLUDE_PATH:=/home/yhao/sf/ffmpeg-3.3.3/Android/arm/include

#編譯好的 FFmpeg 動態庫目錄
FFMPEG_LIB_PATH:=/home/yhao/sf/ffmpeg-3.3.3/Android/arm/lib

include $(CLEAR_VARS)
LOCAL_MODULE:= libavcodec
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavcodec-57.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
 
include $(CLEAR_VARS)
LOCAL_MODULE:= libavformat
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavformat-57.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
 
include $(CLEAR_VARS)
LOCAL_MODULE:= libswscale
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libswscale-4.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
 
include $(CLEAR_VARS)
LOCAL_MODULE:= libavutil
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavutil-55.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
 
include $(CLEAR_VARS)
LOCAL_MODULE:= libavfilter
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavfilter-6.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
 
include $(CLEAR_VARS)
LOCAL_MODULE:= libswresample
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libswresample-2.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)

include $(CLEAR_VARS)
LOCAL_MODULE:= libpostproc
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libpostproc-54.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)

include $(CLEAR_VARS)
LOCAL_MODULE:= libavdevice
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavdevice-57.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)

include $(CLEAR_VARS)
LOCAL_MODULE := ffmpeg
LOCAL_SRC_FILES := com_jni_FFmpegJni.c \
                  cmdutils.c \
                  ffmpeg.c \
                  ffmpeg_opt.c \
                  ffmpeg_filter.c   
LOCAL_C_INCLUDES := /home/yhao/sf/ffmpeg-3.3.3
LOCAL_LDLIBS := -lm -llog
LOCAL_SHARED_LIBRARIES := libavcodec libavfilter libavformat libavutil libswresample libswscale libavdevice
include $(BUILD_SHARED_LIBRARY)

此處使用的 FFmpeg 為本系列上篇文章中的編譯配置,其中引入了 libmp3lame 庫以支持 mp3 格式編碼,與上篇文章不同的是最后對 ffmpeg 動態庫的編譯,加入了 ffmpeg.c 、cmdutils.c 等文件。

然后在 jni 目錄下創建 Application.mk 文件:

APP_ABI := armeabi-v7a
APP_PLATFORM=android-14
NDK_TOOLCHAIN_VERSION=4.9

這時 jni 目錄應該有以下 12 個文件:

這里寫圖片描述

一切準備就緒,在 jni 目錄下運行 ndk 編譯命令:

ndk-build

然后就可以在 ndkBuild 目錄下看到生成的 libs 和 obj 文件夾了。

5.在Java 程序中加載該動態鏈接庫

將 libs 目錄下生成的 armeabi-v7a 動態庫拷貝到 Android 工程中,此時工程應該是這樣的:

這里寫圖片描述

在 FFmpegJni.java 中加載動態庫:

package com.jni;

public class FFmpegJni {
    static {
        System.loadLibrary("avutil-55");
        System.loadLibrary("avcodec-57");
        System.loadLibrary("avformat-57");
        System.loadLibrary("avdevice-57");
        System.loadLibrary("swresample-2");
        System.loadLibrary("swscale-4");
        System.loadLibrary("postproc-54");
        System.loadLibrary("avfilter-6");
        System.loadLibrary("ffmpeg");
    }
    public static native int run(String[] commands);
}

記得在應用的 build.gradle 文件中 android 節點下添加動態庫加載路徑:

    sourceSets {
        main {
            jniLibs.srcDirs = ['libs']
        }
    }

OK,至此集成工作就全部完成了,在程序中調用 run 方法,就能以命令方式調用 FFmpeg 。

接下來以剪切 mp3 文件為例,驗證是否集成成功,首先需要準備一個 mp3 文件,這里我提供兩首歌:泡沫、童話鎮,下載解壓后直接將其放到手機根目錄下即可。

直接給出 MainActivity 代碼:

import com.jni.FFmpegJni;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if ( ActivityCompat.checkSelfPermission(this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ) {
            ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE }, 1);
        }
    }

    public void run(View view) {
        String dir = Environment.getExternalStorageDirectory().getPath() + "/ffmpegTest/";

        //ffmpeg -i source_mp3.mp3 -ss 00:01:12 -t 00:01:42 -acodec copy output_mp3.mp3
        String[] commands = new String[10];
        commands[0] = "ffmpeg";
        commands[1] = "-i";
        commands[2] = dir+"paomo.mp3";
        commands[3] = "-ss";
        commands[4] = "00:01:00";
        commands[5] = "-t";
        commands[6] = "00:01:00";
        commands[7] = "-acodec";
        commands[8] = "copy";
        commands[9] = dir+"paomo_cut_mp3.mp3";

        int result = FFmpegJni.run(commands);
        Toast.makeText(MainActivity.this, "命令行執行完成 result="+result, Toast.LENGTH_SHORT).show();
    }
}

記得在 AndroidManifest.xml 中聲明權限:

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

運行之后,播放生成的 paomo_cut_mp3 試試,剪切成功~ 但是一個例子不夠過癮,再來一個延時播放的命令:

        String[] commands = new String[12];
        commands[0] = "ffmpeg";
        commands[1] = "-i";
        commands[2] = dir+"paomo.mp3";
        commands[3] = "-filter_complex";
        commands[4] = "adelay=5000|5000";
        commands[5] = "-ac"; //聲道數
        commands[6] = "1";
        commands[7] = "-ar"; //采樣率
        commands[8] = "24k";
        commands[9] = "-ab"; //比特率
        commands[10] = "32k";
        commands[11] = dir+"adelay_output.mp3";

更簡單的方法實現

本文的核心工作是 ffmpeg.c 等文件的修改及編譯,其實這些文件的修改是一勞永逸的。無論你的 FFmpeg 如何配置編譯選項,不管是支持 mp3 編碼還是支持 h264 編碼,對 ffmpeg.c 等文件的修改內容都是固定的,所以我把這個工作目錄上傳到了 github ,方便大家直接拿來使用。

github 地址 : https://github.com/yhaolpz/ffmpeg-command-ndkBuild

接下來演示一下如何使用,首先要將 ffmpeg-command-ndkBuild 克隆到你的電腦上,注意這里默認你已經編譯過 FFmpeg,編譯方法見本系列第一章。

  1. 編寫帶有 native 方法的 Java 類

package com.jni;

public class FFmpegJni {
   
    public static native int run(String[] commands);
    
}

如果你編寫的 Java 類跟上面這個類的包名、類名和方法都相同,那就直接跳到第 4 步,因為你可以直接使用 jni 目錄中的 com_jni_FFmpegJni.c 和 com_jni_FFmpegJni.h 。

  1. 生成該類擴展名為 .h 的頭文件

在 Android Studio 的 Terminal 中 切換到 Java 目錄下,運行 javah 命令生成頭文件:

javah -classpath .  com.包名.類名

將該頭文件拷貝到 jni 目錄下,并且刪除 com_jni_FFmpegJni.h 文件。

  1. 創建該頭文件的 C/C++ 文件,實現 native 方法

這里不需要重新創建 C 文件,直接在 com_jni_FFmpegJni.c 基礎上修改即可。

  • 修改第2行 com_jni_FFmpegJni.h 為你自己的頭文件名
  • 修改 Java_com_jni_FFmpegJni_run 方法名為你自己的頭文件中的方法名
  • 修改 com_jni_FFmpegJni.c 文件名為你自己的頭文件名
  • 修改 Android.mk 中文件最后的 com_jni_FFmpegJni.c 為你自己的 C 文件名
  1. 將該 C/C++ 文件編譯成動態鏈接庫

  • 修改 Android.mk 中的 INCLUDE_PATH 、FFMPEG_LIB_PATH 為你自己編譯好的 FFmpeg 動態庫路徑
  • 修改 Android.mk 倒數第四行的 LOCAL_C_INCLUDES 為你自己的 FFmpeg 源碼路徑。

OK~ 直接運行 ndk-build 命令編譯吧,最后一步 "在Java 程序中加載該動態鏈接庫" 以及調用案例在上文中已經描述的很詳細了,就不再贅述了。

總結

本文延續第一篇的規則,將編譯工作置于 Android 工程之外進行,直到生成最后可用的 so 庫再移植到 Android 工程中,我相信這樣對 jni 的理解會更清晰一些。

本文中還有兩個問題待解決,在 Application.mk 文件中通過 NDK_TOOLCHAIN_VERSION=4.9 指定編譯器為 4.9 版本的 gcc,若不指定,將默認使用 clang 編譯器,這時編譯會報錯,提示缺少一些文件。第二個問題就是 Application.mk 中通過 APP_ABI := armeabi-v7a 指定生成 armv7 架構動態庫,若改成 armeabi 架構編譯則會報錯,提示不支持該模式,后續盡快解決這兩個問題。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容