资讯详情

资讯详情

FFmpeg时间基详解:从AVRational到音视频同步与转码实战

1. 时间基到底在解决什么问题但凡用FFmpeg做过音视频开发的人几乎都绕不开一个词——时间基。你可能在代码里见过AVRational这个结构体见过time_base这个字段也见过av_rescale_q、av_rescale_q_rnd这些函数但真正理解它们背后那套逻辑的人并不多。我见过太多项目音视频同步做不好、seek之后画面跳帧、转码出来时长对不上追到根上都是时间基没搞明白。先把结论摆出来时间基就是FFmpeg用来把“整数计数”翻译成“真实时间”的一把尺子。视频里每一帧的时间戳不是直接存成“第3.333秒”这种浮点数而是存成一个整数比如pts100。这个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、1av_q2d(a)转成double内部就是(double)a.num / a.denav_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/48000AVCodecContexttime_base编解码器使用的时间基编码时由用户设置解码时由FFmpeg设置编码视频常设1/帧率AVCodecContextticks_per_frame每帧占多少个时间基单位用于处理帧率与时间基的关系通常为1某些情况为2AVPacketpts/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 375090000 / 25 360090000 / 30 300090000 / 50 180090000 / 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_BASEFFmpeg的“通用时间基”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的差diffdiff 阈值视频超前等待或者重复上一帧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 0timestamp是在streams[stream_index]-time_base下的如果stream_index 0timestamp是在AV_TIME_BASE下的且FFmpeg会自动选择默认流很多人不看文档直接传一个秒数乘以1000然后stream_index传0结果seek位置完全不对。因为流的时间基不是1/1000可能是1/90000。正确做法// 想跳到第30秒 int64_t target_us 30 * AV_TIME_BASE; // 方式一用AV_TIME_BASEstream_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_DOWNint64_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 解码到编码的时间基传递链转码的流程是解封装 → 解码 → 编码 → 封装。时间戳在这条链上要经过多次时基转换解封装出来的AVPacketpts在input_stream-time_base下解码出来的AVFramepts在decoder_ctx-time_base下编码器接收的AVFramepts应在encoder_ctx-time_base下编码出来的AVPacketpts在encoder_ctx-time_base下封装时写入的AVPacketpts应在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/fpsframerate是fps/1。设错一个编码出来的时间戳就全乱。对于可变帧率VFR的视频time_base要设得足够细比如1/1000然后用实际pts来控制每帧的显示时间。这时候framerate可能设成一个平均值或者不设。音频编码器的time_base通常设成1/sample_rateencoder_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不准也可能是时间基错了。排查时我一般按这个顺序打印所有相关的时间基确认每个时间戳变量的时基检查每次换算的源时基和目标时基是否传对检查是否有遗漏的换算比如duration忘了转检查AV_NOPTS_VALUE是否被正确处理检查舍入方向是否符合场景需求7.2 常见问题速查表症状可能原因排查方法画面播放速度异常编码器time_base设错或duration未换算打印encoder_ctx-time_base和pkt-duration音视频不同步比较时未统一时间基确认比较前都转到了AV_TIME_BASEseek位置偏差大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。实操心得调试时间基问题时我习惯写一个辅助函数把任意时间戳和它的时基一起打印出来格式化成“ptsxxx, tbnum/den, timexx.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_frame1所以帧率 1 / time_base。但有些编解码器如某些MPEG系列ticks_per_frame2这时候帧率 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把它转成秒然后和预期的时间对比。如果差得离谱多半是时基搞错了。这个方法虽然土但排查问题时特别有效。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →