Skip to content

leo4048111/ffplay-explained

Repository files navigation

f7af508a-790d-4189-b477-2d3cd5b0b324ffplay.c源码分析与理解

目录

前言

ffplay定义

ffplay是FFmpeg提供的一个极为简单的音视频媒体播放器(由ffmpeg库和SDL库开发),可以用于音视频播放、可视化分析 ,提供音视频显示和播放相关的图像信息、音频的波形等信息。

版本信息

本文的ffplay源码分析基于 Jul 3, 2023,commit 50f34172e0cca2cabc5836308ec66dbf93f5f2a3的最新ffplay.c源码版本。限于本人技术水平有限,分析中如有谬误,欢迎提交issue批评指正!

架构分析

从ffplay.c源码中粗略分析,我发现当前ffplay播放器的架构由4种类型的线程构成,所有的线程类型和其相应的功能描述如下:

  1. **SDL窗口主线程(main):**该线程是ffplay程序的主线程,以main函数为入口,首先初始化了ffmpeg和SDL上下文,随后调用stream_open函数打开输入文件/流,并且完成相关的子线程创建逻辑。最后,进入SDL窗口的event_loop循环。在每次循环中,先通过refresh_loop_wait_event执行图像渲染逻辑,随后在switch(event.type)中处理SDL窗口消息,实现鼠标和键盘控制全屏、暂停播放、继续播放、退出等人机交互功能。
  2. **解复用线程(read_thread):**该线程由主线程调用的stream_open中的is->read_tid = SDL_CreateThread(read_thread, "read_thread", is);一句创建,主要负责对于输入流/文件的解复用工作,即提取AVPacket然后缓存到对应的PacketQueue中。解复用线程启动后,首先通过ffmpeg提供的avformat_open_input(...)方法打开输入流/文件。随后,设置相关扫描参数后,调用avformat_find_stream_info确认输入流/文件是否含有有效的stream信息。紧接着,解复用线程通过av_find_best_stream方法,找到音频、视频和字幕对应的流索引,存放在st_index[AVMEDIA_TYPE_NB]数组中。随后,使用stream_component_open打开每个流,为每个流创建对应的解码线程(详述见下)。在上面的步骤都完成后,解复用线程进入自己的线程循环,每次循环中的主要工作就是通过av_read_frame从输入流/文件中读出一个AVPacket,然后根据这个pktpkt->stream_index,调用packet_queue_put把它缓存到每个stream对应的PacketQueue中,等待在decoder_decode_frame函数调用中被if (packet_queue_get(d->queue, d->pkt, 1, &d->pkt_serial) < 0)一句拿出然后送到解码器去解码。(PacketQueue结构体分析见下文)
  3. **解码线程(video_thread, audio_thread, subtitle_thread):**这一类线程负责对于AVPacket的解码工作,ffplay中解码线程有video_threadaudio_threadsubtitle_thread三个,这里字幕解码不做讨论。这些线程都是在解复用线程read_thread中,由stream_component_open过程中的decoder_start函数调用创建的。笼统来看,解码线程的主体都是一个for(;;)循环,每次循环中的流程首先是获取一个对应的AVFrame视频或者音频帧(audio_thread中直接调用decoder_decode_frame获取音频帧,而video_thread中调用get_video_frame获取视频帧,它是对于decoder_decode_frame的封装)。随后,根据解码后的AVFrame中的数据,计算相关参数。最后,将AVFrame添加到视音频对应的FrameQueue队列中(audio_thread中调用frame_queue_push,而video_thread中调用queue_picture,它是对于frame_queue_push的封装),分别供音频播放线程和主线程在音频播放和图像渲染时使用。(FrameQueue结构体分析见下文)
  4. **音频播放线程(sdl_audio_callback):**该线程由解复用线程在调用stream_component_open打开音频流的时候,stream_component_open内部调用audio_open函数,audio_open内部再调用SDL_OpenAudioDevice创建。该线程的主要工作逻辑实现在sdl_audio_callback中,该回调函数会在音频播放线程中被反复调用,来向外部请求可播放的音频数据。回调函数的类型声明语句是typedef void (SDLCALL * SDL_AudioCallback) (void *userdata, Uint8 * stream, int len),在音频播放线程调用的时候,会填入len参数来告知需要给SDL送入多少字节的数据。随后,我们只需要将需要送入的数据拷贝到stream指向的缓冲区即可。这里ffplay在sdl_audio_callback回调函数实现中,首先通过audio_decode_frame从音频帧的FrameQueue中获取一帧音频数据(调用frame_queue_peek_readable实现),随后通过swr_convert对于音频数据进行重采样,处理后的音频数据放在is->audio_buf中返回sdl_audio_callback,由回调函数逻辑调用memcpy将数据复制到stream中送给SDL进行播放。

综上所述,ffplay整体的架构图如下所示: ffplay_arch

核心结构体分析

PacketQueue

PacketQueue结构体的声明如下所示:

typedef struct PacketQueue
{
    /* ffmpeg封装的队列数据结构,里面的数据对象是MyAVPacketList */
    /* 支持操作alloc2, write, read, freep */
    AVFifo *pkt_list;
    /* 队列中当前的packet数 */
    int nb_packets;
    /* 队列所有节点占用的总内存大小 */
    int size;
    /* 队列中所有节点的合计时长 */
    int64_t duration;
    /* 终止队列操作信号,用于安全快速退出播放 */
    int abort_request;
    /* 序列号,和MyAVPacketList中的序列号作用相同,但改变的时序略有不同 */
    int serial;
    /* 互斥锁,用于保护队列操作 */
    SDL_mutex *mutex;
    /* 条件变量,用于读写进程的相互通知 */
    SDL_cond *cond;
} PacketQueue;

其中AVFifo *pkt_list中存储的数据类型为MyAVPacketList,该结构体声明如下:

typedef struct MyAVPacketList
{
    /* 待解码数据 */
    AVPacket *pkt;
    /* pkt序列号 */
    int serial;
} MyAVPacketList;

该数据结构的引入主要是为了设计⼀个多线程安全的队列,保存AVPacket,同时统计队列内已缓存的数据⼤⼩。(这个统计数据会⽤来后续设置要缓存的数据量)。同时,数据结构中引⼊了serial的概念,区别前后数据包是否连续,主要应⽤于seek操作。 对于该结构体的相关操作方法罗列如下:

  • packet_queue_init:用于初始化一个PacketQueue结构,流程上先给pkt_list分配内存,再创建mutexcond变量,最后将abort_request设为1,这样在stream_open中初始化三个队列后,启动的read_thread里面stream_has_enough_packets会返回true,使得read_thread不会立即开始从输入流/文件中读取AVPacket,而是等待手动调用start启动队列后再读数据
