对于网络摄像机做视频预览这块, 本身其实是非常陌生的, 当时接到这个需求也是相当的头疼(对于当时一年经验不到的我来说).当时我们的应用场景是: 多路网络摄像机通过局域网连接, PC 端能够实时预览监控画面并且画质达到720p, 延迟不能超过10秒, 多个摄像机能够切换查看. 由于后端只提供一个 RTSP 的直播协议, 所以所有的方案都是围绕着 RTSP 这个关键词.

当时时间很赶所以要自己慢慢研究是不可能了, 只能去找现成的库. 现目前用过三种方案, 都不完美.

整体思路

经过一波调研(google)之后, 知道浏览器对 RTSP 协议并不友好, 也就是说我们必须要自己转码再提供给浏览器使用, 找到的解决思路大概是:

  1. 转码: FFmpeg 是一个老牌的转码工具, 非常强大
  2. Node.js 用作中转站接收客户端发来的摄像机信息及控制 FFmpeg 推流
  3. 最终客户端接收视频流

下面我试过的三种方案, 基本结构都如此只是推流的方式和客户端的接收方式有所不同.

准备

安装 FFmpeg 转码工具

  • window 平台
  1. 下载 FFmpeg 解压后应该是已经编译好的文件
  2. 将解压好的文件放入 C 盘根目录(也可以自行放入其他盘符)下重命名为 ffmpeg(方便以后找)
  3. 设置环境变量, 我的电脑 -> 属性 -> 高级系统设置 -> 环境变量 -> 系统变量 -> 新增, 路径选择刚刚 C 盘下的 ffmpeg文件夹中的 bin 文件夹
  4. 注销或重启电脑让环境变量生效
  5. 测试, 在 cmd 中输入 ffmpeg -version, 如果出现版本号之类的东西则成功.
  • Mac 平台
    Mac 下可以直接通过 Homebrew 安装最为简单.
  1. 安装 Homebrew, 在终端中输入 ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
  2. 安装 FFmpeg, 直接在终端中输入 brew install ffmpeg 即可
  3. 测试, 同样在终端中输入 ffmpeg -version 查看版本号

安装 Node.js
这个就不展开详细说了, 每个前端都有吧…

方案一: img 标签

当时想到的第一种方案也是最简单的方案, 是将视频流的每一帧转化为 base64 再通过动态替换 <img> 的 src 属性来达到预览的效果.中间用到了rtsp-ffmpeg, 这个库的思路是通过 websocket 发送每一帧视频的 bytes 到客户端, 客户端可以通过 <img> 标签来展示.

示例:

node 端

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
const app = require('express')()
const server = require('http').Server(app)
const io = require('socket.io')(server)
const rtsp = require('rtsp-ffmpeg')

server.listen(8081, () => {
console.log('server listening on 8081')
})

// uri 以海康摄像机的 rtsp 协议为例
let uri = 'rtps://admin:password@ip:port/h264/ch1/sub/av_stream'
let stream = new rtsp.FFMpeg({
input: uri,
resolution: '1080x720',
quality: 3
})

stream.on('start', function () {
console.log('stream')
})

stream.on('stop', function () {
console.log('stream stopped')
})

io.on('connection', socket => {
const pipeStream = data => {
socket.emit('data', data)
}
stream.on('data', pipeStream)

// 切换摄像机
socket.on('URI', data => {
console.log(data)
uri = `rtsp://${data.userName}:${data.passWord}@${data.ip}:${data.port}/h264/ch1/sub/av_stream`
stream.input = uri

// 重启
stream.restart()
})

socket.on('disconnect' () => {
stream.removeListener('data', pipeStream)
})
})

客户端

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
<body>
<img id='img'>
<script src='/socket.io/socket.io.js'></script>
<script>
var io = io();
let img = document.querySelector('#img')
io.on('data', function(data) {
let bytes = new Unit8Array(data)
img.src = 'data:image/jpeg;base64,' + base64ArrayBuffer(bytes)

})
// byte 数组转 base64 (这段是在其他地方抄的)
function base64ArrayBuffer(arrayBuffer) {
var base64 = '';
var encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
var bytes = new Uint8Array(arrayBuffer);
var byteLength = bytes.byteLength;
var byteRemainder = byteLength % 3;
var mainLength = byteLength - byteRemainder;
var a, b, c, d;
var chunk;
// Main loop deals with bytes in chunks of 3
for (var i = 0; i < mainLength; i = i + 3) {
// Combine the three bytes into a single integer
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
// Use bitmasks to extract 6-bit segments from the triplet
a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
d = chunk & 63; // 63 = 2^6 - 1
// Convert the raw binary segments to the appropriate ASCII encoding
base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d];
}
// Deal with the remaining bytes and padding
if (byteRemainder == 1) {
chunk = bytes[mainLength];
a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
// Set the 4 least significant bits to zero
b = (chunk & 3) << 4; // 3 = 2^2 - 1
base64 += encodings[a] + encodings[b] + '==';
} else if (byteRemainder == 2) {
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
// Set the 2 least significant bits to zero
c = (chunk & 15) << 2; // 15 = 2^4 - 1
base64 += encodings[a] + encodings[b] + encodings[c] + '=';
}
return base64;
}
</script>
</body>

