第16章:转码工程全景:把管线串起来

第 9-15 章我们拆零件,这一章把它们首尾相接:解封装 → 解码 → 滤镜 → 编码 → 封装,一条从文件进来到文件出去的完整转码管线,编译、运行、验证,一个环节都不落下

🔍

本章导师:柯南

核心方法论:追踪数据流

「16 章下来,你已经知道文件里每一帧从哪来、到哪去。这一章我们只做一件事:把第 9 到 15 章搭好的每一段管线首尾相接,通上电。真相只有一个——数据流从文件进来到文件出去,中间经过的每一站,我们亲眼验证一遍。」

16.1 全景图:转码管线五站

第 2 章你敲过 ffmpeg -i in.mp4 -c:v libx264 out.mp4,第 9-15 章我们把这条命令拆成了零件:第 9 章搭编码器骨架、第 10 章调参数、第 11 章学 send/receive、第 12 章学 flush、第 14-15 章学滤镜。现在把零件装回去——一条完整的转码管线,说人话就是五个站解封装(把文件拆成压缩包)→ 解码(把压缩包变成像素帧)→ 滤镜(加工像素帧,可选)→ 编码(把像素帧重新压成包)→ 封装(把包装回文件)。命令行只给了你一句话,API 给了你五个站,本章的转码器 transcode16.cpp 就是这五个站的完整实现。

柯南办案从不看表面,只看数据流。沿着这条管线追一遍,你看到的数据只有两种乘客:AVPacket(压缩的数据包,装着 h264/aac 编码后的字节,站点之间传送)和 AVFrame(解压的帧,装着 YUV 像素或 PCM 采样,站点内部加工)。五站的进出关系是这样一张图:

# 转码管线五站(柯南视角:每一站进什么、出什么)
[in.mp4] -> ①解封装 -> [AVPacket 压缩包] -> ②解码 -> [AVFrame 像素帧]
        -> ③滤镜 -> [AVFrame 加工后] -> ④编码 -> [AVPacket 压缩包]
        -> ⑤封装 -> [out.mp4]

# 数据流形态变化:文件字节 -> 包 -> 帧 -> 帧 -> 包 -> 文件字节
AVPacket -> AVFrame -> AVFrame -> AVPacket   <- 四种形态,两两一对

每一站对应哪一章的零件,也一并交代清楚:① 解封装用 avformat_open_input / av_read_frame(第 4 章容器知识 + 第 13 章自定义 IO 的入口);② 解码是 avcodec_send_packet / avcodec_receive_frame(第 11 章双循环);③ 滤镜是 buffer → scale → buffersink(第 14 章五步、第 15 章实战);④ 编码是 avcodec_send_frame / avcodec_receive_packet(第 9-11 章);⑤ 封装是 av_interleaved_write_frame + 头尾(第 9 章骨架 + 第 12 章收尾)。把命令行那句咒语翻译成 API,就是下面这张对照表:

