FFmpeg入門 - 視頻播放

系列文章:

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

音視頻最好從能夠直接看到東西,也更加貼近用戶的播放開始學(xué)起.

音視頻編解碼基礎(chǔ)

我們可以通過http、rtmp或者本地的視頻文件去播放視頻。這里的"視頻"實際上指的是mp4、avi這種既有音頻也有視頻的文件格式。

這樣的視頻文件可能會有多條軌道例如視頻軌道、音頻軌道、字幕軌道等.
有些格式限制比較多,例如AVI視頻軌道只能有一條,音頻軌道也只能有一條.
而有些格式則比較靈活,例如OGG視頻的視頻、音頻軌道都能有多條.

像音頻、視頻這種數(shù)據(jù)量很大的軌道,上面的數(shù)據(jù)實際上都是通過壓縮的。
視頻軌道上可能是H264、H256這樣壓縮過的圖像數(shù)據(jù),通過解碼可以還原成YUV、RGB等格式的圖像數(shù)據(jù)。
音頻軌道上可能是MP3、AAC這樣壓縮過的的音頻數(shù)據(jù),通過解碼可以還原成PCM的音頻裸流。

截屏2022-09-04 下午1.47.57.png

實際上使用ffmpeg去播放視頻也就是根據(jù)文件的格式一步步還原出圖像數(shù)據(jù)交給顯示設(shè)備顯示、還原出音頻數(shù)據(jù)交給音頻設(shè)備播放:

截屏2022-09-04 下午1.48.08.png

ffmpeg簡單入門

了解了視頻的播放流程之后我們來做一個簡單的播放器實際入門一下ffmpeg。由于這篇博客是入門教程,這個播放器功能會進行簡化:

  1. 使用ffmpeg 4.4.2版本 - 4.x的版本被使用的比較廣泛,而且最新的5.x版本資料比較少
  2. 只解碼一個視頻軌道的畫面進行播放 - 不需要考慮音視頻同步的問題
  3. 使用SDL2在主線程解碼 - 不需要考慮多線程同步問題
  4. 使用源碼+Makefile構(gòu)建 - 在MAC和Ubuntu上驗證過,Windows的同學(xué)需要自己創(chuàng)建下vs的工程了

使用ffmpeg去解碼大概有下面的幾個步驟和關(guān)鍵函數(shù),大家可以和上面的流程圖對應(yīng)一下:

解析文件流(解協(xié)議和解封裝)

  1. avformat_open_input : 可以打開File、RTMP等協(xié)議的數(shù)據(jù)流,并且讀取文件頭解析出視頻信息,如解析出各個軌道和時長等
  2. avformat_find_stream_info : 對于沒有文件頭的格式如MPEG或者H264裸流等,可以通過這個函數(shù)解析前幾幀得到視頻的信息

創(chuàng)建各個軌道的解碼器(分流)

  1. avcodec_find_decoder: 查找對應(yīng)的解碼器
  2. avcodec_alloc_context3: 創(chuàng)建解碼器上下文
  3. avcodec_parameters_to_context: 設(shè)置解碼所需要的參數(shù)
  4. avcodec_open2: 打開解碼器

使用對應(yīng)的解碼器解碼各個軌道(解碼)

  1. av_read_frame: 從視頻流讀取視頻數(shù)據(jù)包
  2. avcodec_send_packet: 發(fā)送視頻數(shù)據(jù)包給解碼器解碼
  3. avcodec_receive_frame: 從解碼器讀取解碼后的幀數(shù)據(jù)

為了幾種精力在音視頻部分,我拆分出了專門進行解碼的VideoDecoder類和專門進行畫面顯示的SdlWindow類,大家主要關(guān)注VideoDecoder部分即可。

視頻流解析

由于實際解碼前的解析文件流和創(chuàng)建解碼器代碼比較固定化,我直接將代碼貼出來,大家可能跟著注釋看下每個步驟的含義:

bool VideoDecoder::Load(const string& url) {
    mUrl = url;

    // 打開文件流讀取文件頭解析出視頻信息如軌道信息、時長等
    // mFormatContext初始化為NULL,如果打開成功,它會被設(shè)置成非NULL的值,在不需要的時候可以通過avcodec_free_context釋放。
    // 這個方法實際可以打開多種來源的數(shù)據(jù),url可以是本地路徑、rtmp地址等
    // 在不需要的時候通過avformat_close_input關(guān)閉文件流
    if(avformat_open_input(&mFormatContext, url.c_str(), NULL, NULL) < 0) {
        cout << "open " << url << " failed" << endl;
        return false;
    }

    // 對于沒有文件頭的格式如MPEG或者H264裸流等,可以通過這個函數(shù)解析前幾幀得到視頻的信息
    if(avformat_find_stream_info(mFormatContext, NULL) < 0) {
        cout << "can't find stream info in " << url << endl;
        return false;
    }

    // 查找視頻軌道,實際上我們也可以通過遍歷AVFormatContext的streams得到,代碼如下:
    // for(int i = 0 ; i < mFormatContext->nb_streams ; i++) {
    //     if(mFormatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
    //         mVideoStreamIndex = i;
    //         break;
    //     }
    // }
    mVideoStreamIndex = av_find_best_stream(mFormatContext, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
    if(mVideoStreamIndex < 0) {
        cout << "can't find video stream in " << url << endl;
        return false;
    }

    // 獲取視頻軌道的解碼器相關(guān)參數(shù)
    AVCodecParameters* codecParam = mFormatContext->streams[mVideoStreamIndex]->codecpar;
    cout << "codec id = " << codecParam->codec_id << endl;
    
    // 通過codec_id獲取到對應(yīng)的解碼器
    // codec_id是enum AVCodecID類型,我們可以通過它知道視頻流的格式,如AV_CODEC_ID_H264(0x1B)、AV_CODEC_ID_H265(0xAD)等
    // 當然如果是音頻軌道的話它的值可能是AV_CODEC_ID_MP3(0x15001)、AV_CODEC_ID_AAC(0x15002)等
    AVCodec* codec = avcodec_find_decoder(codecParam->codec_id);
    if(codec == NULL) {
        cout << "can't find codec" << endl;
        return false;
    }

    // 創(chuàng)建解碼器上下文,解碼器的一些環(huán)境就保存在這里
    // 在不需要的時候可以通過avcodec_free_context釋放
    mCodecContext = avcodec_alloc_context3(codec);
    if (mCodecContext == NULL) {
        cout << "can't alloc codec context" << endl;
        return false;
    }


    // 設(shè)置解碼器參數(shù)
    if(avcodec_parameters_to_context(mCodecContext, codecParam) < 0) {
        cout << "can't set codec params" << endl;
        return false;
    }

    // 打開解碼器,從源碼里面看到在avcodec_free_context釋放解碼器上下文的時候會close,
    // 所以我們可以不用自己調(diào)用avcodec_close去關(guān)閉
    if(avcodec_open2(mCodecContext, codec, NULL) < 0) {
        cout << "can't open codec" << endl;
        return false;
    }

    // 創(chuàng)建創(chuàng)建AVPacket接收數(shù)據(jù)包
    // 無論是壓縮的音頻流還是壓縮的視頻流,都是由一個個數(shù)據(jù)包組成的
    // 解碼的過程實際就是從文件流中讀取一個個數(shù)據(jù)包傳給解碼器去解碼
    // 對于視頻,它通常應(yīng)包含一個壓縮幀
    // 對于音頻,它可能是一段壓縮音頻、包含多個壓縮幀
    // 在不需要的時候可以通過av_packet_free釋放
    mPacket = av_packet_alloc();
    if(NULL == mPacket) {
        cout << "can't alloc packet" << endl;
        return false;
    }

    // 創(chuàng)建AVFrame接收解碼器解碼出來的原始數(shù)據(jù)(視頻的畫面幀或者音頻的PCM裸流)
    // 在不需要的時候可以通過av_frame_free釋放
    mFrame = av_frame_alloc();
    if(NULL == mFrame) {
        cout << "can't alloc frame" << endl;
        return false;
    }

    // 可以從解碼器上下文獲取視頻的尺寸
    // 這個尺寸實際上是從AVCodecParameters里面復(fù)制過去的,所以直接用codecParam->width、codecParam->height也可以
    mVideoWidth = mCodecContext->width;
    mVideoHegiht =  mCodecContext->height;

    // 可以從解碼器上下文獲取視頻的像素格式
    // 這個像素格式實際上是從AVCodecParameters里面復(fù)制過去的,所以直接用codecParam->format也可以
    mPixelFormat = mCodecContext->pix_fmt;

    return true;
}

我們使用VideoDecoder::Load打開視頻流并準備好解碼器。之后就是解碼的過程,解碼完成之后再調(diào)用VideoDecoder::Release去釋放資源:

void VideoDecoder::Release() {
    mUrl = "";
    mVideoStreamIndex = -1;
    mVideoWidth = -1;
    mVideoHegiht = -1;
    mDecodecStart = -1;
    mLastDecodecTime = -1;
    mPixelFormat = AV_PIX_FMT_NONE;

    if(NULL != mFormatContext) {
        avformat_close_input(&mFormatContext);
    }

    if (NULL != mCodecContext) {
        avcodec_free_context(&mCodecContext);
    }
    
    if(NULL != mPacket) {
        av_packet_free(&mPacket);
    }

    if(NULL != mFrame) {
        av_frame_free(&mFrame);
    }
}

視頻解碼

解碼器創(chuàng)建完成之后就可以開始解碼了:

AVFrame* VideoDecoder::NextFrame() {
    if(av_read_frame(mFormatContext, mPacket) < 0) {
        return NULL;
    }

    AVFrame* frame = NULL;
    if(mPacket->stream_index == mVideoStreamIndex
        && avcodec_send_packet(mCodecContext, mPacket) == 0
        && avcodec_receive_frame(mCodecContext, mFrame) == 0) {
        frame = mFrame;

        ... //1.解碼速度問題
    }

    av_packet_unref(mPacket); // 2.內(nèi)存泄漏問題

    if(frame == NULL) {
        return NextFrame(); // 3.AVPacket幀類型問題
    }

    return frame;
}

它的核心邏輯其實就是下面這三步:

  1. 使用av_read_frame 從視頻流讀取視頻數(shù)據(jù)包
  2. 使用avcodec_send_packet 發(fā)送視頻數(shù)據(jù)包給解碼器解碼
  3. 使用avcodec_receive_frame 從解碼器讀取解碼后的幀數(shù)據(jù)

除了關(guān)鍵的三個步驟之外還有些細節(jié)需要注意:

1.解碼速度問題

由于解碼的速度比較快,我們可以等到需要播放的時候再去解碼下一幀。這樣可以降低cpu的占用,也能減少繪制線程堆積畫面隊列造成內(nèi)存占用過高.

由于這個demo沒有單獨的解碼線程,在渲染線程進行解碼,sdl渲染本身就耗時,所以就算不延遲也會發(fā)現(xiàn)畫面是正常速度播放的.可以將繪制的代碼注釋掉,然后在該方法內(nèi)加上打印,會發(fā)現(xiàn)一下子就解碼完整個視頻了。

2.內(nèi)存泄漏問題

解碼完成之后壓縮數(shù)據(jù)包的數(shù)據(jù)就不需要了,需要使用av_packet_unref將AVPacket釋放。

其實AVFrame在使用完成之后也需要使用av_frame_unref去釋放AVFrame的像畫面素數(shù)據(jù),但是在avcodec_receive_frame內(nèi)會調(diào)用av_frame_unref將上一幀的內(nèi)存清除,而最后一幀的數(shù)據(jù)也會在Release的時候被av_frame_free清除,所以我們不需要手動調(diào)用av_frame_unref.

3.AVPacket幀類型問題

由于視頻壓縮幀存在i幀、b幀、p幀這些類型,并不是每種幀都可以直接解碼出原始畫面,b幀是雙向差別幀,也就是說b幀記錄的是本幀與前后幀的差別,還需要后面的幀才能解碼.

如果這一幀AVPacket沒有解碼出數(shù)據(jù)來的話,就遞歸調(diào)用NextFrame解碼下一幀,直到解出下一幀原生畫面來

PTS同步

AVFrame有個pts的成員變量,代表了畫面在什么時候應(yīng)該顯示.由于視頻的解碼速度通常會很快,例如一個1分鐘的視頻可能一秒鐘就解碼完成了.所以我們需要計算出這一幀應(yīng)該在什么時候播放,如果時間還沒有到就添加延遲。

有些視頻流不帶pts數(shù)據(jù),按30fps將每幀間隔統(tǒng)一成32ms:

if(AV_NOPTS_VALUE == mFrame->pts) {
    int64_t sleep = 32000 - (av_gettime() - mLastDecodecTime);
    if(mLastDecodecTime != -1 && sleep > 0) {
        av_usleep(sleep);
    }
    mLastDecodecTime = av_gettime();
} else {
    ...
}

如果視頻流帶pts數(shù)據(jù),我們需要計算這個pts具體是視頻的第幾微秒.

pts的單位可以通過AVFormatContext找到對應(yīng)的AVStream,然后再獲取AVStream的time_base得到:

AVRational timebase = mFormatContext->streams[mPacket->stream_index]->time_base;

AVRational是個分數(shù),代表幾分之幾秒:

/**
 * Rational number (pair of numerator and denominator).
 */
typedef struct AVRational{
    int num; ///< Numerator
    int den; ///< Denominator
} AVRational;

我們用timebase.num * 1.0f / timebase.den計算出這個分數(shù)的值,然后乘以1000等到ms,再乘以1000得到us.后半部分的計算其實可以放到VideoDecoder::Load里面保存到成員變量,但是為了講解方便就放在這里了:

int64_t pts = mFrame->pts * 1000 * 1000 * timebase.num * 1.0f / timebase.den;

這個pts都是以視頻開頭開始計算的,所以我們需要先保存第一幀的時間戳,然后再去計算當前播到第幾微秒.完整代碼如下:

if(AV_NOPTS_VALUE == mFrame->pts) {
    ...
} else {
    AVRational timebase = mFormatContext->streams[mPacket->stream_index]->time_base;
    int64_t pts = mFrame->pts * 1000 * 1000 * timebase.num * 1.0f / timebase.den;

    // 如果是第一幀就記錄開始時間
    if(mFrame->pts == 0) {
        mDecodecStart = av_gettime() - pts;
    }

    // 當前時間減去開始時間,得到當前播放到了視頻的第幾微秒
    int64_t now = av_gettime() - mDecodecStart;

    // 如果這一幀的播放時間還沒有到就等到播放時間到了再返回
    if(pts > now) {
        av_usleep(pts - now);
    }
}

其他

完整的Demo已經(jīng)放到Github上,圖像渲染的部分在SdlWindow類中,它使用SDL2去做ui繪制,由于和音視頻編解碼沒有關(guān)系就不展開講了.視頻解碼部分在VideoDecoder類中.

編譯的時候需要修改Makefile里面ffmpeg和sdl2的路徑,然后make編譯完成之后用下面命令即可播放視頻:

demo -p 視頻路徑播放視頻

PS:

某些函數(shù)會有數(shù)字后綴,如avcodec_alloc_context3、avcodec_open2等,實際上這個數(shù)字后綴是這個函數(shù)的第幾個版本的意思,從源碼的doc/APIchanges可以看出來:

2011-07-10 - 3602ad7 / 0b950fe - lavc 53.8.0
  Add avcodec_open2(), deprecate avcodec_open().
  NOTE: this was backported to 0.7

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

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

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