Nodejs拉取海康威视行车记录仪摄像头视频流的实现方法
1.背景
现有需求,将海康威视行车记录仪摄像头视频流显示在开发web页面上,基于诸多方面的考虑,采用海康的云平台作为转发、存储视频流的平台,而没有搭建私有的视频解决方案。如何将视频流显示在自己的web页面上呢,采用了利用海康提供的sdk截取转发视频流的方式。在研发此方案的过程中,经历了诸多试错,本方案只是诸多尝试中的可以成功运行的方案之一。
本方案采用的是nodejs方案,将RTSP视频流通过FFmpeg转换为FLV格式,并通过WebSocket转发给前端播放器。优点是支持多通道并发并能支持一定的延时缓冲。
2实现
2.1海康汽车SDK
海康汽车sdk即海康汽车电子云API服务,为开发者提供https接口,即开发者通过https方式发起检索请求,获取返回json数据。具体代码可参见海康的官网:https://open.hikvisionauto.com/#/developDoc/api/HttpsAPI
官网似乎进行了改版,对代码进行了删减。改版前有java语言的版本,功能较丰富。以下是根据java语言代码改写成的nodejs语言的版本,支持预览,获得录像列表及查看录像:
/**
* 海康威视汽车设备SDK封装类
* 用于获取设备预览、视频列表和回放功能
*/
const crypto = require('crypto');
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
const querystring = require('querystring');
class CarSDKApp {
/**
* 构造函数 - 初始化SDK配置参数
*/
constructor() {
// API访问密钥
this.ACCESS_KEY = "****************";
// API访问密钥
this.ACCESS_SECRET = "**************";
// 签名方法
this.SIGNATURE_METHOD = "HMAC-SHA1";
// 默认字符编码
this.DEFAULT_CHARSET = "UTF-8";
// 区域ID
this.REGION_ID = "cn-hangzhou";
// API版本号
this.VERSION = "2.1.0";
// API基础URL
this.BASE_URL = "https://open.hikvisionauto.com:14021/v2/";
}
/**
* 获取基础请求参数
* @returns {Object} 包含签名所需基础参数的对象
*/
getBaseParams() {
const params = {
SignatureMethod: this.SIGNATURE_METHOD,
SignatureNonce: uuidv4(), // 随机唯一标识符
AccessKey: this.ACCESS_KEY,
Timestamp: Date.now().toString(), // 当前时间戳
Version: this.VERSION,
RegionId: this.REGION_ID
};
// 按键名排序参数
return Object.keys(params).sort().reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
}
/**
* 特殊URL编码
* 将特殊字符按照API要求进行编码
* @param {string} value - 需要编码的值
* @returns {string} 编码后的字符串
*/
specialUrlEncode(value) {
return querystring.escape(value)
.replace(/\+/g, '%20')
.replace(/\*/g, '%2A')
.replace(/%7E/g, '~');
}
/**
* 生成待签名字符串
* @param {Object} params - 请求参数对象
* @param {string} method - HTTP请求方法(GET/POST)
* @returns {string} 待签名字符串
*/
getStringToSign(params, method) {
// 确保参数按键名排序
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
// 构建排序后的查询字符串
const sortQueryStringTmp = Object.entries(sortedParams)
.map(([key, value]) => `&${this.specialUrlEncode(key)}=${this.specialUrlEncode(value)}`)
.join('');
// 返回格式:HTTP方法&URL编码的路径&URL编码的查询字符串
return `${method}&${this.specialUrlEncode('/')}&${this.specialUrlEncode(sortQueryStringTmp.substring(1))}`;
}
/**
* 生成API签名
* @param {string} accessSecret - 访问密钥
* @param {Object} params - 请求参数
* @param {string} method - HTTP请求方法
* @returns {string} Base64编码的签名字符串
*/
sign(accessSecret, params, method) {
const stringToSign = this.getStringToSign(params, method);
console.log(`StringToSign = [${stringToSign}]`);
// 使用HMAC-SHA1算法生成签名
const hmac = crypto.createHmac('sha1', accessSecret);
hmac.update(stringToSign);
return hmac.digest('base64');
}
/**
* 获取设备实时预览URL
* @param {string} deviceCode - 设备编码
* @param {number} channelID - 通道ID(可选)
* @returns {Promise<string>} 预览URL
*/
async preview(deviceCode, channelID) {
const url = this.BASE_URL + "device/preview/";
const BASE_PARAMS = this.getBaseParams();
// 添加业务参数
BASE_PARAMS.deviceCode = deviceCode;
if (channelID !== undefined) {
BASE_PARAMS.channelNo = channelID.toString();
}
BASE_PARAMS.streamType = "0"; // 0:主码流 1:子码流
// 生成签名(注意:签名时不包含 Signature 参数本身)
const sign = this.sign(this.ACCESS_SECRET + "&", BASE_PARAMS, "GET");
// 构建完整的查询字符串(包含签名)
const allParams = { ...BASE_PARAMS, Signature: sign };
const queryString = Object.entries(allParams)
.map(([key, value]) => `${this.specialUrlEncode(key)}=${this.specialUrlEncode(value)}`)
.join('&');
const fullUrl = `${url}?${queryString}`;
console.log("sendurl:", fullUrl);
try {
const response = await axios.get(fullUrl);
console.log("response:", response.data);
// 检查响应状态
if (response.data.status !== 0) {
throw new Error(`API Error: ${response.data.msg} (status: ${response.data.status})`);
}
// 提取预览URL
const previewUrl = response.data.data;
console.log("previewUrl:", previewUrl);
return previewUrl;
} catch (error) {
console.error('Error in preview:', error);
throw error;
}
}
/**
* 获取设备视频列表
* @param {string} deviceCode - 设备编码
* @param {string} startTime - 开始时间(格式:YYYY-MM-DD HH:mm:ss)
* @param {string} endTime - 结束时间(格式:YYYY-MM-DD HH:mm:ss)
* @returns {Promise<Object>} 视频列表响应数据
*/
async list(deviceCode, startTime, endTime) {
const url = this.BASE_URL + "device/videoList/";
const BASE_PARAMS = this.getBaseParams();
BASE_PARAMS.deviceCode = deviceCode;
BASE_PARAMS.startTime = startTime;
BASE_PARAMS.endTime = endTime;
const sortQueryStringTmp = Object.entries(BASE_PARAMS)
.map(([key, value]) => `&${this.specialUrlEncode(key)}=${this.specialUrlEncode(value)}`)
.join('');
const sign = this.sign(this.ACCESS_SECRET + "&", BASE_PARAMS, "GET");
const fullUrl = url + `?Signature=${this.specialUrlEncode(sign)}${sortQueryStringTmp}`;
try {
const response = await axios.get(fullUrl);
console.log(response.data);
return response.data;
} catch (error) {
console.error('Error in list:', error);
throw error;
}
}
/**
* 获取设备视频回放URL
* @param {string} deviceCode - 设备编码
* @param {string} startTime - 开始时间(格式:YYYY-MM-DD HH:mm:ss)
* @param {string} endTime - 结束时间(格式:YYYY-MM-DD HH:mm:ss)
* @param {number} fileSize - 文件大小(字节)
* @returns {Promise<Object>} 回放URL响应数据
*/
async replay(deviceCode, startTime, endTime, fileSize) {
const url = this.BASE_URL + "device/videoReplay/";
const BASE_PARAMS = this.getBaseParams();
BASE_PARAMS.deviceCode = deviceCode;
BASE_PARAMS.startTime = startTime;
BASE_PARAMS.endTime = endTime;
BASE_PARAMS.fileSize = fileSize.toString();
const sortQueryStringTmp = Object.entries(BASE_PARAMS)
.map(([key, value]) => `&${this.specialUrlEncode(key)}=${this.specialUrlEncode(value)}`)
.join('');
const sign = this.sign(this.ACCESS_SECRET + "&", BASE_PARAMS, "GET");
const fullUrl = url + `?Signature=${this.specialUrlEncode(sign)}${sortQueryStringTmp}`;
try {
const response = await axios.get(fullUrl);
console.log(response.data);
return response.data;
} catch (error) {
console.error('Error in replay:', error);
throw error;
}
}
}
2.2Nodejs视频流服务器
首先是引用包
/**
* RTSP视频流WebSocket转发服务器
* 功能:将RTSP视频流通过FFmpeg转换为FLV格式,并通过WebSocket转发给前端播放器
*/
var express = require("express");
var expressWebSocket = require("express-ws");
var ffmpeg = require("fluent-ffmpeg");
// 设置FFmpeg可执行文件路径
ffmpeg.setFfmpegPath("C:\\ffmpeg-7.1.1-full_build\\bin\\ffmpeg.exe");
var webSocketStream = require("websocket-stream/stream");
var WebSocket = require("websocket-stream");
var http = require("http");
然后是具体实现代码。
// 获取车牌号到设备ID的映射
const carToDeviceDict = initFromDB();
/**
* 启动本地WebSocket服务器
* 监听8002端口,处理RTSP视频流转发请求
*/
function localServer() {
let app = express();
// 提供静态文件服务
app.use(express.static(__dirname));
// 添加WebSocket支持,启用消息压缩
expressWebSocket(app, null, {
perMessageDeflate: true
});
// 注册WebSocket路由处理函数
app.ws("/rtsp/", rtspRequestHandle)
// 启动HTTP服务器监听8002端口
app.listen(8002);
console.log("express listened")
}
/**
* 处理RTSP视频流WebSocket请求
* @param {WebSocket} ws - WebSocket连接对象
* @param {Request} req - HTTP请求对象
*/
async function rtspRequestHandle(ws, req) {
console.log("rtsp request handle");
// 将WebSocket转换为可读写的流
const stream = webSocketStream(ws, {
binary: true, // 使用二进制模式传输
browserBufferTimeout: 1000000 // 浏览器缓冲区超时时间(毫秒)
}, {
browserBufferTimeout: 1000000
});
// 从请求参数中获取车牌号和通道号
let carID = req.query.carID;
let channel = req.query.channel;
console.log("car number:", carID);
// 将车牌号转换为设备ID
const dict = initFromDB();
const deviceId = dict[carID];
// 检查设备ID是否存在
if (!deviceId) {
console.error("Device ID not found for car:", carID);
ws.close();
return;
}
console.log("deviceId:", deviceId, "channel:", channel);
// 导入海康威视汽车设备SDK
const CarSDKApp = require("./carsdkapp");
const carSDKApp = new CarSDKApp();
try {
// 调用SDK获取设备实时预览URL
const url = await carSDKApp.preview(deviceId, channel);
console.log("url:", url);
// 使用FFmpeg处理RTSP流并转换为FLV格式
ffmpeg(url)
// 输入选项配置
.addInputOption("-rtsp_transport", "tcp", "-buffer_size", "102400") // 使用TCP传输,设置缓冲区大小
.addInputOption("-fflags", "nobuffer") // 禁用输入缓冲,减少延迟
.addInputOption("-flags", "low_delay") // 低延迟模式
.addInputOption("-strict", "experimental") // 允许实验性编解码器
// 输出选项配置
.addOutputOption("-f", "flv") // 输出格式为FLV
.addOutputOption("-preset", "ultrafast") // 编码预设:最快速度
.addOutputOption("-tune", "zerolatency") // 调优:零延迟
.addOutputOption("-g", "30") // 关键帧间隔(GOP大小)
.addOutputOption("-keyint_min", "30") // 最小关键帧间隔
.addOutputOption("-sc_threshold", "0") // 禁用场景切换检测
// 视频编解码器设置
.videoCodec("libx264") // 使用H.264编码
.noAudio() // 禁用音频
.format("flv") // 输出格式为FLV
// 事件监听器
.on("start", function (commandLine) {
console.log("FFmpeg started with command:", commandLine);
})
.on("codecData", function (data) {
console.log("Stream codecData:", data);
// 摄像机在线处理
})
.on("error", function (err) {
console.log("FFmpeg error:", err.message);
console.log("Error details:", err);
})
.on("end", function () {
console.log("Stream ended");
// 摄像机断线的处理
})
.on("stderr", function (stderrLine) {
console.log("FFmpeg stderr:", stderrLine);
})
// 将处理后的流通过WebSocket发送给客户端
.pipe(stream, { end: true });
} catch (error) {
console.log("Error getting preview URL or starting ffmpeg:", error);
}
}
// 启动服务器
localServer();
2.3页面代码
<body>
<!-- 页面标题 -->
<div class="header">
六通道视频监控系统
</div>
<!-- 系统状态栏 -->
<div class="status-bar">
<span id="status">系统准备就绪 - 正在初始化...</span>
</div>
<!-- 通道状态指示器 -->
<div class="channel-status">
<div class="channel-indicator" id="indicator1">通道1: 断开</div>
<div class="channel-indicator" id="indicator2">通道2: 断开</div>
<div class="channel-indicator" id="indicator3">通道3: 断开</div>
<div class="channel-indicator" id="indicator4">通道4: 断开</div>
<div class="channel-indicator" id="indicator5">通道5: 断开</div>
<div class="channel-indicator" id="indicator6">通道6: 断开</div>
</div>
<!-- 视频播放网格 -->
<div class="video-grid">
<!-- 通道1 -->
<div class="video-container">
<div class="video-header">通道 1</div>
<div class="video-box">
<video controls="controls" class="demo-video" id="player1" muted></video>
<div id="loading1" class="loading" style="display: block;">加载中...</div>
</div>
</div>
<!-- 通道2 -->
<div class="video-container">
<div class="video-header">通道 2</div>
<div class="video-box">
<video controls="controls" class="demo-video" id="player2" muted></video>
<div id="loading2" class="loading" style="display: block;">加载中...</div>
</div>
</div>
<!-- 通道3 -->
<div class="video-container">
<div class="video-header">通道 3</div>
<div class="video-box">
<video controls="controls" class="demo-video" id="player3" muted></video>
<div id="loading3" class="loading" style="display: block;">加载中...</div>
</div>
</div>
<!-- 通道4 -->
<div class="video-container">
<div class="video-header">通道 4</div>
<div class="video-box">
<video controls="controls" class="demo-video" id="player4" muted></video>
<div id="loading4" class="loading" style="display: block;">加载中...</div>
</div>
</div>
<!-- 通道5 -->
<div class="video-container">
<div class="video-header">通道 5</div>
<div class="video-box">
<video controls="controls" class="demo-video" id="player5" muted></video>
<div id="loading5" class="loading" style="display: block;">加载中...</div>
</div>
</div>
<!-- 通道6 -->
<div class="video-container">
<div class="video-header">通道 6</div>
<div class="video-box">
<video controls="controls" class="demo-video" id="player6" muted></video>
<div id="loading6" class="loading" style="display: block;">加载中...</div>
</div>
</div>
</div>
<!-- 控制按钮区域 -->
<div class="controls">
<button class="btn" onclick="reconnectAll()">🔄 重新连接所有通道</button>
<div class="btn-group">
<button class="btn" onclick="reconnectChannel(1)">重连通道1</button>
<button class="btn" onclick="reconnectChannel(2)">重连通道2</button>
<button class="btn" onclick="reconnectChannel(3)">重连通道3</button>
</div>
<div class="btn-group">
<button class="btn" onclick="reconnectChannel(4)">重连通道4</button>
<button class="btn" onclick="reconnectChannel(5)">重连通道5</button>
<button class="btn" onclick="reconnectChannel(6)">重连通道6</button>
</div>
</div>
<!-- 引入 flv.js 库 - 用于播放FLV格式的视频流 -->
<script src="./flv.js"></script>
<script>
/**
* 视频播放器类
* 封装单个通道的视频播放功能
*/
class VideoPlayer {
/**
* 构造函数
* @param {number} channel - 通道编号(1-6)
*/
constructor(channel) {
this.channel = channel;
this.player = null; // flv.js播放器实例
this.loading = true; // 加载状态标志
this.videoElement = document.getElementById(`player${channel}`);
this.loadingElement = document.getElementById(`loading${channel}`);
// WebSocket视频流地址
this.rtspUrl = `ws://localhost:8888/rtsp?deviceId=16065442039&channel=${channel}`;
this.init();
}
/**
* 初始化播放器
* 绑定事件监听器并开始播放
*/
init() {
// 绑定双击全屏事件
this.videoElement.addEventListener('dblclick', () => {
this.fullScreen();
});
// 等待 flv.js 加载完成后播放视频
this.waitForFlvjs();
// 页面卸载时清理资源
window.addEventListener('beforeunload', () => {
this.cleanup();
});
}
/**
* 等待flv.js库加载完成
* 轮询检查直到flv.js可用
*/
waitForFlvjs() {
// 检查 flv.js 是否已加载
if (typeof flvjs !== 'undefined') {
this.playVideo();
} else {
// 如果还没加载,等待 100ms 后重试
setTimeout(() => {
this.waitForFlvjs();
}, 100);
}
}
/**
* 切换全屏播放
*/
fullScreen() {
const video = this.videoElement;
if (video.requestFullscreen) {
video.requestFullscreen();
} else if (video.mozRequestFullScreen) {
video.mozRequestFullScreen();
} else if (video.webkitRequestFullScreen) {
video.webkitRequestFullScreen();
} else if (video.msRequestFullscreen) {
video.msRequestFullscreen();
}
}
/**
* 播放视频
* 创建flv.js播放器并开始播放FLV流
*/
playVideo() {
const time1 = new Date().getTime();
this.updateChannelStatus('connecting');
// 检查浏览器是否支持FLV.js
if (flvjs.isSupported()) {
const video = this.videoElement;
if (video) {
// 如果已有播放器,先清理
if (this.player) {
this.player.unload();
this.player.destroy();
this.player = null;
this.loading = true;
this.loadingElement.style.display = 'block';
}
// 创建新的播放器
this.player = flvjs.createPlayer({
type: 'flv',
isLive: true, // 标记为直播流
url: this.rtspUrl
});
// 将播放器绑定到video元素
this.player.attachMediaElement(video);
try {
// 加载并播放视频
this.player.load();
this.player.play().then(() => {
console.log(`通道${this.channel}播放开始,耗时:`, new Date().getTime() - time1, 'ms');
this.loading = false;
this.loadingElement.style.display = 'none';
this.updateChannelStatus('connected');
this.updateGlobalStatus(`通道${this.channel}连接成功`);
}).catch((error) => {
console.error(`通道${this.channel}播放失败:`, error);
this.loadingElement.textContent = '播放失败';
this.updateChannelStatus('disconnected');
this.updateGlobalStatus(`通道${this.channel}播放失败`);
});
} catch (error) {
console.error(`通道${this.channel}加载失败:`, error);
this.loadingElement.textContent = '加载失败';
this.updateChannelStatus('disconnected');
this.updateGlobalStatus(`通道${this.channel}加载失败`);
}
}
} else {
console.error('当前浏览器不支持 FLV.js');
this.loadingElement.textContent = '当前浏览器不支持 FLV 播放';
this.updateChannelStatus('disconnected');
this.updateGlobalStatus('浏览器不支持 FLV 播放');
}
}
/**
* 更新通道状态指示器
* @param {string} status - 状态值:connected/connecting/disconnected
*/
updateChannelStatus(status) {
const indicator = document.getElementById(`indicator${this.channel}`);
indicator.className = 'channel-indicator';
switch(status) {
case 'connected':
indicator.classList.add('connected');
indicator.textContent = `通道${this.channel}: 已连接`;
break;
case 'connecting':
indicator.classList.add('connecting');
indicator.textContent = `通道${this.channel}: 连接中...`;
break;
case 'disconnected':
default:
indicator.textContent = `通道${this.channel}: 断开`;
break;
}
}
/**
* 更新全局状态栏
* @param {string} message - 状态消息
*/
updateGlobalStatus(message) {
const statusElement = document.getElementById('status');
const timestamp = new Date().toLocaleTimeString();
statusElement.textContent = `[${timestamp}] ${message}`;
}
/**
* 重新播放(用于重连等场景)
*/
replay() {
this.playVideo();
}
/**
* 清理播放器资源
*/
cleanup() {
if (this.player) {
this.player.unload();
this.player.destroy();
this.player = null;
}
}
}
// 全局变量存储播放器实例
let videoPlayers = {};
/**
* 初始化所有视频播放器
* 确保flv.js加载完成后创建六个通道的播放器
*/
function initVideoPlayers() {
if (typeof flvjs !== 'undefined') {
// 初始化六个通道的播放器
for (let i = 1; i <= 6; i++) {
videoPlayers[i] = new VideoPlayer(i);
}
console.log('六通道视频播放器初始化完成');
updateGlobalStatus('六通道视频播放器初始化完成');
// 注册键盘快捷键
document.addEventListener('keydown', function(event) {
switch(event.key.toLowerCase()) {
case 'r':
// R键:重连所有通道
reconnectAll();
break;
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
// 数字键1-6:重连对应通道
reconnectChannel(parseInt(event.key));
break;
case 'a':
// A键:重连所有通道
reconnectAll();
break;
}
});
} else {
// 如果 flv.js 还没加载,等待 100ms 后重试
setTimeout(initVideoPlayers, 100);
}
}
/**
* 重连所有通道
* 依次延迟启动每个通道,避免同时发起太多连接
*/
function reconnectAll() {
console.log('重新连接所有通道...');
updateGlobalStatus('正在重新连接所有通道...');
Object.values(videoPlayers).forEach((player, index) => {
if (player) {
// 延迟启动,避免同时发起太多连接
setTimeout(() => {
player.replay();
}, index * 500);
}
});
}
/**
* 重连指定通道
* @param {number} channel - 通道编号(1-6)
*/
function reconnectChannel(channel) {
console.log(`重新连接通道${channel}...`);
if (videoPlayers[channel]) {
videoPlayers[channel].replay();
updateGlobalStatus(`正在重新连接通道${channel}...`);
}
}
/**
* 更新全局状态
* @param {string} message - 状态消息
*/
function updateGlobalStatus(message) {
const statusElement = document.getElementById('status');
const timestamp = new Date().toLocaleTimeString();
statusElement.textContent = `[${timestamp}] ${message}`;
}
/**
* 获取连接统计信息
* @returns {Object} 包含已连接、连接中、断开数量的对象
*/
function getConnectionStats() {
const indicators = document.querySelectorAll('.channel-indicator');
let connected = 0;
let connecting = 0;
let disconnected = 0;
indicators.forEach(indicator => {
if (indicator.classList.contains('connected')) {
connected++;
} else if (indicator.classList.contains('connecting')) {
connecting++;
} else {
disconnected++;
}
});
return { connected, connecting, disconnected };
}
// 定期更新连接统计(每5秒)
setInterval(() => {
const stats = getConnectionStats();
if (stats.connected > 0 || stats.connecting > 0) {
updateGlobalStatus(`连接状态: ${stats.connected}个已连接, ${stats.connecting}个连接中, ${stats.disconnected}个断开`);
}
}, 5000);
// 页面加载完成后初始化播放器
document.addEventListener('DOMContentLoaded', initVideoPlayers);
</script>
</body>
3总结
使用nodejs拉取rtsp视频流,通过WebSocket转发给前端,并使用flv播放,不失为一种可行方案。
到此这篇关于Nodejs拉取海康威视行车记录仪摄像头视频流的文章就介绍到这了,更多相关Nodejs拉取海康威视行车记录仪内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
相关文章
Mongoose中document与object的区别示例详解
这篇文章主要给大家介绍了关于Mongoose中document与object区别的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考借鉴,下面随着小编来一起学习学习吧。2017-09-09
nodejs清空/删除指定文件夹下面所有文件或文件夹的方法示例
这篇文章主要介绍了nodejs清空/删除指定文件夹下面所有文件或文件夹的方法,通过两个具体案例形式分析了node.js同步删除文件/文件夹,以及异步删除文件/文件夹的相关实现技巧,涉及递归遍历与文件判断、回调等相关操作,需要的朋友可以参考下2023-04-04


最新评论