# 命令行(第 2 章)                        # C API(第 9-16 章)
ffmpeg -i in.mp4 -vf "scale=320:180" -c:v libx264 -an out.mp4
    -i in.mp4        = avformat_open_input + avformat_find_stream_info
    -vf "scale=320:180" = 滤镜图五步(avfilter_graph_alloc -> ... -> avfilter_graph_config-c:v libx264     = avcodec_find_encoder_by_name("libx264") + avcodec_open2
    -an              = 主循环里跳过非视频包(if (pkt->stream_index != vindex) continue;)
    out.mp4          = avformat_alloc_output_context2 + avformat_write_header

数据流在每一站谁分配、谁释放,是追数据流时最容易栽跟头的地方。表里每一行的规则都来自前面章节的真实教训(第 11 章"送出去就不再是你的了"、第 14 章 KEEP_REF 陷阱):

输入输出谁分配谁释放
① 解封装 av_read_frame文件字节AVPacket(压缩)封装器内部你:av_packet_unref
② 解码 send/receiveAVPacketAVFrame(像素)你:av_frame_alloc你:av_frame_unref
③ 滤镜 add/getAVFrameAVFrame(加工后)你:av_frame_alloc你:av_frame_unref
④ 编码 send/receiveAVFrameAVPacket(压缩)你:av_packet_alloc你:av_packet_free
⑤ 封装 interleaved_writeAVPacket文件字节封装器内部你:avio_closep
柯南提示

记忆口诀:压缩态是包(AVPacket),解压态是帧(AVFrame)。解封装出包、解码出帧、滤镜动帧、编码出包、封装收包——五站的数据流形态恰好是"包 → 帧 → 帧 → 包"的对称结构。你只要记住每个 API 收的是包还是帧,就永远不会接错线。

16.2 初始化:五站的开张手续

完整的转码器程序长什么样?main 函数是经典的三段式:初始化(五站开张)→ 主循环(数据接力)→ 收尾(flush + 清理)。这和命令行 ffmpeg 干的事一模一样——ffmpeg 那个巨大的 C 程序,本质就是这个三段式。先看结构图,再看每段代码:

// main 三段式(本章 transcode16.cpp 的骨架)
int main(int argc, char **argv) {
    /* 阶段一:初始化 —— 五站开张手续(16.2 节) */
    ① 解封装:打开输入,找到视频流
    ② 解码器:按流的编码 ID 找到并打开
    ③ 滤镜图:buffer -> scale=320:180 -> buffersink(可选)
    ④ 编码器:libx264,输出尺寸 320x180
    ⑤ 封装器:建 out.mp4,写容器头

    /* 阶段二:主循环 —— 包 -> 帧 -> 帧 -> 包(16.3 节) */
    while (av_read_frame 还有包) {
        丢弃音频包 -> 解码 -> 滤镜 -> 编码 -> 写容器
    }

    /* 阶段三:收尾 —— 三级 flush + trailer + 清理(16.4 节) */
    解码器 flush -> 滤镜 flush -> 编码器 flush -> av_write_trailer -> 逆序释放
}

阶段一的前两站,解封装和解码。第 9 章教编码器时是"先建档案袋再找师傅",这里正好反过来——输入侧是"先开文件再按流找解码器"。第 9 章提过的 avcodec_parameters_to_context 在解码侧是必需品:流的 codecpar 是容器里读出来的参数卡,必须拷贝给解码器工作台,它才知道画面多大、什么格式:

/* ① 解封装:打开输入,找到视频流 */
AVFormatContext *ifmt = NULL;
ret = avformat_open_input(&ifmt, infile, NULL, NULL);
if (ret < 0) return die("avformat_open_input", ret);
ret = avformat_find_stream_info(ifmt, NULL);
if (ret < 0) return die("avformat_find_stream_info", ret);

int vindex = av_find_best_stream(ifmt, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
if (vindex < 0) return die("av_find_best_stream", vindex);
AVStream *in_st = ifmt->streams[vindex];

/* ② 解码:按输入流的编码 ID 打开解码器 */
const AVCodec *dec = avcodec_find_decoder(in_st->codecpar->codec_id);
AVCodecContext *dec_ctx = avcodec_alloc_context3(dec);
ret = avcodec_parameters_to_context(dec_ctx, in_st->codecpar);
if (ret < 0) return die("avcodec_parameters_to_context(dec)", ret);
ret = avcodec_open2(dec_ctx, dec, NULL);
if (ret < 0) return die("avcodec_open2(dec)", ret);
printf("[ok] 解码器: %s %dx%d %s\n", dec->name, dec_ctx->width,
       dec_ctx->height, av_get_pix_fmt_name(dec_ctx->pix_fmt));

后三站:滤镜、编码、封装。滤镜就是第 14 章的五步原封不动(buffer 的参数串里,video_size / pix_fmt 必须用解码器实际输出的值拼出来);编码器参数是第 9-10 章的老朋友(libx264 + 320x180 + yuv420p + 1/25 时间基);封装器是第 9 章的骨架(建上下文 → 建流 → 拷参数 → 打开 → 写头):

/* ③ 滤镜:buffer -> [scale=320:180] -> buffersink(第 14 章五步) */
AVFilterGraph *graph = avfilter_graph_alloc();
const AVFilter *bufsrc  = avfilter_get_by_name("buffer");
const AVFilter *bufsink = avfilter_get_by_name("buffersink");
AVFilterContext *src_ctx = NULL, *sink_ctx = NULL;
char args[256];
snprintf(args, sizeof args,
         "video_size=%dx%d:pix_fmt=%s:time_base=1/%d:frame_rate=%d/1",
         dec_ctx->width, dec_ctx->height,
         av_get_pix_fmt_name(dec_ctx->pix_fmt), FPS, FPS);
avfilter_graph_create_filter(&src_ctx,  bufsrc,  "in",  args, NULL, graph);
avfilter_graph_create_filter(&sink_ctx, bufsink, "out", NULL, NULL, graph);
AVFilterInOut *inputs  = avfilter_inout_alloc();
AVFilterInOut *outputs = avfilter_inout_alloc();
outputs->name = av_strdup("in");   outputs->filter_ctx = src_ctx;
inputs->name  = av_strdup("out");  inputs->filter_ctx  = sink_ctx;
ret = avfilter_graph_parse_ptr(graph, filter_desc, &inputs, &outputs, NULL);
if (ret < 0) return die("avfilter_graph_parse_ptr", ret);
avfilter_inout_free(&inputs);
avfilter_inout_free(&outputs);
ret = avfilter_graph_config(graph, NULL);
if (ret < 0) return die("avfilter_graph_config", ret);

/* ④ 编码:libx264,输出尺寸 320x180 */
const AVCodec *enc_codec = avcodec_find_encoder_by_name("libx264");
AVCodecContext *enc = avcodec_alloc_context3(enc_codec);
enc->width = 320;  enc->height = 180;
enc->pix_fmt  = AV_PIX_FMT_YUV420P;
enc->time_base = (AVRational){1, FPS};
enc->framerate = (AVRational){FPS, 1};
enc->gop_size  = 25;
enc->max_b_frames = 3;
ret = avcodec_open2(enc, enc_codec, NULL);
if (ret < 0) return die("avcodec_open2(enc)", ret);

/* ⑤ 封装:建输出容器 out.mp4 */
AVFormatContext *ofmt = NULL;
ret = avformat_alloc_output_context2(&ofmt, NULL, NULL, outfile);
if (ret < 0) return die("avformat_alloc_output_context2", ret);
AVStream *out_st = avformat_new_stream(ofmt, NULL);
ret = avcodec_parameters_from_context(out_st->codecpar, enc);
if (ret < 0) return die("avcodec_parameters_from_context(enc)", ret);
out_st->time_base = enc->time_base;
ret = avio_open(&ofmt->pb, outfile, AVIO_FLAG_WRITE);
if (ret < 0) return die("avio_open", ret);
ret = avformat_write_header(ofmt, NULL);
if (ret < 0) return die("avformat_write_header", ret);

注意一个细节:滤镜参数串里的 FPS 是本章程序写死的 25。这不是偷懒,是教学简化——滤镜和编码器的时间基必须一致(都是 1/25),输入是什么帧率,就在主循环里把帧的时间戳换算到 1/25(下一节就是干这个的)。通用的转码器应该用 av_guess_frame_rate 从输入读帧率,这留给章末练习。

柯南提示

五个站的初始化是"先开张、后营业":所有 avcodec_open2 / avformat_write_header 都必须在主循环之前完成。开张手续有一个统一模式——每个 API 的返回值都用同一个 die() 出口检查(第 9 章包青天的铁律),16.5 节我们把这些检查点汇总成一张全景图。

16.3 主循环:包 → 帧 → 帧 → 包的接力

初始化完毕,数据开始流动。主循环是三层嵌套:外层读包av_read_frame),中层解码出帧(send/receive),内层滤镜 + 编码 + 写容器。第 11 章学过:send 一个包,可能要 receive 很多次才能把缓冲里的帧全拿出来;编码器同理。所以每一层都是"送一次、取到 EAGAIN/EOF 为止"的双循环结构。先看外层和中层:

/* 主循环外层:读包 -> 丢音频 -> 送解码器 -> 取帧(第 11 章双循环) */
while (1) {
    ret = av_read_frame(ifmt, pkt);
    if (ret == AVERROR_EOF) break;            // 输入读完了,进收尾阶段
    if (ret < 0) break;
    if (pkt->stream_index != vindex) {          // 非视频包:丢弃(等价命令行 -an)
        av_packet_unref(pkt);
        continue;
    }
    packets_in++;

    ret = avcodec_send_packet(dec_ctx, pkt);      // ① 压缩包进解码器
    av_packet_unref(pkt);
    if (ret < 0) break;

    while (ret >= 0) {                            // ② 像素帧出解码器(一次出多帧)
        ret = avcodec_receive_frame(dec_ctx, frame);
        if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
        if (ret < 0) break;
        frames_decoded++;

        /* 时间基换算:容器时间基(1/12800) -> 滤镜时间基(1/25)(第 12 章的钥匙) */
        frame->pts = av_rescale_q(frame->pts, in_st->time_base,
                                  (AVRational){1, FPS});

        ret = av_buffersrc_add_frame_flags(src_ctx, frame,
                                           AV_BUFFERSRC_FLAG_KEEP_REF);
        av_frame_unref(frame);                   // KEEP_REF:释放权留在自己手里
        if (ret < 0) break;

接着是内层:从滤镜取加工后的帧,送进编码器,再把编码器吐出的压缩包写进容器。这里有三件事要盯紧:① 滤镜出帧要 av_buffersink_get_frame 取到 EAGAIN(第 14 章);② 编码器送帧后必须立刻排空 receive(第 11 章不丢帧的保证);③ 写容器前把时间基从编码器刻度换算成容器刻度av_packet_rescale_ts,第 4 章 + 第 12 章的主角):

        while (1) {                            // ③ 加工后的帧出滤镜
            ret = av_buffersink_get_frame(sink_ctx, filt);
            if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
            if (ret < 0) break;
            frames_filtered++;

            ret = avcodec_send_frame(enc, filt);  // ④ 像素帧进编码器
            av_frame_unref(filt);
            if (ret < 0) break;

            while (1) {                        // ⑤ 压缩包出编码器 -> 写容器
                AVPacket *opkt = av_packet_alloc();
                ret = avcodec_receive_packet(enc, opkt);
                if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
                    av_packet_free(&opkt);
                    break;
                }
                if (ret < 0) { av_packet_free(&opkt); break; }
                packets_out++;
                opkt->stream_index = out_st->index;
                av_packet_rescale_ts(opkt, enc->time_base, out_st->time_base);
                ret = av_interleaved_write_frame(ofmt, opkt);
                av_packet_free(&opkt);
                if (ret < 0) break;
            }
        }
    }
}

