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({{< ref "posts/mibee-oss/camera-test-machines" >}})。完整变更列表见 GitHub Release Notes

延时摄影录制管道

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

flowchart TB RTSP[&#34;RTSP 源<br/>h264 / h265&#34;] --> RC MJPEG[&#34;MJPEG 源<br/>jpeg 帧&#34;] --> RC[&#34;Timelapse<br/>Recorder&#34;] RC --> DD[&#34;帧检测<br/>跳过静止帧&#34;] DD --> SQ[&#34;JPEG 序列<br/>目录&#34;] 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 序列进入两条合并路径和一条直接播放路径:

flowchart TB SQ[&#34;JPEG 序列<br/>目录&#34;] --> PL[&#34;JPEG 播放列表<br/>懒加载&#34;] --> JP[&#34;JPEG 播放器&#34;] SQ --> MF[&#34;Go / FFmpeg<br/>合并&#34;] --> MV[&#34;合成视频<br/>播放&#34;] 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

makefile 复制代码
帧抽取间隔: 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 复制代码
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 复制代码
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 Merge FFmpeg 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 表。

arduino 复制代码
录制完成 → 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 复制代码
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:

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 复制代码
<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 复制代码
{
  "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 复制代码
// 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 个模块化文档文件:

bash 复制代码
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 复制代码
# 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 等),按需补充到自己的配置文件中。

相关链接

相关推荐
DogDaoDao1 小时前
【GitHub】 Open Design 深度技术解析:把 Claude Design 搬回本地的 Agent 设计工作台
深度学习·程序员·github·ai编程·claude·ai agent·open design
小橙讲编程1 小时前
PaddleOCR 3.6 深度解析:0.9B 参数如何跑出 96.3% 准确率,登顶文档解析 SOTA?
人工智能·开源·github
先跑起来再说1 小时前
Go 排行榜系统的工程化实现:分布式锁、快照表与定时刷新
分布式·go·gin
_codemonster3 小时前
git本地以及github查看历史版本、版本回退
git·github
SenChien3 小时前
Golang入门学习笔记
golang·go
星浩AI16 小时前
接手 20 万行代码从哪读起?Understand-Anything 把仓库变成可探索的知识图谱
后端·github·claude
用户4802615847016 小时前
s3fs:用操作本地文件的方式读写 S3
github
Menahem16 小时前
解决 SSH 报错:WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
运维·ssh·github