mirror of
https://github.com/beetbox/beets.git
synced 2026-02-08 16:34:12 +01:00
fetchart/artresizer: add max_filesize support (#3560)
Squashed from the PR, relevant commit messages follow below: Added file size option to artresizer - In line with comments on PR, adjusted the ArtResizer API to add functionality to "resize to X bytes" through `max_filesize` arg - Adjustment to changelog.rst to include max_filesize change to ArtResizer and addition of new plugin. Added explicit tests for PIL & Imagemagick Methods - Checks new resizing functions do reduce the filesize of images Expose max_filesize logic to fetchart plugin - Add syspath escaping for OS cross compatibility - Return smaller PIL image even if max filesize not reached. - Test resize logic against known smaller filesize (//2) - Pass integer (not float) quality argument to PIL - Remove Pillow from dependencies - Implement "max_filesize" fetchart option, including logic to resize and rescale if maxwidth is also set. Added tests & documentation for fetchart additions. Tests now check that a target filesize is reached with a higher initial quality (a difficult check to pass). With a starting quality of 95% PIL takes 4 iterations to succeed in lowering the example cover image to 90% its original size. To cover all bases, the PIL loop has been changed to 5 iterations in the worst case, and the documentation altered to reflect the 50% loss in quality this implies. This seems reasonable as users concerned about performance would most likely be persuaded to install ImageMagick, or remove the maximum filesize constraint. The previous 30% figure was arbitrary.
This commit is contained in:
parent
04ea754d00
commit
07b5e69f40
6 changed files with 231 additions and 20 deletions
|
|
@ -64,12 +64,13 @@ def temp_file_for(path):
|
|||
return util.bytestring_path(f.name)
|
||||
|
||||
|
||||
def pil_resize(maxwidth, path_in, path_out=None, quality=0):
|
||||
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
||||
"""Resize using Python Imaging Library (PIL). Return the output path
|
||||
of resized image.
|
||||
"""
|
||||
path_out = path_out or temp_file_for(path_in)
|
||||
from PIL import Image
|
||||
|
||||
log.debug(u'artresizer: PIL resizing {0} to {1}',
|
||||
util.displayable_path(path_in), util.displayable_path(path_out))
|
||||
|
||||
|
|
@ -83,14 +84,43 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0):
|
|||
quality = -1
|
||||
|
||||
im.save(util.py3_path(path_out), quality=quality)
|
||||
return path_out
|
||||
if max_filesize > 0:
|
||||
# If maximum filesize is set, we attempt to lower the quality of
|
||||
# jpeg conversion by a proportional amount, up to 3 attempts
|
||||
# First, set the maximum quality to either provided, or 95
|
||||
if quality > 0:
|
||||
lower_qual = quality
|
||||
else:
|
||||
lower_qual = 95
|
||||
for i in range(5):
|
||||
# 3 attempts is an abitrary choice
|
||||
filesize = os.stat(util.syspath(path_out)).st_size
|
||||
log.debug(u"PIL Pass {0} : Output size: {1}B", i, filesize)
|
||||
if filesize <= max_filesize:
|
||||
return path_out
|
||||
# The relationship between filesize & quality will be
|
||||
# image dependent.
|
||||
lower_qual -= 10
|
||||
# Restrict quality dropping below 10
|
||||
if lower_qual < 10:
|
||||
lower_qual = 10
|
||||
# Use optimize flag to improve filesize decrease
|
||||
im.save(
|
||||
util.py3_path(path_out), quality=lower_qual, optimize=True
|
||||
)
|
||||
log.warning(u"PIL Failed to resize file to below {0}B",
|
||||
max_filesize)
|
||||
return path_out
|
||||
|
||||
else:
|
||||
return path_out
|
||||
except IOError:
|
||||
log.error(u"PIL cannot create thumbnail for '{0}'",
|
||||
util.displayable_path(path_in))
|
||||
return path_in
|
||||
|
||||
|
||||
def im_resize(maxwidth, path_in, path_out=None, quality=0):
|
||||
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
||||
"""Resize using ImageMagick.
|
||||
|
||||
Use the ``magick`` program or ``convert`` on older versions. Return
|
||||
|
|
@ -103,6 +133,9 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0):
|
|||
# "-resize WIDTHx>" shrinks images with the width larger
|
||||
# than the given width while maintaining the aspect ratio
|
||||
# with regards to the height.
|
||||
|
||||
# "-define jpeg:extent=SIZEb" sets the target filesize
|
||||
# for imagemagick to SIZE in bytes
|
||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
||||
util.syspath(path_in, prefix=False),
|
||||
'-resize', '{0}x>'.format(maxwidth),
|
||||
|
|
@ -111,6 +144,9 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0):
|
|||
if quality > 0:
|
||||
cmd += ['-quality', '{0}'.format(quality)]
|
||||
|
||||
if max_filesize > 0:
|
||||
cmd += ['-define', 'jpeg:extent={0}b'.format(max_filesize)]
|
||||
|
||||
cmd.append(util.syspath(path_out, prefix=False))
|
||||
|
||||
try:
|
||||
|
|
@ -131,6 +167,7 @@ BACKEND_FUNCS = {
|
|||
|
||||
def pil_getsize(path_in):
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
im = Image.open(util.syspath(path_in))
|
||||
return im.size
|
||||
|
|
@ -171,6 +208,7 @@ class Shareable(type):
|
|||
lazily-created shared instance of ``MyClass`` while calling
|
||||
``MyClass()`` to construct a new object works as usual.
|
||||
"""
|
||||
|
||||
def __init__(cls, name, bases, dict):
|
||||
super(Shareable, cls).__init__(name, bases, dict)
|
||||
cls._instance = None
|
||||
|
|
@ -205,7 +243,9 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
|||
self.im_convert_cmd = ['magick']
|
||||
self.im_identify_cmd = ['magick', 'identify']
|
||||
|
||||
def resize(self, maxwidth, path_in, path_out=None, quality=0):
|
||||
def resize(
|
||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||
):
|
||||
"""Manipulate an image file according to the method, returning a
|
||||
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
|
||||
temporary file and encodes with the specified quality level.
|
||||
|
|
@ -213,7 +253,8 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
|||
"""
|
||||
if self.local:
|
||||
func = BACKEND_FUNCS[self.method[0]]
|
||||
return func(maxwidth, path_in, path_out, quality=quality)
|
||||
return func(maxwidth, path_in, path_out,
|
||||
quality=quality, max_filesize=max_filesize)
|
||||
else:
|
||||
return path_in
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class Candidate(object):
|
|||
CANDIDATE_BAD = 0
|
||||
CANDIDATE_EXACT = 1
|
||||
CANDIDATE_DOWNSCALE = 2
|
||||
CANDIDATE_DOWNSIZE = 3
|
||||
|
||||
MATCH_EXACT = 0
|
||||
MATCH_FALLBACK = 1
|
||||
|
|
@ -71,12 +72,14 @@ class Candidate(object):
|
|||
|
||||
Return `CANDIDATE_BAD` if the file is unusable.
|
||||
Return `CANDIDATE_EXACT` if the file is usable as-is.
|
||||
Return `CANDIDATE_DOWNSCALE` if the file must be resized.
|
||||
Return `CANDIDATE_DOWNSCALE` if the file must be rescaled.
|
||||
Return `CANDIDATE_DOWNSIZE` if the file must be resized.
|
||||
"""
|
||||
if not self.path:
|
||||
return self.CANDIDATE_BAD
|
||||
|
||||
if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth):
|
||||
if (not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth
|
||||
or plugin.max_filesize)):
|
||||
return self.CANDIDATE_EXACT
|
||||
|
||||
# get_size returns None if no local imaging backend is available
|
||||
|
|
@ -94,7 +97,7 @@ class Candidate(object):
|
|||
short_edge = min(self.size)
|
||||
long_edge = max(self.size)
|
||||
|
||||
# Check minimum size.
|
||||
# Check minimum dimension.
|
||||
if plugin.minwidth and self.size[0] < plugin.minwidth:
|
||||
self._log.debug(u'image too small ({} < {})',
|
||||
self.size[0], plugin.minwidth)
|
||||
|
|
@ -122,22 +125,44 @@ class Candidate(object):
|
|||
self.size[0], self.size[1])
|
||||
return self.CANDIDATE_BAD
|
||||
|
||||
# Check maximum size.
|
||||
# Check maximum dimension.
|
||||
downscale = False
|
||||
if plugin.maxwidth and self.size[0] > plugin.maxwidth:
|
||||
self._log.debug(u'image needs resizing ({} > {})',
|
||||
self._log.debug(u'image needs rescaling ({} > {})',
|
||||
self.size[0], plugin.maxwidth)
|
||||
return self.CANDIDATE_DOWNSCALE
|
||||
downscale = True
|
||||
|
||||
return self.CANDIDATE_EXACT
|
||||
# Check filesize.
|
||||
filesize = os.stat(syspath(self.path)).st_size
|
||||
downsize = False
|
||||
if plugin.max_filesize and filesize > plugin.max_filesize:
|
||||
self._log.debug(u'image needs resizing ({}B > {}B)',
|
||||
filesize, plugin.max_filesize)
|
||||
downsize = True
|
||||
|
||||
if downscale:
|
||||
return self.CANDIDATE_DOWNSCALE
|
||||
elif downsize:
|
||||
return self.CANDIDATE_DOWNSIZE
|
||||
else:
|
||||
return self.CANDIDATE_EXACT
|
||||
|
||||
def validate(self, plugin):
|
||||
self.check = self._validate(plugin)
|
||||
return self.check
|
||||
|
||||
def resize(self, plugin):
|
||||
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
|
||||
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path,
|
||||
quality=plugin.quality)
|
||||
if self.check == self.CANDIDATE_DOWNSCALE:
|
||||
self.path = \
|
||||
ArtResizer.shared.resize(plugin.maxwidth, self.path,
|
||||
quality=plugin.quality,
|
||||
max_filesize=plugin.max_filesize)
|
||||
elif self.check == self.CANDIDATE_DOWNSIZE:
|
||||
# dimensions are correct, so maxwidth is set to maximum dimension
|
||||
self.path = \
|
||||
ArtResizer.shared.resize(max(self.size), self.path,
|
||||
quality=plugin.quality,
|
||||
max_filesize=plugin.max_filesize)
|
||||
|
||||
|
||||
def _logged_get(log, *args, **kwargs):
|
||||
|
|
@ -892,6 +917,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
'minwidth': 0,
|
||||
'maxwidth': 0,
|
||||
'quality': 0,
|
||||
'max_filesize': 0,
|
||||
'enforce_ratio': False,
|
||||
'cautious': False,
|
||||
'cover_names': ['cover', 'front', 'art', 'album', 'folder'],
|
||||
|
|
@ -910,6 +936,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
|
||||
self.minwidth = self.config['minwidth'].get(int)
|
||||
self.maxwidth = self.config['maxwidth'].get(int)
|
||||
self.max_filesize = self.config['max_filesize'].get(int)
|
||||
self.quality = self.config['quality'].get(int)
|
||||
|
||||
# allow both pixel and percentage-based margin specifications
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ New features:
|
|||
* A new :ref:`extra_tags` configuration option allows more tagged metadata
|
||||
to be included in MusicBrainz queries.
|
||||
* A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets
|
||||
* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
|
||||
option that controls the quality of the image output when the image is
|
||||
resized.
|
||||
* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added new ``quality`` option
|
||||
that controls the quality of the output when an image is resized. Additionally,
|
||||
the new ``max_filesize`` option for fetchart can be used to target a maximum image filesize.
|
||||
* :doc:`plugins/keyfinder`: Added support for `keyfinder-cli`_
|
||||
Thanks to :user:`BrainDamage`.
|
||||
* :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ file. The available options are:
|
|||
estimate the input image quality and uses 92 if it cannot be determined, and
|
||||
PIL defaults to 75.
|
||||
Default: 0 (disabled)
|
||||
- **max_filesize**: The maximum size of a target piece of cover art in bytes.
|
||||
When using an ImageMagick backend this sets
|
||||
``-define jpeg:extent=max_filesize``. Using PIL this will reduce JPG quality
|
||||
by up to 50% to attempt to reach the target filesize. Neither method is
|
||||
*guaranteed* to reach the target size, however in most cases it should
|
||||
succeed.
|
||||
Default: 0 (disabled)
|
||||
- **enforce_ratio**: Only images with a width:height ratio of 1:1 are
|
||||
considered as valid album art candidates if set to ``yes``.
|
||||
It is also possible to specify a certain deviation to the exact ratio to
|
||||
|
|
|
|||
|
|
@ -742,14 +742,17 @@ class ArtImporterTest(UseThePlugin):
|
|||
|
||||
|
||||
class ArtForAlbumTest(UseThePlugin):
|
||||
""" Tests that fetchart.art_for_album respects the size
|
||||
configuration (e.g., minwidth, enforce_ratio)
|
||||
""" Tests that fetchart.art_for_album respects the scale & filesize
|
||||
configurations (e.g., minwidth, enforce_ratio, max_filesize)
|
||||
"""
|
||||
|
||||
IMG_225x225 = os.path.join(_common.RSRC, b'abbey.jpg')
|
||||
IMG_348x348 = os.path.join(_common.RSRC, b'abbey-different.jpg')
|
||||
IMG_500x490 = os.path.join(_common.RSRC, b'abbey-similar.jpg')
|
||||
|
||||
IMG_225x225_SIZE = os.stat(util.syspath(IMG_225x225)).st_size
|
||||
IMG_348x348_SIZE = os.stat(util.syspath(IMG_348x348)).st_size
|
||||
|
||||
def setUp(self):
|
||||
super(ArtForAlbumTest, self).setUp()
|
||||
|
||||
|
|
@ -839,6 +842,29 @@ class ArtForAlbumTest(UseThePlugin):
|
|||
self._assertImageResized(self.IMG_225x225, False)
|
||||
self._assertImageResized(self.IMG_348x348, True)
|
||||
|
||||
def test_fileresize(self):
|
||||
self._require_backend()
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
|
||||
self._assertImageResized(self.IMG_225x225, True)
|
||||
|
||||
def test_fileresize_if_necessary(self):
|
||||
self._require_backend()
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE
|
||||
self._assertImageResized(self.IMG_225x225, False)
|
||||
self._assertImageIsValidArt(self.IMG_225x225, True)
|
||||
|
||||
def test_fileresize_no_scale(self):
|
||||
self._require_backend()
|
||||
self.plugin.maxwidth = 300
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
|
||||
self._assertImageResized(self.IMG_225x225, True)
|
||||
|
||||
def test_fileresize_and_scale(self):
|
||||
self._require_backend()
|
||||
self.plugin.maxwidth = 200
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
|
||||
self._assertImageResized(self.IMG_225x225, True)
|
||||
|
||||
|
||||
class DeprecatedConfigTest(_common.TestCase):
|
||||
"""While refactoring the plugin, the remote_priority option was deprecated,
|
||||
|
|
|
|||
110
test/test_art_resize.py
Normal file
110
test/test_art_resize.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2020, David Swarbrick.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Tests for image resizing based on filesize."""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from test import _common
|
||||
from test.helper import TestHelper
|
||||
from beets.util import syspath
|
||||
from beets.util.artresizer import (
|
||||
pil_resize,
|
||||
im_resize,
|
||||
get_im_version,
|
||||
get_pil_version,
|
||||
)
|
||||
|
||||
|
||||
class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
|
||||
"""Unittest test case for Art Resizer to a specific filesize."""
|
||||
|
||||
IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg")
|
||||
IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size
|
||||
|
||||
def setUp(self):
|
||||
"""Called before each test, setting up beets."""
|
||||
self.setup_beets()
|
||||
|
||||
def tearDown(self):
|
||||
"""Called after each test, unloading all plugins."""
|
||||
self.teardown_beets()
|
||||
|
||||
def _test_img_resize(self, resize_func):
|
||||
"""Test resizing based on file size, given a resize_func."""
|
||||
# Check quality setting unaffected by new parameter
|
||||
im_95_qual = resize_func(
|
||||
225,
|
||||
self.IMG_225x225,
|
||||
quality=95,
|
||||
max_filesize=0,
|
||||
)
|
||||
# check valid path returned - max_filesize hasn't broken resize command
|
||||
self.assertExists(im_95_qual)
|
||||
|
||||
# Attempt a lower filesize with same quality
|
||||
im_a = resize_func(
|
||||
225,
|
||||
self.IMG_225x225,
|
||||
quality=95,
|
||||
max_filesize=0.9 * os.stat(syspath(im_95_qual)).st_size,
|
||||
)
|
||||
self.assertExists(im_a)
|
||||
# target size was achieved
|
||||
self.assertLess(os.stat(syspath(im_a)).st_size,
|
||||
os.stat(syspath(im_95_qual)).st_size)
|
||||
|
||||
# Attempt with lower initial quality
|
||||
im_75_qual = resize_func(
|
||||
225,
|
||||
self.IMG_225x225,
|
||||
quality=75,
|
||||
max_filesize=0,
|
||||
)
|
||||
self.assertExists(im_75_qual)
|
||||
|
||||
im_b = resize_func(
|
||||
225,
|
||||
self.IMG_225x225,
|
||||
quality=95,
|
||||
max_filesize=0.9 * os.stat(syspath(im_75_qual)).st_size,
|
||||
)
|
||||
self.assertExists(im_b)
|
||||
# Check high (initial) quality still gives a smaller filesize
|
||||
self.assertLess(os.stat(syspath(im_b)).st_size,
|
||||
os.stat(syspath(im_75_qual)).st_size)
|
||||
|
||||
@unittest.skipUnless(get_pil_version(), "PIL not available")
|
||||
def test_pil_file_resize(self):
|
||||
"""Test PIL resize function is lowering file size."""
|
||||
self._test_img_resize(pil_resize)
|
||||
|
||||
@unittest.skipUnless(get_im_version(), "ImageMagick not available")
|
||||
def test_im_file_resize(self):
|
||||
"""Test IM resize function is lowering file size."""
|
||||
self._test_img_resize(im_resize)
|
||||
|
||||
|
||||
def suite():
|
||||
"""Run this suite of tests."""
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(defaultTest="suite")
|
||||
Loading…
Reference in a new issue