FFmpeg入門 - Android移植 FFmpeg編譯與集成 OpenGLES播放FFmpeg Demo工程

系列文章:

  1. FFmpeg入門 - 視頻播放
  2. FFmpeg入門 - rtmp推流
  3. FFmpeg入門 - Android移植

前兩篇文章介紹瞭如何使用ffmpeg推流和拉流,這篇我們來看看怎樣將之前的代碼移植到安卓上。

FFmpeg編譯與集成

FFmpeg的安卓交叉編譯網上有很多的資料,基本上都是些編譯配置而已。可以直接將我的腳本放到ffmpeg源碼根目錄,修改下NDK的路徑和想要編譯的ABI之後直接執行。然後就能在android目錄裏面得到編譯好的so和.h

如果的確編譯出現問題,也可以直接用我編出來的

將庫放到AndroidStudio工程的jniLibs目錄,將include目錄放到app/src/main/cpp下,然後修改CMakeLists.txt添加ffmpeg頭文件路徑、庫路徑、鏈接配置等:

cmake_minimum_required(VERSION 3.18.1)

project("ffmpegdemo")

add_library(ffmpegdemo SHARED ffmpeg_demo.cpp video_sender.cpp opengl_display.cpp egl_helper.cpp video_decoder.cpp)

find_library(log-lib log)

# 頭文件路徑
include_directories(${CMAKE_SOURCE_DIR}/include)

# ffmpeg庫依賴
add_library(avcodec SHARED IMPORTED)
set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../../../jniLibs/${ANDROID_ABI}/libavcodec.so)

add_library(avfilter SHARED IMPORTED)
set_target_properties(avfilter PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../../../jniLibs/${ANDROID_ABI}/libavfilter.so)

add_library(avformat SHARED IMPORTED)
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../../../jniLibs/${ANDROID_ABI}/libavformat.so)

add_library(avutil SHARED IMPORTED)
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../../../jniLibs/${ANDROID_ABI}/libavutil.so)

add_library(swresample SHARED IMPORTED)
set_target_properties(swresample PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../../../jniLibs/${ANDROID_ABI}/libswresample.so)

add_library(swscale SHARED IMPORTED)
set_target_properties(swscale PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../../../jniLibs/${ANDROID_ABI}/libswscale.so)

target_link_libraries(
        ffmpegdemo

        # log
        ${log-lib}

        EGL
        GLESv2
        android

        # FFmpeg libs
        avcodec
        avfilter
        avformat
        avutil
        swresample
        swscale
)

這樣一套下來其實ffmpeg的安卓環境就整好了,我們把之前的video_sender.cppvideo_sender.h拷貝過來添加個jni的接口驗證下推流:

// java
File file = new File(getFilesDir(), "video.flv");

try {
    InputStream is = getAssets().open("video.flv");
    OutputStream os = new FileOutputStream(file);
    FileUtils.copy(is, os);
} catch (Exception e) {
    Log.d("FFmpegDemo", "err", e);
}

new Thread(new Runnable() {
    @Override
    public void run() {
        send(file.getAbsolutePath(), "rtmp://" + SERVER_IP + "/live/livestream");
    }
}).start();
//jni
extern "C" JNIEXPORT void JNICALL
Java_me_linjw_demo_ffmpeg_MainActivity_send(
        JNIEnv *env,
        jobject /* this */,
        jstring srcFile,
        jstring destUrl) {
    const char *src = env->GetStringUTFChars(srcFile, NULL);
    const char *dest = env->GetStringUTFChars(destUrl, NULL);
    LOGD("send: %s -> %s", src, dest);
    VideoSender::Send(src, dest);
}

然後就可以用安卓去推流,在pc上用之前的demo進行播放驗證。

OpenGLES播放FFmpeg

之前的demo使用SDL2播放視頻,但是安卓上更常規的做法是通過OpenGLES去播放。其實之前在做攝像教程的時候已經有介紹過OpenGLES的使用了:

安卓特效相機(二) EGL基礎

安卓特效相機(三) OpenGL ES 特效渲染

這篇我們就只補充下之前沒有提到的部分。

YUV

首先有個很重要的知識點在於我們的視頻很多情況下解碼出來都是YUV格式的畫面而不是安卓應用開發常見的RGB格式。

YUV是編譯true-color顏色空間(color space)的種類,Y'UV, YUV, YCbCr,YPbPr等專有名詞都可以稱爲YUV,彼此有重疊。“Y”表示明亮度(Luminance、Luma),“U”和“V”則是色度、濃度(Chrominance、Chroma),也就是說通過UV可以選擇到一種顏色:

然後再加上這種顏色的亮度就能代表我們實際看到的顏色。