这种通过 img 的方式暴露出的问题是需要在前端解码, 导致下一帧到来时上一帧的画面还没解码完成. 就会有花屏甚至画面只显示一半的问题和延迟较大且延迟会累积, 在控制台中你会看到疯狂刷从内存来的请求, 如果你想看看 http 请求可能会疯. 所以这种方式肯定不行

这个 rtsp-ffmpeg 还提供一种 canvas 的方式, 只是在客户端做一些修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var io = io();
// 把之前的 img 标签换成 canvas
let canvas = document.querySelector('#canvas')
io.on('data', data => {
var bytes = new Uint8Array(data)

var blob = new Blob([bytes], {type: 'application/octet-binary'})

var url = URL.createObjectURL(blob)

var img = new Image

var ctx = canvas.getContext("2d")
img.width = img.width * 0.5
img.height = img.height * 0.5
img.onload = function() {
URL.revokeObjectURL(url)
ctx.drawImage(img, 0, 0, 1080, 720)
};
img.src = url
})

用 canvas 的方法虽然比 img 的效果好一点, 但是最终效果仍然是不尽人意. 图像很不稳定, 表现为一半画面一半绿屏, 如果视频中图像变换剧烈的话表现会更差, 所以这种方法也不太行. 其实在这中间我并没有做什么操作只是将这个 demo 集成在了 vue 中, 再加上多摄像机的切换和主流摄像机厂商的支持(因为每个摄像机厂商的 rtsp 协议的结构不一样)而已, 所以第一想法是能不能在解码这块找到更好解决办法, 于是又找到了另一个库 jsmpeg 方案二就来了.

方案二: jsmpeg

这个库还算比较不错的了, 也是通过 websocket 来转发, 看官方的例子是在终端中启动 ffmpeg -> websocket -> 客户端通过 jsmpeg.min.js 解码在 canvas 中播放. 因为这里只是实现了播放, 在这个基础上我们还需要在脚本中自启 ffmpeg / 切换/ 重启, 然后又去找了一个基于 jsmpeg 的库 node-rtsp-stream. 这个库只是做了一些封装让我们不用自己在终端中手动启用 ffmpeg, 在此之上我再加上重启就能满足现在的需求.

示例:

  1. 改造 node-rtsp-stream

node-rtsp-stream/videoStream.js

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
(function() {
var Mpeg1Muxer, STREAM_MAGIC_BYTES, VideoStream, events, util, ws;

ws = require('ws');

util = require('util');

events = require('events');

Mpeg1Muxer = require('./mpeg1muxer');

STREAM_MAGIC_BYTES = "jsmp";

var child_process = require('child_process');

VideoStream = function(options) {
this.name = options.name;
this.streamUrl = options.streamUrl;
this.width = options.width;
this.height = options.height;
this.wsPort = options.wsPort;
this.inputStreamStarted = false;
this.stream = void 0;
this.startMpeg1Stream();
this.pipeStreamToSocketServer();
return this;
};

util.inherits(VideoStream, events.EventEmitter);
// 停止视频流
VideoStream.prototype.stop = function () {
if (this.mpeg1Muxer) {
this.mpeg1Muxer.stream.stop()
}
}
// 重启视频流
VideoStream.prototype.restart = function() {
if (this.mpeg1Muxer) {
this.mpeg1Muxer.stream.stop()
console.log('ffmpeg is restart')
this.inputStreamStarted = false;
this.stream = void 0;
this.startMpeg1Stream();
// 监听 ffmpeg 进程是否关闭
this.mpeg1Muxer.on('ffmpegClose', function(code) {
console.log('ffmpeg closed on ' + code)
})
}
}

VideoStream.prototype.startMpeg1Stream = function() {
// 省略打开流的方法, 这部分没有做改动
};

VideoStream.prototype.pipeStreamToSocketServer = function() {
// 将流塞给 socket, 同样也没改
};

VideoStream.prototype.onSocketConnect = function(socket) {
var self, streamHeader;
self = this;
streamHeader = new Buffer(8);
streamHeader.write(STREAM_MAGIC_BYTES);
streamHeader.writeUInt16BE(this.width, 4);
streamHeader.writeUInt16BE(this.height, 6);
socket.send(streamHeader, {
binary: true
});
console.log(("" + this.name + ": New WebSocket Connection (") + this.wsServer.clients.length + " total)");
return socket.on("close", function(code, message) {
return console.log(("" + this.name + ": Disconnected WebSocket (") + self.wsServer.clients.length + " total)");
});
};

module.exports = VideoStream;

}).call(this);