static int packet_queue_init(PacketQueue *q)
{
    memset(q, 0, sizeof(PacketQueue));
    q->pkt_list = av_fifo_alloc2(1, sizeof(MyAVPacketList), AV_FIFO_FLAG_AUTO_GROW);
    if (!q->pkt_list)
        return AVERROR(ENOMEM);
    q->mutex = SDL_CreateMutex();
    if (!q->mutex)
    {
        av_log(NULL, AV_LOG_FATAL, "SDL_CreateMutex(): %s\n", SDL_GetError());
        return AVERROR(ENOMEM);
    }
    q->cond = SDL_CreateCond();
    if (!q->cond)
    {
        av_log(NULL, AV_LOG_FATAL, "SDL_CreateCond(): %s\n", SDL_GetError());
        return AVERROR(ENOMEM);
    }
    q->abort_request = 1;
    return 0;
}
  • packet_queue_destroy:用于销毁一个PacketQueue结构,流程上先调用packet_queue_flush将管理的所有队列节点清除,随后释放内存然后销毁init里面创建的相关变量
static void packet_queue_destroy(PacketQueue *q)
{
    packet_queue_flush(q);
    av_fifo_freep2(&q->pkt_list);
    SDL_DestroyMutex(q->mutex);
    SDL_DestroyCond(q->cond);
}
  • packet_queue_start:用于启动一个PacketQueue,做的工作就是把abort_request置0,让read_thread开始读数据,然后自增队列的序列号
static void packet_queue_start(PacketQueue *q)
{
    SDL_LockMutex(q->mutex);
    q->abort_request = 0;
    q->serial++;
    SDL_UnlockMutex(q->mutex);
}
  • packet_queue_abort:用于终止一个PacketQueue,做的工作就是把abort_request置1,然后释放一个cond信号。这里释放cond的意义是,在packet_queue_get阻塞调用时,该函数可能会因为队列中没有数据而阻塞等待在SDL_CondWait(q->cond, q->mutex)这一句上。这时候如果abort了,读线程也不会再读入新数据,自然不会再发送新的cond信号来唤醒get的线程。所以,为了避免这种情况下线程永远阻塞,因此在abort时候发送一次cond信号,使得线程能够再运行到循环头,进入if (q->abort_request)的逻辑。
static void packet_queue_abort(PacketQueue *q)
{
    SDL_LockMutex(q->mutex);
    q->abort_request = 1;
    SDL_CondSignal(q->cond);
    SDL_UnlockMutex(q->mutex);
}
  • packet_queue_get:该函数用于从一个PacketQueue中取出一个pkt。该函数的运行可能分为三种情况,对应三个返回值。首先是av_fifo_read(q->pkt_list, &pkt1, 1) >= 0即正确读出了一个pkt的情况,这时候更新相关的队列参数(这里注意q->size减去的是pkt1.pkt->size + sizeof(pkt1),可知q->size算的大小是同时包括包数据和储存包的节点数据结构大小的),然后返回读出的包即可,此时返回值为1。其次,如果队列为空,并且是非阻塞运行(packet_queue_get调用参数block设为0),那么直接返回0。最后,如果队列为空且为阻塞运行(block为1),则用SDL_CondWait(q->cond, q->mutex);入睡,等待别的线程往队列里放数据,或者abort时再醒过来重新执行循环
static int packet_queue_get(PacketQueue *q, AVPacket *pkt, int block, int *serial)
{
    MyAVPacketList pkt1;
    int ret;

    SDL_LockMutex(q->mutex);

    for (;;)
    {
        if (q->abort_request)
        {
            ret = -1;
            break;
        }

        if (av_fifo_read(q->pkt_list, &pkt1, 1) >= 0)
        {
            q->nb_packets--;
            q->size -= pkt1.pkt->size + sizeof(pkt1);
            q->duration -= pkt1.pkt->duration;
            av_packet_move_ref(pkt, pkt1.pkt);
            if (serial)
                *serial = pkt1.serial;
            av_packet_free(&pkt1.pkt);
            ret = 1;
            break;
        }
        else if (!block)
        {
            ret = 0;
            break;
        }
        else
        {
            SDL_CondWait(q->cond, q->mutex);
        }
    }
    SDL_UnlockMutex(q->mutex);
    return ret;
}
  • packet_queue_put:该函数用于向PacketQueue中放入一个节点。在代码逻辑上,首先通过av_packet_alloc方法分配一个AVPacket,在上面的数据结构分析中我们已经可以知道这是MyAVPacketList中存储Packet数据的底层结构。随后,在分配了新的pkt1后,将传入的pkt数据引用传递给pkt1。随后,对于实际的Packet队列操作实现是在packet_queue_put_private函数中进行了封装,下述。
