diff --git a/compose.yaml b/compose.yaml index 215ddd59d..fa424a44e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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) diff --git a/internal/api/share_preview.go b/internal/api/share_preview.go index aedb8c47b..99edfb069 100644 --- a/internal/api/share_preview.go +++ b/internal/api/share_preview.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 0049d99ec..070eb4863 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/config/config_resample.go b/internal/config/config_resample.go index 5043efca0..a178ae65b 100644 --- a/internal/config/config_resample.go +++ b/internal/config/config_resample.go @@ -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). diff --git a/internal/config/config_resample_test.go b/internal/config/config_resample_test.go index 270791bc0..966c3cacd 100644 --- a/internal/config/config_resample_test.go +++ b/internal/config/config_resample_test.go @@ -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()) diff --git a/internal/config/flags.go b/internal/config/flags.go index 369532c4a..ddc9a2deb 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -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{ diff --git a/internal/config/report.go b/internal/config/report.go index 634daccbe..0f290def7 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -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())}, diff --git a/internal/config/test.go b/internal/config/test.go index 437ec4a05..ab05766a7 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -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 } diff --git a/internal/thumb/color.go b/internal/thumb/color.go new file mode 100644 index 000000000..48984377f --- /dev/null +++ b/internal/thumb/color.go @@ -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 + } +} diff --git a/internal/thumb/color_test.go b/internal/thumb/color_test.go new file mode 100644 index 000000000..b4017e1c5 --- /dev/null +++ b/internal/thumb/color_test.go @@ -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)) + }) +} diff --git a/internal/thumb/create.go b/internal/thumb/create.go index 76aeea3da..889f75be1 100644 --- a/internal/thumb/create.go +++ b/internal/thumb/create.go @@ -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) diff --git a/internal/thumb/filter.go b/internal/thumb/filter.go index 5b5fa1aca..ed8ca688d 100644 --- a/internal/thumb/filter.go +++ b/internal/thumb/filter.go @@ -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 + } +} diff --git a/internal/thumb/filter_test.go b/internal/thumb/filter_test.go new file mode 100644 index 000000000..26918af56 --- /dev/null +++ b/internal/thumb/filter_test.go @@ -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)) + }) +} diff --git a/internal/thumb/generator.go b/internal/thumb/generator.go index 3652960ba..a4a682c50 100644 --- a/internal/thumb/generator.go +++ b/internal/thumb/generator.go @@ -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. diff --git a/internal/thumb/jpeg.go b/internal/thumb/jpeg.go index e488d8098..317bea021 100644 --- a/internal/thumb/jpeg.go +++ b/internal/thumb/jpeg.go @@ -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 { diff --git a/internal/thumb/open.go b/internal/thumb/open.go index 20115c544..16e8a3b95 100644 --- a/internal/thumb/open.go +++ b/internal/thumb/open.go @@ -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) } diff --git a/internal/thumb/quality.go b/internal/thumb/quality.go index b711a01c9..f2edc19d4 100644 --- a/internal/thumb/quality.go +++ b/internal/thumb/quality.go @@ -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 } diff --git a/internal/thumb/quality_test.go b/internal/thumb/quality_test.go index 7dd8413f2..f20493586 100644 --- a/internal/thumb/quality_test.go +++ b/internal/thumb/quality_test.go @@ -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()) } diff --git a/internal/thumb/size.go b/internal/thumb/size.go index 3e5399aee..190b2e9bc 100644 --- a/internal/thumb/size.go +++ b/internal/thumb/size.go @@ -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. diff --git a/internal/thumb/sizes.go b/internal/thumb/sizes.go index a923a3196..a846028b8 100644 --- a/internal/thumb/sizes.go +++ b/internal/thumb/sizes.go @@ -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. diff --git a/internal/thumb/sizes_test.go b/internal/thumb/sizes_test.go index 252a96e93..7d085cf7c 100644 --- a/internal/thumb/sizes_test.go +++ b/internal/thumb/sizes_test.go @@ -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) { diff --git a/internal/thumb/vips.go b/internal/thumb/vips.go index f895a7f9c..7f33216b9 100644 --- a/internal/thumb/vips.go +++ b/internal/thumb/vips.go @@ -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 diff --git a/internal/thumb/vips_test.go b/internal/thumb/vips_test.go index 724f046cd..d7ac66ace 100644 --- a/internal/thumb/vips_test.go +++ b/internal/thumb/vips_test.go @@ -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)