FFplay播放avsync学习

FFplay有三种同步方式

命令行通过option sync参数可以设置同步方式

  • sync参数取值范围为:audio/video/ext

audio以音频时钟为主时钟,默认方式

  • 以音频为主时钟的逻辑,拉长或者缩短视频帧的显示时长,或者丢弃视频帧。

video以视频时钟为主时钟:

  • 以视频为主时钟,就是拉长或者缩短音频帧的播放时长(resample),但是不会丢弃音频帧。音频帧连续性太长,丢帧很容易被耳朵发现。

ext以外部时钟为主时钟:

ffmpeg中sync master的类型定义:

c 复制代码
enum {
    AV_SYNC_AUDIO_MASTER,   /* default choice */
    AV_SYNC_VIDEO_MASTER,
    AV_SYNC_EXTERNAL_CLOCK, /* synchronize to an external clock */
};

音视频同步原理

视频同步到音频的基本方法是:如果视频超前音频,继续显示上一帧,以等待音频;如果视频落后音频,则显示下一帧,以追赶音频。

无论是音频还是视频,播放的逻辑都是,在预定的时间(pts)播放对应的frame。

通常会有一个get_clock函数,用来获取当前视频流或者音频流播放到哪里。

以音频时钟为主

  • 视频时钟音频时钟快,等待一段时间再显示
  • 视频时钟音频时钟慢,在阈值范围内,立即播放,超过阈值丢帧处理

ffmpeg以音频时钟为主时钟

解码后的同步预处理

对于视频同步处理在ffplay有两处地方,一是在函数get_video_frame做了简单的丢帧处理,二是在函数video_refresh显示控制时做的同步处理。

对于函数get_video_frame丢帧处理的主要逻辑如下:

c 复制代码
    // 同步时钟不以视频为基准时
    if (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) {
        if (frame->pts != AV_NOPTS_VALUE) {
            // 理论上如果需要连续接上播放的话:  dpts + diff = get_master_clock(is)
            // 所以可以算出diff, 注意绝对值
            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;
            }
        }
    }

就是解码出来的帧已经来不及显示了,直接丢弃。

显示时AV同步

在视频播放线程中,视频播放函数video_refresh实现了视频显示和同步控制,这个函数的调用过程如下:

main()
--> event_loop()
   --> refresh_loop_wait_event()
      --> video_refresh()

其中函数video_refresh具体如下:

c 复制代码
/**
 * 显示视频
 * @param opaque
 * @param remaining_time
 */
static void video_refresh(void *opaque, double *remaining_time)
{
    VideoState *is = opaque;
    double time;

    Frame *sp, *sp2;

    // 外部时钟为基准,忽略
    if (!is->paused && get_master_sync_type(is) == AV_SYNC_EXTERNAL_CLOCK && is->realtime)
        check_external_clock_speed(is);

    if (!display_disable && is->show_mode != SHOW_MODE_VIDEO && is->audio_st) {
        time = av_gettime_relative() / 1000000.0;
        if (is->force_refresh || is->last_vis_time + rdftspeed < time) {
            video_display(is);
            is->last_vis_time = time;
        }
        *remaining_time = FFMIN(*remaining_time, is->last_vis_time + rdftspeed - 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);

            if (vp->serial != is->videoq.serial) {
                // 不在同一个播放序列了,丢弃
                frame_queue_next(&is->pictq);
                goto retry;
            }

            if (lastvp->serial != vp->serial)
                // 不在同一个播放序列,更改最新帧的时间
                is->frame_timer = av_gettime_relative() / 1000000.0;

            if (is->paused)
                // 如果是暂停状态,则更新显示
                goto display;

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

            // 应该显示下一帧了
            // 更新播放时间,与上面 time < is->frame_timer + delay 判断条件对应
            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->pos, 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;
                }
            }

            frame_queue_next(&is->pictq);
            is->force_refresh = 1;

            if (is->step && !is->paused)
                stream_toggle_pause(is);
        }
display:
        /* display picture */
        if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)
            video_display(is);
    }
}

这个函数的核心逻辑是:

  1. 获取正在播放的帧与下一帧,如果播放序列变了则重试,通过两帧计算出正在播放的帧理想情况下应该播放多长时间。
  2. 函数vp_duration就是通过两帧的pts差值计算,通过函数compute_target_delay算出当前播放帧真正的播放时间

