Albums: Regenerate share preview after one hour and after changes #3658

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-09-08 17:36:56 +02:00
parent 6463fe03be
commit a30cbb19b7
15 changed files with 415 additions and 63 deletions

View file

@ -160,6 +160,10 @@ func UpdateAlbum(router *gin.RouterGroup) {
return return
} }
// Flush album cover cache.
RemoveFromAlbumCoverCache(uid)
// Update client.
UpdateClientConfig() UpdateClientConfig()
// Update album YAML backup. // Update album YAML backup.

View file

@ -2,6 +2,8 @@ package api
import ( import (
"fmt" "fmt"
"os"
"path"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -9,6 +11,8 @@ import (
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/internal/ttl" "github.com/photoprism/photoprism/internal/ttl"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
) )
type ThumbCache struct { type ThumbCache struct {
@ -42,8 +46,13 @@ func RemoveFromFolderCache(rootName string) {
// RemoveFromAlbumCoverCache removes covers by album UID e.g. after adding or removing photos. // RemoveFromAlbumCoverCache removes covers by album UID e.g. after adding or removing photos.
func RemoveFromAlbumCoverCache(uid string) { func RemoveFromAlbumCoverCache(uid string) {
if !rnd.IsAlnum(uid) {
return
}
cache := get.CoverCache() cache := get.CoverCache()
// Flush album cover cache.
for thumbName := range thumb.Sizes { for thumbName := range thumb.Sizes {
cacheKey := CacheKey(albumCover, uid, string(thumbName)) cacheKey := CacheKey(albumCover, uid, string(thumbName))
@ -52,6 +61,12 @@ func RemoveFromAlbumCoverCache(uid string) {
log.Debugf("removed %s from cache", cacheKey) 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 { if err := query.UpdateAlbumCovers(); err != nil {
log.Error(err) log.Error(err)
} }

View file

@ -1,12 +1,11 @@
package api package api
import ( import (
"fmt"
"image" "image"
"image/color"
"net/http" "net/http"
"os" "os"
"path" "path"
"path/filepath"
"time" "time"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
@ -14,24 +13,26 @@ import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/frame"
"github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "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. // TODO: Proof of concept, needs refactoring.
func SharePreview(router *gin.RouterGroup) { func SharePreview(router *gin.RouterGroup) {
router.GET("/:token/:shared/preview", func(c *gin.Context) { router.GET("/:token/:shared/preview", func(c *gin.Context) {
conf := get.Config() conf := get.Config()
token := clean.Token(c.Param("token")) token := clean.Token(c.Param("token"))
shared := clean.Token(c.Param("shared")) shared := clean.UID(c.Param("shared"))
links := entity.FindLinks(token, shared) links := entity.FindLinks(token, shared)
if len(links) != 1 { if len(links) != 1 {
@ -48,12 +49,13 @@ func SharePreview(router *gin.RouterGroup) {
return return
} }
previewFilename := fmt.Sprintf("%s/%s.jpg", thumbPath, shared) previewFilename := filepath.Join(thumbPath, shared+fs.ExtJPEG)
yesterday := time.Now().Add(-24 * time.Hour)
expires := entity.TimeStamp().Add(-1 * time.Hour)
if info, err := os.Stat(previewFilename); err != nil { if info, err := os.Stat(previewFilename); err != nil {
log.Debugf("share: creating new preview for %s", clean.Log(shared)) 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)) log.Debugf("share: using cached preview for %s", clean.Log(shared))
c.File(previewFilename) c.File(previewFilename)
return return
@ -63,6 +65,14 @@ func SharePreview(router *gin.RouterGroup) {
return return
} }
a, err := query.AlbumByUID(shared)
if err != nil {
log.Error(err)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
var f form.SearchPhotos var f form.SearchPhotos
// Covers may only contain public content in shared albums. // Covers may only contain public content in shared albums.
@ -75,11 +85,11 @@ func SharePreview(router *gin.RouterGroup) {
f.Primary = true f.Primary = true
// Get first 12 album entries. // Get first 12 album entries.
f.Count = 12 f.Count = 6
f.Order = "relevance" f.Order = a.AlbumOrder
if err := f.ParseQueryString(); err != nil { if parseErr := f.ParseQueryString(); parseErr != nil {
log.Errorf("preview: %s", err) log.Errorf("preview: %s", parseErr)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return return
} }
@ -94,76 +104,44 @@ func SharePreview(router *gin.RouterGroup) {
if count == 0 { if count == 0 {
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) 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 return
} }
width := 908 size, _ := thumb.Sizes[thumb.Tile500]
height := 680
x := 0
y := 0
preview := imaging.New(width, height, color.NRGBA{255, 255, 255, 255}) images := make([]image.Image, 0, len(p))
size, _ := thumb.Sizes[thumb.Tile224]
for _, f := range p { // Get thumbnail images to create album preview.
fileName := photoprism.FileName(f.FileRoot, f.FileName) for _, file := range p {
fileName := photoprism.FileName(file.FileRoot, file.FileName)
if !fs.FileExists(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()) c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return 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 { if imgErr != nil {
log.Error(err) log.Warn(imgErr)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) continue
return
} }
src, err := imaging.Open(thumbnail) img, imgErr := imaging.Open(thumbnail)
if err != nil { if imgErr != nil {
log.Error(err) log.Warn(imgErr)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) continue
return
} }
preview = imaging.Paste(preview, src, image.Pt(x, y)) images = append(images, img)
x += 228
if x > width {
x = 0
y += 228
}
} }
// 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) err = imaging.Save(preview, previewFilename)
if err != nil { if err != nil {

24
internal/frame/angle.go Normal file
View file

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

View file

@ -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)
}
})
}

77
internal/frame/collage.go Normal file
View file

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

View file

@ -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)
})
}

29
internal/frame/frame.go Normal file
View file

@ -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"):
<https://docs.photoprism.app/license/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:
<https://www.photoprism.app/trademark>
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:
<https://docs.photoprism.app/developer-guide/>
*/
package frame
type Type string
const Polaroid = "polaroid"

18
internal/frame/image.go Normal file
View file

@ -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)))
}
}

View file

@ -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)
})
}

29
internal/frame/point.go Normal file
View file

@ -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)
}

View file

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

BIN
internal/frame/polaroid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View file

@ -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)
})
}

BIN
internal/frame/testdata/500x500.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB