NDK學習筆記:FFmpeg解壓MP4提取視頻YUV

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,那新的數據豈不是~~~舊的~~~ /皺眉/皺眉/皺眉

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