Проанализировав существующие решения видеонаблюдения, создаётся впечатление, что каждый хочет создать свой убер-комбайн, в котором будет всё, когда зачастую требуется всего несколько возможностей.
В первую очередь, запись видео с камер, желательно, с motion detection. Во-вторых, просмотр камер онлайн без дополнительных утилит, а в идеале и на мобильных устройствах. Отсюда безальтернативное решение - просмотр в браузере с помощью HTML5-плеера.
Что если попробовать реализовать собственную систему, подыскав готовые модули для решения отдельных задач?
Выбор технологий
Итак, нам нужно:
- Принимать RTSP-поток с камеры
- Передавать его на систему обнаружения движения и записи архивов
- Предоставлять доступ к оригинальному потоку для отображения в реальном времени
- Отдавать видео-архив по HTTP через веб-интерфейс
Наиболее распространённые технологии стриминга с помощью браузера - RTMP и HLS.
- HLS (HTTP Live Streaming) - это по сути набор файлов-кусочков видео-трансляции, объединённые вместе обычным .m3u8 плейлистом. Отсюда очевидный минус: задержка будет равна как минимум продолжительности каждого эпизода. Зато поддерживается повсюду и нативно вплоть до iOS.
- RTMP (Real Time Messaging Protocol) увы, поддерживается только в плеерах на основе Flash, но зато задержки минимальны.
Будем использовать оба протокола, тем более, что они реализованы в RTMP-модуле для nginx.
Для обнаружение движения подойдёт утилита motion. Она умеет забирать RTSP-поток с камер, детектировать движение и записывать в файл по шаблону.
Так как некоторые бюджетные китайские IP-камеры не умеют отдавать видеопоток сразу двум клиентам, придётся использовать RTSP-сервер, забирать видео с камер и отправлять на motion и RTMP-сервер через него.
Более-менее вменяемых чистых RTSP-серверов, похоже, не существует в природе - все находятся в составе крупных стриминг-комплексов. Остаётся FFserver, но он не разрабатывается с лета 2016.
Для просмотра в браузере будем использовать Video.js - гибридный HTML5/Flash видеоплеер с поддержкой RTMP.
Пересылать данные между модулями будем с помощью FFmpeg в исходном формате без декодирования, что существенно снизит нагрузку на CPU.
Cхема системы видеонаблюдения:
__________ __________ __________ __________
| | | | | | | |
| camera |-----| ffserver |-----| nginx |-----| HLS |
|__________| |__________| |__________|\ |__________|
\ \
\ \ __________
\ \ | |
\ \| RTMP |
\ |__________|
\
\ __________ __________
\ | | | |
\| motion |-----| archive |
|__________| |__________|
Запуск ffmpeg может осуществляться как самим ffserver'ом (только для потоков от камеры к серверу), так и RTMP-модулем для nginx (возможно для всех потоков).
Практика показала, что более стабильное решение - директива exec_static
модуля nginx-rtmp-module.
Настройка ffserver
Для нормального функционирования достаточно такого конфигурационного файла:
RTSPPort 554
HTTPPort 8080
HTTPBindAddress 0.0.0.0
RTSPBindAddress 0.0.0.0
MaxClients 2000 # Default is 5
MaxBandwidth 50000 # In kbit/sec. Default is 1000
CustomLog - # Log to stdout
NoDefaults # Do not use default codec options for all streams
<Feed cam1.ffm> # Input feed from cam 1
</Feed>
<Feed cam2.ffm> # Input feed from cam 2
</Feed>
<Stream cam1> # Output stream for cam 1
Feed cam1.ffm
Format rtp # RTSP-stream
NoAudio
VideoSize 1280x720 # Camera resolution
</Stream>
<Stream cam2> # Output stream for cam 2
Feed cam2.ffm
Format rtp
NoAudio
VideoSize 1920x1080
</Stream>
Настройка motion
Берём дефолтный конфиг и подправляем следующие параметры:
# Comment out default capture device
;videodevice /dev/video0
# Event Gap is the seconds of no motion detection that triggers the end of an event.
event_gap 10
# Disable image file output
output_pictures off
# Set video format
ffmpeg_video_codec mp4
# Drop motion-less frames
ffmpeg_duplicate_frames false
# Disable own JPEG-stream server
stream_port 0
# Enable web control on port
webcontrol_port 8080
# Allow access to all
webcontrol_localhost off
# Include cameras configurations
camera_dir /etc/motion/conf.d
Настройки для каждой камеры вынесены в отдельный каталог /etc/motion/conf.d
:
camera_id = 1
width 1280
height 720
framerate 5
# Number of frames to capture after motion is no longer detected (default: 0)
post_capture 50
netcam_url rtsp://127.0.0.1/cam248
;text_left CAMERA %t
# Threshold for number of changed pixels in an image that
# triggers motion detection (default: 1500)
threshold 4000
target_dir /var/www/archive/
movie_filename cam%t/%Y-%m-%d_%H-%M-%S
Настройка nginx
В соответствующую секцию server добавляем следующий конфиг:
location /hls {
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
index index.m3u8;
add_header Cache-Control no-cache;
add_header Access-Control-Allow-Origin *;
autoindex on;
autoindex_format json;
}
# RTMP server statistics page
location /stats.html {
rtmp_stat all;
rtmp_stat_stylesheet stats.xsl;
# Uncomment to allow connections only from specified IPs
#deny all;
#allow 192.168.2.2;
}
location /archive {
autoindex on;
autoindex_format json;
}
В основной конфиг (/etc/nginx/nginx.conf) рядом с секцией http добавляем конфиг модуля RTMP:
rtmp {
server {
listen 1935;
chunk_size 256;
wait_key on;
sync 10ms;
interleave on;
buflen 1s;
application stream {
live on;
hls on;
hls_fragment_naming system;
hls_fragment 1s;
hls_playlist_length 3s;
hls_path /srv/http/hls;
hls_nested on;
allow publish 127.0.0.0/8;
deny publish all;
# cam > ffserver
exec_static ffmpeg -r 5 -fflags nobuffer -probesize 32 -loglevel quiet -re -rtsp_transport tcp -i rtsp://192.168.20.100/channel=1_stream=0 -override_ffserver -c copy http://127.0.0.1:8080/cam1.ffm;
exec_static ffmpeg -r 5 -fflags nobuffer -probesize 32 -loglevel quiet -re -rtsp_transport tcp -i rtsp://192.168.20.101/channel=1_stream=0 -override_ffserver -c copy http://127.0.0.1:8080/cam2.ffm;
# increase probesize in case of stream instability
# ffserver > nginx_rtmp
exec_static ffmpeg -r 5 -fflags nobuffer -probesize 500000 -loglevel quiet -re -rtsp_transport tcp -i rtsp://127.0.0.1/cam1 -c copy -f flv rtmp://127.0.0.1:1935/stream/cam1;
exec_static ffmpeg -r 5 -fflags nobuffer -probesize 500000 -loglevel quiet -re -rtsp_transport tcp -i rtsp://127.0.0.1/cam2 -c copy -f flv rtmp://127.0.0.1:1935/stream/cam2;
}
}
}
Страница просмотра
Теперь займёмся простенькой страничкой для просмотра видео и архива.
HLS трансляция представляет собой набор видео-файлов в отдельных каталогах для каждой камеры. То есть можно с помощью nginx получить содержимое каталогов через autoindex
и далее обращаться к когкретным трансляциясм. Для вывода в удобном JSON-формате используем директиву autoindex_format
.
Страница онлайн-просмотра
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex">
<title>Videoserver - Live</title>
<link href="video-js.min.css" rel="stylesheet">
<script src="video.min.js"></script>
<script src="videojs-contrib-hls.min.js"></script>
<style>
.vjs-big-play-button {
display: none !important;
}
</style>
</head>
<body>
<table style="border-spacing: 0">
<tr>
<td>
<video id="player" width="640" height="360" class="video-js vjs-default-skin" controls></video>
<td>
<div id="playlist" style="overflow: auto; height: 360px; width: 250px"></div>
</table>
<a href="archive.html">archive</a><br>
<a href="stats.html">stats</a>
<script>
var base_url_rtmp = "rtmp://192.168.20.1/stream/";
var base_url_hls = "hls/";
var playlist = document.getElementById("playlist");
var player = videojs("player", {
sourceOrder: true,
techOrder: ["flash", "html5"],
flash: {
swf: "video-js.swf"
}
});
var getJSON = function(url, callback) {
var xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "json";
xhr.onload = function() {
var status = xhr.status;
if (status == 200) {
callback(null, xhr.response);
} else {
callback(status);
}
};
xhr.send();
};
var getCameras = function() {
getJSON(base_url_hls, function(err, data) {
if(err != null) {
alert("getCameras " + err);
} else {
for(i = 0; i < data.length; i++) {
var link = document.createElement("a");
link.appendChild(document.createTextNode(data[i].name));
link.setAttribute("href", '#' + data[i].name);
link.setAttribute("data-camera", data[i].name);
link.onclick = function() {
playCamera(this.dataset.camera);
return false;
}
playlist.appendChild(link);
playlist.appendChild(document.createElement("br"));
}
}
});
};
var playCamera = function(camera) {
player.src([{
src: base_url_rtmp + camera,
type: "rtmp/mp4"
}, {
src: base_url_hls + camera + "/index.m3u8",
type: "application/x-mpegURL"
}]);
if(player.paused()) {
player.play();
}
}
getCameras();
player.ready(function() {
if(window.location.hash) {
playCamera(window.location.hash.substring(1));
}
});
</script>
</body>
</html>
Не забудьте поменять адрес 192.168.20.1 на адрес видео-сервера!
Страница архива
По такой же логике отображаем содержимое архива.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex">
<title>Videoserver - Archive</title>
<link href="video-js.min.css" rel="stylesheet">
<script src="video.min.js"></script>
<script src="videojs-contrib-hls.min.js"></script>
<style>
.vjs-big-play-button {
display: none !important;
}
</style>
</head>
<body>
<table style="border-spacing: 0">
<tr>
<td>
<video id="archive_player" width="640" height="360" class="video-js vjs-default-skin" controls></video>
<td>
<div id="archive_playlist" style="overflow: auto; height: 360px; width: 250px;"></div>
</table>
<a href="./">live</a><br>
<a href="stats.html">stats</a>
<script>
var base_url_archive = "archive/";
var getJSON = function(url, callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'json';
xhr.onload = function() {
var status = xhr.status;
if (status == 200) {
callback(null, xhr.response);
} else {
callback(status);
}
};
xhr.send();
};
var archive_player = videojs('archive_player');
var archive_playlist = document.getElementById("archive_playlist");
var getCameras = function() {
getJSON(base_url_archive + "/", function(err, data) {
if(err != null) {
alert('getCameras' + err);
} else {
for(i = 0; i < data.length; i++) {
var link = document.createElement("a");
link.appendChild(document.createTextNode(data[i].name));
link.setAttribute("href", "#" + data[i].name);
link.setAttribute("data-camera", data[i].name);
/*link.onclick = function() {
clearPlaylist();
setCamera(this.dataset.camera);
return false;
}*/
archive_playlist.appendChild(link);
archive_playlist.appendChild(document.createElement("br"));
}
}
})};
var playFile = function(camera, file) {
archive_player.src(base_url_archive + "/" + camera + "/" + file);
archive_player.play();
}
var clearPlaylist = function() {
archive_playlist.innerHTML = '';
}
var setCamera = function(camera) {
getJSON(base_url_archive + "/" + camera + "/", function(err, data) {
if(err != null) {
alert("setCamera" + err);
} else {
var link = document.createElement("a");
link.appendChild(document.createTextNode(".."));
link.setAttribute("href", "#");
link.onclick = function() {
clearPlaylist();
getCameras();
history.pushState("", document.title, window.location.pathname + window.location.search);
return false;
}
archive_playlist.appendChild(link);
archive_playlist.appendChild(document.createElement("br"));
for(i = data.length - 1; i >= 0; i--) {
var link = document.createElement("a");
link.appendChild(document.createTextNode(data[i].name));
link.setAttribute("href", base_url_archive + camera + "/" + data[i].name);
link.setAttribute("data-camera", camera);
link.setAttribute("data-file", data[i].name);
link.onclick = function() {
playFile(this.dataset.camera, this.dataset.file);
return false;
}
archive_playlist.appendChild(link);
archive_playlist.appendChild(document.createElement("br"));
}
}
});
}
window.onhashchange = function() {
if(!window.location.hash) {
clearPlaylist();
getCameras();
} else {
clearPlaylist();
setCamera(window.location.hash.substring(1));
}
}
if(window.location.hash) {
setCamera(window.location.hash.substring(1));
} else {
getCameras();
}
</script>
</body>
</html>