YUV的發明是由於彩色電視與黑白電視的過渡時期,黑白電視只有亮度的值(Y)到了彩色電視的時代爲了兼容之前的黑白電視,於是在亮度值後面加上了UV值指定顏色,如果忽略了UV那麼剩下的Y,就和黑白電視的信號保持一致。

這種情況下數據是以 平面格式(planar formats) 去保存的,類似YYYYUUUUVVVV,YUV三者分開存放。
另外也有和常見的RGB存放方式類似的 緊縮格式(packed formats) ,類似YUVYUVYUV,每個像素點的YUV數據連續存放。

由於人的肉眼對亮度敏感對顏色相對不敏感,所以我們可以相鄰的幾個像素共用用UV信息,減少數據帶寬。

這裏的共用UV信息並沒有對多個像素點做UV數據的均值,而是簡單的跳過一些像素點不去讀取他們的UV數據。

YUV444

每個像素都有自己的YUV數據,每個像素佔用Y + U + V = 8 + 8 + 8 = 24 bits

444的含義是同一行相鄰的4個像素,分別採樣4個Y,4個U,4個V

YUV422

每兩個像素共用一對UV分量,每像素平均佔用Y + U + V = 8 + 4 + 4 = 16 bits

422的含義是同一行相鄰的4個像素,分別採樣4個Y,2個U,2個V

YUV420

每四個像素共用一對UV分量,每像素平均佔用Y + U + V = 8 + 2 + 2 = 12 bits

YUV420在YUV422的基礎上再隔行掃描UV信息,一行只採集U,下一行只採集V

420的含義是同一行相鄰的4個像素,分別採樣4個Y,2個U,0個V,或者4個Y,0個U,2個V

OpenGLES顯示YUV圖像

由於OpenGLES使用RGB色彩,所以我們需要在fragmentShader裏面將YUV轉成RGB,轉換公式如下:

R = Y + 1.4075 * V;
G = Y - 0.3455 * U - 0.7169*V;
B = Y + 1.779 * U;

由於解碼之後的數據使用平面格式(planar formats)保存,所以我們可以創建三張灰度圖圖片分別存儲YUV的分量,另外由於OpenGLES裏面色彩的值範圍是0~1.0,而UV分量的取值範圍是-0.5~0.5所以我們UV分量統一減去0.5做偏移.於是fragmentShader代碼如下:

static const string FRAGMENT_SHADER = "#extension GL_OES_EGL_image_external : require\n"
                                      "precision highp float;\n"
                                      "varying vec2 vCoord;\n"
                                      "uniform sampler2D texY;\n"
                                      "uniform sampler2D texU;\n"
                                      "uniform sampler2D texV;\n"
                                      "varying vec4 vColor;\n"
                                      "void main() {\n"
                                      "    float y = texture2D(texY, vCoord).x;\n"
                                      "    float u = texture2D(texU, vCoord).x - 0.5;\n"
                                      "    float v = texture2D(texV, vCoord).x - 0.5;\n"
                                      "    float r = y + 1.4075 * v;\n"
                                      "    float g = y - 0.3455 * u - 0.7169 * v;\n"
                                      "    float b = y + 1.779 * u;\n"
                                      "    gl_FragColor = vec4(r, g, b, 1);\n"
                                      "}";

接着由於OpenGLES裏面紋理座標原點是左下角,而解碼的畫面原點是左上角,所以紋理座標需要上下調換一下:

static const float VERTICES[] = {
        -1.0f, 1.0f,
        -1.0f, -1.0f,
        1.0f, -1.0f,
        1.0f, 1.0f
};

// 由於OpenGLES裏面紋理座標原點是左下角,而解碼的畫面原點是左上角,所以紋理座標需要上下調換一下
static const float TEXTURE_COORDS[] = {
        0.0f, 0.0f,
        0.0f, 1.0f,
        1.0f, 1.0f,
        1.0f, 0.0f
};

static const short ORDERS[] = {
        0, 1, 2, // 左下角三角形

        2, 3, 0  // 右上角三角形
};

最後就只要將每幀解析出來的圖像交給OpenGLES去渲染就好:

AVFrame *frame;
while ((frame = decoder.NextFrame()) != NULL) {
    eglHelper.MakeCurrent();
    display.Render(frame->data, frame->linesize);
    eglHelper.SwapBuffers();
}

linesize

接着我們就需要根據這些YUV數據創建三個灰度圖分別存儲各個分量的數據。這裏有個知識點,解碼得到的YUV數據,高是對應分量的高,但是寬卻不一定是對應分量的寬.

這是因爲在做視頻解碼的時候會對寬進行對齊,讓寬是16或者32的整數倍,具體是16還是32由cpu決定.例如我們的video.flv視頻,原始畫面尺寸是289*160,如果按32去對齊的話,他的Y分量的寬則是320.

