☰
FFmpeg时间基详解:从AVRational到音视频同步与转码实战
2026/10/15 3:44:46 网站建设 项目流程

1. 时间基到底在解决什么问题

但凡用FFmpeg做过音视频开发的人,几乎都绕不开一个词——时间基。你可能在代码里见过AVRational这个结构体,见过time_base这个字段,也见过av_rescale_q、av_rescale_q_rnd这些函数,但真正理解它们背后那套逻辑的人并不多。我见过太多项目,音视频同步做不好、seek之后画面跳帧、转码出来时长对不上,追到根上都是时间基没搞明白。

先把结论摆出来:时间基就是FFmpeg用来把“整数计数”翻译成“真实时间”的一把尺子。视频里每一帧的时间戳不是直接存成“第3.333秒”这种浮点数,而是存成一个整数,比如pts=100。这个100到底代表多少秒?取决于时间基。如果时间基是1/1000,那100就代表0.1秒;如果时间基是1/25,那100就代表4秒。同一串整数,配不同的尺子,量出来的时间天差地别。

这就是为什么你在FFmpeg里做任何和时间相关的操作,都必须先问一句:这个时间戳是在哪个时间基下的?不搞清楚这一点,跨流、跨结构体、跨编解码器传时间戳,必然出错。

这篇文章我打算把时间基这件事从头到尾讲透。从AVRational的结构,到不同结构体里time_base的含义差异,到av_rescale_q系列函数的正确用法,再到实际项目中音视频同步、seek、转码时踩过的坑。适合已经能跑通FFmpeg基本流程、但一遇到时间戳换算就发懵的开发者,也适合想系统梳理这块知识的老手。

2. 时间基的核心概念与结构体拆解

2.1 AVRational:一个分数而已

FFmpeg里表示时间基用的是AVRational,定义简单到不能再简单:

typedef struct AVRational { int num; // 分子 numerator int den; // 分母 denominator } AVRational;

它就是一个分数num/den。时间基{1, 1000}表示千分之一秒,{1, 25}表示二十五分之一秒,{1001, 30000}表示……对,就是那个经典的29.97帧率对应的时基。

这里有个新手特别容易犯的错:把AVRational当成浮点数用。有人写double tb = time_base.num / time_base.den;,结果得到0,因为整数除法。正确写法是(double)time_base.num / time_base.den。这个坑我当年也踩过,调试半天才发现时间基算出来是0,所有换算全废。

FFmpeg还提供了几个操作AVRational的宏和函数,常用的有:

  • av_cmp_q(a, b):比较两个分数大小,返回-1、0、1
  • av_q2d(a):转成double,内部就是(double)a.num / a.den
  • av_inv_q(a):取倒数,分子分母互换
  • av_make_q(num, den):构造一个AVRational

注意:av_q2d虽然方便,但在需要精确比较的场景下不要用它。浮点数有精度问题,两个理论上相等的时间基转成double后可能不相等。要比较就用av_cmp_q。

2.2 不同结构体里的time_base,含义完全不一样

这是时间基最容易让人混淆的地方。FFmpeg里好几个结构体都有time_base字段,但它们代表的东西不同,绝对不能混用。

结构体字段含义典型值
AVStreamtime_base该流的时间基,用于表示该流中所有时间戳的单位视频常见1/90000或1/1000,音频常见1/44100或1/48000
AVCodecContexttime_base编解码器使用的时间基,编码时由用户设置,解码时由FFmpeg设置编码视频常设1/帧率
AVCodecContextticks_per_frame每帧占多少个时间基单位,用于处理帧率与时间基的关系通常为1,某些情况为2
AVPacketpts/dts/duration这些时间戳是在AVStream.time_base下的取决于流
AVFramepts/pkt_dts这些时间戳是在AVCodecContext.time_base下的取决于编解码器上下文

我重点说AVStream.time_base和AVCodecContext.time_base的区别,因为这是实际开发中最常搞混的一对。

AVStream.time_base是“对外”的时间基。你从AVPacket里读到的pts、dts,你写进AVPacket的pts、dts,都是在这个时间基下的。做封装、解封装、seek、音视频同步,用的都是这个。

AVCodecContext.time_base是“对内”的时间基。解码器吐出来的AVFrame,它的pts是在这个时间基下的。编码器接收的AVFrame,它的pts也应该在这个时间基下。

所以当你从解码器拿到一个AVFrame,想把它和某个AVPacket的时间戳比较,或者想把它送给编码器,中间必须做一次换算:

// 把frame的pts从codec time_base转到stream time_base int64_t pts_in_stream_tb = av_rescale_q(frame->pts, codec_ctx->time_base, stream->time_base);

反过来,你要把外部的时间戳塞给编码器,也要转:

// 把stream time_base下的pts转到codec time_base frame->pts = av_rescale_q(packet_pts, stream->time_base, codec_ctx->time_base);

实操心得:我习惯在代码里给每个时间戳变量加后缀标明它的时间基,比如pts_stream_tb、pts_codec_tb。虽然名字长一点,但能避免大量“这个pts到底是哪个时基”的困惑。团队协作时这个习惯尤其值钱。

2.3 为什么视频时间基常见90000

很多人好奇,为什么视频流的时间基经常是1/90000?这个数字不是随便选的。

90000这个数有个很好的性质:它能被常见的帧率整除。24、25、30、50、60这些帧率,90000除以它们都是整数:

  • 90000 / 24 = 3750
  • 90000 / 25 = 3600
  • 90000 / 30 = 3000
  • 90000 / 50 = 1800
  • 90000 / 60 = 1500

对于29.97这种非整数帧率,90000也能处理:90000 / 29.97 ≈ 3003,虽然不整除,但误差极小。这就是为什么MPEG-TS等容器标准里把视频时间基定为90000,它能在整数运算下精确表示绝大多数帧率。

音频时间基通常是采样率,比如44100或48000。因为音频的一个采样点就是一个时间单位,这样表示最自然,不会引入舍入误差。

3. 时间基换算的核心函数与实操

3.1 av_rescale_q:跨时间基换算的主力

av_rescale_q是时间基换算最常用的函数,原型是:

int64_t av_rescale_q(int64_t a, AVRational bq, AVRational cq);

它的作用是把时间戳a从时间基bq换算到时间基cq。内部逻辑是:

result = a * bq / cq

但FFmpeg做了防溢出处理,用128位中间结果计算,所以比你自己写a * bq.num * cq.den / (bq.den * cq.num)要安全得多。

我见过有人图省事自己写换算:

// 错误示范,容易溢出 int64_t pts_new = pts * src_tb.num * dst_tb.den / (src_tb.den * dst_tb.num);

当pts很大、时间基分子分母也大时,中间乘积直接溢出int64,结果完全错误。这种bug特别隐蔽,因为小数据量测试时正常,一上真实视频就出问题。永远用av_rescale_q,不要自己算。

3.2 av_rescale_q_rnd:需要控制舍入方式时用

av_rescale_q默认是四舍五入(其实是round half away from zero),但有些场景你需要指定舍入方向。比如做seek时,你希望时间戳向下取整,避免跳到目标位置之后;做时长计算时,可能希望向上取整。这时候用:

int64_t av_rescale_q_rnd(int64_t a, AVRational bq, AVRational cq, enum AVRounding rnd);

rnd的取值有:

  • AV_ROUND_ZERO:向零取整
  • AV_ROUND_INF:向远离零方向取整
  • AV_ROUND_DOWN:向下取整
  • AV_ROUND_UP:向上取整
  • AV_ROUND_NEAR_INF:四舍五入
  • AV_ROUND_PASS_MINMAX:这个是个标志位,可以和上面组合,表示如果输入是INT64_MIN或INT64_MAX就直接返回,不做换算

实际用法举例,seek时向下取整:

int64_t seek_target = av_rescale_q_rnd(target_seconds * AV_TIME_BASE, AV_TIME_BASE_Q, stream->time_base, AV_ROUND_DOWN | AV_ROUND_PASS_MINMAX);

注意:AV_ROUND_PASS_MINMAX这个标志很实用。FFmpeg里很多时间戳用AV_NOPTS_VALUE(即INT64_MIN)表示“无时间戳”。如果你不做这个标志,换算AV_NOPTS_VALUE会得到一个乱七八糟的数,而不是保持它还是AV_NOPTS_VALUE。这个坑我在处理某些流时踩过,明明没有pts的帧,换算后变成了一个巨大的负数,导致后续逻辑全乱。

3.3 AV_TIME_BASE:FFmpeg的“通用时间基”

FFmpeg定义了一个全局的微秒级时间基:

#define AV_TIME_BASE 1000000 #define AV_TIME_BASE_Q (AVRational){1, AV_TIME_BASE}

也就是1/1000000,微秒。这个时间基主要用在AVFormatContext层面的操作,比如av_seek_frame的timestamp参数,如果不指定stream index(传-1),就是在这个时间基下的。

很多API文档里说的“timestamp in AV_TIME_BASE units”,就是指这个。你从format_ctx->duration拿到的时长,也是在这个时间基下的。

所以一个常见的换算场景是:用户输入“跳到第10秒”,你要把它转成某个流的时间基:

int64_t target_us = 10 * AV_TIME_BASE; // 10秒,微秒单位 int64_t target_stream = av_rescale_q(target_us, AV_TIME_BASE_Q, stream->time_base);

3.4 一个完整的换算示例

我把上面这些串起来,写一个实际项目里常见的场景:从解码器拿到frame,算出它在流时间基下的pts,然后和音频的pts比较做同步。

// 假设已经拿到 video_frame 和 video_stream AVRational codec_tb = video_codec_ctx->time_base; AVRational stream_tb = video_stream->time_base; // frame->pts 在 codec_tb 下 int64_t video_pts_stream = av_rescale_q(video_frame->pts, codec_tb, stream_tb); // 假设 audio_pts 已经在 stream_tb 下(从AVPacket来的) // 但音频和视频的stream_tb可能不同,需要统一到一个基准 // 通常统一到 AV_TIME_BASE int64_t video_pts_us = av_rescale_q(video_pts_stream, stream_tb, AV_TIME_BASE_Q); int64_t audio_pts_us = av_rescale_q(audio_pts_stream, audio_stream->time_base, AV_TIME_BASE_Q); int64_t diff = video_pts_us - audio_pts_us; // 根据diff做同步调整...

这段代码里做了两次换算:先把frame的pts从codec时基转到stream时基,再从stream时基转到微秒。为什么要转两次?因为codec时基和stream时基可能不同,不能直接一步从codec时基转到微秒,除非你确认两者相等。分步换算虽然多一次调用,但逻辑清晰,不容易错。

4. 音视频同步中的时间基实战

4.1 为什么音视频同步必须统一时间基

音视频同步的本质是比较两个时间戳。但视频的时间戳在视频流的时间基下,音频的时间戳在音频流的时间基下,这两个时间基通常不一样。视频可能是1/90000,音频可能是1/44100。你直接比较video_pts和audio_pts这两个整数,毫无意义。

必须先把它们换算到同一个时间基下。最常用的基准是AV_TIME_BASE(微秒),因为精度足够,且是FFmpeg的通用标准。

int64_t v_pts_us = av_rescale_q(v_pts, video_stream->time_base, AV_TIME_BASE_Q); int64_t a_pts_us = av_rescale_q(a_pts, audio_stream->time_base, AV_TIME_BASE_Q);

换算到微秒后,两个数的差就是真实的毫秒级差距,可以直接用来决定是丢帧、重复帧还是等待。

4.2 同步策略与时间基的关系

常见的同步策略有三种:以音频为基准、以视频为基准、以外部时钟为基准。不管哪种,时间基换算都是第一步。

以音频为基准时,视频的显示时间要跟着音频走。你算出v_pts_us和a_pts_us的差diff:

  • diff > 阈值(视频超前):等待,或者重复上一帧
  • diff < -阈值(视频落后):丢帧,或者加速播放
  • |diff| < 阈值:正常显示

这个阈值通常取几十毫秒。但要注意,阈值判断必须在统一时间基下做。我见过有人在stream时基下直接比较,视频时基90000、音频时基44100,同样的“1000”在视频里是11毫秒,在音频里是22毫秒,阈值判断完全失准。

4.3 一个容易忽略的细节:音频的ticks_per_frame

视频的ticks_per_frame通常是1,但音频在某些编解码器下可能不是1。这个字段影响帧时长和pts的计算。

帧时长的计算方式是:

int64_t frame_duration = av_rescale_q(frame->nb_samples, (AVRational){1, codec_ctx->sample_rate}, codec_ctx->time_base);

对于音频,nb_samples是采样点数,除以采样率就是秒数,再转到codec时基。如果你忽略了ticks_per_frame,在某些AAC流上算出来的时长会差一倍。

实操心得:处理音频时间戳时,我习惯用frame->nb_samples和sample_rate直接算时长,而不是依赖frame->duration。因为有些解码器不填duration,或者填的值在换算后不精确。自己算更可控。

5. Seek操作中的时间基陷阱

5.1 av_seek_frame的时间基约定

av_seek_frame这个函数的时间基约定特别容易搞错。它的签名是:

int av_seek_frame(AVFormatContext *s, int stream_index, int64_t timestamp, int flags);

关键在stream_index和timestamp的配合:

  • 如果stream_index >= 0:timestamp是在streams[stream_index]->time_base下的
  • 如果stream_index < 0:timestamp是在AV_TIME_BASE下的,且FFmpeg会自动选择默认流

很多人不看文档,直接传一个秒数乘以1000,然后stream_index传0,结果seek位置完全不对。因为流的时间基不是1/1000,可能是1/90000。

正确做法:

// 想跳到第30秒 int64_t target_us = 30 * AV_TIME_BASE; // 方式一:用AV_TIME_BASE,stream_index传-1 av_seek_frame(fmt_ctx, -1, target_us, AVSEEK_FLAG_BACKWARD); // 方式二:转到具体流的时间基 int video_idx = ...; int64_t target_stream = av_rescale_q(target_us, AV_TIME_BASE_Q, fmt_ctx->streams[video_idx]->time_base); av_seek_frame(fmt_ctx, video_idx, target_stream, AVSEEK_FLAG_BACKWARD);

两种方式等价,但方式二更明确,我一般用方式二。

5.2 AVSEEK_FLAG_BACKWARD与舍入方向

AVSEEK_FLAG_BACKWARD表示seek到目标时间戳之前的关键帧。这个“之前”就涉及舍入方向。如果你换算时用了向上取整,可能跳到目标之后,和flag的意图矛盾。

所以配合AVSEEK_FLAG_BACKWARD时,换算应该用AV_ROUND_DOWN:

int64_t target_stream = av_rescale_q_rnd(target_us, AV_TIME_BASE_Q, stream->time_base, AV_ROUND_DOWN | AV_ROUND_PASS_MINMAX);

这样保证换算后的时间戳不会超过目标,seek行为符合预期。

5.3 seek后时间戳重置问题

seek之后,解码器吐出来的frame的pts可能不是从0开始,而是从seek到的关键帧的实际pts开始。这时候如果你用frame的pts直接做显示时间,画面会“跳”一下。

解决办法是记录seek后的第一个frame的pts作为基准,后续frame的pts都减去这个基准:

if (first_frame_after_seek) { base_pts = frame->pts; first_frame_after_seek = 0; } int64_t display_pts = frame->pts - base_pts;

但注意,这个base_pts是在codec时基下的,做减法前要确保两个pts在同一时基下。如果中间有转码或时基变化,还要先换算。

6. 转码场景下的时间基处理

6.1 解码到编码的时间基传递链

转码的流程是:解封装 → 解码 → 编码 → 封装。时间戳在这条链上要经过多次时基转换:

  1. 解封装出来的AVPacket,pts在input_stream->time_base下
  2. 解码出来的AVFrame,pts在decoder_ctx->time_base下
  3. 编码器接收的AVFrame,pts应在encoder_ctx->time_base下
  4. 编码出来的AVPacket,pts在encoder_ctx->time_base下
  5. 封装时写入的AVPacket,pts应在output_stream->time_base下

每一步都可能需要换算。最稳妥的做法是每一步都显式换算,不要假设相邻两步的时基相同。

// 解码后,把frame的pts转到编码器时基 frame->pts = av_rescale_q(frame->pts, decoder_ctx->time_base, encoder_ctx->time_base); // 编码后,把packet的pts转到输出流时基 pkt->pts = av_rescale_q(pkt->pts, encoder_ctx->time_base, output_stream->time_base); pkt->dts = av_rescale_q(pkt->dts, encoder_ctx->time_base, output_stream->time_base); pkt->duration = av_rescale_q(pkt->duration, encoder_ctx->time_base, output_stream->time_base);

注意:pkt->duration也要换算,很多人只换算pts和dts,忘了duration,导致播放器算出来的帧时长不对,画面播放速度异常。

6.2 编码器time_base的设置

编码器的time_base需要用户自己设置。视频编码器通常设成1/帧率:

encoder_ctx->time_base = (AVRational){1, fps}; encoder_ctx->framerate = (AVRational){fps, 1};

注意time_base和framerate是互为倒数的关系。time_base是1/fps,framerate是fps/1。设错一个,编码出来的时间戳就全乱。

对于可变帧率(VFR)的视频,time_base要设得足够细,比如1/1000,然后用实际pts来控制每帧的显示时间。这时候framerate可能设成一个平均值或者不设。

音频编码器的time_base通常设成1/sample_rate:

encoder_ctx->time_base = (AVRational){1, sample_rate};

这样每个采样点对应一个时间单位,最精确。

6.3 时基转换中的精度损失

每次av_rescale_q都可能引入舍入误差。转码链上换算次数多了,误差会累积。对于长时间视频,累积误差可能导致音视频不同步。

减少误差的办法:

  • 尽量用大的时间基分母(如90000而不是1000),减少舍入
  • 能一步换算就不要分多步
  • 对于音频,尽量保持采样率不变,避免重采样引入的时基变化

我做过一个测试,把一段1小时的视频在1/1000和1/90000两种时基下反复换算100次,1/1000的累积误差达到了几十毫秒,而1/90000几乎无误差。这就是为什么专业容器标准偏爱90000。

7. 常见问题与排查技巧实录

7.1 时间基相关bug的排查思路

时间基的bug有个特点:症状和原因往往隔得很远。画面跳帧可能是时间基错了,音视频不同步可能是时间基错了,seek不准也可能是时间基错了。排查时我一般按这个顺序:

  1. 打印所有相关的时间基,确认每个时间戳变量的时基
  2. 检查每次换算的源时基和目标时基是否传对
  3. 检查是否有遗漏的换算(比如duration忘了转)
  4. 检查AV_NOPTS_VALUE是否被正确处理
  5. 检查舍入方向是否符合场景需求

7.2 常见问题速查表

症状可能原因排查方法
画面播放速度异常编码器time_base设错,或duration未换算打印encoder_ctx->time_base和pkt->duration
音视频不同步比较时未统一时间基确认比较前都转到了AV_TIME_BASE
seek位置偏差大av_seek_frame的timestamp时基传错确认stream_index和timestamp的时基匹配
时间戳变成巨大负数AV_NOPTS_VALUE被换算换算时加AV_ROUND_PASS_MINMAX
换算结果为0整数除法,或时间基分子为0检查是否用了av_q2d,检查时间基是否有效
长时间播放后累积误差时间基精度不够换用更大的时间基分母,减少换算次数
某些流正常某些流异常不同流的time_base不同打印每个流的time_base对比

7.3 几个我踩过的坑

坑一:把AVStream.time_base当成AVCodecContext.time_base用。早期我写代码,从AVFrame拿pts,直接和AVPacket的pts比较,结果怎么都对不上。后来才发现一个是codec时基,一个是stream时基。这个坑的本质是没搞清楚“谁的时间戳在谁的时基下”。

坑二:av_rescale_q的参数顺序搞反。函数签名是av_rescale_q(a, bq, cq),把a从bq转到cq。我一度记成从cq转到bq,结果所有换算都反了。记住口诀:第二个参数是源,第三个参数是目标。

坑三:忽略AV_ROUND_PASS_MINMAX。处理某些直播流时,部分帧没有pts(值为AV_NOPTS_VALUE),换算后变成一个巨大的负数,导致同步逻辑判断这帧“严重落后”,疯狂丢帧。加上AV_ROUND_PASS_MINMAX后,AV_NOPTS_VALUE换算后还是AV_NOPTS_VALUE,逻辑就正常了。

坑四:音频重采样后时基没更新。用swr做重采样,输出采样率变了,但编码器的time_base还是旧的采样率,导致音频时间戳全错。重采样后一定要同步更新time_base。

实操心得:调试时间基问题时,我习惯写一个辅助函数,把任意时间戳和它的时基一起打印出来,格式化成“pts=xxx, tb=num/den, time=xx.xxx秒”。这样一眼就能看出时间戳对应的真实时间,比盯着整数看高效得多。

void print_ts(const char *name, int64_t ts, AVRational tb) { double sec = ts * av_q2d(tb); printf("%s: pts=%lld, tb=%d/%d, time=%.3f sec\n", name, (long long)ts, tb.num, tb.den, sec); }

这个函数我在每个项目里都会放一份,排查时间问题时省了无数时间。

8. 时间基的进阶话题

8.1 可变帧率下的时间基处理

可变帧率(VFR)视频里,每帧的显示时长不一样,不能简单用1/帧率做时间基。这时候时间基要设得足够细,比如1/1000或1/90000,然后每帧的pts根据实际显示时间设置。

处理VFR时,AVFrame的pts和pkt_duration都要仔细设置。pkt_duration表示这帧显示多久,单位是时间基。如果duration设错,播放器会按错误的时长显示每帧,画面就会忽快忽慢。

8.2 时间基与帧率的关系

帧率和时间基的关系是:帧率 = 1 / (time_base * ticks_per_frame)。对于大多数情况ticks_per_frame=1,所以帧率 = 1 / time_base。

但有些编解码器(如某些MPEG系列)ticks_per_frame=2,这时候帧率 = 1 / (2 * time_base)。如果你按1/time_base算帧率,会得到两倍的值。这个细节在处理老格式视频时要注意。

8.3 跨容器转封装的时间基转换

转封装(不重新编解码,只换容器)时,时间基也要转换。输入容器的流时基和输出容器的流时基可能不同,AVPacket的pts、dts、duration都要从输入流时基转到输出流时基。

pkt->pts = av_rescale_q(pkt->pts, in_stream->time_base, out_stream->time_base); pkt->dts = av_rescale_q(pkt->dts, in_stream->time_base, out_stream->time_base); pkt->duration = av_rescale_q(pkt->duration, in_stream->time_base, out_stream->time_base);

转封装时还要注意,输出流的time_base要在写头之前设置好,否则封装器可能用默认值,导致时间戳不对。

9. 写在最后的一些个人体会

时间基这个东西,说难不难,说简单也不简单。它的核心就一句话:时间戳是整数,时间基是尺子,换算就是换尺子。但真正把它用对,需要在每个涉及时间戳的地方都问自己一句“这个时间戳在哪个时基下”。

我个人的经验是,与其在出bug后到处排查,不如在写代码时就养成习惯:每个时间戳变量都明确标注时基,每次跨结构体传时间戳都显式换算,每次换算都用av_rescale_q而不是手算。这些习惯看起来麻烦,但能省下大量调试时间。

还有一点,FFmpeg的文档对时间基的描述比较分散,很多细节要靠读源码或者踩坑才能明白。我建议遇到不确定的地方,直接去看FFmpeg源码里对应函数的实现,比猜要靠谱得多。比如av_rescale_q的实现就在libavutil/mathematics.c里,不长,读一遍就明白它怎么处理溢出的。

最后分享一个小技巧:如果你不确定某个时间戳的时基,可以用av_q2d把它转成秒,然后和预期的时间对比。如果差得离谱,多半是时基搞错了。这个方法虽然土,但排查问题时特别有效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询