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" }