音视频-ffplay的音视频同步

最近自己在做一个视频播放器,渲染视频帧时有些疑惑,所以特意来学习一下ffplay中是如何处理视频帧的渲染的!😊

我自己最初的理解是这样

1、只关心视频本身的时间戳,不考虑音视频同步以及其他的同步时钟,或者说以视频帧的时钟为主时钟,那就是渲染一帧视频,然后渲染线程休眠duration时长,再渲染下一帧。

2、视频帧的时钟需要和音频时钟或者其他的主时钟对其,这种情况就会比较复杂一点,每一次渲染都需要和主时钟来检测,当前帧应该立即渲染,延迟还是跳过等等。大致的思路如下:

        获取当前帧的时长,duration;

        然后获取视频时钟与主时钟的差值diff;

        不需要调整的最小阈值sync_min;不需要调整的最大阈值sync_max;小于sync_min时,不需要同步调整,位于sync_min和sync_max之间,需要做调整,大于sync_max时,可能需要跳帧。这个比较还需要考虑本身视频帧的duration,同时还需要从diff的

正负,也就是当前视频始终比主时钟提前还是滞后来判断。

现在来看看ffplay中,是如何来实现这个逻辑的。

ffplay定义了几个阈值如下:

/* no AV sync correction is done if below the minimum AV sync threshold */
#define AV_SYNC_THRESHOLD_MIN 0.04
/* AV sync correction is done if above the maximum AV sync threshold */
#define AV_SYNC_THRESHOLD_MAX 0.1
/* If a frame duration is longer than this, it will not be duplicated to compensate AV sync */
#define AV_SYNC_FRAMEDUP_THRESHOLD 0.1
/* no AV correction is done if too big error */
#define AV_NOSYNC_THRESHOLD 10.0

 比较清晰,从注释就可以看出来含义,不用过多说明。

现在从代码来进入正题,在ffplay.c中有个video_refresh(void *opaque, double *remaining_time),第一个参数就是VideoState,里面保存了播放中的所有数据和状态,第二个可以看到,传入的是一个double指针,这个值在当前修改之后,其值会保留,就是渲染下一帧之前的休眠时长。

static void video_refresh(void *opaque, double *remaining_time) {
    VideoState *is = opaque;
    ...

    // lastvp和vp都是AVFrame;
    // lastvp是上一次展示的视频帧,vp是当前即将展示的视频帧;
    if (lastvp->serial != vp->serial) {
        is->frame_timer = av_gettime_relative() / 1000000.0;
    }

    ...
    // 计算当前帧的时长;
    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;
    }

    ...
    // display picture;
    video_display(is);
    ...
}

正常情况下的主要流程就是以上,重点看看vp_duration和compute_target_delay这两个函数的细节。

首先是vp_duration:

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;
    }
}

这个函数比较简单,两个Frame的serial正常情况下都是相等的,所以这个条件先跳过。使用当前帧与后一帧的差值来作为当前帧的展示时长,如果这个差值异常,就是用当前帧的duration。由于当前帧的duration是在解码之后算出来的,所以在能获取下一帧的情况下,优先使用这个差值。

接下来是compute_target_delay:

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;
}

第一个参数delay实际上是前一步得到的当前帧的展示时长,以此为基础来计算具体的实际展示时长。 

1、如果视频时钟作为主时钟时,就直接返回当前delay;

2、视频时钟不是主时钟,就算当前时钟与主时钟的差值diff,然后开始来判断如何同步;

3、以已经定义同步调整上下限为区域,来确定当前要进行同步的阈值,sync_threshold;

4、max_frame_duration是当前视频帧连续的时长阈值,如果diff超过了这个范围,说明此时已经的时间戳已经不连续,此时不属于连续播放,所以不考虑这个情况。接下来的判断可以用下图来表示了:

 

 1、第一个判断,diff属于上图中1的范围,此时,diff+delay应该是在图中3和4的范围,在3范围时,说明当前视频时钟加上当前帧的展示时长仍后落后于主时钟,所以当前帧应该立即展示;如果在4范围,则说明当前帧的展示需要延长一段时间。

2、第一个条件不满足时,diff落在图中2的范围,并且当前帧的展示时长大于AV_SYNC_FRAMEDUP_THRESHOLD,表示此时不会通过复制当前帧来做同步操作,所以直接将diff+delay作为当前帧的实际展示时长。

3、最后一种情况,是当前帧的展示时长不大于AV_SYNC_FRAMEDUP_THRESHOLD,将展示时长的2倍作为延迟,这种情况相当于是复制了当前帧吧。

上面这个方法,主要是将视频时钟与当前主时钟的同步操作,然后还需要计算与前一次视频帧的渲染时间来确定当前帧的渲染已经线程需要需要休眠的时间。

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);

...

frame_queue_next(&is->pictq);

再回头来看以上代码,已知delay是前面计算完成的当前帧实际展示时长,首先time,是获取当的相对时间,is->frame_timer是上一次画面更新的相对时间。

所以第一个判断的逻辑的意义就是,上一次画面更新时间+当前帧时长超过了这个当前时间,表示当前应该展示当前帧,所以调用了直接展示当前帧的逻辑,同时也更新了线程的休眠时长。

如果以上判断不成立,就把当前帧的展示时长累加到frame_timer中,接下来的判断就是,累加当前帧时长之后,仍然没有达到当前时间,并且差值大于同步调整的最大阈值,就将当前的时间作为frame_timer,然后展示当前帧。

然后更新视频帧展示的时钟数据,并且将视频帧队列往后推进,绘制画面。

另外,看一下上面代码中vp和lastvp的来历。

lastvp = frame_queue_peek_last(&is->pictq);
vp = frame_queue_peek(&is->pictq);

// function implementations;
static Frame *frame_queue_peek_last(FrameQueue *f) {
    return &f->queue[f->rindex];
}

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

可以看到,在rindex_shown为0的时候,vp和lastvp其实是同一个位置,在ffplay中这个rindex_shown在队列执行第一次next的时候,会被置为1,并且一直保持这个值。代码中也有使用lastvp、vp、nextvp(索引位置是(f->rindex + f->rindex_shown) + 1)来判断的逻辑,我个人的理解,这样来判断帧与帧之间的关系应该会更可靠一点吧。

以上方法是ffplay作者的思路,也给了我一些启发,从注释里面可以看出来作者其实也有一些疑惑的地方,当然我的疑惑可能比作者更多一点,但大体思路应该是类似,所以我要准备自己去实现一下这个东西,不知道会遇到点什么东西🤔。

如果有幸被大佬们看到,也希望指点一二,感激不尽🫡。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值