MiBeeNvr v0.6.0: 延时摄影 + 转码界面 + ONVIF 增强 + 文档重构

MiBeeNvr 连续跑了几周录像后,存储最先告急。单路 1080p 摄像头每天要写几十 GB,30 天留存一件 1TB 硬盘就没了大半。社区里不少朋友反馈了同样的问题,讨论中延时摄影和转码保存的方案呼声最高——画面大部分时间静止,用 timelapse 压缩后同样时长只需要 5% 的空间。

v0.6.0 就在这些讨论中起步。延时摄影管道从配置、帧抽取到滚动合并形成了完整闭环;转码系统支持 H.264/H.265/HEVC 互转和回填历史数据;为了同时兼顾新老硬件,转码引擎启动时自动探测 V4L2/VAAPI/NVENC 编码器——检测不到硬编码就关闭该功能,树莓派 3B 不受影响,性能更强的香蕉派 M5(RK3588)则能跑满 H.265 硬编码。

这个版本也在为下一个阶段铺路。internal/ai/ 目录下搭好了 ONNX Runtime 推理引擎的基础框架——通过子进程解耦 CGO 依赖,YOLOv11n 模型就绪,只差一个 feature flag 就能启用实时目标检测。可观测性方面引入了 Prometheus 指标体系和 VictoriaLogs 远程日志,社区反馈的问题通过 metrics 和结构化日志排查,不再靠猜。播放端实现了冻帧检测,通过帧时间戳监控画面停滞,结合 H.265 SPS 补丁、LL-HLS 配置和 WebRTC 连接追踪,播放体验明显改善。

发布前在真实摄像头环境下充分验证了所有这些新功能——相关的摄像头测试项目做了适配改造,具体见 camera-test-machines。完整变更列表见 GitHub Release Notes

延时摄影录制管道

从架构上看,timelapse 管道不是一个简单的定时截图,而是一个多阶段流程:

mermaid
flowchart TB
    RTSP["RTSP 源<br/>h264 / h265"] --> RC
    MJPEG["MJPEG 源<br/>jpeg 帧"] --> RC["Timelapse<br/>Recorder"]
    RC --> DD["帧检测<br/>跳过静止帧"]
    DD --> SQ["JPEG 序列<br/>目录"]

    classDef source fill:#E3F2FD,stroke:#1565C0,color:#1565C0
    classDef rec fill:#FFF3E0,stroke:#E65100,color:#BF360C
    classDef store fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
    class RTSP,MJPEG source
    class RC,DD rec
    class SQ store

JPEG 序列进入两条合并路径和一条直接播放路径:

mermaid
flowchart TB
    SQ["JPEG 序列<br/>目录"] --> PL["JPEG 播放列表<br/>懒加载"] --> JP["JPEG 播放器"]
    SQ --> MF["Go / FFmpeg<br/>合并"] --> MV["合成视频<br/>播放"]

    classDef store fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
    classDef merge fill:#F3E5F5,stroke:#9C27B0,color:#6A1B9A
    classDef view fill:#FFEBEE,stroke:#C62828,color:#C62828
    class SQ,PL store
    class MF merge
    class JP,MV view

帧抽取策略

Timelapse Recorder 从 RTSP 或 MJPEG 源中按时间间隔抽取 JPEG 帧。核心逻辑在 internal/recorder/timelapse.go

1
2
3
帧抽取间隔: config.timelapse.interval  (默认 30 秒)
抽取窗口:   interval ± 5% 随机抖动    (避免多摄像头同时抽取)
跳过策略:   连续 N 帧画面静止时跳过     (节省存储空间)

间隔加了 5% 的随机抖动是为了避免多个摄像头在同一时刻发起帧抽取请求——RTSP 源的 DESCRIBE/PLAY 会话建立有开销,集中请求会导致瞬时 CPU 尖峰。

跳过静止帧的检测复用了 health 模块的画面冻结检测(internal/health/quality.go),通过比较相邻帧的直方图差异判断画面是否变化。连续 3 个抽取周期都检测到静止时,跳过后续抽取直到画面恢复变化。