这段代码里藏着两个"时间刻度":帧的 pts 在滤镜里是 1/25 刻度(buffer 参数串里指定的),编码器吐出的包的 pts 是编码器时间基刻度(也是 1/25),而 mp4 容器内部是 1/12800 刻度(第 4 章实测)。本程序里编码器和滤镜刻度相同,所以帧直接递送;但容器刻度不同,所以每个包在写容器前都要 av_packet_rescale_ts 换算一次——这正是第 12 章"时间基是每站的度量衡"的实战应用。

注意:三个"乘客计数器"——packets_in(读了多少包)、frames_decoded(解出多少帧)、packets_out(写出多少包)——是本章验证管线完整性的三把尺子。正常转码时它们近似相等(150 包进 → 150 帧 → 150 包出);如果 frames_decoded 明显少于 packets_in,说明有帧在解码环节丢了;如果 packets_out 少于 frames_filtered,说明编码器还在缓冲——收尾阶段就是为它准备的。

16.4 收尾:三级 flush 与 trailer

输入读完了(av_read_frame 返回 EOF),但数据流还没走完——解码器、滤镜、编码器里都可能还有半成品:解码器缓冲着 B 帧重排的帧,滤镜链上还挂着最后一帧,编码器里压着还没吐出的 B 帧。第 12 章教过:每个"送"都有一个"喂 NULL 表示 flush"的暗号。转码器收尾要按数据流方向做三级 flush:先刷解码器(把剩下的帧全解出来送进滤镜),再刷滤镜(把链上最后一帧推出来送进编码器),最后刷编码器(把缓冲的 B 帧全吐出来写进容器):

/* ① 解码器 flush:喂 NULL,把缓冲的解码帧全部吐出来 */
avcodec_send_packet(dec_ctx, NULL);
while (avcodec_receive_frame(dec_ctx, frame) >= 0) {
    frames_decoded++;
    frame->pts = av_rescale_q(frame->pts, in_st->time_base, (AVRational){1, FPS});
    ret = av_buffersrc_add_frame_flags(src_ctx, frame, AV_BUFFERSRC_FLAG_KEEP_REF);
    av_frame_unref(frame);
    if (ret < 0) break;
}

/* ② 滤镜 flush:同样喂 NULL,让流水线上的最后一帧走完 */
ret = av_buffersrc_add_frame_flags(src_ctx, NULL, 0);
if (ret < 0) return die("filter flush", ret);
while (av_buffersink_get_frame(sink_ctx, filt) >= 0) {
    frames_filtered++;
    ret = avcodec_send_frame(enc, filt);
    av_frame_unref(filt);
    if (ret < 0) break;
    /* 边送边取:编码器缓冲满了会拒收(EAGAIN),必须立刻排空 */
    while (avcodec_receive_packet(enc, pkt) >= 0) {
        packets_out++;
        pkt->stream_index = out_st->index;
        av_packet_rescale_ts(pkt, enc->time_base, out_st->time_base);
        ret = av_interleaved_write_frame(ofmt, pkt);
        av_packet_unref(pkt);
        if (ret < 0) break;
    }
}

