🎬 第二章 FFmpeg命令行初体验
📄 一句话概括

一堆图拼成视频,命令只有一行,真正要动脑的是「帧率从哪儿来」。 图片本身没有「每秒几张」这个概念,这个数必须由你告诉它。

📝 本节和前面几节的关系

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 秒实验

  1. 拼一次:ffmpeg -framerate 25 -i seq/%04d.png -c:v libx264 out.mp4,再把 -framerate 改成 5 看效果。
  2. 单张图:ffmpeg -loop 1 -i cover.png -t 10 -pix_fmt yuv420p out.mp4,做一张静态封面片。
  3. 断一次:故意删掉中间一张再拼,看它是不是静悄悄地只出了一半。

常见误区

  1. 不写 -framerate。图片没有天然帧率,ffmpeg 默认按 25 处理。素材本来是 30 帧的,出来的片子就会变慢。
  2. 单张图片不配 -loop。默认只产生一帧,成片只有一帧长。要定时长得写 -loop 1 -t 秒数。
  3. 以为缺几张没关系。编号一断,后面的全部读不到,而且不报错。批量导完序列帧先数张数。
自测三问(点开对答案) 1. 为什么图片序列必须显式指定帧率? —— 因为 PNG 里只有画面没有时长。视频需要「每张占多久」这个信息,只能由命令补上,否则 ffmpeg 按 25 猜。 2. 「存成 PNG 再读回来」多付了哪四笔账? —— 编码 PNG、写文件、读文件、解码 PNG。四笔全是为了「换一个进程传数据」,同进程内完全不需要。 3. 序列帧中间缺了一张,ffmpeg 会报错吗? —— 不会。它顺着 `%04d` 往后找,找不到就当结束了,成片静悄悄地短了一截。所以要自己数张数。

相关笔记