node-rtsp-stream/videoStream.js

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
54
55
56
57
58
59
60
(function() {
var Mpeg1Muxer, child_process, events, util;

child_process = require('child_process');

util = require('util');

events = require('events');

Mpeg1Muxer = function(options) {
var self;
self = this;
this.url = options.url;
this.stream = child_process.spawn("ffmpeg",
[
"-rtsp_transport",
"tcp",
"-i",
this.url,
'-s',
// 图像宽高
`${options.width}x${options.height}`,
'-f',
'mpeg1video',
'-b:v',
'800k',
'-r',
'30',
'-'
],
{
detached: false
}
);
this.inputStreamStarted = true;
this.stream.stdout.on('data', function(data) {
return self.emit('mpeg1data', data);
});
this.stream.stderr.on('data', function(data) {
return self.emit('ffmpegError', data);
});
// kill ffmpeg
this.stream.stop = function() {
// console.log(self.stream.pid)
self.stream.stdin.pause();
self.stream.kill()
console.log('ffmpeg is be kill')
};
// 监听 ffmpeg 退出
this.stream.on('exit', function(code) {
return self.emit('ffmpegClose', code)
})
return this;
};

util.inherits(Mpeg1Muxer, events.EventEmitter);

module.exports = Mpeg1Muxer;

}).call(this);

  1. node 端使用
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
const app = require('express')()
const server = require('http').Server(app)
const io = require('socket.io')(server)
// 引入改造后的 node-rtsp-stream
const Rtsp = require('./node-rtsp-stream')

server.listen(8081, () => {
console.log('server listening on 8081')
})

// uri 以海康摄像机的 rtsp 协议为例
let uri = 'rtps://admin:password@ip:port/h264/ch1/sub/av_stream'
let stream = new Rtsp({
name: 'rtsp_stream',
streamUrl: uri,
wsPort: 11111,
width: 720,
height: 405
})

stream.on('start', function () {
console.log('stream')
})

stream.on('stop', function () {
console.log('stream stopped')
})

io.on('connection', socket => {
const pipeStream = data => {
socket.emit('data', data)
}
stream.on('data', pipeStream)

// 切换摄像机
socket.on('URI', data => {
console.log(data)
uri = `rtsp://${data.userName}:${data.passWord}@${data.ip}:${data.port}/h264/ch1/sub/av_stream`
stream.streamUrl = uri;
// 重启
stream.restart()
})

socket.on('disconnect' () => {
stream.removeListener('data', pipeStream)
})
})
  1. 客户端
1
2
3
4
5
6
7
8
9
10
<canvas id='can'></canvas>
<script src='jsmpeg.min.js'></script>
<script>
let canvas = document.querySelector('#can')
let ws = new WebSocket('ws://127.0.0.1:11111')
let player = new jsmpeg(ws, {
canvas: canvas,
autoplay: true
})
</script>

这个方案其实使用了很久一直都没发现问题, 也没有出现过花屏延迟累计的情况. 但是在一路摄像机安置在天桥附近人流量剧增, 与这个视频预览同一页面还有一个人脸实时抓拍的即时消息推送的功能, 导致在20 - 30分钟浏览器直接假死或者崩溃.(然后这个方案又凉了😂)

方案三: FFmpeg + Nginx + video.js

在经过两次实验 + 失败之后找到一个较为大众的解决方式, 前两种方案都是通过库直接使用 rtsp 转成 bytes 提供给外部使用, 而方案三的流程如下:

  • Node 使用 fluent-ffmpeg 调整参数启动 FFmpeg
  • FFmpeg 负责编码再解码后转为 RTMP 协议
  • Node 将流推到 Nginx (nginx 需要安装 nginx-rtmp-modules 插件)
  • 客户端使用 Video.js 访问 Nginx 配置的连接并播放视频