/* ③ 编码器 flush:缓冲里的 B 帧全靠这一步出来 */
ret = avcodec_send_frame(enc, NULL);
if (ret < 0) return die("encoder flush", ret);
while (avcodec_receive_packet(enc, pkt) >= 0) {
    packets_out++;
    pkt->stream_index = out_st->index;
    av_packet_rescale_ts(pkt, enc->time_base, out_st->time_base);
    ret = av_interleaved_write_frame(ofmt, pkt);
    av_packet_unref(pkt);
    if (ret < 0) break;
}
av_write_trailer(ofmt);   // 写容器尾(mp4 的 moov 索引在这里落盘)

flush 完了写 trailer,最后按"创建顺序相反"清理所有对象:先释放帧和包(av_frame_free / av_packet_free),再释放滤镜图、编码器、解码器(avfilter_graph_free / avcodec_free_context),最后关 IO 和输入(avio_closep / avformat_free_context / avformat_close_input)。顺序记不住也没关系,记住一条铁律:谁分配谁释放,后分配的先释放

柯南提示

三级 flush 的顺序不能乱:必须沿数据流方向(解码 → 滤镜 → 编码)。反着刷(先刷编码器)会让滤镜还挂着的帧无处可去。本程序运行时会看到 x264 打印 forced frame type (5) at 24 was changed to frame type (3)——这是流的末尾 B 帧排不下(B 帧需要看未来的帧),x264 把末段帧型降级成 P/I 帧,是编码器缓冲在收尾时的正常调整,不是错误。

注意:② 滤镜 flush 里那个"边送边取"是本章实测踩过的坑——第一版只在主循环里排空编码器,flush 阶段只送不取,结果编码器缓冲塞满,avcodec_send_frame 直接返回 EAGAIN(Resource temporarily unavailable),程序带着半截文件退出,ffprobemoov atom not found编码器的"送"和"取"是跷跷板,任何阶段送完都要立刻排空,否则它就用 EAGAIN 拒收。

16.5 错误处理全景:ret 检查点与常见运行时错误

第 9 章包青天立下铁律:每一个 ret 都不能放过。五站串起来之后,检查点的数量也翻了五倍——本程序一共 16 个 ret 检查点,全部走同一个 die() 出口:打印出错的站点名 + 错误码的人类可读文本(av_err2str),返回 1。出错时你一眼就能看到"哪一站、为什么":

// 统一错误出口:站点名 + av_err2str 把负数错误码翻译成人话
static int die(const char *where, int ret) {
    fprintf(stderr, "[FAIL] %s: %s\n", where, av_err2str(ret));
    return 1;
}

// 用法:每个检查点都是同一句话,只有站点名不同
ret = avformat_open_input(&ifmt, infile, NULL, NULL);
if (ret < 0) return die("avformat_open_input", ret);

16 个检查点按五站分布:解封装 3 个(open_input / find_stream_info / find_best_stream)、解码 2 个(parameters_to_context / open2)、滤镜 4 个(create_filter ×2 / parse_ptr / graph_config)、编码 3 个(open2 / parameters_from_context / 以及 flush 阶段 2 个)、封装 3 个(alloc_output_context2 / avio_open / write_header),外加主循环里每个 send/receive 的返回值判断。整理成一张"站点 × 检查点"全景图:

# 五站 × ret 检查点全景(transcode16.cpp 实测,16 处)
① 解封装  avformat_open_input / avformat_find_stream_info / av_find_best_stream
② 解码    avcodec_parameters_to_context / avcodec_open2(dec)
③ 滤镜    avfilter_graph_create_filter x2 / avfilter_graph_parse_ptr / avfilter_graph_config
④ 编码    avcodec_open2(enc) / avcodec_parameters_from_context / flush 时 avcodec_send_frame(NULL)
⑤ 封装    avformat_alloc_output_context2 / avio_open / avformat_write_header
循环内    send/receive 的返回值每个都判断(EAGAIN 是正常节奏,其余 < 0 都是事故)

把常见的运行时错误都亲手触发一遍,你才能像柯南一样"看见报错就知道凶手是谁"。下面四条是本机实测的真实报错(./t16 跑出来的,一字未改):

