Configure on-demand rendering of regular thumbnail sizes #294

Can be enabled by setting PHOTOPRISM_RESAMPLE_UNCACHED to true

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-05-05 15:42:54 +02:00
parent ee6dd2be72
commit 1c53a565a7
27 changed files with 432 additions and 239 deletions

View file

@ -12,10 +12,11 @@ ENV PHOTOPRISM_PUBLIC true
ENV PHOTOPRISM_EXPERIMENTAL true
ENV PHOTOPRISM_UPLOAD_NSFW false
ENV PHOTOPRISM_DETECT_NSFW true
ENV PHOTOPRISM_THUMB_QUALITY 95
ENV PHOTOPRISM_THUMB_SIZE 3840
ENV PHOTOPRISM_THUMB_LIMIT 3840
ENV PHOTOPRISM_THUMB_FILTER lanczos
ENV PHOTOPRISM_JPEG_QUALITY 95
ENV PHOTOPRISM_RESAMPLE_SIZE 3840
ENV PHOTOPRISM_RESAMPLE_LIMIT 3840
ENV PHOTOPRISM_RESAMPLE_UNCACHED true
ENV PHOTOPRISM_RESAMPLE_FILTER lanczos
ENV PHOTOPRISM_GEOCODING_API places
# Import example photos

View file

@ -42,10 +42,14 @@ services:
PHOTOPRISM_WEBDAV_PASSWORD: "photoprism" # Plain text only (username "photoprism")
PHOTOPRISM_DATABASE_DRIVER: "tidb" # Change to "mysql" for external MySQL or MariaDB
PHOTOPRISM_DATABASE_DSN: "root:photoprism@tcp(localhost:2343)/photoprism?parseTime=true"
# PHOTOPRISM_THUMB_QUALITY: 95 # High-quality thumbnails (optional)
# PHOTOPRISM_THUMB_SIZE: 3840
# PHOTOPRISM_THUMB_LIMIT: 3840
# PHOTOPRISM_THUMB_FILTER: "lanczos"
# PHOTOPRISM_DATABASE_DRIVER: "mysql" # Using MariaDB or MySQL instead of the internal TiDB is optional
# PHOTOPRISM_DATABASE_DSN: "photoprism:photoprism@tcp(photoprism-db:3306)/photoprism?parseTime=true"
PHOTOPRISM_JPEG_QUALITY: 90 # Use 95 for high-quality thumbnails (requires more storage)
PHOTOPRISM_RESAMPLE_FILTER: "lanczos" # Resample filter, best to worst: blackman, lanczos, cubic, linear
PHOTOPRISM_RESAMPLE_UNCACHED: "false" # On-demand rendering of thumbnails (high memory and cpu usage)
PHOTOPRISM_RESAMPLE_SIZE: 2048 # Cached thumbnail size (default 2048, min 720, max 3840)
# PHOTOPRISM_RESAMPLE_SIZE: 3840 # For retina screens (requires more storage)
PHOTOPRISM_RESAMPLE_LIMIT: 3840 # On-demand thumbnail size (default 2048, min 720, max 3840)
volumes:
- "~/Pictures/Originals:/photoprism/originals" # [local path]:[container path]
- "~/Pictures/Import:/photoprism/import" # [local path]:[container path] (optional)

View file

@ -43,9 +43,12 @@ services:
PHOTOPRISM_DATABASE_DSN: "root:photoprism@tcp(localhost:2343)/photoprism?parseTime=true"
# PHOTOPRISM_DATABASE_DRIVER: "mysql" # Using MariaDB or MySQL instead of the internal TiDB is optional
# PHOTOPRISM_DATABASE_DSN: "photoprism:photoprism@tcp(photoprism-db:3306)/photoprism?parseTime=true"
# PHOTOPRISM_THUMB_QUALITY: 95 # High-quality thumbnails (optional, default JPEG quality is 90)
# PHOTOPRISM_THUMB_SIZE: 3840 # For retina screens, default is 2048
# PHOTOPRISM_THUMB_FILTER: "lanczos" # Resample filter, best to worst: blackman, lanczos, cubic, linear
PHOTOPRISM_JPEG_QUALITY: 90 # Use 95 for high-quality thumbnails (requires more storage)
PHOTOPRISM_RESAMPLE_FILTER: "lanczos" # Resample filter, best to worst: blackman, lanczos, cubic, linear
PHOTOPRISM_RESAMPLE_UNCACHED: "false" # On-demand rendering of thumbnails (high memory and cpu usage)
PHOTOPRISM_RESAMPLE_SIZE: 2048 # Cached thumbnail size (default 2048, min 720, max 3840)
# PHOTOPRISM_RESAMPLE_SIZE: 3840 # For retina screens (requires more storage)
PHOTOPRISM_RESAMPLE_LIMIT: 3840 # On-demand thumbnail size (default 2048, min 720, max 3840)
volumes:
- "~/Pictures/Originals:/photoprism/originals" # [local path]:[container path]
- "~/Pictures/Import:/photoprism/import" # [local path]:[container path] (optional)