在这个阶段又遇到了不同厂商的摄像机, 在同一协议下不能正常运作, 在此也记录一下各厂商的 rtsp 协议

  • 海康威视: rtps://admin:password@ip:port/h264/ch1/sub/av_stream
  • 大华: rtps://admin:password@ip:port/cam/realmonitor?channel=1&subtype=0
  • 天地伟业: rtps://admin:password@ip:port/1/1
  • 科达: rtps://admin:password@ip:port/id=1
  • 华易明新: rtps://admin:password@ip:port/ch3

示例:

node 端

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
var uri = 'rtps://admin:password@ip:port/h264/ch1/sub/av_stream'

// FFMpeg 命令参数设置
// ffmpeg - i rtsp://admin:hk123456@192.168.0.64:554/h264/ch1/sub/av_stream -fflags nobuffer -vcodec libx264
// -preset superfast -f flv -r 15 -s 1280x720 -an rtmp://127.0.0.1:1935/live/camera
let ffmpeg = require('fluent-ffmpeg')
let running = false
var command = ffmpeg(uri)
.outputOptions([
'-fflags',
'nobuffer',
'-vcodec',
'libx264',
'-preset',
'superfast',
// '-rtsp_transport',
// 'tcp',
// '-threads',
// '4',
'-f',
'flv',
'-r',
'15',
'-s',
'1280x720',
'-an'
])
// 此处的 /live/camera, camera类似于一个房间的概念, 你可以设置为你想要的名字
.save(`rtmp://${ nginxHost }:${ nginxPort }/live/camera`)
.on('start', function (e) {
running = true
console.log('stream is start: ' + e)
})
.on('end', function () {
running = false
console.log('ffmpeg is end')
})
.on('error', function (err) {
running = false
console.log('ffmpeg is error! '+ err)
reloadStream(uri)
})

const reloadStream = (uri, userAgents) => {
if (!uri) return
let userAgent = userAgents
if (running) {
command.kill()
}else {
if (command._inputs[0]) {
command._inputs[0].source = uri
} else {
command.input(uri)
}
command.run()
}
}

const camFactory = {
// 海康
'0' () {
return 'h264/ch1/sub/av_stream'
},
// 大华
'1' () {
return 'cam/realmonitor?channel=1&subtype=0'
},
// 天地伟业
'2' () {
return '1/1'
},
// 科达
'3' () {
return 'id=1'
},
// 华易明新
'4' () {
return 'ch3'
}
}


io.on('connection', socket => {
console.log('connection')
let cameraInfo = {}
if (cameraInfo.cameraName) {
socket.emit('CameraInfo', cameraInfo)
}
// 获取网络摄像头信息
socket.on('URI', data => {
if (cameraInfo.cameraName === data.cameraName || cameraInfo.ip === data.ip) return
cameraInfo = data
cameraInfo.factory = data.factory || '0'
uri = `rtsp://${data.userName}:${data.passWord}@${data.ip}:${data.RTSP}/${camFactory[cameraInfo.factory]()}`
reloadStream(uri, data.userAgent)
socket.emit('CameraInfo', cameraInfo)
})

socket.on('disconnect', _ => {
console.log('disconnect')
});
socket.on('end', _ => {
console.log('socket-io end')
})
socket.on('err', err => {
console.log(err)
})
})

FFmpeg 的参数对视频推流的画面质量和延迟有较大影响, 以下对我用过的 FFmpeg 参数作一些说明.如果使用海康摄像机 rtsp 转 rtmp 是可以直接使用 copy 参数性能会提高很多, 剩去了 FFmpeg 编码解码的过程. 下面的例子是当时的项目要兼容的华易明新的摄像机, 所以没有使用 copy 参数

