在 Web 页面中实时显示海康威视行车记录仪摄像头视频流,常见方案是利用海康云平台做转发和存储,再通过 SDK 截取流。本文介绍一种经实际验证的 Node.js 方案:通过海康汽车电子云 API 获取设备预览 URL(RTSP 地址),再使用 FFmpeg 将 RTSP 流转码为 FLV 格式,最后通过 WebSocket 推送到前端 flv.js 播放。该方案支持多通道并发,具有可调节的缓冲延时。
- // 海康汽车设备 SDK 封装类
- // 用于获取设备预览 URL、录像列表和回放地址
- const crypto = require('crypto');
- const axios = require('axios');
- const { v4: uuidv4 } = require('uuid');
- const querystring = require('querystring');
- class CarSDKApp {
- constructor() {
- this.ACCESS_KEY = "****************";
- this.ACCESS_SECRET = "**************";
- this.SIGNATURE_METHOD = "HMAC-SHA1";
- this.DEFAULT_CHARSET = "UTF-8";
- this.REGION_ID = "cn-hangzhou";
- this.VERSION = "2.1.0";
- this.BASE_URL = "https://open.hikvisionauto.com:14021/v2/";
- }
- 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;
- }, {});
- }
- specialUrlEncode(value) {
- return querystring.escape(value)
- .replace(/\+/g, '%20')
- .replace(/\*/g, '%2A')
- .replace(/%7E/g, '~');
- }
- 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('');
- return `${method}&${this.specialUrlEncode('/')}&${this.specialUrlEncode(sortQueryStringTmp.substring(1))}`;
- }
- sign(accessSecret, params, method) {
- const stringToSign = this.getStringToSign(params, method);
- const hmac = crypto.createHmac('sha1', accessSecret);
- hmac.update(stringToSign);
- return hmac.digest('base64');
- }
- 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:子码流
- 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);
- if (response.data.status !== 0) {
- throw new Error(`API Error: ${response.data.msg} (status: ${response.data.status})`);
- }
- const previewUrl = response.data.data;
- console.log("previewUrl:", previewUrl);
- return previewUrl;
- } catch (error) {
- console.error('Error in preview:', error);
- throw error;
- }
- }
- 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;
- }
- }
- 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;
- }
- }
- }
复制代码
以上代码中,ACCESS_KEY 和 ACCESS_SECRET 需替换为开发者真实密钥。preview() 方法返回一个 RTSP 预览 URL,该 URL 将在后续 FFmpeg 处理中使用。SDK 的签名算法遵循海康汽车云 API 规范,采用 HMAC-SHA1,参数按字典序排序后进行 URL 编码。
接下来是 Node.js 视频流转发服务器,它基于 Express 和 WebSocket 实现。服务器监听 8002 端口,通过车牌号从数据库映射获取设备 ID,调用 SDK 得到 RTSP 地址,然后用 FFmpeg 将其转为 FLV 并通过 WebSocket 管道发送给客户端。
- // RTSP 视频流 WebSocket 转发服务器
- var express = require("express");
- var expressWebSocket = require("express-ws");
- var ffmpeg = require("fluent-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();
- function localServer() {
- let app = express();
- app.use(express.static(__dirname));
- expressWebSocket(app, null, {
- perMessageDeflate: true
- });
- app.ws("/rtsp/", rtspRequestHandle)
- app.listen(8002);
- console.log("express listened")
- }
- async function rtspRequestHandle(ws, req) {
- console.log("rtsp request handle");
- 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);
- const dict = initFromDB();
- const deviceId = dict[carID];
- if (!deviceId) {
- console.error("Device ID not found for car:", carID);
- ws.close();
- return;
- }
- console.log("deviceId:", deviceId, "channel:", channel);
- const CarSDKApp = require("./carsdkapp");
- const carSDKApp = new CarSDKApp();
- try {
- const url = await carSDKApp.preview(deviceId, channel);
- console.log("url:", url);
- ffmpeg(url)
- .addInputOption("-rtsp_transport", "tcp", "-buffer_size", "102400")
- .addInputOption("-fflags", "nobuffer")
- .addInputOption("-flags", "low_delay")
- .addInputOption("-strict", "experimental")
- .addOutputOption("-f", "flv")
- .addOutputOption("-preset", "ultrafast")
- .addOutputOption("-tune", "zerolatency")
- .addOutputOption("-g", "30")
- .addOutputOption("-keyint_min", "30")
- .addOutputOption("-sc_threshold", "0")
- .videoCodec("libx264")
- .noAudio()
- .format("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);
- })
- .on("end", function () {
- console.log("Stream ended");
- })
- .on("stderr", function (stderrLine) {
- console.log("FFmpeg stderr:", stderrLine);
- })
- .pipe(stream, { end: true });
- } catch (error) {
- console.log("Error getting preview URL or starting ffmpeg:", error);
- }
- }
- localServer();
复制代码
注意 FFmpeg 路径需要根据实际安装位置修改。服务器启动后,前端页面通过 WebSocket 连接 ws://localhost:8002/rtsp/?carID=xxx&channel=1 来获取特定通道的 FLV 流。前端页面使用 flv.js 播放,HTML 结构包含六个视频容器和对应的控制按钮,每个视频框内有一个元素和加载提示。
- <!-- 页面核心部分 -->
- <div class="video-grid">
- <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>
- <!-- 其他通道类似,共6个 -->
- </div>
- <script src="./flv.js"></script>
- <script>
- // 播放器初始化逻辑
- // 每个通道创建 FlvPlayer 实例,连接对应 WebSocket 地址
- // 参考 flv.js 官方文档创建 player
- // 并在 onerror/onstop 时重新连接
- </script>
复制代码
该方案的关键在于:通过海康云 API 获取 RTSP 地址(而非直接使用设备 RTSP 地址,避免公网直接暴露),再利用 FFmpeg 转码为 FLV,WebSocket 传输实现了低延迟、多通道并发播放。实测中需要确保 FFmpeg 版本与系统兼容,并根据网络状况调整缓冲区大小和 GOP 间隔。若需要更高实时性,可将和值调低,但会增大带宽消耗。 |