最近在做一个视频相关的项目。一开始我们使用开源播放器,遇到不少坑,以至于我要深入去研究一下播放器的原理和实现。我在这个过程中用C实现了一个简单的Player,这里总结记录一下。
完整的demo源码GitHub。
播放器原理简介
视频的播放我们要从视频文件说起。视频文件可以简单的理解为主要的两个流(当然还有额外的信息):视频流和音频流。视频流也可以简单的理解为是连续的图片帧编码而成的,具体的编码算法那就博大精深了,比如现在市面上比较流行的h264,不过这些编解码算法mmfpeg都已经封装成库了。音频流就是可以播放的音频了,是声音采样信号PCM经过一定的编码的数据流,比较常见的有aac。
明白了视频文件的结构,我们就有了一个很直接的的播放流程:将视频流解码出来逐帧播放与此同时播放声音流,我们需要特别注意的就是视频和音频的同步的问题,视频文件里面包含了足够的信息让我么来进行同步。
是播放的流程如图:
FFmpeg+SDL2.0
SDL是Simple Direct Media Layer,是C实现的跨平台底层库,我在播放器实现里主要使用到他的图像渲染以及音频能力。
我重点关注解码以及同步实现。我需要几个主要线程来运行不同的任务。
- 视频解包线程(decode_thread):这个线程将视频文件进行解包,将视频流和音频流解析成packet,然后分发到视频解码线程和音频解码线程。
- 视频解码线程(video_thread):这个线程进行实际的视频解码操作,将packet解码成实际的AVFrame,然后交个渲染层。
- 音频线程(audio_thread):SDL音频本运行在一个独立线程,我们需要实现相关回调为其提供数据。
在播放器里面我创建了三个队列,需要在这里说明一下,以防混淆。
队列名称 | 队列作用 |
---|---|
Video Packet队列(videoQueue) | 存放从视频文件中直接读取出来的视频packet包数据。 |
Audio Packet队列(audioQueue) | 存放从视频文件中直接读取出来的音频packet包数据。 |
Video Frame队列(videoFrameQueue) | 视频帧队列存放的是已经解码完成的视频帧数据。 |
FFmpeg 解码流程
首先定义一个存储播放器上下文的结构体:
struct JDCMediaContext {
AVFormatContext *fmtCtx;//视频文件上下文
AVCodec *codecVideo;//视频解码器
AVCodecContext *codecCtxVideo;//视频解码上下文
AVStream *videoStream;//视频流
int videoStreamIdx;//视频流在format的index
AVCodec *codecAudio;//音频解码器
AVCodecContext *codecCtxAudio;//音频解码上下文
AVStream *audioStream;//音频流
int audioStreamIdx;//音频流在format的indx
JDCSDLContext *sdlCtx;//SDL2.0 上下文
SDL_Thread *parse_tid;//解包线程tid
SDL_Thread *video_tid;//视频解码线程tid
struct SwsContext *swsCtx;//AVFrame变换上线文
JDCSDLPacketQueue *audioQueue;//音频packet队列
JDCSDLPacketQueue *videoQueue;//视频packet队列
JDCSDLPacketQueue *videoFrameQueue;//解码完成的视频帧队列
char filename[1024];//文件名
int quit;//退出标志
};
打开视频文件
首先我们需要打开一个视频文件:
JDCMediaContext *jdc_media_open_input(const char *url,JDCError **error)
{
JDCMediaContext *mCtx = (JDCMediaContext *)av_mallocz(sizeof(JDCMediaContext));
AVFormatContext *pFmtCtx = avformat_alloc_context();
strcpy(mCtx->filename, url);
//打开一个视频文件
if (avformat_open_input(&pFmtCtx, url, NULL, NULL) != 0) {
av_free(mCtx);
return NULL;
}
mCtx->fmtCtx = pFmtCtx;
if (avformat_find_stream_info(pFmtCtx, NULL) < 0) {
av_free(mCtx);
return NULL;
}
// Dump information about file onto standard error
av_dump_format(pFmtCtx, 0, mCtx->filename, 0);
//找到文件中的视频流和音频流
for(int i = 0 ; i < pFmtCtx->nb_streams ; i++){
if(pFmtCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO &&
mCtx->videoStream == NULL){
mCtx->videoStreamIdx = i;
}
if(pFmtCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO &&
mCtx->audioStream == NULL){
mCtx->audioStreamIdx = i;
}
}
return mCtx;
}
做好解码准备
找到视频流和音频流以后我们需要找到对应的解码器并且打开流,做好解码的准备。
jdc_media_open_stream(mCtx, mCtx->audioStreamIdx);
jdc_media_open_stream(mCtx, mCtx->videoStreamIdx);
int jdc_media_open_stream(JDCMediaContext *mCtx , int sIdx){
AVFormatContext *pFormatCtx = mCtx->fmtCtx;
AVCodecContext *codecCtx = NULL;
AVCodec *codec = NULL;
if (sIdx < 0 || sIdx >= pFormatCtx->nb_streams) {
return -1;
}
AVStream *stream = pFormatCtx->streams[sIdx];
//找到解码器
codec = avcodec_find_decoder(stream->codecpar->codec_id);
if (!codec) {
fprintf(stderr, "Unsupported codec!\n");
return -1;
}
codecCtx = avcodec_alloc_context3(codec);
//配置解码上下文
if(avcodec_parameters_to_context(codecCtx, stream->codecpar) < 0){
fprintf(stderr, "avcodec parameters to context failed!\n");
return -1;
}
//SDL 音频配置
if (codecCtx->codec_type == AVMEDIA_TYPE_AUDIO) {
SDL_AudioSpec wanted_spec;
SDL_AudioSpec spec;
wanted_spec.freq = codecCtx->sample_rate;
wanted_spec.format = AUDIO_S16SYS;
wanted_spec.channels = codecCtx->channels;
wanted_spec.silence = 0;
wanted_spec.samples = 1024;
//音频回调,我在这个回调中向音频设备feed数据。
wanted_spec.callback = jdc_sdl_audio_callback;
wanted_spec.userdata = mCtx;
if(SDL_OpenAudio(&wanted_spec, &spec) < 0) {
fprintf(stderr, "SDL_OpenAudio: %s\n", SDL_GetError());
return -1;
}
}
//打开流开始解码
if(avcodec_open2(codecCtx, codec, NULL) < 0) {
fprintf(stderr, "Unsupported codec!\n");
return -1;
}
switch(codecCtx->codec_type) {
case AVMEDIA_TYPE_AUDIO:
mCtx->audioStreamIdx = sIdx;
mCtx->audioStream = stream;
mCtx->codecAudio = codec;
mCtx->codecCtxAudio = codecCtx;
mCtx->audioQueue = jdc_packet_queue_alloc();
jdc_packet_queue_init(mCtx->audioQueue);
SDL_PauseAudio(0);
break;
case AVMEDIA_TYPE_VIDEO:
mCtx->videoStreamIdx = sIdx;
mCtx->videoStream = stream;
mCtx->codecVideo = codec;
mCtx->codecCtxVideo = codecCtx;
mCtx->videoQueue = jdc_packet_queue_alloc();
mCtx->video_tid = SDL_CreateThread(jdc_media_video_thread,
"video thread",
mCtx);
jdc_packet_queue_init(mCtx->videoQueue);
mCtx->swsCtx = sws_getContext(mCtx->codecCtxVideo->width,
mCtx->codecCtxVideo->height,
mCtx->codecCtxVideo->pix_fmt,
mCtx->codecCtxVideo->width,
mCtx->codecCtxVideo->height,
AV_PIX_FMT_YUV420P,
SWS_BILINEAR,
NULL,
NULL,
NULL);
break;
default:
break;
}
return 0;
}
从视频当中读取数据
配置好解码上下文以后,我们开一个线程专门从视频视频文件里面读取packet,我们将读取到的packet分别放到视频packet队列和音频packet队列。队列的实现我在另一篇文章中讨论:通用队列实现链接。
//这里我用的是SDL的线程创建接口,也可以用标准的pthread接口实现。
mCtx->parse_tid = SDL_CreateThread(jdc_media_decode_thread, "decode thread",mCtx);
//线程运行的方法
int jdc_media_decode_thread(void *userData)
{
JDCMediaContext *mCtx = userData;
AVFrame *pFrame = NULL;
pFrame = av_frame_alloc();
if (pFrame == NULL) {
return -1;
}
int numBytes;
numBytes = av_image_get_buffer_size(AV_PIX_FMT_YUV420P,
mCtx->codecCtxVideo->width,
mCtx->codecCtxVideo->height,
1);
AVPacket *packet;
//这个方法的核心就是不断的读取视频文件数据,存储到AVPacket结构
//视频则放到视频packet队列,音频则放到音频packet队列。
int ret = -1;
do{
packet = av_packet_alloc();
ret = av_read_frame(mCtx->fmtCtx, packet);
if (ret >= 0) {
if (packet->stream_index == mCtx->videoStream->index) {
jdc_packet_queue_push(mCtx->videoQueue, packet);
}else if(packet->stream_index == mCtx->audioStream->index){
jdc_packet_queue_push(mCtx->audioQueue, packet);
}
}
}while(ret >= 0);
return 0;
}
解码数据
从视频文件拿到的packet需要经过解码才能拿到实际的帧数据AVFrame,我们已经把视频和音频packet分别放到了两个队列。针对视频我需要一个专门进行解码操作,这个线程将解码得到的AVFrame放到一个专门的视频帧队列。播放器主线程从视频帧拿到数据进行渲染。
int jdc_media_video_thread(void *data)
{
JDCMediaContext *mCtx = data;
mCtx->videoFrameQueue = jdc_packet_queue_alloc();
jdc_packet_queue_init(mCtx->videoFrameQueue);
while(1){
AVFrame *pFrame = av_frame_alloc();
AVPacket *packet;
//这个方法从视频packet队列里面取出一个packet进行解码,注意如果队列为空这里会
//挂起,packet新加到队列则会唤醒此线程。
if(jdc_packet_queue_get_packet(mCtx->videoQueue, (void **)&packet, 1) < 0) {
break;
}
//将packet数据送给解码器。
int r = avcodec_send_packet(mCtx->codecCtxVideo, packet);
if (r != 0) {
av_packet_unref(packet);
av_packet_free(&packet);
continue;
}
//尝试获取解码结果
r = avcodec_receive_frame(mCtx->codecCtxVideo, pFrame);
if (r != 0) {
av_packet_unref(packet);
av_packet_free(&packet);
continue;
}
//解码成功,将解码好的视频帧数据放到帧队列。
jdc_packet_queue_push(mCtx->videoFrameQueue, pFrame);
av_packet_unref(packet);
av_packet_free(&packet);
}
return 0;
}
到这里,我们已经完成了视频帧的解码,得到了渲染需要的数据。音频的数据解码,我们将在SDL的音频线程进行。现在我们进入数据呈现的实现。
使用SDL 2.0进行视频呈现
SDL 图像渲染
始化SDL组件
int jdc_sdl_init(){
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)) {
return -1;
}
return 0;
}
创建用于显示的window
SDL在iOS平台使用UIWindow实现Window,我这里调用SDL提供的接口创建Window。
SDL_Window *window = NULL;
window = SDL_CreateWindow("video",
SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED,
mCtx->codecCtxVideo->width,
mCtx->codecCtxVideo->height,
SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_OPENGL |SDL_WINDOW_BORDERLESS);
给window配置渲染方式和Texture
SDL_Renderer *pRenderer = SDL_CreateRenderer(sdlCtx->window, -1, 0);
if (pRenderer == NULL) {
av_free(sdlCtx);
return NULL;
}
sdlCtx->renderer = pRenderer;
//注意这里我们使用YUV格式
SDL_Texture *pTexture = SDL_CreateTexture(pRenderer,
SDL_PIXELFORMAT_IYUV,
SDL_TEXTUREACCESS_STREAMING,
mCtx->codecCtxVideo->width,
mCtx->codecCtxVideo->height);
if (pTexture == NULL) {
av_free(sdlCtx);
return NULL;
}
SDL_SetTextureBlendMode(pTexture,SDL_BLENDMODE_BLEND);
实现渲染方法
真正的将视频绘制到window上,我们拿到AVFrame即可。
int video_display(JDCMediaContext *mCtx , void *data) {
AVFrame *pFrameYUV = mCtx->sdlCtx->frame;
AVFrame *pFrame = data;
JDCSDLContext *sdlCtx = mCtx->sdlCtx;
struct SwsContext *swsCtx = mCtx->swsCtx;
sws_scale(swsCtx,
(uint8_t const * const *)pFrame->data,
pFrame->linesize,
0,
pFrame->height,
pFrameYUV->data,
pFrameYUV->linesize);
SDL_Rect sdlRect;
sdlRect.x = 0;
sdlRect.y = 0;
sdlRect.w = pFrame->width;
sdlRect.h = pFrame->height;
SDL_UpdateYUVTexture(sdlCtx->texture, &sdlRect,
pFrameYUV->data[0], pFrameYUV->linesize[0],
pFrameYUV->data[1], pFrameYUV->linesize[1],
pFrameYUV->data[2], pFrameYUV->linesize[2]);
SDL_RenderClear(sdlCtx->renderer );
SDL_RenderCopy( sdlCtx->renderer, sdlCtx->texture,NULL, &sdlRect );
SDL_RenderPresent( sdlCtx->renderer );
av_frame_unref(pFrame);
av_frame_free(&pFrame);
return 0;
}
视频主循环
接下来我们只需一个timer定时触发事件,从视频帧队列里面拿出数据,绘制到屏幕上即可。
while(1){
SDL_WaitEvent(&event);
switch(event.type) {
case FF_QUIT_EVENT:
case SDL_QUIT:
mCtx->quit = 1;
SDL_Quit();
return 0;
break;
case FF_REFRESH_EVENT:
video_refresh_timer(event.user.data1);
break;
default:
break;
}
}
void video_refresh_timer(void *userdata) {
JDCMediaContext *mCtx = (JDCMediaContext *)userdata;
if(mCtx->videoStream) {
if(jdc_packet_queue_size(mCtx->videoFrameQueue) == 0) {
schedule_refresh(mCtx, 1);
} else {
//这个方法设定timer下一次触发的时间间隔
//现在我们不考虑同步问题,设定一个估计值。
schedule_refresh(mCtx, 40);
void *videoFrameData = NULL;
//从帧队列拿出帧数据,如果没有则挂起直到有新的frame数据。
jdc_packet_queue_get_packet(mCtx->videoFrameQueue, &videoFrameData, 1);
video_display(mCtx,videoFrameData);
}
} else {
schedule_refresh(mCtx, 100);
}
}
SDL 音频解码与播放
简单来说,使用SDL播放音频有以下几个步骤:
- 打开音频设备,设置回调。
- 在回调里面feed音频数据。
实际上,我们之前拿到的音频数据还是AVPacket,我们需要在想音频设备feed之前对其先进行解码。
打开音频设备
SDL_AudioSpec wantedSpec;
SDL_AudioSpec obtainedSpec;
wantedSpec.freq = mCtx->audioStream->codecpar->sample_rate;
wantedSpec.format = AUDIO_S16;
wantedSpec.channels = mCtx->codecCtxAudio->channels;
wantedSpec.silence = 0;
wantedSpec.samples = SDL_AUDIO_BUFFER_SIZE;
wantedSpec.callback = jdc_sdl_audio_callback;//回调方法
wantedSpec.userdata = mCtx;
if(SDL_OpenAudio(&wantedSpec, &obtainedSpec) < 0) {
av_free(sdlCtx);
fprintf(stderr, "SDL_OpenAudio: %s\n", SDL_GetError());
return -1;
}
SDL_PauseAudio(0);
实现音频回调
我们在回调里面要做的就是将解码好的数据,按照回调要求的数据量copy到缓冲区就行了。
//stream是音频设备缓冲区的指针我们往里面填数据,len表示当前设备要求数据的长度。
//userdata是我们自己的自定义数据.
void jdc_sdl_audio_callback(void *userdata, Uint8 * stream,int len)
{
JDCMediaContext *mCtx = (JDCMediaContext *)userdata;
AVCodecContext *codecCtx = mCtx->codecCtxAudio;
int len1,audio_size;
//用来缓存我们解码好的音频数据。
static uint8_t audio_buf[192000 * 4 / 2];
static unsigned int audio_buf_size = 0;
static unsigned int audio_buf_index = 0;
while(len > 0) {
//标明解码的数据已经用完了,我们需要重新解码一些数据。
if(audio_buf_index >= audio_buf_size) {
/* We have already sent all our data; get more */
audio_size = jdc_sdl_audio_decode_frame(codecCtx,
audio_buf,
sizeof(audio_buf),
mCtx);
if(audio_size < 0) {
/* If error, output silence */
audio_buf_size = 1024;
memset(audio_buf, 0, audio_buf_size);
} else {
audio_buf_size = audio_size;
}
audio_buf_index = 0;
}
len1 = audio_buf_size - audio_buf_index;
if(len1 > len) len1 = len;
//往设备缓冲区填数据。
memcpy(stream, (uint8_t *)audio_buf + audio_buf_index, len1);
len -= len1;
stream += len1;
audio_buf_index += len1;
}
}
音频数据解码
音频数据的解码跟视频数据解码方式基本一致。需要注意的是对于视频数据,一个AVPacket解码出对应一个AVFrame。但是这对于音频数据是不一定的,某些AVPacket可能包含多个frame,这里需要特别处理一下。
int jdc_sdl_audio_decode_frame(AVCodecContext *aCodecCtx,
uint8_t *audio_buf,
int buf_size,
JDCMediaContext *mCtx)
{
AVPacket *pkt = NULL;
static AVFrame frame;
int len1, data_size = 0;
while(1){
if (pkt != NULL && pkt->data != NULL) {
if (avcodec_send_packet(aCodecCtx, pkt) < 0) {
av_packet_unref(pkt);
av_packet_free(&pkt);
return -1;
}
data_size = 0;
//用循环处理多个frame的情况
while(avcodec_receive_frame(aCodecCtx, &frame) >= 0){
len1 = frame.linesize[0];
if(len1 < 0) {
/* if error, skip frame */
break;
}
int fData_size = 0;
fData_size = av_samples_get_buffer_size(NULL,
aCodecCtx->channels,
frame.nb_samples,
aCodecCtx->sample_fmt,
1);
assert(fData_size <= buf_size);
//将解码好的数据先存到缓冲区,以便后面使用。
memcpy(audio_buf+data_size, frame.data[0], fData_size);
fData_size = AudioResampling(aCodecCtx,
&frame,
AV_SAMPLE_FMT_S16,
2,
44100,audio_buf+data_size);
data_size += fData_size;
}
if (data_size > 0) {
av_packet_unref(pkt);
av_packet_free(&pkt);
return data_size;
}
av_packet_unref(pkt);
av_packet_free(&pkt);
}
if(mCtx->quit) {
return -1;
}
//从Audio Queue里面拿packet,如果没有则先挂起直到有数据。
if(jdc_packet_queue_get_packet(mCtx->audioQueue, (void **)&pkt, 1)< 0) {
return -1;
}
}
return -1;
}
好了,到这里我们已经实现了音频的播放。
总结
我在这篇文章里面讨论了如何实现一个简易的播放器,demo可以正常的播放视频,但是没有进度条的功能。播放器的实现思路其实很直接,解码,渲染,同步。目前没有讨论视频同步的问题,我在另外一篇文章中讨论视频同步讨论。完整的demo源码请到GitHub。
网友评论