启用填充时,每个水印的旋转角度,注意是角度而不是弧度,默认:0
43 | ```
44 |
45 | ## 多个水印信息的配置
46 |
47 | 本次脚本支持的水印有文本(`-text`)和文件(`-file`),且支持同时设置多个水印信息。因此,需要了解如何在命令行参数里通过相同的参数设置不同的水印配置。
48 |
49 | 和ffmpeg类似,当你使用-text的时候,可以用-fontsize、-fontcolor等参数来定义文本的格式。
50 | 脚本规定:`定义水印信息的参数,均必须在-text或-file之前`,如果你把-fontsize放在-text之后,那它其实定义的是下一个水印(如果有的话)的字号。
51 |
52 | 例如,
53 | ```shell
54 | node ffmpeg.watermark.js -i input.mp4 ^
55 | -fontsize 40 -fontcolor blue -left 0 -top 0 -text "左上角蓝色的40号文字" ^
56 | -left 0.5 -top 0.5 -text "画面中间的文本" ^
57 | -move dvd -xspeed 400 -yspeed 300 -file "模拟dvd待机运动的图片.png" ^
58 | -repeat -boxw 400 -boxh 40 -fontsize 30 -text "填充水印内容" ^
59 | -right 0 -bottom 0 -scale 120:-1 -file "宽度缩放到120显示到右下角的图片.png"
60 | ```
61 |
62 | > 为了方便观看,使用windows下的命令行分隔符^进行了多行分隔
63 |
64 | 这个示例里,分别定义了5个水印。其中第一个文本水印`-text "左上角蓝色的40号文字"`,它的自定义参数是`-fontsize 40 -fontcolor blue -text -left 0 -top 0`,这个参数列只会对这个-text生效。
65 |
66 | 第二个水印文本`-text "画面中间的文本"`,未定义`-fontsize`,前面的`-fontsize 40`也不会对它生效,因此它的字号是默认的`20`,同理这个文本的颜色也是默认的`white`而不是`blue`,其它参数同理。
67 |
68 | 第三个水印这是一个png图片,定义了“dvd”的运动方式,水平速度400px/s,垂直速度300px/s:`-move dvd -xspeed 400 -yspeed 300`,命令行里其它参数对它无效。
69 |
70 | 第四个、第五个同理,也都只有它前面的参数定义,如果未设置则使用默认值,而不是使用别的水印的设置。
71 |
72 | ## preset的编写
73 |
74 | 本次脚本,由于增加了很多配置参数,如果每次使用的时候都需要编写一长串参数,那么显然是十分麻烦的。因此,本次脚本支持使用-preset来定义一组参数。
75 |
76 | 首先需要编写preset文件,它是一个`utf8`编码的`文本文件`。文件名及存储路径可以随意。
77 | 比如创建一个文本文件`C:\mypreset\custom.txt`,并填写内容:
78 |
79 | ```shell
80 | # 这是一个自定义的preset文件。这一行是注释
81 | -fontsize
82 | 40
83 | -fontcolor
84 | blue
85 | -left
86 | 0
87 | -top
88 | 0
89 | ```
90 |
91 | ### 编写规则:
92 |
93 | - 在命令行里用空格分隔的每个参数,在这里用换行符分隔。
94 | - 前后不需要用引号,即使参数有空格也不需要引号,且含空格的行也算一个参数。
95 | - 如果有对齐参数的需求,可以在前面使用空格。前后的空格均会被忽略。
96 | - 以#或//开头的为注释。目前只支持行注释。如果要传的参数是#或//开头,则只需要在前面加空格即可。
97 | - 脚本的所有参数,除了-preset,均可以在preset文件里定义。
98 |
99 | > 在项目目录 `/preset` 有一些示例文件可以参考。
100 |
101 | 现在,上面示例的第一个水印则可以改成
102 |
103 | ```shell
104 | node ffmpeg.watermark.js -i input.mp4 -preset "C:\mypreset\custom.txt" -text "左上角蓝色的40号文字" ^
105 | ```
106 |
107 | ### 使用方式
108 |
109 | - 在`-preset xxx`的地方,可以看作是在命令行这里插入`xxx`中定义的参数,同样会受到参数顺序的影响;
110 | - 可以多次使用`-preset`。如果有可能被覆盖的参数,则后定义的会覆盖先定义的;
111 | - 如果文件是存放在脚本所在目录的`/preset/abc.preset`,则直接写`-preset abc`即可。
112 |
--------------------------------------------------------------------------------
/docs/ffmpeg.watermark.md:
--------------------------------------------------------------------------------
1 | # 为图片或视频添加自定义的水印,具有动态水印等多种高级功能。
2 |
3 | - [效果预览](#效果预览)
4 | - [前言](#前言)
5 | - [技术分析](#技术分析)
6 | - [运动路径:模拟DVD待机画面](#运动路径模拟dvd待机画面)
7 | - [设置透明度](#设置透明度)
8 | - [水印填充](#水印填充)
9 |
10 | ## 效果预览
11 |
12 |
13 | 模拟DVD待机画面
14 |
15 |
16 |
17 | ----
18 |
19 |
20 | 每1秒随机变换水印位置
21 |
22 |
23 |
24 | ----
25 |
26 | 身份证添加水印:
27 |
28 | 
29 |
30 |
31 | ## 前言
32 |
33 | 给图片或视频增加水印,这是一个很常见的功能。在ffmpeg里,其实就是一个`overlay`画面叠加的过滤器。
34 | 这个过滤器的x、y值支持表达式,于是可以在表达式上根据播放进度t来动态计算x、y值,实现动态水印。
35 | 而如果是要叠加文本,则是用`drawtext`过滤器,它是直接在画面上绘制文本,同时x、y也支持表达式。
36 |
37 | 通过动态算法,可以实现很多有趣的效果,比如模拟DVD待机画面、每n秒随机变换水印位置等。
38 |
39 | ## 技术分析
40 |
41 | ### 运动路径:模拟DVD待机画面
42 |
43 | 从上面效果预览可以看到,这个运动是一个匀速运动,会在画面内有类似“回弹”的效果。
44 | 这个可以做个拆解,水平方向x轴的运动,和垂直方向y轴的运动。
45 | 只要实现了一个方向的往复运动,两个方向同时运动即可达到预想效果。
46 |
47 | > 先了解:视频宽度W,水印宽度w,为了让水印在水平运动有触底回弹的效果,因此x轴最小为0,最大则为W-w。
48 |
49 | #### 方法一:绝对值和取余函数
50 |
51 | 一开始我尝试了取余函数`mod(t,W-w)`,但是这个效果是,水印运动到最右边之后,会立刻在最左边出现。并没有“回来”的效果。
52 | 我们可以在“数字帝国”这个网站上看到[函数图(x%10)](https://zh.numberempire.com/graphingcalculator.php?functions=x%2510&xmin=0&xmax=100&ymin=-40&ymax=40&var=x)
53 |
54 | > 假设W-w=10,其中x轴表示时间
55 |
56 | 
57 |
58 | 不过,通过函数图我们可以看到,y值从0到10,如果函数变成 x%10-10/2,也就是减去最大值的一半,则有一半会变成负数:
59 |
60 | > x%10-5
61 |
62 | 
63 |
64 | 如果,再对y值取绝对值,那负数就可以变成正数了:
65 |
66 | > abs(x%10-5)
67 |
68 | 
69 |
70 | 等等,x=0的时候,y=5,还需要将函数图整体`向左移动5`,才能使得x=0时,y=0
71 |
72 | > abs((x+5)%10-5)
73 |
74 | 
75 |
76 | 我们希望得到一个在0和n之间回弹的函数,那么就是`abs((x+n)%(2*n)-n)`,我这里不把n提取出来,是因为要设置初始坐标、移动速度等都是以像素为单位,只需要修改函数里x的初始值及倍数即可。例如初始坐标100,速度是每秒移动200像素,那么就用`abs((100+200*x+n)%(2*n)-n)`即可,其中x表示时间,单位秒。
77 |
78 |
79 | #### 方法二:正弦与反正弦函数
80 |
81 | > 注意,这里三角函数里的值用的是`弧度`,而不是`角度`;
82 |
83 | 后来我又想起来,数学里有名的正弦函数sin(x),它的函数图就是一个波浪形的曲线:
84 |
85 | 
86 |
87 | 只不过,它不是一个线性变化的曲线,如果直接用它来表示x轴的运动,会有加速减速的效果,且模拟出来的就不是回弹的效果了。
88 | 因此,需要有什么手段把函数图的曲线变成直线。
89 | 此时,可以再加一个反正弦函数,变成`asin(sin(x))`,就会发现,当x取值在(0,2𝜋)时,y值在(-0.5𝜋,0.5𝜋)之间线性变化。
90 | 因此,如果我们希望得到一个x取值(0,1)和(1,2)时,y值从0到1,再从1到0的一个函数,就改成`asin(sin((x-0.5)*pi))/pi+0.5`
91 |
92 | 
93 |
94 | 虽然这个函数可以达到回弹的效果,但是涉及到要设置初始位置、运动速度时,公式就会变得比较麻烦,所以,目前在ffmpeg里我采用了第一种方式来实现。
95 |
96 | ### 设置透明度
97 |
98 | 本次学习到了2种“使画面变半透明”的方法。分别是[geq](https://ffmpeg.org/ffmpeg-filters.html#geq)和[colorchannelmixer](https://ffmpeg.org/ffmpeg-filters.html#colorchannelmixer)过滤器。
99 | 不过要注意使用这个过滤器之前要保证画面是argb格式的,因此还需要经过[format](https://ffmpeg.org/ffmpeg-filters.html#format-1)转换一次。
100 | 以下两种方式均演示了把输入画面的透明度设置为50%:
101 |
102 | ```shell
103 | [1:v]format=argb,geq=a='0.5*alpha(X,Y)'[out1]
104 | [1:v]format=argb,colorchannelmixer=aa=0.5[out1]
105 | ```
106 |
107 | ### 水印填充
108 |
109 | ffmpeg本身没有提供类似背景填充的功能,需要我们自己实现。
110 | 我们可以在脚本里计算好需要填充的数量及相应的位置,然后一个个通过overlay叠加上去。
111 | 但是我们可以变换一种思路,先把水印通过`hstack`过滤器水平拼接m个,再把水平拼接好的通过`vstack`过滤器垂直拼接n个,这样就可以把水印填充成mxn的大小。再通过`rotate`旋转指定的角度后,和原画面进行叠加。
112 | 脚本这里假设不知道原画面的大小,于是填充了一张`足够大`的图来进行叠加。
113 |
114 |
--------------------------------------------------------------------------------
/docs/ffmpeg.xfade.md:
--------------------------------------------------------------------------------
1 | # ffmpeg过滤器xfade自定义动画的研究
2 |
3 | - [前言](#前言)
4 | - [效果预览](#效果预览)
5 | - [水滴](#水滴)
6 | - [百叶窗](#百叶窗)
7 | - [简易翻页](#简易翻页)
8 | - [ffmpeg官方wiki](#ffmpeg官方wiki)
9 | - [ffmpeg官方文档翻译](#ffmpeg官方文档翻译)
10 | - [理解 P](#理解-p)
11 | - [理解 X,Y,W,H](#理解-xywh)
12 | - [理解 PLANE,A,B,a0(x,y),...,b0(x,y),...](#理解-planeaba0xyb0xy)
13 | - [理解 expr](#理解-expr)
14 | - [尝试1,实现渐隐渐显效果](#尝试1实现渐隐渐显效果)
15 | - [尝试2,实现擦除效果](#尝试2实现擦除效果)
16 | - [尝试3,实现推走效果](#尝试3实现推走效果)
17 | - [小结](#小结)
18 | - [性能](#性能)
19 | - [其它转场过滤器](#其它转场过滤器)
20 | - [xfade\_opencl](#xfade_opencl)
21 | - [gl-transition](#gl-transition)
22 | - [结语](#结语)
23 |
24 |
25 | ## 前言
26 |
27 | 使用`xfade`过滤器做视频转场切换效果,本身ffmpeg已经提供了56种效果,能满足大部分需求。不过,更复杂的过渡效果(例如翻页)还没有。
28 | 根据文档,使用transition=custom+expr,可以实现自定义的效果。但是,官方文档并没有对`expr`如何编写做详细说明,也没有google到。
29 | 因此,对其进行了一番研究,尝试实现了几种效果。简单做一个使用教程,希望能够帮助到有需要的人。
30 |
31 | ## 效果预览
32 |
33 | ### 水滴
34 |
35 |
36 |
37 | ### 百叶窗
38 |
39 |
40 |
41 | ### 简易翻页
42 |
43 |
44 |
45 | ## ffmpeg官方wiki
46 |
47 | [https://trac.ffmpeg.org/wiki/Xfade](https://trac.ffmpeg.org/wiki/Xfade)
48 |
49 | ## ffmpeg官方文档翻译
50 |
51 | 以下翻译自[FFmpeg xfade官方文档](https://ffmpeg.org/ffmpeg-filters.html#xfade)
52 |
53 |
54 |
55 | xfade
56 |
57 | 将淡入淡出从一个输入视频流应用到另一个输入视频流。淡入淡出将持续指定的时间。
58 | 两个输入必须是恒定帧速率,并且具有相同的分辨率、像素格式、帧速率和时间基准。
59 |
60 | 该过滤器接受以下选项:
61 |
62 | transition
63 | 'custom'
64 | [忽略]
65 |
66 | duration
67 | 设置交叉淡入淡出持续时间(以秒为单位)。范围为 0 至 60 秒。默认持续时间为 1 秒。
68 |
69 | offset
70 | 设置相对于第一个输入流的交叉淡入淡出开始时间(以秒为单位)。默认偏移量为 0。
71 |
72 | expr
73 | 设置自定义过渡效果的表达式。
74 | 表达式可以使用以下变量和函数:
75 |
76 | X
77 | Y
78 | 当前样本的坐标。
79 |
80 | W
81 | H
82 | 图像的宽度和高度。
83 |
84 | P
85 | 过渡效果的进展。
86 | 【译注】过渡开始时,P=1.0,过渡结束时,P=0.0。
87 |
88 | PLANE
89 | 目前正在处理的平面。
90 | 【译注】这里的平面,其实就是指像素格式的分量。
91 | 【译注】取值范围由输入流的像素格式pix_fmt决定,如 yuv420p,则取值范围是0,1,2;如 rgba,则取值范围是0,1,2,3。
92 |
93 | A
94 | 返回第一个输入流在当前位置和平面的值。
95 |
96 | B
97 | 返回第二个输入流在当前位置和平面的值。
98 |
99 | a0(x,y)
100 | a1(x,y)
101 | a2(x,y)
102 | a3(x,y)
103 | 返回第一个输入的第一/第二/第三/第四个分量的 位置 (x,y) 处的像素的值。
104 | 【译注】例如,像素格式是yuv420p,a0返回的是 Y 分量。a1返回的是 U 分量。a2返回的是 V 分量。没有a3
105 |
106 | b0(x,y)
107 | b1(x,y)
108 | b2(x,y)
109 | b3(x,y)
110 | 返回第二个输入的第一/第二/第三/第四个分量的 位置 (x,y) 处的像素的值。
111 |
112 |
113 |
114 | ## 理解 P
115 |
116 | 一般来说,ffmpeg中支持时间轴编辑的过滤器,都有`t`和`n`参数可以用在表达式中,其中`t`表示时间秒,`n`表示帧数。
117 | 但是xfade里却是用的P,它不是`t`或`n`。如果你理解错了,会发现自定义效果完全没效。
118 | 因为,它表示的是过渡效果的进度,而且,重要的是,它是个递减的数。
119 | - 过渡动画开始的时候,P=1.0;
120 | - 过渡动画结束的时候,P=0.0;
121 | - 它的值是按帧线性递减的,例如,duration=4,fps=25,那么第二帧的时候,P=1.0-1/(4*25)=0.99;
122 | - 可以通过数学函数来改变P的“线性”,例如 P\*P\*(3-2P),([Smoothstep](https://en.wikipedia.org/wiki/Smoothstep),[函数图](https://zh.numberempire.com/graphingcalculator.php?functions=x*x*(3-2*x)&xmin=0&xmax=1&ymin=0&ymax=1&var=x))。
123 | - 注意,P是从1.0到0.0,因此查看函数图的时候要注意从右往左看。
124 | - 如果你觉得从右往左看不直观,把所有P都改成(1-P)吧。
125 | - win11自带的计算器有一个“绘图”功能,能够很好的显示各种数学函数的图形,可以用来辅助理解。
126 |
127 | ## 理解 X,Y,W,H
128 |
129 | X,Y表示坐标,是指“当前正在计算表达式的像素的坐标”,按照我们要实现的效果,决定该像素对应的颜色码。
130 |
131 | W,H是图像的宽高,这个在整个渐变过程是保持不变的。
132 |
133 | ## 理解 PLANE,A,B,a0(x,y),...,b0(x,y),...
134 |
135 | a0(x,y)表示第一个视频坐标x,y处的像素的第一个分量值。
136 | PLANE表示当前是计算的第几个分量值。
137 | A是一个简写,当PLANE=0时,A=a0(X,Y);PLANE=1时,A=a1(X,Y);PLANE=2时,A=a2(X,Y);以此类推。
138 | b和B同a和A。
139 |
140 | > 注意,无法通过类似`a(plane,x,y)`的方法来获得指定坐标指定分量的值,因此在像素有位移的时候,表达式会比较长。如`if(eq(PLANE,0),a0(X,Y),if(eq(PLANE,1),a1(X,Y),if(eq(PLANE,2),a2(X,Y),0)))`
141 |
142 | ## 理解 expr
143 |
144 | `xfade`的`expr`,返回一个值,但是这个值是什么含义呢,一般人看文档很难理解。
145 | 以 `300x200` 的输入源为例,假设其像素格式是yuv420p,则其分量个数是3。(ffmpeg支持的像素格式及格式信息,可以通过`ffmpeg -pix_fmts`查看)。
146 | 像素点是`60000`个,每一帧的像素分量总数就是`60000*3=18万`个。
147 | 那么,过渡开始的第一帧,ffmpeg会遍历每个像素点的每个分量,分别调用`expr`,并设置X,Y,PLANE等值。总共调用`18万`次获得对应的值,来完成第一帧的渲染。
148 | 如果我们希望每一帧就是显示第一个视频的画面,那么可以写`expr=A`即可。`A`表示的就是第一个视频当前像素当前分量的值。
149 |
150 | ### 尝试1,实现渐隐渐显效果
151 |
152 | 如果我们希望实现第一个视频渐渐变透明,第二个视频由透明渐渐显现,类似`xfade`默认的效果`fade`,那么可以写`expr='A*P+B*(1-P)'`。
153 | 因为P是从1.0线性变成0.0的。所以一开始P=1,表达式计算结果=`A`,看到的就是只有第一个视频画面,到一半时,P=0.5,结果=`0.5A+0.5B`,画面就是两个视频分别半透明叠加在一起。最后P=0.0时,结果=`B`,就只剩下第二个视频的画面了。
154 |
155 | ### 尝试2,实现擦除效果
156 |
157 | 同样的,如果我们希望实现一个从右往左擦除的效果(图片引用自[https://trac.ffmpeg.org/wiki/Xfade](https://trac.ffmpeg.org/wiki/Xfade)):
158 | 
159 |
160 | 分析一下,分割线在画面水平线上的位置X,除以宽度W,其实就是等于P,于是,我们可以让分割线左边的显示画面A,右边的显示画面B。
161 | `expr='if(lt(X/W,P),A,B)'`:当`X/W 分割线上显示A还是B,影响不大。这里是显示了B,如果要显示A,可以用`lte`代替`lt`。
164 |
165 | ### 尝试3,实现推走效果
166 |
167 | 从上面两个例子你大概能理解expr要返回什么内容了。我们接着第三个例子。
168 | 如果我们希望实现的是一个从右往左`推走`的效果:
169 | 
170 |
171 | 你会发现,变得更复杂了。你可以先暂停试试自己能否写出来。
172 |
173 | 为什么更复杂了?以坐标(0,0)为例,他显示的像素时刻都在变化(因为画面在往左移动)。
174 | 例如,在P=0.8的时候,它(0,0)应该是视频A X=W*0.2,Y=0坐标处的像素值。(这里需要好好理解,参考下图帮忙理解)
175 |
176 | 
177 |
178 | 在`X/W>P`的地方,应该显示视频B的画面,其坐标转换关系是(X-P*W,Y)。
179 | 注意,此时你没法再用值`A`和`B`了,因为它们是坐标(X,Y)的分量,而我们要在(X,Y)处显示别的坐标的像素,这个我们在上面[理解 PLANE,A,B,a0(x,y),...,b0(x,y),...](#理解-planeaba0xyb0xy)的地方说过了。
180 |
181 | 那么这个表达式要怎么写呢?
182 |
183 | ```
184 | expr='if(lt(X/W,P),^
185 | if(eq(PLANE,0),a0(X+(1-P)*W,Y),^
186 | if(eq(PLANE,1),a1(X+(1-P)*W,Y),^
187 | if(eq(PLANE,2),a2(X+(1-P)*W,Y),0)))^
188 | ,^
189 | if(eq(PLANE,0),b0(X-P*W,Y),^
190 | if(eq(PLANE,1),b1(X-P*W,Y),^
191 | if(eq(PLANE,2),b2(X-P*W,Y),0)))^
192 | )'
193 | ```
194 |
195 | > 我测试的时候用的是windows的bat脚本,为了方便理解和修改,用^进行了换行。注意不要有空格,否则会报错。
196 | > 测试的时候用的是yuv420p像素格式,因此表达式没有用到a3,如果是用了4个分量的像素格式需要把a3按照上面的格式加进去。
197 |
198 | 其中,分割线左边显示视频A的画面,且x坐标左移了(1-P)*W个像素,因此其x坐标表达式是`X+(1-P)*W`;
199 | 右边显示视频B的画面,且x坐标右移到了分割线右边,因此其x坐标表达式是`X-P*W`。
200 | 因为是水平移动,所以y坐标保持`Y`即可。
201 |
202 | 于是,随着P从1.0渐变到0.0,视频A就像被视频B从右边推到了左边,完成了一个过渡效果。
203 |
204 | ## 小结
205 |
206 | 现在,你已经了解了expr要怎么编写来实现过渡效果了。我还实现了一些其它效果,包括示例里的,你可以在GitHub上[查看](https://github.com/jifengg/ffmpeg-script/tree/main/preset/xfade)。
207 |
208 | ## 性能
209 |
210 | 在windows下创建2个bat文件,分别输入测试命令:
211 |
212 | ```bat
213 | @echo off
214 | @REM 使用custom实现slideleft效果
215 | ffmpeg -y -hide_banner ^
216 | -f lavfi -i "pal100bars=r=1/1000" ^
217 | -f lavfi -i "colorchart=r=1/1000" ^
218 | -filter_complex ^
219 | [0:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40[v1];^
220 | [1:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40.04[v2];^
221 | [v1][v2]xfade=duration=40:offset=0:transition=custom:^
222 | expr='if(lt(X/W,P),^
223 | if(eq(PLANE,0),a0(X+(1-P)*W,Y),^
224 | if(eq(PLANE,1),a1(X+(1-P)*W,Y),^
225 | if(eq(PLANE,2),a2(X+(1-P)*W,Y),0)))^
226 | ,^
227 | if(eq(PLANE,0),b0(X-P*W,Y),^
228 | if(eq(PLANE,1),b1(X-P*W,Y),^
229 | if(eq(PLANE,2),b2(X-P*W,Y),0)))^
230 | )' ^
231 | -crf 23 -c:v h264 -pix_fmt yuv420p -movflags +faststart -r 25 -aspect 960:480 ^
232 | out1.mp4
233 | ```
234 |
235 | ```bat
236 | @echo off
237 | @REM 使用内置的slideleft效果
238 | ffmpeg -y -hide_banner ^
239 | -f lavfi -i "pal100bars=r=1/1000" ^
240 | -f lavfi -i "colorchart=r=1/1000" ^
241 | -filter_complex ^
242 | [0:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40[v1];^
243 | [1:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40.04[v2];^
244 | [v1][v2]xfade=duration=40:offset=0:transition=slideleft ^
245 | -crf 23 -c:v h264 -pix_fmt yuv420p -movflags +faststart -r 25 -aspect 960:480 ^
246 | out2.mp4
247 | ```
248 |
249 | 这里使用的动画时长是40秒,可以自行修改成0~60秒。
250 | 在我电脑上运行,耗时分别是:自定义`17.514秒`,内置`1.605秒`。
251 | 可以看出,使用自定义的效果,远比内置效果更耗时。原因我们在“[理解 expr](#理解-expr)”有提过,因为每一帧需要调用expr次数=960×480×3=1,382,400。一百多万次。而且是纯CPU运算,因此效率自然底下。
252 |
253 | 好在一般的过场时长是3、4秒左右,影响还在可接受范围内。
254 |
255 | 如果你在寻找更高效的自定义效果,可以考虑使用`xfade_opencl`过滤器,或者自行编译ffmpeg,加入`gl-transition`过滤器。
256 |
257 | ## 其它转场过滤器
258 |
259 | ### xfade_opencl
260 |
261 | 要使用`xfade_opencl`,需要编译的时候加入`--enable-opencl`,且运行的机器有支持opencl的设备(一般指显卡)。
262 | 要查看当前机器有哪些opencl的设备,可以运行以下命令:
263 | ```
264 | ffmpeg -v debug -init_hw_device opencl
265 | ```
266 |
267 | 打印出类似信息:
268 | ```
269 | [AVHWDeviceContext @ 0000027894f28400] 1 OpenCL platforms found.
270 | [AVHWDeviceContext @ 0000027894f28400] 1 OpenCL devices found on platform "NVIDIA CUDA".
271 | [AVHWDeviceContext @ 0000027894f28400] 0.0: NVIDIA CUDA / NVIDIA GeForce RTX *****
272 | ```
273 | 其中`0.0`就是可用的opencl设备编号,在ffmpeg命令中指定使用该设备:
274 |
275 | ```
276 | ffmpeg -y -hide_banner -init_hw_device opencl=ocldev:0.0 -filter_hw_device ocldev ^
277 | -f lavfi -r 25 -t 40 -i "pal100bars" ^
278 | -f lavfi -r 25 -t 40.04 -i "colorchart" ^
279 | -filter_complex ^
280 | [0:v]format=yuv420p,scale=960:480,hwupload[v0];^
281 | [1:v]format=yuv420p,scale=960:480,hwupload[v1];^
282 | [v0][v1]xfade_opencl=duration=40:offset=0:transition=slideleft,hwdownload,format=yuv420p ^
283 | -c:v h264_nvenc -pix_fmt yuv420p -movflags +faststart -r 25 -aspect 960:480 ^
284 | out3.mp4
285 | ```
286 |
287 | 性能比自定义xfade效果好很多,唯一要求就是需要支持opencl的设备(一般指显卡)。
288 | 且,`xfade_opencl`也是支持自定义效果的,[官方文档](https://ffmpeg.org/ffmpeg-filters.html#xfade_005fopencl)。
289 | 内置的几个效果的源码可以查看GitHub上ffmpeg的源码:[https://github.com/FFmpeg/FFmpeg/blob/master/libavfilter/opencl/xfade.cl](https://github.com/FFmpeg/FFmpeg/blob/master/libavfilter/opencl/xfade.cl)
290 |
291 | ### gl-transition
292 |
293 | [gl-transitions](https://gl-transitions.com/)是由开发者 Gilles Lamothe 创建的,它封装了大量的GPU加速过渡效果,包括但不限于溶解、推拉、旋转等多种类型。这些过渡效果可以轻松地整合到你的图形应用程序中,无论你是开发游戏、视频编辑软件还是实验性的艺术项目。
294 | 它使用OpenGL进行加速,因此,也需要支持OpenGL的设备(一般指显卡)。
295 | 它不是ffmpeg专属的,但是可以做为一个过滤器添加到ffmpeg中。参考这个GitHub项目[transitive-bullshit/ffmpeg-gl-transition](https://github.com/transitive-bullshit/ffmpeg-gl-transition)。
296 | 编译后,你将可以使用其官网上的[所有效果](https://gl-transitions.com/gallery),当然也可以自己编写自定义的效果。
297 |
298 | 性能方面,因为我没有自行编译测试,所以无法给出具体数据。
299 |
300 | 它使用GLSL语言编写,如果你看了上面OpenCL的部分,你会发现它们有很多共同点。
301 | 甚至,我在编写`xfade`自定义表达式的时候,也参考了它的GLSL代码。比如效果预览中的[水滴](#水滴),就是参考了[WaterDrop](https://gl-transitions.com/editor/WaterDrop)。
302 |
303 | ## 结语
304 |
305 | 不知道是ffmpeg官方觉得xfade的expr编写太过容易,还是觉得性能不行不建议使用,反正官方文档及wiki都没有示例,也没有提及如何编写。
306 | 我自己基本上是自己看着文档猜测、尝试,慢慢的摸索出来一些门道。想着网上没有一个类似的教程,于是变写了这个文章。
307 | 如果你发现文章哪里有问题,欢迎指出,大家共同进步。
308 |
--------------------------------------------------------------------------------
/docs/imgs/subtitle.stack.demo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jifengg/ffmpeg-script/376ea76b9783eadf78c3b4ea0a2d0a0def1b800b/docs/imgs/subtitle.stack.demo.jpg
--------------------------------------------------------------------------------
/ffmpeg.images.rolling.js:
--------------------------------------------------------------------------------
1 |
2 | const fs = require('fs');
3 | const path = require('path');
4 | const child_process = require('child_process');
5 |
6 | let boolArgsKey = [
7 | 'y', 'h', 'v', 'debug',
8 | ]
9 | function parseArgs(args) {
10 | /*
11 | -name hello -t 1
12 | */
13 | let rs = {
14 | '_': []
15 | };
16 | let key = null;
17 | for (let i = 0; i < args.length; i++) {
18 | let v = args[i];
19 | if (v.startsWith('-')) {
20 | key = v.substring(1);
21 | if (boolArgsKey.includes(key)) {
22 | rs[key] = true;
23 | key = null;
24 | }
25 | } else {
26 | if (key != null) {
27 | rs[key] = v;
28 | key = null;
29 | } else {
30 | rs._.push(v);
31 | }
32 | }
33 | }
34 | return rs;
35 | }
36 |
37 | function parseNumber(str, defaultValue) {
38 | if (str != null) {
39 | let num = Number(str);
40 | if (!isNaN(num)) {
41 | return num;
42 | }
43 | }
44 | return defaultValue;
45 | }
46 |
47 | function parseTimeString2ms(timeStr) {
48 | try {
49 | // 将时间字符串拆分成小时、分钟、秒和毫秒
50 | const [hours, minutes, seconds] = timeStr.trim().split(':');
51 | // 转换成毫秒
52 | const totalMilliseconds =
53 | parseInt(hours) * 60 * 60 * 1000 +
54 | parseInt(minutes) * 60 * 1000 +
55 | parseFloat(seconds) * 1000;
56 | return totalMilliseconds;
57 | } catch {
58 |
59 | }
60 | }
61 |
62 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x
63 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) [L]*size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig;
64 | function tryParseProgress(line) {
65 | let match = ffmpegProgressReg.exec(line);
66 | if (match != null) {
67 | return {
68 | frame: parseInt(match[1].trim()),
69 | fps: parseFloat(match[2].trim()),
70 | q: parseFloat(match[3].trim()),
71 | size: match[4].trim(),
72 | time: parseTimeString2ms(match[5].trim()),
73 | bitrate: match[6].trim(),
74 | speed: parseFloat(match[7].trim()),
75 | }
76 | }
77 | }
78 |
79 | function showCmdHelp() {
80 | let msg = `${process.argv.slice(0, 2).join(' ')} -i [-o ...]
81 | -i [必须]图片所在的目录
82 | -duration 每张图片从出现到消失的时长(秒),默认:20
83 | -direction 图片滚动的方向,可选:rl(从右到左,默认),lr(从左到右)
84 | -margin
85 | 图片之间的间距,支持的格式:all、vertical|horizontal、top|right|bottom|left,默认all=20
86 | -o 输出视频的路径,默认为输入目录下的output.mp4
87 | -fps 输出视频的帧率,默认:25
88 | -y 是否覆盖已经存在的输出文件,默认:false
89 | -bgimage 背景图片的路径,比bgcolor优先,默认:无
90 | -blursigma 背景图片虚化的sigma值,为0表示不虚化,默认:15
91 | -bgcolor 背景颜色,值的格式同ffmpeg的color,默认:black
92 | -width 输出视频的宽度,默认:1920
93 | -height 输出视频的高度,默认:1080
94 | -top 图片区距离视频顶部的距离,默认:0
95 | -bottom 图片区距离视频底部的距离,默认:0
96 | -title 视频的标题,显示在画面上方,默认:无
97 | -tsize 标题文字大小,默认:80
98 | -tcolor 标题文字颜色,值的格式同ffmpeg的color,默认:white
99 | -tbordercolor 标题边框颜色,值的格式同ffmpeg的color,默认:black
100 | -tfont 标题字体文件路径,非windows下使用时必传,默认:c:/Windows/Fonts/msyh.ttc(微软雅黑)
101 | -footer 视频的底部文字(脚注),显示在画面下方,默认:无
102 | -fsize 脚注文字大小,默认:30
103 | -fcolor 脚注文字颜色,值的格式同ffmpeg的color,默认:white
104 | -fbordercolor 脚注边框颜色,值的格式同ffmpeg的color,默认:black
105 | -ffont 脚注字体文件路径,非windows下使用时必传,默认:c:/Windows/Fonts/msyh.ttc(微软雅黑)
106 | -h 显示这个帮助信息
107 | -debug 是否开启debug模式,打印更详细的日志
108 | `;
109 | console.log(msg);
110 | }
111 |
112 | let videoFormat = ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', '3gp', 'ts', 'webm', 'mpv'];
113 | function isVideo(filepath) {
114 | return videoFormat.includes(path.extname(filepath).substring(1).toLowerCase());
115 | }
116 |
117 | let imageFormat = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp', 'svg', 'tif', 'raw', 'ico'];
118 | function isImage(filepath) {
119 | return imageFormat.includes(path.extname(filepath).substring(1).toLowerCase());
120 | }
121 |
122 | function getAllImageFile(dir) {
123 | let list = fs.readdirSync(dir, { withFileTypes: true });
124 | let rs = [];
125 | for (const item of list) {
126 | let fullpath = path.join(dir, item.name);
127 | if (item.isFile()) {
128 | if (isImage(fullpath)) {
129 | rs.push(fullpath);
130 | }
131 | } else if (item.isDirectory()) {
132 | let sublist = getAllImageFile(fullpath);
133 | rs.push(...sublist);
134 | }
135 | }
136 | return rs;
137 | }
138 |
139 | function getDrawtextFilter(text, size, color, bordercolor, fontfile, istitle) {
140 | text = text.replace(/\\n/img, '\n');
141 | return `drawtext=text=${text}:fontsize=${size}:fontcolor=${color}:bordercolor=${bordercolor}:borderw=2:x=(w-tw)/2:y=${istitle ? size / 2 : `h-th-${size / 2}`}:fontfile='${fontfile}'`;
142 | }
143 |
144 | let debug = false;
145 | let defaultFontfile = 'c:/Windows/Fonts/msyh.ttc';
146 |
147 | async function start(args) {
148 | if (args == null) {
149 | args = parseArgs(process.argv.slice(2));
150 | }
151 | let input = args.i;
152 | if (input == null || !!args.h) {
153 | showCmdHelp();
154 | return;
155 | }
156 | if (!fs.existsSync(input)) {
157 | console.log('输入文件夹不存在', input);
158 | return;
159 | }
160 | let overwrite = !!args.y;
161 | debug = !!args.debug;
162 | let width = parseNumber(args.width, 1920);
163 | let height = parseNumber(args.height, 1080);
164 | let top = parseNumber(args.top, 0);
165 | let bottom = parseNumber(args.bottom, 0);
166 | let oneDuration = parseNumber(args.duration, 20);
167 | oneDuration = Math.max(1, oneDuration);
168 |
169 | let images = getAllImageFile(input);
170 | if (images.length == 0) {
171 | console.log('没有找到图片文件');
172 | return;
173 | }
174 | let outputVideo = args.o || path.join(input, 'output.mp4');
175 | if (fs.existsSync(outputVideo) && !overwrite) {
176 | console.log('输出文件已存在', outputVideo);
177 | return;
178 | }
179 | let direction = args.direction == 'lr' ? 'lr' : 'rl';
180 | let bgimage = args.bgimage;
181 | let bgcolor = args.bgcolor || 'black';
182 | let blursigma = parseNumber(args.blursigma, 15);
183 | let title = args.title;
184 | let titlefontSize = parseNumber(args.tsize, 80);
185 | let titlecolor = args.tcolor || 'white';
186 | let titlebordercolor = args.tbordercolor || 'black';
187 | let titlefontPath = path.resolve(args.tfont || defaultFontfile);
188 | if (title && !fs.existsSync(titlefontPath)) {
189 | console.log('字体文件不存在', titlefontPath);
190 | return;
191 | }
192 | titlefontPath = titlefontPath.replace(/\\/g, '/');
193 | let titleHeight = top;//+ title ? titlefontSize * 2 : 0;
194 |
195 | let footer = args.footer;
196 | let footerfontSize = parseNumber(args.fsize, 30);
197 | let footercolor = args.fcolor || 'white';
198 | let footerbordercolor = args.fbordercolor || 'black';
199 | let footerfontPath = path.resolve(args.ffont || defaultFontfile);
200 | if (footer && !fs.existsSync(footerfontPath)) {
201 | console.log('字体文件不存在', footerfontPath);
202 | return;
203 | }
204 | footerfontPath = footerfontPath.replace(/\\/g, '/');
205 | let footerHeight = bottom;//+ footer ? footerfontSize * 2 : 0;
206 |
207 | let leftMargin = 0;
208 | let topMargin = 0;
209 | let rightMargin = 0;
210 | let bottomMargin = 0;
211 | let marginStr = args.margin || '20';
212 | if (/^\d+$/.test(marginStr)) {
213 | leftMargin = rightMargin = topMargin = bottomMargin = parseNumber(marginStr, 0);
214 | } else if (/^\d+\|\d+$/.test(marginStr)) {
215 | let [v, h] = marginStr.split('|');
216 | leftMargin = rightMargin = parseNumber(h, 0);
217 | topMargin = bottomMargin = parseNumber(v, 0);
218 | } else if (/^\d+\|\d+\|\d+\|\d+$/.test(marginStr)) {
219 | let [t, r, b, l] = marginStr.split('|');
220 | leftMargin = parseNumber(l, 0);
221 | rightMargin = parseNumber(r, 0);
222 | topMargin = parseNumber(t, 0);
223 | bottomMargin = parseNumber(b, 0);
224 | } else {
225 | console.log('margin参数设置无效:“', marginStr, '”,将使用默认值0');
226 | }
227 |
228 | let fps = parseNumber(args.fps, 25);
229 |
230 | let startTime = Date.now();
231 | console.log('开始处理。');
232 | console.log(
233 | `输入目录:${input}
234 | 图片数量:${images.length}
235 | 视频分辨率:${width}x${height}`);
236 | let cmd = 'ffmpeg';
237 | let filter_complex = '';
238 | let duration = oneDuration * images.length;
239 | let bginputs = [];
240 | if (bgimage != null) {
241 | bginputs = [...'-loop 1 -r 1/1000 -i'.split(' '), bgimage];
242 | } else {
243 | bginputs = [...'-f lavfi -t 1 -r 1/1000 -i'.split(' '), `color=c=${bgcolor}:s=${width}x${height}`];
244 | }
245 | let imageInputs = [];
246 | for (let i = 0; i < images.length; i++) {
247 | imageInputs.push('-i', images[i]);
248 | }
249 | filter_complex += `[0:v]${bgimage ? `gblur=sigma=${blursigma},scale=${width}:${height},` : ''}`
250 | + `${title ? getDrawtextFilter(title, titlefontSize, titlecolor, titlebordercolor, titlefontPath, true) + ',' : ''}`
251 | + `${footer ? getDrawtextFilter(footer, footerfontSize, footercolor, footerbordercolor, footerfontPath, false) + ',' : ''}`
252 | + `fps=${fps},trim=duration=${duration}[v0];`;
253 | let imageHeight = height - footerHeight - titleHeight;
254 | for (let i = 0; i < images.length; i++) {
255 | filter_complex += `[${i + 1}:v]scale=-2:${imageHeight}-${topMargin + bottomMargin},`
256 | + `pad=iw+${leftMargin + rightMargin}:h=ih+${topMargin + bottomMargin}:x=${leftMargin}:y=${topMargin}:color=black@0[v${i + 1}];`;
257 | }
258 | let speed = `((W+w)/${duration})`;
259 | let xStep = direction == 'rl' ? `W-t*${speed}` : `t*${speed}-w`;
260 | filter_complex += `${new Array(images.length).fill(0).map((v, i) => `[v${i + 1}]`).join('')}hstack=inputs=${images.length}[fg];`
261 | + `[v0][fg]overlay=x=${xStep}:y=${titleHeight}`;
262 | let ffmpeg_args = [
263 | '-y', '-hide_banner',
264 | ...bginputs, ...imageInputs,
265 | '-filter_complex', filter_complex,
266 | // 输出视频的一些参数,这里只用了质量控制参数 -crf 23,可自行添加如 -c:v libx265 等
267 | '-crf', '23',
268 | outputVideo
269 | ];
270 | if (debug) {
271 | console.log(cmd, ffmpeg_args.map(i => i.includes(' ') ? `"${i}"` : i).join(' '));
272 | }
273 | let output = '';
274 | let offset = 0;
275 | let progressPosition = 0;
276 | await new Promise((resolve, reject) => {
277 | let p = child_process.execFile(cmd, ffmpeg_args, {});
278 | p.on('exit', (code) => {
279 | if (process.stdin.isTTY) {
280 | process.stdout.write('\n');
281 | }
282 | if (code == 0) {
283 | resolve(code);
284 | } else {
285 | reject(code);
286 | }
287 | });
288 | p.stderr.on('data', (chunk) => {
289 | output += chunk + '';
290 | while (true) {
291 | let index = output.indexOf('\n', offset);
292 | let index2 = output.indexOf('\r', offset);
293 | if (index == -1 && index2 == -1) {
294 | break;
295 | }
296 | if (index == -1) {
297 | index = Number.MAX_SAFE_INTEGER;
298 | }
299 | if (index2 == -1) {
300 | index2 = Number.MAX_SAFE_INTEGER;
301 | }
302 | index = Math.min(index, index2);
303 | let line = output.substring(offset, index);
304 | offset = index + 1;
305 | let progress = tryParseProgress(line);
306 | if (progress != null) {
307 | progressPosition = progress.time;
308 | } else {
309 | continue;
310 | }
311 | if (isNaN(progressPosition)) {
312 | continue;
313 | }
314 | let progressStr = duration != null && progressPosition != 0 ? `处理进度:${(progressPosition / 1000).toFixed(2).padStart(7, ' ')} 秒(${(progressPosition / 1000 / duration * 100).toFixed(2).padStart(5, ' ')}%)` : '';
315 | let msg = progressStr;
316 | if (!process.stdin.isTTY) {
317 | console.log(msg);
318 | } else {
319 | process.stdout.write('\r' + msg);
320 | }
321 | }
322 | if (debug) {
323 | if (!process.stdin.isTTY) {
324 | console.log(chunk + '');
325 | } else {
326 | process.stdout.write(chunk);
327 | }
328 | }
329 | });
330 | });
331 | let processTime = Date.now() - startTime;
332 | console.log('处理完毕。耗时:', processTime / 1000, '秒');
333 |
334 | }
335 |
336 | module.exports = { start }
337 |
338 | if (process.argv[1] == __filename) {
339 | start();
340 | }
--------------------------------------------------------------------------------
/ffmpeg.img2video.js:
--------------------------------------------------------------------------------
1 | const child_process = require('child_process');
2 | const fs = require('fs');
3 | const path = require('path');
4 |
5 | /**
6 | * @type {[string|{name:string,expr:string}]}
7 | */
8 | let builtinTransitions = [
9 | // 'custom',
10 | 'fade',
11 | 'wipeleft',
12 | 'wiperight',
13 | 'wipeup',
14 | 'wipedown',
15 | 'slideleft',
16 | 'slideright',
17 | 'slideup',
18 | 'slidedown',
19 | 'circlecrop',
20 | 'rectcrop',
21 | 'distance',
22 | 'fadeblack',
23 | 'fadewhite',
24 | 'radial',
25 | 'smoothleft',
26 | 'smoothright',
27 | 'smoothup',
28 | 'smoothdown',
29 | 'circleopen',
30 | 'circleclose',
31 | 'vertopen',
32 | 'vertclose',
33 | 'horzopen',
34 | 'horzclose',
35 | 'dissolve',
36 | 'pixelize',
37 | 'diagtl',
38 | 'diagtr',
39 | 'diagbl',
40 | 'diagbr',
41 | 'hlslice',
42 | 'hrslice',
43 | 'vuslice',
44 | 'vdslice',
45 | 'hblur',
46 | 'fadegrays',
47 | 'wipetl',
48 | 'wipetr',
49 | 'wipebl',
50 | 'wipebr',
51 | 'squeezeh',
52 | 'squeezev',
53 | // 'zoomin',//效果不好,不用
54 | 'fadefast',
55 | 'fadeslow',
56 | 'hlwind',
57 | 'hrwind',
58 | 'vuwind',
59 | 'vdwind',
60 | 'coverleft',
61 | 'coverright',
62 | 'coverup',
63 | 'coverdown',
64 | 'revealleft',
65 | 'revealright',
66 | 'revealup',
67 | 'revealdown',
68 | ];
69 | let transitions = builtinTransitions;
70 |
71 | //-transitions fade,wipeleft,wiperight,wipeup,wipedown,slideleft,slideright,slideup,slidedown,circlecrop,rectcrop,distance,fadeblack,fadewhite,radial,smoothleft,smoothright,smoothup,smoothdown,circleopen,circleclose,vertopen,vertclose,horzopen,horzclose,dissolve,pixelize,diagtl,diagtr,diagbl,diagbr,hlslice,hrslice,vuslice,vdslice,hblur,fadegrays,wipetl,wipetr,wipebl,wipebr,squeezeh,squeezev,fadefast,fadeslow,hlwind,hrwind,vuwind,vdwind,coverleft,coverright,coverup,coverdown,revealleft,revealright,revealup,revealdown
72 |
73 | let boolArgsKey = [
74 | 'y', 'h', 'v', 'debug', 'repeat', 'disable_buildin_transitions'
75 | ]
76 |
77 | let groupArgsKey = [];
78 |
79 | let groupArgsEndKey = [];
80 |
81 | let groupArgsKeyAll = [...groupArgsKey, ...groupArgsEndKey];
82 |
83 | function parseArgs(args) {
84 | /*
85 | -name hello -t 1
86 | 支持分组的,类似ffmpeg的
87 | -size 30 -color green -alpha 0.5 -text 文字 ||| -size 20 -color red -alpha 0.35 -text 其他 ||| -width 100 -height 80 -alpha 0.75 -file path/to/image.jpg
88 | */
89 | let rs = {
90 | '_': [],
91 | '__groups': []
92 | };
93 | let group = null;
94 | let isGroupKey = false;
95 | let key = null;
96 | for (let i = 0; i < args.length; i++) {
97 | let v = args[i];
98 | // 兼容传负数值类似 -2 或 -1:100 等情况,减号后面跟着数字则认为是“值”而不是“key”
99 | if (v.startsWith('-') && (v.length > 1 && isNaN(Number(v[1])))) {
100 | key = v.substring(1);
101 | if (groupArgsKeyAll.includes(key)) {
102 | // 是组的key
103 | isGroupKey = true;
104 | if (group == null) {
105 | group = {};
106 | rs.__groups.push(group);
107 | }
108 | } else {
109 | isGroupKey = false;
110 | }
111 | if (boolArgsKey.includes(key)) {
112 | if (isGroupKey) {
113 | group[key] = true;
114 | if (groupArgsEndKey.includes(key)) {
115 | group = null;
116 | }
117 | } else {
118 | rs[key] = true;
119 | }
120 | key = null;
121 | }
122 | } else {
123 | if (key != null) {
124 | if (isGroupKey) {
125 | group[key] = v;
126 | if (groupArgsEndKey.includes(key)) {
127 | group = null;
128 | }
129 | } else {
130 | rs[key] = v;
131 | if (key == 'preset') {
132 | let params = loadPreset(v);
133 | args.splice(i + 1, 0, ...params);
134 | }
135 | }
136 | key = null;
137 | } else {
138 | rs._.push(v);
139 | }
140 | }
141 | }
142 | return rs;
143 | }
144 |
145 | function loadPreset(filepath = '') {
146 | let exist = true;
147 | if (!fs.existsSync(filepath)) {
148 | // 如果路径不含文件路径分隔符,则尝试在指定目录中查找
149 | if (!path.normalize(filepath).includes(path.sep)) {
150 | filepath = path.join('preset', filepath);
151 | // 再加上后缀试试
152 | if (!fs.existsSync(filepath)) {
153 | filepath = `${filepath}.preset`;
154 | exist = fs.existsSync(filepath);
155 | }
156 | } else {
157 | exist = false;
158 | }
159 | }
160 | if (!exist) {
161 | throw `预设置文件不存在:${filepath}`;
162 | }
163 | let lines = fs.readFileSync(filepath).toString().replace(/\r/g, '').split('\n');
164 | // 移除lines中的空白行,并去除每行前后的空格。#开头的为注释行,也忽略
165 | lines = lines.filter(line => line.trim().length > 0 && !line.startsWith('#') && !line.startsWith('//')).map(line => line.trim());
166 | return lines;
167 | }
168 |
169 | function parseNumber(str, defaultValue) {
170 | if (str != null) {
171 | let num = Number(str);
172 | if (!isNaN(num)) {
173 | return num;
174 | }
175 | }
176 | return defaultValue;
177 | }
178 |
179 | /**
180 | * 获取媒体文件的时长
181 | * @param {string} filepath
182 | * @returns {number} 如果能获取到时长,则返回毫秒,否则返回null
183 | */
184 | async function getMediaDuration(filepath) {
185 | let cmd = 'ffmpeg';
186 | let args = ['-hide_banner', '-i', filepath];
187 | return await new Promise((resolve, reject) => {
188 | let p = child_process.execFile(cmd, args, {}, function (err, stdout, stderr) {
189 | if (stderr != null) {
190 | resolve(tryParseDuration(stderr));
191 | } else {
192 | resolve(null);
193 | }
194 | });
195 | });
196 | }
197 |
198 | let ffmpegDurationReg = /Duration: (.+?), start: .+?, bitrate: .+/ig;
199 | function tryParseDuration(line) {
200 | let match = ffmpegDurationReg.exec(line);
201 | if (match != null) {
202 | //02:09:44.74
203 | return parseTimeString2ms(match[1]);
204 | }
205 | return null;
206 | }
207 |
208 | function parseTimeString2ms(timeStr) {
209 | try {
210 | // 将时间字符串拆分成小时、分钟、秒和毫秒
211 | const [hours, minutes, seconds] = timeStr.trim().split(':');
212 | // 转换成毫秒
213 | const totalMilliseconds =
214 | parseInt(hours) * 60 * 60 * 1000 +
215 | parseInt(minutes) * 60 * 1000 +
216 | parseFloat(seconds) * 1000;
217 | return totalMilliseconds;
218 | } catch {
219 |
220 | }
221 | }
222 |
223 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x
224 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) [L]*size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig;
225 | function tryParseProgress(line) {
226 | let match = ffmpegProgressReg.exec(line);
227 | if (match != null) {
228 | return {
229 | frame: parseInt(match[1].trim()),
230 | fps: parseFloat(match[2].trim()),
231 | q: parseFloat(match[3].trim()),
232 | size: match[4].trim(),
233 | time: parseTimeString2ms(match[5].trim()),
234 | bitrate: match[6].trim(),
235 | speed: parseFloat(match[7].trim()),
236 | }
237 | }
238 | }
239 |
240 | let Display = {
241 | Contain: 'contain',
242 | Original: 'original',
243 | Cover: 'cover',
244 | Fill: 'fill'
245 | }
246 |
247 | function showCmdHelp() {
248 | let msg = `${process.argv.slice(0, 2).join(' ')} -i [-o ...]
249 | -preset 本脚本除了-preset之外的所有参数,均可以通过传递preset文件来设置。
250 | 如果使用./preset/abc.preset来设置,则-preset abc即可。
251 | preset文件的编写请参考github(https://github.com/jifengg/ffmpeg-script)。
252 | -i [必须]要处理的图片/音频/字幕文件所在的目录,扫描时不包含子目录。
253 | 支持的图片:jpg jpeg png bmp webp
254 | 支持的音频:mp3 aac wav flac wma ape
255 | 支持的字幕:lrc srt ass
256 | -o 视频文件的保存路径,默认为输入目录/output.mp4
257 | -display 图片的显示方式,默认为contain。可选值为:
258 | original:原图;
259 | contain:等比例缩放至显示全图,可能有黑边;
260 | cover:等比例缩放至能覆盖整个画面,可能有裁剪。
261 | fill:拉伸变形至填充整个画面
262 | -fps 输出视频的帧率,默认:25
263 | -crf ffmpeg控制输出视频质量的参数,越小画面质量越好,视频文件也会越大,建议18~30之间。默认:23
264 | -c:v 输出视频的编码器,默认:h264
265 | -c:a 输出视频的音频编码器,默认:aac
266 | -width 输出视频的宽度,默认:1920
267 | -height 输出视频的高度,默认:1080
268 | -td 图片切换动画时长,默认为4秒
269 | -sd 图片独立显示时长,默认为7秒
270 | -repeat 图片数量太少导致视频时长比音频时长短的时候,循环图片以达到音频的时长。默认:不循环
271 | -transitions 要使用的转场动画集,使用逗号分隔,如 fade,wipeleft,wiperight,wipeup,mytran1
272 | 其中,支持自定义的转场动画,如 mytran1 表示 ./preset/xfade/mytran1.txt
273 | 自定义转场动画的编写请参考github(https://github.com/jifengg/ffmpeg-script)。
274 | -disable_buildin_transitions
275 | 禁用脚本中内置的ffmpeg的转场动画,只使用-transitions定义的,默认:false
276 | -y 覆盖已经存在的输出文件,默认:false
277 | -h 显示这个帮助信息
278 | -debug 开启debug模式,打印更详细的日志
279 | `;
280 | console.log(msg);
281 | }
282 |
283 | /**
284 | *
285 | * @param {{imgs:[string],audio_file:string,subtitle_file:string,output_file:string, width:number, height:number,showDuration:number,tranDuration:number,repeat:boolean,fps:number,crf:number}} param0
286 | */
287 | async function run({ imgs, audio_file, subtitle_file, output_file,
288 | width, height, showDuration, tranDuration, repeat, display,
289 | fps, crf, video_codec, audio_codec,
290 | }) {
291 | let w = Math.floor((width || 1920) / 4) * 4;
292 | let h = Math.floor((height || (w * 9 / 16)) / 4) * 4;
293 | console.log('输出视频分辨率:', w, 'x', h);
294 | let cmd = 'ffmpeg';
295 | let args = ['-y', '-hide_banner'];
296 | let filters_lain = '';
297 | let lain_index = 0;
298 | let input_image_start_index = 0;
299 | let audio_duration = -1;
300 | if (audio_file) {
301 | //获取音频时长,单位秒
302 | audio_duration = await getMediaDuration(audio_file);
303 | if (audio_duration == null) {
304 | console.warn('音频文件读取失败,将忽略音频文件')
305 | } else {
306 | args.push('-i', audio_file);
307 | input_image_start_index++;
308 | audio_duration = audio_duration / 1000;
309 | console.log('音频时长:', audio_duration, '秒');
310 | }
311 | }
312 | //输入图片循环时长
313 | let loopDuration = showDuration + tranDuration * 2;
314 | let imgDuration = imgs.length * (showDuration + tranDuration);
315 | let duration = imgDuration;
316 | if (repeat && imgDuration < audio_duration) {
317 | //如果图片动画时长不够,则循环
318 | let toAdd = Math.ceil((audio_duration - imgDuration) / (showDuration + tranDuration));
319 | let i = 0;
320 | let list = [];
321 | console.log('图片数量不足,将循环补足', toAdd, '张');
322 | while (toAdd > 0) {
323 | list.push(imgs[i % imgs.length]);
324 | toAdd--;
325 | i++;
326 | }
327 | imgs.push(...list);
328 | duration = audio_duration;
329 | };
330 | console.log('图片数量', imgs.length);
331 | console.log('图片动画时长', tranDuration, '秒');
332 | console.log('图片独立显示时长', showDuration, '秒');
333 | console.log('输出视频时长为', duration, '秒');
334 |
335 | for (let i = 0; i < imgs.length; i++) {
336 | const img = imgs[i];
337 | args.push(
338 | '-loop', '1', '-r', `1/1000`, '-i', img
339 | );
340 | // filters_lain += `[${i}]setsar=1/1,scale=${w}:${h}[v${i}_${lain_index}];`;
341 | // force_original_aspect_ratio=decrease,increase
342 | // decrease: 保持宽高比,缩小图片,搭配pad做居中和黑边
343 | // increase: 保持宽高比,放大图片,搭配crop=1920:1080做裁剪
344 | let display_filter = '';
345 | switch (display) {
346 | case Display.Cover:
347 | display_filter = `scale=${w}:${h}:force_original_aspect_ratio=increase:force_divisible_by=4,crop=w=${w}:h=${h}`;
348 | break;
349 | case Display.Fill:
350 | display_filter = `scale=${w}:${h}`;
351 | break;
352 | case Display.Original:
353 | // 如果图片尺寸太大,则先裁剪。裁剪后放在视频画面大小的画板上居中
354 | display_filter = `crop=w='if(gt(iw,${w}),${w},iw)':h='if(gt(ih,${h}),${h},ih)',pad=w=${w}:h=${h}:x=(ow-iw)/2:y=(oh-ih)/2:color=black`;
355 | break;
356 | case Display.Contain:
357 | default:
358 | display_filter = `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=4,pad=w=${w}:h=${h}:x=(ow-iw)/2:y=(oh-ih)/2:color=black`;
359 | break;
360 | }
361 | // 最后一张图片不需要消失的转场,因此时长需要减去一个动画时长
362 | filters_lain += `[${i + input_image_start_index}]setsar=1/1,${display_filter},fps=${fps},trim=duration=${loopDuration - (i == imgs.length - 1 ? tranDuration : 0)}[v${i}_${lain_index}];`;
363 | }
364 | let last_output_label = `v0_${lain_index}`;
365 | for (let i = 1; i < imgs.length; i++) {
366 | let transition = getTransition();
367 | let isCustomTransition = typeof (transition) == 'object';
368 | let duration = tranDuration;
369 | let offset = i * (showDuration + tranDuration) - tranDuration;
370 | let output_label = `ov${i}_${lain_index}`;
371 | filters_lain += `[${last_output_label}][v${i}_${lain_index}]xfade=transition=`
372 | + (isCustomTransition ? `custom:expr='${transition.expr}'` : transition)
373 | + `:duration=${duration}:offset=${offset}[${output_label}];`;
374 | last_output_label = output_label;
375 | }
376 | if (subtitle_file) {
377 | let output_label = 'ov_with_sub';
378 | filters_lain += `[${last_output_label}]subtitles=filename='${subtitle_file.replace(/\\/g, '/').replace(/:/g, '\\:')}'[${output_label}];`;
379 | last_output_label = output_label;
380 | }
381 | filters_lain = filters_lain.substring(0, filters_lain.length - 1 - last_output_label.length - 2);
382 | args.push(
383 | '-filter_complex', filters_lain
384 | );
385 | args.push(
386 | // '-map', `[${last_output_label}]`,
387 | //-keyint_min 30 -g 30 -sc_threshold 0 //设置i帧最小间距为30帧
388 | ...`-crf ${crf} -c:v ${video_codec} -pix_fmt yuv420p -movflags +faststart -r ${fps} -aspect ${w}:${h}`.split(' '),
389 | );
390 | args.push(
391 | ...`-c:a ${audio_codec} -b:a 128k -ac 2 -ar 44100`.split(' '),
392 | );
393 | args.push(
394 | '-t', duration + '', '-shortest', output_file
395 | )
396 | let line = [cmd, ...args].map(v => (v + '').includes(' ') ? `"${v}"` : v).join(' ');
397 | if (debug) {
398 | console.log('即将开始使用ffmpeg处理,命令行:');
399 | console.log(line);
400 | }
401 | let start = Date.now();
402 | let output = '';
403 | let offset = 0;
404 | let progressPosition = 0;
405 | await new Promise((resolve, reject) => {
406 | let p = child_process.execFile(cmd, args, {});
407 | p.on('exit', (code) => {
408 | if (process.stdin.isTTY) {
409 | process.stdout.write('\n');
410 | }
411 | if (code == 0) {
412 | resolve(code);
413 | } else {
414 | reject(code);
415 | }
416 | });
417 | p.stderr.on('data', (chunk) => {
418 | output += chunk + '';
419 | while (true) {
420 | let index = output.indexOf('\n', offset);
421 | let index2 = output.indexOf('\r', offset);
422 | if (index == -1 && index2 == -1) {
423 | break;
424 | }
425 | if (index == -1) {
426 | index = Number.MAX_SAFE_INTEGER;
427 | }
428 | if (index2 == -1) {
429 | index2 = Number.MAX_SAFE_INTEGER;
430 | }
431 | index = Math.min(index, index2);
432 | let line = output.substring(offset, index);
433 | offset = index + 1;
434 | let progress = tryParseProgress(line);
435 | if (progress != null) {
436 | progressPosition = progress.time;
437 | } else {
438 | continue;
439 | }
440 | if (isNaN(progressPosition)) {
441 | continue;
442 | }
443 | let progressStr = duration != null && progressPosition != 0 ? `处理进度:${(progressPosition / 1000).toFixed(2).padStart(7, ' ')} 秒(${(progressPosition / 1000 / duration * 100).toFixed(2).padStart(5, ' ')}%)` : '';
444 | let msg = progressStr;
445 | if (!process.stdin.isTTY) {
446 | console.log(msg);
447 | } else {
448 | process.stdout.write('\r' + msg);
449 | }
450 | }
451 | if (debug) {
452 | if (!process.stdin.isTTY) {
453 | console.log(chunk + '');
454 | } else {
455 | process.stdout.write(chunk);
456 | }
457 | }
458 | });
459 | });
460 |
461 | let haoshi = Date.now() - start;
462 | console.log('处理完毕,输出文件:', output_file);
463 | console.log('耗时:', haoshi / 1000, '秒');
464 | }
465 |
466 | function getTransition() {
467 | let len = transitions.length;
468 | let i = Math.floor(Math.random() * len);
469 | return transitions[i];
470 | }
471 |
472 | function getMediaFiles(process_path) {
473 | let imgs = [];
474 | let audio_file = null;
475 | let subtitle_file = null;
476 | let list = fs.readdirSync(process_path, { withFileTypes: true });
477 | list = list.filter(v => v.isFile());
478 | list.sort((a, b) => {
479 | return a.name > b.name ? 1 : -1;
480 | });
481 | console.log('文件列表:', list.length == 0 ? '无' : '');
482 | list.length > 0 && console.log(list.map(v => v.name).join('\n'));
483 | for (const f of list) {
484 | let fullpath = path.join(process_path, f.name);
485 | if (isImage(fullpath)) {
486 | imgs.push(fullpath);
487 | } else if (isAudio(fullpath)) {
488 | if (!audio_file) {
489 | audio_file = fullpath;
490 | }
491 | } else if (isSubtitle(fullpath)) {
492 | if (!subtitle_file) {
493 | subtitle_file = fullpath;
494 | }
495 | }
496 | }
497 | console.log('图片列表:', imgs.length == 0 ? '无' : '');
498 | imgs.length > 0 && console.log(imgs.join('\n'));
499 | console.log('音频文件:', audio_file || '无');
500 | console.log('字幕文件:', subtitle_file || '无');
501 | return {
502 | imgs, audio_file, subtitle_file
503 | }
504 | }
505 |
506 | const IMAGE = 'image';
507 | const AUDIO = 'audio';
508 | const SUBTITLE = 'subtitle';
509 | /**
510 | * @type {{image:[string],audio:[string],subtitle:[string]}}
511 | */
512 | const FileExt = {
513 | 'image': 'jpg jpeg png bmp webp'.split(' '),
514 | 'audio': 'mp3 aac wav flac wma ape'.split(' '),
515 | 'subtitle': 'lrc srt ass'.split(' '),
516 | }
517 | function isFileType(type, name) {
518 | return FileExt[type].includes(path.extname(name).toLowerCase().substr(1));
519 | }
520 | function isImage(name) {
521 | return isFileType(IMAGE, name);
522 | }
523 | function isAudio(name) {
524 | return isFileType(AUDIO, name);
525 | }
526 | function isSubtitle(name) {
527 | return isFileType(SUBTITLE, name);
528 | }
529 |
530 | function parseTransitions(args) {
531 | if (args.transitions != null) {
532 | let trans = Array.from(new Set(args.transitions.split(',').map(s => s.trim()).filter(i => i.length > 0)));
533 | let disable_buildin_transitions = !!args.disable_buildin_transitions;
534 | let new_trans = [];
535 | // 读取文件信息,文件存在./preset/xfade/ 下
536 | for (let trans_name of trans) {
537 | if (builtinTransitions.includes(trans_name)) {
538 | new_trans.push(trans_name);
539 | continue;
540 | }
541 | let trans_file = path.join(__dirname, 'preset', 'xfade', trans_name + '.txt');
542 | if (!fs.existsSync(trans_file)) {
543 | console.warn(`转场文件【${trans_file}】不存在。`);
544 | continue;
545 | } else {
546 | let lines = fs.readFileSync(trans_file).toString().replace(/\r/g, '').split('\n');
547 | // 移除lines中的空白行,并去除每行前后的空格。#开头的为注释行,也忽略
548 | lines = lines.filter(line => line.trim().length > 0 && !line.startsWith('#') && !line.startsWith('//')).map(line => line.trim());
549 | new_trans.push({
550 | name: trans_name,
551 | expr: lines.join(''),
552 | });
553 | }
554 | }
555 |
556 | if (new_trans.length == 0) {
557 | if (disable_buildin_transitions) {
558 | throw '禁用内置效果,但未指定转场效果。必须至少有一个转场效果';
559 | }
560 | } else {
561 | if (disable_buildin_transitions) {
562 | transitions = new_trans;
563 | } else {
564 | transitions = Array.from(new Set([...transitions, ...new_trans]));
565 | }
566 | }
567 | }
568 | }
569 |
570 | let debug = false;
571 |
572 | /**
573 | *
574 | * @param {{[x:string]:string}} args
575 | * @returns
576 | */
577 | async function start(args) {
578 | if (args == null) {
579 | args = parseArgs(process.argv.slice(2));
580 | }
581 | let input = args.i;
582 | if (input == null || !!args.h) {
583 | showCmdHelp();
584 | return;
585 | }
586 | if (!fs.existsSync(input)) {
587 | console.log('输入文件(夹)不存在', input);
588 | return;
589 | }
590 | debug = !!args.debug;
591 | console.log('启动【图片转视频】');
592 | console.log('处理目录:', input);
593 | let overwrite = !!args.y;
594 | let output_file = args.o || path.join(input, 'output.mp4');
595 | if (fs.existsSync(output_file) && !overwrite) {
596 | console.log('输出文件已存在', output_file);
597 | return;
598 | }
599 | console.log('输出视频:', output_file)
600 | let { imgs, audio_file, subtitle_file } = getMediaFiles(input);
601 | if (imgs.length == 0) {
602 | console.log(`目录下无图片文件【${FileExt.image}】。`);
603 | return;
604 | }
605 | let display = args.display || Display.Contain;
606 | if (Object.values(Display).includes(display) === false) {
607 | console.log('display参数值【', display, '】错误,将使用默认值“contain”');
608 | display = Display.Contain;
609 | }
610 | parseTransitions(args);
611 | return await run({
612 | output_file, imgs, audio_file, subtitle_file,
613 | width: parseNumber(args.width, 1920),
614 | height: parseNumber(args.height, null),
615 | fps: parseNumber(args.fps, 25),
616 | crf: parseNumber(args.crf, 23),
617 | video_codec: args['c:v'] || 'h264',
618 | audio_codec: args['c:a'] || 'aac',
619 | display: display,
620 | repeat: !!args.repeat,
621 | tranDuration: parseNumber(args.td, 4),
622 | showDuration: parseNumber(args.sd, 7),
623 | });
624 | }
625 |
626 | module.exports = { start }
627 |
628 | process.on('uncaughtException', (err) => {
629 | console.error(err);
630 | });
631 | process.on('unhandledRejection', (err) => {
632 | console.error(err);
633 | });
634 |
635 | if (process.argv[1] == __filename) {
636 | start();
637 | }
--------------------------------------------------------------------------------
/ffmpeg.subtitle.stack.js:
--------------------------------------------------------------------------------
1 |
2 | const fs = require('fs');
3 | const path = require('path');
4 | const child_process = require('child_process');
5 |
6 | let boolArgsKey = [
7 | 'y', 'h', 'v', 'debug',
8 | ]
9 | function parseArgs(args) {
10 | /*
11 | -name hello -t 1
12 | */
13 | let rs = {
14 | '_': []
15 | };
16 | let key = null;
17 | for (let i = 0; i < args.length; i++) {
18 | let v = args[i];
19 | if (v.startsWith('-')) {
20 | key = v.substring(1);
21 | if (boolArgsKey.includes(key)) {
22 | rs[key] = true;
23 | key = null;
24 | }
25 | } else {
26 | if (key != null) {
27 | rs[key] = v;
28 | key = null;
29 | } else {
30 | rs._.push(v);
31 | }
32 | }
33 | }
34 | return rs;
35 | }
36 |
37 | function parseNumber(str, defaultValue) {
38 | if (str != null) {
39 | let num = Number(str);
40 | if (!isNaN(num)) {
41 | return num;
42 | }
43 | }
44 | return defaultValue;
45 | }
46 |
47 | let ffmpegDurationReg = /Duration: (.+?), start: .+?, bitrate: .+/ig;
48 | function tryParseDuration(line) {
49 | let match = ffmpegDurationReg.exec(line);
50 | if (match != null) {
51 | //02:09:44.74
52 | return parseTimeString2ms(match[1]);
53 | }
54 | return null;
55 | }
56 |
57 | function parseTimeString2ms(timeStr) {
58 | try {
59 | // 将时间字符串拆分成小时、分钟、秒和毫秒
60 | const [hours, minutes, seconds] = timeStr.trim().split(':');
61 | // 转换成毫秒
62 | const totalMilliseconds =
63 | parseInt(hours) * 60 * 60 * 1000 +
64 | parseInt(minutes) * 60 * 1000 +
65 | parseFloat(seconds) * 1000;
66 | return totalMilliseconds;
67 | } catch {
68 |
69 | }
70 | }
71 |
72 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x
73 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig;
74 | function tryParseProgress(line) {
75 | let match = ffmpegProgressReg.exec(line);
76 | if (match != null) {
77 | return {
78 | frame: parseInt(match[1].trim()),
79 | fps: parseFloat(match[2].trim()),
80 | q: parseFloat(match[3].trim()),
81 | size: match[4].trim(),
82 | time: parseTimeString2ms(match[5].trim()),
83 | bitrate: match[6].trim(),
84 | speed: parseFloat(match[7].trim()),
85 | }
86 | }
87 | }
88 |
89 | function showCmdHelp() {
90 | let msg = `${process.argv.slice(0, 2).join(' ')} -i -t -font [-o