FFplay 是 FFmpeg 项目中内置的轻量级媒体播放器,作为展示 FFmpeg 核心功能的工具,它能够高效播放音频和视频文件。通过分析 FFplay 的源码,开发者可以深入理解多媒体处理的关键环节,包括解码、同步和渲染机制。本文将深入剖析 FFplay 源码的重点模块,揭示 FFplay 作为高效、灵活媒体播放解决方案的设计原理与实现细节。
FFMPEG 源码版本:7.1.1
1. 整体架构
FFplay 是基于 FFmpeg 库的一个简单但功能完整的媒体播放器,它采用了模块化设计和多线程架构,以实现高效的媒体解码和播放,整体架构如下图所示。

1.1. 数据结构
FFplay 的核心数据结构是 VideoState,它包含了播放器的所有状态和组件,重要数据包括:
1)三类数据:音频、视频、字幕
2)两类队列:解码队列、帧队列
3)三种时钟:音频时钟、视频时钟和外部时钟
4)三种解码器:音频解码器、视频解码器和字幕解码器
1.2. 线程模型
FFplay 采用多线程架构,主要包含以下线程:
1)主线程(渲染线程)
- 负责初始化播放器
- 处理用户输入和事件循环
- 控制播放状态(暂停/恢复/跳转等)
- 刷新视频显示
2)读取线程 (read_thread)
- 从媒体源读取压缩的数据包
- 将数据包分发到相应的队列(音频/视频/字幕)
- 处理跳转请求
- 处理播放器状态变化(如暂停/恢复)
3)解码线程
- 音频解码线程: 解码音频帧并应用音频过滤器
- 视频解码线程: 解码视频帧并应用视频过滤器
- 字幕解码线程: 解码字幕数据
4)播放线程(SDL音频回调)
- 在单独的高优先级线程中运行
- 负责从解码后的音频缓冲区获取数据送入音频设备
- 更新音频时钟
2. 处理流程
2.1. Read Thread
读取线程打开输入文件(网络流),获取媒体流信息,初始化解码器和过滤器,启动对应媒体的解码线程,然后就开始自己的本职工作:循环读取数据包。

循环读取的数据包会插入到对应的解码队列,在插入队列前,需要处理暂停、Seek、退出等控制逻辑。

2.2. Decode Thread
解码线程的核心功能就是从解码队列读取数据包解码为帧,然后并对帧应用过滤器(如果有的话),最后将处理完的帧插入到帧队列等待渲染。
2.2.1. Audio Decode
音频解码后,如果发现音频格式、声道布局或采样率发生变化,需要重新配置过滤器。

2.2.2. Video Decode
视频解码也是一样,如果视频帧宽高、格式发生变化,也要重建视频过滤器。

2.2.3. decoder_decode_frame
音视频解码复用相同的解码逻辑,核心逻辑就是调用 avcodec_send_packet 送入待解码数据包,调用 avcodec_receive_frame 取出解码成功的帧。只是解码过程中,还需要配合做一些控制逻辑,比如解码队列为空后通知读取线程、退出、serial 变化刷新解码器等。

2.3. Main Thread
主线程在程序启动时做了很多初始化工作,随后就进入事件循环处理用户事件和视频刷新,如下代码所示:
static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {
double remaining_time = 0.0;
// 更新 SDL 的事件队列
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;
}
// 避免占用过多 CPU 资源
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 的事件队列
SDL_PumpEvents();
}
}
3. 音视频同步
FFplay 采用了一套精心设计的音视频同步系统,确保音频和视频在播放过程中的同步,这套系统具备高度的灵活性,能够处理各种复杂情况,通过以下关键机制实现高质量的同步:
1)灵活的主时钟选择:支持三种同步模式,适应不同场景。
2)支持双向同步调整:视频通过调整显示延迟同步,音频通过调整采样数同步。
3)平滑平均同步算法:使用指数移动平均和阈值,避免同步调整过于频繁和剧烈
4)精确严密时钟管理:考虑硬件缓冲区和播放延迟,准确计算各时钟值
5)强大的序列号机制:基于 serial 处理非连续播放的同步问题
3.1. 时钟
时钟用来标识播放进度,是音视频同步的基础。它通过精确跟踪音视频的播放时间点,确保各种媒体流能够协调一致地播放。
3.1.1. 音频时钟
音频时钟记录当前播放音频帧的 PTS 时间戳(单位:秒)。独立播放音频时,系统根据设定参数定期回调应用层代码,持续写入音频数据,推动 PTS 向前滚动。

