ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FFmpeg+WebSocket+JSMpeg:轻量低延迟视频方案全解析

FFmpeg+WebSocket+JSMpeg:轻量低延迟视频方案全解析

做低延迟视频方案的时候,很多人的第一反应是上 WebRTC,但真要落地时往往被信令、网关、服务端开发折腾得够呛。我个人这些年用得最顺手、也最省心的一套组合,反而是 FFmpeg + WebSocket + JSMpeg 这套偏“轻量”的方案——编码用 FFmpeg,传输走 WebSocket,解码渲染交给 JSMpeg,整条链路跑下来,延迟能压到 1 秒以内,局域网环境甚至能做到 200~500 毫秒。这个方案的核心思路,就是绕开 HLS 的分片打包、绕开播放器的缓冲策略,用最直接的二进制帧把视频从采集端送到浏览器里的 Canvas 上。这篇文章是系列第一篇总览,面向的是想快速做出一套可用系统、又不希望一头扎进复杂流媒体协议里的开发者,先帮你把整条链路怎么搭、每一步解决什么问题讲清楚。

1. 整体架构与设计思路

1.1 三个组件各管哪一段

很多人第一次看到 FFmpeg、WebSocket、JSMpeg 这三个词堆在一起会有点懵,其实它们分工非常明确:采集端设备把画面交给 FFmpeg,FFmpeg 负责把原始视频压缩成适合网络传输的 MPEG1 或 MJPEG 编码流;然后通过 WebSocket 协议把编码后的字节流源源不断地推给浏览器端;浏览器端由 JSMpeg 这个 JavaScript 写的解码器负责解析,最终把画面画到 Canvas 上。整个过程没有后端的转码服务器,也没有需要部署的播放器插件。

我在实际项目里经常把服务端这一层单独拎出来做 WebSocket 转发,因为采集端不一定直接把流推到浏览器——比如摄像头在采集端机房,浏览器分布在各个办公室,这时候一个轻量 WebSocket 服务端做中转就很有必要。它做的事情很简单:接收 FFmpeg 推上来的二进制数据,再分发给所有已连接的浏览器客户端。抽象成一句话就是“生产者-分发者-消费者”三端模型。

这套架构最舒服的地方在于它的解耦程度:FFmpeg 只负责采流编码,WebSocket 服务端只负责转发,JSMpeg 只负责播放。哪个环节出问题就单独排查哪个环节,替换也方便。我后来在另一个项目里把 FFmpeg 换成了硬件编码器,JSMpeg 端完全没动,这就是分层的价值。

1.2 低延迟从哪里来:数据链路对比

传统网页直播方案走到浏览器里,绕不开 HLS 或 HTTP-FLV。HLS 的核心问题是“切分—拉取—拼接”这套机制:服务端把视频切成 2~6 秒一段的小文件,客户端再一小段一小段去拉取播放。哪怕把切片切得很小、留很短的窗口,算上播放器的缓冲水位,端到端延迟稳稳落在 5~30 秒这个区间,对于远程控制摄像头、无人机图传这种场景来说,等看到画面确认完状态再做动作,黄花菜都凉了。

这套三件套方案把延迟压缩的关键,是去掉了三种“缓冲”:

  • 编码缓冲:FFmpeg 用-tune zerolatency和low_delay相关参数,让编码器不要为了追求压缩率而积攒多帧再输出;
  • 传输缓冲:WebSocket 是全双工长连接,二进制帧直接顺带发送,不像 HTTP 每帧请求都要带一堆头部字段和握手开销;
  • 播放缓冲:JSMpeg 默认不维护大的帧队列,来一帧解码一帧,画面即时渲染到 Canvas。

延迟的本质就是链路上所有排队和缓冲时间之和。这套方案把能砍掉的排队点都砍了,剩下的就只有网络物理传输时间再加解码耗时,自然就能做到亚秒级。

1.3 为什么不是 WebRTC,也不是 HLS

有人一定会问:WebRTC 不是更专业吗?没错,WebRTC 是当前延迟最低的浏览器实时方案,理想状态下能到 100~300 毫秒,但它要处理 STUN/TURN 打洞、ICE 协商、信令服务、音视频引擎的适配,服务端的压力和客户端的兼容性坑都更多。如果项目只是要“看到实时画面,操作延迟不太离谱”,上 WebRTC 有种杀鸡用牛刀的感觉。

