最近做的需求里有视频的快速裁切,和 webm 转 gif,用到了 ffmpeg 的一些滤镜操作。ffmpeg 的滤镜是通过 libavfilter 库实现的,它可以对多个输入和多个输出做一系列的操作,产出最终的视频。下面记录几个我在工作中使用的滤镜指令。
一个完整的 ffmpeg 裁剪命令类似这样:
ffmpeg -i input.mp4 -vf "crop=986:800:2:0,scale=759:-1,pad=767:1024:0:204" -n output.mp4
这里都使用了缩写的形式。这个滤镜的意思是把源素材 (2, 0) 这个点右下方 986x800 区域的像素,等比缩放到宽为 759 像素(此时高约为 616 像素),最终输出一个 767x1024 尺寸的视频,把缩放后的素材左上角点置于 (0, 204) 这个位置,并用黑色补足剩余像素。
在 ffmpeg 里,可以使用 crop 来截取输入视频素材的某个区域,通过 scale 放大到所需的尺寸,再用 pad 把缩放后的画面放到目标区域的某个位置,并将多余区域用某个颜色补足。
下面定义这个过程的一些输入变量,来生成裁剪的滤镜指令。
假设目标尺寸的宽和高分别为 mw
和 mh
,原素材的锚点为 (ax, ay)
,素材锚点相对目标区域的坐标为 (x, y)
,素材相对锚点的缩放值为 s
。
源素材上的任意一个点 (x0, y0)
,在缩放并移动之后,于目标尺寸坐标系下的坐标 (x1, y1)
为
function pointInOutputCoord(x0, y0) {
const x1 = x0 * s - ax * s + x;
const y1 = y0 * s - ay * s + y;
return [x1, y1];
}
相应地,目标尺寸坐标系下的坐标 (x1, y1)
在源素材坐标系下的值 (x0, y0) 即为
function pointInObjectCoord(x1, y1) {
const x0 = x1 / s - x / s + ax;
const y0 = y1 / s - y / s + ay;
return [x0, y0];
}
首先要找到素材与目标尺寸相交的左上角点和右下角点。源素材在未应用任何变换矩阵时,它的左上角点为 (0, 0)
,右下角点为 (m, w)
。代入上述等式后,可算出这两个点在目标尺寸下的坐标
const topLeft = pointInOutputCoord(0, 0);
const bottomRight = pointInOutputCoord(w, h);
若素材左上角点和右下角点的某个坐标值超出容器范围,要计算出素材与容器相交的点在源素材坐标系上的值。
let cropAx = 0;
let cropAy = 0;
let cropBx = w;
let cropBy = h;
if (topLeft[0] < 0) {
cropAx = pointInObjectCoord(0, 0)[0];
}
if (topLeft[1] < 0) {
cropAy = pointInObjectCoord(0, 0)[1];
}
if (bottomRight[0] > mw) {
cropBx = pointInObjectCoord(mw, 0)[0];
}
if (bottomRight[1] > mh) {
cropBy = pointInObjectCoord(0, mh)[1];
}
这样就能得出裁切区域的宽和高。
const cropW = Math.floor(cropBx - cropAx);
const cropH = Math.floor(cropBy - cropAy);
于是,就有了 crop 滤镜所需的参数:
crop=${cropW}:${cropH}:${cropAx}:${cropAy}
scale 和 pad 要一起考虑。因为 pad 不允许输出尺寸小于输入尺寸,故裁切后的像素若不需要填充额外的留白,则只需 scale,无需 pad:
let filter = '';
if (topLeft.x <= 0 && topLeft.y <= 0 && bottomRight.x >= mw && bottomRight.y >= mh) {
filter = `crop=${cropW}:${cropH}:${cropAx}:${cropAy},scale=${mw}:${mh}`;
}
若缩放后仍需在左上或右下填充一些留白像素,则使用 scale 时,只能以像素为单位指定宽或高的其中一个值,另一个值要配置成 -1
以实现等比缩放。之后再用 pad 命令,补全留白像素,使输出尺寸与目标尺寸一致。
let filter = `crop=${cropW}:${cropH}:${cropAx}:${cropAy}`;
const widthAfterScale = Math.round(s * cropW);
const heightAfterScale = Math.round(s * cropH);
if (widthAfterScale <= mw && heightAfterScale <= mh) {
filter += `,scale=${widthAfterScale}:-1,pad=${mw}:${mh}`;
} else if (widthAfterScale > mw) {
const _h = mw / cropW * cropH;
if (_h > mh) filter += `,scale=${mw}:${mh}`;
else filter += `,scale=${mw}:-1,pad=${mw}:${mh}`;
} else if (heightAfterScale > mh) {
filter += `,scale=-1:${mh},pad=${mw}:${mh}`;
}
上述命令会让留白像素全部补充在右下方。当素材缩放后,其左上角点位于目标区域内时,左侧或上方也需要补充一些留白像素。
if (topLeft.x > 0) {
filter += `:${Math.round(topLeft.x)}`;
if (topLeft.y > 0) {
filter += `:${Math.round(topLeft.y)}`;
}
} else if (topLeft.y > 0) {
filter += `:0:${Math.round(topLeft.y)}`;
}
这样 filter
变量就有了最终值。
一个完成的 ffmpeg webm 转 gif 命令类似这样:
ffmpeg -vcodec libvpx-vp9 -i input.webm -vsync 0 -filter_complex "[0:v] fps=25, split [a][b];[a] palettegen=max_colors=32 [p];[b][p] paletteuse" -loop 0 output.gif
这里用到了 filter_complex
,用 ;
分隔构成了 3 条滤镜链 (filter chain)。而第一条滤镜链里,又用 ,
分隔使用了两个滤镜。
这个命令的含义大致如下:
[0:v] fps=25, split [a][b]
把输入文件的视频流先应用一个 fps
滤镜,再给中间结果应用 split
滤镜。
fps=25
是 fps=fps=25
的简写,即使用 fps
滤镜,并把这个滤镜的 fps
参数配置为 25
。因为 fps
滤镜的 fps
参数是源码里的第一个参数,所以可以把 fps=fps=25
简写成 fps=25
。
在 ,
后面的 split
会拿到经过 fps
滤镜后输出的影像作为它的输入,这里可以得到两份一样的输出,分别为 [a]
和 [b]
。
[a] palettegen=max_colors=32 [p]
把第一个滤镜链里的输出结果 [a]
作为输入,使用 palettegen
滤镜,生成一个最多含有 32 种色彩的色板,并输出为 [p]
。这个滤镜默认会保留透明,所以不需要额外配置 reserve_transparent
参数。
[b][p] paletteuse
把第一个滤镜链里输出的视频流 [b]
和第二个链里输出的色板 [p]
作为 paletteuse
滤镜的输入,把原画面通过指定的色板重新采样。这是最后一个滤镜了,就省略了最后输出流的名称,和写成 [b][p] paletteuse [output]
的效果是一样的。
除了滤镜配置外,这条转换命令还有诸如 -loop 0
让输出的 gif 一直循环等配置。
通过上面的例子,可以知道 filter_complex
的通用语法。使用下面的结构构造一条滤镜链:
[input1][input2][...] filter1=f1_param1=f1_val1:f1_param2=f1_val2, filter2=f2_param1=f2_val1 [output1][output2][...]
输入与输出的个数应根据滤镜的支持情况按需指定,多个滤镜之间用 , 分隔,第一个 =
左边的是滤镜名称,右边的是滤镜的配置项,多个配置项之间用 :
分隔。若只要按顺序指定滤镜的配置项,则可以省略配置项的名称,写成 filter1=f1_val1:f1_val2
这样的简写格式。
多条滤镜链之间用 ;
来连接。若不显示指定输入与输出的名称,也没有歧义,则可是省略命名。
在很多短视频软件,因为视频比例不符,通常会把原来的视频缩放至刚好能完全展现其内容,上下或左右补充模糊背景,以让画面撑满屏幕。使用上面提到的视频裁切计算和滤镜配置,可以用 ffmpeg 输出这样的视频。
类似“视频裁剪 - Crop”一节,下面定义一些输入变量,来生成带主体缩放居中到目标尺寸区域内,并添加背景模糊的滤镜指令。
假设目标尺寸的宽和高分别为 mw
和 mh
,源素材宽和高分别为 w
和 h
,原素材锚点为 (ax1, ay1)
,素材锚点相对目标区域的坐标为 (x1, y1)
,素材相对锚点的缩放值为 s1
,模糊素材锚点为 (ax2, ay2)
,模糊素材锚点相对目标区域的坐标为 (x2, y2)
,模糊素材相对锚点的缩放值为 s2
。
可以通过 split 滤镜把原始输入的视频流拆成两份:
[0:v] split [bg_input][fg_input]
先处理模糊背景。把 (ax2, ay2)
、(x2, y2)
、s2
、mw
、mh
代入“视频裁剪 - Crop”一节的计算流程,可以得到一组 crop 滤镜所需的参数 crop=${cropW}:${cropH}:${cropAx}:${cropAy}
。因为模糊背景裁切和缩放后理论上总是能填充满目标尺寸的,故 scale 滤镜也就直接配置为 scale=${mw}:${mh}
。最后给画面加上 boxblur 滤镜即可,完整的滤镜链类似这样:
`[bg_input] crop=${cropW}:${cropH}:${cropAx}:${cropAy},scale=${mw}:${mh},boxblur=12 [bg_output]`
在目标尺寸里缩放居中完整呈现的素材,因为是完整呈现,故无需 crop,只要 scale:
`[fg_input] scale=${w*s1}:${h*s1} [fg_output]`
然后用 overlay 滤镜把它们叠起来:
`[bg_output][fg_output] overlay=${x1-s1*ax1}:${y1-s1*ay1}`
最后用 ;
把上面几个滤镜链连起来,就可以作为最终的 filter_complex
了。
例如,把一个 1508x1078 的视频,转为 540x960 并带上模糊背景,则 mw=540,mh=960,w=1508,h=1078,ax1=ax2=754,ay1=ay2=539,x1=x2=270,y1=y2=480,s1=0.3581,s2=0.8905,代入计算得到结果后就可以这么操作:
ffmpeg -i input.mp4 -filter_complex "[0:v] split [bg_input][fg_input];[bg_input] crop=606:1078:450:0,scale=540:960,boxblur=12 [bg_output];[fg_input] scale=540:386 [fg_output];[bg_output][fg_output] overlay=0:287" -n output.mp4