137 lines
4.6 KiB
Go
137 lines
4.6 KiB
Go
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()
|
|
}
|