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 管道不是一个简单的定时截图,而是一个多阶段流程:
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 storeJPEG 序列进入两条合并路径和一条直接播放路径:
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:
| |
间隔加了 5% 的随机抖动是为了避免多个摄像头在同一时刻发起帧抽取请求——RTSP 源的 DESCRIBE/PLAY 会话建立有开销,集中请求会导致瞬时 CPU 尖峰。
跳过静止帧的检测复用了 health 模块的画面冻结检测(internal/health/quality.go),通过比较相邻帧的直方图差异判断画面是否变化。连续 3 个抽取周期都检测到静止时,跳过后续抽取直到画面恢复变化。
滚动合并的竞态处理
滚动合并(Rolling Merge)是延时摄影最复杂的一块。internal/timelapse/rolling.go 中的 RollingMergeManager 为每个摄像头维护一个活跃合并协程:
| |
当新的 segment 完成时,StartSegmentMerge() 先 cancel 旧的合并协程(如果有),再启动新的。但这里有一个竞态:旧的协程可能正在执行 Merge(),cancel 后它退出并执行 defer 清理,而此时新的协程也尝试写入同一个输出文件。
修复是 runMerge() 的 cleanup 中检查 entry.id == ownID:
| |
每个 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 表。
| |
Wave 2:轮询和重试
前端每 3 秒轮询 /api/transcoding/jobs 获取任务列表。失败的 job 显示"重试"按钮,点击后调用 POST /api/transcoding/jobs/:id/retry,将状态重置为 pending,消费者协程重新处理。
Wave 3:回填和历史管理
回填(Backfill)是最实用的功能——选中一段历史录像日期范围,批量创建转码任务。前端弹窗允许用户选择目标编码器:
| |
后端扫描指定日期范围内的录制文件,逐条创建转码任务。对于 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 直接解析
endWS-Security 的 PasswordText Digest 计算方式:
| |
onvif-go 库的标准实现使用库内的 digest 函数,但某些旧摄像头要求特定的 Nonce 编码格式(hex vs base64),标准库不暴露这个参数。Raw SOAP 回退允许手动控制 XML 构造细节。
摄像头能力标志
v0.6.0 通过 /api/events SSE 端点返回完整的摄像头能力标志:
| |
前端根据能力标志动态显示/隐藏控制面板——不支持 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:
| |
这个补丁不是通用的——不同摄像头的 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 个模块化文档文件:
| |
中英文各一套,保持同步。每个文件聚焦一个 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% 更快)。主要优化:
- 并行测试:独立测试用例用
t.Parallel()并发执行 - SQLite WAL 模式:测试 DB 使用
PRAGMA journal_mode=WAL,减少写锁竞争 - 模拟时钟:时间相关测试(如健康评分的时间窗口)用
clock.Mock替代time.Sleep
升级
配置向后兼容,直接替换二进制即可:
| |
升级后建议检查 config.example.yaml 中的新配置项(timelapse、transcoding、streaming 等),按需补充到自己的配置文件中。