package video import ( "bytes" "context" "fmt" "log" "os/exec" "path/filepath" "sync" "time" ) const ( defaultCacheEntries = 200 thumbnailWidth = 160 thumbnailHeight = 90 defaultFFmpegPath = "ffmpeg" ) type Cache struct { mu sync.Mutex max int ffmpegPath string entries map[string][]byte order []string } func NewCache(max int, ffmpegPath string) *Cache { if max <= 0 { max = defaultCacheEntries } if ffmpegPath == "" { ffmpegPath = defaultFFmpegPath } return &Cache{ max: max, ffmpegPath: ffmpegPath, entries: make(map[string][]byte), } } // thumbnailTimeout is the maximum time we allow ffmpeg to spend extracting // a single frame. Most clips produce a frame in under a second; 30 s is a // generous safety net for slow hardware or unexpectedly large files. const thumbnailTimeout = 30 * time.Second func (c *Cache) Thumbnail(absPath string) ([]byte, error) { if cached, ok := c.get(absPath); ok { return cached, nil } ctx, cancel := context.WithTimeout(context.Background(), thumbnailTimeout) defer cancel() cmd := exec.CommandContext( ctx, c.ffmpegPath, "-loglevel", "error", "-i", absPath, // Do NOT use -ss here. // -ss before -i: input seeking — raw H.265 has no index, causes // "could not seek to position 0.000" and empty output. // -ss after -i: output seeking — decodes from start then skips, // still trips on files whose first NAL units are invalid. // Dropping -ss entirely and using only -vframes 1 lets ffmpeg decode // until it finds the first valid frame, which is robust across all // camera files regardless of their NAL unit layout. "-vframes", "1", "-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", thumbnailWidth, thumbnailHeight), "-f", "image2", "-vcodec", "mjpeg", "pipe:1", ) var stderr bytes.Buffer cmd.Stderr = &stderr output, err := cmd.Output() if err != nil { log.Printf("video thumbnail failed for %s: %v — stderr: %s", filepath.Base(absPath), err, stderr.String()) return nil, fmt.Errorf("generate video thumbnail: %w", err) } // ffmpeg can exit 0 but write nothing when the file has no decodable // frame (truncated clip, all-invalid NAL units, etc.). Treat empty // output as an error so we never cache broken placeholder bytes and // serve them as a "successful" thumbnail on every subsequent request. if len(output) == 0 { log.Printf("video thumbnail: empty output for %s — stderr: %s", filepath.Base(absPath), stderr.String()) return nil, fmt.Errorf("video thumbnail: no frame decoded from %s", filepath.Base(absPath)) } c.add(absPath, output) return append([]byte(nil), output...), nil } func (c *Cache) get(key string) ([]byte, bool) { c.mu.Lock() defer c.mu.Unlock() value, ok := c.entries[key] if !ok { return nil, false } return append([]byte(nil), value...), true } func (c *Cache) add(key string, value []byte) { c.mu.Lock() defer c.mu.Unlock() if _, ok := c.entries[key]; !ok { c.order = append(c.order, key) } c.entries[key] = append([]byte(nil), value...) for len(c.order) > c.max { oldest := c.order[0] c.order = c.order[1:] delete(c.entries, oldest) } }