|
|
@ -2,9 +2,13 @@ package video |
|
|
|
|
|
|
|
|
import ( |
|
|
import ( |
|
|
"bytes" |
|
|
"bytes" |
|
|
|
|
|
"context" |
|
|
"fmt" |
|
|
"fmt" |
|
|
|
|
|
"log" |
|
|
"os/exec" |
|
|
"os/exec" |
|
|
|
|
|
"path/filepath" |
|
|
"sync" |
|
|
"sync" |
|
|
|
|
|
"time" |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
const ( |
|
|
const ( |
|
|
@ -36,20 +40,32 @@ func NewCache(max int, ffmpegPath string) *Cache { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 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) { |
|
|
func (c *Cache) Thumbnail(absPath string) ([]byte, error) { |
|
|
if cached, ok := c.get(absPath); ok { |
|
|
if cached, ok := c.get(absPath); ok { |
|
|
return cached, nil |
|
|
return cached, nil |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
cmd := exec.Command( |
|
|
ctx, cancel := context.WithTimeout(context.Background(), thumbnailTimeout) |
|
|
|
|
|
defer cancel() |
|
|
|
|
|
|
|
|
|
|
|
cmd := exec.CommandContext( |
|
|
|
|
|
ctx, |
|
|
c.ffmpegPath, |
|
|
c.ffmpegPath, |
|
|
"-loglevel", "error", |
|
|
"-loglevel", "error", |
|
|
"-i", absPath, |
|
|
"-i", absPath, |
|
|
// -ss must come AFTER -i for raw H.265 bitstreams.
|
|
|
// Do NOT use -ss here.
|
|
|
// Input seeking (-ss before -i) requires an index that raw streams lack,
|
|
|
// -ss before -i: input seeking — raw H.265 has no index, causes
|
|
|
// causing "could not seek to position 0.000" and an empty output.
|
|
|
// "could not seek to position 0.000" and empty output.
|
|
|
// Output seeking (-ss after -i) decodes from the start and is reliable.
|
|
|
// -ss after -i: output seeking — decodes from start then skips,
|
|
|
"-ss", "0", |
|
|
// 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", |
|
|
"-vframes", "1", |
|
|
"-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", thumbnailWidth, thumbnailHeight), |
|
|
"-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", thumbnailWidth, thumbnailHeight), |
|
|
"-f", "image2", |
|
|
"-f", "image2", |
|
|
@ -62,7 +78,19 @@ func (c *Cache) Thumbnail(absPath string) ([]byte, error) { |
|
|
|
|
|
|
|
|
output, err := cmd.Output() |
|
|
output, err := cmd.Output() |
|
|
if err != nil { |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("generate video thumbnail: %w%s", err, stderrSuffix(stderr.String())) |
|
|
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) |
|
|
c.add(absPath, output) |
|
|
|