You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
125 lines
3.2 KiB
125 lines
3.2 KiB
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)
|
|
}
|
|
}
|
|
|