第14章:滤镜图原理:buffer → filters → buffersink

第 2 章你已经在命令行里玩过 -vf scale=320:180,fps=12——这一章把它拆开:滤镜图是什么、buffer 和 buffersink 为什么是图的两端、同一串滤镜描述怎么原封不动地搬进 C API,并亲手跑通一个 320x240 → 320x180 的滤镜图程序

🎨

本章导师:达芬奇

核心方法论:艺术与工程

「工程是戴着镣铐的艺术。滤镜图就是那副镣铐——buffer 定住入口,buffersink 定住出口,中间每一个滤镜都是戴着参数跳舞。把镣铐摸清楚,艺术才真正自由:这一章我们从一张 12 行的素材出发,把整条滤镜流水线从命令行搬到 C 代码里,亲眼看着一帧 320x240 的画面流进图、变成 320x180 流出来。」

14.1 素材一句话:滤镜图的三要素

本章的素材是一篇 2019 年的老博客《ffmpeg_avfilter的使用详解》,全文只有 12 行,核心就一句话:过滤的过程 = 解码后的画面 → buffer 过滤器 → 其他过滤器 → buffersink 过滤器 → 处理好的画面。说人话:滤镜图(filter graph)就是一条"画面加工流水线"——帧从流水线一头进去,经过一道道工序,从另一头出来。加滤镜、加水印、调色、改帧率,本质都是在这条流水线上加工序。

流水线上有三类角色:buffer 是入口传送带(把帧送进图里)、中间一串滤镜是加工车间(scale、fps、overlay……每一道工序)、buffersink 是出口(把处理好的帧取出图外)。你回想第 2 章的 -vf scale=320:180,fps=12:逗号串起来的 scale 和 fps 就是"中间一串滤镜",而 buffer 和 buffersink 两头,是命令行 ffmpeg 悄悄替你接上的——它内部就是这么搭的图:

# 素材原文(《ffmpeg_avfilter的使用详解》,2019-03-27):
在ffmpeg,我们对视音频进行一些处理,如添加水印,有个很方便的库,avfilter,
过滤的过程
解码后的画面 -> buffer过滤器 -> 其他过滤器 -> buffersink 过滤器 -> 处理好的画面

# 把它画成一张图:方框是滤镜(节点),箭头是帧的流动方向(边)
[解码后的画面] -> [buffer 源滤镜] -> [scale] -> [fps] -> [buffersink 汇滤镜] -> [处理好的画面]
                       <- 入口:把帧送进图          <- 出口:把帧取出图
# 第 2 章实测过的命令:-vf 后面就是一条滤镜链(逗号分隔,从左到右依次执行)
ffmpeg -y -i in.mp4 -vf "scale=320:180,fps=12" -c:v libx264 -an out_chain.mp4
# 第 2 章实测:width=320  height=180  r_frame_rate=12/1(滤镜链真的生效了)
达芬奇提示

这一章的目标只有一个:把 -vf 背后那台"流水线机器"拆开给你看。第 2 章你是流水线旁的工人(递参数),这一章你是造机器的人(搭图、接帧)。拆完你会发现:命令行的 -vf 字符串和 C API 里传给解析器的字符串是同一个东西——第 14.5 节我们会亲手验证这一点。

14.2 滤镜图:节点与边,源与汇

滤镜图借用了图论的语言:节点(node)就是滤镜边(edge)就是帧的流动路径。节点之间靠什么连接?靠 pad(插孔)——每个滤镜有输入插孔(input pad)和输出插孔(output pad),一根边就是把上一个滤镜的输出插孔插进下一个滤镜的输入插孔。说人话:帧从 A 的输出插孔出来,顺着边流进 B 的输入插孔,B 加工完再从自己的输出插孔吐出来。

现在看两端。buffer 是源滤镜(source filter):只有输出插孔、没有输入插孔——帧在图里"凭空"出现,所以它叫源。buffersink 是汇滤镜(sink filter):只有输入插孔、没有输出插孔——帧在图里"消失",所以它叫汇。一进一出,正好把整条链的两端卡死。用 ffmpeg 自带的滤镜说明书验证一下(这是查任何滤镜参数的标准动作):

