diff --git a/assets/page/main.html b/assets/page/main.html
index 3890b30..415c176 100644
--- a/assets/page/main.html
+++ b/assets/page/main.html
@@ -13,7 +13,7 @@
{{range .Entries}}
- {{if .ThumbURL}}
{{else}}{{.Icon}}{{end}}
+ {{if .ThumbURL}}
{{if .IsVideo}}▶{{end}}{{else}}{{.Icon}}{{end}}
{{.Name}}
{{end}}
diff --git a/assets/style.css b/assets/style.css
index 696df8a..1b96674 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -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;
diff --git a/render.go b/render.go
index 6ae5102..5471714 100644
--- a/render.go
+++ b/render.go
@@ -36,6 +36,9 @@ type entry struct {
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
//
![]()
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)
}
diff --git a/thumb.go b/thumb.go
index 2a06e58..b179608 100644
--- a/thumb.go
+++ b/thumb.go
@@ -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,11 +124,39 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
}
}
- digest, data, err := sourceDigest(srcFS, srcInfo)
- if err != nil {
- log.Printf("thumb digest %s: %v", rel, err)
- http.Error(w, "thumbnail failed", http.StatusInternalServerError)
- return
+ // 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))
@@ -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
diff --git a/thumb_video.go b/thumb_video.go
new file mode 100644
index 0000000..887ae21
--- /dev/null
+++ b/thumb_video.go
@@ -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
` 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()
+}