滚动合并的竞态处理

滚动合并(Rolling Merge)是延时摄影最复杂的一块。internal/timelapse/rolling.go 中的 RollingMergeManager 为每个摄像头维护一个活跃合并协程:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type RollingMergeManager struct {
    mu      sync.Mutex
    merger  TimelapseMerger
    active  map[string]*activeEntry  // cameraID → merge goroutine
}

type activeEntry struct {
    cancel context.CancelFunc
    id     uint64
}

当新的 segment 完成时,StartSegmentMerge() 先 cancel 旧的合并协程(如果有),再启动新的。但这里有一个竞态:旧的协程可能正在执行 Merge(),cancel 后它退出并执行 defer 清理,而此时新的协程也尝试写入同一个输出文件。

修复是 runMerge() 的 cleanup 中检查 entry.id == ownID

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func (r *RollingMergeManager) runMerge(ctx context.Context, ownID uint64, cameraID, ...) {
    defer func() {
        r.mu.Lock()
        if entry, ok := r.active[cameraID]; ok && entry.id == ownID {
            delete(r.active, cameraID)
        }
        r.mu.Unlock()
    }()
    // ...
}

每个 merge goroutine 持有创建时的 ownID,cleanup 时只有当前 goroutine 的 ID 仍然是该摄像头的 active entry 时,才执行删除。如果已经被替换,说明新协程已经接替工作,旧协程直接退出即可,不会误删新协程的 entry。

Go 原生合并 vs FFmpeg 合并

对比维度Go Native MergeFFmpeg Merge
依赖需要 FFmpeg
合并方式JPEG 序列直接封装为 MP4重新编码为 H.264
输出文件大小较大(JPEG 原始大小)较小(H.264 压缩)
CPU 开销低(仅 mux)高(编码)
使用场景实时预览、临时合并最终存档、长期保存

Go 的合并实现(internal/timelapse/go_merge.go)直接将 JPEG 帧作为 H.264 的 IDR 帧封装进 MP4 容器。这种做法不需要编解码,CPU 开销极低(在树莓派 3B 上实测 ~5% CPU 就能实时合并 10 个 1080p JPEG),但文件体积大。

FFmpeg 合并(internal/timelapse/ffmpeg_merge.go)将 JPEG 序列重新编码为 H.264,体积可缩小 5-10 倍,适合长期存档。每日合并(internal/timelapse/daily.go)在每天的固定时间点触发,将过去 24 小时的所有 JPEG 序列合并为一个 MP4 文件。

转码界面:三阶段交付

v0.6.0 的转码界面分三个 wave 交付,每个 wave 对应一个独立的前端页面和一组后端 API:

Wave 1:任务队列和自动入队

基础架构:DB-backed 任务队列(internal/transcoding/queue.go)。录制完成时通过事件总线自动创建转码任务,写入 SQLite 的 transcoding_jobs 表。

1
2
3
录制完成 → event.TranscodingRequired → transcoding.Manager.Enqueue()
    → 写入 SQLite → goroutine 消费队列 → 执行 FFmpeg
    → 更新状态(pending/running/done/failed)

Wave 2:轮询和重试

前端每 3 秒轮询 /api/transcoding/jobs 获取任务列表。失败的 job 显示"重试"按钮,点击后调用 POST /api/transcoding/jobs/:id/retry,将状态重置为 pending,消费者协程重新处理。

Wave 3:回填和历史管理

回填(Backfill)是最实用的功能——选中一段历史录像日期范围,批量创建转码任务。前端弹窗允许用户选择目标编码器:

json
1
2
3
4
5
6
7
8
POST /api/transcoding/backfill
{
  "camera_id": "front-door",
  "date_from": "2026-05-01",
  "date_to": "2026-06-01",
  "target_codec": "h264",        // h264 / hevc / mjpeg
  "replace_original": false
}

后端扫描指定日期范围内的录制文件,逐条创建转码任务。对于 ARM 平台(如树莓派),如果检测不到硬件编码器(/dev/dri/renderD128 或 Video4Linux 编码节点),自动降级为软件编码(libx264)。

历史管理支持页面化分页清理——DELETE /api/transcoding/history?page_size=50&page=1,避免一次清理大量记录导致 SQLite WAL 文件膨胀。

ONVIF 增强

Raw SOAP 回退

部分 ONVIF 摄像头对 GetUsers 操作的响应不符合标准库的解析预期——返回的 XML 命名空间前缀不一致,或者安全头格式有差异。v0.6.0 增加了 raw SOAP fallback:

mermaid
sequenceDiagram
    participant NVR as MiBeeNvr (Client)
    participant CAM as ONVIF 摄像头

    NVR->>CAM: SOAP GetUsers (via onvif-go lib)
    alt 标准响应
        CAM-->>NVR: Users 列表
    else Namespace 不匹配 / 解析错误
        NVR->>CAM: Raw SOAP GetUsers (自定义 XML)
        Note over NVR: WS-Security PasswordText<br/>Digest 手动计算
        CAM-->>NVR: Raw XML 响应
        NVR->>NVR: XPath 直接解析
    end

WS-Security 的 PasswordText Digest 计算方式:

xml
1
2
3
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">
    Base64(SHA1(Nonce + Created + Password))
</wsse:Password>

onvif-go 库的标准实现使用库内的 digest 函数,但某些旧摄像头要求特定的 Nonce 编码格式(hex vs base64),标准库不暴露这个参数。Raw SOAP 回退允许手动控制 XML 构造细节。

摄像头能力标志

v0.6.0 通过 /api/events SSE 端点返回完整的摄像头能力标志:

json
1
2
3
4
5
6
7
8
9
{
  "camera_id": "front-door",
  "capabilities": {
    "ptz": { "absolute": true, "relative": true, "continuous": true, "presets": true, "home": true },
    "imaging": { "brightness": true, "contrast": true, "saturation": true, "sharpness": true },
    "events": { "pullpoint": true, "motion": false, "tampering": false },
    "snapshot": { "uri": "http://192.168.1.100:8080/onvif/snapshot" }
  }
}

前端根据能力标志动态显示/隐藏控制面板——不支持 PTZ 的摄像头不会显示摇杆,不支持 Imaging 的摄像头不显示调节滑块。

H.265 SPS 补丁

部分廉价摄像头产生的 H.265 码流带有不标准的 SPS(Sequence Parameter Set)字段——比如 profile_idc 设为 0(标准不允许)或 level_idc 超出规范范围。某些播放器(特别是 hls.js)对此容忍度低,直接黑屏。

internal/hls/sps_patch.go 实现在 HLS 片段写入前拦截并修复 SPS:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// PatchSPS 修复 H.265 SPS NAL 单元中的非标准字段。
// 返回修复后的 SPS 和是否需要替换。
func PatchSPS(nalu []byte) (patched []byte, modified bool) {
    if len(nalu) < 7 || (nalu[0]&0x7E)>>1 != 39 { // HEVC VPS NUT check
        return nalu, false
    }
    // 跳过 VPS 和 PPS,定位到 SPS
    // 修正 profile_tier_level 中的非标准值
    profileIdx := 21 // offset varies by resolution
    if nalu[profileIdx] == 0 {
        nalu[profileIdx] = 1 // Main Profile
        modified = true
    }
    // ...
}

这个补丁不是通用的——不同摄像头的 SPS 结构可能不同。目前针对的是实测中发现的两款特定摄像头,后续会根据反馈扩展。

安全加固

v0.6.0 在安全方面做了几个改动:

COOP/COEP 条件启用:Cross-Origin-Opener-Policy 和 Cross-Origin-Embedder-Policy 头部只在 TLS 启用时设置。原因是这些头部的 strict 模式在 HTTP(非 HTTPS)下会破坏 WebSocket 连接——浏览器强制隔离非安全上下文,导致 SSE/WebSocket 无法跨域通信。之前没有发现是因为开发环境通常用 localhost(被视为安全上下文),部署时如果没有配置 HTTPS 就会出问题。

