From fc4cab3ad64a2fb201b33b462ad0fe064157775a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 26 May 2026 23:03:31 -0700 Subject: [PATCH] Seek 5s into videos for thumbnail extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.5s seek often landed on the initial keyframe or a fade-in, producing blocky thumbnails. Aim ~5s in instead, with an ffprobe midpoint fallback for clips shorter than that and a 2s → 0 retry chain if the seek still overshoots. --- internal/web/thumbs.go | 43 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/web/thumbs.go b/internal/web/thumbs.go index 0655202..ecaf32c 100644 --- a/internal/web/thumbs.go +++ b/internal/web/thumbs.go @@ -17,6 +17,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "sync" "time" @@ -305,15 +306,49 @@ func buildAvifFromFFmpeg(body []byte, outPath string) error { outPNG.Close() defer os.Remove(pngPath) - if err := extractVideoFrame(inPath, pngPath, "0.5"); err != nil { - // Some clips are shorter than the seek offset; retry from frame 0. - if err := extractVideoFrame(inPath, pngPath, "0"); err != nil { - return err + // Aim ~5s in — past the typical fade-in / first-GOP blockiness while + // still likely within the clip. For shorter clips we fall back to the + // midpoint (when ffprobe is available) so we don't seek off the end. + seek := "5" + if d := probeVideoDuration(inPath); d > 0 && d < 5 { + seek = strconv.FormatFloat(d/2, 'f', 2, 64) + } + if err := extractVideoFrame(inPath, pngPath, seek); err != nil { + // Seek overshot or container's funky — try 2s, then frame 0. + if err := extractVideoFrame(inPath, pngPath, "2"); err != nil { + if err := extractVideoFrame(inPath, pngPath, "0"); err != nil { + return err + } } } return avifEncodeFromPNG(pngPath, outPath) } +// probeVideoDuration returns the clip length in seconds, or 0 if ffprobe is +// missing or can't read it (image-as-video fallback, malformed containers). +func probeVideoDuration(inPath string) float64 { + if _, err := exec.LookPath("ffprobe"); err != nil { + return 0 + } + ctx, cancel := context.WithTimeout(context.Background(), ffmpegTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + inPath, + ) + out, err := cmd.Output() + if err != nil { + return 0 + } + d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil || d <= 0 { + return 0 + } + return d +} + // extractVideoFrame asks ffmpeg to seek to seekSec, grab one frame, and // downscale it to at most thumbWidth wide before writing pngPath. func extractVideoFrame(inPath, pngPath, seekSec string) error {