From 34f347bf55b8d1ba4e5bb41fd9ea5d17ab4fee8b Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 31 May 2024 19:25:53 -0700 Subject: [PATCH 01/47] Fix text and RAW image handling - Fix RAW images not being loaded correctly in the preview panel - Fix trying to read size data from null images - Refactor `os.stat` to `.stat()` - Remove unnecessary upper/lower conversions - Improve encoding compatibility beyond UTF-8 when reading text files - Code cleanup --- tagstudio/src/core/constants.py | 2 +- tagstudio/src/qt/widgets/preview_panel.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 2bd1fc379..492b9675b 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -207,4 +207,4 @@ ] TAG_FAVORITE = 1 -TAG_ARCHIVED = 0 +TAG_ARCHIVED = 0 \ No newline at end of file diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 6892c7f5e..c38ab02e4 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -569,6 +569,7 @@ def update_widgets(self): font = ImageFont.truetype(filepath) self.dimensions_label.setText( f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{font.getname()[0]} ({font.getname()[1]}) " + ) else: self.dimensions_label.setText( @@ -784,7 +785,7 @@ def set_tags_updated_slot(self, slot: object): """ if self.is_connected: self.tags_updated.disconnect() - + logging.info("[UPDATE CONTAINER] Setting tags updated slot") self.tags_updated.connect(slot) self.is_connected = True From 3c27b37b565b6c8b446911162fcf5275438eab0f Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 31 May 2024 22:46:19 -0700 Subject: [PATCH 02/47] Use chardet for character encoding detection --- tagstudio/src/core/constants.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 492b9675b..7f25f374b 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -205,6 +205,5 @@ "cool gray", "olive", ] - TAG_FAVORITE = 1 -TAG_ARCHIVED = 0 \ No newline at end of file +TAG_ARCHIVED = 0 From 7ce35192b567e585f31c8abe5a4368e0e1fa251f Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Mon, 3 Jun 2024 19:34:56 -0700 Subject: [PATCH 03/47] Add support for waveform + album cover thumbnails --- requirements.txt | 3 + tagstudio/src/core/constants.py | 1 + tagstudio/src/qt/helpers/gradient.py | 3 + tagstudio/src/qt/widgets/thumb_renderer.py | 101 ++++++++++++++++++++- 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a353c70c8..e27da13b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,6 @@ 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 diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 7f25f374b..a828db068 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -70,6 +70,7 @@ ".wma", ".ogg", ".aiff", + ".aif", ] DOC_TYPES: list[str] = [ ".txt", diff --git a/tagstudio/src/qt/helpers/gradient.py b/tagstudio/src/qt/helpers/gradient.py index dabe7639a..b76844a03 100644 --- a/tagstudio/src/qt/helpers/gradient.py +++ b/tagstudio/src/qt/helpers/gradient.py @@ -46,6 +46,9 @@ def four_corner_gradient_background( image.putalpha(mask) final = image + 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)) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 47421b4f3..1ab3a4953 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,10 +5,9 @@ import logging import math -from pathlib import Path - import cv2 import rawpy +import numpy from pillow_heif import register_heif_opener, register_avif_opener from PIL import ( Image, @@ -19,12 +18,17 @@ ImageOps, ImageFile, ) +from io import BytesIO +from pathlib import Path from PIL.Image import DecompressionBombError +from pydub import AudioSegment, exceptions +from mutagen import id3, flac, mp4 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 src.core.constants import ( + AUDIO_TYPES, PLAINTEXT_TYPES, FONT_TYPES, VIDEO_TYPES, @@ -224,6 +228,99 @@ def render( ) * draw.textbbox((0, 0), "A", font=font)[-1] image = bg + # Audio + elif _filepath.suffix.lower() in AUDIO_TYPES: + try: + artwork = None + if _filepath.suffix.lower() in [".mp3"]: + audio_tags = id3.ID3(_filepath) + covers = audio_tags.getall("APIC") + if covers: + artwork = Image.open(BytesIO(covers[0].data)) + elif _filepath.suffix.lower() in [".flac"]: + audio_tags = flac.FLAC(_filepath) + covers = audio_tags.pictures + if covers: + artwork = Image.open(BytesIO(covers[0].data)) + elif _filepath.suffix.lower() in [".mp4", ".m4a", ".aac"]: + audio_tags = mp4.MP4(_filepath) + covers = audio_tags.get("covr") + if covers: + artwork = Image.open(BytesIO(covers[0])) + if artwork: + image = artwork + except (mp4.MP4MetadataError, mp4.MP4StreamInfoError) as e: + logging.error( + f"[ThumbRenderer]{ERROR}: Couldn't read album artwork for {_filepath.name} ({type(e).__name__})" + ) + if image is None: + try: + audio: AudioSegment = AudioSegment.from_file( + _filepath, _filepath.suffix.lower()[1:] + ) + data = numpy.fromstring(audio._data, numpy.int16) + data_indices = numpy.linspace(1, len(data), num=adj_size) + + BARS = adj_size // 5 + BAR_MARGIN = 4 + BAR_HEIGHT = adj_size - (adj_size // BAR_MARGIN) + LINE_WIDTH = 6 + + length = len(data_indices) + RATIO = length / BARS + + count = 0 + maximum_item = 0 + max_array = [] + highest_line = 0 + + for i in range(1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < RATIO: + 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) + + image = Image.new( + "RGB", (adj_size, adj_size), color="#1e1e1e" + ) + draw = ImageDraw.Draw(image) + + current_x = 1 + for item in max_array: + item_height = item / line_ratio + + current_y = ( + BAR_HEIGHT - item_height + (adj_size // BAR_MARGIN) + ) / 2 + draw.line( + ( + current_x, + current_y, + current_x, + current_y + item_height, + ), + fill=(169, 171, 172), + width=4, + joint="curve", + ) + + current_x = current_x + LINE_WIDTH + except exceptions.CouldntDecodeError as e: + logging.error( + f"[ThumbRenderer]{ERROR}: Couldn't render waveform for {_filepath.name} ({type(e).__name__})" + ) + # 3D =========================================================== # elif extension == 'stl': # # Create a new plot From 6b892ce2bb308474f681957557f67abf97c89d05 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Mon, 3 Jun 2024 19:49:51 -0700 Subject: [PATCH 04/47] Rename "cover" variables for MyPy --- tagstudio/src/qt/widgets/thumb_renderer.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 1ab3a4953..ea9b51c40 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -234,19 +234,19 @@ def render( artwork = None if _filepath.suffix.lower() in [".mp3"]: audio_tags = id3.ID3(_filepath) - covers = audio_tags.getall("APIC") - if covers: - artwork = Image.open(BytesIO(covers[0].data)) + id3_covers = audio_tags.getall("APIC") + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) elif _filepath.suffix.lower() in [".flac"]: audio_tags = flac.FLAC(_filepath) - covers = audio_tags.pictures - if covers: - artwork = Image.open(BytesIO(covers[0].data)) + flac_covers = audio_tags.pictures + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) elif _filepath.suffix.lower() in [".mp4", ".m4a", ".aac"]: audio_tags = mp4.MP4(_filepath) - covers = audio_tags.get("covr") - if covers: - artwork = Image.open(BytesIO(covers[0])) + mp4_covers = audio_tags.get("covr") + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) if artwork: image = artwork except (mp4.MP4MetadataError, mp4.MP4StreamInfoError) as e: From ff17b93119d4a005b04ace3f902b41c9baca3785 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Mon, 3 Jun 2024 22:11:21 -0700 Subject: [PATCH 05/47] Rename "audio_tags" variables for MyPy + typing --- tagstudio/src/qt/widgets/thumb_renderer.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index ea9b51c40..cc33f6077 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -233,18 +233,18 @@ def render( try: artwork = None if _filepath.suffix.lower() in [".mp3"]: - audio_tags = id3.ID3(_filepath) - id3_covers = audio_tags.getall("APIC") + 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 _filepath.suffix.lower() in [".flac"]: - audio_tags = flac.FLAC(_filepath) - flac_covers = audio_tags.pictures + 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 _filepath.suffix.lower() in [".mp4", ".m4a", ".aac"]: - audio_tags = mp4.MP4(_filepath) - mp4_covers = audio_tags.get("covr") + 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: From c1cd96f507199bdee2abfade5732700a4c569878 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Mon, 3 Jun 2024 22:16:58 -0700 Subject: [PATCH 06/47] Add # type: ignore to fromstring method --- tagstudio/src/qt/widgets/thumb_renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index cc33f6077..c77ce9fda 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -258,7 +258,7 @@ def render( audio: AudioSegment = AudioSegment.from_file( _filepath, _filepath.suffix.lower()[1:] ) - data = numpy.fromstring(audio._data, numpy.int16) + data = numpy.fromstring(audio._data, numpy.int16) # type: ignore data_indices = numpy.linspace(1, len(data), num=adj_size) BARS = adj_size // 5 From 31444403658058b897336a242bca369c270666f7 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 4 Jun 2024 13:43:30 -0700 Subject: [PATCH 07/47] Add GIF preview support --- .../src/qt/helpers/rounded_pixmap_style.py | 31 ++++++++++ tagstudio/src/qt/widgets/preview_panel.py | 57 +++++++++++++------ tagstudio/src/qt/widgets/thumb_renderer.py | 2 +- 3 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 tagstudio/src/qt/helpers/rounded_pixmap_style.py 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..577382167 --- /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 QPixmap, QPainter, QBrush +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("#00000000") + p = QPainter(pix) + p.setBrush(QBrush(pixmap)) + p.setPen("#00000000") + 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/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index c38ab02e4..9781475f9 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 QResizeEvent, QAction, QMovie from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -27,7 +26,6 @@ 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 ( @@ -37,6 +35,7 @@ TS_FOLDER_NAME, FONT_TYPES, ) +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 @@ -95,9 +94,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() @@ -119,6 +126,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) @@ -399,20 +408,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) @@ -482,6 +485,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) @@ -492,6 +496,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: @@ -522,6 +527,21 @@ def update_widgets(self): # TODO: Do this somewhere else, this is just here temporarily. 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 filepath.suffix.lower() in IMAGE_TYPES: image = Image.open(str(filepath)) @@ -601,6 +621,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( diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index c77ce9fda..5b62064e2 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -258,7 +258,7 @@ def render( audio: AudioSegment = AudioSegment.from_file( _filepath, _filepath.suffix.lower()[1:] ) - data = numpy.fromstring(audio._data, numpy.int16) # type: ignore + data = numpy.fromstring(audio._data, numpy.int16) # type: ignore data_indices = numpy.linspace(1, len(data), num=adj_size) BARS = adj_size // 5 From d339f868a98b7aa0971a436fa8e5d50360bc8e39 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 8 Jun 2024 10:47:19 -0700 Subject: [PATCH 08/47] Add rough check for invalid video codecs --- tagstudio/src/qt/widgets/thumb_renderer.py | 27 ++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 5b62064e2..5dc5c7aa0 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,6 +5,7 @@ import logging import math +import sys import cv2 import rawpy import numpy @@ -171,11 +172,17 @@ def render( # Videos ======================================================= elif _filepath.suffix.lower() in VIDEO_TYPES: - 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) + video = cv2.VideoCapture(str(_filepath), cv2.CAP_FFMPEG) + # Stupid check to try and tell if the codec can be read. + # TODO: Find a way to intercept the native FFMPEG errors. + h = int(video.get(cv2.CAP_PROP_FOURCC)) + codec = h.to_bytes(4, byteorder=sys.byteorder).decode() + logging.info(f"{codec} - {h} - {video.getBackendName()}") + if h != 22: + 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 @@ -183,8 +190,14 @@ def render( # 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) + 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) # Plain Text =================================================== elif _filepath.suffix.lower() in PLAINTEXT_TYPES: From dc135f7b0ea624b564374ce3bb38c23280621241 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 8 Jun 2024 10:48:11 -0700 Subject: [PATCH 09/47] Add ".plist" to PLAINTEXT_TYPES --- tagstudio/src/core/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index a828db068..c985a44de 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -98,6 +98,7 @@ ".php", ".sh", ".bat", + ".plist", ] SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"] PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"] From 10d81b3fa12f6d7233f18e063a645f190e33a60c Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 8 Jun 2024 21:55:58 -0700 Subject: [PATCH 10/47] Add readable video tester --- tagstudio/src/qt/helpers/file_tester.py | 26 ++++++++++++ tagstudio/src/qt/widgets/collage_icon.py | 48 +++++++++++----------- tagstudio/src/qt/widgets/preview_panel.py | 37 +++++++++-------- tagstudio/src/qt/widgets/thumb_renderer.py | 13 +++--- 4 files changed, 76 insertions(+), 48 deletions(-) create mode 100644 tagstudio/src/qt/helpers/file_tester.py diff --git a/tagstudio/src/qt/helpers/file_tester.py b/tagstudio/src/qt/helpers/file_tester.py new file mode 100644 index 000000000..36a48c2b1 --- /dev/null +++ b/tagstudio/src/qt/helpers/file_tester.py @@ -0,0 +1,26 @@ +# 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): + """ + probe = ffmpeg.probe(Path(filepath)) + for stream in probe["streams"]: + if stream.get("codec_tag_string") in [ + "[0][0][0][0]", + "drma", + "drms", + "drmi", + ]: + return False + return True diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index b9234d7d2..a344ce0b2 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -25,6 +25,7 @@ from src.core.library import Library from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES +from src.qt.helpers.file_tester import is_readable_video ERROR = f"[ERROR]" @@ -112,30 +113,31 @@ def render( except DecompressionBombError as e: logging.info(f"[ERROR] One of the images was too big ({e})") elif filepath.suffix.lower() in VIDEO_TYPES: - 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) + ) + # collage.paste(pic, (y*thumb_size, x*thumb_size)) + self.rendered.emit(pic) except (UnidentifiedImageError, FileNotFoundError): logging.info( f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}" diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 9781475f9..5d73edad4 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -47,6 +47,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. @@ -558,25 +559,27 @@ def update_widgets(self): ): pass elif filepath.suffix.lower() in VIDEO_TYPES: - 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 filepath.suffix.lower() in ( diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 5dc5c7aa0..c01a5a165 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,7 +5,6 @@ import logging import math -import sys import cv2 import rawpy import numpy @@ -41,6 +40,7 @@ ) from src.core.utils.encoding import detect_char_encoding from src.qt.helpers.blender_thumbnailer import blend_thumb +from src.qt.helpers.file_tester import is_readable_video ImageFile.LOAD_TRUNCATED_IMAGES = True @@ -172,13 +172,8 @@ def render( # Videos ======================================================= elif _filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(_filepath), cv2.CAP_FFMPEG) - # Stupid check to try and tell if the codec can be read. - # TODO: Find a way to intercept the native FFMPEG errors. - h = int(video.get(cv2.CAP_PROP_FOURCC)) - codec = h.to_bytes(4, byteorder=sys.byteorder).decode() - logging.info(f"{codec} - {h} - {video.getBackendName()}") - if h != 22: + 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), @@ -198,6 +193,8 @@ def render( success, frame = video.read() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image = Image.fromarray(frame) + else: + image = self.thumb_file_default_512 # Plain Text =================================================== elif _filepath.suffix.lower() in PLAINTEXT_TYPES: From 087176edaeba1f044560c86c6f53892c04c691f0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Thu, 13 Jun 2024 16:35:23 -0700 Subject: [PATCH 11/47] Add ".psd" to IMAGE_TYPES; Handle ID3NoHeaderError --- tagstudio/src/core/constants.py | 1 + tagstudio/src/qt/widgets/item_thumb.py | 2 +- tagstudio/src/qt/widgets/thumb_renderer.py | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index c985a44de..1224d353f 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -33,6 +33,7 @@ ".jp2", ".j2k", ".jpg2", + ".psd", ] RAW_IMAGE_TYPES: list[str] = [ ".raw", diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 0adcb644e..3822d1350 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -358,7 +358,7 @@ def set_mode(self, mode: Optional[ItemType]) -> None: def set_extension(self, ext: str) -> None: if ext and ext.startswith(".") is False: ext = "." + ext - if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]: + if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng", ".psd"]: self.ext_badge.setHidden(False) self.ext_badge.setText(ext.upper()[1:]) if ext in VIDEO_TYPES + AUDIO_TYPES: diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index c01a5a165..8ed749482 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -259,7 +259,11 @@ def render( artwork = Image.open(BytesIO(mp4_covers[0])) if artwork: image = artwork - except (mp4.MP4MetadataError, mp4.MP4StreamInfoError) as e: + except ( + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + id3.ID3NoHeaderError, + ) as e: logging.error( f"[ThumbRenderer]{ERROR}: Couldn't read album artwork for {_filepath.name} ({type(e).__name__})" ) From cee42545f7bf665db17b0bbb66bd8cb871f798ab Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 15 Jun 2024 02:44:14 -0700 Subject: [PATCH 12/47] Improve and style waveform previews --- requirements.txt | 1 + tagstudio/src/qt/widgets/thumb_renderer.py | 252 ++++++++++++--------- 2 files changed, 152 insertions(+), 101 deletions(-) diff --git a/requirements.txt b/requirements.txt index e27da13b5..5658340c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ 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/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 8ed749482..5ab63234e 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -23,8 +23,8 @@ from PIL.Image import DecompressionBombError from pydub import AudioSegment, exceptions from mutagen import id3, flac, mp4 -from PySide6.QtCore import QObject, Signal, QSize -from PySide6.QtGui import QPixmap +from PySide6.QtCore import Qt, QObject, Signal, QSize +from PySide6.QtGui import QGuiApplication, QPixmap from src.qt.helpers.gradient import four_corner_gradient_background from src.qt.helpers.text_wrapper import wrap_full_text from src.core.constants import ( @@ -131,8 +131,9 @@ def render( self.updated_ratio.emit(1) elif _filepath: try: + ext = _filepath.suffix.lower() # Images ======================================================= - if _filepath.suffix.lower() in IMAGE_TYPES: + if ext in IMAGE_TYPES: try: image = Image.open(_filepath) if image.mode != "RGB" and image.mode != "RGBA": @@ -148,7 +149,7 @@ def render( f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})" ) - elif _filepath.suffix.lower() in RAW_IMAGE_TYPES: + elif ext in RAW_IMAGE_TYPES: try: with rawpy.imread(str(_filepath)) as raw: rgb = raw.postprocess() @@ -171,7 +172,7 @@ def render( ) # Videos ======================================================= - elif _filepath.suffix.lower() in VIDEO_TYPES: + elif ext in VIDEO_TYPES: if is_readable_video(_filepath): video = cv2.VideoCapture(str(_filepath), cv2.CAP_FFMPEG) video.set( @@ -197,7 +198,7 @@ def render( image = self.thumb_file_default_512 # Plain Text =================================================== - elif _filepath.suffix.lower() in PLAINTEXT_TYPES: + elif ext in PLAINTEXT_TYPES: encoding = detect_char_encoding(_filepath) with open(_filepath, "r", encoding=encoding) as text_file: text = text_file.read(256) @@ -238,102 +239,13 @@ def render( ) * draw.textbbox((0, 0), "A", font=font)[-1] image = bg - # Audio - elif _filepath.suffix.lower() in AUDIO_TYPES: - try: - artwork = None - if _filepath.suffix.lower() 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 _filepath.suffix.lower() 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 _filepath.suffix.lower() 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, - ) as e: - logging.error( - f"[ThumbRenderer]{ERROR}: Couldn't read album artwork for {_filepath.name} ({type(e).__name__})" - ) + # Audio ======================================================== + elif ext in AUDIO_TYPES: + image = self._album_artwork(_filepath, ext) if image is None: - try: - audio: AudioSegment = AudioSegment.from_file( - _filepath, _filepath.suffix.lower()[1:] - ) - data = numpy.fromstring(audio._data, numpy.int16) # type: ignore - data_indices = numpy.linspace(1, len(data), num=adj_size) - - BARS = adj_size // 5 - BAR_MARGIN = 4 - BAR_HEIGHT = adj_size - (adj_size // BAR_MARGIN) - LINE_WIDTH = 6 - - length = len(data_indices) - RATIO = length / BARS - - count = 0 - maximum_item = 0 - max_array = [] - highest_line = 0 - - for i in range(1, len(data_indices)): - d = data[math.ceil(data_indices[i]) - 1] - if count < RATIO: - 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) - - image = Image.new( - "RGB", (adj_size, adj_size), color="#1e1e1e" - ) - draw = ImageDraw.Draw(image) - - current_x = 1 - for item in max_array: - item_height = item / line_ratio - - current_y = ( - BAR_HEIGHT - item_height + (adj_size // BAR_MARGIN) - ) / 2 - draw.line( - ( - current_x, - current_y, - current_x, - current_y + item_height, - ), - fill=(169, 171, 172), - width=4, - joint="curve", - ) - - current_x = current_x + LINE_WIDTH - except exceptions.CouldntDecodeError as e: - logging.error( - f"[ThumbRenderer]{ERROR}: Couldn't render waveform for {_filepath.name} ({type(e).__name__})" - ) + image = self._audio_waveform(_filepath, ext, adj_size) + if image is not None: + image = self._apply_overlay_color(image) # 3D =========================================================== # elif extension == 'stl': @@ -477,3 +389,141 @@ def render( self.updated.emit( timestamp, QPixmap(), QSize(*base_size), _filepath.suffix.lower() ) + + def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: + """Gets an album cover from an audio file if one is present.""" + try: + 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: + return artwork + except ( + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + id3.ID3NoHeaderError, + ) as e: + logging.error( + f"[ThumbRenderer]{ERROR}: Couldn't read album artwork for {filepath.name} ({type(e).__name__})" + ) + + def _audio_waveform( + self, filepath: Path, ext: str, size: int + ) -> Image.Image | None: + """Renders a waveform image from an audio file.""" + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + BASE_SCALE: int = 2 + size_scaled: int = size * BASE_SCALE + ALLOW_SMALL_MIN: bool = False + SAMPLES_PER_BAR: int = 5 + + try: + BARS: int = 24 + audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) + data = numpy.fromstring(audio._data, numpy.int16) # type: ignore + data_indices = numpy.linspace(1, len(data), num=BARS * SAMPLES_PER_BAR) + + BAR_MARGIN: float = ((size_scaled / (BARS * 3)) * BASE_SCALE) / 2 + LINE_WIDTH: float = ((size_scaled - BAR_MARGIN) / (BARS * 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) + + image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(image) + + logging.info(f"data_ind {len(data_indices)}, max_array {len(max_array)}") + 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 + + image.resize((size, size), Image.Resampling.BILINEAR) + return image + except exceptions.CouldntDecodeError as e: + logging.error( + f"[ThumbRenderer]{ERROR}: Couldn't render waveform for {filepath.name} ({type(e).__name__})" + ) + + def _apply_overlay_color(self, image=Image.Image) -> Image.Image: + """Apply a gradient effect over an an image. + Red channel for foreground, green channel for outline, none for background.""" + bg_color: str = ( + "#0d3828" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#28bb48" + ) + fg_color: str = ( + "#28bb48" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#93e2c8" + ) + ol_color: str = ( + "#43c568" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#93e2c8" + ) + + bg: Image.Image = Image.new("RGB", image.size, color=bg_color) + fg: Image.Image = Image.new("RGB", image.size, color=fg_color) + ol: Image.Image = Image.new("RGB", image.size, color=ol_color) + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(ol, (0, 0), mask=image.getchannel(1)) + return bg From 127fed7aa95ef7eadf2eec8d5365d94ed2304f4b Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 15 Jun 2024 13:06:36 -0700 Subject: [PATCH 13/47] Add final return statement to _album_artwork() --- tagstudio/src/qt/widgets/thumb_renderer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 5ab63234e..fc44720b8 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -392,6 +392,7 @@ def render( def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: """Gets an album cover from an audio file if one is present.""" + image: Image.Image = None try: artwork = None if ext in [".mp3"]: @@ -410,7 +411,7 @@ def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: if mp4_covers: artwork = Image.open(BytesIO(mp4_covers[0])) if artwork: - return artwork + image = artwork except ( mp4.MP4MetadataError, mp4.MP4StreamInfoError, @@ -419,6 +420,7 @@ def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: logging.error( f"[ThumbRenderer]{ERROR}: Couldn't read album artwork for {filepath.name} ({type(e).__name__})" ) + return image def _audio_waveform( self, filepath: Path, ext: str, size: int From 32257f662f7ee31cd1ffa09bcbee83153d9bbcb2 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 15 Jun 2024 13:10:35 -0700 Subject: [PATCH 14/47] Add final return statement to _audio_waveform() --- tagstudio/src/qt/widgets/thumb_renderer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index fc44720b8..5117013f6 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -432,6 +432,7 @@ def _audio_waveform( size_scaled: int = size * BASE_SCALE ALLOW_SMALL_MIN: bool = False SAMPLES_PER_BAR: int = 5 + image: Image.Image = None try: BARS: int = 24 @@ -498,11 +499,12 @@ def _audio_waveform( current_x = current_x + LINE_WIDTH + BAR_MARGIN image.resize((size, size), Image.Resampling.BILINEAR) - return image + except exceptions.CouldntDecodeError as e: logging.error( f"[ThumbRenderer]{ERROR}: Couldn't render waveform for {filepath.name} ({type(e).__name__})" ) + return image def _apply_overlay_color(self, image=Image.Image) -> Image.Image: """Apply a gradient effect over an an image. From 3e00a771dbe3a1274cf640e9d596781061a719b4 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 15 Jun 2024 13:58:11 -0700 Subject: [PATCH 15/47] Tweak waveform color and size --- tagstudio/src/qt/widgets/thumb_renderer.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 5117013f6..abe478237 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -243,7 +243,9 @@ def render( elif ext in AUDIO_TYPES: image = self._album_artwork(_filepath, ext) if image is None: - image = self._audio_waveform(_filepath, ext, adj_size) + image = self._audio_waveform( + _filepath, ext, adj_size, pixel_ratio + ) if image is not None: image = self._apply_overlay_color(image) @@ -423,7 +425,7 @@ def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: return image def _audio_waveform( - self, filepath: Path, ext: str, size: int + self, filepath: Path, ext: str, size: int, pixel_ratio: float ) -> Image.Image | None: """Renders a waveform image from an audio file.""" # BASE_SCALE used for drawing on a larger image and resampling down @@ -431,11 +433,12 @@ def _audio_waveform( BASE_SCALE: int = 2 size_scaled: int = size * BASE_SCALE ALLOW_SMALL_MIN: bool = False - SAMPLES_PER_BAR: int = 5 + SAMPLES_PER_BAR: int = 3 image: Image.Image = None try: - BARS: int = 24 + logging.info(f"{size}, {pixel_ratio}") + BARS: int = min(math.floor((size // pixel_ratio) / 5), 64) audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) data = numpy.fromstring(audio._data, numpy.int16) # type: ignore data_indices = numpy.linspace(1, len(data), num=BARS * SAMPLES_PER_BAR) @@ -517,12 +520,12 @@ def _apply_overlay_color(self, image=Image.Image) -> Image.Image: fg_color: str = ( "#28bb48" if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#93e2c8" + else "#DDFFCC" ) ol_color: str = ( "#43c568" if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#93e2c8" + else "#FFFFFF" ) bg: Image.Image = Image.new("RGB", image.size, color=bg_color) From 15297140c3465383492635acac5499a027cc185b Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 15 Jun 2024 14:14:42 -0700 Subject: [PATCH 16/47] Fix ItemThumb label text color in light mode --- tagstudio/src/qt/widgets/item_thumb.py | 38 ++++++++++++++------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 3822d1350..d639babf2 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -64,27 +64,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__( From d2b5e31792004515eeafae5c0a8dda5faacf41da Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 15 Jun 2024 15:55:13 -0700 Subject: [PATCH 17/47] Fix most theme UI legibility issues --- .../qt/images/thumb_loading_dark_512.png | Bin 11270 -> 0 bytes tagstudio/src/core/enums.py | 4 +- tagstudio/src/qt/helpers/color_overlay.py | 3 ++ tagstudio/src/qt/widgets/preview_panel.py | 48 +++++++++++------- tagstudio/src/qt/widgets/thumb_renderer.py | 22 ++++++-- 5 files changed, 55 insertions(+), 22 deletions(-) delete mode 100644 tagstudio/resources/qt/images/thumb_loading_dark_512.png 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 7dcd99db78373733cc38310a1ebc5f4a166bdd09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11270 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 diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 7610a2ccc..8c5f44c0e 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 = "#33000000" + COLOR_DARK_LABEL = "#DD000000" COLOR_HOVER = "#65AAAAAA" COLOR_PRESSED = "#65EEEEEE" COLOR_DISABLED = "#65F39CAA" diff --git a/tagstudio/src/qt/helpers/color_overlay.py b/tagstudio/src/qt/helpers/color_overlay.py index c19ba73ed..68457061e 100644 --- a/tagstudio/src/qt/helpers/color_overlay.py +++ b/tagstudio/src/qt/helpers/color_overlay.py @@ -12,6 +12,8 @@ # here, in enums.py, and in palette.py. _THEME_DARK_FG: str = "#FFFFFF55" _THEME_LIGHT_FG: str = "#000000DD" +_THEME_DARK_BG: str = "#000000DD" +_THEME_LIGHT_BG: str = "#FFFFFF55" def theme_fg_overlay(image: Image.Image) -> Image.Image: @@ -27,6 +29,7 @@ def theme_fg_overlay(image: Image.Image) -> Image.Image: if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else _THEME_LIGHT_FG ) + im = Image.new(mode="RGBA", size=image.size, color=overlay_color) return _apply_overlay(image, im) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 5d73edad4..d659532a1 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -12,7 +12,7 @@ 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, QMovie +from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -84,6 +84,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) @@ -145,15 +156,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) @@ -184,9 +196,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;" "}" ) @@ -291,6 +304,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() @@ -301,11 +315,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;", ] @@ -336,11 +348,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: ( diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index abe478237..1cc38129f 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -25,6 +25,7 @@ from mutagen import id3, flac, mp4 from PySide6.QtCore import Qt, QObject, Signal, QSize from PySide6.QtGui import QGuiApplication, QPixmap +from src.qt.helpers.color_overlay import theme_fg_overlay from src.qt.helpers.gradient import four_corner_gradient_background from src.qt.helpers.text_wrapper import wrap_full_text from src.core.constants import ( @@ -75,6 +76,10 @@ class ThumbRenderer(QObject): ) thumb_loading_512.load() + # TODO: Allow this to be dynamically updated at runtime + if QGuiApplication.styleHints().colorScheme() is not Qt.ColorScheme.Dark: + thumb_loading_512 = theme_fg_overlay(thumb_loading_512) + thumb_broken_512: Image.Image = Image.open( Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png" ) @@ -199,12 +204,24 @@ def render( # Plain Text =================================================== elif ext in PLAINTEXT_TYPES: + 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" + ) 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") + bg = Image.new("RGB", (256, 256), color=bg_color) draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=(255, 255, 255)) + draw.text((16, 16), text, fill=fg_color) image = bg # Fonts ======================================================== elif _filepath.suffix.lower() in FONT_TYPES: @@ -237,7 +254,6 @@ def render( y_offset += ( len(text_wrapped.split("\n")) + lines_of_padding ) * draw.textbbox((0, 0), "A", font=font)[-1] - image = bg # Audio ======================================================== elif ext in AUDIO_TYPES: From 05a486048cef07cddb8ca61fbd9bd1a9df232405 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 15 Jun 2024 22:52:48 -0700 Subject: [PATCH 18/47] Match additional UI to color scheme --- tagstudio/src/core/enums.py | 2 +- tagstudio/src/qt/helpers/color_overlay.py | 2 +- tagstudio/src/qt/widgets/fields.py | 5 +++++ tagstudio/src/qt/widgets/tag_box.py | 23 +++++++++++++++++++--- tagstudio/src/qt/widgets/thumb_renderer.py | 13 ++++++------ 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 8c5f44c0e..613c45d4b 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -13,7 +13,7 @@ class SettingItems(str, enum.Enum): class Theme(str, enum.Enum): COLOR_BG_DARK = "#65000000" - COLOR_BG_LIGHT = "#33000000" + COLOR_BG_LIGHT = "#22000000" COLOR_DARK_LABEL = "#DD000000" COLOR_HOVER = "#65AAAAAA" COLOR_PRESSED = "#65EEEEEE" diff --git a/tagstudio/src/qt/helpers/color_overlay.py b/tagstudio/src/qt/helpers/color_overlay.py index 68457061e..9bb474c20 100644 --- a/tagstudio/src/qt/helpers/color_overlay.py +++ b/tagstudio/src/qt/helpers/color_overlay.py @@ -10,7 +10,7 @@ # 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" diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 355a0fa94..477a7cd33 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -14,6 +14,7 @@ from PySide6.QtGui import QPixmap, QEnterEvent from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.helpers.color_overlay import theme_fg_overlay class FieldContainer(QWidget): @@ -47,6 +48,10 @@ def __init__(self, title: str = "Field", inline: bool = True) -> 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) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 06b8b1fe5..3311327cc 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_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 1cc38129f..5e894a987 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -76,10 +76,6 @@ class ThumbRenderer(QObject): ) thumb_loading_512.load() - # TODO: Allow this to be dynamically updated at runtime - if QGuiApplication.styleHints().colorScheme() is not Qt.ColorScheme.Dark: - thumb_loading_512 = theme_fg_overlay(thumb_loading_512) - thumb_broken_512: Image.Image = Image.open( Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png" ) @@ -112,6 +108,8 @@ def render( update_on_ratio_change=False, ): """Internal renderer. Renders an entry/element thumbnail for the GUI.""" + loading_thumb: Image.Image = ThumbRenderer.thumb_loading_512 + image: Image.Image = None pixmap: QPixmap = None final: Image.Image = None @@ -124,9 +122,12 @@ def render( math.floor(12 * ThumbRenderer.font_pixel_ratio), ) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Light: + loading_thumb = theme_fg_overlay(loading_thumb) + adj_size = math.ceil(max(base_size[0], base_size[1]) * 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) @@ -453,7 +454,6 @@ def _audio_waveform( image: Image.Image = None try: - logging.info(f"{size}, {pixel_ratio}") BARS: int = min(math.floor((size // pixel_ratio) / 5), 64) audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) data = numpy.fromstring(audio._data, numpy.int16) # type: ignore @@ -488,7 +488,6 @@ def _audio_waveform( image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") draw = ImageDraw.Draw(image) - logging.info(f"data_ind {len(data_indices)}, max_array {len(max_array)}") current_x = BAR_MARGIN for item in max_array: item_height = item / line_ratio From c582f3d370863b40417aeba5d26607002cc33d21 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 19 Jul 2024 08:02:30 -0700 Subject: [PATCH 19/47] ruff format --- tagstudio/src/qt/widgets/preview_panel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index d659532a1..6b4b85e8a 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -604,7 +604,6 @@ def update_widgets(self): font = ImageFont.truetype(filepath) self.dimensions_label.setText( f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{font.getname()[0]} ({font.getname()[1]}) " - ) else: self.dimensions_label.setText( @@ -821,7 +820,7 @@ def set_tags_updated_slot(self, slot: object): """ if self.is_connected: self.tags_updated.disconnect() - + logging.info("[UPDATE CONTAINER] Setting tags updated slot") self.tags_updated.connect(slot) self.is_connected = True From c0e56dc7c82ace5bbcdc0e8ac2a26a3b2c0571cc Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 19 Jul 2024 10:04:48 -0700 Subject: [PATCH 20/47] feat(ui): add UI color palette dict --- tagstudio/src/core/palette.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 886e0bd6c..0b36f953d 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,6 +277,28 @@ class ColorType(int, Enum): }, } +_UI_COLORS: dict = { + "": { + ColorType.PRIMARY: "#1e1e1e", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#333333", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#222222", + }, + "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", + }, +} + def get_tag_color(type, color): color = color.lower() @@ -287,3 +309,9 @@ def get_tag_color(type, color): return _TAG_COLORS[color][type] except KeyError: return "#FF00FF" + + +def get_ui_color(type: ColorType, color: str): + """Returns a hex value given a color name and ColorType.""" + color = color.lower() + return _UI_COLORS.get(color).get(type) From 598aa4f1029dd2e389896c956eb0a6a027449fc0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 19 Jul 2024 10:06:34 -0700 Subject: [PATCH 21/47] feat(ui) center and color small font previews --- tagstudio/src/qt/widgets/thumb_renderer.py | 109 +++++++++++++++------ 1 file changed, 79 insertions(+), 30 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 5e894a987..e1bd4004f 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -7,7 +7,7 @@ import math import cv2 import rawpy -import numpy +import numpy as np from pillow_heif import register_heif_opener, register_avif_opener from PIL import ( Image, @@ -40,6 +40,7 @@ BLENDER_TYPES, ) from src.core.utils.encoding import detect_char_encoding +from src.core.palette import ColorType, get_ui_color from src.qt.helpers.blender_thumbnailer import blend_thumb from src.qt.helpers.file_tester import is_readable_video @@ -181,16 +182,13 @@ def render( elif ext in VIDEO_TYPES: 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) success, frame = video.read() if not success: # Depending on the video format, compression, and frame @@ -198,6 +196,12 @@ def render( # must be pulled from the earliest available frame. video.set(cv2.CAP_PROP_POS_FRAMES, 0) 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) else: @@ -226,21 +230,16 @@ def render( image = bg # Fonts ======================================================== elif _filepath.suffix.lower() in FONT_TYPES: - # 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) + # Handles small thumbnails + image = self._font_preview_small(_filepath, adj_size) else: - # handles big thumbnails and renders a sample text in multiple font sizes + # Handles big thumbnails and renders a sample text in multiple font sizes. + # 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 + ] bg = Image.new("RGB", (adj_size, adj_size), color="#1e1e1e") draw = ImageDraw.Draw(bg) lines_of_padding = 2 @@ -255,7 +254,7 @@ def render( y_offset += ( len(text_wrapped.split("\n")) + lines_of_padding ) * draw.textbbox((0, 0), "A", font=font)[-1] - image = bg + image = bg # Audio ======================================================== elif ext in AUDIO_TYPES: image = self._album_artwork(_filepath, ext) @@ -264,7 +263,7 @@ def render( _filepath, ext, adj_size, pixel_ratio ) if image is not None: - image = self._apply_overlay_color(image) + image = self._apply_overlay_color(image, "green") # 3D =========================================================== # elif extension == 'stl': @@ -456,8 +455,8 @@ def _audio_waveform( try: BARS: int = min(math.floor((size // pixel_ratio) / 5), 64) audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) - data = numpy.fromstring(audio._data, numpy.int16) # type: ignore - data_indices = numpy.linspace(1, len(data), num=BARS * SAMPLES_PER_BAR) + data = np.fromstring(audio._data, np.int16) # type: ignore + data_indices = np.linspace(1, len(data), num=BARS * SAMPLES_PER_BAR) BAR_MARGIN: float = ((size_scaled / (BARS * 3)) * BASE_SCALE) / 2 LINE_WIDTH: float = ((size_scaled - BAR_MARGIN) / (BARS * 3)) * BASE_SCALE @@ -524,21 +523,71 @@ def _audio_waveform( ) return image - def _apply_overlay_color(self, image=Image.Image) -> Image.Image: + def _font_preview_small(self, filepath: Path, size: int) -> Image.Image: + """Renders a small font preview ("Aa") thumbnail from a font file.""" + bg = Image.new("RGB", (size, size), color="#000000") + raw = Image.new("RGB", (size * 2, size * 2), 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)), + ) + return self._apply_overlay_color(bg, "purple") + + def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: """Apply a gradient effect over an an image. Red channel for foreground, green channel for outline, none for background.""" bg_color: str = ( - "#0d3828" + get_ui_color(ColorType.DARK_ACCENT, color) if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#28bb48" + else get_ui_color(ColorType.PRIMARY, color) ) fg_color: str = ( - "#28bb48" + get_ui_color(ColorType.PRIMARY, color) if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#DDFFCC" + else get_ui_color(ColorType.LIGHT_ACCENT, color) ) ol_color: str = ( - "#43c568" + get_ui_color(ColorType.BORDER, color) if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else "#FFFFFF" ) From ffdfd6ccdf162870439290806dc06583bcc6644d Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 19 Jul 2024 20:25:14 -0700 Subject: [PATCH 22/47] fix(ui): large font previews follow app theme --- tagstudio/src/qt/helpers/color_overlay.py | 8 ++-- tagstudio/src/qt/widgets/thumb_renderer.py | 48 ++++++++++++---------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/tagstudio/src/qt/helpers/color_overlay.py b/tagstudio/src/qt/helpers/color_overlay.py index 9bb474c20..c468c2b39 100644 --- a/tagstudio/src/qt/helpers/color_overlay.py +++ b/tagstudio/src/qt/helpers/color_overlay.py @@ -16,18 +16,20 @@ _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) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index e1bd4004f..6f2976e44 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -232,29 +232,10 @@ def render( elif _filepath.suffix.lower() in FONT_TYPES: if gradient: # Handles small thumbnails - image = self._font_preview_small(_filepath, adj_size) + image = self._font_preview_short(_filepath, adj_size) else: # Handles big thumbnails and renders a sample text in multiple font sizes. - # 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 - ] - 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 + image = self._font_preview_long(_filepath, adj_size) # Audio ======================================================== elif ext in AUDIO_TYPES: image = self._album_artwork(_filepath, ext) @@ -523,7 +504,7 @@ def _audio_waveform( ) return image - def _font_preview_small(self, filepath: Path, size: int) -> Image.Image: + def _font_preview_short(self, filepath: Path, size: int) -> Image.Image: """Renders a small font preview ("Aa") thumbnail from a font file.""" bg = Image.new("RGB", (size, size), color="#000000") raw = Image.new("RGB", (size * 2, size * 2), color="#000000") @@ -573,6 +554,29 @@ def _font_preview_small(self, filepath: Path, size: int) -> Image.Image: ) return self._apply_overlay_color(bg, "purple") + def _font_preview_long(self, filepath: Path, size: int) -> Image.Image: + """Renders a large font preview ("Alphabet") thumbnail from a font file.""" + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + 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] + return theme_fg_overlay(bg, use_alpha=False) + def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: """Apply a gradient effect over an an image. Red channel for foreground, green channel for outline, none for background.""" From 3bfeb3c409939e6035a253a263cf38d931276de4 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 19 Jul 2024 21:04:49 -0700 Subject: [PATCH 23/47] fix(ui): blender previews follow app theme --- tagstudio/src/qt/widgets/thumb_renderer.py | 31 +++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 6f2976e44..db9efdde1 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -116,6 +116,17 @@ def render( final: Image.Image = None _filepath: Path = Path(filepath) resampling_method = Image.Resampling.BILINEAR + 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" + ) + if ThumbRenderer.font_pixel_ratio != pixel_ratio: ThumbRenderer.font_pixel_ratio = pixel_ratio ThumbRenderer.ext_font = ImageFont.truetype( @@ -146,7 +157,7 @@ def render( 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 = Image.new("RGB", image.size, color=bg_color) new_bg.paste(image, mask=image.getchannel(3)) image = new_bg @@ -209,18 +220,6 @@ def render( # Plain Text =================================================== elif ext in PLAINTEXT_TYPES: - 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" - ) encoding = detect_char_encoding(_filepath) with open(_filepath, "r", encoding=encoding) as text_file: text = text_file.read(256) @@ -231,10 +230,10 @@ def render( # Fonts ======================================================== elif _filepath.suffix.lower() in FONT_TYPES: if gradient: - # Handles small thumbnails + # Short (Aa) Preview image = self._font_preview_short(_filepath, adj_size) else: - # Handles big thumbnails and renders a sample text in multiple font sizes. + # Large (Full Alphabet) Preview image = self._font_preview_long(_filepath, adj_size) # Audio ======================================================== elif ext in AUDIO_TYPES: @@ -271,7 +270,7 @@ def render( try: blend_image = blend_thumb(str(_filepath)) - bg = Image.new("RGB", blend_image.size, color="#1e1e1e") + bg = Image.new("RGB", blend_image.size, color=bg_color) bg.paste(blend_image, mask=blend_image.getchannel(3)) image = bg From 086fc1e522b2889f242b6bcef7a13c3f91a120b0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 19 Jul 2024 23:43:50 -0700 Subject: [PATCH 24/47] feat(ui): add resizable thumbnail options --- .../resources/qt/images/thumb_border_512.png | Bin 5649 -> 0 bytes .../resources/qt/images/thumb_mask_128.png | Bin 2245 -> 0 bytes .../resources/qt/images/thumb_mask_512.png | Bin 4902 -> 0 bytes .../resources/qt/images/thumb_mask_hl_512.png | Bin 5392 -> 0 bytes tagstudio/src/qt/main_window.py | 20 ++-- tagstudio/src/qt/ts_qt.py | 50 ++++++++- tagstudio/src/qt/widgets/thumb_renderer.py | 95 +++++++++++++++--- 7 files changed, 136 insertions(+), 29 deletions(-) delete mode 100644 tagstudio/resources/qt/images/thumb_border_512.png delete mode 100644 tagstudio/resources/qt/images/thumb_mask_128.png delete mode 100644 tagstudio/resources/qt/images/thumb_mask_512.png delete mode 100644 tagstudio/resources/qt/images/thumb_mask_hl_512.png 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 605717e3de726590cfadfeac82e232272c9c4638..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5649 zcmeHLXXd;UWs3VJ`MiB@oDoB8!C`Ji^c>&k4o%t|d{g7YYegCuHbIv{Y{kl4E zg`xf&eE@)=pRe~?0CbQ?9iTTA`Fkn2GYr6#FltB`H!Q#(M`Y5i2_$AT*;+tnAvgf8 z?gAEp7)R!sN0TX31|B^itwNhqNqBUqeSl2>%ZnUK^%b(o!NR~0qA-r=OhUW6>AMPW zhyXg7OE4GEX$%fdfJcwZ#Uany*BG?I2J7tXjIpuB*xFhl6jq!B2A3ePVsI8|MNG)> zCUb~vDvL{HGR(Cy3DHa*7mr3GxcOV*B;tG7EFPOSE}2BckZEK(nZe~?u+~`2$Mj?Y z^@BAG&f9Pyfx~DKgxuy6axOLIFUYmXXXLC{CYQ;HWwPEI^@)o2@Mnxjxc{6*Kw$kD zaX`R-C#Tataf8G4iAMtWb`w6a>74>jNCJzDSxe?Hd2AxtCmxC3qVd3K=~y<>W4(&68;yxwhGK$Nkkl%%B7LVR})p?LE}!WCYCg+wiIwQ0)v7_ z3#>@w7y^&RMZ0lhdGu%ofl7;{5}6E+D@I#EH0oPHU#%?}bF8(Kwf(0I{8%y~?62dA`Ph?pmbqd+vwgzxk6H=BH}MW>QAn@Dyl-~M z;(fa$Gmsw1Mw%gW_vv~7P^nZT6+TM=0KVVD+jC=;r^h!ts?L;IO!YYvyKqhMvZ5b1 z-}da8BJ(sXYk4{}vtW}F@@$z(|7mMMK#7lL%2~tpx*lsL|LfdcANop0_vYWd8*RFi zUSxBo*-@%z`2RqyxNF$szwSklaK6D2pB??%mg7#C#C>Hc!OhLmTgyvo4<-(8$P6zO zwpAT^2Ifzsf82KpTbgvV-17F(94gp;mbIqr zpJg?Vp2h1IFO029{Ud*EQ*x7Yg7|?XNoL>I?^RXPnwf%}Wt8%Ko__*zs(r=s4d(!0 zV66S2z`;YN08G2(=j{<9IMgrqNZa%z;p#)x+=3{=w@YWI?%L|v9Qsdm@z#HoUS8b( zGii2SO;OanmBqhtWdWyu(Vy9~7djSlWDWXj-~45BUA#TFF2%;p!d`Z=T`SGvxU=28Bxru?s|DbJ zUQ02oDpcG$T*8MHZKgziwuxD5*cqu=0c?>O8F>Php4&1XyiSv>gSrb98SwBE(?^4U zU`kJcr%Gv|;yyea18Eu--&`>`-oCy!NwYca2 zx9hd=a5p@>8(TNTo3-8&NYwt2=685hn8O3&jEx0w${gZ|%EQEIHQ=heprNDdN0QCJ zd3^3otABx}d1W~)9Q1Jwoq{uiZ@h-I&BqF9guUlf#U&Lnd-4PppxMBvwzUN#H+?OKq9`}QLnxEaUg#zFSNW>?%u0BE>X9ib<_bUiTX7= zew60CDs*UYP*-)jelrh>>ep{3<+Wd8AVLCa9-h$+0s@&X*3{?-c2PM6L=i*C^`4MGR#gWu570 zZ`F`I<%~hCfGK zw1{e)JE`i0pFx{kZbDi5Kt2jsKfmm}jS5cgkn`n=$5pvo27?$^L?~b^QRmO*j0`Y% zo!Q0~o8pY#pu~$#m{jR!38Np(O$@@_m*2Ap6h3s28v`3J%!0WWb9?2Q>IMr}mo1kW ziRawGJZi=3R#eKNte&P2NdDTldY^IJ=;pNNm*UF}K@ripFvWgLb95J9bt(dBhVDUh z(LGSzB2jNJw$p4zl3SU~?yMf`RVFf3hA41bq89K|mSJ0~&ILoe{fmZLOdq8f5pRQP zVV&t%FjNvS@71+HDCeNl)1h5`dcbLE@M-m(LqNdv%0_`{SMT&4o(VD?&ddf6SpE&l zaMZKW?GBT`y!3Dsi{0P@#7D`tU^!n^3X0!Pw5r`9k-a4 zh|Gdv{K$R{o~M^M72Lfe?>Ly%gN$v>ji$tMxG|_mHFTwU87Mt>;(&r(MwZ6F<&b{sr)ZJV;P zd-{M8Xg(?(=uwta>if$jiImN!?CfSKl*v3c7DQR@cRy2wgk73MSdQvD6kGE&G6rw=snhL?Srd5`Wk5BhTynHhg^O}HJ;PaHJ z3##Wx)RoFn)#2pJ9T83R&Q2G(W^kv8GGYX~HCNTyG-E+W_1Gkk)WuUhf6_OS5Hv=} z-H>pBlC+Xp-&P+8Y2qNwBh*0mQFxd>ekFip`LHnM%|HgW?yBxa6X3=}QVN!oz=4qL z`jaZ7buuC}B!J`z)%m$4@W3wY$)Nd8mVkk{W*kujZ*X5Z9h{$k5|Y^w)6AOignUo{ zD{5WzQ`SJL5}`FH-I7uOGt;o`S+0YNE#0p(6H+md>S6e?)Yri~OU5Ou3|eSI6o zn({2Qcw~V%=$4!xDzk%z8sOp9fRl#ck_2uqdV4vGLoR2QUJ~sUuLP3qgND4{+yS7d zb3K$(2#3Oosyfq5M^S3=*l+FftFXcn8uGPr>prjQZ9b<0V8u;_)!`kdRlPYd&b_W# d-c>s`Ir7%2y!)Dna_yH~Kc7JFlb%sq{|yg52o(ST 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 52a0a1353c955e7a7215bb1378fb3f9ad98e3a09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2245 zcmeHJ`%@EF7+pbL0mdrUA_KBr>fnoP5+;GHNkCqbYA7HKi4Vpl*(3`|c1<=Az)?h{ zLw$@$r7aIpr&>l3wU$;bQzBGl%2>pf+A30U5NBE~l{#WUv3C;+QndXC`onJa-gCaQ z-|K#x(&VI-zMKFK1VO&?#JE)OTf|`g!#f~o<{2_g$tV@8F*Rl6;uxk-;xp5SY|Rkqn`Nr=VjAJ(*ZwCe{=rrKt;Y)M5=1CF4kJ z7!WWL3=Z3j29pJ|NfC!G2An)Vlm1SpaMY{V1!xnO$=@eGg(4d38zLJVNsh&njt9@ z%xdCUl$DVp2*BZSal%GUs+%n1;e$k=EQ0d6JoH66Luy|^XOU@iT2C>QMNiQa0Ze(t z1U$_Ur0?Y>Hk^JLS)urMy3sf#3=0#V3lcG&)G0pw{Q^r`K24yhgoU!2)kJ(QVPZlY znPBsRC6hM7po}ApgvkQ3D@FK%$bX=!SD~?#fikZFZAAbxhY_1QOm4(=L>ZQ;`Eii|F3Q7aq3wKyeLh{(y zWHY5D4FI#Gu8e`@@v$PlSR@J)aGjxm!ePLc)aJ+G3}6X(g77e&IE)vO#us722rOI# zo-v-&9|s$p2Mx~P|HWs!8kPVHg#t??EevJOcWgOZeGRe6xpFQIq@%}S*imT=SF=fy zBD9&(Sk;8a5f+ehq83Wad??} z$58gr{q30qo#wm5L3rVm0z2m(IQ?LZpc8WhEG8xqVFKgF4CYL7d#FDI&DchQs!Xc} z1T8AFmbI*kNtt^&io~OK7w-rNcljni7;eso!#yRbdG>)y%e2M-UGaoc!+oApG6^ zdoS(m+_9l@ee#oUzgI8ZA{3|Jp~{=+tFtO{aozFv)aeZ$H#MAUuI@Ex(-;4Id2^|> ztjb${fA(NPXd)Dxbj<&9r|0(F-$;t9-gMW*@7df*jV)WczrMZU)4i7ji?8)>Dm}lX z+iy~rNQ_>`B>}Fyp~4^zuqhT zB&lfF9$@nhjQV4B=H4*c%}~^7zf!xPy2!H8qbK6|-TOsno~fb)Gd_53FTR2gWNjS? zJRRvFdu>;Seb()pUk_Dn^=&9=d_Q*7tNEacyE*8qPEKTv$~3xZ?K}3ok$PQIjncHU zta2r~?=&a65$gZ{_F@jS8KiNjkV9LKHVIUfd2Tj=A5f+==SfmX?AmHq}vap q;w=LoH3zP%*fr+<#HB~_bm$HHd^ewzL*icc--kRtDXuYQWAR@VJTlw> 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 ce641abc48961a476b30cd87ea346c215ad834db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4902 zcmeHLX;f3!7Cr$P1T0IjMHC^%K%7WI5RjMnKF}WmxBsa|f2~dnu<@qXD zZxkp)X|)cupcF+#sen`?l|gN(78R#d>Qfo5G$r7>2@dtyzW3+-WUXZ7ob!Eq-#wk3 zz3%pysIW9N6~FNCojlORwiq;v`;Jw2VA?n6doe2RBKKmdhGqtIwx7{g1hmMAzXFNu7%NyV6s zU|7zT38e}lBEg$2^LpLFB z(WL@Jfyf1j^pyi|JmMAnmLL|szZOw(q<=+bv;R9?EPlfcxgsPD3&e|{zG2hL2gu{p zQkW78%MqoF3x}k^62)wDBusIk#|TxhC?QxVh9z<=x=ezvH|;;r#J@v>5D_9*Vl|+d7GiHKWDB(56B>2;?!ZIb@ z3?=?0^ePv0vwduYhneSNmU<0AK|wJxBv~lJFnMg)GCUL#5RWq{+-4z`mAA41z?@ymUG)*mR!r5ggdEn6Egwyv zPCN`X*_|t}-oN0GweQ80E8RzzJn8>z@|Eew?W|Tr)NlL4K4nsI&gXNs2r6q2?bj>U z+b!4EmSzSHW%dT#%p@nAbMg54S|+~aQfi~7J-|It+PPkEE|j$1bwx-#f0+{}rL&u< zOs-Rw)TERTUwi-F#C4lbhu-xL=@>YtJ@-SZxP<5Z`Zuu|bvweZmOvNdO9_d|uFn~%&pC1@->F>g-F1z6;qa%{nb<;w;w&gUJI22XM3 zdq$E6Gvpgk!-s)VXL&EPJMc{R;_lM!()}%6GE@#lc_zAK&bvM?pY#G0kMQsI(iuHL!XKj}F`(bO% z>LG2`ZvD4Jgk)%6m`2ZoGIr~0h%9KpIQ5wg5cP(l?j%D9nz^%jU)UtgAe3FK|8aJY z4Y*#P3Ux&er38c-HRLKUnHtN(6000~_T!iR?k-wOhoa#M+Ec z@(~4%Fz=9N+Ct07>K{9sy-u5th20pzb|P%GK{6 zn#F%KW6e(B${r9ht~!Hv_RWCSU#z0v`C>LezSBE`+>Y%I@(IjK^cV9L8^hxo?a&gYjlC-VFW^ z%|Ni5C%%#cz=IU=ZvC^pVK~szQMm$L^Rb&1pvGgbE$k*@Lp>3I&o5)gkH(I#6`~7B z214^LD@SWk^zCoNc1f2_lCm)e2OivC?$=}a3DKP_BZ7vTSw+N%8Jk?A2Mh)EI89S` zHoAwH?za=%j2h^_cfk^yeZrs~q9e=U*IVazSb-v~jeO*%pwTdGRu3XtYRuz` z9X?uYxU&fw-J}1-%E1m4Rl0i`h99`!cOJ<@jkBA-Qs>)(qOVe+C{pc7!v(=(`ysS0 zpnWn9_zdk6eQMN}6^^WfWN8nYa3ERi+g#i4pKhmdaw)KKEBWOgK=bKoY~CXJ>sZrR zR#gu}QEcq&b+EAlEo~WSe08h7oKa_&bh9p=?&TF?cS$%CAd$B+MLDEbYKvh11&KAxBJ-z%K$nE)XoM<%)~W+iyv(ONeg zU?rgGR)IV`>HZrRd z*;m^=)N`A+I^>dGPVVh}NoO5VBe}Uu_`HEJ8nL0RkJqS$R-aINwR0Rm->Jk1S!&6( i-yEEWDq9D1&mHN|OK7-%wu6+%TvGuSW= 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 ON%Hdb%w4^E|Gxp|9T9&3 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/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 73382b1da..c11697599 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,30 @@ 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. + """ + # 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() + for it in self.item_thumbs: + 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 // 10, 12)) + def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index db9efdde1..09b474f02 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -62,15 +62,10 @@ class ThumbRenderer(QObject): # 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() + # Cached thumbnail elements. + # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) + thumb_masks: dict = {} + thumb_borders: dict = {} thumb_loading_512: Image.Image = Image.open( Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" @@ -98,6 +93,76 @@ class ThumbRenderer(QObject): math.floor(12 * font_pixel_ratio), ) + @staticmethod + def _get_mask(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + """ + Returns a thumbnail mask given a size and pixel ratio. + If one is not already cached, then a new one will be rendered. + """ + item: Image.Image = ThumbRenderer.thumb_masks.get((*size, pixel_ratio)) + if not item: + item = ThumbRenderer._render_mask(size, pixel_ratio) + ThumbRenderer.thumb_masks[(*size, pixel_ratio)] = item + return item + + @staticmethod + def _get_border(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + """ + Returns a thumbnail border given a size and pixel ratio. + If one is not already cached, then a new one will be rendered. + """ + item: Image.Image = ThumbRenderer.thumb_borders.get((*size, pixel_ratio)) + if not item: + item = ThumbRenderer._render_border(size, pixel_ratio) + ThumbRenderer.thumb_borders[(*size, pixel_ratio)] = item + return item + + @staticmethod + def _render_mask(size: tuple[int, int], pixel_ratio) -> Image.Image: + """Renders a thumbnail mask.""" + smooth_factor: int = math.ceil(2 * pixel_ratio) + 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), + fill="white", + ) + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + return im + + @staticmethod + def _render_border(size: tuple[int, int], pixel_ratio) -> Image.Image: + """Renders a thumbnail border.""" + smooth_factor: int = math.ceil(2 * pixel_ratio) + radius_factor: int = 8 + im: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + 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), + fill=None, + outline="white", + width=math.floor(pixel_ratio * 2), + ) + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + return im + def render( self, timestamp: float, @@ -324,11 +389,11 @@ def render( ) 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 + mask: Image.Image = ThumbRenderer._get_mask( + (adj_size, adj_size), pixel_ratio + ) + hl: Image.Image = ThumbRenderer._get_border( + (adj_size, adj_size), pixel_ratio ) final = four_corner_gradient_background(image, adj_size, mask, hl) else: @@ -340,7 +405,7 @@ def render( ) draw = ImageDraw.Draw(rec) draw.rounded_rectangle( - (0, 0) + rec.size, + (0, 0) + tuple([d - 1 for d in rec.size]), (base_size[0] // 32) * scalar * pixel_ratio, fill="red", ) From ef8cc6cc85c3e11f770546abc4d579bf025d2eb1 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 20 Jul 2024 08:21:09 -0700 Subject: [PATCH 25/47] fix: mkv files with "[0][0][0][0]" codec load properly --- tagstudio/src/qt/helpers/file_tester.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tagstudio/src/qt/helpers/file_tester.py b/tagstudio/src/qt/helpers/file_tester.py index 36a48c2b1..dd115f85e 100644 --- a/tagstudio/src/qt/helpers/file_tester.py +++ b/tagstudio/src/qt/helpers/file_tester.py @@ -17,7 +17,6 @@ def is_readable_video(filepath: Path | str): probe = ffmpeg.probe(Path(filepath)) for stream in probe["streams"]: if stream.get("codec_tag_string") in [ - "[0][0][0][0]", "drma", "drms", "drmi", From ad53f10ecc0cedacb018d071d91f81cdeb208a74 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 20 Jul 2024 08:24:26 -0700 Subject: [PATCH 26/47] fix: missing audio files properly handled --- tagstudio/src/qt/widgets/thumb_renderer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 09b474f02..469635eff 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -22,7 +22,7 @@ from pathlib import Path from PIL.Image import DecompressionBombError from pydub import AudioSegment, exceptions -from mutagen import id3, flac, mp4 +from mutagen import id3, flac, mp4, MutagenError from PySide6.QtCore import Qt, QObject, Signal, QSize from PySide6.QtGui import QGuiApplication, QPixmap from src.qt.helpers.color_overlay import theme_fg_overlay @@ -457,6 +457,9 @@ def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: """Gets an album cover from an audio file if one is present.""" 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) @@ -479,6 +482,7 @@ def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: 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__})" From 91ee2428ca35d09e7b6d82443f2e76dde0460bc0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 20 Jul 2024 09:48:59 -0700 Subject: [PATCH 27/47] feat(ui): use system accent color for thumb selections --- tagstudio/src/qt/widgets/thumb_button.py | 57 +++++++++++++++++------- 1 file changed, 41 insertions(+), 16 deletions(-) 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: From 196c1ba7f33ac81242e9c65ef1e9f17fbc77331a Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 20 Jul 2024 12:53:34 -0700 Subject: [PATCH 28/47] fix(ui): hide gif preview in multi-selections --- tagstudio/src/qt/widgets/preview_panel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 6b4b85e8a..a86c413d2 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -665,6 +665,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: From 39324142f1f1e27d09077e2119ebff2533ac05b4 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sun, 21 Jul 2024 08:40:19 -0700 Subject: [PATCH 29/47] feat(ui): add dynamic file thumb icons --- .../resources/qt/images/broken_link_icon.png | Bin 0 -> 18571 bytes .../qt/images/file_icons/generic.png | Bin 0 -> 5768 bytes .../resources/qt/images/thumb_broken_512.png | Bin 24707 -> 0 bytes .../qt/images/thumb_file_default_512.png | Bin 12661 -> 0 bytes tagstudio/src/core/palette.py | 13 +- tagstudio/src/qt/resource_manager.py | 10 +- tagstudio/src/qt/resources.json | 8 + tagstudio/src/qt/widgets/thumb_renderer.py | 252 ++++++++++++------ 8 files changed, 201 insertions(+), 82 deletions(-) create mode 100644 tagstudio/resources/qt/images/broken_link_icon.png create mode 100644 tagstudio/resources/qt/images/file_icons/generic.png delete mode 100644 tagstudio/resources/qt/images/thumb_broken_512.png delete mode 100644 tagstudio/resources/qt/images/thumb_file_default_512.png 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 0000000000000000000000000000000000000000..d431097084777d038020ea832bdbe5d5e16641f8 GIT binary patch literal 18571 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 literal 0 HcmV?d00001 diff --git a/tagstudio/resources/qt/images/file_icons/generic.png b/tagstudio/resources/qt/images/file_icons/generic.png new file mode 100644 index 0000000000000000000000000000000000000000..13685e3a60e24547d254059427ee09ec56b95cb2 GIT binary patch literal 5768 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 Ym$pBgQy!fa1rI=4YWjCdZaX~w541AvfdBvi 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 28dfbd433568600ee6397a0baec5ad25791308ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12661 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 G-})bN^c95w diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 0b36f953d..b8e93d3aa 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -279,11 +279,16 @@ class ColorType(int, Enum): _UI_COLORS: dict = { "": { - ColorType.PRIMARY: "#1e1e1e", - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: "#333333", + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", ColorType.LIGHT_ACCENT: "#FFFFFF", - ColorType.DARK_ACCENT: "#222222", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + "red": { + ColorType.PRIMARY: "#e22c3c", + ColorType.BORDER: "#e54252", + ColorType.LIGHT_ACCENT: "#f39caa", + ColorType.DARK_ACCENT: "#440d12", }, "green": { ColorType.PRIMARY: "#28bb48", diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index 0db8bb194..5d1d18510 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,7 +47,7 @@ def get(self, id: str) -> Any: return cached_res else: res: dict = ResourceManager._map.get(id) - if res.get("mode") in ["r", "rb"]: + if res and res.get("mode") in ["r", "rb"]: with open( (Path(__file__).parents[2] / "resources" / res.get("path")), res.get("mode"), @@ -56,7 +57,12 @@ def get(self, id: str) -> Any: data = bytes(data) ResourceManager._cache[id] = data return data - elif res.get("mode") in ["qt"]: + elif res and res.get("mode") == "pil": + data = Image.open( + Path(__file__).parents[2] / "resources" / res.get("path") + ) + return data + elif res and res.get("mode") in ["qt"]: # TODO: Qt resource loading logic pass diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 1f8663d37..9f3d3e49c 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -14,5 +14,13 @@ "volume_mute_icon": { "path": "qt/images/volume_mute.svg", "mode": "rb" + }, + "broken_link_icon": { + "path": "qt/images/broken_link_icon.png", + "mode": "pil" + }, + "file_generic": { + "path": "qt/images/file_icons/generic.png", + "mode": "pil" } } diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 469635eff..88933b9d0 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -25,6 +25,7 @@ from mutagen import id3, flac, mp4, MutagenError from PySide6.QtCore import Qt, QObject, Signal, QSize from PySide6.QtGui import QGuiApplication, QPixmap +from src.qt.resource_manager import ResourceManager from src.qt.helpers.color_overlay import theme_fg_overlay from src.qt.helpers.gradient import four_corner_gradient_background from src.qt.helpers.text_wrapper import wrap_full_text @@ -44,6 +45,7 @@ from src.qt.helpers.blender_thumbnailer import blend_thumb from src.qt.helpers.file_tester import is_readable_video + ImageFile.LOAD_TRUNCATED_IMAGES = True ERROR = "[ERROR]" @@ -56,36 +58,23 @@ class ThumbRenderer(QObject): - # finished = Signal() + rm: ResourceManager = ResourceManager() updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - # updatedImage = Signal(QPixmap) - # updatedSize = Signal(QSize) # Cached thumbnail elements. # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) thumb_masks: dict = {} thumb_borders: dict = {} + # Key: ("name", "color", 512, 512, 1.25) + icons: dict = {} + 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( @@ -106,22 +95,33 @@ def _get_mask(size: tuple[int, int], pixel_ratio: float) -> Image.Image: return item @staticmethod - def _get_border(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + def _get_hl_border(size: tuple[int, int], pixel_ratio: float) -> Image.Image: """ Returns a thumbnail border given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ item: Image.Image = ThumbRenderer.thumb_borders.get((*size, pixel_ratio)) if not item: - item = ThumbRenderer._render_border(size, pixel_ratio) + item = ThumbRenderer._render_hl_border(size, pixel_ratio) ThumbRenderer.thumb_borders[(*size, pixel_ratio)] = item return item + @staticmethod + def _get_icon( + name: str, color: str, size: tuple[int, int], pixel_ratio: float + ) -> Image.Image: + item: Image.Image = ThumbRenderer.icons.get((name, color, *size, pixel_ratio)) + if not item: + item = ThumbRenderer._render_icon(name, color, size, pixel_ratio) + ThumbRenderer.thumb_borders[(name, *color, size, pixel_ratio)] = item + return item + @staticmethod def _render_mask(size: tuple[int, int], pixel_ratio) -> Image.Image: """Renders a thumbnail mask.""" - smooth_factor: int = math.ceil(2 * pixel_ratio) + 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 @@ -140,9 +140,9 @@ def _render_mask(size: tuple[int, int], pixel_ratio) -> Image.Image: return im @staticmethod - def _render_border(size: tuple[int, int], pixel_ratio) -> Image.Image: - """Renders a thumbnail border.""" - smooth_factor: int = math.ceil(2 * pixel_ratio) + def _render_hl_border(size: tuple[int, int], pixel_ratio) -> Image.Image: + """Renders a thumbnail highlight border.""" + smooth_factor: int = 2 radius_factor: int = 8 im: Image.Image = Image.new( mode="RGBA", @@ -163,6 +163,127 @@ def _render_border(size: tuple[int, int], pixel_ratio) -> Image.Image: ) return im + @staticmethod + def _render_icon( + name: str, color: str, size: tuple[int, int], pixel_ratio: float + ) -> Image.Image: + 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=ThumbRenderer._get_mask( + tuple([d * smooth_factor for d in size]), # type: ignore + (pixel_ratio * smooth_factor), + ), + ) + + # Draw rounded rectangle 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), + fill="black", + outline="#FF0000", + width=math.floor(pixel_ratio * 8), + ) + + # 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 = ThumbRenderer.rm.get(name) + + # 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 = ThumbRenderer._apply_overlay_color( + im, + color, + ) + + return im + + @staticmethod + def _apply_overlay_color(image: Image.Image, color: str) -> Image.Image: + """Apply a gradient effect over an an image. + Red channel for foreground, green channel for outline, none for background.""" + 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 "#FFFFFF" + ) + + 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 + + @staticmethod + def get_mime_icon_resource(ext: str = "") -> str: + if ext in IMAGE_TYPES: + return "image_photo" + elif ext in VIDEO_TYPES: + return "doc_presentation" + return "" + def render( self, timestamp: float, @@ -271,17 +392,15 @@ def render( # 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() - 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) else: - image = self.thumb_file_default_512 + image = ThumbRenderer._get_icon( + name="file_generic", + color="red", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) # Plain Text =================================================== elif ext in PLAINTEXT_TYPES: @@ -308,7 +427,7 @@ def render( _filepath, ext, adj_size, pixel_ratio ) if image is not None: - image = self._apply_overlay_color(image, "green") + image = ThumbRenderer._apply_overlay_color(image, "green") # 3D =========================================================== # elif extension == 'stl': @@ -355,16 +474,7 @@ def render( 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 - ) - # No Rendered Thumbnail ======================================== - else: - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - if not image: raise UnidentifiedImageError @@ -392,7 +502,7 @@ def render( mask: Image.Image = ThumbRenderer._get_mask( (adj_size, adj_size), pixel_ratio ) - hl: Image.Image = ThumbRenderer._get_border( + hl: Image.Image = ThumbRenderer._get_hl_border( (adj_size, adj_size), pixel_ratio ) final = four_corner_gradient_background(image, adj_size, mask, hl) @@ -415,21 +525,37 @@ def render( ) final = Image.new("RGBA", image.size, (0, 0, 0, 0)) final.paste(image, mask=rec.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 = ThumbRenderer._get_icon( + name="broken_link_icon", + color="red", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) except ( UnidentifiedImageError, - FileNotFoundError, cv2.error, DecompressionBombError, UnicodeDecodeError, ) as e: - if e is not UnicodeDecodeError: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" - ) + # if e is not UnicodeDecodeError: + 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 = ThumbRenderer._get_icon( + # name=ThumbRenderer.get_mime_icon_resource(_filepath.suffix.lower()), + name="file_generic", + color="", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, ) qim = ImageQt.ImageQt(final) if image: @@ -620,7 +746,7 @@ def _font_preview_short(self, filepath: Path, size: int) -> Image.Image: cropped_im, box=(margin, margin + ((size - new_y) // 2)), ) - return self._apply_overlay_color(bg, "purple") + return ThumbRenderer._apply_overlay_color(bg, "purple") def _font_preview_long(self, filepath: Path, size: int) -> Image.Image: """Renders a large font preview ("Alphabet") thumbnail from a font file.""" @@ -644,29 +770,3 @@ def _font_preview_long(self, filepath: Path, size: int) -> Image.Image: len(text_wrapped.split("\n")) + lines_of_padding ) * draw.textbbox((0, 0), "A", font=font)[-1] return theme_fg_overlay(bg, use_alpha=False) - - def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: - """Apply a gradient effect over an an image. - Red channel for foreground, green channel for outline, none for background.""" - 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 "#FFFFFF" - ) - - bg: Image.Image = Image.new("RGB", image.size, color=bg_color) - fg: Image.Image = Image.new("RGB", image.size, color=fg_color) - ol: Image.Image = Image.new("RGB", image.size, color=ol_color) - bg.paste(fg, (0, 0), mask=image.getchannel(0)) - bg.paste(ol, (0, 0), mask=image.getchannel(1)) - return bg From c6a5202c91abc9f8c4ac97a5a205b63b5a611296 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Mon, 22 Jul 2024 07:33:49 -0700 Subject: [PATCH 30/47] fix(ui): hide previous thumbnail before resizing --- tagstudio/src/qt/ts_qt.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index c11697599..8421cc5ec 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1132,8 +1132,11 @@ def thumb_size_callback(self, index: int): 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) From ad12d64f1e349cc9692483d357c0ab72f9c0d4bd Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Thu, 25 Jul 2024 11:54:44 -0700 Subject: [PATCH 31/47] (fix): catch ffmpeg errors in file tester --- tagstudio/src/qt/helpers/file_tester.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tagstudio/src/qt/helpers/file_tester.py b/tagstudio/src/qt/helpers/file_tester.py index dd115f85e..3fbea0903 100644 --- a/tagstudio/src/qt/helpers/file_tester.py +++ b/tagstudio/src/qt/helpers/file_tester.py @@ -14,12 +14,16 @@ def is_readable_video(filepath: Path | str): Args: filepath (Path | str): """ - probe = ffmpeg.probe(Path(filepath)) - for stream in probe["streams"]: - if stream.get("codec_tag_string") in [ - "drma", - "drms", - "drmi", - ]: - return False + 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 From 8d2e67ddadf3e54e01a2fe2eb8bb4c3fcf9b87e7 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Thu, 25 Jul 2024 12:13:59 -0700 Subject: [PATCH 32/47] Squashed commit of the following: commit 9a3c19d398b82a3ffe8349f19b837a3d8cc97421 Author: Travis Abendshien Date: Wed Jul 24 22:57:32 2024 -0700 fix: add missing comma + sort extensions commit 53b2db9b5fa696d3a58cb3107606b9c7b3eaa9da Author: Travis Abendshien Date: Wed Jul 24 14:46:16 2024 -0700 refactor: move type constants to new media classes --- tagstudio/src/core/constants.py | 153 -------- tagstudio/src/core/media_types.py | 409 +++++++++++++++++++++ tagstudio/src/qt/widgets/collage_icon.py | 17 +- tagstudio/src/qt/widgets/item_thumb.py | 22 +- tagstudio/src/qt/widgets/preview_panel.py | 62 +++- tagstudio/src/qt/widgets/thumb_renderer.py | 106 +++--- 6 files changed, 526 insertions(+), 243 deletions(-) create mode 100644 tagstudio/src/core/media_types.py diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 1224d353f..00cb0a1ec 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -12,159 +12,6 @@ ) FONT_SAMPLE_SIZES: list[int] = [10, 15, 20] -# TODO: Turn this whitelist into a user-configurable blacklist. -IMAGE_TYPES: list[str] = [ - ".png", - ".jpg", - ".jpeg", - ".jpg_large", - ".jpeg_large", - ".jfif", - ".gif", - ".tif", - ".tiff", - ".heic", - ".heif", - ".webp", - ".bmp", - ".svg", - ".avif", - ".apng", - ".jp2", - ".j2k", - ".jpg2", - ".psd", -] -RAW_IMAGE_TYPES: list[str] = [ - ".raw", - ".dng", - ".rw2", - ".nef", - ".arw", - ".crw", - ".cr2", - ".cr3", -] -VIDEO_TYPES: list[str] = [ - ".mp4", - ".webm", - ".mov", - ".hevc", - ".mkv", - ".avi", - ".wmv", - ".flv", - ".gifv", - ".m4p", - ".m4v", - ".3gp", -] -AUDIO_TYPES: list[str] = [ - ".mp3", - ".mp4", - ".mpeg4", - ".m4a", - ".aac", - ".wav", - ".flac", - ".alac", - ".wma", - ".ogg", - ".aiff", - ".aif", -] -DOC_TYPES: list[str] = [ - ".txt", - ".rtf", - ".md", - ".doc", - ".docx", - ".pdf", - ".tex", - ".odt", - ".pages", -] -PLAINTEXT_TYPES: list[str] = [ - ".txt", - ".md", - ".css", - ".html", - ".xml", - ".json", - ".js", - ".ts", - ".ini", - ".htm", - ".csv", - ".php", - ".sh", - ".bat", - ".plist", -] -SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"] -PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"] -ARCHIVE_TYPES: list[str] = [ - ".zip", - ".rar", - ".tar", - ".tar", - ".gz", - ".tgz", - ".7z", - ".s7z", -] -BLENDER_TYPES: list[str] = [ - ".blend", - ".blend1", - ".blend2", - ".blend3", - ".blend4", - ".blend5", - ".blend6", - ".blend7", - ".blend8", - ".blend9", - ".blend10", - ".blend11", - ".blend12", - ".blend13", - ".blend14", - ".blend15", - ".blend16", - ".blend17", - ".blend18", - ".blend19", - ".blend20", - ".blend21", - ".blend22", - ".blend23", - ".blend24", - ".blend25", - ".blend26", - ".blend27", - ".blend28", - ".blend29", - ".blend30", - ".blend31", - ".blend32", -] -PROGRAM_TYPES: list[str] = [".exe", ".app"] -SHORTCUT_TYPES: list[str] = [".lnk", ".desktop", ".url"] -FONT_TYPES: list[str] = [".ttf", ".otf", ".woff", ".woff2", ".ttc"] - -ALL_FILE_TYPES: list[str] = ( - IMAGE_TYPES - + VIDEO_TYPES - + AUDIO_TYPES - + DOC_TYPES - + SPREADSHEET_TYPES - + PRESENTATION_TYPES - + ARCHIVE_TYPES - + PROGRAM_TYPES - + SHORTCUT_TYPES - + FONT_TYPES -) - BOX_FIELDS = ["tag_box", "text_box"] TEXT_FIELDS = ["text_line", "text_box"] DATE_FIELDS = ["datetime"] diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py new file mode 100644 index 000000000..d1974343a --- /dev/null +++ b/tagstudio/src/core/media_types.py @@ -0,0 +1,409 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import logging +import mimetypes +from enum import Enum +from pathlib import Path + +logging.basicConfig(format="%(message)s", level=logging.INFO) + + +class MediaType(str, Enum): + """Names of media types.""" + + ARCHIVE: str = "archive" + AUDIO: str = "audio" + BLENDER: str = "blender" + DATABASE: str = "database" + DISK_IMAGE: str = "disk_image" + DOCUMENT: str = "document" + FONT: str = "font" + IMAGE_RAW: str = "image_raw" + IMAGE_VECTOR: str = "image_vector" + IMAGE: str = "image" + INSTALLER: str = "installer" + MATERIAL: str = "material" + MODEL: str = "model" + PACKAGE: str = "package" + PHOTOSHOP: str = "photoshop" + PLAINTEXT: str = "plaintext" + PRESENTATION: str = "presentation" + PROGRAM: str = "program" + SHORTCUT: str = "shortcut" + SPREADSHEET: str = "spreadsheet" + TEXT: str = "text" + VIDEO: str = "video" + + +class MediaCategory: + """An object representing a category of media. Includes a MediaType identifier, + extensions set, and IANA status flag. + + Args: + media_type (MediaType): The MediaType Enum representing this category. + + extensions (set[str]): The set of file extensions associated with this category. + Includes leading ".", all lowercase, and does not need to be unique to this category. + + is_iana (bool): Represents whether or not this is an IANA registered category. + """ + + def __init__( + self, + media_type: MediaType, + extensions: set[str], + is_iana: bool = False, + ) -> None: + self.media_type: MediaType = media_type + self.extensions: set[str] = extensions + self.is_iana: bool = is_iana + + +class MediaCategories: + """Contains pre-made MediaCategory objects as well as methods to interact with them.""" + + # 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. + _ARCHIVE_SET: set[str] = { + ".7z", + ".gz", + ".rar", + ".s7z", + ".tar", + ".tgz", + ".zip", + } + _AUDIO_SET: set[str] = { + ".aac", + ".aif", + ".aiff", + ".alac", + ".flac", + ".m4a", + ".m4p", + ".mp3", + ".mpeg4", + ".ogg", + ".wav", + ".wma", + } + _BLENDER_SET: set[str] = { + ".blen_tc", + ".blend", + ".blend1", + ".blend10", + ".blend11", + ".blend12", + ".blend13", + ".blend14", + ".blend15", + ".blend16", + ".blend17", + ".blend18", + ".blend19", + ".blend2", + ".blend20", + ".blend21", + ".blend22", + ".blend23", + ".blend24", + ".blend25", + ".blend26", + ".blend27", + ".blend28", + ".blend29", + ".blend3", + ".blend30", + ".blend31", + ".blend32", + ".blend4", + ".blend5", + ".blend6", + ".blend7", + ".blend8", + ".blend9", + } + _DATABASE_SET: set[str] = { + ".accdb", + ".mdb", + ".sqlite", + } + _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".iso"} + _DOCUMENT_SET: set[str] = { + ".doc", + ".docm", + ".docx", + ".dot", + ".dotm", + ".dotx", + ".odt", + ".pages", + ".pdf", + ".rtf", + ".tex", + ".wpd", + ".wps", + } + _FONT_SET: set[str] = { + ".fon", + ".otf", + ".ttc", + ".ttf", + ".woff", + ".woff2", + } + _IMAGE_RAW_SET: set[str] = { + ".arw", + ".cr2", + ".cr3", + ".crw", + ".dng", + ".nef", + ".raw", + ".rw2", + } + _IMAGE_VECTOR_SET: set[str] = {".svg"} + _IMAGE_SET: set[str] = { + ".apng", + ".avif", + ".bmp", + ".exr", + ".gif", + ".heic", + ".heif", + ".j2k", + ".jfif", + ".jp2", + ".jpeg_large", + ".jpeg", + ".jpg_large", + ".jpg", + ".jpg2", + ".png", + ".psb", + ".psd", + ".tif", + ".tiff", + ".webp", + } + _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", + } + _PLAINTEXT_SET: set[str] = { + ".bat", + ".css", + ".csv", + ".htm", + ".html", + ".ini", + ".js", + ".json", + ".jsonc", + ".md", + ".php", + ".plist", + ".prefs", + ".sh", + ".ts", + ".txt", + ".xml", + } + _PRESENTATION_SET: set[str] = { + ".key", + ".odp", + ".ppt", + ".pptx", + } + _PROGRAM_SET: set[str] = {".app", ".exe"} + _SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"} + _SPREADSHEET_SET: set[str] = { + ".csv", + ".numbers", + ".ods", + ".xls", + ".xlsx", + } + _VIDEO_SET: set[str] = { + ".3gp", + ".avi", + ".flv", + ".gifv", + ".hevc", + ".m4p", + ".m4v", + ".mkv", + ".mov", + ".mp4", + ".webm", + ".wmv", + } + + ARCHIVE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.ARCHIVE, + extensions=_ARCHIVE_SET, + is_iana=False, + ) + AUDIO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AUDIO, + extensions=_AUDIO_SET, + is_iana=True, + ) + BLENDER_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.BLENDER, + extensions=_BLENDER_SET, + is_iana=False, + ) + DATABASE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DATABASE, + extensions=_DATABASE_SET, + is_iana=False, + ) + DISK_IMAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DISK_IMAGE, + extensions=_DISK_IMAGE_SET, + is_iana=False, + ) + DOCUMENT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DOCUMENT, + extensions=_DOCUMENT_SET, + is_iana=False, + ) + FONT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.FONT, + extensions=_FONT_SET, + is_iana=True, + ) + IMAGE_RAW_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_RAW, + extensions=_IMAGE_RAW_SET, + is_iana=False, + ) + IMAGE_VECTOR_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_VECTOR, + extensions=_IMAGE_VECTOR_SET, + is_iana=False, + ) + IMAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE, + extensions=_IMAGE_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET, + is_iana=True, + ) + INSTALLER_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.INSTALLER, + extensions=_INSTALLER_SET, + is_iana=False, + ) + MATERIAL_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.MATERIAL, + extensions=_MATERIAL_SET, + is_iana=False, + ) + MODEL_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.MODEL, + extensions=_MODEL_SET, + is_iana=True, + ) + PACKAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PACKAGE, + extensions=_PACKAGE_SET, + is_iana=False, + ) + PHOTOSHOP_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PHOTOSHOP, + extensions=_PHOTOSHOP_SET, + is_iana=False, + ) + PLAINTEXT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PLAINTEXT, + extensions=_PLAINTEXT_SET, + is_iana=False, + ) + PRESENTATION_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PRESENTATION, + extensions=_PRESENTATION_SET, + is_iana=False, + ) + PROGRAM_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PROGRAM, + extensions=_PROGRAM_SET, + is_iana=False, + ) + SHORTCUT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SHORTCUT, + extensions=_SHORTCUT_SET, + is_iana=False, + ) + SPREADSHEET_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SPREADSHEET, + extensions=_SPREADSHEET_SET, + is_iana=False, + ) + TEXT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.TEXT, + extensions=_DOCUMENT_SET | _PLAINTEXT_SET, + is_iana=True, + ) + VIDEO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.VIDEO, + extensions=_VIDEO_SET, + is_iana=True, + ) + + ALL_CATEGORIES: list[MediaCategory] = [ + ARCHIVE_TYPES, + AUDIO_TYPES, + BLENDER_TYPES, + DATABASE_TYPES, + DISK_IMAGE_TYPES, + DOCUMENT_TYPES, + FONT_TYPES, + IMAGE_RAW_TYPES, + IMAGE_TYPES, + IMAGE_VECTOR_TYPES, + INSTALLER_TYPES, + MATERIAL_TYPES, + MODEL_TYPES, + PACKAGE_TYPES, + PHOTOSHOP_TYPES, + PLAINTEXT_TYPES, + PRESENTATION_TYPES, + PROGRAM_TYPES, + SHORTCUT_TYPES, + SPREADSHEET_TYPES, + TEXT_TYPES, + VIDEO_TYPES, + ] + + @staticmethod + def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]: + """Returns a set of MediaTypes given a file extension. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + types: set[MediaType] = set() + mime_guess: bool = False + + for cat in MediaCategories.ALL_CATEGORIES: + if ext in cat.extensions: + types.add(cat.media_type) + elif mime_fallback and cat.is_iana: + type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if type and type.startswith(cat.media_type.value): + types.add(cat.media_type) + mime_guess = True + + # logging.info( + # f"({ext}) Media Categories Found: {[x.value for x in types]}{' (MIME)' if mime_guess else ''}" + # ) + return types diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index a344ce0b2..5d9ac3b43 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -24,7 +24,7 @@ ) from src.core.library import Library -from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES +from src.core.media_types import MediaCategories, MediaType from src.qt.helpers.file_tester import is_readable_video @@ -94,7 +94,8 @@ def render( ) # 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() - if filepath.suffix.lower() in IMAGE_TYPES: + ext: str = filepath.suffix.lower() + if MediaType.IMAGE in MediaCategories.get_types(ext): try: with Image.open( str(self.lib.library_dir / entry.path / entry.filename) @@ -112,7 +113,7 @@ def render( self.rendered.emit(pic) except DecompressionBombError as e: logging.info(f"[ERROR] One of the images was too big ({e})") - elif filepath.suffix.lower() in VIDEO_TYPES: + elif MediaType.VIDEO in MediaCategories.get_types(ext): if is_readable_video(filepath): video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) video.set( @@ -169,14 +170,16 @@ def render( self.done.emit() # logging.info('Done!') + # NOTE: Depreciated def get_file_color(self, ext: str): - if ext.lower().replace(".", "", 1) == "gif": + _ext = ext.lower().replace(".", "", 1) + if _ext == "gif": return "\033[93m" - if ext.lower().replace(".", "", 1) in IMAGE_TYPES: + elif MediaType.IMAGE in MediaCategories.get_types(_ext): return "\033[37m" - elif ext.lower().replace(".", "", 1) in VIDEO_TYPES: + elif MediaType.VIDEO in MediaCategories.get_types(_ext): return "\033[96m" - elif ext.lower().replace(".", "", 1) in DOC_TYPES: + elif MediaType.DOCUMENT in MediaCategories.get_types(_ext): return "\033[92m" else: return "\033[97m" diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index d639babf2..a03c1ae55 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -24,12 +24,10 @@ from src.core.enums import FieldID from src.core.library import ItemType, Library, Entry from src.core.constants import ( - AUDIO_TYPES, - VIDEO_TYPES, - IMAGE_TYPES, TAG_FAVORITE, TAG_ARCHIVED, ) +from src.core.media_types import MediaCategories, MediaType from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -360,10 +358,24 @@ def set_mode(self, mode: Optional[ItemType]) -> None: def set_extension(self, ext: str) -> None: if ext and ext.startswith(".") is False: ext = "." + ext - if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng", ".psd"]: + if ( + ext + 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 ext + in [ + ".apng", + ".exr", + ".gif", + ] + ): self.ext_badge.setHidden(False) self.ext_badge.setText(ext.upper()[1:]) - if ext in VIDEO_TYPES + AUDIO_TYPES: + if (MediaType.VIDEO in MediaCategories.get_types(ext)) or ( + MediaType.AUDIO in MediaCategories.get_types(ext) + ): self.count_badge.setHidden(False) else: if self.mode == ItemType.ENTRY: diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index a86c413d2..8f908227a 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -29,12 +29,9 @@ from src.core.enums import SettingItems, Theme from src.core.library import Entry, ItemType, Library from src.core.constants import ( - VIDEO_TYPES, - IMAGE_TYPES, - RAW_IMAGE_TYPES, TS_FOLDER_NAME, - FONT_TYPES, ) +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 @@ -538,7 +535,8 @@ def update_widgets(self): self.opener.open_explorer ) - # TODO: Do this somewhere else, this is just here temporarily. + # 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)) @@ -556,9 +554,19 @@ def update_widgets(self): self.preview_gif.show() image = None - if filepath.suffix.lower() in IMAGE_TYPES: + if ( + (MediaType.IMAGE in MediaCategories.get_types(ext)) + and ( + MediaType.IMAGE_RAW + not in MediaCategories.get_types(ext) + ) + and ( + MediaType.IMAGE_VECTOR + not in MediaCategories.get_types(ext) + ) + ): image = Image.open(str(filepath)) - elif filepath.suffix.lower() in RAW_IMAGE_TYPES: + elif MediaType.IMAGE_RAW in MediaCategories.get_types(ext): try: with rawpy.imread(str(filepath)) as raw: rgb = raw.postprocess() @@ -570,7 +578,7 @@ def update_widgets(self): rawpy._rawpy.LibRawFileUnsupportedError, ): pass - elif filepath.suffix.lower() in VIDEO_TYPES: + elif MediaType.VIDEO in MediaCategories.get_types(ext): if is_readable_video(filepath): video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) video.set( @@ -594,33 +602,47 @@ def update_widgets(self): self.preview_vid.show() # Stats for specific file types are displayed here. - if image and filepath.suffix.lower() in ( - IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES - ): - self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px" + if image and ( + (MediaType.IMAGE in MediaCategories.get_types(ext)) + or (MediaType.VIDEO in MediaCategories.get_types(ext, True)) + or ( + MediaType.IMAGE_RAW + in MediaCategories.get_types(ext, True) ) - elif filepath.suffix.lower() in FONT_TYPES: - font = ImageFont.truetype(filepath) + ): self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{font.getname()[0]} ({font.getname()[1]}) " + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px" ) + elif MediaType.FONT in MediaCategories.get_types(ext, True): + try: + font = ImageFont.truetype(filepath) + self.dimensions_label.setText( + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{font.getname()[0]} ({font.getname()[1]}) " + ) + except OSError: + self.dimensions_label.setText( + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" + ) + logging.info( + f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}" + ) else: + self.dimensions_label.setText(f"{ext.upper()[1:]}") self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) if not filepath.is_file(): raise FileNotFoundError except FileNotFoundError as e: - self.dimensions_label.setText(f"{filepath.suffix.upper()[1:]}") + self.dimensions_label.setText(f"{ext.upper()[1:]}") logging.info( f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" ) except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{filepath.suffix.upper()}") + self.dimensions_label.setText(f"{ext.upper()}") logging.info( f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" ) @@ -629,7 +651,7 @@ def update_widgets(self): DecompressionBombError, ) as e: self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) logging.info( f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 88933b9d0..c08f0291b 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -29,17 +29,8 @@ from src.qt.helpers.color_overlay import theme_fg_overlay from src.qt.helpers.gradient import four_corner_gradient_background from src.qt.helpers.text_wrapper import wrap_full_text -from src.core.constants import ( - AUDIO_TYPES, - PLAINTEXT_TYPES, - FONT_TYPES, - VIDEO_TYPES, - IMAGE_TYPES, - RAW_IMAGE_TYPES, - FONT_SAMPLE_TEXT, - FONT_SAMPLE_SIZES, - BLENDER_TYPES, -) +from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from src.core.media_types import MediaType, MediaCategories from src.core.utils.encoding import detect_char_encoding from src.core.palette import ColorType, get_ui_color from src.qt.helpers.blender_thumbnailer import blend_thumb @@ -278,11 +269,7 @@ def _apply_overlay_color(image: Image.Image, color: str) -> Image.Image: @staticmethod def get_mime_icon_resource(ext: str = "") -> str: - if ext in IMAGE_TYPES: - return "image_photo" - elif ext in VIDEO_TYPES: - return "doc_presentation" - return "" + pass def render( self, @@ -335,48 +322,50 @@ def render( self.updated_ratio.emit(1) elif _filepath: try: - ext = _filepath.suffix.lower() + ext: str = _filepath.suffix.lower() # Images ======================================================= - if ext in IMAGE_TYPES: - 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=bg_color) - 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__})" - ) - - elif ext in RAW_IMAGE_TYPES: - 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", + 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__})" ) - 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__})" - ) + # 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__})" + ) # Videos ======================================================= - elif ext in VIDEO_TYPES: + elif MediaType.VIDEO in MediaCategories.get_types(ext, True): if is_readable_video(_filepath): video = cv2.VideoCapture(str(_filepath), cv2.CAP_FFMPEG) # TODO: Move this check to is_readable_video() @@ -403,7 +392,7 @@ def render( ) # Plain Text =================================================== - elif ext in PLAINTEXT_TYPES: + 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) @@ -412,7 +401,7 @@ def render( draw.text((16, 16), text, fill=fg_color) image = bg # Fonts ======================================================== - elif _filepath.suffix.lower() in FONT_TYPES: + elif MediaType.FONT in MediaCategories.get_types(ext, True): if gradient: # Short (Aa) Preview image = self._font_preview_short(_filepath, adj_size) @@ -420,7 +409,7 @@ def render( # Large (Full Alphabet) Preview image = self._font_preview_long(_filepath, adj_size) # Audio ======================================================== - elif ext in AUDIO_TYPES: + elif MediaType.AUDIO in MediaCategories.get_types(ext, True): image = self._album_artwork(_filepath, ext) if image is None: image = self._audio_waveform( @@ -450,7 +439,7 @@ def render( # image = Image.open(img_buf) # Blender =========================================================== - elif _filepath.suffix.lower() in BLENDER_TYPES: + elif MediaType.BLENDER in MediaCategories.get_types(ext): try: blend_image = blend_thumb(str(_filepath)) @@ -542,6 +531,7 @@ def render( cv2.error, DecompressionBombError, UnicodeDecodeError, + OSError, ) as e: # if e is not UnicodeDecodeError: logging.info( From 6883f9ef6da9263db3295f9c92e3a1c8a3903d55 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Thu, 25 Jul 2024 16:00:33 -0700 Subject: [PATCH 33/47] feat(ui): add media types and icon resources --- .../images/file_icons/adobe_illustrator.png | Bin 0 -> 10553 bytes .../qt/images/file_icons/adobe_photoshop.png | Bin 0 -> 12552 bytes .../qt/images/file_icons/affinity_photo.png | Bin 0 -> 11147 bytes .../qt/images/file_icons/document.png | Bin 0 -> 8832 bytes .../{generic.png => file_generic.png} | Bin .../resources/qt/images/file_icons/font.png | Bin 0 -> 9027 bytes .../resources/qt/images/file_icons/image.png | Bin 0 -> 8998 bytes .../qt/images/file_icons/material.png | Bin 0 -> 16977 bytes .../resources/qt/images/file_icons/model.png | Bin 0 -> 13247 bytes .../resources/qt/images/file_icons/text.png | Bin 0 -> 7126 bytes .../resources/qt/images/file_icons/video.png | Bin 0 -> 8448 bytes tagstudio/src/core/media_types.py | 46 +++++++++++++----- tagstudio/src/qt/resources.json | 42 +++++++++++++++- tagstudio/src/qt/widgets/item_thumb.py | 5 +- tagstudio/src/qt/widgets/thumb_renderer.py | 33 +++++++++++-- 15 files changed, 107 insertions(+), 19 deletions(-) create mode 100644 tagstudio/resources/qt/images/file_icons/adobe_illustrator.png create mode 100644 tagstudio/resources/qt/images/file_icons/adobe_photoshop.png create mode 100644 tagstudio/resources/qt/images/file_icons/affinity_photo.png create mode 100644 tagstudio/resources/qt/images/file_icons/document.png rename tagstudio/resources/qt/images/file_icons/{generic.png => file_generic.png} (100%) create mode 100644 tagstudio/resources/qt/images/file_icons/font.png create mode 100644 tagstudio/resources/qt/images/file_icons/image.png create mode 100644 tagstudio/resources/qt/images/file_icons/material.png create mode 100644 tagstudio/resources/qt/images/file_icons/model.png create mode 100644 tagstudio/resources/qt/images/file_icons/text.png create mode 100644 tagstudio/resources/qt/images/file_icons/video.png 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 0000000000000000000000000000000000000000..141ae6203951666eecbc4b120b03a5fea50ba389 GIT binary patch literal 10553 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f4305fb8c67c14e876d11bdd57ccf1eb28481cda GIT binary patch literal 11147 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 D#e&zb literal 0 HcmV?d00001 diff --git a/tagstudio/resources/qt/images/file_icons/generic.png b/tagstudio/resources/qt/images/file_icons/file_generic.png similarity index 100% rename from tagstudio/resources/qt/images/file_icons/generic.png rename to tagstudio/resources/qt/images/file_icons/file_generic.png 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 0000000000000000000000000000000000000000..174750dbb2533b94b7d3e0802937ad60f3e529b5 GIT binary patch literal 9027 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 D6%`Z0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..94264aec02eec677d074ad7d623eb54b15429c98 GIT binary patch literal 8998 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c0c10afd37a0f970cddb20a02cc4b9530788d94 GIT binary patch literal 16977 zcmb8W2UJsAw=lYs5F)+zUIe8hy@OH`5s==S2pFUU2pu7jqx2S3lzt$hfCx$#geVAD z04WNBKm?VdG-;8RxAAz44DB!|c80oNM+q_grhGUbHl0qUWav0DuXFG`0Z% zDEJc!oS+5&+ln6B0|2-O+Rh24ha@Lrs;htBm%1^E)I^1{)_l!pZ}zdi3kh)L;13gqHmyYkZ&+HTv1s; zS@FNa`$nVxi<{u^f2j*poZ|5a2(I*R;8?WZ{{j4XL|HsHNcYzVd?qDB`2IcvOg+EA6|23v3u5m{3zfI)) zzYhJUEwumeXAyE4WKZP}d;c|rI(P13Sco4w5F87)F*_@YGC8NNtfj6le@5Y-89>8n zgScqF7-MfN2z5s3jHi>=rb;TE?_J7u@;(r(AKUmXM{2$E!Bl&+zC?MWH@4!R_W<16JOnKnLet&~|JGshEUeR1&7Zdlp^Xd0ai%$2d z+(d=5n!oCvSia(7F6QDrPbWJ;MNP~+k>g|~|K!%s=v&WrLbC_bTJ&oi4#TM~=Hlpw zUg7lF)de1`C9EYbvRAm*PDDMt=X92GVWQ?O{KlG`p2s(y^ilhxr%u_XXT3sr97eh{ zkJ3M6lyKb1pMUL}zC3j8QGTP;W_Sa8Tu-}l3aeDgOG%%{9F_akZ2w+tbl{H=5y? zS;I}6CVzwdB3(nQd*?F$&d%RfgY9>^AdWx%XLYWe4g}XWItyl%r`n8s-oYWxByPb%x;V^}=#&`0R)yABLLBXO|4T*6Cm~QZS?2#rv-L2a^E*zy^trVkwzZ& z5S#0PoHR9|3E_z#yPxOxZqyA<)4I{QsU7T|C)55^%c1vyh;9c9*Xv}5{EWIZ&=A3O z6^@b5R-{)ztY%#}Uo8vHqLM_)BF%I^VW|(gV#*)Nfth*ODvS_2Z~GL;>D6GE$WzNa zY(Nfe-0s@PZ93bsy3xq73?Sd+z;U%3qwF6aYj z7hE{Y!mRox99Iw=N4`O%P4pD}yY96feUXI>Z33zw_LLd{9J}I5_9eZ=KcHDCk8Ou) zLMURGaEyY@-*Uhhr)xvyaH0!FA)wOS$(Hdme^L6F1_SWn6F2E^qA8W{McYGn1xYTb z0W_Fp&}2Ckj$8JVAS;o%h+2C#jp6t;mgG2Iqz#@HcY_p#+H6T?l<0P%jYmYQAGO*r zMd!)*hvOY#b%@9lAa?H>-UlC&*KB-8(!!fHT6pRAGW}fQ2R>7SC)crx2H877zaPE( z1S@mXWs82g;f}DEb!Jb@_v)kVa{ToYmj%xx_nsK#93ako?}; z2_^A)yAl5vGft>Xby)OCbVSplqDOcCX$3?UN$8ZlSdFE<_c?MVX$m)x6rLLviCEVn z^OEsow=oPiAT#=%q=VA{ejpaaKfF#Nij!4H#V^{BHht>wEUA@t;pwIT-iWMkB!7Gi z65fC?G@0a$)>t1RX@fu(zfP>1kWYD`Kqa1F0Jyx>t7@n#q}b!39Z_@!h_s&+Zav@( zJQVqnN4xVp&yEIJ-FryffRsve!EMGtGo(l0TL{ngHJArY0vb+|9^e!qEFdpcFKAJl zlr_-=%q?%;t8>gF{-cMqW6E*BSk-gnbY>z)aSD;{kXe(xRY{xe+fabD?I)-vb1NW_ zcsgoG^qNCgdU`OLezct9VzVD)4Q2_Ha2KT$SAWuWq;3f%vaLG>YQM?N22?)+>t{wt z<+aztbH}<|QLxBFV}3j;n)AnSwZHH1Z-BbtSafUWIZS#s$&D3uy`oBvu7A zpS-ryWx1ztk>L~Uy*`X2M$9x(XBsrI>1am^L?7}Z=$g~uhV6kyE@!sZ$~kgx6_@7T zp>%?T?7NL{D@w=$j(B!-LcL^WlLR`4d`P-KA@DwW2T+~;PHMr!Kny#92e7){8JuZ2 zt5?HwnpSp!MAWnvZfkmDZqe9Im;vYK!lHRc^`XBdMuD)5W?ttY^NvKN@%Nem1DjZK zG#NFViVVmQz<1-)D>$q}1!*P(M{&D^$kjptP=$VhavhT#uY2Rob3ZFuU#b52OgZX_wgJ7UYr97CC1ZS^c=+xG4{P9yGb^^yw9sZVRX^o5yJ=-dGPWbe&~S|0Nw zeIgD$PI`OwCA3V~4>Btr^i@Sq{q{>HhygSba>VAO`kfjA5&h7OCBG$Upk|#3dyPPA z#@I!5`2@&M{Z~H{J!oV2Zw|Y=o!?s}j8(w!`zcf<9-*$kHy3VL^^1CTD?Q6TNnBGx zxyLdrwMB0wxAtqYOw=V`4Yi_e6Sz5i?UDGkeAF^YbR9is&}!0qlJw%5Tb|09Xr`Oq zTf$~v?y8_|e&j^1rytdGbB#k4?^CT`l)fc5@!lNvEJ{g&HC!^elO03!e5ROcNd(^F zcVSlE7Dl5i{!rxhuFXi)=Z;0=TTZ}NZ!_{)GT(42Cz;X9k=%t$-{X7%H4M1<9NA8{ z%37Qv2ZYtLYL@O<-D}HkbYFy!EZ`#h{CjIAIk%F7kS2z}_()>N?ED}YQ3=UvhXUs*IR7=)I&{QwLQNBY{i!frG_A@Q=B*~4zE$be)Xh=Ihl zU|jevG%HxR9c>wW7pPzPeQ`* z_N;M0T9f*^?eaK26F}~PzpNhA$Pdp9s=-&37=Ts=XJ*aIXXP&KQN_2_7ffBA()u$$8A2?5yQ{bb&^|PG66w zWA~M?0}k}*nMGagOzq4UHkm67y5|p=W>N8YKYfXPmbtoGZ&fW}Xe6zEzL7$4PX*E0 z!Ui1)tV$gDtx4)%98v3kVV>I6d2@KHmHsaLfMMN<_V)#@-Q3QK`!5Gz!BR(3r7{kX z5SbKeQo#X2p2QIXG~!yudnEIgN9m7z6e8gv`nQ!H&$#X@ zwt{c@1%L&=oKd?CKccW8@uMk2gZ-ipUSHx`YIj?n zfc`37jnZH7UMMfTZuh_lxZ4e(J3KB<^#d4N>gAFUI;z!Sw zGMZoA)=ju|$Visv`>N*bmZkzzy#XOl^~^rXGvUbJS@{r>l4!tKk*OT33y@q$yJz~Z zKBaIExbfM;UtfDao4-LT{6qmGwXv4!-KfU=ZG#rYfG}B5bJjd{aXwb6{yIefTdpZA zKxNy9wXjl4cufoEvq?8~!3=SqF|6>ELr&+9`(4pc3}QoL!8nvYav%1HB_8f%aS(Tj z*K|)e>-K)dm=4aO@3pJEYH=eqV#`9y+6@Og0!qwWHbwUil^A_29{C-3j$MUF`{v9V zhe{niWXGs(Xv`ajGOd&>BhW8$eNqw?8M}E69$z}>#RieL7=IO>($}#Z49Go~+C?Xw z=89euH;^b{IlxnPUn*t1p1+n6qDPM6`>J7`A=Lly&IG*^=!2RbpU>&!?+PgkjPGe= zXupymsb_>Rqd$x+3ihSnCo*M?iI4X0wo#KBLwrhyOPD^J9_)MZnnrDOT?kb9RooUX zRb_F}yd#@Z7~BzkD>7(FXA)RXAS=(!BD$4g)4T6YsFRBMzG`}OL=ieZh*}{W*b}=m zGwmC9W5(Rb<2_vnaNXdEf(R%-aYtU=G+WS8cNkeM8Fz4EX~k0tgF2#ZQ|om6_X)fF zj5ocpJU;n!!dJy&6rM%G&^N5r>>iK4)_3}zJ~$@26fyNJgm)nA1M!J&UQ7^eOcsx4 zp?JuLG4qtJw%&BbTffUy)L1k#DOW>yO|?Sh(fOp;@!Cnced#<_xh(;H;lJWnxAZ1k zI;lrrby)kAGUhH-mkbz@7x=zvso%-*lYa=IeH}hEv5B6wxwR(E6q(VvbJP2l$XmR$ za@#geR;528u*ft($1XfX{K$`AAPVMsJY|DAMsQ<%S}7x7{&Hor8hXbN``Bl`RJ-4u zZ%UA&vY|1u=wvcEQ7Qi&vcLugQFltJzjBj*ZY*PYxR4MFIqYYX8gP(7DZCOG|L`48 z@jZ78!l3&~T#=D~R=?=@lTh{*fd?N>Xa#SD)33DMW0vZg;ptYoG^d6AKF{}6Tb+4H zDa!W}^KU)dXIvAlAc0(o(1fnzQdC!41)1P_f|~l;QNzx!)AGPVMOzqNV!bRo*zH1c zOzoPv>HU=lF?vrrhktr8-mK5AMnHxG!J62Rk(qdUn`^;a;#W~Hn%7h~l&;tSs)swM zWQ^M5J!kY05-fgX$|q!S>KKTDtUXuPgA;01WenM&F`l$C#sFh!xr6I05o*rwQQDScR4iz<)Gn>GO04omL#* z`rx9Z>2H~J&tcT9D`M+cy&|#VgaCh^k;cWh+6S|W;sZ*PEsYt^8q_dM=)P{3t7o}X z7+QrVWVCa=d0_iFl}BpBMx^Xer3~WCG<_h;qs8?^gQrzfNB4clie2%=IA5?lsGw^X zdY&H7PrXmHuPB(Lh@5rt_|)rZW6(_NG5(60ptc?`vRtk;QP2@v?1~O2d(V~9W%gWs zbFJ-fkEEJ?zMIx=pPAngb?Syh*K(up7KCuy?J5JFv?bidJ@qQM7BgvULJL4WrxKeX z`OO~7!|Hj~+ZQEct;apFNu-5}Jl=2Jstp?kG@7(v$^C7DJ=!~; z`dytYbn@#+&5-SEbK@Nk)WK$HZflXM$$d1lIFnySCukQ{odk;z%h#g2xtisDeVhOr&kVB!RJX1`{!w_&NLwLpgrcPSaSXZ#k{%~Sg#a5nqK+3dCE)0X39=B;s?a3v{7cPcQ_SD z6s^5My@(ZuPr@}B?jC90}tW$*?5af>_Z4F{(#ENTqWo zwV8xIradD_{-$>{r9tS6+UO;9nMx1aHK1fyOjve!p`D(;uGUU+6H9I{{s`bTjjJfF z!mhm*rHa`I*ca;AERVs~nSZs`@8-se7hFjmp=;<62JAb0u7t34`q;H4JuXTNzHKYu+K5zy45{+#lP^POemNyRJ7vb6Xu;I2)N5+c z^%Y#iYbrduyNS)RS0ff7(Teq!Say=xmBFws1gtdG@+_fuypA&PcE z`R~thz>HD+Jj=RK!7ybAmVnRfB6e(R^RkXe;G(#1BSriOL+5YIziC5wsiVgqY+v7O z<($ada(giNQObU7D9zHCP`S>##1n7ORbmAzalN3e7njpG{{{9UCPQ{X7^lHHOF6f< z?YRrqjX#dIc!sXY0=TqYB=n?;Y%FZ8j046sQQNmHw?fLo%(Enwes)%Q($Xguk;xK?Z5cZzr9F2eI5~7@k zv~9^IH?M?-{UA&*wTf#di+N=;{#xwoB%1MppZd8({pYk4htj^g44=;)ynE`7uSp80 zq<@YD!kkpU7%xwQ>h8=2)$JD@3h8S_P~stdOODJ8oQEcsdVm#8@X5$$Ju)LUZR3dV z@3SEzSx$)pAe&s5wd(26NyQ28q*l1z{|HVpaYDI}7qjtN2gi7IClelj4IOLQNLHPl z1@l$jI~ry=vgR&Fm-Tz>h(xB`*AbW!auOY4V6N>8ol;MMLZ8D_L0Yd@I-1O#VutK>LrG8z%Wm{Sn$hfK0DQ#W@qi7RlONQ zqB=zDqtbRzbd-A}?b9#BVmSstWdsY^N z0N<2Gt@k6>vv%o6IG0boMb(gJL_&Y{L&>$oCElRMf=1<66MZK%l(&8UIz$N^8t={^ zZT?Ea+k(=#kOoK&aG^S$5O}9x=v=?)S3$2*(&oYbEh{X(Ta>!!MIm7@rp z-zrGS^tv-TxFOEyj^N^R{0tY{9zwks$jxQpk4mf5(i@Z zxT4<$GeTV1>$xq)&Kf4u7Pmb9o_oisc`f|97^S1JtpGtR^pIO`G=$*?B{O7f%G!ND*##=r(9=nKg83x>fW3&Rwrs4Jz zQC89#{;j!Y@B^8vvNbZm$5+0ACsD6td<20W1*Q%#Lvu-2R|69wcddupBvC6D9U$v@ zgsiF5CRC8$Qnd`gT)n7EAnr_55a&G@+&o8~=vbo)olPK0I5#7I?M)!$I#5pMH!k|SHeS}KzE$I;Ab&16Zj>sC z@;x1Xc+l^G8<`q>%7AkwC7dihM;P<)AbO+yH_2tN~awa)y&cj~@bVxdU#< zSWZ^&kn;gGZ)Jc;8g-ea34&y%LnNf0U98y3N^s`c$Z|Aj4u!Hp412Y z2PyoqYFGVik9-znadGDQeZ~Q{z&lv8?3Y%8q{Zd8glq$vgJzJ-R}`_i>7p+u(4LRE zfFejWc)%A@WB{PVW()H66c8!<DK-{oXqXp*Zaka>OVa{^!{Q@-mB+5pk`X{9I z;mh#MCUa|q%v0duB^^_cZ(CmoMz6m5qMoNLLQ)FoosfmDb`YhOvUwz7EImaSrvn?LgEzIAZ~+4U%*Qqi!$gs^{w`B* zNr8H?-nAu#f->40vlw26ypP&0 z7aW&<3Wz^a1Xl~g_c3d9+99(+b$`5Ll}S4!2m#$YVMrLGF~2@2U=L8h-;zHeiv@b1 zgG<#T%FK6;4NVfp9e2bC1)|kJtL$Qj^A_+y889oJf(AJbe`2vF0nM;H=_N-eY{Q=rxe*X5XX zTfm7W;m(}Vs}G^1V^#i;XV#4xlK_rwS?kYY$nq1CBl%%^jufs#3nBwG;GTh%95@)U zW9DT-{_Q#q6GhqF>R+G&=UyzVIkUkHFMm6U3TRn`iUtU}&FZlnN1yiowe{Z>!b!W+ zJKWZQu^u%?a_J3ubLtou2fF$(J2E@|mmUDuj~TLGtzM-0!|WTAC~DH5R|?7h0=v`w z4|mn?Sz|bXW7+Kfc11M+4YvTYAl#lBnrcIc>bR0HU~M&HV&ukvp6iMik*$ zs0S9>txl)C_WV$o1T`esnSs@cwH^R!$%GTQ<|qcbr?(acjb-mX7Q9NFCwGXk@jrA^ zBv4E`Wt|Ni4>egT3{n67ruQeiR5L;r%%qjml7N$_YElGgWKr^FEj*AHyDBFP`ONZ2 zfvgE8jFS&mscl9J?o7Jx*Ni~(Ily+gFrcGXhFM~v6@fz7nUkFB-R&t*KoB8{l9^>) z{{ep7IIT6$$X6vq7n zYyuZDBM?)I*o#TLn@NMNf4slG_1J$^OYW0y45KpGKEUc*Q<5e?D^w?q;_Sg!XHEoi zVT1n|Ki74a>PN~b3s+Q_69t2$<;x||0m2x+03@~=WKi%d7`+bA79Ok|ieTCfrPyNG zecrKXudR@LOavc3{Fe}Yb>$qqeOX2e@{ekpfE>wht7gM=89ps#n|{uKoWgPNLk>!| zPKau>(-udCS|gruBh%t1(2LVh*y4DIL~D7agq(Yb#<2-KO%=aaqPFI0?;L3xuag+}7k#(Dts~`Ox(ni9)ZV$S9=v_)82||U zIST;cx5h#MecBw!?bTqi|1j!;m(=^cwucyaqJ^$(!4-%45ovQ4FxQ~OsFk0+6+rO5(e=!d|NucgQR zKuy8S397f}k*?F<0{~5>#m~FeBb7*BE}-TKk0C*|=>sXzL|XaA1L!6=q3{wv*W*V} z;5ZrB?u*4(9l|tEt$+U4(tYY2sSo^4B5lHk++AF?FMh=1ok=j#7WCw zECAUBMxlexp%bi59=$K#{)9Nuy4lQu6~^{X=`bCZ9MhwElr#C>hClIC3jk=ge4RHZ zEH9~&%NHfJ--9{lVgdwX3U2F2ei-d5=U5~GPll*gikoELEC^1OnXEq|{08)|BA8r; zWjU-JmJctE(^5*_(tOV8y9>xktgK3+MpCqO_Yu)XTeozASX6ge8HH^Y8e#0+x z-lKOgdsGhazU%|tsPQ}4%U~BulLKVXDmAA7uqGjlMQT8#jirbGF2+kEO1cQ@4b@O` z3-KfqSYB<>dD$PsA8b=kRSn3w+rvrSBxkN-bHc6sDcl-}b)$cn>JQ1b3oWmv2QY#- z$)*no-0kDZPZRk7>*JzS4oGnR0@j`}nuW24d)YsalIf@3Jk5s+{|K{*05f!FAM)*z z2M4%^LW>;zxPU08hIwUITRMR2_n1G;L&>Vd(hB`d17dCB0>~d&F)s__f>5?blBj`5 zoqCRSush>UVZ$KFzgzCVys=rm3TfqQCC-JPCsgl7E`I<&Xpi*Q4|r@hs@v>&0J0B& z3V7xTlLmV+_Z?xQupv*djNg_3aJBeXkcHclx7Vz_c$RltcWKEe{K70h<=k!Q46vT! zYE>e45Qx97rR}L5?Xyi$JPluE8uV}ifB~a+on)16%f-@_1hcqv>%Y%U_N15rfNRd3 zo3|v@`WSF|pa+f!k}bVf$~IXbuFxpEYja?NoWh4E4a%crW>8~w8;EGGP*BR|`d_^w zbMydCgr>xmU{_>@YcY`?PFf-^rRBbyd9}6jYD7m!p7^crrY|KK7--R!%OOTHq=9zH=Kp#!1Ct<9##iq0tWR&lTEmgFY zDWd?cw0AJh*1r2xvPxqX#++?gj)0N4NRRPaZY;X@R2f8#u`DE3Ody7tKwO=Mb%1RQ z;X|P($IL1|+DN7d1GqNgY1Z7UO6CM@4H`jXm(8xMBbMk`@^43IVP4uprC~iFN8#8f zkp%4>y5%VfRUSkqSy{SL(@^rgY!lsJ}HRno4k&Xk8S3} zEg*%oVfYL?fNh5yK(Zt|HxLy5d+J9yQJ{YPNb;n~V=0pKPW)tXV+S`cn}Nr$j|_u8 zTJ`c6e~d5(*rEeQZtS1OvJ=Le>FZooE}cSAhip+SUXP9R9Xzhw547!N~vQyhyqKaPQ&k+24fWn zz{_Xk88CPvhj{5gdVBUuNFxhGB+K%;YRM%Cn&bW>i*(Dfb8o?~&(33RVPNw`PdgPF z1dA_KJye{O#vjM0v3o24_~pey{LG}qQEHLwm=pD2a~^s~uc@g`M@|^2qwa&fZ)2fe zAfr`;asiac&c`2CR|a-_(_dF?BsYFfl;=-;=m=Uz8AwO^KqXPd?S#fRp3MxSTV6sV zMbz4I4l%t=PeXp#MTUrF2oCFQz*YR0I35#TAF z-78{KXjjuKviu1KRAJfxN+GD=)3&lW9_RzGSAM-4KVP25yQ4#PCfajdz5f_ouRVz3 zhJ>x?bHtMgiLAv)y6*&9RedpVdm=734L4aeFM(s@Ay~mrq1t$%Gv`$Vs1S1d*HKDh zeu4VY#Wj%I0Tc{6eF{miX`$Aj{lM<6K6xH&OUM1Cy1teL9tp6|zmJptCAy^cQv~do z!Q)O);>ZibskbhRkafO}h99zx(ErtB584_TN8@ItA8vHADquIqbl+337>QDcC`fPb zeJ4ANsP-Ihg-GO|uNHZWOH2nPzb+>=uo)6CZ(z9ht<)TJ^nt2~d6t1w0FXda!~ts9 z{^$7-&PJ)hO_7aYav505Y9Tb0wNu4azp#-~^l<49sjalA<(^NZ+2M|F*UY8_H1l+u ze}i43krHAbThlnP_m>qZmMCCKDEKK!HX+`&E)4J?YkX~jA3Q+6-`bSg1`G?Bqfgcc zfo*p?f)8F}_>ugzK7Jqp(g7Z5unwk;ybl>VGE?#qlo$@akxXI#Fr_u5OF0vJdAyPeo^wx$e0-*p)RdQKLHA& z>;N6D$jiA^%YaW5mDif0jm!P0*1r_DF+|F%+VC^5MTddjud%8JR`Bx)3dcAoEJlyffl`X`x*u8f3d?{A(-r%DWbyLKwcjj=l=`{dVgxJUCgDvsCK zbN@*w{{R*LnZGbk-)&gq!vy7Yy$U9RwvG2D8_z=CEV#rneG)oFnMi%^_Mh&NC%wtA6&jj4{ zNvTulOHULYT)Ry{l9VRdccF+;OHaw8y=)#&*-aDKkFg}Cp4rPj^U5D}_rd6sNh)IO zKBYJaZ!Rl7DyIA}0GqZ>Ej{D4jJn0DOp1Gc0s<@$E1|(qZuHXY#ba1?hrBzR1HN%n z)z88Ga<2|2)Z}+Z`E~udkk*YkOJM`nelzIJ;i6WAX39=1TUCq7-Zfsu8P~ZV4t|vo zfA+-DOdiMFkKMvgB=p&pGM@Em5kDb=nsOTRJht|gJc>|pv^`fR?hv<&D?&Oq=EQ}& zV!z0Z2$P3n_`t(hk7)HR+Xe1UBUJ>;XVW(>on9$SsPax`PlQ==F8msY*)YS8?ivN( z^u8bz1w)JkZ=$l*D`wME=iZNav;};*G8ghPbq?$G{cgNJ%=6^o)0Dv>rHqaFXfj>n zl-sYO2sQRn`L+8s3Y9Q_wnP<6PU$>{cl zmf5K#oi=|jIy^1t^*+p>*CMMFa1}$kzDq2n>|QQ+GMbEa+LH|tJEAR?>$Kd%S$xHi zA2a?^%~0w3E1iJr`6bRb$)r2HCa?1!o$8W0jM3ADC zsAt$R;Fq(jNEIWyUO1XQt<2C0?v6)P3&d2%ykbDX2<72w8B(vRNRd|>xBIDVU+)vw zBGKm=aQi7;XzBD)=vAu?Z9hOn^6*mX;JDJVU<)L$ownmXe{4~Bay?3%Mn%>u~AS0D=IabxtZ$&o;Kqu=s)|* zeJT+vX#Wv@F^&-`&7P=Y#nl2#VSi6D(<;0cjq2_f3#Rc%PXB%3yUEw~n%PCo+|+T4 z+-GMPT5*)Z*y`52)pj`qEBc+u-8on~~q>iHwAuMrpSRkW7Vx!7T=#IP; z8P?dfd-B(q$#|_1S?QO^m=CYXD-)t;;~-P%BqAlTOFOj?rNTjcxng)U3QNYB;m5Zq z{0|^?aBlx~ZN`cWO#!o;Q30$J@r^ZS=}?g+9T%B_vo`i zW!~wYx~1BvN40){f#}h-ACkhab{)91GAII_qZF;f0q+vJj$+j_+IvmBrY%qSHR+ZnWt5HL0V@HPy#u)af^sxh45W{JVTgzq0L#O-oTG(*+D}7Y^bk z&>ihXZ$&LuD|%)RihE0uuEIRtIwN~0%+Ex--&EFi0A_)bzha;8Z1Yn$b8Q}9AHzey zvc`a>xHpWP0`;U`Ab%i!I8otQIK1j~c290+YV?BxjMR>V+ik^tLdSFaV@^!~7{(2e zo56GQoL$J?QREb>LD7pJDw7;tC-tbs0X}jTfz0a+bF*)Q=Mw8T9HlBDeH_%k7o{u_ zB^O^d-DT=vYSm8~zoM$L3e``YJ}`-v+7=+k2`Wqy-(8Rn8WuSGvM6tfl}b=ZHykVv({pdkam=V<*30?%RWamXuxKy zo||$D#Pob4#`){Zyy+#?r6h*5XWDL9XP;?dM&Ez3$YS1N{ZRt{q#1g|rI{4_NH*9` z#1SUaV?X@{ELQ|J)|^#ZjS1CT z_xAF)y9psbAFF$tn6l;mEMX(xSZ0WpSgp3#JsJca3uSYBe*60~qB~s|gWQU8RE0+< z-@3AwhFzrvE-y8)D=xJGRFFmOxx-$Xj2=GHomZZOtP6+|L_FWF_!S#DxTWa!o-Uic zw+K0MZg$^e&hF=q<3O#&p&ufn2S)ml)+OlK@Y$J11KW-h}x0Qxrix z^WN~1K>5guF@FokwbCsPv-w}00j-ebrgU<2sKNRqcosC379}xY7E7nlSMK#R8qmyF zJ+Wy)YCtx)+$xPg&HF4jrVd4Kumv8-F2J)lgGXY&M?}(BSwGhUPk9DG167OyjT+j; z9rK>0@i6tR;boKOKax7e+IaF}Ie&D*=HhTx;OD6+H}Hg6;!wOc+;RCF(R#)#FX4vj zE3DU5FUUO4iH3QbAN@WgJz{UXa#gO#@Ai~N$c4O_I`vPq6!9fx$KqO13%bJujBu?l zgf&DA!(MNV6~Fcj-`KNOSHV%$Z%T|{|0ogBops>el1z=$8}UiwB2@?-TmxEpoX&h- zTcjQs#wfZQeUv(MNf1#|(acYfhaU-RN^7&7o@6Es^cW<|id%{w2HdeLg5d>Hsqs8) z8uR)rBlGVnO7;;MJa3Unv3^{!H;6l*t~cI~-12|+uoc&6i6<)i4L@SpzeK)o;vrgf z@*{Kxu8TZaXnm;3mdJ_B12^f#kGfqh7u{^{Ko!TTW4Ja-r^M%N?YdsvwQ~&-TY1NG z_(ERBAvbbws<}U1Qmy|EsU?;Ai2JZDF+7(C6_5@?RXnRO=rQR%Pfo-QFmzbj6bB&$ z%@27t`2@_7v)+W*MqFb^>{fEyB6w|r8$na`JQC7rb}i~>p_~HzJ5S_&B6lG?KApl6 z!M7ob7W?}%&&S-fr_$*3^~zwEZ41Gl(p&+!ExB6s^j{L8B53Nbi1cS(Hhv`*&s&y? zWgK#LvM_k9D#;<5Rnenv4~Ng^T+VrUSo2(<-)SLz>Pc*VlQD(TF+MNzsgihYqQYRy zxA)A*3|WU8J3y3S%DMCG&|uEM+_&!@aZM7%bd0QI(s`L;~WDrmOqAE{lQ74xd` zMN(=nMbW8)1{~B^gmZ3YlZjs02@2Kx-U#wJl3LGfz808?401bFKsWhL8cLxOUY`zz z=v%m4`lR-5$~%fYiOiCbS39(wk-ED)T}$f9bx;wcA2i{60=zf&%PooO%HB#beC1@aceXQB zGnjw7lqdJumBPTBM$$Xzh~mDG^C;OB!~KJe+=Uwg-d+#4z9|Q&-X1^=F=nS&65+xy z?oG-Z*^<0!{X|u!jky;74&Ll%Az`q$<}h*1^Trw>ZC9t9!@3i^lvDK_Hv}01vA#JF zXhmoAEI-3_FT`%u$G@9RJ=$cgSFf@zSzZhD^6|>EPSoJroDFiCF?U2HAUc95b&_0u zIb$5)A>N96Ox;c#EqVHd3SVlnz}rn8k6;~K(JV`jr9@tHq-}JUaqM@+h<8xKL#I;J z+C_KbsyFIGYiVQhD;^vIyf|b5UK~2nfR6$LBn^uC7R{;|tGNnZ#!~Z#l!*sjCvHLrbU^y&K>G9ogvbCLf*mK4e|Ckqt)2xJ1}|(8=v9(b9e;<-wL36LJZNRq6ug@y zsRmXx0ET|Uv*RWk--8@|hMnH?0mbh45JK~4=Fu7T44F&trIJ~$(>eAJ!ZwV_YNQ{i z!d|3BL&{blwU8tN-o7IKWP}dEElD1bilaX_zj(%%9Q}SmE)IT&)`<^@PKaspR0)jd zC9EVSH^x;+R)O8h29#&z+Z3w3KkxjjGvq0fK4t#A8rh=y*{2iHuP{G3iqchSiWm+6 zityv1I{qiX#o{1lLHZ8C30X~^rnhhs3tj{gMYABj-2*kWc%pVS4X+2vuHt@y|NX86 z`6Jnl*a_+(wb|D~xjqh~P~Gwtm3hR1cLlFR(fdNmDyM|O+a2*tHQz>z~4{8|nRH0r3yt^03QIHuHs)9z%*y2_2$>$n!WiCO=)Z^H}`3`aS{{Hk( z<}H4fz<*$i6Yv?$y##Jtzm^-3xDLZTN5SZ(@73}-0QtZIYiVedtyZ(mEjxyetc# NOe~EbpY^)_{{X)gX8ZsE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..631db6e9a9ab19037734fb22d14ec9fb469483f0 GIT binary patch literal 13247 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 Qj3z_)gZAd-2MA~W7s;GBIRF3v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..79d7d91b03bfd02a16b927ef9ae3f8d72d117c17 GIT binary patch literal 7126 zcmeHLdpwkB-@ooLGGt?DD_fYRL9N3KB4p$+m2rqoF_jpHG2}4Q%s35lcSMxB$)SXv z?MzXgQfaw&ZL8Umo~M+eDAUO*ha#tVuA!~%-RJQ6ynjCL`-hMDUBC0;`d-&{XLzq# z`IXu?Y5;(**siSA01)6$0w7I;|K26s9RWZ&AYu(i!twO53lT({1%(QNd1eXGLdXZe zep!MrC?t|6p#<~z5iv|^PfZ<_5)sOz`dN80JcTa2@Ca9_i030+wI)Ow8DbksUFM)> zpI`?KMDrv;l!WN07_nUflR9bF4*pgw)2Wn65lJMI%JKB3xClf%ij|p_8H4JeMzI%# zhS{xVt@x-6DJC^sA`#lr>GARLX7QG00ui5XZfk2xXIRiJEKH$r45|m&XBVM2| zF=d0r6NiW*gpvqB3`JoxC|D3HVN$7(Px)v(G~|(sF1BofpN6=EX?FbaOLv z`XA+a2@#*8i4lJ+7c3lI!GYq8FT^DgVgEv0!TFQ8FkB!Jh{FZKPeFa*;uHH%im=@O zTqPk$_|JqrJ^#IUbo3W#h$YM8VBtPC;ft7lcOYJqDCE&s^TdK!Q3!8&9IW1g$-*h> zV&@%^z>DIrB4AO)up3OOmAS?L;I8{uTo*xLM7p! zt$k*A@M8FX;wYkvidBrm+L#EK&r}GLN}SI;CUuDg{SS`T{ZrPbUf4~>=P3w<;aN_G z_nF0Zaq$)j!Xly|PrQ1iGljj}#oFA~+S=5@Y{~;{oE_AS2uoxINuZPk!(y>1V~MGS z)f#h4yT#UaRtnON!I-o>DGm2eXpki6Kglb00mVKv#7+_+iQ-LCQ@g?`N-{-Fkx>zf ztzZ`w6vJmy6HG&SVL`D`5~_nFh%c7JhDHbi74Zi~ga~59_H@NIii-GXZIb%koIU*u zU-mFoPft772(d&UO8kR+AKvCK-B0dD3T1M}?1DlR?O{@dB0*?u2rqOpZJ5XuS1bsV z#0QCZPJB3COsZ2@SOjcjBE?&=ZzyoDGR&;M)QAt~L2LiIs`Njm@_T6Z^gjhZmH8h4 z1?o+$L7;+&NB;zQkoW|eycmd-A_$4W?R%O5K(?EV^eB`3|aI+nqY zD&)U#gEqVV)s417GulLJvDzl`f}u^=4R_b4oAW6@`c{5DlQlPR=eY7iwR_u27jENh zFn$ArBkVk?ZE*aXhwr)* za<2?-&TiaY?NH|XK|HbEof>hHD^|-seK0_?ShIM&armqrQvAt6j`NU5OW6(OojoSZ zfM>Ha@2nj^&&gil%oWV`{d=9wcxH1}v36d5r;L}`)s#}6|5wAe;wr7ApKHw1H4W2$ zqJ`|36O57z(|XCJfxdTT@BRAuoCE%A!}e&|86TB-{XeS3ZJjfUI3#Qfp)<6ZOB>oRNC-mzEdc!<6&J2D^#yIn^q?)n`!xbLT~6VY}no)ff27MvLqnB^ztIz8M@m;&rPguMY9+=C>~m zUlaw;HdUePIxzs#wH1E|I9N0XfSLQ*ET=UIMXw*FzT_otZ0Nh+UEbbvwr;)X!#T^4 ziG^;)>&%y3q+Aa?$$PquwDsq_nNIIku9*LeEqa%t6gfvZoIEeFMzTO!Vk`@k?*N>`+u2*pWK5mM&y>mr-qG~aN$7z~N2F+BLNu1@ z*`L<;RAcnjCgNE50EH1t;My)ROr*$?a@qpyr%UD&xXMKQaf$%bFdRPCx`YAc+RzUU0ExUj5s;oKS%lQmI1V#y`$`GM$ZX zCL@*Z1n!FfOzw9&4V-l+;$|fDuj&I@`M3Rd~Z5vL6FKHj0`T< zNl9Ek7Km~IQ9WO?i~X3y%1A1hr3qSVs-%G1!~*_lfYa@`OMe784vlJSg3L$z;BCPCpBYcxRj zT(V;%{C()O3K%>WSpiTwUMl109y2^=y!0CM^_RV>!EIH z23P_8b}u92FQL?cCX%%qD(RE)a0RJ_WNm}wGP0vB0=!*_-fjTgB_p|H04}hA0TDPd z$@oK<&Sp)d`~U!RG$08TVH?YLLK51kh2A=8gcd*O?J5h5t^gqG5Q!TIrTVmy@*)5V zrjcZ10FN9JcM;UR!U9P!>EoWnN+U>)us{tYpF0sNVUf8G zEU;8TE+j=tb|vv<0RUVwnc+hK=<9S;2L{u1-?{UC=n~!V@}*y$IMd0DU;^Nra7jvT>ts9B!KJxm z#%jgV57QiRim8pPijTlhsuSXLY5cWKDoyF%|3MNxs&~%`8WA7)7fE(qVyLO-G%fMzy~G z({uWs{X5rw_0z(1l&V;|Z`#+=kTdq+^t7PGEXS=%%}QBqzF2*;3$C6Lh)uM0hLjwT z-(eSDys~*t#ss^+OPw!m;okz_>pa`(y*p`w4b7CS-Ihos4WMfAX2V0(M#F92P79K- z@Pi~!`ct;iWnM-@s;P`=d~jzU8HuDoIS65m*k}SncEXo29Zu(9ApoaMl5{$Pu|U`U zJzwAa$tD9$ffm25Sk<3v@V@T}t?uf!b#v(CN-rYtes+qzC02QKcss4f|HmzgRgGi~ zJh&F(z=J`9&r`3dnuvah={`?L^aDqQz+6SeOi94Ms4hbC<0!oH}%KBz}R1k;ZN9$J9$02|n0#<2HIHm5>LMJ(s}7N&@;ECT!~O&qz(aHp;VS z;d-io>oLAircselGLb6}$Cellckh1RXp@dIVHXSRZDmQAebTkjAByA~*#QMl{*I@f zNtn2+jnqJA=MrPEI=AzeHAZXqJ$a75;0;;*nA)0Fx807)ot>=R5nQXP*=3(A=il#r zf0ZHo&0@^vZ@PWIgwIG0>6if_l?d*R4A2CDo$c?tX%&q^{hy9RUVITcbS3Dx3lUv#8lZSTEl6Tt&6k) zK2vv9{}c6g?T)ap79|9DG&kyO>3$K4<x7xv>4xeBx;gSPy~SXk<+ zej}YNUVsBPzF6df)nLir$~NTKe;+q*4t+_J$@4=X=%Jb6uQ z4H)zZY(DYvVR`4ZD#t}gWdT5kN3v->?)|Qq_8122UhPf|+3wIV{BR^-Jq87<&VDh-R4WSL)1;Ju54S>@52zWqYHe? zD^8^Un=WcN6(xaM+N+<`A8GBk-DZ(g?U>qZVyX zG;X%nv)@$cZIh~;R+)483@xoJt%d)>a%?VrjRv8-aRpXYII)u0WN5pZ`7_5>36~}G zrNx-hY}Yc07l`0QnF?^@j5ex*dBjqq$U0jDJv32jgxoFyc>Jh~*S#&`|I~WnNhv7Z zaiP5kbc7E8$Bd9>C2x1oK>^Hlf3%Bd(B{G$SmcHMimTUMEzovQ{s!KjE=a|@E@=2S z_+mG{x|q4yk|Nn`Qh%_*;Z1o+>-xWmt=(^deKeIE``pkfsU|ltI*jw1N}0-$V$gAV zwm~9=3Z8GWtpBF&2dQSkHnV(i_R+1MpMioyuugFUeb)?t;rAiH|CTVN_wkSfcsyEx zRUd7#N<}KCf3DX;i38lsCn_G?I?N|0y)tgCXMfCJ_BUF?*(f8dZM_}Yizdw8u}M zPXsx)7lB(pXD++>C`@`-RTE4fnhDsVMY4@-y)r#wi(fX6^IQIDMPn)KH#dZ7%NVH| zMv7~Bm+nbDuKXf4@2p;M$>FA6!BuZ2snU8b`&QVvKH2_|+;u=Rb$0Z#bRnpgVh5n#H!A^y zy=yfbO%ZfEco+JGT_Y@NjRsQQyaMZNc7dZWjKpfn(!jm0MxA>OLfs8)vn4HNzciw^ zuboqmtR1;jwKqC)dD8ZkJ70+#vzNzdZEl#I5AkC!BDAd^_%W&T4#J3?%YKYG+#gfM&E|co z*I#ZA(@tPTwP8#B?8ueZkEr(yR>U;i8I75!V((ouc@Ug?15W}Tfr~6b2>(Se|A@l` z-^V$~WN=$YX&Bi~I1SOgel3O?HeHZ;)87KrCin(3!x~lZ~Zarq}C^<73PsIsCqGGQL!b?r(Jy zU6Q7!#5vU0^*Ikv@P|<}+e5>ttlp2j-cZ!FRClT=rf+ z4JT6sKf``=R*%3bTf*mz;;~T%m)p2CbCxJM&u*|NAMWbBWq0w+*GztuTpjLghRlU+ zU8Xp)!ggKQQYqE!QUzzF-(f0L0{5)7WqDs~8N58b6sdMReC^>s1HEjU7ZmNMALviu zTG&m)pQ+zh>Dl9@g5Qm%CAyFJznu+ggrDo>3{PV>(a6Eq{;ya#HO=B`Yuy!Hub!F@ z9}hZJ^x72_cxO{ZmD`A<|G&umZ*OmvEfetT1zv2si%tQvM&auabU%P-m<7+m?n$aJ zWQYLolM{*H_zm-9_%)@)cT(s<7xIe-Fd58-&{@gXKc&aLIb)3mL!@FcQy$&C9aI`; z8`>7hMFSKF-gt0+&eaMj3BzkKG5>G2Hs4#_vh7AKheSmi*x1sX-yS+L{xWJ zg7tWb&Pn%~i>Gmu+FeEQ1Dc?_MbMBraj^(#DO6%RyxppJBCQ^v z_{;}!J$dTym9CabH33E|xhZ@!aie*=M@AB4xU;ud)His5lLN146&bT9HfqDKSB2^q zhTp?17Z1j>SB2UbR=LLz+G;9{68$iK=#T~pAGvBNYoZOecR3w*112x})R!nb6v@*t z`>MEk=z}Dh><~5jm2WKjfRJZK184&gx2-ugV%KSUN(`P0KPrXX%$`~ zKIUhTxaqYG$Qfv+>EGx_;q1fU>iCbBbcz4!zk04f9kVUYz zjrCkBCJJT{j;94`F~dm_R1_1fJ;N6Tf6KR#+FCOxv@o=`w~L#WEjbdeWny4rV5n_9 zSIa6gI0UuR&fzmLyh3Y-(r6SE63JjN3>d}+C1wT)(oJzNP=P1osL_yoa}8)jVw$}^*0!NIsv{o6iiDD*=H9>gRwRk zZrG@AXr{lx*u%&SWoU{rGKZHaL&F))GuUwa1P9Ur|0jHT6lhrm2cc+08VNtM`Vrq|(Td`@TBf9l!V6<7c8#OKZl*P=P`6>p^Q%BFVw@AbjwQ z+OQ&@pj2`QjS(1$-%Nnr3$495B!mcG1PkV5pf-@fSOUi>6Uy>$10?w3?a7xT0Z%G3Hv7ZP##2*|1fFDoL zPn0<;ZeDblZ@2cY^5RPYfqS>!_+^d5qVV-gHqPC%SZ89-$r|UaulA;D-SsM1_PyOo z+##t_>)hwRUik4>Z{KyTq2@hHR%?fxdNDaxVp%!3zU8uy31Opnbtq37Rn#0IG zCn)6H1@4(LX)So$UE6#O8{BbtC4RmO%NVWUwSI_^&@-&)WFG2y`eio zy}pEfzs%^!BHbhR)&w0~zB_L5nZq9zU%+`ido+&uNbvrrWFT~cWEe11ZO zXOoq3S8Ln|@9YnqhZ0ir&%qT+2VSD!Ijgi;b$FZNh&eA9zC6}RHT!ctpV+)XV+YamMT2xn*8 z4&Uz03C4y>S$pUXf?wv;ui5ms{vY?`>`*0cuCi+?NF! zZPf6E*Ukj#v;>Ov%<5Smng=KaEGN-ph|SBY8@&YzRHrwKt_u(Il0EgZVgx@a3d}q^ zg-=HLJl4H~$x5K@RhSUD@!VmMy(%)@wzbN>ZW8RPzO(fSM7(9G0Z1S_-btiW|zccH@k!J&pi==WKGYo|0Ep(;us_ ziXU8g8F?D)6a0n1?`*Y4EWg`bA)|EwIb;gdxiOc--B3}x^>Sdytx>Cgi1U;rNA2C; z715zEu}&Dvi+Jft1Oj#I9UPn}mhrLDQUxUESYtWWRl-@IwyT-T-_g#vsQI$b7szh2 zUR?E<1-1?L360h2TWq-JXT@o9r7x~}!Kkl7D_6}T@4KN{`otG>`g7K(P9p@^kDElt zAb!S>vW7meDl08!O+U0pfTpst(u*kRR@q22qANi3UWFl61Y?<<+3CLLSranz&KyZ| z=A@zkJKn6u5}Prv7=z^em@@J6pVldWkl|P^nK!ua1J1f}MO^HOcMIW*@mb4v*D+ZW#ikSU&skO4?H|tvb*EtS)y8W&6)0 zD1b}`w|iGy8FaB3MB~od01{78rM!X_3Yc%FivcheCtzd8^yBnQ{gxLrA(a)tSo#!C z4}(LqXLqq1rx5{d1LTSH3_f#S*RmR+0;ug>W^zY~VlVnTRG4r|5!7}!@bvt>%C+h= z!Sh#z!g&U~yj`VBK!6tp>$M77Iv4&rtOS5}MrEp}tN+Zp9st4I?*J&+;@XaBZ~kDi z-+ihQs)ydpPcB}0m`5lv8$38s4B1ug>L{V;1A;#meKpXAbMIbp|9y#yr1yT~A*dq$xJ!CFN*rdklaXcI4qPt==nWx$RGF4?@Z9}egYmw`-WDJr-&eIh5 zOsS0(C71N4i))0&ndhN3W1jgDy*X@cFzuBlJ0{y=!0UK1?b`TSSkpMMf1oIzwXagc zY`j8TU(Xq;!_seEEQz22=7rL|my}Mo?T6g!#@9$TMLe2MDyjCeX^NhLt_>ug=*bFxU-|``GZ?7=HNcDE_Kh_UWsrZq zE*6>^f;)`IE0DcV=Dx^|MyqigXz@rj>p$e)Ux;ZiPqs+h=f%3QAB%o_T=ho2`0@kJ zZfiF*quvRSvU^OVRpGJE#?t*cbe(HWr!^BNpd&%yCz}#&O2$3;)*nv;_R}8jc(T{n zq(2bME;6g%22M9iR0setvope4bj}=0y{@m7s z`Jhc*cMO&dSY~Pt2u>%^>f}nV73CJrUOBbp z=(y)$2MwxIJ`a0`wixucI&+&1{O=!G;5np86%;B8o#n1!jF}?;75Ui|*eOaDKMfcr=?*D3nK6yY$<-SKXrx;{FcQfY$ zlkcUC#>!xQ-($#=kR)t-*S1yx^sN4p3gC*H`!~4cu3Ph;ZqWi>4G;0GcgQ7d_c?!V z!ie#nLieY0=s|R0(OY16qKxYc3$%T`Z@rnhH@Xa#)n544-MXvqXp*a!W9Is31!Z2^ z$}Nw-4~ryKa!t3XrJ=pz~oNnB##nP{|pU|z+JeKCt14wfnVOWBs~CXiN^qi zP?=tVnK5juBBE=cDfe!FUf!S+0HXrttbZ>gcB9c0&y0{R{-nQg2UvL%ZV-5tEZQDsd6nH)X7WF07=un))FP^_5uA4JEWi=lsq6H>Gm;6N=1 z51hr^Tdw4@SX^nr6J%U?+2(^=f%=8#cDgGPy$qptVypH^`@ zyv~!(nk)vOo(%g-X`n0rHp;xy!(s20i>ccivledio=_3466y=Xg#*H#mD1IwkGkV| zKj-I1sC!u=+h7m31{_70}Wd3C{~39o3|kEWk8-He<}ZF#5g=z|z~i(yOpQ z_lw>}A2=YqZrPlI_3*cTtC*hNI}uC|etb`lQc!w(^_6Xfy4CN}<_glOxx}%K6ru0R zv5E3GTj^m9qmPW3Tcw zqU*8>d&1PB^$40>n~aFLSQ>PH*z|56bYR9OIN8SektGjTsgy^3A2d6z%|SgZtAPDg!2T*=@<#H{mAStP*#B_>dncN>IuEo2j6GcO z9T;Ij^0us?(pj}Y7jECIZX@a&G%R!w0a%Q|SDjq35QiKffzjTQzySWmLOQ!@O#_e7Fet=Y1;#NqhNcHdZ9MNaLzV^D({InlxY1H;8^$X-2X zFuYZ-uMcvJ)CJy$IV$XqXH!tQ{Gqas@jCBm_5rk8yZMV}J@gA=k0u+Z1`W+@4a=HZ zaB8>(BAg}T%8R>aliIrMRnSpEih@3=1?AVRnJ29l^4ii8`_Bj+(_~KKuGgyD6^bW`cBiQ2%c$~YUWVKdt!H2USo~l8=xTK5~#72*W&rC zvH1cF&-WBh8JxbWr^b@nR_IJt);xp9Wp9uy$pQYQ@fAWcMmYotBedo~ZK#o9nQ{hV zq8-vp&7K2JzjPhFz?Sv1TV&C{RQkkK*ZC|4hB<65nS85@&+1(f$F<4rFecao!JCez z17~A7ui-7ehh)3x8dW~Gglbgsr9(4=!yYNWgTfG`*13HT46kWIi>)EqRs!HI@Zw`v zohi}=0Xz9S&?VhV!NTDWVW1x6!@<$V56WkklTA>j#>L6zNySaVVR(qEto!3!aJpVm z1wB168Pm08Tq@f_$B9ZD*2CM+7h-HAp*966NZIYH-SwLt9e;!sc|OH6!}cDoXDsfj z^v6nm5v@wyDObhS6B%L7mSt-UsKqIl<%NrY!vX%cKlv%^AU*?6=$HZ3+1GzW}nD B5_$jt literal 0 HcmV?d00001 diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index d1974343a..47b9721b4 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,6 @@ class MediaType(str, Enum): MATERIAL: str = "material" MODEL: str = "model" PACKAGE: str = "package" - PHOTOSHOP: str = "photoshop" PLAINTEXT: str = "plaintext" PRESENTATION: str = "presentation" PROGRAM: str = "program" @@ -67,6 +69,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 +84,10 @@ class MediaCategories: ".tgz", ".zip", } + _AUDIO_MIDI_SET: set[str] = { + ".mid", + ".midi", + } _AUDIO_SET: set[str] = { ".aac", ".aif", @@ -182,6 +194,7 @@ class MediaCategories: ".jpg_large", ".jpg", ".jpg2", + ".jxl", ".png", ".psb", ".psd", @@ -193,11 +206,6 @@ class MediaCategories: _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", - } _PLAINTEXT_SET: set[str] = { ".bat", ".css", @@ -247,14 +255,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,11 +340,6 @@ class MediaCategories: extensions=_PACKAGE_SET, is_iana=False, ) - PHOTOSHOP_TYPES: MediaCategory = MediaCategory( - media_type=MediaType.PHOTOSHOP, - extensions=_PHOTOSHOP_SET, - is_iana=False, - ) PLAINTEXT_TYPES: MediaCategory = MediaCategory( media_type=MediaType.PLAINTEXT, extensions=_PLAINTEXT_SET, @@ -359,7 +377,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 +394,6 @@ class MediaCategories: MATERIAL_TYPES, MODEL_TYPES, PACKAGE_TYPES, - PHOTOSHOP_TYPES, PLAINTEXT_TYPES, PRESENTATION_TYPES, PROGRAM_TYPES, diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 9f3d3e49c..b27b7e36e 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -19,8 +19,48 @@ "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" + }, + "document": { + "path": "qt/images/file_icons/document.png", + "mode": "pil" + }, "file_generic": { - "path": "qt/images/file_icons/generic.png", + "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" + }, + "material": { + "path": "qt/images/file_icons/material.png", + "mode": "pil" + }, + "model": { + "path": "qt/images/file_icons/model.png", + "mode": "pil" + }, + "text": { + "path": "qt/images/file_icons/text.png", + "mode": "pil" + }, + "video": { + "path": "qt/images/file_icons/video.png", "mode": "pil" } } diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index a03c1ae55..5cd1ea23b 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -363,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/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index c08f0291b..bb352c5ab 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -205,6 +205,10 @@ def _render_icon( # Get icon by name icon: Image.Image = ThumbRenderer.rm.get(name) + if not icon: + icon = ThumbRenderer.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( @@ -268,8 +272,29 @@ def _apply_overlay_color(image: Image.Image, color: str) -> Image.Image: return bg @staticmethod - def get_mime_icon_resource(ext: str = "") -> str: - pass + def get_icon_resource(url: Path) -> str: + """Return the name of the icon resource to use for a file type. + + Args: + url (Path): The file url to assess. + """ + 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 render( self, @@ -541,8 +566,8 @@ def render( if update_on_ratio_change: self.updated_ratio.emit(1) final = ThumbRenderer._get_icon( - # name=ThumbRenderer.get_mime_icon_resource(_filepath.suffix.lower()), - name="file_generic", + name=ThumbRenderer.get_icon_resource(_filepath), + # name="file_generic", color="", size=(adj_size, adj_size), pixel_ratio=pixel_ratio, From 447b5e6894f0c80eb4453e8739ede6ef057913e6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 20 Aug 2024 23:37:19 -0700 Subject: [PATCH 34/47] feat(ui): add more default media types and icons Add additional default icons for: - Blender - Presentation - Program - Spreadsheet Add/expand additional media types: - PDF - Packages --- .../qt/images/file_icons/document.png | Bin 8832 -> 9200 bytes .../qt/images/file_icons/presentation.png | Bin 0 -> 11326 bytes .../qt/images/file_icons/program.png | Bin 0 -> 6748 bytes .../qt/images/file_icons/spreadsheet.png | Bin 0 -> 5721 bytes tagstudio/src/core/media_types.py | 20 +- tagstudio/src/qt/resources.json | 16 + tagstudio/src/qt/widgets/thumb_renderer.py | 1034 ++++++++++------- 7 files changed, 644 insertions(+), 426 deletions(-) create mode 100644 tagstudio/resources/qt/images/file_icons/presentation.png create mode 100644 tagstudio/resources/qt/images/file_icons/program.png create mode 100644 tagstudio/resources/qt/images/file_icons/spreadsheet.png diff --git a/tagstudio/resources/qt/images/file_icons/document.png b/tagstudio/resources/qt/images/file_icons/document.png index dddf93b059110da283455dfccf4b1c8d4b1bddd8..a3dacb01efdab57f803bbe8ec4c9f911ca5f04e9 100644 GIT binary patch delta 7482 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 D_sM(q literal 8832 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 D#e&zb 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 0000000000000000000000000000000000000000..86a3b37c9eb6cee6ae219c1e09028d526b081d9a GIT binary patch literal 11326 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f7d64c1a18fa8a470ea01be63627f60c90ffa238 GIT binary patch literal 6748 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fb1dbeac285d357ab0ba845488832201b204d5a1 GIT binary patch literal 5721 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 literal 0 HcmV?d00001 diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index 47b9721b4..449aa8aee 100644 --- a/tagstudio/src/core/media_types.py +++ b/tagstudio/src/core/media_types.py @@ -30,6 +30,7 @@ class MediaType(str, Enum): MATERIAL: str = "material" MODEL: str = "model" PACKAGE: str = "package" + PDF: str = "pdf" PLAINTEXT: str = "plaintext" PRESENTATION: str = "presentation" PROGRAM: str = "program" @@ -205,7 +206,18 @@ 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"} + _PACKAGE_SET: set[str] = { + ".aab", + ".akp", + ".apk", + ".apkm", + ".apks", + ".pkg", + ".xapk", + } + _PDF_SET: set[str] = { + ".pdf", + } _PLAINTEXT_SET: set[str] = { ".bat", ".css", @@ -340,6 +352,11 @@ class MediaCategories: extensions=_PACKAGE_SET, is_iana=False, ) + PDF_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PDF, + extensions=_PDF_SET, + is_iana=False, + ) PLAINTEXT_TYPES: MediaCategory = MediaCategory( media_type=MediaType.PLAINTEXT, extensions=_PLAINTEXT_SET, @@ -394,6 +411,7 @@ class MediaCategories: MATERIAL_TYPES, MODEL_TYPES, PACKAGE_TYPES, + PDF_TYPES, PLAINTEXT_TYPES, PRESENTATION_TYPES, PROGRAM_TYPES, diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index b27b7e36e..967fe5af2 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -31,6 +31,10 @@ "path": "qt/images/file_icons/affinity_photo.png", "mode": "pil" }, + "blender": { + "path": "qt/images/file_icons/blender.png", + "mode": "pil" + }, "document": { "path": "qt/images/file_icons/document.png", "mode": "pil" @@ -55,6 +59,18 @@ "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" diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index bb352c5ab..ffa577edd 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,62 +5,54 @@ import logging import math +from io import BytesIO +from pathlib import Path + import cv2 -import rawpy import numpy as np -from pillow_heif import register_heif_opener, register_avif_opener +import rawpy +from mutagen import MutagenError, flac, id3, mp4 from PIL import ( Image, - UnidentifiedImageError, - ImageQt, + ImageChops, ImageDraw, + ImageEnhance, + ImageFile, ImageFont, ImageOps, - ImageFile, + ImageQt, + UnidentifiedImageError, ) -from io import BytesIO -from pathlib import Path from PIL.Image import DecompressionBombError +from pillow_heif import register_avif_opener, register_heif_opener from pydub import AudioSegment, exceptions -from mutagen import id3, flac, mp4, MutagenError -from PySide6.QtCore import Qt, QObject, Signal, QSize +from PySide6.QtCore import QObject, QSize, Qt, Signal from PySide6.QtGui import QGuiApplication, QPixmap -from src.qt.resource_manager import ResourceManager -from src.qt.helpers.color_overlay import theme_fg_overlay -from src.qt.helpers.gradient import four_corner_gradient_background -from src.qt.helpers.text_wrapper import wrap_full_text from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT -from src.core.media_types import MediaType, MediaCategories -from src.core.utils.encoding import detect_char_encoding +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_background +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): + """A class for rendering image and file thumbnails.""" + rm: ResourceManager = ResourceManager() updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - # Cached thumbnail elements. - # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) - thumb_masks: dict = {} - thumb_borders: dict = {} - - # Key: ("name", "color", 512, 512, 1.25) - icons: dict = {} - thumb_loading_512: Image.Image = Image.open( Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" ) @@ -73,42 +65,88 @@ class ThumbRenderer(QObject): math.floor(12 * font_pixel_ratio), ) - @staticmethod - def _get_mask(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + def __init__(self) -> None: + super().__init__() + + # Cached thumbnail elements. + # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) + 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) -> Image.Image: """ Returns a thumbnail mask given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ - item: Image.Image = ThumbRenderer.thumb_masks.get((*size, pixel_ratio)) + item: Image.Image = self.thumb_masks.get((*size, pixel_ratio)) if not item: - item = ThumbRenderer._render_mask(size, pixel_ratio) - ThumbRenderer.thumb_masks[(*size, pixel_ratio)] = item + item = self._render_mask(size, pixel_ratio) + self.thumb_masks[(*size, pixel_ratio)] = item return item - @staticmethod - def _get_hl_border(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + def _get_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: """ - Returns a thumbnail border given a size and pixel ratio. + Returns a thumbnail raised edge graphic given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ - item: Image.Image = ThumbRenderer.thumb_borders.get((*size, pixel_ratio)) + item: tuple[Image.Image, Image.Image] = self.raised_edges.get( + (*size, pixel_ratio) + ) if not item: - item = ThumbRenderer._render_hl_border(size, pixel_ratio) - ThumbRenderer.thumb_borders[(*size, pixel_ratio)] = item + item = self._render_edge(size, pixel_ratio) + self.raised_edges[(*size, pixel_ratio)] = item + else: + logging.info("using cached edge") return item - @staticmethod def _get_icon( - name: str, color: str, size: tuple[int, int], pixel_ratio: float + self, name: str, color: str, size: tuple[int, int], pixel_ratio: float = 1.0 ) -> Image.Image: - item: Image.Image = ThumbRenderer.icons.get((name, color, *size, pixel_ratio)) + """Retrieves a new or cached 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. + """ + item: Image.Image = self.icons.get((name, color, *size, pixel_ratio)) if not item: - item = ThumbRenderer._render_icon(name, color, size, pixel_ratio) - ThumbRenderer.thumb_borders[(name, *color, size, pixel_ratio)] = item + item = self._render_icon(name, color, size, pixel_ratio) + self.raised_edges[(name, *color, size, pixel_ratio)] = item return item - @staticmethod - def _render_mask(size: tuple[int, int], pixel_ratio) -> Image.Image: + def _render_mask(self, size: tuple[int, int], pixel_ratio) -> Image.Image: """Renders a thumbnail mask.""" smooth_factor: int = 2 radius_factor: int = 8 @@ -130,33 +168,97 @@ def _render_mask(size: tuple[int, int], pixel_ratio) -> Image.Image: ) return im - @staticmethod - def _render_hl_border(size: tuple[int, int], pixel_ratio) -> Image.Image: + def _render_edge( + self, size: tuple[int, int], pixel_ratio + ) -> tuple[Image.Image, Image.Image]: """Renders a thumbnail highlight border.""" + logging.info("rendering edge") smooth_factor: int = 2 radius_factor: int = 8 - im: Image.Image = Image.new( + 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) + draw = ImageDraw.Draw(im_hl) draw.rounded_rectangle( - (0, 0) + tuple([d - 1 for d in im.size]), - radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + (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=math.floor(pixel_ratio * 2), + width=width, ) - im = im.resize( + im_hl = im_hl.resize( size, resample=Image.Resampling.BILINEAR, ) - return im - @staticmethod + # 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, + ) + # sh_bg = sh_bg.resize( + # size, + # resample=Image.Resampling.BILINEAR, + # ) + + # Shadow + # sh_bg: Image.Image = Image.new( + # mode="RGBA", + # size=tuple([d * smooth_factor for d in size]), # type: ignore + # color="black", + # ) + # sh_inner_mask: Image.Image = Image.new( + # mode="RGBA", + # size=tuple([d * smooth_factor for d in size]), # type: ignore + # color="red", + # ) + # draw = ImageDraw.Draw(sh_inner_mask) + # draw.rounded_rectangle( + # (0, 0) + tuple([d - 1 for d in sh_bg.size]), + # radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + # fill="black", + # outline="red", + # width=width, + # ) + # sh_bg.putalpha(sh_inner_mask.getchannel(0)) + # # sh_bg = sh_bg.resize( + # # size, + # # resample=Image.Resampling.BILINEAR, + # # ) + + # alpha_mask: Image.Image = self._get_mask(sh_bg.size, pixel_ratio) + # im_sh = Image.new("RGBA", sh_bg.size, "#00000000") + # im_sh.paste(sh_bg, mask=alpha_mask.getchannel(0)) + + # im_sh = im_sh.resize( + # size, + # resample=Image.Resampling.BILINEAR, + # ) + + return (im_hl, im_sh) + def _render_icon( - name: str, color: str, size: tuple[int, int], pixel_ratio: float + self, name: str, color: str, size: tuple[int, int], pixel_ratio: float ) -> Image.Image: smooth_factor: int = math.ceil(2 * pixel_ratio) radius_factor: int = 8 @@ -180,7 +282,7 @@ def _render_icon( im.paste( bg, (0, 0), - mask=ThumbRenderer._get_mask( + mask=self._get_mask( tuple([d * smooth_factor for d in size]), # type: ignore (pixel_ratio * smooth_factor), ), @@ -204,9 +306,9 @@ def _render_icon( fg: Image.Image = Image.new("RGB", size=size, color="#00FF00") # Get icon by name - icon: Image.Image = ThumbRenderer.rm.get(name) + icon: Image.Image = self.rm.get(name) if not icon: - icon = ThumbRenderer.rm.get("file_generic") + icon = self.rm.get("file_generic") if not icon: icon = Image.new(mode="RGBA", size=(32, 32), color="magenta") @@ -228,15 +330,14 @@ def _render_icon( ) # Apply color overlay - im = ThumbRenderer._apply_overlay_color( + im = self._apply_overlay_color( im, color, ) return im - @staticmethod - def _apply_overlay_color(image: Image.Image, color: str) -> Image.Image: + def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: """Apply a gradient effect over an an image. Red channel for foreground, green channel for outline, none for background.""" bg_color: str = ( @@ -271,30 +372,414 @@ def _apply_overlay_color(image: Image.Image, color: str) -> Image.Image: return bg - @staticmethod - def get_icon_resource(url: Path) -> str: - """Return the name of the icon resource to use for a file type. + def _apply_edge(self, image: Image.Image, edge: tuple[Image.Image, Image.Image]): + """Apply a given edge effect to an image. Args: - url (Path): The file url to assess. + image (Image.Image): The image to apply the edge to. + edge (Image.Image): The edge image to apply. """ - ext = url.suffix.lower() - types: set[MediaType] = MediaCategories.get_types(ext, True) + logging.info("applying edge") + im: Image.Image = image + im_hl, im_sh = edge + + # Configure and apply a soft light overlay. + # This makes up the bulk of the effect. + # edge_soft = im_hl.copy() + im_hl.putalpha(ImageEnhance.Brightness(im_hl.getchannel(3)).enhance(0.75)) + im.paste(ImageChops.soft_light(im, im_hl), mask=im_hl.getchannel(3)) + + # Configure and apply a hard light overlay. + # This helps with contrast. + # edge_hard = im_sh.copy() + # edge_hard.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(0.75)) + im_sh.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(0.75)) + im.paste(im_sh, mask=im_sh.getchannel(3)) + # im.paste(edge_hard, mask=im_sh.getchannel(3)) - # 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 + return im - # 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 + def _audio_album_thumb(self, filepath: Path, ext: str) -> Image.Image | None: + """Gets an album cover from an audio file if one is present.""" + image: Image.Image = None + try: + if not filepath.is_file(): + raise FileNotFoundError - return "file_generic" + 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.""" + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + BASE_SCALE: int = 2 + size_scaled: int = size * BASE_SCALE + ALLOW_SMALL_MIN: bool = False + SAMPLES_PER_BAR: int = 3 + im: Image.Image = None + + try: + BARS: 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=BARS * SAMPLES_PER_BAR) + + BAR_MARGIN: float = ((size_scaled / (BARS * 3)) * BASE_SCALE) / 2 + LINE_WIDTH: float = ((size_scaled - BAR_MARGIN) / (BARS * 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: + 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.""" + 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.""" + # 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: + 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: + 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: + # TODO: Implement. + im: Image.Image = None + return im + + def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: + # TODO: Implement. + 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, size: int) -> Image.Image: + 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: + 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) + # else: + # im = self._get_icon( + # name="file_generic", + # color="red", + # size=(size, size), + # pixel_ratio=pixel_ratio, + # ) + 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, @@ -314,16 +799,6 @@ def render( final: Image.Image = None _filepath: Path = Path(filepath) resampling_method = Image.Resampling.BILINEAR - 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" - ) if ThumbRenderer.font_pixel_ratio != pixel_ratio: ThumbRenderer.font_pixel_ratio = pixel_ratio @@ -352,141 +827,39 @@ 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): - 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) - image = Image.fromarray(frame) - else: - image = ThumbRenderer._get_icon( - name="file_generic", - color="red", - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) - + 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=bg_color) - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=fg_color) - image = bg + image = self._text_thumb(_filepath, adj_size) # Fonts ======================================================== elif MediaType.FONT in MediaCategories.get_types(ext, True): if gradient: # Short (Aa) Preview - image = self._font_preview_short(_filepath, adj_size) + image = self._font_short_thumb(_filepath, adj_size) else: # Large (Full Alphabet) Preview - image = self._font_preview_long(_filepath, adj_size) + image = self._font_long_thumb(_filepath, adj_size) # Audio ======================================================== elif MediaType.AUDIO in MediaCategories.get_types(ext, True): - image = self._album_artwork(_filepath, ext) + image = self._audio_album_thumb(_filepath, ext) if image is None: - image = self._audio_waveform( + image = self._audio_waveform_thumb( _filepath, ext, adj_size, pixel_ratio ) if image is not None: - image = ThumbRenderer._apply_overlay_color(image, "green") - - # 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) + 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=bg_color) - 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 = self._blender(_filepath) # No Rendered Thumbnail ======================================== if not image: @@ -513,39 +886,46 @@ def render( ) image = image.resize((new_x, new_y), resample=resampling_method) if gradient: - mask: Image.Image = ThumbRenderer._get_mask( + mask: Image.Image = self._get_mask( (adj_size, adj_size), pixel_ratio ) - hl: Image.Image = ThumbRenderer._get_hl_border( + edge: tuple[Image.Image, Image.Image] = self._get_edge( (adj_size, adj_size), pixel_ratio ) - final = four_corner_gradient_background(image, adj_size, mask, hl) + final = self._apply_edge( + four_corner_gradient_background( + image, (adj_size, adj_size), mask + ), + edge, + ) else: scalar = 4 - rec: Image.Image = Image.new( - "RGB", - tuple([d * scalar for d in image.size]), # type: ignore - "black", - ) - draw = ImageDraw.Draw(rec) - draw.rounded_rectangle( - (0, 0) + tuple([d - 1 for d in 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, - ) + mask: Image.Image = self._get_mask(image.size, pixel_ratio) + # rec: Image.Image = Image.new( + # "RGB", + # tuple([d * scalar for d in image.size]), # type: ignore + # "black", + # ) + # draw = ImageDraw.Draw(rec) + # draw.rounded_rectangle( + # (0, 0) + tuple([d - 1 for d in 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 = Image.new("RGBA", image.size, (0, 0, 0, 0)) - final.paste(image, mask=rec.getchannel(0)) + 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__})" + 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._get_icon( + final = self._get_icon( name="broken_link_icon", color="red", size=(adj_size, adj_size), @@ -553,20 +933,16 @@ def render( ) except ( UnidentifiedImageError, - 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__})" + 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._get_icon( - name=ThumbRenderer.get_icon_resource(_filepath), + final = self._get_icon( + name=self._get_resource_id(_filepath), # name="file_generic", color="", size=(adj_size, adj_size), @@ -593,195 +969,3 @@ def render( self.updated.emit( timestamp, QPixmap(), QSize(*base_size), _filepath.suffix.lower() ) - - def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: - """Gets an album cover from an audio file if one is present.""" - 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( - self, filepath: Path, ext: str, size: int, pixel_ratio: float - ) -> Image.Image | None: - """Renders a waveform image from an audio file.""" - # BASE_SCALE used for drawing on a larger image and resampling down - # to provide an antialiased effect. - BASE_SCALE: int = 2 - size_scaled: int = size * BASE_SCALE - ALLOW_SMALL_MIN: bool = False - SAMPLES_PER_BAR: int = 3 - image: Image.Image = None - - try: - BARS: 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=BARS * SAMPLES_PER_BAR) - - BAR_MARGIN: float = ((size_scaled / (BARS * 3)) * BASE_SCALE) / 2 - LINE_WIDTH: float = ((size_scaled - BAR_MARGIN) / (BARS * 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) - - image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") - draw = ImageDraw.Draw(image) - - 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 - - image.resize((size, size), Image.Resampling.BILINEAR) - - except exceptions.CouldntDecodeError as e: - logging.error( - f"[ThumbRenderer]{ERROR}: Couldn't render waveform for {filepath.name} ({type(e).__name__})" - ) - return image - - def _font_preview_short(self, filepath: Path, size: int) -> Image.Image: - """Renders a small font preview ("Aa") thumbnail from a font file.""" - bg = Image.new("RGB", (size, size), color="#000000") - raw = Image.new("RGB", (size * 2, size * 2), 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)), - ) - return ThumbRenderer._apply_overlay_color(bg, "purple") - - def _font_preview_long(self, filepath: Path, size: int) -> Image.Image: - """Renders a large font preview ("Alphabet") thumbnail from a font file.""" - # Scale the sample font sizes to the preview image - # resolution,assuming the sizes are tuned for 256px. - 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] - return theme_fg_overlay(bg, use_alpha=False) From c070f84e7f260b6d52887662835ce44157bf4d34 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 20 Aug 2024 23:38:10 -0700 Subject: [PATCH 35/47] fix: remove leading dot in preview panel ext --- tagstudio/src/qt/widgets/preview_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 8f908227a..458724719 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -642,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})" ) From a244098f8e1cf630ac509028836318743df6cf7d Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 20 Aug 2024 23:44:03 -0700 Subject: [PATCH 36/47] refactor: remove edge from `four_corner_gradient()` --- tagstudio/src/qt/helpers/gradient.py | 38 ++++++++++++---------- tagstudio/src/qt/widgets/thumb_renderer.py | 7 ++-- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/tagstudio/src/qt/helpers/gradient.py b/tagstudio/src/qt/helpers/gradient.py index b76844a03..c109e5d5a 100644 --- a/tagstudio/src/qt/helpers/gradient.py +++ b/tagstudio/src/qt/helpers/gradient.py @@ -2,21 +2,21 @@ # 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): + if image.size != 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 = Image.new(mode='RGB',size=size,color=bg_col) # bg.thumbnail((1, 1)) - # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) + # bg = bg.resize(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) + # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize(size,resample=Image.Resampling.BILINEAR) # Four-Corner Gradient Background. # Not exactly a one-liner, but it's (subjectively) really cool. @@ -29,29 +29,31 @@ 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)) + + # bg.putalpha(mask) + # final = bg else: - image.putalpha(mask) - final = image + # 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/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index ffa577edd..c8d49ce7b 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -35,7 +35,7 @@ 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_background +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 @@ -119,6 +119,7 @@ def _get_edge( Returns a thumbnail raised edge graphic given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ + logging.info((*size, pixel_ratio)) item: tuple[Image.Image, Image.Image] = self.raised_edges.get( (*size, pixel_ratio) ) @@ -893,9 +894,7 @@ def render( (adj_size, adj_size), pixel_ratio ) final = self._apply_edge( - four_corner_gradient_background( - image, (adj_size, adj_size), mask - ), + four_corner_gradient(image, (adj_size, adj_size), mask), edge, ) else: From f91861d2fe356cd1c257d6f4552d147e56d42e60 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 20 Aug 2024 23:44:39 -0700 Subject: [PATCH 37/47] fix: handle missing files in `resource_manager` --- tagstudio/src/qt/resource_manager.py | 38 ++++++++++++++++------------ 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index 5d1d18510..3c4e80996 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -47,24 +47,30 @@ def get(self, id: str) -> Any: return cached_res else: res: dict = ResourceManager._map.get(id) - 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 + 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 and res.get("mode") == "pil": - data = Image.open( - Path(__file__).parents[2] / "resources" / res.get("path") + 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 data - elif res and res.get("mode") in ["qt"]: - # TODO: Qt resource loading logic - pass + return None def __getattr__(self, __name: str) -> Any: attr = self.get(__name) From e4f7055ca706f625421aa108039001c201b9d9f3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Wed, 21 Aug 2024 00:37:50 -0700 Subject: [PATCH 38/47] fix(ui): thumb edges fading on refresh --- tagstudio/src/qt/widgets/thumb_renderer.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index c8d49ce7b..277733990 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -3,6 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from copy import deepcopy import logging import math from io import BytesIO @@ -119,15 +120,13 @@ def _get_edge( Returns a thumbnail raised edge graphic given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ - logging.info((*size, 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 - else: - logging.info("using cached edge") return item def _get_icon( @@ -173,7 +172,6 @@ def _render_edge( self, size: tuple[int, int], pixel_ratio ) -> tuple[Image.Image, Image.Image]: """Renders a thumbnail highlight border.""" - logging.info("rendering edge") smooth_factor: int = 2 radius_factor: int = 8 width: int = math.floor(pixel_ratio * 2) @@ -380,9 +378,8 @@ def _apply_edge(self, image: Image.Image, edge: tuple[Image.Image, Image.Image]) image (Image.Image): The image to apply the edge to. edge (Image.Image): The edge image to apply. """ - logging.info("applying edge") im: Image.Image = image - im_hl, im_sh = edge + im_hl, im_sh = deepcopy(edge) # Configure and apply a soft light overlay. # This makes up the bulk of the effect. From 81dfb50b8f8ef9613fee6b9cbf5823364c9ba8a5 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Wed, 21 Aug 2024 12:28:28 -0700 Subject: [PATCH 39/47] feat(ui): add default icons for audio+vector thumbs --- .../resources/qt/images/file_icons/audio.png | Bin 0 -> 8860 bytes .../qt/images/file_icons/image_vector.png | Bin 0 -> 10640 bytes tagstudio/src/qt/resources.json | 8 ++++++++ 3 files changed, 8 insertions(+) create mode 100644 tagstudio/resources/qt/images/file_icons/audio.png create mode 100644 tagstudio/resources/qt/images/file_icons/image_vector.png 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 0000000000000000000000000000000000000000..9019de01abdacf77f7bdc7527a16539b63689cc5 GIT binary patch literal 8860 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f0e38a3d9c5c4ed1fdde54c3e3571cd8378e9661 GIT binary patch literal 10640 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 literal 0 HcmV?d00001 diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 967fe5af2..30e23f7b8 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -31,6 +31,10 @@ "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" @@ -51,6 +55,10 @@ "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" From a658fc4fe415e9696c79d78a5bae5d41c7be078b Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Wed, 21 Aug 2024 13:00:41 -0700 Subject: [PATCH 40/47] feat(ui): apply edge to default icon thumbs --- tagstudio/src/qt/widgets/thumb_renderer.py | 23 +++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index e8faf928d..aa3f3ba23 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -125,7 +125,6 @@ def _get_edge( ) if not item: item = self._render_edge(size, pixel_ratio) - self.raised_edges[(*size, pixel_ratio)] = item return item @@ -142,8 +141,10 @@ def _get_icon( """ item: Image.Image = self.icons.get((name, color, *size, pixel_ratio)) if not item: - item = self._render_icon(name, color, size, pixel_ratio) - self.raised_edges[(name, *color, size, pixel_ratio)] = item + item_flat: Image.Image = self._render_icon(name, color, size, pixel_ratio) + 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) -> Image.Image: @@ -259,6 +260,7 @@ def _render_edge( def _render_icon( self, name: str, color: str, size: tuple[int, int], pixel_ratio: float ) -> Image.Image: + border_factor: int = 12 smooth_factor: int = math.ceil(2 * pixel_ratio) radius_factor: int = 8 icon_ratio: float = 1.75 @@ -294,7 +296,7 @@ def _render_icon( radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), fill="black", outline="#FF0000", - width=math.floor(pixel_ratio * 8), + width=math.floor(pixel_ratio * border_factor), ) # Resize image to final size @@ -371,27 +373,34 @@ def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: return bg - def _apply_edge(self, image: Image.Image, edge: tuple[Image.Image, Image.Image]): + 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 (Image.Image): The edge image to apply. + faded (bool): Whether or not to apply a faded version of the edge. """ + opacity: float = 0.75 if not faded else 0.6 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. # edge_soft = im_hl.copy() - im_hl.putalpha(ImageEnhance.Brightness(im_hl.getchannel(3)).enhance(0.75)) + 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 hard light overlay. # This helps with contrast. # edge_hard = im_sh.copy() # edge_hard.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(0.75)) - im_sh.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(0.75)) + im_sh.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(opacity)) im.paste(im_sh, mask=im_sh.getchannel(3)) # im.paste(edge_hard, mask=im_sh.getchannel(3)) From ccf3d788d028c56626bb77b63cc4cfb9cc85410a Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Wed, 21 Aug 2024 13:15:55 -0700 Subject: [PATCH 41/47] chore: remove unused code --- tagstudio/src/qt/widgets/thumb_renderer.py | 67 +--------------------- 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index aa3f3ba23..9863d9e1b 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -3,9 +3,9 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from copy import deepcopy import logging import math +from copy import deepcopy from io import BytesIO from pathlib import Path @@ -216,44 +216,6 @@ def _render_edge( size, resample=Image.Resampling.BILINEAR, ) - # sh_bg = sh_bg.resize( - # size, - # resample=Image.Resampling.BILINEAR, - # ) - - # Shadow - # sh_bg: Image.Image = Image.new( - # mode="RGBA", - # size=tuple([d * smooth_factor for d in size]), # type: ignore - # color="black", - # ) - # sh_inner_mask: Image.Image = Image.new( - # mode="RGBA", - # size=tuple([d * smooth_factor for d in size]), # type: ignore - # color="red", - # ) - # draw = ImageDraw.Draw(sh_inner_mask) - # draw.rounded_rectangle( - # (0, 0) + tuple([d - 1 for d in sh_bg.size]), - # radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), - # fill="black", - # outline="red", - # width=width, - # ) - # sh_bg.putalpha(sh_inner_mask.getchannel(0)) - # # sh_bg = sh_bg.resize( - # # size, - # # resample=Image.Resampling.BILINEAR, - # # ) - - # alpha_mask: Image.Image = self._get_mask(sh_bg.size, pixel_ratio) - # im_sh = Image.new("RGBA", sh_bg.size, "#00000000") - # im_sh.paste(sh_bg, mask=alpha_mask.getchannel(0)) - - # im_sh = im_sh.resize( - # size, - # resample=Image.Resampling.BILINEAR, - # ) return (im_hl, im_sh) @@ -392,17 +354,13 @@ def _apply_edge( # Configure and apply a soft light overlay. # This makes up the bulk of the effect. - # edge_soft = im_hl.copy() 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 hard light overlay. # This helps with contrast. - # edge_hard = im_sh.copy() - # edge_hard.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(0.75)) im_sh.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(opacity)) im.paste(im_sh, mask=im_sh.getchannel(3)) - # im.paste(edge_hard, mask=im_sh.getchannel(3)) return im @@ -770,13 +728,6 @@ def _video_thumb(self, filepath: Path) -> Image.Image: video.set(cv2.CAP_PROP_POS_FRAMES, 0) frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) im = Image.fromarray(frame) - # else: - # im = self._get_icon( - # name="file_generic", - # color="red", - # size=(size, size), - # pixel_ratio=pixel_ratio, - # ) except ( UnidentifiedImageError, cv2.error, @@ -903,23 +854,7 @@ def render( edge, ) else: - scalar = 4 mask = self._get_mask(image.size, pixel_ratio) - # rec: Image.Image = Image.new( - # "RGB", - # tuple([d * scalar for d in image.size]), # type: ignore - # "black", - # ) - # draw = ImageDraw.Draw(rec) - # draw.rounded_rectangle( - # (0, 0) + tuple([d - 1 for d in 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 = Image.new("RGBA", image.size, (0, 0, 0, 0)) final.paste(image, mask=mask.getchannel(0)) From 148f792c344ad91ce07b82475ff2b55ef0f17753 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Wed, 21 Aug 2024 14:47:26 -0700 Subject: [PATCH 42/47] refactor(ui): move loading icon to `ResourceManager` --- .../resources/qt/images/thumb_loading.png | Bin 0 -> 18128 bytes .../resources/qt/images/thumb_loading_512.png | Bin 12467 -> 0 bytes tagstudio/src/qt/resources.json | 4 ++ tagstudio/src/qt/widgets/thumb_renderer.py | 43 ++++++++++-------- 4 files changed, 29 insertions(+), 18 deletions(-) create mode 100644 tagstudio/resources/qt/images/thumb_loading.png delete mode 100644 tagstudio/resources/qt/images/thumb_loading_512.png diff --git a/tagstudio/resources/qt/images/thumb_loading.png b/tagstudio/resources/qt/images/thumb_loading.png new file mode 100644 index 0000000000000000000000000000000000000000..174a1879d211a7a98963b903087326172ee79056 GIT binary patch literal 18128 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 diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 30e23f7b8..ef007b6f2 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -86,5 +86,9 @@ "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/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 9863d9e1b..f6df0bce0 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -54,12 +54,7 @@ class ThumbRenderer(QObject): updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - thumb_loading_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" - ) - thumb_loading_512.load() - - # TODO: Make dynamic font sized given different pixel ratios + # TODO: Make dynamic font sizes given different pixel ratios font_pixel_ratio: float = 1 ext_font = ImageFont.truetype( Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", @@ -220,9 +215,13 @@ def _render_edge( return (im_hl, im_sh) def _render_icon( - self, name: str, color: str, size: tuple[int, int], pixel_ratio: float + self, + name: str, + color: str, + size: tuple[int, int], + pixel_ratio: float, ) -> Image.Image: - border_factor: int = 12 + border_factor: int = 5 smooth_factor: int = math.ceil(2 * pixel_ratio) radius_factor: int = 8 icon_ratio: float = 1.75 @@ -255,10 +254,14 @@ def _render_icon( 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=math.ceil( + (radius_factor * smooth_factor * pixel_ratio) + (pixel_ratio * 1.5) + ), fill="black", outline="#FF0000", - width=math.floor(pixel_ratio * border_factor), + width=math.floor( + (border_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 1.5) + ), ) # Resize image to final size @@ -266,7 +269,11 @@ def _render_icon( size, resample=Image.Resampling.BILINEAR, ) - fg: Image.Image = Image.new("RGB", size=size, color="#00FF00") + fg: Image.Image = Image.new( + "RGB", + size=size, + color="#00FF00", + ) # Get icon by name icon: Image.Image = self.rm.get(name) @@ -301,7 +308,7 @@ def _render_icon( return im def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: - """Apply a gradient effect over an an 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.""" bg_color: str = ( get_ui_color(ColorType.DARK_ACCENT, color) @@ -750,14 +757,18 @@ def render( update_on_ratio_change=False, ): """Internal renderer. Renders an entry/element thumbnail for the GUI.""" - loading_thumb: Image.Image = ThumbRenderer.thumb_loading_512 - + 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 + # Initialize "Loading" thumbnail + loading_thumb: Image.Image = self._get_icon( + "thumb_loading", "", (adj_size, adj_size), pixel_ratio + ) + if ThumbRenderer.font_pixel_ratio != pixel_ratio: ThumbRenderer.font_pixel_ratio = pixel_ratio ThumbRenderer.ext_font = ImageFont.truetype( @@ -765,10 +776,6 @@ def render( math.floor(12 * ThumbRenderer.font_pixel_ratio), ) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Light: - loading_thumb = theme_fg_overlay(loading_thumb) - - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) if is_loading: final = loading_thumb.resize( (adj_size, adj_size), resample=Image.Resampling.BILINEAR From 7efde5c44dc8f159c402bca6b4b270513a94e482 Mon Sep 17 00:00:00 2001 From: Heiholf Date: Fri, 23 Aug 2024 13:39:39 +0200 Subject: [PATCH 43/47] Added thumbnail rendering for PDF-files --- tagstudio/src/qt/widgets/thumb_renderer.py | 57 +++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index f6df0bce0..039197b09 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -27,8 +27,19 @@ from PIL.Image import DecompressionBombError 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 PySide6.QtCore import ( + QObject, + QSize, + QSizeF, + Qt, + Signal, + QFile, + QFileDevice, + QIODeviceBase, + QBuffer, +) +from PySide6.QtGui import QGuiApplication, QPixmap, QImage +from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT from src.core.media_types import MediaCategories, MediaType from src.core.palette import ColorType, get_ui_color @@ -746,6 +757,44 @@ def _video_thumb(self, filepath: Path) -> Image.Image: ) return im + def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image: + file: QFile = QFile(filepath) + was_open_success: bool = file.open(QIODeviceBase.ReadOnly, QFileDevice.ReadUser) + if not was_open_success: + logging.error( + f"[ThumbRenderer][PDF][ERROR]: Couldn't open pdf-file {filepath.name}" + ) + return None + document: QPdfDocument = QPdfDocument() + document.load(file) + # Transform page_size in points to pixels with proper aspect ratio + page_size: QSizeF = document.pagePointSize(0) + ratio_hw: float = page_size.height() / page_size.width() + if ratio_hw >= 1: + page_size *= size / page_size.height() + else: + page_size *= size / page_size.width() + # Enlarge image to improve image downscaling (kind of arbitrary) + page_size *= 2.5 + # Render image with no anti-aliasing for speed + render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() + render_options.setRenderFlags( + QPdfDocumentRenderOptions.RenderFlag.TextAliased + | QPdfDocumentRenderOptions.RenderFlag.ImageAliased + | QPdfDocumentRenderOptions.RenderFlag.PathAliased + ) + # Convert QImage to PIL Image + rendered_qimage: QImage = document.render(0, page_size.toSize(), render_options) + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.ReadWrite) + rendered_qimage.save(buffer, "PNG") + pil_image: Image = Image.open(BytesIO(buffer.data())) + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + pixel_array = np.asarray(pil_image.convert("RGBA")).copy() + pixel_array[pixel_array[:, :, 3] == 0] = [255, 255, 255, 255] + return Image.fromarray(pixel_array) + def render( self, timestamp: float, @@ -825,6 +874,10 @@ def render( # Blender =========================================================== elif MediaType.BLENDER in MediaCategories.get_types(ext): image = self._blender(_filepath) + # Documents =================================================== + elif MediaType.DOCUMENT in MediaCategories.get_types(ext): + if ext == ".pdf": + image = self._pdf_thumb(_filepath, adj_size) # No Rendered Thumbnail ======================================== if not image: From c699ca150c512ca5e254bab8a8e791edbd0a6689 Mon Sep 17 00:00:00 2001 From: Heiholf Date: Fri, 23 Aug 2024 21:18:43 +0200 Subject: [PATCH 44/47] Fixed MyPy errors in PDF thumbnail renderer --- tagstudio/src/qt/widgets/thumb_renderer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 039197b09..e81bc53a1 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -759,7 +759,9 @@ def _video_thumb(self, filepath: Path) -> Image.Image: def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image: file: QFile = QFile(filepath) - was_open_success: bool = file.open(QIODeviceBase.ReadOnly, QFileDevice.ReadUser) + was_open_success: bool = file.open( + QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser + ) if not was_open_success: logging.error( f"[ThumbRenderer][PDF][ERROR]: Couldn't open pdf-file {filepath.name}" @@ -786,9 +788,9 @@ def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image: # Convert QImage to PIL Image rendered_qimage: QImage = document.render(0, page_size.toSize(), render_options) buffer: QBuffer = QBuffer() - buffer.open(QBuffer.ReadWrite) + buffer.open(QBuffer.OpenModeFlag.ReadWrite) rendered_qimage.save(buffer, "PNG") - pil_image: Image = Image.open(BytesIO(buffer.data())) + pil_image: Image.Image = Image.open(BytesIO(buffer.buffer().data())) buffer.close() # Replace transparent pixels with white (otherwise Background defaults to transparent) pixel_array = np.asarray(pil_image.convert("RGBA")).copy() From afdfe61451d04337c85c9b020e343063ff60b04c Mon Sep 17 00:00:00 2001 From: Heiholf Date: Tue, 10 Sep 2024 15:02:21 +0200 Subject: [PATCH 45/47] Fixed suggestions by eivl - put transparent pixel replacement logic into new util file - made pdf scale less magical - fixed bug of not closing buffer --- tagstudio/src/core/utils/image.py | 28 ++++++++++++++++++++++ tagstudio/src/qt/widgets/thumb_renderer.py | 16 +++++++------ 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 tagstudio/src/core/utils/image.py diff --git a/tagstudio/src/core/utils/image.py b/tagstudio/src/core/utils/image.py new file mode 100644 index 000000000..12d4f545c --- /dev/null +++ b/tagstudio/src/core/utils/image.py @@ -0,0 +1,28 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PIL import Image +import numpy as np + + +def replace_transparent_pixels( + img: Image.Image, color: tuple[int, int, int, int] = (255, 255, 255, 255) +) -> Image.Image: + """ + Replaces (copying/without mutating) all transparent pixels in an image with the color. + + Args: + img (Image.Image): + The source image + color (tuple[int, int, int, int]): + The color (RGBA, 0 to 255) which transparent pixels should be set to. + Defaults to white (255, 255, 255, 255) + + Returns: + Image.Image: + A copy of img with the pixels replaced. + """ + pixel_array = np.asarray(img.convert("RGBA")).copy() + pixel_array[pixel_array[:, :, 3] == 0] = color + return Image.fromarray(pixel_array) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index e81bc53a1..2cb977700 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -44,6 +44,7 @@ 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.core.utils.image import replace_transparent_pixels 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 @@ -777,7 +778,8 @@ def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image: else: page_size *= size / page_size.width() # Enlarge image to improve image downscaling (kind of arbitrary) - page_size *= 2.5 + IMAGE_ENLARGEMENT_FACTOR = 2.5 + page_size *= IMAGE_ENLARGEMENT_FACTOR # Render image with no anti-aliasing for speed render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() render_options.setRenderFlags( @@ -789,13 +791,13 @@ def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image: rendered_qimage: QImage = document.render(0, page_size.toSize(), render_options) buffer: QBuffer = QBuffer() buffer.open(QBuffer.OpenModeFlag.ReadWrite) - rendered_qimage.save(buffer, "PNG") - pil_image: Image.Image = Image.open(BytesIO(buffer.buffer().data())) - buffer.close() + try: + rendered_qimage.save(buffer, "PNG") + pil_image: Image.Image = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() # Replace transparent pixels with white (otherwise Background defaults to transparent) - pixel_array = np.asarray(pil_image.convert("RGBA")).copy() - pixel_array[pixel_array[:, :, 3] == 0] = [255, 255, 255, 255] - return Image.fromarray(pixel_array) + return replace_transparent_pixels(pil_image) def render( self, From 528f018134555b8a448a7a682e96a465cdb33352 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sun, 15 Sep 2024 00:42:52 -0700 Subject: [PATCH 46/47] chore: fix missing import --- tagstudio/src/qt/widgets/thumb_renderer.py | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index dd548f098..e0b41c9a9 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,10 +5,10 @@ import logging import math +import struct from copy import deepcopy from io import BytesIO from pathlib import Path -import struct import cv2 import numpy as np @@ -28,18 +28,18 @@ from PIL.Image import DecompressionBombError from pillow_heif import register_avif_opener, register_heif_opener from pydub import exceptions -from src.qt.helpers.vendored.pydub.audio_segment import _AudioSegment as AudioSegment # type: ignore from PySide6.QtCore import ( - QObject, - QSize, - QSizeF, - Qt, - Signal, - QFile, - QIODeviceBase, - QBuffer, + QBuffer, + QFile, + QFileDevice, + QIODeviceBase, + QObject, + QSize, + QSizeF, + Qt, + Signal, ) -from PySide6.QtGui import QGuiApplication, QPixmap, QImage +from PySide6.QtGui import QGuiApplication, QImage, QPixmap from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT from src.core.media_types import MediaCategories, MediaType @@ -51,6 +51,9 @@ 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.helpers.vendored.pydub.audio_segment import ( + _AudioSegment as AudioSegment, # type: ignore +) from src.qt.resource_manager import ResourceManager from vtf2img import Parser From c0e0bf93e4a17827f674f72cf9fc4b628a66a986 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sun, 15 Sep 2024 00:46:19 -0700 Subject: [PATCH 47/47] fix: reposition `type: ignore` for import --- tagstudio/src/qt/widgets/thumb_renderer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index e0b41c9a9..dc238e315 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -51,9 +51,7 @@ 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.helpers.vendored.pydub.audio_segment import ( - _AudioSegment as AudioSegment, # type: ignore -) +from src.qt.helpers.vendored.pydub.audio_segment import _AudioSegment as AudioSegment # type: ignore from src.qt.resource_manager import ResourceManager from vtf2img import Parser