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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user