# ffmpeg -h filter=buffer(本机 8.1.1 真实输出,节选)
Filter buffer
  Buffer video frames, and make them accessible to the filterchain.
    Inputs:
        none (source filter)   <- 源滤镜:没有输入
    Outputs:
       #0: default (video)
buffer AVOptions:
   video_size        <image_size> ..FV.......
   pix_fmt           <pix_fmt>    ..FV....... (default none)
   time_base         <rational>   ..FV....... (default 0/1)
   frame_rate        <rational>   ..FV.......
# ffmpeg -h filter=buffersink(本机 8.1.1 真实输出,节选)
Filter buffersink
  Buffer video frames, and make them available to the end of the filter graph.
    Inputs:
       #0: default (video)
    Outputs:
        none (sink filter)      <- 汇滤镜:没有输出
buffersink AVOptions:
   pix_fmts          <binary>     ..FV......P set the supported pixel formats
角色滤镜输入插孔输出插孔职责命令行对应
buffer1 个(video)把外部帧送进图自动接在 -vf 链最前面
中间scale / fps1 个1 个逐帧加工-vf 里逗号串起来的每个滤镜
buffersink1 个(video)把处理好的帧取出图自动接在 -vf 链最后面

注意buffer 滤镜和 av_frame_get_buffer 里的"buffer"是两个东西,纯粹名字撞车。滤镜 buffer 是流水线的入口节点;av_frame_get_buffer 是给 AVFrame 分配像素内存的实用函数。记法:滤镜是"缓冲帧的节点",函数是"给帧分内存"。第 9-12 章你已经在用后者,这一章的主角是前者。

14.3 API 五步流程:命令行字符串原样进图

命令行 -vf scale=320:180 的背后,C API 里是五个动作:① 分配滤镜图 avfilter_graph_alloc → ② 创建 buffer / buffersink 两个端 avfilter_graph_create_filter → ③ 把滤镜链字符串交给 avfilter_graph_parse_ptr 解析并接线 → ④ avfilter_graph_config 配置整张图 → ⑤ 用 av_buffersrc_add_frame 送帧、av_buffersink_get_frame 取帧。注意第 ③ 步:传给解析器的正是命令行里那个字符串——"scale=320:180" 原封不动,一个字符都不改。

第 ② 步给 buffer 传的参数是一串"格式声明",告诉图"进来的帧长什么样":video_size=320x240(宽高)、pix_fmt=yuv420p(像素格式)、time_base=1/25(时间基,呼应第 4 章)、frame_rate=25/1(帧率)。这一句就是在替命令行回答"解码器吐出来的帧是什么格式":

// 第 1 步:分配滤镜图(一张图 = 一条完整流水线)
AVFilterGraph *graph = avfilter_graph_alloc();
if (!graph) { return 1; }

// 第 2 步:创建 buffer 源滤镜 + buffersink 汇滤镜(图的两端)
const AVFilter *src_filt  = avfilter_get_by_name("buffer");
const AVFilter *sink_filt = avfilter_get_by_name("buffersink");
AVFilterContext *src_ctx = NULL, *sink_ctx = NULL;

char args[256];
snprintf(args, sizeof(args),
         "video_size=320x240:pix_fmt=yuv420p:time_base=1/25:frame_rate=25/1");

avfilter_graph_create_filter(&src_ctx,  src_filt,  "in",  args, NULL, graph);
avfilter_graph_create_filter(&sink_ctx, sink_filt, "out", NULL, NULL, graph);
// 第 3 步:parse —— 把滤镜链字符串变成图里的节点,并接线
// 两张"接线表":outputs = buffer 的输出(链的输入接这里,标签 "in")
//                inputs  = buffersink 的输入(链的输出接这里,标签 "out")
AVFilterInOut *inputs  = avfilter_inout_alloc();
AVFilterInOut *outputs = avfilter_inout_alloc();
outputs->name       = av_strdup("in");
outputs->filter_ctx = src_ctx;
outputs->pad_idx    = 0;
inputs->name        = av_strdup("out");
inputs->filter_ctx  = sink_ctx;
inputs->pad_idx     = 0;

int ret = avfilter_graph_parse_ptr(graph, "scale=320:180", &inputs, &outputs, NULL);
if (ret < 0) { return ret; }   // 字符串写错了,报错在这一步
avfilter_inout_free(&inputs);
avfilter_inout_free(&outputs);