static int packet_queue_put(PacketQueue *q, AVPacket *pkt)
{
    AVPacket *pkt1;
    int ret;

    pkt1 = av_packet_alloc();
    if (!pkt1)
    {
        av_packet_unref(pkt);
        return -1;
    }
    av_packet_move_ref(pkt1, pkt);

    SDL_LockMutex(q->mutex);
    ret = packet_queue_put_private(q, pkt1);
    SDL_UnlockMutex(q->mutex);

    if (ret < 0)
        av_packet_free(&pkt1);

    return ret;
}
  • packet_queue_put_private:该函数中封装了将pkt放入PacketQueue维护的Packet队列的逻辑。元素的入队采用了av_fifo_write进行数据拷贝,然后更新了PacketQueue中维护的总pkt个数增加1,q->size增加pkt1.pkt->size + sizeof(pkt1)(所以说这里q->size是队列中所有MyAVPacketList数据结构的大小外加其中维护的AVPacket数据大小。最后,将q->duration加上AVPacketduration,实现了元素的入队操作。
static int packet_queue_put_private(PacketQueue *q, AVPacket *pkt)
{
    MyAVPacketList pkt1;
    int ret;

    if (q->abort_request)
        return -1;

    pkt1.pkt = pkt;
    pkt1.serial = q->serial;

    ret = av_fifo_write(q->pkt_list, &pkt1, 1);
    if (ret < 0)
        return ret;
    q->nb_packets++;
    q->size += pkt1.pkt->size + sizeof(pkt1);
    q->duration += pkt1.pkt->duration;
    /* XXX: should duplicate packet data in DV case */
    SDL_CondSignal(q->cond);
    return 0;
}
  • packet_queue_flush:这个方法用于flush一个PacketQueue,具体原理从代码看比较简单,就是通过一个while循环读出队列中的所有pkt,逐个调用av_packet_free进行释放。最后,更新相关的队列参数,比如说队列中包总数、总大小、总时长等等。这里还有一个q->serial++的操作,具体作用将会在下文的重要变量功能分析中阐述。
static void packet_queue_flush(PacketQueue *q)
{
    MyAVPacketList pkt1;

    SDL_LockMutex(q->mutex);
    while (av_fifo_read(q->pkt_list, &pkt1, 1) >= 0)
        av_packet_free(&pkt1.pkt);
    q->nb_packets = 0;
    q->size = 0;
    q->duration = 0;
    q->serial++;
    SDL_UnlockMutex(q->mutex);
}

FrameQueue

FrameQueue结构体的声明如下所示:

typedef struct FrameQueue
{
    Frame queue[FRAME_QUEUE_SIZE]; /* 用于存放帧数据的队列 */
    int rindex;                    /* 读索引 */
    int windex;                    /* 写索引 */
    int size;                      /* 队列中的帧数 */
    int max_size;                  /* 队列最大缓存的帧数 */
    int keep_last;                 /* 播放后是否在队列中保留上一帧不销毁 */
    int rindex_shown;              /* keep_last的实现,读的时候实际上读的是rindex + rindex_shown,分析见下 */
    SDL_mutex *mutex;              /* 互斥锁,用于保护队列操作 */
    SDL_cond *cond;                /* 条件变量,用于解码和播放线程的相互通知 */
    PacketQueue *pktq;             /* 指向对应的PacketQueue,FrameQueue里面的数据就是这个队列解码出来的 */
} FrameQueue;

可见这里FrameQueue中的Frame存储和PacketQueue不同,没有使用AVFifo *,而是通过一个循环数组queue来模拟队列,rindex指向当前读取的位置(即队头),windex指向当前写入的位置(即队尾),两者之间就是队列的数据范围。其中,Frame的数据都是从pktq指向的PacketQueue中解码得到的,Frame结构中就是AVFrame的数据外加一些其它的不同类型Frame参数,这样的设计应该是为了使得Frame结构能够同时存储视音频和字幕等不同类型的帧,这里不再赘述。下面结合对于FrameQueue结构体的操作,简要阐述这里面变量的作用:

  • frame_queue_init:该方法用来初始化一个FrameQueue,其中变量的初始化操作很容易理解,可以直接看源代码。注意f->max_size = FFMIN(max_size, FRAME_QUEUE_SIZE);一句,队列的理论最大大小是FRAME_QUEUE_SIZE即开辟的数组大小,但是实际上用的时候的max_size是可能小于FRAME_QUEUE_SIZE的,具体取决于初始化时传的参数。还有f->keep_last = !!keep_last;的写法本人第一次见,应该是因为c语言没有bool关键字,而keep_last在逻辑上又是一个布尔值,所以为了防止传进来非01的初始化数值,比如说999,用!!可以强制将这种数值转成1,这个trick学到了。
static int frame_queue_init(FrameQueue *f, PacketQueue *pktq, int max_size, int keep_last)
{
    int i;
    memset(f, 0, sizeof(FrameQueue));
    if (!(f->mutex = SDL_CreateMutex()))
    {
        av_log(NULL, AV_LOG_FATAL, "SDL_CreateMutex(): %s\n", SDL_GetError());
        return AVERROR(ENOMEM);
    }
    if (!(f->cond = SDL_CreateCond()))
    {
        av_log(NULL, AV_LOG_FATAL, "SDL_CreateCond(): %s\n", SDL_GetError());
        return AVERROR(ENOMEM);
    }
    f->pktq = pktq;
    f->max_size = FFMIN(max_size, FRAME_QUEUE_SIZE);
    f->keep_last = !!keep_last;
    for (i = 0; i < f->max_size; i++)
        if (!(f->queue[i].frame = av_frame_alloc()))
            return AVERROR(ENOMEM);
    return 0;
}
  • frame_queue_destory:该函数用于销毁一个FrameQueue(这个名字比较奇怪,怀疑destory是拼错了,已经email提了MR,开发者反馈LGTM Thx,估计就是拼错了,不知道commit能不能合进去)。该函数的核心流程见源代码,做的工作比较简单,就是遍历了f->max_size范围下的所有Frame,然后解除内部对于AVFrame数据缓冲区的引用然后释放。
static void frame_queue_destory(FrameQueue *f)
{
    int i;
    for (i = 0; i < f->max_size; i++)
    {
        Frame *vp = &f->queue[i];
        frame_queue_unref_item(vp);
        av_frame_free(&vp->frame);
    }
    SDL_DestroyMutex(f->mutex);
    SDL_DestroyCond(f->cond);
}
  • frame_queue_signal:内部就是对于SDL_CondSignal(f->cond);的封装,用于线程通信。
static void frame_queue_signal(FrameQueue *f)
{
    SDL_LockMutex(f->mutex);
    SDL_CondSignal(f->cond);
    SDL_UnlockMutex(f->mutex);
}
  • frame_queue_peek_writable:这个函数用于返回一个队列中的可写位置。从ffplay.c源码来看,FrameQueue的写入操作一般分为三步。首先通过frame_queue_peek_writable获取一个可写位置。随后,直接用=对于位置中的Frame结构进行赋值。最后,再调用frame_queue_push来更新FrameQueue来更新windex写指针的位置。这里的这个函数逻辑其实很简单,就是返回了windex指向的可写位置,其它特殊情况的处理见源码
static Frame *frame_queue_peek_writable(FrameQueue *f)
{
    /* wait until we have space to put a new frame */
    SDL_LockMutex(f->mutex);
    while (f->size >= f->max_size &&
           !f->pktq->abort_request)
    {
        SDL_CondWait(f->cond, f->mutex);
    }
    SDL_UnlockMutex(f->mutex);

    if (f->pktq->abort_request)
        return NULL;

    return &f->queue[f->windex];
}
  • frame_queue_push:上面提到了,这个函数的作用就是更新windexsize。并且,由于FrameQueue的队列实现是一个循环数组,因此如果f->windex加到了f->max_size,那么就回到0索引,起到一个循环的效果。
static void frame_queue_push(FrameQueue *f)
{
    if (++f->windex == f->max_size)
        f->windex = 0;
    SDL_LockMutex(f->mutex);
    f->size++;
    SDL_CondSignal(f->cond);
    SDL_UnlockMutex(f->mutex);
}
  • frame_queue_peek,frame_queue_peek_next,frame_queue_peek_last:这三个peek函数返回的是分别是下一帧,下一帧的下一帧,和当前的队首。其中第一个函数可见返回元素的索引使用的是return &f->queue[(f->rindex + f->rindex_shown) % f->max_size];,这其实是keep_last实现逻辑的一部分,具体原理下面叙述。
static Frame *frame_queue_peek(FrameQueue *f)
{
    return &f->queue[(f->rindex + f->rindex_shown) % f->max_size];
}

static Frame *frame_queue_peek_next(FrameQueue *f)
{
    return &f->queue[(f->rindex + f->rindex_shown + 1) % f->max_size];
}

static Frame *frame_queue_peek_last(FrameQueue *f)
{
    return &f->queue[f->rindex];
}
  • frame_queue_peek_readable:这个函数的作用是返回队列中的下一帧。这里可以看到返回的时候,使用的索引同样是(f->rindex + f->rindex_shown) % f->max_size。容易想象,如果f->rindex_shown0,那么返回的就是队首。而如果f->rindex_shown是1,那么返回的是排在队首的后面一个元素。这样设计的用意是为了实现keep_last,即播放后保留上一次播放的帧。具体逻辑需要结合紧接着的frame_queue_next函数逻辑进行叙述。
static Frame *frame_queue_peek_readable(FrameQueue *f)
{
    /* wait until we have a readable a new frame */
    SDL_LockMutex(f->mutex);
    while (f->size - f->rindex_shown <= 0 &&
           !f->pktq->abort_request)
    {
        SDL_CondWait(f->cond, f->mutex);
    }
    SDL_UnlockMutex(f->mutex);

    if (f->pktq->abort_request)
        return NULL;

    return &f->queue[(f->rindex + f->rindex_shown) % f->max_size];
}
  • frame_queue_next:这个函数的作用是推进队首指针rindex(即类似队列的pop操作)。这里注意第一句if (f->keep_last && !f->rindex_shown) {...},其中的keep_last是在init的时候就写死的1或者0,表示这个队列是否需要保留播放完毕的上一帧。因为从frame_queue_next函数中可以看到,每次rindex自增前,都会对于之前已经被拿走的帧调用frame_queue_unref_item来解除引用。因此,ffplay这里在keep_last为1且rindex_shown为0,即第一次进入frame_queue_next这个函数的时候,会将f->rindex_shown置1,然后直接返回。这个操作使得第一次frame_queue_next之后rindex不变,和上次播放的那一帧的index一样,同时由于函数直接返回了,自然不会去释放第一帧。随后,在frame_queue_peek_readable的逻辑里面,我们可以看到,由于这个时候rindex_show已经恒为1了,所以每次返回的其实是队首的后一帧,取出的帧依然是新的下一帧。然后,每次进到frame_queue_next里面后,释放的其实就是上上帧,这样可以一直保证队列里面队首就是被keep_last保留的上一个播放完毕的帧,而队首的下一帧就是frame_queue_peek_readable取出的被送去播放的帧,这个设计与代码实现可谓妙哉妙哉,值得学习。
static void frame_queue_next(FrameQueue *f)
{
    if (f->keep_last && !f->rindex_shown)
    {
        f->rindex_shown = 1;
        return;
    }
    frame_queue_unref_item(&f->queue[f->rindex]);
    if (++f->rindex == f->max_size)
        f->rindex = 0;
    SDL_LockMutex(f->mutex);
    f->size--;
    SDL_CondSignal(f->cond);
    SDL_UnlockMutex(f->mutex);
}

VideoState

VideoState结构体声明如下:

typedef struct VideoState
{
    SDL_Thread *read_tid;         /* 读线程 */
    const AVInputFormat *iformat; /* 输入文件格式 */
    int abort_request;            /* =1时请求退出播放 */
    int force_refresh;            /* =1时请求立即刷新画面 */
    int paused;                   /* =1时请求暂停播放,=0时播放 */
    int last_paused;              /* 暂存“暂停”和“播放”状态 */
    int queue_attachments_req;    /* =1时请求读取附加数据 */
    int seek_req;                 /* =1时请求seek */
    int seek_flags;               /* seek标志 */
    int64_t seek_pos;             /* 本次seek的目标位置(当前位置+增量) */
    int64_t seek_rel;             /* 本次seek的位置增量 */
    int read_pause_return;        /* 读线程暂停后的返回值 */
    AVFormatContext *ic;          /* iformat上下文 */
    int realtime;                 /* =1为实时播放 */

    Clock audclk; /* 音频时钟 */
    Clock vidclk; /* 视频时钟 */
    Clock extclk; /* 外部时钟 */

    FrameQueue pictq; /* 视频帧队列 */
    FrameQueue subpq; /* 字幕帧队列 */
    FrameQueue sampq; /* 音频帧队列 */

    Decoder auddec; /* 音频解码器 */
    Decoder viddec; /* 视频解码器 */
    Decoder subdec; /* 字幕解码器 */

    int audio_stream; /* 音频流索引 */

    int av_sync_type; /* 音视频同步类型(默认同步到音频时钟,即audio master) */

    double audio_clock;     /* 音频播放时钟(当前帧pts+duration) */
    int audio_clock_serial; /* 播放序列号,可被seek设置 */

    /* 下面4个参数在非audio master同步时使用 */
    double audio_diff_cum; /* used for AV difference average computation */
    double audio_diff_avg_coef;
    double audio_diff_threshold;
    int audio_diff_avg_count;

    AVStream *audio_st;                  /* 音频流 */
    PacketQueue audioq;                  /* 音频包队列 */
    int audio_hw_buf_size;               /* SDL音频缓冲区大小(单位B) */
    uint8_t *audio_buf;                  /* 指向待播放的一帧音频数据,在audio_decode_frame中被设置,如果重采样则指向重采样得到的音频数据,否则指向frame中的数据 */
    uint8_t *audio_buf1;                 /* 指向重采样得到的音频数据 */
    unsigned int audio_buf_size;         /* audio_buf指向缓冲区大小(单位B) */
    unsigned int audio_buf1_size;        /* audio_buf1指向缓冲区大小(单位B) */
    int audio_buf_index;                 /* 当前audio_buf中待拷贝数据的第一个字节的索引 */
    int audio_write_buf_size;            /* is->audio_buf_size - is->audio_buf_index,待拷贝字节数 */
    int audio_volume;                    /* 音量 */
    int muted;                           /* =1时静音 */
    struct AudioParams audio_src;        /* 音频源参数 */
    struct AudioParams audio_filter_src; /* 音频滤波器源参数 */
    struct AudioParams audio_tgt;        /* 音频目标参数 */
    struct SwrContext *swr_ctx;          /* 重采样上下文 */
    int frame_drops_early;               /* 丢弃的packet数 */
    int frame_drops_late;                /* 丢弃的frame数 */

    /* 显示模式(视频、波形...) */
    enum ShowMode
    {
        SHOW_MODE_NONE = -1,
        SHOW_MODE_VIDEO = 0,
        SHOW_MODE_WAVES,
        SHOW_MODE_RDFT,
        SHOW_MODE_NB
    } show_mode;
    int16_t sample_array[SAMPLE_ARRAY_SIZE];
    int sample_array_index;
    int last_i_start;
    RDFTContext *rdft;
    int rdft_bits;
    FFTSample *rdft_data;
    int xpos;
    double last_vis_time;
    SDL_Texture *vis_texture; /* 视频纹理 */
    SDL_Texture *sub_texture; /* 字幕纹理 */
    SDL_Texture *vid_texture; /* 视频纹理 */

    int subtitle_stream;   /* 字幕流索引 */
    AVStream *subtitle_st; /* 字幕流 */
    PacketQueue subtitleq; /* 字幕包队列 */

    double frame_timer;                 /* 最后一帧播放的时刻 */
    double frame_last_returned_time;    /* 最后一帧返回的时刻 */
    double frame_last_filter_delay;     /* 最后一帧滤波延迟 */
    int video_stream;                   /* 视频流索引 */
    AVStream *video_st;                 /* 视频流 */
    PacketQueue videoq;                 /* 视频包队列 */
    double max_frame_duration;          // maximum duration of a frame - above this, we consider the jump a timestamp discontinuity
    struct SwsContext *sub_convert_ctx; /* 字幕转换上下文 */
    int eof;

    char *filename;
    int width, height, xleft, ytop;
    int step;

    int vfilter_idx;
    AVFilterContext *in_video_filter;  // the first filter in the video chain
    AVFilterContext *out_video_filter; // the last filter in the video chain
    AVFilterContext *in_audio_filter;  // the first filter in the audio chain
    AVFilterContext *out_audio_filter; // the last filter in the audio chain
    AVFilterGraph *agraph;             // audio filter graph

    int last_video_stream, last_audio_stream, last_subtitle_stream; /* 最近的相关流索引 */

    SDL_cond *continue_read_thread; /* 当读取数据队列满了后进入休眠时,可以通过该condition唤醒该读线程 */
} VideoState;

VideoState是整个ffplay的核心管理者,所有资源的申请和释放以及线程的状态变化都是由其管理。从ffplay源码来看,这个数据结构的实例可以看做是一个单例,通过opaque指针在不同线程之间传递。虽然它是在main函数中的stream_open调用中通过av_mallocz创建,但是其中的变量会被各个线程使用和设置,因此其中的变量很多,功能也比较繁杂。涉及到比较关键的变量功能,将会在下文中针对性地进行阐述。

Clock

Clock结构体声明如下:

typedef struct Clock
{
    double pts;       /* clock base */
    double pts_drift; /* clock base minus time at which we updated the clock */
    double last_updated;
    double speed;
    int serial; /* clock is based on a packet with this serial */
    int paused;
    int *queue_serial; /* pointer to the current packet queue serial, used for obsolete clock detection */
} Clock;

这个结构体是ffplay中的时钟结构。ffplay中一共有三个时钟,分别是audclkvidclkextclk。时钟的主要功能是参与音视频同步的计算,具体原理下文中会详细阐述。

Decoder

Decoder结构体声明如下:

typedef struct Decoder
{
    AVPacket *pkt;
    PacketQueue *queue;
    AVCodecContext *avctx;
    int pkt_serial;
    int finished;
    int packet_pending;
    SDL_cond *empty_queue_cond;
    int64_t start_pts;
    AVRational start_pts_tb;
    int64_t next_pts;
    AVRational next_pts_tb;
    SDL_Thread *decoder_tid;
} Decoder;

该结构体主要是封装了ffmpeg中的AVCodecContext*解码器上下文,在这个基础上加上了一些其它参数,比如说取Packet的队列指针,解码器线程tid等等。同样,这里面的参数在用到的时候我再进行详细研究分析。

核心操作实现原理分析

Start(开始播放)

ffplay的start过程基本上已经在上文中的架构图中能够比较清晰地呈现了,这里我再用一张图更加具体地给出ffplay的start过程中相关重要的函数调用逻辑和线程之间的数据通路,如下:

ffplay_play

流程上,大体就是从main的函数调用开始,先通过stream_open打开输入流/文件,然后初始化帧队列、包队列以及时钟结构,紧接着创建解复用线程后返回,进入event_loopevent_loop中如果没有SDL事件,代码就会一直运行在refresh_loop_wait_event的循环中,执行video_refresh的图像渲染逻辑。video_refresh中会根据SHOW_MODE_VIDEO是否被设置,决定是渲染波形图还是渲染视频。这里不考虑渲染波形图的情况,所以代码进入retry:下的逻辑。这里就是ffplay的音视频同步逻辑实现位置,具体算法待下文阐述。最后,音视频同步逻辑执行完毕后,进入display:下的逻辑,调用video_display把画面交给SDL渲染,然后循环往复。

在解复用线程中,主要工作就是创建音视频解码线程,然后往音视频解码线程读出PacketPacket队列(is->videoq, is->audioq)里面放入待解码的Packets数据。音视频解码线程创建后,不停地从各自的PacketQueue中取包然后解码送进FrameQueue,即is->pictqis->sampq中。第一个FrameQueue的数据会在上面video_refresh的代码中被访问取出使用,而第二个FrameQueue中的数据会在sdl_audio_callback回调函数的执行逻辑中被取出然后塞进stream缓冲区里面送到SDL进行播放。

Pause(暂停播放)

ffplay的暂停播放逻辑入口在SDL事件处理的event_loop中,在用户按下p或者space的时候触发,进入函数toggle_pause,大致的函数调用流程和数据通路如下:

ffplay_pause

这里可以看到,按下按键后,首先进入到toggle_pause中,里面主要做2个工作。第一个是调用stream_toggle_pause来更新is->paused、外部时钟和三个时钟的paused状态。第二个是将is->step置为了0(这个变量用于实现播放器的按键s功能,即按一下往后走一帧,具体原理见下文对于Step功能的原理分析)。代码中,用到is->paused和三个时钟的paused变量的位置主要有4处。第一个地方是refresh_loop_wait_event对于video_refresh的调用位置,源码如下:

static void refresh_loop_wait_event(VideoState *is, SDL_Event *event)
{
	...	
        if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
            video_refresh(is, &remaining_time);
	...
}

static void video_refresh(void *opaque, double *remaining_time)
{
    ...
        if (is->paused)
            goto display;
    ...
}

这里可以看到,如果is->paused为1即暂停时,video_refresh只可能在is->force_refresh被设置的时候才会被调用。这个is->force_refresh在源码中可以看出,会在窗口大小更改的时候被设置。所以video_refreshis->paused被设置时,直接可以跳过后面的音视频同步代码,直接进入display来渲染画面。

第二个用到audio_decode_frame里面,源代码如下:

static int audio_decode_frame(VideoState *is)
{
	...
    if (is->paused)
        return -1;
    ...   
}

这里的目的是为了在暂停时不从is->sampq中取数据,实现暂停时的静音效果。因为从ffplay的源码来看,解码线程是没有暂停这个操作的,就算is->paused被设置了,播放器的播放暂停,但是解码工作还是会继续,直到把缓存队列塞满或者无码可解。所以,这里让audio_decode_frame直接返回-1,不去从FrameQueue中读数据,这样外边调用它的sdl_audio_callback看到返回值audio_size < 0,就会把is->audio_buf设为NULL,然后后面送数据的时候就会走到memset(stream, 0, len1)的逻辑,从而往SDL里面送空数据,实现了暂停时的音频也一样暂停。

第三个用到is->paused的地方是解复用线程read_thread,这个里面的代码逻辑如下:

static int read_thread(void *arg) {
...
        if (is->paused != is->last_paused)
        {
            is->last_paused = is->paused;
            if (is->paused)
                is->read_pause_return = av_read_pause(ic);
            else
                av_read_play(ic);
        }
...
}

可以看到,这里做了一个判断,如果暂停的状态发生了改变(比如原来在播放现在暂停,或者原来暂停现在播放),就分别调用av_read_pauseav_read_play来暂停或者继续从流/文件中读取AVPacket。这三处代码块通过访问is->paused变量的状态,执行不同的代码逻辑,从而实现了播放器的暂停功能。

最后,可以发现在暂停的时候,三个Clockpaused变量也被设置为了is->paused的状态。这里设置的paused会在get_clock函数调用中被使用。这里get_clock的计算原理暂时不进行阐述,留到视音频同步算法解析部分一并分析研究。

Step(逐帧前进)

在看上面ffplay暂停功能的源码实现时,我发现了在暂停时还设置了is->step这个变量等于0,不知道意义何在,于是简单研究了一下它的用途。最终,我定位到了event_loop里面的step_to_next_frame代码,简单看了一下逻辑,发现这是一个逐帧前进的功能实现。在ffplay中,对于正在播放的视频,按一下s键,就会往后前进一帧并且暂停,随后每按一下s前进一帧,视频保持暂停。这个功能实现的源码如下:

static void step_to_next_frame(VideoState *is)
{
    /* if the stream is paused unpause it, then step */
    if (is->paused)
        stream_toggle_pause(is);
    is->step = 1;
}

static void video_refresh(void *opaque, double *remaining_time)
{
    ...
    if (is->step && !is->paused)
        stream_toggle_pause(is);
    ...
}

这里可以看到,step_to_next_frame中第一步是如果视频正在暂停,就让视频继续播放。随后,将is->step设置为1,表示现在正在进行逐帧前进操作。随后,在下一次进入video_refresh的时候,会走到上面的这个代码里面。这时候视频肯定是正在播放的状态,所以里面调用了stream_toggle_pause就把视频暂停了,这时候紧接着代码执行当前读出的这一帧的渲染逻辑,这一帧渲染完后视频就是一个暂停的状态。然后如果再按一下s,视频又往后渲染一帧之后暂停,以此类推,直到用户按space或者p,调用stream_toggle_pause的时候清除is->step,才能让视频继续播放。所以可以看出,逐帧前进这个功能的实现其实就是通过按一下按键,播放器就播放一帧后暂停,再按一下就再播放一帧后暂停这样的逻辑实现的,这是is->step变量设置的意义所在。

Seek(跳转播放)

Seek操作的主要流程大致为下图所示:

ffplay_seek

可见其中主要有三大步骤,第一是在event_loop中根据用户按的按钮不同,分别设置跳转的时长(目前看下来ffplay只支持快进快退10s或者60s。然后,根据文件类型不同,选择不同的seek方式。如果是按字节seekseek_by_bytes),则通过incr乘上字节率(比特率 / 8)计算出需要seek偏移(这里单位字节)。否则,直接在计算中用incr和当前get_master_clock返回的主时钟相加即可,这时候算出来的posrel单位是秒。

上一步执行完毕后,第二步两个分支都会调用stream_seek将计算完毕的posrel设置到isseek参数中(其中上面算出以秒为单位的数据在传进来的时候转成了微秒)。stream_seek函数只进行参数设置,然后把seek_req置1,表示当前请求seek操作,本身它并不做实际的seek工作。

第三步之前的上述工作由主线程完成,最后在is->seek_req被设置后,解复用线程read_thread会进入到下面的代码逻辑中:

if (is->seek_req)
        {
            int64_t seek_target = is->seek_pos;
            int64_t seek_min = is->seek_rel > 0 ? seek_target - is->seek_rel + 2 : INT64_MIN;
            int64_t seek_max = is->seek_rel < 0 ? seek_target - is->seek_rel - 2 : INT64_MAX;
            // FIXME the +-2 is due to rounding being not done in the correct direction in generation
            //      of the seek_pos/seek_rel variables

            ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek_max, is->seek_flags);
            if (ret < 0)
            {
                av_log(NULL, AV_LOG_ERROR,
                       "%s: error while seeking\n", is->ic->url);
            }
            else
            {
                if (is->audio_stream >= 0)
                    packet_queue_flush(&is->audioq);
                if (is->subtitle_stream >= 0)
                    packet_queue_flush(&is->subtitleq);
                if (is->video_stream >= 0)
                    packet_queue_flush(&is->videoq);
                if (is->seek_flags & AVSEEK_FLAG_BYTE)
                {
                    set_clock(&is->extclk, NAN, 0);
                }
                else
                {
                    set_clock(&is->extclk, seek_target / (double)AV_TIME_BASE, 0);
                }
            }
            is->seek_req = 0;
            is->queue_attachments_req = 1;
            is->eof = 0;
            if (is->paused)
                step_to_next_frame(is);
        }

