第11章:send/receive 模式:告别丢帧

一段 10 秒的视频转码后只剩 7 秒——丢掉的帧去了哪里?这一章我们当一回侦探:先看案发现场,再审 API 的设计,再用最小程序亲手复现丢帧,最后解剖生产代码。把"不可能"一项项排除,剩下的真相只有一个:receive 循环与 flush

🎩

本章导师:福尔摩斯

核心方法论:排除不可能

「排除一切不可能,剩下的就是真相。一个 10 秒的视频转码后只剩 7 秒——这不是玄学,是有一处确定的原因。帧不会自己消失:要么编码器根本没把它产出来,要么产出来了却没被取走、没被写进文件。这一章我们用最小程序把两种写法摆在桌上对比,让丢帧在你自己机器上现形。当你把'不可能'一项项排除干净,剩下的那个真相,就是 send/receive 的正确循环与结尾的 flush。」

11.1 案发现场:10 秒的视频,转码后只剩 7 秒

2020 年的一篇生产环境实录(本线素材笔记《ffmpeg-转码后丢帧的问题》)记录了一起经典事故:一段 10 秒的视频,经过转码后只剩 7 秒。整整 3 秒的画面凭空消失——不是卡顿、不是变慢,是彻底没有了。转码没有报错,文件能打开,但时长就是不对。用福尔摩斯的方法论先排除不可能:视频帧不会自己蒸发,它们要么没有被编码器产出来,要么产出来了却没被取走。生产转码器是用 FFmpeg 的 C API 手写的,嫌疑就集中在 API 的使用方式上。

先看历史。在 send/receive 模式出现之前,FFmpeg 提供的是"一次调用、一进一出"的旧 API:avcodec_encode_audio2()(编码一帧音频)、avcodec_decode_video2()(解码一帧视频)。每次调用塞一个输入、拿一个输出,输入输出一一对应。这个模型在 B 帧面前天然漏帧——还记得第 4 章的结论吗?B 帧是"事后诸葛",要参考未来的帧,编码器必须把 B 帧攒在内部缓冲里等后面的帧到齐,一进一出的调用根本取不干净缓冲里的货。这套旧 API 已经彻底退役:本机 FFmpeg 8.1.1 的头文件里 grep 这两个名字,结果为零——连"已弃用"的声明都删干净了。

# 老 API 的历史签名(FFmpeg 4.x 时代,现行 8.1.1 头文件中已彻底删除)
# 设计缺陷:一次调用 = 一个输入 + 一个输出,没有"把缓冲吐完"的机制
int avcodec_encode_audio2(AVCodecContext *avctx, AVPacket *avpkt,
                          const AVFrame *frame, int *got_packet_ptr);
int avcodec_decode_video2(AVCodecContext *avctx, AVFrame *picture,
                          int *got_frame_ptr, const AVPacket *avpkt);

那么用现行 API 还会丢帧吗?会——如果你把"一进一出"的思维惯性带过来:每送一帧只 receive 一次,拿不到就当没有。下面这个错误写法就是 10 秒变 7 秒的现场还原(本章 11.4 节会把它编译出来实测):

// 错误写法:send 后只 receive 一次 —— 丢帧现场(对应素材笔记的 10s→7s 事故)
for (int n = 0; n < nframes; n++) {
    avcodec_send_frame(ctx, frame);      // ① 送一帧进去
    if (avcodec_receive_packet(ctx, pkt) >= 0)  // ② 只收一次!
        fwrite(pkt->data, 1, pkt->size, out);
    // ③ 没收到?帧进缓冲了 —— 之后再也不管它
}
// ④ 没有 flush —— 缓冲里攒的 B 帧和线程缓冲帧,全部丢失

注意:丢帧不是随机发生的。编码器内部有一个缓冲(B 帧延迟 + 多线程 lookahead,11.2 节细讲),输入帧先排队、后产出。receive 返回"没有输出"时,帧不是丢了,是还在缓冲里等着——但如果你从此不再取它、结尾也不 flush,它就真的永远出不来了。10 秒变 7 秒,丢的就是这一批缓冲帧。

