C#实现 通过ffmpeg拉取海康摄像头rtsp流转m3u8,并在前端播放的解决方案。
前言:
最近拿到一个需求,客户希望在公司自研系统上能够播放他们自己安装的(海康)摄像头实时画面。刚开始跟老大一起技术选型定的是easydarwin 流媒体服务器,去github查看文档然后测试发现拉流非常不稳定经常容易断。随着深入了解发现easydarwin内部本身还是依托的ffmpeg,只不过做了一个前台页面对拉流进程进行了管理,然后提供了接口供调用,在很多情况下并不能满足当前 的需求。所以最终决定还是用自己熟悉的C#去实现这个需求。
查阅资料 常见网络摄像机(摄像头)的端口及RTSP地址发现 海康摄像头视频信号是rtsp流的,C# 调用ffmpeg 拉取rtsp流生成m3u8文件,然后通过js播放m3u8文件。大概思路是这样,不过中间还是踩了很多坑,自己在这里总结一下。
一些下载地址资源:
ffmpeg下载地址:https://pan.baidu.com/s/1n1t7rmal9LnQY8Ngp5YcNA 提取码:vncv
海康客户端 IVMS-4200 V3,3,1,8下载 : https://www.hikvision.com/cn/download_more_390.html#prettyPhoto
开发前准备工作:
1. 配置ffmpeg环境变量,将ffmpeg.exe的路径配入Path环境变量(为了减少篇幅,不懂的麻烦自行百度)
百度的时候, C#通过 Process 命令调用进程网上很多都说直接在命令行里面写exe全路径等等后面发现都无效,所以最省事的办法就是直接配到环境变量里面去。
2. 明确摄像头rtsp地址各个参数的含义.(上文中有链接,这里在强调一下)
例如:rtsp://admin:KTTHVE@192.168.137.239:554/h265/ch1/main/av_stream
1) admin / KTTHVE:摄像头的账号/密码
2)192.168.137.239:摄像头所连接的wifi ip地址, 554 默认端口号
3)h265编码方式
4)ch1 通道1,如果摄像头为热成像摄像头则一般会有两个通道。分别对应普通画面和热成像画面
5)main主码流,sub子码流
验证rtsp是否正确的途径之一就是用上面下载的VCL播放器播放,如果能播放那么就正确(这句话其实有个坑,后文关于热成像画面的时候我们会补充)
特别注意:
1) 如果你的VCL播放器的PC端 IP 和摄像头连得IP不是同一局域网那么默认是访问不到的(当时我就在这卡了很久,所以网络也是有必要好好学的)。其实最简单的方法是通过 cmd ping摄像头的ip 能否ping通,如果ping不通就只能找公司网络工程师解决了。
2) 关于如何查到摄像头的ip, 可以通过海康客户端,搜索可连接设备,然后就可以看到了。
点击设备管理->在线设备->双击记录可以看到摄像头的mac地址和ip
3. 新建windows服务 我这里用的是VS2015版本(第一版的时候只想到用控制台程序手动启动。后面第一次部署到客户服务器的时候客户那边的技术就建议做成服务。万一服务器宕机重启就不用手动启动exe,而且避免了人为误关控制台窗口造成程序中止的问题。所以第二版就改进了一下,确实说的有道理,没有考虑到这个问题,感谢对方的宝贵建议)
3.1 文件->新建项目->...经典桌面-> window服务
3.2 完成后将 HlsService1.cs 重命名为HlsService.cs
3.3 如图所示 点击HlsService 编辑代码
3.4 添加windows安装程序
双击HlsService.cs 在右边空白处右键 -》添加安装程序
然后双击ProjectInstaller.cs,分别点击serviceProcessInstaller1,serviceInstaller1
属性分别配置如下:
3.5 接下来贴代码:HlsService代码
using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.ServiceProcess; using System.Threading; namespace HlsService { public partial class HlsService : ServiceBase { public class ReceiveInfo { public string OnlineRootAddress { get; set; }//外网ip+端口路径 public int IsStartCheck { get; set; } //是否开启定时检查 public int IsRecordLog { get; set; } //重启拉流是否记录日志 //WaitTimeBeforeCheck / LoopPeriodSeconds / OutdateSecends 需要根据服务器cpu等性能 适当调节,电脑配置高可以小点,配置低调大点 //开启服务 多少秒后开启定时检查 摄像头比较多需要所有的都开启了拉流 才进行检查 public int WaitTimeBeforeCheck { get; set; } //定时器循环周期 (秒) 摄像头拉流需要一定时间 否则时间太断 在进行重启检查时 在拉到流之前 文件永远都是过期 public int LoopPeriodSeconds { get; set; }
//判断过期的文件名 public string JudgeFileName { get; set; } //判断文件过期时间 (秒) 切片3秒1个 30个切片 2分钟足够判断过期 public int OutdateSecends { get; set; }
//摄像头配置信息 public List<ConfigInfo> ConfigInfos { get; set; } } public class ConfigInfo { public int CameraId { get; set; } //摄像头id 从1开始递增 public string CameraName { get; set; } //摄像头名称 (对应视频监控添加 标题) public string OutDirName { get; set; } //摄像头推流生成的m3u8文件存放目录名 public string MacAddress { get; set; } //通过mac地址到时候可以方便摄像头所连接的wifi ip public string RtspPath { get; set; } //摄像头rtsp地址 public int ProcessId { get; set; } //摄像头对应的 ffmpeg推流进程ID public string PlayUrl { get; set; } //可播放的m3u8 http地址 (对应视频监控添加 url) } public static string OnlineRootAddress = "";//"test.kingnen.com:12400/";外网ip端口 public static string ConfigFileName = "config.json";//摄像头配置文件名称 public static string BasePath = AppDomain.CurrentDomain.BaseDirectory;//HLSTransfer所在文件夹路径 public static string M3u8FileBaseDir = "FileDir"; //m3u8文件存放 根目录 public static string M3u8FileName = "play.m3u8"; //名称统一为play.m3u8 public static ReceiveInfo receiveInfo = null; //配置文件接收类 public static System.Threading.Timer timer; //定时器 public HlsService() { InitializeComponent(); base.ServiceName = "HlsService"; } protected override void OnStart(string[] args) { MainStart(); } protected override void OnStop() { timer?.Dispose(); StopAllProcess(); } /// <summary> /// 主方法 /// </summary> /// <param name="args"></param> static void MainStart() { if (!ReadConfigFile(BasePath + ConfigFileName)) return; StopAllProcess(); //首先关闭之前所有的拉流 OnlineRootAddress = receiveInfo.OnlineRootAddress;//设置外网ip 端口 foreach (var item in receiveInfo.ConfigInfos) { item.ProcessId = 0; item.PlayUrl = ""; item.ProcessId = StartPull(item); //重启推流 item.PlayUrl = item.ProcessId > 0 ? (OnlineRootAddress + M3u8FileBaseDir + "/" + item.OutDirName + "/" + M3u8FileName) : ""; WriteLog("start cameraId:" + item.CameraId + " ok!, processId:" + item.ProcessId); } //写回配置文件 JsonWriteBack(); if (receiveInfo.IsStartCheck == 1) {
//先保证启动第一次拉流成功,拉流需要时间,所以需要等待一会然后再开启定时任务 Thread.Sleep(receiveInfo.WaitTimeBeforeCheck * 1000); timer = new Timer(p => TaskCheck(), null, 0, receiveInfo.LoopPeriodSeconds*1000);//启动定时任务每 LoopPeriodSeconds启动一次 } } /// <summary> /// 读取配置文件 /// </summary> /// <param name="path"></param> /// <returns></returns> public static bool ReadConfigFile(string path) { using (StreamReader fileRead = new StreamReader(path)) { string strRead = fileRead.ReadToEnd(); if (string.IsNullOrEmpty(strRead)) { WriteLog("read config.json error!"); return false; } try { receiveInfo = JsonConvert.DeserializeObject<ReceiveInfo>(strRead); if (receiveInfo == null || receiveInfo.ConfigInfos == null || receiveInfo.ConfigInfos.Count <= 0) { WriteLog("read config.json error!"); return false; } } catch (Exception) { WriteLog("read config.json error!"); return false; } } return true; } /// <summary> /// 拉流后 将配置重新写回配置文件 /// </summary> /// <param name="receiveInfo"></param> public static void JsonWriteBack() { if (receiveInfo == null || receiveInfo.ConfigInfos == null || receiveInfo.ConfigInfos.Count <= 0) { WriteLog("JsonWriteBack() receiveInfo == null ..."); return; } try { //将配置文件重新写回 using (StreamWriter fileWriter = new StreamWriter(BasePath + ConfigFileName, false)) { //fileWriter.WriteAsync(JsonConvert.SerializeObject(receiveInfo)); fileWriter.Write(JsonConvert.SerializeObject(receiveInfo)); } } catch (Exception e) { WriteLog("JsonWriteBack() error:" + e.Message); } } /// <summary> /// 定时检查拉流进程 /// </summary> public static void TaskCheck() { //重新读取配置文件 if (!ReadConfigFile(BasePath + ConfigFileName)) return; //重新设置外网ip 端口 OnlineRootAddress = receiveInfo.OnlineRootAddress; bool isRestart = true; int changeCount = 0; Process[] processArr = Process.GetProcessesByName("ffmpeg"); List<int> pIdList = (processArr != null && processArr.Length > 0) ? processArr.Select(m => m.Id).ToList() : new List<int>(); foreach (var item in receiveInfo.ConfigInfos) { isRestart = true; //该摄像头对应进程是否需要重启 //进程id存在且m3u8文件未过期 if (pIdList.Contains(item.ProcessId) && item.ProcessId > 0) { string tsFilePath = BasePath+ M3u8FileBaseDir + "/" + item.OutDirName + "/" + receiveInfo.JudgeFileName; if (File.Exists(tsFilePath)) { FileInfo fi = new FileInfo(tsFilePath); if ((DateTime.Now - fi.LastWriteTime).TotalSeconds < receiveInfo.OutdateSecends)//文件未过期 一直在拉流 { isRestart = false; } } else { //覆盖文件时,会存在 JudgeFileName 刚好不存在的情况(ffmpeg会先删除文件然后再生成,所以必须要保证第一次开启所有的摄像头都能生成m3u8文件) isRestart = false; } } //重启进程 if (isRestart) { string str = "Restart CameraId:" + item.CameraId + ", ProcessId:" + item.ProcessId + " -> "; if (pIdList.Contains(item.ProcessId)) { processArr.FirstOrDefault(p => p.Id == item.ProcessId)?.Kill(); } item.ProcessId = 0; item.PlayUrl = ""; item.ProcessId = StartPull(item); //重启推流 if (item.ProcessId > 0) { changeCount++; item.PlayUrl = OnlineRootAddress + M3u8FileBaseDir + "/" + item.OutDirName + "/" + M3u8FileName; } //是否记录日志 if (receiveInfo.IsRecordLog == 1) { WriteLog(str + item.ProcessId); } } } //写回配置文件 if (changeCount > 0) { JsonWriteBack(); } } /// <summary> /// 开启拉流 /// </summary> /// <param name="item"></param> /// <returns></returns> public static int StartPull(ConfigInfo item) { if (item == null) return 0; if (!Directory.Exists(BasePath + M3u8FileBaseDir + "\\" + item.OutDirName)) { Directory.CreateDirectory(BasePath + M3u8FileBaseDir + "\\" + item.OutDirName); } Process p = null; try { var startInfo = new ProcessStartInfo(); startInfo.FileName = "ffmpeg.exe"; //需提前配置环境变量 startInfo.Arguments = " -rtsp_transport tcp -i " + item.RtspPath + " -s 640x480 -force_key_frames \"expr: gte(t, n_forced * 3)\" "; startInfo.Arguments += " -c:v libx264 -hls_time 3 -hls_list_size 30 -hls_wrap 30 -f hls "; startInfo.Arguments += (BasePath + M3u8FileBaseDir + "\\" + item.OutDirName + "\\" + M3u8FileName); startInfo.CreateNoWindow = true; startInfo.UseShellExecute = false; startInfo.Verb = "RunAs";//以管理员身份运行 p = Process.Start(startInfo); return p != null ? p.Id : 0; } catch (Exception ex) { WriteLog("restart cameraId:" + item.CameraId + " error,"+ ex.Message); p?.Close(); return 0; } } /// <summary> /// 结束掉所有的推流进程 /// </summary> public static void StopAllProcess() { WriteLog("StopAllProcess() start.."); //结束掉所有的进程 ffmpeg进程 List<Process> processList = Process.GetProcessesByName("ffmpeg").ToList(); if (processList != null && processList.Count > 0) { processList.ForEach(p => { WriteLog("processId:" + p.Id + " be killed;"); p.Kill(); }); } //将ProcessId,PlayUrl 清空 receiveInfo.ConfigInfos.ForEach(p => { p.ProcessId = 0; p.PlayUrl = ""; }); JsonWriteBack(); processList = Process.GetProcessesByName("CrashServerDamon").ToList(); if (processList != null && processList.Count > 0) processList.ForEach(p => { p.Kill(); }); } /// <summary> /// 写日志 /// </summary> /// <param name="msg"></param> public static void WriteLog(string msg) { string path = BasePath + "Log" + "\\" + DateTime.Now.ToString("yyyyMMdd") + ".txt"; if (!File.Exists(path)) { using (File.Create(path))//释放文件流 { } } using (StreamWriter fileWriter = new StreamWriter(path, true)) { fileWriter.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss ") + "-" + msg); } } } }
Program.cs代码
namespace HlsService { static class Program { /// <summary> /// 应用程序的主入口点。 /// </summary> static void Main() { ServiceBase[] ServicesToRun; ServicesToRun = new ServiceBase[] { new HlsService() }; ServiceBase.Run(ServicesToRun); } } }
3.6 将工程编译编译后,自己创建新的文件夹结构如下:
FileDir,Log文件夹需要自己手动创建。config.json按照HlsService类中的ReceiveInfo类去对应创建。
HlsService.exe, Newtonsoft.Json.dll从工程目录的bin/Debug 或 bin/Release文件夹复制过来
InstallService.bat: 安装服务脚本 exe写绝对路径,内容如下:
installutil E:\HlsService\Service.exe
pause
Uninstall.bat:卸载服务脚本 exe应写绝对路径 内容如下:
installutil E:\HlsService\HlsService.exe /u
pause
注意事项:
1-运行脚本的时候以管理员身份运行
2- 运行时可能会报错 说installutil不存在
将C:\Windows\Microsoft.NET\Framework64\v4.0.30319 配入环境变量
这里只是一个例子,可能Framework64文件夹下有很多.framework版本,然后你选最新版本的文件夹打开,确定里面有installutil.exe 就行了
首先管理员身份启动InstallService.bat, win+R service.msc 看服务是否注册成功,
然后启动确保程序正确运行后,将服务设置为自动启动。
这里有一个小坑:可能ffmpeg无法进行拉流,会报错当前操作系统缺少 mfplat.dll文件
就把下面的地址下载解压后将对应文件夹内的 dll copy到左边文件夹中。
mfplat.dll 下载地址:https://pan.baidu.com/s/195QiIP0f42jXoWGZjCxWGw 提取码:imtf
3.7 本地部署IIS站点 这一步是为了让m3u8文件对应到tcp端口。外网ip+端口 映射到内网ip+端口
按win 键,键盘右下角介于 fn和alt的那个键。输入iis确定,进入到iis管理器。右键网站,选择添加网站。
2 中的目录就是 3.6步骤中 所有文件的父目录,3 端口可以自己定只要不端口冲突就行。
右键Hls(就是你刚刚新建的那个网站),添加虚拟目录。注意名称别名固定FileDir,然后物理路径固定到 3.6步骤中那个 FileDir文件夹。
左键点击Hls,右边双击Http响应表头
双击FileDir,双击右边MIME类型。
寻找 .m3u8, .ts这两项,如果原来已经有的点击编辑把类型替换没有项点击添加。
文件扩展名 MIME类型
.m3u8 application/x-mpegURL
.ts video/MP2T
比如我们当前站点端口是12400,然后我们拉了一个摄像头的流,文件生成名为One, 这时候内网地址就是 localhost:12400/FileDir/One/play.m3u8
这个地址就可以拿到下面的demo本地播放了,至于映射到外网的话 ,就得需要网络工程师去弄这个东西了或者一些内网穿透软件。
3.8 前端播放m3u8
Vjs.rar为前端播放Demo,如果是vue,react,angular对应着各自的语法进行改造。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title></title> <link href="video-js.css" rel="stylesheet"> <script src='video.js'></script> </head> <body> <style> .video-js .vjs-tech {position: relative !important;} </style> <div> <video id="myVideo" class="video-js vjs-default-skin vjs-big-play-centered" controls preload="auto" data-setup='{}' style='width: 100%;height: auto'> <source id="source" src="http://hls.open.ys7.com/openlive/f01018a141094b7fa138b9d0b856507b.m3u8" type="application/x-mpegURL"></source> </video> </div> </body> <script> // videojs 简单使用 var myVideo = videojs('myVideo', { bigPlayButton: true, textTrackDisplay: false, posterImage: false, errorDisplay: false, }) myVideo.play(); </script>
前端Demo下载地址: 链接:https://pan.baidu.com/s/1aVIkKwqzCyPoGDZjk5p6ww 提取码:2mx1
大概就是这样。有问题欢迎评论留言。