这里是真正实现seek操作的位置,原理是调用了avformat_seek_file这个函数。调用完之后,文件或者流的读取位置就被正确更新了,之后的av_read_frame都会从新的位置开始取出AVPacket。随后,ffplay会通过packet_queue_flushPacketQueue缓存清空,同时重设外部时钟到seek到的新位置,然后清除seek_req的标志。最后,如果当前视频是暂停的状态,则进行一次step操作,目的是为了让播放器上显示seek到的最新位置的画面,最终实现了整体的seek操作逻辑。

同时,这里还涉及到一个serial变量的更新。在packet_queue_flush之后,PacketQueue维护的serial序号会自增1。之后放进去的所有Packetserial都会与这个序号保持一致。这时候如果解码线程从队列里面取数据,会发现取出来的PacketserialDecoderserial不一致,这时候就直接扔掉不解了。更加详细的分析可以见这篇Blog分析ffplay源码之serial变量

视音频同步算法原理与代码实现分析

这里所涵盖的函数与算法分析主要围绕音视频同步算法中所用到的相关变量设置与函数调用流程开展,先介绍例如audclk,vidclk,audio_clock等涉及时间计算的变量的设置时机与计算方式,最后分析ffplay所使用的视音频同步算法原理及其实现。

audio_clock变量的设置时机与计算方法

