mirror of
https://github.com/Alexander-D-Karpov/photodock.git
synced 2026-03-16 22:06:35 +03:00
285 lines
6.8 KiB
Go
285 lines
6.8 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
_ "image/jpeg"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/Alexander-D-Karpov/photodock/internal/models"
|
|
"github.com/disintegration/imaging"
|
|
)
|
|
|
|
type ThumbnailService struct {
|
|
mediaRoot string
|
|
cacheDir string
|
|
existsCache sync.Map
|
|
}
|
|
|
|
func NewThumbnailService(mediaRoot, cacheDir string) *ThumbnailService {
|
|
_ = os.MkdirAll(filepath.Join(cacheDir, "small"), 0755)
|
|
_ = os.MkdirAll(filepath.Join(cacheDir, "medium"), 0755)
|
|
_ = os.MkdirAll(filepath.Join(cacheDir, "large"), 0755)
|
|
_ = os.MkdirAll(filepath.Join(cacheDir, "placeholder"), 0755)
|
|
return &ThumbnailService{
|
|
mediaRoot: mediaRoot,
|
|
cacheDir: cacheDir,
|
|
}
|
|
}
|
|
|
|
func (s *ThumbnailService) GetThumbnailPathByID(photoID int, photoPath, size string) (string, error) {
|
|
ext := ".jpg"
|
|
if strings.HasSuffix(strings.ToLower(photoPath), ".png") {
|
|
ext = ".png"
|
|
}
|
|
thumbPath := filepath.Join(s.cacheDir, size, fmt.Sprintf("%d%s", photoID, ext))
|
|
|
|
if _, ok := s.existsCache.Load(thumbPath); ok {
|
|
return thumbPath, nil
|
|
}
|
|
|
|
if _, err := os.Stat(thumbPath); err == nil {
|
|
s.existsCache.Store(thumbPath, struct{}{})
|
|
return thumbPath, nil
|
|
}
|
|
|
|
srcPath := filepath.Join(s.mediaRoot, photoPath)
|
|
if err := s.generateThumbnail(srcPath, thumbPath, size); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
s.existsCache.Store(thumbPath, struct{}{})
|
|
return thumbPath, nil
|
|
}
|
|
|
|
func (s *ThumbnailService) generateThumbnail(srcPath, dstPath, size string) error {
|
|
img, err := imaging.Open(srcPath, imaging.AutoOrientation(true))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var width int
|
|
var quality int
|
|
switch size {
|
|
case "small":
|
|
width = 300
|
|
quality = 80
|
|
case "medium":
|
|
width = 800
|
|
quality = 85
|
|
case "large":
|
|
width = 1440
|
|
quality = 85
|
|
default:
|
|
width = 300
|
|
quality = 80
|
|
}
|
|
|
|
thumb := imaging.Resize(img, width, 0, imaging.Lanczos)
|
|
|
|
if strings.HasSuffix(strings.ToLower(dstPath), ".png") {
|
|
return imaging.Save(thumb, dstPath)
|
|
}
|
|
return imaging.Save(thumb, dstPath, imaging.JPEGQuality(quality))
|
|
}
|
|
|
|
func (s *ThumbnailService) GenerateBlurhash(photoPath string) (string, error) {
|
|
srcPath := filepath.Join(s.mediaRoot, photoPath)
|
|
img, err := imaging.Open(srcPath, imaging.AutoOrientation(true))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tiny := imaging.Resize(img, 4, 4, imaging.Box)
|
|
bounds := tiny.Bounds()
|
|
var pixels []byte
|
|
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
r, g, b, _ := tiny.At(x, y).RGBA()
|
|
pixels = append(pixels, byte(r>>8), byte(g>>8), byte(b>>8))
|
|
}
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(pixels), nil
|
|
}
|
|
|
|
func (s *ThumbnailService) GetImageDimensions(photoPath string) (int, int, error) {
|
|
srcPath := filepath.Join(s.mediaRoot, photoPath)
|
|
f, err := os.Open(srcPath)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
config, _, err := image.DecodeConfig(f)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
return config.Width, config.Height, nil
|
|
}
|
|
|
|
func (s *ThumbnailService) GeneratePlaceholder(blurhash string, width, height int) (image.Image, error) {
|
|
data, err := base64.StdEncoding.DecodeString(blurhash)
|
|
if err != nil || len(data) < 48 {
|
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
gray := color.RGBA{128, 128, 128, 255}
|
|
for y := 0; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
img.Set(x, y, gray)
|
|
}
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
tiny := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
for y := 0; y < 4; y++ {
|
|
for x := 0; x < 4; x++ {
|
|
idx := (y*4 + x) * 3
|
|
tiny.Set(x, y, color.RGBA{data[idx], data[idx+1], data[idx+2], 255})
|
|
}
|
|
}
|
|
|
|
return imaging.Resize(tiny, width, height, imaging.Linear), nil
|
|
}
|
|
|
|
func (s *ThumbnailService) GetPlaceholderPathByID(photoID int, blurhash string) (string, error) {
|
|
placeholderPath := filepath.Join(s.cacheDir, "placeholder", fmt.Sprintf("%d.png", photoID))
|
|
|
|
if _, ok := s.existsCache.Load(placeholderPath); ok {
|
|
return placeholderPath, nil
|
|
}
|
|
|
|
if _, err := os.Stat(placeholderPath); err == nil {
|
|
s.existsCache.Store(placeholderPath, struct{}{})
|
|
return placeholderPath, nil
|
|
}
|
|
|
|
img, err := s.GeneratePlaceholder(blurhash, 32, 32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
f, err := os.Create(placeholderPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
if err := png.Encode(f, img); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
s.existsCache.Store(placeholderPath, struct{}{})
|
|
return placeholderPath, nil
|
|
}
|
|
|
|
func (s *ThumbnailService) DeleteThumbnailsByID(photoID int) error {
|
|
for _, size := range []string{"small", "medium", "large", "placeholder"} {
|
|
for _, ext := range []string{".jpg", ".png"} {
|
|
path := filepath.Join(s.cacheDir, size, fmt.Sprintf("%d%s", photoID, ext))
|
|
_ = os.Remove(path)
|
|
s.existsCache.Delete(path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ThumbnailService) PrewarmCache() {
|
|
for _, size := range []string{"small", "medium", "large", "placeholder"} {
|
|
dir := filepath.Join(s.cacheDir, size)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
s.existsCache.Store(filepath.Join(dir, entry.Name()), struct{}{})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *ThumbnailService) CacheDir() string {
|
|
return s.cacheDir
|
|
}
|
|
|
|
func (s *ThumbnailService) AnalyzeColors(photoPath string) (*models.ColorInfo, error) {
|
|
srcPath := filepath.Join(s.mediaRoot, photoPath)
|
|
img, err := imaging.Open(srcPath, imaging.AutoOrientation(true))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
w, h := bounds.Dx(), bounds.Dy()
|
|
|
|
info := &models.ColorInfo{
|
|
IsLandscape: w > h,
|
|
AspectRatio: float64(w) / float64(h),
|
|
MegaPixels: float64(w*h) / 1_000_000,
|
|
}
|
|
|
|
tiny := imaging.Resize(img, 16, 16, imaging.Box)
|
|
tb := tiny.Bounds()
|
|
|
|
var totalR, totalG, totalB float64
|
|
var totalBright float64
|
|
colorCounts := make(map[string]int)
|
|
count := 0
|
|
|
|
for y := tb.Min.Y; y < tb.Max.Y; y++ {
|
|
for x := tb.Min.X; x < tb.Max.X; x++ {
|
|
r, g, b, _ := tiny.At(x, y).RGBA()
|
|
fr, fg, fb := float64(r>>8), float64(g>>8), float64(b>>8)
|
|
totalR += fr
|
|
totalG += fg
|
|
totalB += fb
|
|
totalBright += (0.299*fr + 0.587*fg + 0.114*fb) / 255.0
|
|
|
|
qr := int(fr/32) * 32
|
|
qg := int(fg/32) * 32
|
|
qb := int(fb/32) * 32
|
|
key := fmt.Sprintf("#%02x%02x%02x", qr, qg, qb)
|
|
colorCounts[key]++
|
|
count++
|
|
}
|
|
}
|
|
|
|
if count > 0 {
|
|
info.AvgBrightness = totalBright / float64(count)
|
|
info.DominantColor = fmt.Sprintf("#%02x%02x%02x",
|
|
int(totalR/float64(count)),
|
|
int(totalG/float64(count)),
|
|
int(totalB/float64(count)))
|
|
}
|
|
|
|
type colorEntry struct {
|
|
hex string
|
|
count int
|
|
}
|
|
var entries []colorEntry
|
|
for k, v := range colorCounts {
|
|
entries = append(entries, colorEntry{k, v})
|
|
}
|
|
for i := 0; i < len(entries); i++ {
|
|
for j := i + 1; j < len(entries); j++ {
|
|
if entries[j].count > entries[i].count {
|
|
entries[i], entries[j] = entries[j], entries[i]
|
|
}
|
|
}
|
|
}
|
|
for i := 0; i < len(entries) && i < 5; i++ {
|
|
info.Palette = append(info.Palette, entries[i].hex)
|
|
}
|
|
|
|
return info, nil
|
|
}
|