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 ` 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() }