美文网首页视频音频iOS深度开发iOS Developer
FFmpeg,SDL2.0 C语言跨平台播放器(iOS)

FFmpeg,SDL2.0 C语言跨平台播放器(iOS)

作者: 7aab148ad169 | 来源:发表于2017-04-13 16:52 被阅读398次

    最近在做一个视频相关的项目。一开始我们使用开源播放器,遇到不少坑,以至于我要深入去研究一下播放器的原理和实现。我在这个过程中用C实现了一个简单的Player,这里总结记录一下。

    完整的demo源码GitHub

    播放器原理简介

    视频的播放我们要从视频文件说起。视频文件可以简单的理解为主要的两个流(当然还有额外的信息):视频流和音频流。视频流也可以简单的理解为是连续的图片帧编码而成的,具体的编码算法那就博大精深了,比如现在市面上比较流行的h264,不过这些编解码算法mmfpeg都已经封装成库了。音频流就是可以播放的音频了,是声音采样信号PCM经过一定的编码的数据流,比较常见的有aac。

    明白了视频文件的结构,我们就有了一个很直接的的播放流程:将视频流解码出来逐帧播放与此同时播放声音流,我们需要特别注意的就是视频和音频的同步的问题,视频文件里面包含了足够的信息让我么来进行同步。
    是播放的流程如图:

    Player.jpg

    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

    相关文章

      网友评论

        本文标题:FFmpeg,SDL2.0 C语言跨平台播放器(iOS)

        本文链接:https://www.haomeiwen.com/subject/fhobattx.html