Video thumbnail generation
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
thumbnailers = append(thumbnailers, &videoThumbnailer{})
|
||||
}
|
||||
|
||||
// ffmpegPath is resolved once at startup. Empty means ffmpeg is not installed,
|
||||
// in which case the video thumbnailer disables itself and videos keep their
|
||||
// icon. ffmpeg is a graceful soft-dependency, not a hard requirement: the
|
||||
// binary still runs standalone without it.
|
||||
var ffmpegPath, _ = exec.LookPath("ffmpeg")
|
||||
|
||||
type videoThumbnailer struct{}
|
||||
|
||||
func isVideoExt(ext string) bool {
|
||||
switch ext {
|
||||
case ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isVideoFile reports whether name is a video container we generate previews
|
||||
// for. Independent of ffmpeg availability — used to tag tiles with a play badge.
|
||||
func isVideoFile(name string) bool {
|
||||
return isVideoExt(strings.ToLower(filepath.Ext(name)))
|
||||
}
|
||||
|
||||
func (vt *videoThumbnailer) CanHandle(ext string) bool {
|
||||
return ffmpegPath != "" && isVideoExt(ext)
|
||||
}
|
||||
|
||||
// Generate satisfies Thumbnailer but is never used: handleThumb routes video
|
||||
// through the PathThumbnailer branch (GenerateFromPath), since ffmpeg needs a
|
||||
// seekable file path and we must not read large videos into memory.
|
||||
func (vt *videoThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
|
||||
return fmt.Errorf("video thumbnails require a file path; use GenerateFromPath")
|
||||
}
|
||||
|
||||
// GenerateFromPath extracts a single frame and writes it to dst as JPEG. It
|
||||
// prefers a frame from the middle of the clip (see seekOffsets) to avoid the
|
||||
// black/fade-in frames common at the very start, falling back to earlier
|
||||
// offsets. The (small) JPEG is buffered first so a failed attempt never leaves
|
||||
// partial bytes in dst.
|
||||
func (vt *videoThumbnailer) GenerateFromPath(srcPath string, dst io.Writer, width int) error {
|
||||
for _, seek := range seekOffsets(srcPath) {
|
||||
var buf bytes.Buffer
|
||||
if err := runFFmpegFrame(srcPath, &buf, width, seek); err == nil && buf.Len() > 0 {
|
||||
_, err = dst.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ffmpeg produced no frame for %s", srcPath)
|
||||
}
|
||||
|
||||
// seekOffsets returns seek targets (in seconds) to try in order. When the clip
|
||||
// duration is known the midpoint comes first — a representative frame that
|
||||
// dodges black intros and end credits. "1" and "0" are fallbacks for when the
|
||||
// duration is unknown or the midpoint seek yields nothing (very short clips, or
|
||||
// a keyframe gap at the midpoint).
|
||||
func seekOffsets(srcPath string) []string {
|
||||
if d := videoDurationSec(srcPath); d > 2 {
|
||||
return []string{strconv.FormatFloat(d/2, 'f', 2, 64), "1", "0"}
|
||||
}
|
||||
return []string{"1", "0"}
|
||||
}
|
||||
|
||||
// videoDurationSec parses the clip duration from ffmpeg's own stderr, so it
|
||||
// depends only on ffmpeg (ffprobe is not installed everywhere we deploy).
|
||||
// `ffmpeg -i <file>` with no output exits non-zero but prints the container
|
||||
// metadata, including a "Duration: HH:MM:SS.ss" line. Returns 0 when the
|
||||
// duration can't be determined, so the caller falls back to a fixed offset.
|
||||
func videoDurationSec(srcPath string) float64 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, "-i", srcPath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
_ = cmd.Run() // expected to "fail": no output file specified. We want stderr.
|
||||
return parseFFmpegDuration(stderr.String())
|
||||
}
|
||||
|
||||
// parseFFmpegDuration extracts seconds from an ffmpeg log containing a line like
|
||||
// " Duration: 00:01:23.45, start: 0.000000, bitrate: 1234 kb/s".
|
||||
func parseFFmpegDuration(log string) float64 {
|
||||
_, after, ok := strings.Cut(log, "Duration:")
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
field := strings.TrimSpace(after)
|
||||
if c := strings.IndexByte(field, ','); c >= 0 {
|
||||
field = field[:c]
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(field), ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
h, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
m, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
s, err3 := strconv.ParseFloat(parts[2], 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return 0
|
||||
}
|
||||
return h*3600 + m*60 + s
|
||||
}
|
||||
|
||||
func runFFmpegFrame(srcPath string, dst io.Writer, width int, seek string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||
"-ss", seek,
|
||||
"-i", srcPath,
|
||||
"-frames:v", "1",
|
||||
// Cap width at the requested size but never upscale (matches the image
|
||||
// thumbnailer); -2 keeps the height even. The comma in min() is escaped
|
||||
// so ffmpeg does not read it as a filter separator.
|
||||
"-vf", "scale='min(iw\\,"+strconv.Itoa(width)+")':-2",
|
||||
"-f", "mjpeg",
|
||||
"-q:v", "5",
|
||||
"pipe:1",
|
||||
)
|
||||
cmd.Stdout = dst
|
||||
return cmd.Run()
|
||||
}
|
||||
Reference in New Issue
Block a user