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 d41282e6..2a251279 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java @@ -13,6 +13,7 @@ import com.inspect.nvr.hik.exception.HikNvrPlaybackException; import com.inspect.nvr.hik.vo.HikNvrLiveStreamConfigVo; import com.inspect.nvr.ivs.mapper.BasedataEqpbookChannelMapper; import com.inspect.nvr.ivs.mapper.IvsCameraMapper; +import com.inspect.nvr.ivs.pojo.BasedataEqpbookChannel; import com.inspect.nvr.ivs.pojo.IvsCamera; import com.inspect.nvr.ivs.service.IvsOpenApiService; import com.inspect.nvr.service.HikLoginService; @@ -36,6 +37,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -1328,15 +1332,21 @@ public class HikNvrLiveStreamService { } // 没有设备 IP 时无法直连,保持原有 NVR 取流行为。 if (camera == null || !hasText(camera.getDevIP())) { + log.info("[live] 跳过相机直连 cameraCode={} 原因=ivs_camera未配置dev_ip", + target.getCameraCode()); return null; } String devIp = camera.getDevIP().trim(); // 已确认该相机凭据不可用时不再重复尝试直连。 if (cameraDirectAuthFailed.contains(devIp)) { + log.info("[live] 跳过相机直连 cameraCode={} devIp={} 原因=该相机凭据此前鉴权失败", + target.getCameraCode(), devIp); return null; } // RTSP 端口不可达(未开放或被网络隔离)时直接回退,避免白等一次尝试。 if (!isCameraRtspReachable(devIp)) { + log.info("[live] 跳过相机直连 cameraCode={} devIp={} 原因=RTSP端口{}不可达", + target.getCameraCode(), devIp, cameraRtspPort); return null; } NvrInfo nvrInfo = target.getNvrInfo(); @@ -1346,6 +1356,8 @@ public class HikNvrLiveStreamService { ? cameraPassword : nvrInfo.getPassword(); // 账号缺失时无法建立 RTSP 会话,回退 NVR 路径由 IVS 侧给出明确错误。 if (!hasText(username) || password == null) { + log.info("[live] 跳过相机直连 cameraCode={} devIp={} 原因=缺少账号或密码", + target.getCameraCode(), devIp); return null; } // 复用统一 URL 构造器,保证中文或特殊字符账号的百分号编码规则与 NVR 路径一致。 @@ -1367,19 +1379,25 @@ public class HikNvrLiveStreamService { * 计算平台通道对应的相机内通道号。 * *

同一台多镜头相机(双光谱:可见光 + 热成像)在平台上占多个通道,直连 RTSP 时 - * 必须带上相机自己的通道号,否则两个平台通道会拉到同一个镜头。判定顺序:

+ * 必须带上相机自己的通道号,否则两个平台通道会拉到同一个镜头。台账与通道名都只 + * 描述单个通道的镜头类型,必须先确认它们确实把同一 IP 下的兄弟通道区分开了才可用, + * 否则两个平台通道会一起被映射到同一个相机通道。判定顺序:

*
    *
  1. 台账 {@code basedata_eqpbook_channel.channel_type},{@code vl} 可见光、 - * {@code ir} 红外热成像。这是镜头类型的权威来源,{@code ir} 用的相机内通道号 - * 由 {@code hik.playback.live.ir-camera-channel} 指定(默认 2,即 1=可见光、 - * 2=热成像);
  2. - *
  3. 台账缺失或类型与镜头无关(ptz/ng 等)时按平台通道名关键字判断;
  4. - *
  5. 名称也判断不出来时按平台通道编码顺序分配 1..N,整站顺序相反时用 - * {@code hik.playback.live.camera-channel-order=desc} 反转。
  6. + * {@code ir} 红外热成像。{@code ir} 用的相机内通道号由 + * {@code hik.playback.live.ir-camera-channel} 指定(默认 2,即 1=可见光、 + * 2=热成像),{@code vl} 用另一个通道。台账只有在确实把同一 IP 下的兄弟通道 + * 区分成不同镜头时才生效,见 {@link #resolveLedgerChannel}; + *
  7. 台账缺失、类型与镜头无关(ptz/ng 等)或不足以区分镜头时,按平台通道名 + * 关键字判断,名称同样要能区分镜头才生效,见 {@link #resolveNamedChannel};
  8. + *
  9. 上述信号都判断不出镜头类型时按平台通道编码顺序分配 1..N,整站顺序相反时用 + * {@code hik.playback.live.camera-channel-order=desc} 反转。顺序分配天然让 + * 兄弟通道落在不同的相机通道上,默认升序即多数现场的 1=可见光、2=热成像。
  10. *
* *

只有同 IP 多通道(真双光谱机身机)才做镜头判定:单通道设备的相机内通道号 - * 恒为 1,单镜头热成像相机套用镜头映射反而会取到不存在的通道。

+ * 恒为 1,单镜头热成像相机套用镜头映射反而会取到不存在的通道,因此单通道判断 + * 排在所有镜头映射之前。

* * @param cameraCode 当前平台通道编码 * @param devIp 摄像机 IP @@ -1395,32 +1413,28 @@ public class HikNvrLiveStreamService { devIp, queryFailure.getMessage()); return 1; } - // 单通道相机(绝大多数)直接使用相机通道 1,不必再查台账。 + // 单通道相机(绝大多数)直接使用相机通道 1,不参与任何镜头映射。 if (peers == null || peers.size() <= 1) { return 1; } - // 同 IP 多通道说明是多镜头机身相机,先按台账镜头类型判定,避免依赖通道名。 - String channelType = resolveChannelType(cameraCode); - int typedChannel = cameraChannelByChannelType(channelType); - if (typedChannel > 0) { - log.info("[live] 按台账镜头类型分配相机通道 cameraCode={} channelType={} cameraChannel={}", - cameraCode, channelType, typedChannel); - return typedChannel; - } + 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); + + // 1.同 IP 多通道说明是多镜头机身相机,先按台账镜头类型判定,避免依赖通道名。 + int ledgerChannel = resolveLedgerChannel(peers, index); + if (ledgerChannel > 0) { + return ledgerChannel; + } + // 2.台账不具备区分镜头的能力时退回名称判断。 + int namedChannel = resolveNamedChannel(peers, index); if (namedChannel > 0) { return namedChannel; } @@ -1428,33 +1442,147 @@ public class HikNvrLiveStreamService { // 当前通道不在同 IP 列表中(数据异常)时退回相机通道 1。 return 1; } - // 名称也无法区分(例如两个通道都叫“双光谱”)时按平台通道顺序分配。 - int ordered = reverse ? peers.size() - 1 - index : index; - return ordered + 1; + // 3.名称也无法区分(例如两个通道都叫“双光谱”,或都是可见光)时按平台通道顺序分配。 + boolean reverse = "desc".equals(cameraChannelOrder); + int orderedChannel = (reverse ? peers.size() - 1 - index : index) + 1; + log.info("[live] 台账与通道名都未区分镜头,按平台通道顺序分配相机通道" + + " cameraCode={} index={}/{} order={} cameraChannel={}", + cameraCode, index, peers.size(), cameraChannelOrder, orderedChannel); + return orderedChannel; } /** - * 读取台账 basedata_eqpbook_channel.channel_type。 + * 按台账镜头类型判定当前通道的相机内通道号。 * - * @param cameraCode IVS 完整摄像机编码 - * @return 台账通道类型(vl/ir 等);无记录、字段为空或查询异常时返回 null + *

台账能用来判定镜头的前提,是它确实把同一 IP 下的兄弟通道区分成了不同镜头。 + * 现场既有“1=可见光、2=热成像”的双光谱相机,也有两个镜头都是可见光、台账两个 + * 通道都记为 {@code vl} 的机身机;后者逐通道套用类型会让两个平台通道解析到同一个 + * 相机通道,两个窗口拉到同一个镜头。因此这里先统计兄弟通道按台账解析出的相机 + * 通道,只有一个取值时视为台账不具备区分能力,返回 0 交给通道名与平台通道顺序 + * 兜底,由顺序分配把两个通道分别落到相机通道 1 和 2:双可见光相机两路各自取到 + * 一个可见光镜头,双光谱相机则分别取到可见光与热成像镜头。

+ * + * @param peers 同一 IP 下的平台通道,按完整编码升序 + * @param index 当前通道在 {@code peers} 中的下标,-1 表示不在列表中 + * @return 台账判定的相机内通道号;台账不具备区分能力时返回 0 + */ + private int resolveLedgerChannel(List peers, int index) { + if (index < 0) { + return 0; + } + // 同一ip下的key:channelCode,value:channelType + Map channelTypes = loadChannelTypes(peers); + Set resolvedChannels = new HashSet<>(peers.size()); + for (IvsCamera peer : peers) { + if (peer == null) { + continue; + } + int peerChannel = cameraChannelByChannelType(channelTypes.get(peer.getCode())); + if (peerChannel > 0) { + resolvedChannels.add(peerChannel); + } + } + // 兄弟通道全部落在同一个相机通道上,说明台账没有区分镜头,不能据此分配。 + if (resolvedChannels.size() <= 1) { + return 0; + } + String cameraCode = peers.get(index).getCode(); + String channelType = channelTypes.get(cameraCode); + int typedChannel = cameraChannelByChannelType(channelType); + if (typedChannel > 0) { + log.info("[live] 按台账镜头类型分配相机通道 cameraCode={} channelType={} cameraChannel={}", + cameraCode, channelType, typedChannel); + } + return typedChannel; + } + + /** + * 按平台通道名判定当前通道的相机内通道号。 + * + *

与台账一样必须先确认信号能区分镜头:两个通道都带“可见光”的相机(双可见光 + * 机身机,或热成像那一路名称没标注)逐个通道套用关键字会让两个平台通道落到同一个 + * 相机通道。因此兄弟通道名称解析出的相机通道只有一个取值时返回 0,交给平台通道 + * 顺序兜底。

+ * + * @param peers 同一 IP 下的平台通道,按完整编码升序 + * @param index 当前通道在 {@code peers} 中的下标,-1 表示不在列表中 + * @return 名称判定的相机内通道号;名称不足以区分镜头时返回 0 */ - private String resolveChannelType(String cameraCode) { + private int resolveNamedChannel(List peers, int index) { + if (index < 0) { + return 0; + } + Set resolvedChannels = new HashSet<>(peers.size()); + for (IvsCamera peer : peers) { + if (peer == null) { + continue; + } + int peerChannel = cameraChannelByThermalName(peer.getName()); + if (peerChannel > 0) { + resolvedChannels.add(peerChannel); + } + } + // 各通道名称指向同一个镜头,说明名称没有区分镜头,不能据此分配。 + if (resolvedChannels.size() <= 1) { + return 0; + } + IvsCamera current = peers.get(index); + int namedChannel = cameraChannelByThermalName(current.getName()); + if (namedChannel > 0) { + log.info("[live] 按通道名分配相机通道 cameraCode={} cameraName={} cameraChannel={}", + current.getCode(), current.getName(), namedChannel); + } + return namedChannel; + } + + /** + * 批量读取同一 IP 下各平台通道的台账镜头类型。 + * + * @param peers 同一 IP 下的平台通道 + * @return 摄像机编码到台账 channel_type 的映射;同一编码有多条历史台账时取最新一条, + * 无记录或查询异常时该编码不出现在映射中 + */ + private Map loadChannelTypes(List peers) { + List codes = new ArrayList<>(peers.size()); + for (IvsCamera peer : peers) { + if (peer != null && hasText(peer.getCode())) { + codes.add(peer.getCode()); + } + } + if (codes.isEmpty()) { + return Collections.emptyMap(); + } + Map channelTypes = new HashMap<>(codes.size()); try { - String channelType = eqpbookChannelMapper.selectChannelTypeByChannelCode(cameraCode); - // 台账没有该编码时保持为空,由调用方回退名称与顺序判定。 - return hasText(channelType) ? channelType.trim() : null; + List records = + eqpbookChannelMapper.selectChannelTypesByChannelCodes(codes); + for (BasedataEqpbookChannel record : records) { + if (record == null || !hasText(record.getChannelCode())) { + continue; + } + // 同一编码存在历史重复台账时查询按 channel_id 倒序返回,保留首条即最新记录。 + if (channelTypes.containsKey(record.getChannelCode())) { + continue; + } + channelTypes.put(record.getChannelCode(), + hasText(record.getChannelType()) ? record.getChannelType().trim() : null); + } } catch (RuntimeException queryFailure) { // 台账不是取流必需数据,查询失败只记录原因,不阻断实时预览。 - log.warn("[live] 读取basedata_eqpbook_channel失败,回退名称判定 cameraCode={}:{}", - cameraCode, queryFailure.getMessage()); - return null; + log.warn("[live] 读取basedata_eqpbook_channel失败,回退通道名判定 同IP通道数={}:{}", + codes.size(), queryFailure.getMessage()); + return Collections.emptyMap(); } + return channelTypes; } /** * 按台账通道类型返回可见光/热成像对应的相机内通道号。 * + *

只做单点映射,不判断台账是否真的区分了兄弟通道:两个通道都记 {@code vl} 时 + * 会一起落到同一个相机通道。因此调用方必须先确认台账具备区分能力,见 + * {@link #resolveLedgerChannel}。

+ * * @param channelType 台账 channel_type,vl 可见光 / ir 红外热成像 * @return {@code ir} 返回配置的相机内通道号,{@code vl} 返回另一个通道;无法判定返回 0 */ @@ -1486,8 +1614,9 @@ public class HikNvrLiveStreamService { /** * 按通道名判断镜头类型并返回对应的相机内通道号。 * - *

只在台账 {@code channel_type} 缺失时使用:部分平台通道名带“热成像/可见光” - * 字样,但名称由台账人员维护、可能两个通道同名,因此不作为首选依据。

+ *

只在台账 {@code channel_type} 缺失或不具备区分能力时使用:部分平台通道名带 + * “热成像/可见光”字样,但名称由台账人员维护、可能两个通道同名。只做单点映射, + * 调用方必须先确认名称能区分兄弟通道,见 {@link #resolveNamedChannel}。

* * @param cameraName 平台通道名 * @return 热成像/测温返回配置的相机内通道号,可见光返回另一个通道,无法判断返回 0 @@ -1569,9 +1698,9 @@ public class HikNvrLiveStreamService { command.add("error"); command.add("-nostats"); // 海康 RTSP 不在 SDP 中给出帧率,ffmpeg 会自愿读满默认 5 秒探测窗口来估帧率; - // 显式压到 3 秒让推流提前约 2 秒开始,否则首次尝试 5 秒预算会在探测完成前杀掉进程。 + // 压到 1.5 秒让推流提前约 3.5 秒开始:25fps 子码流下约 37 帧已足够估准帧率。 command.add("-analyzeduration"); - command.add("3000000"); + command.add("1500000"); command.add("-rtsp_transport"); command.add("tcp"); command.add("-i"); 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 5b1946b4..1343e5c8 100644 --- a/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java +++ b/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java @@ -54,8 +54,8 @@ public class HikNvrLiveStreamConfigVo { @Value("${hik.playback.live.stop-grace-millis:2000}") private long stopGraceMillis = 2000L; /** 单台 NVR 允许同时播放的实时流上限,0 表示不限制。 */ - @Value("${hik.playback.live.max-sessions-per-nvr:8}") - private int maxSessionsPerNvr = 8; + @Value("${hik.playback.live.max-sessions-per-nvr:0}") + private int maxSessionsPerNvr = 0; /** 是否回收长时间无人观看的实时流。 */ @Value("${hik.playback.live.idle-reap-enabled:true}") private boolean idleReapEnabled = true; @@ -90,6 +90,9 @@ public class HikNvrLiveStreamConfigVo { * 是可见光、{@code ir} 是热成像,本值决定 {@code ir} 使用哪个相机内通道: * 默认 2,即 1=可见光、2=热成像;{@code vl} 自动使用另一个通道。

* + *

台账只在该相机的多个平台通道确实被区分成不同镜头时才生效;两个通道都记为 + * {@code vl} 时台账不具备区分能力,会退回通道名与平台通道顺序判定。

+ * *

现场若发现两个镜头画面互换,把本值改成 1 即可,台账判定与通道名判定会 * 一起反转,不需要再动 {@code camera-channel-order}。

*/ @@ -99,8 +102,10 @@ public class HikNvrLiveStreamConfigVo { * 同一 IP 多通道相机的相机内通道分配顺序。 * *

{@code asc}(默认)按平台通道编码升序分配 1..N,{@code desc} 反向。 - * 只在台账 {@code channel_type} 与平台通道名都判断不出可见光/热成像时才生效; - * 镜头判定结果不由本配置调整,改镜头方向请用 {@code ir-camera-channel}。

+ * 台账与平台通道名都判断不出可见光/热成像,或台账把兄弟通道记成了同一种类型 + * (例如两个通道都是 {@code vl})时生效,此时升序正好对应多数现场的 + * 1=可见光、2=热成像;镜头判定结果不由本配置调整,改镜头方向请用 + * {@code ir-camera-channel}。

*/ @Value("${hik.playback.live.camera-channel-order:asc}") private String cameraChannelOrder = "asc"; diff --git a/src/main/java/com/inspect/nvr/ivs/mapper/BasedataEqpbookChannelMapper.java b/src/main/java/com/inspect/nvr/ivs/mapper/BasedataEqpbookChannelMapper.java index fdfb59ef..09d92d2e 100644 --- a/src/main/java/com/inspect/nvr/ivs/mapper/BasedataEqpbookChannelMapper.java +++ b/src/main/java/com/inspect/nvr/ivs/mapper/BasedataEqpbookChannelMapper.java @@ -1,8 +1,13 @@ package com.inspect.nvr.ivs.mapper; +import com.inspect.nvr.ivs.pojo.BasedataEqpbookChannel; + import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.util.Collection; +import java.util.List; + /** * 台账通道表 basedata_eqpbook_channel 的 MyBatis Mapper。 * @@ -16,10 +21,15 @@ import org.apache.ibatis.annotations.Param; public interface BasedataEqpbookChannelMapper { /** - * 按 IVS 完整摄像机编码读取台账通道类型。 + * 按 IVS 完整摄像机编码批量读取台账通道类型。 + * + *

判定镜头必须同时看同一 IP 下的兄弟通道,以确认台账是否真的把两个镜头 + * 区分开了,因此这里一次取回整组编码,避免逐通道查询。

* - * @param channelCode IVS 完整摄像机编码,与 {@code ivs_camera.code} 同值 - * @return 台账 channel_type(vl/ir 等);无记录或字段为空时返回 null + * @param channelCodes IVS 完整摄像机编码集合,与 {@code ivs_camera.code} 同值,不可为空 + * @return 按 channel_code 升序、同一编码内按 channel_id 倒序排列的记录;调用方 + * 对每个编码取首条即为最新台账记录,无记录的编码不会出现在结果中 */ - String selectChannelTypeByChannelCode(@Param("channelCode") String channelCode); + List selectChannelTypesByChannelCodes( + @Param("channelCodes") Collection channelCodes); } diff --git a/src/main/java/com/inspect/nvr/ivs/pojo/BasedataEqpbookChannel.java b/src/main/java/com/inspect/nvr/ivs/pojo/BasedataEqpbookChannel.java new file mode 100644 index 00000000..acf9692e --- /dev/null +++ b/src/main/java/com/inspect/nvr/ivs/pojo/BasedataEqpbookChannel.java @@ -0,0 +1,28 @@ +package com.inspect.nvr.ivs.pojo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 台账通道信息,对应 basedata_eqpbook_channel 表的镜头判定投影。 + * + *

实时预览直连摄像机时只关心两列:{@code channel_code} 用于关联平台通道, + * {@code channel_type} 用于判定可见光/热成像镜头。台账同一编码存在历史重复 + * 记录时,查询按 channel_id 倒序返回,调用方保留首条即可取到最新记录。

+ */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BasedataEqpbookChannel implements Serializable { + + /** 序列化版本号。 */ + private static final long serialVersionUID = 1L; + + /** 完整摄像机编码,与 ivs_camera.code 同值。 */ + private String channelCode; + /** 通道镜头类型:vl 可见光、ir 红外(热成像),另有 ptz 云台、ng 导航等。 */ + private String channelType; +} diff --git a/src/main/resources/mapper/ivs/BasedataEqpbookChannelMapper.xml b/src/main/resources/mapper/ivs/BasedataEqpbookChannelMapper.xml index a37224df..20b06ee7 100644 --- a/src/main/resources/mapper/ivs/BasedataEqpbookChannelMapper.xml +++ b/src/main/resources/mapper/ivs/BasedataEqpbookChannelMapper.xml @@ -5,17 +5,22 @@ - + SELECT + channel_code, + channel_type FROM basedata_eqpbook_channel - WHERE channel_code = #{channelCode} - ORDER BY channel_id DESC - LIMIT 1 + WHERE channel_code IN + + #{channelCode} + + ORDER BY channel_code, channel_id DESC