diff --git a/picard/const/defaults.py b/picard/const/defaults.py index 43955d6b0..5bce4f324 100644 --- a/picard/const/defaults.py +++ b/picard/const/defaults.py @@ -153,3 +153,4 @@ DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S' DEFAULT_COVER_MIN_SIZE = 250 DEFAULT_COVER_MAX_SIZE = 1000 +DEFAULT_COVER_CONVERTING_FORMAT = "jpeg" diff --git a/picard/coverart/processing/processors.py b/picard/coverart/processing/processors.py index 6068eebb1..de640b49d 100644 --- a/picard/coverart/processing/processors.py +++ b/picard/coverart/processing/processors.py @@ -70,8 +70,8 @@ class ResizeImage(ImageProcessor): target_width = config.setting['cover_tags_resize_target_width'] use_height = config.setting['cover_tags_resize_use_height'] target_height = config.setting['cover_tags_resize_target_height'] - stretch = config.setting["cover_tags_stretch"] - crop = config.setting["cover_tags_crop"] + stretch = config.setting['cover_tags_stretch'] + crop = config.setting['cover_tags_crop'] else: scale_up = config.setting['cover_file_scale_up'] scale_down = config.setting['cover_file_scale_down'] @@ -79,8 +79,8 @@ class ResizeImage(ImageProcessor): target_width = config.setting['cover_file_resize_target_width'] use_height = config.setting['cover_file_resize_use_height'] target_height = config.setting['cover_file_resize_target_height'] - stretch = config.setting["cover_file_stretch"] - crop = config.setting["cover_file_crop"] + stretch = config.setting['cover_file_stretch'] + crop = config.setting['cover_file_crop'] width_resize = target_width if use_width else image.info.width height_resize = target_height if use_height else image.info.height @@ -130,4 +130,41 @@ class ResizeImage(ImageProcessor): image.set_result(scaled_image) +class ConvertImage(ImageProcessor): + + _format_aliases = { + "jpeg": {"jpg", "jpeg"}, + "png": {"png"}, + "webp": {"webp"}, + "tiff": {"tif", "tiff"}, + } + + def save_to_tags(self): + config = get_config() + return config.setting['cover_tags_convert_images'] + + def save_to_file(self): + config = get_config() + return config.setting['cover_file_convert_images'] + + def same_processing(self): + config = get_config() + same_format = config.setting['cover_tags_convert_to_format'] == config.setting['cover_file_convert_to_format'] + return self.save_to_file() and self.save_to_tags() and same_format + + def run(self, image, target): + config = get_config() + if target == ProcessingTarget.TAGS: + new_format = config.setting['cover_tags_convert_to_format'].lower() + else: + new_format = config.setting['cover_file_convert_to_format'].lower() + previous_format = image.info.format + if previous_format in self._format_aliases[new_format]: + return + image.info.extension = f".{new_format}" + image.info.mime = f"image/{new_format}" + log.debug("Changed cover art format from %s to %s", previous_format, new_format) + + register_cover_art_processor(ResizeImage) +register_cover_art_processor(ConvertImage) diff --git a/picard/extension_points/cover_art_processors.py b/picard/extension_points/cover_art_processors.py index e7456f26f..651369810 100644 --- a/picard/extension_points/cover_art_processors.py +++ b/picard/extension_points/cover_art_processors.py @@ -72,7 +72,8 @@ class ProcessingImage: buffer = QBuffer() if not self._qimage.save(buffer, image_format, quality=quality): raise CoverArtEncodingError(f"Failed to encode into {image_format}") - return buffer.data() + qbytearray = buffer.data() + return qbytearray.data() class ImageProcessor: diff --git a/picard/options.py b/picard/options.py index 79d154870..3d45f9492 100644 --- a/picard/options.py +++ b/picard/options.py @@ -39,6 +39,7 @@ from picard.const.defaults import ( DEFAULT_CAA_IMAGE_TYPE_EXCLUDE, DEFAULT_CAA_IMAGE_TYPE_INCLUDE, DEFAULT_CACHE_SIZE_IN_BYTES, + DEFAULT_COVER_CONVERTING_FORMAT, DEFAULT_COVER_IMAGE_FILENAME, DEFAULT_COVER_MAX_SIZE, DEFAULT_COVER_MIN_SIZE, @@ -183,6 +184,8 @@ BoolOption('setting', 'cover_tags_resize_use_height', True) IntOption('setting', 'cover_tags_resize_target_height', DEFAULT_COVER_MAX_SIZE) BoolOption('setting', 'cover_tags_stretch', False) BoolOption('setting', 'cover_tags_crop', False) +BoolOption('setting', 'cover_tags_convert_images', False) +TextOption('setting', 'cover_tags_convert_to_format', DEFAULT_COVER_CONVERTING_FORMAT) BoolOption('setting', 'cover_file_scale_up', False) BoolOption('setting', 'cover_file_scale_down', False) BoolOption('setting', 'cover_file_resize_use_width', True) @@ -191,6 +194,8 @@ BoolOption('setting', 'cover_file_resize_use_height', True) IntOption('setting', 'cover_file_resize_target_height', DEFAULT_COVER_MAX_SIZE) BoolOption('setting', 'cover_file_stretch', False) BoolOption('setting', 'cover_file_crop', False) +BoolOption('setting', 'cover_file_convert_images', False) +TextOption('setting', 'cover_file_convert_to_format', DEFAULT_COVER_CONVERTING_FORMAT) # picard/ui/options/dialog.py # Attached Profiles diff --git a/picard/ui/forms/ui_options_cover_processing.py b/picard/ui/forms/ui_options_cover_processing.py index 54a023fc1..8b0ca6f27 100644 --- a/picard/ui/forms/ui_options_cover_processing.py +++ b/picard/ui/forms/ui_options_cover_processing.py @@ -17,7 +17,7 @@ from picard.i18n import gettext as _ class Ui_CoverProcessingOptionsPage(object): def setupUi(self, CoverProcessingOptionsPage): CoverProcessingOptionsPage.setObjectName("CoverProcessingOptionsPage") - CoverProcessingOptionsPage.resize(478, 423) + CoverProcessingOptionsPage.resize(529, 467) self.verticalLayout = QtWidgets.QVBoxLayout(CoverProcessingOptionsPage) self.verticalLayout.setObjectName("verticalLayout") self.filtering = QtWidgets.QGroupBox(parent=CoverProcessingOptionsPage) @@ -272,6 +272,45 @@ class Ui_CoverProcessingOptionsPage(object): self.verticalLayout_4.addWidget(self.file_resize_mode) self.horizontalLayout_7.addWidget(self.save_to_file) self.verticalLayout.addWidget(self.resizing) + self.converting = QtWidgets.QGroupBox(parent=CoverProcessingOptionsPage) + self.converting.setCheckable(False) + self.converting.setChecked(False) + self.converting.setObjectName("converting") + self.horizontalLayout_12 = QtWidgets.QHBoxLayout(self.converting) + self.horizontalLayout_12.setObjectName("horizontalLayout_12") + self.convert_tags = QtWidgets.QGroupBox(parent=self.converting) + self.convert_tags.setCheckable(True) + self.convert_tags.setObjectName("convert_tags") + self.horizontalLayout_13 = QtWidgets.QHBoxLayout(self.convert_tags) + self.horizontalLayout_13.setObjectName("horizontalLayout_13") + self.convert_tags_label = QtWidgets.QLabel(parent=self.convert_tags) + self.convert_tags_label.setObjectName("convert_tags_label") + self.horizontalLayout_13.addWidget(self.convert_tags_label) + self.convert_tags_format = QtWidgets.QComboBox(parent=self.convert_tags) + self.convert_tags_format.setObjectName("convert_tags_format") + self.convert_tags_format.addItem("") + self.convert_tags_format.addItem("") + self.convert_tags_format.addItem("") + self.convert_tags_format.addItem("") + self.horizontalLayout_13.addWidget(self.convert_tags_format) + self.horizontalLayout_12.addWidget(self.convert_tags) + self.convert_file = QtWidgets.QGroupBox(parent=self.converting) + self.convert_file.setCheckable(True) + self.convert_file.setObjectName("convert_file") + self.horizontalLayout_14 = QtWidgets.QHBoxLayout(self.convert_file) + self.horizontalLayout_14.setObjectName("horizontalLayout_14") + self.convert_file_label = QtWidgets.QLabel(parent=self.convert_file) + self.convert_file_label.setObjectName("convert_file_label") + self.horizontalLayout_14.addWidget(self.convert_file_label) + self.convert_file_format = QtWidgets.QComboBox(parent=self.convert_file) + self.convert_file_format.setObjectName("convert_file_format") + self.convert_file_format.addItem("") + self.convert_file_format.addItem("") + self.convert_file_format.addItem("") + self.convert_file_format.addItem("") + self.horizontalLayout_14.addWidget(self.convert_file_format) + self.horizontalLayout_12.addWidget(self.convert_file) + self.verticalLayout.addWidget(self.converting) spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) self.verticalLayout.addItem(spacerItem) @@ -306,3 +345,16 @@ class Ui_CoverProcessingOptionsPage(object): self.file_keep.setText(_("Fit")) self.file_crop.setText(_("Fill")) self.file_stretch.setText(_("Stretch")) + self.converting.setTitle(_("Convert images to the given format")) + self.convert_tags.setTitle(_("Convert images saved to tags")) + self.convert_tags_label.setText(_("New format:")) + self.convert_tags_format.setItemText(0, _("JPEG")) + self.convert_tags_format.setItemText(1, _("PNG")) + self.convert_tags_format.setItemText(2, _("WebP")) + self.convert_tags_format.setItemText(3, _("TIFF")) + self.convert_file.setTitle(_("Convert images saved to files")) + self.convert_file_label.setText(_("New format:")) + self.convert_file_format.setItemText(0, _("JPEG")) + self.convert_file_format.setItemText(1, _("PNG")) + self.convert_file_format.setItemText(2, _("WebP")) + self.convert_file_format.setItemText(3, _("TIFF")) diff --git a/picard/ui/options/cover_processing.py b/picard/ui/options/cover_processing.py index abd090cb8..71d1457d8 100644 --- a/picard/ui/options/cover_processing.py +++ b/picard/ui/options/cover_processing.py @@ -55,6 +55,8 @@ class CoverProcessingOptionsPage(OptionsPage): self.register_setting('cover_tags_resize_target_height') self.register_setting('cover_tags_stretch') self.register_setting('cover_tags_crop') + self.register_setting('cover_tags_convert_images') + self.register_setting('cover_tags_convert_to_format') self.register_setting('cover_file_scale_up') self.register_setting('cover_file_scale_down') self.register_setting('cover_file_resize_use_width') @@ -63,6 +65,8 @@ class CoverProcessingOptionsPage(OptionsPage): self.register_setting('cover_file_resize_target_height') self.register_setting('cover_file_stretch') self.register_setting('cover_file_crop') + self.register_setting('cover_file_convert_images') + self.register_setting('cover_file_convert_to_format') tooltip_keep = N_( "

