第12章:flush 与时间基:收尾的艺术

编码器把尾帧憋在肚子里,不"排水"就全丢;时间戳跨了刻度必须换算。收尾不是可选项——画完最后一笔才签名,转码管线的最后几帧,是工程与艺术的交界

🎨

本章导师:达芬奇

核心方法论:艺术与工程

「工程是戴着镣铐的艺术。编码器有自己的脾气——B 帧要攒、预读要看、线程要排队,这些规则就是镣铐;而收尾(flush)与换算(rescale),就是在镣铐允许的范围内,把每一帧都体面地送到终点。这一章我们只做一件事:把'收工'这两个字,做到无可挑剔。」

12.1 编码器的"拖延症":为什么结尾会缺帧

第 11 章我们把 send/receive 模式跑通了:送一帧、收一包,循环往复。但官方文档里藏着一句容易被跳过的话——"for each input frame, the codec will typically return 1 output frame, but it can also be 0 or more than 1"(说人话:你送进去一帧,编码器通常吐一个包出来,但有时候一个都不吐)。那"0 输出"的那些帧去哪儿了?答案:被编码器内部缓冲(buffer)暂时扣下了。这不是 bug,是编码器故意的——它必须"拖延"。

为什么必须拖延?三个原因,全都和压缩的本质有关。第一,B 帧重排(呼应第 4 章的 bframes=3 实测):B 帧要参考"后面"的帧,编码器必须等未来几帧到齐才能决定怎么压,所以它手里永远压着几个帧。第二,码率控制预读(rc-lookahead):x264 要"偷看"未来若干帧,才能判断当前这帧怎么分配码率。第三,多线程流水线:开了 frame threads 后,编码器一次在途好几帧。这三笔账合起来,就是 12.4 节实测的"欠你 21 个包"。而最致命的是:你不主动讨债,这笔账就永远烂掉——流结束时直接关编码器,缓冲里没吐出来的帧随编码器一起蒸发,播放器看到的结尾就缺了一截。素材里那个 10 秒视频转完只剩 7 秒,就是这起"案发现场"。

/* /opt/homebrew/include/libavcodec/avcodec.h 第 120-147 行(真实原文节选)
 * 注意"0 or more than 1"与"End of stream situations"两处 */
