FFmpeg入門 - 視頻播放

系列文章:

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

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

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

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

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

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

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

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

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

ffmpeg簡單入門

了解了視頻的播放流程之后我們來做一個(gè)簡單的播放器實(shí)際入門一下ffmpeg粪糙。由于這篇博客是入門教程,這個(gè)播放器功能會進(jìn)行簡化:

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

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

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

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

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

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

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

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

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

視頻流解析

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

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

    // 打開文件流讀取文件頭解析出視頻信息如軌道信息城舞、時(shí)長等
    // mFormatContext初始化為NULL,如果打開成功,它會被設(shè)置成非NULL的值,在不需要的時(shí)候可以通過avcodec_free_context釋放轩触。
    // 這個(gè)方法實(shí)際可以打開多種來源的數(shù)據(jù),url可以是本地路徑、rtmp地址等
    // 在不需要的時(shí)候通過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裸流等,可以通過這個(gè)函數(shù)解析前幾幀得到視頻的信息
    if(avformat_find_stream_info(mFormatContext, NULL) < 0) {
        cout << "can't find stream info in " << url << endl;
        return false;
    }

    // 查找視頻軌道,實(shí)際上我們也可以通過遍歷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)等
    // 當(dāng)然如果是音頻軌道的話它的值可能是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)境就保存在這里
    // 在不需要的時(shí)候可以通過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釋放解碼器上下文的時(shí)候會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ù)包
    // 無論是壓縮的音頻流還是壓縮的視頻流,都是由一個(gè)個(gè)數(shù)據(jù)包組成的
    // 解碼的過程實(shí)際就是從文件流中讀取一個(gè)個(gè)數(shù)據(jù)包傳給解碼器去解碼
    // 對于視頻,它通常應(yīng)包含一個(gè)壓縮幀
    // 對于音頻拉馋,它可能是一段壓縮音頻榨为、包含多個(gè)壓縮幀
    // 在不需要的時(shí)候可以通過av_packet_free釋放
    mPacket = av_packet_alloc();
    if(NULL == mPacket) {
        cout << "can't alloc packet" << endl;
        return false;
    }

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

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

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

    return true;
}

我們使用VideoDecoder::Load打開視頻流并準(zhǔn)備好解碼器煌茴。之后就是解碼的過程,解碼完成之后再調(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;
}

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

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

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

1.解碼速度問題

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

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

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

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

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

3.AVPacket幀類型問題

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

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

PTS同步

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

有些視頻流不帶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ù),我們需要計(jì)算這個(gè)pts具體是視頻的第幾微秒.

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

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

AVRational是個(gè)分?jǐn)?shù),代表幾分之幾秒:

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

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

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

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

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;

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

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

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

其他

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

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

demo -p 視頻路徑播放視頻

PS:

某些函數(shù)會有數(shù)字后綴,如avcodec_alloc_context3撒璧、avcodec_open2等透葛,實(shí)際上這個(gè)數(shù)字后綴是這個(gè)函數(shù)的第幾個(gè)版本的意思,從源碼的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)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市卿樱,隨后出現(xiàn)的幾起案子僚害,更是在濱河造成了極大的恐慌,老刑警劉巖繁调,帶你破解...
    沈念sama閱讀 219,188評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件萨蚕,死亡現(xiàn)場離奇詭異,居然都是意外死亡蹄胰,警方通過查閱死者的電腦和手機(jī)岳遥,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,464評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來裕寨,“玉大人浩蓉,你說我怎么就攤上這事”鐾啵” “怎么了捻艳?”我有些...
    開封第一講書人閱讀 165,562評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長庆猫。 經(jīng)常有香客問我认轨,道長,這世上最難降的妖魔是什么月培? 我笑而不...
    開封第一講書人閱讀 58,893評論 1 295
  • 正文 為了忘掉前任嘁字,我火速辦了婚禮昨稼,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘拳锚。我一直安慰自己假栓,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,917評論 6 392
  • 文/花漫 我一把揭開白布霍掺。 她就那樣靜靜地躺著匾荆,像睡著了一般。 火紅的嫁衣襯著肌膚如雪杆烁。 梳的紋絲不亂的頭發(fā)上牙丽,一...
    開封第一講書人閱讀 51,708評論 1 305
  • 那天,我揣著相機(jī)與錄音兔魂,去河邊找鬼烤芦。 笑死,一個(gè)胖子當(dāng)著我的面吹牛析校,可吹牛的內(nèi)容都是我干的构罗。 我是一名探鬼主播,決...
    沈念sama閱讀 40,430評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼智玻,長吁一口氣:“原來是場噩夢啊……” “哼遂唧!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起吊奢,我...
    開封第一講書人閱讀 39,342評論 0 276
  • 序言:老撾萬榮一對情侶失蹤盖彭,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后页滚,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體召边,經(jīng)...
    沈念sama閱讀 45,801評論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,976評論 3 337
  • 正文 我和宋清朗相戀三年裹驰,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了隧熙。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,115評論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡邦马,死狀恐怖贱鼻,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情滋将,我是刑警寧澤邻悬,帶...
    沈念sama閱讀 35,804評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站随闽,受9級特大地震影響父丰,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,458評論 3 331
  • 文/蒙蒙 一蛾扇、第九天 我趴在偏房一處隱蔽的房頂上張望攘烛。 院中可真熱鬧,春花似錦镀首、人聲如沸坟漱。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,008評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽芋齿。三九已至,卻和暖如春成翩,著一層夾襖步出監(jiān)牢的瞬間觅捆,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,135評論 1 272
  • 我被黑心中介騙來泰國打工麻敌, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留栅炒,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,365評論 3 373
  • 正文 我出身青樓术羔,卻偏偏與公主長得像赢赊,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子聂示,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,055評論 2 355

推薦閱讀更多精彩內(nèi)容