一、  背景:

一步一步从资料收集、技术选型、代码编写、性能优化,动手搭建一款支持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播放器效果

一步一步搭建基于ffmpeg和sdl2的流媒体播放器的更多相关文章

  1. 最简单的基于FFMPEG+SDL的音频播放器 ver2 (采用SDL2.0)

    ===================================================== 最简单的基于FFmpeg的音频播放器系列文章列表: <最简单的基于FFMPEG+SDL ...

  2. 最简单的基于FFMPEG+SDL的音频播放器 ver2 (採用SDL2.0)

    ===================================================== 最简单的基于FFmpeg的音频播放器系列文章列表: <最简单的基于FFMPEG+SDL ...

  3. 基于FFmpeg和Qt的播放器 QtAV库

    http://blog.csdn.net/ibingow/article/details/8144795

  4. 基于FFMPEG和SDL实现视频播放器

    这个是雷大牛实现的project. http://download.csdn.net/detail/leixiaohua1020/5122959 有兴趣的能够好好研究研究.

  5. 最简单的基于FFmpeg的封装格式处理:视音频分离器简化版(demuxer-simple)

    ===================================================== 最简单的基于FFmpeg的封装格式处理系列文章列表: 最简单的基于FFmpeg的封装格式处理 ...

  6. 最简单的基于FFmpeg的移动端例子:IOS 视频转码器

    ===================================================== 最简单的基于FFmpeg的移动端例子系列文章列表: 最简单的基于FFmpeg的移动端例子:A ...

  7. 最简单的基于FFmpeg的封装格式处理:视音频分离器(demuxer)

    ===================================================== 最简单的基于FFmpeg的封装格式处理系列文章列表: 最简单的基于FFmpeg的封装格式处理 ...

  8. 最简单的基于FFmpeg的内存读写的例子:内存转码器

    ===================================================== 最简单的基于FFmpeg的内存读写的例子系列文章列表: 最简单的基于FFmpeg的内存读写的 ...

  9. 最简单的基于FFmpeg的内存读写的例子:内存播放器

    ===================================================== 最简单的基于FFmpeg的内存读写的例子系列文章列表: 最简单的基于FFmpeg的内存读写的 ...

随机推荐

  1. TensorFlow.NET机器学习入门【4】采用神经网络处理分类问题

    上一篇文章我们介绍了通过神经网络来处理一个非线性回归的问题,这次我们将采用神经网络来处理一个多元分类的问题. 这次我们解决这样一个问题:输入一个人的身高和体重的数据,程序判断出这个人的身材状况,一共三 ...

  2. 七、Uniapp+vue+腾讯IM+腾讯音视频开发仿微信的IM聊天APP,支持各类消息收发,音视频通话,附vue实现源码(已开源)-聊天消息项的实现

    会话好友列表的实现 1.项目引言 2.腾讯云后台配置TXIM 3.配置项目并实现IM登录 4.会话好友列表的实现 5.聊天输入框的实现 6.聊天界面容器的实现 7.聊天消息项的实现 8.聊天输入框扩展 ...

  3. Chapter 10 Random Variability

    目录 10.1 Identification versus estimation 10.2 Estimation of causal effects 10.3 The myth of the supe ...

  4. CH7511|LT7211|PS8625替代方案 CS5211 设计EDP转LVDS优势方案原理图+PCB板设计

    CH7511|LT7211|PS8625这三款都是专门用于设计EDP转LVDS转接板或者屏转换方案板,CH7511.LT7211.PS8625目前这几款都是出于缺货状态,台湾瑞奇达Capstone 新 ...

  5. 编写Java程序,使用循环结构打印出九九乘法表

    编写Java程序,使用循环结构打印出九九乘法表 效果如下: 实现代码: public class Multiplication99 { public static void main(String[] ...

  6. Hadoop Jobhistory配置启用

    Hadoop Jobhistory记录下已运行完的MapReduce作业信息并存放在指定的HDFS目录下, 默认情况下是没有启动的,需要配置完后手工启动服务. 1.修改hadoop的配置文件mapre ...

  7. 利用pyinstaller给工程打包生成python可执行文件

    step1.下载pyinstaller: step2.将pyinstaller的路径加入系统环境变量PATH中:(关键) step3.进入工程的根目录下(即可执行文件.py)所在目录下,cmd,输入命 ...

  8. 初识python: 属性方法 @property

    属性方法:通过@property把一个方法变成一个静态属性 实例: 调用航班的状态 # 查看航班状态 import random class CheckState(object): def __ini ...

  9. Selenium_单选框和复选框的选中状态判定以及元素是否可用和可见判定(10)

    简单写个单选框和复选框界面 <!DOCTYPE html> <html> <head> <meta charset="utf-8" /&g ...

  10. linux修改默认的SSH远程端口22

    1.编辑sshd_config文件 [root@localhost ~]#  vim /etc/ssh/sshd_config 搜索 #Port 22行,删除开头的 # 字符,然后将其替换为要使用的端 ...