mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Config: Improve thumbnail generation option parsing and defaults #1474
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -95,13 +95,8 @@ services:
|
||||
PHOTOPRISM_DETECT_NSFW: "false" # automatically flags photos as private that MAY be offensive (requires TensorFlow)
|
||||
PHOTOPRISM_UPLOAD_NSFW: "false" # allows uploads that MAY be offensive (no effect without TensorFlow)
|
||||
PHOTOPRISM_THUMB_LIBRARY: "auto" # image processing library to be used for generating thumbnails (auto, imaging, vips)
|
||||
PHOTOPRISM_THUMB_FILTER: "lanczos" # image downscaling filter, best to worst: lanczos, cubic, linear
|
||||
PHOTOPRISM_THUMB_FILTER: "auto" # downscaling filter (imaging best to worst: blackman, lanczos, cubic, linear, nearest)
|
||||
PHOTOPRISM_THUMB_UNCACHED: "true" # enables on-demand thumbnail rendering (high memory and cpu usage)
|
||||
PHOTOPRISM_THUMB_SIZE: 2048 # pre-rendered thumbnail size limit (default 2048, min 720, max 7680)
|
||||
# PHOTOPRISM_THUMB_SIZE: 4096 # Retina 4K, DCI 4K (requires more storage); 7680 for 8K Ultra HD
|
||||
PHOTOPRISM_THUMB_SIZE_UNCACHED: 7680 # on-demand rendering size limit (default 7680, min 720, max 7680)
|
||||
PHOTOPRISM_JPEG_SIZE: 7680 # size limit for converted image files in pixels (720-30000)
|
||||
PHOTOPRISM_JPEG_QUALITY: 85 # a higher value increases the quality and file size of JPEG images and thumbnails (25-100)
|
||||
TF_CPP_MIN_LOG_LEVEL: 0 # show TensorFlow log messages for development
|
||||
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
|
||||
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
|
||||
|
@@ -145,7 +145,7 @@ func SharePreview(router *gin.RouterGroup) {
|
||||
preview = imaging.Resize(preview, 1200, 0, imaging.Lanczos)
|
||||
|
||||
// Save the resulting album preview as JPEG.
|
||||
err = imaging.Save(preview, previewFilename, thumb.JpegQualitySmall.EncodeOption())
|
||||
err = imaging.Save(preview, previewFilename, thumb.JpegQualitySmall().EncodeOption())
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
|
@@ -197,11 +197,11 @@ func (c *Config) Propagate() {
|
||||
|
||||
// Initialize the thumbnail generation package.
|
||||
thumb.Library = c.ThumbLibrary()
|
||||
thumb.StandardRGB = c.ThumbSRGB()
|
||||
thumb.SizePrecached = c.ThumbSizePrecached()
|
||||
thumb.SizeUncached = c.ThumbSizeUncached()
|
||||
thumb.Color = c.ThumbColor()
|
||||
thumb.Filter = c.ThumbFilter()
|
||||
thumb.JpegQuality = c.JpegQuality()
|
||||
thumb.SizeCached = c.ThumbSizePrecached()
|
||||
thumb.SizeOnDemand = c.ThumbSizeUncached()
|
||||
thumb.JpegQualityDefault = c.JpegQuality()
|
||||
thumb.CachePublic = c.HttpCachePublic()
|
||||
|
||||
// Set cache expiration defaults.
|
||||
|
@@ -1,9 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// JpegSize returns the size limit for automatically converted files in `PIXELS` (720-30000).
|
||||
@@ -35,46 +34,22 @@ func (c *Config) JpegQuality() thumb.Quality {
|
||||
|
||||
// ThumbLibrary returns the name of the image processing library to be used for generating thumbnails.
|
||||
func (c *Config) ThumbLibrary() string {
|
||||
switch strings.ToLower(c.options.ThumbLibrary) {
|
||||
case thumb.LibVips:
|
||||
return thumb.LibVips
|
||||
default:
|
||||
switch clean.TypeLowerUnderscore(c.options.ThumbLibrary) {
|
||||
case thumb.LibImaging, "", "imagine", "internal":
|
||||
return thumb.LibImaging
|
||||
default:
|
||||
return thumb.LibVips
|
||||
}
|
||||
}
|
||||
|
||||
// ThumbColor returns the color profile name for thumbnails.
|
||||
func (c *Config) ThumbColor() string {
|
||||
if c.options.ThumbColor == "auto" {
|
||||
if c.ThumbLibrary() != thumb.LibVips {
|
||||
return "srgb"
|
||||
}
|
||||
|
||||
return c.options.ThumbColor
|
||||
}
|
||||
|
||||
return strings.ToLower(c.options.ThumbColor)
|
||||
}
|
||||
|
||||
// ThumbSRGB checks if colors should be normalized to standard RGB in thumbnails.
|
||||
func (c *Config) ThumbSRGB() bool {
|
||||
return c.ThumbColor() == "srgb"
|
||||
// ThumbColor returns the color space for thumbnails.
|
||||
func (c *Config) ThumbColor() thumb.ColorSpace {
|
||||
return thumb.ParseColor(c.options.ThumbColor, c.ThumbLibrary())
|
||||
}
|
||||
|
||||
// ThumbFilter returns the thumbnail resample filter (best to worst: blackman, lanczos, cubic or linear).
|
||||
func (c *Config) ThumbFilter() thumb.ResampleFilter {
|
||||
switch strings.ToLower(c.options.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
|
||||
}
|
||||
return thumb.ParseFilter(c.options.ThumbFilter, c.ThumbLibrary())
|
||||
}
|
||||
|
||||
// ThumbUncached checks if on-demand thumbnail rendering is enabled (high memory and cpu usage).
|
||||
|
@@ -20,41 +20,45 @@ func TestConfig_ConvertSize(t *testing.T) {
|
||||
func TestConfig_JpegQuality(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
assert.Equal(t, thumb.QualityDefault, c.JpegQuality())
|
||||
assert.Equal(t, thumb.QualityMedium, c.JpegQuality())
|
||||
c.options.JpegQuality = "110"
|
||||
assert.Equal(t, thumb.QualityDefault, c.JpegQuality())
|
||||
assert.Equal(t, thumb.QualityMedium, c.JpegQuality())
|
||||
c.options.JpegQuality = "98"
|
||||
assert.Equal(t, thumb.Quality(98), c.JpegQuality())
|
||||
c.options.JpegQuality = ""
|
||||
assert.Equal(t, thumb.QualityDefault, c.JpegQuality())
|
||||
assert.Equal(t, thumb.QualityMedium, c.JpegQuality())
|
||||
c.options.JpegQuality = "best "
|
||||
assert.Equal(t, thumb.QualityBest, c.JpegQuality())
|
||||
assert.Equal(t, thumb.QualityMax, c.JpegQuality())
|
||||
c.options.JpegQuality = "high"
|
||||
assert.Equal(t, thumb.QualityHigh, c.JpegQuality())
|
||||
c.options.JpegQuality = "med "
|
||||
assert.Equal(t, thumb.QualityMedium, c.JpegQuality())
|
||||
c.options.JpegQuality = "medium "
|
||||
assert.Equal(t, thumb.QualityDefault, c.JpegQuality())
|
||||
assert.Equal(t, thumb.QualityMedium, c.JpegQuality())
|
||||
c.options.JpegQuality = "low "
|
||||
assert.Equal(t, thumb.QualityLow, c.JpegQuality())
|
||||
c.options.JpegQuality = "bad"
|
||||
assert.Equal(t, thumb.QualityBad, c.JpegQuality())
|
||||
c.options.JpegQuality = "worst "
|
||||
assert.Equal(t, thumb.QualityWorst, c.JpegQuality())
|
||||
c.options.JpegQuality = "max"
|
||||
assert.Equal(t, thumb.QualityMax, c.JpegQuality())
|
||||
c.options.JpegQuality = "min "
|
||||
assert.Equal(t, thumb.QualityMin, c.JpegQuality())
|
||||
c.options.JpegQuality = "default"
|
||||
assert.Equal(t, thumb.QualityDefault, c.JpegQuality())
|
||||
assert.Equal(t, thumb.QualityMedium, c.JpegQuality())
|
||||
}
|
||||
|
||||
func TestConfig_ThumbFilter(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
assert.Equal(t, thumb.ResampleFilter("cubic"), c.ThumbFilter())
|
||||
assert.Equal(t, thumb.ResampleAuto, c.ThumbFilter())
|
||||
c.options.ThumbFilter = "blackman"
|
||||
assert.Equal(t, thumb.ResampleFilter("blackman"), c.ThumbFilter())
|
||||
assert.Equal(t, thumb.ResampleBlackman, c.ThumbFilter())
|
||||
c.options.ThumbFilter = "lanczos"
|
||||
assert.Equal(t, thumb.ResampleFilter("lanczos"), c.ThumbFilter())
|
||||
assert.Equal(t, thumb.ResampleLanczos, c.ThumbFilter())
|
||||
c.options.ThumbFilter = "linear"
|
||||
assert.Equal(t, thumb.ResampleFilter("linear"), c.ThumbFilter())
|
||||
c.options.ThumbFilter = "cubic"
|
||||
assert.Equal(t, thumb.ResampleFilter("cubic"), c.ThumbFilter())
|
||||
assert.Equal(t, thumb.ResampleLinear, c.ThumbFilter())
|
||||
c.options.ThumbFilter = "auto"
|
||||
assert.Equal(t, thumb.ResampleAuto, c.ThumbFilter())
|
||||
c.options.ThumbFilter = ""
|
||||
assert.Equal(t, thumb.ResampleAuto, c.ThumbFilter())
|
||||
}
|
||||
|
||||
func TestConfig_ThumbSizeUncached(t *testing.T) {
|
||||
@@ -66,17 +70,17 @@ func TestConfig_ThumbSizeUncached(t *testing.T) {
|
||||
func TestConfig_ThumbSize(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
assert.Equal(t, int(720), c.ThumbSizePrecached())
|
||||
assert.Equal(t, 720, c.ThumbSizePrecached())
|
||||
c.options.ThumbSize = 7681
|
||||
assert.Equal(t, int(7680), c.ThumbSizePrecached())
|
||||
assert.Equal(t, 7680, c.ThumbSizePrecached())
|
||||
}
|
||||
|
||||
func TestConfig_ThumbSizeUncached2(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
assert.Equal(t, int(720), c.ThumbSizeUncached())
|
||||
assert.Equal(t, 720, c.ThumbSizeUncached())
|
||||
c.options.ThumbSizeUncached = 7681
|
||||
assert.Equal(t, int(7680), c.ThumbSizeUncached())
|
||||
assert.Equal(t, 7680, c.ThumbSizeUncached())
|
||||
c.options.ThumbSizeUncached = 800
|
||||
c.options.ThumbSize = 900
|
||||
assert.Equal(t, int(900), c.ThumbSizeUncached())
|
||||
|
@@ -760,26 +760,26 @@ var Flags = CliFlags{
|
||||
}}, {
|
||||
Flag: cli.StringFlag{
|
||||
Name: "thumb-color",
|
||||
Usage: "default color `PROFILE` for thumbnails (leave blank to disable normalization)",
|
||||
Value: "auto",
|
||||
Usage: "default color `PROFILE` for thumbnails (auto, preserve, srgb, none)",
|
||||
Value: thumb.ColorAuto,
|
||||
EnvVar: EnvVar("THUMB_COLOR"),
|
||||
}}, {
|
||||
Flag: cli.StringFlag{
|
||||
Name: "thumb-filter, filter",
|
||||
Usage: "image downscaling filter `NAME` (best to worst: lanczos, cubic, linear)",
|
||||
Value: "lanczos",
|
||||
Usage: "downscaling filter `NAME` (imaging best to worst: blackman, lanczos, cubic, linear, nearest)",
|
||||
Value: thumb.ResampleAuto.String(),
|
||||
EnvVar: EnvVar("THUMB_FILTER"),
|
||||
}}, {
|
||||
Flag: cli.IntFlag{
|
||||
Name: "thumb-size",
|
||||
Usage: "maximum size of thumbnails generated while indexing in `PIXELS` (720-7680)",
|
||||
Value: 2048,
|
||||
Usage: "maximum size of pre-generated thumbnails in `PIXELS` (720-7680)",
|
||||
Value: thumb.SizeCached,
|
||||
EnvVar: EnvVar("THUMB_SIZE"),
|
||||
}}, {
|
||||
Flag: cli.IntFlag{
|
||||
Name: "thumb-size-uncached",
|
||||
Usage: "maximum size of thumbnails generated on demand in `PIXELS` (720-7680)",
|
||||
Value: 7680,
|
||||
Value: thumb.SizeOnDemand,
|
||||
EnvVar: EnvVar("THUMB_SIZE_UNCACHED"),
|
||||
}}, {
|
||||
Flag: cli.BoolFlag{
|
||||
@@ -790,7 +790,7 @@ var Flags = CliFlags{
|
||||
Flag: cli.StringFlag{
|
||||
Name: "jpeg-quality, q",
|
||||
Usage: "higher values increase the image `QUALITY` and file size (25-100)",
|
||||
Value: thumb.JpegQuality.String(),
|
||||
Value: thumb.JpegQualityDefault.String(),
|
||||
EnvVar: EnvVar("JPEG_QUALITY"),
|
||||
}}, {
|
||||
Flag: cli.IntFlag{
|
||||
|
@@ -222,7 +222,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
|
||||
{"preview-token", c.PreviewToken()},
|
||||
{"thumb-library", c.ThumbLibrary()},
|
||||
{"thumb-color", c.ThumbColor()},
|
||||
{"thumb-filter", string(c.ThumbFilter())},
|
||||
{"thumb-filter", c.ThumbFilter().String()},
|
||||
{"thumb-size", fmt.Sprintf("%d", c.ThumbSizePrecached())},
|
||||
{"thumb-size-uncached", fmt.Sprintf("%d", c.ThumbSizeUncached())},
|
||||
{"thumb-uncached", fmt.Sprintf("%t", c.ThumbUncached())},
|
||||
|
@@ -183,10 +183,10 @@ func NewTestConfig(pkg string) *Config {
|
||||
c.RegisterDb()
|
||||
c.InitTestDb()
|
||||
|
||||
thumb.SizePrecached = c.ThumbSizePrecached()
|
||||
thumb.SizeUncached = c.ThumbSizeUncached()
|
||||
thumb.SizeCached = c.ThumbSizePrecached()
|
||||
thumb.SizeOnDemand = c.ThumbSizeUncached()
|
||||
thumb.Filter = c.ThumbFilter()
|
||||
thumb.JpegQuality = c.JpegQuality()
|
||||
thumb.JpegQualityDefault = c.JpegQuality()
|
||||
|
||||
return c
|
||||
}
|
||||
|
30
internal/thumb/color.go
Normal file
30
internal/thumb/color.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package thumb
|
||||
|
||||
import "github.com/photoprism/photoprism/pkg/clean"
|
||||
|
||||
type ColorSpace = string
|
||||
|
||||
// Supported thumbnail color profiles.
|
||||
const (
|
||||
ColorNone ColorSpace = "none"
|
||||
ColorAuto ColorSpace = "auto"
|
||||
ColorSRGB ColorSpace = "srgb"
|
||||
ColorPreserve ColorSpace = "preserve"
|
||||
)
|
||||
|
||||
// Color sets the default color profiles for thumbnails.
|
||||
var Color = ColorAuto
|
||||
|
||||
// ParseColor returns a ColorSpace based on the config value string and image library.
|
||||
func ParseColor(name string, lib Lib) ColorSpace {
|
||||
if lib == LibVips {
|
||||
return ColorPreserve
|
||||
}
|
||||
|
||||
switch clean.TypeLowerUnderscore(name) {
|
||||
case ColorNone, "":
|
||||
return ColorNone
|
||||
default:
|
||||
return ColorSRGB
|
||||
}
|
||||
}
|
22
internal/thumb/color_test.go
Normal file
22
internal/thumb/color_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseColor(t *testing.T) {
|
||||
t.Run("Vips", func(t *testing.T) {
|
||||
assert.Equal(t, ColorPreserve, ParseColor("", LibVips))
|
||||
assert.Equal(t, ColorPreserve, ParseColor(ColorAuto, LibVips))
|
||||
assert.Equal(t, ColorPreserve, ParseColor(ColorSRGB, LibVips))
|
||||
assert.Equal(t, ColorPreserve, ParseColor(ColorNone, LibVips))
|
||||
})
|
||||
t.Run("Imaging", func(t *testing.T) {
|
||||
assert.Equal(t, ColorNone, ParseColor("", LibImaging))
|
||||
assert.Equal(t, ColorSRGB, ParseColor(ColorAuto, LibImaging))
|
||||
assert.Equal(t, ColorSRGB, ParseColor(ColorSRGB, LibImaging))
|
||||
assert.Equal(t, ColorNone, ParseColor(ColorNone, LibImaging))
|
||||
})
|
||||
}
|
@@ -138,10 +138,8 @@ func Create(img image.Image, fileName string, width, height int, opts ...Resampl
|
||||
|
||||
if fs.FileType(fileName) == fs.ImagePNG {
|
||||
quality = imaging.PNGCompressionLevel(png.DefaultCompression)
|
||||
} else if width <= 150 && height <= 150 {
|
||||
quality = JpegQualitySmall.EncodeOption()
|
||||
} else {
|
||||
quality = JpegQuality.EncodeOption()
|
||||
quality = JpegQuality(width, height).EncodeOption()
|
||||
}
|
||||
|
||||
err = imaging.Save(result, fileName, quality)
|
||||
|
@@ -3,10 +3,15 @@ package thumb
|
||||
import (
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// ResampleFilter represents a downscaling filter.
|
||||
type ResampleFilter string
|
||||
|
||||
// Supported downscaling filter types.
|
||||
const (
|
||||
ResampleAuto ResampleFilter = "auto"
|
||||
ResampleBlackman ResampleFilter = "blackman"
|
||||
ResampleLanczos ResampleFilter = "lanczos"
|
||||
ResampleCubic ResampleFilter = "cubic"
|
||||
@@ -17,15 +22,17 @@ const (
|
||||
// Filter specifies the default downscaling filter.
|
||||
var Filter = ResampleLanczos
|
||||
|
||||
// ResampleFilter represents a downscaling filter.
|
||||
type ResampleFilter string
|
||||
// String returns the downscaling filter name as string.
|
||||
func (a ResampleFilter) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
// Imaging returns the downscaling filter for use with the "imaging" library.
|
||||
func (a ResampleFilter) Imaging() imaging.ResampleFilter {
|
||||
switch a {
|
||||
case ResampleBlackman:
|
||||
return imaging.Blackman
|
||||
case ResampleLanczos:
|
||||
case ResampleLanczos, ResampleAuto:
|
||||
return imaging.Lanczos
|
||||
case ResampleCubic:
|
||||
return imaging.CatmullRom
|
||||
@@ -43,7 +50,7 @@ func (a ResampleFilter) Vips() vips.Kernel {
|
||||
switch a {
|
||||
case ResampleBlackman:
|
||||
return vips.KernelLanczos3
|
||||
case ResampleLanczos:
|
||||
case ResampleLanczos, ResampleAuto:
|
||||
return vips.KernelLanczos3
|
||||
case ResampleCubic:
|
||||
return vips.KernelCubic
|
||||
@@ -55,3 +62,19 @@ func (a ResampleFilter) Vips() vips.Kernel {
|
||||
return vips.KernelLanczos3
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFilter returns a ResampleFilter based on the config value string and image library.
|
||||
func ParseFilter(name string, lib Lib) ResampleFilter {
|
||||
if lib == LibVips {
|
||||
return ResampleAuto
|
||||
}
|
||||
|
||||
filter := ResampleFilter(clean.TypeLowerUnderscore(name))
|
||||
|
||||
switch filter {
|
||||
case ResampleBlackman, ResampleLanczos, ResampleCubic, ResampleLinear, ResampleNearest:
|
||||
return filter
|
||||
default:
|
||||
return ResampleAuto
|
||||
}
|
||||
}
|
||||
|
28
internal/thumb/filter_test.go
Normal file
28
internal/thumb/filter_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseFilter(t *testing.T) {
|
||||
t.Run("Vips", func(t *testing.T) {
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("", LibVips))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("auto", LibVips))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("blackman", LibVips))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("lanczos", LibVips))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("cubic", LibVips))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("linear", LibVips))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("invalid", LibVips))
|
||||
})
|
||||
t.Run("Imaging", func(t *testing.T) {
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("", LibImaging))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("auto", LibImaging))
|
||||
assert.Equal(t, ResampleBlackman, ParseFilter("blackman", LibImaging))
|
||||
assert.Equal(t, ResampleLanczos, ParseFilter("lanczos", LibImaging))
|
||||
assert.Equal(t, ResampleCubic, ParseFilter("cubic", LibImaging))
|
||||
assert.Equal(t, ResampleLinear, ParseFilter("linear", LibImaging))
|
||||
assert.Equal(t, ResampleAuto, ParseFilter("invalid", LibImaging))
|
||||
})
|
||||
}
|
@@ -1,9 +1,11 @@
|
||||
package thumb
|
||||
|
||||
type Lib = string
|
||||
|
||||
// Supported image processing libraries.
|
||||
const (
|
||||
LibVips = "vips"
|
||||
LibImaging = "imaging"
|
||||
LibVips Lib = "vips"
|
||||
LibImaging Lib = "imaging"
|
||||
)
|
||||
|
||||
// Library specifies the image library to be used.
|
||||
|
@@ -42,7 +42,7 @@ func Jpeg(srcFile, jpgFile string, orientation int) (img image.Image, err error)
|
||||
}
|
||||
|
||||
// Get JPEG quality setting.
|
||||
quality := JpegQuality.EncodeOption()
|
||||
quality := JpegQualityDefault.EncodeOption()
|
||||
|
||||
// Save JPEG file.
|
||||
if err = imaging.Save(img, jpgFile, quality); err != nil {
|
||||
|
@@ -9,9 +9,6 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// StandardRGB configures whether colors in the Apple Display P3 color space should be converted to standard RGB.
|
||||
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?
|
||||
@@ -24,8 +21,8 @@ func Open(fileName string, orientation int) (result image.Image, err error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Open JPEG?
|
||||
if StandardRGB && fs.FileType(fileName) == fs.ImageJPEG {
|
||||
// Open JPEG as sRGB image?
|
||||
if Color == ColorSRGB && fs.FileType(fileName) == fs.ImageJPEG {
|
||||
return OpenJpeg(fileName, orientation)
|
||||
}
|
||||
|
||||
|
@@ -4,11 +4,24 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// Standard JPEG image quality levels,
|
||||
// see https://docs.photoprism.app/user-guide/settings/advanced/#jpeg-quality
|
||||
const (
|
||||
QualityMax Quality = 90
|
||||
QualityHigh Quality = 85
|
||||
QualityMedium Quality = 83
|
||||
QualityLow Quality = 78
|
||||
QualityMin Quality = 70
|
||||
)
|
||||
|
||||
// JpegQualityDefault sets the compression level of newly created JPEGs.
|
||||
var JpegQualityDefault = QualityMedium
|
||||
|
||||
// Quality represents a JPEG image quality.
|
||||
type Quality int
|
||||
|
||||
@@ -27,53 +40,56 @@ func (q Quality) Int() int {
|
||||
return int(q)
|
||||
}
|
||||
|
||||
// Common Quality levels.
|
||||
// see https://docs.photoprism.app/user-guide/settings/advanced/#jpeg-quality
|
||||
const (
|
||||
QualityBest Quality = 95
|
||||
QualityHigh Quality = 88
|
||||
QualityDefault Quality = 82
|
||||
QualityLow Quality = 80
|
||||
QualityBad Quality = 75
|
||||
QualityWorst Quality = 70
|
||||
)
|
||||
|
||||
// QualityLevels maps human-readable settings to a numeric Quality.
|
||||
var QualityLevels = map[string]Quality{
|
||||
"5": QualityBest,
|
||||
"ultra": QualityBest,
|
||||
"best": QualityBest,
|
||||
"4": QualityHigh,
|
||||
"excellent": QualityHigh,
|
||||
"good": QualityHigh,
|
||||
"high": QualityHigh,
|
||||
"3": QualityDefault,
|
||||
"": QualityDefault,
|
||||
"ok": QualityDefault,
|
||||
"default": QualityDefault,
|
||||
"standard": QualityDefault,
|
||||
"medium": QualityDefault,
|
||||
"2": QualityLow,
|
||||
"low": QualityLow,
|
||||
"small": QualityLow,
|
||||
"1": QualityBad,
|
||||
"bad": QualityBad,
|
||||
"0": QualityWorst,
|
||||
"worst": QualityWorst,
|
||||
"lowest": QualityWorst,
|
||||
"max": QualityMax,
|
||||
"ultra": QualityMax,
|
||||
"best": QualityMax,
|
||||
"6": QualityMax,
|
||||
"5": QualityMax,
|
||||
"high": QualityHigh,
|
||||
"good": QualityHigh,
|
||||
"4": QualityHigh,
|
||||
"medium": QualityMedium,
|
||||
"med": QualityMedium,
|
||||
"default": QualityMedium,
|
||||
"standard": QualityMedium,
|
||||
"auto": QualityMedium,
|
||||
"": QualityMedium,
|
||||
"3": QualityMedium,
|
||||
"low": QualityLow,
|
||||
"small": QualityLow,
|
||||
"2": QualityLow,
|
||||
"min": QualityMin,
|
||||
"1": QualityMin,
|
||||
"0": QualityMin,
|
||||
}
|
||||
|
||||
// Current Quality settings.
|
||||
var (
|
||||
JpegQuality = QualityDefault
|
||||
JpegQualitySmall = QualityLow
|
||||
)
|
||||
// JpegQuality returns the JPEG image quality depending on the image size.
|
||||
func JpegQuality(width, height int) Quality {
|
||||
// Use default quality for images larger than 150 pixels.
|
||||
if width > 150 || height > 150 {
|
||||
return JpegQualityDefault
|
||||
}
|
||||
|
||||
// Use lower quality for very small thumbnails.
|
||||
return JpegQualitySmall()
|
||||
}
|
||||
|
||||
// JpegQualitySmall returns the quality for images that should be more heavily compressed.
|
||||
func JpegQualitySmall() Quality {
|
||||
if q := JpegQualityDefault - 5; q < QualityMin || q > QualityMax {
|
||||
return JpegQualityDefault
|
||||
} else {
|
||||
return q
|
||||
}
|
||||
}
|
||||
|
||||
// ParseQuality returns the matching quality based on a config value string.
|
||||
func ParseQuality(s string) Quality {
|
||||
// Default if empty.
|
||||
if s == "" {
|
||||
return QualityDefault
|
||||
return QualityMedium
|
||||
}
|
||||
|
||||
// Try to parse as positive integer.
|
||||
@@ -89,5 +105,5 @@ func ParseQuality(s string) Quality {
|
||||
return l
|
||||
}
|
||||
|
||||
return QualityDefault
|
||||
return QualityMedium
|
||||
}
|
||||
|
@@ -6,61 +6,79 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseQuality(t *testing.T) {
|
||||
t.Run("Worst", func(t *testing.T) {
|
||||
assert.Equal(t, QualityWorst, ParseQuality("worst"))
|
||||
func TestJpegQuality(t *testing.T) {
|
||||
t.Run("Large", func(t *testing.T) {
|
||||
assert.Equal(t, JpegQualityDefault, JpegQuality(100, 500))
|
||||
})
|
||||
t.Run("Lowest", func(t *testing.T) {
|
||||
assert.Equal(t, QualityWorst, ParseQuality("lowest"))
|
||||
t.Run("Small", func(t *testing.T) {
|
||||
assert.Equal(t, JpegQualityDefault-5, JpegQuality(50, 150))
|
||||
})
|
||||
}
|
||||
|
||||
func TestJpegQualitySmall(t *testing.T) {
|
||||
t.Run("Default", func(t *testing.T) {
|
||||
assert.Equal(t, JpegQualityDefault-5, JpegQualitySmall())
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseQuality(t *testing.T) {
|
||||
t.Run("Max", func(t *testing.T) {
|
||||
assert.Equal(t, QualityMax, ParseQuality("max"))
|
||||
})
|
||||
t.Run("Min", func(t *testing.T) {
|
||||
assert.Equal(t, QualityMin, ParseQuality("min"))
|
||||
})
|
||||
t.Run("bad", func(t *testing.T) {
|
||||
assert.Equal(t, QualityBad, ParseQuality("bad"))
|
||||
assert.Equal(t, QualityMedium, ParseQuality("bad"))
|
||||
})
|
||||
t.Run("low", func(t *testing.T) {
|
||||
assert.Equal(t, QualityLow, ParseQuality("low"))
|
||||
})
|
||||
t.Run("high", func(t *testing.T) {
|
||||
assert.Equal(t, QualityHigh, ParseQuality("high"))
|
||||
})
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
assert.Equal(t, QualityDefault, ParseQuality(""))
|
||||
assert.Equal(t, QualityDefault, ParseQuality(" "))
|
||||
assert.Equal(t, QualityMedium, ParseQuality(""))
|
||||
assert.Equal(t, QualityMedium, ParseQuality(" "))
|
||||
})
|
||||
t.Run("Default", func(t *testing.T) {
|
||||
assert.Equal(t, QualityDefault, ParseQuality("default"))
|
||||
assert.Equal(t, QualityMedium, ParseQuality("default"))
|
||||
})
|
||||
t.Run("Medium", func(t *testing.T) {
|
||||
assert.Equal(t, QualityDefault, ParseQuality("medium"))
|
||||
assert.Equal(t, QualityDefault, ParseQuality(" \t medium \n\r"))
|
||||
assert.Equal(t, QualityDefault, ParseQuality("MEDIUM"))
|
||||
assert.Equal(t, QualityMedium, ParseQuality("medium"))
|
||||
assert.Equal(t, QualityMedium, ParseQuality(" \t medium \n\r"))
|
||||
assert.Equal(t, QualityMedium, ParseQuality("MEDIUM"))
|
||||
})
|
||||
t.Run("Good", func(t *testing.T) {
|
||||
assert.Equal(t, QualityHigh, ParseQuality("Good"))
|
||||
assert.Equal(t, QualityHigh, ParseQuality("GOOD"))
|
||||
})
|
||||
t.Run("Best", func(t *testing.T) {
|
||||
assert.Equal(t, QualityBest, ParseQuality("Best"))
|
||||
assert.Equal(t, QualityMax, ParseQuality("Best"))
|
||||
})
|
||||
t.Run("Ultra", func(t *testing.T) {
|
||||
assert.Equal(t, QualityBest, ParseQuality("ultra"))
|
||||
assert.Equal(t, QualityMax, ParseQuality("ultra"))
|
||||
})
|
||||
t.Run("0", func(t *testing.T) {
|
||||
assert.Equal(t, QualityWorst, ParseQuality("0"))
|
||||
assert.Equal(t, QualityMin, ParseQuality("0"))
|
||||
})
|
||||
t.Run("1", func(t *testing.T) {
|
||||
assert.Equal(t, QualityBad, ParseQuality("1"))
|
||||
assert.Equal(t, QualityMin, ParseQuality("1"))
|
||||
})
|
||||
t.Run("2", func(t *testing.T) {
|
||||
assert.Equal(t, QualityLow, ParseQuality("2"))
|
||||
})
|
||||
t.Run("3", func(t *testing.T) {
|
||||
assert.Equal(t, QualityDefault, ParseQuality("3"))
|
||||
assert.Equal(t, QualityMedium, ParseQuality("3"))
|
||||
})
|
||||
t.Run("4", func(t *testing.T) {
|
||||
assert.Equal(t, QualityHigh, ParseQuality("4"))
|
||||
})
|
||||
t.Run("5", func(t *testing.T) {
|
||||
assert.Equal(t, QualityBest, ParseQuality("5"))
|
||||
assert.Equal(t, QualityMax, ParseQuality("5"))
|
||||
})
|
||||
t.Run("6", func(t *testing.T) {
|
||||
assert.Equal(t, QualityDefault, ParseQuality("6"))
|
||||
assert.Equal(t, QualityMax, ParseQuality("6"))
|
||||
})
|
||||
t.Run("50", func(t *testing.T) {
|
||||
assert.Equal(t, Quality(50), ParseQuality("50"))
|
||||
@@ -83,9 +101,9 @@ func TestParseQuality(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuality_String(t *testing.T) {
|
||||
assert.Equal(t, "95", QualityBest.String())
|
||||
assert.Equal(t, "88", QualityHigh.String())
|
||||
assert.Equal(t, "82", QualityDefault.String())
|
||||
assert.Equal(t, "75", QualityBad.String())
|
||||
|
||||
assert.Equal(t, "90", QualityMax.String())
|
||||
assert.Equal(t, "85", QualityHigh.String())
|
||||
assert.Equal(t, "83", QualityMedium.String())
|
||||
assert.Equal(t, "78", QualityLow.String())
|
||||
assert.Equal(t, "70", QualityMin.String())
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@ func (s Size) Bounds() image.Rectangle {
|
||||
|
||||
// Uncached tests if thumbnail type exceeds the cached thumbnails size limit.
|
||||
func (s Size) Uncached() bool {
|
||||
return s.Width > SizePrecached || s.Height > SizePrecached
|
||||
return s.Width > SizeCached || s.Height > SizeCached
|
||||
}
|
||||
|
||||
// ExceedsLimit tests if thumbnail type is too large, and can not be rendered at all.
|
||||
|
@@ -1,17 +1,17 @@
|
||||
package thumb
|
||||
|
||||
var (
|
||||
SizePrecached = 2048
|
||||
SizeUncached = 7680
|
||||
SizeCached = SizeFit1920.Width
|
||||
SizeOnDemand = SizeFit7680.Width
|
||||
)
|
||||
|
||||
// MaxSize returns the max supported size in pixels.
|
||||
func MaxSize() int {
|
||||
if SizePrecached > SizeUncached {
|
||||
return SizePrecached
|
||||
if SizeCached > SizeOnDemand {
|
||||
return SizeCached
|
||||
}
|
||||
|
||||
return SizeUncached
|
||||
return SizeOnDemand
|
||||
}
|
||||
|
||||
// InvalidSize tests if the size in pixels is invalid.
|
||||
|
@@ -7,18 +7,18 @@ import (
|
||||
)
|
||||
|
||||
func TestMaxSize(t *testing.T) {
|
||||
SizePrecached = 7680
|
||||
SizeUncached = 1024
|
||||
SizeCached = 7680
|
||||
SizeOnDemand = 1024
|
||||
|
||||
assert.Equal(t, MaxSize(), 7680)
|
||||
|
||||
SizePrecached = 2048
|
||||
SizeUncached = 7680
|
||||
SizeCached = 2048
|
||||
SizeOnDemand = 7680
|
||||
}
|
||||
|
||||
func TestSize_ExceedsLimit(t *testing.T) {
|
||||
SizePrecached = 1024
|
||||
SizeUncached = 2048
|
||||
SizeCached = 1024
|
||||
SizeOnDemand = 2048
|
||||
|
||||
fit4096 := Sizes[Fit4096]
|
||||
assert.True(t, fit4096.ExceedsLimit())
|
||||
@@ -29,13 +29,13 @@ func TestSize_ExceedsLimit(t *testing.T) {
|
||||
tile500 := Sizes[Tile500]
|
||||
assert.False(t, tile500.ExceedsLimit())
|
||||
|
||||
SizePrecached = 2048
|
||||
SizeUncached = 7680
|
||||
SizeCached = 2048
|
||||
SizeOnDemand = 7680
|
||||
}
|
||||
|
||||
func TestSize_Uncached(t *testing.T) {
|
||||
SizePrecached = 1024
|
||||
SizeUncached = 2048
|
||||
SizeCached = 1024
|
||||
SizeOnDemand = 2048
|
||||
|
||||
fit4096 := Sizes[Fit4096]
|
||||
assert.True(t, fit4096.Uncached())
|
||||
@@ -46,8 +46,8 @@ func TestSize_Uncached(t *testing.T) {
|
||||
tile500 := Sizes[Tile500]
|
||||
assert.False(t, tile500.Uncached())
|
||||
|
||||
SizePrecached = 2048
|
||||
SizeUncached = 7680
|
||||
SizeCached = 2048
|
||||
SizeOnDemand = 7680
|
||||
}
|
||||
|
||||
func TestResampleFilter_Imaging(t *testing.T) {
|
||||
|
@@ -88,20 +88,12 @@ func Vips(imageName string, imageBuffer []byte, hash, thumbPath string, width, h
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Export to PNG or JPEG.
|
||||
if fs.FileType(thumbName) == fs.ImagePNG {
|
||||
params := vips.NewPngExportParams()
|
||||
thumbBuffer, _, err = img.ExportPng(params)
|
||||
} else {
|
||||
params := vips.NewJpegExportParams()
|
||||
|
||||
if width <= 150 && height <= 150 {
|
||||
params.Quality = JpegQualitySmall.Int()
|
||||
} else {
|
||||
params.Quality = JpegQuality.Int()
|
||||
}
|
||||
|
||||
thumbBuffer, _, err = img.ExportJpeg(params)
|
||||
// Export to standard image format.
|
||||
switch fs.FileType(thumbName) {
|
||||
case fs.ImagePNG:
|
||||
thumbBuffer, _, err = img.ExportPng(VipsPngExportParams(width, height))
|
||||
default:
|
||||
thumbBuffer, _, err = img.ExportJpeg(VipsJpegExportParams(width, height))
|
||||
}
|
||||
|
||||
// Check if export failed.
|
||||
@@ -127,6 +119,44 @@ func VipsImportParams() *vips.ImportParams {
|
||||
return params
|
||||
}
|
||||
|
||||
// VipsPngExportParams returns PNG image encoding parameters for libvips.
|
||||
func VipsPngExportParams(width, height int) *vips.PngExportParams {
|
||||
params := vips.NewPngExportParams()
|
||||
params.Filter = vips.PngFilterNone
|
||||
params.Interlace = false
|
||||
params.Palette = false
|
||||
|
||||
// Set compression depending on image size.
|
||||
if width > 20 || height > 20 {
|
||||
params.Compression = 6
|
||||
} else {
|
||||
params.Compression = 0
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// VipsJpegExportParams returns JPEG image encoding parameters for libvips.
|
||||
func VipsJpegExportParams(width, height int) *vips.JpegExportParams {
|
||||
params := vips.NewJpegExportParams()
|
||||
params.Quality = JpegQuality(width, height).Int()
|
||||
params.Interlace = true
|
||||
params.SubsampleMode = vips.VipsForeignSubsampleAuto
|
||||
|
||||
// Enable quality enhancements depending on image size.
|
||||
if width > 150 || height > 150 {
|
||||
params.OptimizeCoding = true
|
||||
// The following options can only be set if libvips
|
||||
// has been compiled with the "--with-mozjpeg" flag:
|
||||
// params.QuantTable = 3
|
||||
// params.TrellisQuant = true
|
||||
// params.OvershootDeringing = true
|
||||
// params.OptimizeScans = true
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// VipsRotate rotates a vips image based on the Exif orientation.
|
||||
func VipsRotate(img *vips.ImageRef, orientation int) error {
|
||||
var err error
|
||||
|
@@ -127,6 +127,77 @@ func TestVips(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestVipsImportParams(t *testing.T) {
|
||||
t.Run("Default", func(t *testing.T) {
|
||||
result := VipsImportParams()
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("result is nil")
|
||||
}
|
||||
|
||||
assert.True(t, result.AutoRotate.Get())
|
||||
assert.False(t, result.FailOnError.Get())
|
||||
})
|
||||
}
|
||||
|
||||
func TestVipsPngExportParams(t *testing.T) {
|
||||
t.Run("Standard", func(t *testing.T) {
|
||||
result := VipsPngExportParams(500, 500)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("result is nil")
|
||||
}
|
||||
|
||||
assert.False(t, result.Interlace)
|
||||
assert.Equal(t, vips.PngFilterNone, result.Filter)
|
||||
assert.Equal(t, 0, result.Quality)
|
||||
assert.Equal(t, 6, result.Compression)
|
||||
})
|
||||
t.Run("Small", func(t *testing.T) {
|
||||
result := VipsPngExportParams(3, 3)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("result is nil")
|
||||
}
|
||||
|
||||
assert.False(t, result.Interlace)
|
||||
assert.Equal(t, vips.PngFilterNone, result.Filter)
|
||||
assert.Equal(t, 0, result.Quality)
|
||||
assert.Equal(t, 0, result.Compression)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVipsJpegExportParams(t *testing.T) {
|
||||
t.Run("Standard", func(t *testing.T) {
|
||||
result := VipsJpegExportParams(1920, 1200)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("result is nil")
|
||||
}
|
||||
|
||||
assert.True(t, result.Interlace)
|
||||
assert.False(t, result.TrellisQuant)
|
||||
assert.False(t, result.OptimizeScans)
|
||||
assert.True(t, result.OptimizeCoding)
|
||||
assert.False(t, result.OvershootDeringing)
|
||||
assert.Equal(t, JpegQualityDefault.Int(), result.Quality)
|
||||
})
|
||||
t.Run("Small", func(t *testing.T) {
|
||||
result := VipsJpegExportParams(50, 50)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("result is nil")
|
||||
}
|
||||
|
||||
assert.True(t, result.Interlace)
|
||||
assert.False(t, result.TrellisQuant)
|
||||
assert.False(t, result.OptimizeScans)
|
||||
assert.False(t, result.OptimizeCoding)
|
||||
assert.False(t, result.OvershootDeringing)
|
||||
assert.Equal(t, JpegQualitySmall().Int(), result.Quality)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVipsRotate(t *testing.T) {
|
||||
if err := os.MkdirAll("testdata/vips/rotate", fs.ModeDir); err != nil {
|
||||
t.Fatal(err)
|
||||
|
Reference in New Issue
Block a user