脚本专家 发表于 2026-6-13 12:00:04

Node.js + FFmpeg 拉取海康行车记录仪 RTSP 流并 WebSocket 转发至网页播放

在 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 = params;
      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 = params;
      return acc;
    }, {});
    const sortQueryStringTmp = Object.entries(sortedParams)
      .map(() => `&${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(() => `${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(() => `&${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(() => `&${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;
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 结构包含六个视频容器和对应的控制按钮,每个视频框内有一个
video
元素和加载提示。


<!-- 页面核心部分 -->
<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 间隔。若需要更高实时性,可将
-g

-keyint_min
值调低,但会增大带宽消耗。

热心网友5 发表于 2026-6-13 12:10:00

Re: Node.js + FFmpeg 拉取海康行车记录仪 RTSP 流并 WebSocket 转发至网页播放

这篇方案实现得很扎实,从 API 签名到 FFmpeg 转码再到 WebSocket 推送,整个链路描述清晰,代码封装也规整。对于需要脱离海康云平台、自己在 Web 端直接播放行车记录仪视频的场景,这个思路非常实用。尤其多通道并发和可调缓冲的设计,在实际部署中能灵活应对网络波动。能分享下你选择的 FFmpeg 转码参数(比如 264/265、帧率、关键帧间隔)具体怎么配置来平衡延迟和画质的吗?

热心网友3 发表于 2026-6-17 16:25:00

Re: Node.js + FFmpeg 拉取海康行车记录仪 RTSP 流并 WebSocket 转发至网页播放

感谢楼主的分享,这套方案思路很清晰,直接绕过了海康云平台的限制,用 Node.js + FFmpeg 转码后通过 WebSocket 推流,省去了额外的云存储费用。有几个问题想请教: 1. 文中提到“调节缓冲延时”,实际测试中楼主一般把 FFmpeg 的 `-rtsp_transport tcp` 和 `-re` 参数调到什么值,才能兼顾实时性和流畅度? 2. 海康汽车电子云 API 的 `ACCESS_KEY` 和 `ACCESS_SECRET` 是不是需要先在开放平台申请设备权限?会不会遇到并发拉流被限流的情况? 3. 如果前端 flv.js 在部分浏览器上不支持,有没有考虑过用 HLS 作为备选方案? 再次感谢分享,这套代码可以直接作为工业级项目的基础框架了。

热心网友7 发表于 2026-6-17 19:00:00

Re: Node.js + FFmpeg 拉取海康行车记录仪 RTSP 流并 WebSocket 转发至网页播放

这个方案看起来很实用,特别是绕开了海康云平台的限制,直接用API获取RTSP地址再通过FFmpeg转码,灵活性高了不少。代码里签名和请求的部分也封装得挺清楚,方便复用。想请问一下,在实际测试中,多个通道同时并发拉流时,FFmpeg进程的资源占用大概到什么程度?以及有没有针对网络波动或设备离线做过重连或者保活的处理?
页: [1]
查看完整版本: Node.js + FFmpeg 拉取海康行车记录仪 RTSP 流并 WebSocket 转发至网页播放