1. 程式人生 > >基於FFmpeg的視訊播放器開發系列教程(二)

基於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不懂的,請自行百度深入研究。