一、 背景:
一步一步從資料收集、技術選型、代碼編寫、性能優化,動手搭建一款支持rtsp、rtmp等常用流媒體格式的視頻播放器,ffmpeg用於流媒體解碼,sdl2用於視頻畫面渲染和聲音播放。
二、 實現思路:
技術選型:qt+ffmpeg+sdl2,qt基於c++運行效率高,跨平台兼容windows和linux;ffmpeg支持多種視頻格式和流協議軟解和硬解(目前主流的協議是rtmp和rtsp,視頻編碼主要是h264和h265);sdl2兼容性強,適應多個平台和硬件設備,同時支持簡單的配置實現視頻軟渲染或顯卡渲染。
實現流程:
三、 FFMPEG流解析
FFMPEG的工作是流獲取到流解析,其中涉及到幾個重要的結構體做個簡單的說明。
AVFormatContext:使用到的第一個結構體,通過avformat_alloc_context 、avformat_open_input 、avformat_find_stream_info 3個步驟完善這個結構體。
AVCodecParameters:音視頻的流參數,這個參數可以從流信息直接獲取。
AVCodec:音視頻解碼器,控制着解碼類型和軟/硬解碼方式。
AVCodecContext:解碼器重要結構體,解碼幀需要用到。
1. 打開流和獲取流信息
AVFormatContext avFormatCtx = avformat_alloc_context(); AVDictionary *options = NULL; if (avformat_open_input(&avFormatCtx, filepath, NULL, &options) != 0){ printf(打開流失敗\n"); return ; } //獲取音視頻流數據信息 if (avformat_find_stream_info(avFormatCtx, NULL) < 0){ errorCode+=1; renderFrame(NULL,errorCode); printf("無法獲取流信息\n"); return ; }
打開流和獲取流信息是關鍵的部分,兩步驟中任何一個步驟的返回值<0就無法進行后續的解碼。
這里有一個AVDictionary *options,這個參數的設置可以參考ffmpeg命令,可以使用參數的方式配置ffmpeg解碼。以下的配置,可以減少流讀取等待時間。
//設置鏈接超時時間3S av_dict_set(&options, "stimeout", std::to_string( 3* 1000).c_str(), 0); //設置rtsp拉流的方式tcp,默認udp。 av_dict_set(&options, "rtsp_transport", "tcp", 0); //不設置緩沖 av_dict_set(&options, "buffer_size", "0", 0);
2. 視頻流信息獲取/配置
//01 獲取視頻流序號 int videoIndex=av_find_best_stream(avFormatCtx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if(videoIndex<0) return; //02 獲取視頻編解碼信息 AVCodecParameters avCodecParameters=avFormatCtx->streams[videoIndex]->codecpar; //03 獲取解碼器 AVCodec *videoCodec = avcodec_find_decoder_by_name("h264"); //AVCodec *videoCodec = avcodec_find_decoder_by_name("h264_cuvid");//nvida顯卡硬解 //AVCodec *videoCodec = avcodec_find_decoder_by_name("h264_qsv");//intel顯卡硬解 if (!videoCodec) { printf("不支持硬解碼\n"); videoCodec= avcodec_find_decoder(avCodecParameters->codec_id); }else{ //調用硬解碼需要設置pix_fmt格式,軟解碼不需要 if(nullptr!=videoCodec->pix_fmts){ avCodecParameters->format=videoCodec->pix_fmts[0]; } } //04 初始化視頻解碼器結構 AVCodecContext videoCodecCtx= avcodec_alloc_context3(videoCodec); if(videoCodecCtx==NULL){ printf("無法分配解碼結構內容\n"); return; } avcodec_parameters_to_context(videoCodecCtx,avCodecParameters); if(avcodec_open2(_videoCodecCtx,videoCodec,NULL)<0) { //初始化解碼器失敗 return; }
3.音頻流信息獲取/配置
//01 獲取音頻流序號 int audioIndex=av_find_best_stream(avFormatCtx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); if(audioIndex<0) return; //02 獲取音頻編解碼信息 AVCodecParameters avCodecParameters=avFormatCtx->streams[audioIndex]->codecpar; //03 獲取解碼器 AVCodec *audioCodec= avcodec_find_decoder(_avCodecParameters->codec_id); //04 設置解碼器結構 AVCodecContext *audioCodecCtx= avcodec_alloc_context3(audioCodec); if(audioCodecCtx==NULL){ printf("無法分配解碼器結構\n"); return; } avcodec_parameters_to_context(audioCodecCtx, avCodecParameters); if(avcodec_open2(audioCodecCtx,audioCodec,NULL)<0){ printf("無法找到音頻解碼器\n"); //avformat_free_context(avFormatCtx); }else{ //05 配置PCM音頻重采樣 int inChannels= audioCodecCtx ->channels; int outChannels =AV_CH_LAYOUT_MONO; AVSampleFormat inFormat=audioCodecCtx ->sample_fmt; AVSampleFormat outFormat=AV_SAMPLE_FMT_S16; int inSampleRate=audioCodecCtx ->sample_rate; int outSampleRate=audioCodecCtx ->sample_rate; int inChannelLayout=av_get_channel_layout_nb_channels(inChannels); int outChannelLayout=av_get_channel_layout_nb_channels(outChannels); //重采樣配置,說明參考https://blog.csdn.net/u011003120/article/details/81542347 SwrContext *swrctx=swr_alloc(); swrctx=swr_alloc_set_opts(swrctx, outChannels, outFormat, outSampleRate, inChannels, inFormat, inSampleRate, 0, NULL); swr_init(swrctx); }
4. 幀數據接收
AVPacket *packet=av_packet_alloc(); while (true){ //讀取一幀未解碼的數據 if(av_read_frame(avFormatCtx, packet) >= 0){ if (packet->stream_index == videoIndex){ //視頻數據 }else if (packet->stream_index == audioIndex){ //音頻數據 } av_packet_unref(packet); } }
接收幀數據比較簡單,每一幀是一個AVPacket,再根據stream_index 判斷是視頻幀還是音頻幀分別對應解碼即可。需要注意的是av_packet_unref和av_packet_free兩個釋放AVPacket的方法。
av_packet_unref 只是釋放內容,結構還在,適合AVPacket 作為局部變量需要重復使用這個變量。
av_packet_free 釋放內容和結構,調用后AVPacket為空,內存被清空無法重復使用。
5. 視頻幀解碼
ffmpeg推薦的幀解碼使用了avcodec_send_packet和avcodec_receive_frame兩個方法,相比於之前的avcodec_decode_video2來說感覺穩定性更好一點,異常的幾率降低了,畢竟兩個方法可以通過返回值來確定下一個方法是否需要執行。
AVFrame *videoFrame=av_frame_alloc(); if(packet->size>0) int ret= avcodec_send_packet(videoCodecCtx, packet); while (ret>=0) { ret = avcodec_receive_frame(videoCodecCtx, videoFrame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF){ //printf("視頻解碼錯誤.\n"); }else if (ret < 0) { printf("視頻解碼錯誤.\n"); }else{ //videoFrame->extended_data //需要顯示的圖像數據,h264編碼下為YUV圖像 //videoFrame->linesize //圖像數據參數,代表了YUV的數據列的信息。For video, size in bytes of each picture line. } }
這里用到了AVFrame,可以理解為幀數據,之前的AVPacket可以理解為數據包,幀解碼本質上是數據包轉換為數據幀的過程。
解碼出來的視頻數據為YUV圖像,得到了videoFrame->extended_data和videoFrame->linesize參數后,即可對YUV圖像進行顯示。
6. 音頻幀解碼
同樣使用avcodec_send_packet和avcodec_receive_frame兩個方法進行音頻幀解碼,相比於之前的avcodec_decode_audio4更能規避異常。
int ret =0; if(packet->size>0) ret = avcodec_send_packet(_audioCodecCtx, packet); while (ret>=0) { ret = avcodec_receive_frame(_audioCodecCtx, audioFrame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF){ //printf("解碼聲音異常.\n"); }else if (ret < 0) { printf("解碼聲音異常\n"); }else{ //進行音頻重采樣 int len = swr_convert(_swrctx, &_outAudioBuffer, 19200 , (const uint8_t **)audioFrame->data, audioFrame->nb_samples); if (len>0){ int size=len*1*2; //播放音頻 } }
四、 SDL2視頻渲染和音頻播放
1. SDL2簡介
SDL2就我個人使用體驗來講是一款優秀簡單的視頻渲染組件,性能上比QPixmap高出了太多。對於音頻播放,SDL2相對QIODevice使用要復雜點,但是好處在於兼容性好,無論在linux還是在windows都能有一樣的編碼和使用體驗。
2. 視頻渲染
視頻渲染就是將ffmpeg解析出來的YUV數據一張一張按照順序顯示出來。SDL2渲染的流程為 初始化、綁定顯示控件、設置渲染參數和渲染圖形 4個步驟。
//01 初始化 if(SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO|SDL_INIT_TIMER)){ printf( "SDL2初始化失敗 - %s\n", SDL_GetError()); }else{ //02 綁定控件 SDL_Window sdlWindow=SDL_CreateWindowFrom((const void *)widget->winId()); //03 設置軟/硬渲染方式 SDL_RENDERER_SOFTWARE:CPU渲染 SDL_RENDERER_ACCELERATED:GPU渲染 SDL_Renderer sdlRender=SDL_CreateRenderer(sdlWindow,-1,SDL_RENDERER_SOFTWARE); //04 設置渲染參數 SDL_Texture sdlTexture=SDL_CreateTexture(sdlRender,SDL_PIXELFORMAT_IYUV,SDL_TEXTUREACCESS_STREAMING,w,h); SDL_Rect sdlRect; sdlRect.x=0; sdlRect.y=0; sdlRect.w=w; sdlRect.h=h; }
上述代碼中的w,h指SDL2渲染的范圍,SDL2的新特征之一就是在一個控件上渲染不同的區域,比如要做個多屏顯示只需要初始化一個SDL2就可以,渲染的位置和范圍由sdlRect控制。
接下來就是接收視頻YUV數據渲染顯示。
int result=SDL_UpdateYUVTexture(sdlTexture,&sdlRect,data[0], linesize[0], data [1], linesize [1],data [2], linesize [2]); result= SDL_RenderCopy(sdlRender,sdlTexture,nullptr,&sdlRect); if(result>=0) SDL_RenderPresent(sdlRender);
3. 音頻播放
使用SDL2進行音頻播放是將ffmpeg解析出來的PCM音頻數據播放出來的過程,涉及到SDL2音頻參數設置、SDL2回調設置、SDL2填充聲音3個步驟。
//01 SDL2音頻參數設置 SDL_AudioSpec sdlAudioSpec; SDL_memset(&sdlAudioSpec, 0, sizeof(sdlAudioSpec)); sdlAudioSpec.freq=sampleRate;//采樣率 sdlAudioSpec.format=AUDIO_S16SYS;//聲音格式 sdlAudioSpec.channels=channels;//聲道數 sdlAudioSpec.silence=0; //sdlAudioSpec.samples=1024;// sdlAudioSpec.userdata = static_cast<void*>(this); sdlAudioSpec.callback=sdlAudioCallback; if(SDL_OpenAudio(&sdlAudioSpec,NULL)<0){ printf("SDL 音頻播放開啟失敗"); }else{ //Play } //02 SDL2回調設置 Uint32 audioLen; Uint8 *audioChunk; Uint8 * audioPos; void sdlAudioCallback (void *uData,Uint8 *stream,int length){ SDL_memset(stream,0, static_cast<size_t>(length)); if(audioLen<=0) return; length=(length>audioLen?audioLen:length); SDL_MixAudio(stream,audioPos,length,SDL_MIX_MAXVOLUME); audioPos+=static_cast<unsigned int>(length); audioLen-=static_cast<unsigned int>(length); } //03 填充聲音 void playPCM(const char *data, int length){ audioChunk=(Uint8*)data; audioLen=length; audioPos=audioChunk; //循環等待前面聲音播放完成 while(audioLen>0){ SDL_Delay(1); } }
五、 QT界面搭建與兼容調優
1. QT界面搭建,SDL2渲染遮擋按鈕問題
簡單的播放器界面需要的組件很少,一個QWidget作為SDL2顯示圖像控件,一個QPushButton關閉按鈕。然后使用中發現SDL2渲染圖像時遮擋了關閉按鈕。
原因可能是SDL2渲染圖像不是直接在QWidget上渲染,而是內部建一個蒙版在QWidget上,因此渲染時候會遮擋掉QWidget上的QPushButton關閉按鈕。
解決方法:
單獨建一個窗體,窗體內放一個QPushButton關閉按鈕,將窗體設置為無邊框和背景透明,用窗體作為按鈕放到播放器界面,設置關閉按鈕窗體的父對象為setParent播放器窗體,點擊窗體代替點擊按鈕。
CloseFrm closeFrm=new CloseFrm(this); closeFrm->setParent(this); closeFrm->show();
可以在resizeEvent中實時更新closeFrm的位置,例如一直保持在右上角。
void resizeEvent(QResizeEvent *event) { QSize size=this->size(); int width=size.width(); int height=size.height(); //修改圖像顯示區域大小 ui->widget->resize(size); ui->widget->lower(); ui->widget->update(); int w=50; int x=width-w; int y=0; //修改刪除按鈕位置 if(nullptr!=closeFrm) { closeFrm->move(x,y); closeFrm->raise(); closeFrm->update(); closeFrm->activateWindow(); closeFrm->isTopLevel(); } //linux下加這句sdlwindow窗體尺寸才會變化 SDL_SetWindowSize(sdlWindow,width,height); //UI界面刷新 QCoreApplication::processEvents(); }
2. SDL2聲音語速失真、延遲問題
SDL2播放聲音類型是AAC和MP3類型的時候,偶爾會出現聲音失真不正常的情況。這個是SDL2比較坑的一個地方。
原因是針對AAC和MP3,MP3,接收到的幀數據和流數據是不同的samples,需要重新初始化SDL音頻。
if(sdlAudioSpec.samples!=audioFrame->nb_samples){ SDL_CloseAudio(); sdlAudioSpec.samples= audioFrame->nb_samples; SDL_OpenAudio(&sdlAudioSpec,NULL); SDL_PauseAudio(0); }
至於音頻延遲的問題,我在windows上遇到過,linux上略好一些暫時沒有徹底解決,不過在windows上可以考慮用QIODevice代替SDL2播放音頻,音頻播放不再延遲,可以參考以下代碼。
QAudioFormat audioFormat; QAudioOutput *audioOutput; QIODevice *outDevice; //設置采樣率 audioFormat.setSampleRate(sampleRate); //設置采樣大小,8/16位 audioFormat.setSampleSize(sampleSize); //設置通道數 audioFormat.setChannelCount(channels); //設置編碼方式 audioFormat.setCodec("audio/pcm"); //設置字節序 audioFormat.setByteOrder(QAudioFormat::LittleEndian); //設置樣本數據類型 audioFormat.setSampleType(QAudioFormat::UnSignedInt); //獲取默認聲卡 QList<QAudioDeviceInfo> ls= QAudioDeviceInfo::availableDevices(QAudio::AudioOutput); QAudioDeviceInfo deviceInfo=QAudioDeviceInfo::defaultOutputDevice(); if(deviceInfo.isNull()){ error=QString("沒有找到可用聲卡").toUtf8().data(); printf(error); } qDebug() << "Device name: " << deviceInfo.deviceName(); if(!deviceInfo.isFormatSupported(audioFormat)) { error=QString("聲卡不支持當前配置").toUtf8().data(); printf(error); } if(result!=0){ audioOutput=new QAudioOutput(deviceInfo,audioFormat); //audioOutput->setBufferSize(1024*1000000); outDevice= audioOutput->start(); }else{ outDevice=NULL; } //播放音頻 void playPCM(const char *data, int length){ if(outDevice!=NULL) outDevice ->write(data,length); } }
3. SDL2軟渲染拖拽窗體畫面卡住問題
窗口模式下,SDL2渲染圖像過程中一旦修改了窗體尺寸,畫面就會卡住不再渲染,網上很多方法都是說屏蔽SDL_WINDOWEVENT的,發現並沒有用,最后解決方法在窗體尺寸改變后重新設置下SDLTexture。
void sdlResize(){ int w,h; SDL_GetWindowSize(sdlWindow, &w, &h); if(sdlRect.w!=w||sdlRect.h!=h){ SDL_DestroyTexture(sdlTexture); sdlTexture=SDL_CreateTexture(sdlRender,SDL_PIXELFORMAT_IYUV,SDL_TEXTUREACCESS_STREAMING,w,h); sdlRect.w=w; sdlRect.h=h; SDL_RenderSetViewport(sdlRender, &sdlRect); } }
4. 更優化的圖像縮放方案
ffmpeg提供了SwsContext方法對解析出來的圖像進行分辨率調整,這種方法調整后的圖像效果略差,尤其文字不太清晰。谷歌提供了libyuv庫,可以根據顯示控件范圍在顯示YUV圖像前修改YUV尺寸達到拖拽縮放的目的,效率較高,有4種效率和清晰度調整參數。
int result=0; int w=sdlRect.w; int h=sdlRect.h; uint8_t *outbuf[4]; outbuf[0] = (uint8_t*)malloc(w*h); outbuf[1] = (uint8_t*)malloc(w*h>>1); outbuf[2] = (uint8_t*)malloc(w*h>>1); outbuf[3] = NULL; int outlinesize[4] = {w,w/2, w/2, 0}; int videoWidth=linesize[0]; int videoHeight=linesize[3]; //轉換yuv分辨率為窗體長寬 result= libyuv::I420Scale( data[0],linesize[0],data[1],linesize[1],data[2],linesize[2],videoWidth,videoHeight, outbuf[0],outlinesize[0],outbuf[1],outlinesize[1],outbuf[2],outlinesize[2],w,h, libyuv::FilterMode::kFilterBox); if(result>=0){ result=SDL_UpdateYUVTexture(sdlTexture,&sdlRect,outbuf[0],outlinesize[0], outbuf[1],outlinesize[1],outbuf[2],outlinesize[2]); result= SDL_RenderCopy(sdlRender,sdlTexture,nullptr,&sdlRect); if(result>=0) SDL_RenderPresent(sdlRender); free(outbuf[0]); free(outbuf[1]); free(outbuf[2]); free(outbuf[3]); } }
六、 寫在最后
這么多年一直做C#、java和js的開發,有幸正好有個機會和時間去學習qt、C++,就拿這個基於ffmpeg的流媒體播放器來練習。本文從講述了自己從選型到編碼一步步探索的過程,從功能實現到穩定優化前后花費了1個月左右時間,過程中有幸得到公司陳xx高級工程師的指導,也參考了很多網上大神的博客,於是把這些記錄下來希望能對有這方面需求或者像我一樣也在探索學習的同行提供些許幫助。
windows播放器測試地址:https://download.csdn.net/download/jiangfei200809/79669341
windows播放器下載后 修改 test.bat 中 rtmp://media3.scctv.net/live/scctv_800 為 測試的rtmp或rtsp地址,保存后雙擊運行 test.bat即可。最后一位參數 0代表顯示關閉按鈕,1代表不顯示關閉按鈕。
windows播放器效果