HLS 和 HTTP-FLV 则是另一个极端:虽然部署成熟、CDN 链路完善,但前者延迟太高,后者依赖浏览器对 FLV 的解析能力,还需要额外引入 flv.js 并维护 MSE 缓冲逻辑。我做过一个对比测试,拿同一路 RTSP 摄像头信号分别做 HLS 和 FFmpeg+WebSocket 推流,在不优化参数的前提下,HLS 显示延迟为 8~14 秒,三件套方案在局域网内约为 0.4~0.8 秒。这个数量级的差距,直接决定了远程看护或者机器人操控场景到底能不能用。

下表是我自己做技术选型时常用的对比维度:

方案核心协议端到端延迟服务端复杂度适合场景
HLSHTTP+TS 分片5~30 秒低(静态切片分发)大规模直播、点播
HTTP-FLVHTTP 长连接1~4 秒中直播平台、弹幕交互
WebRTCSRTP/UDP0.1~0.5 秒高(信令+网关)音视频会议、双向互动
FFmpeg+WebSocket+JSMpegWebSocket 二进制帧0.2~1 秒低监控、无人值守、单向低延迟查看

2. FFmpeg、WebSocket、JSMpeg 三个核心组件的关键原理

2.1 FFmpeg 编码侧:为什么默认用 MPEG1 而不用 H.264

FFmpeg 支持的编码格式一大堆,但在 JSMpeg 面前,选项其实被限制得比较死。JSMpeg 的解码核心是用 JavaScript 实现的 MPEG1 视频解码器,这意味着它只能处理 MPEG1 或者 MJPEG 这两种格式。为什么不把 H.264 直接拿到浏览器里让 JSMpeg 解?因为 H.264 的完整解码复杂度极高,纯 JS 实现跑不动,浏览器内置的 H.264 解码能力又不存在开放给普通 JavaScript 调用的通用接口。用 WebCodecs 是个出路,但那已经是另一个方案的范畴,不在这个系列的总览范围里。

所以在 FFmpeg 侧,实际上只需要搞清楚两件事:输出格式选mpeg1video或mjpeg。两者的取舍是这样的:MJPEG 是把每一帧 JPEG 压缩,实现最简单、解码最容易,拖到 JSMpeg 里逐帧就能画出来,但单帧体积大,网络上跑起来带宽占用高,画面动作一多文件体积飙升得很厉害;MPEG1 则更进一步,引入了 I 帧、P 帧的帧间预测机制,用时间和计算量换取更低的码率。我在做摄像头监控时传 640x480@30fps,选 MPEG1 大约只需要 800kbps 左右码率,比 MJPEG 低一大截,画面平滑度还更好。

一个基本命令长这样:

ffmpeg -rtsp_transport tcp -i "rtsp://user:pass@192.168.1.100/stream1" \ -an -c:v mpeg1video -s 640x480 -r 25 -b:v 800k \ -f mpeg1video -fflags nobuffer -flags low_delay \ ws://127.0.0.1:8082/

这里的-fflags nobuffer用来把输入侧缓冲降到最低,-flags low_delay减小编码器内部缓冲,-f mpeg1video让 FFmpeg 以裸 MPEG1 流的方式输出裸数据。没有-f mpegts封装,也省掉了一层容器开销。后面系列文章里我还会专门写一篇命令参数的逐项拆解,这里先有个整体印象就行。

2.2 WebSocket 传输侧:为什么它是这套方案的传输底座

WebSocket 在这套方案里不只是一个可选的通道,它是一个刚需。传统 HTTP 传输视频流时,要么是边下边播的分块响应,要么是客户端不断去拉取新片段。但 WebSocket 是建立一次 TCP 长连接,之后两端随时可以往管道里塞数据,HTTP 头部的开销只在握手阶段出现一次,后续传输二进制裸数据时,帧头只有很小的开销。更重要的是,WebSocket 的数据帧天然自带边界,JSMpeg 收到一个二进制消息就可以当一个完整的片断来处理。

实际落地时,我建议在中间加一层转发服务。FFmpeg 把编码流推到一个 WebSocket 服务端入口,服务端通过广播的方式推给所有订阅的浏览器。这个转发服务通常用 Node.js 的 ws 库,几十行代码就能搭完,而且可以顺手做连接鉴权、客户端数量统计、录制存储等扩展功能。转发时有一个细节:JSMpeg 需要的是原始的 MPEG1 字节流,不需要额外封装,因为你一旦给数据加了壳,JSMpeg 还得先花功夫解这个壳,没必要。

2.3 JSMpeg 播放端:纯 JS 解码器如何工作

