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 合并
| 依赖 | 无 | 需要 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% 更快)。主要优化:
升级
配置向后兼容,直接替换二进制即可:
# 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