對齊之後的寬在ffmpeg裏面稱爲linesize,而由於我們這個demo只支持YUV420的格式,它的Y分量的高度爲原始圖像的高度,UV分量的高度由於是隔行掃描,所以是原生圖像高度的一半:

void OpenGlDisplay::Render(uint8_t *yuv420Data[3], int lineSize[3]) {
    // 解碼得到的YUV數據,高是對應分量的高,但是寬卻不一定是對應分量的寬
    // 這是因爲在做視頻解碼的時候會對寬進行對齊,讓寬是16或者32的整數倍,具體是16還是32由cpu決定
    // 例如我們的video.flv視頻,原始畫面尺寸是689x405,如果按32去對齊的話,他的Y分量的寬則是720
    // 對齊之後的寬在ffmpeg裏面稱爲linesize
    // 而對於YUV420來說Y分量的高度爲原始圖像的高度,UV分量的高度由於是隔行掃描,所以是原生圖像高度的一半
    setTexture(0, "texY", yuv420Data[0], lineSize[0], mVideoHeight);
    setTexture(1, "texU", yuv420Data[1], lineSize[1], mVideoHeight / 2);
    setTexture(2, "texV", yuv420Data[2], lineSize[2], mVideoHeight / 2);

    // 由於對齊之後創建的紋理寬度大於原始畫面的寬度,所以如果直接顯示,視頻的右側會出現異常
    // 所以我們將紋理座標進行縮放,忽略掉右邊對齊多出來的部分
    GLint scaleX = glGetAttribLocation(mProgram, "aCoordScaleX");
    glVertexAttrib1f(scaleX, mVideoWidth * 1.0f / lineSize[0]);

    glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
    glDrawElements(GL_TRIANGLES, sizeof(ORDERS) / sizeof(short), GL_UNSIGNED_SHORT, ORDERS);
}

另外由於對齊之後創建的紋理寬度大於原始畫面的寬度,所以如果直接顯示,視頻的右側會出現異常:

所以我們將紋理座標進行縮放,忽略掉右邊對齊多出來的部分:

// VERTICES_SHADER
vCoord = vec2(aCoord.x * aCoordScaleX, aCoord.y);

保持視頻長寬比

雖然視頻能正常播放了,但是可以看到整個視頻是鋪滿屏幕的。所以我們需要對視頻進行縮放讓他保持長寬比然後屏幕居中:

void OpenGlDisplay::SetVideoSize(int videoWidth, int videoHeight) {
    mVideoWidth = videoWidth;
    mVideoHeight = videoHeight;

    // 如果不做處理(-1.0f, 1.0f),(-1.0f, -1.0f),(1.0f, -1.0f),(1.0f, 1.0f)這個矩形會鋪滿整個屏幕導致圖像拉伸
    // 由於座標的原點在屏幕中央,所以只需要判斷是橫屏還是豎屏然後對x軸或者y軸做縮放就能讓圖像屏幕居中,然後恢復原始視頻的長寬比
    if (mWindowHeight > mWindowWidth) {
        // 如果是豎屏的話,圖像的寬不需要縮放,圖像的高縮小使其豎直居中
        GLint scaleX = glGetAttribLocation(mProgram, "aPosScaleX");
        glVertexAttrib1f(scaleX, 1.0f);

        // y座標 * mWindowWidth / mWindowHeight 得到屏幕居中的正方形
        // 然後再 * videoHeight / videoWidth 就能恢復原始視頻的長寬比
        float r = 1.0f * mWindowWidth / mWindowHeight * videoHeight / videoWidth;
        GLint scaleY = glGetAttribLocation(mProgram, "aPosScaleY");
        glVertexAttrib1f(scaleY, r);
    } else {
        // 如果是橫屏的話,圖像的高不需要縮放,圖像的寬縮小使其水平居中
        GLint scaleY = glGetAttribLocation(mProgram, "aPosScaleY");
        glVertexAttrib1f(scaleY, 1.0f);

        // x座標 * mWindowHeight / mWindowWidth 得到屏幕居中的正方形
        // 然後再 * videoWidth / videoHeight 就能恢復原始視頻的長寬比
        float r = 1.0f * mWindowHeight / mWindowWidth * videoWidth / videoHeight;
        GLint scaleX = glGetAttribLocation(mProgram, "aPosScaleX");
        glVertexAttrib1f(scaleX, r);
    }
}
// VERTICES_SHADER
gl_Position = vec4(aPosition.x * aPosScaleX, aPosition.y * aPosScaleY, 0, 1);

Demo工程

完整的代碼已經上傳到Github

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章