JSMpeg 的原理是,把浏览器端接收到的 MPEG1 编码数据逐步喂给内部的解码器,解码器还原出原始图像的宽度、高度、亮度和色度数据,再通过 Canvas 的putImageData或drawImage的方式渲染到页面上。整个过程完全在浏览器线程内执行,所以它不需要后端配合转封装格式,也不依赖系统解码器,兼容性反而好控制。

这里重点提醒一下:JSMpeg 播放时对 CPU 是实打实有消耗的,尤其是分辨率高、帧率高的画面。我之前实测在一般办公笔记本上播放 720x480@25fps,CPU 占用大约 10~15%,这还可以接受;但如果你把它搬到低端安卓盒子上跑,解码压力会明显增大,画面掉帧会非常明显。所以在做系统规划时,不要把前端播放器的性能当成免费午餐,编码侧分辨率、帧率和码率三者必须一起权衡。音频方面 JSMpeg 也能处理 MPEG1 音频,但音频和视频的同步会带来额外的复杂度,很多项目干脆先去掉音频,把音频需求放到独立通道处理。我个人处理安防类场景时是直接-an丢弃音频,画面优先。

3. 跑通最小系统的路线图

3.1 环境准备

先把这个系统需要的东西列一下:一台能跑 FFmpeg 的机器,一般用 Linux 或者 macOS;一个 WebSocket 服务端环境,我习惯用 Node.js + ws 库;一个现代浏览器;一个视频源。视频源是最好解决的,没有摄像头的话,直接拿本机桌面录制或者一个本地 mp4 文件当输入也行。

搭建最小验证环境时,我强烈建议把“采集端-转发端-播放端”控制在同一台机器上,先跑通逻辑再去考虑跨网络的问题。这样可以最大化排除网络因素的干扰,确定问题出在编码还是出在传输。这个最小系统需要的代码量并不大,下面每一步都能在半小时内走完。

3.2 编码端推流命令

假设你的前端页面运行在 8082 端口,WebSocket 服务端也在 8082,那么 FFmpeg 推流命令可以简化成:

ffmpeg -re -i /path/to/input.mp4 \ -an -c:v mpeg1video -s 640x480 -r 25 -b:v 800k \ -f mpeg1video -fflags nobuffer -flags low_delay \ http://127.0.0.1:8082/video

注意这里我改了输出地址,因为某些版本的 FFmpeg 对ws://协议支持并不统一,而输出到 HTTP 地址后,由服务端在收到 HTTP 分块请求时把数据读取出来再转成 WebSocket 广播,这样反倒最简单稳妥。当然了,如果你用的 FFmpeg 版本支持直接推ws://,也可以省掉服务端 HTTP 接收这一步。我这里采用的是 99% 环境都能直接跑的方式。

如果只是验证用本地文件,-re表示按原视频帧率逐步读取文件推送;如果用的是直播摄像头输入,通常不应该加-re,而是让 FFmpeg 按实时解码出来的速度推流——摄像头本身就是实时源,不需要人为限速。

3.3 服务端转发逻辑

服务端模块拆成两部分:一部分负责接收 HTTP 分块上传的数据(也就是 FFmpeg 推上来的流),另一部分维护所有 WebSocket 播放连接。收到编码数据后不做任何加工,直接写进每个 WebSocket 连接的发送缓冲区。

Node.js 侧的核心逻辑大致可以是:

const http = require('http'); const WebSocket = require('ws'); const clients = new Set(); const server = http.createServer((req, res) => { if (req.url.startsWith('/video')) { req.on('data', chunk => { for (const client of clients) { if (client.readyState === WebSocket.OPEN) { client.send(chunk); } } }); req.on('end', () => res.end('ok')); } }); const wss = new WebSocket.Server({ port: 8082 }); wss.on('connection', ws => { clients.add(ws); ws.on('close', () => clients.delete(ws)); });

这只是最粗糙版本,生产环境还得考虑客户端发送速度、背压、断线重连、WebSocket 最大消息体限制等问题,但作为总览篇先看核心脉络已经足够了。这条链路的延迟瓶颈其实很少出现在这里——Node 转发基本是内存拷贝级别,瓶颈还是在编码等待和网络状况。

3.4 浏览器端接入

浏览器端引入 JSMpeg 只需要一个 JavaScript 文件和几行代码:

<script src="jsmpeg.min.js"></script> <canvas id="screen" width="640" height="480"></canvas> <script> const canvas = document.getElementById('screen'); const player = new JSMpeg.Player('ws://127.0.0.1:8082/', { canvas: canvas, video: true, audio: false, autoplay: true }); </script>

