前端接入 海康威视摄像头视频(可扩展其他厂家),接入协议 rtsp、hls、ws(针对海康) 【P2】
前端接入 海康威视摄像头视频(可扩展其他厂家),接入协议 rtsp、hls、ws(针对海康) 【P2】
作者环境
- java 1.8.281
- 后端jeecgboot框架
- 前端jeecgboot配套的前端框架
- Vue3
实现思路
- 由于厂家服务器,可能因为各种原因,不能开发某些协议的端口,捡可用的协议播放即可
- 海康厂家提供账号Ak,Sk,后端代理获取各种资料
- 针对hls协议,前端通过hls.js可以直接播放
- 针对rtsp协议,由于web前端无法直接播放,需要后端通过ffmpeg代为转成hls格式后,由前端通过hls.js播放
- 针对ws(针对海康)协议,海康提供了前端SDK,获得播放地址后直接调用即可
依赖引入
▼xml复制代码<dependency> <groupId>com.hikvision.ga</groupId> <artifactId>artemis-http-client</artifactId> <version>1.1.12.RELEASE</version> </dependency>
什么是HLS协议
HLS(HTTP Live Streaming)是一种基于 HTTP 的流媒体传输协议,由苹果公司开发,用于在移动设备和桌面浏览器上进行视频和音频的流式传输。
一、工作原理
-
分段传输
- HLS 将视频或音频内容分割成一系列小的媒体片段,通常每个片段的时长为几秒钟到几十秒钟不等。这些片段可以是不同的编码格式和分辨率,以适应不同的网络条件和设备性能。
- 例如,一个视频可以被分割成多个 10 秒的片段,每个片段都是一个独立的媒体文件。
-
索引文件
- 除了媒体片段之外,HLS 还使用一个索引文件(通常以
.m3u8为扩展名)来描述媒体内容的结构和可用性。索引文件包含了每个媒体片段的 URL 和播放顺序等信息。 - 当客户端请求播放 HLS 流时,首先会下载索引文件,然后根据索引文件中的信息逐个下载并播放媒体片段。
- 除了媒体片段之外,HLS 还使用一个索引文件(通常以
-
自适应码率切换
- HLS 支持自适应码率切换,即根据网络条件和设备性能自动选择最合适的媒体片段进行播放。例如,当网络条件较差时,客户端可以选择较低分辨率和码率的片段进行播放,以保证流畅性;当网络条件改善时,客户端可以自动切换到更高分辨率和码率的片段,以提高画质。
- 为了实现自适应码率切换,HLS 通常会提供多个不同分辨率和码率的媒体片段供客户端选择。
接口文档
编写海康厂商服务类
▼java复制代码package org.jeecg.modules.videoplayplus.util.hk; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.hikvision.artemis.sdk.ArtemisHttpUtil; import com.hikvision.artemis.sdk.config.ArtemisConfig; import lombok.extern.slf4j.Slf4j; import org.jeecg.modules.videoplayplus.entity.PlayReq; import org.jeecg.modules.videoplayplus.entity.PlayResp; import org.jeecg.modules.videoplayplus.util.IVideoPlay; import org.jeecg.modules.videoplayplus.util.VideoPlay; import org.jeecg.modules.videoplayplus.util.pub.ConversionProtocol; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; @Slf4j @VideoPlay public class HKPlay implements IVideoPlay { HashMap<String, ArtemisConfig> AkSks = new HashMap<String, ArtemisConfig>() {{ // 账号 1 键为账号id put("hns", new ArtemisConfig("你的海康服务器IP和端口号", "你的海康账号Ak", "你的海康账号Sk")); // 账号 2 键为账号id put("tlc", new ArtemisConfig("你的海康服务器IP和端口号", "你的海康账号Ak", "你的海康账号Sk")); }}; // 引入redis @Autowired RedisTemplate<String, Object> redisTemplate; // 缓存请求结果的 redis键值前缀 final String VIDEO_MU_HK_REDIS_NAME = "VIDEO_REDIS_NAME"; // 返回厂商id @Override public String getMakerId() { return "mhk"; } // 返回视频树的list结够,地点树结构 @Override public PlayResp tree(PlayReq playReq) { // 构建返回值对象 PlayResp.PlayRespBuilder builder = PlayResp.builder().done(false); // 由于海康的服务器,访问的时候会出现超时的情况,这里加入重试机制,在抛出异常时自动重试,最多尝试10次 for (int i = 0; i < 10; i++) { try { // 判断厂商id是否存在 if (!AkSks.containsKey(playReq.getAkSkId())) throw new RuntimeException("不存在的AkSk"); // 先读取缓存,缓存存在直接返回缓存 Object list = redisTemplate.opsForValue().get(StrUtil.format("{}-mu-hk-tree-{}", VIDEO_MU_HK_REDIS_NAME, playReq.getAkSkId())); if (list != null) { builder.done(true).data(list); return builder.build(); } // 拼接访问海康安防平台的请求地址 String getSecurityApi = "/artemis" + "/api/resource/v1/regions"; Map<String, String> path = new HashMap<String, String>(2) { { put("https://", getSecurityApi); } }; // 拼接请求信息构建请求体 Map<String, Object> paramMap = new HashMap<>(); paramMap.put("pageNo", 1); paramMap.put("pageSize", 999); // 转json String jsonString = JSON.toJSONString(paramMap); // 调用海康sdk的请求工具栏发送请求 String resultJson = ArtemisHttpUtil.doPostStringArtemis(AkSks.get(playReq.getAkSkId()), path, jsonString, null, null, "application/json"); JSONObject jsonObject = JSON.parseObject(resultJson); // 拿到返回值中的code String code = jsonObject.getString("code"); // 检查是否出现异常 if (!Objects.equals(code, "0")) throw new RuntimeException("对方服务器,响应异常"); // 拿到返回信息的总数 Integer intValue = jsonObject.getJSONObject("data").getInteger("total"); // 检查总数是否大于初始值,这里的目的是一次性拿到所有的结果,不分页查询 // 由于pageSize传-1时,海康平台报错,因此只能通过这种方法 // 在总数大于999时,直接是定成总数,重新获取 if (intValue > 999) { // 在总数大于999时,直接是定成总数,重新获取 paramMap.put("pageSize", intValue); String jsonString1 = JSON.toJSONString(paramMap); resultJson = ArtemisHttpUtil.doPostStringArtemis(AkSks.get(playReq.getAkSkId()), path, jsonString1, null, null, "application/json"); jsonObject = JSON.parseObject(resultJson); } code = jsonObject.getString("code"); if (!Objects.equals(code, "0")) throw new RuntimeException("对方服务器,响应异常"); // 拿到结果的list Object outList = jsonObject.getJSONObject("data").get("list"); // 由于结果往往很大,我们将结果缓存3h,避免重复发送请求 redisTemplate.opsForValue().set(StrUtil.format("{}-mu-hk-tree-{}", VIDEO_MU_HK_REDIS_NAME, playReq.getAkSkId()), outList, 3, TimeUnit.HOURS); // 构建返回结果 builder.done(true).data(outList); return builder.build(); } catch (Exception e) { log.error("tree 异常!{}", e.getMessage()); } } return builder.build(); } // 获取所有的视频点位 @Override public PlayResp cameraList(PlayReq playReq) { PlayResp.PlayRespBuilder builder = PlayResp.builder().done(false); // 由于海康的服务器,访问的时候会出现超时的情况,这里加入重试机制,在抛出异常时自动重试,最多尝试10次 for (int i = 0; i < 10; i++) { try { if (!AkSks.containsKey(playReq.getAkSkId())) throw new RuntimeException("不存在的AkSk"); Object list = redisTemplate.opsForValue().get(StrUtil.format("{}-mu-hk-cameraList-{}", VIDEO_MU_HK_REDIS_NAME, playReq.getAkSkId())); if (list != null) { builder.done(true).data(list); return builder.build(); } //1. 接口地址 String getSecurityApi = "/artemis" + "/api/resource/v2/camera/search"; Map<String, String> path = new HashMap<String, String>(2) { { put("https://", getSecurityApi); } }; // 拼接请求信息构建请求体 Map<String, Object> paramMap = new HashMap<>(); // 直接传入根节点,地点码,查询全部信息 paramMap.put("regionIndexCodes", playReq.getRegionIndexCodes()); paramMap.put("pageNo", 1); paramMap.put("pageSize", 999); paramMap.put("isSubRegion", true); String jsonString = JSON.toJSONString(paramMap); // 调用海康sdk的请求工具栏发送请求 String resultJson = ArtemisHttpUtil.doPostStringArtemis(AkSks.get(playReq.getAkSkId()), path, jsonString, null, null, "application/json"); JSONObject jsonObject = JSON.parseObject(resultJson); String code = jsonObject.getString("code"); if (!Objects.equals(code, "0")) throw new RuntimeException("对方服务器,响应异常"); Integer intValue = jsonObject.getJSONObject("data").getInteger("total"); // 检查总数是否大于初始值,这里的目的是一次性拿到所有的结果,不分页查询 // 由于pageSize传-1时,海康平台报错,因此只能通过这种方法 // 在总数大于999时,直接是定成总数,重新获取 if (intValue > 999) { paramMap.put("pageSize", intValue); String jsonString1 = JSON.toJSONString(paramMap); resultJson = ArtemisHttpUtil.doPostStringArtemis(AkSks.get(playReq.getAkSkId()), path, jsonString1, null, null, "application/json"); jsonObject = JSON.parseObject(resultJson); } code = jsonObject.getString("code"); if (!Objects.equals(code, "0")) throw new RuntimeException("对方服务器,响应异常"); Object outList = jsonObject.getJSONObject("data").get("list"); builder.done(true).data(outList); // 由于结果往往很大,我们将结果缓存3h,避免重复发送请求 redisTemplate.opsForValue().set(StrUtil.format("{}-mu-hk-cameraList-{}", VIDEO_MU_HK_REDIS_NAME, playReq.getAkSkId()), outList, 3, TimeUnit.HOURS); return builder.build(); } catch (Exception e) { log.error("cameraList 异常!{}", e.getMessage()); } } return builder.build(); } // 注入协议转化组件 @Autowired ConversionProtocol conversionProtocol; // 视频点的获取预览地址 @Override public PlayResp playUrl(PlayReq playReq) { PlayResp.PlayRespBuilder builder = PlayResp.builder().done(false); // 由于海康的服务器,访问的时候会出现超时的情况,这里加入重试机制,在抛出异常时自动重试,最多尝试10次 for (int i = 0; i < 10; i++) { try { if (!AkSks.containsKey(playReq.getAkSkId())) throw new RuntimeException("不存在的AkSk"); //1. 接口地址 String getSecurityApi = "/artemis" + "/api/video/v2/cameras/previewURLs"; Map<String, String> path = new HashMap<String, String>(2) { { put("https://", getSecurityApi); } }; Map<String, Object> paramMap = new HashMap<String, Object>() {{ // 视频点的视频码 put("cameraIndexCode", playReq.getCameraIndexCode()); // 预览地址的协议类型 put("protocol", playReq.getProtocol()); put("streamType", 0); put("transmode", 1); put("expand", "transcode=0"); }}; String jsonString = JSON.toJSONString(paramMap); // 调用海康sdk的请求工具栏发送请求 String resultJson = ArtemisHttpUtil.doPostStringArtemis(AkSks.get(playReq.getAkSkId()), path, jsonString, null, null, "application/json"); JSONObject jsonObject = JSON.parseObject(resultJson); String code = jsonObject.getString("code"); if (!Objects.equals(code, "0")) throw new RuntimeException("对方服务器,响应异常"); String url = jsonObject.getJSONObject("data").getString("url"); // 根据具体的协议类型来判断具体的操作 // hls、ws直接返回即可 // rtsp协议需要后台,通过ffmpeg转化成 hls协议 if ("hls".equals(playReq.getProtocol())) { builder.data(url).delay(0); } else if ("ws".equals(playReq.getProtocol())) { builder.data(url).delay(0); } else if ("rtsp".equals(playReq.getProtocol())) { // 通过转化器,生成hls地址 String rtspUrl = conversionProtocol.getHlsByRtspUrl(url, playReq.getWebProxyBackPath()); builder.data(rtspUrl).delay(30); } else { throw new RuntimeException("不存在的协议"); } builder.done(true); return builder.build(); } catch (Exception e) { log.error("playUrl 获取url异常!{}", e.getMessage()); } } return builder.build(); } // 云台操控接口 @Override public PlayResp yunControl(PlayReq playReq) { PlayResp.PlayRespBuilder builder = PlayResp.builder().done(false); for (int i = 0; i < 5; i++) { try { if (!AkSks.containsKey(playReq.getAkSkId())) throw new RuntimeException("不存在的AkSk"); //1. 接口地址 String getSecurityApi = "/artemis" + "/api/video/v1/ptzs/controlling"; Map<String, String> path = new HashMap<String, String>(2) { { put("https://", getSecurityApi); } }; Map<String, Object> paramMap = new HashMap<String, Object>() {{ put("cameraIndexCode", playReq.getCameraIndexCode()); put("action", playReq.getAction()); put("command", playReq.getCommand()); }}; String jsonString = JSON.toJSONString(paramMap); // 调用海康sdk的请求工具栏发送请求,来操控摄像头 String resultJson = ArtemisHttpUtil.doPostStringArtemis(AkSks.get(playReq.getAkSkId()), path, jsonString, null, null, "application/json"); JSONObject jsonObject = JSON.parseObject(resultJson); String code = jsonObject.getString("code"); if (!Objects.equals(code, "0")) throw new RuntimeException("对方服务器,响应异常" + code); builder.done(true); return builder.build(); } catch (Exception e) { log.error("yunControl 异常!{}", e.getMessage()); } } return builder.build(); } }
rtsp协议转hls协议转换类
▼java复制代码package org.jeecg.modules.videoplayplus.util.pub; import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.watch.SimpleWatcher; import cn.hutool.core.io.watch.WatchMonitor; import cn.hutool.core.io.watch.watchers.DelayWatcher; import cn.hutool.core.text.StrSpliter; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.RuntimeUtil; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.annotation.PreDestroy; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.WatchEvent; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @Slf4j @Component public class ConversionProtocol { // 启动的 ffmpeg进程map final HashMap<String, Process> map = new HashMap<>(); // 启动的 ffmpeg日志进程map final HashMap<String, BufferedReader> ffmLogMap = new HashMap<>(); // ffmpeg进程启动时间map,方便管理进程,防止长时间运行 final HashMap<String, Long> startTimeMap = new HashMap<>(); // ffmpeg将rtsp转成hls时,生成的.ts文件存储根目录 final String root = "D:/program/ffm-ts"; // 清理线程 Thread clearThread; // 最大不被清除时间 final long maxWaitTime = 3 * 60 * 1000; // 清理线程的循环周期 final long clearWaitTime = 4 * 60 * 1000; // 协议转化,生成hls地址方法 public String getHlsByRtspUrl(String rtspUrl, String proxyPath) throws IOException { List<String> split = StrSpliter.split(rtspUrl, '/', true, true); // 生成转化线程唯一id String m3u8Id = split.get(split.size() - 1); // 构建.ts文件保存路径 String m3u8Path = StrUtil.format("{}/{}", root, m3u8Id); // 创建保存文件 FileUtil.mkdir(Paths.get(m3u8Path).toAbsolutePath().toString()); // 构建.m3u8文件保存路径 String tsPath = Paths.get(StrUtil.format("{}/{}.m3u8", m3u8Path, m3u8Id)).toAbsolutePath().toString(); // 构建启动命令 // 命令的参数的意义见下文 // 记得下载并配置 ffmpeg.exe应用,并配置好环境变量 String[] command = new String[]{"ffmpeg", "-i", rtspUrl, "-codec:", "copy", "-hls_time", "8", "-hls_list_size", "10", "-hls_flags", "delete_segments", "-f", "hls", tsPath}; ProcessBuilder builder = new ProcessBuilder(command); // 启动进程 Process process = builder.start(); // 获取,并启动日志线程 BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream())); Thread thread = new Thread(() -> { String line; while (true) { try { if ((line = errorReader.readLine()) == null) break; log.debug(line); } catch (IOException e) { throw new RuntimeException(e); } } }); thread.start(); // 枷锁,并保存相关信息 synchronized (map) { ffmLogMap.put(m3u8Id, errorReader); map.put(m3u8Id, process); startTimeMap.put(m3u8Id, System.currentTimeMillis()); } // 启动清理线程 creatClearThread(); // 构建并返回 hls流地址 return StrUtil.format("{}/new/video-file/video/{}/{}.m3u8", proxyPath, m3u8Id, m3u8Id); } // 代理读取 hls流文件的函数 public byte[] readVideoFile(String m3u8Id, String name) { // 构建路径 String path = StrUtil.format("{}/{}/{}", root, m3u8Id, name); // 检查文件是否存在 if (FileUtil.exist(path)) { // 文件被读取,证明有人再看流,更新存活时间,防止清理线程将ffmpeg进程关闭。 startTimeMap.put(m3u8Id, System.currentTimeMillis()); // 获取文件字节数据并返回 return FileUtil.readBytes(Paths.get(path).toAbsolutePath().toString()); } return null; } // 清理资源方法 public void stopConversion(String m3u8Id) { // 枷锁, synchronized (map) { log.info("关闭进程 {}", m3u8Id); // 获取并移除ffmpeg进程句柄 Process remove = map.remove(m3u8Id); if (remove != null) { // 关闭进程 remove.destroy(); } // 获取并移除日志流 BufferedReader remove1 = ffmLogMap.remove(m3u8Id); try { if (remove1 != null) { // 关闭日志流 remove1.close(); } } catch (IOException ignored) { } } // 移除存活计时 synchronized (startTimeMap) { log.info("清除计时 {}", m3u8Id); startTimeMap.remove(m3u8Id); } log.info("清理文件 {}", m3u8Id); // 删除生成的文件,释放空间 FileUtil.del(Paths.get(StrUtil.format("{}/{}", root, m3u8Id))); } // 清理线程 private void creatClearThread() { // 如果清理线程不存在,或者失活,则启动线程 if (clearThread == null || !clearThread.isAlive()) { log.info("start clear zhe"); clearThread = new Thread(() -> { while (true) { // 需要被清理的m3u8Id列表 ArrayList<String> clearNames = new ArrayList<>(); // 文件名 List<String> names = new ArrayList<>(); // ffmpeg将rtsp转成hls时,生成的.ts文件存储根目录 Path path1 = Paths.get(root); try { // 获取此时根文件夹下的所有子文件夹的名字 Files.walk(path1) .filter(Files::isDirectory) .forEach(path -> { if (!path.toAbsolutePath().equals(path1)) { names.add(path.getFileName().toString()); } }); // 判断需要被清理的 m3u8Id names.forEach(m3u8Id -> { // 文件存在但是脱离监控的,直接删除 if (startTimeMap.containsKey(m3u8Id)) { Long startTime = startTimeMap.get(m3u8Id); // 被监控了,但是已经超时的,直接删除 if (startTime + maxWaitTime < System.currentTimeMillis()) { clearNames.add(m3u8Id); } } else { clearNames.add(m3u8Id); } }); // 循环调用清理资源方法 // 释放资源 clearNames.forEach(this::stopConversion); // 休眠一段时间后再次检查 Thread.sleep(clearWaitTime); } catch (InterruptedException | IOException e) { throw new RuntimeException(e); } } }); clearThread.start(); } } // 在应用程序关闭时,关闭所有正在运行的ffmpeg进程 @PreDestroy public void close() { log.info("关闭 ffmpeg process..."); map.forEach((s, process) -> process.destroy()); log.info("关闭 ffmpeg process"); } }
启动命令解释
▼text复制代码{"ffmpeg", "-i", rtspUrl, "-codec:", "copy", "-hls_time", "8", "-hls_list_size", "10", "-hls_flags", "delete_segments", "-f", "hls", tsPath}
FFmpeg 命令行工具将一个 RTSP(Real Time Streaming Protocol)视频流转换为 HLS(HTTP Live Streaming)格式,并保存到指定的路径。
以下是对各个参数的解释:
"ffmpeg":调用 FFmpeg 命令行工具。"-i", rtspUrl:指定输入的视频源为rtspUrl,即一个 RTSP 视频流地址。"-codec:", "copy":表示复制输入流的编码格式,不进行重新编码,这样可以加快处理速度。"-hls_time", "8":设置 HLS 视频片段的时长为 8 秒。"-hls_list_size", "10":设置 HLS 播放列表中可保留的最大片段数量为 10。"-hls_flags", "delete_segments":设置 HLS 标志为在播放列表更新时删除旧的片段。"-f", "hls":指定输出格式为 HLS。tsPath:指定输出的 HLS 文件的保存路径。
这种方式可以将 RTSP 视频流转换为 HLS 格式,以便在支持 HLS 播放的设备上进行播放,例如在网页浏览器中使用 HTML5 的 <video>
标签播放 HLS 视频流。
结语
- 这期实现播放所需的后端部分
- 下期,会编写web前端播放的实现
最终目录结构
传送门
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论

