diff --git a/requirements.txt b/requirements.txt index a353c70c8..5658340c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,7 @@ numpy==1.26.4 rawpy==0.21.0 pillow-heif==0.16.0 chardet==5.2.0 +pydub==0.25.1 +mutagen==1.47.0 +numpy==1.26.4 +ffmpeg-python==0.2.0 diff --git a/tagstudio/resources/qt/images/broken_link_icon.png b/tagstudio/resources/qt/images/broken_link_icon.png new file mode 100644 index 000000000..d43109708 Binary files /dev/null and b/tagstudio/resources/qt/images/broken_link_icon.png differ diff --git a/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png b/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png new file mode 100644 index 000000000..141ae6203 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png differ diff --git a/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png b/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png new file mode 100644 index 000000000..44a2c1674 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png differ diff --git a/tagstudio/resources/qt/images/file_icons/affinity_photo.png b/tagstudio/resources/qt/images/file_icons/affinity_photo.png new file mode 100644 index 000000000..f4305fb8c Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/affinity_photo.png differ diff --git a/tagstudio/resources/qt/images/file_icons/audio.png b/tagstudio/resources/qt/images/file_icons/audio.png new file mode 100644 index 000000000..9019de01a Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/audio.png differ diff --git a/tagstudio/resources/qt/images/file_icons/document.png b/tagstudio/resources/qt/images/file_icons/document.png new file mode 100644 index 000000000..a3dacb01e Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/document.png differ diff --git a/tagstudio/resources/qt/images/file_icons/file_generic.png b/tagstudio/resources/qt/images/file_icons/file_generic.png new file mode 100644 index 000000000..13685e3a6 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/file_generic.png differ diff --git a/tagstudio/resources/qt/images/file_icons/font.png b/tagstudio/resources/qt/images/file_icons/font.png new file mode 100644 index 000000000..174750dbb Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/font.png differ diff --git a/tagstudio/resources/qt/images/file_icons/image.png b/tagstudio/resources/qt/images/file_icons/image.png new file mode 100644 index 000000000..94264aec0 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/image.png differ diff --git a/tagstudio/resources/qt/images/file_icons/image_vector.png b/tagstudio/resources/qt/images/file_icons/image_vector.png new file mode 100644 index 000000000..f0e38a3d9 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/image_vector.png differ diff --git a/tagstudio/resources/qt/images/file_icons/material.png b/tagstudio/resources/qt/images/file_icons/material.png new file mode 100644 index 000000000..0c0c10afd Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/material.png differ diff --git a/tagstudio/resources/qt/images/file_icons/model.png b/tagstudio/resources/qt/images/file_icons/model.png new file mode 100644 index 000000000..631db6e9a Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/model.png differ diff --git a/tagstudio/resources/qt/images/file_icons/presentation.png b/tagstudio/resources/qt/images/file_icons/presentation.png new file mode 100644 index 000000000..86a3b37c9 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/presentation.png differ diff --git a/tagstudio/resources/qt/images/file_icons/program.png b/tagstudio/resources/qt/images/file_icons/program.png new file mode 100644 index 000000000..f7d64c1a1 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/program.png differ diff --git a/tagstudio/resources/qt/images/file_icons/spreadsheet.png b/tagstudio/resources/qt/images/file_icons/spreadsheet.png new file mode 100644 index 000000000..fb1dbeac2 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/spreadsheet.png differ diff --git a/tagstudio/resources/qt/images/file_icons/text.png b/tagstudio/resources/qt/images/file_icons/text.png new file mode 100644 index 000000000..79d7d91b0 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/text.png differ diff --git a/tagstudio/resources/qt/images/file_icons/video.png b/tagstudio/resources/qt/images/file_icons/video.png new file mode 100644 index 000000000..5dae57a6d Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/video.png differ diff --git a/tagstudio/resources/qt/images/thumb_border_512.png b/tagstudio/resources/qt/images/thumb_border_512.png deleted file mode 100644 index 605717e3d..000000000 Binary files a/tagstudio/resources/qt/images/thumb_border_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_broken_512.png b/tagstudio/resources/qt/images/thumb_broken_512.png deleted file mode 100644 index 5022f2eb3..000000000 Binary files a/tagstudio/resources/qt/images/thumb_broken_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_file_default_512.png b/tagstudio/resources/qt/images/thumb_file_default_512.png deleted file mode 100644 index 28dfbd433..000000000 Binary files a/tagstudio/resources/qt/images/thumb_file_default_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_loading.png b/tagstudio/resources/qt/images/thumb_loading.png new file mode 100644 index 000000000..174a1879d Binary files /dev/null and b/tagstudio/resources/qt/images/thumb_loading.png differ diff --git a/tagstudio/resources/qt/images/thumb_loading_512.png b/tagstudio/resources/qt/images/thumb_loading_512.png deleted file mode 100644 index 05008af58..000000000 Binary files a/tagstudio/resources/qt/images/thumb_loading_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_loading_dark_512.png b/tagstudio/resources/qt/images/thumb_loading_dark_512.png deleted file mode 100644 index 7dcd99db7..000000000 Binary files a/tagstudio/resources/qt/images/thumb_loading_dark_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_128.png b/tagstudio/resources/qt/images/thumb_mask_128.png deleted file mode 100644 index 52a0a1353..000000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_128.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_512.png b/tagstudio/resources/qt/images/thumb_mask_512.png deleted file mode 100644 index ce641abc4..000000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_hl_512.png b/tagstudio/resources/qt/images/thumb_mask_hl_512.png deleted file mode 100644 index 36c896b85..000000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_hl_512.png and /dev/null differ diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index bd6f53023..bac1edd46 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -55,6 +55,5 @@ "cool gray", "olive", ] - TAG_FAVORITE = 1 TAG_ARCHIVED = 0 diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 7610a2ccc..613c45d4b 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -12,7 +12,9 @@ class SettingItems(str, enum.Enum): class Theme(str, enum.Enum): - COLOR_BG = "#65000000" + COLOR_BG_DARK = "#65000000" + COLOR_BG_LIGHT = "#22000000" + COLOR_DARK_LABEL = "#DD000000" COLOR_HOVER = "#65AAAAAA" COLOR_PRESSED = "#65EEEEEE" COLOR_DISABLED = "#65F39CAA" diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index d1974343a..449aa8aee 100644 --- a/tagstudio/src/core/media_types.py +++ b/tagstudio/src/core/media_types.py @@ -13,7 +13,10 @@ class MediaType(str, Enum): """Names of media types.""" + ADOBE_PHOTOSHOP: str = "adobe_photoshop" + AFFINITY_PHOTO: str = "affinity_photo" ARCHIVE: str = "archive" + AUDIO_MIDI: str = "audio_midi" AUDIO: str = "audio" BLENDER: str = "blender" DATABASE: str = "database" @@ -27,7 +30,7 @@ class MediaType(str, Enum): MATERIAL: str = "material" MODEL: str = "model" PACKAGE: str = "package" - PHOTOSHOP: str = "photoshop" + PDF: str = "pdf" PLAINTEXT: str = "plaintext" PRESENTATION: str = "presentation" PROGRAM: str = "program" @@ -67,6 +70,12 @@ class MediaCategories: # These sets are used either individually or together to form the final sets # for the MediaCategory(s). # These sets may be combined and are NOT 1:1 with the final categories. + _ADOBE_PHOTOSHOP_SET: set[str] = { + ".pdd", + ".psb", + ".psd", + } + _AFFINITY_PHOTO_SET: set[str] = {".afphoto"} _ARCHIVE_SET: set[str] = { ".7z", ".gz", @@ -76,6 +85,10 @@ class MediaCategories: ".tgz", ".zip", } + _AUDIO_MIDI_SET: set[str] = { + ".mid", + ".midi", + } _AUDIO_SET: set[str] = { ".aac", ".aif", @@ -182,6 +195,7 @@ class MediaCategories: ".jpg_large", ".jpg", ".jpg2", + ".jxl", ".png", ".psb", ".psd", @@ -192,11 +206,17 @@ class MediaCategories: _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} _MATERIAL_SET: set[str] = {".mtl"} _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} - _PACKAGE_SET: set[str] = {".pkg"} - _PHOTOSHOP_SET: set[str] = { - ".pdd", - ".psb", - ".psd", + _PACKAGE_SET: set[str] = { + ".aab", + ".akp", + ".apk", + ".apkm", + ".apks", + ".pkg", + ".xapk", + } + _PDF_SET: set[str] = { + ".pdf", } _PLAINTEXT_SET: set[str] = { ".bat", @@ -247,14 +267,29 @@ class MediaCategories: ".wmv", } + ADOBE_PHOTOSHOP_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.ADOBE_PHOTOSHOP, + extensions=_ADOBE_PHOTOSHOP_SET, + is_iana=False, + ) + AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AFFINITY_PHOTO, + extensions=_AFFINITY_PHOTO_SET, + is_iana=False, + ) ARCHIVE_TYPES: MediaCategory = MediaCategory( media_type=MediaType.ARCHIVE, extensions=_ARCHIVE_SET, is_iana=False, ) + AUDIO_MIDI_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AUDIO_MIDI, + extensions=_AUDIO_MIDI_SET, + is_iana=False, + ) AUDIO_TYPES: MediaCategory = MediaCategory( media_type=MediaType.AUDIO, - extensions=_AUDIO_SET, + extensions=_AUDIO_SET | _AUDIO_MIDI_SET, is_iana=True, ) BLENDER_TYPES: MediaCategory = MediaCategory( @@ -317,9 +352,9 @@ class MediaCategories: extensions=_PACKAGE_SET, is_iana=False, ) - PHOTOSHOP_TYPES: MediaCategory = MediaCategory( - media_type=MediaType.PHOTOSHOP, - extensions=_PHOTOSHOP_SET, + PDF_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PDF, + extensions=_PDF_SET, is_iana=False, ) PLAINTEXT_TYPES: MediaCategory = MediaCategory( @@ -359,7 +394,10 @@ class MediaCategories: ) ALL_CATEGORIES: list[MediaCategory] = [ + ADOBE_PHOTOSHOP_TYPES, + AFFINITY_PHOTO_TYPES, ARCHIVE_TYPES, + AUDIO_MIDI_TYPES, AUDIO_TYPES, BLENDER_TYPES, DATABASE_TYPES, @@ -373,7 +411,7 @@ class MediaCategories: MATERIAL_TYPES, MODEL_TYPES, PACKAGE_TYPES, - PHOTOSHOP_TYPES, + PDF_TYPES, PLAINTEXT_TYPES, PRESENTATION_TYPES, PROGRAM_TYPES, diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 886e0bd6c..7edacc747 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -13,7 +13,7 @@ class ColorType(int, Enum): DARK_ACCENT = 4 -_TAG_COLORS = { +_TAG_COLORS: dict = { "": { ColorType.PRIMARY: "#1e1e1e", ColorType.TEXT: ColorType.LIGHT_ACCENT, @@ -277,13 +277,58 @@ class ColorType(int, Enum): }, } +_UI_COLORS: dict = { + "": { + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + "green": { + ColorType.PRIMARY: "#28bb48", + ColorType.BORDER: "#43c568", + ColorType.LIGHT_ACCENT: "#DDFFCC", + ColorType.DARK_ACCENT: "#0d3828", + }, + "purple": { + ColorType.PRIMARY: "#C76FF3", + ColorType.BORDER: "#c364f2", + ColorType.LIGHT_ACCENT: "#EFD4FB", + ColorType.DARK_ACCENT: "#3E1555", + }, + "red": { + ColorType.PRIMARY: "#e22c3c", + ColorType.BORDER: "#e54252", + ColorType.LIGHT_ACCENT: "#f39caa", + ColorType.DARK_ACCENT: "#440d12", + }, + "theme_dark": { + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + "theme_light": { + ColorType.PRIMARY: "#FFFFFF", + ColorType.BORDER: "#333333", + ColorType.LIGHT_ACCENT: "#999999", + ColorType.DARK_ACCENT: "#888888", + }, +} + -def get_tag_color(type, color): +def get_tag_color(color_type, color): color = color.lower() try: - if type == ColorType.TEXT: - return get_tag_color(_TAG_COLORS[color][type], color) + if color_type == ColorType.TEXT: + return get_tag_color(_TAG_COLORS[color][color_type], color) else: - return _TAG_COLORS[color][type] + return _TAG_COLORS[color][color_type] except KeyError: return "#FF00FF" + + +def get_ui_color(color_type: ColorType, color: str): + """Returns a hex value given a color name and ColorType.""" + color = color.lower() + return _UI_COLORS.get(color).get(color_type) diff --git a/tagstudio/src/qt/helpers/color_overlay.py b/tagstudio/src/qt/helpers/color_overlay.py index c19ba73ed..c468c2b39 100644 --- a/tagstudio/src/qt/helpers/color_overlay.py +++ b/tagstudio/src/qt/helpers/color_overlay.py @@ -10,23 +10,28 @@ # TODO: Consolidate the built-in QT theme values with the values # here, in enums.py, and in palette.py. -_THEME_DARK_FG: str = "#FFFFFF55" +_THEME_DARK_FG: str = "#FFFFFF77" _THEME_LIGHT_FG: str = "#000000DD" +_THEME_DARK_BG: str = "#000000DD" +_THEME_LIGHT_BG: str = "#FFFFFF55" -def theme_fg_overlay(image: Image.Image) -> Image.Image: +def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image: """ Overlay the foreground theme color onto an image. Args: image (Image): The PIL Image object to apply an overlay to. """ + dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG + light_fg: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG overlay_color = ( - _THEME_DARK_FG + dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else _THEME_LIGHT_FG + else light_fg ) + im = Image.new(mode="RGBA", size=image.size, color=overlay_color) return _apply_overlay(image, im) diff --git a/tagstudio/src/qt/helpers/file_tester.py b/tagstudio/src/qt/helpers/file_tester.py new file mode 100644 index 000000000..3fbea0903 --- /dev/null +++ b/tagstudio/src/qt/helpers/file_tester.py @@ -0,0 +1,29 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import ffmpeg +from pathlib import Path + + +def is_readable_video(filepath: Path | str): + """Test if a video is in a readable format. Examples of unreadable videos + include files with undetermined codecs and DRM-protected content. + + Args: + filepath (Path | str): + """ + try: + probe = ffmpeg.probe(Path(filepath)) + for stream in probe["streams"]: + # DRM check + if stream.get("codec_tag_string") in [ + "drma", + "drms", + "drmi", + ]: + return False + except ffmpeg.Error: + return False + return True diff --git a/tagstudio/src/qt/helpers/gradient.py b/tagstudio/src/qt/helpers/gradient.py index dabe7639a..fe3f7c7de 100644 --- a/tagstudio/src/qt/helpers/gradient.py +++ b/tagstudio/src/qt/helpers/gradient.py @@ -2,24 +2,14 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from PIL import Image, ImageEnhance, ImageChops +from PIL import Image -def four_corner_gradient_background( - image: Image.Image, adj_size, mask, hl +def four_corner_gradient( + image: Image.Image, size: tuple[int, int], mask: Image.Image ) -> Image.Image: - if image.size != (adj_size, adj_size): - # Old 1 color method. - # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) - # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col) - # bg.thumbnail((1, 1)) - # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) - - # Small gradient background. Looks decent, and is only a one-liner. - # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR) - + if image.size != size: # Four-Corner Gradient Background. - # Not exactly a one-liner, but it's (subjectively) really cool. tl = image.getpixel((0, 0)) tr = image.getpixel(((image.size[0] - 1), 0)) bl = image.getpixel((0, (image.size[1] - 1))) @@ -29,26 +19,25 @@ def four_corner_gradient_background( bg.paste(tr, (1, 0, 2, 2)) bg.paste(bl, (0, 1, 2, 2)) bg.paste(br, (1, 1, 2, 2)) - bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC) - + bg = bg.resize(size, resample=Image.Resampling.BICUBIC) bg.paste( image, box=( - (adj_size - image.size[0]) // 2, - (adj_size - image.size[1]) // 2, + (size[0] - image.size[0]) // 2, + (size[1] - image.size[1]) // 2, ), ) - bg.putalpha(mask) - final = bg + final = Image.new("RGBA", bg.size, (0, 0, 0, 0)) + final.paste(bg, mask=mask.getchannel(0)) else: - image.putalpha(mask) - final = image + final = Image.new("RGBA", size, (0, 0, 0, 0)) + final.paste(image, mask=mask.getchannel(0)) + + if final.mode != "RGBA": + final = final.convert("RGBA") - hl_soft = hl.copy() - hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5)) - final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3)) return final diff --git a/tagstudio/src/qt/helpers/rounded_pixmap_style.py b/tagstudio/src/qt/helpers/rounded_pixmap_style.py new file mode 100644 index 000000000..d5a581b7d --- /dev/null +++ b/tagstudio/src/qt/helpers/rounded_pixmap_style.py @@ -0,0 +1,31 @@ +# Based on the implementation by eyllanesc: +# https://stackoverflow.com/questions/54230005/qmovie-with-border-radius +# Licensed under the Creative Commons CC BY-SA 4.0 License: +# https://creativecommons.org/licenses/by-sa/4.0/ +# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap +from PySide6.QtWidgets import ( + QProxyStyle, +) + + +class RoundedPixmapStyle(QProxyStyle): + def __init__(self, radius=8): + super().__init__() + self._radius = radius + + def drawItemPixmap(self, painter, rectangle, alignment, pixmap): + painter.save() + pix = QPixmap(pixmap.size()) + pix.fill(QColor("transparent")) + p = QPainter(pix) + p.setBrush(QBrush(pixmap)) + p.setPen(QColor("transparent")) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.drawRoundedRect(pixmap.rect(), self._radius, self._radius) + p.end() + super(RoundedPixmapStyle, self).drawItemPixmap( + painter, rectangle, alignment, pix + ) + painter.restore() diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index a77f87447..bd03b0b1f 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -66,7 +66,7 @@ def setupUi(self, MainWindow): self.horizontalLayout = QHBoxLayout() self.horizontalLayout.setObjectName(u"horizontalLayout") - # ComboBox goup for search type and thumbnail size + # ComboBox group for search type and thumbnail size self.horizontalLayout_3 = QHBoxLayout() self.horizontalLayout_3.setObjectName("horizontalLayout_3") @@ -83,17 +83,17 @@ def setupUi(self, MainWindow): self.horizontalLayout_3.addWidget(self.comboBox_2) # Thumbnail Size placeholder - self.comboBox = QComboBox(self.centralwidget) - self.comboBox.setObjectName(u"comboBox") + self.thumb_size_combobox = QComboBox(self.centralwidget) + self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox") sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( - self.comboBox.sizePolicy().hasHeightForWidth()) - self.comboBox.setSizePolicy(sizePolicy) - self.comboBox.setMinimumWidth(128) - self.comboBox.setMaximumWidth(128) - self.horizontalLayout_3.addWidget(self.comboBox) + self.thumb_size_combobox.sizePolicy().hasHeightForWidth()) + self.thumb_size_combobox.setSizePolicy(sizePolicy) + self.thumb_size_combobox.setMinimumWidth(128) + self.thumb_size_combobox.setMaximumWidth(352) + self.horizontalLayout_3.addWidget(self.thumb_size_combobox) self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1) self.splitter = QSplitter() @@ -212,10 +212,10 @@ def retranslateUi(self, MainWindow): # Search type selector self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)")) self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)")) - self.comboBox.setCurrentText("") + self.thumb_size_combobox.setCurrentText("") # Thumbnail size selector - self.comboBox.setPlaceholderText( + self.thumb_size_combobox.setPlaceholderText( QCoreApplication.translate("MainWindow", u"Thumbnail Size", None)) # retranslateUi diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index 0db8bb194..3c4e80996 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -5,6 +5,7 @@ import logging from pathlib import Path from typing import Any +from PIL import Image import ujson @@ -46,19 +47,30 @@ def get(self, id: str) -> Any: return cached_res else: res: dict = ResourceManager._map.get(id) - if res.get("mode") in ["r", "rb"]: - with open( - (Path(__file__).parents[2] / "resources" / res.get("path")), - res.get("mode"), - ) as f: - data = f.read() - if res.get("mode") == "rb": - data = bytes(data) - ResourceManager._cache[id] = data + try: + if res and res.get("mode") in ["r", "rb"]: + with open( + (Path(__file__).parents[2] / "resources" / res.get("path")), + res.get("mode"), + ) as f: + data = f.read() + if res.get("mode") == "rb": + data = bytes(data) + ResourceManager._cache[id] = data + return data + elif res and res.get("mode") == "pil": + data = Image.open( + Path(__file__).parents[2] / "resources" / res.get("path") + ) return data - elif res.get("mode") in ["qt"]: - # TODO: Qt resource loading logic - pass + elif res and res.get("mode") in ["qt"]: + # TODO: Qt resource loading logic + pass + except FileNotFoundError: + logging.error( + f"[ResourceManager][ERROR]: Could not find resource: {Path(__file__).parents[2] / "resources" / res.get("path")}" + ) + return None def __getattr__(self, __name: str) -> Any: attr = self.get(__name) diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 1f8663d37..ef007b6f2 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -14,5 +14,81 @@ "volume_mute_icon": { "path": "qt/images/volume_mute.svg", "mode": "rb" + }, + "broken_link_icon": { + "path": "qt/images/broken_link_icon.png", + "mode": "pil" + }, + "adobe_illustrator": { + "path": "qt/images/file_icons/adobe_illustrator.png", + "mode": "pil" + }, + "adobe_photoshop": { + "path": "qt/images/file_icons/adobe_photoshop.png", + "mode": "pil" + }, + "affinity_photo": { + "path": "qt/images/file_icons/affinity_photo.png", + "mode": "pil" + }, + "audio": { + "path": "qt/images/file_icons/audio.png", + "mode": "pil" + }, + "blender": { + "path": "qt/images/file_icons/blender.png", + "mode": "pil" + }, + "document": { + "path": "qt/images/file_icons/document.png", + "mode": "pil" + }, + "file_generic": { + "path": "qt/images/file_icons/file_generic.png", + "mode": "pil" + }, + "font": { + "path": "qt/images/file_icons/font.png", + "mode": "pil" + }, + "image": { + "path": "qt/images/file_icons/image.png", + "mode": "pil" + }, + "image_vector": { + "path": "qt/images/file_icons/image_vector.png", + "mode": "pil" + }, + "material": { + "path": "qt/images/file_icons/material.png", + "mode": "pil" + }, + "model": { + "path": "qt/images/file_icons/model.png", + "mode": "pil" + }, + "presentation": { + "path": "qt/images/file_icons/presentation.png", + "mode": "pil" + }, + "program": { + "path": "qt/images/file_icons/program.png", + "mode": "pil" + }, + "spreadsheet": { + "path": "qt/images/file_icons/spreadsheet.png", + "mode": "pil" + }, + "text": { + "path": "qt/images/file_icons/text.png", + "mode": "pil" + }, + "video": { + "path": "qt/images/file_icons/video.png", + "mode": "pil" + }, + "thumb_loading": { + "path": "qt/images/thumb_loading.png", + "mode": "pil" } } diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 73382b1da..f5b10cbd7 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -557,11 +557,17 @@ def start(self) -> None: str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf") ) + self.thumb_sizes: list[tuple[str, int]] = [ + ("Extra Large Thumbnails", 256), + ("Large Thumbnails", 192), + ("Medium Thumbnails", 128), + ("Small Thumbnails", 96), + ("Mini Thumbnails", 76), + ] self.thumb_size = 128 self.max_results = 500 self.item_thumbs: list[ItemThumb] = [] self.thumb_renderers: list[ThumbRenderer] = [] - self.collation_thumb_size = math.ceil(self.thumb_size * 2) self.init_library_window() @@ -596,23 +602,35 @@ def start(self) -> None: self.shutdown() def init_library_window(self): - # self._init_landing_page() # Taken care of inside the widget now - self._init_thumb_grid() - # TODO: Put this into its own method that copies the font file(s) into memory # so the resource isn't being used, then store the specific size variations # in a global dict for methods to access for different DPIs. # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) # self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size) + # Search Button search_button: QPushButton = self.main_window.searchButton search_button.clicked.connect( lambda: self.filter_items(self.main_window.searchField.text()) ) + + # Search Field search_field: QLineEdit = self.main_window.searchField search_field.returnPressed.connect( lambda: self.filter_items(self.main_window.searchField.text()) ) + + # Thumbnail Size ComboBox + thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox + for size in self.thumb_sizes: + thumb_size_combobox.addItem(size[0]) + thumb_size_combobox.setCurrentIndex(2) # Default: Medium + thumb_size_combobox.currentIndexChanged.connect( + lambda: self.thumb_size_callback(thumb_size_combobox.currentIndex()) + ) + self._init_thumb_grid() + + # Search Type ComboBox search_type_selector: QComboBox = self.main_window.comboBox_2 search_type_selector.currentIndexChanged.connect( lambda: self.set_search_type( @@ -1099,6 +1117,37 @@ def update_clipboard_actions(self): else: self.paste_entry_fields_action.setText("&Paste Fields") + def thumb_size_callback(self, index: int): + """ + Performs actions needed when the thumbnail size selection is changed. + + Args: + index (int): The index of the item_thumbs/ComboBox list to use. + """ + SPACING_DIVISOR: int = 10 + MIN_SPACING: int = 12 + # Index 2 is the default (Medium) + if index < len(self.thumb_sizes) and index >= 0: + self.thumb_size = self.thumb_sizes[index][1] + else: + logging.error( + f"ERROR: Invalid thumbnail size index ({index}). Defaulting to 128px." + ) + self.thumb_size = 128 + + self.update_thumbs() + blank_icon: QIcon = QIcon() + for it in self.item_thumbs: + it.thumb_button.setIcon(blank_icon) + it.resize(self.thumb_size, self.thumb_size) + it.thumb_size = (self.thumb_size, self.thumb_size) + it.setMinimumSize(self.thumb_size, self.thumb_size) + it.setMaximumSize(self.thumb_size, self.thumb_size) + it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size) + self.flow_container.layout().setSpacing( + min(self.thumb_size // SPACING_DIVISOR, MIN_SPACING) + ) + def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index ddfda5c06..c73eb7c7e 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import logging -import os import traceback from pathlib import Path @@ -12,24 +11,15 @@ from PIL.Image import DecompressionBombError from PySide6.QtCore import ( QObject, - QThread, Signal, - QRunnable, - Qt, - QThreadPool, - QSize, - QEvent, - QTimer, - QSettings, ) - from src.core.library import Library from src.core.media_types import MediaCategories, MediaType +from src.qt.helpers.file_tester import is_readable_video - -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" +ERROR = "[ERROR]" +WARNING = "[WARNING]" +INFO = "[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) @@ -53,7 +43,6 @@ def render( ): entry = self.lib.get_entry(entry_id) filepath = self.lib.library_dir / entry.path / entry.filename - file_type = os.path.splitext(filepath)[1].lower()[1:] color: str = "" try: @@ -85,14 +74,11 @@ def render( if data_only_mode: pic = Image.new("RGB", size, color) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) if not data_only_mode: logging.info( - f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m" + f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{entry.filename}\033[0m" ) - # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') - # sys.stdout.flush() ext: str = filepath.suffix.lower() if MediaType.IMAGE in MediaCategories.get_types(ext): try: @@ -108,39 +94,36 @@ def render( pic = ImageChops.hard_light( pic, Image.new("RGB", size, color) ) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except DecompressionBombError as e: logging.info(f"[ERROR] One of the images was too big ({e})") elif MediaType.VIDEO in MediaCategories.get_types(ext): - video = cv2.VideoCapture(str(filepath)) - video.set( - cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - ) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) + if is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - with Image.fromarray(frame, mode="RGB") as pic: - if keep_aspect: - pic.thumbnail(size) - else: - pic = pic.resize(size) - if data_tint_mode and color: - pic = ImageChops.hard_light( - pic, Image.new("RGB", size, color) - ) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) - self.rendered.emit(pic) + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + with Image.fromarray(frame, mode="RGB") as pic: + if keep_aspect: + pic.thumbnail(size) + else: + pic = pic.resize(size) + if data_tint_mode and color: + pic = ImageChops.hard_light( + pic, Image.new("RGB", size, color) + ) + self.rendered.emit(pic) except (UnidentifiedImageError, FileNotFoundError): - logging.info( - f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}" - ) + logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}") with Image.open( str( Path(__file__).parents[2] @@ -151,22 +134,16 @@ def render( if data_tint_mode and color: pic = pic.convert(mode="RGB") pic = ImageChops.hard_light(pic, Image.new("RGB", size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except KeyboardInterrupt: - # self.quit(save=False, backup=True) - run = False - # clear() logging.info("\n") logging.info(f"{INFO} Collage operation cancelled.") - clear_scr = False - except: - logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}") + except Exception: + logging.info(f"{ERROR} {entry.path}/{entry.filename}") traceback.print_exc() logging.info("Continuing...") self.done.emit() - # logging.info('Done!') # NOTE: Depreciated def get_file_color(self, ext: str): diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 355a0fa94..03b43d848 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -4,16 +4,16 @@ import math -import os from types import FunctionType, MethodType from pathlib import Path -from typing import Optional, cast, Callable, Any +from typing import Optional, cast, Callable from PIL import Image, ImageQt from PySide6.QtCore import Qt, QEvent from PySide6.QtGui import QPixmap, QEnterEvent -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.helpers.color_overlay import theme_fg_overlay class FieldContainer(QWidget): @@ -35,22 +35,21 @@ class FieldContainer(QWidget): def __init__(self, title: str = "Field", inline: bool = True) -> None: super().__init__() - # self.mode:str = mode self.setObjectName("fieldContainer") - # self.item = item self.title: str = title self.inline: bool = inline - # self.editable:bool = editable self.copy_callback: FunctionType = None self.edit_callback: FunctionType = None self.remove_callback: Callable = None button_size = 24 - # self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;') + + self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128) + self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128) + self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128) self.root_layout = QVBoxLayout(self) self.root_layout.setObjectName("baseLayout") self.root_layout.setContentsMargins(0, 0, 0, 0) - # self.setStyleSheet('background-color:red;') self.inner_layout = QVBoxLayout() self.inner_layout.setObjectName("innerLayout") @@ -62,7 +61,6 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.root_layout.addWidget(self.inner_container) self.title_container = QWidget() - # self.title_container.setStyleSheet('background:black;') self.title_layout = QHBoxLayout(self.title_container) self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.title_layout.setObjectName("fieldLayout") @@ -75,9 +73,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.title_widget.setObjectName("fieldTitle") self.title_widget.setWordWrap(True) self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;") - # self.title_widget.setStyleSheet('background-color:orange;') self.title_widget.setText(title) - # self.inner_layout.addWidget(self.title_widget) self.title_layout.addWidget(self.title_widget) self.title_layout.addStretch(2) @@ -119,11 +115,8 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.field_layout.setObjectName("fieldLayout") self.field_layout.setContentsMargins(0, 0, 0, 0) self.field_container.setLayout(self.field_layout) - # self.field_container.setStyleSheet('background-color:#666600;') self.inner_layout.addWidget(self.field_container) - # self.set_inner_widget(mode) - def set_copy_callback(self, callback: Optional[MethodType]): if self.copy_button.is_connected: self.copy_button.clicked.disconnect() @@ -151,12 +144,7 @@ def set_remove_callback(self, callback: Optional[Callable]): self.remove_button.is_connected = True def set_inner_widget(self, widget: "FieldWidget"): - # widget.setStyleSheet('background-color:green;') - # self.inner_container.dumpObjectTree() - # logging.info('') if self.field_layout.itemAt(0): - # logging.info(f'Removing {self.field_layout.itemAt(0)}') - # self.field_layout.removeItem(self.field_layout.itemAt(0)) self.field_layout.itemAt(0).widget().deleteLater() self.field_layout.addWidget(widget) @@ -172,12 +160,7 @@ def set_title(self, title: str): def set_inline(self, inline: bool): self.inline = inline - # def set_editable(self, editable:bool): - # self.editable = editable - def enterEvent(self, event: QEnterEvent) -> None: - # if self.field_layout.itemAt(1): - # self.field_layout.itemAt(1). # NOTE: You could pass the hover event to the FieldWidget if needed. if self.copy_callback: self.copy_button.setHidden(False) @@ -202,5 +185,4 @@ class FieldWidget(QWidget): def __init__(self, title) -> None: super().__init__() - # self.item = item self.title = title diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index fdeb6739e..5cd1ea23b 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -62,27 +62,29 @@ class ItemThumb(FlowWidget): tag_group_icon_128.load() small_text_style = ( - f"background-color:rgba(0, 0, 0, 192);" - f"font-family:Oxanium;" - f"font-weight:bold;" - f"font-size:12px;" - f"border-radius:3px;" - f"padding-top: 4px;" - f"padding-right: 1px;" - f"padding-bottom: 1px;" - f"padding-left: 1px;" + "background-color:rgba(0, 0, 0, 192);" + "color:#FFFFFF;" + "font-family:Oxanium;" + "font-weight:bold;" + "font-size:12px;" + "border-radius:3px;" + "padding-top: 4px;" + "padding-right: 1px;" + "padding-bottom: 1px;" + "padding-left: 1px;" ) med_text_style = ( - f"background-color:rgba(0, 0, 0, 192);" - f"font-family:Oxanium;" - f"font-weight:bold;" - f"font-size:18px;" - f"border-radius:3px;" - f"padding-top: 4px;" - f"padding-right: 1px;" - f"padding-bottom: 1px;" - f"padding-left: 1px;" + "background-color:rgba(0, 0, 0, 192);" + "color:#FFFFFF;" + "font-family:Oxanium;" + "font-weight:bold;" + "font-size:18px;" + "border-radius:3px;" + "padding-top: 4px;" + "padding-right: 1px;" + "padding-bottom: 1px;" + "padding-left: 1px;" ) def __init__( @@ -361,12 +363,15 @@ def set_extension(self, ext: str) -> None: and (MediaType.IMAGE not in MediaCategories.get_types(ext)) or (MediaType.IMAGE_RAW in MediaCategories.get_types(ext)) or (MediaType.IMAGE_VECTOR in MediaCategories.get_types(ext)) - or (MediaType.PHOTOSHOP in MediaCategories.get_types(ext)) + or (MediaType.ADOBE_PHOTOSHOP in MediaCategories.get_types(ext)) or ext in [ ".apng", + ".avif", ".exr", ".gif", + ".jxl", + ".webp", ] ): self.ext_badge.setHidden(False) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 537ee09f4..458724719 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -7,13 +7,12 @@ import time import typing from datetime import datetime as dt - import cv2 import rawpy from PIL import Image, UnidentifiedImageError, ImageFont from PIL.Image import DecompressionBombError from PySide6.QtCore import QModelIndex, Signal, Qt, QSize -from PySide6.QtGui import QResizeEvent, QAction +from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -27,13 +26,13 @@ QMessageBox, ) from humanfriendly import format_size - from src.core.enums import SettingItems, Theme from src.core.library import Entry, ItemType, Library from src.core.constants import ( TS_FOLDER_NAME, ) from src.core.media_types import MediaCategories, MediaType +from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file from src.qt.modals.add_field import AddFieldModal from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -45,6 +44,7 @@ from src.qt.widgets.text_line_edit import EditTextLine from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.widgets.video_player import VideoPlayer +from src.qt.helpers.file_tester import is_readable_video # Only import for type checking/autocompletion, will not be imported at runtime. @@ -81,6 +81,17 @@ def __init__(self, library: Library, driver: "QtDriver"): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 + self.label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_DARK_LABEL.value + ) + self.panel_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + self.image_container = QWidget() image_layout = QHBoxLayout(self.image_container) image_layout.setContentsMargins(0, 0, 0, 0) @@ -92,9 +103,17 @@ def __init__(self, library: Library, driver: "QtDriver"): self.preview_img.setMinimumSize(*self.img_button_size) self.preview_img.setFlat(True) self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.addAction(self.open_file_action) self.preview_img.addAction(self.open_explorer_action) + + self.preview_gif = QLabel() + self.preview_gif.setMinimumSize(*self.img_button_size) + self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) + self.preview_gif.addAction(self.open_file_action) + self.preview_gif.addAction(self.open_explorer_action) + self.preview_gif.hide() + self.preview_vid = VideoPlayer(driver) self.preview_vid.hide() self.thumb_renderer = ThumbRenderer() @@ -116,6 +135,8 @@ def __init__(self, library: Library, driver: "QtDriver"): image_layout.addWidget(self.preview_img) image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) + image_layout.addWidget(self.preview_gif) + image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) image_layout.addWidget(self.preview_vid) image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) self.image_container.setMinimumSize(*self.img_button_size) @@ -132,15 +153,16 @@ def __init__(self, library: Library, driver: "QtDriver"): # Qt.TextInteractionFlag.TextSelectableByMouse) properties_style = ( - f"background-color:{Theme.COLOR_BG.value};" - f"font-family:Oxanium;" - f"font-weight:bold;" - f"font-size:12px;" - f"border-radius:6px;" - f"padding-top: 4px;" - f"padding-right: 1px;" - f"padding-bottom: 1px;" - f"padding-left: 1px;" + f"background-color:{self.label_bg_color};" + "color:#FFFFFF;" + "font-family:Oxanium;" + "font-weight:bold;" + "font-size:12px;" + "border-radius:3px;" + "padding-top: 4px;" + "padding-right: 1px;" + "padding-bottom: 1px;" + "padding-left: 1px;" ) self.dimensions_label.setStyleSheet(properties_style) @@ -171,9 +193,10 @@ def __init__(self, library: Library, driver: "QtDriver"): # background and NOT the scroll container background, so that the # rounded corners are maintained when scrolling. I was unable to # find the right trick to only select that particular element. + scroll_area.setStyleSheet( "QWidget#entryScrollContainer{" - f"background: {Theme.COLOR_BG.value};" + f"background:{self.panel_bg_color};" "border-radius:6px;" "}" ) @@ -278,6 +301,7 @@ def clear_layout(layout_item: QVBoxLayout): clear_layout(layout) label = QLabel("Recent Libraries") + label.setStyleSheet("font-weight:bold;") label.setAlignment(Qt.AlignCenter) # type: ignore row_layout = QHBoxLayout() @@ -288,11 +312,9 @@ def set_button_style( btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None ): base_style = [ - f"background-color:{Theme.COLOR_BG.value};", + f"background-color:{self.panel_bg_color};", "border-radius:6px;", - "text-align: left;", "padding-top: 3px;", - "padding-left: 6px;", "padding-bottom: 4px;", ] @@ -323,11 +345,11 @@ def open_library_button_clicked(path): return lambda: self.driver.open_library(Path(path)) button.clicked.connect(open_library_button_clicked(full_val)) - set_button_style(button) - button_remove = QPushButton("➖") + set_button_style(button, ["padding-left: 6px;", "text-align: left;"]) + button_remove = QPushButton("—") button_remove.setCursor(Qt.CursorShape.PointingHandCursor) - button_remove.setFixedWidth(30) - set_button_style(button_remove) + button_remove.setFixedWidth(24) + set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"]) def remove_recent_library_clicked(key: str): return lambda: ( @@ -396,20 +418,14 @@ def update_image_size(self, size: tuple[int, int], ratio: float = None): self.preview_vid.resizeVideo(adj_size) self.preview_vid.setMaximumSize(adj_size) self.preview_vid.setMinimumSize(adj_size) - # self.preview_img.setMinimumSize(adj_size) - - # if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10: - # if type(self.item) == Entry: - # filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}') - # self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True) - - # logging.info(f' Img Aspect Ratio: {self.image_ratio}') - # logging.info(f' Max Button Size: {size}') - # logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}') - # logging.info(f'Final Button Size: {(adj_width, adj_height)}') - # logging.info(f'') - # logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}') - # logging.info(f'Button Size: {self.preview_img.size().toTuple()}') + self.preview_gif.setMaximumSize(adj_size) + self.preview_gif.setMinimumSize(adj_size) + proxy_style = RoundedPixmapStyle(radius=8) + self.preview_gif.setStyle(proxy_style) + self.preview_vid.setStyle(proxy_style) + m = self.preview_gif.movie() + if m: + m.setScaledSize(adj_size) def place_add_field_button(self): self.scroll_layout.addWidget(self.afb_container) @@ -479,6 +495,7 @@ def update_widgets(self): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.preview_gif.hide() self.selected = list(self.driver.selected) self.add_field_button.setHidden(True) @@ -489,6 +506,7 @@ def update_widgets(self): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.preview_gif.hide() item: Entry = self.lib.get_entry(self.driver.selected[0][1]) # If a new selection is made, update the thumbnail and filepath. if not self.selected or self.selected != self.driver.selected: @@ -520,6 +538,21 @@ def update_widgets(self): # TODO: Do this all somewhere else, this is just here temporarily. ext: str = filepath.suffix.lower() try: + if filepath.suffix.lower() in [".gif"]: + movie = QMovie(str(filepath)) + image = Image.open(str(filepath)) + self.preview_gif.setMovie(movie) + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + movie.start() + self.preview_img.hide() + self.preview_vid.hide() + self.preview_gif.show() + image = None if ( (MediaType.IMAGE in MediaCategories.get_types(ext)) @@ -546,25 +579,27 @@ def update_widgets(self): ): pass elif MediaType.VIDEO in MediaCategories.get_types(ext): - video = cv2.VideoCapture(str(filepath)) - if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - if success: - self.preview_img.hide() - self.preview_vid.play( - filepath, QSize(image.width, image.height) + if is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), ) - self.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + if success: + self.preview_img.hide() + self.preview_vid.play( + filepath, QSize(image.width, image.height) ) - ) - self.preview_vid.show() + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + self.preview_vid.show() # Stats for specific file types are displayed here. if image and ( @@ -607,7 +642,7 @@ def update_widgets(self): ) except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{ext.upper()}") + self.dimensions_label.setText(f"{ext.upper()[1:]}") logging.info( f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" ) @@ -622,6 +657,7 @@ def update_widgets(self): f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" ) + # TODO: Implement a clickable label to use for the GIF preview. if self.preview_img.is_connected: self.preview_img.clicked.disconnect() self.preview_img.clicked.connect( @@ -651,6 +687,7 @@ def update_widgets(self): # Multiple Selected Items elif len(self.driver.selected) > 1: self.preview_img.show() + self.preview_gif.hide() self.preview_vid.stop() self.preview_vid.hide() if self.selected != self.driver.selected: diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 2b1e2b424..5d018547c 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -9,6 +9,7 @@ from PySide6.QtCore import Signal, Qt from PySide6.QtWidgets import QPushButton +from PySide6.QtGui import QGuiApplication from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED from src.core.library import Library, Tag @@ -49,6 +50,22 @@ def __init__( self.base_layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.base_layout) + bg_color: str = ( + "#1E1E1E" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#EEEEEE" + ) + fg_color: str = ( + "#FFFFFF" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#444444" + ) + ol_color: str = ( + "#333333" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#F5F5F5" + ) + self.add_button = QPushButton() self.add_button.setCursor(Qt.CursorShape.PointingHandCursor) self.add_button.setMinimumSize(23, 23) @@ -56,10 +73,10 @@ def __init__( self.add_button.setText("+") self.add_button.setStyleSheet( f"QPushButton{{" - f"background: #1e1e1e;" - f"color: #FFFFFF;" + f"background: {bg_color};" + f"color: {fg_color};" f"font-weight: bold;" - f"border-color: #333333;" + f"border-color: {ol_color};" f"border-radius: 6px;" f"border-style:solid;" f"border-width:{math.ceil(1*self.devicePixelRatio())}px;" diff --git a/tagstudio/src/qt/widgets/thumb_button.py b/tagstudio/src/qt/widgets/thumb_button.py index 179efaec8..9924c3bdb 100644 --- a/tagstudio/src/qt/widgets/thumb_button.py +++ b/tagstudio/src/qt/widgets/thumb_button.py @@ -5,7 +5,15 @@ from PySide6 import QtCore from PySide6.QtCore import QEvent -from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent +from PySide6.QtGui import ( + QEnterEvent, + QPainter, + QColor, + QPen, + QPainterPath, + QPaintEvent, + QPalette, +) from PySide6.QtWidgets import QWidget from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper @@ -17,7 +25,31 @@ def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: self.hovered = False self.selected = False - # self.clicked.connect(lambda checked: self.set_selected(True)) + self.select_color: QColor = QPalette.color( + self.palette(), + QPalette.ColorGroup.Active, + QPalette.ColorRole.Accent, + ) + + self.select_color_faded: QColor = QColor(self.select_color) + self.select_color_faded.setHsl( + self.select_color_faded.hslHue(), + self.select_color_faded.hslSaturation(), + max(self.select_color_faded.lightness(), 127), + 127, + ) + + self.hover_color: QColor = QPalette.color( + self.palette(), + QPalette.ColorGroup.Active, + QPalette.ColorRole.Accent, + ) + self.hover_color.setHsl( + self.hover_color.hslHue(), + self.hover_color.hslSaturation(), + min(self.hover_color.lightness() + 80, 255), + self.hover_color.alpha(), + ) def paintEvent(self, event: QPaintEvent) -> None: super().paintEvent(event) @@ -25,7 +57,6 @@ def paintEvent(self, event: QPaintEvent) -> None: painter = QPainter() painter.begin(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - # painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) path = QPainterPath() width = 3 radius = 6 @@ -40,27 +71,21 @@ def paintEvent(self, event: QPaintEvent) -> None: radius, ) - # color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6') - # pen = QPen(color, width) - # painter.setPen(pen) - # # brush.setColor(fill) - # painter.drawPath(path) - if self.selected: painter.setCompositionMode( QPainter.CompositionMode.CompositionMode_HardLight ) - color = QColor("#bb4ff0") - color.setAlphaF(0.5) - pen = QPen(color, width) + pen = QPen(self.select_color_faded, width) painter.setPen(pen) - painter.fillPath(path, color) + painter.fillPath(path, self.select_color_faded) painter.drawPath(path) painter.setCompositionMode( QPainter.CompositionMode.CompositionMode_Source ) - color = QColor("#bb4ff0") if not self.hovered else QColor("#55bbf6") + color: QColor = ( + self.select_color if not self.hovered else self.hover_color + ) pen = QPen(color, width) painter.setPen(pen) painter.drawPath(path) @@ -68,10 +93,10 @@ def paintEvent(self, event: QPaintEvent) -> None: painter.setCompositionMode( QPainter.CompositionMode.CompositionMode_Source ) - color = QColor("#55bbf6") - pen = QPen(color, width) + pen = QPen(self.hover_color, width) painter.setPen(pen) painter.drawPath(path) + painter.end() def enterEvent(self, event: QEnterEvent) -> None: diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 4b37b0c32..4bcb4158c 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,83 +5,870 @@ import logging import math +from copy import deepcopy +from io import BytesIO from pathlib import Path import cv2 +import numpy as np import rawpy -from pillow_heif import register_heif_opener, register_avif_opener +from mutagen import MutagenError, flac, id3, mp4 from PIL import ( Image, - UnidentifiedImageError, - ImageQt, + ImageChops, ImageDraw, + ImageEnhance, + ImageFile, ImageFont, ImageOps, - ImageFile, + ImageQt, + UnidentifiedImageError, ) from PIL.Image import DecompressionBombError -from PySide6.QtCore import QObject, Signal, QSize -from PySide6.QtGui import QPixmap -from src.qt.helpers.gradient import four_corner_gradient_background -from src.qt.helpers.text_wrapper import wrap_full_text +from pillow_heif import register_avif_opener, register_heif_opener +from pydub import AudioSegment, exceptions +from PySide6.QtCore import QObject, QSize, Qt, Signal +from PySide6.QtGui import QGuiApplication, QPixmap from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT -from src.core.media_types import MediaType, MediaCategories +from src.core.media_types import MediaCategories, MediaType +from src.core.palette import ColorType, get_ui_color from src.core.utils.encoding import detect_char_encoding from src.qt.helpers.blender_thumbnailer import blend_thumb +from src.qt.helpers.color_overlay import theme_fg_overlay +from src.qt.helpers.file_tester import is_readable_video +from src.qt.helpers.gradient import four_corner_gradient +from src.qt.helpers.text_wrapper import wrap_full_text +from src.qt.resource_manager import ResourceManager ImageFile.LOAD_TRUNCATED_IMAGES = True -ERROR = "[ERROR]" -WARNING = "[WARNING]" -INFO = "[INFO]" - logging.basicConfig(format="%(message)s", level=logging.INFO) register_heif_opener() register_avif_opener() class ThumbRenderer(QObject): - # finished = Signal() + """A class for rendering image and file thumbnails.""" + + rm: ResourceManager = ResourceManager() updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - # updatedImage = Signal(QPixmap) - # updatedSize = Signal(QSize) - - thumb_mask_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_mask_512.png" - ) - thumb_mask_512.load() - - thumb_mask_hl_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_mask_hl_512.png" - ) - thumb_mask_hl_512.load() - - thumb_loading_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" - ) - thumb_loading_512.load() - - thumb_broken_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png" - ) - thumb_broken_512.load() - - thumb_file_default_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_file_default_512.png" - ) - thumb_file_default_512.load() - - # thumb_debug: Image.Image = Image.open(Path( - # f'{Path(__file__).parents[2]}/resources/qt/images/temp.jpg')) - # thumb_debug.load() - - # TODO: Make dynamic font sized given different pixel ratios - font_pixel_ratio: float = 1 - ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * font_pixel_ratio), - ) + + def __init__(self) -> None: + """Initialize the class.""" + super().__init__() + + # Cached thumbnail elements. + # Key: Size + Pixel Ratio Tuple + Radius Scale + # (Ex. (512, 512, 1.25, 4)) + self.thumb_masks: dict = {} + self.raised_edges: dict = {} + + # Key: ("name", "color", 512, 512, 1.25) + self.icons: dict = {} + + def _get_resource_id(self, url: Path) -> str: + """Return the name of the icon resource to use for a file type. + + Special terms will return special resources. + + Args: + url (Path): The file url to assess. "$LOADING" will return the loading graphic. + """ + ext = url.suffix.lower() + types: set[MediaType] = MediaCategories.get_types(ext, True) + + # Loop though the specific (non-IANA) categories and return the string + # name of the first matching category found. + for cat in MediaCategories.ALL_CATEGORIES: + if not cat.is_iana: + if cat.media_type in types: + return cat.media_type.value + + # If the type is broader (IANA registered) then search those types. + for cat in MediaCategories.ALL_CATEGORIES: + if cat.is_iana: + if cat.media_type in types: + return cat.media_type.value + + return "file_generic" + + def _get_mask( + self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False + ) -> Image.Image: + """Return a thumbnail mask given a size, pixel ratio, and radius scaling option. + + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + scale_radius (bool): Option to scale the radius up (Used for Preview Panel). + """ + THUMB_SCALE: int = 512 + radius_scale: float = 1 + if scale_radius: + radius_scale = max(size[0], size[1]) / THUMB_SCALE + + item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale)) + if not item: + item = self._render_mask(size, pixel_ratio, radius_scale) + self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item + return item + + def _get_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: + """Return a thumbnail edge given a size, pixel ratio, and radius scaling option. + + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + """ + item: tuple[Image.Image, Image.Image] = self.raised_edges.get( + (*size, pixel_ratio) + ) + if not item: + item = self._render_edge(size, pixel_ratio) + self.raised_edges[(*size, pixel_ratio)] = item + return item + + def _get_icon( + self, name: str, color: str, size: tuple[int, int], pixel_ratio: float = 1.0 + ) -> Image.Image: + """Return an icon given a size, pixel ratio, and radius scaling option. + + Args: + name (str): The name of the icon resource. "thumb_loading" will not draw a border. + color (str): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + """ + draw_border: bool = True + if name == "thumb_loading": + draw_border = False + + item: Image.Image = self.icons.get((name, color, *size, pixel_ratio)) + if not item: + item_flat: Image.Image = self._render_icon( + name, color, size, pixel_ratio, draw_border + ) + edge: tuple[Image.Image, Image.Image] = self._get_edge(size, pixel_ratio) + item = self._apply_edge(item_flat, edge, faded=True) + self.icons[(name, *color, size, pixel_ratio)] = item + return item + + def _render_mask( + self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1 + ) -> Image.Image: + """Render a thumbnail mask graphic. + + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + radius_scale (float): The scale factor of the border radius (Used by Preview Panel). + """ + SMOOTH_FACTOR: int = 2 + RADIUS_FACTOR: int = 8 + + im: Image.Image = Image.new( + mode="L", + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore + color="black", + ) + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil( + RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio * radius_scale + ), + fill="white", + ) + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + return im + + def _render_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: + """Render a thumbnail edge graphic. + + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + """ + SMOOTH_FACTOR: int = 2 + RADIUS_FACTOR: int = 8 + WIDTH: int = math.floor(pixel_ratio * 2) + + # Highlight + im_hl: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore + color="#00000000", + ) + draw = ImageDraw.Draw(im_hl) + draw.rounded_rectangle( + (WIDTH, WIDTH) + tuple([d - (WIDTH + 1) for d in im_hl.size]), + radius=math.ceil( + (RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio) - (pixel_ratio * 3) + ), + fill=None, + outline="white", + width=WIDTH, + ) + im_hl = im_hl.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + + # Shadow + im_sh: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore + color="#00000000", + ) + draw = ImageDraw.Draw(im_sh) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im_sh.size]), + radius=math.ceil(RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio), + fill=None, + outline="black", + width=WIDTH, + ) + im_sh = im_sh.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + + return (im_hl, im_sh) + + def _render_icon( + self, + name: str, + color: str, + size: tuple[int, int], + pixel_ratio: float, + draw_border: bool = True, + ) -> Image.Image: + """Render a thumbnail icon. + + Args: + name (str): The name of the icon resource. + color (str): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + draw_border (bool): Option to draw a border. + """ + BORDER_FACTOR: int = 5 + SMOOTH_FACTOR: int = math.ceil(2 * pixel_ratio) + RADIUS_FACTOR: int = 8 + ICON_RATIO: float = 1.75 + + # Create larger blank image based on smooth_factor + im: Image.Image = Image.new( + "RGBA", + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore + color="#00000000", + ) + + # Create solid background color + bg: Image.Image = Image.new( + "RGB", + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore + color="#000000", + ) + + # Paste background color with rounded rectangle mask onto blank image + im.paste( + bg, + (0, 0), + mask=self._get_mask( + tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore + (pixel_ratio * SMOOTH_FACTOR), + ), + ) + + # Draw rounded rectangle border + if draw_border: + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil( + (RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio) + (pixel_ratio * 1.5) + ), + fill="black", + outline="#FF0000", + width=math.floor( + (BORDER_FACTOR * SMOOTH_FACTOR * pixel_ratio) - (pixel_ratio * 1.5) + ), + ) + + # Resize image to final size + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + fg: Image.Image = Image.new( + "RGB", + size=size, + color="#00FF00", + ) + + # Get icon by name + icon: Image.Image = self.rm.get(name) + if not icon: + icon = self.rm.get("file_generic") + if not icon: + icon = Image.new(mode="RGBA", size=(32, 32), color="magenta") + + # Resize icon to fit icon_ratio + icon = icon.resize( + (math.ceil(size[0] // ICON_RATIO), math.ceil(size[1] // ICON_RATIO)) + ) + + # Paste icon centered + im.paste( + im=fg.resize( + (math.ceil(size[0] // ICON_RATIO), math.ceil(size[1] // ICON_RATIO)) + ), + box=( + math.ceil((size[0] - (size[0] // ICON_RATIO)) // 2), + math.ceil((size[1] - (size[1] // ICON_RATIO)) // 2), + ), + mask=icon.getchannel(3), + ) + + # Apply color overlay + im = self._apply_overlay_color( + im, + color, + ) + + return im + + def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: + """Apply a color overlay effect to an image based on its color channel data. + + Red channel for foreground, green channel for outline, none for background. + + Args: + image (Image.Image): The image to apply an overlay to. + color (str): The name of the ColorType color to use. + """ + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.PRIMARY, color) + ) + fg_color: str = ( + get_ui_color(ColorType.PRIMARY, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + ol_color: str = ( + get_ui_color(ColorType.BORDER, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + + bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) + fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) + ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(ol, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image.Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg + + def _apply_edge( + self, + image: Image.Image, + edge: tuple[Image.Image, Image.Image], + faded: bool = False, + ): + """Apply a given edge effect to an image. + + Args: + image (Image.Image): The image to apply the edge to. + edge (tuple[Image.Image, Image.Image]): The edge images to apply. + Item 0 is the inner highlight, and item 1 is the outer shadow. + faded (bool): Whether or not to apply a faded version of the edge. + Used for light themes. + """ + opacity: float = 0.75 if not faded else 0.6 + shade_reduction: float = ( + 0.15 + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else 0.3 + ) + im: Image.Image = image + im_hl, im_sh = deepcopy(edge) + + # Configure and apply a soft light overlay. + # This makes up the bulk of the effect. + im_hl.putalpha(ImageEnhance.Brightness(im_hl.getchannel(3)).enhance(opacity)) + im.paste(ImageChops.soft_light(im, im_hl), mask=im_hl.getchannel(3)) + + # Configure and apply a normal shading overlay. + # This helps with contrast. + im_sh.putalpha( + ImageEnhance.Brightness(im_sh.getchannel(3)).enhance( + max(0, opacity - shade_reduction) + ) + ) + im.paste(im_sh, mask=im_sh.getchannel(3)) + + return im + + def _audio_album_thumb(self, filepath: Path, ext: str) -> Image.Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + """ + image: Image.Image = None + try: + if not filepath.is_file(): + raise FileNotFoundError + + artwork = None + if ext in [".mp3"]: + id3_tags: id3.ID3 = id3.ID3(filepath) + id3_covers: list = id3_tags.getall("APIC") + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) + elif ext in [".flac"]: + flac_tags: flac.FLAC = flac.FLAC(filepath) + flac_covers: list = flac_tags.pictures + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) + elif ext in [".mp4", ".m4a", ".aac"]: + mp4_tags: mp4.MP4 = mp4.MP4(filepath) + mp4_covers: list = mp4_tags.get("covr") + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + if artwork: + image = artwork + except ( + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + id3.ID3NoHeaderError, + MutagenError, + ) as e: + logging.error( + f"[ThumbRenderer][ERROR]: Couldn't read album artwork for {filepath.name} ({type(e).__name__})" + ) + return image + + def _audio_waveform_thumb( + self, filepath: Path, ext: str, size: int, pixel_ratio: float + ) -> Image.Image | None: + """Render a waveform image from an audio file. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + size (tuple[int,int]): The size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + """ + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + BASE_SCALE: int = 2 + SAMPLES_PER_BAR: int = 3 + size_scaled: int = size * BASE_SCALE + allow_small_min: bool = False + im: Image.Image = None + + try: + BAR_COUNT: int = min(math.floor((size // pixel_ratio) / 5), 64) + audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) + data = np.fromstring(audio._data, np.int16) # type: ignore + data_indices = np.linspace(1, len(data), num=BAR_COUNT * SAMPLES_PER_BAR) + bar_margin: float = ((size_scaled / (BAR_COUNT * 3)) * BASE_SCALE) / 2 + line_width: float = ( + (size_scaled - bar_margin) / (BAR_COUNT * 3) + ) * BASE_SCALE + bar_height: float = (size_scaled) - (size_scaled // bar_margin) + + count: int = 0 + maximum_item: int = 0 + max_array: list = [] + highest_line: int = 0 + + for i in range(-1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < SAMPLES_PER_BAR: + count = count + 1 + if abs(d) > maximum_item: + maximum_item = abs(d) + else: + max_array.append(maximum_item) + + if maximum_item > highest_line: + highest_line = maximum_item + + maximum_item = 0 + count = 1 + + line_ratio = max(highest_line / bar_height, 1) + + im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(im) + + current_x = bar_margin + for item in max_array: + item_height = item / line_ratio + + # If small minimums are not allowed, raise all values + # smaller than the line width to the same value. + if not allow_small_min: + item_height = max(item_height, line_width) + + current_y = ( + bar_height - item_height + (size_scaled // bar_margin) + ) // 2 + + draw.rounded_rectangle( + ( + current_x, + current_y, + (current_x + line_width), + (current_y + item_height), + ), + radius=100 * BASE_SCALE, + fill=("#FF0000"), + outline=("#FFFF00"), + width=max(math.ceil(line_width / 6), BASE_SCALE), + ) + + current_x = current_x + line_width + bar_margin + + im.resize((size, size), Image.Resampling.BILINEAR) + + except exceptions.CouldntDecodeError as e: + logging.error( + f"[ThumbRenderer][WAVEFORM][ERROR]: Couldn't render waveform for {filepath.name} ({type(e).__name__})" + ) + return im + + def _blender(self, filepath: Path) -> Image.Image: + """Get an emended thumbnail from a Blender file, if a thumbnail is present. + + Args: + filepath (Path): The path of the file. + """ + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + im: Image.Image = None + try: + blend_image = blend_thumb(str(filepath)) + + bg = Image.new("RGB", blend_image.size, color=bg_color) + bg.paste(blend_image, mask=blend_image.getchannel(3)) + im = bg + + except ( + AttributeError, + UnidentifiedImageError, + FileNotFoundError, + TypeError, + ) as e: + if str(e) == "expected string or buffer": + logging.info( + f"[ThumbRenderer][BLENDER][INFO] {filepath.name} Doesn't have an embedded thumbnail. ({type(e).__name__})" + ) + + else: + logging.error( + f"[ThumbRenderer][BLENDER][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im + + def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + im: Image.Image = None + try: + bg = Image.new("RGB", (size, size), color="#000000") + raw = Image.new("RGB", (size * 3, size * 3), color="#000000") + draw = ImageDraw.Draw(raw) + font = ImageFont.truetype(filepath, size=size) + # NOTE: While a stroke effect is desired, the text + # method only allows for outer strokes, which looks + # a bit weird when rendering fonts. + draw.text( + (size // 8, size // 8), + "Aa", + font=font, + fill="#FF0000", + # stroke_width=math.ceil(size / 96), + # stroke_fill="#FFFF00", + ) + # NOTE: Change to getchannel(1) if using an outline. + data = np.asarray(raw.getchannel(0)) + + m, n = data.shape[:2] + col: np.ndarray = data.any(0) + row: np.ndarray = data.any(1) + cropped_data = np.asarray(raw)[ + row.argmax() : m - row[::-1].argmax(), + col.argmax() : n - col[::-1].argmax(), + ] + cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(size // 16) + + orig_x, orig_y = cropped_im.size + new_x, new_y = (size, size) + if orig_x > orig_y: + new_x = size + new_y = math.ceil(size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = size + new_x = math.ceil(size * (orig_x / orig_y)) + + cropped_im = cropped_im.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_im, + box=(margin, margin + ((size - new_y) // 2)), + ) + im = self._apply_overlay_color(bg, "purple") + except OSError as e: + logging.info( + f"[ThumbRenderer][FONT][ERROR] Couldn't Render thumbnail for font {filepath.name} ({type(e).__name__})" + ) + return im + + def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + im: Image.Image = None + try: + scaled_sizes: list[int] = [ + math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES + ] + bg = Image.new("RGBA", (size, size), color="#00000000") + draw = ImageDraw.Draw(bg) + lines_of_padding = 2 + y_offset = 0 + + for font_size in scaled_sizes: + font = ImageFont.truetype(filepath, size=font_size) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, font=font, width=size, draw=draw + ) + draw.multiline_text((0, y_offset), text_wrapped, font=font) + y_offset += ( + len(text_wrapped.split("\n")) + lines_of_padding + ) * draw.textbbox((0, 0), "A", font=font)[-1] + im = theme_fg_overlay(bg, use_alpha=False) + except OSError as e: + logging.info( + f"[ThumbRenderer][FONT][ERROR] Couldn't Render thumbnail for font {filepath.name} ({type(e).__name__})" + ) + return im + + def _image_raw_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a RAW image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + im = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + except DecompressionBombError as e: + logging.info( + f"[ThumbRenderer][RAW][WARNING] Couldn't Render thumbnail for {filepath.name} ({type(e).__name__})" + ) + except ( + rawpy._rawpy.LibRawIOError, + rawpy._rawpy.LibRawFileUnsupportedError, + ) as e: + logging.info( + f"[ThumbRenderer][RAW][ERROR] Couldn't Render thumbnail for raw image {filepath.name} ({type(e).__name__})" + ) + return im + + def _image_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a standard image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + im = Image.open(filepath) + if im.mode != "RGB" and im.mode != "RGBA": + im = im.convert(mode="RGBA") + if im.mode == "RGBA": + new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg.paste(im, mask=im.getchannel(3)) + im = new_bg + + im = ImageOps.exif_transpose(im) + except ( + UnidentifiedImageError, + DecompressionBombError, + ) as e: + logging.error( + f"[ThumbRenderer][IMAGE][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im + + def _image_vector_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for a vector image, such as SVG. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + # TODO: Implement. + im: Image.Image = None + return im + + def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for an STL file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the icon. + """ + # TODO: Implement. + # The following commented code describes a method for rendering via + # matplotlib. + # This implementation did not play nice with multithreading. + im: Image.Image = None + # # Create a new plot + # matplotlib.use('agg') + # figure = plt.figure() + # axes = figure.add_subplot(projection='3d') + + # # Load the STL files and add the vectors to the plot + # your_mesh = mesh.Mesh.from_file(_filepath) + + # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) + # poly_collection.set_color((0,0,1)) # play with color + # scale = your_mesh.points.flatten() + # axes.auto_scale_xyz(scale, scale, scale) + # axes.add_collection3d(poly_collection) + # # plt.show() + # img_buf = io.BytesIO() + # plt.savefig(img_buf, format='png') + # im = Image.open(img_buf) + + return im + + def _text_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a plaintext file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + fg_color: str = ( + "#FFFFFF" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#111111" + ) + + try: + encoding = detect_char_encoding(filepath) + with open(filepath, "r", encoding=encoding) as text_file: + text = text_file.read(256) + bg = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(bg) + draw.text((16, 16), text, fill=fg_color) + im = bg + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + ) as e: + logging.info( + f"[ThumbRenderer][TEXT][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im + + def _video_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a video file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + if is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + # TODO: Move this check to is_readable_video() + if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: + raise cv2.error("File is invalid or has 0 frames") + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + im = Image.fromarray(frame) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: + logging.error( + f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im def render( self, @@ -90,25 +877,42 @@ def render( base_size: tuple[int, int], pixel_ratio: float, is_loading=False, - gradient=False, + is_grid_thumb=False, update_on_ratio_change=False, ): - """Internal renderer. Renders an entry/element thumbnail for the GUI.""" + """Render a thumbnail or preview image. + + Args: + timestamp (float): The timestamp for which this this job was dispatched. + filepath (str | Path): The path of the file to render a thumbnail for. + base_size (tuple[int,int]): The unmodified base size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + is_loading (bool): Is this a loading graphic? + is_grid_thumb (bool): Is this a thumbnail for the thumbnail grid? + Or else the Preview Pane? + update_on_ratio_change (bool): Should an updated ratio signal be sent? + + """ + adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) image: Image.Image = None pixmap: QPixmap = None final: Image.Image = None _filepath: Path = Path(filepath) resampling_method = Image.Resampling.BILINEAR - if ThumbRenderer.font_pixel_ratio != pixel_ratio: - ThumbRenderer.font_pixel_ratio = pixel_ratio - ThumbRenderer.ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * ThumbRenderer.font_pixel_ratio), - ) - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) + theme_color: str = ( + "theme_light" + if QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Light + else "theme_dark" + ) + + # Initialize "Loading" thumbnail + loading_thumb: Image.Image = self._get_icon( + "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio + ) + if is_loading: - final = ThumbRenderer.thumb_loading_512.resize( + final = loading_thumb.resize( (adj_size, adj_size), resample=Image.Resampling.BILINEAR ) qim = ImageQt.ImageQt(final) @@ -123,158 +927,41 @@ def render( if MediaType.IMAGE in MediaCategories.get_types(ext, True): # Raw Images ----------------------------------------------- if MediaType.IMAGE_RAW in MediaCategories.get_types(ext, True): - try: - with rawpy.imread(str(_filepath)) as raw: - rgb = raw.postprocess() - image = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except DecompressionBombError as e: - logging.info( - f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - except ( - rawpy._rawpy.LibRawIOError, - rawpy._rawpy.LibRawFileUnsupportedError, - ) as e: - logging.info( - f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {_filepath.name} ({type(e).__name__})" - ) - + image = self._image_raw_thumb(_filepath) + elif MediaType.IMAGE_VECTOR in MediaCategories.get_types(ext, True): + image = self._image_vector_thumb(_filepath, adj_size) # Normal Images -------------------------------------------- else: - try: - image = Image.open(_filepath) - if image.mode != "RGB" and image.mode != "RGBA": - image = image.convert(mode="RGBA") - if image.mode == "RGBA": - new_bg = Image.new("RGB", image.size, color="#1e1e1e") - new_bg.paste(image, mask=image.getchannel(3)) - image = new_bg - - image = ImageOps.exif_transpose(image) - except DecompressionBombError as e: - logging.info( - f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - + image = self._image_thumb(_filepath) # Videos ======================================================= elif MediaType.VIDEO in MediaCategories.get_types(ext, True): - video = cv2.VideoCapture(str(_filepath)) - frame_count = video.get(cv2.CAP_PROP_FRAME_COUNT) - if frame_count <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set(cv2.CAP_PROP_POS_FRAMES, frame_count // 2) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - + image = self._video_thumb(_filepath) # Plain Text =================================================== elif MediaType.PLAINTEXT in MediaCategories.get_types(ext): - encoding = detect_char_encoding(_filepath) - with open(_filepath, "r", encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color="#1e1e1e") - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=(255, 255, 255)) - image = bg + image = self._text_thumb(_filepath) # Fonts ======================================================== elif MediaType.FONT in MediaCategories.get_types(ext, True): - # Scale the sample font sizes to the preview image - # resolution,assuming the sizes are tuned for 256px. - scaled_sizes: list[int] = [ - math.floor(x * (adj_size / 256)) for x in FONT_SAMPLE_SIZES - ] - if gradient: - # handles small thumbnails - bg = Image.new("RGB", (adj_size, adj_size), color="#1e1e1e") - draw = ImageDraw.Draw(bg) - font = ImageFont.truetype( - _filepath, size=math.ceil(adj_size * 0.65) - ) - draw.text((10, 0), "Aa", font=font) + if is_grid_thumb: + # Short (Aa) Preview + image = self._font_short_thumb(_filepath, adj_size) else: - # handles big thumbnails and renders a sample text in multiple font sizes - bg = Image.new("RGB", (adj_size, adj_size), color="#1e1e1e") - draw = ImageDraw.Draw(bg) - lines_of_padding = 2 - y_offset = 0 - - for font_size in scaled_sizes: - font = ImageFont.truetype(_filepath, size=font_size) - text_wrapped: str = wrap_full_text( - FONT_SAMPLE_TEXT, font=font, width=adj_size, draw=draw - ) - draw.multiline_text((0, y_offset), text_wrapped, font=font) - y_offset += ( - len(text_wrapped.split("\n")) + lines_of_padding - ) * draw.textbbox((0, 0), "A", font=font)[-1] - - image = bg - # 3D =========================================================== - # elif extension == 'stl': - # # Create a new plot - # matplotlib.use('agg') - # figure = plt.figure() - # axes = figure.add_subplot(projection='3d') - - # # Load the STL files and add the vectors to the plot - # your_mesh = mesh.Mesh.from_file(_filepath) - - # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) - # poly_collection.set_color((0,0,1)) # play with color - # scale = your_mesh.points.flatten() - # axes.auto_scale_xyz(scale, scale, scale) - # axes.add_collection3d(poly_collection) - # # plt.show() - # img_buf = io.BytesIO() - # plt.savefig(img_buf, format='png') - # image = Image.open(img_buf) + # Large (Full Alphabet) Preview + image = self._font_long_thumb(_filepath, adj_size) + # Audio ======================================================== + elif MediaType.AUDIO in MediaCategories.get_types(ext, True): + image = self._audio_album_thumb(_filepath, ext) + if image is None: + image = self._audio_waveform_thumb( + _filepath, ext, adj_size, pixel_ratio + ) + if image is not None: + image = self._apply_overlay_color(image, "green") # Blender =========================================================== elif MediaType.BLENDER in MediaCategories.get_types(ext): - try: - blend_image = blend_thumb(str(_filepath)) - - bg = Image.new("RGB", blend_image.size, color="#1e1e1e") - bg.paste(blend_image, mask=blend_image.getchannel(3)) - image = bg - - except ( - AttributeError, - UnidentifiedImageError, - FileNotFoundError, - TypeError, - ) as e: - if str(e) == "expected string or buffer": - logging.info( - f"[ThumbRenderer]{ERROR} {_filepath.name} Doesn't have thumbnail saved. ({type(e).__name__})" - ) - - else: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) + image = self._blender(_filepath) # No Rendered Thumbnail ======================================== - else: - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - if not image: raise UnidentifiedImageError @@ -298,49 +985,45 @@ def render( else Image.Resampling.BILINEAR ) image = image.resize((new_x, new_y), resample=resampling_method) - if gradient: - mask: Image.Image = ThumbRenderer.thumb_mask_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ).getchannel(3) - hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - final = four_corner_gradient_background(image, adj_size, mask, hl) - else: - scalar = 4 - rec: Image.Image = Image.new( - "RGB", - tuple([d * scalar for d in image.size]), # type: ignore - "black", + mask: Image.Image = None + if is_grid_thumb: + mask = self._get_mask((adj_size, adj_size), pixel_ratio) + edge: tuple[Image.Image, Image.Image] = self._get_edge( + (adj_size, adj_size), pixel_ratio ) - draw = ImageDraw.Draw(rec) - draw.rounded_rectangle( - (0, 0) + rec.size, - (base_size[0] // 32) * scalar * pixel_ratio, - fill="red", - ) - rec = rec.resize( - tuple([d // scalar for d in rec.size]), - resample=Image.Resampling.BILINEAR, + final = self._apply_edge( + four_corner_gradient(image, (adj_size, adj_size), mask), + edge, ) + else: + mask = self._get_mask(image.size, pixel_ratio, scale_radius=True) final = Image.new("RGBA", image.size, (0, 0, 0, 0)) - final.paste(image, mask=rec.getchannel(0)) - except ( - UnidentifiedImageError, - FileNotFoundError, - cv2.error, - DecompressionBombError, - UnicodeDecodeError, - OSError, - ) as e: - if e is not UnicodeDecodeError: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" - ) + final.paste(image, mask=mask.getchannel(0)) + + except FileNotFoundError as e: + logging.info( + f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" + ) + if update_on_ratio_change: + self.updated_ratio.emit(1) + final = self._get_icon( + name="broken_link_icon", + color="red", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + except (UnidentifiedImageError, DecompressionBombError, ValueError) as e: + logging.info( + f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" + ) + if update_on_ratio_change: self.updated_ratio.emit(1) - final = ThumbRenderer.thumb_broken_512.resize( - (adj_size, adj_size), resample=resampling_method + final = self._get_icon( + name=self._get_resource_id(_filepath), + color=theme_color, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, ) qim = ImageQt.ImageQt(final) if image: