| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- package images
- import (
- "archive/zip"
- "bytes"
- "context"
- "fmt"
- "image"
- "image/jpeg"
- _ "image/jpeg"
- _ "image/png"
- "io"
- "mime"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "golang.org/x/image/draw"
- _ "golang.org/x/image/webp"
- "viewer/internal/albums"
- "viewer/internal/cache"
- "viewer/internal/storage"
- )
- type Service struct {
- albums *albums.Service
- store *storage.S3Store
- cache *cache.DiskCache
- zipCache string
- }
- type ImageResult struct {
- Bytes []byte
- ContentType string
- }
- func NewService(albumsService *albums.Service, store *storage.S3Store, cacheDir string, zipCacheDir string) (*Service, error) {
- dc, err := cache.NewDiskCache(cacheDir)
- if err != nil {
- return nil, err
- }
- if err := os.MkdirAll(zipCacheDir, 0o755); err != nil {
- return nil, fmt.Errorf("create zip cache dir: %w", err)
- }
- return &Service{
- albums: albumsService,
- store: store,
- cache: dc,
- zipCache: zipCacheDir,
- }, nil
- }
- func sourceKey(albumID string) string {
- return fmt.Sprintf("albums/%s/source.zip", albumID)
- }
- func (s *Service) GetImage(ctx context.Context, albumID string, idx int, mode string, wallWidth int) (ImageResult, error) {
- album, err := s.albums.GetAlbum(ctx, albumID)
- if err != nil {
- return ImageResult{}, err
- }
- if idx < 0 || idx >= len(album.Photos) {
- return ImageResult{}, fmt.Errorf("photo index out of range")
- }
- photo := album.Photos[idx]
- data, contentType, err := s.readEntryBytes(ctx, albumID, photo.Name)
- if err != nil {
- return ImageResult{}, err
- }
- if mode != "wall" {
- return ImageResult{Bytes: data, ContentType: contentType}, nil
- }
- if wallWidth <= 0 {
- wallWidth = 480
- }
- if wallWidth < 64 {
- wallWidth = 64
- }
- if wallWidth > 2048 {
- wallWidth = 2048
- }
- cacheKey := strings.Join([]string{albumID, strconv.Itoa(idx), "wall", strconv.Itoa(wallWidth)}, ":")
- if cached, ok := s.cache.Get(cacheKey); ok {
- return ImageResult{Bytes: cached, ContentType: "image/jpeg"}, nil
- }
- img, _, err := image.Decode(bytes.NewReader(data))
- if err != nil {
- return ImageResult{}, fmt.Errorf("decode image: %w", err)
- }
- b := img.Bounds()
- origW := b.Dx()
- origH := b.Dy()
- if origW <= 0 || origH <= 0 {
- return ImageResult{}, fmt.Errorf("invalid image dimensions")
- }
- targetH := int(float64(origH) * float64(wallWidth) / float64(origW))
- if targetH < 1 {
- targetH = 1
- }
- dst := image.NewRGBA(image.Rect(0, 0, wallWidth, targetH))
- draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
- var out bytes.Buffer
- if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 82}); err != nil {
- return ImageResult{}, fmt.Errorf("encode jpeg: %w", err)
- }
- encoded := out.Bytes()
- _ = s.cache.Set(cacheKey, encoded)
- return ImageResult{Bytes: encoded, ContentType: "image/jpeg"}, nil
- }
- func (s *Service) localZipPath(albumID string) string {
- return filepath.Join(s.zipCache, albumID+".zip")
- }
- func (s *Service) ensureZipCached(ctx context.Context, albumID string) (string, error) {
- zipPath := s.localZipPath(albumID)
- if _, err := os.Stat(zipPath); err == nil {
- return zipPath, nil
- }
- body, _, err := s.store.GetObject(ctx, sourceKey(albumID))
- if err != nil {
- return "", err
- }
- defer body.Close()
- tmp, err := os.CreateTemp(s.zipCache, albumID+"-*.zip")
- if err != nil {
- return "", fmt.Errorf("create temp zip: %w", err)
- }
- tmpPath := tmp.Name()
- if _, err := io.Copy(tmp, body); err != nil {
- tmp.Close()
- os.Remove(tmpPath)
- return "", fmt.Errorf("cache zip: %w", err)
- }
- if err := tmp.Close(); err != nil {
- os.Remove(tmpPath)
- return "", err
- }
- if err := os.Rename(tmpPath, zipPath); err != nil {
- if _, stErr := os.Stat(zipPath); stErr == nil {
- _ = os.Remove(tmpPath)
- return zipPath, nil
- }
- return "", fmt.Errorf("move cached zip: %w", err)
- }
- return zipPath, nil
- }
- func (s *Service) readEntryBytes(ctx context.Context, albumID string, entryName string) ([]byte, string, error) {
- zipPath, err := s.ensureZipCached(ctx, albumID)
- if err != nil {
- return nil, "", err
- }
- r, err := zip.OpenReader(zipPath)
- if err != nil {
- return nil, "", fmt.Errorf("open zip cache: %w", err)
- }
- defer r.Close()
- for _, f := range r.File {
- if f.Name != entryName {
- continue
- }
- rc, err := f.Open()
- if err != nil {
- return nil, "", fmt.Errorf("open zip entry: %w", err)
- }
- data, err := io.ReadAll(rc)
- rc.Close()
- if err != nil {
- return nil, "", fmt.Errorf("read zip entry: %w", err)
- }
- ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(f.Name)))
- if ct == "" {
- ct = "application/octet-stream"
- }
- return data, ct, nil
- }
- return nil, "", fmt.Errorf("image entry not found")
- }
|