VideoState里面有两个和音频时钟计算有关的变量。一个是audclk,它是一个Clock数据结构对象。另一个是一个double型数据audio_clock。这里我先分析一下audio_clock变量的含义、功能、设置时机与计算方式。这个变量的设置是在audio_decode_frame中进行的,代码如下:

static int audio_decode_frame(VideoState *is)
{
...
/* update the audio clock with the pts */
    if (!isnan(af->pts))
        is->audio_clock = af->pts + (double)af->frame->nb_samples / af->frame->sample_rate;
    else
        is->audio_clock = NAN;
...
}

这里audio_decode_frame这个函数在每次sdl_audio_callback时都会被调用,用来从FrameQueue中取出一帧解码后的音频数据,进行一定的加工(比如说重采样和采样率调整、通道数调整等,一般不会被执行),最后将其中的数据返回给sdl_audio_callback。在最后,可以看到audio_clock的计算是当前取出这一帧的pts加上了(double)af->frame->nb_samples / af->frame->sample_rate。这里的这个(double)af->frame->nb_samples / af->frame->sample_rate很明显就是帧的duration时长,因此我们可以得出结论,is->audio_clock应该是等于当前最新被audio_decode_frameFrameQueue取出的帧的pts + duration,也就是这帧被播放完时刻的时间戳。至于这里设置完audio_clockis->audclk的音频时钟计算有什么关系,我们下文继续阐述。