// 第 4 步:config —— 校验整张图、协商像素格式、分配内部缓冲
ret = avfilter_graph_config(graph, NULL);
if (ret < 0) { return ret; }   // 图没接好,报错在这一步
步骤API 调用命令行对应一句话人话
① 分配图avfilter_graph_alloc——开一条空流水线
② 建两端avfilter_graph_create_filter × 2-vf 自动接的 buffer / buffersink装上入口传送带和出口
③ 解析并接线avfilter_graph_parse_ptr-vf scale=320:180 的字符串把滤镜链字符串变成节点并连好
④ 配置avfilter_graph_config——整条线通电自检
⑤ 送取帧av_buffersrc_add_frame / av_buffersink_get_frame解码器送帧、编码器取帧帧进图、加工、出图

注意:parse 家族有三个成员——avfilter_graph_parseavfilter_graph_parse_ptravfilter_graph_parse2。本章用的是经典的 parse_ptr:它靠你预先准备好的两张接线表(AVFilterInOut 列表)把 buffer 和 buffersink 接到链的两端。如果像这样传两个 NULL,buffer 的输出插孔就悬空着——avfilter_graph_config 会直接报 not connected to any destination(14.6 节报错 3),这正是我写本章示例时亲手踩过的坑。

达芬奇提示

记住第 ③ 步的字符串是"原封不动"进 API 的,这是一个可以偷懒的工程技巧:先在命令行把 -vf 滤镜链调通,再把同一个字符串塞进 avfilter_graph_parse_ptr。命令行是你的调试台,API 是你的生产环境——同一张配方,两头通用。

14.4 完整程序实测:320x240 → 320x180

把五步串起来,写一个完整的滤镜图程序 filter_graph_demo.cpp:程序手工造一帧 320x240 的 YUV420P 纯色画面,送进 buffer,经过 scale=320:180 加工,从 buffersink 取出来,打印进出的宽高——如果宽高从 320x240 变成 320x180,就说明整条流水线真的转起来了。程序还支持两个命令行参数:argv[1] 是滤镜链字符串(默认 scale=320:180),argv[2] 是送几帧(默认 1),一会儿 14.5 节要用它试 fps 滤镜。

送帧和取帧是本章的新动作,也是全程序的"心脏"(第 1-4 步骨架见 14.3 节):

// 第 5 步(送):造帧并循环交给 buffer 源 —— av_buffersrc_add_frame
AVFrame *in_frame = av_frame_alloc();
in_frame->format = AV_PIX_FMT_YUV420P;
in_frame->width  = 320;
in_frame->height = 240;

for (int i = 0; i < frame_count; i++) {
    in_frame->format = AV_PIX_FMT_YUV420P;  // add_frame 会重置帧,每轮重新声明格式
    in_frame->width  = 320;
    in_frame->height = 240;
    av_frame_get_buffer(in_frame, 32);
    memset(in_frame->data[0], 128, in_frame->linesize[0] * in_frame->height);  // Y 平面填色
    in_frame->pts = i;                    // 时间戳必须给:fps 滤镜靠它工作
    ret = av_buffersrc_add_frame(src_ctx, in_frame);   <- 帧送进图
    if (ret < 0) { return ret; }
}
ret = av_buffersrc_add_frame(src_ctx, NULL);   // NULL = EOF,冲刷整条链(呼应第 12 章 flush)

// 第 5 步(取):从 buffersink 取加工好的帧,取到 EAGAIN / EOF 为止
AVFrame *out_frame = av_frame_alloc();
int n = 0;
while ((ret = av_buffersink_get_frame(sink_ctx, out_frame)) >= 0) {
    printf("output frame[%d]: %dx%d\n", n, out_frame->width, out_frame->height);
    av_frame_unref(out_frame);
    n++;
}
printf("got %d output frame(s)\n", n);
# 编译(本机真实执行;C++ 必须加 -D__STDC_CONSTANT_MACROS,原因见 14.6 报错 0)
g++ -O2 -D__STDC_CONSTANT_MACROS filter_graph_demo.cpp $(pkg-config --cflags --libs libavfilter libavcodec libavutil) -o t