//   For each input frame/packet, the codec will typically return 1
//   output frame/packet, but it can also be 0 or more than 1.
//
//   End of stream situations. These require "flushing" (aka draining)
//   the codec, as the codec might buffer multiple frames or packets
//   internally for performance or out of necessity (consider B-frames).
# 丢尾帧示意(本机实测数字见 12.4 节):30 帧进去,不 flush 就收工
送帧:   1 2 3 ... 30
包输出: 9 个(pts 0 4 2 1 3 8 6 5 7<- 只有前 9 帧附近变成包
被吞:   21 个包(pts 9~29<- 编码器缓冲:B 帧 + 预读 + 线程
收工:   avcodec_free_context() <- 缓冲里的 21 个包直接蒸发
缓冲来源说人话本机默认少几个包怎么关
B 帧重排B 帧要参考后面的帧,编码器必须攒着等bframes=33ctx->max_b_frames = 0
码率控制预读偷看未来 N 帧,决定当前帧怎么压rc-lookahead=10(veryfast)10av_opt_set(ctx->priv_data, "rc-lookahead", "0", 0)
线程流水线多线程编码,多帧同时在途threads=8(8 核)8ctx->thread_count = 1
合计三笔账加总——21全关掉仍延迟 1 帧(流水线深度)

注意:不 flush 的丢帧是静默的——没有报错、没有警告,avcodec_receive_packet 只是永远返回 AVERROR(EAGAIN)(说人话:还需要更多输入才能继续吐包),而你已经没有更多输入了。封装器照常写完文件头,ffprobe 一查:时长比输入短、结尾画面消失。第 11 章的丢帧案发现场之一是时间基换算错;这里是之二——结尾缺帧,作案手法是"忘了收尾"。

达芬奇提示

记忆锚点:编码器像画壁画的米开朗琪罗——颜料(帧)进了桶,不把桶底刮干净,最后一笔永远上不了墙。"0 or more than 1"这句文档原文,翻译成工程语言就是:收工前必须主动把桶底刮干净。收尾不是可选项,是编码循环的一部分。

12.2 draining mode:三步把缓冲榨干

官方为此设计了 draining mode(排水模式,说人话:让编码器把肚子里欠的包全部吐出来再收工)。操作只有三步,全部在 avcodec.h 的文档里白纸黑字写着:① 向 avcodec_send_frameNULL 代替真实帧,宣布"没有输入了"——编码器进入排水状态,从此不再接受新帧;② 在循环里反复调 avcodec_receive_packet,直到它返回 AVERROR_EOF;③ 如果你还要复用这个编码器(比如 seek 之后接着编),先调 avcodec_flush_buffers 重置内部缓冲

第二步有个官方明说的关键保证:排水阶段 avcodec_receive_packet 不会再返回 AVERROR(EAGAIN)——"unless you forgot to enter draining mode"(除非你忘了开排水模式)。这句话反过来读就是排错口诀:如果你在排水阶段还收到 EAGAIN,说明第一步的 NULL 没传对。为什么这个保证重要?因为 EAGAIN 和 EOF 是两种完全不同的"收工信号":EAGAIN 表示"我还要输入",EOF 表示"我真的结束了"。靠"收到 EOF"判断收尾,才不会把没吐完的包当成"已经吐完"。

// draining mode 三步(第 9-11 章语境下的标准收尾)
// ① 宣布收工:send(NULL) 开启排水模式,编码器不再接受新帧
avcodec_send_frame(ctx, NULL);
// ② 反复 receive 直到 AVERROR_EOF(这个阶段不会再返回 EAGAIN)
while (avcodec_receive_packet(ctx, pkt) >= 0) {
    av_packet_rescale_ts(pkt, ctx->time_base, ost->time_base);
    av_interleaved_write_frame(ofmt_ctx, pkt);
    av_packet_unref(pkt);
}
// ③ 需要复用编码器(seek / 拼接场景):重置内部状态
avcodec_flush_buffers(ctx);
/* avcodec.h 第 134-147 行(真实原文节选)——三步的官方出处 */
//   End of stream situations. These require "flushing" (aka draining)
//   the codec, as the codec might buffer multiple frames or packets
//   internally for performance or out of necessity (consider B-frames).
//   This is handled as follows:
//   - Instead of valid input, send NULL to the avcodec_send_packet()
//     (decoding) or avcodec_send_frame() (encoding) functions. This
//     will enter draining mode.
//   - Call avcodec_receive_frame() (decoding) or avcodec_receive_packet()
//     (encoding) in a loop until AVERROR_EOF is returned. The functions
//     will not return AVERROR(EAGAIN), unless you forgot to enter
//     draining mode.
步骤调用返回 / 语义
① 开启排水avcodec_send_frame(ctx, NULL)进入 draining mode,不再接受新帧;再送真帧会失败
② 收完剩余包循环 avcodec_receive_packet()持续吐包直到 AVERROR_EOF;不会再出现 EAGAIN
③ 重置复用avcodec_flush_buffers(ctx)清内部缓冲;实测:对 x264 不可靠,见 12.4 节
达芬奇提示

EAGAIN 与 EOF 的判别口诀:EAGAIN = "还想要输入"(编码中);EOF = "已经结束"(排水完)。所以"反复 receive 直到 EOF"必须写成循环,不能只收一次——你根本不知道缓冲里还压着几个包。12.4 节的实测会告诉你:可能是 0 个,也可能是 21 个。

注意:忘掉第一步(send NULL)直接反复 receive,你会永远卡在 EAGAIN 上——要么死循环,要么误判"已经收完"提前收工,尾帧照丢。"The functions will not return AVERROR(EAGAIN), unless you forgot to enter draining mode"——这是官方文档点名警告,排错时第一眼就查这里。

12.3 素材复盘:生产代码里的 do-while flush

素材(2020 年某生产转码服务)里的 flush 不是教科书三步,而是一个 do-while 循环——原文件第 1653-1669 行。它把"送 NULL + 反复收包"封装进了自家的 encodeMediaFrame() 函数:每次调用 = 一次 send(NULL) + 循环 receive 直到 EAGAIN/EOF;只要这一轮有包产出,就把 dataWritten 置 1,循环再来一轮;直到某一轮一个包都没吐出来,说明排水彻底结束。这个"以产出判定结束"的写法,比"收到 EOF 才停"更稳:它天然兜住了第二次 avcodec_send_frame(NULL) 会返回 AVERROR_EOF 的坑(排水模式只能进入一次,之后 send 不再接受任何东西)。

对照 12.2 节的三步,素材其实把第 ① 步和第 ② 步揉成了一个"排水循环",第 ③ 步(复用)则单独写在别处。生产代码这么写有一个现实理由:它的 encodeMediaFrame 同时服务正常编码和排水两种模式(正常模式传真帧、排水模式传 NULL),用一个函数 + 一个标志,主流程的调用点就统一了。写法可以灵活,但语义必须和官方三步一一对应——这是读懂任何"变体"的钥匙。

// 素材实录:生产转码服务 flush 视频编码器(原文件第 1653-1669 行,原样)
int dataWritten;
int ret;
if (NULL != output->videoStream) {
    do {
        dataWritten = 0;
        ret = encodeMediaFrame(NULL, output->formatContext,
                               output->videoCodecCtx, &dataWritten,
                               output->videoStream->index);
        if (0 > ret) {
            av_log(NULL, AV_LOG_ERROR,
                   "Failed whileflush video encode, %s:%d\n", __FILE__,
                   __LINE__);
            return ret;
        }
        av_log(NULL, AV_LOG_INFO, "flush video encoder data\n");
    } while (dataWritten);
}
// 同一件事的现行写法(语义与素材完全等价)
int dataWritten;
do {
    dataWritten = 0;
    int ret = avcodec_send_frame(vctx, NULL);  // 第一次 NULL 开启排水
    if (ret < 0 && ret != AVERROR_EOF) return ret; // 第二次 NULL 返回 EOF,正常
    while (avcodec_receive_packet(vctx, pkt) >= 0) {
        dataWritten = 1;                          // 有包产出,再试一轮
        av_packet_rescale_ts(pkt, vctx->time_base, ost->time_base);
        av_interleaved_write_frame(ofmt_ctx, pkt);
        av_packet_unref(pkt);
    }
} while (dataWritten);
维度素材写法(2020 生产代码)现行推荐写法
排水入口do-while 反复调 encodeMediaFrame(NULL, ...)send(NULL) 一次 + receive 循环到 EOF
结束判定dataWritten 标志(这一轮有没有产出包)收到 AVERROR_EOF
错误处理EAGAIN / EOF 均按"不是错误"处理(素材 1514-1559 行:error=0 后 goto cleanup)EAGAIN = 继续喂输入、EOF = 结束,都不是错误
语言C++(头文件需 extern "C" 包裹,见下方警告)C / C++ 均可,规则相同
达芬奇提示

素材 receive 循环(第 1514-1559 行)里,EAGAINEOF 都被当作"正常结束"处理(error = 0 后 goto cleanup),只有真正的负错误码才走报错分支。这与官方文档的语义完全一致:EAGAIN 不是失败,是"该喂输入了"。写自己的循环时沿用这个态度,日志里就不会全是假报错。

注意:素材是 C++ 工程(代码注释里就有 cout),但Homebrew 的 FFmpeg 头文件没有 extern "C" 保护——本机实测,直接 #include <libavcodec/avcodec.h>g++ 编译,链接期报一屏 Undefined symbols(C++ 名字修饰把 _avcodec_send_frame 变成了 mangled 名字,找不到 C 符号)。修复方法:把 include 包进 extern "C" { ... }(12.4 节的实验程序就是这么写的)。这是素材没贴出来的"第零步",也是 C++ 调用 FFmpeg 的必经之路。

12.4 本机实测:不 flush 与 flush 的包数差

理论说够了,动手。用最小编码器程序把"丢尾帧"钉死:libx264、320×240、25 fps、veryfast 预设,送 30 帧(1.2 秒)纯色渐变画面,分别用"不 flush 直接收工"和"flush 三步"两种方式收尾,数一数各收到多少个包。程序的核心是两个函数:make_frame() 造帧,pump() 送一帧并榨干编码器当前能吐的所有包(返回最后一次 receive 的结果,一定是 EAGAIN 或 EOF)——pump 就是 12.3 节那个 encodeMediaFrame 的最小化身:

// flush_demo.cpp(节选一:make_frame / pump / open_enc)
static AVFrame *make_frame(AVCodecContext *ctx, int idx) {
    AVFrame *f = av_frame_alloc();
    f->format = ctx->pix_fmt;
    f->width  = ctx->width;
    f->height = ctx->height;
    av_frame_get_buffer(f, 32);
    // Y 平面逐帧变化,U/V 固定 128
    memset(f->data[0], 16 + (idx * 7) % 220, f->linesize[0] * ctx->height);
    memset(f->data[1], 128, f->linesize[1] * ctx->height / 2);
    memset(f->data[2], 128, f->linesize[2] * ctx->height / 2);
    f->pts = idx;   // 编码器时间基 {1,25} 下的刻度
    return f;
}
// 送一帧并榨干所有包;frame 为 NULL 即开启 draining mode
static int pump(AVCodecContext *ctx, AVFrame *frame, int *n,
                int64_t *pts_log, int *log_len) {
    int ret = avcodec_send_frame(ctx, frame);
    if (ret < 0) return ret;
    AVPacket *pkt = av_packet_alloc();
    while ((ret = avcodec_receive_packet(ctx, pkt)) >= 0) {
        if (*log_len < 40) pts_log[(*log_len)++] = pkt->pts;
        (*n)++;
        av_packet_unref(pkt);
    }
    av_packet_free(&pkt);
    return ret;   // 一定是 AVERROR(EAGAIN) 或 AVERROR_EOF
}
// flush_demo.cpp(节选二:main 的三大场景)
// ---- 场景一:不 flush,直接收工(丢尾帧现场)----
AVCodecContext *c1 = open_enc();
int n = 0; int64_t pts1[40]; int len1 = 0;
for (int i = 0; i < 30; i++) {
    AVFrame *f = make_frame(c1, i);
    pump(c1, f, &n, pts1, &len1);
    av_frame_free(&f);
}
int ret = avcodec_receive_packet(c1, pkt);   // 再收一次:只能等到 EAGAIN
printf("场景一 不 flush : 送 30 帧 -> 收 %d 包(最后一次 receive = %s)\n",
       n, ret == AVERROR(EAGAIN) ? "AVERROR(EAGAIN)" : "其它");
// ---- 场景二:flush 三步(send NULL -> 收到 EOF)----
AVCodecContext *c2 = open_enc();
n = 0; int64_t pts2[40]; int len2 = 0;
for (int i = 0; i < 30; i++) {
    AVFrame *f = make_frame(c2, i);
    pump(c2, f, &n, pts2, &len2);
    av_frame_free(&f);
}
ret = pump(c2, NULL, &n, pts2, &len2);   // ① send(NULL) ② 反复 receive
printf("场景二 flush   : 送 30 帧 + NULL -> 收 %d 包(排水结束 = %s)\n",
       n, ret == AVERROR_EOF ? "AVERROR_EOF" : "其它");
// ---- 场景三:flush 之后想复用编码器?----
avcodec_flush_buffers(c2);               // ③ 官方文档建议的重置
AVFrame *f = make_frame(c2, 0);
ret = avcodec_send_frame(c2, f);
printf("场景三 复用    : flush_buffers 后 send_frame = %s\n",
       ret < 0 ? av_err2str(ret) : "成功");
# 编译(本机真实执行,注意 extern "C" 与 pkg-config):
g++ -O2 flush_demo.cpp $(pkg-config --cflags --libs libavcodec libavformat libavutil) -o flush_demo
# 运行(本机真实输出,完整;0x... 是内存地址,每次运行不同):
./flush_demo
场景一 不 flush :30->9 包(最后一次 receive = AVERROR(EAGAIN)输出的包 pts: 0 4 2 1 3 8 ... 5 7
场景二 flush   :30 帧 + NULL ->30 包(排水结束 = AVERROR_EOF输出的包 pts: 0 4 2 1 3 8 ... 27 29
[libx264 @ 0x...] lookahead thread is already stopped
场景三 复用    : flush_buffers 后 send_frame = Generic error in an external library
  关掉重开后    : 再送 5 帧 + NULL ->5 包(排水结束 = AVERROR_EOF时间基换算 : 帧 pts=25(t=1.000s)-> mp4=12800 / mkv=1000 / ts=90000
素材进度公式 : pts2ms = 12800 × (1/12800) × 1000 = 1000 ms

结果触目惊心:不 flush 只收到 9 个包,flush 后是 30 个包——21 个包(70%)憋在编码器里随收工蒸发。看 pts 更直观:不 flush 的 9 个包 pts 是 0 4 2 1 3 8 6 5 7,全部落在前 9 帧附近(0 4 2 1 3 正是第 4 章见过的 I P B B B 重排序列);而 pts 9 到 29 的 21 帧一个都没出来。flush 后 pts 一路铺到 29,30 帧全齐。素材开头那句"一个 10s 的视频经过转码后只有了 7s",机理与此完全相同——比例不同只是因为编码器配置不同。再看场景三:avcodec_flush_buffers 之后立刻送帧,返回 Generic error in an external libraryAVERROR_EXTERNAL)——官方文档只保证 decoding 可以靠它恢复(原文 "Before decoding can be resumed again"),对 x264 编码器,实测结论是:EOF 之后想复用,别赌 flush_buffers,关掉重开avcodec_free_context + 重新 open_enc),重开后一切正常。

# 把三个缓冲来源逐一关掉,看"不 flush"少几个包(本机实测)
# 改法:open_enc() 里分别设 ctx->max_b_frames / rc-lookahead / thread_count
bframes=3               : 30-> 9包(延迟 21 = 3+10+8bframes=0               : 30-> 12包(延迟 18 = 0+10+8bframes=0 rc-lookahead=0: 30-> 22包(延迟 8 = 0+0+8bframes=0 threads=1     : 30-> 19包(延迟 11 = 0+10+1全关(+threads=1)      : 30-> 29包(延迟 1,流水线深度)
# 拟合:延迟 = bframes + rc-lookahead + threads,与五组实测分毫不差
配置不 flush 收到延迟(少几个包)与公式核对
默认(bframes=3)9 包213 + 10 + 8
bframes=012 包180 + 10 + 8
bframes=0 + rc-lookahead=022 包80 + 0 + 8
bframes=0 + threads=119 包110 + 10 + 1
bframes=0 + rc-lookahead=0 + threads=129 包1流水线深度(最小延迟)

注意:这些数字是本机实测(FFmpeg 8.1.1 + libx264 + veryfast 预设 + 8 线程),换版本、换预设、换机器都会变——但"不 flush 必丢尾帧"的机理不变。到了生产环境,先跑一遍这个最小程序拿到自己机器的延迟数字,再决定进度条和时长校验怎么设计。bframes=0 只能减小延迟、不能消除它,所以"关 B 帧就不用 flush"是错的。

达芬奇提示

输出里那行 [libx264 @ 0x...] lookahead thread is already stopped 是 x264 关闭编码器时的良性噪音(预读线程收尾),不是错误。真实输出就是会有这类信息,看到先别慌——对照场景三的位置,它恰好出现在"带病关闭"的上下文附近,反而成了复用失败的旁证。

12.5 时间基收尾:av_packet_rescale_ts 与进度换算

收尾的第二件事:时间戳跨刻度。编码器有自己的 time_base(我们设的是 {1, 25},一格 = 1/25 秒),而封装器写容器时用的是容器刻度——第 4 章实测:同一码流进 MP4 是 1/12800、进 MKV 是 1/1000、进 TS 是 1/90000。这两个刻度不相等,包在写进容器之前必须换算,否则时长、音画同步全错。FFmpeg 给的函数就是 av_packet_rescale_ts(packet.h 第 930 行):给定源刻度 tb_src 和目标刻度 tb_dst,把包上的 pts/dts 一次性换算过去——素材第 1535-1536 行的调用正是标准姿势:编码器刻度 → 输出流(容器)刻度

换算之后每个包的 pts 有了"容器的意义"。本机实测(flush_demo 尾部):第 25 帧在编码器刻度下 pts=25,换算到 MP4 刻度变成 12800、MKV 刻度变成 1000、TS 刻度变成 90000——三个数字刻度不同,但 × time_base 之后都是 1.0 秒,分毫不差。这就是第 4 章说的"分数时间基的威力":全程整数运算、零累计误差。换算用 av_rescale_q(整数精确换算),进度条则常用素材里的公式:pts2ms = pts × av_q2d(time_base) × 1000——av_q2d 把分数转成 double,乘 1000 得到毫秒,进度条按毫秒推进(素材第 1541-1548 行还做了"只取最大值"保证进度不回退)。

// /opt/homebrew/include/libavcodec/packet.h 第 930 行(真实签名)
void av_packet_rescale_ts(AVPacket *pkt, AVRational tb_src, AVRational tb_dst);
// 素材里的真实调用(原文件第 1535-1536 行):
av_packet_rescale_ts(&outputPacket, encCtx->time_base,
                     ofmtCtx->streams[streamIndex]->time_base);
// 说人话:把包上的 pts/dts 从编码器刻度(1/25)换成容器刻度(如 1/12800)
# 本机实测(flush_demo.cpp 尾部输出):
时间基换算 : 帧 pts=25(t=1.000s)-> mp4=12800 / mkv=1000 / ts=90000
素材进度公式 : pts2ms = 12800 × (1/12800) × 1000 = 1000 ms
# 换算验证(三个刻度殊途同归):
#   25 × (1/25) = 1.0 秒;12800 × (1/12800) = 1.0 秒;90000 × (1/90000) = 1.0 秒
目标容器容器时间基(第 4 章实测)rescale 后 pts秒数验证
MP41/128001280012800 × (1/12800) = 1.0 秒
MKV1/1000(毫秒)10001000 × (1/1000) = 1.0 秒
MPEG-TS1/90000(90 kHz)9000090000 × (1/90000) = 1.0 秒
达芬奇提示

一句话分工:进度条用 av_q2d 够用(double 精度无所谓),写容器必须 av_rescale_q / av_packet_rescale_ts(整数精确)。素材把这两件事拆得很清楚:pts2ms 只管显示,av_packet_rescale_ts 管落盘。别为了省事用浮点换算时间戳——第 4 章说过,150 帧的浮点累计误差就够音画不同步了。

注意:素材注释写"单位ms(微妙)"是笔误——× 1000 得到的是毫秒× 1000000 才是微秒(微妙是口语讹写)。另外它那句 if (pts2ms > _pts2ms) _pts2ms = pts2ms; 是音视频双流的进度策略:视频和音频各有自己的时间轴,进度只取较大的那个,才能保证进度条单调向前。把两路进度分开显示再取平均,进度就会来回跳。

12.6 收尾的艺术:收官清单与第 13 章预告

达芬奇收笔,是把每一笔都交代干净。转码收尾同理——别忘了音频也要 flush。aac 编码器同样有内部缓冲(启动延迟帧),本机实测:100 帧音频(约 2.3 秒)不 flush 只出 99 包,flush 后 101 包(延迟帧和收尾帧在排水时补出)。不 flush 音频,封装器写出来的音轨结尾就缺一帧(1024 采样 ≈ 23 毫秒),ffprobe 一验时长就对不上。所以收官清单里,视频、音频两条流各自走一遍 draining,最后再 av_write_trailer 写文件尾。

# aac 也有缓冲:不 flush 少 1 包(本机实测,aac_demo.cpp)
aac 不 flush : 100-> 99 包
aac flush 后 : 100 帧 + NULL -> 101 包(排水结束 = AVERROR_EOF# 结论:音频流的排水不是可选项,结尾缺帧照样发生
// 完整收官的顺序(音视频双流,第 9-11 章主循环的收尾段):
// 1. 主循环:送帧 -> receive -> rescale -> 交错写(第 11 章已跑通)
// 2. 视频排水:
avcodec_send_frame(vctx, NULL);
while (avcodec_receive_packet(vctx, pkt) >= 0) { write_one(pkt); }
// 3. 音频排水:
avcodec_send_frame(actx, NULL);
while (avcodec_receive_packet(actx, pkt) >= 0) { write_one(pkt); }
// 4. 写文件尾:
av_write_trailer(ofmt_ctx);
// 5. 要复用编码器(seek / 拼接场景):关掉重开,别赌 flush_buffers
avcodec_free_context(&vctx); vctx = open_enc();
步骤函数检查点
主循环收尾第 11 章 send/receive 循环视频、音频各自把能收的包先收完
视频排水send(vctx, NULL) + receive 到 EOF包数 = 送帧数(实测 30/30)
音频排水send(actx, NULL) + receive 到 EOF不 flush 会少帧(实测 99 vs 101)
时间戳换算av_packet_rescale_ts每包从编码器刻度换到容器刻度
写文件尾av_write_trailer(ofmt_ctx)ffprobe 验证时长与输入一致
资源释放 / 复用avcodec_free_context关掉重开是实测唯一可靠的"复用"
达芬奇提示

全章收束成一句话:收尾 = 把编码器欠你的包全部要回来(flush)+ 把每个包的时间戳换算到容器的刻度(rescale)。第 9 章打开输出、第 10 章配置编码器、第 11 章跑通循环,本章补上最后的收笔——到这一步,一个"能交付"的转码管线就齐了:帧进去,完好的文件出来。

注意:在 av_write_trailer 之前不 flush 音频,封装器写出的 duration 会短一截——这是"能播但时长不对"类 bug 的常见来源。验收动作别省:转完立刻 ffprobe 对比输入输出的时长与帧数(第 3 章的主场),数字对不上,先查排水,再查刻度。

至此,"文件 → 内存 → 编码器 → 容器"这条默认路径走完了。但注意一个隐含前提:全程用的都是 FFmpeg 的默认文件 IO——打开的是磁盘路径,读写交给系统。如果输出不想落盘呢?想直接进内存缓冲区、走网络流、接自定义协议?第 13 章自定义 IO(avio_open)就是干这个的:把"写到哪里"从默认文件句柄换成你自己的回调,管道的两头从此都自由了。那是诸葛亮的主场——运筹帷幄,决胜千里。

章末练习

练习 1:收尾信号配对 入门

把下列信号/动作与它们的含义配对:① AVERROR(EAGAIN);② AVERROR_EOF;③ avcodec_send_frame(ctx, NULL);④ avcodec_flush_buffers(ctx)。候选含义:A. 排水完成,编码器没货了;B. 编码器还需要新输入;C. 开启 draining mode,宣布不再有输入;D. 重置编码器内部缓冲(复用前调用)。

提示

回顾 12.2 节的三步速查表和 EAGAIN/EOF 判别口诀:"EAGAIN = 还想要输入,EOF = 已经结束"。

参考答案

① → B(EAGAIN:编码器还需要新输入才能继续吐包);② → A(EOF:排水完成);③ → C(send NULL 开启 draining mode);④ → D(flush_buffers 重置内部缓冲)。口诀:EAGAIN 是"还要",EOF 是"没了",NULL 是"关门",flush_buffers 是"打扫干净再开张"

练习 2:补全排水三步 进阶

下面的代码少了两行,请补全,使编码器在送完 30 帧后能吐出全部缓冲包并正确结束:

for (int i = 0; i < 30; i++) {
    AVFrame *f = make_frame(ctx, i);
    pump(ctx, f, &n, NULL, NULL);
    av_frame_free(&f);
}
// 请补全下面两处:
__________(ctx, NULL);                    // ① 开启排水模式
while (avcodec_receive_packet(ctx, pkt) >= 0) { // ② 循环收到 ?
    n++;
    av_packet_unref(pkt);
}
提示

① 是 12.2 节的第一步;② 的循环退出条件写在 while 条件里,问号处填循环"收到什么就停"。

参考答案

avcodec_send_frame(ctx, NULL);② while 条件 >= 0 意味着 avcodec_receive_packet 返回 AVERROR_EOF(负数)时循环自然退出——正是"反复 receive 直到 EOF"。补全后:

avcodec_send_frame(ctx, NULL);
while (avcodec_receive_packet(ctx, pkt) >= 0) {
    n++;
    av_packet_unref(pkt);
}

如果只写一次 receive 而不循环,就会漏掉缓冲里剩余的包——12.4 节实测是 21 个。

练习 3:复现延迟分解 进阶

把 12.4 节的 flush_demo.cpp 里 open_enc() 的三行参数分别改成:ctx->max_b_frames = 0av_opt_set(ctx->priv_data, "rc-lookahead", "0", 0)ctx->thread_count = 1。逐一重新编译运行,记录"不 flush 收包数",并验证"延迟 = bframes + rc-lookahead + threads"在本机是否成立。

提示

对照 12.4 节末尾的五组实测表;每次只改一个参数,保持其他参数不变,才能归因。跑完用 sysctl -n hw.ncpu 确认本机核数(对应 threads 默认值)。

参考答案

本机(FFmpeg 8.1.1,8 核)实测:默认收 9 包(延迟 21 = 3+10+8);bframes=0 收 12 包(18 = 0+10+8);再加 rc-lookahead=0 收 22 包(8 = 0+0+8);threads=1 收 19 包(11 = 0+10+1);三个全改收 29 包(延迟 1,流水线深度)。公式成立。注意 bframesAVCodecContext 的字段,直接赋值即可;老教程里 av_opt_set(ctx->priv_data, "bframes", ...) 的写法在本机实测返回"选项不存在"(AVERROR_OPTION_NOT_FOUND)——API 演进,以本机头文件为准。

练习 4:被文档坑一次 挑战

官方文档写"Before decoding can be resumed again, the codec has to be reset with avcodec_flush_buffers()"。请实测验证:对 libx264 编码器,drain 到 EOF 之后调用 avcodec_flush_buffers(),再送新帧会发生什么?给出修复方案并解释:为什么文档只承诺了 decoding 方向的恢复?

提示

12.4 节场景三已经实测过了:send_frame 返回 Generic error in an external library(AVERROR_EXTERNAL)。修复方向:释放上下文重建。想想编码器和解码器的状态机差异——编码器内部的 x264 实例在排水后处于什么状态?

参考答案

实测结果:avcodec_flush_buffers()avcodec_send_frame 返回 AVERROR_EXTERNALGeneric error in an external library),编码器无法继续使用。修复方案:avcodec_free_context(&ctx) 后重新 avcodec_alloc_context3 + avcodec_open2(12.4 节场景三实测重开后一切正常)。原因:文档的承诺对象是解码器(seek 场景要重置的是解码状态);而编码器内部持有 x264 实例,排水(EOF)后其内部状态不可逆,flush_buffers 并不会重建它——这是"文档只说 decoding"的深层原因。教训:涉及编码器复用,先实测再相信文档