" @@ -146,6 +150,8 @@ class CoverProcessingOptionsPage(OptionsPage): self.ui.tags_resize_height_value.setValue(config.setting['cover_tags_resize_target_height']) self.ui.tags_stretch.setChecked(config.setting['cover_tags_stretch']) self.ui.tags_crop.setChecked(config.setting['cover_tags_crop']) + self.ui.convert_tags.setChecked(config.setting['cover_tags_convert_images']) + self.ui.convert_tags_format.setCurrentText(config.setting['cover_tags_convert_to_format']) self.ui.file_scale_up.setChecked(config.setting['cover_file_scale_up']) self.ui.file_scale_down.setChecked(config.setting['cover_file_scale_down']) self.ui.file_resize_width_label.setChecked(config.setting['cover_file_resize_use_width']) @@ -154,6 +160,8 @@ class CoverProcessingOptionsPage(OptionsPage): self.ui.file_resize_height_value.setValue(config.setting['cover_file_resize_target_height']) self.ui.file_stretch.setChecked(config.setting['cover_file_stretch']) self.ui.file_crop.setChecked(config.setting['cover_file_crop']) + self.ui.convert_file.setChecked(config.setting['cover_file_convert_images']) + self.ui.convert_file_format.setCurrentText(config.setting['cover_file_convert_to_format']) def save(self): config = get_config() @@ -168,6 +176,8 @@ class CoverProcessingOptionsPage(OptionsPage): config.setting['cover_tags_resize_target_height'] = self.ui.tags_resize_height_value.value() config.setting['cover_tags_stretch'] = self.ui.tags_stretch.isChecked() config.setting['cover_tags_crop'] = self.ui.tags_crop.isChecked() + config.setting['cover_tags_convert_images'] = self.ui.convert_tags.isChecked() + config.setting['cover_tags_convert_to_format'] = self.ui.convert_tags_format.currentText() config.setting['cover_file_scale_up'] = self.ui.file_scale_up.isChecked() config.setting['cover_file_scale_down'] = self.ui.file_scale_down.isChecked() config.setting['cover_file_resize_use_width'] = self.ui.file_resize_width_label.isChecked() @@ -176,6 +186,8 @@ class CoverProcessingOptionsPage(OptionsPage): config.setting['cover_file_resize_target_height'] = self.ui.file_resize_height_value.value() config.setting['cover_file_stretch'] = self.ui.file_stretch.isChecked() config.setting['cover_file_crop'] = self.ui.file_crop.isChecked() + config.setting['cover_file_convert_images'] = self.ui.convert_file.isChecked() + config.setting['cover_file_convert_to_format'] = self.ui.convert_file_format.currentText() register_options_page(CoverProcessingOptionsPage) diff --git a/test/test_coverart_processing.py b/test/test_coverart_processing.py index bba96c5f7..aa9aad717 100644 --- a/test/test_coverart_processing.py +++ b/test/test_coverart_processing.py @@ -32,12 +32,16 @@ from picard.coverart.processing.filters import ( size_filter, size_metadata_filter, ) -from picard.coverart.processing.processors import ResizeImage +from picard.coverart.processing.processors import ( + ConvertImage, + ResizeImage, +) from picard.extension_points.cover_art_processors import ( CoverArtProcessingError, ProcessingImage, ProcessingTarget, ) +from picard.util import imageinfo def create_fake_image(width, height, image_format): @@ -59,9 +63,9 @@ class ImageFiltersTest(PicardTestCase): self.set_config_values(settings) def test_filter_by_size(self): - image1 = create_fake_image(400, 600, "png") - image2 = create_fake_image(500, 500, "jpeg") - image3 = create_fake_image(600, 600, "bmp") + image1 = create_fake_image(400, 600, 'png') + image2 = create_fake_image(500, 500, 'jpeg') + image3 = create_fake_image(600, 600, 'bmp') self.assertFalse(size_filter(image1)) self.assertTrue(size_filter(image2)) self.assertTrue(size_filter(image3)) @@ -88,6 +92,8 @@ class ImageProcessorsTest(PicardTestCase): 'cover_tags_resize_target_height': 500, 'cover_tags_stretch': False, 'cover_tags_crop': False, + 'cover_tags_convert_images': False, + 'cover_tags_convert_to_format': 'jpeg', 'cover_file_scale_up': True, 'cover_file_scale_down': True, 'cover_file_resize_use_width': True, @@ -98,11 +104,13 @@ class ImageProcessorsTest(PicardTestCase): 'cover_file_crop': False, 'save_images_to_tags': True, 'save_images_to_files': True, + 'cover_file_convert_images': False, + 'cover_file_convert_to_format': 'jpeg', } def _check_image_processors(self, size, expected_tags_size, expected_file_size=None): coverartimage = CoverArtImage() - image = create_fake_image(size[0], size[1], "jpg") + image = create_fake_image(size[0], size[1], 'jpg') run_image_processors(image, coverartimage) tags_size = (coverartimage.width, coverartimage.height) if config.setting['save_images_to_tags']: @@ -116,7 +124,7 @@ class ImageProcessorsTest(PicardTestCase): else: self.assertIsNone(coverartimage.external_file_coverart) extension = coverartimage.extension[1:] - self.assertEqual(extension, "jpg") + self.assertEqual(extension, 'jpg') def test_image_processors_save_to_both(self): self.set_config_values(self.settings) @@ -151,7 +159,7 @@ class ImageProcessorsTest(PicardTestCase): self.set_config_values(self.settings) def _check_resize_image(self, size, expected_size): - image = ProcessingImage(create_fake_image(size[0], size[1], "jpg")) + image = ProcessingImage(create_fake_image(size[0], size[1], 'jpg')) processor = ResizeImage() processor.run(image, ProcessingTarget.TAGS) new_size = (image.get_result().width(), image.get_result().height()) @@ -167,7 +175,7 @@ class ImageProcessorsTest(PicardTestCase): def test_scale_down_only_width(self): settings = copy(self.settings) - settings["cover_tags_resize_use_height"] = False + settings['cover_tags_resize_use_height'] = False self.set_config_values(settings) self._check_resize_image((1000, 1000), (500, 500)) self._check_resize_image((1000, 500), (500, 250)) @@ -176,7 +184,7 @@ class ImageProcessorsTest(PicardTestCase): def test_scale_down_only_height(self): settings = copy(self.settings) - settings["cover_tags_resize_use_width"] = False + settings['cover_tags_resize_use_width'] = False self.set_config_values(settings) self._check_resize_image((1000, 1000), (500, 500)) self._check_resize_image((1000, 500), (1000, 500)) @@ -191,7 +199,7 @@ class ImageProcessorsTest(PicardTestCase): def test_scale_up_only_width(self): settings = copy(self.settings) - settings["cover_tags_resize_use_height"] = False + settings['cover_tags_resize_use_height'] = False self.set_config_values(settings) self._check_resize_image((250, 250), (500, 500)) self._check_resize_image((400, 500), (500, 625)) @@ -200,7 +208,7 @@ class ImageProcessorsTest(PicardTestCase): def test_scale_up_only_height(self): settings = copy(self.settings) - settings["cover_tags_resize_use_width"] = False + settings['cover_tags_resize_use_width'] = False self.set_config_values(settings) self._check_resize_image((250, 250), (500, 500)) self._check_resize_image((400, 500), (400, 500)) @@ -209,15 +217,15 @@ class ImageProcessorsTest(PicardTestCase): def test_scale_priority(self): settings = copy(self.settings) - settings["cover_tags_resize_target_width"] = 500 - settings["cover_tags_resize_target_height"] = 1000 + settings['cover_tags_resize_target_width'] = 500 + settings['cover_tags_resize_target_height'] = 1000 self.set_config_values(settings) self._check_resize_image((750, 750), (500, 500)) self.set_config_values(self.settings) def test_stretch_both_dimensions(self): settings = copy(self.settings) - settings["cover_tags_stretch"] = True + settings['cover_tags_stretch'] = True self.set_config_values(settings) self._check_resize_image((1000, 100), (500, 500)) self._check_resize_image((200, 500), (500, 500)) @@ -226,8 +234,8 @@ class ImageProcessorsTest(PicardTestCase): def test_stretch_only_width(self): settings = copy(self.settings) - settings["cover_tags_stretch"] = True - settings["cover_tags_resize_use_height"] = False + settings['cover_tags_stretch'] = True + settings['cover_tags_resize_use_height'] = False self.set_config_values(settings) self._check_resize_image((1000, 100), (500, 100)) self._check_resize_image((200, 500), (500, 500)) @@ -236,8 +244,8 @@ class ImageProcessorsTest(PicardTestCase): def test_stretch_only_height(self): settings = copy(self.settings) - settings["cover_tags_stretch"] = True - settings["cover_tags_resize_use_width"] = False + settings['cover_tags_stretch'] = True + settings['cover_tags_resize_use_width'] = False self.set_config_values(settings) self._check_resize_image((1000, 100), (1000, 500)) self._check_resize_image((200, 500), (200, 500)) @@ -246,7 +254,7 @@ class ImageProcessorsTest(PicardTestCase): def test_crop_both_dimensions(self): settings = copy(self.settings) - settings["cover_tags_crop"] = True + settings['cover_tags_crop'] = True self.set_config_values(settings) self._check_resize_image((1000, 100), (500, 500)) self._check_resize_image((750, 1000), (500, 500)) @@ -255,8 +263,8 @@ class ImageProcessorsTest(PicardTestCase): def test_crop_only_width(self): settings = copy(self.settings) - settings["cover_tags_crop"] = True - settings["cover_tags_resize_use_height"] = False + settings['cover_tags_crop'] = True + settings['cover_tags_resize_use_height'] = False self.set_config_values(settings) self._check_resize_image((1000, 100), (500, 100)) self._check_resize_image((750, 1000), (500, 1000)) @@ -265,16 +273,34 @@ class ImageProcessorsTest(PicardTestCase): def test_crop_only_height(self): settings = copy(self.settings) - settings["cover_tags_crop"] = True - settings["cover_tags_resize_use_width"] = False + settings['cover_tags_crop'] = True + settings['cover_tags_resize_use_width'] = False self.set_config_values(settings) self._check_resize_image((1000, 100), (1000, 500)) self._check_resize_image((750, 1000), (750, 500)) self._check_resize_image((250, 1000), (250, 500)) self.set_config_values(self.settings) + def _check_convert_image(self, format, expected_format): + image = ProcessingImage(create_fake_image(100, 100, format)) + processor = ConvertImage() + processor.run(image, ProcessingTarget.TAGS) + new_image = image.get_result(default_format=True) + new_info = imageinfo.identify(new_image) + self.assertIn(new_info.format, ConvertImage._format_aliases[expected_format]) + + def test_format_conversion(self): + settings = copy(self.settings) + settings['cover_tags_convert_images'] = True + formats = ['jpeg', 'png', 'webp', 'tiff'] + for format in formats: + settings['cover_tags_convert_to_format'] = format + self.set_config_values(settings) + self._check_convert_image('jpeg', format) + self.set_config_values(self.settings) + def test_identification_error(self): - image = create_fake_image(0, 0, "jpg") + image = create_fake_image(0, 0, 'jpg') coverartimage = CoverArtImage() with self.assertRaises(CoverArtProcessingError): run_image_processors(image, coverartimage) diff --git a/ui/options_cover_processing.ui b/ui/options_cover_processing.ui index a706a61a8..d1daed7e2 100644 --- a/ui/options_cover_processing.ui +++ b/ui/options_cover_processing.ui @@ -6,8 +6,8 @@ 0 0 - 478 - 423 + 529 + 467 @@ -564,6 +564,107 @@ + + + + Convert images to the given format + + + false + + + false + + + + + + Convert images saved to tags + + + true + + + + + + New format: + + + + + + + + JPEG + + + + + PNG + + + + + WebP + + + + + TIFF + + + + + + + + + + + Convert images saved to files + + + true + + + + + + New format: + + + + + + + + JPEG + + + + + PNG + + + + + WebP + + + + + TIFF + + + + + + + + + +