ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FFmpeg时间戳换算核心:av_packet_rescale_ts原理与踩坑实战

FFmpeg时间戳换算核心:av_packet_rescale_ts原理与踩坑实战 做 FFmpeg 开发的人十有八九会被时间戳坑过一次。我印象最深的一次是帮朋友调一个 MP4 转 FLV 的小工具播放器进度条直接放飞日志里疯狂刷Application provided invalid, non monotonically increasing dts to muxer。折腾了一下午最后定位到问题源头代码里根本没有调用av_packet_rescale_ts。这个函数名字看着很底层、很不起眼但可以说是所有读写容器时间戳的枢纽。一句话讲清楚它的作用把AVPacket里的pts、dts、duration从一种时间基单位换算成另一种时间基单位。不管你是刚下载 FFmpeg 命令行体验还是被 Android 交叉编译、Qt FFmpeg 这些词吸引进来的新手只要你开始碰 libav* 系列的 API迟早要和它打交道。命令行工具把太多细节都掩盖了而真正决定你能不能写出一个能跑的播放器、转码器、推流器的往往就是这个函数背后那套时间基逻辑。这篇文章我就把这个函数拆开讲透包括它内部做了什么、何时必须调用、调用时有哪些容易踩的坑。1. 理解 av_packet_rescale_ts 的真实使命1.1 函数签名、参数与基本行为先看函数声明它位于libavcodec/avcodec.h属于 libavcodec 库void av_packet_rescale_ts(AVPacket *pkt, AVRational tb_src, AVRational tb_dst);参数一共三个pkt是要处理的 AVPackettb_src是源时间基tb_dst是目标时间基。函数没有返回值也不是失败后什么都不改它是就地修改pkt的pts、dts、duration三个字段把它们的单位从tb_src换算到tb_dst。这里需要强调一下“就地修改”这个特性。很多新手会以为这个函数会返回一个新的 packet或者需要再把返回值赋回去。其实不需要你传入的pkt在调用完之后pts、dts、duration这三个字段就已经被换算了。如果你在同一个pkt上连续调用两次同样的源/目标时间基时间戳会被二次放大直接造成 dts 非单调这是非常经典的低级错误。另外这个函数只处理时间相关字段。pkt-pos是字节位置不会动pkt-stream_index不会动side_data里的内容也不会动。它会做的换算内部等价于这样一段逻辑if (pkt-pts ! AV_NOPTS_VALUE) pkt-pts av_rescale_q_rnd(pkt-pts, tb_src, tb_dst, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); if (pkt-dts ! AV_NOPTS_VALUE) pkt-dts av_rescale_q_rnd(pkt-dts, tb_src, tb_dst, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); if (pkt-duration 0) pkt-duration av_rescale_q(pkt-duration, tb_src, tb_dst);注意三个细节。第一AV_NOPTS_VALUE会被跳过因为无时间戳的值没有换算意义。第二duration用的是av_rescale_q默认舍入方式更接近四舍五入。第三AV_ROUND_PASS_MINMAX这个标志位会在极端溢出时用原值兜底避免一换算直接把pts搞成一个极端大数。这些都是源码里真实存在的细节很多人在写自己的换算代码时往往处理不到位。1.2 为什么是“换算单位”而不是“设置时间戳”我见过最典型的错误写法是这样pkt-pts 0; pkt-dts 0;这相当于把时间戳“硬重置”完全抛弃了原始时间信息。你做流复制时源文件的音画时序、B 帧顺序、起始偏移全都在时间戳里直接清零输出文件基本就废了。av_packet_rescale_ts做的事情不是“设置”而是“换算”。它保留时间戳的真实含义只改变数字的刻度单位。举个生活的例子一根绳子长 3 英尺你把它换算成 91.44 厘米。你不会说长度变成了 3 厘米也不会说长度变成 91.44 英尺。av_packet_rescale_ts就是在做这个“英尺到厘米”的换算。它最重要的价值是不管源容器的时间基是1/90000还是1/1000目标容器需要什么刻度你只负责提供正确的源/目标时间基函数保证时间戳背后表示的“时刻”在换算前后是同一个点。搞清楚这一层你就能理解为什么说时间戳不是“随便设的”而是有明确物理含义的采样刻度。pts的单位是“tick”而不是“秒”。同一个时刻在 MP4 里可能是tick123456到了 TS 里就变成tick1372两个数字不一样但代表的是同一个时间点。av_packet_rescale_ts就是这两个刻度之间的翻译官。2. time_base 与时间戳换算的底层数学2.1 time_base 的本质把 1 秒切成多少份要真正掌握av_packet_rescale_ts绕不开AVRational和time_base。AVRational是一个分数结构体定义在libavutil/rational.h里typedef struct AVRational { int num; // 分子 int den; // 分母 } AVRational;time_base表示一个 tick 等于多少秒。比如(AVRational){1, 90000}表示 1 tick 1/90000 秒也就是 90000 tick 代表 1 秒。为什么是 90000因为 MPEG-TS 的 PCR 时钟频率是 27MHz除以 300 得到 90kHz所以 TS 流的时间刻度普遍是 1/90000 秒。这个精度足够保证音画同步又是一个能被常见帧率整除的整数所以成了视频领域的“公制单位”。常见的时间基分布很有意思容器/场景常见视频 time_base常见音频 time_base说明MP4/MOV1/15360、1/90000 等1/采样率如 1/44100由 muxer 或编码器配置决定MPEG-TS1/900001/90000标准强制要求FLV1/10001/1000容器粒度就是毫秒裸 H.264 Annex-B无容器通常 1/90000 或帧率倒数无音频取决于封装习惯编码器内部常为帧率倒数如 1/25、1/301/采样率编码器时间基这里我不是让你背表格而是想说明一个问题不同容器、不同流的时间基很可能不一样。你从 MP4 读出来的 packet 时间戳单位是ist-time_base写到 TS 时 muxer 要求的时间戳单位是ost-time_base。如果这两个不是同一个分数数字就不能直接搬过去必须换算。这就是av_packet_rescale_ts存在的根本原因把“源容器刻度”翻译成“目标容器刻度”。2.2 av_rescale_q 的分数换算与舍入规则av_packet_rescale_ts内部依赖的核心函数是av_rescale_q它完成一次分数乘法运算。公式可以写出来dst src * (tb_src.num / tb_src.den) / (tb_dst.num / tb_dst.den)展开成整数运算就是dst src * tb_src.num * tb_dst.den / (tb_src.den * tb_dst.num)看起来很简单但有个致命问题src本身可能是很大的 int64再乘一个分母因子很容易溢出。FFmpeg 的av_rescale_q内部使用了两步移位、约分、以及若干数论技巧来降低溢出风险同时保证足够精度。这也正是为什么我不建议你自己手写pts * 1000 / 90000之类的代码因为你很难处理好溢出和舍入边界。举个例子假设某个 TS 流里的pts 123456源时间基是(AVRational){1, 90000}要换算到毫秒时间基(AVRational){1, 1000}。根据公式dst 123456 * 90000?不对我们来细算。向av_rescale_q(123456, (AVRational){1,90000}, (AVRational){1,1000})中代入dst 123456 * (1/90000) / (1/1000) 123456 * 1000 / 90000 123456000 / 90000 1371.733...四舍五入后是1372。也就是说这个 TS 里的pts123456在毫秒刻度下表述为1372毫秒。反过来如果之后你再从毫秒时间基换回 90k 时间基src 1372 * 90000 / 1000 123480两次换算后数字从123456变成123480差 24 个 tick。24 tick 等于多少秒24 / 90000 0.0002666...秒也就是大约 0.27 毫秒。这个误差在绝大多数场景下可以接受但它确实存在。所以如果你的系统里有多次往返换算误差会累积最终可能造成音画不同步。这就是为什么专业工具在做时间处理时会尽量避免“毫秒 - 90k - 毫秒 - 90k”这种反复横跳。2.3 为什么不能用浮点直接换算有人可能会说我用double先换算成秒再换算成目标 tick不也一样吗double t pkt-pts * tb_src.num / (double)tb_src.den; pkt-pts (int64_t)(t * tb_dst.den / tb_dst.num);在小样本测试里确实看不出问题但一旦跑长视频、高帧率、音频 48kHz 采样浮点误差会被放大。再者double只有 53 位有效二进制位而int64_t有 63 位有效位。时间戳计算要求的是“整数刻度”不是“浮点秒数”。FFmpeg 的av_rescale_q在内部用分数运算保持精度并在最后一步做整数舍入这是专门设计给时间戳换算用的。还有一个你可能没想到的点time_base本身是分数所以1/90000在浮点里是无理数的近似值根本无法精确表示。你先把pts乘分数再除以分数两步误差叠加时间一长就漂。整数分数运算天然规避了这个问题它只在最后一步做一次舍入。所以永远不要用浮点去做时间戳换算这是 FFmpeg 开发里一条非常基础的经验。3. 典型场景与完整实操代码3.1 场景一流复制转封装最经典的调用位置假设你写一个程序把 MP4 转成 TS视频流、音频流都不重新编码只做remux。这里时间基几乎一定不同你必须在把包交给目标 muxer 之前调用av_packet_rescale_ts。代码骨架如下AVFormatContext *ifmt_ctx NULL; AVFormatContext *ofmt_ctx NULL; AVPacket *pkt av_packet_alloc(); const char *in_file input.mp4; const char *out_file output.ts; avformat_open_input(ifmt_ctx, in_file, NULL, NULL); avformat_find_stream_info(ifmt_ctx, NULL); avformat_alloc_output_context2(ofmt_ctx, NULL, NULL, out_file); for (int i 0; i ifmt_ctx-nb_streams; i) { AVStream *in_stream ifmt_ctx-streams[i]; AVStream *out_stream avformat_new_stream(ofmt_ctx, NULL); avcodec_parameters_copy(out_stream-codecpar, in_stream-codecpar); out_stream-codecpar-codec_tag 0; // 必须清 tag否则会串格式 } avio_open(ofmt_ctx-pb, out_file, AVIO_FLAG_WRITE); avformat_write_header(ofmt_ctx, NULL); while (av_read_frame(ifmt_ctx, pkt) 0) { AVStream *in_stream ifmt_ctx-streams[pkt-stream_index]; AVStream *out_stream ofmt_ctx-streams[pkt-stream_index]; // 关键一行把 packet 从源流时间基换算到目标流时间基 av_packet_rescale_ts(pkt, in_stream-time_base, out_stream-time_base); pkt-pos -1; // 字节位置对目标 muxer 没有意义置 -1 避免干扰 av_interleaved_write_frame(ofmt_ctx, pkt); av_packet_unref(pkt); } av_write_trailer(ofmt_ctx); avformat_close_input(ifmt_ctx); avformat_free_context(ofmt_ctx); av_packet_free(pkt);这里有一个很关键的细节out_stream-time_base在什么时候才是最终值答案是avformat_write_header之后。很多 muxer 会在写头时根据编码参数、帧率、容器标准再次调整流的time_base。所以你在avformat_new_stream之后手动设置的out_stream-time_base有可能被覆盖。正确做法是在编码循环里每次都实时读取out_stream-time_base而不是在write_header前把它存到一个临时变量里。这个坑我踩过不止一次。另外pkt-pos -1是我个人的习惯。pos表示源文件中的字节偏移目标 muxer 在做交织写入时根本不关心源 pos保留一个源文件的偏移值反而可能让 muxer 在内部做某些启发式判断时产生误导。置成-1是最稳妥的。3.2 场景二解码-转码-编码流程里的时间戳接力转码比流复制复杂。因为解码器、滤镜、编码器各自都有自己的时间基约定你需要一步步接力。典型流程是这样的读包av_read_frame拿到的 packet时间戳单位是ist-time_base。解码把 packet 送给解码器解码器输出的AVFrame上的pts在大多数情况下沿用输入 packet 的刻度也就是ist-time_base。编码你交给编码器的frame-pts编码器要求单位是enc_ctx-time_base编码器输出AVPacket后这个包的pts/dts也是enc_ctx-time_base。写文件目标 muxer 要求 packet 时间戳的单位是ost-time_base。所以转码流程里至少有两处需要换算。第一处是编码前把解码/滤镜得到的 frame pts 从来源刻度转成enc_ctx-time_base第二处是编码后把enc_ctx-time_base转成目标流ost-time_base。先看编码前从滤镜链路拿 frame 时的处理AVRational filter_tb av_buffersink_get_time_base(buffersink_ctx); frame-pts av_rescale_q(frame-pts, filter_tb, enc_ctx-time_base);如果没有滤镜只是单纯解码再编码解码器输出的 frame 通常直接用但你要确认 frame-pts 不是AV_NOPTS_VALUE。如果源文件某些包的时间戳缺失best_effort_timestamp往往是更安全的选择。这里建议用av_frame_get_best_effort_timestamp或直接拷贝解码 packet 的 dts 作为兜底。再看编码后从编码器拿到输出包时while (avcodec_receive_packet(enc_ctx, pkt) 0) { av_packet_rescale_ts(pkt, enc_ctx-time_base, out_stream-time_base); pkt-pos -1; av_interleaved_write_frame(ofmt_ctx, pkt); av_packet_unref(pkt); }注意这里不要再从源流的ist-time_base换算而是从enc_ctx-time_base换算。很多人在这一步把tb_src写成ist-time_base如果编码器内部的时间基恰好和源流不同时间戳就会出现整体偏移。为什么编码器要用自己的time_base因为在编码器看来帧率是它工作的节拍器比如编码器配置了framerate 25它的time_base通常就是1/25。你给它一个1/90000刻度的 pts 也不是不行但它会先做内部换算而更规范的方式是你自己显式换算清楚再送进去。3.3 场景三滤镜、裁剪、拼接与负 dts 处理如果你做视频剪辑类工具处理的时间场景更复杂。滤镜处理完后buffersink输出的 frame 时间基要用av_buffersink_get_time_base拿不能再自作主张用ist-time_base。因为滤镜内部可能做了帧率变换、时间重映射时间基可能已经变了。拿到 filter 时间基之后再统一换算到编码器时间基。拼接场景有个经验所有片段最好先换算到一个统一的目标时间基再进行时间戳累加而不是每个片段都用自己的源时间基做offset。不同片段的time_base很可能不一样你累加的offset就会失去意义。统一换算后再偏移才能保证拼接后的时间轴单调。还有一个很多人没注意的坑MP4 转 TS 时如果源视频首帧 pts 不是 0换算后可能出现负 dts。TS 流里 dts 为负是非法状态。命令行里用-avoid_negative_ts make_zero能处理换成 libav* API你要在avformat_write_header前设置ofmt_ctx-avoid_negative_ts 1。这个字段控制 muxer 是否自动把所有 dts 提升到非负。它是在 muxer 层面做的偏移和av_packet_rescale_ts干的事不同但两者经常配合使用。你能看到命令行工具很正常是因为 FFmpeg 工具内部把这一整套逻辑都封装好了你自己写 C/Python 调用 libav* 时没人帮你做这些。4. 高频踩坑与排查实录4.1 “Application provided invalid, non monotonically increasing dts” 到底是谁的锅这个报错可能是 FFmpeg 开发过程中出现频率最高的 muxer 报错之一。原因五花八门但按我排查经验最常见的有三种。第一种也是概率最高的没有调用av_packet_rescale_ts或调用了但源/目标时间基写反了。源流时间基是1/90000目标流是1/1000你写反成从1/1000到1/90000换算结果全乱dts 自然不单调。第二种同一个pkt被换算两次。比如你写了个工具函数内部调用了av_packet_rescale_ts外层代码又调了一次。第二次会把已经换到目标刻度的时间戳再乘一次比例数字直接爆炸。第三种B 帧顺序问题。源流是有 B 帧的dts和pts本来就不同如果你在写目标容器时为了某种目的手动重排了包的顺序但没有相应调整 dts也可能触发这个报错。注意av_packet_rescale_ts只做刻度换算不负责给包排序。排查办法很笨但有效在av_interleaved_write_frame之前打印每个包的时间戳以及对应的out_stream-time_base。打印代码长这样fprintf(stderr, stream%d tb%d/%d pts% PRId64 dts% PRId64 dur% PRId64 \n, pkt-stream_index, out_stream-time_base.num, out_stream-time_base.den, pkt-pts, pkt-dts, pkt-duration);如果你看到同样一个stream_index前后两个包的dts一会儿大一会儿小先确认是不是时间基换算问题如果换算正确还乱再查是不是流复制时某一帧丢了AVPacket导致索引错位。日志能解决 80% 的谜团。4.2 音画不同步与舍入精度陷阱音画不同步的排查比 dts 报错更隐蔽因为程序不报错只是播放时对不上。最常见的问题出现在音频duration换算上。假设音频是 44100Hz每包 1024 个采样。在time_base 1/44100下packet-duration 1024。换算到 TS 的1/90000后duration 1024 * 90000 / 44100 ≈ 2089.79四舍五入得到2090。这一包实际时长是2089.79 / 90000秒但 muxer 记成2090 / 90000秒每包误差大约 0.0000023 秒。单包误差很小但假设一段 3 分钟音频有 7734 包累计误差会到 18 毫秒左右。这个量级已经能被人耳感知到。怎么规避不能光靠四舍五入的 duration 精度还要靠 muxer 在写交织时用自己的时钟机制修正。你能做的是尽可能避免在多个时间基之间反复换算使用工具时优先选择源和目标时间基都能整除的中间值。如果音频很敏感对音频包可以自己算更精确的 duration或者直接基于采样数来维护时间轴不要把时间戳的“节奏”完全交给舍入后的 duration。还有一个小技巧不要为了“精确”把 duration 换算函数从av_rescale_q改成av_rescale_q_rnd(..., AV_ROUND_DOWN)或AV_ROUND_UP除非你明确知道目标容器有边界要求。四舍五入通常是最平衡的选择向上取整会导致音视频总时长虚长向下取整会导致总时长偏短。4.3 流复制中容易忽略的三个隐藏坑第一个坑是side_data里的计数不会跟着时间基换算。av_packet_rescale_ts只处理pts、dts、durationside_data里的像跳过采样数、回放增益这类信息很多跟采样数或帧数有关不会因为时间基变化而自动更新。做流复制时如果你发现输出的 audio 在某些播放器里开头多一秒或者少一秒可以先检查是不是skip_samples这类 side data 出问题。第二个坑是AV_NOPTS_VALUE的传播。源文件如果某些包的 pts 或 dts 是AV_NOPTS_VALUEav_packet_rescale_ts会跳过它保持AV_NOPTS_VALUE不变。这在数学上没问题但问题在于如果目标容器的 muxer 严格要求每个包都有有效 dts你的输出就会失败或行为异常。这种时候需要在写入前自己决策是丢弃这个包还是用相邻包的时间戳插值补一个。第三个坑是目标流的time_base没有按预期设置。前面提过muxer 可能在write_header时覆盖你预设的时间基。所以不要在循环外“缓存”out_stream-time_base的副本每次写包前都从ofmt_ctx-streams[pkt-stream_index]-time_base现场读取。这能避免很多“我明明设置了时间基但输出不对”的诡异问题。4.4 排查工具速查让 ffprobe 和日志帮你说话当你面对一个播放异常的文件第一件事不是看代码而是先用 ffprobe 看文件的时间基和时间戳分布。命令示例ffprobe -show_entries streamindex,codec_type,time_base,start_time,duration \ -of json input.mp4输出里能看到每条流的time_base、start_time、duration。你会立刻发现视频流可能time_base1/15360音频流time_base1/44100而 TS 输出要求两者都接近1/90000。这解释了为什么不能把视频的 pts 直接赋给音频也解释了为什么av_packet_rescale_ts必须每条流单独调用。如果你想看更细的每一包时间戳可以用ffprobe -show_frames -select_streams v:0 -show_entries framepts,dts,duration,pkt_size \ -of csv input.mp4有时候你写出来一个文件播放器卡顿用这条命令一看就明白是不是输出文件的 dts 不单调、pts 跳跃太大、或者 duration 异常。ffprobe 是你的眼睛打印日志是你的触觉两者结合绝大多数时间戳问题都能快速定位。写在最后的小经验和av_packet_rescale_ts打了这么多次交道我的体会是它本身很简单难的是你愿不愿意承认“时间戳是带单位的数值”。很多 bug 的根源不是函数不会用而是脑子里没有“单位换算”这根弦。我自己现在写任何 FFmpeg 程序都会在核心循环里放一个带条件编译的日志宏专门打印每个包写入前的stream_index、time_base、pts、dts、duration。上线跑之前开日志看一遍几乎能挡掉所有时间轴错乱问题。这个习惯帮我省了无数个排查夜晚也分享给你。
返回列表