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() +}