Video thumbnail generation
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
<div class="thumb-grid">
|
||||
{{range .Entries}}
|
||||
<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>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
--link-hover: #d6d24d;
|
||||
--danger: #c40141;
|
||||
--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-dashed: 1px dashed var(--secondary);
|
||||
@@ -484,6 +486,24 @@ button.fab { display: none; }
|
||||
display: block;
|
||||
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 {
|
||||
height: 150px;
|
||||
display: flex;
|
||||
|
||||
@@ -36,6 +36,9 @@ type entry struct {
|
||||
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
|
||||
// <img> when it is non-empty and falls back to Icon otherwise.
|
||||
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
|
||||
// formatted Meta string.
|
||||
modTime time.Time
|
||||
@@ -217,6 +220,7 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
||||
}
|
||||
if hasThumbnail(name) {
|
||||
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
||||
f.IsVideo = isVideoFile(name)
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ type Thumbnailer interface {
|
||||
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
|
||||
|
||||
// thumbCacheDir is set from the -cache flag at startup.
|
||||
@@ -116,12 +124,40 @@ 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
|
||||
// the cache on path+mtime+size, so the file is never read into memory.
|
||||
// Content thumbnailers (images) stay content-addressed so renames reuse
|
||||
// the cache entry. Either way, generation runs through the closure below.
|
||||
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))
|
||||
if _, err := os.Stat(cacheFS); err == nil {
|
||||
@@ -138,21 +174,7 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var src io.Reader
|
||||
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 {
|
||||
if err := generateThumb(cacheFS, write); err != nil {
|
||||
log.Printf("thumb %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -160,6 +182,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// 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
|
||||
@@ -191,7 +221,10 @@ func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -200,7 +233,7 @@ func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) erro
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := t.Generate(src, tmp, width); err != nil {
|
||||
if err := write(tmp); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
|
||||
+136
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user