Video thumbnail generation

This commit is contained in:
2026-06-24 08:36:03 +02:00
parent fa22244709
commit 9f6a611dff
5 changed files with 216 additions and 23 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="thumb-grid"> <div class="thumb-grid">
{{range .Entries}} {{range .Entries}}
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}"> <a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
{{if .ThumbURL}}<img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}} {{if .ThumbURL}}<span class="thumb-media"><img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{if .IsVideo}}<span class="thumb-play" aria-hidden="true"></span>{{end}}</span>{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
<span class="thumb-label truncate">{{.Name}}</span> <span class="thumb-label truncate">{{.Name}}</span>
</a> </a>
{{end}} {{end}}
+20
View File
@@ -24,6 +24,8 @@
--link-hover: #d6d24d; --link-hover: #d6d24d;
--danger: #c40141; --danger: #c40141;
--danger-hover: #d03467; --danger-hover: #d03467;
/* Translucent scrim for badges/overlays sitting on top of media. */
--overlay: rgba(0, 0, 0, 0.55);
--border: 1px solid var(--secondary); --border: 1px solid var(--secondary);
--border-dashed: 1px dashed var(--secondary); --border-dashed: 1px dashed var(--secondary);
@@ -484,6 +486,24 @@ button.fab { display: none; }
display: block; display: block;
background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat; background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
} }
/* Wrapper so a video tile can overlay a play badge on its extracted frame. */
.thumb-media { position: relative; display: block; }
.thumb-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding-left: 0.15rem;
border-radius: 50%;
background: var(--overlay);
color: var(--text);
pointer-events: none;
}
.thumb-icon { .thumb-icon {
height: 150px; height: 150px;
display: flex; display: flex;
+4
View File
@@ -36,6 +36,9 @@ type entry struct {
// ThumbURL is set for thumbnailable files; the thumbnail view renders an // ThumbURL is set for thumbnailable files; the thumbnail view renders an
// <img> when it is non-empty and falls back to Icon otherwise. // <img> when it is non-empty and falls back to Icon otherwise.
ThumbURL string ThumbURL string
// IsVideo marks a thumbnail tile as a video so the grid can overlay a play
// badge. Only meaningful when ThumbURL is set.
IsVideo bool
// modTime/size carry the raw sort keys; the template only reads the // modTime/size carry the raw sort keys; the template only reads the
// formatted Meta string. // formatted Meta string.
modTime time.Time modTime time.Time
@@ -217,6 +220,7 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
} }
if hasThumbnail(name) { if hasThumbnail(name) {
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300) f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
f.IsVideo = isVideoFile(name)
} }
files = append(files, f) files = append(files, f)
} }
+55 -22
View File
@@ -26,6 +26,14 @@ type Thumbnailer interface {
Generate(src io.Reader, dst io.Writer, width int) error Generate(src io.Reader, dst io.Writer, width int) error
} }
// PathThumbnailer is an optional capability for thumbnailers whose source must
// be a seekable file on disk rather than a stream (e.g. ffmpeg for video). When
// a Thumbnailer also implements this, handleThumb passes the file path directly
// and skips reading the file into memory and content-hashing it.
type PathThumbnailer interface {
GenerateFromPath(srcPath string, dst io.Writer, width int) error
}
var thumbnailers []Thumbnailer var thumbnailers []Thumbnailer
// thumbCacheDir is set from the -cache flag at startup. // thumbCacheDir is set from the -cache flag at startup.
@@ -116,11 +124,39 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
} }
} }
digest, data, err := sourceDigest(srcFS, srcInfo) // Path thumbnailers (video/ffmpeg) take the source path directly and key
if err != nil { // the cache on path+mtime+size, so the file is never read into memory.
log.Printf("thumb digest %s: %v", rel, err) // Content thumbnailers (images) stay content-addressed so renames reuse
http.Error(w, "thumbnail failed", http.StatusInternalServerError) // the cache entry. Either way, generation runs through the closure below.
return var (
digest string
write func(dst io.Writer) error
)
if pt, ok := t.(PathThumbnailer); ok {
digest = pathDigest(srcFS, srcInfo)
write = func(dst io.Writer) error { return pt.GenerateFromPath(srcFS, dst, width) }
} else {
d, data, err := sourceDigest(srcFS, srcInfo)
if err != nil {
log.Printf("thumb digest %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
digest = d
write = func(dst io.Writer) error {
var src io.Reader
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
return err
}
defer f.Close()
src = f
}
return t.Generate(src, dst, width)
}
} }
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width)) cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
@@ -138,21 +174,7 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
return return
} }
var src io.Reader if err := generateThumb(cacheFS, write); err != nil {
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
log.Printf("thumb open %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
defer f.Close()
src = f
}
if err := generateThumb(t, src, cacheFS, width); err != nil {
log.Printf("thumb %s: %v", rel, err) log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError) http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return return
@@ -160,6 +182,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
serveThumb(w, r, cacheFS) serveThumb(w, r, cacheFS)
} }
// pathDigest keys the cache for path-based thumbnailers on the source path plus
// its mtime and size, so the (potentially large) file is never read into memory
// just to hash it. Overwriting the file changes mtime/size and busts the entry.
func pathDigest(srcFS string, info os.FileInfo) string {
sum := sha256.Sum256(fmt.Appendf(nil, "%s\x00%d\x00%d", srcFS, info.ModTime().UnixNano(), info.Size()))
return hex.EncodeToString(sum[:])
}
// sourceDigest returns the SHA-256 hex digest of a source file's content. // sourceDigest returns the SHA-256 hex digest of a source file's content.
// On a cache hit (path + mtime + size unchanged) the returned data is nil, // On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read // so the caller knows to open the file itself. On a miss the file is read
@@ -191,7 +221,10 @@ func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
http.ServeFile(w, r, cacheFS) http.ServeFile(w, r, cacheFS)
} }
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) error { // generateThumb writes a thumbnail to cacheFS atomically: write builds the
// image into a temp file in the same dir, which is renamed into place only on
// success so readers never see a partial file.
func generateThumb(cacheFS string, write func(dst io.Writer) error) error {
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
return err return err
} }
@@ -200,7 +233,7 @@ func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) erro
return err return err
} }
tmpName := tmp.Name() tmpName := tmp.Name()
if err := t.Generate(src, tmp, width); err != nil { if err := write(tmp); err != nil {
tmp.Close() tmp.Close()
os.Remove(tmpName) os.Remove(tmpName)
return err return err
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func init() {
thumbnailers = append(thumbnailers, &videoThumbnailer{})
}
// ffmpegPath is resolved once at startup. Empty means ffmpeg is not installed,
// in which case the video thumbnailer disables itself and videos keep their
// icon. ffmpeg is a graceful soft-dependency, not a hard requirement: the
// binary still runs standalone without it.
var ffmpegPath, _ = exec.LookPath("ffmpeg")
type videoThumbnailer struct{}
func isVideoExt(ext string) bool {
switch ext {
case ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v":
return true
}
return false
}
// isVideoFile reports whether name is a video container we generate previews
// for. Independent of ffmpeg availability — used to tag tiles with a play badge.
func isVideoFile(name string) bool {
return isVideoExt(strings.ToLower(filepath.Ext(name)))
}
func (vt *videoThumbnailer) CanHandle(ext string) bool {
return ffmpegPath != "" && isVideoExt(ext)
}
// Generate satisfies Thumbnailer but is never used: handleThumb routes video
// through the PathThumbnailer branch (GenerateFromPath), since ffmpeg needs a
// seekable file path and we must not read large videos into memory.
func (vt *videoThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
return fmt.Errorf("video thumbnails require a file path; use GenerateFromPath")
}
// GenerateFromPath extracts a single frame and writes it to dst as JPEG. It
// prefers a frame from the middle of the clip (see seekOffsets) to avoid the
// black/fade-in frames common at the very start, falling back to earlier
// offsets. The (small) JPEG is buffered first so a failed attempt never leaves
// partial bytes in dst.
func (vt *videoThumbnailer) GenerateFromPath(srcPath string, dst io.Writer, width int) error {
for _, seek := range seekOffsets(srcPath) {
var buf bytes.Buffer
if err := runFFmpegFrame(srcPath, &buf, width, seek); err == nil && buf.Len() > 0 {
_, err = dst.Write(buf.Bytes())
return err
}
}
return fmt.Errorf("ffmpeg produced no frame for %s", srcPath)
}
// seekOffsets returns seek targets (in seconds) to try in order. When the clip
// duration is known the midpoint comes first — a representative frame that
// dodges black intros and end credits. "1" and "0" are fallbacks for when the
// duration is unknown or the midpoint seek yields nothing (very short clips, or
// a keyframe gap at the midpoint).
func seekOffsets(srcPath string) []string {
if d := videoDurationSec(srcPath); d > 2 {
return []string{strconv.FormatFloat(d/2, 'f', 2, 64), "1", "0"}
}
return []string{"1", "0"}
}
// videoDurationSec parses the clip duration from ffmpeg's own stderr, so it
// depends only on ffmpeg (ffprobe is not installed everywhere we deploy).
// `ffmpeg -i <file>` with no output exits non-zero but prints the container
// metadata, including a "Duration: HH:MM:SS.ss" line. Returns 0 when the
// duration can't be determined, so the caller falls back to a fixed offset.
func videoDurationSec(srcPath string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath, "-i", srcPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
_ = cmd.Run() // expected to "fail": no output file specified. We want stderr.
return parseFFmpegDuration(stderr.String())
}
// parseFFmpegDuration extracts seconds from an ffmpeg log containing a line like
// " Duration: 00:01:23.45, start: 0.000000, bitrate: 1234 kb/s".
func parseFFmpegDuration(log string) float64 {
_, after, ok := strings.Cut(log, "Duration:")
if !ok {
return 0
}
field := strings.TrimSpace(after)
if c := strings.IndexByte(field, ','); c >= 0 {
field = field[:c]
}
parts := strings.Split(strings.TrimSpace(field), ":")
if len(parts) != 3 {
return 0
}
h, err1 := strconv.ParseFloat(parts[0], 64)
m, err2 := strconv.ParseFloat(parts[1], 64)
s, err3 := strconv.ParseFloat(parts[2], 64)
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
return h*3600 + m*60 + s
}
func runFFmpegFrame(srcPath string, dst io.Writer, width int, seek string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath,
"-ss", seek,
"-i", srcPath,
"-frames:v", "1",
// Cap width at the requested size but never upscale (matches the image
// thumbnailer); -2 keeps the height even. The comma in min() is escaped
// so ffmpeg does not read it as a filter separator.
"-vf", "scale='min(iw\\,"+strconv.Itoa(width)+")':-2",
"-f", "mjpeg",
"-q:v", "5",
"pipe:1",
)
cmd.Stdout = dst
return cmd.Run()
}