FFmpeg入門 - Android移植

系列文章:

  1. FFmpeg入門 - 視頻播放
  2. FFmpeg入門 - rtmp推流
  3. FFmpeg入門 - Android移植
  4. FFmpeg入門 - 格式轉(zhuǎn)換

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

FFmpeg編譯與集成

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

如果的確編譯出現(xiàn)問題,也可以直接用我編出來的

將庫放到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
)

這樣一套下來其實(shí)ffmpeg的安卓環(huán)境就整好了,我們把之前的video_sender.cppvideo_sender.h拷貝過來添加個(gè)jni的接口驗(yàn)證下推流:

// 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進(jìn)行播放驗(yàn)證。

OpenGLES播放FFmpeg

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

安卓特效相機(jī)(二) EGL基礎(chǔ)

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

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

YUV

首先有個(gè)很重要的知識(shí)點(diǎn)在于我們的視頻很多情況下解碼出來都是YUV格式的畫面而不是安卓應(yīng)用開發(fā)常見的RGB格式。

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

1.png

然后再加上這種顏色的亮度就能代表我們實(shí)際看到的顏色。

YUV的發(fā)明是由于彩色電視與黑白電視的過渡時(shí)期,黑白電視只有亮度的值(Y)到了彩色電視的時(shí)代為了兼容之前的黑白電視,于是在亮度值后面加上了UV值指定顏色,如果忽略了UV那么剩下的Y,就和黑白電視的信號(hào)保持一致。

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

由于人的肉眼對(duì)亮度敏感對(duì)顏色相對(duì)不敏感,所以我們可以相鄰的幾個(gè)像素共用用UV信息,減少數(shù)據(jù)帶寬。

這里的共用UV信息并沒有對(duì)多個(gè)像素點(diǎn)做UV數(shù)據(jù)的均值,而是簡單的跳過一些像素點(diǎn)不去讀取他們的UV數(shù)據(jù)。

YUV444

每個(gè)像素都有自己的YUV數(shù)據(jù),每個(gè)像素占用Y + U + V = 8 + 8 + 8 = 24 bits

YUV444.png

444的含義是同一行相鄰的4個(gè)像素,分別采樣4個(gè)Y,4個(gè)U,4個(gè)V

YUV422

每兩個(gè)像素共用一對(duì)UV分量,每像素平均占用Y + U + V = 8 + 4 + 4 = 16 bits

YUV422.png

422的含義是同一行相鄰的4個(gè)像素,分別采樣4個(gè)Y,2個(gè)U,2個(gè)V

YUV420

每四個(gè)像素共用一對(duì)UV分量,每像素平均占用Y + U + V = 8 + 2 + 2 = 12 bits

YUV420.png

YUV420在YUV422的基礎(chǔ)上再隔行掃描UV信息,一行只采集U,下一行只采集V

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

OpenGLES顯示YUV圖像

由于OpenGLES使用RGB色彩,所以我們需要在fragmentShader里面將YUV轉(zhuǎn)成RGB,轉(zhuǎn)換公式如下:

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

由于解碼之后的數(shù)據(jù)使用平面格式(planar formats)保存,所以我們可以創(chuàng)建三張灰度圖圖片分別存儲(chǔ)YUV的分量,另外由于OpenGLES里面色彩的值范圍是0~1.0,而UV分量的取值范圍是-0.5~0.5所以我們UV分量統(tǒng)一減去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里面紋理坐標(biāo)原點(diǎn)是左下角,而解碼的畫面原點(diǎn)是左上角,所以紋理坐標(biāo)需要上下調(diào)換一下:

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

// 由于OpenGLES里面紋理坐標(biāo)原點(diǎn)是左下角,而解碼的畫面原點(diǎn)是左上角,所以紋理坐標(biāo)需要上下調(diào)換一下
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

接著我們就需要根據(jù)這些YUV數(shù)據(jù)創(chuàng)建三個(gè)灰度圖分別存儲(chǔ)各個(gè)分量的數(shù)據(jù)。這里有個(gè)知識(shí)點(diǎn),解碼得到的YUV數(shù)據(jù),高是對(duì)應(yīng)分量的高,但是寬卻不一定是對(duì)應(yīng)分量的寬.

這是因?yàn)樵谧鲆曨l解碼的時(shí)候會(huì)對(duì)寬進(jìn)行對(duì)齊,讓寬是16或者32的整數(shù)倍,具體是16還是32由cpu決定.例如我們的video.flv視頻,原始畫面尺寸是289*160,如果按32去對(duì)齊的話,他的Y分量的寬則是320.

對(duì)齊之后的寬在ffmpeg里面稱為linesize,而由于我們這個(gè)demo只支持YUV420的格式,它的Y分量的高度為原始圖像的高度,UV分量的高度由于是隔行掃描,所以是原生圖像高度的一半:

void OpenGlDisplay::Render(uint8_t *yuv420Data[3], int lineSize[3]) {
    // 解碼得到的YUV數(shù)據(jù),高是對(duì)應(yīng)分量的高,但是寬卻不一定是對(duì)應(yīng)分量的寬
    // 這是因?yàn)樵谧鲆曨l解碼的時(shí)候會(huì)對(duì)寬進(jìn)行對(duì)齊,讓寬是16或者32的整數(shù)倍,具體是16還是32由cpu決定
    // 例如我們的video.flv視頻,原始畫面尺寸是689x405,如果按32去對(duì)齊的話,他的Y分量的寬則是720
    // 對(duì)齊之后的寬在ffmpeg里面稱為linesize
    // 而對(duì)于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);

    // 由于對(duì)齊之后創(chuàng)建的紋理寬度大于原始畫面的寬度,所以如果直接顯示,視頻的右側(cè)會(huì)出現(xiàn)異常
    // 所以我們將紋理坐標(biāo)進(jìn)行縮放,忽略掉右邊對(duì)齊多出來的部分
    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);
}

另外由于對(duì)齊之后創(chuàng)建的紋理寬度大于原始畫面的寬度,所以如果直接顯示,視頻的右側(cè)會(huì)出現(xiàn)異常:

2.png

所以我們將紋理坐標(biāo)進(jìn)行縮放,忽略掉右邊對(duì)齊多出來的部分:

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

保持視頻長寬比

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

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)這個(gè)矩形會(huì)鋪滿整個(gè)屏幕導(dǎo)致圖像拉伸
    // 由于坐標(biāo)的原點(diǎn)在屏幕中央,所以只需要判斷是橫屏還是豎屏然后對(duì)x軸或者y軸做縮放就能讓圖像屏幕居中,然后恢復(fù)原始視頻的長寬比
    if (mWindowHeight > mWindowWidth) {
        // 如果是豎屏的話,圖像的寬不需要縮放,圖像的高縮小使其豎直居中
        GLint scaleX = glGetAttribLocation(mProgram, "aPosScaleX");
        glVertexAttrib1f(scaleX, 1.0f);

        // y坐標(biāo) * mWindowWidth / mWindowHeight 得到屏幕居中的正方形
        // 然后再 * videoHeight / videoWidth 就能恢復(fù)原始視頻的長寬比
        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坐標(biāo) * mWindowHeight / mWindowWidth 得到屏幕居中的正方形
        // 然后再 * videoWidth / videoHeight 就能恢復(fù)原始視頻的長寬比
        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);
3.jpeg

Demo工程

完整的代碼已經(jīng)上傳到Github

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容