FFplay有三种同步方式
命令行通过option sync
参数可以设置同步方式
sync
参数取值范围为:audio/video/ext
audio
以音频时钟为主时钟,默认方式
- 以音频为主时钟的逻辑,拉长或者缩短视频帧的显示时长,或者丢弃视频帧。
video
以视频时钟为主时钟:
- 以视频为主时钟,就是拉长或者缩短音频帧的播放时长(resample),但是不会丢弃音频帧。音频帧连续性太长,丢帧很容易被耳朵发现。
ext
以外部时钟为主时钟:
ffmpeg中sync master的类型定义:
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
丢帧处理的主要逻辑如下:
// 同步时钟不以视频为基准时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具体如下:
/*** 显示视频* @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);}
}
这个函数的核心逻辑是:
- 获取正在播放的帧与下一帧,如果播放序列变了则重试,通过两帧计算出正在播放的帧理想情况下应该播放多长时间。
- 函数
vp_duration
就是通过两帧的pts差值计算,通过函数compute_target_delay
算出当前播放帧真正的播放时间
计算视频帧显示需要的时间(delay)
- delay等于0,立即显示
- delay如果大于阈值,丢帧
- 如果小于阈值,延时delay时长显示
/* compute nominal last_duration */last_duration = vp_duration(is, lastvp, vp);delay = compute_target_delay(last_duration, is);
last_duration
:两帧之间显示间隔
delay
:视频帧需要显示的时长
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 byduplicating or deleting a frame */// 计算视频时钟和master时钟的差距// 如果是以音频时钟为基准,那么get_master_clock拿到的就是音频时钟的ptsdiff = get_clock(&is->vidclk) - get_master_clock(is);/* skip or repeat frame. We take into account thedelay to compute the threshold. I still don't knowif 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
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
的逻辑
typedef struct Clock {double pts; // 当前正在播放的帧的ptsdouble 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_clock
中pts_drift
加上了当前系统时间:
static double get_clock(Clock *c)
{// 如果时钟的播放序列与待解码包队列的序列不一致了,返回NAN// 肯定就是不同步或者需要丢帧if (*c->queue_serial != c->serial)return NAN;if (c->paused) {// 暂停状态则返回原来的ptsreturn c->pts;} else {// speed可以先忽略播放速度控制// 如果speed是1倍播放速度,c->pts_drift + timedouble 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
的计算公式可以如下分三步推出来:
1. 视频流当前的播放时刻 = 当前帧的 pts - 之前记录的系统时间 + 当前的系统时间
2. 视频流当前的播放时刻 = 当前帧的 pts + 当前的系统时间 - 之前记录的系统时间
3. 视频流当前的播放时刻 = 当前帧的 pts + 消逝的时间
最后就是:
视频流当前的播放时刻 = 当前帧的 pts + 消逝的时间
这里对照set_clock_at
中pts_drift
的计算:
pts_drift = 当前帧的pts - 之前记录的系统时间
sync_threshold阈值的计算
- 不同帧率计算的阈值不一样,帧率越低,阈值越大
- 根据帧间隔来,最大是一个帧间隔,最小ffmpeg里面定义的是0.04
/* 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.1sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
渲染线程
视频播放线程就是main
主线程,对于FFplay
播放器,就是在主线程里面播放视频流的,如下:
/* 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
时长。
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
就丢帧:
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源码分析