audclk变量的设置时机与计算方法

audclk变量的设置时机紧接着上面所述的audio_clock变量,在sdl_audio_callback调用audio_decode_frame并且返回后,sdl_audio_callback会使用memcpyFrame中的数据拷进SDL,如果不够拷那就再取下一个frame放到is->audio_buff里面,直到len == 0为止,同时更新is->audio_buf_indexis->audio_write_buf_size。紧接着,关键的计算代码如下:

static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{
...
    /* Let's assume the audio driver that is used by SDL has two periods. */
    if (!isnan(is->audio_clock))
    {
        set_clock_at(&is->audclk, is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec, is->audio_clock_serial, audio_callback_time / 1000000.0);
        sync_clock_to_slave(&is->extclk, &is->audclk);
    }
...
}

这里可以看到,set_clock_at函数调用时传入的pts参数是is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec。参考CSDN博客文章FFplay源码分析-音视频同步1的分析内容,后面的这个2 * is->audio_hw_buf_size + is->audio_write_buf_size这个数值的含义其实就是当前还没有开始播的被缓存数据字节数。这个算式由两部分构成,首先是前面的2 * is->audio_hw_buf_size,这个是在SDL内部的未播完数据长度,结构如下所示: image

这里可见一段红色的,就是SDL里面正在播的数据,长度为is->audio_hw_buf_size,后面那个len就是这次sdl_audio_callback函数调用中我们手动拷进去的数据长度。这里注意,SDL调用sdl_audio_callback拿数据的时候,传进来的lenaudio_hw_buf_size是恒相等的。所以,在这里的计算中,2 * is->audio_hw_buf_size其实是len + is->audio_hw_buf_size。只不过len变量在上面的while循环取数据中已经被减成0了,所以这里直接就2 * is->audio_hw_buf_size进行计算。然后,算式的第二部分是is->audio_write_buf_size,这是is->audio_buf中还没有被送给SDL的剩余帧数据长度。拿这三部分的和除以is->audio_tgt.bytes_per_sec,算出的就是播完这三部分所用的时间。最后,用之前算出来的播完这一帧的时间戳is->audio_clock,减去播完这三段的总时间,得到的就是当前音频时钟audclk的准确pts,大概的图示如下:

ffplay_audclk

在确定了时钟pts后,通过set_clock_at设置is->audclkpts,然后将is->extclk外部时钟同步到is->audclk上,以供在音视频同步计算中使用。

音视频同步算法解析

上面我们已经知道了如何正确计算音频时钟audclk,这时候就已经可以开始执行音视频同步算法流程了。这个算法通过计算每一帧的延迟remaining_time来控制av_usleep的睡眠时间,从而让帧和音频的播放保持在一个可接受的不同步范围内。如果帧落后音频太多,ffplay的音视频同步算法还会进行丢帧操作,来让视频播放快速追上音频。这个同步操作的代码实现据我观察主要分散在两个位置,分别由视频解码线程和主线程执行。而我们要重点阐述的核心算法位于第二个位置,处理逻辑由主线程进行执行,代码在video_refresh函数下进行实现。

首先,第一个涉及音视频同步代码逻辑的位置如下所示:

static int get_video_frame(VideoState *is, AVFrame *frame)
{
...
	    if (got_picture)
    {
        double dpts = NAN;

        if (frame->pts != AV_NOPTS_VALUE)
            dpts = av_q2d(is->video_st->time_base) * frame->pts;

        frame->sample_aspect_ratio = av_guess_sample_aspect_ratio(is->ic, is->video_st, frame);

        if (framedrop > 0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER))
        {
            if (frame->pts != AV_NOPTS_VALUE)
            {
                double diff = dpts - get_master_clock(is);
                if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD &&
                    diff - is->frame_last_filter_delay < 0 &&
                    is->viddec.pkt_serial == is->vidclk.serial &&
                    is->videoq.nb_packets)
                {
                    is->frame_drops_early++;
                    av_frame_unref(frame);
                    got_picture = 0;
                }
            }
        }
    } 
...
}

这里上面已经提到了,视频解码线程获取解码后数据的方式是调用get_video_frame,这个函数是对于decoder_decode_frame即实际调用avcodec_receive_frame获取解码后数据的函数的一个封装。至于为什么音频解码直接调用的就是decoder_decode_frame获取解码后数据,而视频解码则需要这一个封装流程,原因就在上面我贴出的这段代码。因为音频解码后的数据不需要丢弃,直接往SDL里送就可以了,但是视频解码出来之后,这里先一步做了一个丢帧的操作,所以需要一层封装来wrap这个函数。从代码里来看,这里首先算了一个解码出来帧dpts,也就是pts * timebase,单位是秒。然后,如果同步模式不是AV_SYNC_VIDEO_MASTER,即不是以视频时钟为主时钟同步到视频(一般都不会是这种模式)的话,进入丢帧计算逻辑。先算出当前拿出来帧dpts和主时钟之间的差值diff。随后,如果这个差值在可同步阈值范围AV_NOSYNC_THRESHOLD内(这个阈值的作用就是控制如果差的太大就直接不走同步逻辑了),并且diff - is->frame_last_filter_delay < 0也就是diff < 0(这里的is->frame_last_filter_delay是个常数0),表示当前主时钟已经比拿出来的帧的dpts快了,也就是帧慢了。同时,在这个基础上,如果帧队列里面还有数据,就正式进入丢帧逻辑,is->frame_drops_early++记录丢掉的总帧数后,直接av_frame_unref(frame);丢帧,然后将got_picture设置为0,表示重新从队列取帧,直到帧的dpts追上甚至超过主时钟为止。所以,其实ffplay在解码的时候就已经开始进行初步的音视频同步操作了。

其次,第二个涉及音视频同步的位置,就是ffplay的音视频同步核心算法实现,在video_refresh函数中,实现主体如下:

