其实没太想好FFmpeg这一篇博客放在哪个分类下面,后续应该不会开一个单独的音视频处理的分类,就接着上次的多路视频流采集继续写一些。

视频的基本概念

诚然,我对视频处理其实并不熟悉,因此需要对各个概念做些明确与区分,比如MP4是什么?H264又是什么?以及推流中的关键帧等概念,需要提前认识一下。

封装格式(快递箱子)

例如:.mp4.flv.mkv.avi。这些文件后缀名,只是一个外壳。它就像一个快递箱,里面装着“视频画面”和“音频声音”两样东西。有些箱子是透明的(支持网页直接播),有些箱子很结实(支持更多功能)。直播推流通常用 FLV 箱子,因为它传输快。

编码格式

例子:H.264H.265AACMP3,这里面混杂了视频与音频的编码方法。原始的视频画面是非常巨大的(几秒钟就能有几百兆)。为了塞进箱子传输,我们必须把它“压缩”。H.264:这是一种把视频画面“折叠/压缩”的标准(方法)。AAC:这是一种把声音“折叠/压缩”的标准。

注意,H.264H.265这俩有啥区别?

  • H.264 (也叫 AVC) —— 老大哥,最稳,目前世界上兼容性最好的视频编码标准。上到十几年前的诺基亚,下到最新的 iPhone,甚至是网页浏览器,都能直接播放它。因为兼容性好,推流基本上必须用 H.264,否则观众可能看不了。
  • H.265 (也叫 HEVC) —— 接班人,省流量,画质一样的情况下,它的体积只有 H.264 的一半!这意味着更省带宽。

推流中的关键概念(码率、GOP、Copy)

码率(Bitrate):水管的水流大小

参数:-b:v 2000k:每秒钟传送多少数据。

  • 太大:画质清晰,但如果观众网速不好,就会一直转圈缓冲(水管爆了)。
  • 太小:流畅,但是画面全是马赛克(水流太细)。

GOP (Group of Pictures) 与关键帧:翻书的完整页

参数:-g 60 视频压缩不是每一帧都存一张完整的照片。

  • I帧(关键帧):一张完整的照片。
  • P帧/B帧:只记录相对于上一张照片“哪里变了”。
    直播痛点:如果你把关键帧间隔设得太长(比如 10秒一个 I帧),新进来的观众必须等到下一个 I帧来了才能看到画面,这导致他进来可能会黑屏好几秒。直播通常要求 2秒一个 I帧。如果帧率是 30,那就设 -g 60

Copy 模式:直接换个箱子

参数:-c copy (或 -c:v copy -c:a copy)
解释:这是 FFmpeg 最神的一个命令。
场景:假设你有一个 .mp4 视频,里面的视频本来就是 H.264,音频本来就是 AAC。你想把它推流到服务器。
做法:你不需要让 libx264 重新把衣服折叠一遍(不需要重新编码)。你只需要把 .mp4 盒子拆开,直接把里面的流拿出来,塞进 .flv 的盒子里发走。
好处:极快!CPU 占用几乎为 0! 画质完全无损。

小疑问

这里其实会有一个神奇的问题,我个人的疑问:是不是固定了FPS、画面宽高(分辨率),是不是就能确定码率?确定了码率,是不是能决定实际观看中的FPS?

对于1秒的直播数据量(RAW),我们大约估算为:分辨率*颜色深度*FPS,这个数据量是比较大的,因此我们采用了压缩算法,那么压缩后的数据量有多少?这个数据的预算为:码率。(注意,我们已经锚定了时间窗:1秒)

理想的状态是什么?码率足够!编解码之后对画面的还原度高,也流畅!

但如果码率设置低会发生什么?全力保障帧率与流畅性,牺牲画面质量,即便是分辨率为1080,我画成简笔画,他用的数据量就会少(时刻注意,我们讨论的画面是压缩后的,并不是原始的图像理论数据量)

老板让我每秒画30张蒙娜丽莎,但是给我的笔只够画成15张,但是数量不能少,所以我只能把每一张都画得很潦草,最后保证交付30张

但有没有一种可能?30张是真画不完?这时候ffmpeg要求30帧,而CPU撑死渲染了20帧,此时不能赖数据量不够大,不能说是画师颜料配发少了,而是画的本来就慢,会发生丢帧

FFmpeg 推流的基本命令结构

一条标准的推流命令通常包含以下几个部分:

1
ffmpeg [全局参数] [输入文件参数] -i [输入地址] [视频编码参数] [音频编码参数] [输出封装格式] [推流地址]

最简单的推流示例(直接复制流,不重新编码):

1
ffmpeg -re -i local_video.mp4 -c copy -f flv rtmp://server/live/streamName

核心参数详解

通用与输入参数

  • -i filename:指定输入文件或设备地址(可以是本地文件、摄像头、RTSP流等)。
  • -re(Read native): 表示按本地帧率读取数据。推本地文件(如 .mp4)时必须加,否则 FFmpeg 会以最快速度读取文件并发送,导致流媒体服务器瞬间由于数据量过大而崩盘。如果是推摄像头或实时流(RTSP),通常不要加这个参数,因为源本身就是实时的。
  • -y:若输出文件已存在,直接覆盖(在推流场景下用的少,录制时用得多)。
  • -stream_loop -1:无限循环输入文件(放在 -i 之前)。

视频编码参数 (Video)

  • -c:v-vcodec:指定视频编码器。
    • copy: 最常用。直接复制视频流,不进行重新编码(CPU占用极低,画质无损,速度快)。
    • libx264: 软件编码 H.264(通用性最好,但吃 CPU)。
    • h264_nvenc: NVIDIA 显卡硬件加速编码 H.264。
    • libx265: H.265 编码。
  • -b:v 2000k: 设置视频平均码率为 2000kbps。控制画质和带宽占用的核心参数。
  • -maxrate 2500k: 设置视频最大码率。
  • -bufsize 5000k: 设置码率控制缓冲区大小。通常设置为 maxrate 的 2 倍,用于平滑码率波动。
  • -r 30: 强制设置帧率为 30 fps。
  • -s 1920x1080: 设置分辨率(Scale)。
  • -g 60(GOP size): 关键参数。设置关键帧间隔(Group of Pictures)。直播通常要求 2秒一个关键帧。如果帧率是 30fps,建议 -g 60。如果不设置,进入直播间可能首屏加载很慢。
  • -pix_fmt yuv420p: 设置像素格式。为了兼容各种播放器,建议强制指定为 yuv420p
  • -preset(仅限 libx264): 编码速度预设。
    • 可选值:ultrafast, superfast, veryfast, faster, fast, medium (默认), slow
  • -tune zerolatency(仅限 libx264): 优化编码参数以实现零延迟,直播必备。

音频编码参数 (Audio)

  • -c:a-acodec: 指定音频编码器。
    • aac: 最通用的直播音频编码。
    • copy: 不转码,直接复制。
  • -b:a 128k: 音频码率,通常 128k 或 64k 足够人声使用。
  • -ar 44100: 音频采样率,通常为 44100Hz 或 48000Hz。
  • -ac 2: 声道数,2 代表立体声。
  • -an: 去除音频(静音推流)。

输出与封装参数

  • -f flv: 强制输出格式为 FLV。RTMP 推流必须加。
  • -f mpegts: UDP/RTP 推流常用格式。
  • -f rtsp: 推送 RTSP 流。

实践时的一些问题

最近做yolox的端侧推理的时候,MediaMTX有时会报写队列满的警告,在此处复盘分析一下:

