FFmpeg RTSP播放时钟实现揭秘

前言

最近在学习ffmpeg的rtsp拉流实时播放;遇到一些问题。

旧的实现方式是,通过网络拉取一帧,就显示一帧,实时播放;但是,如果网络延迟,隔了几百毫秒甚至几千毫秒才拿到下一帧画面,那么期间就会卡主,导致整体播放流程一卡一卡的。

所以,使用播放时钟约定播放很有必要。

有一些必要的了解:

实现方式是在一个线程函数内,持续循环读取帧来显示:

cpp 复制代码
void YViewTask::Main()
{
    while (is_running_) {
        AVFrame *frame = list_.Pop();
        if (!frame) {
            break;      // 只可能是被 Abort 叫醒 → 收工
        }

        std::cout << "* ";
        yview_->DrawFrame(frame);
        av_frame_free(&frame);
    }
}

YViewTask::Main() 是C++线程执行函数。

list_ 是一个链表,用于缓存已经读取到的AVFrame帧,其结构如下: AvObjList<AVFrame> list_;

cpp 复制代码
// 通用队列
template <typename T>
class AvObjList {
public:
    explicit AvObjList(size_t max = 100) : max_(max) { }
    ~AvObjList() {
        Clear();
    }

    // 禁止拷贝,因为含有mutex
    AvObjList(const AvObjList &) = delete;
    AvObjList &operator=(const AvObjList &) = delete;

    // 生产者,慢则丢弃最旧的
    bool Add(T *obj) {
        std::unique_lock<std::mutex> lock(mux_);

        if (!obj) {
            LOGWARNING("pkt is null!");
            return false;
        }

        // 要么被终止了(abort_ = true) || 要么还有空位
        not_full_.wait(lock, [this] {return abort_ || list_.size() < max_;});


        if (abort_) {
            AVDeleter<T>::Free(obj);
            return false;
        }

        list_.push_back(obj);

        not_empty_.notify_one();        // 通知,链表有元素
        return true;
    }

    // 消费者
    T *Pop() {
        std::unique_lock<std::mutex> lock(mux_);

        // 要么被终止了(abort_ = true) || 要么链表不为空
        not_empty_.wait(lock, [this] { return abort_ || !list_.empty();});

        if (list_.empty()) {
            return nullptr;     // 只有被 Abort 叫醒时才会走到这里
        }

        T *obj = list_.front();
        list_.pop_front();

        not_full_.notify_one();     // 通知,链表腾出位置了
        return obj;
    }

    void Clear() {
        std::unique_lock<std::mutex> lock(mux_);

        for (T *obj : list_) {
            AVDeleter<T>::Free(obj);
        }

        list_.clear();
        not_full_.notify_all();
    }

    size_t Size() {
        std::unique_lock<std::mutex> lock(mux_);
        return list_.size();
    }

    void Abort() {
        std::unique_lock<std::mutex> lock(mux_);
        abort_ = true;

        not_empty_.notify_all();
        not_full_.notify_all();
    }

private:
    std::mutex mux_;
    size_t max_;
    std::list<T *> list_;

    bool abort_ = false;
    std::condition_variable not_empty_;     // 链表有元素了
    std::condition_variable not_full_;      // 链表有空位了
};

yview_->DrawFrame(frame); 内部通过SDL渲染显示。

下方两个变量是线程中常用的,必须说明一下:

cpp 复制代码
double ms_per_tick_  = av_q2d(v_time_base) * 1000.0;		// 时间刻度,v_time_base是解复用视频流的timebase,计算出毫秒
double prebuffer_ms_ = 300.0;		// 缓存时间和最大迟到时间

如上内容知道即可,对下方学习理解有帮助。

最终加上"播放时钟"代码如下:

cpp 复制代码
void YViewTask::Main()
{
    int64_t pts0 = AV_NOPTS_VALUE;
    int64_t last_pts = AV_NOPTS_VALUE;
    double wall0 = 0.0;
    double frame_ms = 100.0;

    while (is_running_) {
        AVFrame *frame = list_.Pop();
        if (!frame) {
            break;      // 只可能是被 Abort 叫醒 → 收工
        }


        if (0.0 < ms_per_tick_ && AV_NOPTS_VALUE != frame->pts) {
            const double cur_ms = (double)now_ms();

            // 判断首帧
            if (AV_NOPTS_VALUE == pts0) {
                pts0 = frame->pts;
                wall0 = cur_ms + prebuffer_ms_;

            } else {
                const double d = (double)(frame->pts - last_pts) * ms_per_tick_;
                if (0.0 < d && 1000.0 > d) {
                    frame_ms = d;               // 记录真实真间隔,备用
                } else if (0.0 > d || 1000.0 < d) {
                    // 时间戳倒退或大跳(重连 / RTSP 时间戳跳变)→ 重锚,
                    // 不重锚的话 due 会落到很远的过去(狂丢帧)或未来(画面冻住)
                    pts0 = frame->pts;
                    wall0 = cur_ms;
                }
            }

            last_pts = frame->pts;

            // 算这一帧该几点出现;
            const double due = wall0 + (double)(frame->pts - pts0) * ms_per_tick_;

            // 计算最晚迟到时间
            const double late_tol = (prebuffer_ms_ > 0.0) ? prebuffer_ms_ : frame_ms;

            // 晚到了
            if (cur_ms - due > late_tol) {
                if (cur_ms - due > 1000.0) {
                    // 落后 1 秒以上不是"晚一帧",是起播堆积或断流后恢复
                    // → 重锚:把"现在"当新起跑线,从这一帧起重新排表
                    pts0  = frame->pts;
                    wall0 = cur_ms;
                    std::cout << "X(" << (cur_ms - due) << ") ";
                }

                // 300~1000ms 这一段:不丢,往下走,直接显示
            }

            // 还没到该显示的时刻,就等 ------ 这一句才是"播放时钟"
            while(is_running_ && (double)now_ms() < due) {
                MSleep(1);
            }
        }

        std::cout << "* ";
        yview_->DrawFrame(frame);
        av_frame_free(&frame);
    }
}

当前时间和睡眠函数:

cpp 复制代码
inline int64_t now_ms() {
    return std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now().time_since_epoch()
    ).count();
}

inline void MSleep(int64_t ms) {

    if (ms <= 0) return;

    int64_t deadline = now_ms() + ms;
    while (now_ms() < deadline) {
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }
}

测试rtsp拉流地址:rtsp://stream.strba.sk:1935/strba/VYHLAD_JAZERO.stream


YViewTask::Main() 逐行讲解

零、一句话总纲

这段代码就干一件事:给每一张画片算一个"该上台的墙上时刻",到点就画,没到点就盯着钟等。

复制代码
旧做法:网上一来帧就画  → 网络快就快放,网络慢就卡住   (像公交,站站停,忽快忽慢)
新做法:先看钟,钟说该第 5 张了,才去拿第 5 张          (像地铁,按时刻表发车)

一、先认识 6 个道具

代码里的名字 用比喻说 真实含义
frame->pts 画片右上角印的号码 这一帧的显示时间戳,单位是"流自己的刻度"
ms_per_tick_ 换算尺:1 个刻度 = 多少毫秒 av_q2d(time_base) × 1000,这路流是 1/90000,所以尺子上 1 刻度 = 0.0111 ms
pts0 起跑号码:拿哪一张当第 0 张 起跑线的参考帧号码
wall0 起跑线:它该在墙上几点出现 参考帧的应播时刻(毫秒)
cur_ms 现在墙上几点 now_ms(),单调时钟
due 这一张该上台的时刻 wall0 + (pts - pts0) × ms_per_tick_

还有两个配角:

名字 干什么的
prebuffer_ms_ 起播先攒 300ms 的货,同时也当"容忍迟到量"
frame_ms 最近观测到的真实帧间隔,prebuffer_ms_ 为 0 时拿它当容忍量

