一、概况
我们有一个项目,有多路摄像头。甲方要求在应用系统(Web)上集成视频流历史回放功能,可以回放指定时间段的视频流,还可以将它们保存成文件,方便播放。
视频流存储肯定要专业的NVR(网络硬盘录像机)服务器来,自己去弄是不可想象。项目有一个海康CVR(中心级视频网络存储设备)服务器,有磁盘阵列,好多个T,容量挺大,用于存储各路视频流。它提供了SDK,可用于二次开发,支持外部读取历史视频。就靠它了。
我根据海康官方给出的sdk demo(java)版进行改造,改造成一个spring boot程序,然后部署在docker。目前运行状态尚算良好。
二、海康CVR简介

它有SDK可以进行二次开发,并提供了java、c#等语言的demo。

三、工作原理
我根据java版的demo进行改造,改造成一个Spring Boot项目,称为历史回放服务,接收前端和后端的请求,然后将视频流返回给前端,成为前端、后端与CVR的连结器。当然,对前端和后端来说,它并不知道CVR的存在,视频流都来自于后端。

下面是历史回放服务的功能介绍。
四、从CVR下载视频流
前端指定播放历史视频流的开始时间,历史回放服务接收到前端请求后,向CVR下载视频流,长度限制为1分钟。视频流下载以后,保存成一个临时的视频文件,然后使用ffmpeg解码,推给前端。
为什么要使用ffmpeg呢?原因是前端使用mpegts.js,播放的是flv格式的视频流。因此从CVR拿到视频流涉及到一个转码的问题。转码异常复杂,别想着自己写代码来完成,我只能依靠ffmpeg。
然后为什么视频流从CVR下载以后,要先保存成文件呢?主要是使用ffmpeg边接收边转码的话,总是报溢出的问题。搞来搞去,我也不懂,就先保存成文件,然后ffmpeg处理文件。另外保存成文件也有个好处,就是如果用户觉得这段视频有价值,按下保存按钮,那系统就可以将这份文件拷贝一份,放到正式文件目录,以后用户可以随时播放了。
前面说到,前端过来的播放历史视频流的请求,只包含了开始时间,没有结束时间。因为不需要,系统强制限定长度为1分钟。1分钟足够长了,超过1分钟没有意义,保存下来的文件也很大。
向CVR下载视频流的方式,有个巨大无比的坑,让我浪费了许多时间。官方提供了两种方式进行回放,一个是按时间,一个是按文件。我一开始想当然地按时间进行播放,发现下载的视频流长度是随机的,有时是20秒,有时是2、3秒甚至是0秒,毫无规律可言,并且达不到指定的长度。这让人抓狂。后来使用按文件回放就正常了。事实上按文件回放,也是要指定时间的,而且我们并不需要指定什么文件位置,调用起来跟按时间回放一样简单。

五、播放视频流历史文件
其实就是用ffmpeg处理保存下来的正式视频文件(见从CVR下载视频流),然后推给前端就好了,更简单。播放历史回放视频,通常要等待几秒钟,播放历史文件,一下子就有画面了。
六、部署
1、docker
我们应用服务器是linux,很自然将历史回放服务部署在docker。由于使用了ffmpeg,因此构建镜像时,需要把ffmpeg打包在内。Dockerfile内容如下:
1)Dockerfile
bash
FROM eclipse-temurin:17-jre-jammy
# 设置非交互式安装
ENV DEBIAN_FRONTEND=noninteractive
# 合并所有依赖安装,并单独处理 Ubuntu 22.04 缺失的 libssl1.1
RUN apt-get update && \
apt-get install -y --no-install-recommends wget && \
# 从 Ubuntu 官方安全源下载并安装 libssl1.1
wget http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb && \
dpkg -i libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb && \
rm libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb && \
# 安装 FFmpeg 和海康 SDK 其他所需的系统依赖库
apt-get install -y --no-install-recommends \
ffmpeg \
libstdc++6 \
libgl1 \
libglib2.0-0 \
libx11-6 \
libxext6 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/data
ENTRYPOINT ["sh", "/usr/data/run.sh"]
我一般是针对批处理文件构建镜像,然后在批处理文件里运行jar包。好处是更新程序,不需要重新构建镜像,只需覆盖jar包就好。详见拙作《docker部署可执行jar包》
2)run.sh
比如本Dockerfile最后一行写的run.sh,内容如下:
bash
#!/bin/bash
# run.sh
# 设置系统动态库搜索路径(关键!)
export LD_LIBRARY_PATH=/usr/data/lib:$LD_LIBRARY_PATH
# 可选:进入工作目录(确保 user.dir 正确)
cd /usr/data
# 启动 Java 应用
exec java \
-Djava.awt.headless=true \
-Duser.timezone=Asia/Shanghai \
-Djna.library.path=/usr/data/lib \
-jar hkCvr-1.0.0.jar \
--spring.config.location=/usr/data/application.yml
3)构建镜像

