People: Improve thumb size config and flag descriptions #22

This commit is contained in:
Michael Mayer 2021-09-05 13:48:53 +02:00
parent b2a30d8091
commit b9d1c7afb3
25 changed files with 334 additions and 269 deletions

View file

@ -57,8 +57,8 @@ func RemoveFromFolderCache(rootName string) {
func RemoveFromAlbumCoverCache(uid string) {
cache := service.CoverCache()
for typeName := range thumb.Sizes {
cacheKey := CacheKey(albumCover, uid, typeName)
for thumbName := range thumb.Sizes {
cacheKey := CacheKey(albumCover, uid, string(thumbName))
cache.Delete(cacheKey)

View file

@ -20,14 +20,16 @@ const (
labelCover = "label-cover"
)
// GET /api/v1/albums/:uid/t/:token/:type
// AlbumCover returns an album cover image.
//
// GET /api/v1/albums/:uid/t/:token/:size
//
// Parameters:
// uid: string album uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
// size: string thumb type, see photoprism.ThumbnailTypes
func AlbumCover(router *gin.RouterGroup) {
router.GET("/albums/:uid/t/:token/:type", func(c *gin.Context) {
router.GET("/albums/:uid/t/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", albumIconSvg)
return
@ -35,19 +37,19 @@ func AlbumCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
thumbName := thumb.Name(c.Param("size"))
uid := c.Param("uid")
size, ok := thumb.Sizes[typeName]
size, ok := thumb.Sizes[thumbName]
if !ok {
log.Errorf("%s: invalid type %s", albumCover, typeName)
log.Errorf("%s: invalid size %s", albumCover, thumbName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
cache := service.CoverCache()
cacheKey := CacheKey(albumCover, uid, typeName)
cacheKey := CacheKey(albumCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
@ -130,14 +132,16 @@ func AlbumCover(router *gin.RouterGroup) {
})
}
// GET /api/v1/labels/:uid/t/:token/:type
// LabelCover returns a label cover image.
//
// GET /api/v1/labels/:uid/t/:token/:size
//
// Parameters:
// uid: string label uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
// size: string thumb type, see photoprism.ThumbnailTypes
func LabelCover(router *gin.RouterGroup) {
router.GET("/labels/:uid/t/:token/:type", func(c *gin.Context) {
router.GET("/labels/:uid/t/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", labelIconSvg)
return
@ -145,19 +149,19 @@ func LabelCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
thumbName := thumb.Name(c.Param("size"))
uid := c.Param("uid")
size, ok := thumb.Sizes[typeName]
size, ok := thumb.Sizes[thumbName]
if !ok {
log.Errorf("%s: invalid type %s", labelCover, txt.Quote(typeName))
log.Errorf("%s: invalid size %s", labelCover, thumbName)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
cache := service.CoverCache()
cacheKey := CacheKey(labelCover, uid, typeName)
cacheKey := CacheKey(labelCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))

View file

@ -18,14 +18,16 @@ const (
folderCover = "folder-cover"
)
// GET /api/v1/folders/t/:hash/:token/:type
// FolderCover returns a folder cover image.
//
// GET /api/v1/folders/t/:hash/:token/:size
//
// Parameters:
// uid: string folder uid
// token: string url security token, see config
// type: string thumb type, see thumb.Sizes
func GetFolderCover(router *gin.RouterGroup) {
router.GET("/folders/t/:uid/:token/:type", func(c *gin.Context) {
// size: string thumb type, see thumb.Sizes
func FolderCover(router *gin.RouterGroup) {
router.GET("/folders/t/:uid/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", folderIconSvg)
return
@ -34,21 +36,21 @@ func GetFolderCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
uid := c.Param("uid")
typeName := c.Param("type")
thumbName := thumb.Name(c.Param("size"))
download := c.Query("download") != ""
size, ok := thumb.Sizes[typeName]
size, ok := thumb.Sizes[thumbName]
if !ok {
log.Errorf("folder: invalid thumb type %s", txt.Quote(typeName))
log.Errorf("%s: invalid size %s", folderCover, thumbName)
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}
if size.Uncached() && !conf.ThumbUncached() {
typeName, size = thumb.Find(conf.ThumbSizePrecached())
thumbName, size = thumb.Find(conf.ThumbSizePrecached())
if typeName == "" {
if thumbName == "" {
log.Errorf("folder: invalid thumb size %d", conf.ThumbSizePrecached())
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
@ -56,7 +58,7 @@ func GetFolderCover(router *gin.RouterGroup) {
}
cache := service.CoverCache()
cacheKey := CacheKey(folderCover, uid, typeName)
cacheKey := CacheKey(folderCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))

View file

@ -10,28 +10,28 @@ import (
func TestGetFolderCover(t *testing.T) {
t.Run("no cover yet", func(t *testing.T) {
app, router, conf := NewApiTest()
GetFolderCover(router)
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid thumb type", func(t *testing.T) {
app, router, conf := NewApiTest()
GetFolderCover(router)
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
GetFolderCover(router)
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/xxx/tile_500")
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("could not find original", func(t *testing.T) {
app, router, conf := NewApiTest()
GetFolderCover(router)
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn2f87f02oi/"+conf.PreviewToken()+"/fit_7680")
assert.Equal(t, http.StatusOK, r.Code)
})

View file

@ -18,14 +18,14 @@ import (
// GetThumb returns a thumbnail image matching the hash and type.
//
// GET /api/v1/t/:hash/:token/:type
// GET /api/v1/t/:hash/:token/:size
//
// Parameters:
// hash: string sha1 file hash
// token: string url security token, see config
// type: string thumb type, see thumb.Sizes
// size: string thumb type, see thumb.Sizes
func GetThumb(router *gin.RouterGroup) {
router.GET("/t/:hash/:token/:type", func(c *gin.Context) {
router.GET("/t/:hash/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
@ -34,21 +34,21 @@ func GetThumb(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
fileHash := c.Param("hash")
typeName := c.Param("type")
thumbName := thumb.Name(c.Param("size"))
download := c.Query("download") != ""
size, ok := thumb.Sizes[typeName]
size, ok := thumb.Sizes[thumbName]
if !ok {
log.Errorf("thumbs: invalid type %s", txt.Quote(typeName))
log.Errorf("thumbs: invalid size %s", thumbName)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if size.Uncached() && !conf.ThumbUncached() {
typeName, size = thumb.Find(conf.ThumbSizePrecached())
thumbName, size = thumb.Find(conf.ThumbSizePrecached())
if typeName == "" {
if thumbName == "" {
log.Errorf("thumbs: invalid size %d", conf.ThumbSizePrecached())
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
@ -56,7 +56,7 @@ func GetThumb(router *gin.RouterGroup) {
}
cache := service.ThumbCache()
cacheKey := CacheKey("thumbs", fileHash, typeName)
cacheKey := CacheKey("thumbs", fileHash, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
@ -173,15 +173,15 @@ func GetThumb(router *gin.RouterGroup) {
// GetThumbCrop returns a cropped thumbnail image matching the hash and type.
//
// GET /api/v1/t/:hash/:token/:type/:area
// GET /api/v1/t/:hash/:token/:size/:area
//
// Parameters:
// hash: string sha1 file hash
// token: string url security token, see config
// type: string thumb type, see thumb.Sizes
// size: string thumb type, see thumb.Sizes
// area: string image area identifier, e.g. 022004010015
func GetThumbCrop(router *gin.RouterGroup) {
router.GET("/t/:hash/:token/:type/:area", func(c *gin.Context) {
router.GET("/t/:hash/:token/:size/:area", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
@ -189,18 +189,18 @@ func GetThumbCrop(router *gin.RouterGroup) {
conf := service.Config()
fileHash := c.Param("hash")
typeName := c.Param("type")
thumbName := thumb.Name(c.Param("size"))
cropArea := c.Param("area")
download := c.Query("download") != ""
size, ok := thumb.Sizes[typeName]
size, ok := thumb.Sizes[thumbName]
if !ok || len(size.Options) < 1 {
log.Errorf("thumbs: invalid type %s", txt.Quote(typeName))
log.Errorf("thumbs: invalid size %s", thumbName)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
} else if size.Options[0] != thumb.ResampleCrop {
log.Errorf("thumbs: invalid crop %s", txt.Quote(typeName))
log.Errorf("thumbs: invalid size %s", thumbName)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
@ -220,7 +220,7 @@ func GetThumbCrop(router *gin.RouterGroup) {
AddThumbCacheHeader(c)
if download {
c.FileAttachment(fileName, typeName+fs.JpegExt)
c.FileAttachment(fileName, thumbName.Jpeg())
} else {
c.File(fileName)
}

View file

@ -21,6 +21,8 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// SharePreview returns a link share preview image.
//
// GET /s/:token/:uid/preview
// TODO: Proof of concept, needs refactoring.
func SharePreview(router *gin.RouterGroup) {
@ -88,7 +90,7 @@ func SharePreview(router *gin.RouterGroup) {
return
} else if count < 12 {
f := p[0]
size, _ := thumb.Sizes["fit_720"]
size, _ := thumb.Sizes[thumb.Fit720]
fileName := photoprism.FileName(f.FileRoot, f.FileName)
@ -117,7 +119,7 @@ func SharePreview(router *gin.RouterGroup) {
y := 0
preview := imaging.New(width, height, color.NRGBA{255, 255, 255, 255})
size, _ := thumb.Sizes["tile_224"]
size, _ := thumb.Sizes[thumb.Tile224]
for _, f := range p {
fileName := photoprism.FileName(f.FileRoot, f.FileName)

View file

@ -13,7 +13,7 @@ import (
var ResampleCommand = cli.Command{
Name: "resample",
Aliases: []string{"thumbs"},
Usage: "Pre-caches thumbnails to reduce memory and cpu usage",
Usage: "Pre-caches thumbnail images for improved performance",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",

View file

@ -77,7 +77,7 @@ func init() {
t := thumb.Sizes[name]
if t.Public {
Thumbs = append(Thumbs, ThumbSize{Size: name, Use: t.Use, Width: t.Width, Height: t.Height})
Thumbs = append(Thumbs, ThumbSize{Size: string(name), Use: t.Use, Width: t.Width, Height: t.Height})
}
}
}

View file

@ -380,25 +380,25 @@ var GlobalFlags = []cli.Flag{
EnvVar: "PHOTOPRISM_PREVIEW_TOKEN",
},
cli.StringFlag{
Name: "thumb-filter, f",
Usage: "downscaling filter `NAME` (best to worst: blackman, lanczos, cubic, linear)",
Name: "thumb-filter",
Usage: "image downscaling `FILTER` (best to worst: blackman, lanczos, cubic, linear)",
Value: "lanczos",
EnvVar: "PHOTOPRISM_THUMB_FILTER",
},
cli.IntFlag{
Name: "thumb-size, s",
Usage: "pre-cached thumbnail size in `PIXELS` (720-7680)",
Usage: "max pre-cached thumbnail size in `PIXELS` (720-7680)",
Value: 2048,
EnvVar: "PHOTOPRISM_THUMB_SIZE",
},
cli.BoolFlag{
Name: "thumb-uncached, u",
Usage: "enable dynamic thumbnail rendering (high memory and cpu usage)",
Usage: "enable on-demand thumbnail generation (high memory and cpu usage)",
EnvVar: "PHOTOPRISM_THUMB_UNCACHED",
},
cli.IntFlag{
Name: "thumb-size-uncached, x",
Usage: "dynamic rendering size limit in `PIXELS` (720-7680)",
Usage: "on-demand thumbnail generation size limit in `PIXELS` (720-7680)",
Value: 7680,
EnvVar: "PHOTOPRISM_THUMB_SIZE_UNCACHED",
},

View file

@ -6,6 +6,8 @@ import (
"math"
"github.com/lucasb-eyer/go-colorful"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/colors"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -16,7 +18,7 @@ func (m *MediaFile) Colors(thumbPath string) (perception colors.ColorPerception,
return perception, fmt.Errorf("%s is not a jpeg", txt.Quote(m.BaseName()))
}
img, err := m.Resample(thumbPath, "colors")
img, err := m.Resample(thumbPath, thumb.Colors)
if err != nil {
log.Debugf("colors: %s in %s (resample)", err, txt.Quote(m.BaseName()))

View file

@ -5,6 +5,8 @@ import (
"time"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -12,18 +14,18 @@ import (
func (ind *Index) classifyImage(jpeg *MediaFile) (results classify.Labels) {
start := time.Now()
var thumbs []string
var sizes []thumb.Name
if jpeg.AspectRatio() == 1 {
thumbs = []string{"tile_224"}
sizes = []thumb.Name{thumb.Tile224}
} else {
thumbs = []string{"tile_224", "left_224", "right_224"}
sizes = []thumb.Name{thumb.Tile224, thumb.Left224, thumb.Right224}
}
var labels classify.Labels
for _, thumb := range thumbs {
filename, err := jpeg.Thumbnail(Config().ThumbPath(), thumb)
for _, size := range sizes {
filename, err := jpeg.Thumbnail(Config().ThumbPath(), size)
if err != nil {
log.Debugf("%s in %s", err, txt.Quote(jpeg.BaseName()))

View file

@ -4,6 +4,8 @@ import (
"time"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -14,15 +16,15 @@ func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces {
}
var minSize int
var thumbSize string
var thumbSize thumb.Name
// Select best thumbnail depending on configured size.
if Config().ThumbSizePrecached() < 1280 {
minSize = 30
thumbSize = "fit_720"
thumbSize = thumb.Fit720
} else {
minSize = 40
thumbSize = "fit_1280"
thumbSize = thumb.Fit1280
}
thumbName, err := jpeg.Thumbnail(Config().ThumbPath(), thumbSize)

View file

@ -15,6 +15,8 @@ import (
"github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -751,7 +753,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
// NSFW returns true if media file might be offensive and detection is enabled.
func (ind *Index) NSFW(jpeg *MediaFile) bool {
filename, err := jpeg.Thumbnail(Config().ThumbPath(), "fit_720")
filename, err := jpeg.Thumbnail(Config().ThumbPath(), thumb.Fit720)
if err != nil {
log.Error(err)

View file

@ -16,6 +16,7 @@ import (
"github.com/disintegration/imaging"
"github.com/djherbis/times"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/internal/thumb"
@ -887,12 +888,12 @@ func (m *MediaFile) Orientation() int {
}
// Thumbnail returns a thumbnail filename.
func (m *MediaFile) Thumbnail(path string, typeName string) (filename string, err error) {
size, ok := thumb.Sizes[typeName]
func (m *MediaFile) Thumbnail(path string, sizeName thumb.Name) (filename string, err error) {
size, ok := thumb.Sizes[sizeName]
if !ok {
log.Errorf("media: invalid type %s", typeName)
return "", fmt.Errorf("media: invalid type %s", typeName)
log.Errorf("media: invalid type %s", sizeName)
return "", fmt.Errorf("media: invalid type %s", sizeName)
}
thumbnail, err := thumb.FromFile(m.FileName(), m.Hash(), path, size.Width, size.Height, m.Orientation(), size.Options...)
@ -907,8 +908,8 @@ func (m *MediaFile) Thumbnail(path string, typeName string) (filename string, er
}
// Resample returns a resampled image of the file.
func (m *MediaFile) Resample(path string, typeName string) (img image.Image, err error) {
filename, err := m.Thumbnail(path, typeName)
func (m *MediaFile) Resample(path string, sizeName thumb.Name) (img image.Image, err error) {
filename, err := m.Thumbnail(path, sizeName)
if err != nil {
return nil, err
@ -937,7 +938,7 @@ func (m *MediaFile) ResampleDefault(thumbPath string, force bool) (err error) {
var originalImg image.Image
var sourceImg image.Image
var sourceImgType string
var sourceName thumb.Name
for _, name := range thumb.DefaultSizes {
size := thumb.Sizes[name]
@ -948,7 +949,7 @@ func (m *MediaFile) ResampleDefault(thumbPath string, force bool) (err error) {
}
if fileName, err := thumb.FileName(hash, thumbPath, size.Width, size.Height, size.Options...); err != nil {
log.Errorf("media: failed creating %s (%s)", txt.Quote(name), err)
log.Errorf("media: failed creating %s (%s)", txt.Quote(string(name)), err)
return err
} else {
@ -970,18 +971,18 @@ func (m *MediaFile) ResampleDefault(thumbPath string, force bool) (err error) {
}
if size.Source != "" {
if size.Source == sourceImgType && sourceImg != nil {
if size.Source == sourceName && sourceImg != nil {
_, err = thumb.Create(sourceImg, fileName, size.Width, size.Height, size.Options...)
} else {
_, err = thumb.Create(originalImg, fileName, size.Width, size.Height, size.Options...)
}
} else {
sourceImg, err = thumb.Create(originalImg, fileName, size.Width, size.Height, size.Options...)
sourceImgType = name
sourceName = name
}
if err != nil {
log.Errorf("media: failed creating %s (%s)", txt.Quote(name), err)
log.Errorf("media: failed creating %s (%s)", txt.Quote(string(name)), err)
return err
}

View file

@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/stretchr/testify/assert"
)
@ -1822,7 +1823,7 @@ func TestMediaFile_Resample(t *testing.T) {
t.Fatal(err)
}
thumbnail, err := image.Resample(thumbsPath, "tile_500")
thumbnail, err := image.Resample(thumbsPath, thumb.Tile500)
if err != nil {
t.Fatal(err)
@ -1873,7 +1874,7 @@ func TestMediaFile_RenderDefaultThumbs(t *testing.T) {
t.Fatal(err)
}
thumbFilename, err := thumb.FileName(m.Hash(), thumbsPath, thumb.Sizes["tile_50"].Width, thumb.Sizes["tile_50"].Height, thumb.Sizes["tile_50"].Options...)
thumbFilename, err := thumb.FileName(m.Hash(), thumbsPath, thumb.Sizes[thumb.Tile50].Width, thumb.Sizes[thumb.Tile50].Height, thumb.Sizes[thumb.Tile50].Options...)
if err != nil {
t.Fatal(err)

View file

@ -87,23 +87,6 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.PhotoPrimary(v1)
api.PhotoUnstack(v1)
api.GetSubjects(v1)
api.GetSubject(v1)
api.GetLabels(v1)
api.UpdateLabel(v1)
api.GetLabelLinks(v1)
api.CreateLabelLink(v1)
api.UpdateLabelLink(v1)
api.DeleteLabelLink(v1)
api.LikeLabel(v1)
api.DislikeLabel(v1)
api.LabelCover(v1)
api.GetFoldersOriginals(v1)
api.GetFoldersImport(v1)
api.GetFolderCover(v1)
api.Upload(v1)
api.StartImport(v1)
api.CancelImport(v1)
@ -118,6 +101,24 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.BatchAlbumsDelete(v1)
api.BatchLabelsDelete(v1)
api.GetSubjects(v1)
api.GetSubject(v1)
api.LabelCover(v1)
api.GetLabels(v1)
api.UpdateLabel(v1)
api.GetLabelLinks(v1)
api.CreateLabelLink(v1)
api.UpdateLabelLink(v1)
api.DeleteLabelLink(v1)
api.LikeLabel(v1)
api.DislikeLabel(v1)
api.FolderCover(v1)
api.GetFoldersOriginals(v1)
api.GetFoldersImport(v1)
api.AlbumCover(v1)
api.GetAlbum(v1)
api.CreateAlbum(v1)
api.UpdateAlbum(v1)
@ -130,7 +131,6 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DeleteAlbumLink(v1)
api.LikeAlbum(v1)
api.DislikeAlbum(v1)
api.AlbumCover(v1)
api.CloneAlbums(v1)
api.AddPhotosToAlbum(v1)
api.RemovePhotosFromAlbum(v1)

View file

@ -15,57 +15,6 @@ import (
"github.com/disintegration/imaging"
)
// ResampleOptions extracts filter, format, and method from resample options.
func ResampleOptions(opts ...ResampleOption) (method ResampleOption, filter imaging.ResampleFilter, format fs.FileFormat) {
method = ResampleFit
filter = imaging.Lanczos
format = fs.FormatJpeg
for _, option := range opts {
switch option {
case ResamplePng:
format = fs.FormatPng
case ResampleNearestNeighbor:
filter = imaging.NearestNeighbor
case ResampleDefault:
filter = Filter.Imaging()
case ResampleFillTopLeft:
method = ResampleFillTopLeft
case ResampleFillCenter:
method = ResampleFillCenter
case ResampleFillBottomRight:
method = ResampleFillBottomRight
case ResampleFit:
method = ResampleFit
case ResampleResize:
method = ResampleResize
}
}
return method, filter, format
}
// Resample downscales an image and returns it.
func Resample(img image.Image, width, height int, opts ...ResampleOption) image.Image {
var resImg image.Image
method, filter, _ := ResampleOptions(opts...)
if method == ResampleFit {
resImg = imaging.Fit(img, width, height, filter)
} else if method == ResampleFillCenter {
resImg = imaging.Fill(img, width, height, imaging.Center, filter)
} else if method == ResampleFillTopLeft {
resImg = imaging.Fill(img, width, height, imaging.TopLeft, filter)
} else if method == ResampleFillBottomRight {
resImg = imaging.Fill(img, width, height, imaging.BottomRight, filter)
} else if method == ResampleResize {
resImg = imaging.Resize(img, width, height, filter)
}
return resImg
}
// Suffix returns the thumb cache file suffix.
func Suffix(width, height int, opts ...ResampleOption) (result string) {
method, _, format := ResampleOptions(opts...)

View file

@ -35,7 +35,7 @@ func TestResampleOptions(t *testing.T) {
func TestResample(t *testing.T) {
t.Run("tile50 options", func(t *testing.T) {
tile50 := Sizes["tile_50"]
tile50 := Sizes[Tile50]
src := "testdata/example.jpg"
@ -60,7 +60,7 @@ func TestResample(t *testing.T) {
assert.Equal(t, 50, boundsNew.Max.Y)
})
t.Run("left_224 options", func(t *testing.T) {
left224 := Sizes["left_224"]
left224 := Sizes[Left224]
src := "testdata/example.jpg"
@ -85,7 +85,7 @@ func TestResample(t *testing.T) {
assert.Equal(t, 224, boundsNew.Max.Y)
})
t.Run("right_224 options", func(t *testing.T) {
right224 := Sizes["right_224"]
right224 := Sizes[Right224]
src := "testdata/example.jpg"
@ -110,7 +110,7 @@ func TestResample(t *testing.T) {
assert.Equal(t, 224, boundsNew.Max.Y)
})
t.Run("fit_1280 options", func(t *testing.T) {
fit1280 := Sizes["fit_1280"]
fit1280 := Sizes[Fit1280]
src := "testdata/example.jpg"
@ -137,7 +137,7 @@ func TestResample(t *testing.T) {
}
func TestSuffix(t *testing.T) {
tile50 := Sizes["tile_50"]
tile50 := Sizes[Tile50]
result := Suffix(tile50.Width, tile50.Height, tile50.Options...)
@ -146,7 +146,7 @@ func TestSuffix(t *testing.T) {
func TestFileName(t *testing.T) {
t.Run("colors", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
result, err := FileName("123456789098765432", "testdata", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
@ -158,7 +158,7 @@ func TestFileName(t *testing.T) {
})
t.Run("fit_720", func(t *testing.T) {
fit720 := Sizes["fit_720"]
fit720 := Sizes[Fit720]
result, err := FileName("123456789098765432", "testdata", fit720.Width, fit720.Height, fit720.Options...)
@ -169,7 +169,7 @@ func TestFileName(t *testing.T) {
assert.Equal(t, "testdata/1/2/3/123456789098765432_720x720_fit.jpg", result)
})
t.Run("invalid width", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
result, err := FileName("123456789098765432", "testdata", -2, colorThumb.Height, colorThumb.Options...)
@ -180,7 +180,7 @@ func TestFileName(t *testing.T) {
assert.Empty(t, result)
})
t.Run("invalid height", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
result, err := FileName("123456789098765432", "testdata", colorThumb.Width, -3, colorThumb.Options...)
@ -191,7 +191,7 @@ func TestFileName(t *testing.T) {
assert.Empty(t, result)
})
t.Run("invalid hash", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
result, err := FileName("12", "testdata", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
@ -202,7 +202,7 @@ func TestFileName(t *testing.T) {
assert.Empty(t, result)
})
t.Run("invalid thumb path", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
result, err := FileName("123456789098765432", "", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
@ -216,7 +216,7 @@ func TestFileName(t *testing.T) {
func TestFromFile(t *testing.T) {
t.Run("colors", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
src := "testdata/example.gif"
dst := "testdata/1/2/3/123456789098765432_3x3_resize.png"
@ -234,7 +234,7 @@ func TestFromFile(t *testing.T) {
})
t.Run("orientation >1 ", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
src := "testdata/example.gif"
dst := "testdata/1/2/3/123456789098765432_3x3_resize.png"
@ -252,7 +252,7 @@ func TestFromFile(t *testing.T) {
})
t.Run("missing file", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
src := "testdata/example.xxx"
assert.NoFileExists(t, src)
@ -263,7 +263,7 @@ func TestFromFile(t *testing.T) {
assert.Error(t, err)
})
t.Run("empty filename", func(t *testing.T) {
colorThumb := Sizes["colors"]
colorThumb := Sizes[Colors]
fileName, err := FromFile("", "193456789098765432", "testdata", colorThumb.Width, colorThumb.Height, OrientationNormal, colorThumb.Options...)
@ -277,7 +277,7 @@ func TestFromFile(t *testing.T) {
func TestFromCache(t *testing.T) {
t.Run("missing thumb", func(t *testing.T) {
tile50 := Sizes["tile_50"]
tile50 := Sizes[Tile50]
src := "testdata/example.jpg"
assert.FileExists(t, src)
@ -292,7 +292,7 @@ func TestFromCache(t *testing.T) {
})
t.Run("missing file", func(t *testing.T) {
tile50 := Sizes["tile_50"]
tile50 := Sizes[Tile50]
src := "testdata/example.xxx"
assert.NoFileExists(t, src)
@ -303,7 +303,7 @@ func TestFromCache(t *testing.T) {
assert.Error(t, err)
})
t.Run("invalid hash", func(t *testing.T) {
tile50 := Sizes["tile_50"]
tile50 := Sizes[Tile50]
src := "testdata/example.jpg"
assert.FileExists(t, src)
@ -317,7 +317,7 @@ func TestFromCache(t *testing.T) {
assert.Empty(t, fileName)
})
t.Run("empty filename", func(t *testing.T) {
tile50 := Sizes["tile_50"]
tile50 := Sizes[Tile50]
fileName, err := FromCache("", "193456789098765432", "testdata", tile50.Width, tile50.Height, tile50.Options...)
@ -331,7 +331,7 @@ func TestFromCache(t *testing.T) {
func TestCreate(t *testing.T) {
t.Run("tile_500", func(t *testing.T) {
tile500 := Sizes["tile_500"]
tile500 := Sizes[Tile500]
src := "testdata/example.jpg"
dst := "testdata/example.tile_500.jpg"
@ -368,7 +368,7 @@ func TestCreate(t *testing.T) {
assert.Equal(t, 500, boundsNew.Max.Y)
})
t.Run("width & height <= 150", func(t *testing.T) {
tile500 := Sizes["tile_500"]
tile500 := Sizes[Tile500]
src := "testdata/example.jpg"
dst := "testdata/example.tile_500.jpg"
@ -405,7 +405,7 @@ func TestCreate(t *testing.T) {
assert.Equal(t, 150, boundsNew.Max.Y)
})
t.Run("invalid width", func(t *testing.T) {
tile500 := Sizes["tile_500"]
tile500 := Sizes[Tile500]
src := "testdata/example.jpg"
dst := "testdata/example.tile_500.jpg"
@ -433,7 +433,7 @@ func TestCreate(t *testing.T) {
t.Log(resized)
})
t.Run("invalid height", func(t *testing.T) {
tile500 := Sizes["tile_500"]
tile500 := Sizes[Tile500]
src := "testdata/example.jpg"
dst := "testdata/example.tile_500.jpg"

31
internal/thumb/names.go Normal file
View file

@ -0,0 +1,31 @@
package thumb
import "github.com/photoprism/photoprism/pkg/fs"
// Name represents a thumbnail size name.
type Name string
// Jpeg returns the thumbnail name with a jpeg file extension suffix as string.
func (n Name) Jpeg() string {
return string(n) + fs.JpegExt
}
// Names of thumbnail sizes.
const (
Tile50 Name = "tile_50"
Tile100 Name = "tile_100"
Crop160 Name = "crop_160"
Tile224 Name = "tile_224"
Tile500 Name = "tile_500"
Colors Name = "colors"
Left224 Name = "left_224"
Right224 Name = "right_224"
Fit720 Name = "fit_720"
Fit1280 Name = "fit_1280"
Fit1920 Name = "fit_1920"
Fit2048 Name = "fit_2048"
Fit2560 Name = "fit_2560"
Fit3840 Name = "fit_3840"
Fit4096 Name = "fit_4096"
Fit7680 Name = "fit_7680"
)

View file

@ -0,0 +1,28 @@
package thumb
import (
"image"
"github.com/disintegration/imaging"
)
// Resample downscales an image and returns it.
func Resample(img image.Image, width, height int, opts ...ResampleOption) image.Image {
var resImg image.Image
method, filter, _ := ResampleOptions(opts...)
if method == ResampleFit {
resImg = imaging.Fit(img, width, height, filter)
} else if method == ResampleFillCenter {
resImg = imaging.Fill(img, width, height, imaging.Center, filter)
} else if method == ResampleFillTopLeft {
resImg = imaging.Fill(img, width, height, imaging.TopLeft, filter)
} else if method == ResampleFillBottomRight {
resImg = imaging.Fill(img, width, height, imaging.BottomRight, filter)
} else if method == ResampleResize {
resImg = imaging.Resize(img, width, height, filter)
}
return resImg
}

View file

@ -0,0 +1,27 @@
package thumb
import "github.com/disintegration/imaging"
const (
ResampleBlackman ResampleFilter = "blackman"
ResampleLanczos ResampleFilter = "lanczos"
ResampleCubic ResampleFilter = "cubic"
ResampleLinear ResampleFilter = "linear"
)
type ResampleFilter string
func (a ResampleFilter) Imaging() imaging.ResampleFilter {
switch a {
case ResampleBlackman:
return imaging.Blackman
case ResampleLanczos:
return imaging.Lanczos
case ResampleCubic:
return imaging.CatmullRom
case ResampleLinear:
return imaging.Linear
default:
return imaging.Lanczos
}
}

View file

@ -0,0 +1,59 @@
package thumb
import (
"github.com/disintegration/imaging"
"github.com/photoprism/photoprism/pkg/fs"
)
type ResampleOption int
const (
ResampleFillCenter ResampleOption = iota
ResampleFillTopLeft
ResampleFillBottomRight
ResampleFit
ResampleCrop
ResampleResize
ResampleNearestNeighbor
ResampleDefault
ResamplePng
)
var ResampleMethods = map[ResampleOption]string{
ResampleFillCenter: "center",
ResampleFillTopLeft: "left",
ResampleFillBottomRight: "right",
ResampleFit: "fit",
ResampleCrop: "crop",
ResampleResize: "resize",
}
// ResampleOptions extracts filter, format, and method from resample options.
func ResampleOptions(opts ...ResampleOption) (method ResampleOption, filter imaging.ResampleFilter, format fs.FileFormat) {
method = ResampleFit
filter = imaging.Lanczos
format = fs.FormatJpeg
for _, option := range opts {
switch option {
case ResamplePng:
format = fs.FormatPng
case ResampleNearestNeighbor:
filter = imaging.NearestNeighbor
case ResampleDefault:
filter = Filter.Imaging()
case ResampleFillTopLeft:
method = ResampleFillTopLeft
case ResampleFillCenter:
method = ResampleFillCenter
case ResampleFillBottomRight:
method = ResampleFillBottomRight
case ResampleFit:
method = ResampleFit
case ResampleResize:
method = ResampleResize
}
}
return method, filter, format
}

View file

@ -1,7 +1,5 @@
package thumb
import "github.com/disintegration/imaging"
var (
SizePrecached = 2048
SizeUncached = 7680
@ -22,102 +20,57 @@ func InvalidSize(size int) bool {
return size < 0 || size > MaxSize()
}
const (
ResampleBlackman ResampleFilter = "blackman"
ResampleLanczos ResampleFilter = "lanczos"
ResampleCubic ResampleFilter = "cubic"
ResampleLinear ResampleFilter = "linear"
)
type ResampleFilter string
func (a ResampleFilter) Imaging() imaging.ResampleFilter {
switch a {
case ResampleBlackman:
return imaging.Blackman
case ResampleLanczos:
return imaging.Lanczos
case ResampleCubic:
return imaging.CatmullRom
case ResampleLinear:
return imaging.Linear
default:
return imaging.Lanczos
}
}
const (
ResampleFillCenter ResampleOption = iota
ResampleFillTopLeft
ResampleFillBottomRight
ResampleFit
ResampleCrop
ResampleResize
ResampleNearestNeighbor
ResampleDefault
ResamplePng
)
type ResampleOption int
var ResampleMethods = map[ResampleOption]string{
ResampleFillCenter: "center",
ResampleFillTopLeft: "left",
ResampleFillBottomRight: "right",
ResampleFit: "fit",
ResampleCrop: "crop",
ResampleResize: "resize",
}
type Size struct {
Use string `json:"use"`
Source string `json:"-"`
Source Name `json:"-"`
Width int `json:"w"`
Height int `json:"h"`
Public bool `json:"-"`
Options []ResampleOption `json:"-"`
}
type SizeMap map[string]Size
type SizeMap map[Name]Size
// Sizes contains the properties of all thumbnail sizes.
var Sizes = SizeMap{
"tile_50": {"Lists", "tile_500", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
"tile_100": {"Maps", "tile_500", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
"crop_160": {"FaceNet", "", 160, 160, false, []ResampleOption{ResampleCrop, ResampleDefault}},
"tile_224": {"TensorFlow, Mosaic", "tile_500", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
"tile_500": {"Tiles", "", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
"colors": {"Color Detection", "fit_720", 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}},
"left_224": {"TensorFlow", "fit_720", 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}},
"right_224": {"TensorFlow", "fit_720", 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}},
"fit_720": {"Mobile, TV", "", 720, 720, true, []ResampleOption{ResampleFit, ResampleDefault}},
"fit_1280": {"Mobile, HD Ready TV", "fit_2048", 1280, 1024, true, []ResampleOption{ResampleFit, ResampleDefault}},
"fit_1920": {"Mobile, Full HD TV", "fit_2048", 1920, 1200, true, []ResampleOption{ResampleFit, ResampleDefault}},
"fit_2048": {"Tablets, Cinema 2K", "", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleDefault}},
"fit_2560": {"Quad HD, Retina Display", "", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleDefault}},
"fit_3840": {"Ultra HD", "", 3840, 2400, false, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096
"fit_4096": {"Ultra HD, Retina 4K", "", 4096, 4096, true, []ResampleOption{ResampleFit, ResampleDefault}},
"fit_7680": {"8K Ultra HD 2, Retina 6K", "", 7680, 4320, true, []ResampleOption{ResampleFit, ResampleDefault}},
Tile50: {"Lists", Tile500, 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile100: {"Maps", Tile500, 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Crop160: {"FaceNet", "", 160, 160, false, []ResampleOption{ResampleCrop, ResampleDefault}},
Tile224: {"TensorFlow, Mosaic", Tile500, 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile500: {"Tiles", "", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Colors: {"Color Detection", Fit720, 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}},
Left224: {"TensorFlow", Fit720, 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}},
Right224: {"TensorFlow", Fit720, 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}},
Fit720: {"Mobile, TV", "", 720, 720, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit1280: {"Mobile, HD Ready TV", Fit2048, 1280, 1024, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit1920: {"Mobile, Full HD TV", Fit2048, 1920, 1200, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit2048: {"Tablets, Cinema 2K", "", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit2560: {"Quad HD, Retina Display", "", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit3840: {"Ultra HD", "", 3840, 2400, false, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096
Fit4096: {"Ultra HD, Retina 4K", "", 4096, 4096, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit7680: {"8K Ultra HD 2, Retina 6K", "", 7680, 4320, true, []ResampleOption{ResampleFit, ResampleDefault}},
}
var DefaultSizes = []string{
"fit_7680",
"fit_4096",
"fit_2560",
"fit_2048",
"fit_1920",
"fit_1280",
"fit_720",
"right_224",
"left_224",
"colors",
"tile_500",
"tile_224",
"tile_100",
"tile_50",
// DefaultSizes contains all default size names.
var DefaultSizes = []Name{
Fit7680,
Fit4096,
Fit2560,
Fit2048,
Fit1920,
Fit1280,
Fit720,
Right224,
Left224,
Colors,
Tile500,
Tile224,
Tile100,
Tile50,
}
// Find returns the largest default thumbnail type for the given size limit.
func Find(limit int) (name string, result Size) {
func Find(limit int) (name Name, size Size) {
for _, name = range DefaultSizes {
t := Sizes[name]

View file

@ -10,13 +10,13 @@ func TestSize_ExceedsLimit(t *testing.T) {
SizePrecached = 1024
SizeUncached = 2048
fit4096 := Sizes["fit_4096"]
fit4096 := Sizes[Fit4096]
assert.True(t, fit4096.ExceedsLimit())
fit2048 := Sizes["fit_2048"]
fit2048 := Sizes[Fit2048]
assert.False(t, fit2048.ExceedsLimit())
tile500 := Sizes["tile_500"]
tile500 := Sizes[Tile500]
assert.False(t, tile500.ExceedsLimit())
SizePrecached = 2048
@ -27,13 +27,13 @@ func TestSize_Uncached(t *testing.T) {
SizePrecached = 1024
SizeUncached = 2048
fit4096 := Sizes["fit_4096"]
fit4096 := Sizes[Fit4096]
assert.True(t, fit4096.Uncached())
fit2048 := Sizes["fit_2048"]
fit2048 := Sizes[Fit2048]
assert.True(t, fit2048.Uncached())
tile500 := Sizes["tile_500"]
tile500 := Sizes[Tile500]
assert.False(t, tile500.Uncached())
SizePrecached = 2048
@ -57,16 +57,16 @@ func TestResampleFilter_Imaging(t *testing.T) {
func TestFind(t *testing.T) {
t.Run("2048", func(t *testing.T) {
tName, tType := Find(2048)
assert.Equal(t, "fit_2048", tName)
assert.Equal(t, 2048, tType.Width)
assert.Equal(t, 2048, tType.Height)
name, size := Find(2048)
assert.Equal(t, Fit2048, name)
assert.Equal(t, 2048, size.Width)
assert.Equal(t, 2048, size.Height)
})
t.Run("2000", func(t *testing.T) {
tName, tType := Find(2000)
assert.Equal(t, "fit_1920", tName)
assert.Equal(t, 1920, tType.Width)
assert.Equal(t, 1200, tType.Height)
name, size := Find(2000)
assert.Equal(t, Fit1920, name)
assert.Equal(t, 1920, size.Width)
assert.Equal(t, 1200, size.Height)
})
}

View file

@ -97,7 +97,7 @@ func (worker *Share) Start() (err error) {
srcFileName := photoprism.FileName(file.File.FileRoot, file.File.FileName)
if a.ShareSize != "" {
size, ok := thumb.Sizes[a.ShareSize]
size, ok := thumb.Sizes[thumb.Name(a.ShareSize)]
if !ok {
log.Errorf("share: invalid size %s", a.ShareSize)