一、前言
学习ffmpeg和sdl,并编写一个视频播放器,是一个很好的音视频开发项目。
虽然关于视频播放器的原理已经有很多人在博客中进行了讲解,但是很多人不提供视频和代码,这也是我写这篇博客的主要原因。
二、在视频播放器中,主要涉及以下几个基本原理:
2.1 视频文件解封装
视频文件通常将音频和视频数据进行封装,因此在处理视频文件时,首先需要进行解封装操作,将视频流和音频流的压缩编码数据分离。常见的封装格式有MP4、MKV、FLV、AVI、RMVB、TS等。例如,解封装FLV格式的文件可能会得到H.264编码的视频流和AAC编码的音频流。
在FFMPEG中,解封装的过程如下所示:
这一步最重要的是得到解封装器的上下文结构体"AVFormatContext *m_pFormatCtx", 以及接下来我们要解码的音视频流索引。
2.2 音视频解码
原始数据通常经过压缩编码,解码过程则是将H.264、AAC等压缩后的数据解码为非压缩的音频/视频原始数据,其中视频一般为YUV或RGB数据,音频一般为PCM采样数据。
解码的步骤如下:
2.3 使用SDL2播放视频数据
我们知道视频是由连续的一帧帧图像快速播放形成的动态效果,一般设置为每秒播放25帧图像。
在播放视频时,我们使用SDL2库。每个图像在SDL2中被表示为一个纹理,而纹理与SDL2的渲染器相关联。
在视频解码后,我们可以从avcodec_receive_frame函数中获取到一个AVFrame对象,该对象包含了一帧视频数据。我们的目标是将这一帧的数据渲染到SDL的渲染器中。总体流程如下所示:
首先,我们需要使用sws_scale函数对获取到的AVFrame数据进行大小和格式的转换。接下来,我们需要更新SDL2中的纹理(Texture)和渲染器(Render)。以下是相关的关键代码示例:
AVFrame *frame = m_videoFrameQueue.front();
m_videoFrameQueue.pop();
AVFrame *frameYUV = av_frame_alloc();
int ret = av_image_alloc(frameYUV->data, frameYUV->linesize, m_sdlRect.w, m_sdlRect.h, AV_PIX_FMT_YUV420P, 1);
//Convert image
if (m_imgConvertCtx)
{
sws_scale(m_imgConvertCtx, frame->data, frame->linesize, 0, m_videoCodecParams.height, frameYUV->data, frameYUV->linesize);
SDL_UpdateYUVTexture(m_sdlTexture, NULL, frameYUV->data[0], frameYUV->linesize[0], frameYUV->data[1], frameYUV->linesize[1], frameYUV->data[2], frameYUV->linesize[2]);
SDL_RenderClear(m_sdlRender);
SDL_RenderCopy(m_sdlRender, m_sdlTexture, NULL, &m_sdlRect);
// Present picture
SDL_RenderPresent(m_sdlRender);
}
2.4 使用SDL2播放音频数据
对于音频数据,在使用avcodec_receive_frame函数接收到AVFrame后,我们得到的是音频的PCM数据。
与视频数据不同,音频数据并不是以帧为单位表示的,它可能包含多个采样数据(samples)。 为了播放音频,我们同样需要对音频数据进行格式转换,以适应音频设备的播放要求。音频格式转换主要通过swr_convert函数来完成。转换后的音频数据可以存放在一个公共缓冲区中。
使用SDL_OpenAudio函数进行音频播放,该函数需要传入一个SDL_AudioSpec结构体来设置播放参数。其中需要设置一个回调函数(callback),用于在音频设备需要获取数据时执行。因此,我们需要在此回调函数中向音频设备提供数据,实现数据的"喂养":
SDL_AudioSpec m_sdlAudioSpec;
auto audioCtx = m_audioDecoder.GetCodecContext();
m_sdlAudioSpec.freq = audioCtx->sample_rate; //根据你录制的PCM采样率决定
m_sdlAudioSpec.format = AUDIO_S16SYS;
m_sdlAudioSpec.channels = audioCtx->channels;
m_sdlAudioSpec.silence = 0;
m_sdlAudioSpec.samples = SDL_AUDIO_BUFFER_SIZE;
m_sdlAudioSpec.callback = &SDLVideoPlayer::ReadAudioData;
m_sdlAudioSpec.userdata = NULL;
int re = SDL_OpenAudio(&m_sdlAudioSpec, NULL);
if (re < 0)
{
std::cout << "can't open audio: " << GetErrorInfo(re);
}
else
{
//Start play audio
SDL_PauseAudio(0);
}
void SDLVideoPlayer::ReadAudioData(void *udata, Uint8 *stream, int len) {
SDL_memset(stream, 0, len);
//需要向stream中填充len长度的音频数据
...
SDL_MixAudio(stream, m_audioPcmDataBuf, len, g_volum);
}
2.5 音视频同步的设计
为了实现音视频同步,我们使用了两个线程分别播放音频和视频。音频可以直接通过设置回调函数来传递数据,而视频则需要我们自己控制播放速度,这涉及到统一两者播放速度的问题。 音视频同步的基本方式是确定一个主时钟作为同步基准。在播放过程中,我们不断检查当前流的播放时间与主时钟的差异,以调节自身的播放速度。根据不同类型的主时钟,可以分为以下几种方式:
- 音频同步到视频:使用视频时钟作为主时钟。
- 视频同步到音频:使用音频时钟作为主时钟。
- 音视频都同步到外部时钟。 由于音频播放通常会将大量数据发送到设备缓存中,并且音频对人的敏感度更高,因此以音频时钟作为主时钟是比较合理且简单的方法。具体实现如下:
- 在每次传递音频数据时,记录送入数据的起始pts时间戳,表示当前音频的播放进度。
- 每次刷新视频帧时,记录当前图片帧的pts时间戳。
- 在记录当前音频pts的同时,根据记录的图片pts,计算两者之间的延迟。
- 在刷新视频帧时,根据延迟值判断,如果当前视频比音频快,那么调整视频等待时间为正常两帧之间的间隔加上音视频之间的延迟,并将延迟值置为0;如果音频比视频快,那么直接丢弃当前的视频帧,直到音频和视频时间一致。
2.6 快进和快退
快进和快退,以及通过拖动进度条来实现播放跳转,其实现思路都是一样的,即通过使用av_seek_frame函数来实现:
av_seek_frame(m_pFormatCtx, -1, pts * AV_TIME_BASE, AVSEEK_FLAG_BACKWARD);
因此关键就是获取要跳转的时间戳,这个在做音视频同步处理后,这个时间戳就很容易拿到。
2.7 SDL事件处理
对于窗口大小更改、暂停、快进快退等操作,都需要与用户进行交互,而这可以通过SDL的事件机制来实现。 监听事件:
SDL_Event event;
SDL_WaitEvent(&event);
if (event.type == SDL_WINDOWEVENT) {
...
}
...
除了预定义的事件,比如窗口事件、鼠标事件、按键事件等,你也可以自己触发或定义新的事件:
SDL_Event event;
event.type = SFM_REFRESH_PIC_EVENT;
SDL_PushEvent(&event);