feat: v0.3.3 — rec_/snap_ prefix, latest.jpg grid, per-minute archival snapshots
This commit is contained in:
parent
06a1252088
commit
69d3a7c47f
6 changed files with 131 additions and 119 deletions
50
api.go
50
api.go
|
|
@ -243,6 +243,7 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
Live bool `json:"live"`
|
Live bool `json:"live"`
|
||||||
|
Snap string `json:"snap"`
|
||||||
}
|
}
|
||||||
|
|
||||||
clips := make([]Clip, 0)
|
clips := make([]Clip, 0)
|
||||||
|
|
@ -308,18 +309,11 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
||||||
// Skip in-progress .part files.
|
// Only process rec_*.mp4 files (not snap_*.jpg).
|
||||||
isPart := strings.HasSuffix(strings.ToLower(name), ".part.mp4")
|
if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") {
|
||||||
isMP4 := strings.HasSuffix(strings.ToLower(name), ".mp4")
|
|
||||||
|
|
||||||
if !isMP4 && !isPart {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract timestamp from filename: YYYY-MM-DD-HH-MM.mp4
|
|
||||||
base := strings.TrimSuffix(strings.TrimSuffix(name, ".mp4"), ".part")
|
|
||||||
fileTime, parseErr := time.Parse("2006-01-02-15-04", base)
|
|
||||||
|
|
||||||
info, statErr := entry.Info()
|
info, statErr := entry.Info()
|
||||||
if statErr != nil {
|
if statErr != nil {
|
||||||
continue
|
continue
|
||||||
|
|
@ -329,21 +323,31 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
|
||||||
Name: name,
|
Name: name,
|
||||||
Path: camDir + "/" + name,
|
Path: camDir + "/" + name,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
Live: isPart,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if parseErr == nil {
|
// Matching snapshot: replace rec_ with snap_, .mp4 with .jpg
|
||||||
clip.Time = fileTime.Format("15:04")
|
snapName := strings.Replace(name, "rec_", "snap_", 1)
|
||||||
// Apply date filter.
|
snapName = strings.Replace(snapName, ".mp4", ".jpg", 1)
|
||||||
if !fromTime.IsZero() && fileTime.Before(fromTime) {
|
snapPath := camDir + "/" + snapName
|
||||||
|
if _, err := os.Stat(recDir + "/" + snapName); err == nil {
|
||||||
|
clip.Snap = snapPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract time from filename: rec_YYYY-MM-DD-HH-MM-SS.mp4
|
||||||
|
timeStr := strings.TrimPrefix(name, "rec_")
|
||||||
|
timeStr = strings.TrimSuffix(timeStr, ".mp4")
|
||||||
|
timeStr = strings.TrimSuffix(timeStr, ".part")
|
||||||
|
if t, err := time.Parse("2006-01-02-15-04-05", timeStr); err == nil {
|
||||||
|
clip.Time = t.Format("15:04")
|
||||||
|
if !fromTime.IsZero() && t.Before(fromTime) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !toTime.IsZero() && fileTime.After(toTime) {
|
if !toTime.IsZero() && t.After(toTime) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clips = append([]Clip{clip}, clips...) // prepend = newest first
|
clips = append([]Clip{clip}, clips...) // newest first
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
|
||||||
|
|
@ -380,3 +384,17 @@ func itoa(i int) string {
|
||||||
}
|
}
|
||||||
return digits
|
return digits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatBytes returns a human-readable byte count.
|
||||||
|
func formatBytes(bytes int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if bytes < unit {
|
||||||
|
return itoa(int(bytes)) + " B"
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := bytes / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B"
|
||||||
|
}
|
||||||
|
|
|
||||||
71
cleaner.go
71
cleaner.go
|
|
@ -1,7 +1,6 @@
|
||||||
// NextNVR v0.2.1 — Storage retention cleaner
|
// NextNVR v0.3.3 — Storage retention cleaner
|
||||||
// Background ticker that purges recordings older than retention_days.
|
// Purges rec_*.mp4 and snap_*.jpg files older than retention_days.
|
||||||
// Skips .part.mp4 files (in-progress recordings).
|
// Skips latest.jpg (live wall) and rec_*.part.mp4 (in-progress recordings).
|
||||||
// Works with flat directory: /mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -38,7 +37,6 @@ func (c *Cleaner) Start() {
|
||||||
log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s",
|
log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s",
|
||||||
c.config.RetentionDays, interval, c.config.RecordingsPath)
|
c.config.RetentionDays, interval, c.config.RecordingsPath)
|
||||||
|
|
||||||
// Run immediately on startup.
|
|
||||||
go c.purge()
|
go c.purge()
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
|
@ -61,60 +59,53 @@ func (c *Cleaner) Stop() {
|
||||||
close(c.stopCh)
|
close(c.stopCh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// purge walks the recordings directory and deletes files older than retention_days.
|
// purge walks the recordings directory and deletes expired files.
|
||||||
// Skips *.part.mp4 files (currently being recorded or crashed mid-segment).
|
|
||||||
func (c *Cleaner) purge() {
|
func (c *Cleaner) purge() {
|
||||||
cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour)
|
cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour)
|
||||||
deleted := 0
|
deleted := 0
|
||||||
var freedBytes int64
|
var freedBytes int64
|
||||||
|
|
||||||
err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
|
filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil || info.IsDir() {
|
||||||
log.Printf("cleaner: walk error for %s: %v", path, err)
|
|
||||||
return nil // skip unreadable paths
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
name := info.Name()
|
||||||
|
|
||||||
// Skip in-progress recordings.
|
// Skip live wall snapshot and in-progress recordings.
|
||||||
if strings.HasSuffix(strings.ToLower(info.Name()), ".part.mp4") {
|
if name == "latest.jpg" {
|
||||||
// Also clean up orphaned .part files older than 1 hour
|
return nil
|
||||||
// (crashed recordings that were never renamed).
|
}
|
||||||
|
if strings.HasSuffix(name, ".part.mp4") {
|
||||||
|
// Clean orphaned .part files older than 1 hour.
|
||||||
if time.Since(info.ModTime()) > 1*time.Hour {
|
if time.Since(info.ModTime()) > 1*time.Hour {
|
||||||
size := info.Size()
|
size := info.Size()
|
||||||
if err := os.Remove(path); err == nil {
|
os.Remove(path)
|
||||||
deleted++
|
deleted++
|
||||||
freedBytes += size
|
freedBytes += size
|
||||||
log.Printf("cleaner: removed orphaned .part file: %s", filepath.Base(path))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process .mp4 files.
|
// Process rec_*.mp4 and snap_*.jpg files.
|
||||||
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") {
|
isRec := strings.HasPrefix(name, "rec_") && strings.HasSuffix(name, ".mp4")
|
||||||
|
isSnap := strings.HasPrefix(name, "snap_") && strings.HasSuffix(name, ".jpg")
|
||||||
|
|
||||||
|
if !isRec && !isSnap {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete if older than retention cutoff.
|
|
||||||
if info.ModTime().Before(cutoff) {
|
if info.ModTime().Before(cutoff) {
|
||||||
size := info.Size()
|
size := info.Size()
|
||||||
if err := os.Remove(path); err != nil {
|
if err := os.Remove(path); err != nil {
|
||||||
log.Printf("cleaner: failed to remove %s: %v", path, err)
|
log.Printf("cleaner: failed to remove %s: %v", path, err)
|
||||||
return nil
|
} else {
|
||||||
|
deleted++
|
||||||
|
freedBytes += size
|
||||||
}
|
}
|
||||||
deleted++
|
|
||||||
freedBytes += size
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("cleaner: walk error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove empty camera directories.
|
|
||||||
c.pruneEmptyDirs(c.config.RecordingsPath)
|
c.pruneEmptyDirs(c.config.RecordingsPath)
|
||||||
|
|
||||||
if deleted > 0 {
|
if deleted > 0 {
|
||||||
|
|
@ -139,17 +130,3 @@ func (c *Cleaner) pruneEmptyDirs(root string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatBytes returns a human-readable byte count.
|
|
||||||
func formatBytes(bytes int64) string {
|
|
||||||
const unit = 1024
|
|
||||||
if bytes < unit {
|
|
||||||
return itoa(int(bytes)) + " B"
|
|
||||||
}
|
|
||||||
div, exp := int64(unit), 0
|
|
||||||
for n := bytes / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B"
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,8 @@ function renderLiveGrid() {
|
||||||
tile.dataset.camId = cam ? cam.id : '';
|
tile.dataset.camId = cam ? cam.id : '';
|
||||||
|
|
||||||
if (cam && cam.enabled) {
|
if (cam && cam.enabled) {
|
||||||
// Static snapshot from disk — updated every 2s by the backend.
|
// Load latest.jpg — the snapshot engine updates this every 2 seconds.
|
||||||
// Cache-bust with timestamp to force refresh.
|
const snapURL = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
|
||||||
const snapURL = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`;
|
|
||||||
tile.innerHTML = `
|
tile.innerHTML = `
|
||||||
<img src="${snapURL}" class="grid-snap" loading="lazy"
|
<img src="${snapURL}" class="grid-snap" loading="lazy"
|
||||||
onerror="this.parentElement.classList.add('offline')"
|
onerror="this.parentElement.classList.add('offline')"
|
||||||
|
|
@ -81,12 +80,9 @@ function renderLiveGrid() {
|
||||||
<span class="tile-status online"></span>
|
<span class="tile-status online"></span>
|
||||||
<span class="tile-label">${cam.name}</span>
|
<span class="tile-label">${cam.name}</span>
|
||||||
`;
|
`;
|
||||||
// Auto-refresh every 3 seconds — no flooding, simple reload.
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const img = tile.querySelector('img');
|
const img = tile.querySelector('img');
|
||||||
if (img && !tile.classList.contains('offline')) {
|
if (img) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
|
||||||
img.src = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`;
|
|
||||||
}
|
|
||||||
}, 3000);
|
}, 3000);
|
||||||
tile.addEventListener('click', () => openFocus(cam));
|
tile.addEventListener('click', () => openFocus(cam));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -192,6 +188,8 @@ function renderClips(clips) {
|
||||||
if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>'; return; }
|
if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>'; return; }
|
||||||
grid.innerHTML = clips.map(c => `
|
grid.innerHTML = clips.map(c => `
|
||||||
<div class="clip-card" onclick="playClip('${c.path}')">
|
<div class="clip-card" onclick="playClip('${c.path}')">
|
||||||
|
<img src="${c.snap || ''}" class="clip-thumb" loading="lazy"
|
||||||
|
onerror="this.style.display='none'" alt="${c.time}">
|
||||||
<div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div>
|
<div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div>
|
||||||
<div class="clip-time">${c.name}</div>
|
<div class="clip-time">${c.name}</div>
|
||||||
<div class="clip-size">${formatSize(c.size)}</div>
|
<div class="clip-size">${formatSize(c.size)}</div>
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,10 @@ body {
|
||||||
.clip-card:hover { border-color: var(--accent); }
|
.clip-card:hover { border-color: var(--accent); }
|
||||||
.clip-card .clip-time { font-size: 12px; color: var(--text-muted); }
|
.clip-card .clip-time { font-size: 12px; color: var(--text-muted); }
|
||||||
.clip-card .clip-size { font-size: 11px; color: var(--text-muted); }
|
.clip-card .clip-size { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.clip-thumb {
|
||||||
|
width: 100%; height: 100px; object-fit: cover;
|
||||||
|
border-radius: 4px; margin-bottom: 6px; background: var(--bg);
|
||||||
|
}
|
||||||
.clip-player {
|
.clip-player {
|
||||||
position: fixed; inset: 0; z-index: 200; background: #000;
|
position: fixed; inset: 0; z-index: 200; background: #000;
|
||||||
display: flex; align-items: center; justify-content: center; flex-direction: column;
|
display: flex; align-items: center; justify-content: center; flex-direction: column;
|
||||||
|
|
|
||||||
19
recorder.go
19
recorder.go
|
|
@ -1,8 +1,6 @@
|
||||||
// NextNVR v0.2.1 — FFmpeg stream recorder
|
// NextNVR v0.3.3 — FFmpeg stream recorder
|
||||||
// Launches per-segment FFmpeg processes with stream-copy mode.
|
// Records 5-minute segments with rec_ prefix and second-level timestamps.
|
||||||
// Each recording runs for exactly 5 minutes (-t 300).
|
// Output: /mnt/recordings/{cam-name}/rec_{YYYY-MM-DD-HH-MM-SS}.mp4
|
||||||
// In-progress files use .part.mp4 suffix; atomically renamed on completion.
|
|
||||||
// This prevents partial/corrupt files and provides crash recovery.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -118,14 +116,11 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate filename with start-of-segment timestamp.
|
// Generate filename: rec_{YYYY-MM-DD-HH-MM-SS}.mp4
|
||||||
// All timestamps are in local time (BST) — no UTC conversion.
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// Round down to the nearest 5-minute boundary for clean naming.
|
timeStr := now.Format("2006-01-02-15-04-05")
|
||||||
rounded := now.Truncate(5 * time.Minute)
|
partFile := filepath.Join(outDir, "rec_"+timeStr+".part.mp4")
|
||||||
timeStr := rounded.Format("2006-01-02-15-04")
|
finalFile := filepath.Join(outDir, "rec_"+timeStr+".mp4")
|
||||||
partFile := filepath.Join(outDir, timeStr+".part.mp4")
|
|
||||||
finalFile := filepath.Join(outDir, timeStr+".mp4")
|
|
||||||
|
|
||||||
// Build FFmpeg command with hard 5-minute timeout.
|
// Build FFmpeg command with hard 5-minute timeout.
|
||||||
cmd := exec.Command("ffmpeg",
|
cmd := exec.Command("ffmpeg",
|
||||||
|
|
|
||||||
94
snapshot.go
94
snapshot.go
|
|
@ -1,7 +1,6 @@
|
||||||
// NextNVR v0.3.2 — Snapshot engine
|
// NextNVR v0.3.3 — Snapshot engine
|
||||||
// Grabs a single JPEG frame from each camera every 2 seconds.
|
// Writes latest.jpg every 2s (live wall) and snap_{timestamp}.jpg every 60s (archive/thumbnails).
|
||||||
// Writes to /mnt/recordings/snapshots/{cam_id}.jpg via atomic rename.
|
// All files go into the per-camera directory: /mnt/recordings/{cam-name}/
|
||||||
// The web UI loads these static images — no go2rtc polling, no CORS, no flooding.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -16,9 +15,9 @@ import (
|
||||||
|
|
||||||
// SnapshotEngine manages per-camera snapshot goroutines.
|
// SnapshotEngine manages per-camera snapshot goroutines.
|
||||||
type SnapshotEngine struct {
|
type SnapshotEngine struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopChs map[string]chan struct{}
|
stopChs map[string]chan struct{}
|
||||||
config StorageConfig
|
config StorageConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSnapshotEngine creates a new snapshot engine.
|
// NewSnapshotEngine creates a new snapshot engine.
|
||||||
|
|
@ -31,12 +30,6 @@ func NewSnapshotEngine(cfg StorageConfig) *SnapshotEngine {
|
||||||
|
|
||||||
// StartAll launches a snapshot goroutine for each enabled camera.
|
// StartAll launches a snapshot goroutine for each enabled camera.
|
||||||
func (se *SnapshotEngine) StartAll(cameras []CameraConfig) {
|
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 {
|
for _, cam := range cameras {
|
||||||
if !cam.Enabled {
|
if !cam.Enabled {
|
||||||
continue
|
continue
|
||||||
|
|
@ -45,9 +38,9 @@ func (se *SnapshotEngine) StartAll(cameras []CameraConfig) {
|
||||||
se.mu.Lock()
|
se.mu.Lock()
|
||||||
se.stopChs[cam.ID] = stopCh
|
se.stopChs[cam.ID] = stopCh
|
||||||
se.mu.Unlock()
|
se.mu.Unlock()
|
||||||
go se.snapshotLoop(cam, outDir, stopCh)
|
go se.snapshotLoop(cam, stopCh)
|
||||||
}
|
}
|
||||||
log.Printf("snapshot: %d cameras started → %s", len(se.stopChs), outDir)
|
log.Printf("snapshot: %d cameras started", len(se.stopChs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopAll terminates all snapshot goroutines.
|
// StopAll terminates all snapshot goroutines.
|
||||||
|
|
@ -61,53 +54,80 @@ func (se *SnapshotEngine) StopAll() {
|
||||||
se.stopChs = make(map[string]chan struct{})
|
se.stopChs = make(map[string]chan struct{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotLoop grabs a JPEG frame every 2 seconds and writes it atomically.
|
// snapshotLoop grabs a JPEG frame every 2 seconds and writes latest.jpg.
|
||||||
func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, outDir string, stopCh chan struct{}) {
|
// Every 60 seconds, copies latest.jpg to snap_{timestamp}.jpg for archival.
|
||||||
|
func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, stopCh chan struct{}) {
|
||||||
rtspURL := cam.RTSPSub
|
rtspURL := cam.RTSPSub
|
||||||
if rtspURL == "" {
|
if rtspURL == "" {
|
||||||
rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102",
|
rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102",
|
||||||
cam.Username, cam.Password, cam.IP)
|
cam.Username, cam.Password, cam.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
outFile := filepath.Join(outDir, cam.ID+".jpg")
|
camDirName := cam.Name
|
||||||
tmpFile := outFile + ".tmp"
|
if camDirName == "" {
|
||||||
interval := 2 * time.Second
|
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
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("snapshot: %s → %s (every %v)", cam.ID, outFile, interval)
|
latestFile := filepath.Join(outDir, "latest.jpg")
|
||||||
|
tmpFile := latestFile + ".tmp"
|
||||||
|
lastArchive := time.Time{} // track when we last wrote an archival snapshot
|
||||||
|
|
||||||
// Immediately grab the first frame.
|
log.Printf("snapshot: %s → %s (live every 2s, archive every 60s)", cam.ID, outDir)
|
||||||
se.grabFrame(rtspURL, tmpFile, outFile, cam.ID)
|
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
// Grab first frame immediately.
|
||||||
defer ticker.Stop()
|
se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive)
|
||||||
|
|
||||||
|
fastTicker := time.NewTicker(2 * time.Second)
|
||||||
|
defer fastTicker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-stopCh:
|
case <-stopCh:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-fastTicker.C:
|
||||||
se.grabFrame(rtspURL, tmpFile, outFile, cam.ID)
|
se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// grabFrame launches ffmpeg to capture a single JPEG frame.
|
// grabFrame launches ffmpeg to capture a JPEG. Updates latest.jpg every call,
|
||||||
func (se *SnapshotEngine) grabFrame(rtspURL, tmpFile, outFile, camID string) {
|
// 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",
|
cmd := exec.Command("ffmpeg",
|
||||||
"-hide_banner", "-loglevel", "error",
|
"-hide_banner", "-loglevel", "error",
|
||||||
"-rtsp_transport", "tcp",
|
"-rtsp_transport", "tcp",
|
||||||
"-i", rtspURL,
|
"-i", rtspURL,
|
||||||
"-vframes", "1", // exactly one frame
|
"-vframes", "1",
|
||||||
"-q:v", "5", // good JPEG quality (2-31, lower=better)
|
"-q:v", "5",
|
||||||
"-f", "image2",
|
"-f", "image2",
|
||||||
"-y", // overwrite
|
"-y", tmpFile,
|
||||||
tmpFile,
|
|
||||||
)
|
)
|
||||||
|
cmd.Run()
|
||||||
|
|
||||||
cmd.Run() // ignore errors — camera may be offline
|
if _, err := os.Stat(tmpFile); err != nil {
|
||||||
|
return // ffmpeg failed — camera may be offline
|
||||||
|
}
|
||||||
|
os.Rename(tmpFile, latestFile)
|
||||||
|
|
||||||
// Atomic rename: if ffmpeg succeeded, tmpFile exists.
|
// Archive: once per minute, copy latest.jpg to snap_{timestamp}.jpg
|
||||||
if _, err := os.Stat(tmpFile); err == nil {
|
now := time.Now()
|
||||||
os.Rename(tmpFile, outFile)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue