// NextNVR v0.3.2 — Snapshot engine // Grabs a single JPEG frame from each camera every 2 seconds. // Writes to /mnt/recordings/snapshots/{cam_id}.jpg via atomic rename. // The web UI loads these static images — no go2rtc polling, no CORS, no flooding. package main import ( "fmt" "log" "os" "os/exec" "path/filepath" "sync" "time" ) // SnapshotEngine manages per-camera snapshot goroutines. type SnapshotEngine struct { mu sync.Mutex stopChs map[string]chan struct{} config StorageConfig } // NewSnapshotEngine creates a new snapshot engine. func NewSnapshotEngine(cfg StorageConfig) *SnapshotEngine { return &SnapshotEngine{ stopChs: make(map[string]chan struct{}), config: cfg, } } // StartAll launches a snapshot goroutine for each enabled camera. func (se *SnapshotEngine) StartAll(cameras []CameraConfig) { outDir := filepath.Join(se.config.RecordingsPath, "snapshots") if err := os.MkdirAll(outDir, 0755); err != nil { log.Printf("snapshot: mkdir %s failed: %v", outDir, err) return } for _, cam := range cameras { if !cam.Enabled { continue } stopCh := make(chan struct{}) se.mu.Lock() se.stopChs[cam.ID] = stopCh se.mu.Unlock() go se.snapshotLoop(cam, outDir, stopCh) } log.Printf("snapshot: %d cameras started → %s", len(se.stopChs), outDir) } // StopAll terminates all snapshot goroutines. func (se *SnapshotEngine) StopAll() { se.mu.Lock() defer se.mu.Unlock() for id, ch := range se.stopChs { close(ch) log.Printf("snapshot: stopped camera %s", id) } se.stopChs = make(map[string]chan struct{}) } // snapshotLoop grabs a JPEG frame every 2 seconds and writes it atomically. func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, outDir string, stopCh chan struct{}) { rtspURL := cam.RTSPSub if rtspURL == "" { rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102", cam.Username, cam.Password, cam.IP) } outFile := filepath.Join(outDir, cam.ID+".jpg") tmpFile := outFile + ".tmp" interval := 2 * time.Second log.Printf("snapshot: %s → %s (every %v)", cam.ID, outFile, interval) // Immediately grab the first frame. se.grabFrame(rtspURL, tmpFile, outFile, cam.ID) ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-stopCh: return case <-ticker.C: se.grabFrame(rtspURL, tmpFile, outFile, cam.ID) } } } // grabFrame launches ffmpeg to capture a single JPEG frame. func (se *SnapshotEngine) grabFrame(rtspURL, tmpFile, outFile, camID string) { cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-rtsp_transport", "tcp", "-i", rtspURL, "-vframes", "1", // exactly one frame "-q:v", "5", // good JPEG quality (2-31, lower=better) "-f", "image2", "-y", // overwrite tmpFile, ) cmd.Run() // ignore errors — camera may be offline // Atomic rename: if ffmpeg succeeded, tmpFile exists. if _, err := os.Stat(tmpFile); err == nil { os.Rename(tmpFile, outFile) } }