// NextNVR v0.3.3 — Snapshot engine // Writes latest.jpg every 2s (live wall) and snap_{timestamp}.jpg every 60s (archive/thumbnails). // All files go into the per-camera directory: /mnt/recordings/{cam-name}/ 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) { 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, stopCh) } log.Printf("snapshot: %d cameras started", len(se.stopChs)) } // 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 latest.jpg. // Every 60 seconds, copies latest.jpg to snap_{timestamp}.jpg for archival. func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, 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) } camDirName := cam.Name if camDirName == "" { camDirName = cam.ID } outDir := filepath.Join(se.config.RecordingsPath, camDirName) if err := os.MkdirAll(outDir, 0755); err != nil { log.Printf("snapshot: %s — mkdir %s failed: %v", cam.ID, outDir, err) return } latestFile := filepath.Join(outDir, "latest.jpg") tmpFile := latestFile + ".tmp" lastArchive := time.Time{} // track when we last wrote an archival snapshot log.Printf("snapshot: %s → %s (live every 2s, archive every 60s)", cam.ID, outDir) // Grab first frame immediately. se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) fastTicker := time.NewTicker(2 * time.Second) defer fastTicker.Stop() for { select { case <-stopCh: return case <-fastTicker.C: se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) } } } // grabFrame launches ffmpeg to capture a JPEG. Updates latest.jpg every call, // and copies to snap_*.jpg once per minute. func (se *SnapshotEngine) grabFrame(rtspURL, tmpFile, latestFile, outDir, camID string, lastArchive *time.Time) { cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-rtsp_transport", "tcp", "-i", rtspURL, "-vframes", "1", "-q:v", "5", "-f", "image2", "-y", tmpFile, ) cmd.Run() if _, err := os.Stat(tmpFile); err != nil { return // ffmpeg failed — camera may be offline } os.Rename(tmpFile, latestFile) // Archive: once per minute, copy latest.jpg to snap_{timestamp}.jpg now := time.Now() if now.Sub(*lastArchive) >= 60*time.Second { archiveFile := filepath.Join(outDir, "snap_"+now.Format("2006-01-02-15-04-05")+".jpg") copyFile(latestFile, archiveFile) *lastArchive = now } } // copyFile copies a file from src to dst. func copyFile(src, dst string) { data, err := os.ReadFile(src) if err != nil { return } os.WriteFile(dst, data, 0644) }