# 本机真实报错(ffmpeg 8.1.1, macOS arm64)
$ ./t16 nofile.mp4 out.mp4
[FAIL] avformat_open_input: No such file or directory

$ ./t16 sample4.mp4 out.mp4 "scale=abc"
[FAIL] avfilter_graph_parse_ptr: Invalid argument

$ ./t16 sample4.mp4 /nonexistent/out.mp4
[FAIL] avio_open: No such file or directory

$ ./t16 "scale=320:180,no_such_filter"   # 滤镜名写错(第 14 章报错)
[FAIL] avfilter_graph_parse_ptr: No such filter or filtergraph

四条报错对应四类病因:文件不存在(解封装)、滤镜参数非法(滤镜)、输出路径不可写(封装)、滤镜名拼错(滤镜)。共同点:报错文本足够精确,但前提是你把站点名写进了 die()——如果只打印错误码不打印站点,Invalid argument 这种报错会让人在五站里瞎找。最后还有一类错误发生在编译期,专属于 C++ 用户,值得单独说:

注意:FFmpeg 8 的公共头文件不再自带 extern "C" 守卫(旧版本有,8.0 移除了)。用 g++ 编译时,头文件里的函数声明会被当作 C++ 符号修饰,链接时报出一长串 Undefined symbols ... declaration possibly missing 'extern "C"'(真实报错)。对策很简单:把全部 FFmpeg 头文件的 include 包进 extern "C" { ... },本章 transcode16.cpp 开头就是这么写的。

