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>
215 lines
5.2 KiB
Go
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"
|
|
}
|