在之前的博客中我曾使用ffmpeg推一个mp4视频,模拟监控设备的RSTP流,bash脚本为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# MediaMTX 服务器地址
RTSP_URL="rtsp://localhost:8554"
# 视频目录
VIDEO_DIR="/mnt/e/Linux/testvideo"
# 建议:先杀掉旧的 ffmpeg 进程,防止端口占用或重复推流
pkill -f "ffmpeg -re"
# 遍历视频目录中的所有 mp4 文件
for VIDEO_FILE in "$VIDEO_DIR"/*.mp4; do
if [ -f "$VIDEO_FILE" ]; then
# 获取文件名并替换所有非法字符
STREAM_NAME=$(basename "$VIDEO_FILE" .mp4 | sed -e 's/[^A-Za-z0-9._-]/_/g')
# 启动 ffmpeg 推流 (静音版)
ffmpeg \
-re \
-stream_loop -1 \
-i "$VIDEO_FILE" \
-c:v copy \
-an \
-f rtsp \
-rtsp_transport tcp \
"$RTSP_URL/$STREAM_NAME" \
> /dev/null 2>&1 &

echo "已启动推流(无音频): $VIDEO_FILE -> $RTSP_URL/$STREAM_NAME"
fi
done
echo "所有推流已启动!请等待 5-10 秒后访问 HLS 地址。"

首先过一遍命令,采用copy的方式,推流到MediaMTX服务器,其中有一个之前没有提到的命令:-rtsp_transport tcp,他保证了mp4文件到MediaMTX的过程是满足TCP的,如果这部分采用了udp,虽然RSTP协议本身甚至默认采用UDP发送数据包,摄像头像个机关枪,向ffmpeg发送,但有的时候会因为网络问题导致丢包,如果丢弃了关键帧,可能要等更长时间,花屏更久。加了TCP,至少不会出现丢包。

但是这个参数与我实际的场景还是稍微有一点点不同的,我们分上下行来说:

上行(推流)

路径:本地文件 -> FFmpeg -> MediaMTX
参数作用:-rtsp_transport tcp FFmpeg 会跟 MediaMTX 握手说:“老兄,我要给你发视频,用 TCP 协议,别丢包。” MediaMTX 同意了。

下行(拉流)

路径:MediaMTX -> C++ 程序
参数作用:

  • FFmpeg 脚本里的参数跟这里毫无关系。
  • 这一段是用 TCP 还是 UDP,完全取决于你的 C++ 代码是怎么写的。
  • 如果在 OpenCV 里没指定,默认通常是 UDP(也就是那个容易花屏的模式)。
  • 但是! 既然 MediaMTX 报了 Write Queue Full,这就反向证明了你的 C++ 程序实际上正在使用 TCP 连接。因为只有 TCP 才会让服务器“排队等待”,UDP 服务器早就直接扔掉了,根本不会排队。

原因分析

两段路拼起来,看看“写队列满”是怎么发生的:

  • 供货商(FFmpeg):通过 TCP,源源不断、保质保量地把货(视频帧)运到了仓库(MediaMTX)
  • 仓库(MediaMTX):因为是 TCP,供货商发多少,我就得收多少,不能拒收。
  • 取货人(C++):也在用 TCP 跟仓库连着。
    • 取货人虽然连着线,但是取货动作特别慢(read 慢,解码慢)。
    • 取货人通过 TCP 告诉仓库:“等等!我处理不过来了,别发了!”

灾难发生:

  • 仓库夹在中间。
  • 左边进货停不下来(FFmpeg 还在推)。
  • 右边出货被堵住了(C++ 不要)。
  • 仓库里的货越堆越高(Write Queue 变大)。
  • 最后仓库爆仓了 -> Write Queue Full -> 断开 C++ 的连接。

根本原因:C++ 消费速度 < FFmpeg 生产速度。

再来看看C++的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
void InferenceWorker::captureLoop(const std::string& streamId) {


StreamInfo* stream = nullptr;

{
std::shared_lock lock(streamsMutex_);
auto it = streams_.find(streamId);
if (it == streams_.end()) return;
stream = it->second.get();
}

cv::Mat frame;
const int max_queue_size = config_.maxQueueSize;

while (stream->state == StreamState::RUNNING && !shouldStop_ && !stream->shouldStop.load()) {
if(stream->capture.read(frame)) {


auto capture_time = std::chrono::steady_clock::now();
++stream->framesCaptured;

std::lock_guard<std::mutex>lock(queueMutex_);

if(frameQueue_.size() >= max_queue_size) {
std::queue<FrameData> temp_queue;
bool dropped = false;

while (!frameQueue_.empty()) {
auto& front = frameQueue_.front();

if(!dropped && front.streamId == stream->id) {
dropped = true;
++stream->framesDropped;
} else {
temp_queue.push(std::move(front));
}
frameQueue_.pop();
}
frameQueue_ = std::move(temp_queue);
}

frameQueue_.push({frame.clone(), streamId, capture_time});
queueCV_.notify_one();
} else {
std::cerr << "Stream " << streamId << " disconnected, attempting reconnect..." << std::endl;
stream->capture.release();
std::this_thread::sleep_for(std::chrono::seconds(1));
stream->capture.open(stream->url, cv::CAP_FFMPEG);
stream->capture.set(cv::CAP_PROP_BUFFERSIZE, 1);
}
}
}

存在以下性能隐患:

  • 全局锁与 O(N)O(N) 复杂度的丢帧逻辑
  • frame.clone() 的深拷贝成本
  • stream->capture.read(frame) 的隐性阻塞(是同步阻塞的,并且包含软解码。)