Web端接入视频设备(NVR/IPC)

Posted Yampery

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Web端接入视频设备(NVR/IPC)相关的知识,希望对你有一定的参考价值。

概述

本文主要介绍视频监控设备,视频监控设备接入,常用的开源流媒体及接入过程中的一些问题。

第一章 视频监控设备

目前常见的视频监控设备主要有NVR和IPC,以海康为例。

1.1 视频监控设备介绍

IPC即IP Camera,可以接入网络的摄像头,如下图。

NVR即Network Video Recorder,网络视频录像机,可以进行视频流存储和转发,管理多个IPC,如下图,以海康为例,通道管理可以查看各路IPC视频设备。

1.2 接入方式

视频监控设备接入普遍使用GB28181或RTSP协议介绍,在接入的过程中本人发现,由于云平台发展,GB接入有很多限制,例如海康通常会上一套IVMS和萤石云,萤石云接入可以让用户在手机端查看视频,但是会导致不能使用GB接入,所以我后来都优先使用RTSP协议接入,RTSP整体还是很适合监控视频接入。如下图,是客户现场的一个NVR配置,平台接入使用的萤石云:

RTSP基本主流的监控设备都支持,默认554端口,RTSP支持推拉流双向模式,低延时,很适合监控视频,可以使用VLC播放器播放,不过在浏览器端不能直接播放,需要使用ffmpeg转一下,最终使用RTMP或HLS等形式在浏览器端直播,下面会逐个介绍。

第二章 视频设备接入

视频设备接入有以下几种方式:

  • 使用视频设备SDK解码接入,官方有较详细的资料,比较麻烦;
  • 搭建流媒体服务,使用GB、RTSP等协议接入,流媒体服务开源的比较多,接入较简单,不过也有很多坑要踩一踩。
    下面介绍几种开源流媒体及接入方式。

2.1 SRS

SRS开源地址:SRS开源地址
GB28181分支:已经合并到srs5.0
srs的目标是打造一个好的流媒体服务,而不是接入,srs5.0后来没用过,笔者使用过srs-gb28181(合并之前),gb支持维护并不是很好,接入后有很多问题,例如NVR接入,一段时间后流会掉,需要重启或者手动注销会话后才能再次推流,推流或断开不能回调等。
关于国标的一些问题以及接入方式可参考:SRS国标接入说明

2.2 wvp-GB28181-pro

wvp开源地址:wvp开源地址
wvp需要结合ZLMediaKit流媒体服务,是一个开箱即用的28181协议视频平台,使用Java和Vue开发,包含设备管理、录像、视频广场等功能,代码前后端均开源,开发、部署文档完善,很适合监控设备接入和流媒体前端开发学习。

2.3 EasyDarwin

EasyDarwin是一个纯粹的RTSP流媒体服务,基于golang开发,部署简单,不依赖其他流媒体服务,结合ffmpeg可以实现HLS直播,配合开源的EasyPlayer实现Web端直播,是笔者目前主要使用的流媒体服务。
EasyDarwin开源地址:EasyDarwin开源地址
EasyPlayer Demo开源地址:EasyPlayer Demo开源地址
具体如何部署,参考EasyDwrwin地址即可,接下来说几个存在的问题以及解决方式(有些是临时解决方式)。

  1. 生成ts文件过多的问题
    EasyDarwin运行一段时间会生成大量的ts切片,ts切片生成配置并没放出来,源码写死。
    笔者目前临时解决方案:将下面脚本定时执行,每天凌晨清理一次,并重启EasyDarwin,则会重新生成在当天日期的文件夹,后续从源码改造,前端播放需要根据当前日期生成播放路径:http://IP:10008/record/路径名称/20230105/out.m3u8。
# 本地存储保存路径
root_dir=/var/Streaming
file_name=$(date +%Y%m%d)
current_dir=`pwd`
easydarwin_dir=/opt/EasyDarwin
# EasyDarwin重启后会以当天日期命名重新生成文件夹
systemctl restart EasyDarwin_Service.service
for i in $root_dir/*
    do
    if test -d $i
    then
    	# 由于我只需要直播,不需要回放,直接清理掉昨天的,需要保存的话挪到另一个文件夹即可
        cd $i && rm -rf `ls | grep -v "$file_name"`
        echo "$(date "+%Y-%m-%d %H:%M:%S") clean..." >> $current_dir/clean.log
    fi
done
# 每天凌晨执行
# 0 0 * * * /opt/EasyDarwin/clean_ts.sh
  1. easyplayer.js播放问题
    easyplayer算是一个基于videojs的HLS播放器,对应的播放地址:http://IP:10008/record/路径名称/20230105/out.m3u8。
    使用官方demo的时候发现h265播放黑屏,不过在官方的演示地址和VLC是可以播放的,官方演示地址
    经测试,发现依赖文件EasyPlayer-element.min.js不同,把官方的依赖下载下来替换了demo中的依赖,按照说明部署即可,依赖下载地址
  2. 要改进的问题
    EasyDarwin添加rtsp通道地址很麻烦,需要一个一个加,如一个NVR,用户名密码是不变的,但是我们需要每个都拼接一次地址:rtsp://账号:密码@ip/Streaming/Channel/102,完全可以改成只添加一次NVR地址、用户、密码,然后再统一添加需要监控的通道。

2.4 m7s

m7s(Monibuca ),按作者的定义,是一个开源的Go语言实现的流媒体服务器开发框架,下图仓库分布就能看出来,二次开发接口很强,而且开源已经支持主流的协议。可以到官网下载直接安装,也可以参考文档编译运行,二次开发,后面要重点研究一下。
官网地址:m7s官网地址
开源地址:m7s开源地址

记录监控摄像头的接入过程及web端播放

1.rtsp视频流网页播放概述

需求:当我们通过ONVIF协议,获取到了摄像头的rtsp流地址(长这样:rtsp://admin:123456789@192.168.9.16:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif)后,通过vlc播放器,我们可以查看监控视频内容,可是,我们应该如何在网页上查看视频内容呢?因为现在的浏览器都不支持rtsp流(详见:https://blog.csdn.net/SY__CSDN/article/details/129255690?utm_medium=distribute.pc_relevant.none-task-blog-2defaultbaidujs_baidulandingword~default-0-129255690-blog-113454774.pc_relevant_landingrelevant&spm=1001.2101.3001.4242.1&utm_relevant_index=3),因此我所选用的解决方案便是推流 + 转码

(1)转码推流工具ffmpeg(安装教程详见:https://www.cnblogs.com/h2285409/p/16982120.html),安装好之后,便可使用命令 ffmpeg -re -rtsp_transport tcp -i rtsp://admin:123456789@192.168.9.16:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif -c:v copy -c:a copy -f flv rtmp://127.0.0.1/live/16 将我们的rtsp视频流转码并推至流媒体服务器上,在这个命令中含有两个URL,前面的是我们的rtsp流地址,而后面的URL便是我们流媒体服务器的地址,以及一个-f参数,指定了我们视频流转码后的格式为flv

(2)流媒体服务器,主要调研了2款,一是整合了Rtmp模块的Nginx,二是SRS视频服务器,而我所选用的是SRS(官方文档:http://ossrs.net/lts/zh-cn/),在使用ffmpeg推流上SRS后,便可直接从SRS获得HTTP-FLV视频流地址(如本例:http://127.0.0.1/live/16.flv ),然后,前端通过flv.js组件库便可直接在页面上播放该视频流

SRS与ffmpeg参考:https://blog.csdn.net/diyangxia/article/details/120172920

ffmpeg进阶参考:https://segmentfault.com/a/1190000039782685

flv.js参考:http://www.kaotop.com/it/446261.html

2.rtsp推流转码相关代码实现

//ffmpeg安装路径
@Value("$ffmpegPath")
private String ffmpegPathPrefix;

//srs视频服务器地址
@Value("$srsAddress")
private String srsAddress;

//srs端口,默认为8080
@Value("$srsPort")
private String srsPort;

//srs-http-api端口,默认为1985
@Value("$srsHttpApiPort")
private String srsHttpApiPort;

@Resource
private MonitorMapper monitorMapper;

@Resource
private RedisTemplate redisTemplate;

@Resource
private RestTemplate restTemplate;

@Resource
private ThreadPoolTaskExecutor threadPoolTaskExecutor;

private ConcurrentHashMap<String, TranscodeModel> id2transcodeModelMap = new ConcurrentHashMap<>();

/**
* 进行推流转码
* @param id ipc的主键id
* @return 转码推流后的http-flv地址,前端可通过flv.js直接播放
*/
public String transcodeAndPushStream(String id) 
    Ipc ipc = monitorMapper.getIpcInfoById(id);
    try 
        //先给这个流加锁,防止其他用户请求该流信息
        while(!redisTemplate.opsForValue().setIfAbsent(id, 1, Duration.ofSeconds(60))) 
            Thread.sleep(200);
        
        //避免重复对某一个流的推流工作
        if(!id2transcodeModelMap.containsKey(id)) 
            String command = String.format("%sffmpeg -re -rtsp_transport tcp -i %s -c:v copy -c:a copy -f flv %s",this.ffmpegPathPrefix,  ipc.getRtspUrl(), "rtmp://" + this.srsAddress + "/live/" + id);
            //通过命令行执行推流转码
            System.out.println("启动推流转码, 其命令为: " + command);
            Process process = Runtime.getRuntime().exec(command);
            //可选,开启异步线程,观察推流进程所打印的日志
            Future<Void> processOutputHandler = threadPoolTaskExecutor.submit(() -> 
                BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream()));
                String msg = null;
                try 
                    while ((msg = br.readLine()) != null) 
                        if (Thread.currentThread().isInterrupted()) 
                            System.out.println("关闭推流进程的日志输出线程:  " + Thread.currentThread().getName());
                            break;
                        
                        if (msg.contains("fail") || msg.contains("miss")) 
                            System.err.println(ipc.getId() + " 在推流过程中发生故障或丢包: " + msg);
                        
                    
                 catch (IOException e) 
                    System.err.println(ipc.getId() + " 在推流转码过程中发生异常错误,原因: " + e.getMessage());
                 finally 
                    if (Thread.currentThread().isAlive()) 
                        Thread.currentThread().interrupt();
                    
                
                return null;
            );
            id2transcodeModelMap.put(id, new TranscodeModel(id, command, process, processOutputHandler));
        
     catch (Exception e) 
        this.closeTranscode(id);
        throw new RuntimeException("启动对 " + id + " 的推流转码失败,原因: " + e.getMessage());
     finally 
        redisTemplate.delete(id);
    
    //返回转码后的flv流地址
    return "http://" + this.srsAddress + ":" + this.srsPort + "/live/" + id + ".flv";


