一步一步搭建基於ffmpeg和sdl2的流媒體播放器

馨語隨風發表於2022-02-08

一、  背景:

一步一步從資料收集、技術選型、程式碼編寫、效能優化,動手搭建一款支援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高階工程師的指導,也參考了很多網上大神的部落格,於是把這些記錄下來希望能對有這方面需求或者像我一樣也在探索學習的同行提供些許幫助。

原始碼地址:https://gitee.com/JFly/jfplayer.git

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播放器效果

 

相關文章