# 海康 NVR SDK 受控 HLS 回放 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在当前 Spring 服务中提供由海康 SDK 实际控制的 HLS 历史回放,支持跳转、暂停/恢复和 1/2/4/8 倍速。 **Architecture:** 服务通过 `NET_DVR_PlayBackByTime_V40` 建立唯一可控回放句柄,从 `NET_DVR_SetPlayBackESCallBack` 获取 H.264 裸帧,异步写入本机 FFmpeg 标准输入,FFmpeg 推 RTMP 到本机 Docker ZLMediaKit,ZLM 输出 HLS。会话控制直接调用同一 SDK 句柄;定位时切换到新的 HLS 流代次。 **Tech Stack:** Java 8、Spring Boot 2.3、JNA/HCNetSDK、FFmpeg、Docker Desktop、ZLMediaKit、JUnit 4、Mockito。 **User constraint:** 在当前 `master` 本地直接修改,不创建 worktree、不暂存、不提交。 --- ### Task 1: 部署本机媒体运行环境 **Files:** - Create: `deploy/zlmediakit/docker-compose.yml` - Create: `deploy/zlmediakit/config.ini` - Modify: `.gitignore` - External: `C:\tools\ffmpeg\bin\ffmpeg.exe` - [ ] **Step 1: 增加 Docker 与端口配置的断言说明** 写入 compose,唯一暴露端口为: ```yaml services: zlmediakit: image: zlmediakit/zlmediakit:master container_name: inspect-nvr-zlmediakit restart: unless-stopped ports: - "127.0.0.1:18081:80" - "127.0.0.1:1935:1935" ``` - [ ] **Step 2: 启动 Docker Desktop 并验证失败前置条件** Run: `docker info --format '{{.ServerVersion}}'` Expected: Docker server version is printed; if it is unavailable, start Docker Desktop and retry. - [ ] **Step 3: 下载 FFmpeg 到本机工具目录** 从 Windows FFmpeg 发行包解压到 `C:\tools\ffmpeg`,验证: Run: `C:\tools\ffmpeg\bin\ffmpeg.exe -version` Expected: command prints an FFmpeg version and exits with code 0. - [ ] **Step 4: 写入最小 ZLM 配置** `config.ini` 必须至少包含: ```ini [http] port=80 allow_cross_domains=1 [protocol] enable_hls=1 enable_audio=0 hls_demand=0 [hls] segDur=2 segNum=3 segRetain=5 ``` compose 必须将本文件挂载为 `/opt/media/conf/config.ini:ro`;该镜像从 `/opt/media/bin` 启动 `MediaServer` 并以 `../conf/config.ini` 作为配置文件。 - [ ] **Step 5: 启动 ZLM 并验证 HTTP 与 RTMP 端口** Run: `docker compose -f deploy/zlmediakit/docker-compose.yml up -d` Expected: `inspect-nvr-zlmediakit` is running and `Test-NetConnection 127.0.0.1 -Port 18081` plus `Test-NetConnection 127.0.0.1 -Port 1935` return `TcpTestSucceeded : True`. - [ ] **Step 6: 忽略本机凭据与运行时文件** 在 `.gitignore` 增加: ```gitignore /deploy/zlmediakit/.env /deploy/zlmediakit/log/ /deploy/zlmediakit/www/ ``` 验证 `git status --short` 中不出现本机 `.env`、日志或 HLS 切片。 ### Task 2: 修正并覆盖 HCNetSDK 回放控制 JNA 契约 **Files:** - Modify: `src/main/java/com/inspect/nvr/hikVision/utils/jna/HCNetSDK.java` - Create: `src/test/java/com/inspect/nvr/hik/service/HikSdkPlaybackGatewayTest.java` - [ ] **Step 1: 写出会失败的控制码与结构测试** ```java @Test public void exposesAbsoluteSeekControlAndStructuredPacketLayout() { assertEquals(26, HCNetSDK.NET_DVR_PLAYSETTIME); assertTrue(new HCNetSDK.NET_DVR_PACKET_INFO_EX().size() > 80); } ``` - [ ] **Step 2: 运行测试确认当前契约缺失** Run: `mvn -Dtest=HikSdkPlaybackGatewayTest#exposesAbsoluteSeekControlAndStructuredPacketLayout test` Expected: compilation failure because `NET_DVR_PLAYSETTIME` is absent, or assertion failure because the packet structure is represented as a JNA union. - [ ] **Step 3: 进行最小 JNA 修复** 在控制常量区加入: ```java public static final int NET_DVR_PLAYSETTIME = 26; ``` 将 `NET_DVR_PACKET_INFO_EX` 从 `Union` 改为 `HCStructure`,保留字段顺序和所有字段;它是 SDK 回调返回的顺序结构体,不是 union。 - [ ] **Step 4: 运行单测确认修复** Run: `mvn -Dtest=HikSdkPlaybackGatewayTest#exposesAbsoluteSeekControlAndStructuredPacketLayout test` Expected: PASS. ### Task 3: 定义 HLS 会话领域模型与 URL/控制映射 **Files:** - Create: `src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackHlsSession.java` - Create: `src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackHlsControlRequest.java` - Create: `src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackHlsControlAction.java` - Create: `src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackHlsStatus.java` - Create: `src/main/java/com/inspect/nvr/hik/service/HikPlaybackHlsUrlBuilder.java` - Create: `src/test/java/com/inspect/nvr/hik/service/HikPlaybackHlsUrlBuilderTest.java` - Create: `src/test/java/com/inspect/nvr/hik/domain/HikNvrPlaybackHlsControlRequestTest.java` - [ ] **Step 1: 写出 HLS 地址和合法速度的失败测试** ```java @Test public void buildsLocalHlsUrlForStreamGeneration() { assertEquals("http://127.0.0.1:18081/playback/a1-2/hls.m3u8", builder.hlsUrl("a1", 2)); } @Test public void rejectsUnsupportedPlaybackSpeed() { assertFalse(HikNvrPlaybackHlsControlRequest.isSupportedSpeed(3)); } ``` - [ ] **Step 2: 运行测试确认失败** Run: `mvn -Dtest=HikPlaybackHlsUrlBuilderTest,HikNvrPlaybackHlsControlRequestTest test` Expected: compilation failure because the classes do not exist. - [ ] **Step 3: 实现最小领域模型** `HikNvrPlaybackHlsControlAction` 只包含 `PAUSE`、`RESUME`、`SET_SPEED`、`SEEK`、`STOP`。`isSupportedSpeed` 只接受 1、2、4、8。HLS 地址固定为: ```java return httpBaseUrl + "/playback/" + sessionId + "-" + generation + "/hls.m3u8"; ``` - [ ] **Step 4: 运行测试确认通过** Run: `mvn -Dtest=HikPlaybackHlsUrlBuilderTest,HikNvrPlaybackHlsControlRequestTest test` Expected: PASS. ### Task 4: 以测试驱动实现异步 H.264 到 FFmpeg/RTMP 的帧桥 **Files:** - Create: `src/main/java/com/inspect/nvr/hik/service/HikPlaybackFrame.java` - Create: `src/main/java/com/inspect/nvr/hik/service/HikPlaybackFrameSink.java` - Create: `src/main/java/com/inspect/nvr/hik/service/HikFfmpegProcessFactory.java` - Create: `src/main/java/com/inspect/nvr/hik/service/HikFfmpegH264Publisher.java` - Create: `src/test/java/com/inspect/nvr/hik/service/HikFfmpegH264PublisherTest.java` - [ ] **Step 1: 写出非阻塞入队、I 帧优先和 FFmpeg 命令的失败测试** ```java @Test public void usesPipeInputAndPublishesToSessionRtmpUrl() throws Exception { publisher.start(); assertThat(processFactory.command()).contains("pipe:0"); assertThat(processFactory.command()).contains("rtmp://127.0.0.1:1935/playback/a1-1"); } @Test public void preservesIFrameWhenQueueIsFull() { // fill bounded queue with B/P frames, then offer an I frame assertTrue(publisher.offer(frameOfType(1))); } ``` - [ ] **Step 2: 运行测试确认失败** Run: `mvn -Dtest=HikFfmpegH264PublisherTest test` Expected: compilation failure because the publisher does not exist. - [ ] **Step 3: 实现最小帧桥** FFmpeg 参数必须包含: ```text -hide_banner -loglevel warning -fflags +genpts -use_wallclock_as_timestamps 1 -f h264 -i pipe:0 -c:v copy -an -f flv rtmp://127.0.0.1:1935/playback/{streamId} ``` JNA 回调只调用 `offer`;写线程把 `byte[]` 写入 `process.getOutputStream()`。关闭时停止接收、关闭 stdin、等待进程有限时间后强制销毁。 - [ ] **Step 4: 运行帧桥单测** Run: `mvn -Dtest=HikFfmpegH264PublisherTest test` Expected: PASS. ### Task 5: 以测试驱动实现海康 SDK 回放句柄和控制 **Files:** - Create: `src/main/java/com/inspect/nvr/hik/service/HikSdkPlaybackGateway.java` - Create: `src/main/java/com/inspect/nvr/hik/service/HikSdkPlaybackHandle.java` - Create: `src/main/java/com/inspect/nvr/hik/service/HikSdkPlaybackGatewayImpl.java` - Modify: `src/main/java/com/inspect/nvr/hik/exception/HikNvrPlaybackException.java` - Modify: `src/test/java/com/inspect/nvr/hik/service/HikSdkPlaybackGatewayTest.java` - [ ] **Step 1: 写出 VOD 创建、启动、倍速和定位的失败测试** ```java @Test public void startsSdkPlaybackWithOriginalSdkChannelAndActualRange() { gateway.open(request, start, end, sink); verify(sdk).NET_DVR_PlayBackByTime_V40(eq(7), vodCaptor.capture()); assertEquals(33, vodCaptor.getValue().struIDInfo.dwChannel); verify(sdk).NET_DVR_PlayBackControl_V40(eq(91), eq(HCNetSDK.NET_DVR_PLAYSTART), isNull(), eq(0), isNull(), any(IntByReference.class)); } @Test public void mapsSpeedEightToNormalThenThreeFastCommands() { handle.setSpeed(8); verifyControl(HCNetSDK.NET_DVR_PLAYNORMAL, 1); verifyControl(HCNetSDK.NET_DVR_PLAYFAST, 3); } @Test public void seeksUsingNetDvrTimePointer() { handle.seek(LocalDateTime.of(2026, 7, 13, 12, 45)); verify(sdk).NET_DVR_PlayBackControl_V40(eq(91), eq(26), any(Pointer.class), eq(new HCNetSDK.NET_DVR_TIME().size()), isNull(), any(IntByReference.class)); } ``` - [ ] **Step 2: 运行测试确认失败** Run: `mvn -Dtest=HikSdkPlaybackGatewayTest test` Expected: compilation failure because the gateway and handle do not exist. - [ ] **Step 3: 实现 SDK 网关** `open` 必须:登录缓存中获取 `userId`;填充 `NET_DVR_VOD_PARA.dwSize`、`struIDInfo.dwSize`、实际 SDK 通道、北京时间起止时间、主/子码流;调用 `NET_DVR_PlayBackByTime_V40`;保存回调强引用;注册 ES 回调;最后调用 `NET_DVR_PLAYSTART`。 回调必须复制 `pPacketBuffer` 中 `dwPacketSize` 字节;只将视频 I/B/P 包写入当前 `AtomicReference`,忽略文件头、音频和私有包。`close` 必须先断开 sink,再 `NET_DVR_StopPlayBack`。 - [ ] **Step 4: 运行 SDK 网关测试** Run: `mvn -Dtest=HikSdkPlaybackGatewayTest test` Expected: PASS. ### Task 6: 实现会话编排、定位代次切换和自动清理 **Files:** - Create: `src/main/java/com/inspect/nvr/hik/service/HikNvrPlaybackHlsService.java` - Create: `src/main/java/com/inspect/nvr/hik/service/HikNvrPlaybackHlsSessionRegistry.java` - Create: `src/test/java/com/inspect/nvr/hik/service/HikNvrPlaybackHlsServiceTest.java` - [ ] **Step 1: 写出创建、定位、倍速、删除的失败测试** ```java @Test public void createsSessionFromOneActualRecordingRangeWithoutReturningRtspUrl() { HikNvrPlaybackHlsSession session = service.create(request); assertEquals(HikNvrPlaybackHlsStatus.PLAYING, session.getStatus()); assertFalse(serialized(session).contains("rtsp://")); } @Test public void seekCreatesNextGenerationAndReturnsNewHlsUrl() { HikNvrPlaybackHlsSession moved = service.control(id, seek("2026-07-13 12:45:00")); assertEquals(2, moved.getGeneration()); assertTrue(moved.getHlsUrl().contains("-2/hls.m3u8")); } ``` - [ ] **Step 2: 运行测试确认失败** Run: `mvn -Dtest=HikNvrPlaybackHlsServiceTest test` Expected: compilation failure because the service and registry do not exist. - [ ] **Step 3: 实现会话服务** 创建流程:调用已有 `HikNvrPlaybackService.search`;空记录抛 404;多个记录抛 409;将唯一实际区间解析为 `LocalDateTime`;创建 publisher 和 SDK handle;登记会话。 定位流程:暂停旧句柄,停止旧 publisher,启动新代次 publisher,切换句柄 sink,调用 SDK `PLAYSETTIME`;失败时关闭旧句柄并以目标时间重建;恢复目标速度;更新 HLS URL 与状态。自动清理使用当前已有 `@EnableScheduling`,过期时调用同一个 `stop` 路径。 - [ ] **Step 4: 运行会话服务测试** Run: `mvn -Dtest=HikNvrPlaybackHlsServiceTest test` Expected: PASS. ### Task 7: 暴露 Swagger 完整标注的 HLS REST 接口 **Files:** - Create: `src/main/java/com/inspect/nvr/hik/controller/HikNvrPlaybackHlsController.java` - Modify: `src/main/java/com/inspect/nvr/hik/controller/HikNvrPlaybackExceptionHandler.java` - Create: `src/test/java/com/inspect/nvr/hik/controller/HikNvrPlaybackHlsControllerTest.java` - Modify: `src/test/java/com/inspect/nvr/config/ApiDocumentationCoverageTest.java` - [ ] **Step 1: 写出 MVC 失败测试** ```java mockMvc.perform(post("/hik/nvr/playback/hls/sessions") .contentType(MediaType.APPLICATION_JSON).content(REQUEST_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.hlsUrl").value("http://127.0.0.1:18081/playback/a1-1/hls.m3u8")) .andExpect(jsonPath("$.rtspUrl").doesNotExist()); mockMvc.perform(post("/hik/nvr/playback/hls/sessions/a1/controls") .contentType(MediaType.APPLICATION_JSON) .content("{\"action\":\"SET_SPEED\",\"speed\":8}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.speed").value(8)); ``` - [ ] **Step 2: 运行测试确认失败** Run: `mvn -Dtest=HikNvrPlaybackHlsControllerTest test` Expected: compilation failure because controller does not exist. - [ ] **Step 3: 实现控制器和错误映射** 暴露: ```text POST /hik/nvr/playback/hls/sessions GET /hik/nvr/playback/hls/sessions/{sessionId} POST /hik/nvr/playback/hls/sessions/{sessionId}/controls DELETE /hik/nvr/playback/hls/sessions/{sessionId} ``` 所有请求、响应、枚举加中文 `@Schema`、`@Operation` 和错误码说明。`HikNvrPlaybackException` 增加 `notFound`、`conflict` 和 `mediaFailure` 工厂方法,统一由既有 ControllerAdvice 返回。 - [ ] **Step 4: 运行 MVC 和 Swagger 覆盖测试** Run: `mvn -Dtest=HikNvrPlaybackHlsControllerTest,ApiDocumentationCoverageTest test` Expected: PASS. ### Task 8: 运行时验证与实际 NVR 验证 **Files:** - Modify: `docs/superpowers/specs/2026-07-13-hik-nvr-sdk-controlled-hls-design.md`(仅记录实际验证结论) - [ ] **Step 1: 运行完整 Java 测试套件** Run: `mvn test -DskipTests=false` Expected: all tests pass with zero failures and zero errors. - [ ] **Step 2: 用临时端口启动服务** Run: set `HIK_NVR_PLAYBACK_HLS_FFMPEG_PATH`, then start Spring on `18080`. Expected: application starts without affecting the user-owned process on port 8080. - [ ] **Step 3: 使用已验证的 NVR 实际录像区间创建 HLS 会话** Run: call `POST /hik/nvr/playback/hls/sessions` with a known continuous Beijing-time recording interval. Expected: `status=PLAYING`, no RTSP credential in response, and `hlsUrl` returns an m3u8 playlist after ZLM receives the stream. - [ ] **Step 4: 验证控制行为** 依次调用 `PAUSE`、`RESUME`、`SET_SPEED` 2/4/8、`SEEK` 到区间内时间,并在每一步检查 SDK 控制返回值、会话状态与 HLS URL/playlist;定位后确认 `generation` 增加且前端必须加载返回的新 URL。 - [ ] **Step 5: 验证清理** Run: `DELETE /hik/nvr/playback/hls/sessions/{sessionId}`. Expected: SDK `NET_DVR_StopPlayBack` 被调用、FFmpeg 进程退出、ZLM 不再持续接收 RTMP,查询该会话返回 404。 - [ ] **Step 6: 核对 Git 边界** Run: `git status --short` and `git diff --cached --name-status`. Expected: no staging or commit action by this work; preserve all pre-existing user changes.