延迟影响相关参数(以下测试都在海康威视和华易明新摄像机测试):

  1. fast 参数

    1
    2
    ffmpeg -i rtsp://admin:hk123456@192.168.0.64:554/h264/ch1/sub/av_stream -vcodec libx264
    -preset fast -f flv -r 15 -s 1280x720 -an rtmp://127.0.0.1:1935/live/camera

    fast 参数延迟表现较为不明显

  2. superfast 参数

    1
    2
    ffmpeg -i rtsp://admin:hk123456@192.168.0.64:554/h264/ch1/sub/av_stream -vcodec libx264
    -preset superfast -f flv -r 15 -s 1280x720 -an rtmp://127.0.0.1:1935/live/camera

    superfast 参数表现为 2-3 秒延迟, 但是画质会下降一点

  3. ultrafast 参数

    1
    2
    ffmpeg -i rtsp://admin:hk123456@192.168.0.64:554/h264/ch1/sub/av_stream -vcodec libx264
    -preset ultrafast -f flv -r 15 -s 1280x720 -an rtmp://127.0.0.1:1935/live/camera

    ultrafast 参数在我实验中表现跟 superfast 的延迟差距不是特别大, 大概在 1-2 秒左右, 画质会下降的比较明显

  4. zerolatency 和 ultrafast 参数

    1
    2
    ffmpeg -i rtsp://admin:hk123456@192.168.0.64:554/h264/ch1/sub/av_stream -tune zerolatency 
    -vcodec libx264 -preset ultrafast -f flv -r 15 -s 1280x720 -an rtmp://127.0.0.1:1935/live/camera

    讲道理第一个参数是零延迟, 但是添加这个参数之后在天桥部署的摄像机花屏会非常严重

  5. nobuffer 和 superfast 参数

    1
    2
    ffmpeg -i rtsp://admin:hk123456@192.168.0.64:554/h264/ch1/sub/av_stream -fflags nobuffer
    -vcodec libx264 -preset superfast -f flv -r 15 -s 1280x720 -an rtmp://127.0.0.1:1935/live/camera

    nobuffer 参数表示不使用缓存, 在以上实验中华易明新摄像机在解码中丢包非常严重, nobuffer 参数在解码跟不上发送时放弃缓存, 所以最终选用 nobuffer + superfast 参数达到一个平衡的状态.

其他参数

  • rtsp_transport 参数: 传输方式, tcp/udp 两种, 默认为 udp.

    本来 udp 的传输方式更快消耗的资源也更少, 但是项目中反而改成 tcp 的方式效果更好

  • threads 参数

    threads 也是讲道理应该是解码线程数多一点效果好, 反而项目中将线程数减少效果更佳

以上两个参数是我没理解的部分, 当然也可能是当时项目的环境有所影响. 这个项目也有一点奇怪的地方在这儿一起说, 在启动 FFmpeg 转码时电脑会蓝屏. 最终排查到的问题是: 1. CPU 启用涡轮加速 2. flash 开启硬件加速 3. ffmpeg 版本 4. 华易明新摄像机

解决方法:

  1. CPU 涡轮加速关闭
  2. flash 关闭硬件加速 (据说是 flash 本身实现硬件加速不好)
  3. 将 FFmpeg 版本换至最新
  4. 更新摄像机硬件包

蓝屏的这个问题只在一台电脑上遇见过特殊性比较高, 仅供参考.

nginx 配置

1
2
3
4
5
6
7
8
9
10
rtmp {
server {
# 服务端口(默认端口)
listen 1935;
# 直播
application live {
live on;
}
}
}

客户端

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>

<!-- Old version compatible with flash -->
<!-- <link href="//vjs.zencdn.net/5.19/video-js.min.css" rel="stylesheet">
<script src="//vjs.zencdn.net/5.19/video.min.js"></script> -->

<!-- New version uncompatible with flash -->
<link href="http://vjs.zencdn.net/6.2.4/video-js.css" rel="stylesheet">
<script src="http://vjs.zencdn.net/6.2.4/video.js"></script>
<script src="./videojs-flash.min.js"></script>
<script> videojs.options.flash.swf = './video-js.swf'; </script>
</head>

<body>
<video id="my-player" class="video-js">
<source src="rtmp://127.0.0.1:1935/live/camera" type="rtmp/mp4"></source>
</video>
</body>

<script>
videojs('my-player', {
controls: true,
autoplay: true,
flash: {
swf: './video-js.swf'
},
height: 300,
width: 300
});
</script>
</html>

补充一句在 vue 中使用可以试试 vue-video-player

总结

这一波网络摄像机的直播还是涨了很多姿势, 达成 FFmpeg 调参员. 不过在我离职之前还有一个问题一直没有解决, 客户端在运行 3-4 小时之后就会黑屏, 在刷新页面后又恢复. 我尝试过改 chrome 中 flash 的设置还是不行, 苟且了一点定时刷新暂时解决. 最终还是 flash 这个问题没法从根本上解决.

Created on 2017/7/26 by Cara