福尔摩斯提示

素材笔记写于 2020 年,但它总结的规则和现行官方文档一字不差:"反复调用 receive 直到返回 EAGAIN 或其他错误""每个输入可能输出 0 个或者多于 1 个"。素材是官方文档的中文实践版——这条铁律十年没变,因为它描述的是编码器物理上怎么工作。

11.2 新 API 的设计:send 与 receive 分离

现行 FFmpeg 提供两组函数,分别负责解码和编码:解码用 avcodec_send_packet() + avcodec_receive_frame(),编码用 avcodec_send_frame() + avcodec_receive_packet()。官方文档(本机 avcodec.h 第 95 行起)第一句话就点明了设计意图:"decouples input and output"——把输入和输出解耦。说人话:送东西和取东西是两个独立的闸口。输入闸口一次只收一个输入;输出闸口你爱取几次取几次,直到它告诉你"暂时没货了"。

为什么必须解耦?因为 codec 内部有缓冲——这正是第 4 章 B 帧结论的延续。编码器要编 B 帧,就必须把 B 帧攒起来等后面的参考帧;x264 还有多线程 lookahead(提前几十帧做运动分析),进一步加深缓冲。于是官方文档给出了契约:每个输入帧/包,"通常"输出 1 个,"但也可以是 0 个或多于 1 个"("it can also be 0 or more than 1")。0 个:帧进缓冲了,还没轮到产出;多于 1 个:缓冲满了一次吐好几个,或 flush 时一次吐完剩余的全部。接受这个契约,循环写法就是唯一正确解。

# 两组 API 的真实签名(本机 /opt/homebrew/include/libavcodec/avcodec.h)
# 解码:喂压缩包,取未压缩帧
int avcodec_send_packet(AVCodecContext *avctx, const AVPacket *avpkt);   // 第 2364 行
int avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame);   // 第 2391 行

# 编码:喂未压缩帧,取压缩包
int avcodec_send_frame(AVCodecContext *avctx, const AVFrame *frame);  // 第 2427 行
int avcodec_receive_packet(AVCodecContext *avctx, AVPacket *avpkt);   // 第 2444 行
# 数据流全貌:输入走一个闸口,输出走另一个闸口
输入                        codec(内部缓冲)               输出
frame --> send_frame ──► ┌──────────────────┐ ──► receive_packet --> packet(0 个 / 1 个 / 多个)
                          │  B 帧延迟 + lookahead │
NULL --> send_frame ──► │  draining 模式      │ ──► 剩余 packet 全部吐出 --> AVERROR_EOF
方向输入输出成功返回值说人话
解码avcodec_send_packet()压缩包 AVPacket0喂一个压缩包进去
解码avcodec_receive_frame()未压缩帧 AVFrame0取一帧原始画面出来
编码avcodec_send_frame()未压缩帧 AVFrame0喂一帧原始画面进去
编码avcodec_receive_packet()压缩包 AVPacket0取一个压缩包出来
福尔摩斯提示

记住两个返回值的语义,这是本章的破案钥匙:AVERROR(EAGAIN) = "缓冲空了,我需要新的输入才能产出新输出"(说人话:你喂下一帧吧,这不是错误);AVERROR_EOF = "流结束了,我再也没有输出了"(只有进入 draining 模式后才会出现)。官方文档原话:Repeat this call until it returns AVERROR(EAGAIN) or an error(反复调用直到 EAGAIN 或错误)。

11.3 编码循环模板:send 一帧,收尽所有输出

把 11.2 的契约翻译成代码,就是本线所有转码程序的地基:先 send 一个输入,然后反复 receive 直到 EAGAIN,处理每个拿到的输出,再送下一个输入。这个模板对编码、解码、音频、视频完全通用——差别只是 send/receive 的函数名换一换。下面是编码方向的模板,直接取自本章实测程序 enc_sr.c 的循环体(真实编译运行过,11.4 节完整展示):

// 编码一帧的完整循环:send 之后,把 codec 吐出的所有包收干净
int ret = avcodec_send_frame(ctx, frame);      // ① 送一个输入
if (ret < 0) { /* 真错误:送不进去 */ return ret; }

