You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

15 KiB

海康 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

写入 compose,唯一暴露端口为:

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"

Run: docker info --format '{{.ServerVersion}}'

Expected: Docker server version is printed; if it is unavailable, start Docker Desktop and retry.

从 Windows FFmpeg 发行包解压到 C:\tools\ffmpeg,验证:

Run: C:\tools\ffmpeg\bin\ffmpeg.exe -version

Expected: command prints an FFmpeg version and exits with code 0.

config.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 作为配置文件。

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.

.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

@Test
public void exposesAbsoluteSeekControlAndStructuredPacketLayout() {
    assertEquals(26, HCNetSDK.NET_DVR_PLAYSETTIME);
    assertTrue(new HCNetSDK.NET_DVR_PACKET_INFO_EX().size() > 80);
}

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.

在控制常量区加入:

public static final int NET_DVR_PLAYSETTIME = 26;

NET_DVR_PACKET_INFO_EXUnion 改为 HCStructure,保留字段顺序和所有字段;它是 SDK 回调返回的顺序结构体,不是 union。

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

@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));
}

Run: mvn -Dtest=HikPlaybackHlsUrlBuilderTest,HikNvrPlaybackHlsControlRequestTest test

Expected: compilation failure because the classes do not exist.

HikNvrPlaybackHlsControlAction 只包含 PAUSERESUMESET_SPEEDSEEKSTOPisSupportedSpeed 只接受 1、2、4、8。HLS 地址固定为:

return httpBaseUrl + "/playback/" + sessionId + "-" + generation + "/hls.m3u8";

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

@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)));
}

Run: mvn -Dtest=HikFfmpegH264PublisherTest test

Expected: compilation failure because the publisher does not exist.

FFmpeg 参数必须包含:

-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、等待进程有限时间后强制销毁。

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

@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));
}

Run: mvn -Dtest=HikSdkPlaybackGatewayTest test

Expected: compilation failure because the gateway and handle do not exist.

open 必须:登录缓存中获取 userId;填充 NET_DVR_VOD_PARA.dwSizestruIDInfo.dwSize、实际 SDK 通道、北京时间起止时间、主/子码流;调用 NET_DVR_PlayBackByTime_V40;保存回调强引用;注册 ES 回调;最后调用 NET_DVR_PLAYSTART

回调必须复制 pPacketBufferdwPacketSize 字节;只将视频 I/B/P 包写入当前 AtomicReference<HikPlaybackFrameSink>,忽略文件头、音频和私有包。close 必须先断开 sink,再 NET_DVR_StopPlayBack

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

@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"));
}

Run: mvn -Dtest=HikNvrPlaybackHlsServiceTest test

Expected: compilation failure because the service and registry do not exist.

创建流程:调用已有 HikNvrPlaybackService.search;空记录抛 404;多个记录抛 409;将唯一实际区间解析为 LocalDateTime;创建 publisher 和 SDK handle;登记会话。

定位流程:暂停旧句柄,停止旧 publisher,启动新代次 publisher,切换句柄 sink,调用 SDK PLAYSETTIME;失败时关闭旧句柄并以目标时间重建;恢复目标速度;更新 HLS URL 与状态。自动清理使用当前已有 @EnableScheduling,过期时调用同一个 stop 路径。

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

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));

Run: mvn -Dtest=HikNvrPlaybackHlsControllerTest test

Expected: compilation failure because controller does not exist.

暴露:

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 增加 notFoundconflictmediaFailure 工厂方法,统一由既有 ControllerAdvice 返回。

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(仅记录实际验证结论)

Run: mvn test -DskipTests=false

Expected: all tests pass with zero failures and zero errors.

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.

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.

依次调用 PAUSERESUMESET_SPEED 2/4/8、SEEK 到区间内时间,并在每一步检查 SDK 控制返回值、会话状态与 HLS URL/playlist;定位后确认 generation 增加且前端必须加载返回的新 URL。

Run: DELETE /hik/nvr/playback/hls/sessions/{sessionId}.

Expected: SDK NET_DVR_StopPlayBack 被调用、FFmpeg 进程退出、ZLM 不再持续接收 RTMP,查询该会话返回 404。

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.