package video import ( "bytes" "fmt" "os/exec" "sync" ) 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), } } func (c *Cache) Thumbnail(absPath string) ([]byte, error) { if cached, ok := c.get(absPath); ok { return cached, nil } cmd := exec.Command( c.ffmpegPath, "-loglevel", "error", "-i", absPath, // -ss must come AFTER -i for raw H.265 bitstreams. // Input seeking (-ss before -i) requires an index that raw streams lack, // causing "could not seek to position 0.000" and an empty output. // Output seeking (-ss after -i) decodes from the start and is reliable. "-ss", "0", "-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 { return nil, fmt.Errorf("generate video thumbnail: %w%s", err, stderrSuffix(stderr.String())) } 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) } }