欢迎光临
我们一直在努力

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 管道不是一个简单的定时截图,而是一个多阶段流程:

#mermaid-svg-y5iZljB4OdmIoVXE{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-y5iZljB4OdmIoVXE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-y5iZljB4OdmIoVXE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-y5iZljB4OdmIoVXE .error-icon{fill:#552222;}#mermaid-svg-y5iZljB4OdmIoVXE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-y5iZljB4OdmIoVXE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-y5iZljB4OdmIoVXE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-y5iZljB4OdmIoVXE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-y5iZljB4OdmIoVXE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-y5iZljB4OdmIoVXE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-y5iZljB4OdmIoVXE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-y5iZljB4OdmIoVXE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-y5iZljB4OdmIoVXE .marker.cross{stroke:#333333;}#mermaid-svg-y5iZljB4OdmIoVXE svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-y5iZljB4OdmIoVXE p{margin:0;}#mermaid-svg-y5iZljB4OdmIoVXE .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-y5iZljB4OdmIoVXE .cluster-label text{fill:#333;}#mermaid-svg-y5iZljB4OdmIoVXE .cluster-label span{color:#333;}#mermaid-svg-y5iZljB4OdmIoVXE .cluster-label span p{background-color:transparent;}#mermaid-svg-y5iZljB4OdmIoVXE .label text,#mermaid-svg-y5iZljB4OdmIoVXE span{fill:#333;color:#333;}#mermaid-svg-y5iZljB4OdmIoVXE .node rect,#mermaid-svg-y5iZljB4OdmIoVXE .node circle,#mermaid-svg-y5iZljB4OdmIoVXE .node ellipse,#mermaid-svg-y5iZljB4OdmIoVXE .node polygon,#mermaid-svg-y5iZljB4OdmIoVXE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-y5iZljB4OdmIoVXE .rough-node .label text,#mermaid-svg-y5iZljB4OdmIoVXE .node .label text,#mermaid-svg-y5iZljB4OdmIoVXE .image-shape .label,#mermaid-svg-y5iZljB4OdmIoVXE .icon-shape .label{text-anchor:middle;}#mermaid-svg-y5iZljB4OdmIoVXE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-y5iZljB4OdmIoVXE .rough-node .label,#mermaid-svg-y5iZljB4OdmIoVXE .node .label,#mermaid-svg-y5iZljB4OdmIoVXE .image-shape .label,#mermaid-svg-y5iZljB4OdmIoVXE .icon-shape .label{text-align:center;}#mermaid-svg-y5iZljB4OdmIoVXE .node.clickable{cursor:pointer;}#mermaid-svg-y5iZljB4OdmIoVXE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-y5iZljB4OdmIoVXE .arrowheadPath{fill:#333333;}#mermaid-svg-y5iZljB4OdmIoVXE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-y5iZljB4OdmIoVXE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-y5iZljB4OdmIoVXE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-y5iZljB4OdmIoVXE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-y5iZljB4OdmIoVXE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-y5iZljB4OdmIoVXE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-y5iZljB4OdmIoVXE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-y5iZljB4OdmIoVXE .cluster text{fill:#333;}#mermaid-svg-y5iZljB4OdmIoVXE .cluster span{color:#333;}#mermaid-svg-y5iZljB4OdmIoVXE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-y5iZljB4OdmIoVXE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-y5iZljB4OdmIoVXE rect.text{fill:none;stroke-width:0;}#mermaid-svg-y5iZljB4OdmIoVXE .icon-shape,#mermaid-svg-y5iZljB4OdmIoVXE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-y5iZljB4OdmIoVXE .icon-shape p,#mermaid-svg-y5iZljB4OdmIoVXE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-y5iZljB4OdmIoVXE .icon-shape .label rect,#mermaid-svg-y5iZljB4OdmIoVXE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-y5iZljB4OdmIoVXE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-y5iZljB4OdmIoVXE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-y5iZljB4OdmIoVXE :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-y5iZljB4OdmIoVXE .source>*{fill:#E3F2FD!important;stroke:#1565C0!important;color:#1565C0!important;}#mermaid-svg-y5iZljB4OdmIoVXE .source span{fill:#E3F2FD!important;stroke:#1565C0!important;color:#1565C0!important;}#mermaid-svg-y5iZljB4OdmIoVXE .source tspan{fill:#1565C0!important;}#mermaid-svg-y5iZljB4OdmIoVXE .rec>*{fill:#FFF3E0!important;stroke:#E65100!important;color:#BF360C!important;}#mermaid-svg-y5iZljB4OdmIoVXE .rec span{fill:#FFF3E0!important;stroke:#E65100!important;color:#BF360C!important;}#mermaid-svg-y5iZljB4OdmIoVXE .rec tspan{fill:#BF360C!important;}#mermaid-svg-y5iZljB4OdmIoVXE .store>*{fill:#E8F5E9!important;stroke:#2E7D32!important;color:#1B5E20!important;}#mermaid-svg-y5iZljB4OdmIoVXE .store span{fill:#E8F5E9!important;stroke:#2E7D32!important;color:#1B5E20!important;}#mermaid-svg-y5iZljB4OdmIoVXE .store tspan{fill:#1B5E20!important;}

