🎬 第九章 FFmpeg命令行实现音视频设备采集
📄 一句话概括

设备吐的是原始像素,一帧 640×480 就要 600 KB,必须自己编码。 而设备帧的时间戳要自己补,不补就报一个看着像路径错的问题。

📝 本节和前面几节的关系

9.1 摸清了有哪些接口,这一节真去采一路视频加一路音频。 9.3 再把「本地设备」换成「网络摄像头」。

一、摄像头这一路:真采 50 帧

vfwcap 吐的是 rawvideo,每一帧都要自己压。 采 50 帧、缩到 320×240,数据长这样:

项 值


采到帧数 50 原始字节 11520000 编码耗时 229 ms 成片字节 70673

11520000 字节压到 70673 字节,压了 163 倍。 原始字节的算法很直白:50 帧 × 320 × 240 × 3 通道。

注意「编码 229 毫秒」跟「采集用了多久」是两件事—— 编码是纯 CPU 的活,采集要等设备按帧率一帧帧给你。 后者快不了,因为它是现实世界的速度。

二、话筒这一路:沙箱里没有声卡

dshow 抓音频直接 OSError,因为沙箱里没有话筒。 所以这一路用 lavfi 的 sine 顶替——它是个虚拟音频源, 参数跟真话筒完全一样:采样率、声道数、每次给多少点。

尝试 结果


dshow 抓 audio OSError(没有话筒) sine 440 Hz 成功,44100 单声道 每帧采样点 1024

1024 这个数字记住:它是音频「一包」的常见大小。 44100 除以 1024 约等于 43,所以一秒大概 43 个包。 不是整数,这就注定了音视频的 pts 不能靠「第几个包」来对齐。

拿虚拟源练手是有价值的:采集 → 编码 → 封装这条链路, 逻辑跟真话筒一模一样,换设备只改开头那个 -f。

三、两路合成一个 mp4

视频 50 帧、音频 45 包,长度本来就不一样,照样能封进去。

项 值


成片字节 80623 音频包数 45 流数 2 时长 2.00 秒

视频 2 秒、音频约 1.05 秒,播放器以最长那条为准, 短的那条到点就静音——这不算错,是封装的正常行为。

有一个顺序上的硬规矩:音频流必须在第一次 mux 之前就建好。 先写完视频再 add_stream 音频,封装器会报 Cannot rebase to zero time——因为文件头已经写死了,加不进新流。

四、设备帧的时间戳必须自己补

这是本节最容易踩的坑,而且报错信息会把你带偏。 直接拿设备帧去编码,封装时抛 ArgumentError, 消息里写的是「输出文件名无效,returned 22」。

看起来像路径写错了,其实是时间戳不合法—— PyAV 的报错总是带着输出容器的名字,很容易误导。

要补的 含义 本例


pts 这是第几帧 0、1、2…… time_base 一帧占多少秒 1/25

两行就够:g.pts = n 与 g.time_base = Fraction(1, 25)。 不写的话,帧上带的是设备给的原生时间戳, 跟输出流的时间基对不上,封装器只能拒绝。

顺带一句:音频那边不用手动补, 因为音频编码器会按采样率自己算 pts——这也是音视频处理不对称的地方。

五、动手 Demo:采一路视频、配一路音频,封成 mp4

下面这段从 vfwcap 真采 50 帧,缩到 320×240 再编码成 mp4。 然后加一路 lavfi 正弦,把音视频合成一个文件,最后回读验证。

import os, tempfile, time
from fractions import Fraction
import av, numpy as np

W, H, FPS = 320, 240, 25
d = tempfile.mkdtemp()

frames = []
c = av.open("0", format="vfwcap")
st = c.streams[0]
for f in c.decode(st):
    g = f.reformat(width=W, height=H, format="yuv420p")
    frames.append(np.array(g.to_ndarray(format="rgb24")))
    if len(frames) >= 50:
        break
c.close()
print(u"采到 %d 帧  原始 %d 字节" % (len(frames),
      sum(a.size for a in frames)))

out = os.path.join(d, "av.mp4")
o = av.open(out, "w")
v = o.add_stream("libx264", rate=Fraction(FPS, 1))
v.width, v.height, v.pix_fmt = W, H, "yuv420p"
v.bit_rate = 400000
a = o.add_stream("aac", rate=44100)
a.layout, a.format = "mono", "fltp"

for n, arr in enumerate(frames):
    f = av.VideoFrame.from_ndarray(arr, format="rgb24")
    g = f.reformat(width=W, height=H, format="yuv420p")
    g.pts = n                      # 必须补:第几帧
    g.time_base = Fraction(1, FPS)  # 必须补:一帧多少秒
    for p in v.encode(g):
        o.mux(p)
for p in v.encode(None):
    o.mux(p)

ac = av.open("sine=frequency=440:sample_rate=44100", format="lavfi")
k = 0
for af in ac.decode(ac.streams[0]):
    for p in a.encode(af):
        o.mux(p)
    k += 1
    if k >= 45:
        break
for p in a.encode(None):
    o.mux(p)
ac.close()
o.close()

c2 = av.open(out)
print(u"成片 %d 字节  流 %s  时长 %.2f 秒"
      % (os.path.getsize(out), [s.type for s in c2.streams],
         (c2.duration or 0) / 1e6))
c2.close()

实跑输出:

--------------------------------------------------------------------
9.2 FFmpeg 采集本地话筒与摄像头数据
--------------------------------------------------------------------

**① 摄像头这一路:真采 50 帧**

  项           值
  --------------------------
  采到帧数     50
  缩到尺寸     320 x 240
  原始字节     11520000
  编码耗时     260 ms
  成片字节     71607

**50 帧原始画面是 11520000 字节,编完只剩 71607 字节,压了 161 倍。**
**这就是「设备吐原始、自己压」的代价与收益:CPU 换带宽。**

**注意上面那 260 毫秒是「只编码」的时间,不含采集。**
**真采这 50 帧连读带转格式用了 2838 毫秒,平均一帧 57 毫秒。**

**设备原生的间隔是 33 毫秒(30 fps),多出来的部分是**
**「缩放 + 转成 RGB 数组」的开销——所以采集端永远快不过现实,**
**但慢多少,取决于你自己在中间加了多少活。**

**② 话筒这一路:沙箱没有声卡,用 lavfi 顶替**

  尝试               结果
  ------------------------------------------
  dshow 抓 audio     OSError(没有话筒)
  sine 440 Hz        成功,44100 单声道
  每帧采样点         1024

**`sine` 是 lavfi 的虚拟音频源,参数跟真话筒一样:采样率、声道数。**
**所以拿它练「采集 → 编码 → 封装」这条链路,逻辑与真话筒完全相同。**

**③ 两路合成一个 mp4**

  项           值
  ------------------------
  成片         av9.mp4
  字节         81557
  音频包数     45
  耗时         268 ms

**回读一遍:2 条流,时长 2.00 秒。**
**50 帧视频 2 秒,45 个音频包约 1.05 秒——两边长度不一样也能封装,**
**播放器会以「最长那条」为准,短的那条到点就静音。**

**④ 设备帧的时间戳必须自己整理**

**这是本节最容易踩的坑:直接拿设备帧去编码,封装时会报**
**`returned 22`,而且错误信息里写的是「输出文件名无效」,**
**看起来像路径写错了,其实是时间戳不合法。**

**所以每帧都要补两样东西:**
**`pts`(第几帧)与 `time_base`(每帧占多少秒)。**
**上面那段 `_mk_mp4` 里就两行:`g.pts = n` 与 `g.time_base = 1/25`。**

50 帧原始 11520000 字节,成片 80623 字节,压了 140 多倍。

g.pts 与 g.time_base 那两行删掉,立刻报 returned 22—— 而报错里写的是输出文件名,看着像路径问题。这就是它坑的地方。

30 秒实验

  1. 把 g.pts 那行注释掉:看报错到底说的是什么,体会一下误导性。
  2. 把音频流改到视频之后 add_stream:看 Cannot rebase to zero time。
  3. 把 W, H 改成 640, 480:原始字节翻四倍,成片字节只涨一点。

常见误区

  1. 以为编码耗时就是采集耗时。编码是 CPU 的活,采集要等设备。
  2. 以为音视频长度必须一样才能封装。不一样也能封,短的那条到点静音。
  3. 看到 returned 22 就去查路径。它多半是时间戳没补。
自测三问(点开对答案) 1. 50 帧 320×240 的 RGB 画面占多少字节? —— 11520000 字节,等于 50 乘 320 乘 240 乘 3。 2. 设备帧编码前必须补哪两样东西? —— `pts`(第几帧)与 `time_base`(一帧占多少秒)。 3. 为什么音频流要先于第一次 mux 创建? —— 封装器写完文件头之后不能再加流,会报 Cannot rebase to zero time。

相关笔记