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 管道不是一个简单的定时截图,而是一个多阶段流程:
JPEG 序列进入两条合并路径和一条直接播放路径:
帧抽取策略
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:
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% 更快)。主要优化:
- 并行测试 :独立测试用例用
t.Parallel()并发执行 - SQLite WAL 模式 :测试 DB 使用
PRAGMA journal_mode=WAL,减少写锁竞争 - 模拟时钟 :时间相关测试(如健康评分的时间窗口)用
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 等),按需补充到自己的配置文件中。