RTSP 源h264 / h265

TimelapseRecorder

MJPEG 源jpeg 帧

帧检测跳过静止帧

JPEG 序列目录

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

#mermaid-svg-JvBtPZsDIugm966I{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JvBtPZsDIugm966I .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JvBtPZsDIugm966I .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JvBtPZsDIugm966I .error-icon{fill:#552222;}#mermaid-svg-JvBtPZsDIugm966I .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JvBtPZsDIugm966I .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JvBtPZsDIugm966I .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JvBtPZsDIugm966I .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JvBtPZsDIugm966I .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JvBtPZsDIugm966I .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JvBtPZsDIugm966I .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JvBtPZsDIugm966I .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JvBtPZsDIugm966I .marker.cross{stroke:#333333;}#mermaid-svg-JvBtPZsDIugm966I svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JvBtPZsDIugm966I p{margin:0;}#mermaid-svg-JvBtPZsDIugm966I .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-JvBtPZsDIugm966I .cluster-label text{fill:#333;}#mermaid-svg-JvBtPZsDIugm966I .cluster-label span{color:#333;}#mermaid-svg-JvBtPZsDIugm966I .cluster-label span p{background-color:transparent;}#mermaid-svg-JvBtPZsDIugm966I .label text,#mermaid-svg-JvBtPZsDIugm966I span{fill:#333;color:#333;}#mermaid-svg-JvBtPZsDIugm966I .node rect,#mermaid-svg-JvBtPZsDIugm966I .node circle,#mermaid-svg-JvBtPZsDIugm966I .node ellipse,#mermaid-svg-JvBtPZsDIugm966I .node polygon,#mermaid-svg-JvBtPZsDIugm966I .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JvBtPZsDIugm966I .rough-node .label text,#mermaid-svg-JvBtPZsDIugm966I .node .label text,#mermaid-svg-JvBtPZsDIugm966I .image-shape .label,#mermaid-svg-JvBtPZsDIugm966I .icon-shape .label{text-anchor:middle;}#mermaid-svg-JvBtPZsDIugm966I .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JvBtPZsDIugm966I .rough-node .label,#mermaid-svg-JvBtPZsDIugm966I .node .label,#mermaid-svg-JvBtPZsDIugm966I .image-shape .label,#mermaid-svg-JvBtPZsDIugm966I .icon-shape .label{text-align:center;}#mermaid-svg-JvBtPZsDIugm966I .node.clickable{cursor:pointer;}#mermaid-svg-JvBtPZsDIugm966I .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JvBtPZsDIugm966I .arrowheadPath{fill:#333333;}#mermaid-svg-JvBtPZsDIugm966I .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JvBtPZsDIugm966I .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JvBtPZsDIugm966I .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JvBtPZsDIugm966I .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JvBtPZsDIugm966I .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JvBtPZsDIugm966I .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JvBtPZsDIugm966I .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JvBtPZsDIugm966I .cluster text{fill:#333;}#mermaid-svg-JvBtPZsDIugm966I .cluster span{color:#333;}#mermaid-svg-JvBtPZsDIugm966I div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JvBtPZsDIugm966I .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JvBtPZsDIugm966I rect.text{fill:none;stroke-width:0;}#mermaid-svg-JvBtPZsDIugm966I .icon-shape,#mermaid-svg-JvBtPZsDIugm966I .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JvBtPZsDIugm966I .icon-shape p,#mermaid-svg-JvBtPZsDIugm966I .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JvBtPZsDIugm966I .icon-shape .label rect,#mermaid-svg-JvBtPZsDIugm966I .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JvBtPZsDIugm966I .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JvBtPZsDIugm966I .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JvBtPZsDIugm966I :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-JvBtPZsDIugm966I .store>*{fill:#E8F5E9!important;stroke:#2E7D32!important;color:#1B5E20!important;}#mermaid-svg-JvBtPZsDIugm966I .store span{fill:#E8F5E9!important;stroke:#2E7D32!important;color:#1B5E20!important;}#mermaid-svg-JvBtPZsDIugm966I .store tspan{fill:#1B5E20!important;}#mermaid-svg-JvBtPZsDIugm966I .merge>*{fill:#F3E5F5!important;stroke:#9C27B0!important;color:#6A1B9A!important;}#mermaid-svg-JvBtPZsDIugm966I .merge span{fill:#F3E5F5!important;stroke:#9C27B0!important;color:#6A1B9A!important;}#mermaid-svg-JvBtPZsDIugm966I .merge tspan{fill:#6A1B9A!important;}#mermaid-svg-JvBtPZsDIugm966I .view>*{fill:#FFEBEE!important;stroke:#C62828!important;color:#C62828!important;}#mermaid-svg-JvBtPZsDIugm966I .view span{fill:#FFEBEE!important;stroke:#C62828!important;color:#C62828!important;}#mermaid-svg-JvBtPZsDIugm966I .view tspan{fill:#C62828!important;}