在计算音频时钟 PTS 时,需借助 audio_clock。audio_clock 记录播放缓冲区中已处理(解码、过滤、重采样)的最后一帧音频时间戳。若播放缓冲区为空,则为送入 SDL 的最后一帧音频时间戳。同时,需考虑播放设备驱动缓冲区与硬件缓冲区的延迟,最终音频时钟 PTS 通过以下公式计算(FFplay 假设驱动缓冲区和硬件缓冲区长度一样):
pts = audio_clock - (drv_buf_size + hw_buf_size + audio_write_buf_size) / bytes_per_sec
3.1.2. 视频时钟
视频时钟记录当前渲染视频帧的 PTS 时间戳(单位:秒)。视频可独立播放,依靠自身机制实现。具体而言,每渲染一帧后,根据该帧播放时长计算下一帧渲染时间,待系统时间到达后完成渲染,进入下一循环。与音频时钟 PTS 由系统定时器驱动不同,视频时钟 PTS 由系统时间结合帧时长形成的模拟定时器驱动。

3.1.3. 外部时钟
外部时钟比较简单,本质就是系统时间戳,是自驱动的,其 pts = pts_drift + time,具体请参考源码。

3.2. 同步
基于各自时钟,音视频都具备独立播放的能力。但当自己不是主时钟时,就需要进行同步了,所谓的同步,是指自己的时钟如何追赶和等待主时钟。具体来说,音频同步和视频同步的逻辑和策略稍微有些差异。
3.2.1. 音频同步
音频的播放是由系统驱动的,在回调函数 sdl_audio_callback 中,会调用 audio_decode_frame 函数获取处理后的音频帧,放入音频播放缓冲区。然后从音频播放缓冲区拷贝音频数据到设备播放缓冲区。
static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{
VideoState *is = opaque;
int audio_size, len1;
// 更新音频回调时间
audio_callback_time = av_gettime_relative();
// 写入指定长度的音频数据到播放缓冲区
while (len > 0) {
// 如果音频缓冲区为空,则解码音频帧
if (is->audio_buf_index >= is->audio_buf_size) {
// 从音频帧队列中获取一帧音频数据
audio_size = audio_decode_frame(is);
if (audio_size < 0) {
// 没有获取到音频数据,则填充静音数据
is->audio_buf = NULL;
// 确保 audio_buf_size 为 audio_tgt.frame_size 的整数倍
is->audio_buf_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_tgt.frame_size * is->audio_tgt.frame_size;
} else {
// 拷贝解码后的音频数据到 sample_array,用于音频可视化
if (is->show_mode != SHOW_MODE_VIDEO)
update_sample_display(is, (int16_t *)is->audio_buf, audio_size);
is->audio_buf_size = audio_size;
}
is->audio_buf_index = 0;
}
// 可用音频数据长度
len1 = is->audio_buf_size - is->audio_buf_index;
if (len1 > len)
len1 = len;
// 如果是非静音状态,且音量为最大值,则直接复制音频数据到输出缓冲区
if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)
memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
else { // 如果是静音状态,或者音量不是最大值,则使用SDL_MixAudioFormat函数进行音量混合
memset(stream, 0, len1);
if (!is->muted && is->audio_buf)
SDL_MixAudioFormat(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, AUDIO_S16SYS, len1, is->audio_volume);
}
len -= len1;
stream += len1;
is->audio_buf_index += len1;
}
// 更新音频写入缓冲区大小
is->audio_write_buf_size = is->audio_buf_size - is->audio_buf_index;
/* Let's assume the audio driver that is used by SDL has two periods. */
if (!isnan(is->audio_clock)) {
set_clock_at(&is->audclk,
// audio_clock 是最后一个音频 frame 的时间戳。
// 基于 audio_clock 计算真实播放时间戳时要考虑:
// 1)剩余未拷贝到播放缓冲区的音频数据
// 2)驱动缓冲区
// 3)硬件缓冲区
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);
}
}
audio_decode_frame 从音频帧队列中获取一帧数据,根据同步规则计算所需采样数,然后调用 swr_set_compensation 实现变速,最后更新 audio_clock。
static int audio_decode_frame(VideoState *is)
{
...
do {
...
// 从音频帧队列中获取一个可读的音频帧(阻塞)
if (!(af = frame_queue_peek_readable(&is->sampq)))
return -1;
// 将音频帧从队列中移除
frame_queue_next(&is->sampq);
} while (af->serial != is->audioq.serial);
// 计算音频帧的大小
data_size = av_samples_get_buffer_size(NULL, af->frame->ch_layout.nb_channels,
af->frame->nb_samples,
af->frame->format, 1);
// 根据同步类型计算所需的音频采样数量
// 1)如果音频是主时钟,则不需调整,按照原始音频数据顺序播放即可
// 2)如果音频不是主时钟,则需要根据主时钟调整播放样本数,以实现追赶和等待的效果
wanted_nb_samples = synchronize_audio(is, af->frame->nb_samples);
...
// 使用 swr 来实现音频变速,比粗暴的丢帧或者重复帧效果要好
if (wanted_nb_samples != af->frame->nb_samples) {
if (swr_set_compensation(is->swr_ctx,
(wanted_nb_samples - af->frame->nb_samples) * is->audio_tgt.freq / af->frame->sample_rate,
wanted_nb_samples * is->audio_tgt.freq / af->frame->sample_rate) < 0) {
return -1;
}
}
// 更新 audio_clock:已经处理的最后一个音频帧的时间戳
if (!isnan(af->pts))
is->audio_clock = af->pts + (double) af->frame->nb_samples / af->frame->sample_rate;
else
is->audio_clock = NAN;
// 更新音频时钟序列号
is->audio_clock_serial = af->serial;
return resampled_data_size;
}
synchronize_audio 基于音频时钟与主时钟的差异(平滑值),通过调整音频采样数量,实现音频播放快进和慢放。
static int synchronize_audio(VideoState *is, int nb_samples)
{
// 如果音频是主时钟,则不需要调整音频采样数量
int wanted_nb_samples = nb_samples;
/* if not master, then we try to remove or add samples to correct the clock */
if (get_master_sync_type(is) != AV_SYNC_AUDIO_MASTER) {
double diff, avg_diff;
int min_nb_samples, max_nb_samples;
// 计算音频时钟与主时钟的差异
diff = get_clock(&is->audclk) - get_master_clock(is);
if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD) {
// 使用指数加权移动平均算更新累积差异值
// c(n) = d(n) + a * c(n-1)
// c(n) = d(n) + a * d(n-1) + a^2 * d(n-2) + ...
is->audio_diff_cum = diff + is->audio_diff_avg_coef * is->audio_diff_cum;
if (is->audio_diff_avg_count < AUDIO_DIFF_AVG_NB) {
// 尚未收集足够样本,无法进行估计
is->audio_diff_avg_count++;
} else {
// 计算平均差异
// avg = (1-α) * d(n) + (1-α) * α * d(n-1) + (1-α) * α² * d(n-2) + ...
avg_diff = is->audio_diff_cum * (1.0 - is->audio_diff_avg_coef);
// 当差异值超过阈值时才进行调整
if (fabs(avg_diff) >= is->audio_diff_threshold) {
// 调整音频采样数量
wanted_nb_samples = nb_samples + (int)(diff * is->audio_src.freq);
// 限制调整范围:[90% ~ 110%]
min_nb_samples = ((nb_samples * (100 - SAMPLE_CORRECTION_PERCENT_MAX) / 100));
max_nb_samples = ((nb_samples * (100 + SAMPLE_CORRECTION_PERCENT_MAX) / 100));
wanted_nb_samples = av_clip(wanted_nb_samples, min_nb_samples, max_nb_samples);
}
}
} else {
// 差异过大,可能是初始 PTS 错误,重置
is->audio_diff_avg_count = 0;
is->audio_diff_cum = 0;
}
}
return wanted_nb_samples;
}
3.2.2. 视频同步
视频播放由系统时间 + frame_timer 驱动,当需要渲染视频时调用 video_refresh,内部调用 compute_target_delay 计算帧渲染持续时间,如果视频时主时钟,delay 等于视频帧的 duration,如果视频时钟不是主时钟,则需要根据同步规则对 duration 进行缩放,以实现加速播放和等待。
static void video_refresh(void *opaque, double *remaining_time)
{
VideoState *is = opaque;
double time;
Frame *sp, *sp2;
// 只要有视频流,就需要处理视频帧,即使显示模式不是视频。
if (is->video_st) {
retry:
if (frame_queue_nb_remaining(&is->pictq) == 0) {
// 视频帧队列没有数据,啥也不做
} else {
double last_duration, duration, delay;
Frame *vp, *lastvp;
// 获取上一个已显示的帧
lastvp = frame_queue_peek_last(&is->pictq);
// 获取下一个要显示的帧
vp = frame_queue_peek(&is->pictq);
// 如果当前帧的序列号与视频解码队列的序列号不匹配,可能是seek,跳过当前帧
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;
// 计算上一帧显示持续时间
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;
// 使用当前帧的 PTS 更新视频时钟
SDL_LockMutex(is->pictq.mutex);
if (!isnan(vp->pts))
update_video_pts(is, vp->pts, vp->serial);
SDL_UnlockMutex(is->pictq.mutex);
// 如果帧队列中剩余的帧数大于1,则看下是否需要丢帧
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) // 对于视频来说,keep_last 为真,rindex_shown 必须设置
video_display(is);
}
...
}
关于注释中所说的追赶效应,可以举一个例子来说明:
假设播放器因为系统负载过高而卡顿了1秒:
time= 当前系统时间 = 100.0秒is->frame_timer= 上一帧的显示时间 = 99.0秒- 假设
AV_SYNC_THRESHOLD_MAX= 0.1秒,delay= 0.04秒计算:
time - is->frame_timer= 1.0秒,大于0.1秒如果不重置
frame_timer,系统会计算:
- 下一帧的显示时间 = 99.0 + 0.04 = 99.04秒
- 已经过去了1秒,所以系统会立即显示多个帧来"追赶",导致快速播放
通过重置:
is->frame_timer = time(即100.0秒),下一帧将在 100.0 + 0.04 = 100.04秒显示,恢复正常播放节奏,避免了快速播放的问题。
duration 具体缩放逻辑如下面代码所示,逻辑比较清晰,参考代码注释,不赘述了。
static double compute_target_delay(double delay, VideoState *is)
{
double sync_threshold, diff = 0;
// 如果视频时主时钟,直接返回 delay
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)
// 视频落后于主时钟,减少延迟,追赶主时钟(diff是负数)
delay = FFMAX(0, delay + diff);
else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
// 视频领先于主时钟,增加延迟,等待主时钟(diff是正数)
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;
}
4. 视频渲染
ffplay 的视频渲染支持 SDL 和 vulkan,vulkan 需要配合硬件解码一起使用,本文仅分析 SDL 渲染实现。
SDL 渲染流程比较简单:
1)创建 SDL 窗口
2)创建关联 SDL 窗口的渲染器(renderer)
以上两步在主线程初始化时完成,下面三步针对每一个视频帧反复执行:
3)计算宽高并创建纹理(texture)
4)上传视频帧数据到纹理
5)拷贝纹理到渲染器
为了保证视频渲染的兼容性,FFplay 做了很多细致工作,值得了解和学习:
1)宽高比计算的兼容
2)YUV 到 RGB 色彩转换的兼容
3)颜色空间和混合模式的兼容
4)视频数据存储垂直翻转的兼容。
static void video_image_display(VideoState *is)
{
...
// 获取最近解码的视频帧
vp = frame_queue_peek_last(&is->pictq);
if (vk_renderer) {
// vulkan 渲染
vk_renderer_display(vk_renderer, vp->frame);
return;
}
// 计算视频帧在窗口中的显示区域,需要考虑:
// 1)视频素宽高比
// 2)屏幕宽高比
// 3)设备像素宽高比(没有处理,默认1:1)
calculate_display_rect(&rect,
is->xleft,
is->ytop,
is->width,
is->height,
vp->width,
vp->height,
vp->sar);
// 根据帧的色彩范围和色彩空间来设置 YUV 到 RGB 的转换模式
// 否则可能会导致色彩失真(亮度、色度、饱和度异常/HDR无法正确显示)
set_sdl_yuv_conversion_mode(vp->frame);
// 这里用 uploaded 避免重复上传同一帧(暂停状态可能会重复渲染同一帧)
if (!vp->uploaded) {
// 拷贝视频帧数据到 SDL 纹理
if (upload_texture(&is->vid_texture, vp->frame) < 0) {
set_sdl_yuv_conversion_mode(NULL);
return;
}
vp->uploaded = 1;
// 当 vp->frame->linesize[0] < 0 时,
// 表示视频帧数据是以自下而上的方式存储的,需要在渲染时进行垂直翻转。
vp->flip_v = vp->frame->linesize[0] < 0;
}
// 渲染视频到屏幕
SDL_RenderCopyEx(renderer,
is->vid_texture,
NULL,
&rect,
0,
NULL,
vp->flip_v ? SDL_FLIP_VERTICAL : 0);
// 恢复默认的 YUV 到 RGB 转换模式
set_sdl_yuv_conversion_mode(NULL);
}
5. 播放控制
5.1. 暂停
简单的一个暂停,实现却非常复杂,涉及到两个层面的暂停状态:主暂停状态和时钟暂停状态。主暂停状态是由 VideoState 结构体的 paused 字段表示的全局状态,控制播放器的整体暂停行为;时钟暂停状态是各个时钟对象的内部状态,控制时钟计时的行为。
5.1.1. 主暂停状态相关逻辑
1)控制读取线程行为
当暂停状态变化时,对于支持暂停的网络流(如RTSP),会调用 av_read_pause() 通知底层协议暂停数据传输。
static int read_thread(void *arg)
{
...
for (;;) {
...
// 处理暂停状态变化
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);
}
...
}
...
}
2)控制音频播放
在 SDL 回调中,获取不到音频帧,会填充静音数据。
static int audio_decode_frame(VideoState *is)
{
...
if (is->paused)
return -1;
...
}
3)控制视频渲染
主线程事件循环中,如果主暂停状态置位,则不会调用 video_refresh 渲染视频。
static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {
...
while (!SDL_PeepEvents(event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT)) {
...
if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
video_refresh(is, &remaining_time);
// 更新 SDL 的事件队列
SDL_PumpEvents();
}
}
即使进入到视频渲染逻辑中,检测到主暂停状态置位,会重复显示之前的视频帧。
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 {
...
// 如果播放已暂停,还是显示之前的帧
if (is->paused)
goto display;
...
}
display:
/* display picture */
if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO &&
is->pictq.rindex_shown)
video_display(is);
}
...
}
5.1.2. 时钟暂停状态相关逻辑
1)冻结时钟计时
当时钟处于暂停状态时,时钟停止推进,始终返回上次设置的 PTS 值。
double get_clock(Clock *c)
{
if (*c->queue_serial != c->serial)
return NAN;
if (c->paused) {
return c->pts;
} else {
double time = av_gettime_relative() / 1000000.0;
return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);
}
}
5.1.3. 暂停切换处理
主暂停状态和时钟暂停状态在 stream_toggle_pause 函数中协同设置。这里不用处理音频时钟的原因是,主暂停状态已经控制不会读取音频数据,音频时钟不会变化,不需要更新。
static void stream_toggle_pause(VideoState *is)
{
// 暂停恢复
if (is->paused) {
// 需要更新帧计时器,将暂停期间的时间差加到帧计时器上,确保恢复播放后,
// 视频帧不会因为暂停而尝试追赶,避免视频突然加速或跳帧。
is->frame_timer += av_gettime_relative() / 1000000.0 - is->vidclk.last_updated;
// 对于支持暂停网络流,视频时钟的 pts 要一直往前走
// 对于不支持暂停的流,视频时钟的 pts 需要保持不变
// 在调用 get_clock 之前,要先设置 paused 状态,否则可能获取错误的值
if (is->read_pause_return != AVERROR(ENOSYS)) {
is->vidclk.paused = 0;
}
// 更新视频时钟
set_clock(&is->vidclk, get_clock(&is->vidclk), is->vidclk.serial);
}
// 更新外部时钟
set_clock(&is->extclk, get_clock(&is->extclk), is->extclk.serial);
// 统一切换所有暂停状态
is->paused = is->audclk.paused = is->vidclk.paused = is->extclk.paused = !is->paused;
}
5.2. 步进
步进基于暂停实现。用户按下“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->video_st) {
retry:
if (frame_queue_nb_remaining(&is->pictq) == 0) {
// nothing to do, no picture to display in the queue
} else {
...
// 步进模式,显示完一帧后切换到暂停状态
if (is->step && !is->paused)
stream_toggle_pause(is);
}
...
}
...
}
5.3. Seek
用户使用右键拖动进行跳转,跳转位置的计算比较简单,都是计算鼠标位置相对屏幕宽度的比例。
case SDL_MOUSEMOTION:
// 如果鼠标移动事件发生时,光标是隐藏的,则显示光标
if (cursor_hidden) {
SDL_ShowCursor(1);
cursor_hidden = 0;
}
cursor_last_shown = av_gettime_relative();
if (event.type == SDL_MOUSEBUTTONDOWN) {
// 检查是否是右键点击,不是则退出
if (event.button.button != SDL_BUTTON_RIGHT)
break;
x = event.button.x;
} else {
// 检查是否是右键拖动,如果不是则退出
if (!(event.motion.state & SDL_BUTTON_RMASK))
break;
x = event.motion.x;
}
// seek_by_bytes 为真,或者当前流的持续时间小于等于0,则按字节位置进行 seek
if (seek_by_bytes || cur_stream->ic->duration <= 0) {
// 获取当前流的大小
uint64_t size = avio_size(cur_stream->ic->pb);
// 根据鼠标在窗口宽度中的位置计算 seek 的字节偏移量
stream_seek(cur_stream, size*x/cur_stream->width, 0, 1);
} else {
// 计算跳转比例
frac = x / cur_stream->width;
// 计算 seek 的时间戳(以微秒为单位)
ts = frac * cur_stream->ic->duration;
// 如果流有 start_time,则加上 start_time
if (cur_stream->ic->start_time != AV_NOPTS_VALUE)
ts += cur_stream->ic->start_time;
stream_seek(cur_stream, ts, 0, 0);
}
break;
在 Read Thread 中执行跳转操作,跳转要考虑时间精度,跳转后要立即清空解码队列,从新的位置读取数据。
if (is->seek_req) {
// +/-2 是为了补偿舍入误差,允许在精确时间点附近找到合适的关键帧
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;
// 执行 seek 操作
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);
// 如果是基于字节的跳转,因为无法准确计算 PTS,所以设置为 NAN
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);
}
}
// 重置 seek 请求标志
is->seek_req = 0;
// 确保附加图片在seek后能重新显示
is->queue_attachments_req = 1;
// 即使跳转到文件末尾,也重置 eof 标志,系统可以重新检测 eof
is->eof = 0;
// 如果处于暂停状态,确保至少显示跳转后的第一帧,提供即时视觉反馈
if (is->paused)
step_to_next_frame(is);
}
需要关注很重要的一点:自增解码队列的 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);
}
ffplay 使用复杂的 serial 机制来实现全局数据的同步刷新,serial 散落在多个结构体中,如下图所示:

serial 的处理逻辑如下图所示,具体请参考标注,需要关注几个要点:
1)PacketQueue 中的 serial 是源头,其变化由 seek 驱动。
2)Serial 变化会触发刷新解码队列、刷新解码器、丢弃 serial 过时的解码后的帧。

ffplay 为什么要用一套这么复杂的机制实现“逻辑清空”,而不是 seek 成功后清除所有队列数据并更新相关状态实现“物理清空”,可能存在以下几方面原因:
1)“物理清空”可能涉及跨线程数据操作,解码器内部清空等,可能会导致 seek 卡顿。
2)当前的处理架构,直接清空数据可能会导致显示黑屏。
3)网络流的处理可能比较麻烦(暂未深入分析)
5.4. 倍速
ffplay 没有实现倍速播放,但已经具备实现倍速播放的基础机制。
以下函数用于设置时钟的播放速度,通过修改c->speed参数可以实现倍速播放。当speed > 1.0时加快播放,当speed < 1.0时减慢播放。
static void set_clock_speed(Clock *c, double speed)
{
set_clock(c, get_clock(c), c->serial);
c->speed = speed;
}
以下函数用于获取时钟,当c->speed != 1.0时,时钟的更新速率会相应调整,从而影响视频和音频的播放速度。
static double get_clock(Clock *c)
{
if (*c->queue_serial != c->serial)
return NAN;
if (c->paused) {
return c->pts;
} else {
double time = av_gettime_relative() / 1000000.0;
return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);
}
}
除此之外,还需要结合专门的音频时间拉伸算法(如WSOLA、PSOLA等)来实现高质量的速度变化而不改变音高。
5.5. 音量调节
音量调节比较简单,直接调用 SDL 接口实现。SDL_MixAudioFormat 函数可以将源音频数据按指定音量混合到目标缓冲区,实现音量调节效果。
// 如果是非静音状态,且音量为最大值,则直接复制音频数据到输出缓冲区
if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)
memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
else { // 如果是静音状态,或者音量不是最大值,则使用SDL_MixAudioFormat函数进行音量混合
memset(stream, 0, len1);
if (!is->muted && is->audio_buf)
SDL_MixAudioFormat(stream,
(uint8_t *)is->audio_buf + is->audio_buf_index,
AUDIO_S16SYS,
len1,
is->audio_volume);
}
6. 遗留问题
6.1. empty_queue_cond 没有使用
当解码队列中没有数据时,本意应该是要通知读取线程立即读取数据,这里是不是搞错了,应该是唤醒: continue_read_thread,而不是 empty_queue_cond,没看到等待此条件变量的代码。
static int decoder_decode_frame(Decoder *d, AVFrame *frame, AVSubtitle *sub) {
...
for (;;) {
...
// 走到这里,说明当前解码器的序列号与队列的序列号不匹配,或者没有数据包可供解码
do {
// 如果解码队列中没有数据,通知读取线程立即读取数据
if (d->queue->nb_packets == 0)
SDL_CondSignal(d->empty_queue_cond);
...
} while (1);
...
}
}
7. 总结
本文试图用浅显的语言来描述 FFplay 这一个经典播放器的内部实现和运行机理,深入进去会发现,FFplay 代码量不大,但是涉及的细节却非常多。要实现一个具备足够兼容性和健壮性的播放器,需要付出巨大的努力,复杂度远超想象。因此,本文只是选取 FFplay 最主要的执行路径和实现逻辑,进行力所能及的解构,但难免有挂一漏万、分析不周的地方,希望大家及时指出,我也会在后续不断完善此文。

949

被折叠的 条评论
为什么被折叠?