需要注意的是,wss 和 ws 协议在跨域、混合内容限制上是跟着页面协议走的。如果页面是 HTTPS 部署,那 WebSocket 也必须用 WSS,否则浏览器会直接拦掉。这地方我在项目里踩过坑:FFmpeg 采集端在局域网用 ws,浏览器页面在公网 HTTPS 环境下怎么也连不上,排查半天最后发现是加密 WebSocket 没做反向代理,补一层 WSS 转发就通了。

3.5 延迟测量方法

系统跑通之后,首先要量化延迟多少,不能凭感觉说“挺快”。最省事的办法是准备一个秒表,让视频源对准秒表画面,然后手机拍一张浏览器播放画面,对比当前秒表读数和画面里秒表读数的差值,多测几次取平均。这个偏差就是端到端延迟的近似值。我见过用毫秒级的系统时钟来测的,但那个对设备同步要求高,反而不如物理秒表直观。

我的经验是,连续测量 10 组数据,如果最大值和最小值之间的抖动能控制在 200 毫秒以内,这套系统在对应网络环境下的稳定性就还能接受。抖动比延迟绝对值更可怕,因为视频控制功能对“每次操作后的等待时间不一致”这种情况非常敏感。

4. 延迟从哪里来,以及调优方向

4.1 四段链路延迟拆解

把整条链路切开看,延迟主要分布在四个地方:编码端缓冲、网络传输、解码器缓冲、渲染间隔。

编码端最常见的问题是采集卡或摄像头的内部缓冲,以及 FFmpeg 的输入/输出缓冲。有些网络摄像头在 RTSP 拉流时,TCP 模式比 UDP 模式更稳,但会带来一定缓冲延迟;-ftps_transport tcp并不是灵丹妙药,要以实际测试为准。网络传输这一段的延迟主要由链路质量和跨运营商时延决定,局域网内通常低于 5 毫秒,跨公网则会到几十毫秒甚至更多,这不是码率能解决的。解码器这一侧,JSMpeg 本身不维护大缓冲,但浏览器主线程如果有其他 JS 任务抢占 CPU,解码节奏会被打断,视频帧会瞬间积压造成隐性延迟。渲染间隔则取决于浏览器帧率,一般是 16.6ms 或 33.3ms,这部分影响不大但客观存在。

四段加起来,理想状况下一个 640x480@25fps 的本地局域网系统,端到端延迟在 0.3~0.6 秒范围内是很正常的。

4.2 核心调优参数

调优第一个重点在 FFmpeg 命令,把preset调成ultrafast,把tune设成zerolatency,关闭 B 帧和某些高级预测选项,延缓编码器内部等待。第二个重点是分辨率和帧率之间的取舍,有些人为了画面清晰,一上来就 1080p@30fps,结果 CPU 解码跟不上,实际体验反而比 480p@15fps 差。我通常会建议:如果只看画面不抠细节,720x480@25fps 是个大量场景下最均衡的档位。

第三个重点在 WebSocket 服务端:不要对每一个连接单独开阻塞式同步写,要利用 ws 库的bufferedAmount做背压检测。当某个客户端网络差、发送缓冲区越积越多的时候,主动丢掉旧帧或断开重连,别让一个慢客户端把整个服务的发送队列拖垮。我在多路并发项目里就是因为没做这个限制,一路弱网客户端不断堆积,最后整个进程 OOM 了。

第四个重点在前端 Canvas:如果页面需要同时显示多路、多画面,不要让每个播放器独立一个高刷 Canvas,最好只对可见画面做绘制,甚至在低端设备上把渲染分辨率降一档。JSMpeg 默认渲染到 Canvas 的清晰度与视频分辨率一致,这个可以通过 CSS 缩放去控制实际显示大小,但要意识到 CSS 放大并不会增加画面信息量。

4.3 常见问题与排查清单

把项目里实际遇到的高频问题整理一下:

现象可能原因处理思路
画面花屏、绿色马赛克WebSocket 丢帧或数据乱序,MPEG1 解码状态被破坏,且 P 帧依赖前序帧优先排查网络丢包,降低码率或分辨率,必要时重启推流
播放端黑屏无画面WebSocket 握手失败,或服务端从未收到 FFmpeg 数据检查服务端日志确认连接数,单独 curl HTTP 入口确认数据流
画面有延迟但不卡顿编码缓冲或者播放缓冲仍然存在检查fflags nobuffer是否生效,确认 JSMpeg 没有设置过大的 streamBufferSize
CPU 占用过高分辨率或帧率超出 JS 解码能力降低分辨率、降帧率,或改用 MJPEG 模式用空间换时间
多路并发后内存飙升WebSocket 发送缓冲不释放开启背压检测,丢弃落后客户端帧数据

