NDK學習筆記:FFmpeg解壓MP4提取視頻YUV
繼上一篇NDK的開發筆記,既然我們已經從源碼手動編譯ffmpeg-so出來了,這篇文章就當是檢驗編譯的so是否可用,對FFmpeg進行一番學習,寫一個最簡單的例子。並結合工作中的一些架構內容,推出一些簡單架構的話題。歡迎大家互相學習。事不宜遲,馬上擼碼。
一、準備工作
定義native方法的java入口。FFmpegUtils
public class ZzrFFmpeg {
public static native int Mp4TOYuv(String input_path_str, String output_path_str );
static
{
try {
System.loadLibrary("avutil");
System.loadLibrary("swscale");
System.loadLibrary("swresample");
System.loadLibrary("avcodec");
System.loadLibrary("avformat");
System.loadLibrary("postproc");
System.loadLibrary("avfilter");
System.loadLibrary("avdevice");
//以上庫最後要手動放置到jniLibs文件夾的對應ANDROID_ABI當中
//System.loadLibrary("zzr-ffmpeg-utils");
//我們自己編寫方法的庫,最後生成之後要打開註釋
} catch (Exception e) {
e.printStackTrace();
}
}
}
這裏要強調一點,以上的so加載順序是有序的!其中依賴關係我們可以查閱ffmpeg的編譯配置configure(關於編譯與配置有不明白的,請參考上一篇NDK的文章)依賴關係如下所示:
# libraries, in linking order
avcodec_deps="avutil"
avdevice_deps="avformat avcodec avutil"
avfilter_deps="avutil"
avformat_deps="avcodec avutil"
avresample_deps="avutil"
postproc_deps="avutil gpl"
swresample_deps="avutil"
swscale_deps="avutil"
建立好native入口的java文件之後,我們就可以開始實現native方法了。我們在項目的cpp中新建文件夾ffmpeg,把我們手動編譯的ffmpeg產物全部拷貝,並新建ffmpeg_util.c文件,目錄結構大概如下:
其中我們在zzr_ffmpeg_util.c文件編寫我們的native方法Mp4TOYuv的實現:
#include <jni.h>
JNIEXPORT jint JNICALL
Java_org_zzrblog_mp_ZzrFFmpeg_Mp4TOYuv(JNIEnv *env, jclass clazz,
jstring input_path_jstr, jstring output_path_jstr)
{
// ... ...
}
有強迫症的同學(例如我自己)可能懊惱爲啥java文件的native方法還是標紅報錯,找不到對應的c/c++實現,rebuild還是不行。這是因爲CMake編譯腳本還沒真正的生成符號表,所以導致找不到c/c++實現的入口。所以我們還是把CMake編譯腳本準備好
# 引入外部 ffmpeg so 供源文件編譯
add_library(avcodec SHARED IMPORTED )
set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavcodec.so)
set_target_properties(avcodec PROPERTIES LINKER_LANGUAGE CXX)
add_library(avdevice SHARED IMPORTED )
set_target_properties(avdevice PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavdevice.so)
set_target_properties(avdevice PROPERTIES LINKER_LANGUAGE CXX)
add_library(avfilter SHARED IMPORTED )
set_target_properties(avfilter PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavfilter.so)
set_target_properties(avfilter PROPERTIES LINKER_LANGUAGE CXX)
add_library(avformat SHARED IMPORTED )
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavformat.so)
set_target_properties(avformat PROPERTIES LINKER_LANGUAGE CXX)
add_library(avutil SHARED IMPORTED )
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavutil.so)
set_target_properties(avutil PROPERTIES LINKER_LANGUAGE CXX)
add_library(postproc SHARED IMPORTED )
set_target_properties(postproc PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libpostproc.so)
set_target_properties(postproc PROPERTIES LINKER_LANGUAGE CXX)
add_library(swresample SHARED IMPORTED )
set_target_properties(swresample PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libswresample.so)
set_target_properties(swresample PROPERTIES LINKER_LANGUAGE CXX)
add_library(swscale SHARED IMPORTED )
set_target_properties(swscale PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libswscale.so)
set_target_properties(swscale PROPERTIES LINKER_LANGUAGE CXX)
add_library( # 生成動態庫的名稱
zzr-ffmpeg-utils
# 指定是動態庫SO
SHARED
# 編譯庫的源代碼文件
src/main/cpp/ffmpeg/zzr_ffmpeg_util.c)
target_link_libraries( # 指定目標鏈接庫
zzr-ffmpeg-utils
# 添加預編譯庫到目標鏈接庫中
${log-lib}
avutil
avcodec
avformat
swscale )
我們在之前的fmod基礎上,很快就可以理解並編寫腳本。如果有問題的同學可以去查看之前的fmod(下)文章
二、實現Mp4TOYuv (RGB)(H264)
到這裏我們開始編寫一個ffmpeg最簡單的解碼例子。並從中學習ffmpeg的使用步驟。在寫代碼之前,我們先看看 “雷神” 雷霄驊的一頁ppt教學。緬懷念這位同期的偉人。願天堂還能繼續做自己喜歡的研究 R.I.P
這頁ppt對應的是3.x之前的版本的API,我們使用的是3.3.9略有變化,但是大體流程是一致的。用雷神的原話:初次學習,一定要將這流程和函數名稱熟記於心。 現在開始真正的編碼。
JNIEXPORT jint JNICALL
Java_org_zzrblog_mp_ZzrFFmpeg_Mp4TOYuv(JNIEnv *env, jclass clazz, jstring input_path_jstr, jstring output_path_jstr) {
const char *input_path_cstr = (*env)->GetStringUTFChars(env, input_path_jstr, 0);
const char *output_path_cstr = (*env)->GetStringUTFChars(env, output_path_jstr, 0);
LOGD("輸入文件:%s", input_path_cstr);
LOGD("輸出文件:%s", output_path_cstr);
// 1.註冊組件
av_register_all();
avcodec_register_all();
avformat_network_init();
// 2.獲取格式上下文指針,便於打開媒體容器文件獲取媒體信息
AVFormatContext *pFormatContext = avformat_alloc_context();
// 打開輸入視頻文件
if(avformat_open_input(&pFormatContext, input_path_cstr, NULL, NULL) != 0){
LOGE("%s","打開輸入視頻文件失敗");
return -1;
}
// 獲取視頻信息
if(avformat_find_stream_info(pFormatContext,NULL) < 0){
LOGE("%s","獲取視頻信息失敗");
return -2;
}
// 3.準備視頻解碼器,根據視頻AVStream所在pFormatCtx->streams中位置,找出索引
int video_stream_idx = -1;
for(int i=0; i<pFormatContext->nb_streams; i++)
{
//根據類型判斷,是否是視頻流
if(pFormatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_stream_idx = i;
break;
}
}
LOGD("VIDEO的索引位置:%d", video_stream_idx);
// 根據codec_parameter的codec索引,提取對應的解碼器。
AVCodec *pCodec = avcodec_find_decoder(pFormatContext->streams[video_stream_idx]->codecpar->codec_id);
if(pCodec == NULL) {
LOGE("%s","解碼器創建失敗.");
return -3;
}
// 4.創建解碼器對應的上下文
AVCodecContext * pCodecContext = avcodec_alloc_context3(pCodec);
if(pCodecContext == NULL) {
LOGE("%s","創建解碼器對應的上下文失敗.");
return -4;
}
// 坑位!!!
//pCodecContext->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
//pCodecContext->width = pFormatContext->streams[video_stream_idx]->codecpar->width;
//pCodecContext->height = pFormatContext->streams[video_stream_idx]->codecpar->height;
//pCodecContext->pix_fmt = AV_PIX_FMT_YUV420P;
avcodec_parameters_to_context(pCodecContext,
pFormatContext->streams[video_stream_idx]->codecpar);
// 5.打開解碼器
if(avcodec_open2(pCodecContext, pCodec, NULL) < 0){
LOGE("%s","解碼器無法打開");
return -5;
} else {
LOGI("設置解碼器解碼格式pix_fmt:%d", pCodecContext->pix_fmt);
}
// ... ...
return 0;
}
以上代碼對應的是ppt上的前五步驟。我們來逐一分析:
- 第1步:固定步驟,註冊FFmpeg所需的組件,啓動初始化運行環境。關於有多個xxx_register_xxx的方法,其實可以通過各種寫代碼編譯器的智能提示,輸入register就能看到也沒多少註冊方法,也就3~4~5~6個而已。/doge
- 第2步:關於任何媒體容器文件(MP4、RMVB、TS、FLV、AVI)還是各種在線傳輸協議(udp-rtp、rtmp)在正式解碼之前,我們都必須要獲取當前視頻的格式信息,以便更好的創建解碼器&運行時環境。 ffmpeg獲取視頻格式信息通過三個API完成:(1)avformat_alloc_context 創建 AVFormatContext * 格式上下文指針;(2)通過avformat_open_input 關聯打開 格式上下文指針 & 媒體文件/流;(3)avformat_find_stream_info 獲取媒體文件/流的格式信息。
- 第3步:到達這一步,我們已經掌握了媒體文件/流的格式信息了。通過遍歷AVFormatContext->streams的 AVStream 數組,獲取AVCodecParameters 編解碼器的參數列表 中的codec_type編碼類型。(這裏AVStream代表的是媒體文件/流中具體的某一軌信息,一個正常媒體資源可能包括:視頻+音頻+字幕 等等其他信息)我們這裏找出視頻軌的索引位置,根據索引出來的AVCodecParameters->codec_id解碼器ID,通過avcodec_find_decoder提取對應的AVCodec視頻解碼器。(這裏的引用有點多而且複雜,請認真理解)(這一步驟也是3.x前後的小區別之一,請注意)
- 第4步:你以爲有AVCodec解碼器就完事了嗎?NONONO,此時的AVCodec解碼器還沒完成初始化,不知道你想要它幹啥呢!此時我們需要構建AVCodecContext指針關聯AVCodec解碼器。注意!這一步的內容是3.x前後的明顯區別之一,之前版本的API,我們是不需要這樣自己創建關聯。通過AVCodecContext * pCodecContext = avcodec_alloc_context3(pCodec); 我們創建AVCodecContext指針,但是此時的AVCodecContext 還需要通過新的API接口avcodec_parameters_to_context(AVCodecContext *, AVCodecParameters * )來完成初始化工作,從上面的註釋我可以誠懇的告訴大家,別想着自己完成初始化賦值工作,因爲變量實在太多了,一個錯了,就會導致後面解碼工作的失敗。所以還是乖乖的用API吧。
- 第5步:基本工作準備好我們就可以正式啓動解碼器,通過avcodec_open2(AVCodecContext * , AVCodec * , AVDictionary**); 結束FFmpeg解碼工作環境的建立。
接下來,我們進入解碼流程:
// ... ... 緊接上方 ... ...
// 解碼流程,多看多理解。
// 解壓縮前的數據對象
AVPacket *packet = av_packet_alloc();
// 解碼後數據對象
AVFrame *frame = av_frame_alloc();
AVFrame *yuvFrame = av_frame_alloc();
// 爲yuvFrame緩衝區分配內存,只有指定了AVFrame的像素格式、畫面大小才能真正分配內存
int buffer_size = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, pCodecContext->width, pCodecContext->height, 1);
uint8_t *out_buffer = (uint8_t *)av_malloc((size_t) buffer_size);
// 初始化yuvFrame緩衝區
av_image_fill_arrays(yuvFrame->data, yuvFrame->linesize, out_buffer,
AV_PIX_FMT_YUV420P, pCodecContext->width, pCodecContext->height, 1 );
// yuv輸出文件
FILE* fp_yuv = fopen(output_path_cstr,"wb");
// test:264輸出文件
char save264str[100]={0};
sprintf(save264str, "%s", "/storage/emulated/0/10s_test.h264");
FILE* fp_264 = fopen(save264str,"wb");
//用於像素格式轉換或者縮放
struct SwsContext *sws_ctx = sws_getContext(
pCodecContext->width, pCodecContext->height, pCodecContext->pix_fmt,
pCodecContext->width, pCodecContext->height, AV_PIX_FMT_YUV420P,
SWS_BICUBIC, NULL, NULL, NULL); //SWS_BILINEAR
int ret, frameCount = 0;
// 5. 循環讀取視頻數據的分包 AVPacket
while(av_read_frame(pFormatContext, packet) >= 0)
{
if(packet->stream_index == video_stream_idx)
{
// test:h264數據寫入本地文件
fwrite(packet->data, 1, (size_t) packet->size, fp_264);
//AVPacket->AVFrame
ret = avcodec_send_packet(pCodecContext, packet);
if(ret < 0){
LOGE("avcodec_send_packet:%d\n", ret);
continue;
}
while(ret >= 0) {
ret = avcodec_receive_frame(pCodecContext, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF){
LOGD("avcodec_receive_frame:%d\n", ret);
break;
}else if (ret < 0) {
LOGW("avcodec_receive_frame:%d\n", AVERROR(ret));
goto end; //end處進行資源釋放等善後處理
}
if (ret >= 0)
{ //frame->yuvFrame (調整縮放)
sws_scale(sws_ctx,
(const uint8_t* const*)frame->data, frame->linesize, 0, frame->height,
yuvFrame->data, yuvFrame->linesize);
//向YUV文件保存解碼之後的幀數據
//AVFrame->YUV,一個像素包含一個Y
int y_size = frame->width * frame->height;
fwrite(yuvFrame->data[0], 1, (size_t) y_size, fp_yuv);
fwrite(yuvFrame->data[1], 1, (size_t) y_size/4, fp_yuv);
fwrite(yuvFrame->data[2], 1, (size_t) y_size/4, fp_yuv);
frameCount++ ;
}
}
}
av_packet_unref(packet);
}
LOGI("總共解碼%d幀",frameCount++);
解碼流程如上,我們快速簡單分析一波,然後祭出靈魂畫師(我的)畫的圖,幫助大家理解:
1、首先創建 解壓縮數據對象 AVPacket *packet,解碼後數據的對象AVFrame *frame/*yuvFrame 先別問爲啥有兩個AVFrame。
2、然後爲yuvFrame緩衝區分配內存,通過av_image_get_buffer_size計算yuv對應大小的內存大小buffer_size,然後就是av_malloc(buffer_size),最後就是av_image_fill_arrays關聯綁定 內存區 和 yuvFrame。只有指定了像素格式、畫面大小AVFrame才能真正分配內存。 繼續別問爲啥只對yuvFrame操作,frame不需要。
3、然後就是打開本地文件句柄,準備寫入相關數據流。接着我們創建yuv/rgb 轉換器,這部分代碼比較固定而且簡單。
4、然後分析關鍵循環,通過av_read_frame循環讀取媒體文件/流,獲取裸碼流數據AVPacket,判斷當前是否視頻數據。如果是視頻數據,那此時的AVPacket裝載的就是h264的源數據了。我們把數據寫入本地文件。循環最後需要調用av_packet_unref來解除AVPacket對象的引用次數。
接着我們把h264數據通過avcodec_send_packet(pCodecContext, packet) 發送到解碼器,判斷返回值是否正常,然後我可以立刻通過avcodec_receive_frame(pCodecContext, frame) 獲取解碼後的數據,通過此對API,我們就完成了從裸碼流AVPacket->AVFrame的解碼數據的轉換。但請注意,AVPacket和AVFrame的關係並不是一對一的!更多時候是N個AVPacket纔對應一個AVFrame!此時AVFrame的格式就是對應的之前準備工作avcodec_parameters_to_context中AVCodecParameters的格式,也就是通過FFmpeg解讀出來的pix_fmt(其實就是YUV,不信你自己debug看看)
出來AVFrame之後,我們繼續通過sws_scale達到yuv/rgb的格式轉換,轉換成之後把數據寫入本地文件。
5、好了,回到1和2的兩個問題,然後我們怎麼對這個流程加深理解呢?看看下圖:我們以之前分析Android.MediaCodec的工作原理圖,然後再結合流程分析,我們就可以明白了,爲啥frame不需要獨立分配內存空間,而縮放後得到我們想要的yuvFrame需要費一堆API來配合工作。 配合圖解還不懂的,請不要私信我~~~
重要的內容已經結束了。但是不要忘了資源回收的收尾工作。
end:
// 結束回收工作
fclose(fp_yuv);
fclose(fp_264);
sws_freeContext(sws_ctx);
av_free(out_buffer);
av_frame_free(&frame);
av_frame_free(&yuvFrame);
avcodec_close(pCodecContext);
avcodec_free_context(&pCodecContext);
avformat_close_input(&pFormatContext);
avformat_free_context(pFormatContext);
(*env)->ReleaseStringUTFChars(env, input_path_jstr, input_path_cstr);
(*env)->ReleaseStringUTFChars(env, output_path_jstr, output_path_cstr);
return 0;
三、思考問題
代碼確實可以運行起來了,然後也能生成.h264 和 .yuv了,然而。。。我現在每次avcodec_send_packet就立刻avcodec_receive_frame,然後直接fwrite,那新的數據豈不是~~~舊的~~~ /皺眉/皺眉/皺眉