系列文章:
前兩篇文章介紹瞭如何使用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.cpp和video_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的使用了:
這篇我們就只補充下之前沒有提到的部分。
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