一堆图拼成视频,命令只有一行,真正要动脑的是「帧率从哪儿来」。 图片本身没有「每秒几张」这个概念,这个数必须由你告诉它。
2.3 是把视频拆成图,这一节是把图拼回视频,两条路正好互为逆操作。 2.5 讲播放——拼出来的片子总要有个东西放它。
一、图变成视频,缺的是什么
一张 PNG 里有什么:宽、高、每个像素的颜色。 缺的是什么:这一张要显示多久。
视频 = 画面序列 + 每张画面占多长时间。
图片序列只给了前半截,后半截必须由命令补上——这就是 -framerate。
输入是什么 帧率从哪来 常见坑
图片序列 必须写 -framerate 不写按 25 猜,常猜错
单张图片 配 -loop 1 再用 -t 定时长 不配只出一帧
已有视频 沿用源帧率 改了要重编码
第一行是最常见的坑:不写 -framerate,ffmpeg 会按 25 处理。
如果素材本来是 30 帧的动画,出来的片子就会比原速慢一点点。
第二行也有坑:单张图片当输入时,默认只产生一帧。
想让这张图撑满 10 秒,得写 -loop 1 -t 10——「循环取这一张,取 10 秒」。
二、走磁盘那一趟贵在哪
做批处理时经常有人问:为什么把帧存成 PNG 再读回来拼,比直接在内存里拼慢那么多?
因为「存成 PNG 再读回来」这一趟,实际付了四笔账:
账目 在干什么 能不能省
编码 PNG 把像素压成 PNG 字节流 能省 写文件 把字节流落到磁盘 能省 读文件 再从磁盘读回来 能省 解码 PNG 把 PNG 还原成像素 能省
四笔全是可以省的——只要帧不从磁盘上过。 中间文件唯一的作用是「换一个进程传数据」,同进程内根本不需要它。
但要注意:需要跨进程、跨机器、或者要留证据的时候,中间文件还是得存。 省的是「纯为了传数据而落盘」那一类,不是所有落盘。
三、编号必须连续
ffmpeg 认的是 %04d 这种格式串,它会从 0001 开始一个个往后找。
中间断了一个(比如缺了 0007),后面的全部读不到。
表现是「片子只有一半」或者「只有开头几秒」,而不报任何错。 这个坑很隐蔽,因为 ffmpeg 认为「找不到下一张就是结束了」,逻辑上没毛病。
所以批量导出序列帧之后,先数一遍张数再拼。
用 -start_number 可以把起点改掉,但改不了「中间断了」这件事。
四、动手 Demo:序列帧的代价
把同一批 25 帧编成 1 秒视频,走两条路:一条先存成 PNG 再读回来,一条帧直接在内存里过。 两条路的输出内容完全一样,差别只在中间有没有经过磁盘。
import os
import time
import av
from PIL import Image
# 先抽出 25 帧,同时留一份在内存里
frames = []
c = av.open(u"src.mp4")
for i, f in enumerate(c.decode(video=0)):
if i >= 25:
break
f.to_image().save(u"seq/%04d.png" % i)
frames.append(f)
c.close()
def enc(name, getter):
t0 = time.perf_counter()
oc = av.open(name, u"w")
s = oc.add_stream(u"libx264", rate=25)
s.width, s.height, s.pix_fmt = 160, 90, u"yuv420p"
for i in range(25):
for o in s.encode(getter(i)):
oc.mux(o)
for o in s.encode(None):
oc.mux(o)
oc.close()
return (time.perf_counter() - t0) * 1000
# 路线一:每帧从磁盘读 PNG 再解码
t1 = enc(u"disk.mp4", lambda i: av.VideoFrame.from_image(
Image.open(u"seq/%04d.png" % i)))
# 路线二:帧本来就在内存里
t2 = enc(u"mem.mp4", lambda i: frames[i])
print(u"走磁盘 %.1f 毫秒" % t1)
print(u"走内存 %.1f 毫秒" % t2)
print(u"慢 %.1f 倍" % (t1 / t2))
实跑输出:
--------------------------------------------------------------------
图片拼接成视频——序列帧的代价
--------------------------------------------------------------------
**① 把 25 张图读进来编成 1 秒视频,比比「走磁盘」和「走内存」**
路线 耗时(毫秒) 输出字节
---------------------------------------------------
存成 PNG 再读回来 36.7 3129
帧直接在内存里过 14.5 3151
**走磁盘那一趟慢了 2.5 倍,多花的全是「编码 PNG、写文件、读文件、解码 PNG」四笔账。**
**这就是为什么做批处理时,能不动磁盘就别动——序列帧这条路开销全在磁盘往返上。**
**② 序列帧的门槛不在编码,在于「帧率从哪儿来」**
输入是什么 帧率怎么定 坑在哪
-------------------------------------------------------------------------
图片序列 必须显式指定 -framerate 不指定会按 25 猜,常常猜错
单张图片 必须配 -loop 或 -t 否则只出一帧的片子
已有的视频 沿用源帧率 改了要重编
**第一行是最常见的坑:图片本身没有「每秒几张」这个概念,**
**所以必须由你告诉它。写错了的表现是「视频快得离谱」或者「慢得像幻灯片」。**
**③ 图片序列还有一个隐藏问题:编号必须连续**
**ffmpeg 认的是 %04d 这种格式串,它会从 0001 开始一个个找。**
**中间断了(比如缺了 0007),后面的全部读不到,片子就半截结束。**
**所以批量导出序列帧之后,先数一遍张数,再拼。**
两条路的 enc 是同一个函数,变的只有 getter——
一个从磁盘现读,一个直接拿内存里的帧。这样比出来的差异才干净。
走磁盘那一路慢出来的部分,正好对应前面那四笔账。 素材只有 160×90,PNG 才几百字节,所以四笔账加起来也只有几十毫秒。
分辨率一放大,这个差距就成比例放大: 1080p 一帧的 PNG 是几百 KB,编解码 PNG 本身就要几十毫秒,25 帧就是一两秒。
注意两份输出的字节数不完全一样,差了几十个字节。这不是 bug。 两条路的输入格式不同:走磁盘那条是 PIL 读出来的 RGB 帧,要转成 YUV 才编码; 走内存那条本来就是解码得到的 YUV 帧。颜色转换的路径不一样,编码器看到的输入就有细微差别。
30 秒实验
- 拼一次:
ffmpeg -framerate 25 -i seq/%04d.png -c:v libx264 out.mp4,再把-framerate改成 5 看效果。 - 单张图:
ffmpeg -loop 1 -i cover.png -t 10 -pix_fmt yuv420p out.mp4,做一张静态封面片。 - 断一次:故意删掉中间一张再拼,看它是不是静悄悄地只出了一半。
常见误区
- 不写
-framerate。图片没有天然帧率,ffmpeg 默认按 25 处理。素材本来是 30 帧的,出来的片子就会变慢。 - 单张图片不配
-loop。默认只产生一帧,成片只有一帧长。要定时长得写-loop 1 -t 秒数。 - 以为缺几张没关系。编号一断,后面的全部读不到,而且不报错。批量导完序列帧先数张数。
自测三问(点开对答案)
1. 为什么图片序列必须显式指定帧率? —— 因为 PNG 里只有画面没有时长。视频需要「每张占多久」这个信息,只能由命令补上,否则 ffmpeg 按 25 猜。 2. 「存成 PNG 再读回来」多付了哪四笔账? —— 编码 PNG、写文件、读文件、解码 PNG。四笔全是为了「换一个进程传数据」,同进程内完全不需要。 3. 序列帧中间缺了一张,ffmpeg 会报错吗? —— 不会。它顺着 `%04d` 往后找,找不到就当结束了,成片静悄悄地短了一截。所以要自己数张数。- ☐ (本节完,下一节 2.5 ffplay 视频播放)