一段 10 秒的视频转码后只剩 7 秒——丢掉的帧去了哪里?这一章我们当一回侦探:先看案发现场,再审 API 的设计,再用最小程序亲手复现丢帧,最后解剖生产代码。把"不可能"一项项排除,剩下的真相只有一个:receive 循环与 flush
核心方法论:排除不可能
「排除一切不可能,剩下的就是真相。一个 10 秒的视频转码后只剩 7 秒——这不是玄学,是有一处确定的原因。帧不会自己消失:要么编码器根本没把它产出来,要么产出来了却没被取走、没被写进文件。这一章我们用最小程序把两种写法摆在桌上对比,让丢帧在你自己机器上现形。当你把'不可能'一项项排除干净,剩下的那个真相,就是 send/receive 的正确循环与结尾的 flush。」
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 个"。素材是官方文档的中文实践版——这条铁律十年没变,因为它描述的是编码器物理上怎么工作。
现行 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() | 压缩包 AVPacket | 0 | 喂一个压缩包进去 |
| 解码 | avcodec_receive_frame() | 未压缩帧 AVFrame | 0 | 取一帧原始画面出来 |
| 编码 | avcodec_send_frame() | 未压缩帧 AVFrame | 0 | 喂一帧原始画面进去 |
| 编码 | avcodec_receive_packet() | 压缩包 AVPacket | 0 | 取一个压缩包出来 |
记住两个返回值的语义,这是本章的破案钥匙:AVERROR(EAGAIN) = "缓冲空了,我需要新的输入才能产出新输出"(说人话:你喂下一帧吧,这不是错误);AVERROR_EOF = "流结束了,我再也没有输出了"(只有进入 draining 模式后才会出现)。官方文档原话:Repeat this call until it returns AVERROR(EAGAIN) or an error(反复调用直到 EAGAIN 或错误)。
把 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 个"的实锤。
推理到这一步,需要物证。我们写一个最小 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_frames | once 输出 | loop 输出 | flush 补出 | 丢帧数 |
|---|---|---|---|---|---|
| 自动(多线程) | 3 | 114 | 150 | 36 | 36 |
| 1(单线程) | 3 | 124 | 150 | 26 | 26 |
| 2 | 3 | 119 | 150 | 31 | 31 |
| 4 | 3 | 117 | 150 | 33 | 33 |
| 自动 | 0 | 117 | 150 | 33 | 33 |
| 自动 | 5 | 112 | 150 | 38 | 38 |
这张表读三遍,破案就完成了:① 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,是唯一不需要赌运气的写法。
回到素材笔记。当年那个生产转码器其实已经用上了正确模式——它的 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 章我们会系统拆它。
最后把模板接到真实输出上:写一个完整转码器 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.960000 而 nb_frames=150 帧数分毫不差。这个"最后一帧的收尾"细节,连同 draining mode 的完整机制,就是第 12 章《flush 与时间基:收尾的艺术》的主角——丢帧的另一个案发现场:不 flush,缓冲里的 B 帧就随进程一起消失。
调用 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,再处理下一帧。
下面这段"转码"代码上线后,用户反馈输出视频时长忽长忽短。指出至少两处会导致丢帧或损坏文件的错误,并给出修复后的循环:
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,每个返回值都判断。
下面这段 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) 有没有执行。
把 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 帧。