for (;;) {
    ret = avcodec_receive_packet(ctx, pkt);    // ② 取一个输出
    if (ret == AVERROR(EAGAIN)) { ret = 0; break; }  // 缓冲空了,等下一个输入
    if (ret < 0) break;                        // EOF 或错误
    fwrite(pkt->data, 1, pkt->size, out);   // ③ 处理这个包(写文件/封装)
    got++;
}

把分支拆开看,receive 的返回值一共四路,素材笔记的生产代码(11.5 节)就是这四路的完整实现:EAGAIN 是"正常收尾,等下一个输入";AVERROR_EOF 是"流结束";负数且不是 EAGAIN/EOF 才是真错误,要打印出来;返回值 >= 0 表示成功拿到一个包,处理完继续循环——因为同一次 send 可能触发多个输出,绝不能在拿了一个包后就跳出。开头编码时"吞帧"尤其容易让人误判:codec 可能连吃好几帧输入都不吐一个包(缓冲在攒货),这时 receive 返回 EAGAIN 是教科书级的正常现象。

// 四路分支的标准写法(含错误打印,素材同款风格)
int ret = avcodec_receive_packet(ctx, pkt);
if (ret == AVERROR(EAGAIN)) {
    // 正常:codec 需要新输入,退出接收循环
    break;
} else if (ret == AVERROR_EOF) {
    // 流结束:只有 draining mode(flush)后才会返回
    break;
} else if (ret < 0) {
    av_log(NULL, AV_LOG_ERROR, "Could not encode frame: %s\n",
           av_err2str(ret));
    return ret;
}
// ret >= 0:成功拿到一个压缩包,处理它,然后继续循环

注意:最阴险的写法是"if (receive(>= 0)) 处理"——每帧只尝试收一次。在 x264 上它偶尔能"工作"(多数时候每 send 恰好触发一个输出),于是上线几个月都平安无事,直到某个素材的缓冲节奏一变,帧就悄悄丢了。官方契约写得明明白白:0 个或多于 1 个都是合法输出,把运气当设计,总有一天会踩雷。

福尔摩斯提示

还有个细节:素材笔记说"对于每个输入的 packet 或 frame,codec 一般会输出一个,但是也有可能输出 0 个或者多于 1 个"。本章实测里"多于 1 个"最壮观的现场在 flush 阶段——send_frame(NULL) 只送了一个输入,receive 循环却连续吐出 36 个包(11.4 节表格)。一个输入 → 36 个输出,这就是契约里"多于 1 个"的实锤。

11.4 实测:只 receive 一次,丢了 24% 的帧

推理到这一步,需要物证。我们写一个最小 send/receive 编码器 enc_sr.c:程序内部生成 320×240 的 yuv420p 渐变画面(不需要任何外部素材文件),libx264 编码,GOP 25 帧、最多 3 个 B 帧,共 150 帧 = 6 秒(25 fps)。同一份代码支持两种模式:once(每帧 send 后只 receive 一次,不 flush——11.1 节的错误写法)和 loop(send 后 while receive 直到 EAGAIN,结尾 flush——11.3 节的正确模板)。先编译(本机实测命令):