计算视频帧显示需要的时间(delay)

  • delay等于0,立即显示
  • delay如果大于阈值,丢帧
  • 如果小于阈值,延时delay时长显示
c 复制代码
    /* compute nominal last_duration */
    last_duration = vp_duration(is, lastvp, vp);
    delay = compute_target_delay(last_duration, is);

last_duration:两帧之间显示间隔
delay:视频帧需要显示的时长

compute_target_delay

c 复制代码
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 */
        // 计算视频时钟和master时钟的差距
        // 如果是以音频时钟为基准,那么get_master_clock拿到的就是音频时钟的pts
        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置为0,因为:
                // delay + diff大于0的情况好像不太可能,这里的条件是视频比音频晚超过一个过同步阈值
                delay = FFMAX(0, delay + diff);
            else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
                // 视频快了,且超过了同步阈值和大于帧复制阈值
                // AV_SYNC_FRAMEDUP_THRESHOLD:帧复制阈值
                delay = delay + diff;
            else if (diff >= sync_threshold)
                // 视频快了,大于阈值, delay传进来的是last_duration,
                // 这时候delay就是两个duration周期
                delay = 2 * delay;
        }
    }

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

    return delay;
}

update_video_pts更新视频pts

c 复制代码
static void update_video_pts(VideoState *is, double pts, int64_t pos, int serial) {
    /* update current video pts */
    set_clock(&is->vidclk, pts, serial);
    sync_clock_to_slave(&is->extclk, &is->vidclk);
}

static void set_clock(Clock *c, double pts, int serial)
{
    double time = av_gettime_relative() / 1000000.0;
    set_clock_at(c, pts, serial, time);
}

static void set_clock_at(Clock *c, double pts, int serial, double time)
{
    c->pts = pts;
    c->last_updated = time;
    c->pts_drift = c->pts - time;
    c->serial = serial;
}

音频和视频每次在播放新的一帧数据时都会调用函数set_clock更新音频时钟或视频时钟。

其中pts_drift是当前帧的pts与系统时间system clock的差值,有了这个差值在未来的某一刻就能够很方便地算出当前帧对于的时钟点。

所以pts_drift字段实际上是由两个字段组成,当前帧的pts跟之前记录的系统时间,这两个字段的信息量合在一起存储就会看起来有点奇怪,是一个很大的负数,要理解它,结合后面的get_clock就好理解了。

get_clock的逻辑

c 复制代码
typedef struct Clock {
    double pts;          // 当前正在播放的帧的pts
    double pts_drift;    // 当前的pts与系统时间的差值,保持设置pts时候的差值
                         // 后面就可以利用这个差值推算下一个pts播放的时间点
    double last_updated; // 最后一次更新时钟的时间,应该是一个系统时间
    double speed;        // 播放速度
    int serial;          // 播放序列      /* clock is based on a packet with this serial */
    int paused;          // 是否暂停
    int *queue_serial;   // 队列的播放序列 PacketQueue中的 serial
} Clock;

get_clockpts_drift加上了当前系统时间:

c 复制代码
static double get_clock(Clock *c)
{
    // 如果时钟的播放序列与待解码包队列的序列不一致了,返回NAN
    // 肯定就是不同步或者需要丢帧
    if (*c->queue_serial != c->serial)
        return NAN;
    if (c->paused) {
        // 暂停状态则返回原来的pts
        return c->pts;
    } else {
        // speed可以先忽略播放速度控制
        // 如果speed是1倍播放速度,c->pts_drift + time
        double time = av_gettime_relative() / 1000000.0;
        return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);
    }
}

get_clock里面会用av_gettime_relative获取当前的系统时间,所以get_clock 的计算公式可以如下分三步推出来:

css 复制代码
1. 视频流当前的播放时刻 = 当前帧的 pts - 之前记录的系统时间 + 当前的系统时间
2. 视频流当前的播放时刻 = 当前帧的 pts + 当前的系统时间 - 之前记录的系统时间
3. 视频流当前的播放时刻 = 当前帧的 pts + 消逝的时间

最后就是:

视频流当前的播放时刻 = 当前帧的 pts + 消逝的时间

这里对照set_clock_atpts_drift的计算:

pts_drift = 当前帧的pts - 之前记录的系统时间