2
go.mod
View file

@ -16,7 +16,7 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200401235223-7e979d0e0d02 // indirect
github.com/dsoprea/go-png-image-structure v0.0.0-20200402000326-c0fdb803026f
github.com/dsoprea/go-utility v0.0.0-20200412174200-5aee815e0920 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dustin/go-humanize v1.0.0
github.com/gin-gonic/gin v1.6.2
github.com/go-errors/errors v1.0.2 // indirect
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d

View file

@ -439,7 +439,7 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !ok {
log.Errorf("album: invalid thumb type %s", typeName)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
@ -466,7 +466,7 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !fs.FileExists(fileName) {
log.Errorf("album: could not find original for %s", fileName)
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore
f.FileMissing = true
@ -477,33 +477,40 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsLimit() && c.Query("download") == "" {
log.Debugf("album: using original, thumbnail size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
c.File(fileName)
return
}
if thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil {
if c.Query("download") != "" {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", f.ShareFileName()))
}
var thumbnail string
thumbData, err := ioutil.ReadFile(thumbnail)
if err != nil {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
gc.Set(cacheKey, thumbData, time.Hour)
log.Debugf("album: %s cached [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", thumbData)
if conf.ResampleUncached() || thumbType.SkipPreRender() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
log.Errorf("album: %s", err)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if c.Query("download") != "" {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", f.ShareFileName()))
}
thumbData, err := ioutil.ReadFile(thumbnail)
if err != nil {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
gc.Set(cacheKey, thumbData, time.Hour)
log.Debugf("album: %s cached [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", thumbData)
})
}

View file

@ -271,7 +271,7 @@ func TestAlbumThumbnail(t *testing.T) {
AlbumThumbnail(router, ctx)
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba7/thumbnail/xxx")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("album has no photo (because is not existing)", func(t *testing.T) {
app, router, ctx := NewApiTest()
@ -283,6 +283,6 @@ func TestAlbumThumbnail(t *testing.T) {
app, router, ctx := NewApiTest()
AlbumThumbnail(router, ctx)
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba8/thumbnail/tile_500")
assert.Equal(t, http.StatusNotFound, r.Code)
assert.Equal(t, http.StatusOK, r.Code)
})
}

View file

@ -220,25 +220,32 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
return
}
if thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil {
thumbData, err := ioutil.ReadFile(thumbnail)
var thumbnail string
if err != nil {
log.Errorf("label: %s", err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
gc.Set(cacheKey, thumbData, time.Hour*4)
log.Debugf("label: %s cached [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", thumbData)
if conf.ResampleUncached() || thumbType.SkipPreRender() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
log.Errorf("label: %s", err)
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("label: %s", err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
thumbData, err := ioutil.ReadFile(thumbnail)
if err != nil {
log.Errorf("label: %s", err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
gc.Set(cacheKey, thumbData, time.Hour*4)
log.Debugf("label: %s cached [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", thumbData)
})
}

View file

@ -27,7 +27,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !ok {
log.Errorf("photo: invalid thumb type %s", txt.Quote(typeName))
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
@ -36,12 +36,12 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
f, err := q.FileByHash(fileHash)
if err != nil {
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if f.FileError != "" {
c.Data(http.StatusBadRequest, "image/svg+xml", brokenIconSvg)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
@ -49,7 +49,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !fs.FileExists(fileName) {
log.Errorf("photo: could not find original for %s", fileName)
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore
f.FileMissing = true
@ -66,19 +66,24 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
return
}
if thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil {
if c.Query("download") != "" {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", f.ShareFileName()))
}
var thumbnail string
c.File(thumbnail)
if conf.ResampleUncached() || thumbType.SkipPreRender() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
log.Errorf("photo: %s", err)
f.FileError = err.Error()
db.Save(&f)
c.Data(http.StatusBadRequest, "image/svg+xml", brokenIconSvg)
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("photo: %s", err)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if c.Query("download") != "" {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", f.ShareFileName()))
}
c.File(thumbnail)
})
}

View file

@ -13,20 +13,20 @@ func TestGetThumbnail(t *testing.T) {
GetThumbnail(router, ctx)
result := PerformRequest(app, "GET", "/api/v1/thumbnails/1/xxx")
assert.Equal(t, http.StatusBadRequest, result.Code)
assert.Equal(t, http.StatusOK, result.Code)
})
t.Run("invalid hash", func(t *testing.T) {
app, router, ctx := NewApiTest()
GetThumbnail(router, ctx)
result := PerformRequest(app, "GET", "/api/v1/thumbnails/1/tile_500")
assert.Equal(t, http.StatusNotFound, result.Code)
assert.Equal(t, http.StatusOK, result.Code)
})
t.Run("could not find original", func(t *testing.T) {
app, router, ctx := NewApiTest()
GetThumbnail(router, ctx)
result := PerformRequest(app, "GET", "/api/v1/thumbnails/123xxx/tile_500")
assert.Equal(t, http.StatusNotFound, result.Code)
assert.Equal(t, http.StatusOK, result.Code)
})
}

View file

@ -25,6 +25,10 @@ var brokenIconSvg = []byte(`
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M21 5v6.59l-3-3.01-4 4.01-4-4-4 4-3-3.01V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2zm-3 6.42l3 3.01V19c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2v-6.58l3 2.99 4-4 4 4 4-3.99z"/></svg>`)
var uncachedIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/>
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>`)
// GET /api/v1/svg/*
func GetSvg(router *gin.RouterGroup) {
router.GET("/svg/photo", func(c *gin.Context) {
@ -42,4 +46,8 @@ func GetSvg(router *gin.RouterGroup) {
router.GET("/svg/broken", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
})
router.GET("/svg/uncached", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", uncachedIconSvg)
})
}

View file

@ -19,71 +19,72 @@ var ConfigCommand = cli.Command{
func configAction(ctx *cli.Context) error {
conf := config.NewConfig(ctx)
fmt.Printf("NAME VALUE\n")
fmt.Printf("admin-password %s\n", conf.AdminPassword())
fmt.Printf("webdav-password %s\n", conf.WebDAVPassword())
fmt.Printf("name %s\n", conf.Name())
fmt.Printf("url %s\n", conf.Url())
fmt.Printf("title %s\n", conf.Title())
fmt.Printf("subtitle %s\n", conf.Subtitle())
fmt.Printf("description %s\n", conf.Description())
fmt.Printf("author %s\n", conf.Author())
fmt.Printf("twitter %s\n", conf.Twitter())
fmt.Printf("version %s\n", conf.Version())
fmt.Printf("copyright %s\n", conf.Copyright())
fmt.Printf("debug %t\n", conf.Debug())
fmt.Printf("read-only %t\n", conf.ReadOnly())
fmt.Printf("public %t\n", conf.Public())
fmt.Printf("experimental %t\n", conf.Experimental())
fmt.Printf("workers %d\n", conf.Workers())
fmt.Printf("wakeup-interval %d\n", conf.WakeupInterval()/time.Second)
fmt.Printf("log-level %s\n", conf.LogLevel())
fmt.Printf("log-filename %s\n", conf.LogFilename())
fmt.Printf("pid-filename %s\n", conf.PIDFilename())
fmt.Printf("config-file %s\n", conf.ConfigFile())
fmt.Printf("config-path %s\n", conf.ConfigPath())
fmt.Printf("%-25s VALUE\n", "NAME")
fmt.Printf("%-25s %s\n", "admin-password", conf.AdminPassword())
fmt.Printf("%-25s %s\n", "webdav-password", conf.WebDAVPassword())
fmt.Printf("%-25s %s\n", "name", conf.Name())
fmt.Printf("%-25s %s\n", "url", conf.Url())
fmt.Printf("%-25s %s\n", "title", conf.Title())
fmt.Printf("%-25s %s\n", "subtitle", conf.Subtitle())
fmt.Printf("%-25s %s\n", "description", conf.Description())
fmt.Printf("%-25s %s\n", "author", conf.Author())
fmt.Printf("%-25s %s\n", "twitter", conf.Twitter())
fmt.Printf("%-25s %s\n", "version", conf.Version())
fmt.Printf("%-25s %s\n", "copyright", conf.Copyright())
fmt.Printf("%-25s %t\n", "debug", conf.Debug())
fmt.Printf("%-25s %t\n", "read-only", conf.ReadOnly())
fmt.Printf("%-25s %t\n", "public", conf.Public())
fmt.Printf("%-25s %t\n", "experimental", conf.Experimental())
fmt.Printf("%-25s %d\n", "workers", conf.Workers())
fmt.Printf("%-25s %d\n", "wakeup-interval", conf.WakeupInterval()/time.Second)
fmt.Printf("%-25s %s\n", "log-level", conf.LogLevel())
fmt.Printf("%-25s %s\n", "log-filename", conf.LogFilename())
fmt.Printf("%-25s %s\n", "pid-filename", conf.PIDFilename())
fmt.Printf("%-25s %s\n", "config-file", conf.ConfigFile())
fmt.Printf("%-25s %s\n", "config-path", conf.ConfigPath())
fmt.Printf("assets-path %s\n", conf.AssetsPath())
fmt.Printf("originals-path %s\n", conf.OriginalsPath())
fmt.Printf("import-path %s\n", conf.ImportPath())
fmt.Printf("temp-path %s\n", conf.TempPath())
fmt.Printf("cache-path %s\n", conf.CachePath())
fmt.Printf("thumbnails-path %s\n", conf.ThumbnailsPath())
fmt.Printf("resources-path %s\n", conf.ResourcesPath())
fmt.Printf("tf-version %s\n", conf.TensorFlowVersion())
fmt.Printf("tf-model-path %s\n", conf.TensorFlowModelPath())
fmt.Printf("templates-path %s\n", conf.HttpTemplatesPath())
fmt.Printf("favicons-path %s\n", conf.HttpFaviconsPath())
fmt.Printf("static-path %s\n", conf.HttpStaticPath())
fmt.Printf("static-build-path %s\n", conf.HttpStaticBuildPath())
fmt.Printf("%-25s %s\n", "assets-path", conf.AssetsPath())
fmt.Printf("%-25s %s\n", "originals-path", conf.OriginalsPath())
fmt.Printf("%-25s %s\n", "import-path", conf.ImportPath())
fmt.Printf("%-25s %s\n", "temp-path", conf.TempPath())
fmt.Printf("%-25s %s\n", "cache-path", conf.CachePath())
fmt.Printf("%-25s %s\n", "thumbnails-path", conf.ThumbnailsPath())
fmt.Printf("%-25s %s\n", "resources-path", conf.ResourcesPath())
fmt.Printf("%-25s %s\n", "tf-version", conf.TensorFlowVersion())
fmt.Printf("%-25s %s\n", "tf-model-path", conf.TensorFlowModelPath())
fmt.Printf("%-25s %s\n", "templates-path", conf.HttpTemplatesPath())
fmt.Printf("%-25s %s\n", "favicons-path", conf.HttpFaviconsPath())
fmt.Printf("%-25s %s\n", "static-path", conf.HttpStaticPath())
fmt.Printf("%-25s %s\n", "static-build-path", conf.HttpStaticBuildPath())
fmt.Printf("http-host %s\n", conf.HttpServerHost())
fmt.Printf("http-port %d\n", conf.HttpServerPort())
fmt.Printf("http-mode %s\n", conf.HttpServerMode())
fmt.Printf("%-25s %s\n", "http-host", conf.HttpServerHost())
fmt.Printf("%-25s %d\n", "http-port", conf.HttpServerPort())
fmt.Printf("%-25s %s\n", "http-mode", conf.HttpServerMode())
fmt.Printf("tidb-host %s\n", conf.TidbServerHost())
fmt.Printf("tidb-port %d\n", conf.TidbServerPort())
fmt.Printf("tidb-password %s\n", conf.TidbServerPassword())
fmt.Printf("tidb-path %s\n", conf.TidbServerPath())
fmt.Printf("%-25s %s\n", "tidb-host", conf.TidbServerHost())
fmt.Printf("%-25s %d\n", "tidb-port", conf.TidbServerPort())
fmt.Printf("%-25s %s\n", "tidb-password", conf.TidbServerPassword())
fmt.Printf("%-25s %s\n", "tidb-path", conf.TidbServerPath())
fmt.Printf("database-driver %s\n", conf.DatabaseDriver())
fmt.Printf("database-dsn %s\n", conf.DatabaseDsn())
fmt.Printf("%-25s %s\n", "database-driver", conf.DatabaseDriver())
fmt.Printf("%-25s %s\n", "database-dsn", conf.DatabaseDsn())
fmt.Printf("sips-bin %s\n", conf.SipsBin())
fmt.Printf("darktable-bin %s\n", conf.DarktableBin())
fmt.Printf("exiftool-bin %s\n", conf.ExifToolBin())
fmt.Printf("heifconvert-bin %s\n", conf.HeifConvertBin())
fmt.Printf("%-25s %s\n", "sips-bin", conf.SipsBin())
fmt.Printf("%-25s %s\n", "darktable-bin", conf.DarktableBin())
fmt.Printf("%-25s %s\n", "exiftool-bin", conf.ExifToolBin())
fmt.Printf("%-25s %s\n", "heifconvert-bin", conf.HeifConvertBin())
fmt.Printf("detect-nsfw %t\n", conf.DetectNSFW())
fmt.Printf("upload-nsfw %t\n", conf.UploadNSFW())
fmt.Printf("geocoding-api %s\n", conf.GeoCodingApi())
fmt.Printf("thumb-quality %d\n", conf.ThumbQuality())
fmt.Printf("thumb-size %d\n", conf.ThumbSize())
fmt.Printf("thumb-limit %d\n", conf.ThumbLimit())
fmt.Printf("thumb-filter %s\n", conf.ThumbFilter())
fmt.Printf("%-25s %t\n", "detect-nsfw", conf.DetectNSFW())
fmt.Printf("%-25s %t\n", "upload-nsfw", conf.UploadNSFW())
fmt.Printf("%-25s %s\n", "geocoding-api", conf.GeoCodingApi())
fmt.Printf("%-25s %d\n", "jpeg-quality", conf.JpegQuality())
fmt.Printf("%-25s %d\n", "resample-size", conf.ResampleSize())
fmt.Printf("%-25s %d\n", "resample-limit", conf.ResampleLimit())
fmt.Printf("%-25s %s\n", "resample-filter", conf.ResampleFilter())
fmt.Printf("%-25s %t\n", "resample-uncached", conf.ResampleUncached())
fmt.Printf("disable-tf %t\n", conf.DisableTensorFlow())
fmt.Printf("disable-settings %t\n", conf.DisableSettings())
fmt.Printf("%-25s %t\n", "disable-tf", conf.DisableTensorFlow())
fmt.Printf("%-25s %t\n", "disable-settings", conf.DisableSettings())
return nil
}

View file

@ -17,7 +17,7 @@ func TestConfigCommand(t *testing.T) {
err = ConfigCommand.Run(ctx)
})
assert.Contains(t, output, "NAME VALUE")
assert.Contains(t, output, "NAME VALUE")
assert.Contains(t, output, "config-file")
assert.Contains(t, output, "darktable-cli")
assert.Contains(t, output, "originals-path")

View file

@ -99,6 +99,7 @@ func (c *Config) PublicClientConfig() ClientConfig {
"colors": colors.All.List(),
"categories": []string{},
"clip": txt.ClipDefault,
"server": RuntimeInfo{},
}
return result
@ -247,6 +248,7 @@ func (c *Config) ClientConfig() ClientConfig {
"colors": colors.All.List(),
"categories": categories,
"clip": txt.ClipDefault,
"server": NewRuntimeInfo(),
}
return result

View file

@ -3,7 +3,6 @@ package config
import (
"context"
"runtime"
"strings"
"sync"
"time"
@ -72,10 +71,10 @@ func NewConfig(ctx *cli.Context) *Config {
func (c *Config) Propagate() {
log.SetLevel(c.LogLevel())
thumb.JpegQuality = c.ThumbQuality()
thumb.PreRenderSize = c.ThumbSize()
thumb.MaxRenderSize = c.ThumbLimit()
thumb.Filter = c.ThumbFilter()
thumb.Size = c.ResampleSize()
thumb.Limit = c.ResampleLimit()
thumb.Filter = c.ResampleFilter()
thumb.JpegQuality = c.JpegQuality()
c.Settings().Propagate()
}
@ -242,61 +241,6 @@ func (c *Config) WakeupInterval() time.Duration {
return time.Duration(c.params.WakeupInterval) * time.Second
}
// ThumbQuality returns the thumbnail jpeg quality setting (25-100).
func (c *Config) ThumbQuality() int {
if c.params.ThumbQuality > 100 {
return 100
}
if c.params.ThumbQuality < 25 {
return 25
}
return c.params.ThumbQuality
}
// ThumbSize returns the pre-rendered thumbnail size limit in pixels (720-3840).
func (c *Config) ThumbSize() int {
if c.params.ThumbSize > 3840 {
return 3840
}
if c.params.ThumbSize < 720 {
return 720
}
return c.params.ThumbSize
}
// ThumbLimit returns the on-demand thumbnail size limit in pixels (720-3840).
func (c *Config) ThumbLimit() int {
if c.params.ThumbLimit > 3840 {
return 3840
}
if c.params.ThumbLimit < 720 {
return 720
}
return c.params.ThumbLimit
}
// ThumbFilter returns the thumbnail resample filter (blackman, lanczos, cubic or linear).
func (c *Config) ThumbFilter() thumb.ResampleFilter {
switch strings.ToLower(c.params.ThumbFilter) {
case "blackman":
return thumb.ResampleBlackman
case "lanczos":
return thumb.ResampleLanczos
case "cubic":
return thumb.ResampleCubic
case "linear":
return thumb.ResampleLinear
default:
return thumb.ResampleCubic
}
}
// GeoCodingApi returns the preferred geo coding api (none, osm or places).
func (c *Config) GeoCodingApi() string {
switch c.params.GeoCodingApi {

View file

@ -239,28 +239,33 @@ var GlobalFlags = []cli.Flag{
EnvVar: "PHOTOPRISM_GEOCODING_API",
},
cli.IntFlag{
Name: "thumb-quality, q",
Name: "jpeg-quality, q",
Usage: "jpeg quality of thumbnails (25-100)",
Value: 90,
EnvVar: "PHOTOPRISM_THUMB_QUALITY",
EnvVar: "PHOTOPRISM_JPEG_QUALITY",
},
cli.IntFlag{
Name: "thumb-size, s",
Usage: "pre-render size limit in pixels (720-3840)",
Name: "resample-size, s",
Usage: "pre-rendered thumbnail size limit in pixels (720-3840)",
Value: 2048,
EnvVar: "PHOTOPRISM_THUMB_SIZE",
EnvVar: "PHOTOPRISM_RESAMPLE_SIZE",
},
cli.IntFlag{
Name: "thumb-limit, x",
Usage: "on-demand size limit in pixels (720-3840)",
Name: "resample-limit, x",
Usage: "on-demand thumbnail size limit in pixels (720-3840)",
Value: 3840,
EnvVar: "PHOTOPRISM_THUMB_LIMIT",
EnvVar: "PHOTOPRISM_RESAMPLE_LIMIT",
},
cli.StringFlag{
Name: "thumb-filter, f",
Name: "resample-filter, f",
Usage: "resample filter (blackman, lanczos, cubic or linear)",
Value: "lanczos",
EnvVar: "PHOTOPRISM_THUMB_FILTER",
EnvVar: "PHOTOPRISM_RESAMPLE_FILTER",
},
cli.BoolFlag{
Name: "resample-uncached, u",
Usage: "enables on-demand rendering of uncached thumbnails (high memory and cpu usage)",
EnvVar: "PHOTOPRISM_RESAMPLE_UNCACHED",
},
cli.BoolFlag{
Name: "disable-tf",

View file

@ -64,22 +64,23 @@ type Params struct {
HttpServerPort int `yaml:"http-port" flag:"http-port"`
HttpServerMode string `yaml:"http-mode" flag:"http-mode"`
HttpServerPassword string `yaml:"http-password" flag:"http-password"`
SipsBin string `yaml:"sips-bin" flag:"sips-bin"`
DarktableBin string `yaml:"darktable-bin" flag:"darktable-bin"`
ExifToolBin string `yaml:"exiftool-bin" flag:"exiftool-bin"`
HeifConvertBin string `yaml:"heifconvert-bin" flag:"heifconvert-bin"`
PIDFilename string `yaml:"pid-filename" flag:"pid-filename"`
LogFilename string `yaml:"log-filename" flag:"log-filename"`
DetachServer bool `yaml:"detach-server" flag:"detach-server"`
DetectNSFW bool `yaml:"detect-nsfw" flag:"detect-nsfw"`
UploadNSFW bool `yaml:"upload-nsfw" flag:"upload-nsfw"`
GeoCodingApi string `yaml:"geocoding-api" flag:"geocoding-api"`
ThumbQuality int `yaml:"thumb-quality" flag:"thumb-quality"`
ThumbSize int `yaml:"thumb-size" flag:"thumb-size"`
ThumbLimit int `yaml:"thumb-limit" flag:"thumb-limit"`
ThumbFilter string `yaml:"thumb-filter" flag:"thumb-filter"`
DisableTensorFlow bool `yaml:"disable-tf" flag:"disable-tf"`
DisableSettings bool `yaml:"disable-settings" flag:"disable-settings"`
SipsBin string `yaml:"sips-bin" flag:"sips-bin"`
DarktableBin string `yaml:"darktable-bin" flag:"darktable-bin"`
ExifToolBin string `yaml:"exiftool-bin" flag:"exiftool-bin"`
HeifConvertBin string `yaml:"heifconvert-bin" flag:"heifconvert-bin"`
PIDFilename string `yaml:"pid-filename" flag:"pid-filename"`
LogFilename string `yaml:"log-filename" flag:"log-filename"`
DetachServer bool `yaml:"detach-server" flag:"detach-server"`
DetectNSFW bool `yaml:"detect-nsfw" flag:"detect-nsfw"`
UploadNSFW bool `yaml:"upload-nsfw" flag:"upload-nsfw"`
GeoCodingApi string `yaml:"geocoding-api" flag:"geocoding-api"`
JpegQuality int `yaml:"jpeg-quality" flag:"jpeg-quality"`
ResampleSize int `yaml:"resample-size" flag:"resample-size"`
ResampleLimit int `yaml:"resample-limit" flag:"resample-limit"`
ResampleFilter string `yaml:"resample-filter" flag:"resample-filter"`
ResampleUncached bool `yaml:"resample-uncached" flag:"resample-uncached"`
DisableTensorFlow bool `yaml:"disable-tf" flag:"disable-tf"`
DisableSettings bool `yaml:"disable-settings" flag:"disable-settings"`
}
// NewParams creates a new configuration entity by using two methods:

View file

@ -0,0 +1,63 @@
package config
import (
"strings"
"github.com/photoprism/photoprism/internal/thumb"
)
// JpegQuality returns the thumbnail jpeg quality setting (25-100).
func (c *Config) JpegQuality() int {
if c.params.JpegQuality > 100 {
return 100
}
if c.params.JpegQuality < 25 {
return 25
}
return c.params.JpegQuality
}
// Size returns the pre-rendered thumbnail size limit in pixels (720-3840).
func (c *Config) ResampleSize() int {
if c.params.ResampleSize > 3840 {
return 3840
}
if c.params.ResampleSize < 720 {
return 720
}
return c.params.ResampleSize
}
// Limit returns the on-demand thumbnail size limit in pixels (720-3840).
func (c *Config) ResampleLimit() int {
if c.params.ResampleLimit > 3840 || c.params.ResampleLimit < 720 || c.ResampleSize() > c.params.ResampleLimit {
return c.ResampleSize()
}
return c.params.ResampleLimit
}
// ResampleFilter returns the thumbnail resample filter (blackman, lanczos, cubic or linear).
func (c *Config) ResampleFilter() thumb.ResampleFilter {
switch strings.ToLower(c.params.ResampleFilter) {
case "blackman":
return thumb.ResampleBlackman
case "lanczos":
return thumb.ResampleLanczos
case "cubic":
return thumb.ResampleCubic
case "linear":
return thumb.ResampleLinear
default:
return thumb.ResampleCubic
}
}
// ResampleUncached returns true for on-demand rendering of uncached thumbnails (high memory and cpu usage).
func (c *Config) ResampleUncached() bool {
return c.params.ResampleUncached
}

View file

@ -0,0 +1,38 @@
package config
import (
"fmt"
"runtime"
"github.com/dustin/go-humanize"
)
// RuntimeInfo represents memory and cpu usage statistics.
type RuntimeInfo struct {
Cores int `json:"cores"`
Routines int `json:"routines"`
Memory struct {
Used uint64 `json:"used"`
Reserved uint64 `json:"reserved"`
Info string `json:"info"`
} `json:"memory"`
}
// NewRuntimeInfo returns a new RuntimeInfo instance.
func NewRuntimeInfo() (r RuntimeInfo) {
r = RuntimeInfo{}
r.Refresh()
return r
}
// Refresh updates runtime info values like number of goroutines and memory usage.
func (r *RuntimeInfo) Refresh() {
r.Cores = runtime.NumCPU()
r.Routines = runtime.NumGoroutine()
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
r.Memory.Used = mem.Alloc
r.Memory.Reserved = mem.Sys
r.Memory.Info = fmt.Sprintf("Used %s / Reserved %s", humanize.Bytes(r.Memory.Used), humanize.Bytes(r.Memory.Reserved))
}

View file

@ -0,0 +1,33 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewRuntimeInfo(t *testing.T) {
info := NewRuntimeInfo()
assert.LessOrEqual(t, 1, info.Cores)
assert.LessOrEqual(t, 1, info.Routines)
assert.LessOrEqual(t, uint64(1), info.Memory.Reserved)
assert.LessOrEqual(t, uint64(1), info.Memory.Used)
}
func TestRuntimeInfo_Refresh(t *testing.T) {
info := NewRuntimeInfo()
assert.LessOrEqual(t, 1, info.Cores)
assert.LessOrEqual(t, 1, info.Routines)
assert.LessOrEqual(t, uint64(1), info.Memory.Reserved)
assert.LessOrEqual(t, uint64(1), info.Memory.Used)
info.Refresh()
assert.LessOrEqual(t, 1, info.Cores)
assert.LessOrEqual(t, 1, info.Routines)
assert.LessOrEqual(t, uint64(1), info.Memory.Reserved)
assert.LessOrEqual(t, uint64(1), info.Memory.Used)
}

View file

@ -104,10 +104,10 @@ func NewTestConfig() *Config {
c.ResetDb(true)
thumb.JpegQuality = c.ThumbQuality()
thumb.PreRenderSize = c.ThumbSize()
thumb.MaxRenderSize = c.ThumbLimit()
thumb.Filter = c.ThumbFilter()
thumb.JpegQuality = c.JpegQuality()
thumb.Size = c.ResampleSize()
thumb.Limit = c.ResampleLimit()
thumb.Filter = c.ResampleFilter()
return c
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path"
"runtime"
"sort"
"sync"
@ -192,6 +193,8 @@ func (imp *Import) Start(opt ImportOptions) {
if err != nil {
log.Error(err.Error())
}
runtime.GC()
}
// Cancel stops the current import operation.

View file

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"sync"
"runtime"
"github.com/jinzhu/gorm"
"github.com/karrick/godirwalk"
@ -165,5 +166,7 @@ func (ind *Index) Start(options IndexOptions) map[string]bool {
log.Error(err.Error())
}
runtime.GC()
return done
}

View file

@ -38,8 +38,6 @@ func ResampleOptions(opts ...ResampleOption) (method ResampleOption, filter imag
method = ResampleFit
case ResampleResize:
method = ResampleResize
default:
panic(fmt.Errorf("not a valid resample option: %d", option))
}
}
@ -75,11 +73,11 @@ func Postfix(width, height int, opts ...ResampleOption) (result string) {
}
func Filename(hash string, thumbPath string, width, height int, opts ...ResampleOption) (filename string, err error) {
if width < 0 || width > MaxRenderSize {
if InvalidSize(width) {
return "", fmt.Errorf("resample: width exceeds limit (%d)", width)
}
if height < 0 || height > MaxRenderSize {
if InvalidSize(height) {
return "", fmt.Errorf("resample: height exceeds limit (%d)", height)
}
@ -103,7 +101,7 @@ func Filename(hash string, thumbPath string, width, height int, opts ...Resample
return filename, nil
}
func FromFile(imageFilename string, hash string, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
func FromCache(imageFilename, hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
if len(hash) < 4 {
return "", fmt.Errorf("resample: file hash is empty or too short (%s)", txt.Quote(hash))
}
@ -115,7 +113,7 @@ func FromFile(imageFilename string, hash string, thumbPath string, width, height
fileName, err = Filename(hash, thumbPath, width, height, opts...)
if err != nil {
log.Errorf("resample: can't determine filename (%s)", err)
log.Error(err)
return "", err
}
@ -123,6 +121,23 @@ func FromFile(imageFilename string, hash string, thumbPath string, width, height
return fileName, nil
}
return "", ErrThumbNotCached
}
func FromFile(imageFilename, hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
if fileName, err := FromCache(imageFilename, hash, thumbPath, width, height, opts...); err == nil {
return fileName, err
} else if err != ErrThumbNotCached {
return "", err
}
fileName, err = Filename(hash, thumbPath, width, height, opts...)
if err != nil {
log.Error(err)
return "", err
}
img, err := imaging.Open(imageFilename, imaging.AutoOrientation(true))
if err != nil {
@ -138,11 +153,11 @@ func FromFile(imageFilename string, hash string, thumbPath string, width, height
}
func Create(img *image.Image, fileName string, width, height int, opts ...ResampleOption) (result *image.Image, err error) {
if width < 0 || width > MaxRenderSize {
if InvalidSize(width) {
return img, fmt.Errorf("resample: width has an invalid value (%d)", width)
}
if height < 0 || height > MaxRenderSize {
if InvalidSize(height) {
return img, fmt.Errorf("resample: height has an invalid value (%d)", height)
}

View file

@ -109,6 +109,35 @@ func TestFromFile(t *testing.T) {
})
}
func TestFromCache(t *testing.T) {
t.Run("missing thumb", func(t *testing.T) {
tile50 := Types["tile_50"]
src := "testdata/example.jpg"
assert.FileExists(t, src)
fileName, err := FromCache(src, "193456789098765432", "testdata", tile50.Width, tile50.Height, tile50.Options...)
assert.Equal(t, "", fileName)
if err != ErrThumbNotCached {
t.Fatal("ErrThumbNotCached expected")
}
})
t.Run("missing file", func(t *testing.T) {
tile50 := Types["tile_50"]
src := "testdata/example.xxx"
assert.NoFileExists(t, src)
fileName, err := FromCache(src, "193456789098765432", "testdata", tile50.Width, tile50.Height, tile50.Options...)
assert.Equal(t, "", fileName)
assert.Error(t, err)
})
}
func TestCreate(t *testing.T) {
t.Run("tile_500", func(t *testing.T) {
tile500 := Types["tile_500"]

9
internal/thumb/errors.go Normal file
View file

@ -0,0 +1,9 @@
package thumb
import (
"errors"
)
var (
ErrThumbNotCached = errors.New("thumbnail not cached")
)

View file

@ -3,13 +3,25 @@ package thumb
import "github.com/disintegration/imaging"
var (
PreRenderSize = 3840
MaxRenderSize = 3840
Size = 3840
Limit = 3840
Filter = ResampleLanczos
JpegQuality = 95
JpegQualitySmall = 80
Filter = ResampleLanczos
)
func MaxSize() int {
if Size > Limit {
return Size
}
return Limit
}
func InvalidSize(size int) bool {
return size < 0 || size > MaxSize()
}
const (
ResampleBlackman ResampleFilter = "blackman"
ResampleLanczos ResampleFilter = "lanczos"
@ -84,9 +96,9 @@ var DefaultTypes = []string{
}
func (t Type) ExceedsLimit() bool {
return t.Width > MaxRenderSize || t.Height > MaxRenderSize
return t.Width > MaxSize() || t.Height > MaxSize()
}
func (t Type) SkipPreRender() bool {
return t.Width > PreRenderSize || t.Height > PreRenderSize
return t.Width > Size || t.Height > Size
}

View file

@ -7,8 +7,8 @@ import (
)
func TestType_ExceedsLimit(t *testing.T) {
PreRenderSize = 1024
MaxRenderSize = 2048
Size = 1024
Limit = 2048
fit3840 := Types["fit_3840"]
assert.True(t, fit3840.ExceedsLimit())
@ -19,13 +19,13 @@ func TestType_ExceedsLimit(t *testing.T) {
tile500 := Types["tile_500"]
assert.False(t, tile500.ExceedsLimit())
PreRenderSize = 3840
MaxRenderSize = 3840
Size = 3840
Limit = 3840
}
func TestType_SkipPreRender(t *testing.T) {
PreRenderSize = 1024
MaxRenderSize = 2048
Size = 1024
Limit = 2048
fit3840 := Types["fit_3840"]
assert.True(t, fit3840.SkipPreRender())
@ -36,6 +36,6 @@ func TestType_SkipPreRender(t *testing.T) {
tile500 := Types["tile_500"]
assert.False(t, tile500.SkipPreRender())
PreRenderSize = 3840
MaxRenderSize = 3840
Size = 3840
Limit = 3840
}