4)创建容器:
bash
sudo docker run --restart=always -d -it --name=hkcvr \
--network docker-fixed-net \
--ip 172.18.0.14 \
-p 8084:8084 \
-v /home/work/docker/hkcvr:/usr/data \
-v /home/work/docker/hkcvr/lib:/usr/data/lib \
-v /home/work/docker/hkcvr/sdkLog:/usr/data/sdkLog \
-v /data/hkcvr/temp_streams:/usr/temp_streams \
-v /data/hkcvr/records:/usr/records \
--workdir /usr/data \
hkcvr:1.0.0
2、库
本服务依赖官方提供的各种库。因此构建时,需要把这些库都带上。
1)代码结构
首先,代码结构是这样的:

用瘦模式打包jar包,打包出来的jar包只有几百K。pom.xml长这样:
2)pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.monkey</groupId>
<artifactId>hkCvr</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.boot.version>2.7.0</spring.boot.version>
</properties>
<!-- 依赖 -->
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<!-- Spring Boot WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
<!-- JNA -->
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.12.1</version>
</dependency>
<!-- JNA Platform (包含 Windows API) -->
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
<version>5.12.1</version>
</dependency>
<!-- 海康 SDK 示例包 (本地 JAR) -->
<dependency>
<groupId>com.sun.jna</groupId>
<artifactId>examples</artifactId>
<version>3.0.9</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/examples.jar</systemPath>
</dependency>
<!-- Apache Commons Exec - 用于执行 FFmpeg 进程 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>
<!-- Apache HttpClient - HTTP状态码 -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.15</version>
</dependency>
</dependencies>
<build>
<sourceDirectory>src</sourceDirectory>
<outputDirectory>classes</outputDirectory>
<plugins>
<!-- 编译插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<!-- 复制lib目录到target,与JAR同级(用于Docker部署) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<executions>
<execution>
<id>copy-lib-to-target</id>
<phase>package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/lib</directory>
<includes>
<include>**/*</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<!-- Spring Boot Maven 插件 - 禁用repackage -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<!-- 复制依赖插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeSystemScope>true</includeSystemScope>
</configuration>
</execution>
</executions>
</plugin>
<!-- JAR插件 - 添加Main-Class和Class-Path -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.NetSDKDemo.CvrApplication</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
<useUniqueVersions>false</useUniqueVersions>
</manifest>
<manifestEntries>
<Class-Path>lib/examples-3.0.9.jar</Class-Path>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
3)target目录
存放打包文件的target目录:

4)加载库
官方给的库,分为windows和linux。我开发环境是windows,部署环境是linux。因此lib目录下,两种库都有。代码中做了适配:
java
private void initSDK() {
try {
String strDllPath;
// 根据操作系统选择库文件
if (osSelect.isLinux()) {
strDllPath = System.getProperty("user.dir") + "/lib/libhcnetsdk.so";
} else {
strDllPath = System.getProperty("user.dir") + "\\lib\\HCNetSDK.dll";
}
hCNetSDK = (HCNetSDK) com.sun.jna.Native.loadLibrary(strDllPath, HCNetSDK.class);
// Linux 系统需要额外配置组件库路径
if (osSelect.isLinux()) {
System.out.println("检测到 Linux 系统,配置 SDK 组件库路径...");
// 设置OpenSSL 库路径
HCNetSDK.BYTE_ARRAY ptrByteArray1 = new HCNetSDK.BYTE_ARRAY(256);
HCNetSDK.BYTE_ARRAY ptrByteArray2 = new HCNetSDK.BYTE_ARRAY(256);
String strPath1 = System.getProperty("user.dir") + "/lib/libcrypto.so.1.1";
String strPath2 = System.getProperty("user.dir") + "/lib/libssl.so.1.1";
System.arraycopy(strPath1.getBytes(), 0, ptrByteArray1.byValue, 0, strPath1.length());
ptrByteArray1.write();
hCNetSDK.NET_DVR_SetSDKInitCfg(HCNetSDK.NET_SDK_INIT_CFG_LIBEAY_PATH, ptrByteArray1.getPointer());
System.arraycopy(strPath2.getBytes(), 0, ptrByteArray2.byValue, 0, strPath2.length());
ptrByteArray2.write();
hCNetSDK.NET_DVR_SetSDKInitCfg(HCNetSDK.NET_SDK_INIT_CFG_SSLEAY_PATH, ptrByteArray2.getPointer());
// 设置 SDK 组件库路径
String strPathCom = System.getProperty("user.dir") + "/lib/";
HCNetSDK.NET_DVR_LOCAL_SDK_PATH struComPath = new HCNetSDK.NET_DVR_LOCAL_SDK_PATH();
System.arraycopy(strPathCom.getBytes(), 0, struComPath.sPath, 0, strPathCom.length());
struComPath.write();
hCNetSDK.NET_DVR_SetSDKInitCfg(HCNetSDK.NET_SDK_INIT_CFG_SDK_PATH, struComPath.getPointer());
System.out.println("Linux 系统 SDK 组件库路径配置完成");
}
if (!hCNetSDK.NET_DVR_Init()) {
System.err.println("SDK 初始化失败,错误码:" + hCNetSDK.NET_DVR_GetLastError());
return;
}
System.out.println("SDK 初始化成功");
loginDevice(deviceIp, devicePort, deviceUsername, devicePassword);
} catch (Exception e) {
e.printStackTrace();
}
}
附录
NVR (Network Video Recorder) - 网络硬盘录像机
它是现代视频监控系统的核心设备之一。NVR 通过网络接收 IP 摄像机(IPC)传输过来的数字视频流,并进行集中的存储、管理、解码显示和智能分析。它通常是一台嵌入式的"一体机",部署灵活,是目前中小规模监控中最主流的选择。
CVR (Central Video Recorder) - 中心级视频网络存储设备
它是一种安防视频监控专用的企业级存储技术。CVR 的核心特点是支持"视频流直写"技术,即前端摄像机的视频数据可以直接写入 CVR 存储设备中,省去了中间的存储服务器。它专为海量视频数据的安全存储和高并发读写而生,适合智慧城市、机场、地铁等大型项目。
也就是说,NVR包含了存储和播放,适合中小规模场景;CVR侧重存储,适合大型项目,当然它也能播放。
选型建议
选 NVR:如果你的监控需求是普通的家庭安防、小型超市、或者一栋写字楼(比如摄像头数量在 100 路以内),追求性价比、安装简单且需要手机远程查看,NVR 是最成熟且经济的选择。
选 CVR:如果你面对的是智慧城市、机场、地铁站或大型工业园区等项目,摄像头数量成百上千,要求 7×24 小时不间断录像,且对数据的安全性、存储性能和后期扩容有极高的要求,那么必须选择 CVR。