From 5d327e61af32c4413be1c07f23a311eb640307b9 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 5 Aug 2026 13:12:02 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20snapshots=20via=20go2rtc=20frame.jpeg=20?= =?UTF-8?q?=E2=80=94=20instant,=20no=20RTSP=20renegotiation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- snapshot.go | 101 ++++++++++++++++++++++------------------------------ 1 file changed, 42 insertions(+), 59 deletions(-) diff --git a/snapshot.go b/snapshot.go index eb17dab..835529f 100644 --- a/snapshot.go +++ b/snapshot.go @@ -1,18 +1,22 @@ -// 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}/ +// NextNVR v0.3.4 — Snapshot engine (go2rtc-based) +// Fetches JPEG frames from go2rtc's already-decoded stream buffer. +// go2rtc maintains persistent RTSP connections — frames are instant. +// Writes latest.jpg every 1s (live wall) and snap_*.jpg every 60s (archive). package main import ( "fmt" + "io" "log" + "net/http" "os" - "os/exec" "path/filepath" "sync" "time" ) +var httpClient = &http.Client{Timeout: 5 * time.Second} + // SnapshotEngine manages per-camera snapshot goroutines. type SnapshotEngine struct { mu sync.Mutex @@ -20,7 +24,6 @@ type SnapshotEngine struct { config StorageConfig } -// NewSnapshotEngine creates a new snapshot engine. func NewSnapshotEngine(cfg StorageConfig) *SnapshotEngine { return &SnapshotEngine{ stopChs: make(map[string]chan struct{}), @@ -28,7 +31,6 @@ func NewSnapshotEngine(cfg StorageConfig) *SnapshotEngine { } } -// StartAll launches a snapshot goroutine for each enabled camera. func (se *SnapshotEngine) StartAll(cameras []CameraConfig) { for _, cam := range cameras { if !cam.Enabled { @@ -40,10 +42,9 @@ func (se *SnapshotEngine) StartAll(cameras []CameraConfig) { se.mu.Unlock() go se.snapshotLoop(cam, stopCh) } - log.Printf("snapshot: %d cameras started", len(se.stopChs)) + log.Printf("snapshot: %d cameras started (via go2rtc)", len(se.stopChs)) } -// StopAll terminates all snapshot goroutines. func (se *SnapshotEngine) StopAll() { se.mu.Lock() defer se.mu.Unlock() @@ -54,80 +55,62 @@ func (se *SnapshotEngine) StopAll() { 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 - } + os.MkdirAll(outDir, 0755) latestFile := filepath.Join(outDir, "latest.jpg") tmpFile := latestFile + ".tmp" - lastArchive := time.Time{} // track when we last wrote an archival snapshot + frameURL := fmt.Sprintf("http://127.0.0.1:1984/api/frame.jpeg?src=%s_sub", cam.ID) + lastArchive := time.Time{} - log.Printf("snapshot: %s → %s (live every 2s, archive every 60s)", cam.ID, outDir) + log.Printf("snapshot: %s → %s", cam.ID, outDir) // Grab first frame immediately. - se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) + se.grab(frameURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) - fastTicker := time.NewTicker(2 * time.Second) - defer fastTicker.Stop() + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() for { select { case <-stopCh: return - case <-fastTicker.C: - se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) + case <-ticker.C: + se.grab(frameURL, 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) +func (se *SnapshotEngine) grab(url, tmpFile, latestFile, outDir, camID string, lastArchive *time.Time) { + resp, err := httpClient.Get(url) if err != nil { return } - os.WriteFile(dst, data, 0644) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return + } + + data, err := io.ReadAll(resp.Body) + if err != nil || len(data) < 100 { + return + } + + // Atomic write. + os.WriteFile(tmpFile, data, 0644) + os.Rename(tmpFile, latestFile) + + // Archive every 60s. + now := time.Now() + if now.Sub(*lastArchive) >= 60*time.Second { + archiveFile := filepath.Join(outDir, "snap_"+now.Format("2006-01-02-15-04-05")+".jpg") + os.WriteFile(archiveFile, data, 0644) + *lastArchive = now + } }