# 编译(macOS arm64,Homebrew ffmpeg-full 8.1.1 实测通过)
gcc -O2 -o enc_sr enc_sr.c $(pkg-config --cflags --libs libavcodec libavformat libavutil)
// enc_sr.c —— 最小 send/receive 编码器(完整源码,实测版本) #include <stdio.h> #include <stdlib.h> #include <string.h> #include <libavcodec/avcodec.h> #include <libavutil/imgutils.h> static const int W = 320, H = 240, FPS = 25, BFRAMES = 3; // 生成第 n 帧:水平渐变 + 亮度递增,保证每帧内容不同 static void fill_frame(AVFrame *f, int n) { for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) f->data[0][y * f->linesize[0] + x] = (x * 255 / W + n * 3) & 0xff; memset(f->data[1], 128, f->linesize[1] * H / 2); memset(f->data[2], 128, f->linesize[2] * H / 2); } int main(int argc, char **argv) { int once = strcmp(argv[1], "once") == 0; int nframes = argc > 2 ? atoi(argv[2]) : 150; const AVCodec *codec = avcodec_find_encoder_by_name("libx264"); AVCodecContext *ctx = avcodec_alloc_context3(codec); ctx->width = W; ctx->height = H; ctx->pix_fmt = AV_PIX_FMT_YUV420P; ctx->time_base = (AVRational){1, FPS}; ctx->framerate = (AVRational){FPS, 1}; ctx->gop_size = 25; ctx->max_b_frames = BFRAMES; // 开 B 帧:让缓冲有货可丢 avcodec_open2(ctx, codec, NULL); FILE *out = fopen("out.h264", "wb"); AVFrame *frame = av_frame_alloc(); frame->format = ctx->pix_fmt; frame->width = W; frame->height = H; av_frame_get_buffer(frame, 32); AVPacket *pkt = av_packet_alloc(); long sent = 0, got = 0, eagain = 0, flushed = 0; for (int n = 0; n < nframes; n++) { fill_frame(frame, n); frame->pts = n; if (avcodec_send_frame(ctx, frame) < 0) break; sent++; if (once) { // 错误版:只 receive 一次 int ret = avcodec_receive_packet(ctx, pkt); if (ret >= 0) { fwrite(pkt->data, 1, pkt->size, out); got++; } else if (ret == AVERROR(EAGAIN)) eagain++; } else { // 正确版:while receive 直到 EAGAIN for (;;) { int ret = avcodec_receive_packet(ctx, pkt); if (ret == AVERROR(EAGAIN)) { eagain++; break; } if (ret < 0) break; fwrite(pkt->data, 1, pkt->size, out); got++; } } } if (!once) { // 结尾 flush:把缓冲里的帧全挤出来 avcodec_send_frame(ctx, NULL); // NULL = 进入 draining mode for (;;) { int ret = avcodec_receive_packet(ctx, pkt); if (ret == AVERROR_EOF) break; if (ret < 0) break; fwrite(pkt->data, 1, pkt->size, out); got++; flushed++; } } printf("mode=%s frames=%d sent=%ld got=%ld (flush=%ld) eagain=%ld\n", once ? "once" : "loop", nframes, sent, got, flushed, eagain); printf("duration=%.2fs (got/fps) 期望 %.2fs\n", (double)got / FPS, (double)nframes / FPS); return 0; }

运行两个模式,输出对比触目惊心(本机真实输出,节选 x264 统计后)——同一份输入,once 模式只拿到 114 个包(4.56 秒),loop 模式拿到 150 个包(6.00 秒),整整丢了 36 帧、24%。把同样的逻辑放到 10 秒素材上:10 × 24% ≈ 2.4 秒,正好是素材笔记"10s 变成 7s"的量级。丢掉的 36 帧不是随机帧,是 x264 统计里都没出现的帧——once 模式结束时 x264 只报告了 I:5 / P:109 共 114 帧,另外 36 帧还在缓冲里没编完,flush 一缺,全部蒸发。

# ./enc_sr once 150 —— 错误写法(真实输出,节选)
mode=once frames=150 sent=150 got=114 (flush=0) eagain=36
duration=4.56s (got/fps)  期望 6.00s
frame I:5     Avg QP:28.97  size:   296
frame P:109   Avg QP:25.12  size:    86
<- 114 个包全是 I/P;36 帧还在缓冲里没编完,就永远没了
# ./enc_sr loop 150 —— 正确写法(真实输出,节选)
mode=loop frames=150 sent=150 got=150 (flush=36) eagain=150
duration=6.00s (got/fps)  期望 6.00s
frame I:7     Avg QP:29.60  size:   260
frame P:143   Avg QP:25.11  size:    86
<- flush 补出 36 个包;eagain=150 说明每次 send 后都正常等到缓冲空