帧看门狗:录制器启动时,如果 RTSP DESCRIBE 响应中没有 SDP 的 SPS/PPS 信息,播放器无法初始化解码。之前的代码无限等待 SPS/PPS,导致协程泄漏。v0.6.0 增加了帧看门狗——从第一个 RTP 包到达开始计时,5 秒内没有收到 SPS/PPS 则主动断开重连。

零时长录制修复

internal/storage/db_recording.go 中发现了一个边界情况:录制器在 segment 关闭时如果写入的帧数为 0,数据库中的 duration 字段为 0。后期在播放列表渲染时,0 时长的条目会导致前端计算 playlist 总时长时出现 NaN

修复是 internal/recorder/pts_check.go 中的 PTS 时间戳有效性检查——录制开始时校验第一个 RTP 包的 PTS(Presentation Timestamp)是否有效。如果 PTS 为 0 或 NaN,跳过该帧等待下一个有效 PTS,避免将无效时间戳写入 MP4 的 mvhd/tkhd 盒。

文档重构

v0.5.0 的文档还只有一份 api-reference.md 来涵盖所有 API 端点,一个文件超过 2000 行,翻都翻不动。v0.6.0 拆成了 19 个模块化文档文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
docs/en/api/
├── README.md               # API 总览
├── authentication.md        # 认证
├── cameras.md               # 摄像头管理
├── recordings.md            # 录制管理
├── streaming.md             # 流媒体协议
├── timelapse-protocols.md   # 延时摄影协议
├── transcoding.md           # 转码
├── onvif.md                 # ONVIF 接口
├── health-monitoring.md     # 健康监控
├── events.md                # 事件系统
├── archives.md              # 归档
├── merge.md                 # 合并
├── ai-detection.md          # AI 检测
├── settings.md              # 设置
├── system.md                # 系统管理
├── backup.md                # 备份
├── camera-details.md        # 摄像头详情
├── xiaomi.md                # 小米云集成
├── errors.md                # 错误码
└── ...                      # 中文镜像目录

中英文各一套,保持同步。每个文件聚焦一个 API 端点或功能模块,方便用户按需查阅,也方便 CI 检查文档覆盖率。

Bug 修复

  • 转码队列竞态:多个录制任务同时完成时,事件总线并发触发转码入队,导致 SQLite UNIQUE 约束冲突。修复:转码入队操作加互斥锁,幂等检查前先 SELECT。
  • ONVIF 预置位名称编码:部分摄像头返回的预置位名称不是 UTF-8(如 GBK 编码),前端渲染时出现乱码。修复:按 RFC 3629 检测非 UTF-8 序列,回退到 ISO-8859-1 解码。
  • HLS segment 边界黑帧:LL-HLS 的局部刷新模式下,segment 边界处的 GOP 结构不完整导致短暂黑帧。修复:segment 切分时强制等待下一个 IDR 帧。
  • ARM 平台转码崩溃:软件编码 libx264 在 ARMv7 上因 NEON 优化路径检查不通过导致 SIGILL。修复:FFmpeg 命令中增加 -cpuflags none

性能

测试套件从 ~340s 优化到 ~88s(74% 更快)。主要优化:

  1. 并行测试:独立测试用例用 t.Parallel() 并发执行
  2. SQLite WAL 模式:测试 DB 使用 PRAGMA journal_mode=WAL,减少写锁竞争
  3. 模拟时钟:时间相关测试(如健康评分的时间窗口)用 clock.Mock 替代 time.Sleep

升级

配置向后兼容,直接替换二进制即可:

bash
1
2
3
4
5
# Docker
docker pull ghcr.io/mi-bee-studio/mibeenvr:latest

# 或下载二进制
wget https://github.com/Mi-Bee-Studio/MiBeeNvr/releases/latest/download/mibee-nvr-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')

升级后建议检查 config.example.yaml 中的新配置项(timelapse、transcoding、streaming 等),按需补充到自己的配置文件中。

相关链接