與解碼相關的主要代碼在上一篇博客中已經做了介紹,本篇我們會先討論一下如何控制解碼速度再提供一個我個人的封裝思路。最后回歸到界面設計環節重點看一下如何保證播放器界面在縮放和拖動的過程中保證視頻畫面的寬高比例。
一、解碼速度
播放器播放媒體文件的時候播放進度需要我們自己控制。基本的控制方法有兩種:
- 根據FPS控制視頻的播放幀率,讓音頻跟隨。
- 控制音頻的播放解碼速度,讓視頻跟隨。
媒體文件在編碼的時候,正常情況下視頻數據和音頻輸出是交替寫入的。換句話說,解碼每一幀視頻數據伴隨需要播放的音頻數據也應該被解碼。所以,方案一的實現就比較簡單和直接。但是在有些情況下也可能會出現音視頻編碼不同步的問題,大部分情況是視頻提前於音頻。萬一遇到這樣的情況,如果需要讓我們的播放器帶有一定糾錯功能就必須采用第二種方案。方案二的設計思路是當遇到音頻數據時正常播放,遇到視頻數據時先緩沖起來,再根據pts參數同步。
方案一
QTime t; QIODevice ioDevice; t.restart(); AVPacket *pkt = readPacket(); if (pkt->stream_index == videoIndex) { // 當前為視頻幀,計算視頻播放每幀的間隔時間(1000/fps) - 解碼消耗的時間(毫秒) = 實際解碼間隔時間interval codecPacket(pkt); int el = t.elapsed(); int interval = 1000 / fps - el > 0 ? 1000 / fps - el : 1; QThread::msleep(interval); } else if (pkt->stream_index == audioIndex) { // 當前為音頻幀,直接讓Qt的音頻播放器播放 codecPacket(pkt); char data[10000] = { 0 }; int len = toPCM(data); ioDevice->write(data, len); }
方案二
AVPacket *pkt = readPacket(); if (pkt->stream_index == audioIndex) { codecPacket(pkt); char data[AUDIO_IODEVICE_WRITE_SIZE] = { 0 }; int len = toPCM(data); ioDevice->write(data, len); } else if (pkt->stream_index == videoIndex) { videoPacketList.push_back(pkt); } while (videoPacketList.size() > 0 && videoPts < audioPts) { AVPacket *pkt = videoPacketList.front(); videoPacketList.pop_front(); codecPacket(pkt); }
這個方案遇到的另外一個問題是我們如何獲取videoPts和audioPts這兩個值。我個人的解決思路是在解碼環節進行,即,每次對pkt進行一次解碼就根據pkt的stream_index值分別記錄解碼后的AVFrame的pts。不過音頻的pts和視頻的pts不能直接比較。我們還需要根據各自的AVRational做一次換算。算法如下:
AVRational r; frame->pts * (double)r.num / (double)r.den;
二、封裝思路討論
代碼封裝實際是一個見仁見智的工作,可能不同的人對代碼結構的理解不同,實現的封裝方式也會存在差異。包括我們的解決方案到底針對哪些需求也會按照不同的思路做封裝。在這里插一句題外話,大家認為程序開發到底是一種什么樣的工作性質?是僅僅為了實現客戶的需求嗎?如果你只能理解到這一層,那恐怕還遠遠不夠!客戶需求只能算是拋給你的一個問題,而你反饋給客戶的應該是一套合理的解決方案。從這個觀點出發我們進行再抽象,程序開發應該是一種從問題空間到解空間的映射。既然如此,我們就不能將自己的工作僅僅停留在功能實現這個層面,我們還應該提供更好的解決思路——最佳實踐。
基本上,如果我們只需要設計一個簡單的播放器。大概需要三個模塊的支持:
界面模塊(av_player):包括了界面的樣式和基礎互動功能
解碼模塊(Decoder):這個部分主要通過對FFmpeg的功能二次封裝,並對外提供接口支持
播放器模塊(PlayerWidget):負責界面和解碼模塊的連接,界面中嵌入播放器模塊,視頻顯示和音頻播放都由播放器模塊獨立負責。
下面看一下我設計的解碼模塊對外提供的接口:Decoder.h
class Decoder : protected QThread { public: Decoder(); virtual ~Decoder(); bool open(const char *filename); void close(); // 從文件中讀取一個壓縮報文 AVPacket* readPacket(); // 解碼報文並釋放空間,返回值為當前解碼報文的pts時間(毫秒) int codecPacket(AVPacket* pkt); // 將解碼幀Frame轉碼為RGB或PCM int toRGB(char *outData, int outWidth, int outHeight); int toPCM(char *outData); int durationMsec; // 文件時長 int fps; // 視頻FPS int srcWidth; // 視頻寬度 int srcHeight; // 視頻高度 int videoIndex; // 視頻通道 int audioIndex; // 音頻通道 int sampleRate; // 音頻采樣率 int channels; // 聲道 int sampleSize; // 樣本位數 bool endFlag; // 線程結束標志 bool pauseFlag; // 線程暫停標志 // 記錄當前的音視頻所處在的pts時間戳(毫秒) int videoPts; int audioPts; // 記錄音視頻的編解碼格式 int sampleFmt; int pixFmt; /************************************************************************/ /* default: CD音質(16bit 44100Hz stereo) */ /************************************************************************/ int dstSampleRate = 44100; // 采樣率 int dstSampleSize = 16; // 采樣大小 int dstChannels = 2; // 通道數 // 線程啟動的代理方法 void start(); // 音頻輸出 QAudioOutput *audioOutput = NULL; protected: void run(); private: QMutex mtx; AVFormatContext *pFormatCtx = NULL; SwsContext *videoSwsCtx = NULL; AVFrame *yuv = NULL; SwrContext *audioSwrCtx = NULL; AVFrame *pcm = NULL; QIODevice *ioDevice = NULL; std::list<AVPacket*> videoPacketList; AVInputTypeEnum avType = AVInputTypeEnum::NOTYPE; QString fileName; };
乍一看很復雜,我們稍微理一下思路。首先Decoder繼承了QThread,並重寫了start()方法。重寫的好處是,在對調用者完全透明的情況下,我們可以在這個函數中做一些初始化工作。在設計模式中,它數據代理模式。其他方法介紹:
- bool open(const char *filename):開發多媒體文件
- void close():關閉和析構所有編碼,這個步驟在音視頻編解碼的開發中非常重要
- AVPacket* readPacket():讀取一幀數據並返回
- int codecPacket(AVPacket* pkt):解碼之前讀取到的一幀數據,返回該幀數據表示的pts值並將傳入的pkt析構釋放內存空間
- int toRGB(char *outData, int outWidth, int outHeight):轉碼視頻幀,將yuv轉換為rgb
- int toPCM(char *outData):轉碼音頻幀
播放器模塊:PlayerWidget.h
class PlayerWidget : public QOpenGLWidget { public: PlayerWidget(Decoder *dec, QWidget *parent, int interval); virtual ~PlayerWidget(); /************************************************************************/ /* default: 720p 25fps */ /************************************************************************/ int videoWidth = 720; int videoHeight = 480; int m_interval = 40; /************************************************************************/ /* default: CD音質(16bit 44100Hz stereo) */ /************************************************************************/ int sampleRate = 44100; // 采樣率 int sampleSize = 16; // 采樣大小 int channels = 2; // 通道數 protected: void timerEvent(QTimerEvent *e); void paintEvent(QPaintEvent *e); private: Decoder *decoder = NULL; QAudioOutput *out; QIODevice *io; };
這個模塊繼承自QOpenGLWidget,並包含了QAudioOutput。這兩個Qt類分別代表了視頻播放和音頻播放。
界面模塊:在這個模塊中有一個重要的工作就是當我們在播放視頻的時候放大和縮小播放器窗口如何保證視頻畫面依然保持正確的寬高比,為此我寫了一個靜態函數:
struct AspectRatio { double width; double height; }; static AspectRatio* fitRatio(int outWidth, int outHeight, int inWidth, int inHeight) { double r1 = ((double)outWidth / (double)outHeight); double r2 = ((double)inWidth / (double)inHeight); AspectRatio *ar = new AspectRatio; if (r1 > r2) { int newWidth = (double)(outHeight * inWidth) / (double)inHeight; ar->width = newWidth; ar->height = outHeight; return ar; } else { int newHeight = (double)(inHeight * outWidth) / (double)inWidth; ar->width = outWidth; ar->height = newHeight; return ar; } }
最后附上我自己設計的播放器界面
項目源碼:https://gitee.com/learnhow/ffmpeg_studio/tree/master/_64bit/src/av_player