再深挖一步:缓冲到底有多深?缓冲里是什么?把线程数和 B 帧上限当成变量重跑(enc_threads 实验,同一渐变素材、150 帧),得到一张完整的"丢帧地图"(全部真实输出):

线程数max_b_framesonce 输出loop 输出flush 补出丢帧数
自动(多线程)31141503636
1(单线程)31241502626
231191503131
431171503333
自动01171503333
自动51121503838
福尔摩斯提示

这张表读三遍,破案就完成了:① loop 那一列永远等于 150——正确循环 + flush 在任何线程数、任何 B 帧设置下都一帧不丢,这是模板的可靠性证明;② once 丢的帧数恰好等于 flush 补出的帧数——丢的就是缓冲里没取走的货;③ 缓冲深度 = B 帧延迟 + 线程 lookahead——bframes 从 0 加到 5,丢帧从 33 涨到 38(B 帧贡献),而 bframes=0 时仍丢 33(多线程 lookahead 贡献,单线程则恒 26)。缓冲不是玄学,是可测量、可解释的。

注意:once 模式在本素材上"循环内恰好每 send 一个输出",所以循环里没丢、全丢在 flush——但这不代表"只 receive 一次"安全。一旦素材节奏变化触发一次多输出(比如缓冲满时一次吐两包),第二个包当场丢失。官方契约"0 个或多于 1 个"是编码器的物理属性,不是可选项。用 while 循环 + flush,是唯一不需要赌运气的写法。

11.5 生产代码解剖:素材里的 while(true) 循环

回到素材笔记。当年那个生产转码器其实已经用上了正确模式——它的 encodeMediaFrame() 函数里有一个著名的 while(true) 循环(素材原文第 1514-1559 行):send 一帧之后进入循环,反复 avcodec_receive_packet(),直到 EAGAIN 或 EOF 才 goto cleanup 返回。这个循环就是 11.3 节模板的生产形态:EAGAIN 走 cleanup 不是错误,而是"本次输入处理完毕,等下一个输入"的正常收尾。看懂它,生产级转码器的骨架就有了。

循环体里还有三件必做的正经事,逐一看:outputPacket.stream_index = streamIndex——封装器按流索引归类,多流文件(视频+音频)里漏设这行,包会被写进错误的流;av_packet_rescale_ts()——把包的时间戳从编码器刻度换算到容器刻度,这正是第 4 章预告的"丢帧排错第一嫌疑":编码器用 1/25 刻度、MP4 容器用 1/12800,不换算,播放器按旧刻度读数,音画直接错乱;③ pts2ms 进度——pts × av_q2d(time_base) × 1000 把时间戳换算成毫秒,音视频两条轨取最大值,保证进度条单调向前。素材代码里的 av_log("progress: %s")sendProgress() 就是在干这件事。

/* 素材真实代码(原文件 1514-1559 行),已去掉项目特有字段,逻辑未改 */
while (true) {
    // 每个输入 packet/frame 一般输出 1 个,但也可能 0 个或多于 1 个
    // 多于 1 个的情况,用 while 循环解决
    error = avcodec_receive_packet(encCtx, &outputPacket);
    if (error == AVERROR(EAGAIN)) {      // 需要新输入
        error = 0;
        goto cleanup;                  // 收尾返回,等下一个输入
    } else if (error == AVERROR_EOF) {  // 流结束(draining 后)
        error = 0;
        goto cleanup;
    } else if (error < 0) {         // 真错误
        av_log(NULL, AV_LOG_ERROR, "Could not encode frame\n");
        goto cleanup;
    } else {
        *dataPresent = 1;              // 有包写出(flush 循环靠它判断)
    }

    outputPacket.stream_index = streamIndex;   // 封装器按流索引归类

    // 时间基换算:编码器刻度 -> 容器刻度(第 4 章预告的钥匙)
    av_packet_rescale_ts(&outputPacket, encCtx->time_base,
                         ofmtCtx->streams[streamIndex]->time_base);

    // 进度:pts 转毫秒,音视频两条轨取最大值保证一直向前
    pts2ms = outputPacket.pts *
             av_q2d(ofmtCtx->streams[streamIndex]->time_base) * 1000;
    if (pts2ms > _pts2ms) _pts2ms = pts2ms;
    av_log(NULL, AV_LOG_INFO, "progress: %s\n", printProgress().c_str());

    if (*dataPresent &&
        (error = av_interleaved_write_frame(ofmtCtx, &outputPacket)) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Could not write frame, %s\n",
               av_err2str(error));
        goto cleanup;
    }
}
# 两个换算工具的真实签名(本机头文件)
# /opt/homebrew/include/libavcodec/packet.h 第 930 行
void av_packet_rescale_ts(AVPacket *pkt, AVRational tb_src,
                          AVRational tb_dst);   // 把 pkt 的 pts/dts 从 tb_src 换到 tb_dst

