最近在做一个视频相关的项目。一开始我们使用开源播放器,遇到不少坑,以至于我要深入去研究一下播放器的原理和实现。我在这个过程中用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