系列文章:
音視頻最好從能夠直接看到東西,也更加貼近用戶的播放開始學(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的音頻裸流案训。
實(shí)際上使用ffmpeg去播放視頻也就是根據(jù)文件的格式一步步還原出圖像數(shù)據(jù)交給顯示設(shè)備顯示、還原出音頻數(shù)據(jù)交給音頻設(shè)備播放:
ffmpeg簡單入門
了解了視頻的播放流程之后我們來做一個(gè)簡單的播放器實(shí)際入門一下ffmpeg粪糙。由于這篇博客是入門教程,這個(gè)播放器功能會進(jìn)行簡化:
- 使用ffmpeg 4.4.2版本 - 4.x的版本被使用的比較廣泛,而且最新的5.x版本資料比較少
- 只解碼一個(gè)視頻軌道的畫面進(jìn)行播放 - 不需要考慮音視頻同步的問題
- 使用SDL2在主線程解碼 - 不需要考慮多線程同步問題
- 使用源碼+Makefile構(gòu)建 - 在MAC和Ubuntu上驗(yàn)證過,Windows的同學(xué)需要自己創(chuàng)建下vs的工程了
使用ffmpeg去解碼大概有下面的幾個(gè)步驟和關(guān)鍵函數(shù),大家可以和上面的流程圖對應(yīng)一下:
解析文件流(解協(xié)議和解封裝)
- avformat_open_input : 可以打開File强霎、RTMP等協(xié)議的數(shù)據(jù)流,并且讀取文件頭解析出視頻信息,如解析出各個(gè)軌道和時(shí)長等
- avformat_find_stream_info : 對于沒有文件頭的格式如MPEG或者H264裸流等,可以通過這個(gè)函數(shù)解析前幾幀得到視頻的信息
創(chuàng)建各個(gè)軌道的解碼器(分流)
- avcodec_find_decoder: 查找對應(yīng)的解碼器
- avcodec_alloc_context3: 創(chuàng)建解碼器上下文
- avcodec_parameters_to_context: 設(shè)置解碼所需要的參數(shù)
- avcodec_open2: 打開解碼器
使用對應(yīng)的解碼器解碼各個(gè)軌道(解碼)
- av_read_frame: 從視頻流讀取視頻數(shù)據(jù)包
- avcodec_send_packet: 發(fā)送視頻數(shù)據(jù)包給解碼器解碼
- 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í)就是下面這三步:
- 使用av_read_frame 從視頻流讀取視頻數(shù)據(jù)包
- 使用avcodec_send_packet 發(fā)送視頻數(shù)據(jù)包給解碼器解碼
- 使用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().