JPEG 序列目录

JPEG 播放列表懒加载

JPEG 播放器

Go / FFmpeg合并

合成视频播放

帧抽取策略

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

帧抽取间隔: 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 为每个摄像头维护一个活跃合并协程:

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:

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 表。

录制完成 → 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)是最实用的功能——选中一段历史录像日期范围,批量创建转码任务。前端弹窗允许用户选择目标编码器:

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:

ONVIF 摄像头

MiBeeNvr (Client)

ONVIF 摄像头

MiBeeNvr (Client)

#mermaid-svg-un3bkeVaWRoqe6Yv{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-un3bkeVaWRoqe6Yv .error-icon{fill:#552222;}#mermaid-svg-un3bkeVaWRoqe6Yv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-un3bkeVaWRoqe6Yv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-un3bkeVaWRoqe6Yv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-un3bkeVaWRoqe6Yv .marker.cross{stroke:#333333;}#mermaid-svg-un3bkeVaWRoqe6Yv svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-un3bkeVaWRoqe6Yv p{margin:0;}#mermaid-svg-un3bkeVaWRoqe6Yv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-un3bkeVaWRoqe6Yv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-un3bkeVaWRoqe6Yv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-un3bkeVaWRoqe6Yv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-un3bkeVaWRoqe6Yv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-un3bkeVaWRoqe6Yv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-un3bkeVaWRoqe6Yv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-un3bkeVaWRoqe6Yv .sequenceNumber{fill:white;}#mermaid-svg-un3bkeVaWRoqe6Yv #sequencenumber{fill:#333;}#mermaid-svg-un3bkeVaWRoqe6Yv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-un3bkeVaWRoqe6Yv .messageText{fill:#333;stroke:none;}#mermaid-svg-un3bkeVaWRoqe6Yv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-un3bkeVaWRoqe6Yv .labelText,#mermaid-svg-un3bkeVaWRoqe6Yv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-un3bkeVaWRoqe6Yv .loopText,#mermaid-svg-un3bkeVaWRoqe6Yv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-un3bkeVaWRoqe6Yv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-un3bkeVaWRoqe6Yv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-un3bkeVaWRoqe6Yv .noteText,#mermaid-svg-un3bkeVaWRoqe6Yv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-un3bkeVaWRoqe6Yv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-un3bkeVaWRoqe6Yv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-un3bkeVaWRoqe6Yv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-un3bkeVaWRoqe6Yv .actorPopupMenu{position:absolute;}#mermaid-svg-un3bkeVaWRoqe6Yv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-un3bkeVaWRoqe6Yv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-un3bkeVaWRoqe6Yv .actor-man circle,#mermaid-svg-un3bkeVaWRoqe6Yv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-un3bkeVaWRoqe6Yv :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

WS-Security PasswordText

Digest 手动计算

alt

[标准响应]

[Namespace 不匹配 / 解析错误]

SOAP GetUsers (via onvif-go lib)

Users 列表

Raw SOAP GetUsers (自定义 XML)

Raw XML 响应

XPath 直接解析

WS-Security 的 PasswordText Digest 计算方式:

<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 端点返回完整的摄像头能力标志:

{
"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:

// 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 个模块化文档文件:

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% 更快)。主要优化:

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

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

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

    相关链接

    • MiBeeNvr GitHub
    • MiBeeNvr v0.6.0 Release Notes
    赞(0)
    未经允许不得转载:171主机测评 » MiBeeNvr v0.6.0: 延时摄影 + 转码界面 + ONVIF 增强 + 文档重构
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址