基於FFmpeg的視訊播放器開發系列教程(二)
本節課程的目的:讀幀解碼顯示視訊
開始進入ffmepg的開發之旅。音視訊的細節知識不統一講解,我在教程中逐點滲透,容我以雷神的話開篇。
視訊播放器播放一個網際網路上的視訊檔案,需要經過以下幾個步驟:解協議,解封裝,解碼視音訊,視音訊同步。如果播放本地檔案則不需要解協議,為以下幾個步驟:解封裝,解碼視音訊,視音訊同步。
----雷霄驊
對於ffmpeg的架構介紹,請參考24歲“封神”雷霄驊的部落格,他已離開江湖,但江湖仍有他的傳說。
FFmpeg原始碼結構圖 - 編碼:https://blog.csdn.net/leixiaohua1020/article/details/44226355
FFmpeg原始碼結構圖 - 解碼:https://blog.csdn.net/leixiaohua1020/article/details/44220151
一.ffmpeg開發入門
下面是一個開啟視訊的小例子。
先用Win32控制檯程式來講解ffmpeg的簡單開發,建立Win32的控制檯專案,在專案屬性中加入ffmpeg的庫檔案。沒有ffmpeg3.2.4庫檔案的同學,請點選下載。
程式碼如下:
// FFmpeg_開啟視訊檔案.cpp : 定義控制檯應用程式的入口點。 // #include "stdafx.h" #include <iostream> extern "C" { #include <libavformat/avformat.h> } #pragma comment(lib, "avformat.lib") #pragma comment(lib, "avutil.lib") #pragma comment(lib, "avcodec.lib") using namespace std; int main() { av_register_all(); //ffmpeg程式的第一句,註冊庫 AVFormatContext *afc = NULL; //開啟視訊檔案 int nRet = avformat_open_input(&afc, "天下有情人.mp4", 0, 0); if (nRet < 0) { cout << "找不到視訊檔案" << endl; } else { cout << "視訊開啟成功" << endl; } int durTime = afc->duration / AV_TIME_BASE; //視訊時間 4分20秒 unsigned int numberOfStream = afc->nb_streams; //包含流的個數2:一個視訊流一個音訊流 for (int i = 0; i < afc->nb_streams; i++) { AVCodecContext *acc = afc->streams[i]->codec; if (acc->codec_type == AVMEDIA_TYPE_VIDEO) //如果是視訊型別 { AVCodec *codec = avcodec_find_decoder(acc->codec_id); if (!codec) { cout << "沒有該型別解碼器" << endl; } int ret = avcodec_open2(acc, codec, NULL); if (ret != 0) { char buf[1024] = { 0 }; av_strerror(ret, buf, sizeof(buf)); } cout << "解碼器開啟成功" << endl; } } if (afc) { avformat_close_input(&afc); //關閉視訊流 } system("pause"); return 0; }
可能會出現以下編譯錯誤:
errorC4996: 'AVStream::codec': 被宣告為已否決
解決方法如下
由於ffmpeg的原始碼是C語言寫的,在呼叫它的標頭檔案時,需要用extern"C", 例外匯入的lib可以直接放到屬性列表,也可以寫到程式碼裡。在寫ffmpeg程式時, 第一句是av_register_all()用來註冊ffmpeg庫。
我們是做播放器,需要開啟視訊檔案,avformat_open_input()是開啟一個輸入流並且讀它的頭部資訊,但編解碼器不會被開啟,如果開啟成功,會返回一個AVFormatContext的例項.該例項包含了很多的視訊資訊,例如一個視訊檔案,會有視訊流,音訊流,字幕流,視訊的時間,解碼器型別等等資訊。視訊開啟後,需要進行解碼,而解碼需要解碼器,先找解碼器avcodec_find_decoder, 找到解碼器後再開啟解碼器,然後進行解碼,視訊畫素轉換解析,音訊解析,再用執行緒同步技術實現音視訊同步,將視訊內容顯示在螢幕上。
做視訊開發,對於資源的利用要格外重視,開啟的資源用完後要及時釋放,避免造成過大的記憶體開銷,造成程式的崩潰。
二. 視訊播放器FFVideoPlayer的開發
創立Qt GUI專案,工程名稱:FFVideoPlayer. 目前的介面如下圖,後續根據需求會逐漸優化更新。
中間黑色部分是QOpenGLWidget控制元件,用來顯示視訊。
編寫各功能模組的程式碼。
(1)【開啟視訊】:選擇視訊檔案,開啟並顯示在OpenGLWidget控制元件上。實現【開啟視訊】的槽函式,程式碼如下:
void FFVideoPlyer::slotOpenFile()
{
QString fname = QFileDialog::getOpenFileName(this, QString::fromLocal8Bit("開啟視訊檔案"));
if (fname.isEmpty())
{
return;
}
ui.lineEdit_VideoName->setText(fname);
MyFFmpeg::GetObj()->OpenVideo(fname.toLocal8Bit());
MyFFmpeg::GetObj()->m_isPlay = true;
ui.btn_Play->setText(QString::fromLocal8Bit("暫停"));
}
對於的視訊的開啟,讀幀,解碼,畫素轉換,音訊解碼等等,這些方法,我封裝程類MyFFmpeg. 在專案中新增C++類,類名MyFFmpeg即可。同時為了保證物件的維一性,我們使用單例模式來實現。
本教程的開發流程如下:
開啟視訊檔案,查詢解碼器,開啟解碼器的程式碼如下。為了循序漸進,先實現視訊讀幀解碼,下篇部落格進行音訊解碼。
void MyFFmpeg::OpenVideo(const char *path)
{
mtx.lock();
int nRet = avformat_open_input(&m_afc, path, 0, 0);
for (int i = 0; i < m_afc->nb_streams; i++) //nb_streams開啟的視訊檔案中流的數量,一般nb_streams = 2,音訊流和視訊流
{
AVCodecContext *acc = m_afc->streams[i]->codec; //分別獲取音訊流和視訊流的解碼器
if (acc->codec_type == AVMEDIA_TYPE_VIDEO) //如果是視訊
{
m_videoStream = i;
AVCodec *codec = avcodec_find_decoder(acc->codec_id); // 查詢解碼器
//"沒有該型別的解碼器"
if (!codec)
{
mtx.unlock();
return;
}
int err = avcodec_open2(acc, codec, NULL); //開啟解碼器
if (err != 0)
{
//解碼器開啟失敗
}
}
}
mtx.unlock();
}
(2)讀幀解碼
視訊開啟後,需要進行讀幀,解碼,顯示,此過程比較耗時,如果放到主執行緒中,一旦主執行緒阻塞,就會容易“介面卡死”,所以放到子線執行緒來實現。新增Qt執行緒類PlayThread, 繼承於QThread,重寫執行緒的run函式。
程式碼如下:
void PlayThread::run()
{
//在子執行緒裡做什麼,當然是讀視訊幀,解碼視訊了
//何時讀,何時解碼呢,在視訊開啟之後讀幀解碼, 讀幀解碼執行緒要一直執行
//視訊沒開啟之前執行緒要阻塞, run,while(1)這是基本套路
while (1)
{
if (!(MyFFmpeg::GetObj()->m_isPlay))
{
msleep(5); //除錯方便,5微秒後窗口又關閉了,執行緒繼續阻塞,此時可以點選【開啟視訊按鈕】選擇視訊
continue;
}
while (g_videos.size() > 0)
{
AVPacket pack = g_videos.front();
MyFFmpeg::GetObj()->DecodeFrame(&pack);
av_packet_unref(&pack);
g_videos.pop_front(); //解碼完成的幀從list前面彈出
}
AVPacket pkt = MyFFmpeg::GetObj()->ReadFrame();
if (pkt.size <= 0)
{
msleep(10);
}
g_videos.push_back(pkt);
}
}
有些變數的定義,這裡不做指出,需要原始碼的請點選下載。
讀幀的實現如下:
AVPacket MyFFmpeg::ReadFrame()
{
AVPacket pkt;
memset(&pkt, 0, sizeof(AVPacket));
mtx.lock();
if (!m_afc)
{
mtx.unlock();
return pkt;
}
int err = av_read_frame(m_afc, &pkt);
if (err != 0)
{
//失敗
}
mtx.unlock();
return pkt;
}
解碼的實現:
void MyFFmpeg::DecodeFrame(const AVPacket *pkt)
{
mtx.lock();
if (!m_afc)
{
mtx.unlock();
return;
}
if (m_yuv == NULL)
{
m_yuv = av_frame_alloc();
}
AVFrame *frame = m_yuv; //指標傳值
int re = avcodec_send_packet(m_afc->streams[pkt->stream_index]->codec, pkt);
if (re != 0)
{
mtx.unlock();
return;
}
re = avcodec_receive_frame(m_afc->streams[pkt->stream_index]->codec, frame);
if (re != 0)
{
//失敗
mtx.unlock();
return;
}
mtx.unlock();
}
下面對畫素做轉換,為顯示準備。
bool MyFFmpeg::YuvToRGB(char *out, int outweight, int outheight)
{
mtx.lock();
if (!m_afc || !m_yuv) //畫素轉換的前提是視訊已經開啟
{
mtx.unlock();
return false;
}
AVCodecContext *videoCtx = m_afc->streams[this->m_videoStream]->codec;
m_cCtx = sws_getCachedContext(m_cCtx, videoCtx->width, videoCtx->height,
videoCtx->pix_fmt, //畫素點的格式
outweight, outheight, //目標寬度與高度
AV_PIX_FMT_BGRA, //輸出的格式
SWS_BICUBIC, //演算法標記
NULL, NULL, NULL
);
if (m_cCtx)
{
//sws_getCachedContext 成功"
}
else
{
//"sws_getCachedContext 失敗"
}
uint8_t *data[AV_NUM_DATA_POINTERS] = { 0 };
data[0] = (uint8_t *)out; //指標傳值,形參的值會被改變,out的值一直在變,所以QImage每次的畫面都不一樣,畫面就這樣顯示出來了,這應該是整個開發過程最難的點
int linesize[AV_NUM_DATA_POINTERS] = { 0 };
linesize[0] = outweight * 4; //每一行轉碼的寬度
//返回轉碼後的高度
int h = sws_scale(m_cCtx, m_yuv->data, m_yuv->linesize, 0, videoCtx->height,
data,
linesize
);
mtx.unlock();
}
轉碼處理後的視訊是YUV, RGB和色度的四通道, 我們需要把它轉化成RGB進行顯示。
(3)視訊顯示
視訊的顯示用OpenGLWidget顯示,把每一幀當做圖片來處理,即可顯示。關於OpenGLWidget如何顯示圖片,請檢視我給出的方法。下列程式碼是進行顯示,解碼後的視訊是四通道,所以在給QImage分配空間時用 width() * height() * 4
void VideoViewWidget::paintEvent(QPaintEvent *e)
{
static QImage *image;
if (image == NULL)
{
//視訊是YVU四通道的型別。
uchar *buf = new uchar[width() * height() * 4];
image = new QImage(buf, width(), height(), QImage::Format_ARGB32);
}
bool ret = MyFFmpeg::GetObj()->YuvToRGB((char *)(image->bits()), width(), height());
QPainter painter;
painter.begin(this);
painter.drawImage(QPoint(0, 0), *image);
painter.end();
}
當然,在開啟介面時,就讓子執行緒執行,但由於視訊沒有開啟,就會一直出阻塞狀態,當新增視訊檔案後,子執行緒繼續執行。畫面也就顯示了。
效果如下:
只有畫面沒有音訊,而且畫面重新整理很快,這是由於只解碼了視訊,沒有關音訊。下篇進行解碼音訊。
本篇的原始碼,請點選【原始碼下載】。很多ffmpeg的API不懂的,請自行百度深入研究。