From 70e1dfc2b856069ff1d3ca37852b5ec7a08de5da Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 25 May 2026 14:43:18 -0700 Subject: [PATCH] Extract frame for video thumbnails via ffmpeg mp4/webm/mov/m4v/mkv URLs now route to a frame-extraction path with a larger 64 MiB download cap, and ffmpeg pulls a single resized frame that the existing avifenc step turns into the cached thumb. ffmpeg is optional: if missing, we fall through like any other build failure and the handler redirects to the source URL. --- internal/web/thumbs.go | 130 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 8 deletions(-) diff --git a/internal/web/thumbs.go b/internal/web/thumbs.go index cb082e0..a4573c7 100644 --- a/internal/web/thumbs.go +++ b/internal/web/thumbs.go @@ -2,6 +2,7 @@ package web import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "fmt" @@ -29,13 +30,25 @@ import ( const ( thumbWidth = 800 - thumbMaxBytes = 12 << 20 // 12 MiB cap on source download + thumbMaxBytes = 12 << 20 // 12 MiB cap on source download for images + videoMaxBytes = 64 << 20 // 64 MiB cap when the source is a video file thumbFetchTimeout = 12 * time.Second thumbCacheDir = "./data/img-cache" thumbQuality = "45" thumbSpeed = "8" + ffmpegTimeout = 20 * time.Second ) +// videoExtensions are file extensions we route through the ffmpeg +// frame-extraction path instead of trying to decode as an image. +var videoExtensions = map[string]bool{ + ".mp4": true, + ".m4v": true, + ".mov": true, + ".webm": true, + ".mkv": true, +} + var ( thumbClient = safehttp.NewClient(thumbFetchTimeout) @@ -128,7 +141,8 @@ func serveAvif(w http.ResponseWriter, r *http.Request, path string) { } // buildAvifThumb downloads src, decodes + resizes in Go, then shells out to -// avifenc to produce a compact AVIF at outPath. +// avifenc to produce a compact AVIF at outPath. Video URLs (mp4, webm, ...) +// take a parallel path that extracts a frame with ffmpeg before re-encoding. func buildAvifThumb(src, outPath string) error { req, err := http.NewRequest(http.MethodGet, src, nil) if err != nil { @@ -143,12 +157,28 @@ func buildAvifThumb(src, outPath string) error { if resp.StatusCode != http.StatusOK { return fmt.Errorf("upstream %d", resp.StatusCode) } - body, err := io.ReadAll(io.LimitReader(resp.Body, thumbMaxBytes+1)) + + // URL extension is enough for the cap decision (Content-Type may be + // missing or generic on some CDNs); Content-Type is checked again below + // to decide which decode path to take. + maxBytes := int64(thumbMaxBytes) + urlIsVideo := isVideoURL(src) + if urlIsVideo { + maxBytes = videoMaxBytes + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) if err != nil { return err } - if len(body) > thumbMaxBytes { - return fmt.Errorf("image too big (>%d bytes)", thumbMaxBytes) + if int64(len(body)) > maxBytes { + if urlIsVideo { + return fmt.Errorf("video too big (>%d bytes)", maxBytes) + } + return fmt.Errorf("image too big (>%d bytes)", maxBytes) + } + + if urlIsVideo || isVideoContentType(resp.Header.Get("Content-Type")) { + return buildAvifFromVideoFrame(body, outPath) } if isAvif(body) { @@ -201,13 +231,17 @@ func buildAvifThumb(src, outPath string) error { return err } - // Encode to a sibling temp file then atomically rename so partial outputs - // never appear in the cache. + return avifEncodeFromPNG(tmpPath, outPath) +} + +// avifEncodeFromPNG runs avifenc on pngPath and atomically renames the +// result into outPath so partial outputs never appear in the cache. +func avifEncodeFromPNG(pngPath, outPath string) error { stagedOut := outPath + ".tmp" cmd := exec.Command("avifenc", "-q", thumbQuality, "-s", thumbSpeed, - tmpPath, stagedOut, + pngPath, stagedOut, ) if out, err := cmd.CombinedOutput(); err != nil { os.Remove(stagedOut) @@ -216,6 +250,86 @@ func buildAvifThumb(src, outPath string) error { return os.Rename(stagedOut, outPath) } +// isVideoURL reports whether the URL's path extension is one we treat as a +// video source for thumbnail extraction. +func isVideoURL(src string) bool { + u, err := url.Parse(src) + if err != nil { + return false + } + return videoExtensions[strings.ToLower(filepath.Ext(u.Path))] +} + +// isVideoContentType reports whether a Content-Type header announces a video. +func isVideoContentType(ct string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(ct)), "video/") +} + +// buildAvifFromVideoFrame writes the downloaded video to a temp file, asks +// ffmpeg to extract a single resized frame as PNG, then hands it to avifenc. +// ffmpeg is optional — if it's missing we return an error and the caller +// falls back to redirecting to the source URL, just like any other build +// failure. +func buildAvifFromVideoFrame(body []byte, outPath string) error { + if _, err := exec.LookPath("ffmpeg"); err != nil { + return fmt.Errorf("ffmpeg not available: %w", err) + } + + in, err := os.CreateTemp("", "pete-vid-*.bin") + if err != nil { + return err + } + inPath := in.Name() + defer os.Remove(inPath) + if _, err := in.Write(body); err != nil { + in.Close() + return err + } + if err := in.Close(); err != nil { + return err + } + + outPNG, err := os.CreateTemp("", "pete-vid-frame-*.png") + if err != nil { + return err + } + pngPath := outPNG.Name() + 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 + } + } + return avifEncodeFromPNG(pngPath, outPath) +} + +// 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 { + ctx, cancel := context.WithTimeout(context.Background(), ffmpegTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "ffmpeg", + "-y", + "-ss", seekSec, + "-i", inPath, + "-frames:v", "1", + "-update", "1", + "-vf", fmt.Sprintf("scale='min(%d,iw)':-2", thumbWidth), + "-f", "image2", + pngPath, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out))) + } + if fi, err := os.Stat(pngPath); err != nil || fi.Size() == 0 { + return fmt.Errorf("ffmpeg produced empty frame") + } + return nil +} + // isAvif reports whether body looks like an AVIF file via the ISO-BMFF "ftyp" // brand at bytes 4..12 ("ftypavif"/"ftypavis"). func isAvif(body []byte) bool {