/**
* 关闭推流进程
*/
private void closeTranscode(String id) 
    TranscodeModel transcodeModel = null;
    if((transcodeModel = id2transcodeModelMap.get(id)) != null) 
        Future<Void> outputHandler = transcodeModel.getOutputHandler();
        //关闭输出线程
        if(outputHandler != null && !outputHandler.isDone()) 
            outputHandler.cancel(true);
        
        //停止推流转码进程
        if (transcodeModel.getProcess() != null) 
            transcodeModel.getProcess().destroy();
        
        id2transcodeModelMap.remove(id);
        System.out.println("关闭对 " + id + " 的推流转码");
    


/**
* 客户端结束播放流后,srs可配置触发一个on_stop回调,通过该回调,我们就可以知道哪些流可能没人看了,然后结束对该流进行的推流转码工作
* @param data srs触发回调时所携带的参数
*/
public void stopPlay(CallbackOnStopPlay data) 
    String clientId = data.getClient_id();
    JSONObject srsClient = this.requestSrsClientById(clientId);
    if(!srsClient.isEmpty()) 
        String streamId = srsClient.getString("stream");
        if (!StringUtils.hasText(streamId)) 
            System.err.println("获取client " + clientId +" 的流失败, 未关联流");
            return;
        
        //在请求这个流的信息之前,先给这个流加锁,防止其他用户预览该流
        try 
            while(!redisTemplate.opsForValue().setIfAbsent(data.getStream(), 1, Duration.ofSeconds(60))) 
                Thread.sleep(200);
            
            JSONObject vidiconStream = this.requestSrsStreamById(streamId);
            if(!vidiconStream.isEmpty()) 
                Integer clients = vidiconStream.getInteger("clients");
                //当前观看该流的人数 <= 2时,说明没人看了可以停止推流,至于为什么是2,可以自己观察打印日志看看
                if(clients <= 2) 
                    this.closeTranscode(vidiconStream.getString("name"));
                
            
         catch (Exception e) 
            System.err.println("关闭视频流 " + streamId + " 失败, 原因: " + e.getMessage());
         finally 
            redisTemplate.delete(data.getStream());
        
    