# /opt/homebrew/include/libavutil/rational.h 第 104 行
static inline double av_q2d(AVRational a) {
    return a.num / (double)a.den;   // 分数转小数:{1,25} -> 0.04
}

注意:别被 goto cleanup 吓到——在这里它几乎是"无害 goto"。EAGAIN 和 EOF 两条路径都把 error 置 0 再走,语义是"正常结束本轮";只有第三条(真错误)才带着错误码退出。它的反面教材是把 EAGAIN 当成错误处理(打印、中断),那样 receive 循环就被打断了,缓冲里的输出再也取不出来——又一个丢帧现场。

福尔摩斯提示

dataPresent 这个标记配合素材第 1653-1669 行的 flush 循环用:流结束时 do { dataWritten = 0; encodeMediaFrame(NULL, ...); } while (dataWritten)——反复以 NULL 调用编码函数,直到某轮一个包都没写出。encodeMediaFrame 内部收到 NULL 时调 avcodec_send_frame(NULL) 进入 draining mode,receive 循环吐完剩余缓冲、返回 EOF,循环自然结束。这就是生产环境里 flush 的完整形态,第 12 章我们会系统拆它。

11.6 完整转码器:MP4 封装实测

最后把模板接到真实输出上:写一个完整转码器 enc_mp4.c,把渐变帧编码成 H.264 并封装成 MP4 文件。与 enc_sr 相比多了三样东西:AVFormatContext(输出容器)、avformat_new_stream() + avcodec_parameters_from_context()(把编码参数登记成流)、avformat_write_header()/av_write_trailer()(容器开合)。编码循环则原封不动套用 11.3 模板,再加上素材生产代码里的 stream_index、rescale_ts、pts 毫秒进度——三条线在这里汇合。

核心函数 encode_one() 接受"编码一帧或 flush(frame 为 NULL)",内部就是 send + while receive 的标准循环,产出每个包都设置 stream_index、换算时间基、更新进度、写入容器(av_interleaved_write_frame 会按 dts 排序后写盘):

// enc_mp4.c 核心函数(真实代码,实测编译运行)
// 编码一帧(或 flush,frame == NULL):send 后反复 receive 直到 EAGAIN/EOF
static int encode_one(AVCodecContext *enc, AVFormatContext *ofmt,
                      int stream_index, AVFrame *frame,
                      long *got, long *pts_ms) {
    int ret = avcodec_send_frame(enc, frame);
    if (ret < 0) return ret;

    AVPacket *pkt = av_packet_alloc();
    for (;;) {
        ret = avcodec_receive_packet(enc, pkt);
        if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { ret = 0; break; }
        if (ret < 0) break;

        (*got)++;
        pkt->stream_index = stream_index;   // 封装器按流索引归类

        // 时间基换算:编码器刻度 -> 容器刻度
        av_packet_rescale_ts(pkt, enc->time_base,
                             ofmt->streams[stream_index]->time_base);

        // 进度:pts 换算成毫秒,取最大
        long ms = (long)(pkt->pts *
                    av_q2d(ofmt->streams[stream_index]->time_base) * 1000);
        if (ms > *pts_ms) *pts_ms = ms;

        ret = av_interleaved_write_frame(ofmt, pkt);   // 写容器(内部排序)
        if (ret < 0) break;
    }
    av_packet_free(&pkt);
    return ret;
}
// main 中的关键片段(完整源码见 enc_mp4.c,fill_frame 等与 11.4 相同)
avformat_alloc_output_context2(&ofmt, NULL, NULL, "out.mp4");
AVStream *st = avformat_new_stream(ofmt, NULL);
avcodec_parameters_from_context(st->codecpar, enc);
st->time_base = enc->time_base;
avio_open(&ofmt->pb, "out.mp4", AVIO_FLAG_WRITE);
avformat_write_header(ofmt, NULL);     // 返回值要检查!见下方警告

