前言:對於從未接觸過音視頻編解碼的同學來說,使用FFmpeg的學習曲線恐怕略顯陡峭。本人由於工作需要,正好需要在項目中使用。因此特地將開發過程總結下來。只當提供給有興趣的同學參考和學習。
由於FFmpeg是使用C語言開發,所有和函數調用都是面向過程的。以我目前的學習經驗來說,通常我會把一個功能的代碼全部放在main函數中實現。經過測試和修改認為功能正常,再以C++面向對象的方式逐步將代碼分解和封裝。因此在對本套指南中我也會采用先代碼實現再功能封裝的步驟。
一、開發前的准備工作
開發工具為VS2013+Qt5,目錄結構:
- bin:工作和測試目錄
- doc:開發文檔目錄
- include:ffmpeg頭文件配置目錄
- lib:ffmpeg靜態庫配置目錄
- src:源碼目錄
屬性頁配置:
- 常規-輸出目錄:..\..\bin
- 調試-工作目錄:..\..\bin
- C/C++-常規-附加包含目錄:..\..\include
- 鏈接器-常規-附加庫目錄:..\..\lib
- 鏈接器-系統-子系統:控制台 (/SUBSYSTEM:CONSOLE)
二、編解碼基礎知識
(1)封裝格式
所謂封裝格式是指音視頻的組合格式,例如最常見的封裝格式有mp4、mp3、flv等。簡單來說,我們平時接觸到的帶有后綴的音視頻文件都是一種封裝格式。不同的封裝格式遵循不同的協議標准。有興趣的同學可以自行擴展,更深的東西我也不懂。
(2)編碼格式
以mp4為例,通常應該包含有視頻和音頻。視頻的編碼格式為YUV420P,音頻的編碼格式為PCM。再以YUV420編碼格式為例。我們知道通常圖像的顯示為RGB(紅綠藍三原色),在視頻壓縮的時候會首先將代表每一幀畫面的RGB壓縮為YUV,再按照關鍵幀(I幀),過渡幀(P幀或B幀)進行運算和編碼。解碼的過程正好相反,解碼器會讀到I幀,並根據I幀運算和解碼P幀以及B幀。並最終根據視頻文件預設的FPS還原每一幀畫面的RGB數據。最后推送給顯卡。所以通常我們說的編碼過程就包括:畫面采集、轉碼、編碼再封裝。
(3)視頻解碼和音頻解碼有什么區別
玩游戲的同學肯定對FPS不陌生,FPS太低畫面會感覺閃爍不夠連貫,FPS越高需要顯卡性能越好。一些高速攝像機的采集速度能夠達到11000幀/秒,那么在播放這類影片的時候我們是否也需要以11000幀/秒播放呢?當然不是,通常我們會按照25幀/秒或者60幀/秒設定圖像的FPS值。但是由於視頻存在關鍵幀和過渡幀的區別,關鍵幀保存了完整的畫面而過渡幀只是保存了與前一幀畫面的變化部分,需要通過關鍵幀計算獲得。因此我們需要對每一幀都進行解碼,即獲取畫面的YUV數據。同時只對我們真正需要顯示的畫面進行轉碼,即將YUV數據轉換成RGB數據,包括計算畫面的寬高等。
但是音頻則不然,音頻的播放必須和采集保持同步。提高或降低音頻的播放速度都會讓音質發生變化,這也是變聲器的原理。因此在實際開發中為了保證播放的音視頻同步,我們往往會按照音頻的播放速度來控制視頻的解碼轉碼速度。
三、代碼實現
(1)注冊FFmpeg組件:注冊和初始化FFmpeg封裝器和網絡設備
av_register_all();
avformat_network_init();
avdevice_register_all();
(2)打開文件和創建輸入設備
AVFormatContext *pFormatCtx = NULL; int errnum = avformat_open_input(&pFormatCtx, filename, NULL, NULL); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; }
AVFormatContext 表示一個封裝器,在讀取多媒體文件的時候,它負責保存與封裝和編解碼有關的上下文信息。avformat_open_input函數可以根據文件后綴名來創建封裝器。
(3)遍歷流並初始化解碼器
for (int i = 0; i < pFormatCtx->nb_streams; ++i) { AVCodecContext *pCodecCtx = pFormatCtx->streams[i]->codec; // 解碼器上下文 if (pCodecCtx->codec_type == AVMEDIA_TYPE_VIDEO) { // 視頻通道 int videoIndex = i; // 視頻的寬,高 int srcWidth = pCodecCtx->width; int srcHeight = pCodecCtx->height; // 創建視頻解碼器,打開解碼器 AVCodec *codec = avcodec_find_decoder(pCodecCtx->codec_id); if (!codec) { // 無法創建對應的解碼器 } errnum = avcodec_open2(pCodecCtx, codec, NULL); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } cout << "video decoder open success!" << endl; } if (pCodecCtx->codec_type == AVMEDIA_TYPE_AUDIO) { // 音頻通道 int audioIndex = i; // 創建音頻解碼器,打開解碼器 AVCodec *codec = avcodec_find_decoder(pCodecCtx->codec_id); if (!codec) { // 無法創建對應的解碼器 } errnum = avcodec_open2(pCodecCtx, codec, NULL); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } int sampleRate = pCodecCtx->sample_rate; // 音頻采樣率 int channels = pCodecCtx->channels; // 聲道數 AVSampleFormat fmt = pCodecCtx->sample_fmt; // 樣本格式 cout << "audio decoder open success!" << endl; } }
封裝器中保存了各種流媒體的通道,通常視頻通道為0,音頻通道為1。除此以外可能還包含字幕流通道等。
第2步和第3步基本就是打開多媒體文件的主要步驟,解碼和轉碼的所有參數都可以在這里獲取。接下來我們就需要循環進行讀取、解碼、轉碼直到播放完成。
(4)讀取壓縮數據:之所以稱為壓縮數據主要是為了區分AVPacket和AVFrame兩個結構體。AVPacket表示一幅經過了關鍵幀或過渡幀編碼后的畫面,AVFrame表示一個AVPacket經過解碼后的完整YUV畫面
AVPacket *pkt = NULL; pkt = av_packet_alloc(); // 初始化AVPacket // 讀取一幀數據 errnum = av_read_frame(pFormatCtx, pkt); if (errnum == AVERROR_EOF) { // 已經讀取到文件尾 av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; }
(5)解碼
errnum = avcodec_send_packet(pCodecCtx, pkt); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } AVFrame *yuv = av_frame_alloc(); AVFrame *pcm = av_frame_alloc(); if (pkt->stream_index == videoIndex) { // 判斷當前解碼幀為視頻幀 errnum = avcodec_receive_frame(pCodecCtx, yuv); // 解碼視頻 if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } } if (pkt->stream_index == audioIndex) { // 判斷當前解碼幀為音頻幀 errnum = avcodec_receive_frame(pCodecCtx, pcm); // 解碼音頻 if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } }
(6)視頻轉碼
// 720p輸出標准 int outWidth = 720; int outHeight = 480; char *outData = new char[outWidth * outHeight * 4] SwsContext *videoSwsCtx = NULL; videoSwsCtx = sws_getCachedContext(videoSwsCtx, srcWidth, srcHeight, (AVPixelFormat)pixFmt, // 輸入 outWidth, outHeight, AV_PIX_FMT_BGRA, // 輸出 SWS_BICUBIC, // 算法 0, 0, 0); // 分配數據空間 uint8_t *dstData[AV_NUM_DATA_POINTERS] = { 0 }; dstData[0] = (uint8_t *)outData; int dstStride[AV_NUM_DATA_POINTERS] = { 0 }; dstStride[0] = outWidth * 4; int h = sws_scale(videoSwsCtx, yuv->data, yuv->linesize, 0, srcHeight, dstData, dstStride); if (h != outHeight) { // 轉碼失敗 }
這里需要解釋一下outWidth * outHeight * 4計算理由:720p標准的視頻畫面包含720 * 480個像素點,每一個像素點包含了RGBA4類數據,每一類數據分別由1個byte即8個bit表示。因此一幅完整畫面所占的大小為outWidth * outHeight * 4。
(7)音頻轉碼
char *outData = new char[10000]; 輸出指針 AVCodecContext *pCodecCtx = pFormatCtx->streams[audioIndex]->codec; // 獲取音頻解碼器上下文 SwrContext *audioSwrCtx = NULL; audioSwrCtx = swr_alloc(); audioSwrCtx = swr_alloc_set_opts(audioSwrCtx, AV_CH_LAYOUT_STEREO, AV_SAMPLE_FMT_S16, 44100, // 輸出參數:雙通道立體聲 CD音質 pCodecCtx->channel_layout, pCodecCtx->sample_fmt, pCodecCtx->sample_rate, // 輸入參數 0, 0); swr_init(audioSwrCtx); uint8_t *out[AV_NUM_DATA_POINTERS] = { 0 }; out[0] = (uint8_t *)outData; // 計算輸出空間 int dst_nb_samples = av_rescale_rnd(pcm->nb_samples, pCodecCtx->sample_rate, pCodecCtx->sample_rate, AV_ROUND_UP); int len = swr_convert(audioSwrCtx, out, dst_nb_samples, (const uint8_t **)pcm->data, pcm->nb_samples); int channels = av_get_channel_layout_nb_channels(AV_CH_LAYOUT_STEREO); // AV_CH_LAYOUT_STEREO -> 2 根據聲道類型得到聲道數 // 實際音頻數據長度 int dst_bufsize = av_samples_get_buffer_size(NULL, channels, // 通道數 pcm->nb_samples,// 1024 AV_SAMPLE_FMT_S16, 0); if (dst_bufsize < 0) { // 音頻轉碼錯誤 }
至此我們已經基本完成了對一個多媒體文件的解碼工作,不過離真正的播放還有一些工作沒有完成。包括對代碼的封裝和界面設計我們都會放在下一篇博客中介紹。
完整的項目代碼:https://gitee.com/learnhow/ffmpeg_studio/tree/master/_64bit/src/av_player