Browse Source

解决抓拍问题

master
王寅 4 weeks ago
parent
commit
139518e29a
12 changed files with 529 additions and 124 deletions
  1. +24
    -0
      src/main/java/com/inspect/nvr/config/HikLibConfig.java
  2. +10
    -11
      src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java
  3. +10
    -26
      src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java
  4. +15
    -2
      src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java
  5. +19
    -10
      src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java
  6. +14
    -0
      src/main/java/com/inspect/nvr/hikVision/utils/jna/HCNetSDK.java
  7. +71
    -24
      src/main/java/com/inspect/nvr/service/CommonCameraService.java
  8. +119
    -19
      src/main/java/com/inspect/nvr/service/HikCameraService.java
  9. +80
    -6
      src/main/java/com/inspect/nvr/service/HikLoginService.java
  10. +14
    -0
      src/main/java/com/inspect/nvr/service/HikLoginSession.java
  11. +151
    -25
      src/main/resources/static/index.html
  12. +2
    -1
      src/main/resources/static/playback.html

+ 24
- 0
src/main/java/com/inspect/nvr/config/HikLibConfig.java View File

@ -23,6 +23,10 @@ public class HikLibConfig {
@Value("${hik.sdk.connect-timeout-millis:1000}")
private int sdkConnectTimeoutMillis;
/** SDK 其他模块的接收超时,默认五秒,限制抓图和健康查询的设备响应等待。 */
@Value("${hik.sdk.receive-timeout-millis:5000}")
private int sdkReceiveTimeoutMillis;
/** 海康 Linux64 SDK 6.1.11.5 随包提供的 OpenSSL 加密库文件名。 */
private static final String LINUX_CRYPTO_LIBRARY_NAME = "libcrypto.so.3";
@ -105,6 +109,8 @@ public class HikLibConfig {
//设置连接时间与重连时间
// 将 SDK 单次建连限制在可配置的短超时内,避免断网时占满 Tomcat 请求线程。
hcNetSDK.NET_DVR_SetConnectTime(Math.max(500, sdkConnectTimeoutMillis), 1);
// Java Future 超时不能停止原生网络等待,必须同时设置 SDK 接收超时。
configureReceiveTimeout();
hcNetSDK.NET_DVR_SetReconnect(100000,true);
//连接所有nvr
@ -113,6 +119,24 @@ public class HikLibConfig {
return hcNetSDK;
}
/** 为 SDK 其他模块设置有限接收等待;设置失败时阻止无保护的 SDK 进入业务流程。 */
void configureReceiveTimeout() {
// 禁止零值恢复默认长等待,且单次接收不能超过抓图请求总预算的一半。
if (sdkReceiveTimeoutMillis < 1_000 || sdkReceiveTimeoutMillis > 10_000) {
throw new IllegalArgumentException("海康 SDK 接收超时必须在 1000~10000 毫秒之间");
}
HCNetSDK.NET_DVR_LOCAL_MODULE_RECV_TIMEOUT_CFG timeout =
new HCNetSDK.NET_DVR_LOCAL_MODULE_RECV_TIMEOUT_CFG();
// 仅指定其他模块的时间,其余三个模块保留 SDK 默认值。
timeout.dwElse = sdkReceiveTimeoutMillis;
timeout.write();
// 类型 3 的结构体布局来自海康官方 SDK 本地参数接口定义。
if (!hcNetSDK.NET_DVR_SetSDKLocalCfg(3, timeout.getPointer())) {
throw new IllegalStateException("海康 SDK 接收超时设置失败,错误码:"
+ hcNetSDK.NET_DVR_GetLastError());
}
}
/**
* 动态库加载


+ 10
- 11
src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java View File

@ -66,6 +66,7 @@ import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -135,8 +136,15 @@ public class DahuaIvsVendorService implements IvsVendorAdapter {
@Resource
private NetSDKLib sdk;
/** 已完成的大华抓图任务。 */
private final Map<String, SnapshotRecord> snapshotRecords = new ConcurrentHashMap<>();
/** 按完成顺序保留最近 256 个大华任务,插入和淘汰原子执行。 */
private final Map<String, SnapshotRecord> snapshotRecords = Collections.synchronizedMap(
new LinkedHashMap<String, SnapshotRecord>() {
/** 只删除最早完成的任务,确保刚创建的任务可以立即取图。 */
@Override
protected boolean removeEldestEntry(Map.Entry<String, SnapshotRecord> eldest) {
return size() > MAX_SNAPSHOT_CACHE_SIZE;
}
});
/** 按 NVR 和通道维护手动录像自动停止任务。 */
private final Map<String, ActiveRecord> activeRecords = new ConcurrentHashMap<>();
/** 按 NVR 和通道维护当前连续 PTZ 动作。 */
@ -302,7 +310,6 @@ public class DahuaIvsVendorService implements IvsVendorAdapter {
domainOfCameraCode(target.getCameraCode()),
snapTime,
image.clone()));
trimSnapshotCache();
return IvsSnapshotCommandResponse.builder()
.resultCode(0)
.taskID(taskId)
@ -1349,14 +1356,6 @@ public class DahuaIvsVendorService implements IvsVendorAdapter {
|| record.domainCode.equalsIgnoreCase(request.getDomainCode().trim()));
}
/** 超出抓图缓存上限时删除一个最早迭代到的任务。 */
private void trimSnapshotCache() {
while (snapshotRecords.size() > MAX_SNAPSHOT_CACHE_SIZE) {
String first = snapshotRecords.keySet().iterator().next();
snapshotRecords.remove(first);
}
}
/** 从完整 cameraCode 提取 # 前设备部分。 */
private String devicePart(String cameraCode) {
int hash = cameraCode.indexOf('#');


+ 10
- 26
src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java View File

@ -48,12 +48,12 @@ import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 海康厂商的 IVS 北向适配器。
@ -130,8 +130,15 @@ public class HikIvsVendorService implements IvsVendorAdapter {
@Resource
private HikNvrUiActionService uiActionService;
/** 已完成的抓图任务缓存,任务列表和图片下载都从这里读取。 */
private final Map<String, SnapshotRecord> snapshotRecords = new ConcurrentHashMap<>();
/** 按完成顺序保留最近 256 个任务,插入和淘汰在同一把锁下完成。 */
private final Map<String, SnapshotRecord> snapshotRecords = Collections.synchronizedMap(
new LinkedHashMap<String, SnapshotRecord>() {
/** 只淘汰最早完成的任务,避免新任务刚返回就被删除。 */
@Override
protected boolean removeEldestEntry(Map.Entry<String, SnapshotRecord> eldest) {
return size() > MAX_SNAPSHOT_CACHE_SIZE;
}
});
/**
* 返回海康厂商在 IVS 路由表中的统一编码。
@ -293,8 +300,6 @@ public class HikIvsVendorService implements IvsVendorAdapter {
snapshotRecords.put(taskId, new SnapshotRecord(
taskId, request.getCameraCode(), request.getDomainCode(),
snapTime, image == null ? new byte[0] : image.clone()));
// 超过缓存上限时删除一个迭代到的任务,避免长期运行导致内存持续增长。
trimSnapshotCache();
// 按 IVS 4.1.12.1 结构把 resultCode 和 taskID 直接放在响应顶层。
return IvsSnapshotCommandResponse.builder()
.resultCode(0)
@ -379,27 +384,6 @@ public class HikIvsVendorService implements IvsVendorAdapter {
return cameraMatches && domainMatches;
}
/**
* 将抓图缓存控制在固定大小,避免长时间运行的进程无限保留图片。
*/
private void trimSnapshotCache() {
// 缓存未超过上限时不做任何删除,保证正常查询路径不产生额外开销。
while (snapshotRecords.size() > MAX_SNAPSHOT_CACHE_SIZE) {
String firstKey = null;
// 选取一个当前可见的任务键,ConcurrentHashMap 不保证键的插入顺序。
for (String key : snapshotRecords.keySet()) {
firstKey = key;
break;
}
// 并发清空缓存时直接结束清理,避免对空迭代器调用 next。
if (firstKey == null) {
break;
}
// 删除成功或失败都重新检查大小,直到缓存回到上限以内。
snapshotRecords.remove(firstKey);
}
}
/**
* 调用海康回放查询服务,并将录像结果转换为 IVS 录像列表结构。
*/


+ 15
- 2
src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java View File

@ -1872,12 +1872,25 @@ public class HikNvrLiveStreamService {
/** 返回当前流的 HLS 浏览器地址。 */
private String hlsUrl(String streamId, String baseUrl) {
return "/live/" + streamId + "/hls.m3u8";
return absoluteMediaUrl(baseUrl, "/live/" + streamId + "/hls.m3u8");
}
/** 返回当前流的 HTTP-FLV 浏览器地址。 */
private String flvUrl(String streamId, String baseUrl) {
return "/live/" + streamId + ".live.flv";
return absoluteMediaUrl(baseUrl, "/live/" + streamId + ".live.flv");
}
/**
* 拼接浏览器可达的绝对媒体地址。
*
* <p>页面会对"与本页面同源且以 /live/ 开头"的相对地址强制改写媒体端口(现场约定),
* 因此只要解析出了基础地址就必须返回绝对地址;仅在完全无基础地址时保留相对路径。</p>
*/
private String absoluteMediaUrl(String baseUrl, String path) {
if (!hasText(baseUrl)) {
return path;
}
return trimTrailingSlash(baseUrl.trim()) + path;
}
/**


+ 19
- 10
src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java View File

@ -54,6 +54,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@ -628,6 +629,8 @@ public class HikNvrUiActionService {
* @return JPEG 图片字节
*/
public byte[] snapshot(HikNvrUiActionRequest request, String preferredCameraIp) {
// 直连、排队和 SDK 回退共用 20 秒预算,留出页面下载图片的时间。
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(HikCameraService.CAPTURE_TIMEOUT_MILLIS);
// 使用请求通道或默认通道,并在真正抓图前校验通道号。
// 页面通道缺失时只允许使用数据库 NVR 明确保存的 start_channel。
int channel = requestedChannel(request);
@ -647,8 +650,8 @@ public class HikNvrUiActionService {
cameraInfo.setNvrIp(cameraIp);
// 相机本机主码流 101
String template = "http://%s/ISAPI/Streaming/channels/101/picture";
// 调用公共 Digest 抓图能力读取相机主码流图片。
byte[] image = CommonCameraService.captureDigest(cameraInfo, 1, template, savePath);
// 直连也进入海康抓图服务的设备/通道锁,避免 HTTP 路径绕过并发保护。
byte[] image = cameraService.captureDigestToPath(cameraInfo, 1, template, savePath);
// 仅当响应是有效 JPEG 时才认为直连抓图成功。
if (image != null && image.length > 0 && isJpeg(image)) {
log.info("[snapshot] Digest 抓图成功 channel={} cameraIp={} path={} size={}",
@ -668,14 +671,18 @@ public class HikNvrUiActionService {
HikLoginSession session = loginSession();
// 计算海康 SDK 通道编号。
int sdkChannel = session.toSdkChannel(channel);
// 调用已有海康相机抓图服务获取 NVR 转发的图片。
byte[] image = cameraService.capture(toNvrInfo(), sdkChannel);
// SDK 只使用直连之后的剩余时间,避免回退重新开始完整超时周期。
long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
byte[] image = cameraService.capture(toNvrInfo(), sdkChannel, remainingMillis);
// 校验 SDK 返回的图片内容,避免把错误 XML 当成图片返回。
if (image != null && image.length > 0 && isJpeg(image)) {
// 确保目标目录存在并持久化图片文件。
Files.createDirectories(savePath.getParent());
// 将 SDK 图片写入前面生成的保存路径。
Files.write(savePath, image);
// 图片留档失败不能把已经成功的 SDK 抓图变成设备失败。
try {
Files.createDirectories(savePath.getParent());
Files.write(savePath, image);
} catch (java.io.IOException saveFailure) {
log.warn("[snapshot] 图片留档失败 path={}:{}", savePath, saveFailure.getMessage());
}
log.info("[snapshot] SDK 抓图成功 channel={} sdkChannel={} path={} size={}",
channel, sdkChannel, savePath, image.length);
return image;
@ -889,8 +896,10 @@ public class HikNvrUiActionService {
/** 根据日期和通道生成抓图文件保存路径。 */
private Path buildCapturePath(int channel) {
String day = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
String name = "ui_ch" + channel + "_" + LocalDateTime.now().format(CAPTURE_TIME) + ".jpg";
Path dir = Paths.get("D:\\captures", day);
// 使用随机后缀隔离同毫秒请求,保存根目录统一复用公共服务的跨平台可写目录。
String name = "ui_ch" + channel + "_" + LocalDateTime.now().format(CAPTURE_TIME)
+ "_" + UUID.randomUUID() + ".jpg";
Path dir = Paths.get(CommonCameraService.FILE_DIR, day);
try {
Files.createDirectories(dir);
} catch (Exception ex) {


+ 14
- 0
src/main/java/com/inspect/nvr/hikVision/utils/jna/HCNetSDK.java View File

@ -7809,6 +7809,20 @@ DVR实现巡航数据结构
public byte[] byRes1 = new byte[32];
}
/** SDK 各模块的接收等待时间,供 NET_DVR_SetSDKLocalCfg 类型 3 使用。 */
public static class NET_DVR_LOCAL_MODULE_RECV_TIMEOUT_CFG extends HCStructure {
/** 预览模块接收超时,单位毫秒;0 使用 SDK 默认值。 */
public int dwPreviewTime;
/** 报警模块接收超时,单位毫秒;0 使用 SDK 默认值。 */
public int dwAlarmTime;
/** 回放模块接收超时,单位毫秒;0 使用 SDK 默认值。 */
public int dwVodTime;
/** 其他模块接收超时,单位毫秒,用于限制设备命令的响应等待。 */
public int dwElse;
/** 协议保留字段,必须全部置零。 */
public byte[] byRes = new byte[512];
}
public static class NET_DVR_LOCAL_GENERAL_CFG extends HCStructure {
public byte byExceptionCbDirectly; //0-通过线程池异常回调,1-直接异常回调给上层
public byte byNotSplitRecordFile; //回放和预览中保存到本地录像文件不切片 0-默认切片,1-不切片


+ 71
- 24
src/main/java/com/inspect/nvr/service/CommonCameraService.java View File

@ -4,15 +4,17 @@ import com.inspect.nvr.domain.Infrared.NvrInfo;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.util.Timeout;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.StreamUtils;
import java.io.FileOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileAlreadyExistsException;
@ -25,7 +27,9 @@ import java.time.format.DateTimeFormatter;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.UUID;
/**
* 通用摄像头服务类
@ -40,6 +44,12 @@ public class CommonCameraService {
static final int DEFAULT_MAX_RETRIES = 10;
/** 单台 NVR 允许同时执行的最大 SDK 任务数。 */
static final int MAX_CONCURRENT_PER_NVR = 4;
/** 设备和通道排队的总等待上限,单位为毫秒。 */
static final long CAPTURE_QUEUE_TIMEOUT_MILLIS = 2_000L;
/** HTTP 抓图读取预算,单位为毫秒;每次读阻塞另受响应超时约束。 */
static final long CAPTURE_HTTP_TIMEOUT_MILLIS = 10_000L;
/** JPEG 响应允许的最大字节数,与海康 SDK 缓冲区容量保持一致。 */
static final int MAX_CAPTURE_BYTES = 10 * 1024 * 1024;
/** 按 NVR IP 维护的信号量,用于限制单台设备的总并发数。 */
final ConcurrentHashMap<String, Semaphore> nvrSemaphoreMap = new ConcurrentHashMap<>();
/** 按 NVR IP 和通道维护的独占锁,确保同一通道的 SDK 操作串行执行。 */
@ -61,27 +71,38 @@ public class CommonCameraService {
// 获取设备级信号量和通道级独占锁,建立“设备限流+通道串行”的两级保护。
Semaphore nvrSemaphore = getOrCreateSemaphore(ip);
ReentrantLock channelLock = getOrCreateLock(ip, channel);
// 先获取 NVR 的全局并发许可,避免超出设备 SDK 的并发承受能力。
nvrSemaphore.acquireUninterruptibly();
// 用单一截止时间限制排队,避免超时请求永久占据 Web 或工作线程。
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(CAPTURE_QUEUE_TIMEOUT_MILLIS);
try {
// 再获取通道独占锁,防止同一通道同时抓图或测温导致 SDK 状态冲突。
channelLock.lock();
try {
// 在两级并发约束生效期间执行真实的厂商 SDK 调用。
return task.call();
} catch (Exception e) {
// 将各厂商 SDK 异常统一记录并转换为 null,保持原有服务返回约定。
log.info("任务执行异常:", e);
// 先等通道锁,避免同通道排队耗尽 NVR 许可而阻塞其他通道。
if (!channelLock.tryLock(CAPTURE_QUEUE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
log.warn("设备通道任务排队超时,ip={},channel={}", ip, channel);
return null;
} finally {
// 先释放通道锁,再由外层释放 NVR 许可,避免其他任务拿到许可后仍被旧通道锁阻塞。
if (channelLock.isHeldByCurrentThread()) {
channelLock.unlock();
}
try {
// 获取设备许可只使用剩余排队时间,等待失败后不继续执行设备操作。
if (!nvrSemaphore.tryAcquire(Math.max(0L, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) {
log.warn("设备任务排队超时,ip={},channel={}", ip, channel);
return null;
}
try {
// 两级保护同时生效时才调用设备,并在完成或异常后归还许可。
return task.call();
} finally {
nvrSemaphore.release();
}
} finally {
// 所有退出路径均释放已获得的通道锁。
channelLock.unlock();
}
} finally {
// 无论 SDK 调用是否成功都释放 NVR 许可,防止异常路径造成永久限流。
nvrSemaphore.release();
} catch (InterruptedException interrupted) {
// 请求已取消时停止排队并保留中断标记,不再发出设备命令。
Thread.currentThread().interrupt();
return null;
} catch (Exception failure) {
// 保持厂商服务失败返回 null 的约定,由入口转换成业务错误。
log.warn("设备任务执行失败,ip={},channel={}:{}", ip, channel, failure.getMessage());
return null;
}
}
@ -117,27 +138,52 @@ public class CommonCameraService {
credentialsProvider.setCredentials(new AuthScope(null, -1),
new UsernamePasswordCredentials(username, password.toCharArray()));
// 显式限制连接和借用连接等待,主码流 JPEG 响应允许等待五秒。
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(1_000))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(1_000))
.setResponseTimeout(Timeout.ofMilliseconds(5_000))
.build();
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(CAPTURE_HTTP_TIMEOUT_MILLIS);
// 关闭自动重试,确保 HTTP 失败后及时进入上层 SDK 回退。
try (CloseableHttpClient httpClient = HttpClients.custom()
.setDefaultCredentialsProvider(credentialsProvider)
.setDefaultRequestConfig(requestConfig)
.disableAutomaticRetries()
.build()) {
HttpGet httpGet = new HttpGet(url);
return httpClient.execute(httpGet, httpResponse -> {
int statusCode = httpResponse.getCode();
if (statusCode == 200) {
try (InputStream in = httpResponse.getEntity().getContent();
FileOutputStream out = new FileOutputStream(fullPath.toString())) {
// 8KB 是一个经过大量实践验证的甜点(sweet spot):既能显著减少系统调用次数,又不会占用过多内存。
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
// 在当前请求自己的内存中读取图片,不从可能被并发覆盖的文件回读。
byte[] buffer = new byte[8192];
int len;
// 限制慢速持续响应的总读取时间和大小,防止无限流耗尽内存。
while ((len = in.read(buffer)) != -1) {
if (System.nanoTime() >= deadline || out.size() + len > MAX_CAPTURE_BYTES) {
// 中止连接,防止关闭响应流时继续排空超时或超大的响应体。
httpGet.cancel();
throw new IOException("抓图响应超时或超过最大图片大小");
}
out.write(buffer, 0, len);
}
byte[] image = out.toByteArray();
// 落盘只作留档,写文件失败不丢弃已经成功取得的设备图片。
try {
Files.createDirectories(fullPath.getParent());
Files.write(fullPath, image);
} catch (IOException saveFailure) {
log.warn("抓图图片留档失败,path={}:{}", fullPath, saveFailure.getMessage());
}
log.info("Digest认证抓图成功:图片地址{}", fullPath);
return Files.readAllBytes(fullPath);
return image;
}
} else {
String errorBody = EntityUtils.toString(httpResponse.getEntity());
throw new RuntimeException("状态码: " + statusCode + ",响应体: " + errorBody);
// 非成功响应立即中止连接,不无限读取设备返回的错误正文。
httpGet.cancel();
throw new IOException("抓图 HTTP 状态码:" + statusCode);
}
});
} catch (Exception e) {
@ -201,7 +247,8 @@ public class CommonCameraService {
*/
Path getFullPath(String flag, String ip, int channel) {
String timeStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss_SSS"));
String fileName = flag + "_" + ip + "_" + channel + "_" + timeStr + ".jpg";
// 随机任务后缀保证同设备、同通道、同毫秒的并发请求也不会覆盖文件。
String fileName = flag + "_" + ip + "_" + channel + "_" + timeStr + "_" + UUID.randomUUID() + ".jpg";
Path fullPath = Paths.get(FILE_DIR)
.resolve(LocalDate.now().toString())
.resolve(fileName)


+ 119
- 19
src/main/java/com/inspect/nvr/service/HikCameraService.java View File

@ -13,11 +13,13 @@ import javax.annotation.Resource;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Arrays;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
@ -25,7 +27,7 @@ import java.util.concurrent.atomic.AtomicLong;
* 海康设备SDK服务
* 同一个IP,同一个通道,串行
* 同一个IP,不同通道,最多4个线程并发
* 不同IP,并发抓图,不限制
* 不同 IP 分别限流,原生抓图另受有限工作线程池保护
*/
@Slf4j
@Service
@ -46,6 +48,23 @@ public class HikCameraService extends CommonCameraService {
/** 海康本地 SDK 映射,用于抓图、测温和错误码查询。 */
@Resource
private HCNetSDK hcNetSDK;
/** SDK 抓图的默认请求预算,单位为毫秒,低于页面 25 秒等待上限。 */
public static final long CAPTURE_TIMEOUT_MILLIS = 20_000L;
/** SDK 最多调用两次,永久性能力错误不重试。 */
private static final int CAPTURE_MAX_ATTEMPTS = 2;
/** 短暂设备忙时的重试间隔,单位为毫秒。 */
private static final long CAPTURE_RETRY_DELAY_MILLIS = 200L;
/** 全局 SDK 工作线程数,允许四台 NVR 各执行四个并发任务。 */
private static final int CAPTURE_WORKER_COUNT = 16;
/** 隔离可能无法响应中断的原生 SDK,工作线程和等待队列均有容量上限。 */
// ponytail: 全局最多 16 个 SDK 工作线程和 16 个排队任务,更多 NVR 同时满载时再调整线程数。
private static final ThreadPoolExecutor CAPTURE_EXECUTOR = new ThreadPoolExecutor(
CAPTURE_WORKER_COUNT, CAPTURE_WORKER_COUNT, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(16), runnable -> {
Thread thread = new Thread(runnable, "hik-capture");
thread.setDaemon(true);
return thread;
});
/**
* 在 NVR 和通道两级并发控制下执行海康抓图。
@ -55,8 +74,46 @@ public class HikCameraService extends CommonCameraService {
* @return JPEG 图片字节;抓图失败时按底层约定返回空数组或 null
*/
public byte[] capture(NvrInfo nvrInfo, int channel) {
// 复用父类的公共并发控制,避免海康服务再维护一套重复锁状态。
return withConcurrencyControl(nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel));
// 独立调用使用默认预算,平台入口可以传入直连失败后的剩余时间。
return capture(nvrInfo, channel, CAPTURE_TIMEOUT_MILLIS);
}
/**
* 在指定预算内获取 SDK 图片;超时后停止等待并取消排队或后续重试。
*
* @param nvrInfo 数据库中的 NVR 连接信息
* @param channel SDK 通道号
* @param timeoutMillis 本次请求剩余预算,单位为毫秒
* @return JPEG 图片;超时、容量不足或设备失败时返回 null
*/
public byte[] capture(NvrInfo nvrInfo, int channel, long timeoutMillis) {
// 已无预算或请求已被取消时不再提交设备调用。
if (timeoutMillis <= 0 || Thread.currentThread().isInterrupted()) {
return null;
}
Future<byte[]> pending = null;
try {
// 有限工作线程隔离原生调用,继续复用现有 NVR 和通道锁。
pending = CAPTURE_EXECUTOR.submit(() -> withConcurrencyControl(
nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel)));
return pending.get(timeoutMillis, TimeUnit.MILLISECONDS);
} catch (InterruptedException interrupted) {
// 上层取消请求时保留中断状态,并在 finally 中取消设备任务。
Thread.currentThread().interrupt();
return null;
} catch (TimeoutException | RejectedExecutionException | ExecutionException failure) {
// 预算耗尽或工作池繁忙时及时返回,避免持续占据 Web 请求线程。
log.warn("[海康]抓图等待失败,IP={},Channel={}:{}",
nvrInfo.getNvrIp(), channel, failure.toString());
return null;
} finally {
// Future 已完成时无需取消;原生 SDK 不支持中断时最多占用固定数量的后台线程。
if (pending != null && !pending.isDone()) {
pending.cancel(true);
// 立即移除已取消的排队请求,不让失效任务继续占用队列容量。
CAPTURE_EXECUTOR.remove((Runnable) pending);
}
}
}
/**
@ -67,8 +124,16 @@ public class HikCameraService extends CommonCameraService {
* @return Digest 接口返回的 JPEG 图片字节,失败时返回 null
*/
public byte[] captureDigest(NvrInfo nvrInfo, int channel) {
// 复用父类公共限流后调用 Digest 抓图,保证同通道的 SDK 和 HTTP 抓图不并发冲突。
return withConcurrencyControl(nvrInfo, channel, () -> captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, getFullPath("hk_digest", nvrInfo.getNvrIp(), channel)));
// 将旧入口转交给同一限流方法,避免平台直连和普通 Digest 调用维护不同的锁。
return captureDigestToPath(nvrInfo, channel, DIGEST_URL_TEMPLATE,
getFullPath("hk_digest", nvrInfo.getNvrIp(), channel));
}
/** 在设备和通道限流下执行指定 HTTP 抓图接口,并留档到请求独占路径。 */
public byte[] captureDigestToPath(NvrInfo nvrInfo, int channel, String template, Path fullPath) {
// 平台直连也必须经过公共锁,避免同相机同通道同时抓图。
return withConcurrencyControl(nvrInfo, channel,
() -> CommonCameraService.captureDigest(nvrInfo, channel, template, fullPath));
}
/**
@ -84,7 +149,7 @@ public class HikCameraService extends CommonCameraService {
}
/**
* 按配置重试海康摄像机抓图,SDK 连续失败后回退到 Digest 抓图。
* 最多尝试两次海康 SDK 抓图,不支持抓图时直接回退到 Digest。
*
* @param nvrInfo 海康 NVR 连接和认证信息
* @param channel 抓图通道号
@ -94,31 +159,50 @@ public class HikCameraService extends CommonCameraService {
Path fullPath = getFullPath("hk", nvrInfo.getNvrIp(), channel);
ensureDirectoryExists(fullPath.getParent());
int retryCount = 0;
int maxRetries = DEFAULT_MAX_RETRIES;
int maxRetries = CAPTURE_MAX_ATTEMPTS;
while (retryCount < maxRetries) {
// 超时或取消后不得继续重试,也不得再发出 HTTP 兜底请求。
if (Thread.currentThread().isInterrupted()) {
return null;
}
try {
// byte[] imageBytes = captureJPEGPicture(nvrInfo, channel, fullPath);
byte[] imageBytes = captureJPEGPictureNew(nvrInfo, channel, fullPath);
if (imageBytes == null) {
int errorCode = hcNetSDK.NET_DVR_GetLastError();
// 23/29 属于设备抓图能力或参数不支持,重复同一请求无法恢复。
if (errorCode == 23 || errorCode == 29) {
log.warn("[海康]SDK 不支持当前抓图请求,错误码={},转 HTTP 兜底", errorCode);
break;
}
throw new RuntimeException("SDK抓图失败,错误码" + errorCode);
}
log.info("[海康]抓图成功(第{}次):{}", retryCount + 1, fullPath);
return imageBytes;
} catch (Exception e) {
log.error("[海康]抓图异常(第{}次):{}", retryCount + 1, e.getMessage());
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
break;
// 只有还会再次尝试时短暂等待,最后一次失败不额外休眠。
if (retryCount + 1 < maxRetries) {
try {
Thread.sleep(CAPTURE_RETRY_DELAY_MILLIS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return null;
}
}
} finally {
retryCount++;
}
}
// 原生调用结束时可能已经超过请求预算,取消后不能继续产生设备负载。
if (Thread.currentThread().isInterrupted()) {
return null;
}
// 当SDK抓图失败时,尝试使用Digest认证抓图
byte[] imageBytes = captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, fullPath);
// ISAPI 使用页面逻辑通道而非 33 起的 SDK 数字通道,同时采用数据库 HTTP 端口。
int httpChannel = hikLoginService.loginSession(nvrInfo).toRtspChannel(channel);
String httpPort = nvrInfo.getHttpPort() == null ? "" : ":" + nvrInfo.getHttpPort();
String template = "http://%s" + httpPort + "/ISAPI/Streaming/channels/%d02/picture";
byte[] imageBytes = captureDigest(nvrInfo, httpChannel, template, fullPath);
if (imageBytes == null) {
// 当所有抓图方式均失败时,记录失败图片
writeCaptureFailedImage(fullPath);
@ -180,24 +264,40 @@ public class HikCameraService extends CommonCameraService {
* @throws Exception 登录、本地文件或 SDK 调用异常
*/
private byte[] captureJPEGPictureNew(NvrInfo nvrInfo, int channel, Path fullPath) throws Exception {
int userId = hikLoginService.login(nvrInfo);
// 保留会话对象身份,故障时只能失效本次调用使用的旧句柄。
HikLoginSession session = hikLoginService.loginSession(nvrInfo);
int userId = session.getUserId();
HCNetSDK.NET_DVR_JPEGPARA jpegpara = new HCNetSDK.NET_DVR_JPEGPARA();
jpegpara.wPicSize = 0xff;
jpegpara.wPicQuality = 0;
jpegpara.write();
HCNetSDK.BYTE_ARRAY byteArray = new HCNetSDK.BYTE_ARRAY(10 * 1024 * 1024);
HCNetSDK.BYTE_ARRAY byteArray = new HCNetSDK.BYTE_ARRAY(MAX_CAPTURE_BYTES);
IntByReference ret = new IntByReference(0);
log.info("[海康]开始抓图,UserID={},IP={},Channel={}", userId, nvrInfo.getNvrIp(), channel);
boolean isCaptured = hcNetSDK.NET_DVR_CaptureJPEGPicture_NEW(userId, channel, jpegpara, byteArray.getPointer(), byteArray.size(), ret);
if (isCaptured) {
// SDK 成功标记仍需配合有效长度,防止空数据或越界长度进入图片缓存。
int pictureSize = ret.getValue();
if (pictureSize <= 0 || pictureSize > byteArray.byValue.length) {
throw new IOException("SDK 返回的图片长度无效:" + pictureSize);
}
byteArray.read();
byte[] imageBytes = byteArray.byValue;
// 图片写入本地
// 只复制实际图片,禁止将 10MB 缓冲区尾部空白作为图片返回。
byte[] imageBytes = Arrays.copyOf(byteArray.byValue, pictureSize);
// 留档失败只记录本地存储错误,仍返回成功抓取的设备图片。
try (FileOutputStream fos = new FileOutputStream(fullPath.toString())) {
fos.write(imageBytes, 0, ret.getValue());
fos.write(imageBytes);
} catch (IOException saveFailure) {
log.warn("[海康]图片留档失败,path={}:{}", fullPath, saveFailure.getMessage());
}
return imageBytes;
}
// 用户不存在表示句柄已经不可用,下一次重试必须重新登录,不能重复使用缓存。
int errorCode = hcNetSDK.NET_DVR_GetLastError();
if (errorCode == HCNetSDK.NET_DVR_USERNOTEXIST) {
hikLoginService.invalidateSession(nvrInfo, session);
throw new IOException("SDK抓图句柄失效,错误码:" + errorCode);
}
return null;
}


+ 80
- 6
src/main/java/com/inspect/nvr/service/HikLoginService.java View File

@ -29,6 +29,9 @@ public class HikLoginService {
/** Redis 中记录 SDK 登出失败事件的有序集合键。 */
private static final String ERROR_LOGOUT_KEY = "hik:error";
/** 连续两次健康探测失败才重建会话,避免一次网络抖动中断在途操作。 */
private static final int MAX_HEALTH_FAILURES = 2;
/** 登出失败时记录原始 SDK 错误,Redis 不可用不能阻断会话释放。 */
@Resource
private RedisService redisService;
@ -40,6 +43,9 @@ public class HikLoginService {
/** 保存已登录 NVR 的服务端连接参数,供健康检查失败后自动重登。 */
private final Map<String, NvrInfo> loginTargets = new ConcurrentHashMap<>();
/** SDK 暂时拒绝登出的旧句柄,保留到后续健康检查重试,避免只删缓存而遗留连接。 */
private final Map<Integer, String> pendingLogouts = new ConcurrentHashMap<>();
/**
* 按 NVR IP 缓存登录句柄;同一个 key 的加载由 Caffeine 原子串行,
* 不同 NVR 的首次登录可以并行。
@ -109,23 +115,42 @@ public class HikLoginService {
return loginSession(nvrInfo);
}
/** 定时用轻量工作状态接口检查缓存句柄,失败时自动注销并重登。 */
/** 定时释放待注销句柄,并在连续两次健康检查失败后重建对应的旧会话。 */
@Scheduled(fixedDelayString = "${hik.login.health-check-interval-millis:60000}")
public void healthCheckSessions() {
// 上轮设备忙或断网导致的登出失败必须继续追踪,不能永久丢弃旧句柄。
retryPendingLogouts();
// 遍历已知登录目标而非仅遍历会话缓存,断网重登失败后仍会在下一轮继续恢复。
for (Map.Entry<String, NvrInfo> entry
: new ArrayList<>(loginTargets.entrySet())) {
String ip = entry.getKey();
NvrInfo target = entry.getValue();
HikLoginSession session = sessionCache.getIfPresent(ip);
// 当前存在且健康的句柄继续复用,不制造额外登录连接。
if (session != null && isSessionHealthy(ip, session)) {
continue;
// 只统计当前会话的连续失败,旧检查不能影响并发业务已经建立的新会话。
if (session != null) {
boolean healthy = isSessionHealthy(ip, session);
// 探测期间缓存已经替换时,本轮检查立即作废,不累加新会话的失败次数。
if (sessionCache.asMap().get(ip) != session) {
continue;
}
// 成功后清零,两个被成功探测隔开的失败不能触发重登。
if (healthy) {
session.resetHealthFailures();
continue;
}
// 首次失败仍保留句柄,让网络抖动后下一轮检查有机会恢复。
if (session.recordHealthFailure() < MAX_HEALTH_FAILURES) {
log.warn("[海康]会话首次健康检查失败,保留句柄等待复查,ip: {},userID: {}",
ip, session.getUserId());
continue;
}
// 条件移除必须确认仍是刚才检查的对象,防止注销并发重登后的新句柄。
if (!invalidateSession(target, session)) {
continue;
}
}
log.warn("[海康]登录会话健康检查失败,准备自动重登,ip: {},userID: {}",
ip, session == null ? null : session.getUserId());
// 先释放缓存和设备端旧句柄,确保后续登录不会再次命中失效对象。
logout(ip);
// 没有可用数据库连接参数时只能完成失效,等待下一次业务请求重新提供参数。
if (target == null) {
continue;
@ -142,6 +167,45 @@ public class HikLoginService {
}
}
/**
* 仅失效指定的旧会话,供健康检查和 SDK 明确报告句柄无效时使用。
*
* @param nvrInfo 当前 NVR 的连接参数,恢复目标继续保留
* @param expectedSession 发生故障的会话对象
* @return 旧会话仍在缓存且由本次调用成功移除时返回 true
*/
public boolean invalidateSession(NvrInfo nvrInfo, HikLoginSession expectedSession) {
// 缺少目标或旧会话时不能安全选择要注销的句柄。
if (nvrInfo == null || !hasText(nvrInfo.getNvrIp()) || expectedSession == null) {
return false;
}
String ip = nvrInfo.getNvrIp().trim();
// 原子比较对象身份并移除,较晚到达的旧请求不得删除新会话。
if (!sessionCache.asMap().remove(ip, expectedSession)) {
return false;
}
// 保留登录目标供下一次请求或健康检查自动恢复,仅释放旧对象的 SDK 句柄。
doLogout(ip, expectedSession.getUserId());
return true;
}
/** 重试之前未释放的 SDK 句柄,当前缓存正在使用的句柄不得被旧清理任务注销。 */
private void retryPendingLogouts() {
// 使用快照迭代,登出结果可以安全更新待清理集合。
for (Map.Entry<Integer, String> pending : new ArrayList<>(pendingLogouts.entrySet())) {
int userId = pending.getKey();
// SDK 可能复用用户编号,新会话已使用同一编号时放弃旧清理记录。
boolean reused = sessionCache.asMap().values().stream()
.anyMatch(session -> session.getUserId() == userId);
if (reused) {
pendingLogouts.remove(userId, pending.getValue());
continue;
}
// 设备忙或网络异常解除后补做登出,失败时记录仍保留到下一轮。
doLogout(pending.getValue(), userId);
}
}
/**
* 对指定缓存会话调用海康轻量工作状态接口。
*
@ -259,6 +323,8 @@ public class HikLoginService {
// 调用海康 SDK 断开设备端登录连接。
logoutSucceeded = hcNetSDK.NET_DVR_Logout(userId);
} catch (RuntimeException sdkFailure) {
// 原生异常时也保留待清理句柄,下一轮继续尝试释放。
pendingLogouts.put(userId, ip);
// 原生注销异常不能阻断缓存失效或后续重新登录。
log.error("[海康]登出调用异常,ip: {},userID: {},原因: {}",
ip, userId, sdkFailure.getMessage());
@ -266,10 +332,18 @@ public class HikLoginService {
}
// SDK 确认成功时记录完整会话生命周期。
if (logoutSucceeded) {
pendingLogouts.remove(userId);
log.info("[海康]登出成功,ip: {},userID: {}", ip, userId);
return;
}
int errorCode = hcNetSDK.NET_DVR_GetLastError();
// SDK 明确表示用户不存在时已无资源可释放,停止重复注销。
if (errorCode == HCNetSDK.NET_DVR_USERNOTEXIST) {
pendingLogouts.remove(userId);
return;
}
// 特别是用户正在操作(错误 74)时,要等在途 SDK 调用结束后再次释放。
pendingLogouts.put(userId, ip);
log.error("[海康]登出失败,ip: {},userID: {},错误码: {}",
ip, userId, errorCode);
// 保留设备端登出失败证据,但 Redis 故障不影响当前调用返回。


+ 14
- 0
src/main/java/com/inspect/nvr/service/HikLoginSession.java View File

@ -1,5 +1,7 @@
package com.inspect.nvr.service;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 海康登录会话及设备通道布局。
*/
@ -10,6 +12,8 @@ public final class HikLoginSession {
private final int analogStartChannel;
private final int digitalChannelCount;
private final int digitalStartChannel;
/** 当前会话连续健康检查失败次数,新会话独立计数,避免旧失败影响重登结果。 */
private final AtomicInteger healthFailures = new AtomicInteger();
/** 保存海康登录句柄及模拟、数字通道布局信息。 */
public HikLoginSession(
@ -30,6 +34,16 @@ public final class HikLoginSession {
return userId;
}
/** 当前会话探测失败时递增计数,供登录服务判断是否需要重登。 */
int recordHealthFailure() {
return healthFailures.incrementAndGet();
}
/** 一次健康探测成功即可结束连续失败序列。 */
void resetHealthFailures() {
healthFailures.set(0);
}
/**
* 将调用方传入的通道号转换为海康SDK通道号。
*


+ 151
- 25
src/main/resources/static/index.html View File

@ -585,8 +585,9 @@
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js"></script>
<!-- 播放器库随 jar 静态资源本地分发,内网环境不依赖公网 CDN。 -->
<script src="flv.min.js?v=1.6.2"></script>
<script src="hls.min.js?v=1.5.17"></script>
<script>
(() => {
'use strict';
@ -889,10 +890,105 @@
}
function clearLivePollTimer(slot) {
// 统一清理槽位的启动轮询与播放巡检定时器,槽位销毁后不留残余巡检。
if (slot && slot.livePollTimer) {
clearTimeout(slot.livePollTimer);
slot.livePollTimer = null;
}
if (slot && slot.liveWatchTimer) {
clearTimeout(slot.liveWatchTimer);
slot.liveWatchTimer = null;
}
}
// 播放中的槽位每 10 秒巡检一次:画面停滞则重挂播放器,服务端停流则自动重连;
// 两种异常都不处理时,格子会定格在最后一帧且永远不恢复。
function startLiveWatch(slotIndex, slot) {
let lastTime = -1;
let stalledChecks = 0;
const watch = async () => {
slot.liveWatchTimer = null;
if (state.liveSlots[slotIndex] !== slot || !slot.started) return;
const card = $('#cameraGrid .camera-card[data-slot-index="' + Number(slotIndex) + '"]');
const video = card ? card.querySelector('video[data-live-video]') : null;
let status = null;
try {
status = await requestJson(
API + '/actions/live/status?cameraCode='
+ encodeURIComponent(slot.cameraCode),
{ headers: {} });
} catch (_) { /* 状态接口瞬时异常不影响本地画面判断 */ }
if (state.liveSlots[slotIndex] !== slot) return;
if (status && (status.status === 'STOPPED' || status.status === 'FAILED')) {
restartAfterInterruption(slotIndex, slot);
return;
}
// 直播画面 currentTime 应持续增长;连续三轮不增长说明拉流连接已死,
// 此时服务端可能仍报 PLAYING(TCP 半开),只有前端能发现停滞。
const t = video ? video.currentTime : -1;
if (t >= 0 && t === lastTime) {
stalledChecks += 1;
} else {
stalledChecks = 0;
slot.healthyChecks = (slot.healthyChecks || 0) + 1;
// 连续健康 50 秒后重置重连次数,允许后续再次自动恢复。
if (slot.healthyChecks >= 5) slot.restarts = 0;
}
lastTime = t;
if (stalledChecks >= 3) {
stalledChecks = 0;
destroySlotPlayer(slotIndex);
renderCameras();
attachLivePlayer(slotIndex);
}
slot.liveWatchTimer = setTimeout(watch, 10000);
};
slot.liveWatchTimer = setTimeout(watch, 10000);
}
// 服务端已停流:保留槽位自动重连(最多两次),仍失败才清格提示。
function restartAfterInterruption(slotIndex, slot) {
slot.restarts = (slot.restarts || 0) + 1;
if (slot.restarts > 2) {
showToast('通道 ' + slot.channel + ' 实时流中断且重连失败,请重新打开', true);
stopLiveSlot(slotIndex);
return;
}
showToast('通道 ' + slot.channel + ' 实时流中断,自动重连中…');
destroySlotPlayer(slotIndex);
requestJson(API + '/actions/live/start', {
method: 'POST',
body: JSON.stringify({ cameraCode: slot.cameraCode, streamType: 2 })
}).catch(() => { /* 启动请求失败交给等待超时兜底 */ });
let tries = 0;
const waitForPlaying = async () => {
tries += 1;
if (state.liveSlots[slotIndex] !== slot) return;
try {
const s = await requestJson(
API + '/actions/live/status?cameraCode='
+ encodeURIComponent(slot.cameraCode),
{ headers: {} });
if (state.liveSlots[slotIndex] !== slot) return;
if (s && s.status === 'PLAYING' && (s.hlsUrl || s.flvUrl)) {
if (s.hlsUrl) slot.hlsUrl = s.hlsUrl;
if (s.flvUrl) slot.flvUrl = s.flvUrl;
slot.healthyChecks = 0;
renderCameras();
attachLivePlayer(slotIndex);
slot.liveWatchTimer = setTimeout(watch0, 10000);
return;
}
} catch (_) { /* 继续等待重连结果 */ }
if (tries < 20 && state.liveSlots[slotIndex] === slot) {
slot.liveWatchTimer = setTimeout(waitForPlaying, 1000);
} else if (state.liveSlots[slotIndex] === slot) {
// 重连等待超时:回到常规巡检,由 restarts 上限最终清格。
slot.liveWatchTimer = setTimeout(watch0, 1000);
}
};
const watch0 = () => startLiveWatch(slotIndex, slot);
slot.liveWatchTimer = setTimeout(waitForPlaying, 1000);
}
function destroySlotPlayer(slotIndex) {
@ -1107,7 +1203,23 @@
}, true);
}
function attachLivePlayer(slotIndex, preferFlv = true) {
/**
* 将旧后端返回的同源实时地址补到媒体端口,保留后端提供的独立媒体地址。
*/
function resolveLiveMediaUrl(source) {
if (!source) return source;
try {
const url = new URL(source, window.location.href);
if (url.origin === window.location.origin && url.pathname.startsWith('/live/')) {
// ponytail: 旧地址按现场 18891 补全;媒体端口变化时需由后端返回正确绝对地址。
url.port = '18891';
return url.href;
}
} catch (_) { /* 保留原地址,由现有播放器错误回调处理无效地址。 */ }
return source;
}
function attachLivePlayer(slotIndex) {
const slot = state.liveSlots[slotIndex];
if (!slot) return;
// 卡片在放大层时仍由原播放器管理,不能在槽位重绘时再次创建播放器。
@ -1117,8 +1229,9 @@
const feed = card.querySelector('.feed');
if (!feed) return;
const hlsSource = slot.hlsUrl;
const flvSource = preferFlv ? slot.flvUrl : null;
const hlsSource = resolveLiveMediaUrl(slot.hlsUrl);
// 后端没有返回 HLS 时才使用 FLV,保证多画面不会占满浏览器 HTTP 长连接。
const flvSource = hlsSource ? null : resolveLiveMediaUrl(slot.flvUrl);
let video = feed.querySelector('video[data-live-video]');
if (!video) {
@ -1134,7 +1247,7 @@
feed.classList.add('has-video');
bindLiveVideo(video, slotIndex);
const source = flvSource || hlsSource;
const source = hlsSource || flvSource;
if (!source) {
return;
}
@ -1150,7 +1263,7 @@
if (!video) return;
video.dataset.liveSource = source;
// 优先使用 HTTP-FLV,减少 HLS 多分片直播缓冲带来的延迟。
// HLS 地址缺失时使用 HTTP-FLV,保留旧媒体服务的兼容播放能力。
if (flvSource && window.flvjs && window.flvjs.isSupported()) {
const flv = window.flvjs.createPlayer(
{ type: 'flv', url: flvSource, isLive: true },
@ -1164,21 +1277,12 @@
flv.on(window.flvjs.Events.ERROR, () => {
if (state.liveSlots[slotIndex] !== slot || slot.liveFlv !== flv) return;
destroySlotPlayer(slotIndex);
if (hlsSource) {
showToast('HTTP-FLV播放失败,已回退HLS', true);
attachLivePlayer(slotIndex, false);
} else {
showToast('实时预览播放异常', true);
}
showToast('实时预览播放异常', true);
});
return;
}
// FLV 不可用时固定切换到 HLS,避免状态轮询重复尝试同一个 FLV 地址。
if (flvSource && hlsSource) {
video.dataset.liveSource = hlsSource;
}
// 后端返回 HLS 时优先加载分片流,避免九画面被 HTTP-FLV 长连接数限制。
if (hlsSource && window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
enableWorker: true,
@ -1262,6 +1366,7 @@
liveHls: null,
liveFlv: null,
livePollTimer: null,
liveWatchTimer: null,
started: false,
errorCode: null,
errorMessage: null
@ -1319,9 +1424,11 @@
}
if (payload.hlsUrl) slot.hlsUrl = payload.hlsUrl;
if (payload.flvUrl) slot.flvUrl = payload.flvUrl;
if (slot.hlsUrl || slot.flvUrl) {
// 后端只有在 ZLM API 确认注册后才返回 PLAYING,此前不让播放器抢拉空地址。
if (payload.status === 'PLAYING' && (slot.hlsUrl || slot.flvUrl)) {
renderCameras();
attachLivePlayer(slotIndex);
startLiveWatch(slotIndex, slot);
}
if (payload.status === 'PLAYING') {
showToast((payload.message || '实时预览已启动') + ' · 通道 ' + ch);
@ -1346,19 +1453,38 @@
}
if (status && status.hlsUrl) slot.hlsUrl = status.hlsUrl;
if (status && status.flvUrl) slot.flvUrl = status.flvUrl;
if (status && (status.hlsUrl || status.flvUrl)) {
renderCameras();
attachLivePlayer(slotIndex);
}
if (status && status.status === 'PLAYING') {
// ZLM 已注册后再挂载播放器,避免 STARTING 阶段首次请求扑空。
if (status.hlsUrl || status.flvUrl) {
renderCameras();
attachLivePlayer(slotIndex);
startLiveWatch(slotIndex, slot);
}
showToast('实时预览已就绪 · 通道 ' + ch);
return;
}
// 排队任务异步失败时立即展示后端分类消息并清理空槽位。
if (status && status.status === 'FAILED') {
clearLivePollTimer(slot);
destroySlotPlayer(slotIndex);
state.liveSlots[slotIndex] = null;
renderCameras();
renderDeviceTree();
showToast(status.message || status.errorCode || '实时预览启动失败', true);
return;
}
} catch (_) { /* 继续轮询启动状态 */ }
if (tries < 20 && state.liveSlots[slotIndex] === slot) {
// 九路按NVR串行排队,通道失败还要消耗整段重试预算;
// 轮询窗口必须覆盖最队尾摄像机的等待时间,超时后清槽避免遗留黑屏幽灵格。
if (tries < 120 && state.liveSlots[slotIndex] === slot) {
slot.livePollTimer = setTimeout(poll, 800);
} else if (state.liveSlots[slotIndex] === slot) {
showToast('实时预览启动超时,请检查ZLMediaKit/FFmpeg', true);
clearLivePollTimer(slot);
destroySlotPlayer(slotIndex);
state.liveSlots[slotIndex] = null;
renderCameras();
renderDeviceTree();
showToast('实时预览等待超时(排队过久或通道未出流),请稍后重试', true);
}
};
slot.livePollTimer = setTimeout(poll, 800);


+ 2
- 1
src/main/resources/static/playback.html View File

@ -328,7 +328,8 @@
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js"></script>
<!-- 播放器库随 jar 静态资源本地分发,内网环境不依赖公网 CDN。 -->
<script src="hls.min.js?v=1.5.17"></script>
<script>
(() => {
'use strict';


Loading…
Cancel
Save