for (int n = 0; n < nframes; n++) {
    fill_frame(frame, n);
    frame->pts = n;
    if (encode_one(enc, ofmt, st->index, frame, &got, &pts_ms) < 0) break;
}
encode_one(enc, ofmt, st->index, NULL, &got, &pts_ms);  // flush 收尾
av_write_trailer(ofmt);

编译运行,再用 ffprobe 验尸(本机真实输出):150 帧进、150 个包出,进度停在 5960 毫秒(最后一帧 pts = 149/25 秒 = 5.96 秒)——一帧不丢,且时间戳全部正确换算进了 MP4 的 1/12800 刻度。容器元数据 nb_frames=150 确认所有包都在文件里。解码冒烟测试用 ffmpeg -f null(解码全部帧但丢弃输出)比 ffplay 可靠:本机无图形会话时 ffplay 会报 Failed to open file or configure filtergraph——连 ffmpeg 命令行生成的文件它也打不开,是环境问题,不是文件问题。

# 编译 + 运行 + 验尸(本机真实输出)
$ gcc -O2 -o enc_mp4 enc_mp4.c $(pkg-config --cflags --libs libavcodec libavformat libavutil)
enc_mp4.c:78:5: warning: ignoring return value of function declared with
  'warn_unused_result' attribute [-Wunused-result]
$ ./enc_mp4 150
frames_in=150 packets_out=150 duration=6.00s progress=5960ms
$ ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames,duration \
    -of default=noprint_wrappers=1 out.mp4
duration=5.960000
nb_frames=150
$ ffmpeg -v error -i out.mp4 -f null -        # 解码冒烟:无任何报错,退出码 0

注意:上面的编译警告不是噪音——avformat_write_header() 的返回值没检查。磁盘满、目录不可写时它返回负数,不检查就继续写,产出一个残缺文件,ffprobe 都救不回来。生产代码里每个 API 的返回值都要检查,这是素材代码 if ((error = ...) < 0) goto cleanup 写法的意义。本章练习 2 会考这一点。

福尔摩斯提示

注意两个数字的微妙差异:进度 5960ms vs 期望 6000ms。150 帧的最后一帧 pts 是 149,149/25 = 5.96 秒——MP4 封装器记录的 duration 取的是最后一个包的起始时间,所以 duration=5.960000nb_frames=150 帧数分毫不差。这个"最后一帧的收尾"细节,连同 draining mode 的完整机制,就是第 12 章《flush 与时间基:收尾的艺术》的主角——丢帧的另一个案发现场:不 flush,缓冲里的 B 帧就随进程一起消失

章末练习

练习 1:破案基本功 入门

调用 avcodec_receive_packet() 返回 AVERROR(EAGAIN),正确的理解是:(A) 编码出错,立即终止程序;(B) codec 需要新的输入才能产出新输出,继续送下一帧;(C) 流结束;(D) 缓冲区溢出。另外回答:官方契约里,一个输入帧对应几个输出包?

提示

回看 11.2 节的 tip 和官方文档原话:"Repeat this call until it returns AVERROR(EAGAIN) or an error";输出数量是"typically 1, but 0 or more than 1"。

参考答案

选 B。EAGAIN = "缓冲空了,需要新输入",是正常状态不是错误;EOF 才是流结束(选 C 的陷阱:EOF 只在 draining 模式后出现)。输出契约:通常 1 个,但可能是 0 个(帧进缓冲)或多于 1 个(缓冲满一次吐多个、flush 时吐完剩余)——所以必须用 while 循环 receive 到 EAGAIN,再处理下一帧。

