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
+55 -22
View File
@@ -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