/* called to display each frame */
static void video_refresh(void *opaque, double *remaining_time)
{
	...
	    if (is->video_st) {
retry:
        if (frame_queue_nb_remaining(&is->pictq) == 0) {
            // nothing to do, no picture to display in the queue
        } else {
            double last_duration, duration, delay;
            Frame *vp, *lastvp;

            /* dequeue the picture */
            lastvp = frame_queue_peek_last(&is->pictq);
            vp = frame_queue_peek(&is->pictq);
            
			...
			
            /* compute nominal last_duration */
            last_duration = vp_duration(is, lastvp, vp);
            delay = compute_target_delay(last_duration, is);

            time= av_gettime_relative()/1000000.0;
            if (time < is->frame_timer + delay) {
                *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
                goto display;
            }

            is->frame_timer += delay;
            if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
                is->frame_timer = time;

            SDL_LockMutex(is->pictq.mutex);
            if (!isnan(vp->pts))
                update_video_pts(is, vp->pts, vp->serial);
            SDL_UnlockMutex(is->pictq.mutex);

            if (frame_queue_nb_remaining(&is->pictq) > 1) {
                Frame *nextvp = frame_queue_peek_next(&is->pictq);
                duration = vp_duration(is, vp, nextvp);
                if(!is->step && (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) && time > is->frame_timer + duration){
                    is->frame_drops_late++;
                    frame_queue_next(&is->pictq);
                    goto retry;
                }
            }
}

其中两个重要函数调用分别是vp_durationcompute_target_delay,实现原理下文中将会详细阐述。整体来看,可以看到这个音视频同步的算法的基本流程和逻辑还是非常简洁明了的,下面我们结合其代码实现进行逐行分析:

  • 变量声明:上面的代码中,我已经去掉了涉及seek后同步的相关代码逻辑,以便于进行分析。首先,可以看到算法入口处声明了5个变量,其中两个Frame* vp, *lastvp指向当前帧和上一帧(因为视音频队列初始化的时候都设置了keep_last,所以能够获取到上一帧,这也是keep_last的意义所在)。还有3个double型,last_duration表示上一帧的长度,duration表示当前帧的长度,delay是这个算法需要输出的延迟时间,用于最后根据这个delay进一步算出remaining_time,然后调整线程入睡时间,从而控制当前帧在SDL窗口中的呈现时长,对应的源码如下:
...
double last_duration, duration, delay;
Frame *vp, *lastvp;
...
  • **获取上一帧和当前帧:**就是调用上面的peek_lastpeek函数,实现原理上文已经阐述过了,见FrameQueue结构体分析,这里不再赘述,直接给出对应源码:
...
/* dequeue the picture */
lastvp = frame_queue_peek_last(&is->pictq);
vp = frame_queue_peek(&is->pictq);
...
  • **计算last_duration:**这里开始,视音频同步算法正式进入计算环节。首先,这里计算了一个上一帧的duration。在serial相同即没有进行过seek的情况下,vp->serial == nextvp->serial肯定是true,所以进到里边的计算逻辑。这个计算也非常直观,就是拿新帧的pts减掉老帧的pts,获取一个准确的duration。然后如果这个duration算出来数据有问题(比如小于0等等),就直接用vp结构体里面的vp->duration作为结果。至于为什么不直接用vp->duration作为返回值,而是要算一遍nextvp->pts - vp->pts,个人认为可能用pts的这个数值的计算更加准确吧。这里也给出源码如下:
...
/* compute nominal last_duration */
last_duration = vp_duration(is, lastvp, vp);
...

static double vp_duration(VideoState *is, Frame *vp, Frame *nextvp) {
    if (vp->serial == nextvp->serial) {
        double duration = nextvp->pts - vp->pts;
        if (isnan(duration) || duration <= 0 || duration > is->max_frame_duration)
            return vp->duration;
        else
            return duration;
    } else {
        return 0.0;
    }
}
  • **计算delay:**上面算完last_duration之后,紧接着再算一个delay。可以看到,delay的计算步骤稍微多一点。从计算实现代码compute_target_delay头部开始看,首先通过变量声明引入了两个概念sync_thresholddiff,需要在这里明确一下。先说第二个diff,在默认情况下,一般是把视频同步到音频时钟上,那么这里的diff就是当前视频时钟和主时钟(也就是音频时钟)的差值。而第一个sync_threshold表示同步阈值,如果diff差值在$[-sync_threshold, +sync_threshold]$这个范围内,认为这时候不需要同步,因为人眼基本看不出视音频之间的不同步情况。而$diff \leq -sync_threshold$,就说明视频太慢了,这时候就要减小delay甚至是丢帧来让视频赶上音频。反之$diff \geq sync_threshold$就说明视频放的太快了,这时候就要让当前帧播放的久一点(也就是主线程睡得久一点),来让音频赶上视频。

    建立了上面对于音视频同步原理的基本认知后,下面的代码就非常容易理解了。首先就是计算diff = get_clock(&is->vidclk) - get_master_clock(is);,然后计算一个sync_threshold(这里的同步阈值可以看到不是写死的,是算出来的,根据算式来看如果delay在$[AV_SYNC_THRESHOLD_MIN, AV_SYNC_THRESHOLD_MAX]$区间就是delay,否则就是区间两个端点)。计算完毕后,如果diff在可以处理的同步范围内(ffplay的逻辑是如果音视频失同步的差值太大就直接不同步了,随便放),那么分三种情况讨论。第一种if (diff <= -sync_threshold)就是视频太慢了,那就和上面说的一样,用delay = FFMAX(0, delay + diff);delay变小(这个算式一般来说算出的delay就是0,因为diff <= -sync_threshold然后sync_threshold一般又等于delay)。然后第二和第三种情况都是视频比音频快,这时候根据快多少分别让delay = delay + diff;或者直接delay = 2 * delay;,很好理解,不再赘述了,算完了之后这个函数就返回了。

...
delay = compute_target_delay(last_duration, is);
...

static double compute_target_delay(double delay, VideoState *is)
{
    double sync_threshold, diff = 0;

    /* update delay to follow master synchronisation source */
    if (get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER) {
        /* if video is slave, we try to correct big delays by
           duplicating or deleting a frame */
        diff = get_clock(&is->vidclk) - get_master_clock(is);

        /* skip or repeat frame. We take into account the
           delay to compute the threshold. I still don't know
           if it is the best guess */
        sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
        if (!isnan(diff) && fabs(diff) < is->max_frame_duration) {
            if (diff <= -sync_threshold)
                delay = FFMAX(0, delay + diff);
            else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
                delay = delay + diff;
            else if (diff >= sync_threshold)
                delay = 2 * delay;
        }
    }

    av_log(NULL, AV_LOG_TRACE, "video: delay=%0.3f A-V=%f\n",
            delay, -diff);

    return delay;
}
  • **计算remaining_time后直接返回event_loop:**上面两个关键数据变量计算完毕后,可以开始计算最终的remaining_time也就是线程要睡的时间,进入渲染或者丢帧逻辑了。这部分代码的第一个分支位于if (time < is->frame_timer + delay)。这里的time是当前时刻,is->frame_timer是上一帧被渲染的时刻,delay就是刚才算出来的这帧应该显示多久的数据。time < is->frame_timer + delay说明当前时间段这个帧应该在SDL窗口中上场渲染了。这时候直接开始计算remaining_time。为啥这里remaining_time不直接等于delay呢?这是因为上面的各种计算代码和上一帧渲染之后走的所有代码逻辑也会耗费时间,而这段时间就是算在delay里面的,所以说这时候要是直接睡delay就太多了。建立了这个认识之后,*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);这句也非常容易理解了,就是从delay中把之前代码的运行时间给扣掉,然后这时候算出来的remaining_time就能正确被外部event_loop拿去当做睡的时长了。
time= av_gettime_relative()/1000000.0;
if (time < is->frame_timer + delay) {
    *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
    goto display;
}
  • **如果上面没返回,那就开始丢帧:**这里就是第二个情况,可知当前的帧按照音视频同步逻辑显示完后的时间还比当前时刻早(没赶上当前时刻),就说明这个帧已经没法同步了,就得丢掉。丢的时候,首先更新is->frame_timer += delay;记录一下这个帧处理完的时刻,然后用丢掉的这个帧更新视频时钟(就是update_video_pts(is, vp->pts, vp->serial);,因为丢帧其实可以理解为这个帧已经放完了,所以这时候视频时钟的pts应该同步到这个帧的pts)。然后如果视频FrameQueue也就是is->pictq中还有帧,那么就更新一下duration,然后直接取下一帧,同时丢帧总数is->frame_drops_late++;更新一下,重新retry再走一遍上面的步骤,直到取出的帧能播为止。
is->frame_timer += delay;
if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
    is->frame_timer = time;

SDL_LockMutex(is->pictq.mutex);
if (!isnan(vp->pts))
    update_video_pts(is, vp->pts, vp->serial);
SDL_UnlockMutex(is->pictq.mutex);

if (frame_queue_nb_remaining(&is->pictq) > 1) {
    Frame *nextvp = frame_queue_peek_next(&is->pictq);
    duration = vp_duration(is, vp, nextvp);
    if(!is->step && (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) && time > is->frame_timer + duration){
        is->frame_drops_late++;
        frame_queue_next(&is->pictq);
        goto retry;
    }
}

上面的逻辑走完后,算出来的remaining_time送回到event_loop,里面紧接着就是一个if (remaining_time > 0.0) av_usleep((int64_t)(remaining_time * 1000000.0));入睡,从而完美收尾了整套音视频同步逻辑。

2023-07-07更新:人生第一次成功给FFmpeg提PR,还被合了哈哈哈哈。虽然只是个typo,还是值得纪念一下😁

https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/79f41a0760780d997ef02e56cec0db72303bed0a image image image

About

Some personal insights on ffplay.c, for learning ffplay.c

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published