Files
website/app/internal/gallery/thumbs.go
jimmy 45b31be9a7 Add gallery admin and video media support.
This updates gallery handling to support video playback with generated poster thumbnails, adds authenticated admin upload/delete flows, and improves dev/runtime behavior including reliable thumbnail generation and media-safe response handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:01:02 +12:00

215 lines
5.2 KiB
Go

package gallery
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/image/draw"
)
const (
gridThumbMaxEdge = 320
heroThumbMaxEdge = 640
gridThumbQuality = 75
heroThumbQuality = 78
thumbDirName = "thumbs"
heroDirName = "hero"
)
// EnsureThumbnails creates grid and hero JPEG derivatives mirroring album folders.
func EnsureThumbnails(imagesDir string) error {
if err := os.MkdirAll(filepath.Join(imagesDir, thumbDirName), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(imagesDir, heroDirName), 0o755); err != nil {
return err
}
var failures []string
walkErr := filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == thumbDirName || d.Name() == heroDirName {
return filepath.SkipDir
}
return nil
}
if !isGalleryMedia(d.Name()) {
return nil
}
rel, err := filepath.Rel(imagesDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !safeRelPath(rel) {
return nil
}
if isVideo(d.Name()) {
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(videoPosterRel(rel)))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
return nil
}
if err := writeVideoPoster(path, thumbDst, gridThumbMaxEdge); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
}
return nil
}
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel))
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
return nil
}
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
return nil
}
if err := writeThumb(path, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
failures = append(failures, fmt.Sprintf("%s thumb: %v", rel, err))
}
if err := writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil {
failures = append(failures, fmt.Sprintf("%s hero: %v", rel, err))
}
return nil
})
if walkErr != nil {
return walkErr
}
if len(failures) > 0 {
return fmt.Errorf("%d file(s): %s", len(failures), strings.Join(failures, "; "))
}
return nil
}
// EnsurePriority generates thumbnails for one image before first page load.
func EnsurePriority(imagesDir, relPath string) error {
if !safeRelPath(relPath) || !isJPEG(filepath.Base(relPath)) {
return nil
}
src := filepath.Join(imagesDir, filepath.FromSlash(relPath))
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(relPath))
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(relPath))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
return err
}
if err := writeThumb(src, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil {
return err
}
return writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality)
}
func writeVideoPoster(src, dst string, maxEdge int) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if dstInfo, err := os.Stat(dst); err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) {
return nil
}
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
scale := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", maxEdge, maxEdge)
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error", "-y",
"-i", src,
"-vf", scale,
"-frames:v", "1",
"-q:v", "5",
dst,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
return err
}
return fmt.Errorf("%w: %s", err, msg)
}
return nil
}
func writeThumb(src, dst string, maxEdge, quality int) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if dstInfo, err := os.Stat(dst); err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) {
return nil
}
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
img, err := jpeg.Decode(f)
if err != nil {
return err
}
thumb := resizeThumb(img, maxEdge)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, thumb, &jpeg.Options{Quality: quality})
}
func resizeThumb(src image.Image, maxEdge int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return src
}
scale := float64(maxEdge) / float64(w)
if h > w {
scale = float64(maxEdge) / float64(h)
}
nw := int(float64(w) * scale)
nh := int(float64(h) * scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
if nw >= w && nh >= h {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}
func isJPEG(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".jpg" || ext == ".jpeg"
}