diff --git a/internal/api/albums.go b/internal/api/albums.go index b107673e5..318c4ad2f 100644 --- a/internal/api/albums.go +++ b/internal/api/albums.go @@ -160,6 +160,10 @@ func UpdateAlbum(router *gin.RouterGroup) { return } + // Flush album cover cache. + RemoveFromAlbumCoverCache(uid) + + // Update client. UpdateClientConfig() // Update album YAML backup. diff --git a/internal/api/cache.go b/internal/api/cache.go index 0cdc01985..6b598932f 100644 --- a/internal/api/cache.go +++ b/internal/api/cache.go @@ -2,6 +2,8 @@ package api import ( "fmt" + "os" + "path" "github.com/gin-gonic/gin" @@ -9,6 +11,8 @@ import ( "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/ttl" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/rnd" ) type ThumbCache struct { @@ -42,8 +46,13 @@ func RemoveFromFolderCache(rootName string) { // RemoveFromAlbumCoverCache removes covers by album UID e.g. after adding or removing photos. func RemoveFromAlbumCoverCache(uid string) { + if !rnd.IsAlnum(uid) { + return + } + cache := get.CoverCache() + // Flush album cover cache. for thumbName := range thumb.Sizes { cacheKey := CacheKey(albumCover, uid, string(thumbName)) @@ -52,6 +61,12 @@ func RemoveFromAlbumCoverCache(uid string) { log.Debugf("removed %s from cache", cacheKey) } + // Delete share preview, if exists. + if sharePreview := path.Join(get.Config().ThumbCachePath(), "share", uid+fs.ExtJPEG); fs.FileExists(sharePreview) { + _ = os.Remove(sharePreview) + } + + // Update album cover images. if err := query.UpdateAlbumCovers(); err != nil { log.Error(err) } diff --git a/internal/api/share_preview.go b/internal/api/share_preview.go index f69765c19..40ff39ce9 100644 --- a/internal/api/share_preview.go +++ b/internal/api/share_preview.go @@ -1,12 +1,11 @@ package api import ( - "fmt" "image" - "image/color" "net/http" "os" "path" + "path/filepath" "time" "github.com/disintegration/imaging" @@ -14,24 +13,26 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/internal/frame" "github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" ) -// SharePreview returns a link share preview image. +// SharePreview returns a preview image for the given share uid if the token is valid. // -// GET /s/:token/:uid/preview +// GET /s/:token/:shared/preview // TODO: Proof of concept, needs refactoring. func SharePreview(router *gin.RouterGroup) { router.GET("/:token/:shared/preview", func(c *gin.Context) { conf := get.Config() token := clean.Token(c.Param("token")) - shared := clean.Token(c.Param("shared")) + shared := clean.UID(c.Param("shared")) links := entity.FindLinks(token, shared) if len(links) != 1 { @@ -48,12 +49,13 @@ func SharePreview(router *gin.RouterGroup) { return } - previewFilename := fmt.Sprintf("%s/%s.jpg", thumbPath, shared) - yesterday := time.Now().Add(-24 * time.Hour) + previewFilename := filepath.Join(thumbPath, shared+fs.ExtJPEG) + + expires := entity.TimeStamp().Add(-1 * time.Hour) if info, err := os.Stat(previewFilename); err != nil { log.Debugf("share: creating new preview for %s", clean.Log(shared)) - } else if info.ModTime().After(yesterday) { + } else if info.ModTime().After(expires) { log.Debugf("share: using cached preview for %s", clean.Log(shared)) c.File(previewFilename) return @@ -63,6 +65,14 @@ func SharePreview(router *gin.RouterGroup) { return } + a, err := query.AlbumByUID(shared) + + if err != nil { + log.Error(err) + c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) + return + } + var f form.SearchPhotos // Covers may only contain public content in shared albums. @@ -75,11 +85,11 @@ func SharePreview(router *gin.RouterGroup) { f.Primary = true // Get first 12 album entries. - f.Count = 12 - f.Order = "relevance" + f.Count = 6 + f.Order = a.AlbumOrder - if err := f.ParseQueryString(); err != nil { - log.Errorf("preview: %s", err) + if parseErr := f.ParseQueryString(); parseErr != nil { + log.Errorf("preview: %s", parseErr) c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) return } @@ -94,76 +104,44 @@ func SharePreview(router *gin.RouterGroup) { if count == 0 { c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) - return - } else if count < 12 { - f := p[0] - size, _ := thumb.Sizes[thumb.Fit720] - - fileName := photoprism.FileName(f.FileRoot, f.FileName) - - if !fs.FileExists(fileName) { - log.Errorf("share: file %s is missing (preview)", clean.Log(f.FileName)) - c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) - return - } - - thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbCachePath(), size.Width, size.Height, f.FileOrientation, size.Options...) - - if err != nil { - log.Error(err) - c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) - return - } - - c.File(thumbnail) - return } - width := 908 - height := 680 - x := 0 - y := 0 + size, _ := thumb.Sizes[thumb.Tile500] - preview := imaging.New(width, height, color.NRGBA{255, 255, 255, 255}) - size, _ := thumb.Sizes[thumb.Tile224] + images := make([]image.Image, 0, len(p)) - for _, f := range p { - fileName := photoprism.FileName(f.FileRoot, f.FileName) + // Get thumbnail images to create album preview. + for _, file := range p { + fileName := photoprism.FileName(file.FileRoot, file.FileName) if !fs.FileExists(fileName) { - log.Errorf("share: file %s is missing (preview)", clean.Log(f.FileName)) + log.Errorf("share: file %s is missing (preview)", clean.Log(file.FileName)) c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) return } - thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbCachePath(), size.Width, size.Height, f.FileOrientation, size.Options...) + thumbnail, imgErr := thumb.FromFile(fileName, file.FileHash, conf.ThumbCachePath(), size.Width, size.Height, file.FileOrientation, size.Options...) - if err != nil { - log.Error(err) - c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) - return + if imgErr != nil { + log.Warn(imgErr) + continue } - src, err := imaging.Open(thumbnail) + img, imgErr := imaging.Open(thumbnail) - if err != nil { - log.Error(err) - c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) - return + if imgErr != nil { + log.Warn(imgErr) + continue } - preview = imaging.Paste(preview, src, image.Pt(x, y)) - - x += 228 - - if x > width { - x = 0 - y += 228 - } + images = append(images, img) } - // Save the resulting image as JPEG. + // Create album preview from thumbnail images. + preview, err := frame.Collage(frame.Polaroid, images) + + // Save the resulting album preview as JPEG. err = imaging.Save(preview, previewFilename) if err != nil { diff --git a/internal/frame/angle.go b/internal/frame/angle.go new file mode 100644 index 000000000..83eebe6d8 --- /dev/null +++ b/internal/frame/angle.go @@ -0,0 +1,24 @@ +package frame + +import ( + "math/rand" +) + +// RandomAngle returns a random angle between -max and max. +func RandomAngle(max float64) float64 { + if max == 0 { + return 0 + } + + if max < 0 { + max = -1 * max + } + + if max > 180 { + max = 180 + } + + r := 2 * max + + return (rand.Float64() - 0.5) * r +} diff --git a/internal/frame/angle_test.go b/internal/frame/angle_test.go new file mode 100644 index 000000000..1d0bed0de --- /dev/null +++ b/internal/frame/angle_test.go @@ -0,0 +1,19 @@ +package frame + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRandomAngle(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + for i := 0; i < 50; i++ { + e := float64(i) + a := RandomAngle(e) + t.Logf("%f => %f", e, a) + assert.LessOrEqual(t, a, e) + assert.GreaterOrEqual(t, a, -1*e) + } + }) +} diff --git a/internal/frame/collage.go b/internal/frame/collage.go new file mode 100644 index 000000000..d80a3a63c --- /dev/null +++ b/internal/frame/collage.go @@ -0,0 +1,77 @@ +package frame + +import ( + "fmt" + "image" + "image/color" + + "github.com/disintegration/imaging" + + "github.com/photoprism/photoprism/pkg/clean" +) + +// CollageBackground is the default background for image collages. +var CollageBackground = color.NRGBA{32, 33, 36, 255} + +// Collage embeds images into a collage and returns the resulting image. +func Collage(t Type, images []image.Image) (collage image.Image, err error) { + width := 1600 + height := 900 + + collage = imaging.New(width, height, CollageBackground) + + if len(images) == 0 { + return collage, nil + } + + switch t { + case Polaroid: + collage, err = polaroidCollage(collage, images) + default: + return collage, fmt.Errorf("unknown collage type %s", clean.Log(string(t))) + } + + return collage, err +} + +// polaroidCollage embeds images into a Polaroid collage and returns the resulting image. +func polaroidCollage(collage image.Image, images []image.Image) (image.Image, error) { + n := len(images) - 1 + + if n == 1 { + if framed, err := polaroid(images[0], RandomAngle(20)); err != nil { + return collage, err + } else { + collage = imaging.Overlay(collage, framed, image.Pt(50, -80), 1) + } + + if framed, err := polaroid(images[1], RandomAngle(20)); err != nil { + return collage, err + } else { + collage = imaging.Overlay(collage, framed, image.Pt(500, -30), 1) + } + } else { + dl := 1500 / n + dr := 1350 / n + + for i := 0; i < n; i++ { + img := images[i+1] + + framed, err := polaroid(img, RandomAngle(30)) + + if err != nil { + return collage, err + } + + collage = imaging.Overlay(collage, framed, RandomPoint(850-i*dl, -150-((i%2)*50), 950-i*dr, 125-((i%2)*125)), 1) + } + + if framed, err := polaroid(images[0], RandomAngle(20)); err != nil { + return collage, err + } else { + collage = imaging.Overlay(collage, framed, image.Pt(275, -50), 1) + } + } + + return collage, nil +} diff --git a/internal/frame/collage_test.go b/internal/frame/collage_test.go new file mode 100644 index 000000000..f73b30b47 --- /dev/null +++ b/internal/frame/collage_test.go @@ -0,0 +1,62 @@ +package frame + +import ( + "image" + "os" + "testing" + + "github.com/disintegration/imaging" + "github.com/photoprism/photoprism/pkg/fs" + + "github.com/stretchr/testify/assert" +) + +func TestCollage(t *testing.T) { + t.Run("Polaroid", func(t *testing.T) { + var images []image.Image + + img, err := imaging.Open("testdata/500x500.jpg") + assert.NoError(t, err) + + for i := 0; i <= 5; i++ { + images = append(images, img) + } + + saveName := "testdata/test-polaroid-collage.jpg" + preview, err := Collage(Polaroid, images) + + assert.NoError(t, err) + + err = imaging.Save(preview, saveName) + + assert.NoError(t, err) + mimeType := fs.MimeType(saveName) + assert.Equal(t, fs.MimeTypeJPEG, mimeType) + + _ = os.Remove(saveName) + }) + + t.Run("Two", func(t *testing.T) { + var images []image.Image + + img, err := imaging.Open("testdata/500x500.jpg") + assert.NoError(t, err) + + for i := 0; i <= 1; i++ { + images = append(images, img) + } + + saveName := "testdata/test-polaroid-collage-two.jpg" + preview, err := Collage(Polaroid, images) + + assert.NoError(t, err) + + err = imaging.Save(preview, saveName) + + assert.NoError(t, err) + mimeType := fs.MimeType(saveName) + assert.Equal(t, fs.MimeTypeJPEG, mimeType) + + _ = os.Remove(saveName) + }) +} diff --git a/internal/frame/frame.go b/internal/frame/frame.go new file mode 100644 index 000000000..a30c8aabd --- /dev/null +++ b/internal/frame/frame.go @@ -0,0 +1,29 @@ +/* +Package frame provides helper functions to embed images into frames. + +Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved. + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an email to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + +*/ +package frame + +type Type string + +const Polaroid = "polaroid" diff --git a/internal/frame/image.go b/internal/frame/image.go new file mode 100644 index 000000000..a81ab7665 --- /dev/null +++ b/internal/frame/image.go @@ -0,0 +1,18 @@ +package frame + +import ( + "fmt" + "image" + + "github.com/photoprism/photoprism/pkg/clean" +) + +// Image embeds the specified image file into a frame and returns the resulting image. +func Image(t Type, img image.Image, rotate float64) (image.Image, error) { + switch t { + case Polaroid: + return polaroid(img, rotate) + default: + return img, fmt.Errorf("unknown collage type %s", clean.Log(string(t))) + } +} diff --git a/internal/frame/image_test.go b/internal/frame/image_test.go new file mode 100644 index 000000000..eaf736b52 --- /dev/null +++ b/internal/frame/image_test.go @@ -0,0 +1,32 @@ +package frame + +import ( + "os" + "testing" + + "github.com/disintegration/imaging" + "github.com/photoprism/photoprism/pkg/fs" + + "github.com/stretchr/testify/assert" +) + +func TestImage(t *testing.T) { + t.Run("Polaroid", func(t *testing.T) { + img, err := imaging.Open("testdata/500x500.jpg") + assert.NoError(t, err) + + saveName := "testdata/test-image.png" + + out, err := Image(Polaroid, img, RandomAngle(30)) + + assert.NoError(t, err) + + err = imaging.Save(out, saveName) + + assert.NoError(t, err) + mimeType := fs.MimeType(saveName) + assert.Equal(t, fs.MimeTypePNG, mimeType) + + _ = os.Remove(saveName) + }) +} diff --git a/internal/frame/point.go b/internal/frame/point.go new file mode 100644 index 000000000..5e649401a --- /dev/null +++ b/internal/frame/point.go @@ -0,0 +1,29 @@ +package frame + +import ( + "image" + "math/rand" +) + +// RandomPoint returns a random image position within the specified range. +func RandomPoint(xMin, yMin, xMax, yMax int) image.Point { + if xMin == 0 && yMin == 0 && xMax == 0 && yMax == 0 { + return image.Pt(0, 0) + } + + if xMin > xMax { + xMin = xMax + } + + xDiff := float64(xMax - xMin) + x := xMin + int(rand.Float64()*xDiff) + + if yMin > yMax { + yMin = yMax + } + + yDiff := float64(yMax - yMin) + y := yMin + int(rand.Float64()*yDiff) + + return image.Pt(x, y) +} diff --git a/internal/frame/polaroid.go b/internal/frame/polaroid.go new file mode 100644 index 000000000..52b55a2ee --- /dev/null +++ b/internal/frame/polaroid.go @@ -0,0 +1,33 @@ +package frame + +import ( + "bytes" + _ "embed" + "image" + "image/color" + + "github.com/disintegration/imaging" +) + +//go:embed polaroid.png +var polaroidPng []byte + +// Polaroid embeds the specified image file into a Polaroid frame and returns the resulting image. +func polaroid(img image.Image, rotate float64) (image.Image, error) { + // Create image frame. + frm, err := imaging.Decode(bytes.NewReader(polaroidPng)) + + if err != nil { + return nil, err + } + + // Paste image into frame. + out := imaging.Paste(frm, img, image.Pt(200, 152)) + + // Rotate image before returning it? + if rotate != 0.0 { + out = imaging.Rotate(out, rotate, color.NRGBA{255, 255, 255, 0}) + } + + return out, nil +} diff --git a/internal/frame/polaroid.png b/internal/frame/polaroid.png new file mode 100644 index 000000000..b613fa0ff Binary files /dev/null and b/internal/frame/polaroid.png differ diff --git a/internal/frame/polaroid_test.go b/internal/frame/polaroid_test.go new file mode 100644 index 000000000..a60a55e39 --- /dev/null +++ b/internal/frame/polaroid_test.go @@ -0,0 +1,32 @@ +package frame + +import ( + "os" + "testing" + + "github.com/disintegration/imaging" + "github.com/photoprism/photoprism/pkg/fs" + + "github.com/stretchr/testify/assert" +) + +func TestPolaroid(t *testing.T) { + t.Run("RandomAngle", func(t *testing.T) { + img, err := imaging.Open("testdata/500x500.jpg") + assert.NoError(t, err) + + saveName := "testdata/test-polaroid.png" + + out, err := polaroid(img, RandomAngle(30)) + + assert.NoError(t, err) + + err = imaging.Save(out, saveName) + + assert.NoError(t, err) + mimeType := fs.MimeType(saveName) + assert.Equal(t, fs.MimeTypePNG, mimeType) + + _ = os.Remove(saveName) + }) +} diff --git a/internal/frame/testdata/500x500.jpg b/internal/frame/testdata/500x500.jpg new file mode 100644 index 000000000..0c4f4fb49 Binary files /dev/null and b/internal/frame/testdata/500x500.jpg differ