练习 2:现场找错 进阶

下面这段"转码"代码上线后,用户反馈输出视频时长忽长忽短。指出至少两处会导致丢帧或损坏文件的错误,并给出修复后的循环:

for (int n = 0; n < nframes; n++) {
    avcodec_send_frame(ctx, frame);
    avcodec_receive_packet(ctx, pkt);      // 只调用,不检查返回值
    fwrite(pkt->data, 1, pkt->size, out);  // 直接写
}
提示

对照 11.3 节模板:① receive 的返回值有四种情况,只调用不检查,EAGAIN 时 pkt 里是垃圾数据却照样 fwrite;② 一次 receive 只能拿一个输出,多输出会丢;③ 结尾没有 flush。三处全中才是完整答案。

参考答案

三处错误:① 不检查 receive 返回值——EAGAIN 时 pkt 没有有效数据,fwrite 写出垃圾字节;② 每帧只 receive 一次——一次 send 可能触发多个输出(尤其 flush 时),多余的包全丢;③ 没有 flush——缓冲里的 B 帧和线程缓冲帧永远出不来(11.4 实测:150 帧丢 36 帧)。修复 = 11.3 节模板:send 后 while (receive(>= 0)) 处理 直到 EAGAIN,全部输入送完后 send_frame(NULL) 进入 draining 并 receive 到 EOF,每个返回值都判断。

练习 3:补全 flush 进阶

下面这段 flush 代码少了关键的一步,导致它永远不会结束。补全它,并说明 AVERROR_EOF 在这里起什么作用:

// 流结束时清空编码器缓冲(不完整版)
avcodec_send_frame(ctx, NULL);        // 进入 draining mode
for (;;) {
    int ret = avcodec_receive_packet(ctx, pkt);
    if (ret < 0) break;              // 这里有问题
    fwrite(pkt->data, 1, pkt->size, out);
}
提示

draining 模式下 receive 返回 EOF 之前,还会先返回 EAGAIN 吗?官方文档说不会——"unless you forgot to enter draining mode"。那么 ret < 0 这一路可能包含两种不同的负值,它们的含义分别是什么?

参考答案

问题在于 ret < 0 把 EAGAIN 和 EOF 混为一谈。draining 模式下不会返回 EAGAIN,但严谨写法要区分:if (ret == AVERROR_EOF) break;(正常收尾);if (ret < 0) { 报错; break; }(真错误)。EOF 在此处的语义 = "缓冲已清空,编码器确认没有更多输出了",它是 flush 完成的信号。官方文档原话:draining 模式下 receive 不会返回 EAGAIN,除非你忘了进入 draining 模式——所以如果这里收到 EAGAIN,先回去检查 send(NULL) 有没有执行。

练习 4:复现与推演 挑战

把 enc_sr.c 的 max_b_frames 改成 0 和 5(其余不变,用默认多线程),分别重跑 once 模式,记录丢帧数。再回答:为什么 bframes=0 时仍然会丢帧?缓冲的构成是什么?(提示:参考 11.4 节的表格,跑完把三个数字对照 33 / 36 / 38。)

提示

改一处常量重编译即可:ctx->max_b_frames = 0= 5。观察 once 模式的 got 值。bframes=0 意味着编码器完全不编 B 帧,但 x264 多线程模式下还有 lookahead 缓冲(提前分析帧),它同样会延迟输出。

参考答案

本机实测:bframes=0 → 丢 33 帧;bframes=3 → 丢 36 帧;bframes=5 → 丢 38 帧(均为默认多线程、150 帧输入)。结论:缓冲 = B 帧延迟 + 线程 lookahead 两部分。B 帧上限每加 1,缓冲大约深 1 帧(3→5 多丢 2 帧);但 bframes=0 时仍有 33 帧缓冲——那是 x264 多线程 lookahead 的贡献(单线程对照只有 26)。无论哪部分,once 写法丢的都是"flush 时才能取出的缓冲帧",而正确循环 + flush 在三种设置下都输出完整 150 帧。