二、骨架(把细节全删掉的样子)

cpp 复制代码
while (还在营业) {
    从筐里拿一张画片            // 没货就睡在这,被叫"收工"就下班
    if (这张有号码) {
        定好或校准 起跑线        // 首帧定,异常时重定
        算出它该上台的时刻 due
        if (已经晚得离谱) 重排时间表
        if (还没到点) 盯着钟等   // ← 这一等,才是"播放时钟"
    }
    上台(画出来)
    扔掉
}

下面逐块拆。


三、逐行拆解

块 1 · 开场四行:准备四个记账本

cpp 复制代码
int64_t pts0 = AV_NOPTS_VALUE;    // 起跑号码,还没定,用"无效值"占位
int64_t last_pts = AV_NOPTS_VALUE;// 上一张的号码,用来量间隔
double  wall0 = 0.0;              // 起跑线的墙上时刻
double  frame_ms = 100.0;         // 真实帧间隔,先随便给个 100 占位

大白话 :这四个变量要在整个循环里一直记着,所以必须写在 while 外面。写在里面的话每次循环都被清零,永远记不住上一帧是谁。

为什么 pts0 用 AV_NOPTS_VALUE 当占位? 因为 pts 可能是 0 甚至是负数,用 0 当"还没定"的标志会误判。FFmpeg 给了个专门的"无效值"常量,用它最安全。


块 2 · 拿一张画片:这里会睡着

cpp 复制代码
AVFrame *frame = list_.Pop();
if (!frame) {
    break;      // 只可能是被 Abort 叫醒 → 收工
}

大白话 :Pop() 内部是"筐空了就睡,谁来塞货就醒"。所以它不会因为"暂时没货"就返回空手。

返回 nullptr 只有一种可能:有人喊了"收工" (list_.Abort())。既然是收工,那就 break 走人。


块 3 · 有号码才排时间表