sync_threshold阈值的计算

  • 不同帧率计算的阈值不一样,帧率越低,阈值越大
  • 根据帧间隔来,最大是一个帧间隔,最小ffmpeg里面定义的是0.04
c 复制代码
/* 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

sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));

渲染线程

视频播放线程就是main主线程,对于FFplay播放器,就是在主线程里面播放视频流的,如下:

c 复制代码
/* handle an event sent by the GUI */
static void event_loop(VideoState *cur_stream)
{
    SDL_Event event;
    double incr, pos, frac;

    for (;;) {
        double x;
        refresh_loop_wait_event(cur_stream, &event);
        switch (event.type) {}
    }
}

event_loop会不断用 refresh_loop_wait_event函数检测是否有键盘事件发生,如果有键盘事件发生, refresh_loop_wait_event就会返回,然后跑到switch{event.type}{...}来处理键盘事件。

如果没有键盘事件发生, refresh_loop_wait_event就不会返回,只会不断循环,不断去播放视频流的画面,如果remaining_time大于0,就会睡眠等待remaining_time时长。

c 复制代码
static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {
    double remaining_time = 0.0;
    SDL_PumpEvents();
    while (!SDL_PeepEvents(event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT)) {
        if (!cursor_hidden && av_gettime_relative() - cursor_last_shown > CURSOR_HIDE_DELAY) {
            SDL_ShowCursor(0);
            cursor_hidden = 1;
        }
        if (remaining_time > 0.0)
            av_usleep((int64_t)(remaining_time * 1000000.0));
        remaining_time = REFRESH_RATE;
        if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
            video_refresh(is, &remaining_time);
        SDL_PumpEvents();
    }
}

其他线程

除了主线程中会refresh video之外,还有4个主要线程:

  • read thread
  • audio thread
  • video thread
  • subtitle thread

总结

ffmpeg以音频为主的同步,就是先获取当前音频帧render的时长,然后根据当前视频帧的pts计算视频帧和音频帧渲染的diff值,然后根据diff值,计算视频帧需要显示的时间长度。

计算显示时长:

  • diff > sync_threshold,视频早了,显示的时间要更长:delay = last_duration + diff或者delay = 2 * last_duration
  • diff <= -sync_threshold:视频晚了,晚的范围大于一个阈值:delay = 0

然后根据delay计算是否送显示,并更新frame_timer,最后显示是根据frame_timer。

至于丢帧要看下后面的处理,当前的时间大于播放时间frame_timer加上duration就丢帧:

c 复制代码
    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;
        }
   } 

还有一个丢帧逻辑在get_video_frame中,在前面有提到。

参考

ffplay音视频同步 | FFmpeg音视频开发
FFplay视频同步分析---ffplay.c源码分析

相关推荐
EasyCVR3 小时前
EHOME视频平台EasyCVR视频融合平台使用OBS进行RTMP推流,WebRTC播放出现抖动、卡顿如何解决?
人工智能·算法·ffmpeg·音视频·webrtc·监控视频接入
简鹿办公4 小时前
使用 FFmpeg 进行音视频转换的相关命令行参数解释
ffmpeg·简鹿视频格式转换器·ffmpeg视频转换
EasyCVR7 小时前
萤石设备视频接入平台EasyCVR多品牌摄像机视频平台海康ehome平台(ISUP)接入EasyCVR不在线如何排查?
运维·服务器·网络·人工智能·ffmpeg·音视频
runing_an_min7 小时前
ffmpeg 视频滤镜:屏蔽边框杂色- fillborders
ffmpeg·音视频·fillborders
岁月小龙18 小时前
如何让ffmpeg运行时从当前目录加载库,而不是从/lib64
ffmpeg·origin·ffprobe·rpath
行者记2 天前
ffmpeg命令——从wireshark包中的rtp包中分离h264
测试工具·ffmpeg·wireshark
EasyCVR2 天前
国标GB28181视频平台EasyCVR私有化视频平台工地防盗视频监控系统方案
运维·科技·ffmpeg·音视频·1024程序员节·监控视频接入
hypoqqq2 天前
使用ffmpeg播放rtsp视频流
ffmpeg
cuijiecheng20182 天前
音视频入门基础:FLV专题(24)——FFmpeg源码中,获取FLV文件视频信息的实现
ffmpeg·音视频
QMCY_jason2 天前
黑豹X2 armbian 编译rkmpp ffmpeg 实现CPU视频转码
ffmpeg