系列文章:
前兩篇文章介紹了如何使用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.cpp和video_sender.h拷貝過來添加個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í)之前在做攝像教程的時候已經(jīng)有介紹過OpenGLES的使用了:
這篇我們就只補(bǔ)充下之前沒有提到的部分。
YUV
首先有個很重要的知識點(diǎn)在于我們的視頻很多情況下解碼出來都是YUV格式的畫面而不是安卓應(yīng)用開發(fā)常見的RGB格式。
YUV是編譯true-color顏色空間(color space)的種類,Y'UV, YUV, YCbCr,YPbPr等專有名詞都可以稱為YUV,彼此有重疊。“Y”表示明亮度(Luminance、Luma),“U”和“V”則是色度、濃度(Chrominance、Chroma),也就是說通過UV可以選擇到一種顏色:
然后再加上這種顏色的亮度就能代表我們實(shí)際看到的顏色。
YUV的發(fā)明是由于彩色電視與黑白電視的過渡時期,黑白電視只有亮度的值(Y)到了彩色電視的時代為了兼容之前的黑白電視,于是在亮度值后面加上了UV值指定顏色,如果忽略了UV那么剩下的Y,就和黑白電視的信號保持一致。
這種情況下數(shù)據(jù)是以 平面格式(planar formats) 去保存的,類似YYYYUUUUVVVV,YUV三者分開存放。
另外也有和常見的RGB存放方式類似的 緊縮格式(packed formats) ,類似YUVYUVYUV,每個像素點(diǎn)的YUV數(shù)據(jù)連續(xù)存放。
由于人的肉眼對亮度敏感對顏色相對不敏感,所以我們可以相鄰的幾個像素共用用UV信息,減少數(shù)據(jù)帶寬。
這里的共用UV信息并沒有對多個像素點(diǎn)做UV數(shù)據(jù)的均值,而是簡單的跳過一些像素點(diǎn)不去讀取他們的UV數(shù)據(jù)。
YUV444
每個像素都有自己的YUV數(shù)據(jù),每個像素占用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的基礎(chǔ)上再隔行掃描UV信息,一行只采集U,下一行只采集V
420的含義是同一行相鄰的4個像素,分別采樣4個Y,2個U,0個V,或者4個Y,0個U,2個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)建三張灰度圖圖片分別存儲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)建三個灰度圖分別存儲各個分量的數(shù)據(jù)。這里有個知識點(diǎn),解碼得到的YUV數(shù)據(jù),高是對應(yīng)分量的高,但是寬卻不一定是對應(yīng)分量的寬.
這是因?yàn)樵谧鲆曨l解碼的時候會對寬進(jìn)行對齊,讓寬是16或者32的整數(shù)倍,具體是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數(shù)據(jù),高是對應(yīng)分量的高,但是寬卻不一定是對應(yīng)分量的寬
// 這是因?yàn)樵谧鲆曨l解碼的時候會對寬進(jìn)行對齊,讓寬是16或者32的整數(shù)倍,具體是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);
// 由于對齊之后創(chuàng)建的紋理寬度大于原始畫面的寬度,所以如果直接顯示,視頻的右側(cè)會出現(xiàn)異常
// 所以我們將紋理坐標(biāo)進(jìn)行縮放,忽略掉右邊對齊多出來的部分
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);
}
另外由于對齊之后創(chuàng)建的紋理寬度大于原始畫面的寬度,所以如果直接顯示,視頻的右側(cè)會出現(xiàn)異常:
所以我們將紋理坐標(biāo)進(jìn)行縮放,忽略掉右邊對齊多出來的部分:
// VERTICES_SHADER
vCoord = vec2(aCoord.x * aCoordScaleX, aCoord.y);
保持視頻長寬比
雖然視頻能正常播放了,但是可以看到整個視頻是鋪滿屏幕的。所以我們需要對視頻進(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)這個矩形會鋪滿整個屏幕導(dǎo)致圖像拉伸
// 由于坐標(biāo)的原點(diǎn)在屏幕中央,所以只需要判斷是橫屏還是豎屏然后對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);
Demo工程
完整的代碼已經(jīng)上傳到Github