cpp 复制代码
if (0.0 < ms_per_tick_ && AV_NOPTS_VALUE != frame->pts) {

大白话:只有"尺子有效"且"这张有印号码"时,才能排时间表。两个条件缺一个,就直接跳过排表、马上画(当作不能定时的流处理)。

3.1 先看现在几点
cpp 复制代码
const double cur_ms = (double)now_ms();

必须用 steady_clock (now_ms() 里封装的)。它只会往前走,用户改系统时间、NTP 对时都不会让它跳。如果用 system_clock,某一刻系统时间被拨回 1 分钟,所有帧都会突然"迟到 60 秒",画面直接乱掉。

3.2 首帧:把第一张定成起跑线
cpp 复制代码
if (AV_NOPTS_VALUE == pts0) {
    pts0  = frame->pts;
    wall0 = cur_ms + prebuffer_ms_;     // ← 注意这个 + 300
}

大白话:

  • 起跑号码 = 第一张的号码。
  • 起跑线 = "现在"再往后推 300ms。

为什么推 300ms?因为第一帧刚到手时,筐里是空的。马上画的话,第二帧还没到就又得等 ------ 画面一开始就一顿。所以先等 300ms 让网络把货攒起来,这叫起播预缓冲。

这就是那个 + prebuffer_ms_ 的唯一含义。它只在首帧出现一次。

3.3 非首帧:量一下这一张和上一张隔了多久
cpp 复制代码
const double d = (double)(frame->pts - last_pts) * ms_per_tick_;

if (0.0 < d && 1000.0 > d) {
    frame_ms = d;               // 正常间隔 → 记下来当真间隔
} else if (0.0 > d || 1000.0 < d) {
    pts0  = frame->pts;         // 号码倒退 或 跳得太远 → 这张当新的起跑线
    wall0 = cur_ms;
}

大白话 :d = 两张画片之间隔了多少毫秒。分三种情况:

d 的取值 意思 怎么办
0 < d < 1000ms 正常的两帧间隔 记进 frame_ms(把这两帧之间的真间隔存下来备用)
d ≥ 1000ms 号码往前跳了一大截 重锚:把这张当新的起点,起跑线挪到"现在"
d < 0 号码倒退了 同上,重锚

为什么跳得远就必须重锚? 假设 3 秒没来画面,下一张的号码直接比上一张大 3 秒:

  • 不重锚的话,due = wall0 + (pts - pts0) × 尺子 会算出一个"3 秒前就该播"的时刻;
  • 于是这一帧一进来就已经"迟到 3 秒",后面每一帧都永远追不上,画面彻底垮掉。

所以这里把这张当成新的第 0 张,时间表从它重排。

顺带说一个陷阱:d > 1000 和后面那句 cur_ms - due > 1000 里的两个 1000,是完全无关的两个量。

  • 前者比较的是"这张和上一张之间隔多久"(帧间距);
  • 后者比较的是"我们落后时间表多久"(跑步落后量)。

一个量的是步长,一个量的是差距,别混。

3.4 记住这张的号码,然后算它该几点上台
cpp 复制代码
last_pts = frame->pts;

const double due = wall0 + (double)(frame->pts - pts0) * ms_per_tick_;

大白话 :last_pts 必须先更新,因为下一轮要拿它量间隔。

due 的公式就一句话:

scss 复制代码
该上台的时刻 = 起跑线 + (这张号码 - 起跑号码) × 换算尺
  • (pts - pts0) 是"这张比起点晚几个刻度",是个整数;
  • 乘上 ms_per_tick_ 就变成毫秒;
  • 加上起跑线,就是墙上时刻。

这和"号码 × 单价 = 价钱"是一样的道理,尺子就是那个单价。

3.5 算容忍量,然后判断是不是已经晚了
cpp 复制代码
const double late_tol = (prebuffer_ms_ > 0.0) ? prebuffer_ms_ : frame_ms;

大白话 :允许它迟到多少。默认用 prebuffer_ms_(300ms)------ 既然起播都敢攒 300ms,那晚 300ms 内都算正常波动。如果调用方把 prebuffer_ms_ 传成 0(表示不预缓冲),就退而用 frame_ms(真实的帧间隔),至少有一帧的余量。

cpp 复制代码
if (cur_ms - due > late_tol) {          // 已经晚了
    if (cur_ms - due > 1000.0) {        // 而且晚得离谱(超过 1 秒)
        pts0  = frame->pts;             // 重新起跑
        wall0 = cur_ms;
        std::cout << "X(" << (cur_ms - due) << ") ";
    }
    // 300~1000ms 这一段:不丢,往下走,直接显示
}

大白话:这里把"晚了"分成两档:

迟到量 判断 做法
≤ 300ms 正常抖动 什么都不做,按原时间表走
300ms ~ 1秒 小落后 不丢帧,直接往下走,马上显示
> 1秒 大落后(起播堆积 / 断流恢复) 重锚到"现在",并且马上显示 ,同时打印一个 X(落后毫秒数)

为什么超过 1 秒要重锚? 因为落后 1 秒以上不可能是"这一帧来晚了",一定是整条时间表已经失效了(断流、重连、时间戳跳变)。这时候继续按老表走,只会让后面每一帧都判成"迟到",一帧都追不回来。把"现在"当新起跑线,从这一帧起重新排表,才是唯一出路。

为什么 300~1000ms 不丢帧,直接画? 因为之前实测过:丢帧并不能让画面更顺。源最大积压也就 0~1 帧,把帧扔掉只会让画面缺一块(出现空洞),而不会让播放追上时间。既然丢也追不上,不如把它画出来。


块 4 · 盯着钟等(真正的"播放时钟")

cpp 复制代码
while (is_running_ && (double)now_ms() < due) {
    MSleep(1);
}

大白话 :这一帧还没到上台的时候,就在这儿原地转,每 1ms 看一眼钟。钟一到 due,立刻跳出、画出去。


块 5 · 上台,然后扔掉

cpp 复制代码
std::cout << "* ";
yview_->DrawFrame(frame);
av_frame_free(&frame);

大白话 :画一帧,打印一个 *(方便数到底画了多少帧),然后把这一帧的内存还回去。

av_frame_free 不能省 :每一帧都是 av_frame_alloc 出来的独立内存,用完不还就一路泄漏,跑久了内存爆掉。


四、带数字走一遍

假设:流是 1/90000 的刻度,ms_per_tick_ = 0.01111,帧间隔约 90.9ms(11fps),prebuffer_ms_ = 300。

# 情形 cur_ms frame->pts pts0 wall0 算出 due cur−due 结果
1 首帧 1000 9000 9000 1300 1300 −300 等 300ms 到 1300 才画(预缓冲)
2 正常 1091 17182 9000 1300 1390.9 −300 d=90.9 记入 frame_ms,等 0.9ms 画
3 正常 1391 25364 9000 1300 1481.8 −91 等 91ms 画
4 晚 500ms 1980 33546 9000 1300 1572.7 +407 超 300 但没超 1000 → 不丢,直接画
5 晚 1.5 秒 3070 41728 9000 1300 1663.6 +1406 超 1000 → 打印 X(1406),重锚 pts0=41728 / wall0=3070,立刻画
6 紧跟一帧 3161 49910 41728 3070 3160.9 0.1 时间表已重置,恢复均匀
7 断流 3 秒 6000 297364 41728 3070 11475.9 −5475 但 d = 3000 > 1000 → 在 3.3 步就重锚了 → due=6000,立刻画

第 5、7 行是同一件事的两种入口:5 是"表算对了但我人迟了",7 是"号码跳了表就该重排" 。两条路都会把 pts0/wall0 重置到"现在"。


五、最容易搞混的两个 1000

1000.0 < d(3.3 步) cur_ms - due > 1000(3.5 步)
量的是什么 帧间距:这张和上张隔多久 落后量:我们落后时间表多久
触发条件 源那边号码跳了(断流/重连) 我们自己播慢了(起播堆积、卡顿恢复)
动作 重锚 打印 X + 重锚
典型场景 源停了 3 秒,下一帧号码直接大 3 秒 首帧堆了 2 秒的货才开始播

动作一样(都是重锚),但原因完全不同。 前者是"原料有问题",后者是"我们干活慢了"。


相关推荐
程序员老陆8 天前
WHIP 与 WHEP:WebRTC 直播的标准化入口与出口
ffmpeg·音视频·webrtc·whip·whep
可乐鸡翅yeah_9 天前
混合内容 Mixed‑Content 安全策略,HLS HTTPS 页面加载 HTTP 资源排错实战
运维·ffmpeg·音视频·媒体·m3u8
刘广睿9 天前
视频导出为什么发灰?色彩空间与色彩范围(BT.601/709/2020)的 ffmpeg 转换实战
ffmpeg·音视频·视频处理·色彩空间·素材管理
yivifu10 天前
精选FFmpeg 实用音频视频转换命令集
ffmpeg·音视频转换
风哥2号13 天前
数据库教程FGMT02‑生产环境Linux+Oracle19c安装配置与项目实战
linux·数据库·ffmpeg
Bruce_Liuxiaowei14 天前
录屏片段无损合并:从 ffmpeg concat 原理到 Python 自动化脚本
python·ffmpeg·自动化
工具派15 天前
口播视频怎么自动剪掉冷场?视频静音删除的阈值实测
ffmpeg·音视频·视频
❀͜͡傀儡师15 天前
移动端 360° 商品展示方案:从 20MB 雪碧图到 800KB 丝滑视频
ffmpeg·webgl·sprite
2501_9304724416 天前
深度复盘|数据库迁移实战(上):腾讯云助手解析慢查询日志,定位索引缺失与语法不兼容
数据库·阿里云·ffmpeg·云计算·腾讯云·aws