Video thumbnail generation

This commit is contained in:
2026-06-24 08:36:03 +02:00
parent fa22244709
commit 9f6a611dff
5 changed files with 216 additions and 23 deletions
+136
View File
@@ -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()
}