diff --git a/docker-compose.yml b/docker-compose.yml index e51dbeea..b52fb420 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: # 实时预览优先直连摄像机取流(CVR 等设备远程转发额度小,直连可绕开该瓶颈)。 HIK_NVR_LIVE_PREFER_CAMERA_DIRECT: ${HIK_NVR_LIVE_PREFER_CAMERA_DIRECT:-true} HIK_NVR_LIVE_CAMERA_RTSP_PORT: ${HIK_NVR_LIVE_CAMERA_RTSP_PORT:-554} + # 同一 IP 多通道相机(双光谱)的相机内通道顺序:asc / desc。 + HIK_NVR_LIVE_CAMERA_CHANNEL_ORDER: ${HIK_NVR_LIVE_CAMERA_CHANNEL_ORDER:-asc} JAVA_TOOL_OPTIONS: ${JAVA_TOOL_OPTIONS:--XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -Djava.library.path=/app/lib:/app/lib/HCNetSDKCom:/app/lib/linux64} REDIS_HOST: ${REDIS_HOST:?REDIS_HOST_required_in_.env} REDIS_PORT: ${REDIS_PORT:-6379} diff --git a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java index e109f415..abcbf35c 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java @@ -145,6 +145,8 @@ public class HikNvrLiveStreamService { private String cameraPassword; /** 直连摄像机 RTSP 端口连通性探测超时。 */ private int cameraProbeTimeoutMillis; + /** 同一 IP 多通道相机的相机内通道分配顺序:asc / desc。 */ + private String cameraChannelOrder; /** 已确认直连鉴权失败(相机凭据与 NVR 不同)的摄像机 IP,避免反复浪费一次尝试。 */ private final Set cameraDirectAuthFailed = ConcurrentHashMap.newKeySet(); @@ -198,6 +200,10 @@ public class HikNvrLiveStreamService { ? "" : config.getCameraPassword(); // 探测必须显著短于启动时限,避免相机不可达时拖慢起流。 this.cameraProbeTimeoutMillis = Math.max(100, config.getCameraProbeTimeoutMillis()); + // 多通道相机(双光谱等)的相机内通道分配顺序,默认按平台通道编码升序。 + this.cameraChannelOrder = "desc".equalsIgnoreCase( + config.getCameraChannelOrder() == null ? "" : config.getCameraChannelOrder().trim()) + ? "desc" : "asc"; } /** @@ -463,13 +469,17 @@ public class HikNvrLiveStreamService { } String rtmpUrl = rtmpBaseUrl + "/live/" + pending.streamId; - // 并发额度按"实际拉取的设备"统计:直连时是相机 IP,走转发时才是 NVR/CVR IP。 - // 否则直连摄像机取流会被所属 NVR 的额度限制住,等于没有绕开 CVR 的瓶颈。 + // 并发额度按"实际拉取的设备"统计:直连时是相机 IP(多镜头相机再细到相机内通道), + // 走转发时才是 NVR/CVR IP;否则直连取流会被所属 NVR 的额度限制住,等于没绕开 CVR 的瓶颈。 String streamHost = cameraTarget == null ? nvrIp : cameraTarget.devIp; - if (!awaitSessionQuota(streamHost, deadline)) { + int streamChannel = cameraTarget == null ? 0 : cameraTarget.cameraChannel; + String quotaKey = streamChannel > 0 ? streamHost + "#" + streamChannel : streamHost; + if (!awaitSessionQuota(quotaKey, deadline)) { + String target = streamChannel > 0 + ? streamHost + "(相机通道" + streamChannel + ")" : streamHost; throw HikNvrPlaybackException.conflict( NVR_LIVE_SESSION_LIMIT, - NVR_LIVE_SESSION_LIMIT + ":取流设备" + streamHost + "的并发实时流已达上限" + NVR_LIVE_SESSION_LIMIT + ":取流设备" + target + "的并发实时流已达上限" + maxSessionsPerNvr + "路,请先关闭其他窗口后重试"); } Process process; @@ -490,6 +500,7 @@ public class HikNvrLiveStreamService { pending.target.getCameraCode(), pending.target.getNvrInfo().getNvrIp(), streamHost, + streamChannel, pending.target.getChannel(), pending.streamType, pending.streamId, @@ -498,9 +509,8 @@ public class HikNvrLiveStreamService { pending.flvUrl); // 注册本地会话后,停止和状态请求即可立即定位并释放正在启动的进程。 sessions.put(sessionKey, session); - log.info("[live] started cameraCode={} nvrIp={} host={} source={} streamType={} streamId={} attempt={} log={}", - session.cameraCode, session.nvrIp, - cameraTarget == null ? session.nvrIp : cameraTarget.devIp, + log.info("[live] started cameraCode={} nvrIp={} host={} cameraChannel={} source={} streamType={} streamId={} attempt={} log={}", + session.cameraCode, session.nvrIp, streamHost, streamChannel, source, session.streamType, session.streamId, attempt, logFile); long remaining = Math.max(0L, deadline - System.currentTimeMillis()); @@ -1231,16 +1241,16 @@ public class HikNvrLiveStreamService { } /** - * 统计仍在运行且正在从指定设备取流的本地实时流数量。 + * 统计仍在运行的、命中同一并发额度键的本地实时流数量。 * - * @param host 实际取流设备地址(相机 IP 或 NVR/CVR IP) + * @param quotaKey 额度键:直连相机为 "相机IP#相机通道",转发为设备 IP * @return 本地活跃会话数 */ - private int activeSessionsOn(String host) { + private int activeSessionsOn(String quotaKey) { int count = 0; - // 本地会话表规模很小,逐条比对取流设备地址即可。 + // 本地会话表规模很小,逐条比对额度键即可。 for (LiveSession session : sessions.values()) { - if (session.isAlive() && host.equalsIgnoreCase(session.sourceHost)) { + if (session.isAlive() && quotaKey.equalsIgnoreCase(session.quotaKey())) { count++; } } @@ -1334,8 +1344,87 @@ public class HikNvrLiveStreamService { .password(password) .streamType(streamType) .build(); - // 直连摄像机只有一个通道,trackId = 1 * 100 + 码流类型,即 101/102。 - return new CameraDirectTarget(devIp, urlBuilder.buildLive(cameraRequest, 1)); + // 双光谱等多镜头相机在平台上一个 IP 占多个通道,必须用相机自己的通道号才能取到对应镜头。 + int cameraChannel = resolveCameraChannel(target.getCameraCode(), devIp); + // trackId = 相机内通道 * 100 + 码流类型,例如通道 1 = 101/102,通道 2 = 201/202。 + return new CameraDirectTarget(devIp, cameraChannel, + urlBuilder.buildLive(cameraRequest, cameraChannel)); + } + + /** + * 计算平台通道对应的相机内通道号。 + * + *