# 运行(本机真实输出,完整)
./t
graph: buffer(video_size=320x240:pix_fmt=yuv420p:time_base=1/25:frame_rate=25/1) -> scale=320:180 -> buffersink
input  frame: 320x240 x 1
output frame[0]: 320x180
got 1 output frame(s)

输出里最关键的一行是 output frame[0]: 320x180——进来的 320x240,经过 scale=320:180,出来 320x180。这一进一出,就是第 2 章命令行 -vf scale=320:180 在 C 代码里完成的事。中间那句 graph: buffer(...) -> scale=320:180 -> buffersink 是程序自己打印的图结构:buffer 是源、buffersink 是汇、中间是加工链,和 14.1 节的素材画的一模一样。

注意:C++ 编译 FFmpeg 程序必须加 -D__STDC_CONSTANT_MACROS(或 #define __STDC_CONSTANT_MACROS)——这是 FFmpeg 头文件对 C++ 的硬性要求,不加会在 libavutil/common.h 第 30 行直接 #error。素材(2019 年)是 C 代码没这问题;用 C++ 写(g++)就要戴这副镣铐。全程用 C 写可以不加,但 C++ 更贴近你第 9-12 章的习惯。

注意av_buffersrc_add_frame接管并重置你传进去的帧(引用计数被拿走、帧被清空)。所以循环里每次送帧前都要重新设置 format / width / height 并重新 av_frame_get_buffer——我第一次写循环时只在循环外设了一次格式,第二帧就报 frame buffer failed: -22(14.6 节报错 5)。这是"谁拥有帧"的经典陷阱:送出去,就不再是你的了

达芬奇提示

取帧这个 while 循环你看着眼熟吗?av_buffersink_get_frame 取到没有帧就返回负值、取到就处理——这就是第 11 章 send/receive 模式的镜像:第 11 章是对编码器 send_frame / receive_packet,这一章是对滤镜图 add_frame / get_frame。FFmpeg 的 API 处处是同一个节奏:一边喂,一边收,喂到 EOF 再收干净

14.5 命令行 ↔ API 对照:同一个字符串的两种用法

现在做第 14.1 节承诺的验证:同一个滤镜链字符串,命令行和 API 各用一次。命令行是 -vf "scale=320:180,fps=12"(第 2 章实测:输出 320x180、12fps),API 就是 ./t "scale=320:180,fps=12" 25——把同一个字符串当 argv[1] 传进去,再送 25 帧。程序的 parse 步骤原封不动地把字符串变成图,结果:

# API 版:同一个字符串交给 avfilter_graph_parse_ptr(本机真实输出,节选)
./t "scale=320:180,fps=12" 25
graph: buffer(video_size=320x240:pix_fmt=yuv420p:time_base=1/25:frame_rate=25/1) -> scale=320:180,fps=12 -> buffersink
input  frame: 320x240 x 25
output frame[0]: 320x180
output frame[1]: 320x180
output frame[2]: 320x180
...
output frame[11]: 320x180
got 12 output frame(s)   <- 25 帧 × (12/25) = 12 帧,fps=12 名副其实

25 帧进去、12 帧出来——fps=12 把 25fps 的输入帧率换算成 12fps 的输出,数学严丝合缝:25 × 12 ÷ 25 = 12。fps 滤镜按时间戳决定每帧的去留(重复或丢弃),所以时间戳(pts)是它的口粮——还记得第 4 章的时间基吗?time_base=1/25 之下,第 i 帧的 pts 就是 i,fps 滤镜就是靠这串数字算节奏的。反过来,如果帧没有时间戳,fps 滤镜直接罢工:

# 单帧 + fps 滤镜(本机真实输出,完整):
./t "scale=320:180,fps=12" 1
graph: buffer(video_size=320x240:pix_fmt=yuv420p:time_base=1/25:frame_rate=25/1) -> scale=320:180,fps=12 -> buffersink
input  frame: 320x240 x 1
got 0 output frame(s)   <- 0 帧!单帧不足以让 fps 建立节奏
命令行C API同一件事
-vf "scale=320:180,fps=12"avfilter_graph_parse_ptr(graph, "scale=320:180,fps=12", ...)同一个滤镜链字符串
解码器输出的帧av_buffersrc_add_frame帧从 buffer 进图
自动接线AVFilterInOut 接线表(标签 "in"/"out")buffer → 链 → buffersink
编码器要的帧av_buffersink_get_frame帧从 buffersink 出图
达芬奇提示

如果你把 in_frame->pts = i; 那行注释掉再跑 fps=12,会看到 ffmpeg 库的实名抱怨:[Parsed_fps_1 @ 0x...] Discarding initial frame(s) with no timestamp.——"没有时间戳的帧,丢了"。这是 14.6 节的报错 4。教训一句话:送进滤镜图的帧,pts 不是可选项

注意:单帧 + fps 得到 0 帧不是程序 bug,是 fps 滤镜的真实语义——它需要连续几帧才能确定节奏,帧太少就直接吞掉。这也解释了为什么第 2 章命令行实测要用整段视频(125 帧)来验证 fps=12:滤镜效果要在足够长的数据流上才看得出来。写测试程序时记得给足帧数,别拿一帧去测帧率滤镜。

14.6 查错手册:把报错当教学点

写本章示例时我踩了四个运行时坑和一个编译坑,全部如实记录。排错的方法论还是第 6 章包青天那一套主循环:看报错原文 → 判断是哪一步的错 → 对症修复 → 重跑验证。好消息是滤镜图的报错非常"话痨",每个都在告诉你它死在哪一步:

# 报错 1:滤镜链字符串里有不存在的滤镜(本机真实输出,节选;0x... 为地址)
./t "scale=320:180,no_such_filter"
[AVFilterGraph @ 0x...] No such filter: 'no_such_filter'
[AVFilterGraph @ 0x...] Error processing filtergraph: Filter not found
parse 'scale=320:180,no_such_filter' failed: -1279870712

# 报错 2:滤镜参数写错(本机真实输出,节选)
./t "scale=abc"
[Parsed_scale_0 @ 0x...] Invalid size 'abc'
[AVFilterGraph @ 0x...] Error processing filtergraph: Invalid argument
parse 'scale=abc' failed: -22
# 报错 3:buffer 的输出插孔没接到任何地方(本机真实输出,完整;-22 = EINVAL)
Output pad "default" with type video of the filter instance "in" of buffer not connected to any destination
graph config failed: -22

# 报错 4:帧没有时间戳,fps 滤镜直接丢弃(本机真实输出,完整)
[Parsed_fps_1 @ 0x...] Discarding initial frame(s) with no timestamp.
# 报错 0(编译期):C++ 没加宏,头文件直接 #error(本机真实报错,节选)
/opt/homebrew/Cellar/ffmpeg-full/8.1.1/include/libavutil/common.h:30:2: error:
      missing -D__STDC_CONSTANT_MACROS / #define __STDC_CONSTANT_MACROS

# 报错 5(运行期):add_frame 重置帧后没重新设置格式(本机真实输出,完整)
frame buffer failed: -22
报错发生在哪一步原因修复
0. missing -D__STDC_CONSTANT_MACROS编译期C++ 编译 FFmpeg 头文件的硬性要求编译命令加 -D__STDC_CONSTANT_MACROS
1. No such filter: 'xxx'parse 期滤镜名写错或 FFmpeg 没编译进该滤镜ffmpeg -filters 核对滤镜名
2. Invalid size 'abc'parse 期滤镜参数格式不对ffmpeg -h filter=scale 查参数语法
3. not connected to any destinationconfig 期buffer/buffersink 没接进图(接线表没给)补上 AVFilterInOut 接线表(14.3 节)
4. Discarding ... no timestamp运行期帧没设 pts送帧前 in_frame->pts = i;
5. frame buffer failed: -22运行期add_frame 重置帧后复用旧格式每轮循环重新设置 format/width/height

注意:同样的错误码 -22(EINVAL,参数无效)背后可能是完全不同的两件事——报错 2 是"滤镜参数写错"(parse 期),报错 3 是"图没接好"(config 期),报错 5 是"帧格式被重置"(运行期)。只看错误码会误诊,要看错误码上面那行带上下文的文字。ffmpeg 的库函数报错时,通常先打印一行"发生了什么",再返回一个错误码——两行一起读,才是完整的证据链。

达芬奇提示

本章把滤镜图的地基打完了:buffer 送帧、字符串搭链、buffersink 取帧。第 15 章《视频滤镜实战:水印、缩放与截图》会在同一张图上叠上 overlay(水印叠加)、drawtext(文字)这些真正的"艺术滤镜",命令行和 API 双线并进。而到了第 16 章,整条管线——解封装、解码、滤镜、编码、封装——会连成一条完整的转码工程。戴着镣铐跳舞,先从把镣铐画清楚开始。

章末练习

练习 1:三要素配对 入门

滤镜图里三个角色——buffer / 中间滤镜(如 scale)/ buffersink——分别对应下面哪句话?A. 把处理好的帧取出图外;B. 把帧送进图里;C. 逐帧加工画面。另外:哪个角色"只有输出没有输入",哪个"只有输入没有输出"?

提示

回顾 14.1 节的流水线比喻:入口传送带、加工车间、出口。再想想 14.2 节的 pad 数量。

参考答案

buffer → B(把帧送进图),中间滤镜 → C(逐帧加工),buffersink → A(把帧取出图)。buffer 是源滤镜,只有输出插孔没有输入插孔;buffersink 是汇滤镜,只有输入插孔没有输出插孔。

练习 2:画出 -vf 的滤镜图 进阶

命令 ffmpeg -i in.mp4 -vf "scale=320:180,fps=12" out.mp4 执行时,ffmpeg 内部搭了一张怎样的滤镜图?请画出节点和边的连接关系,并说明 buffer、buffersink 分别由谁"喂"和"接"。

提示

命令行 ffmpeg 会在 -vf 链两端自动接 buffer 和 buffersink;buffer 前面是解码器,buffersink 后面是编码器。

参考答案

图是:解码器 → buffer → scale=320:180 → fps=12 → buffersink → 编码器。buffer 的输入来自视频解码器(解码后的帧),buffersink 的输出喂给编码器(libx264)。中间的 scale 和 fps 就是 -vf 字符串逗号串起来的那两个滤镜——本章 14.5 节的 API 程序用同一个字符串搭出的正是这张图(去掉了解码/编码两端)。

练习 3:报错分诊 进阶

下面三个报错分别发生在哪一步、原因是什么?(a) No such filter: 'overlay2';(b) Output pad "default" ... not connected to any destination;(c) Discarding initial frame(s) with no timestamp.

提示

对照 14.6 节的报错表格:parse 期两个、config 期一个、运行期一个。

参考答案

(a) parse 期:滤镜名写错了(正确的名字是 overlay),用 ffmpeg -filters 核对;(b) config 期:buffer 或 buffersink 没接进图,补 AVFilterInOut 接线表;(c) 运行期:帧没设 pts,送帧前补 in_frame->pts = i;

练习 4:改造滤镜图,验证旋转 挑战

把本章程序(或你自己重写的版本)的滤镜链改成 transpose=1(旋转 90°),跑一遍,验证输出宽高变成 240x320;再试试 scale=320:180,transpose=1,预测并验证输出宽高。最后用 ffmpeg -h filter=transpose 查一查 transpose 的 0/1/2/3 四个模式分别是什么方向。

提示

只改 argv[1] 传入的字符串即可,程序本身不用改。旋转会交换宽高;先 scale 再 transpose 则是先缩后转。

参考答案
# 本机实测(ffmpeg 8.1.1):
./t "transpose=1"
input  frame: 320x240 x 1
output frame[0]: 240x320   // 宽高交换 = 旋转 90°

./t "scale=320:180,transpose=1"
output frame[0]: 180x320   // 先缩到 320x180,再旋转 → 180x320

transpose 的四个模式(ffmpeg -h filter=transpose 可查):0 = 顺时针 90° + 垂直翻转,1 = 顺时针 90°,2 = 逆时针 90°,3 = 逆时针 90° + 垂直翻转。本章程序一行没改,只换了滤镜链字符串,就验证了"同一张图、任意工序"的威力——这正是滤镜图设计的精髓。

下一章预告:第 15 章《视频滤镜实战:水印、缩放与截图》——overlay 叠加水印、drawtext 写文字、结合 ffmpeg 命令行与 C API 双线实战。学完滤镜图原理,第 15 章就是往这张图上挂真正的"艺术滤镜"。