🎬 第十二章 各种开发语言调用FFmpeg命令行
📄 一句话概括

QProcess 把「拼引号」和「接管道」都包好了,只留下一件事要你自己做: 两路输出都得读,读的时候别把界面冻住。

📝 本节和前面几节的关系

上一节的 CreateProcess 是裸接口,这一节的 QProcess 是包装过的。 包装省掉了拼串,但没省掉管道那 4 KB——坑还在,只是换了个样子。

一、QProcess 替你做了两件事

QProcess::start(program, arguments) 这个重载接受 QStringList。 它内部按平台规则拼命令行,所以你永远不用碰引号。 这是它比 system() 与裸 CreateProcess 都省事的地方。

第二件事是管管道:它把 stdout 与 stderr 各接一根, 再用信号告诉你「有新数据了」。 于是「边读边等」这件事,从手写循环变成了连一个信号。

但要注意:它只是给了你两根管子,读不读还是你的事。 这一点在后面会变成最常见的一种卡死。

二、只连 stdout,等于没连

ffmpeg 的进度与日志全在 stderr 上——这是第二章就定下的规矩。 而 Qt 默认把两路分开,只连 readyReadStandardOutput 的人, stdout 收得干干净净,stderr 一根字节都没人读。

没人读的管道就是一只不排水的桶。 ffmpeg 往里写,写满 4 KB 就写不动了,于是它停住; 你的程序在 waitForFinished() 上等它退出,于是你也停住。 界面上什么都不显示,任务管理器里两个进程都活着,CPU 几乎为零—— 这就是死锁的样子。

本机实测那条管子的容量:写 4096 字节能出来,写 4608 字节就卡住。 换个说法:ffmpeg 打十几行 frame= ... 进度,就够把管子填满了。

三、三条正路,按场合选

① 两路都连信号:两个 readyRead 信号都要连上。 各自 readAll() 追加到日志,这是唯一能实时显示进度的写法。

② 合并成一路:加 setProcessChannelMode(MergedChannels)。 stderr 会并进 stdout,只需连一个信号。 代价是两路分不开了——但你要的本来就是一整段日志。

③ 阻塞式读:while (waitForReadyRead(100)) 循环。 边读边等,直到进程结束。 适合后台任务,不适合带界面的场景——因为它会占住事件循环。

三条路的共同点是「读」和「等」交织在一起。 错的那条路只有一个特征:先等后读。

四、进度怎么解析

ffmpeg 的进度行是用回车覆盖的:frame= 120 fps= 30 q=28.0 size= ..., 每更新一次就在同一行重写。 所以拿到的不是一行一行的文本,而是一串带 \r 的碎片。 按 \n 切行会切不出东西,得按 \r 切。

更稳的做法是让 ffmpeg 输出结构化进度:加 -progress pipe:1 -nostats, 它会按固定格式打 out_time_ms=...、frame=...,一行一个键值对。 这比解析人类可读的进度行可靠一个数量级。

五、取消:先礼后兵

Qt 里停一个子进程有两个方法:terminate() 是「请你自己退出」,kill() 是「立刻干掉」。 ffmpeg 收到优雅退出请求时会把文件尾写完整,直接杀掉则可能留下半截 mp4。

所以顺序应该是:terminate() → 等两秒 → 还在就 kill()。 对应到命令行,也可以让 ffmpeg 自己去监听 stdin 的 q—— 但那需要你给它一根可写的 stdin,而且要注意 -nostdin 会关掉这条路。

六、一句总结

QProcess 把「怎么起进程」这件事做完了, 「怎么读、什么时候等、怎么停」还是你的活。 而这三件事在任何 GUI 框架里都一样: 读到的东西要及时取走,等待要放在不冻界面的地方,停止要留出善后的时间。

七、动手 Demo:只等不读,到底会卡在哪一秒

让子程序往 stdout 写 300 KB,父进程只等不读。 再用同样的数据量改成「边读边等」,两行结果对照着看。

import subprocess, sys, time

PY = sys.executable
BIG = "import sys; sys.stdout.write('x' * 300000); sys.stdout.flush()"

p = subprocess.Popen([PY, "-c", BIG],
                     stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
    p.wait(timeout=2)
    print("只 wait:居然出来了")
except subprocess.TimeoutExpired:
    print("只 wait:卡住")
    p.kill(); p.wait()

for n in (4096, 4608):
    q = subprocess.Popen(
        [PY, "-c", "import sys; sys.stdout.write('x'*%d)" % n],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    try:
        q.wait(timeout=1.5)
        print("%d 字节:出来了" % n)
    except subprocess.TimeoutExpired:
        print("%d 字节:卡住" % n)
        q.kill(); q.wait()

实跑输出:

--------------------------------------------------------------------
12.1.3 Qt 调用 FFmpeg 命令行
--------------------------------------------------------------------
**① 只等不读,会永久卡住——这是 Qt 里最常见的死法**

**让子程序往 stdout 写 300 KB,父进程只 `wait()`、一个字节都不读:**

  父进程怎么做的            结果                      收到多少字节
  ------------------------------------------------------------------
  只 wait,不读             卡住,3 秒还没结束        0
  边读边等(communicate)   正常结束,656 毫秒        300000

**这不是死循环,是【互相等】:子进程等管道腾地方,父进程等子进程退出。**
**两边都不肯先动,就永远停在那儿。**
**在 Qt 里表现成 `waitForFinished()` 一直不返回,界面整个冻住,**
**用户以为程序挂了,其实它在老老实实等。**

**② 管道到底能存多少?测一下就知道**

**同样的写法,只改子进程写的字节数,二分一下临界点:**

  子进程写多少      只 wait 会怎样
  ------------------------------------------------
  1024 字节         正常结束
  4096 字节         正常结束
  4608 字节         卡住
  8192 字节         卡住
  65536 字节        卡住

**4096 出得去,4608 就卡住——管道缓冲的大小在【4 KB 到 4.5 KB】之间。**
**这个数看着小得离谱,但它是操作系统给匿名管道配的默认大小,**
**不是谁写错了。**
**换句话说:ffmpeg 只要往 stderr 打十几行进度、往 stdout 吐一点数据,**
**攒够 4 KB,你不读,它就得停。**

**③ Qt 里的三条正路**

  做法              怎么写                                    适合什么场合
  --------------------------------------------------------------------------------------
  两路都连信号      connect(readyReadStandardOutput…)         要实时显示进度
  合并成一路        setProcessChannelMode(MergedChannels)     只要日志、不分流
  阻塞式读          waitForReadyRead 循环                     后台任务,不要界面

**第一条的要点是【两个信号都要连】:只连 stdout、不管 stderr,**
**ffmpeg 把进度全打在 stderr 上,照样 4 KB 就塞满。**
**第二章那句「ffmpeg 的进度走 stderr」在这里变成了真金白银的 bug。**

**④ 对应的 C++ 骨架(本机没有 Qt,这段是写给你抄的)**

    QProcess p;
    p.setProcessChannelMode(QProcess::MergedChannels);  // 两路合一,最省事
    p.start("ffmpeg", args);          // args 是 QStringList,空格天然安全
    if (!p.waitForStarted(5000)) { /* 起不来 */ }
    while (p.waitForReadyRead(100)) {   // 边读边等,绝不裸 wait
        log += p.readAll();
    }
    p.waitForFinished(-1);
    int rc = p.exitCode();              // 0 与非 0

**注意 `p.start("ffmpeg", args)` 这个重载:它接受 `QStringList`,**
**内部自己拼命令行——所以你不需要管引号,这正是「用库」比「用 system」省事的地方。**
**而 `while (waitForReadyRead)` 那三行,就是防死锁的全部秘密。**

第一行卡住,第二、三行把临界点夹出来了:4096 出得去,4608 就卡。 所以在 Qt 里,ffmpeg 只要打十几行进度,waitForFinished() 就能一直不返回。

这不是 Qt 的 bug,是管道的容量;换成 Java、Python,数字一个字节都不变。

30 秒实验

  1. 把 stderr=subprocess.PIPE 改成 stderr=subprocess.STDOUT:看还卡不卡。
  2. 把子程序改成先写 4 KB 再睡 3 秒:看「边读边等」能不能提前处理。
  3. 给 ffmpeg 加 -progress pipe:1 -nostats:看进度格式变了什么。

常见误区

  1. 只连 readyReadStandardOutput。ffmpeg 的日志全在 stderr,那根管子永远没人读。
  2. 在 UI 线程里 waitForFinished()。界面直接冻住,用户以为程序死了。
  3. 直接 kill() 取消。mp4 的尾没写完,文件成了坏的。
自测三问(点开对答案) 1. QProcess 省掉了哪一步? —— 拼命令行(引号)。它接受 QStringList,按平台规则自己拼。 2. 管道满了以后,子进程为什么会停? —— 写操作阻塞到有空间为止。没人读就永远没空间。 3. 为什么优先用 `-progress pipe:1`? —— 它输出结构化的键值对,不用解析带 `\r` 的人类可读进度行。

相关笔记