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.
This commit is contained in:
prosolis
2026-05-25 14:43:18 -07:00
parent 83b1216541
commit 70e1dfc2b8

View File

@@ -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 {