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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -29,13 +30,25 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
thumbWidth = 800
|
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
|
thumbFetchTimeout = 12 * time.Second
|
||||||
thumbCacheDir = "./data/img-cache"
|
thumbCacheDir = "./data/img-cache"
|
||||||
thumbQuality = "45"
|
thumbQuality = "45"
|
||||||
thumbSpeed = "8"
|
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 (
|
var (
|
||||||
thumbClient = safehttp.NewClient(thumbFetchTimeout)
|
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
|
// 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 {
|
func buildAvifThumb(src, outPath string) error {
|
||||||
req, err := http.NewRequest(http.MethodGet, src, nil)
|
req, err := http.NewRequest(http.MethodGet, src, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,12 +157,28 @@ func buildAvifThumb(src, outPath string) error {
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("upstream %d", resp.StatusCode)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(body) > thumbMaxBytes {
|
if int64(len(body)) > maxBytes {
|
||||||
return fmt.Errorf("image too big (>%d bytes)", thumbMaxBytes)
|
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) {
|
if isAvif(body) {
|
||||||
@@ -201,13 +231,17 @@ func buildAvifThumb(src, outPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode to a sibling temp file then atomically rename so partial outputs
|
return avifEncodeFromPNG(tmpPath, outPath)
|
||||||
// never appear in the cache.
|
}
|
||||||
|
|
||||||
|
// 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"
|
stagedOut := outPath + ".tmp"
|
||||||
cmd := exec.Command("avifenc",
|
cmd := exec.Command("avifenc",
|
||||||
"-q", thumbQuality,
|
"-q", thumbQuality,
|
||||||
"-s", thumbSpeed,
|
"-s", thumbSpeed,
|
||||||
tmpPath, stagedOut,
|
pngPath, stagedOut,
|
||||||
)
|
)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
os.Remove(stagedOut)
|
os.Remove(stagedOut)
|
||||||
@@ -216,6 +250,86 @@ func buildAvifThumb(src, outPath string) error {
|
|||||||
return os.Rename(stagedOut, outPath)
|
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"
|
// isAvif reports whether body looks like an AVIF file via the ISO-BMFF "ftyp"
|
||||||
// brand at bytes 4..12 ("ftypavif"/"ftypavis").
|
// brand at bytes 4..12 ("ftypavif"/"ftypavis").
|
||||||
func isAvif(body []byte) bool {
|
func isAvif(body []byte) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user