同一台多镜头相机(双光谱:可见光 + 热成像)在平台上占多个通道,直连 RTSP 时 + * 必须带上相机自己的通道号,否则两个平台通道会拉到同一个镜头。优先按平台通道名判断 + * 镜头类型(热成像/测温 → 相机通道 1,可见光 → 相机通道 2,与海康双光谱默认一致), + * 名称无法区分时按平台通道编码顺序分配 1..N;整站顺序相反时用 + * {@code hik.playback.live.camera-channel-order=desc} 反转。

+ * + * @param cameraCode 当前平台通道编码 + * @param devIp 摄像机 IP + * @return 相机内通道号,从 1 开始 + */ + private int resolveCameraChannel(String cameraCode, String devIp) { + List peers; + try { + // 该查询命中 ivs_camera.dev_ip 索引,返回同一台相机在平台上的全部通道。 + peers = ivsCameraMapper.selectChannelPeersByDevIp(devIp); + } catch (RuntimeException queryFailure) { + log.warn("[live] 查询同IP通道失败,按单通道相机处理 devIp={}:{}", + devIp, queryFailure.getMessage()); + return 1; + } + // 单通道相机(绝大多数)直接使用相机通道 1。 + if (peers == null || peers.size() <= 1) { + return 1; + } + int index = -1; + String cameraName = null; + // 通道记录按 code 升序返回,包含当前通道自己。 + for (int position = 0; position < peers.size(); position++) { + IvsCamera peer = peers.get(position); + if (peer != null && cameraCode.equalsIgnoreCase(peer.getCode())) { + index = position; + cameraName = peer.getName(); + break; + } + } + boolean reverse = "desc".equals(cameraChannelOrder); + // 先按名称判断镜头类型:双光谱相机两个通道名通常能区分可见光/热成像。 + int namedChannel = cameraChannelByThermalName(cameraName); + if (namedChannel > 0) { + // 顺序反转时交换通道 1 与通道 2。 + return reverse ? (namedChannel == 1 ? 2 : 1) : namedChannel; + } + if (index < 0) { + // 当前通道不在同 IP 列表中(数据异常)时退回相机通道 1。 + return 1; + } + // 名称无法区分(例如两个通道都叫“双光谱”)时按平台通道顺序分配。 + int ordered = reverse ? peers.size() - 1 - index : index; + return ordered + 1; + } + + /** + * 按通道名判断镜头类型并返回对应的相机内通道号。 + * + * @param cameraName 平台通道名 + * @return 热成像/测温返回 1,可见光返回 2,无法判断返回 0 + */ + private int cameraChannelByThermalName(String cameraName) { + if (!hasText(cameraName)) { + return 0; + } + String name = cameraName.toLowerCase(Locale.ROOT); + // 海康双光谱相机默认 1xx 为热成像通道、2xx 为可见光通道。 + if (name.contains("热成像") || name.contains("测温") || name.contains("thermal")) { + return 1; + } + if (name.contains("可见光") || name.contains("optical") + || name.contains("visual") || name.contains("白光")) { + return 2; + } + return 0; } /** @@ -1635,12 +1724,15 @@ public class HikNvrLiveStreamService { private static final class CameraDirectTarget { /** 摄像机 IP,用于鉴权失败后记忆该相机。 */ private final String devIp; + /** 相机内通道号(双光谱相机为 1/2),用于并发额度口径与日志核对。 */ + private final int cameraChannel; /** 可直接交给 FFmpeg 的摄像机 RTSP 地址。 */ private final String rtspUrl; - /** 保存摄像机 IP 与直连 RTSP 地址。 */ - private CameraDirectTarget(String devIp, String rtspUrl) { + /** 保存摄像机 IP、相机内通道与直连 RTSP 地址。 */ + private CameraDirectTarget(String devIp, int cameraChannel, String rtspUrl) { this.devIp = devIp; + this.cameraChannel = cameraChannel; this.rtspUrl = rtspUrl; } } @@ -1667,6 +1759,8 @@ public class HikNvrLiveStreamService { private final String nvrIp; /** 本路流实际拉取的设备地址:直连摄像机时为相机 IP,经转发时为 NVR/CVR IP。 */ private final String sourceHost; + /** 相机内通道号;走 NVR/CVR 转发时为 0。 */ + private final int sourceChannel; /** 页面逻辑通道号。 */ private final Integer channel; /** 主码流 1 或子码流 2。 */ @@ -1687,6 +1781,7 @@ public class HikNvrLiveStreamService { String cameraCode, String nvrIp, String sourceHost, + int sourceChannel, Integer channel, int streamType, String streamId, @@ -1696,6 +1791,7 @@ public class HikNvrLiveStreamService { this.cameraCode = cameraCode; this.nvrIp = nvrIp; this.sourceHost = sourceHost; + this.sourceChannel = sourceChannel; this.channel = channel; this.streamType = streamType; this.streamId = streamId; @@ -1708,6 +1804,16 @@ public class HikNvrLiveStreamService { private boolean isAlive() { return process != null && process.isAlive(); } + + /** + * 返回并发额度统计键。 + * + *

直连摄像机按"相机 IP + 相机内通道"统计,这样双光谱相机的可见光与热成像 + * 可以同时播放,而同一个镜头不会被重复拉取;走转发时按设备 IP 统计。

+ */ + private String quotaKey() { + return sourceChannel > 0 ? sourceHost + "#" + sourceChannel : sourceHost; + } } /** 一次排队或正在执行的起流任务。 */ diff --git a/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java b/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java index 2f574e8e..e0f16a51 100644 --- a/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java +++ b/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java @@ -82,4 +82,13 @@ public class HikNvrLiveStreamConfigVo { /** 直连摄像机 RTSP 端口连通性探测超时时间。 */ @Value("${hik.playback.live.camera-probe-timeout-millis:500}") private int cameraProbeTimeoutMillis = 500; + /** + * 同一 IP 多通道相机的相机内通道分配顺序。 + * + *

{@code asc}(默认)按平台通道编码升序分配 1..N,{@code desc} 反向。 + * 双光谱相机优先按名称判断镜头(热成像/测温 → 相机通道 1,可见光 → 2), + * 名称无法区分时才使用本顺序;现场若发现两个镜头画面互换,改成 {@code desc} 即可。

+ */ + @Value("${hik.playback.live.camera-channel-order:asc}") + private String cameraChannelOrder = "asc"; } diff --git a/src/main/java/com/inspect/nvr/ivs/mapper/IvsCameraMapper.java b/src/main/java/com/inspect/nvr/ivs/mapper/IvsCameraMapper.java index 6d7a884f..e9990fc4 100644 --- a/src/main/java/com/inspect/nvr/ivs/mapper/IvsCameraMapper.java +++ b/src/main/java/com/inspect/nvr/ivs/mapper/IvsCameraMapper.java @@ -22,6 +22,17 @@ public interface IvsCameraMapper { /** 按 IVS 摄像机完整编码查询摄像机记录,用于读取前端设备 IP。 */ IvsCamera selectByCode(@Param("code") String code); + /** + * 按设备 IP 查询共享该 IP 的摄像机通道。 + * + *

双光谱等多镜头相机在同一 IP 下会占用多个平台通道(可见光/热成像), + * 直连取流时需要据此计算相机内通道号。

+ * + * @param devIp 摄像机 IP + * @return 按完整编码升序排列的通道记录,仅填充 code/name/devIP + */ + List selectChannelPeersByDevIp(@Param("devIp") String devIp); + /** * 按 NVR IP 查询所属摄像机,供监控工作台从 ivs_camera 构建设备树。 * diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 477d303f..4e381efa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -84,6 +84,10 @@ hik: camera-password: "${HIK_NVR_LIVE_CAMERA_PASSWORD:}" # 直连摄像机 RTSP 端口连通性探测超时(毫秒)。 camera-probe-timeout-millis: ${HIK_NVR_LIVE_CAMERA_PROBE_TIMEOUT_MILLIS:500} + # 同一 IP 多通道相机(双光谱等)的相机内通道分配顺序:asc / desc。 + # 默认先按通道名判断镜头(热成像/测温 → 相机通道 1,可见光 → 2); + # 若现场发现某台双光相机的两个窗口画面互换,把这里改成 desc。 + camera-channel-order: ${HIK_NVR_LIVE_CAMERA_CHANNEL_ORDER:asc} clip: # 一分钟录像先由SDK下载到临时目录,再由FFmpeg统一封装为MP4。 ffmpeg-path: ${FFMPEG_PATH:C:/tools/ffmpeg/bin/ffmpeg.exe} diff --git a/src/main/resources/mapper/ivs/IvsCameraMapper.xml b/src/main/resources/mapper/ivs/IvsCameraMapper.xml index 3a25c108..addb25de 100644 --- a/src/main/resources/mapper/ivs/IvsCameraMapper.xml +++ b/src/main/resources/mapper/ivs/IvsCameraMapper.xml @@ -147,6 +147,16 @@ LIMIT 1 + +