报错现象(本机实测)出现在哪一站原因对策
[FAIL] avformat_open_input: No such file or directory① 解封装输入路径不对ls 确认文件存在
[FAIL] avfilter_graph_parse_ptr: Invalid argument③ 滤镜滤镜串写错(scale=abc先在命令行 -vf 调通,再原封不动搬进 API
[FAIL] avio_open: No such file or directory⑤ 封装输出目录不存在 / 不可写检查输出路径的父目录
Undefined symbols ... missing 'extern "C"'编译期FFmpeg 8 头文件无 C++ 守卫include 包进 extern "C" { }
[FAIL] encoder flush: Resource temporarily unavailable④ 编码缓冲塞满还硬送(flush 只送不取)边送边取,EAGAIN 就排空再继续
Failed to open file ... or configure filtergraph播放验证ffplay 对无音频文件的已知怪癖去掉 -loglevel error,或用 ffprobe 验证
柯南提示

排错心法:报错永远先看"哪一站",再看"什么码"。die() 的站点名是定位第一刀——知道是滤镜站出的错,你就不用去检查封装代码。这一招把 16 个检查点从"16 个 if"变成了"一张五站地图上的 16 个路标"。

16.6 本机实测与章节总复习

全管线写完了,用第 4 章的 sample4.mp4(h264 + aac,320x240,25fps,6 秒)做输入,跑 320x240 → 320x180 的缩放转码。编译和运行的命令如下——pkg-config 一次性给出五个库的头文件路径和链接参数,这也是本线所有 C 程序的标准编译姿势(第 9 章起沿用):

# 编译 + 运行(本机真实执行,macOS arm64 / g++ / ffmpeg 8.1.1)
$ g++ -O2 -D__STDC_CONSTANT_MACROS transcode16.cpp $(pkg-config --cflags --libs libavformat libavcodec libavutil libavfilter libswscale) -o t16
$ ./t16 sample4.mp4 out16.mp4
[ok] 解码器: h264 320x240 yuv420p
[ok] 滤镜图: scale=320:180
[libx264 @ 0x...] forced frame type (5) at 24 was changed to frame type (3)
[libx264 @ 0x...] forced frame type (5) at 25 was changed to frame type (1)
[libx264 @ 0x...] forced frame type (5) at 50 was changed to frame type (1)
[libx264 @ 0x...] forced frame type (5) at 75 was changed to frame type (1)
[libx264 @ 0x...] forced frame type (5) at 100 was changed to frame type (1)
[libx264 @ 0x...] forced frame type (5) at 125 was changed to frame type (1)
packets_in=150 frames_decoded=150 frames_filtered=150 packets_out=150 duration=6.00s

三个计数器全部相等:150 个包进 → 150 帧解出 → 150 帧过滤镜 → 150 个包写出,时长 6.00 秒——一行不多,一行不少。x264 的 forced frame type 警告是末段 B 帧降级的正常调整(16.4 节讲过)。最后用 ffprobe 给输入输出各做一次体检,验证"320x240 → 320x180"真的落地了:

# 输入侧(节选):h264 320x240 25fps + aac 音频,6 秒
$ ffprobe -show_entries stream=codec_name,width,height,r_frame_rate \
          -show_entries format=duration,size -of default=noprint_wrappers=1 sample4.mp4
codec_name=h264
width=320
height=240
r_frame_rate=25/1
codec_name=aac
duration=6.000000
size=261398

# 输出侧(节选):h264 320x180 25fps,只有视频轨,5.96 秒,179634 字节
$ ffprobe -show_entries stream=codec_name,width,height,pix_fmt,nb_frames \
          -show_entries format=duration,size -of default=noprint_wrappers=1 out16.mp4
codec_name=h264
width=320
height=180
pix_fmt=yuv420p
nb_frames=150
duration=5.960000
size=179634

体检报告对得上账:输出 320x180、yuv420p、150 帧,时长 5.96 秒(mp4 时间基 1/12800 下 150 帧 = 5.96 秒,与第 4 章的知识一致),音频轨按 -an 语义被丢弃。用 ffplay -autoexit -nodisp out16.mp4 冒烟测试,正常播放、退出码 0——管线从头到尾是通的。唯一要注意的是 ffplay 的一个小怪癖:对没有音频轨的文件,加 -loglevel error 会误报 Failed to open file,去掉这个参数就正常(16.5 节错误表最后一行),验证产物优先用 ffprobe。

注意:本程序把滤镜和编码器的时间基写死为 25fps。拿 15fps 的素材(如第 1 章的 lavfi 测试视频)也能跑通(45 包 → 45 帧 → 45 包),但输出会被加速成 25fps——因为 pts 是按 1/25 刻度重算的。通用转码器必须用 av_guess_frame_rate 从输入读帧率,而不是写死。这个升级就作为章末练习 2。

最后一站:回头看这 16 章。你手里的地图已经完整了——从命令行到 C API,从容器到编码,从滤镜到整条管线。把 16 章的知识浓缩成一张表,就是你从"会用 ffmpeg"到"能写 ffmpeg"的全部路径:

核心概念学会的本事
1 FFmpeg 全家桶ffmpeg / ffprobe / ffplay、lavfi 测试素材造素材、冒烟测试任何产物
2 命令行转码-i / -c / -b / -vf、流复制 vs 重编码、GOP 裁剪一条命令完成转码与裁剪
3 ffprobe 探针流 / 帧 / 包、时间基、JSON 输出读懂任何媒体的体检报告
4 音视频基础容器、编码、时间基、PTS/DTS看懂 1/12800 这类数字
5 源码编译准备configure / make、依赖探测从源码定制自己的 FFmpeg
6 依赖库逐个击破x264 / opus / gnutls、缺库排错循环configure 报错不再害怕
7 GPU 硬编解码--enable-cuda、h264_cuvid / nvenc配出 GPU 版 FFmpeg
8 GPU 转码实战hwaccel 流程、容器部署上线 GPU 转码服务
9 编码器 API四对象模型、九步流程搭起编码器骨架
10 编码器配置bit_rate / sample_fmt / 时间基 / 全局头参数落地成文件特征
11 send/receive双循环、EAGAIN、stream_index不丢帧的编解码循环
12 flush 与时间基draining mode、av_packet_rescale_ts收尾不丢数据
13 自定义 IO内存 / 流式输入输出把数据源换成任意来源
14 滤镜图原理buffer → filters → buffersink搭画面加工流水线
15 视频滤镜实战scale / overlay / drawtext / 截图加水印、缩放、截图
16 转码工程全景五站管线、三段式 main、错误全景手写完整转码器
柯南结案

「16 章前,视频对你是一个黑盒子;现在它是一条五站的流水线,每一站进什么、出什么、谁分配、谁释放,你都说得清。数据流的真相只有一个:文件字节 → 包 → 帧 → 帧 → 包 → 文件字节。抓住这条主线,以后遇到任何 FFmpeg 问题——不管是命令行还是 API——你都能顺着数据流找到那一站,亲手验证,亲手修复。真相只有一个,而你已经是那个能找到它的人。」

章末练习

练习 1:五站数据流填空 入门

写出五站各自的输入输出形态(包还是帧),并说出每一站谁负责分配乘客、谁负责释放。

提示

压缩态是包(AVPacket),解压态是帧(AVFrame);16.1 节的表就是答案。

参考答案

① 解封装:文件字节 → AVPacket(你 unref);② 解码:AVPacket → AVFrame(你 alloc + unref);③ 滤镜:AVFrame → AVFrame(你 alloc + unref);④ 编码:AVFrame → AVPacket(你 alloc + free);⑤ 封装:AVPacket → 文件字节(你 avio_closep)。口诀:包 → 帧 → 帧 → 包。

练习 2:把写死的 25fps 改成从输入读取 进阶

16.6 节的警告说:写死 FPS 会让 15fps 素材被加速。请用 av_guess_frame_rate 改造程序:在初始化阶段读出输入帧率,用它替换滤镜参数串和编码器里的 FPS。提示:该函数返回 AVRational

提示

AVRational fr = av_guess_frame_rate(ifmt, in_st, NULL); 在打开解码器之后调用;把滤镜参数串里的 time_base 和编码器的 time_base / framerate 都用 fr.num / fr.den 拼出来。

参考答案

在 ② 之后加一行:AVRational fr = av_guess_frame_rate(ifmt, in_st, NULL);(sample4.mp4 实测返回 25/1,15fps 的 lavfi 视频返回 15/1)。然后把滤镜参数串改为 time_base=1/%d:frame_rate=%d/1 传入 fr.den / fr.num,编码器的 time_base = (AVRational){fr.den, fr.num}framerate = fr,主循环里换算 pts 的目标刻度也换成 {fr.den, fr.num}。改完用 15fps 素材重跑,duration 应保持 3.00s 而不是 1.80s。

练习 3:解释 flush 的"边送边取" 进阶

16.4 节的 warning 说:flush 阶段只送不取,avcodec_send_frame 会返回 EAGAIN。请解释:① EAGAIN 在 send 侧代表什么?② 为什么主循环里同样的问题不会出现?③ 去掉 flush ② 里的排空循环,程序会怎样收场?

提示

想想编码器的内部缓冲是"一进一出"的跷跷板;再想想主循环里 send 之后紧跟的那个 receive 循环在干什么。

参考答案

① send 返回 EAGAIN = 编码器内部缓冲已满,拒绝再接新帧——你得先 receive 排空再送。② 主循环里每次 avcodec_send_frame 之后都紧跟 receive 循环取到 EAGAIN 为止,缓冲始终有位置。③ 去掉后:滤镜 flush 阶段连续送帧,缓冲塞满,send 返回 EAGAIN,代码 break 退出——丢帧 + av_write_trailer 前提前返回,输出文件没有 moov 索引,ffprobe 报 moov atom not found(本机实测过的完整事故链)。

练习 4:给转码器加上音频轨 挑战

本章程序用 -an 语义丢了音频。请把它升级成音视频双轨转码:音频用 aac 编码(第 9-11 章的知识),输出 mp4 同时含视频和音频轨。要求:写出主循环按 stream_index 分流的骨架,并说明音频帧的 pts 换算该用什么时间基(提示:第 10 章学过,音频编码器 time_base 跟采样率走)。

提示

把视频的解码-编码循环抽成函数,音频流走同样的 send/receive 骨架;音频帧不需要滤镜,解码后直接送音频编码器。注意输出容器要建两条流(avformat_new_stream × 2),时间基一个是 1/fps,一个是 1/sample_rate。

参考答案

骨架:int aindex = av_find_best_stream(ifmt, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0); 找到音频流 → 按 codecpar->codec_id 打开解码器 → avcodec_find_encoder_by_name("aac") 建编码器(sample_fmtencoder->sample_fmts[0]time_base = {1, sample_rate},第 10 章全套)→ 输出端 avformat_new_stream 第二条流。主循环里 if (pkt->stream_index == aindex) 走音频分支:send/receive 出帧后直接 avcodec_send_frame 给音频编码器,包写容器前 av_packet_rescale_ts(pkt, audio_enc->time_base, out_ast->time_base)。flush 阶段也要对音频编码器做一次 send NULL。完工后 ffprobe 应看到两条流:h264 + aac。

本章结语:FFmpeg 音视频实战 16 章到此收官。从第 1 章生成第一段测试视频,到这一章亲手写完一条完整转码管线——你走过的这条路,就是 FFmpeg 官方的路。往后遇到任何媒体处理需求,先画出五站数据流,再逐站填代码,最后用 ffprobe 验证每一站:这就是本线教你的全部方法论。