/**
 * 根据clientId获取某个client信息
 */
private JSONObject requestSrsClientById(String clientId) 
    if(!StringUtils.hasText(clientId)) 
        return new JSONObject();
    
    String url = "http://" + this.srsAddress + ":" + this.srsHttpApiPort + "/api/v1/clients/" + clientId;
    ResponseEntity<JSONObject> exchange = null;
    try 
        exchange = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null, new HttpHeaders()), JSONObject.class);
     catch (Exception e) 
        System.err.println("请求srs的client " + clientId + " 失败,原因: " + e.getMessage());
        return new JSONObject();
    
    if (exchange == null || exchange.getBody() == null || exchange.getBody().getInteger("code") != 0) 
        System.err.println("请求srs中client " + clientId + " 失败");
        return new JSONObject();
    
    System.out.println("请求到client " + clientId + " 的信息为: " + exchange.getBody());
    return exchange.getBody().getJSONObject("client");


/**
 * 根据流的id获取某个流
 */
private JSONObject requestSrsStreamById(String streamId) 
    if(!StringUtils.hasText(streamId)) 
        return new JSONObject();
    
    String url = "http://" + this.srsAddress + ":" + this.srsHttpApiPort + "/api/v1/streams/" + streamId;
    ResponseEntity<JSONObject> exchange = null;
    try 
        exchange = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null, new HttpHeaders()), JSONObject.class);
     catch (Exception e) 
        System.err.println("请求srs中的流 " + streamId + " 失败,原因: " + e.getMessage());
        return new JSONObject();
    
    if (exchange == null || exchange.getBody() == null || exchange.getBody().getInteger("code") != 0) 
        System.err.println("请求srs中的流 " + streamId + " 失败, 由于服务器重启或其他原因,该流已失效");
        return new JSONObject();
    
    System.out.println("请求到流 " + streamId + " 的信息为: " + exchange.getBody());
    return exchange.getBody().getJSONObject("stream");


public class TranscodeModel 

    private String id;

    private String command;

    private Process process;
    //推流过程中的输出线程
    private Future<Void> outputHandler;


//客户端关闭流时触发的回调所传递的参数
public class CallbackOnStopPlay 
    private String server_id;

    private String action;

    private String client_id;

    private String ip;

    private String vhost;

    private String app;

    private String stream;

    private String param;


//ipc类
public class Ipc 
    //ipc的主键id
    private String id;
    //ipc的rtsp流地址
    private String rtspUrl;

对推流进程的关闭,可以选择定时任务轮询srs中流的信息,然后对那些没人看的流进行关闭,也可以选择配置srs客户端关闭流时的回调,来进行关闭,至于回调如何配置使用,可以详看官方文档中开放接口相关内容和这篇文章:https://blog.csdn.net/weixin_44341110/article/details/120829847

3.通过海康,大华NVR来接入IPC

未完待续...

以上是关于Web端接入视频设备(NVR/IPC)的主要内容,如果未能解决你的问题,请参考以下文章

妙味WEB前端开发全套视频教程+项目实战+移动端开发(99G)

记录监控摄像头的接入过程及web端播放

视频监控/存储系统设计要点

Android平台GB28181设备接入端对接编码前后音视频源类型浅析

视频监控/存储系统设计要点

RTSP/Onvif视频平台EasyNVR手机端实时调阅菜单缺失情况的优化