这些都算是这个架构比较有代表性的“坑”。在总览篇先摆出来,也是希望你在正式动工前对这套方案的脾气有个预期。细节排查方法每一类问题以后可以单独展开讲。

5. 应用场景和这套方案的边界

5.1 最适合的场景

以我实际做过的项目来看,这套三件套最适合以下场景:安防监控类的远程画面查看、家用或小型机房的无人值守监看、机器人或小车的第一视角回传、以及展会或教学环境中的多屏同显。这些场景的共同特点是“观看到操作之间的延迟控制在 1 秒左右就能接受”,并不需要像视频会议那样唇音同步。

拿一个具体的例子说:帮朋友做的小型仓库监控,就是一台普通工控机 + USB 摄像头,FFmpeg 拉 RTSP 转 MPEG1,WebSocket 转发,浏览器开一个网格页面同时看八个摄像头,画面刷新延迟稳定在 0.6 秒左右。需求变了以后还能直接加录像:WebSocket 服务端在广播的同时把数据写一份本地文件,回放时直接用 FFmpeg 转一下就行。这套组合的扩展性体现在它的简单性,因为每一层都不复杂,改起来反而灵活。

5.2 不适合的场景

这套方案也有非常明显的边界:它不适合需要双向实时互动的音视频通话,那是 WebRTC 的主场;不适合几万人同时观看的大规模直播,因为 WebSocket 中心化转发模式撑不住,需要靠标准流媒体协议和 CDN;也不适合对画质要求极高的场景,MPEG1 压缩率摆在那里,和 H.264/H.265 没法比画面细节。音频支持薄弱也是短板,JSMpeg 对音频同步处理不够完善,强行启用经常会遇到音画不同步,所以我一般建议直接当纯视频系统用。

理解边界比理解优势更重要。你在做技术选型的时候,如果发现需求里写着“保证低延迟且支持大规模分发”,那可能应该考虑 WebRTC 连麦 + CDN 转播混合架构,而不是硬把三件套做大。技术方案没有绝对的好坏,只有适不适合当前的限制条件。

5.3 后续系列规划

这篇是总览篇,因此只画了整体地图。后续我会按这个顺序继续输出:第二篇专门拆解 FFmpeg 编码参数的每个开关到底影响什么,把nobuffer、low_delay、GOP size、B 帧这些概念一次说透;第三篇做 WebSocket 服务端的深入实现,讲清楚背压、广播、鉴权和断线重连,让转发层能扛住真实环境的请求;第四篇讲前端播放器的细节定制,包括多画面网格、错误恢复、低 CPU 渲染策略;第五篇再回到实测调优,用真实数据对比不同参数组合下的延迟和画面质量。如果你手头有具体的参考场景或奇怪的设备源,也欢迎在交流时把你的方案参数一起抛出来,我这边能实测的都会帮你实测一遍,不能实测的也会梳理清楚排查思路。

6. 几个我用下来的经验提示

这套方案我从最早折腾到现在差不多快三年,踩过的坑比顺利站起来的时候多得多。最核心的一条体会是:不要为了“低延迟”这个目标硬堆参数,先把系统跑通,再逐段量化延迟,最后才动手调优。否则很容易进入“参数越调,画面越花”的死循环。

另一个经验是,WebSocket 转发的数据流最好在服务端做好“只转不发、按需丢弃”的控制。一个木桶能装多少水取决于最短的那块板,视频链路的短板经常不在程序里,而在客户端所在网络的稳定性。弱网情况下与其让客户端吃进一堆追不上的旧帧,不如主动降低刷新率,让画面慢一点但稳定一点。我做远程看护画面时,手动设定服务端每 200 毫秒才给某个弱网客户端发一次最新帧,观感立刻就好了很多,延迟从 1.5 秒降到 0.9 秒,画面也不再频繁花屏。

最后还想提一句:这套方案别看组件轻,但长期维护成本并不完全为零。FFmpeg 版本更替、浏览器策略更新、Node 依赖升级都可能影响行为。如果你追求的是一次性交付后不管的项目,可以把它当成标准答案直接用;如果你需要一套长期演进、能接进复杂业务系统里的方案,那就要给每层留好接口和替换余地。我是从 WebRTC 焦虑期一路折腾过来才定的这个方向,现在回头看,它算不上完美,但胜在诚实、可控、够用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进