Index: Skip redundant thumbs and support symbolic file links #1049 #1874

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-07-06 23:01:54 +02:00
parent bbc4f2f276
commit 5ec90a5fff
55 changed files with 1322 additions and 590 deletions

View file

@ -236,35 +236,35 @@ reset-sqlite:
$(info Removing test database files...)
find ./internal -type f -name ".test.*" -delete
run-test-short:
$(info Running short Go unit tests in parallel mode...)
$(info Running short Go tests in parallel mode...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -short -timeout 5m ./pkg/... ./internal/...
run-test-go:
$(info Running all Go unit tests...)
$(info Running all Go tests...)
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m ./pkg/... ./internal/...
run-test-mariadb:
$(info Running all Go unit tests on MariaDB...)
$(info Running all Go tests on MariaDB...)
PHOTOPRISM_TEST_DRIVER="mysql" PHOTOPRISM_TEST_DSN="root:photoprism@tcp(mariadb:4001)/acceptance?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true" $(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m ./pkg/... ./internal/...
run-test-pkg:
$(info Running all Go unit tests in "/pkg"...)
$(info Running all Go tests in "/pkg"...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./pkg/...
run-test-api:
$(info Running all API unit tests...)
$(info Running all API tests...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./internal/api/...
test-parallel:
$(info Running all Go unit tests in parallel mode...)
$(info Running all Go tests in parallel mode...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./pkg/... ./internal/...
test-verbose:
$(info Running all Go unit tests in verbose mode...)
$(info Running all Go tests in verbose mode...)
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m -v ./pkg/... ./internal/...
test-race:
$(info Running all Go unit tests with race detection in verbose mode...)
$(info Running all Go tests with race detection in verbose mode...)
$(GOTEST) -tags slow -race -timeout 60m -v ./pkg/... ./internal/...
test-codecov:
$(info Running all Go unit tests with code coverage report for codecov...)
$(info Running all Go tests with code coverage report for codecov...)
go test -parallel 1 -count 1 -cpu 1 -failfast -tags slow -timeout 30m -coverprofile coverage.txt -covermode atomic ./pkg/... ./internal/...
scripts/codecov.sh -t $(CODECOV_TOKEN)
test-coverage:
$(info Running all Go unit tests with code coverage report...)
$(info Running all Go tests with code coverage report...)
go test -parallel 1 -count 1 -cpu 1 -failfast -tags slow -timeout 30m -coverprofile coverage.txt -covermode atomic ./pkg/... ./internal/...
go tool cover -html=coverage.txt -o coverage.html
docker-develop: docker-develop-latest

View file

@ -73,20 +73,20 @@ func GetThumb(router *gin.RouterGroup) {
return
}
thumbName := thumb.Name(clean.Token(c.Param("size")))
sizeName := thumb.Name(clean.Token(c.Param("size")))
size, ok := thumb.Sizes[thumbName]
size, ok := thumb.Sizes[sizeName]
if !ok {
log.Errorf("%s: invalid size %s", logPrefix, clean.Log(thumbName.String()))
log.Errorf("%s: invalid size %s", logPrefix, clean.Log(sizeName.String()))
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if size.Uncached() && !conf.ThumbUncached() {
thumbName, size = thumb.Find(conf.ThumbSizePrecached())
sizeName, size = thumb.Find(conf.ThumbSizePrecached())
if thumbName == "" {
if sizeName == "" {
log.Errorf("%s: invalid size %d", logPrefix, conf.ThumbSizePrecached())
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
@ -94,7 +94,7 @@ func GetThumb(router *gin.RouterGroup) {
}
cache := service.ThumbCache()
cacheKey := CacheKey("thumbs", fileHash, string(thumbName))
cacheKey := CacheKey("thumbs", fileHash, string(sizeName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
@ -119,7 +119,7 @@ func GetThumb(router *gin.RouterGroup) {
// Return existing thumbs straight away.
if !download {
if fileName, err := thumb.FileName(fileHash, conf.ThumbCachePath(), size.Width, size.Height, size.Options...); err == nil && fs.FileExists(fileName) {
if fileName, err := size.ResolvedName(fileHash, conf.ThumbCachePath()); err == nil {
AddThumbCacheHeader(c)
c.File(fileName)
return
@ -152,7 +152,7 @@ func GetThumb(router *gin.RouterGroup) {
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
if fileName, err = fs.Resolve(fileName); err != nil {
log.Errorf("%s: file %s is missing", logPrefix, clean.Log(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
@ -170,6 +170,12 @@ func GetThumb(router *gin.RouterGroup) {
return
}
// Choose the smallest fitting size if the original image is smaller.
if size.Fit && f.Bounds().In(size.Bounds()) {
size = thumb.FitBounds(f.Bounds())
log.Tracef("%s: smallest fitting size for %s is %s (width %d, height %d)", logPrefix, clean.Log(f.FileName), size.Name, size.Width, size.Height)
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if size.ExceedsLimit() && c.Query("download") == "" {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", logPrefix, size.Width, size.Height)
@ -180,32 +186,37 @@ func GetThumb(router *gin.RouterGroup) {
return
}
var thumbnail string
// thumbName is the thumbnail filename.
var thumbName string
// Try to find or create thumbnail image.
if conf.ThumbUncached() || size.Uncached() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbCachePath(), size.Width, size.Height, f.FileOrientation, size.Options...)
thumbName, err = size.FromFile(fileName, f.FileHash, conf.ThumbCachePath(), f.FileOrientation)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbCachePath(), size.Width, size.Height, size.Options...)
thumbName, err = size.FromCache(fileName, f.FileHash, conf.ThumbCachePath())
}
// Failed?
if err != nil {
log.Errorf("%s: %s", logPrefix, err)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
} else if thumbnail == "" {
} else if thumbName == "" {
log.Errorf("%s: %s has empty thumb name - possible bug", logPrefix, filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
// Cache thumbnail filename to reduce the number of index queries.
cache.SetDefault(cacheKey, ThumbCache{thumbName, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
// Set the download or cache header and return the thumbnail.
if download {
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
c.FileAttachment(thumbName, f.DownloadName(DownloadName(c), 0))
} else {
AddThumbCacheHeader(c)
c.File(thumbnail)
c.File(thumbName)
}
})
}

View file

@ -61,8 +61,8 @@ func init() {
}
// Init public thumb sizes for use in client apps.
for i := len(thumb.DefaultSizes) - 1; i >= 0; i-- {
name := thumb.DefaultSizes[i]
for i := len(thumb.Names) - 1; i >= 0; i-- {
name := thumb.Names[i]
t := thumb.Sizes[name]
if t.Public {
@ -458,7 +458,7 @@ func (c *Config) Debug() bool {
// Trace checks if trace mode is enabled, shows all log messages.
func (c *Config) Trace() bool {
return c.options.Trace
return c.options.Trace || c.options.LogLevel == logrus.TraceLevel.String()
}
// Test checks if test mode is enabled.

View file

@ -43,6 +43,11 @@ func ImageFromThumb(thumbName string, area Area, size Size, cache bool) (img ima
// Extract hash from file name.
hash := thumbHash(thumbName)
// Resolve symlinks.
if thumbName, err = fs.Resolve(thumbName); err != nil {
return nil, err
}
// Compose cached crop image file name.
cropBase := fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, size.Width, size.Height, area.String(), fs.ExtJPEG)
cropName := filepath.Join(filePath, cropBase)
@ -113,7 +118,8 @@ func ThumbFileName(hash string, area Area, size Size, thumbPath string) (string,
return "", fmt.Errorf("not found")
}
return fileName, nil
// Resolve symlinks.
return fs.Resolve(fileName)
}
// FileWidth returns the minimal thumbnail width based on crop area and size.
@ -142,9 +148,10 @@ func findIdealThumbFileName(hash string, width int, filePath string) (fileName s
}
for i, s := range thumbFileSizes {
name := filepath.Join(filePath, fmt.Sprintf(thumbFileNames[i], hash))
// Resolve symlinks.
name, err := fs.Resolve(filepath.Join(filePath, fmt.Sprintf(thumbFileNames[i], hash)))
if !fs.FileExists(name) {
if err != nil || !fs.FileExists(name) {
continue
} else if s.Width < width {
fileName = name
@ -158,7 +165,12 @@ func findIdealThumbFileName(hash string, width int, filePath string) (fileName s
}
// openIdealThumbFile opens the thumbnail file and returns an image.
func openIdealThumbFile(fileName, hash string, area Area, size Size) (image.Image, error) {
func openIdealThumbFile(fileName, hash string, area Area, size Size) (result image.Image, err error) {
// Resolve symlinks.
if fileName, err = fs.Resolve(fileName); err != nil {
return nil, err
}
if len(hash) != 40 || area.W <= 0 || size.Width <= 0 {
// Not a standard thumb name with sha1 hash prefix.
if imageBuffer, err := os.ReadFile(fileName); err != nil {

View file

@ -1,6 +1,7 @@
package crop
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -59,7 +60,7 @@ func TestThumbFileName(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
})
}
@ -98,18 +99,18 @@ func TestFindIdealThumbFileName(t *testing.T) {
})
t.Run("width: 500", func(t *testing.T) {
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 500, "./testdata/b/c/c")
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
})
t.Run("width: 720", func(t *testing.T) {
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 720, "./testdata/b/c/c")
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
})
t.Run("width: 800", func(t *testing.T) {
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 800, "./testdata/b/c/c")
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
})
t.Run("width: 60", func(t *testing.T) {
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 60, "./testdata/b/c/c")
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
})
}

View file

@ -2,6 +2,7 @@ package entity
import (
"fmt"
"image"
"math"
"path/filepath"
"sort"
@ -572,6 +573,11 @@ func (m *File) Panorama() bool {
return float64(m.FileWidth)/float64(m.FileHeight) > 1.9
}
// Bounds returns the file dimensions as image.Rectangle.
func (m *File) Bounds() image.Rectangle {
return image.Rectangle{Min: image.Point{}, Max: image.Point{X: m.FileWidth, Y: m.FileHeight}}
}
// Projection returns the panorama projection name if any.
func (m *File) Projection() projection.Type {
return projection.New(m.FileProjection)

View file

@ -66,8 +66,8 @@ type Data struct {
exif map[string]string
}
// NewData creates a new metadata struct.
func NewData() Data {
// New returns a new metadata struct.
func New() Data {
return Data{}
}

View file

@ -54,6 +54,11 @@ func (data *Data) Exif(fileName string, fileFormat fs.Type, bruteForce bool) (er
}
}()
// Resolve file name e.g. in case it's a symlink.
if fileName, err = fs.Resolve(fileName); err != nil {
return err
}
// Extract raw Exif block.
rawExif, err := RawExif(fileName, fileFormat, bruteForce)

View file

@ -28,7 +28,10 @@ func (data *Data) JSON(jsonName, originalName string) (err error) {
quotedName := clean.Log(filepath.Base(jsonName))
if !fs.FileExists(jsonName) {
// Resolve JSON file name e.g. in case it's a symlink.
if jsonName, err = fs.Resolve(jsonName); err != nil {
return fmt.Errorf("metadata: %s not found (%s)", quotedName, err)
} else if !fs.FileExists(jsonName) {
return fmt.Errorf("metadata: %s not found", quotedName)
}

View file

@ -8,7 +8,7 @@ import (
func TestData_AddKeywords(t *testing.T) {
t.Run("success", func(t *testing.T) {
data := NewData()
data := New()
assert.Equal(t, "", data.Keywords.String())
@ -22,7 +22,7 @@ func TestData_AddKeywords(t *testing.T) {
})
t.Run("ignore", func(t *testing.T) {
data := NewData()
data := New()
assert.Equal(t, "", data.Keywords.String())
@ -34,7 +34,7 @@ func TestData_AddKeywords(t *testing.T) {
func TestData_AutoAddKeywords(t *testing.T) {
t.Run("success", func(t *testing.T) {
data := NewData()
data := New()
assert.Equal(t, "", data.Keywords.String())
@ -44,7 +44,7 @@ func TestData_AutoAddKeywords(t *testing.T) {
})
t.Run("ignore", func(t *testing.T) {
data := NewData()
data := New()
assert.Equal(t, "", data.Keywords.String())
@ -54,7 +54,7 @@ func TestData_AutoAddKeywords(t *testing.T) {
})
t.Run("ignore because too short", func(t *testing.T) {
data := NewData()
data := New()
assert.Equal(t, "", data.Keywords.String())

View file

@ -5,6 +5,8 @@ import (
"path/filepath"
"runtime/debug"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/clean"
)
@ -23,9 +25,14 @@ func (data *Data) XMP(fileName string) (err error) {
}
}()
// Resolve file name e.g. in case it's a symlink.
if fileName, err = fs.Resolve(fileName); err != nil {
return fmt.Errorf("metadata: %s %s (xmp)", err, clean.Log(filepath.Base(fileName)))
}
doc := XmpDocument{}
if err := doc.Load(fileName); err != nil {
if err = doc.Load(fileName); err != nil {
return fmt.Errorf("metadata: cannot read %s (xmp)", clean.Log(filepath.Base(fileName)))
}

View file

@ -29,7 +29,6 @@ func TestXMP(t *testing.T) {
}
assert.Equal(t, "Night Shift / Berlin / 2020", data.Title)
t.Log(data.TakenAt)
assert.Equal(t, time.Date(2020, 1, 1, 17, 28, 25, 729626112, time.UTC), data.TakenAt)
assert.Equal(t, "Michael Mayer", data.Artist)
assert.Equal(t, "Example file for development", data.Description)

View file

@ -152,20 +152,21 @@ func (ind *Index) Start(o IndexOptions) fs.Done {
return errors.New("canceled")
}
isDir := info.IsDir()
isDir, _ := info.IsDirOrSymlinkToDir()
isSymlink := info.IsSymlink()
relName := fs.RelName(fileName, originalsPath)
// Skip directories and known files.
if skip, result := fs.SkipWalk(fileName, isDir, isSymlink, done, ignore); skip {
if (isSymlink || isDir) && result != filepath.SkipDir {
folder := entity.NewFolder(entity.RootOriginals, relName, fs.BirthTime(fileName))
if err := folder.Create(); err == nil {
log.Infof("index: added folder /%s", folder.Path)
}
}
if isDir {
if result != filepath.SkipDir {
folder := entity.NewFolder(entity.RootOriginals, relName, fs.BirthTime(fileName))
if err := folder.Create(); err == nil {
log.Infof("index: added folder /%s", folder.Path)
}
}
event.Publish("index.folder", event.Data{
"filePath": relName,
})

View file

@ -47,7 +47,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID
file, primaryFile := entity.File{}, entity.File{}
photo := entity.NewPhoto(o.Stack)
metaData := meta.NewData()
metaData := meta.New()
labels := classify.Labels{}
stripSequence := Config().Settings().StackSequences() && o.Stack

View file

@ -24,16 +24,11 @@ import (
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/disintegration/imaging"
"github.com/djherbis/times"
"github.com/dustin/go-humanize/english"
"github.com/mandykoh/prism/meta/autometa"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/capture"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
@ -65,12 +60,13 @@ type MediaFile struct {
}
// NewMediaFile returns a new media file.
func NewMediaFile(fileName string) (*MediaFile, error) {
m := &MediaFile{
func NewMediaFile(fileName string) (m *MediaFile, err error) {
// Create struct.
m = &MediaFile{
fileName: fileName,
fileRoot: entity.RootUnknown,
fileType: fs.UnknownType,
metaData: meta.NewData(),
metaData: meta.New(),
width: -1,
height: -1,
}
@ -91,7 +87,14 @@ func (m *MediaFile) Stat() (size int64, mod time.Time, err error) {
return m.fileSize, m.modTime, m.statErr
}
if s, err := os.Stat(m.FileName()); err != nil {
fileName := m.FileName()
// Resolve symlinks.
if fileName, err = fs.Resolve(fileName); err != nil {
m.statErr = err
m.modTime = time.Time{}
m.fileSize = -1
} else if s, err := os.Stat(fileName); err != nil {
m.statErr = err
m.modTime = time.Time{}
m.fileSize = -1
@ -561,14 +564,29 @@ func (m *MediaFile) MimeType() string {
return m.mimeType
}
m.mimeType = fs.MimeType(m.FileName())
var err error
fileName := m.FileName()
// Resolve symlinks.
if fileName, err = fs.Resolve(fileName); err != nil {
return m.mimeType
}
m.mimeType = fs.MimeType(fileName)
return m.mimeType
}
// openFile opens the file and returns the descriptor.
func (m *MediaFile) openFile() (*os.File, error) {
handle, err := os.Open(m.fileName)
func (m *MediaFile) openFile() (handle *os.File, err error) {
fileName := m.FileName()
// Resolve symlinks.
if fileName, err = fs.Resolve(fileName); err != nil {
return nil, fmt.Errorf("%s %s", err, clean.Log(m.RootRelName()))
}
handle, err = os.Open(fileName)
if err != nil {
log.Error(err.Error())
@ -934,7 +952,14 @@ func (m *MediaFile) DecodeConfig() (_ *image.Config, err error) {
m.fileMutex.Lock()
defer m.fileMutex.Unlock()
file, err := os.Open(m.FileName())
fileName := m.FileName()
// Resolve symlinks.
if fileName, err = fs.Resolve(fileName); err != nil {
return nil, fmt.Errorf("%s %s", err, clean.Log(m.RootRelName()))
}
file, err := os.Open(fileName)
if err != nil || file == nil {
return nil, err
@ -1064,114 +1089,6 @@ func (m *MediaFile) Orientation() int {
return 1
}
// Thumbnail returns a thumbnail filename.
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", 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...)
if err != nil {
err = fmt.Errorf("media: failed creating thumbnail for %s (%s)", clean.Log(m.BaseName()), err)
log.Debug(err)
return "", err
}
return thumbnail, nil
}
// Resample returns a resampled image of the file.
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
}
return imaging.Open(filename)
}
// CreateThumbnails creates the default thumbnail sizes if the media file
// is a JPEG and they don't exist yet (except force is true).
func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) {
if !m.IsJpeg() {
// Skip.
return
}
count := 0
start := time.Now()
defer func() {
switch count {
case 0:
log.Debug(capture.Time(start, fmt.Sprintf("media: created no new thumbnails for %s", m.BasePrefix(false))))
default:
log.Info(capture.Time(start, fmt.Sprintf("media: created %s for %s", english.Plural(count, "thumbnail", "thumbnails"), m.BasePrefix(false))))
}
}()
hash := m.Hash()
var originalImg image.Image
var sourceImg image.Image
var sourceName thumb.Name
for _, name := range thumb.DefaultSizes {
size := thumb.Sizes[name]
if size.Uncached() {
// Skip, exceeds pre-cached size limit.
continue
}
if fileName, err := thumb.FileName(hash, thumbPath, size.Width, size.Height, size.Options...); err != nil {
log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err)
return err
} else {
if !force && fs.FileExists(fileName) {
continue
}
if originalImg == nil {
img, err := thumb.Open(m.FileName(), m.Orientation())
if err != nil {
log.Debugf("media: %s in %s", err.Error(), clean.Log(m.BaseName()))
return err
}
originalImg = img
}
if size.Source != "" {
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...)
sourceName = name
}
if err != nil {
log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err)
return err
}
count++
}
}
return nil
}
// RenameSidecars moves related sidecar files.
func (m *MediaFile) RenameSidecars(oldFileName string) (renamed map[string]string, err error) {
renamed = make(map[string]string)
@ -1253,8 +1170,16 @@ func (m *MediaFile) ColorProfile() string {
m.fileMutex.Lock()
defer m.fileMutex.Unlock()
var err error
fileName := m.FileName()
// Resolve symlinks.
if fileName, err = fs.Resolve(fileName); err != nil {
return m.colorProfile
}
// Open file.
fileReader, err := os.Open(m.FileName())
fileReader, err := os.Open(fileName)
if err != nil {
m.noColorProfile = true

View file

@ -9,7 +9,6 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/stretchr/testify/assert"
@ -1784,7 +1783,7 @@ func TestMediaFile_Megapixels(t *testing.T) {
})
t.Run("2018-04-12 19_24_49.mov", func(t *testing.T) {
if _, err := NewMediaFile("testdata/2018-04-12 19_24_49.mov"); err != nil {
assert.EqualError(t, err, "'testdata/2018-04-12 19_24_49.mov' is empty")
assert.ErrorContains(t, err, "testdata/2018-04-12 19_24_49.mov' is empty")
} else {
t.Errorf("error expected")
}
@ -2018,141 +2017,6 @@ func TestMediaFile_Orientation(t *testing.T) {
})
}
func TestMediaFile_Thumbnail(t *testing.T) {
conf := config.TestConfig()
if err := conf.CreateDirectories(); err != nil {
t.Error(err)
}
thumbsPath := conf.CachePath() + "/_tmp"
defer os.RemoveAll(thumbsPath)
t.Run("elephants.jpg", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
if err != nil {
t.Fatal(err)
}
assert.FileExists(t, thumbnail)
})
t.Run("invalid image format", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.xmp")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
assert.EqualError(t, err, "media: failed creating thumbnail for canon_eos_6d.xmp (image: unknown format)")
t.Log(thumbnail)
})
t.Run("invalid thumbnail type", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Thumbnail(thumbsPath, "invalid_500")
assert.EqualError(t, err, "media: invalid type invalid_500")
t.Log(thumbnail)
})
}
func TestMediaFile_Resample(t *testing.T) {
conf := config.TestConfig()
if err := conf.CreateDirectories(); err != nil {
t.Error(err)
}
thumbsPath := conf.CachePath() + "/_tmp"
defer os.RemoveAll(thumbsPath)
t.Run("elephants.jpg", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Resample(thumbsPath, thumb.Tile500)
if err != nil {
t.Fatal(err)
}
assert.NotEmpty(t, thumbnail)
})
t.Run("invalid type", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Resample(thumbsPath, "xxx_500")
if err == nil {
t.Fatal("err should not be nil")
}
assert.Equal(t, "media: invalid type xxx_500", err.Error())
assert.Empty(t, thumbnail)
})
}
func TestMediaFile_RenderDefaultThumbs(t *testing.T) {
conf := config.TestConfig()
thumbsPath := conf.CachePath() + "/_tmp"
defer os.RemoveAll(thumbsPath)
if err := conf.CreateDirectories(); err != nil {
t.Error(err)
}
m, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "elephants.jpg"))
if err != nil {
t.Fatal(err)
}
err = m.CreateThumbnails(thumbsPath, true)
if err != nil {
t.Fatal(err)
}
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)
}
assert.FileExists(t, thumbFilename)
err = m.CreateThumbnails(thumbsPath, false)
assert.Empty(t, err)
}
func TestMediaFile_FileType(t *testing.T) {
m, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "this-is-a-jpeg.png"))

View file

@ -0,0 +1,141 @@
package photoprism
import (
"fmt"
"image"
"time"
"github.com/disintegration/imaging"
"github.com/dustin/go-humanize/english"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/capture"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// Bounds returns the media dimensions as image.Rectangle.
func (m *MediaFile) Bounds() image.Rectangle {
return image.Rectangle{Min: image.Point{}, Max: image.Point{X: m.Width(), Y: m.Height()}}
}
// Thumbnail returns a thumbnail filename.
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", sizeName)
return "", fmt.Errorf("media: invalid type %s", sizeName)
}
// Choose the smallest fitting size if the original image is smaller.
if size.Fit && m.Bounds().In(size.Bounds()) {
size = thumb.FitBounds(m.Bounds())
log.Tracef("media: smallest fitting size for %s is %s (width %d, height %d)", clean.Log(m.RootRelName()), size.Name, size.Width, size.Height)
}
thumbName, err := size.FromFile(m.FileName(), m.Hash(), path, m.Orientation())
if err != nil {
err = fmt.Errorf("media: failed creating thumbnail for %s (%s)", clean.Log(m.BaseName()), err)
log.Debug(err)
return "", err
}
return thumbName, nil
}
// Resample returns a resampled image of the file.
func (m *MediaFile) Resample(path string, sizeName thumb.Name) (img image.Image, err error) {
thumbName, err := m.Thumbnail(path, sizeName)
if err != nil {
return nil, err
}
return imaging.Open(thumbName)
}
// CreateThumbnails creates the default thumbnail sizes if the media file
// is a JPEG and they don't exist yet (except force is true).
func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) {
if !m.IsJpeg() {
// Skip.
return
}
count := 0
start := time.Now()
defer func() {
switch count {
case 0:
log.Debug(capture.Time(start, fmt.Sprintf("media: created no new thumbnails for %s", clean.Log(m.RootRelName()))))
default:
log.Info(capture.Time(start, fmt.Sprintf("media: created %s for %s", english.Plural(count, "thumbnail", "thumbnails"), clean.Log(m.RootRelName()))))
}
}()
hash := m.Hash()
var original image.Image
var srcImg image.Image
var srcName thumb.Name
for _, name := range thumb.Names {
var size thumb.Size
var fileName string
if size = thumb.Sizes[name]; size.Uncached() {
// Skip, exceeds pre-cached size limit.
continue
} else if fileName, err = size.FileName(hash, thumbPath); err != nil {
log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err)
return err
} else if force || !fs.FileExists(fileName) {
// Open original if needed.
if original == nil {
img, err := thumb.Open(m.FileName(), m.Orientation())
if err != nil {
log.Debugf("media: %s in %s", err.Error(), clean.Log(m.RootRelName()))
return err
}
original = img
log.Debugf("media: opened %s [%s]", clean.Log(m.RootRelName()), thumb.MemSize(original).String())
}
// Thumb size too large
// for the original image?
if size.Skip(original) {
continue
}
// Reuse existing thumb to improve performance
// and reduce server load?
if size.Source != "" {
if size.Source == srcName && srcImg != nil {
_, err = size.Create(srcImg, fileName)
} else {
_, err = size.Create(original, fileName)
}
} else {
srcImg, err = size.Create(original, fileName)
srcName = name
}
// Failed?
if err != nil {
log.Errorf("media: failed creating %s (%s)", name.String(), err)
return err
}
count++
}
}
return nil
}

View file

@ -0,0 +1,180 @@
package photoprism
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/thumb"
)
func TestMediaFile_Thumbnail(t *testing.T) {
conf := config.TestConfig()
if err := conf.CreateDirectories(); err != nil {
t.Error(err)
}
thumbsPath := conf.CachePath() + "/.test_mediafile_thumbnail"
defer os.RemoveAll(thumbsPath)
t.Run("elephants.jpg", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
if err != nil {
t.Fatal(err)
}
assert.FileExists(t, thumbnail)
})
t.Run("invalid image format", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.xmp")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
assert.EqualError(t, err, "media: failed creating thumbnail for canon_eos_6d.xmp (image: unknown format)")
t.Log(thumbnail)
})
t.Run("invalid thumbnail type", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Thumbnail(thumbsPath, "invalid_500")
assert.EqualError(t, err, "media: invalid type invalid_500")
t.Log(thumbnail)
})
}
func TestMediaFile_Resample(t *testing.T) {
conf := config.TestConfig()
if err := conf.CreateDirectories(); err != nil {
t.Error(err)
}
thumbsPath := conf.CachePath() + "/.test_mediafile_resample"
defer func(path string) {
_ = os.RemoveAll(path)
}(thumbsPath)
t.Run("elephants.jpg", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Resample(thumbsPath, thumb.Tile500)
if err != nil {
t.Fatal(err)
}
assert.NotEmpty(t, thumbnail)
})
t.Run("invalid type", func(t *testing.T) {
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
thumbnail, err := image.Resample(thumbsPath, "xxx_500")
if err == nil {
t.Fatal("err should not be nil")
}
assert.Equal(t, "media: invalid type xxx_500", err.Error())
assert.Empty(t, thumbnail)
})
}
func TestMediaFile_CreateThumbnails(t *testing.T) {
c := config.TestConfig()
thumbsPath := "./.test_mediafile_createthumbnails"
if p, err := filepath.Abs(thumbsPath); err != nil {
t.Fatal(err)
} else {
thumbsPath = p
}
defer func(path string) {
_ = os.RemoveAll(path)
}(thumbsPath)
if err := c.CreateDirectories(); err != nil {
t.Fatal(err)
}
t.Run("elephants.jpg", func(t *testing.T) {
m, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "elephants.jpg"))
if err != nil {
t.Fatal(err)
}
err = m.CreateThumbnails(thumbsPath, true)
if err != nil {
t.Fatal(err)
}
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)
}
assert.FileExists(t, thumbFilename)
assert.NoError(t, m.CreateThumbnails(thumbsPath, false))
})
t.Run("animated-earth.jpg", func(t *testing.T) {
m, err := NewMediaFile("testdata/animated-earth.jpg")
if err != nil {
t.Fatal(err)
}
err = m.CreateThumbnails(thumbsPath, true)
if err != nil {
t.Fatal(err)
}
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)
}
assert.FileExists(t, thumbFilename)
assert.NoError(t, m.CreateThumbnails(thumbsPath, false))
})
}

View file

@ -5,16 +5,16 @@ import (
"strings"
"testing"
"github.com/photoprism/photoprism/internal/face"
"github.com/stretchr/testify/assert"
"github.com/disintegration/imaging"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/internal/config"
"github.com/stretchr/testify/assert"
)
func TestResample_Start(t *testing.T) {
@ -78,21 +78,21 @@ func TestThumb_Filename(t *testing.T) {
t.FailNow()
}
assert.Equal(t, "resample: file hash is empty or too short (999)", err.Error())
assert.Equal(t, "thumb: file hash is empty or too short (999)", err.Error())
})
t.Run("invalid width", func(t *testing.T) {
_, err := thumb.FileName("99988", thumbsPath, -4, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor)
if err == nil {
t.FailNow()
}
assert.Equal(t, "resample: width exceeds limit (-4)", err.Error())
assert.Equal(t, "thumb: width exceeds limit (-4)", err.Error())
})
t.Run("invalid height", func(t *testing.T) {
_, err := thumb.FileName("99988", thumbsPath, 200, -1, thumb.ResampleFit, thumb.ResampleNearestNeighbor)
if err == nil {
t.FailNow()
}
assert.Equal(t, "resample: height exceeds limit (-1)", err.Error())
assert.Equal(t, "thumb: height exceeds limit (-1)", err.Error())
})
t.Run("empty thumbpath", func(t *testing.T) {
path := ""
@ -100,7 +100,7 @@ func TestThumb_Filename(t *testing.T) {
if err == nil {
t.FailNow()
}
assert.Equal(t, "resample: folder is empty", err.Error())
assert.Equal(t, "thumb: folder is empty", err.Error())
})
}
@ -138,7 +138,7 @@ func TestThumb_FromFile(t *testing.T) {
t.Fatal("err should NOT be nil")
}
assert.Equal(t, "resample: invalid file hash 123", err.Error())
assert.Equal(t, "thumb: invalid file hash 123", err.Error())
})
t.Run("filename too short", func(t *testing.T) {
file := &entity.File{
@ -147,7 +147,7 @@ func TestThumb_FromFile(t *testing.T) {
}
if _, err := thumb.FromFile(file.FileName, file.FileHash, thumbsPath, 224, 224, file.FileOrientation); err != nil {
assert.Equal(t, "resample: invalid file name xxx", err.Error())
assert.Equal(t, "thumb: invalid file name xxx", err.Error())
} else {
t.Error("error is nil")
}
@ -241,7 +241,7 @@ func TestThumb_Create(t *testing.T) {
thumbnail := res
assert.Equal(t, "resample: width has an invalid value (-1)", err.Error())
assert.Equal(t, "thumb: width has an invalid value (-1)", err.Error())
bounds := thumbnail.Bounds()
assert.NotEqual(t, 150, bounds.Dx())
})
@ -267,7 +267,7 @@ func TestThumb_Create(t *testing.T) {
thumbnail := res
assert.Equal(t, "resample: height has an invalid value (-1)", err.Error())
assert.Equal(t, "thumb: height has an invalid value (-1)", err.Error())
bounds := thumbnail.Bounds()
assert.NotEqual(t, 150, bounds.Dx())
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -29,15 +29,15 @@ func (photo Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken
DownloadUrl: viewer.DownloadUrl(photo.FileHash, apiUri, downloadToken),
Width: photo.FileWidth,
Height: photo.FileHeight,
Thumbs: viewer.Thumbs{
Fit720: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
Fit1280: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
Fit1920: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
Fit2048: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
Fit2560: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
Fit3840: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
Fit4096: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
Fit7680: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
Thumbs: thumb.Public{
Fit720: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
Fit1280: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
Fit1920: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
Fit2048: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
Fit2560: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
Fit3840: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
Fit4096: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
Fit7680: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
},
}
}
@ -70,15 +70,15 @@ func (photo GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadTo
DownloadUrl: viewer.DownloadUrl(photo.FileHash, apiUri, downloadToken),
Width: photo.FileWidth,
Height: photo.FileHeight,
Thumbs: viewer.Thumbs{
Fit720: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
Fit1280: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
Fit1920: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
Fit2048: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
Fit2560: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
Fit3840: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
Fit4096: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
Fit7680: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
Thumbs: thumb.Public{
Fit720: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
Fit1280: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
Fit1920: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
Fit2048: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
Fit2560: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
Fit3840: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
Fit4096: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
Fit7680: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
},
}
}

View file

@ -24,22 +24,22 @@ func Suffix(width, height int, opts ...ResampleOption) (result string) {
return result
}
// FileName returns the thumb cache file name based on path, size, and options.
func FileName(hash string, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
// FileName returns the file name of the thumbnail for the matching size.
func FileName(hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
if InvalidSize(width) {
return "", fmt.Errorf("resample: width exceeds limit (%d)", width)
return "", fmt.Errorf("thumb: width exceeds limit (%d)", width)
}
if InvalidSize(height) {
return "", fmt.Errorf("resample: height exceeds limit (%d)", height)
return "", fmt.Errorf("thumb: height exceeds limit (%d)", height)
}
if len(hash) < 4 {
return "", fmt.Errorf("resample: file hash is empty or too short (%s)", clean.Log(hash))
return "", fmt.Errorf("thumb: file hash is empty or too short (%s)", clean.Log(hash))
}
if len(thumbPath) == 0 {
return "", errors.New("resample: folder is empty")
return "", errors.New("thumb: folder is empty")
}
suffix := Suffix(width, height, opts...)
@ -54,35 +54,42 @@ func FileName(hash string, thumbPath string, width, height int, opts ...Resample
return fileName, nil
}
// FromCache returns the thumb cache file name for an image.
// ResolvedName returns the file name of the thumbnail for the matching size with all symlinks resolved.
func ResolvedName(hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
if fileName, err = FileName(hash, thumbPath, width, height, opts...); err != nil {
return fileName, err
} else {
return fs.Resolve(fileName)
}
}
// FromCache returns the filename if a thumbnail image with the matching size is in the cache.
func FromCache(imageFilename, hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
if len(hash) < 4 {
return "", fmt.Errorf("resample: invalid file hash %s", clean.Log(hash))
return "", fmt.Errorf("thumb: invalid file hash %s", clean.Log(hash))
}
if len(imageFilename) < 4 {
return "", fmt.Errorf("resample: invalid file name %s", clean.Log(imageFilename))
return "", fmt.Errorf("thumb: invalid file name %s", clean.Log(imageFilename))
}
fileName, err = FileName(hash, thumbPath, width, height, opts...)
if err != nil {
log.Error(err)
if fileName, err = FileName(hash, thumbPath, width, height, opts...); err != nil {
log.Debugf("thumb: %s in %s (get filename)", err, clean.Log(imageFilename))
return "", err
}
if fs.FileExists(fileName) {
} else if fileName, err = fs.Resolve(fileName); err != nil {
return "", ErrNotCached
} else if fs.FileExists(fileName) {
return fileName, nil
}
return "", ErrThumbNotCached
return "", ErrNotCached
}
// FromFile returns the thumb cache file name for an image, and creates it if needed.
// FromFile creates a new thumbnail with the specified size if it was not found in the cache, and returns the filename.
func FromFile(imageFilename, hash, thumbPath string, width, height, orientation int, opts ...ResampleOption) (fileName string, err error) {
if fileName, err := FromCache(imageFilename, hash, thumbPath, width, height, opts...); err == nil {
if fileName, err = FromCache(imageFilename, hash, thumbPath, width, height, opts...); err == nil {
return fileName, err
} else if err != ErrThumbNotCached {
} else if err != ErrNotCached {
return "", err
}
@ -98,12 +105,12 @@ func FromFile(imageFilename, hash, thumbPath string, width, height, orientation
img, err := Open(imageFilename, orientation)
if err != nil {
log.Debugf("resample: %s in %s", err, clean.Log(filepath.Base(imageFilename)))
log.Debugf("thumb: %s in %s", err, clean.Log(filepath.Base(imageFilename)))
return "", err
}
// Create thumb from image.
if _, err := Create(img, fileName, width, height, opts...); err != nil {
if _, err = Create(img, fileName, width, height, opts...); err != nil {
return "", err
}
@ -113,11 +120,11 @@ func FromFile(imageFilename, hash, thumbPath string, width, height, orientation
// Create creates an image thumbnail.
func Create(img image.Image, fileName string, width, height int, opts ...ResampleOption) (result image.Image, err error) {
if InvalidSize(width) {
return img, fmt.Errorf("resample: width has an invalid value (%d)", width)
return img, fmt.Errorf("thumb: width has an invalid value (%d)", width)
}
if InvalidSize(height) {
return img, fmt.Errorf("resample: height has an invalid value (%d)", height)
return img, fmt.Errorf("thumb: height has an invalid value (%d)", height)
}
result = Resample(img, width, height, opts...)
@ -135,7 +142,7 @@ func Create(img image.Image, fileName string, width, height int, opts ...Resampl
err = imaging.Save(result, fileName, quality)
if err != nil {
log.Debugf("resample: failed to save %s", clean.Log(filepath.Base(fileName)))
log.Debugf("thumb: failed to save %s", clean.Log(filepath.Base(fileName)))
return result, err
}

View file

@ -2,6 +2,7 @@ package thumb
import (
"os"
"strings"
"testing"
"github.com/disintegration/imaging"
@ -177,7 +178,7 @@ func TestFileName(t *testing.T) {
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "resample: width exceeds limit (-2)", err.Error())
assert.Equal(t, "thumb: width exceeds limit (-2)", err.Error())
assert.Empty(t, result)
})
t.Run("invalid height", func(t *testing.T) {
@ -188,7 +189,8 @@ func TestFileName(t *testing.T) {
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "resample: height exceeds limit (-3)", err.Error())
assert.Equal(t, "thumb: height exceeds limit (-3)", err.Error())
assert.Empty(t, result)
})
t.Run("invalid hash", func(t *testing.T) {
@ -199,7 +201,8 @@ func TestFileName(t *testing.T) {
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "resample: file hash is empty or too short (12)", err.Error())
assert.Equal(t, "thumb: file hash is empty or too short (12)", err.Error())
assert.Empty(t, result)
})
t.Run("invalid thumb path", func(t *testing.T) {
@ -210,7 +213,75 @@ func TestFileName(t *testing.T) {
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "resample: folder is empty", err.Error())
assert.Equal(t, "thumb: folder is empty", err.Error())
assert.Empty(t, result)
})
}
func TestResolvedName(t *testing.T) {
t.Run("colors", func(t *testing.T) {
colorThumb := Sizes[Colors]
result, err := ResolvedName("123456789098765432", "testdata", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
assert.Error(t, err)
assert.Equal(t, "", result)
})
t.Run("fit_720", func(t *testing.T) {
fit720 := Sizes[Fit720]
result, err := ResolvedName("123456789098765432", "testdata", fit720.Width, fit720.Height, fit720.Options...)
assert.Error(t, err)
assert.Equal(t, "", result)
})
t.Run("invalid width", func(t *testing.T) {
colorThumb := Sizes[Colors]
result, err := ResolvedName("123456789098765432", "testdata", -2, colorThumb.Height, colorThumb.Options...)
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "thumb: width exceeds limit (-2)", err.Error())
assert.Empty(t, result)
})
t.Run("invalid height", func(t *testing.T) {
colorThumb := Sizes[Colors]
result, err := ResolvedName("123456789098765432", "testdata", colorThumb.Width, -3, colorThumb.Options...)
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "thumb: height exceeds limit (-3)", err.Error())
assert.Empty(t, result)
})
t.Run("invalid hash", func(t *testing.T) {
colorThumb := Sizes[Colors]
result, err := ResolvedName("12", "testdata", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "thumb: file hash is empty or too short (12)", err.Error())
assert.Empty(t, result)
})
t.Run("invalid thumb path", func(t *testing.T) {
colorThumb := Sizes[Colors]
result, err := ResolvedName("123456789098765432", "", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "thumb: folder is empty", err.Error())
assert.Empty(t, result)
})
}
@ -230,7 +301,6 @@ func TestFromFile(t *testing.T) {
}
assert.Equal(t, dst, fileName)
assert.FileExists(t, dst)
})
@ -247,8 +317,7 @@ func TestFromFile(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, dst, fileName)
assert.Truef(t, strings.HasSuffix(fileName, dst), fileName, dst)
assert.FileExists(t, dst)
})
@ -272,7 +341,7 @@ func TestFromFile(t *testing.T) {
t.Fatal("error expected")
}
assert.Equal(t, "", fileName)
assert.Equal(t, "resample: invalid file name ''", err.Error())
assert.Equal(t, "thumb: invalid file name ''", err.Error())
})
}
@ -287,8 +356,8 @@ func TestFromCache(t *testing.T) {
assert.Equal(t, "", fileName)
if err != ErrThumbNotCached {
t.Fatal("ErrThumbNotCached expected")
if err != ErrNotCached {
t.Fatal("ErrNotCached expected")
}
})
@ -314,7 +383,8 @@ func TestFromCache(t *testing.T) {
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "resample: invalid file hash 12", err.Error())
assert.Equal(t, "thumb: invalid file hash 12", err.Error())
assert.Empty(t, fileName)
})
t.Run("empty filename", func(t *testing.T) {
@ -325,7 +395,8 @@ func TestFromCache(t *testing.T) {
if err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "resample: invalid file name ''", err.Error())
assert.Equal(t, "thumb: invalid file name ''", err.Error())
assert.Empty(t, fileName)
})
}
@ -430,7 +501,7 @@ func TestCreate(t *testing.T) {
t.Fatal("error expected")
}
assert.Equal(t, "resample: width has an invalid value (-5)", err.Error())
assert.Equal(t, "thumb: width has an invalid value (-5)", err.Error())
t.Log(resized)
})
t.Run("invalid height", func(t *testing.T) {
@ -458,7 +529,7 @@ func TestCreate(t *testing.T) {
t.Fatal("error expected")
}
assert.Equal(t, "resample: height has an invalid value (-3)", err.Error())
t.Log(resized)
assert.Equal(t, "thumb: height has an invalid value (-3)", err.Error())
assert.NotNil(t, resized)
})
}

View file

@ -5,5 +5,5 @@ import (
)
var (
ErrThumbNotCached = errors.New("thumbnail not cached")
ErrNotCached = errors.New("not cached")
)

34
internal/thumb/fit.go Normal file
View file

@ -0,0 +1,34 @@
package thumb
import "image"
// Fitted contains only "fit" cropped thumbnail sizes from largest to smallest.
// Best for the viewer as proportional resizing maintains the aspect ratio.
var Fitted = []Size{
Sizes[Fit7680],
Sizes[Fit4096],
Sizes[Fit3840],
Sizes[Fit2560],
Sizes[Fit2048],
Sizes[Fit1920],
Sizes[Fit1280],
Sizes[Fit720],
}
// Fit returns the largest fitting thumbnail size.
func Fit(w, h int) (size Size) {
j := len(Fitted) - 1
for i := j; i >= 0; i-- {
if size = Fitted[i]; w <= size.Width && h <= size.Height {
return size
}
}
return Fitted[0]
}
// FitBounds returns the largest thumbnail size fitting the rectangle.
func FitBounds(r image.Rectangle) (s Size) {
return Fit(r.Dx(), r.Dy())
}

View file

@ -0,0 +1,82 @@
package thumb
import (
"testing"
"github.com/disintegration/imaging"
"github.com/stretchr/testify/assert"
)
func TestFit(t *testing.T) {
assert.Equal(t, Sizes[Fit720], Fit(54, 453))
assert.Equal(t, Sizes[Fit1280], Fit(1000, 1000))
assert.Equal(t, Sizes[Fit1280], Fit(1250, 1000))
assert.Equal(t, Sizes[Fit2048], Fit(1300, 1300))
assert.Equal(t, Sizes[Fit2048], Fit(1600, 1600))
assert.Equal(t, Sizes[Fit4096], Fit(1000, 3000))
assert.Equal(t, Sizes[Fit3840], Fit(2300, 2000))
}
func TestFitBounds(t *testing.T) {
t.Run("example.jpg", func(t *testing.T) {
src := "testdata/example.jpg"
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
bounds := img.Bounds()
assert.Equal(t, 750, bounds.Max.X)
assert.Equal(t, 500, bounds.Max.Y)
size := FitBounds(img.Bounds())
assert.Equal(t, "fit_1280", size.Name.String())
})
t.Run("example.bmp", func(t *testing.T) {
src := "testdata/example.bmp"
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
bounds := img.Bounds()
assert.Equal(t, 100, bounds.Max.X)
assert.Equal(t, 67, bounds.Max.Y)
size := FitBounds(img.Bounds())
assert.Equal(t, "fit_720", size.Name.String())
})
t.Run("animated-earth.jpg", func(t *testing.T) {
src := "testdata/animated-earth.jpg"
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
bounds := img.Bounds()
assert.Equal(t, 300, bounds.Max.X)
assert.Equal(t, 300, bounds.Max.Y)
size := FitBounds(img.Bounds())
assert.Equal(t, "fit_720", size.Name.String())
})
}

View file

@ -5,27 +5,42 @@ import (
"path/filepath"
"github.com/disintegration/imaging"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// Jpeg converts an image to jpeg, saves and returns it.
func Jpeg(srcFilename, jpgFilename string, orientation int) (img image.Image, err error) {
img, err = imaging.Open(srcFilename)
if err != nil {
log.Errorf("resample: cannot open %s", clean.Log(filepath.Base(srcFilename)))
// Resolve symlinks.
if srcFilename, err = fs.Resolve(srcFilename); err != nil {
log.Debugf("jpeg: %s in %s (resolve source image filename)", err, clean.Log(srcFilename))
return img, err
}
// Open source image.
img, err = imaging.Open(srcFilename)
// Failed?
if err != nil {
log.Errorf("jpeg: cannot open source image %s", clean.Log(filepath.Base(srcFilename)))
return img, err
}
// Adjust orientation.
if orientation > 1 {
img = Rotate(img, orientation)
}
// Get JPEG quality setting.
quality := JpegQuality.EncodeOption()
// Save JPEG file.
if err = imaging.Save(img, jpgFilename, quality); err != nil {
log.Errorf("resample: failed to save %s", clean.Log(filepath.Base(jpgFilename)))
log.Errorf("jpeg: failed to save %s", clean.Log(filepath.Base(jpgFilename)))
return img, err
}
// Return JPEG image.
return img, nil
}

61
internal/thumb/memsize.go Normal file
View file

@ -0,0 +1,61 @@
package thumb
import (
"image"
"image/color"
"github.com/dustin/go-humanize"
)
// Byte size factors.
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
// Bytes represents memory usage in bytes.
type Bytes uint64
// KByte returns the size in kilobyte.
func (b Bytes) KByte() float64 {
return float64(b) / KB
}
// MByte returns the size in megabyte.
func (b Bytes) MByte() float64 {
return float64(b) / MB
}
// GByte returns the size in gigabyte.
func (b Bytes) GByte() float64 {
return float64(b) / GB
}
// String returns a human-readable memory usage string.
func (b Bytes) String() string {
return humanize.Bytes(uint64(b))
}
// MemSize returns the estimated size of the image in memory in bytes.
func MemSize(img image.Image) Bytes {
r := img.Bounds()
pixels := r.Dx() * r.Dy()
bytesPerPixel := 4
// Image representation in a computer memory:
// https://medium.com/@oleg.shipitko/what-does-stride-mean-in-image-processing-bba158a72bcd
switch img.ColorModel() {
case color.AlphaModel, color.GrayModel:
bytesPerPixel = 1
case color.Alpha16Model, color.Gray16Model:
bytesPerPixel = 2
case color.RGBAModel, color.NRGBAModel:
bytesPerPixel = 4
case color.RGBA64Model, color.NRGBA64Model:
bytesPerPixel = 8
}
return Bytes(pixels * bytesPerPixel)
}

View file

@ -0,0 +1,26 @@
package thumb
import (
"testing"
"github.com/disintegration/imaging"
"github.com/stretchr/testify/assert"
)
func TestMemSize(t *testing.T) {
src := "testdata/example.jpg"
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
result := MemSize(img)
assert.InEpsilon(t, 1464, result.KByte(), 1)
assert.InEpsilon(t, 1.430511474, result.MByte(), 0.1)
assert.Equal(t, "1.5 MB", result.String())
}

View file

@ -33,3 +33,35 @@ const (
Fit4096 Name = "fit_4096"
Fit7680 Name = "fit_7680"
)
// Names contains all default size names.
var Names = []Name{
Fit7680,
Fit4096,
Fit3840,
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 Name, size Size) {
for _, name = range Names {
t := Sizes[name]
if t.Width <= limit && t.Height <= limit {
return name, t
}
}
return "", Size{}
}

View file

@ -11,3 +11,19 @@ func TestName_Jpeg(t *testing.T) {
assert.Equal(t, "tile_50.jpg", Tile50.Jpeg())
})
}
func TestFind(t *testing.T) {
t.Run("2048", func(t *testing.T) {
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) {
name, size := Find(2000)
assert.Equal(t, Fit1920, name)
assert.Equal(t, 1920, size.Width)
assert.Equal(t, 1200, size.Height)
})
}

View file

@ -5,6 +5,7 @@ import (
"image"
"github.com/disintegration/imaging"
"github.com/photoprism/photoprism/pkg/fs"
)
@ -13,10 +14,16 @@ var StandardRGB = true
// Open loads an image from disk, rotates it, and converts the color profile if necessary.
func Open(fileName string, orientation int) (result image.Image, err error) {
// Filename missing?
if fileName == "" {
return result, fmt.Errorf("filename missing")
}
// Resolve symlinks.
if fileName, err = fs.Resolve(fileName); err != nil {
return result, err
}
// Open JPEG?
if StandardRGB && fs.FileType(fileName) == fs.ImageJPEG {
return OpenJpeg(fileName, orientation)
@ -29,7 +36,7 @@ func Open(fileName string, orientation int) (result image.Image, err error) {
return result, err
}
// Rotate?
// Adjust orientation.
if orientation > 1 {
img = Rotate(img, orientation)
}

View file

@ -45,7 +45,7 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error)
var img image.Image
if err != nil {
log.Warnf("resample: %s in %s (read color metadata)", err, logName)
log.Warnf("thumb: %s in %s (read color metadata)", err, logName)
img, err = imaging.Decode(fileReader)
} else {
img, err = imaging.Decode(imgStream)
@ -59,9 +59,9 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error)
if md != nil {
if iccProfile, err := md.ICCProfile(); err != nil || iccProfile == nil {
// Do nothing.
log.Tracef("resample: %s has no color profile", logName)
log.Tracef("thumb: %s has no color profile", logName)
} else if profile, err := iccProfile.Description(); err == nil && profile != "" {
log.Tracef("resample: %s has color profile %s", logName, clean.Log(profile))
log.Tracef("thumb: %s has color profile %s", logName, clean.Log(profile))
switch {
case colors.ProfileDisplayP3.Equal(profile):
img = colors.ToSRGB(img, colors.ProfileDisplayP3)
@ -69,7 +69,7 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error)
}
}
// Rotate?
// Adjust orientation.
if orientation > 1 {
img = Rotate(img, orientation)
}

13
internal/thumb/public.go Normal file
View file

@ -0,0 +1,13 @@
package thumb
// Public represents public thumbnail URLs with dimensions.
type Public struct {
Fit720 Thumb `json:"fit_720"`
Fit1280 Thumb `json:"fit_1280"`
Fit1920 Thumb `json:"fit_1920"`
Fit2048 Thumb `json:"fit_2048"`
Fit2560 Thumb `json:"fit_2560"`
Fit3840 Thumb `json:"fit_3840"`
Fit4096 Thumb `json:"fit_4096"`
Fit7680 Thumb `json:"fit_7680"`
}

View file

@ -40,7 +40,7 @@ func Rotate(img image.Image, o int) image.Image {
case OrientationTransverse:
img = imaging.Transverse(img)
default:
log.Debugf("rotate: invalid orientation %d", o)
log.Debugf("thumb: invalid orientation %d (rotate)", o)
}
return img

67
internal/thumb/size.go Normal file
View file

@ -0,0 +1,67 @@
package thumb
import (
"image"
)
type Size struct {
Name Name `json:"name"`
Source Name `json:"-"`
Use string `json:"use"`
Width int `json:"w"`
Height int `json:"h"`
Public bool `json:"-"`
Fit bool `json:"-"`
Options []ResampleOption `json:"-"`
}
// Bounds returns the thumb size as image.Rectangle.
func (s Size) Bounds() image.Rectangle {
return image.Rectangle{Min: image.Point{}, Max: image.Point{X: s.Width, Y: s.Height}}
}
// Uncached tests if thumbnail type exceeds the cached thumbnails size limit.
func (s Size) Uncached() bool {
return s.Width > SizePrecached || s.Height > SizePrecached
}
// ExceedsLimit tests if thumbnail type is too large, and can not be rendered at all.
func (s Size) ExceedsLimit() bool {
return s.Width > MaxSize() || s.Height > MaxSize()
}
// FromCache returns the filename if a thumbnail image with the matching size is in the cache.
func (s Size) FromCache(fileName, fileHash, cachePath string) (string, error) {
return FromCache(fileName, fileHash, cachePath, s.Width, s.Height, s.Options...)
}
// FromFile creates a new thumbnail with the matching size if it was not found in the cache, and returns the filename.
func (s Size) FromFile(fileName, fileHash, cachePath string, fileOrientation int) (string, error) {
return FromFile(fileName, fileHash, cachePath, s.Width, s.Height, fileOrientation, s.Options...)
}
// Create creates a thumbnail with the matching size and returns it as image.Image.
func (s Size) Create(img image.Image, fileName string) (image.Image, error) {
return Create(img, fileName, s.Width, s.Height, s.Options...)
}
// FileName returns the file name of the thumbnail for the matching size.
func (s Size) FileName(hash, thumbPath string) (string, error) {
return FileName(hash, thumbPath, s.Width, s.Height, s.Options...)
}
// ResolvedName returns the file name of the thumbnail for the matching size with all symlinks resolved.
func (s Size) ResolvedName(hash, thumbPath string) (string, error) {
return ResolvedName(hash, thumbPath, s.Width, s.Height, s.Options...)
}
// Skip checks if the thumbnail size is too large for the image and can be skipped.
func (s Size) Skip(img image.Image) bool {
if !s.Fit || !img.Bounds().In(s.Bounds()) {
return false
} else if newSize := FitBounds(img.Bounds()); newSize.Width < s.Width {
return true
}
return false
}

View file

@ -0,0 +1,79 @@
package thumb
import (
"testing"
"github.com/disintegration/imaging"
"github.com/stretchr/testify/assert"
)
func TestSize_Skip(t *testing.T) {
// Image Size: 750x500px
src := "testdata/example.jpg"
t.Run("Tile500", func(t *testing.T) {
size := Sizes[Tile500]
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
assert.False(t, size.Skip(img))
})
t.Run("Fit720", func(t *testing.T) {
size := Sizes[Fit720]
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
assert.False(t, size.Skip(img))
})
t.Run("Fit1280", func(t *testing.T) {
size := Sizes[Fit1280]
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
assert.False(t, size.Skip(img))
})
t.Run("Fit2048", func(t *testing.T) {
size := Sizes[Fit2048]
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
assert.True(t, size.Skip(img))
})
t.Run("Fit4096", func(t *testing.T) {
size := Sizes[Fit4096]
assert.FileExists(t, src)
img, err := imaging.Open(src, imaging.AutoOrientation(true))
if err != nil {
t.Fatal(err)
}
assert.True(t, size.Skip(img))
})
}

View file

@ -6,6 +6,7 @@ var (
Filter = ResampleLanczos
)
// MaxSize returns the max supported thumb size in pixels.
func MaxSize() int {
if SizePrecached > SizeUncached {
return SizePrecached
@ -14,78 +15,29 @@ func MaxSize() int {
return SizeUncached
}
// InvalidSize tests if the thumb size in pixels is invalid.
func InvalidSize(size int) bool {
return size < 0 || size > MaxSize()
}
type Size struct {
Name Name `json:"name"`
Source Name `json:"-"`
Use string `json:"use"`
Width int `json:"w"`
Height int `json:"h"`
Public bool `json:"-"`
Options []ResampleOption `json:"-"`
}
// SizeMap maps size names to sizes.
type SizeMap map[Name]Size
// Sizes contains the properties of all thumbnail sizes.
var Sizes = SizeMap{
Tile50: {Tile50, Tile500, "Lists", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile100: {Tile100, Tile500, "Maps", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile224: {Tile224, Tile500, "TensorFlow, Mosaic", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile500: {Tile500, "", "Tiles", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Colors: {Colors, Fit720, "Color Detection", 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}},
Left224: {Left224, Fit720, "TensorFlow", 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}},
Right224: {Right224, Fit720, "TensorFlow", 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}},
Fit720: {Fit720, "", "Mobile, TV", 720, 720, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit1280: {Fit1280, Fit2048, "Mobile, HD Ready TV", 1280, 1024, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit1920: {Fit1920, Fit2048, "Mobile, Full HD TV", 1920, 1200, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit2048: {Fit2048, "", "Tablets, Cinema 2K", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit2560: {Fit2560, "", "Quad HD, Retina Display", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit3840: {Fit3840, "", "Ultra HD", 3840, 2400, false, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096
Fit4096: {Fit4096, "", "Ultra HD, Retina 4K", 4096, 4096, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit7680: {Fit7680, "", "8K Ultra HD 2, Retina 6K", 7680, 4320, true, []ResampleOption{ResampleFit, ResampleDefault}},
}
// 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 Name, size Size) {
for _, name = range DefaultSizes {
t := Sizes[name]
if t.Width <= limit && t.Height <= limit {
return name, t
}
}
return "", Size{}
}
// Uncached tests if thumbnail type exceeds the cached thumbnails size limit.
func (s Size) Uncached() bool {
return s.Width > SizePrecached || s.Height > SizePrecached
}
// ExceedsLimit tests if thumbnail type is too large, and can not be rendered at all.
func (s Size) ExceedsLimit() bool {
return s.Width > MaxSize() || s.Height > MaxSize()
Tile50: {Tile50, Tile500, "Lists", 50, 50, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile100: {Tile100, Tile500, "Maps", 100, 100, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile224: {Tile224, Tile500, "TensorFlow, Mosaic", 224, 224, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Tile500: {Tile500, "", "Tiles", 500, 500, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
Colors: {Colors, Fit720, "Color Detection", 3, 3, false, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}},
Left224: {Left224, Fit720, "TensorFlow", 224, 224, false, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}},
Right224: {Right224, Fit720, "TensorFlow", 224, 224, false, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}},
Fit720: {Fit720, "", "Mobile, TV", 720, 720, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit1280: {Fit1280, Fit2048, "Mobile, HD Ready TV", 1280, 1024, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit1920: {Fit1920, Fit2048, "Mobile, Full HD TV", 1920, 1200, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit2048: {Fit2048, "", "Tablets, Cinema 2K", 2048, 2048, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit2560: {Fit2560, "", "Quad HD, Retina Display", 2560, 1600, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit3840: {Fit3840, "", "Ultra HD", 3840, 2400, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096
Fit4096: {Fit4096, "", "Ultra HD, Retina 4K", 4096, 4096, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
Fit7680: {Fit7680, "", "8K Ultra HD 2, Retina 6K", 7680, 4320, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
}

View file

@ -54,19 +54,3 @@ func TestResampleFilter_Imaging(t *testing.T) {
assert.Equal(t, float64(1), r.Support)
})
}
func TestFind(t *testing.T) {
t.Run("2048", func(t *testing.T) {
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) {
name, size := Find(2000)
assert.Equal(t, Fit1920, name)
assert.Equal(t, 1920, size.Width)
assert.Equal(t, 1200, size.Height)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -27,6 +27,9 @@ Additional information can be found in our Developer Guide:
package thumb
import (
"fmt"
"math"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
@ -39,3 +42,38 @@ import (
)
var log = event.Log
// Url returns a thumbnail url based on hash, thumb name, cdn uri, and preview token.
func Url(h, name, contentUri, previewToken string) string {
return fmt.Sprintf("%s/t/%s/%s/%s", contentUri, h, previewToken, name)
}
// Thumb represents a photo thumbnail.
type Thumb struct {
W int `json:"w"`
H int `json:"h"`
Src string `json:"src"`
}
// New creates a new photo thumbnail.
func New(w, h int, hash string, s Size, contentUri, previewToken string) Thumb {
if s.Width >= w && s.Height >= h {
// Smaller
return Thumb{W: w, H: h, Src: Url(hash, s.Name.String(), contentUri, previewToken)}
}
srcAspectRatio := float64(w) / float64(h)
maxAspectRatio := float64(s.Width) / float64(s.Height)
var newW, newH int
if srcAspectRatio > maxAspectRatio {
newW = s.Width
newH = int(math.Round(float64(newW) / srcAspectRatio))
} else {
newH = s.Height
newW = int(math.Round(float64(newH) * srcAspectRatio))
}
return Thumb{W: newW, H: newH, Src: Url(hash, s.Name.String(), contentUri, previewToken)}
}

View file

@ -6,6 +6,8 @@ import (
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
var logBuffer bytes.Buffer
@ -22,3 +24,25 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestNew(t *testing.T) {
fileHash := "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2"
contentUri := "/content"
previewToken := "preview-token"
t.Run("Fit1280", func(t *testing.T) {
result := New(1920, 1080, fileHash, Sizes[Fit1280], contentUri, previewToken)
assert.Equal(t, 1280, result.W)
assert.Equal(t, 720, result.H)
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_1280", result.Src)
})
t.Run("Fit3840", func(t *testing.T) {
result := New(1920, 1080, fileHash, Sizes[Fit3840], contentUri, previewToken)
assert.Equal(t, 1920, result.W)
assert.Equal(t, 1080, result.H)
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_3840", result.Src)
})
}

View file

@ -2,32 +2,22 @@ package viewer
import (
"time"
)
// Thumbs represents photo viewer thumbs in different sizes.
type Thumbs struct {
Fit720 Thumb `json:"fit_720"`
Fit1280 Thumb `json:"fit_1280"`
Fit1920 Thumb `json:"fit_1920"`
Fit2048 Thumb `json:"fit_2048"`
Fit2560 Thumb `json:"fit_2560"`
Fit3840 Thumb `json:"fit_3840"`
Fit4096 Thumb `json:"fit_4096"`
Fit7680 Thumb `json:"fit_7680"`
}
"github.com/photoprism/photoprism/internal/thumb"
)
// Result represents a photo viewer result.
type Result struct {
UID string `json:"UID"`
Title string `json:"Title"`
TakenAtLocal time.Time `json:"TakenAtLocal"`
Description string `json:"Description"`
Favorite bool `json:"Favorite"`
Playable bool `json:"Playable"`
DownloadUrl string `json:"DownloadUrl"`
Width int `json:"Width"`
Height int `json:"Height"`
Thumbs Thumbs `json:"Thumbs"`
UID string `json:"UID"`
Title string `json:"Title"`
TakenAtLocal time.Time `json:"TakenAtLocal"`
Description string `json:"Description"`
Favorite bool `json:"Favorite"`
Playable bool `json:"Playable"`
DownloadUrl string `json:"DownloadUrl"`
Width int `json:"Width"`
Height int `json:"Height"`
Thumbs thumb.Public `json:"Thumbs"`
}
// Results represents a list of viewer search results.

View file

@ -1,48 +0,0 @@
package viewer
import (
"fmt"
"math"
"github.com/photoprism/photoprism/internal/thumb"
)
// DownloadUrl returns a download url based on hash, api uri, and download token.
func DownloadUrl(h, apiUri, downloadToken string) string {
return fmt.Sprintf("%s/dl/%s?t=%s", apiUri, h, downloadToken)
}
// ThumbUrl returns a thumbnail url based on hash, thumb name, cdn uri, and preview token.
func ThumbUrl(h, name, contentUri, previewToken string) string {
return fmt.Sprintf("%s/t/%s/%s/%s", contentUri, h, previewToken, name)
}
// Thumb represents a photo viewer thumbnail.
type Thumb struct {
W int `json:"w"`
H int `json:"h"`
Src string `json:"src"`
}
// NewThumb creates a new photo viewer thumb.
func NewThumb(w, h int, hash string, s thumb.Size, contentUri, previewToken string) Thumb {
if s.Width >= w && s.Height >= h {
// Smaller
return Thumb{W: w, H: h, Src: ThumbUrl(hash, s.Name.String(), contentUri, previewToken)}
}
srcAspectRatio := float64(w) / float64(h)
maxAspectRatio := float64(s.Width) / float64(s.Height)
var newW, newH int
if srcAspectRatio > maxAspectRatio {
newW = s.Width
newH = int(math.Round(float64(newW) / srcAspectRatio))
} else {
newH = s.Height
newW = int(math.Round(float64(newH) * srcAspectRatio))
}
return Thumb{W: newW, H: newH, Src: ThumbUrl(hash, s.Name.String(), contentUri, previewToken)}
}

View file

@ -1,31 +0,0 @@
package viewer
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/thumb"
)
func TestNewThumb(t *testing.T) {
fileHash := "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2"
contentUri := "/content"
previewToken := "preview-token"
t.Run("Fit1280", func(t *testing.T) {
result := NewThumb(1920, 1080, fileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken)
assert.Equal(t, 1280, result.W)
assert.Equal(t, 720, result.H)
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_1280", result.Src)
})
t.Run("Fit3840", func(t *testing.T) {
result := NewThumb(1920, 1080, fileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken)
assert.Equal(t, 1920, result.W)
assert.Equal(t, 1080, result.H)
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_3840", result.Src)
})
}

10
internal/viewer/url.go Normal file
View file

@ -0,0 +1,10 @@
package viewer
import (
"fmt"
)
// DownloadUrl returns a download url based on hash, api uri, and download token.
func DownloadUrl(h, apiUri, downloadToken string) string {
return fmt.Sprintf("%s/dl/%s?t=%s", apiUri, h, downloadToken)
}

View file

@ -0,0 +1,23 @@
package viewer
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDownloadUrl(t *testing.T) {
apiUri := "/api/v1"
t.Run("WithToken", func(t *testing.T) {
dlToken := "3tcsggxy"
result := DownloadUrl("d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2", apiUri, dlToken)
assert.Equal(t, "/api/v1/dl/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2?t=3tcsggxy", result)
})
t.Run("NoToken", func(t *testing.T) {
dlToken := ""
result := DownloadUrl("653cd9e5754e98d899e9ba30c9075da4ebb16141", apiUri, dlToken)
assert.Equal(t, "/api/v1/dl/653cd9e5754e98d899e9ba30c9075da4ebb16141?t=", result)
})
}

View file

@ -8,15 +8,15 @@ import (
// CaseInsensitive tests if a storage path is case-insensitive.
func CaseInsensitive(storagePath string) (result bool, err error) {
tmpName := filepath.Join(storagePath, "caseTest.tmp")
tmpName := filepath.Join(storagePath, ".caseTest.tmp")
if err := os.WriteFile(tmpName, []byte("{}"), 0666); err != nil {
if err = os.WriteFile(tmpName, []byte("{}"), os.ModePerm); err != nil {
return false, fmt.Errorf("%s not writable", filepath.Base(storagePath))
}
defer os.Remove(tmpName)
result = FileExists(filepath.Join(storagePath, "CASETEST.TMP"))
result = FileExists(filepath.Join(storagePath, ".CASETEST.TMP"))
return result, err
}

View file

@ -141,6 +141,13 @@ func Dirs(root string, recursive bool, followLinks bool) (result []string, err e
return filepath.SkipDir
}
// Skip if symlink does not point to existing directory.
if typ == os.ModeSymlink {
if info, err := os.Stat(fileName); err != nil || !info.IsDir() {
return filepath.SkipDir
}
}
if fileName != root {
if !recursive {
appendResult(fileName)
@ -150,7 +157,7 @@ func Dirs(root string, recursive bool, followLinks bool) (result []string, err e
appendResult(fileName)
return nil
} else if resolved, err := filepath.EvalSymlinks(fileName); err == nil {
} else if resolved, err := Resolve(fileName); err == nil {
symlinksMutex.Lock()
defer symlinksMutex.Unlock()

21
pkg/fs/resolve.go Normal file
View file

@ -0,0 +1,21 @@
package fs
import (
"errors"
"path/filepath"
)
// Resolve returns the absolute file path, with all symlinks resolved.
func Resolve(filePath string) (string, error) {
if filePath == "" {
return "", errors.New("no such file or directory")
}
if target, err := filepath.EvalSymlinks(filePath); err != nil {
return "", errors.New("no such file or directory")
} else if target, err = filepath.Abs(target); target != "" {
return target, err
} else {
return filepath.Abs(filePath)
}
}

41
pkg/fs/resolve_test.go Normal file
View file

@ -0,0 +1,41 @@
package fs
import (
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestResolve(t *testing.T) {
tmpDir := os.TempDir()
linkName := filepath.Join(tmpDir, uuid.NewString()+"-link.tmp")
targetName := filepath.Join(tmpDir, uuid.NewString()+".tmp")
// Delete files after test.
defer func(link, target string) {
_ = os.Remove(link)
_ = os.Remove(target)
}(linkName, targetName)
// Create empty test target file.
if targetFile, err := os.OpenFile(targetName, os.O_RDONLY|os.O_CREATE, os.ModePerm); err != nil {
t.Fatal(err)
} else if err = targetFile.Close(); err != nil {
t.Fatal(err)
}
if err := os.Symlink(targetName, linkName); err != nil {
t.Fatal(err)
}
if result, err := Resolve(linkName); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, targetName, result)
}
}

39
pkg/fs/symlink.go Normal file
View file

@ -0,0 +1,39 @@
package fs
import (
"os"
"path/filepath"
"github.com/google/uuid"
)
// SymlinksSupported tests if a storage path supports symlinks.
func SymlinksSupported(storagePath string) (bool, error) {
linkName := filepath.Join(storagePath, uuid.NewString()+"-link.tmp")
targetName := filepath.Join(storagePath, uuid.NewString()+".tmp")
// Delete files after test.
defer func(link, target string) {
_ = os.Remove(link)
_ = os.Remove(target)
}(linkName, targetName)
// Create empty test target file.
if targetFile, err := os.OpenFile(targetName, os.O_RDONLY|os.O_CREATE, os.ModePerm); err != nil {
return false, err
} else if err = targetFile.Close(); err != nil {
return false, err
}
// Create test link.
if err := os.Symlink(filepath.Base(targetName), linkName); err != nil {
return false, err
}
// Resolve and compare test target.
if linkTarget, err := Resolve(linkName); err != nil {
return false, err
} else {
return linkTarget == targetName, nil
}
}

16
pkg/fs/symlink_test.go Normal file
View file

@ -0,0 +1,16 @@
package fs
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSymlinksSupported(t *testing.T) {
t.Run("TempDir", func(t *testing.T) {
ok, err := SymlinksSupported(os.TempDir())
assert.NoError(t, err)
assert.True(t, ok)
})
}

View file

@ -11,11 +11,10 @@ func SkipWalk(fileName string, isDir, isSymlink bool, done Done, ignore *IgnoreL
isIgnored := ignore.Ignore(fileName)
if isSymlink {
// Symlinks are skipped by default unless they are links to directories
skip = true
// Symlink points to directory?
if link, err := os.Stat(fileName); err == nil && link.IsDir() {
// Skip directories.
skip = true
resolved, err := filepath.EvalSymlinks(fileName)
if err != nil || isIgnored || isDone || done[resolved].Exists() {
@ -24,7 +23,9 @@ func SkipWalk(fileName string, isDir, isSymlink bool, done Done, ignore *IgnoreL
// Don't traverse symlinks if they are hidden or already done...
done[resolved] = Found
}
} else {
} else if err != nil {
// Also skip on error.
skip = true
result = filepath.SkipDir
}
} else if isDir {

2
pro

@ -1 +1 @@
Subproject commit b5c6911123794dd8549980f705e9e9e0590bccab
Subproject commit 16df622ddd594f38433fa294a94a2651065484fe