From 34f347bf55b8d1ba4e5bb41fd9ea5d17ab4fee8b Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 31 May 2024 19:25:53 -0700 Subject: [PATCH 01/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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 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 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] (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/79] 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/79] 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 zcmeHtX;_oj_U}#z2b3y$lfh@B3~@ z-tFPCXyJ;50Dwiib~^6?01cODp!qNO>qAVd3;^{&syC5Eba%52q0=maL+J;|7BMsi z04JBaxwz%Wn8BXLw)SaA2vKPn0JA`v2#3mH$ z=&;Z}#ugf&ky*jUF|@F7rfm!XJ8#z(u2p0l)_7iob%cN=y6-l2qDPXAw^(ekz+)X2 z8rw&P9<<%#yyG`zxFcW>u~-aS94^M>Ou%lOS5jeTyNeb~ zA^TB7S%?0#_9w%Q98UQMM-^QdTU8D|;Z&H<#}MX~h=1}3SgTFAzc}*$kE}n=!gfAB zcX}ucZ}WV3f3kKtIqi<5AEbss9&?Y&Hsf8}oh&!nSX!EIviRr$9-J-Ir5=oR4rW2A zP54b;n&Yj^H*NObXl0B4(sr{Y+}h&t^OonOVgG~%vx5I`@~SQ{whs-lWl>pSU==&v&D-@qCTh28XDQhk#{7(nHxH z#KZkD^5Eu=p-B#hlQI&H#DU}CVgOLbsIVaZQ3wEh zex7~4##L>rPLj}hZM}x>HBxZI&O2YP*`Y%-)BAGaVcpf!hre%f-T6A=l5xFn>C#V~ zR|cI@sCO=GJAUQ!X~2v_iR%pjt- z0nVB+aAV2ZycL}vhGQ;PPe!CQe_QDw@Rc)_dtI^AA4trFY5dE9IypKydp8_fHmVu@ zLnd+Cv|GENN&VEQ86ohEe##3U#cg8Rj%_5mzVCCPwIW4)HfKrtrGdxf6r?4-@Y0=i z6U;KbxCb>G&*-c>bAL_9$>j&Qx|vC1x>tgHTOUvPy{8cWz2E2HIX&AAHy-a6Z8-c5 z-*j!Bv*%Ag?zt^fR+j8IjNe_LG?_g^`!2RMb;EoZvC*2fS|Rj4!izM9xmPH~*gvj2(|P=~CT zoV@D!v?g7M@*Ae~*&SLR2A^#U7(4x$0B9{y{h+|*tmOc#e6`DYt9MM+yPmk8-$Vzr zk58>Lxw>uJ4WjYIof=E`9ea*CuBq;%p>AfZU&J=ZD)1`u@VWQNeUe~xFMqWgZRpn+ zr(4mgV8f}ft!ULk4Gl5 z1}_j=qJ^V_S$#9vtD75W{5YRzfrZ_xyF<5vMk9v@0IAr)r&d^9!Rfmdl;)_&<4oO3 zK?EzKftYO-wRmombFv0dV! zvKcjzw*|dS-*G(5VJ6H2J(q(F3ba}zT4=D38O%$07tr@vjzLVGC(6E{S)82pMjK2- z+~Gt@?>8je&kXxzN{~<~%i3(P5VY5(`Dhm?`te3b+hHhdci!Miz>Ve5c>>LZu{MV> zyeTTMU1Flna|BPi-p~Sb5(R^AS4q~0f(i}eieVweMNA<^6|r%zysFE=^oZr?4h%4B zbQBZ|dRcsJ>mC~rU81;A{VqKT4WdSui1mFE<8M3K_pAnv*_H3rxi_n?$htkoy+s9v zsJnD&MTIh|9*7j(trGIwsP(wEM|r(+nyUB&5Km8Z4!^Br+;#!Qg~h#Zt%1lPz`s^D z-DkZKqI&>LixxUfG0o;Il{^-jl-fTtlEm~zwtr7pQ0L)rp+09@1pxcE-B2#5;gS)tJ2(JNg@`kCy) z_pGQ|{__fhqF(m!^oIv!pmgz|U(nHEVpEMI0oloOXFl%rlY6t>r4`8a7pZEZuLJh? zlvH1n<@k<$Dto4G)FK$UD;Mek>mobf7P>5cY=7!w4Qt*2U0~WRldAzc=}F7D=dH7^ z@hH-S2FW6){ln~Q9n{#IQ?Chx3wKB+j}W66G-*{*trZ}7jq78yQiC+Itjy1RJp{uXLV!0$0idS$KZ}nbT+zxi z1f*bxeA#^@P@FAD@wB$!JNO19c1HwA{lqnCa@pHj&qby>0OUterKL!dAg;3G1J1Fr z>)usandQc@S+Q6=>SL8N@b3O+5Z_#O0Y6n)j##XLb`P!IKz^2P82A9tmk*&gy9g#q|eT|Rw* zwY6`R@pj_z0%W_`&(|et;-x5k0e6)F8nmCb^KSXUq4k5ZIQ#K zd}W(;%al>u8}X>GX9i^Kf(+@Dz|XtSR%*OCss=eFo*Mk+E06vjaBQtCj`oz3*x!1> z>_J(oT-I>GqrNZ(1BjPb)1I;_XL2(sZ{C8`)qs0*rVbg(H#~4P=h6~2n;p>R`@5F( zUm7~90(dEFVv~(wTxVy!aX&r|8${kCFZo*eh9{{D0BONUtHktWmnY%JukG(NH<>DwsPadQEHIrT!^0Daud$&S`FNC!YN{EfKMjqu>xY(IG< z8{$x!hz1#gE?*@ZG5Ht6UwXnzyIujE6xWqh54Jje^4d-?>p{TGt_571YKru1LDKRc zXJ~;-NuJBk*saXCp8JEvWzLy;`x~;(f+SKR48&@xILBJ5bAoel@>KBM>&L3be^##<~0Vo=7; zeQV)*7kJXQUHk@yFbPxm8cq7F+jGMjz+E4&XjNl;K9aw5-dZLHEPg1zvke}@^M^3s zt9NsKcS5_jZ<(E#rJjPm*RE{Z^c%6dN2OYs%K;UhD&N5{t&5&8i!M`WIKWW~48Hw` z(torh6a6KKUc%oL=T;RdSP%z?-?E4kq{?{E`yAcz9SP~E>eL|Aor2x6eRWEc+l}JL}zhA z9L!|>RMY$>5HAJY_shjc@(hpn=mL?!y}D*E)RRUnz9;mkIzgMe1)SbiC&p%>trQHa z>UtY^(%8jBT@kCwV{VtUx*+KaKj<ZPxybVf8;=1hBMaEFiukV}q15jwZ6PbZR395GL^^*Sxh58>MU7cCn$6rA)ilcvR z2x8(p(kO7u;%H8)0*!K#P9sV;*xI!>Wc4tP6%e_aDR0li6T7!`O}L0(4z5z3hRwJ( zhQ=E#=tvb78S-AvX}iPmF+L$q%;cO|diNH5SJd~?zJCV;O4sxS)WW`6Hqs^Vb0=V4 zKzP3ICc?Mrd@t?WyB)pc!>vwuZ8&N!3k8OL@uA4pWfC8;SH9u#8JMnKt$?!)+h1B- zP?slbZ9RcEf|7hcAMKxBpeiLZ;s$}zPHOxl1%UM79vW7Ze5{gnr@7_}hqa>xqFoT2 zw~As8i#K(fGH{(%d}TT}@?4_FRsfDLLZsPZtHE!NjTRX0mrI~!I<$Ntw?wghpe|Z; zguj6w?Lp9-fMs1COXImS?nLX(RUsQzSS46HLCNb=-r9wALTN6TqKL2a6aC^N;iE(c zK1#%k#A)8j8*B%o1ShGQIG*q4X0N3MORhZ5lea;`p{{vfe{p?@PtYFu?xDn!Csx4% z`#i8)lK;lkhn^jiUKZgcJ&yF!dYeaE@>eC@gn;b~=LZrAS2%9cYHFj+MNeO;*(n186Z}97yK(BZ&_9djR!lpyJ0{L}Ysvt_L4}O+tTuUmC zt?728E0vFWh_$zxH|USt>Qeed{mRel!)dekmkNX^?y0JR<}H}OxM88Au=+N~S^7rY zQ#mJf2#}W`NJ!%lf2eY9OaDa-5Iu%n)E(j?UQ5eKa#X+%fbj*5w66UrO4&f_P-;(a zO`4qoThc{=!=;}AU+E)u(NRVHpQT8b9Bsl5nCW&Rg1MnYKJjc%lmU?gIGo3I1qFpnuPd z8GT@<^n}<|(9-pk2!__d$-Ii+$IlT*)#(ebarUtvE(_Npi7~*d%1Qby@@!vSPfdC3 zm#>;Cj@AGH7G&J7mng5S>QOutH^FbL{57DxGJvkoa0Vi(CYYoK@sucCz*vap#&=9f z65%Y!ItfTdd7X3GX8?DJCWxXT%}OqOCr<^WOOo3&MzF9OA(wV$^7{%#z^NPDMVDNzXN3s)O zRJ2vhlXWnF5>hlzF3bV_Zbk9)MtqiltmJPjm1c=B8wK34CZ^L_>$qQ+^S-x?r6L;PW)_FUWlHHe&qggfsp&}8YgL9o=|DP4lrCmTA^JO__8Q5 ztuD{qfH7HoSeAHXah#ijC|DcCkc25F?3}^u2X!$$O464v{HBL_JoY7NBdXtkD}7(8 zw6`(k#>YlV+mN7~hl37^_CbdT!IF&|(4=grx(?lbQ%j)imY6|97m`vr(;8A4n`GX*ZnH6Hdngf_VYxJ0qEfq~p5}O?d5W(| z)J2sVS}MtUsIUfyHsz&tXy5Ii@I-TBhci6y+@T;$;Tb@5;3;j-fTxc>Rqam#q9spR z4fhW&s`gw!w1E6%c(jY*-Yg3c^K8r#qFgWxTjpLRmQ;@sy?;U-FSEmPFJunL^if)C zK{`1#CAUWxWk7$b()kV$Q*Cf6o!w1Ds1l43Z88g1sgS~4RW`O_M04izHb`!+`ys&u zBf4iEF)v}}8VCs}jOeI&>^$LzrXPeSInVYQ1mEWH3FxAE5+LQ&55BImGGmmR#XbdUalDUK>U^aMsGt@U9QjR%` z3>R>F%BC~bbn%OZlusaE7u1l)H=ANf!a`-4DSGlNFr50Z#UPn0FIBW;YqoDhm9AXo z$XthxYLfLC>Xtg8Nwa1N>0e=nPfLaISdt4y6y-l>maN%sF_Z|QS**!@cWc@ot6RFN zO*xXxHD3a{Ac~>b{%)djb~U=jbDNUoELx_? zEu)N=?ZEak3YBk@xy&`-DU9fHGWSK9V$cbbr3O2|EMbQ++T*D#*GQL45x$;|=EL^i=$0?miRX@$_|)*STa;55?CNUL z15i30JSACyYI8JQVTsdXjNJI`&{@^qcub+nc}7Wq z%6UbhExK(v4DCtZR?JaxI(_Or5`A0Ikt5zA>O_kq+D#D=(tYAIU;VIGlZFAcMb%39 z$V#pJNt5UW+UJ5ZbH1s)T|MNVpqLS>E@DUS7G3Sf2vWG^7;dvs`ny+RBOlKahr#c1 znKw9L(n%!K?lM$|^TkP}zRIE~p-}b$;R#$l#v}B-{d`LVf%|*na4+f~9mbMYw$!*k zmS_(#{iRHCi|=S*FHxSrPJ`2S=_ecT@xbN{pW>ow*76EuE~73b#}!aE{?XO3TOBC;NEYdOkT zak-Z#3Ji9)kJTZ~UTzpL+|9GAn0eW(b_KDS%~l8P7br$bBU6XfA=_6$0_M6QW)mtGl2 z9C+XYQhhzUx>Wbs-3RE78v#Espf+sqIt+E}IkLr6At`t|E%cO5dKhW~(WY+io)S)D zaw3DjNU=&h5d~k8N}0mWU6x8)`4PovpnMm*D?k-Ot0^DHSI9yWV-!?}@oHp1>^DOC z9FQt=d~08OC&pBRA2=cGG|Ltv0Gh^{#G5^)P)Sw8qo>3IKCbH#eAo1lhfB{O7xHJX zGz3m5pVBO_j2WB7>R|Y3T-W|5qbDjSk0r%t_G$1N{fz9Au0o_|h|;@@Z_T{StbM9{ zC$_4n+G`B(MlOPU??gts=iTxP!1F_+*hSzXp`AdH7N`35?UO&xJo>yN{IzEoGvZiR z_M1U9FgOxIOL(97Dl_4A)!e6vLS(zmMjenh)@#`^V5UvKS zne;=<3Z*|y!5*PcP8@@O1F9N~u>+!4bCJB-soj*>+@CUYFfp5Q!za8&Z1~NoRl(o^ zQFHvY<~Q-V&kl|fOuIC>YV^a*1O0=(Q_TVlAgv7NC){jaFr9y|LYdhqUKgK>GzuDr zf1N~;Ft~!8z*=UP)xDwI=fZokWJ}X807zoG^O9zR$^^L5w4}Fb-a%gRe$p$1cz5_^ zthy_u`1!q)rM!#VT<6MrX_mm|MSoF!s3ph45yVh^_=UE=q>$0_3tgawtF}iZzCQ5UxdgWO#lD@ 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 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 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 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 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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 9f688cd387b48d3dbc940799434978689b57777a Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 23 Aug 2024 13:09:14 -0700 Subject: [PATCH 43/79] fix(ui) color for default icons follow theme --- tagstudio/src/core/palette.py | 24 ++++++-- tagstudio/src/qt/widgets/thumb_renderer.py | 69 +++++++++++++++------- 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index b8e93d3aa..45f2cb843 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -284,12 +284,6 @@ class ColorType(int, Enum): ColorType.LIGHT_ACCENT: "#FFFFFF", ColorType.DARK_ACCENT: "#1e1e1e", }, - "red": { - ColorType.PRIMARY: "#e22c3c", - ColorType.BORDER: "#e54252", - ColorType.LIGHT_ACCENT: "#f39caa", - ColorType.DARK_ACCENT: "#440d12", - }, "green": { ColorType.PRIMARY: "#28bb48", ColorType.BORDER: "#43c568", @@ -302,6 +296,24 @@ class ColorType(int, Enum): ColorType.LIGHT_ACCENT: "#EFD4FB", ColorType.DARK_ACCENT: "#3E1555", }, + "red": { + ColorType.PRIMARY: "#e22c3c", + ColorType.BORDER: "#e54252", + ColorType.LIGHT_ACCENT: "#f39caa", + ColorType.DARK_ACCENT: "#440d12", + }, + "theme_dark": { + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + "theme_light": { + ColorType.PRIMARY: "#FFFFFF", + ColorType.BORDER: "#333333", + ColorType.LIGHT_ACCENT: "#999999", + ColorType.DARK_ACCENT: "#888888", + }, } diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index f6df0bce0..3d8e7e31d 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -129,14 +129,21 @@ def _get_icon( """Retrieves a new or cached icon. Args: - name (str): The name of the icon resource. + name (str): The name of the icon resource. "thumb_loading" will not draw a border. color (str): The color to use for the icon. size (tuple[int,int]): The size of the icon. pixel_ratio (float): The screen pixel ratio. """ + + draw_border: bool = True + if name == "thumb_loading": + draw_border = False + item: Image.Image = self.icons.get((name, color, *size, pixel_ratio)) if not item: - item_flat: Image.Image = self._render_icon(name, color, size, pixel_ratio) + item_flat: Image.Image = self._render_icon( + name, color, size, pixel_ratio, draw_border + ) edge: tuple[Image.Image, Image.Image] = self._get_edge(size, pixel_ratio) item = self._apply_edge(item_flat, edge, faded=True) self.icons[(name, *color, size, pixel_ratio)] = item @@ -220,6 +227,7 @@ def _render_icon( color: str, size: tuple[int, int], pixel_ratio: float, + draw_border: bool = True, ) -> Image.Image: border_factor: int = 5 smooth_factor: int = math.ceil(2 * pixel_ratio) @@ -251,18 +259,19 @@ def _render_icon( ) # 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) + (pixel_ratio * 1.5) - ), - fill="black", - outline="#FF0000", - width=math.floor( - (border_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 1.5) - ), - ) + if draw_border: + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil( + (radius_factor * smooth_factor * pixel_ratio) + (pixel_ratio * 1.5) + ), + fill="black", + outline="#FF0000", + width=math.floor( + (border_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 1.5) + ), + ) # Resize image to final size im = im.resize( @@ -323,7 +332,7 @@ def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: ol_color: str = ( get_ui_color(ColorType.BORDER, color) if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" + else get_ui_color(ColorType.LIGHT_ACCENT, color) ) bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) @@ -356,6 +365,11 @@ def _apply_edge( faded (bool): Whether or not to apply a faded version of the edge. """ opacity: float = 0.75 if not faded else 0.6 + shade_reduction: float = ( + 0.15 + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else 0.3 + ) im: Image.Image = image im_hl, im_sh = deepcopy(edge) @@ -364,9 +378,13 @@ def _apply_edge( 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. + # Configure and apply a normal shading overlay. # This helps with contrast. - im_sh.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(opacity)) + im_sh.putalpha( + ImageEnhance.Brightness(im_sh.getchannel(3)).enhance( + max(0, opacity - shade_reduction) + ) + ) im.paste(im_sh, mask=im_sh.getchannel(3)) return im @@ -764,9 +782,15 @@ def render( _filepath: Path = Path(filepath) resampling_method = Image.Resampling.BILINEAR + theme_color: str = ( + "theme_light" + if QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Light + else "theme_dark" + ) + # Initialize "Loading" thumbnail loading_thumb: Image.Image = self._get_icon( - "thumb_loading", "", (adj_size, adj_size), pixel_ratio + "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio ) if ThumbRenderer.font_pixel_ratio != pixel_ratio: @@ -887,10 +911,15 @@ def render( if update_on_ratio_change: self.updated_ratio.emit(1) + theme_color: str = ( + "theme_light" + if QGuiApplication.styleHints().colorScheme() + == Qt.ColorScheme.Light + else "theme_dark" + ) final = self._get_icon( name=self._get_resource_id(_filepath), - # name="file_generic", - color="", + color=theme_color, size=(adj_size, adj_size), pixel_ratio=pixel_ratio, ) From c377b9d8751de273fc89d0d16dc0bd42b1ce0bc0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 23 Aug 2024 13:10:34 -0700 Subject: [PATCH 44/79] fix: remove `theme_color` redef --- tagstudio/src/qt/widgets/thumb_renderer.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 3d8e7e31d..6789cadf5 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -911,12 +911,6 @@ def render( if update_on_ratio_change: self.updated_ratio.emit(1) - theme_color: str = ( - "theme_light" - if QGuiApplication.styleHints().colorScheme() - == Qt.ColorScheme.Light - else "theme_dark" - ) final = self._get_icon( name=self._get_resource_id(_filepath), color=theme_color, From 12d69baa98cf0d4ab3ac1b6a8d1afd9f4f2194b7 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 24 Aug 2024 16:41:49 -0700 Subject: [PATCH 45/79] refactor: make some consts and args clearer --- tagstudio/src/qt/widgets/thumb_renderer.py | 108 +++++++++++---------- 1 file changed, 56 insertions(+), 52 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 6789cadf5..82ac11eb8 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -55,10 +55,10 @@ class ThumbRenderer(QObject): updated_ratio = Signal(float) # TODO: Make dynamic font sizes given different pixel ratios - font_pixel_ratio: float = 1 + FONT_PIXEL_RATIO: float = 1 ext_font = ImageFont.truetype( Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * font_pixel_ratio), + math.floor(12 * FONT_PIXEL_RATIO), ) def __init__(self) -> None: @@ -151,18 +151,18 @@ def _get_icon( def _render_mask(self, size: tuple[int, int], pixel_ratio) -> Image.Image: """Renders a thumbnail mask.""" - smooth_factor: int = 2 - radius_factor: int = 8 + 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 + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore color="black", ) draw = ImageDraw.Draw(im) draw.rounded_rectangle( (0, 0) + tuple([d - 1 for d in im.size]), - radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + radius=math.ceil(RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio), fill="white", ) im = im.resize( @@ -175,25 +175,25 @@ def _render_edge( self, size: tuple[int, int], pixel_ratio ) -> tuple[Image.Image, Image.Image]: """Renders a thumbnail highlight border.""" - smooth_factor: int = 2 - radius_factor: int = 8 - width: int = math.floor(pixel_ratio * 2) + SMOOTH_FACTOR: int = 2 + RADIUS_FACTOR: int = 8 + WIDTH: int = math.floor(pixel_ratio * 2) # Highlight im_hl: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore color="#00000000", ) draw = ImageDraw.Draw(im_hl) draw.rounded_rectangle( - (width, width) + tuple([d - (width + 1) for d in im_hl.size]), + (WIDTH, WIDTH) + tuple([d - (WIDTH + 1) for d in im_hl.size]), radius=math.ceil( - (radius_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 3) + (RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio) - (pixel_ratio * 3) ), fill=None, outline="white", - width=width, + width=WIDTH, ) im_hl = im_hl.resize( size, @@ -203,16 +203,16 @@ def _render_edge( # Shadow im_sh: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + 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), + radius=math.ceil(RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio), fill=None, outline="black", - width=width, + width=WIDTH, ) im_sh = im_sh.resize( size, @@ -229,22 +229,22 @@ def _render_icon( pixel_ratio: float, draw_border: bool = True, ) -> Image.Image: - border_factor: int = 5 - smooth_factor: int = math.ceil(2 * pixel_ratio) - radius_factor: int = 8 - icon_ratio: float = 1.75 + BORDER_FACTOR: int = 5 + SMOOTH_FACTOR: int = math.ceil(2 * pixel_ratio) + RADIUS_FACTOR: int = 8 + ICON_RATIO: float = 1.75 # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + 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 + size=tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore color="#000000", ) @@ -253,8 +253,8 @@ def _render_icon( bg, (0, 0), mask=self._get_mask( - tuple([d * smooth_factor for d in size]), # type: ignore - (pixel_ratio * smooth_factor), + tuple([d * SMOOTH_FACTOR for d in size]), # type: ignore + (pixel_ratio * SMOOTH_FACTOR), ), ) @@ -264,12 +264,12 @@ def _render_icon( draw.rounded_rectangle( (0, 0) + tuple([d - 1 for d in im.size]), radius=math.ceil( - (radius_factor * smooth_factor * pixel_ratio) + (pixel_ratio * 1.5) + (RADIUS_FACTOR * SMOOTH_FACTOR * pixel_ratio) + (pixel_ratio * 1.5) ), fill="black", outline="#FF0000", width=math.floor( - (border_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 1.5) + (BORDER_FACTOR * SMOOTH_FACTOR * pixel_ratio) - (pixel_ratio * 1.5) ), ) @@ -293,17 +293,17 @@ def _render_icon( # Resize icon to fit icon_ratio icon = icon.resize( - (math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)) + (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)) + (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), + math.ceil((size[0] - (size[0] // ICON_RATIO)) // 2), + math.ceil((size[1] - (size[1] // ICON_RATIO)) // 2), ), mask=icon.getchannel(3), ) @@ -432,20 +432,21 @@ def _audio_waveform_thumb( # 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 + size_scaled: int = size * BASE_SCALE + allow_small_min: bool = False im: Image.Image = None try: - BARS: int = min(math.floor((size // pixel_ratio) / 5), 64) + BAR_COUNT: int = min(math.floor((size // pixel_ratio) / 5), 64) audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) data = np.fromstring(audio._data, np.int16) # type: ignore - data_indices = np.linspace(1, len(data), num=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) + data_indices = np.linspace(1, len(data), num=BAR_COUNT * SAMPLES_PER_BAR) + bar_margin: float = ((size_scaled / (BAR_COUNT * 3)) * BASE_SCALE) / 2 + line_width: float = ( + (size_scaled - bar_margin) / (BAR_COUNT * 3) + ) * BASE_SCALE + bar_height: float = (size_scaled) - (size_scaled // bar_margin) count: int = 0 maximum_item: int = 0 @@ -467,38 +468,38 @@ def _audio_waveform_thumb( maximum_item = 0 count = 1 - line_ratio = max(highest_line / BAR_HEIGHT, 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 + 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) + if not allow_small_min: + item_height = max(item_height, line_width) current_y = ( - BAR_HEIGHT - item_height + (size_scaled // BAR_MARGIN) + bar_height - item_height + (size_scaled // bar_margin) ) // 2 draw.rounded_rectangle( ( current_x, current_y, - (current_x + LINE_WIDTH), + (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), + width=max(math.ceil(line_width / 6), BASE_SCALE), ) - current_x = current_x + LINE_WIDTH + BAR_MARGIN + current_x = current_x + line_width + bar_margin im.resize((size, size), Image.Resampling.BILINEAR) @@ -678,6 +679,9 @@ def _image_vector_thumb(self, filepath: Path, size: int) -> Image.Image: def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: # TODO: Implement. + # The following commented code describes a method for rendering via + # matplotlib. + # This implementation did not play nice with multithreading. im: Image.Image = None # # Create a new plot # matplotlib.use('agg') @@ -771,7 +775,7 @@ def render( base_size: tuple[int, int], pixel_ratio: float, is_loading=False, - gradient=False, + is_thumb=False, update_on_ratio_change=False, ): """Internal renderer. Renders an entry/element thumbnail for the GUI.""" @@ -793,11 +797,11 @@ def render( "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio ) - if ThumbRenderer.font_pixel_ratio != pixel_ratio: - ThumbRenderer.font_pixel_ratio = pixel_ratio + if ThumbRenderer.FONT_PIXEL_RATIO != pixel_ratio: + ThumbRenderer.FONT_PIXEL_RATIO = pixel_ratio ThumbRenderer.ext_font = ImageFont.truetype( Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * ThumbRenderer.font_pixel_ratio), + math.floor(12 * ThumbRenderer.FONT_PIXEL_RATIO), ) if is_loading: @@ -830,7 +834,7 @@ def render( image = self._text_thumb(_filepath, adj_size) # Fonts ======================================================== elif MediaType.FONT in MediaCategories.get_types(ext, True): - if gradient: + if is_thumb: # Short (Aa) Preview image = self._font_short_thumb(_filepath, adj_size) else: @@ -875,7 +879,7 @@ def render( ) image = image.resize((new_x, new_y), resample=resampling_method) mask: Image.Image = None - if gradient: + if is_thumb: mask = self._get_mask((adj_size, adj_size), pixel_ratio) edge: tuple[Image.Image, Image.Image] = self._get_edge( (adj_size, adj_size), pixel_ratio From 5c4a3c58566088d659f292f267b3541d1fcdbe1b Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 24 Aug 2024 17:22:06 -0700 Subject: [PATCH 46/79] refactor: organize arguments, update docstrings The ability to pass a border radius scaling argument is also included. --- tagstudio/src/qt/widgets/thumb_renderer.py | 177 +++++++++++++++++---- 1 file changed, 147 insertions(+), 30 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 82ac11eb8..054c8eaf5 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -65,7 +65,8 @@ def __init__(self) -> None: super().__init__() # Cached thumbnail elements. - # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) + # Key: Size + Pixel Ratio Tuple + Radius Scale + # (Ex. (512, 512, 1.25, 4)) self.thumb_masks: dict = {} self.raised_edges: dict = {} @@ -97,23 +98,36 @@ def _get_resource_id(self, url: Path) -> str: 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. + def _get_mask( + self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False + ) -> Image.Image: + """Return a thumbnail mask given a size, pixel ratio, and radius scaling option. + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + scale_radius (bool): Option to scale the radius up (Used for Preview Panel). """ - item: Image.Image = self.thumb_masks.get((*size, pixel_ratio)) + radius_scale: float = 1 + if scale_radius: + radius_scale = max(size[0], size[1]) / 512 + + item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale)) if not item: - item = self._render_mask(size, pixel_ratio) - self.thumb_masks[(*size, pixel_ratio)] = item + item = self._render_mask(size, pixel_ratio, radius_scale) + self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item return item def _get_edge( self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: - """ - 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. + """Return a thumbnail edge given a size, pixel ratio, and radius scaling option. + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. """ item: tuple[Image.Image, Image.Image] = self.raised_edges.get( (*size, pixel_ratio) @@ -126,7 +140,7 @@ def _get_edge( def _get_icon( self, name: str, color: str, size: tuple[int, int], pixel_ratio: float = 1.0 ) -> Image.Image: - """Retrieves a new or cached icon. + """Return an icon given a size, pixel ratio, and radius scaling option. Args: name (str): The name of the icon resource. "thumb_loading" will not draw a border. @@ -149,8 +163,16 @@ def _get_icon( self.icons[(name, *color, size, pixel_ratio)] = item return item - def _render_mask(self, size: tuple[int, int], pixel_ratio) -> Image.Image: - """Renders a thumbnail mask.""" + def _render_mask( + self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1 + ) -> Image.Image: + """Render a thumbnail mask graphic. + + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + radius scale (float): The scale factor of the border radius (Used by Preview Panel). + """ SMOOTH_FACTOR: int = 2 RADIUS_FACTOR: int = 8 @@ -162,7 +184,9 @@ def _render_mask(self, size: tuple[int, int], pixel_ratio) -> Image.Image: 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 * radius_scale + ), fill="white", ) im = im.resize( @@ -172,9 +196,14 @@ def _render_mask(self, size: tuple[int, int], pixel_ratio) -> Image.Image: return im def _render_edge( - self, size: tuple[int, int], pixel_ratio + self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: - """Renders a thumbnail highlight border.""" + """Render a thumbnail edge graphic. + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + """ + SMOOTH_FACTOR: int = 2 RADIUS_FACTOR: int = 8 WIDTH: int = math.floor(pixel_ratio * 2) @@ -229,6 +258,15 @@ def _render_icon( pixel_ratio: float, draw_border: bool = True, ) -> Image.Image: + """Render a thumbnail icon. + + Args: + name (str): The name of the icon resource. + color (str): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + draw_border (bool): Option to draw a border. + """ BORDER_FACTOR: int = 5 SMOOTH_FACTOR: int = math.ceil(2 * pixel_ratio) RADIUS_FACTOR: int = 8 @@ -318,7 +356,12 @@ def _render_icon( def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: """Apply a color overlay effect to an image based on its color channel data. - Red channel for foreground, green channel for outline, none for background.""" + Red channel for foreground, green channel for outline, none for background. + + Args: + image (Image.Image): The image to apply an overlay to. + color (str): The name of the ColorType color to use. + """ bg_color: str = ( get_ui_color(ColorType.DARK_ACCENT, color) if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark @@ -361,8 +404,10 @@ def _apply_edge( Args: image (Image.Image): The image to apply the edge to. - edge (Image.Image): The edge image to apply. + edge (tuple[Image.Image, Image.Image]): The edge images to apply. + Item 0 is the inner highlight, and item 1 is the outer shadow. faded (bool): Whether or not to apply a faded version of the edge. + Used for light themes. """ opacity: float = 0.75 if not faded else 0.6 shade_reduction: float = ( @@ -390,7 +435,12 @@ def _apply_edge( return im def _audio_album_thumb(self, filepath: Path, ext: str) -> Image.Image | None: - """Gets an album cover from an audio file if one is present.""" + """Return an album cover thumb from an audio file if a cover is present. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + """ image: Image.Image = None try: if not filepath.is_file(): @@ -428,7 +478,14 @@ def _audio_album_thumb(self, filepath: Path, ext: str) -> Image.Image | None: 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.""" + """Render a waveform image from an audio file. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + size (tuple[int,int]): The size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + """ # BASE_SCALE used for drawing on a larger image and resampling down # to provide an antialiased effect. BASE_SCALE: int = 2 @@ -510,6 +567,12 @@ def _audio_waveform_thumb( return im def _blender(self, filepath: Path) -> Image.Image: + """ + Get an emended thumbnail from a Blender file, if a thumbnail is present. + + Args: + filepath (Path): The path of the file. + """ bg_color: str = ( "#1e1e1e" if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark @@ -541,7 +604,12 @@ def _blender(self, filepath: Path) -> Image.Image: return im def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: - """Render a small font preview ("Aa") thumbnail from a font file.""" + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ im: Image.Image = None try: bg = Image.new("RGB", (size, size), color="#000000") @@ -598,7 +666,12 @@ def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: return im def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: - """Render a large font preview ("Alphabet") thumbnail from a font file.""" + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ # Scale the sample font sizes to the preview image # resolution,assuming the sizes are tuned for 256px. im: Image.Image = None @@ -628,6 +701,11 @@ def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: return im def _image_raw_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a RAW image type. + + Args: + filepath (Path): The path of the file. + """ im: Image.Image = None try: with rawpy.imread(str(filepath)) as raw: @@ -652,6 +730,11 @@ def _image_raw_thumb(self, filepath: Path) -> Image.Image: return im def _image_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a standard image type. + + Args: + filepath (Path): The path of the file. + """ im: Image.Image = None try: im = Image.open(filepath) @@ -673,11 +756,23 @@ def _image_thumb(self, filepath: Path) -> Image.Image: return im def _image_vector_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for a vector image, such as SVG. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ # TODO: Implement. im: Image.Image = None return im def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for an STL file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the icon. + """ # TODO: Implement. # The following commented code describes a method for rendering via # matplotlib. @@ -703,7 +798,12 @@ def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: return im - def _text_thumb(self, filepath: Path, size: int) -> Image.Image: + def _text_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a plaintext file. + + Args: + filepath (Path): The path of the file. + """ im: Image.Image = None bg_color: str = ( @@ -738,6 +838,11 @@ def _text_thumb(self, filepath: Path, size: int) -> Image.Image: return im def _video_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a video file. + + Args: + filepath (Path): The path of the file. + """ im: Image.Image = None try: if is_readable_video(filepath): @@ -775,10 +880,22 @@ def render( base_size: tuple[int, int], pixel_ratio: float, is_loading=False, - is_thumb=False, + is_grid_thumb=False, update_on_ratio_change=False, ): - """Internal renderer. Renders an entry/element thumbnail for the GUI.""" + """Render a thumbnail or preview image. + + Args: + timestamp (float): The timestamp for which this this job was dispatched. + filepath (str | Path): The path of the file to render a thumbnail for. + base_size (tuple[int,int]): The unmodified base size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + is_loading (bool): Is this a loading graphic? + is_grid_thumb (bool): Is this a thumbnail for the thumbnail grid? + Or else the Preview Pane? + update_on_ratio_change (bool): Should an updated ratio signal be sent? + + """ adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) image: Image.Image = None pixmap: QPixmap = None @@ -831,10 +948,10 @@ def render( image = self._video_thumb(_filepath) # Plain Text =================================================== elif MediaType.PLAINTEXT in MediaCategories.get_types(ext): - image = self._text_thumb(_filepath, adj_size) + image = self._text_thumb(_filepath) # Fonts ======================================================== elif MediaType.FONT in MediaCategories.get_types(ext, True): - if is_thumb: + if is_grid_thumb: # Short (Aa) Preview image = self._font_short_thumb(_filepath, adj_size) else: @@ -879,7 +996,7 @@ def render( ) image = image.resize((new_x, new_y), resample=resampling_method) mask: Image.Image = None - if is_thumb: + if is_grid_thumb: mask = self._get_mask((adj_size, adj_size), pixel_ratio) edge: tuple[Image.Image, Image.Image] = self._get_edge( (adj_size, adj_size), pixel_ratio @@ -889,7 +1006,7 @@ def render( edge, ) else: - mask = self._get_mask(image.size, pixel_ratio) + mask = self._get_mask(image.size, pixel_ratio, scale_radius=True) final = Image.new("RGBA", image.size, (0, 0, 0, 0)) final.paste(image, mask=mask.getchannel(0)) From a037a3b1e2094eee831207e419854fa05da4ad20 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 24 Aug 2024 17:28:07 -0700 Subject: [PATCH 47/79] chore: format docstrings with ruff --- tagstudio/src/qt/widgets/thumb_renderer.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 054c8eaf5..bd05376bd 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -62,6 +62,7 @@ class ThumbRenderer(QObject): ) def __init__(self) -> None: + """Initialize the class.""" super().__init__() # Cached thumbnail elements. @@ -75,6 +76,7 @@ def __init__(self) -> None: 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: @@ -102,6 +104,7 @@ def _get_mask( self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False ) -> Image.Image: """Return a thumbnail mask given a size, pixel ratio, and radius scaling option. + If one is not already cached, a new one will be rendered. Args: @@ -123,6 +126,7 @@ def _get_edge( self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: """Return a thumbnail edge given a size, pixel ratio, and radius scaling option. + If one is not already cached, a new one will be rendered. Args: @@ -148,7 +152,6 @@ def _get_icon( size (tuple[int,int]): The size of the icon. pixel_ratio (float): The screen pixel ratio. """ - draw_border: bool = True if name == "thumb_loading": draw_border = False @@ -171,7 +174,7 @@ def _render_mask( Args: size (tuple[int,int]): The size of the graphic. pixel_ratio (float): The screen pixel ratio. - radius scale (float): The scale factor of the border radius (Used by Preview Panel). + radius_scale (float): The scale factor of the border radius (Used by Preview Panel). """ SMOOTH_FACTOR: int = 2 RADIUS_FACTOR: int = 8 @@ -199,11 +202,11 @@ def _render_edge( self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: """Render a thumbnail edge graphic. + Args: size (tuple[int,int]): The size of the graphic. pixel_ratio (float): The screen pixel ratio. """ - SMOOTH_FACTOR: int = 2 RADIUS_FACTOR: int = 8 WIDTH: int = math.floor(pixel_ratio * 2) @@ -356,6 +359,7 @@ def _render_icon( def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: """Apply a color overlay effect to an image based on its color channel data. + Red channel for foreground, green channel for outline, none for background. Args: @@ -567,8 +571,7 @@ def _audio_waveform_thumb( return im def _blender(self, filepath: Path) -> Image.Image: - """ - Get an emended thumbnail from a Blender file, if a thumbnail is present. + """Get an emended thumbnail from a Blender file, if a thumbnail is present. Args: filepath (Path): The path of the file. From 2796db6c31c1706249787790b235215b3f0b085a Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 30 Aug 2024 14:24:30 -0700 Subject: [PATCH 48/79] refactor: replace magic numbers with named values --- tagstudio/src/qt/helpers/rounded_pixmap_style.py | 6 +++--- tagstudio/src/qt/ts_qt.py | 6 +++++- tagstudio/src/qt/widgets/thumb_renderer.py | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tagstudio/src/qt/helpers/rounded_pixmap_style.py b/tagstudio/src/qt/helpers/rounded_pixmap_style.py index 577382167..d5a581b7d 100644 --- a/tagstudio/src/qt/helpers/rounded_pixmap_style.py +++ b/tagstudio/src/qt/helpers/rounded_pixmap_style.py @@ -4,7 +4,7 @@ # 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.QtGui import QBrush, QColor, QPainter, QPixmap from PySide6.QtWidgets import ( QProxyStyle, ) @@ -18,10 +18,10 @@ def __init__(self, radius=8): def drawItemPixmap(self, painter, rectangle, alignment, pixmap): painter.save() pix = QPixmap(pixmap.size()) - pix.fill("#00000000") + pix.fill(QColor("transparent")) p = QPainter(pix) p.setBrush(QBrush(pixmap)) - p.setPen("#00000000") + p.setPen(QColor("transparent")) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.drawRoundedRect(pixmap.rect(), self._radius, self._radius) p.end() diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 8421cc5ec..f5b10cbd7 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1124,6 +1124,8 @@ def thumb_size_callback(self, index: int): Args: index (int): The index of the item_thumbs/ComboBox list to use. """ + SPACING_DIVISOR: int = 10 + MIN_SPACING: int = 12 # Index 2 is the default (Medium) if index < len(self.thumb_sizes) and index >= 0: self.thumb_size = self.thumb_sizes[index][1] @@ -1142,7 +1144,9 @@ def thumb_size_callback(self, index: int): 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)) + self.flow_container.layout().setSpacing( + min(self.thumb_size // SPACING_DIVISOR, MIN_SPACING) + ) def mouse_navigation(self, event: QMouseEvent): # print(event.button()) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index bd05376bd..bf37f9836 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -112,9 +112,10 @@ def _get_mask( pixel_ratio (float): The screen pixel ratio. scale_radius (bool): Option to scale the radius up (Used for Preview Panel). """ + THUMB_SCALE: int = 512 radius_scale: float = 1 if scale_radius: - radius_scale = max(size[0], size[1]) / 512 + radius_scale = max(size[0], size[1]) / THUMB_SCALE item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale)) if not item: From dd90add8281c120fb62894adaf7ddc019f51c762 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 30 Aug 2024 14:41:05 -0700 Subject: [PATCH 49/79] refactor: remove unused code, comments, & imports --- tagstudio/src/qt/helpers/gradient.py | 16 ---------- tagstudio/src/qt/widgets/collage_icon.py | 39 +++++------------------- tagstudio/src/qt/widgets/fields.py | 27 ++-------------- 3 files changed, 9 insertions(+), 73 deletions(-) diff --git a/tagstudio/src/qt/helpers/gradient.py b/tagstudio/src/qt/helpers/gradient.py index c109e5d5a..fe3f7c7de 100644 --- a/tagstudio/src/qt/helpers/gradient.py +++ b/tagstudio/src/qt/helpers/gradient.py @@ -9,17 +9,7 @@ def four_corner_gradient( image: Image.Image, size: tuple[int, int], mask: Image.Image ) -> Image.Image: if image.size != size: - # Old 1 color method. - # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) - # bg = Image.new(mode='RGB',size=size,color=bg_col) - # bg.thumbnail((1, 1)) - # 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(size,resample=Image.Resampling.BILINEAR) - # Four-Corner Gradient Background. - # Not exactly a one-liner, but it's (subjectively) really cool. tl = image.getpixel((0, 0)) tr = image.getpixel(((image.size[0] - 1), 0)) bl = image.getpixel((0, (image.size[1] - 1))) @@ -41,13 +31,7 @@ def four_corner_gradient( 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 - final = Image.new("RGBA", size, (0, 0, 0, 0)) final.paste(image, mask=mask.getchannel(0)) diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index 5d9ac3b43..c73eb7c7e 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import logging -import os import traceback from pathlib import Path @@ -12,25 +11,15 @@ from PIL.Image import DecompressionBombError from PySide6.QtCore import ( QObject, - QThread, Signal, - QRunnable, - Qt, - QThreadPool, - QSize, - QEvent, - QTimer, - QSettings, ) - from src.core.library import Library from src.core.media_types import MediaCategories, MediaType from src.qt.helpers.file_tester import is_readable_video - -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" +ERROR = "[ERROR]" +WARNING = "[WARNING]" +INFO = "[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) @@ -54,7 +43,6 @@ def render( ): entry = self.lib.get_entry(entry_id) filepath = self.lib.library_dir / entry.path / entry.filename - file_type = os.path.splitext(filepath)[1].lower()[1:] color: str = "" try: @@ -86,14 +74,11 @@ def render( if data_only_mode: pic = Image.new("RGB", size, color) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) if not data_only_mode: logging.info( - f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m" + f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{entry.filename}\033[0m" ) - # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') - # sys.stdout.flush() ext: str = filepath.suffix.lower() if MediaType.IMAGE in MediaCategories.get_types(ext): try: @@ -109,7 +94,6 @@ def render( pic = ImageChops.hard_light( pic, Image.new("RGB", size, color) ) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except DecompressionBombError as e: logging.info(f"[ERROR] One of the images was too big ({e})") @@ -137,12 +121,9 @@ def render( pic = ImageChops.hard_light( pic, Image.new("RGB", size, color) ) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except (UnidentifiedImageError, FileNotFoundError): - logging.info( - f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}" - ) + logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}") with Image.open( str( Path(__file__).parents[2] @@ -153,22 +134,16 @@ def render( if data_tint_mode and color: pic = pic.convert(mode="RGB") pic = ImageChops.hard_light(pic, Image.new("RGB", size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except KeyboardInterrupt: - # self.quit(save=False, backup=True) - run = False - # clear() logging.info("\n") logging.info(f"{INFO} Collage operation cancelled.") - clear_scr = False - except: - logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}") + except Exception: + logging.info(f"{ERROR} {entry.path}/{entry.filename}") traceback.print_exc() logging.info("Continuing...") self.done.emit() - # logging.info('Done!') # NOTE: Depreciated def get_file_color(self, ext: str): diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 477a7cd33..03b43d848 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -4,15 +4,14 @@ import math -import os from types import FunctionType, MethodType from pathlib import Path -from typing import Optional, cast, Callable, Any +from typing import Optional, cast, Callable from PIL import Image, ImageQt from PySide6.QtCore import Qt, QEvent from PySide6.QtGui import QPixmap, QEnterEvent -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.helpers.color_overlay import theme_fg_overlay @@ -36,17 +35,13 @@ class FieldContainer(QWidget): def __init__(self, title: str = "Field", inline: bool = True) -> None: super().__init__() - # self.mode:str = mode self.setObjectName("fieldContainer") - # self.item = item self.title: str = title self.inline: bool = inline - # self.editable:bool = editable self.copy_callback: FunctionType = None self.edit_callback: FunctionType = None self.remove_callback: Callable = None button_size = 24 - # self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;') self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128) self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128) @@ -55,7 +50,6 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.root_layout = QVBoxLayout(self) self.root_layout.setObjectName("baseLayout") self.root_layout.setContentsMargins(0, 0, 0, 0) - # self.setStyleSheet('background-color:red;') self.inner_layout = QVBoxLayout() self.inner_layout.setObjectName("innerLayout") @@ -67,7 +61,6 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.root_layout.addWidget(self.inner_container) self.title_container = QWidget() - # self.title_container.setStyleSheet('background:black;') self.title_layout = QHBoxLayout(self.title_container) self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.title_layout.setObjectName("fieldLayout") @@ -80,9 +73,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.title_widget.setObjectName("fieldTitle") self.title_widget.setWordWrap(True) self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;") - # self.title_widget.setStyleSheet('background-color:orange;') self.title_widget.setText(title) - # self.inner_layout.addWidget(self.title_widget) self.title_layout.addWidget(self.title_widget) self.title_layout.addStretch(2) @@ -124,11 +115,8 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.field_layout.setObjectName("fieldLayout") self.field_layout.setContentsMargins(0, 0, 0, 0) self.field_container.setLayout(self.field_layout) - # self.field_container.setStyleSheet('background-color:#666600;') self.inner_layout.addWidget(self.field_container) - # self.set_inner_widget(mode) - def set_copy_callback(self, callback: Optional[MethodType]): if self.copy_button.is_connected: self.copy_button.clicked.disconnect() @@ -156,12 +144,7 @@ def set_remove_callback(self, callback: Optional[Callable]): self.remove_button.is_connected = True def set_inner_widget(self, widget: "FieldWidget"): - # widget.setStyleSheet('background-color:green;') - # self.inner_container.dumpObjectTree() - # logging.info('') if self.field_layout.itemAt(0): - # logging.info(f'Removing {self.field_layout.itemAt(0)}') - # self.field_layout.removeItem(self.field_layout.itemAt(0)) self.field_layout.itemAt(0).widget().deleteLater() self.field_layout.addWidget(widget) @@ -177,12 +160,7 @@ def set_title(self, title: str): def set_inline(self, inline: bool): self.inline = inline - # def set_editable(self, editable:bool): - # self.editable = editable - def enterEvent(self, event: QEnterEvent) -> None: - # if self.field_layout.itemAt(1): - # self.field_layout.itemAt(1). # NOTE: You could pass the hover event to the FieldWidget if needed. if self.copy_callback: self.copy_button.setHidden(False) @@ -207,5 +185,4 @@ class FieldWidget(QWidget): def __init__(self, title) -> None: super().__init__() - # self.item = item self.title = title From ad0f472a862795eb84cf44683973374c19434d4c Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 30 Aug 2024 14:46:22 -0700 Subject: [PATCH 50/79] refactor: rename args to not shadow builtins --- tagstudio/src/core/palette.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 45f2cb843..7edacc747 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -317,18 +317,18 @@ class ColorType(int, Enum): } -def get_tag_color(type, color): +def get_tag_color(color_type, color): color = color.lower() try: - if type == ColorType.TEXT: - return get_tag_color(_TAG_COLORS[color][type], color) + if color_type == ColorType.TEXT: + return get_tag_color(_TAG_COLORS[color][color_type], color) else: - return _TAG_COLORS[color][type] + return _TAG_COLORS[color][color_type] except KeyError: return "#FF00FF" -def get_ui_color(type: ColorType, color: str): +def get_ui_color(color_type: ColorType, color: str): """Returns a hex value given a color name and ColorType.""" color = color.lower() - return _UI_COLORS.get(color).get(type) + return _UI_COLORS.get(color).get(color_type) From 2faed2747cf2e16bca7f21f0598c22117611aa73 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 30 Aug 2024 16:53:53 -0700 Subject: [PATCH 51/79] refactor: remove unused vars from `thumb_renderer` --- tagstudio/src/qt/widgets/thumb_renderer.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index bf37f9836..9df1967d2 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -54,13 +54,6 @@ class ThumbRenderer(QObject): updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - # 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", - math.floor(12 * FONT_PIXEL_RATIO), - ) - def __init__(self) -> None: """Initialize the class.""" super().__init__() @@ -918,13 +911,6 @@ def render( "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio ) - if ThumbRenderer.FONT_PIXEL_RATIO != pixel_ratio: - ThumbRenderer.FONT_PIXEL_RATIO = pixel_ratio - ThumbRenderer.ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * ThumbRenderer.FONT_PIXEL_RATIO), - ) - if is_loading: final = loading_thumb.resize( (adj_size, adj_size), resample=Image.Resampling.BILINEAR From 69e1b20adf29888ff93e4a1809177e8cb76f198d Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 30 Aug 2024 16:57:02 -0700 Subject: [PATCH 52/79] fix: handle ValueError in `render()` Handle ValueErrors in `render()`. This case was encountered when attempting to render an `XPM` file during testing. --- tagstudio/src/qt/widgets/thumb_renderer.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 9df1967d2..4bcb4158c 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -1012,10 +1012,7 @@ def render( size=(adj_size, adj_size), pixel_ratio=pixel_ratio, ) - except ( - UnidentifiedImageError, - DecompressionBombError, - ) as e: + except (UnidentifiedImageError, DecompressionBombError, ValueError) as e: logging.info( f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" ) From 992aa829c956ce81e6000904a526bf108c98fe4a Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Fri, 30 Aug 2024 17:00:47 -0700 Subject: [PATCH 53/79] docs: add FFmpeg requirement to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 592332e31..0d9ae28b3 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ If you're interested in contributing to TagStudio, please take a look at the [co To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around. +For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system. + > [!IMPORTANT] > On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application. From 31ced00f54e27c6127050978f203b0a9e8d0bff3 Mon Sep 17 00:00:00 2001 From: peterbousaada Date: Wed, 12 Jun 2024 19:14:36 -0400 Subject: [PATCH 54/79] Added the option to delete files in the right click context menu --- tagstudio/src/qt/helpers/file_deleter.py | 40 ++++++++++++++++++++++++ tagstudio/src/qt/widgets/item_thumb.py | 13 ++++++++ 2 files changed, 53 insertions(+) create mode 100644 tagstudio/src/qt/helpers/file_deleter.py diff --git a/tagstudio/src/qt/helpers/file_deleter.py b/tagstudio/src/qt/helpers/file_deleter.py new file mode 100644 index 000000000..6c1de715c --- /dev/null +++ b/tagstudio/src/qt/helpers/file_deleter.py @@ -0,0 +1,40 @@ +import logging +import os.path +import subprocess +import sys +import traceback +from pathlib import Path +from typing import Callable + +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" + +logging.basicConfig(format="%(message)s", level=logging.INFO) + + +def delete_file(path: str | Path, callback: Callable): + _path = str(path) + logging.info(f"Deleting file: {_path}") + if not os.path.exists(_path): + logging.error(f"File not found: {_path}") + return + try: + os.remove(path) + callback() + except: + traceback.print_exc() + + +class FileDeleterHelper: + def __init__(self, filepath: str | Path): + self.filepath = str(filepath) + + def set_filepath(self, filepath: str | Path): + self.filepath = str(filepath) + + def set_delete_callback(self, callback: Callable): + self.delete_callback = callback + + def delete_file(self): + delete_file(self.filepath, self.delete_callback) diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 5cd1ea23b..8d0636db1 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -30,6 +30,7 @@ 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.helpers.file_deleter import FileDeleterHelper from src.qt.widgets.thumb_renderer import ThumbRenderer from src.qt.widgets.thumb_button import ThumbButton @@ -193,12 +194,16 @@ def __init__( self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.opener = FileOpenerHelper("") + self.deleter = FileDeleterHelper("") open_file_action = QAction("Open file", self) open_file_action.triggered.connect(self.opener.open_file) open_explorer_action = QAction("Open file in explorer", self) open_explorer_action.triggered.connect(self.opener.open_explorer) + delete_action = QAction("Delete", self) + delete_action.triggered.connect(self.deleter.delete_file) self.thumb_button.addAction(open_file_action) self.thumb_button.addAction(open_explorer_action) + self.thumb_button.addAction(delete_action) # Static Badges ======================================================== @@ -440,6 +445,8 @@ def set_item_id(self, id: int): entry = self.lib.get_entry(self.item_id) filepath = self.lib.library_dir / entry.path / entry.filename self.opener.set_filepath(filepath) + self.deleter.set_filepath(filepath) + self.deleter.set_delete_callback(self._on_delete) def assign_favorite(self, value: bool): # Switching mode to None to bypass mode-specific operations when the @@ -540,3 +547,9 @@ def mouseMoveEvent(self, event): mimedata.setUrls(paths) drag.setMimeData(mimedata) drag.exec(Qt.DropAction.CopyAction) + + def _on_delete(self): + entry = self.lib.get_entry(self.item_id) + self.lib.remove_entry(self.item_id) + self.panel.driver.purge_item_from_navigation(entry.type, self.item_id) + self.panel.driver.filter_items() From bbd12b50d7ecb19e4c76a6f89dbdd40126010a6f Mon Sep 17 00:00:00 2001 From: peterbousaada Date: Thu, 13 Jun 2024 08:59:16 -0400 Subject: [PATCH 55/79] Updated to use pathlib instead of os --- tagstudio/src/qt/helpers/file_deleter.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tagstudio/src/qt/helpers/file_deleter.py b/tagstudio/src/qt/helpers/file_deleter.py index 6c1de715c..104b77465 100644 --- a/tagstudio/src/qt/helpers/file_deleter.py +++ b/tagstudio/src/qt/helpers/file_deleter.py @@ -1,5 +1,5 @@ import logging -import os.path +import pathlib import subprocess import sys import traceback @@ -15,12 +15,13 @@ def delete_file(path: str | Path, callback: Callable): _path = str(path) + _file = Path(_path) logging.info(f"Deleting file: {_path}") - if not os.path.exists(_path): + if not _file.exists(): logging.error(f"File not found: {_path}") return try: - os.remove(path) + _file.unlink() callback() except: traceback.print_exc() From c196171aaefc76ed183889e32634958a5f5e320d Mon Sep 17 00:00:00 2001 From: peterbousaada Date: Thu, 13 Jun 2024 11:47:24 -0400 Subject: [PATCH 56/79] - removed unused imports - swapped out `typing` to `collections.abc` - removed unnecessary `str()` conversion in `FileDeleterHelper` constructor and `set_filepath` --- tagstudio/src/qt/helpers/file_deleter.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tagstudio/src/qt/helpers/file_deleter.py b/tagstudio/src/qt/helpers/file_deleter.py index 104b77465..d8a36e346 100644 --- a/tagstudio/src/qt/helpers/file_deleter.py +++ b/tagstudio/src/qt/helpers/file_deleter.py @@ -1,10 +1,7 @@ import logging -import pathlib -import subprocess -import sys import traceback from pathlib import Path -from typing import Callable +from collections.abc import Callable ERROR = f"[ERROR]" WARNING = f"[WARNING]" @@ -12,7 +9,6 @@ logging.basicConfig(format="%(message)s", level=logging.INFO) - def delete_file(path: str | Path, callback: Callable): _path = str(path) _file = Path(_path) @@ -29,10 +25,10 @@ def delete_file(path: str | Path, callback: Callable): class FileDeleterHelper: def __init__(self, filepath: str | Path): - self.filepath = str(filepath) + self.filepath = filepath def set_filepath(self, filepath: str | Path): - self.filepath = str(filepath) + self.filepath = filepath def set_delete_callback(self, callback: Callable): self.delete_callback = callback From c44cbbb6064f5ac6b377b556421cb1d57889e9ac Mon Sep 17 00:00:00 2001 From: peterbousaada Date: Thu, 13 Jun 2024 11:56:22 -0400 Subject: [PATCH 57/79] Swapped stacktrace to logging.exception in `file_deleter.py` --- tagstudio/src/qt/helpers/file_deleter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/qt/helpers/file_deleter.py b/tagstudio/src/qt/helpers/file_deleter.py index d8a36e346..3e99b6963 100644 --- a/tagstudio/src/qt/helpers/file_deleter.py +++ b/tagstudio/src/qt/helpers/file_deleter.py @@ -9,6 +9,7 @@ logging.basicConfig(format="%(message)s", level=logging.INFO) + def delete_file(path: str | Path, callback: Callable): _path = str(path) _file = Path(_path) @@ -19,8 +20,8 @@ def delete_file(path: str | Path, callback: Callable): try: _file.unlink() callback() - except: - traceback.print_exc() + except Exception as exception: + logging.exception(exception) class FileDeleterHelper: From 1e23ec8b52fdbbe5a131bed8d03fbb5d13f55c44 Mon Sep 17 00:00:00 2001 From: UnusualEgg <76134596+UnusualEgg@users.noreply.github.com> Date: Thu, 22 Aug 2024 21:02:03 -0400 Subject: [PATCH 58/79] refactor: combine `open` launch args (#364) Combine the `--open` and `-o` launch arguments into a single argument option. --- tagstudio/tag_studio.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tagstudio/tag_studio.py b/tagstudio/tag_studio.py index 948c6ff88..1861474f3 100644 --- a/tagstudio/tag_studio.py +++ b/tagstudio/tag_studio.py @@ -17,14 +17,9 @@ def main(): # Parse arguments. parser = argparse.ArgumentParser() - parser.add_argument( - "--open", - dest="open", - type=str, - help="Path to a TagStudio Library folder to open on start.", - ) parser.add_argument( "-o", + "--open", dest="open", type=str, help="Path to a TagStudio Library folder to open on start.", From 959de9b9fab969b4607ea3a1a0dad53e9cf1015d Mon Sep 17 00:00:00 2001 From: Sean Krueger Date: Mon, 3 Jun 2024 11:51:41 -0500 Subject: [PATCH 59/79] Setup and activate virtual environment via flake When using the nix flake to generate a development shell, the python virtual environment will now automatically be created and dependecies from both requirements.txt and requirements-dev.txt will be installed. This removes the need for using the setup script after entering the dev shell. Exec bash must be the last thing called, as any other commands past it will not get executed by the shell hook. Also removes some duplicate dependencies that I found. --- flake.nix | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/flake.nix b/flake.nix index dbe54de5d..3aacca51d 100644 --- a/flake.nix +++ b/flake.nix @@ -1,4 +1,6 @@ { + description = "Tag Studio Development Environment"; + inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; @@ -15,6 +17,9 @@ qt6Pkgs = qt6Nixpkgs.legacyPackages.x86_64-linux; in { devShells.x86_64-linux.default = pkgs.mkShell { + name = "Tag Studio Virtual Environment"; + venvDir = "./.venv"; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.gcc-unwrapped pkgs.zlib @@ -35,18 +40,17 @@ qt6Pkgs.qt6.full qt6Pkgs.qt6.qtbase ]; + buildInputs = with pkgs; [ cmake gdb zstd - python312Packages.pip python312Full - python312Packages.virtualenv # run virtualenv . + python312Packages.pip python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip + python312Packages.venvShellHook # Initializes a venv in $venvDir libgcc - makeWrapper - bashInteractive glib libxkbcommon freetype @@ -70,14 +74,31 @@ # this is for the shellhook portion qt6Pkgs.qt6.wrapQtAppsHook ]; + + # Run after the virtual environment is created + postVenvCreation = '' + unset SOURCE_DATE_EPOCH + + echo Installing dependencies into virtual environment + pip install -r requirements.txt + pip install -r requirements-dev.txt + ''; + # set the environment variables that Qt apps expect - shellHook = '' + postShellHook = '' + unset SOURCE_DATE_EPOCH + export QT_QPA_PLATFORM=wayland export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH # export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/ export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix} bashdir=$(mktemp -d) makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}" + + echo Activating Virtual Environment + source $venvDir/bin/activate + export PYTHONPATH=$PWD/$venvDir/${pkgs.python312Full.sitePackages}:$PYTHONPATH + exec "$bashdir/bash" ''; }; From d0ad47ffa50ebeae486de3d7558c8950aa56e607 Mon Sep 17 00:00:00 2001 From: Sean Krueger Date: Thu, 6 Jun 2024 20:50:22 -0500 Subject: [PATCH 60/79] Add xcb as fallback when wayland fails to load Mostly as a fallback for xserver. --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 3aacca51d..23df62250 100644 --- a/flake.nix +++ b/flake.nix @@ -88,7 +88,7 @@ postShellHook = '' unset SOURCE_DATE_EPOCH - export QT_QPA_PLATFORM=wayland + export QT_QPA_PLATFORM="wayland;xcb" export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH # export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/ export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix} From 06a230fb1d154c680f4de05ed322f67d6a2ca43f Mon Sep 17 00:00:00 2001 From: Sean Krueger Date: Fri, 7 Jun 2024 18:09:42 -0500 Subject: [PATCH 61/79] Install ruff via nixpkgs --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index 23df62250..2549173ed 100644 --- a/flake.nix +++ b/flake.nix @@ -49,6 +49,7 @@ python312Packages.pip python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip python312Packages.venvShellHook # Initializes a venv in $venvDir + ruff # Ruff cannot be installed via pip libgcc glib @@ -82,6 +83,7 @@ echo Installing dependencies into virtual environment pip install -r requirements.txt pip install -r requirements-dev.txt + pip uninstall -y ruff # Hacky solution to not fight with other dev deps ''; # set the environment variables that Qt apps expect From 8b9a0e90d356b428451d9e322e77e9012b1e15ba Mon Sep 17 00:00:00 2001 From: Sean Krueger Date: Mon, 10 Jun 2024 19:16:24 -0500 Subject: [PATCH 62/79] Update Contributing documentation for dev on Nix Moves the previous updated blurb from the README to the new CONTRIBUTING file. Also reworks some wording to link to the Flake nix wiki page for nix users who haven't enable flakes yet. --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 942aa3ebb..24f3b0e3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,10 +64,10 @@ _Learn more about setting up a virtual environment [here](https://docs.python.or - Run the "TagStudio.sh" script and the program should launch! (Make sure that the script is marked as executable if on Linux). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `./TagStudio.sh`. - - **NixOS** (TagStudio.sh) + - **NixOS** (Nix Flake) > [!WARNING] > Support for NixOS is still a work in progress. - - Use the provided `flake.nix` file to create and enter a working environment by running `nix develop`. Then, run the `TagStudio.sh` script. + - Use the provided [Flake](https://nixos.wiki/wiki/Flakes) to create and enter a working environment by running `nix develop`. Then, run the program via `python3 tagstudio/tag_studio.py` from the root directory. - **Any** (No Scripts) From 26f28b5abbd752043a3c82e728c45c7ca777682d Mon Sep 17 00:00:00 2001 From: Xarvex Date: Sun, 16 Jun 2024 02:59:59 -0500 Subject: [PATCH 63/79] fix(flake): resolve mypy access to libraries --- flake.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 2549173ed..ccca2cb70 100644 --- a/flake.nix +++ b/flake.nix @@ -50,6 +50,7 @@ python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip python312Packages.venvShellHook # Initializes a venv in $venvDir ruff # Ruff cannot be installed via pip + mypy # MyPy cannot be installed via pip libgcc glib @@ -81,9 +82,12 @@ unset SOURCE_DATE_EPOCH echo Installing dependencies into virtual environment + pip install PySide6==6.6.2 # 6.6.3 has faulty .pyi files pip install -r requirements.txt pip install -r requirements-dev.txt - pip uninstall -y ruff # Hacky solution to not fight with other dev deps + # Hacky solution to not fight with other dev deps + # May show failure if skipped due to same version with nixpkgs + pip uninstall -y mypy ruff ''; # set the environment variables that Qt apps expect From 43a0b937aaee2c1bb4a8fce3e1a4e79a85d5d6fd Mon Sep 17 00:00:00 2001 From: Sean Krueger Date: Mon, 17 Jun 2024 13:02:52 -0500 Subject: [PATCH 64/79] Bump Qt6 version to 6.7.1 --- flake.lock | 14 +++++++------- flake.nix | 5 ++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/flake.lock b/flake.lock index e6a43b0b8..f83ab58f0 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1717602782, - "narHash": "sha256-pL9jeus5QpX5R+9rsp3hhZ+uplVHscNJh8n8VpqscM0=", + "lastModified": 1718318537, + "narHash": "sha256-4Zu0RYRcAY/VWuu6awwq4opuiD//ahpc2aFHg2CWqFY=", "owner": "nixos", "repo": "nixpkgs", - "rev": "e8057b67ebf307f01bdcc8fba94d94f75039d1f6", + "rev": "e9ee548d90ff586a6471b4ae80ae9cfcbceb3420", "type": "github" }, "original": { @@ -18,17 +18,17 @@ }, "qt6Nixpkgs": { "locked": { - "lastModified": 1711460435, - "narHash": "sha256-Qb/J9NFk2Qemg7vTl8EDCto6p3Uf/GGORkGhTQJLj9U=", + "lastModified": 1716287118, + "narHash": "sha256-iUTrXABmJAkPRhwPB8GEP7k52OWHVSRtMzlKQ2kIrz4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f862bd46d3020bcfe7195b3dad638329271b0524", + "rev": "47da0aee5616a063015f10ea593688646f2377e4", "type": "github" }, "original": { "owner": "NixOS", "repo": "nixpkgs", - "rev": "f862bd46d3020bcfe7195b3dad638329271b0524", + "rev": "47da0aee5616a063015f10ea593688646f2377e4", "type": "github" } }, diff --git a/flake.nix b/flake.nix index ccca2cb70..69adee170 100644 --- a/flake.nix +++ b/flake.nix @@ -5,8 +5,8 @@ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; qt6Nixpkgs = { - # Commit bumping to qt6.6.3 - url = "github:NixOS/nixpkgs/f862bd46d3020bcfe7195b3dad638329271b0524"; + # Commit bumping to qt6.7.1 + url = "github:NixOS/nixpkgs/47da0aee5616a063015f10ea593688646f2377e4"; }; }; @@ -82,7 +82,6 @@ unset SOURCE_DATE_EPOCH echo Installing dependencies into virtual environment - pip install PySide6==6.6.2 # 6.6.3 has faulty .pyi files pip install -r requirements.txt pip install -r requirements-dev.txt # Hacky solution to not fight with other dev deps From 9d034935f554dc7078dcfb41c9010617e25a21b5 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sun, 25 Aug 2024 17:11:57 -0700 Subject: [PATCH 65/79] chore: bump version to v9.4.0 --- tagstudio/src/core/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 00cb0a1ec..bac1edd46 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -1,4 +1,4 @@ -VERSION: str = "9.3.2" # Major.Minor.Patch +VERSION: str = "9.4.0" # Major.Minor.Patch VERSION_BRANCH: str = "" # Usually "" or "Pre-Release" # The folder & file names where TagStudio keeps its data relative to a library. From 662b6f5d983bd0e682c8951cb06df8bebdd31eb6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Mon, 26 Aug 2024 21:07:22 -0700 Subject: [PATCH 66/79] feat: send deleted files to system trash This refactors the file deletion code to send files to the system trash instead of performing a hard deletion. It also fixes deleting video files and GIFs loaded in the Preview Panel. --- requirements.txt | 1 + tagstudio/resources/qt/videos/placeholder.mp4 | Bin 0 -> 2590 bytes tagstudio/src/core/media_types.py | 13 +++ tagstudio/src/qt/helpers/file_deleter.py | 52 +++++----- tagstudio/src/qt/resource_manager.py | 14 +++ tagstudio/src/qt/resources.json | 4 + tagstudio/src/qt/ts_qt.py | 90 +++++++++++++++++- tagstudio/src/qt/widgets/fields.py | 6 +- tagstudio/src/qt/widgets/item_thumb.py | 21 ++-- tagstudio/src/qt/widgets/preview_panel.py | 79 ++++++++++++++- 10 files changed, 228 insertions(+), 52 deletions(-) create mode 100644 tagstudio/resources/qt/videos/placeholder.mp4 diff --git a/requirements.txt b/requirements.txt index 5658340c5..39156a437 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ pydub==0.25.1 mutagen==1.47.0 numpy==1.26.4 ffmpeg-python==0.2.0 +Send2Trash==1.8.3 \ No newline at end of file diff --git a/tagstudio/resources/qt/videos/placeholder.mp4 b/tagstudio/resources/qt/videos/placeholder.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1e22e4c724aeefa2a44b44f3462f075495373b1e GIT binary patch literal 2590 zcmdT`&1)M+6d$c+MRpZejve9mp}-u$ss+Ac30A({p#*$ z69)o0rq>>d>7mECy@gW9C6~}b4~5WMA(wpgKWHiKAw_v_b~KVVA1#Fj^0YJW^Y`BD z%)UW{5ZdBfp%VulAsRsrLuZLu^TI}r5K?SKwoS;`_nyT#=t$Z`8Ri4+^Q(Km*evE7 z6#N9HLhifi;tsg-o%<^{fY%G-r?kJ=g{R?KbVuL#edoQ?jq{Ef1#!^gbfcm#HRw6t z3@kgMo3+YfrA`p`YhnE1Pv6dbz8$YW`1;n5XSNsJKjO$V>a=6%i%ay1m|J0N=#5IX zTBTvjSYD=X;u;OwG<4ehG=$)G5E{!mb*uuCc}I#=Bht~(sC%s?ZxCakTo2bHEWM67~K6_N2 z6P62T$$ga{dvY~j*ku`47KzI5&7ukuDn{lhyrnD>RhY@5if^kJi7HMji$oOJ&- zRg6SU9#9sEntUgVDjiZW5>+~^ED}{ZmPJh+Q85xVbyQg-YU=$gs(f6U|T!}b>4R4!4(BA%&QH8W;ygU7)snT@!i3p z-SsSoflNK?#ryH`58x%_WXEzNA;FY57F>rz+5|n=V7}$r7^Ag4$Hzjmi6@fEH44Cj zmHOtbbTMc|f2kL_G?lgEF>ksc--&rF4k@*h9lVo4MjFj24Pe>;5eL$95vh4(p6mBP zt3cNE9Ngpm$1lClbLu*uF#|X9P4_C~&k<~9QVv-h0>^?l=3*|+0>o4oTMgR*uY+fV zG>F|chq2SC9gao#MQ)+I9z8GXA|vy#R#QmBwc=fd>*nCQCVj}jt{nuv?|&SycV>Xa zfN%{BV7D`UrH%BJ+_(SMSE5Qfy`rz=N=WYAf9fHbuR7X8vIkYEj~vflhd%+sHX=XE zHY)jP8)59fXdkjI+ozFfpM{Z{xQCL`I|2_EDEsX~oY=z`eGixe2Y%Os?*Ts|Y1?&P zm?Y_0oB{VeTW}D`k bool: + """Sends a file to the system trash. - def set_filepath(self, filepath: str | Path): - self.filepath = filepath - - def set_delete_callback(self, callback: Callable): - self.delete_callback = callback - - def delete_file(self): - delete_file(self.filepath, self.delete_callback) + Args: + path (str | Path): The path of the file to delete. + """ + _path = Path(path) + try: + logging.info(f"[delete_file] Sending to Trash: {_path}") + send2trash(_path) + return True + except PermissionError as e: + logging.error(f"[delete_file][ERROR] PermissionError: {e}") + except FileNotFoundError: + logging.error(f"[delete_file][ERROR] File Not Found: {_path}") + except Exception as e: + logging.error(e) + return False diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index 3c4e80996..c38d382a4 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -31,6 +31,20 @@ def __init__(self) -> None: ) ResourceManager._initialized = True + @staticmethod + def get_path(id: str) -> Path | None: + """Get a resource's path from the ResourceManager. + Args: + id (str): The name of the resource. + + Returns: + Path: The resource path if found, else None. + """ + res: dict = ResourceManager._map.get(id) + if res: + return Path(__file__).parents[2] / "resources" / res.get("path") + return None + def get(self, id: str) -> Any: """Get a resource from the ResourceManager. This can include resources inside and outside of QResources, and will return diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index ef007b6f2..e5857909b 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -90,5 +90,9 @@ "thumb_loading": { "path": "qt/images/thumb_loading.png", "mode": "pil" + }, + "placeholder_mp4": { + "path": "qt/videos/placeholder.mp4", + "mode": "rb" } } diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index f5b10cbd7..1c7215855 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -69,9 +69,11 @@ TAG_FAVORITE, TAG_ARCHIVED, ) +from src.core.media_types import MediaCategories, MediaType from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout from src.qt.main_window import Ui_MainWindow +from src.qt.helpers.file_deleter import delete_file from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.resource_manager import ResourceManager @@ -532,7 +534,7 @@ def start(self) -> None: folders_to_tags_action.triggered.connect(lambda: ftt_modal.show()) macros_menu.addAction(folders_to_tags_action) - # Help Menu ========================================================== + # Help Menu ============================================================ self.repo_action = QAction("Visit GitHub Repository", menu_bar) self.repo_action.triggered.connect( lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio") @@ -549,6 +551,9 @@ def start(self) -> None: menu_bar.addMenu(window_menu) menu_bar.addMenu(help_menu) + # ====================================================================== + + # Preview Panel -------------------------------------------------------- self.preview_panel = PreviewPanel(self.lib, self) l: QHBoxLayout = self.main_window.splitter l.addWidget(self.preview_panel) @@ -824,6 +829,73 @@ def show_file_extension_modal(self): self.modal.saved.connect(lambda: (panel.save(), self.filter_items(""))) self.modal.show() + def delete_files_callback(self, origin_path: str | Path): + """Callback to send on or more files to the system trash. + + If 0-1 items are currently selected, the origin_path is used to delete the file + from the originating context menu item. + If there are currently multiple items selected, + then the selection buffer is used to determine the files to be deleted. + + Args: + origin_path(str): The file path associated with the widget making the call. + May or may not be the file targeted, depending on the selection rules. + """ + _op: Path = Path(origin_path) + item = None + deleted_count: int = 0 + filepath: Path = None # Initialize + if len(self.selected) <= 1: + if self.selected: + item = self.lib.get_entry(self.selected[0][1]) + filepath = self.lib.library_dir / item.path / item.filename + # If the file to be deleted is currently being displayed on the Preview Panel, + # tell the panel to stop any use of the file. + if origin_path == filepath: + self.preview_panel.stop_file_use() + self.main_window.statusbar.showMessage(f'Deleting file "{origin_path}"...') + self.main_window.statusbar.repaint() + if delete_file(_op): + op_item = self.lib.get_entry_id_from_filepath(_op) + self.lib.remove_entry(op_item) + self.purge_item_from_navigation(ItemType.ENTRY, op_item) + deleted_count += 1 + elif len(self.selected) > 1: + for i, item_pair in enumerate(self.selected): + if item_pair[0] == ItemType.ENTRY: + item = self.lib.get_entry(item_pair[1]) + filepath = self.lib.library_dir / item.path / item.filename + self.main_window.statusbar.showMessage( + f'Deleting file "{filepath}"...' + ) + self.main_window.statusbar.repaint() + if delete_file(filepath): + self.purge_item_from_navigation(item.type, item.id) + self.lib.remove_entry(item.id) + deleted_count += 1 + self.selected.clear() + + self.filter_items() + self.preview_panel.update_widgets() + + if len(self.selected) <= 1 and deleted_count == 0: + self.main_window.statusbar.showMessage( + "No files deleted. Check if any of the files are currently in use." + ) + elif len(self.selected) <= 1 and deleted_count == 1: + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!") + elif len(self.selected) > 1 and deleted_count == 0: + self.main_window.statusbar.showMessage( + "No files deleted! Check if any of the files are currently in use." + ) + elif len(self.selected) > 1 and deleted_count < len(self.selected): + self.main_window.statusbar.showMessage( + f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently in use" + ) + elif len(self.selected) > 1 and deleted_count == len(self.selected): + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!") + self.main_window.statusbar.repaint() + def add_new_files_callback(self): """Runs when user initiates adding new files to the Library.""" # # if self.lib.files_not_in_library: @@ -1453,12 +1525,20 @@ def update_thumbs(self): for i, item_thumb in enumerate(self.item_thumbs, start=0): if i < len(self.nav_frames[self.cur_frame_idx].contents): - filepath = "" + filepath: Path = None # Initialize if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY: entry = self.lib.get_entry( self.nav_frames[self.cur_frame_idx].contents[i][1] ) - filepath = self.lib.library_dir / entry.path / entry.filename + filepath: Path = self.lib.library_dir / entry.path / entry.filename + + try: + item_thumb.delete_action.triggered.disconnect() + except RuntimeWarning: + pass + item_thumb.delete_action.triggered.connect( + lambda checked=False, f=filepath: self.delete_files_callback(f) + ) item_thumb.set_item_id(entry.id) item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED)) @@ -1503,7 +1583,9 @@ def update_thumbs(self): else collation.e_ids_and_pages[0][0] ) cover_e = self.lib.get_entry(cover_id) - filepath = self.lib.library_dir / cover_e.path / cover_e.filename + filepath: Path = ( + self.lib.library_dir / cover_e.path / cover_e.filename + ) item_thumb.set_count(str(len(collation.e_ids_and_pages))) item_thumb.update_clickable( clickable=( diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 03b43d848..78cb66f32 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -120,6 +120,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: def set_copy_callback(self, callback: Optional[MethodType]): if self.copy_button.is_connected: self.copy_button.clicked.disconnect() + self.copy_button.is_connected = False self.copy_callback = callback self.copy_button.clicked.connect(callback) @@ -129,6 +130,7 @@ def set_copy_callback(self, callback: Optional[MethodType]): def set_edit_callback(self, callback: Optional[MethodType]): if self.edit_button.is_connected: self.edit_button.clicked.disconnect() + self.edit_button.is_connected = False self.edit_callback = callback self.edit_button.clicked.connect(callback) @@ -138,10 +140,12 @@ def set_edit_callback(self, callback: Optional[MethodType]): def set_remove_callback(self, callback: Optional[Callable]): if self.remove_button.is_connected: self.remove_button.clicked.disconnect() + self.remove_button.is_connected = False self.remove_callback = callback self.remove_button.clicked.connect(callback) - self.remove_button.is_connected = True + if callback is not None: + self.remove_button.is_connected = True def set_inner_widget(self, widget: "FieldWidget"): if self.field_layout.itemAt(0): diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 8d0636db1..5abe60324 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -8,6 +8,7 @@ import typing from pathlib import Path from typing import Optional +import platform from PIL import Image, ImageQt from PySide6.QtCore import Qt, QSize, QEvent, QMimeData, QUrl @@ -30,7 +31,6 @@ 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.helpers.file_deleter import FileDeleterHelper from src.qt.widgets.thumb_renderer import ThumbRenderer from src.qt.widgets.thumb_button import ThumbButton @@ -194,16 +194,19 @@ def __init__( self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.opener = FileOpenerHelper("") - self.deleter = FileDeleterHelper("") open_file_action = QAction("Open file", self) open_file_action.triggered.connect(self.opener.open_file) open_explorer_action = QAction("Open file in explorer", self) open_explorer_action.triggered.connect(self.opener.open_explorer) - delete_action = QAction("Delete", self) - delete_action.triggered.connect(self.deleter.delete_file) + + trash_term: str = "Trash" + if platform.system() == "Windows": + trash_term = "Recycle Bin" + self.delete_action = QAction(f"Send file to {trash_term}", self) + self.thumb_button.addAction(open_file_action) self.thumb_button.addAction(open_explorer_action) - self.thumb_button.addAction(delete_action) + self.thumb_button.addAction(self.delete_action) # Static Badges ======================================================== @@ -445,8 +448,6 @@ def set_item_id(self, id: int): entry = self.lib.get_entry(self.item_id) filepath = self.lib.library_dir / entry.path / entry.filename self.opener.set_filepath(filepath) - self.deleter.set_filepath(filepath) - self.deleter.set_delete_callback(self._on_delete) def assign_favorite(self, value: bool): # Switching mode to None to bypass mode-specific operations when the @@ -547,9 +548,3 @@ def mouseMoveEvent(self, event): mimedata.setUrls(paths) drag.setMimeData(mimedata) drag.exec(Qt.DropAction.CopyAction) - - def _on_delete(self): - entry = self.lib.get_entry(self.item_id) - self.lib.remove_entry(self.item_id) - self.panel.driver.purge_item_from_navigation(entry.type, self.item_id) - self.panel.driver.filter_items() diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 458724719..3b94f827e 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -4,6 +4,7 @@ import logging from pathlib import Path +import platform import time import typing from datetime import datetime as dt @@ -11,7 +12,7 @@ import rawpy from PIL import Image, UnidentifiedImageError, ImageFont from PIL.Image import DecompressionBombError -from PySide6.QtCore import QModelIndex, Signal, Qt, QSize +from PySide6.QtCore import QModelIndex, Signal, Qt, QSize, QByteArray, QBuffer from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie from PySide6.QtWidgets import ( QWidget, @@ -45,6 +46,7 @@ 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 +from src.qt.resource_manager import ResourceManager # Only import for type checking/autocompletion, will not be imported at runtime. @@ -98,6 +100,10 @@ def __init__(self, library: Library, driver: "QtDriver"): self.open_file_action = QAction("Open file", self) self.open_explorer_action = QAction("Open file in explorer", self) + self.trash_term: str = "Trash" + if platform.system() == "Windows": + self.trash_term = "Recycle Bin" + self.delete_action = QAction(f"Send file to {self.trash_term}", self) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) @@ -105,6 +111,7 @@ def __init__(self, library: Library, driver: "QtDriver"): 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_img.addAction(self.delete_action) self.preview_gif = QLabel() self.preview_gif.setMinimumSize(*self.img_button_size) @@ -112,10 +119,13 @@ def __init__(self, library: Library, driver: "QtDriver"): 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.addAction(self.delete_action) self.preview_gif.hide() + self.gif_buffer: QBuffer = QBuffer() self.preview_vid = VideoPlayer(driver) self.preview_vid.hide() + self.preview_vid.addAction(self.delete_action) self.thumb_renderer = ThumbRenderer() self.thumb_renderer.updated.connect( lambda ts, i, s: (self.preview_img.setIcon(i)) @@ -490,6 +500,14 @@ def update_widgets(self): ) if self.preview_img.is_connected: self.preview_img.clicked.disconnect() + self.preview_img.is_connected = False + + try: + self.delete_action.triggered.disconnect() + except RuntimeWarning: + pass + self.delete_action.setEnabled(False) + for i, c in enumerate(self.containers): c.setHidden(True) self.preview_img.show() @@ -529,6 +547,17 @@ def update_widgets(self): ) self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + try: + self.delete_action.triggered.disconnect() + except RuntimeError: + pass + self.delete_action.setText(f"Send file to {self.trash_term}") + self.delete_action.triggered.connect( + lambda checked=False, + f=filepath: self.driver.delete_files_callback(f) + ) + self.delete_action.setEnabled(True) + self.opener = FileOpenerHelper(filepath) self.open_file_action.triggered.connect(self.opener.open_file) self.open_explorer_action.triggered.connect( @@ -539,16 +568,24 @@ def update_widgets(self): ext: str = filepath.suffix.lower() try: if filepath.suffix.lower() in [".gif"]: - movie = QMovie(str(filepath)) + with open(filepath, mode="rb") as f: + if self.preview_gif.movie(): + self.preview_gif.movie().stop() + self.gif_buffer.close() + + ba = f.read() + self.gif_buffer.setData(ba) + movie = QMovie(self.gif_buffer, QByteArray()) + self.preview_gif.setMovie(movie) + movie.start() + 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() @@ -660,6 +697,7 @@ def update_widgets(self): # 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.is_connected = False self.preview_img.clicked.connect( lambda checked=False, filepath=filepath: open_file(filepath) ) @@ -701,6 +739,16 @@ def update_widgets(self): ) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) + try: + self.delete_action.triggered.disconnect() + except RuntimeError: + pass + self.delete_action.setText(f"Send files to {self.trash_term}") + self.delete_action.triggered.connect( + lambda checked=False, f=None: self.driver.delete_files_callback(f) + ) + self.delete_action.setEnabled(True) + ratio: float = self.devicePixelRatio() self.thumb_renderer.render( time.time(), @@ -1140,3 +1188,26 @@ def remove_message_box(self, prompt: str, callback: typing.Callable) -> None: # logging.info(result) if result == 3: callback() + + def stop_file_use(self): + """Stops the use of the currently previewed file. Used to release file permissions.""" + logging.info("[PreviewPanel] Stopping file use in video playback...") + # This swaps the video out for a placeholder so the previous video's file + # is no longer in use by this object. + self.preview_vid.play(ResourceManager.get_path("placeholder_mp4"), QSize(8, 8)) + self.preview_vid.hide() + + # NOTE: I'm keeping this here until #357 is merged in the case it still needs to be used. + # logging.info("[PreviewPanel] Stopping file use for animated image playback...") + # logging.info(self.preview_gif.movie()) + # if self.preview_gif.movie(): + # self.preview_gif.movie().stop() + # with open(ResourceManager.get_path("placeholder_gif"), mode="rb") as f: + # ba = f.read() + # self.gif_buffer.setData(ba) + # movie = QMovie(self.gif_buffer, QByteArray()) + # self.preview_gif.setMovie(movie) + # movie.start() + + # self.preview_gif.hide() + # logging.info(self.preview_gif.movie()) From ff0f09c4ac8c2a245437e2fc538304842a95ffee Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 27 Aug 2024 01:34:30 -0700 Subject: [PATCH 67/79] feat(ui): add file deletion confirmation boxes --- tagstudio/src/qt/ts_qt.py | 79 +++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 1c7215855..d3e923826 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -12,6 +12,7 @@ import logging import math import os +import platform import sys import time import typing @@ -53,6 +54,7 @@ QMenu, QMenuBar, QComboBox, + QMessageBox, ) from humanfriendly import format_timespan @@ -69,7 +71,6 @@ TAG_FAVORITE, TAG_ARCHIVED, ) -from src.core.media_types import MediaCategories, MediaType from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout from src.qt.main_window import Ui_MainWindow @@ -841,41 +842,38 @@ def delete_files_callback(self, origin_path: str | Path): origin_path(str): The file path associated with the widget making the call. May or may not be the file targeted, depending on the selection rules. """ - _op: Path = Path(origin_path) - item = None + entry = None + pending: list[Path] = [] deleted_count: int = 0 filepath: Path = None # Initialize if len(self.selected) <= 1: - if self.selected: - item = self.lib.get_entry(self.selected[0][1]) - filepath = self.lib.library_dir / item.path / item.filename - # If the file to be deleted is currently being displayed on the Preview Panel, - # tell the panel to stop any use of the file. - if origin_path == filepath: - self.preview_panel.stop_file_use() - self.main_window.statusbar.showMessage(f'Deleting file "{origin_path}"...') - self.main_window.statusbar.repaint() - if delete_file(_op): - op_item = self.lib.get_entry_id_from_filepath(_op) - self.lib.remove_entry(op_item) - self.purge_item_from_navigation(ItemType.ENTRY, op_item) - deleted_count += 1 + pending.append(Path(origin_path)) elif len(self.selected) > 1: for i, item_pair in enumerate(self.selected): if item_pair[0] == ItemType.ENTRY: - item = self.lib.get_entry(item_pair[1]) - filepath = self.lib.library_dir / item.path / item.filename - self.main_window.statusbar.showMessage( - f'Deleting file "{filepath}"...' - ) - self.main_window.statusbar.repaint() - if delete_file(filepath): - self.purge_item_from_navigation(item.type, item.id) - self.lib.remove_entry(item.id) + entry = self.lib.get_entry(item_pair[1]) + filepath = self.lib.library_dir / entry.path / entry.filename + pending.append(filepath) + + if pending: + if self.delete_file_confirmation(len(pending), pending[0]) == 3: + for f in pending: + if origin_path == f: + self.preview_panel.stop_file_use() + if delete_file(f): + self.main_window.statusbar.showMessage( + f'Deleting file "{f}"...' + ) + self.main_window.statusbar.repaint() + + entry_id = self.lib.get_entry_id_from_filepath(f) + self.lib.remove_entry(entry_id) + self.purge_item_from_navigation(ItemType.ENTRY, entry_id) deleted_count += 1 - self.selected.clear() + self.selected.clear() - self.filter_items() + if deleted_count > 0: + self.filter_items() self.preview_panel.update_widgets() if len(self.selected) <= 1 and deleted_count == 0: @@ -896,6 +894,31 @@ def delete_files_callback(self, origin_path: str | Path): self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!") self.main_window.statusbar.repaint() + def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int: + trash_term: str = "Trash" + if platform.system() == "Windows": + trash_term = "Recycle Bin" + + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.setTextFormat(Qt.TextFormat.RichText) + + msg_box.setWindowTitle("Delete File" if count == 1 else "Delete Files") + if count <= 1: + msg_box.setText( + f"Are you sure you want to move this file to the {trash_term}?
    " + "This will remove it from TagStudio AND your file system!

    " + f"{filename if filename else ''}
    " + ) + elif count > 1: + msg_box.setText( + f"Are you sure you want to move these {count} files to the {trash_term}?
    " + "This will remove them from TagStudio AND your file system!
    " + ) + msg_box.addButton("&No", QMessageBox.ButtonRole.NoRole) + msg_box.addButton("&Yes", QMessageBox.ButtonRole.YesRole) + return msg_box.exec() + def add_new_files_callback(self): """Runs when user initiates adding new files to the Library.""" # # if self.lib.files_not_in_library: From 26777f075f85c3764659c0205112f90f170a7ae4 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 27 Aug 2024 01:57:06 -0700 Subject: [PATCH 68/79] feat(ui): add delete file menu option + shortcut --- tagstudio/src/qt/ts_qt.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index d3e923826..49c142d58 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -449,6 +449,15 @@ def start(self) -> None: edit_menu.addSeparator() + self.delete_file_action = QAction("Delete Selected File(s)", menu_bar) + self.delete_file_action.triggered.connect( + lambda f="": self.delete_files_callback(f) + ) + self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete) + edit_menu.addAction(self.delete_file_action) + + edit_menu.addSeparator() + manage_file_extensions_action = QAction("Manage File Extensions", menu_bar) manage_file_extensions_action.triggered.connect( lambda: self.show_file_extension_modal() @@ -541,7 +550,7 @@ def start(self) -> None: lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio") ) help_menu.addAction(self.repo_action) - self.set_macro_menu_viability() + self.set_menu_action_viability() self.update_clipboard_actions() @@ -764,7 +773,7 @@ def close_library(self): self.copied_fields.clear() self.is_buffer_merged = False self.update_clipboard_actions() - self.set_macro_menu_viability() + self.set_menu_action_viability() self.preview_panel.update_widgets() self.filter_items() self.main_window.toggle_landing_page(True) @@ -802,7 +811,7 @@ def select_all_action_callback(self): self.selected.append((item.mode, item.item_id)) item.thumb_button.set_selected(True) - self.set_macro_menu_viability() + self.set_menu_action_viability() self.preview_panel.update_widgets() def clear_select_action_callback(self): @@ -810,7 +819,7 @@ def clear_select_action_callback(self): for item in self.item_thumbs: item.thumb_button.set_selected(False) - self.set_macro_menu_viability() + self.set_menu_action_viability() self.preview_panel.update_widgets() def show_tag_database(self): @@ -846,9 +855,10 @@ def delete_files_callback(self, origin_path: str | Path): pending: list[Path] = [] deleted_count: int = 0 filepath: Path = None # Initialize - if len(self.selected) <= 1: + + if len(self.selected) <= 1 and origin_path: pending.append(Path(origin_path)) - elif len(self.selected) > 1: + elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not origin_path): for i, item_pair in enumerate(self.selected): if item_pair[0] == ItemType.ENTRY: entry = self.lib.get_entry(item_pair[1]) @@ -857,12 +867,12 @@ def delete_files_callback(self, origin_path: str | Path): if pending: if self.delete_file_confirmation(len(pending), pending[0]) == 3: - for f in pending: - if origin_path == f: + for i, f in enumerate(pending): + if (origin_path == f) or (not origin_path): self.preview_panel.stop_file_use() if delete_file(f): self.main_window.statusbar.showMessage( - f'Deleting file "{f}"...' + f'Deleting file [{i}/{len(pending)}]: "{f}"...' ) self.main_window.statusbar.repaint() @@ -1489,17 +1499,19 @@ def select_item(self, type: ItemType, id: int, append: bool, bridge: bool): if it.mode == type and it.item_id == id: self.preview_panel.set_tags_updated_slot(it.update_badges) - self.set_macro_menu_viability() + self.set_menu_action_viability() self.update_clipboard_actions() self.preview_panel.update_widgets() - def set_macro_menu_viability(self): + def set_menu_action_viability(self): if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0: self.autofill_action.setDisabled(True) self.sort_fields_action.setDisabled(True) + self.delete_file_action.setDisabled(True) else: self.autofill_action.setDisabled(False) self.sort_fields_action.setDisabled(False) + self.delete_file_action.setDisabled(False) def update_thumbs(self): """Updates search thumbnails.""" From 085157163b18c9c7f0b53fb4b22939506fed7596 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 27 Aug 2024 17:12:58 -0700 Subject: [PATCH 69/79] ui: update file deletion message boxes --- tagstudio/src/qt/ts_qt.py | 53 ++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 49c142d58..dc81386be 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -71,6 +71,7 @@ TAG_FAVORITE, TAG_ARCHIVED, ) +from src.core.palette import ColorType, get_ui_color from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout from src.qt.main_window import Ui_MainWindow @@ -854,7 +855,6 @@ def delete_files_callback(self, origin_path: str | Path): entry = None pending: list[Path] = [] deleted_count: int = 0 - filepath: Path = None # Initialize if len(self.selected) <= 1 and origin_path: pending.append(Path(origin_path)) @@ -862,7 +862,7 @@ def delete_files_callback(self, origin_path: str | Path): for i, item_pair in enumerate(self.selected): if item_pair[0] == ItemType.ENTRY: entry = self.lib.get_entry(item_pair[1]) - filepath = self.lib.library_dir / entry.path / entry.filename + filepath: Path = self.lib.library_dir / entry.path / entry.filename pending.append(filepath) if pending: @@ -884,7 +884,7 @@ def delete_files_callback(self, origin_path: str | Path): if deleted_count > 0: self.filter_items() - self.preview_panel.update_widgets() + self.preview_panel.update_widgets() if len(self.selected) <= 1 and deleted_count == 0: self.main_window.statusbar.showMessage( @@ -905,29 +905,46 @@ def delete_files_callback(self, origin_path: str | Path): self.main_window.statusbar.repaint() def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int: + """A confirmation dialogue box for deleting files. + + Args: + count(int): The number of files to be deleted. + filename(Path | None): The filename to show if only one file is to be deleted. + """ trash_term: str = "Trash" + perm_warning: str = "" if platform.system() == "Windows": trash_term = "Recycle Bin" + # NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin. + # This is done without any warning, so this message is currently the best way I've got to inform the user. + # https://github.com/arsenetar/send2trash/issues/28 + perm_warning = ( + f"

    " + ) - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Icon.Warning) - msg_box.setTextFormat(Qt.TextFormat.RichText) - - msg_box.setWindowTitle("Delete File" if count == 1 else "Delete Files") + msg = QMessageBox() + msg.setTextFormat(Qt.TextFormat.RichText) + msg.setWindowTitle("Delete File" if count == 1 else "Delete Files") + msg.setIcon(QMessageBox.Icon.Warning) if count <= 1: - msg_box.setText( - f"Are you sure you want to move this file to the {trash_term}?
    " - "This will remove it from TagStudio AND your file system!

    " - f"{filename if filename else ''}
    " + msg.setText( + f"

    Are you sure you want to move this file to the {trash_term}?

    " + "

    This will remove it from TagStudio AND your file system!

    " + f"{filename if filename else ''}" + f"{perm_warning}
    " ) elif count > 1: - msg_box.setText( - f"Are you sure you want to move these {count} files to the {trash_term}?
    " - "This will remove them from TagStudio AND your file system!
    " + msg.setText( + f"

    Are you sure you want to move these {count} files to the {trash_term}?

    " + "

    This will remove them from TagStudio AND your file system!

    " + f"{perm_warning}
    " ) - msg_box.addButton("&No", QMessageBox.ButtonRole.NoRole) - msg_box.addButton("&Yes", QMessageBox.ButtonRole.YesRole) - return msg_box.exec() + msg.addButton("&No", QMessageBox.ButtonRole.NoRole) + msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole) + + return msg.exec() def add_new_files_callback(self): """Runs when user initiates adding new files to the Library.""" From ff2153a15bd6952640ab23a4d594469b02ce8c81 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 31 Aug 2024 01:07:13 -0700 Subject: [PATCH 70/79] fix(ui): same default confirm button on win/mac - Make "Yes" the default choice in the delete file modal for both Windows and macOS (Linux untested) - Change status messages to be more broad, since they also are displayed when cancelling the operation --- tagstudio/src/qt/ts_qt.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index dc81386be..05e872b29 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -866,7 +866,10 @@ def delete_files_callback(self, origin_path: str | Path): pending.append(filepath) if pending: - if self.delete_file_confirmation(len(pending), pending[0]) == 3: + return_code = self.delete_file_confirmation(len(pending), pending[0]) + logging.info(return_code) + # If there was a confirmation and not a cancellation + if return_code == 2 and return_code != 3: for i, f in enumerate(pending): if (origin_path == f) or (not origin_path): self.preview_panel.stop_file_use() @@ -887,18 +890,14 @@ def delete_files_callback(self, origin_path: str | Path): self.preview_panel.update_widgets() if len(self.selected) <= 1 and deleted_count == 0: - self.main_window.statusbar.showMessage( - "No files deleted. Check if any of the files are currently in use." - ) + self.main_window.statusbar.showMessage("No files deleted.") elif len(self.selected) <= 1 and deleted_count == 1: self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!") elif len(self.selected) > 1 and deleted_count == 0: - self.main_window.statusbar.showMessage( - "No files deleted! Check if any of the files are currently in use." - ) + self.main_window.statusbar.showMessage("No files deleted.") elif len(self.selected) > 1 and deleted_count < len(self.selected): self.main_window.statusbar.showMessage( - f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently in use" + f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently missing or in use." ) elif len(self.selected) > 1 and deleted_count == len(self.selected): self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!") @@ -941,8 +940,10 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> "

    This will remove them from TagStudio AND your file system!

    " f"{perm_warning}
    " ) + + yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole) msg.addButton("&No", QMessageBox.ButtonRole.NoRole) - msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole) + msg.setDefaultButton(yes_button) return msg.exec() From 6f2347888899933171eb629f2b21c873554caf7d Mon Sep 17 00:00:00 2001 From: Xarvex Date: Sun, 16 Jun 2024 02:59:59 -0500 Subject: [PATCH 71/79] fix(flake): resolve mypy access to libraries --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 69adee170..f567cbba6 100644 --- a/flake.nix +++ b/flake.nix @@ -82,6 +82,7 @@ unset SOURCE_DATE_EPOCH echo Installing dependencies into virtual environment + pip install PySide6==6.6.2 # 6.6.3 has faulty .pyi files pip install -r requirements.txt pip install -r requirements-dev.txt # Hacky solution to not fight with other dev deps From b01d22a73d984d21f9193f8d51cd1f038f0d0667 Mon Sep 17 00:00:00 2001 From: Sean Krueger Date: Mon, 17 Jun 2024 13:02:52 -0500 Subject: [PATCH 72/79] Bump Qt6 version to 6.7.1 --- flake.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/flake.nix b/flake.nix index f567cbba6..69adee170 100644 --- a/flake.nix +++ b/flake.nix @@ -82,7 +82,6 @@ unset SOURCE_DATE_EPOCH echo Installing dependencies into virtual environment - pip install PySide6==6.6.2 # 6.6.3 has faulty .pyi files pip install -r requirements.txt pip install -r requirements-dev.txt # Hacky solution to not fight with other dev deps From 16bcccba42df5f50554b708512ee7c25e0c12091 Mon Sep 17 00:00:00 2001 From: xarvex Date: Sat, 24 Aug 2024 22:57:00 -0500 Subject: [PATCH 73/79] feat(flake): complete revamp with devenv/direnv Not perfect, and mostly a port of the previous edition. Hours have already been sunk into this, and need to get this out for consumption, and for ironing out. For more information see: https://devenv.sh NOTE: impure is used only because of the devenv-managed state, do not be alarmed! --- .envrc | 10 + .gitignore | 3 + flake.lock | 532 +++++++++++++++++++++++++++++++++++++++++++++++++++-- flake.nix | 261 ++++++++++++++++---------- 4 files changed, 691 insertions(+), 115 deletions(-) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..5bce6abda --- /dev/null +++ b/.envrc @@ -0,0 +1,10 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then + source_url 'https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc' 'sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs=' +fi + +watch_file flake.nix +watch_file flake.lock + +if ! use flake . --impure; then + printf '%s\n' "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 +fi diff --git a/.gitignore b/.gitignore index 4c9ce4015..459e6d880 100644 --- a/.gitignore +++ b/.gitignore @@ -253,3 +253,6 @@ compile_commands.json .TagStudio TagStudio.ini # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt + +.direnv +.devenv diff --git a/flake.lock b/flake.lock index f83ab58f0..76b655a0d 100644 --- a/flake.lock +++ b/flake.lock @@ -1,41 +1,547 @@ { "nodes": { + "cachix": { + "inputs": { + "devenv": "devenv_2", + "flake-compat": [ + "devenv", + "flake-compat" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "pre-commit-hooks": [ + "devenv", + "pre-commit-hooks" + ] + }, + "locked": { + "lastModified": 1712055811, + "narHash": "sha256-7FcfMm5A/f02yyzuavJe06zLa9hcMHsagE28ADcmQvk=", + "owner": "cachix", + "repo": "cachix", + "rev": "02e38da89851ec7fec3356a5c04bc8349cae0e30", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "cachix", + "type": "github" + } + }, + "devenv": { + "inputs": { + "cachix": "cachix", + "flake-compat": "flake-compat_2", + "nix": "nix_2", + "nixpkgs": "nixpkgs_2", + "pre-commit-hooks": "pre-commit-hooks" + }, + "locked": { + "lastModified": 1724504184, + "narHash": "sha256-gP6000c2+zHKJHAxCD3BftvAjmb4CPAZamRAHNxN2MM=", + "owner": "cachix", + "repo": "devenv", + "rev": "51338b58fd666f448db7486ec145dbe52db9b829", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "devenv_2": { + "inputs": { + "flake-compat": [ + "devenv", + "cachix", + "flake-compat" + ], + "nix": "nix", + "nixpkgs": "nixpkgs", + "poetry2nix": "poetry2nix", + "pre-commit-hooks": [ + "devenv", + "cachix", + "pre-commit-hooks" + ] + }, + "locked": { + "lastModified": 1708704632, + "narHash": "sha256-w+dOIW60FKMaHI1q5714CSibk99JfYxm0CzTinYWr+Q=", + "owner": "cachix", + "repo": "devenv", + "rev": "2ee4450b0f4b95a1b90f2eb5ffea98b90e48c196", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "python-rewrite", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1722555600, + "narHash": "sha256-XOQkdLafnb/p9ij77byFQjDf5m5QYl9b2REiVClC+x4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "8471fe90ad337a8074e957b69ca4d0089218391d", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1689068808, + "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "inputs": { + "systems": "systems_3" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "devenv", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nix": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "devenv", + "cachix", + "devenv", + "nixpkgs" + ], + "nixpkgs-regression": "nixpkgs-regression" + }, + "locked": { + "lastModified": 1712911606, + "narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=", + "owner": "domenkozar", + "repo": "nix", + "rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12", + "type": "github" + }, + "original": { + "owner": "domenkozar", + "ref": "devenv-2.21", + "repo": "nix", + "type": "github" + } + }, + "nix-github-actions": { + "inputs": { + "nixpkgs": [ + "devenv", + "cachix", + "devenv", + "poetry2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1688870561, + "narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=", + "owner": "nix-community", + "repo": "nix-github-actions", + "rev": "165b1650b753316aa7f1787f3005a8d2da0f5301", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nix-github-actions", + "type": "github" + } + }, + "nix2container": { + "inputs": { + "flake-utils": "flake-utils_3", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1720642556, + "narHash": "sha256-qsnqk13UmREKmRT7c8hEnz26X3GFFyIQrqx4EaRc1Is=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "3853e5caf9ad24103b13aa6e0e8bcebb47649fe4", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, + "nix_2": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "nixpkgs-regression": "nixpkgs-regression_2" + }, + "locked": { + "lastModified": 1712911606, + "narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=", + "owner": "domenkozar", + "repo": "nix", + "rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12", + "type": "github" + }, + "original": { + "owner": "domenkozar", + "ref": "devenv-2.21", + "repo": "nix", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1718318537, - "narHash": "sha256-4Zu0RYRcAY/VWuu6awwq4opuiD//ahpc2aFHg2CWqFY=", - "owner": "nixos", + "lastModified": 1692808169, + "narHash": "sha256-x9Opq06rIiwdwGeK2Ykj69dNc2IvUH1fY55Wm7atwrE=", + "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9ee548d90ff586a6471b4ae80ae9cfcbceb3420", + "rev": "9201b5ff357e781bf014d0330d18555695df7ba8", "type": "github" }, "original": { - "owner": "nixos", - "ref": "nixos-unstable", + "owner": "NixOS", + "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } }, - "qt6Nixpkgs": { + "nixpkgs-qt6": { "locked": { - "lastModified": 1716287118, - "narHash": "sha256-iUTrXABmJAkPRhwPB8GEP7k52OWHVSRtMzlKQ2kIrz4=", + "lastModified": 1718428119, + "narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "47da0aee5616a063015f10ea593688646f2377e4", + "rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", "type": "github" }, "original": { "owner": "NixOS", "repo": "nixpkgs", - "rev": "47da0aee5616a063015f10ea593688646f2377e4", + "rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", + "type": "github" + } + }, + "nixpkgs-regression": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, + "nixpkgs-regression_2": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1710695816, + "narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "614b4613980a522ba49f0d194531beddbb7220d3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1713361204, + "narHash": "sha256-TA6EDunWTkc5FvDCqU3W2T3SFn0gRZqh6D/hJnM02MM=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "285676e87ad9f0ca23d8714a6ab61e7e027020c6", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1724224976, + "narHash": "sha256-Z/ELQhrSd7bMzTO8r7NZgi9g5emh+aRKoCdaAv5fiO0=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "c374d94f1536013ca8e92341b540eba4c22f9c62", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "poetry2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nix-github-actions": "nix-github-actions", + "nixpkgs": [ + "devenv", + "cachix", + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1692876271, + "narHash": "sha256-IXfZEkI0Mal5y1jr6IRWMqK8GW2/f28xJenZIPQqkY0=", + "owner": "nix-community", + "repo": "poetry2nix", + "rev": "d5006be9c2c2417dafb2e2e5034d83fabd207ee3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "poetry2nix", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "flake-utils": "flake-utils_2", + "gitignore": "gitignore", + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1713775815, + "narHash": "sha256-Wu9cdYTnGQQwtT20QQMg7jzkANKQjwBD9iccfGKkfls=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "2ac4dcbf55ed43f3be0bae15e181f08a57af24a4", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", "type": "github" } }, "root": { "inputs": { - "nixpkgs": "nixpkgs", - "qt6Nixpkgs": "qt6Nixpkgs" + "devenv": "devenv", + "flake-parts": "flake-parts", + "nix2container": "nix2container", + "nixpkgs": "nixpkgs_3", + "nixpkgs-qt6": "nixpkgs-qt6", + "systems": "systems_4" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_4": { + "locked": { + "lastModified": 1689347949, + "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=", + "owner": "nix-systems", + "repo": "default-linux", + "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default-linux", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 69adee170..cef5574a1 100644 --- a/flake.nix +++ b/flake.nix @@ -1,111 +1,168 @@ { - description = "Tag Studio Development Environment"; + description = "TagStudio"; inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + devenv.url = "github:cachix/devenv"; - qt6Nixpkgs = { - # Commit bumping to qt6.7.1 - url = "github:NixOS/nixpkgs/47da0aee5616a063015f10ea593688646f2377e4"; + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; }; - }; - outputs = { self, nixpkgs, qt6Nixpkgs }: - let - pkgs = nixpkgs.legacyPackages.x86_64-linux; - - qt6Pkgs = qt6Nixpkgs.legacyPackages.x86_64-linux; - in { - devShells.x86_64-linux.default = pkgs.mkShell { - name = "Tag Studio Virtual Environment"; - venvDir = "./.venv"; - - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ - pkgs.gcc-unwrapped - pkgs.zlib - pkgs.libglvnd - pkgs.glib - pkgs.stdenv.cc.cc - pkgs.fontconfig - pkgs.libxkbcommon - pkgs.xorg.libxcb - pkgs.freetype - pkgs.dbus - pkgs.zstd - # For PySide6 Multimedia - pkgs.libpulseaudio - pkgs.libkrb5 - - qt6Pkgs.qt6.qtwayland - qt6Pkgs.qt6.full - qt6Pkgs.qt6.qtbase - ]; - - buildInputs = with pkgs; [ - cmake - gdb - zstd - python312Full - python312Packages.pip - python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip - python312Packages.venvShellHook # Initializes a venv in $venvDir - ruff # Ruff cannot be installed via pip - mypy # MyPy cannot be installed via pip - - libgcc - glib - libxkbcommon - freetype - binutils - dbus - coreutils - libGL - libGLU - fontconfig - xorg.libxcb - - # this is for the shellhook portion - makeWrapper - bashInteractive - ] ++ [ - qt6Pkgs.qt6.qtbase - qt6Pkgs.qt6.full - qt6Pkgs.qt6.qtwayland - qt6Pkgs.qtcreator - - # this is for the shellhook portion - qt6Pkgs.qt6.wrapQtAppsHook - ]; - - # Run after the virtual environment is created - postVenvCreation = '' - unset SOURCE_DATE_EPOCH - - echo Installing dependencies into virtual environment - pip install -r requirements.txt - pip install -r requirements-dev.txt - # Hacky solution to not fight with other dev deps - # May show failure if skipped due to same version with nixpkgs - pip uninstall -y mypy ruff - ''; - - # set the environment variables that Qt apps expect - postShellHook = '' - unset SOURCE_DATE_EPOCH - - export QT_QPA_PLATFORM="wayland;xcb" - export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH - # export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/ - export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix} - bashdir=$(mktemp -d) - makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}" - - echo Activating Virtual Environment - source $venvDir/bin/activate - export PYTHONPATH=$PWD/$venvDir/${pkgs.python312Full.sitePackages}:$PYTHONPATH - - exec "$bashdir/bash" - ''; + nix2container = { + url = "github:nlewo/nix2container"; + inputs.nixpkgs.follows = "nixpkgs"; }; + + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + # Pinned to Qt version 6.7.1 + nixpkgs-qt6.url = "github:NixOS/nixpkgs/e6cea36f83499eb4e9cd184c8a8e823296b50ad5"; + + systems.url = "github:nix-systems/default-linux"; }; + + outputs = { flake-parts, nixpkgs, nixpkgs-qt6, self, systems, ... }@inputs: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ inputs.devenv.flakeModule ]; + + systems = import systems; + + perSystem = { config, pkgs, system, ... }: + let + inherit (nixpkgs) lib; + + qt6Pkgs = import nixpkgs-qt6 { inherit system; }; + in + { + devenv.shells = rec { + default = tagstudio; + + tagstudio = + let + cfg = config.devenv.shells.tagstudio; + in + { + # NOTE: many things were simply transferred over from previous, + # there must be additional work in ensuring all relevant dependencies + # are in place (and no extraneous). I have already spent much + # work making this in the first place and just need to get it out + # there, especially after my promises. Would appreciate any help + # (possibly PRs!) on taking care of this. Otherwise, just expect + # this to get ironed out over time. + # + # Thank you! -Xarvex + + name = "TagStudio"; + + dotenv.disableHint = true; + + # Derived from previous flake iteration. + packages = (with pkgs; [ + cmake + binutils + coreutils + dbus + fontconfig + freetype + gdb + glib + libGL + libGLU + libgcc + libxkbcommon + mypy + ruff + xorg.libxcb + zstd + ]) + ++ (with qt6Pkgs; [ + qt6.full + qt6.qtbase + qt6.qtwayland + qtcreator + ]); + + enterShell = + let + setQtEnv = pkgs.runCommand "set-qt-env" + { + buildInputs = with qt6Pkgs.qt6; [ + qtbase # Needed by wrapQtAppsHook. + wrapQtAppsHook + ]; + nativeBuildInputs = with pkgs; [ makeShellWrapper ]; + } + '' + makeShellWrapper "$(type -p sh)" "$out" "''${qtWrapperArgs[@]}" + sed "/^exec/d" -i "$out" + ''; + in + '' + source ${setQtEnv} + ''; + + scripts.tagstudio.exec = '' + python ${cfg.devenv.root}/tagstudio/tag_studio.py + ''; + + env = { + QT_QPA_PLATFORM = "wayland;xcb"; + + # Derived from previous flake iteration. + # Not desired given LD_LIBRARY_PATH pollution. + # See supposed alternative below, further research required. + LD_LIBRARY_PATH = lib.makeLibraryPath ( + (with pkgs; [ + dbus + fontconfig + freetype + gcc-unwrapped + glib + libglvnd + libkrb5 + libpulseaudio + libxkbcommon + stdenv.cc.cc.lib + xorg.libxcb + zlib + zstd + ]) + ++ (with qt6Pkgs.qt6; [ + qtbase + qtwayland + full + ]) + ); + }; + + languages.python = { + enable = true; + venv = { + enable = true; + quiet = true; + requirements = + let + excludeDeps = req: deps: builtins.concatStringsSep "\n" + (builtins.filter (line: !(lib.any (elem: lib.hasPrefix elem line) deps)) + (lib.splitString "\n" req)); + in + '' + ${builtins.readFile ./requirements.txt} + ${excludeDeps (builtins.readFile ./requirements-dev.txt) [ + "mypy" + "ruff" + ]} + ''; + }; + + # Should be able to replace LD_LIBRARY_PATH? + # Was not quite able to get working, + # will be consulting cachix community. -Xarvex + # libraries = with pkgs; [ ]; + }; + }; + }; + }; + }; } From 39867256838fa261d79c17e883ab51ba453cef84 Mon Sep 17 00:00:00 2001 From: xarvex Date: Thu, 29 Aug 2024 19:32:30 -0500 Subject: [PATCH 74/79] fix(flake): add missing media dependencies Fixes: #417 Did not opt for setting VDPAU_DRIVER, should be done on user side See: https://wiki.archlinux.org/title/Hardware_video_acceleration#Configuring_VA-API --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index cef5574a1..50689b6db 100644 --- a/flake.nix +++ b/flake.nix @@ -124,7 +124,9 @@ libpulseaudio libxkbcommon stdenv.cc.cc.lib + wayland xorg.libxcb + xorg.libXrandr zlib zstd ]) From 94851d2206edd41d1ca99858b3a486d2aba93d36 Mon Sep 17 00:00:00 2001 From: Florian Zier <9168602+zierf@users.noreply.github.com> Date: Fri, 30 Aug 2024 06:49:00 +0200 Subject: [PATCH 75/79] fix(flake): GPU hardware acceleration * Enable hardware acceleration for Nix devenv Section "nativeBuildInputs" needs the dependency "qt6.full" to provide hardware acceleration in QT. Library "wayland" is needed to create a proper OpenGL context under Wayland as well. Provide a check for open AMD driver and use it for VDPAU video hardware acceleration. Move "wrapQtAppsHook" to it's correct place under "nativeBuildInputs". * Add xorg.libXrandr for hardware accelerated video playback. Using libva and openssl libraries eliminates the need for a dependency on the qt6.full library. --- flake.nix | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 50689b6db..ab653ea56 100644 --- a/flake.nix +++ b/flake.nix @@ -88,10 +88,15 @@ setQtEnv = pkgs.runCommand "set-qt-env" { buildInputs = with qt6Pkgs.qt6; [ - qtbase # Needed by wrapQtAppsHook. - wrapQtAppsHook + qtbase ]; - nativeBuildInputs = with pkgs; [ makeShellWrapper ]; + + nativeBuildInputs = (with pkgs; [ + makeShellWrapper + ]) + ++ (with qt6Pkgs.qt6; [ + wrapQtAppsHook + ]); } '' makeShellWrapper "$(type -p sh)" "$out" "''${qtWrapperArgs[@]}" @@ -122,7 +127,9 @@ libglvnd libkrb5 libpulseaudio + libva libxkbcommon + openssl stdenv.cc.cc.lib wayland xorg.libxcb From 589fefaba4b717aa6ab46e081f1a966711f98c29 Mon Sep 17 00:00:00 2001 From: xarvex Date: Fri, 30 Aug 2024 14:44:13 -0500 Subject: [PATCH 76/79] feat(flake): remove impurity, update nix-direnv Use path as an input that can be overriden automatically when direnv is in use. nix-direnv version present in .envrc has been updated, using watch_file on the flake is already handled. --- .envrc | 11 +++++------ flake.lock | 37 +++++++++++++++++++++++++------------ flake.nix | 14 ++++++++++++-- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/.envrc b/.envrc index 5bce6abda..650d3bd6b 100644 --- a/.envrc +++ b/.envrc @@ -1,10 +1,9 @@ -if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then - source_url 'https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc' 'sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs=' +if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w=" fi -watch_file flake.nix -watch_file flake.lock - -if ! use flake . --impure; then +DEVENV_ROOT_FILE="$(mktemp)" +printf %s "${PWD}" > "${DEVENV_ROOT_FILE}" +if ! use flake . --override-input devenv-root "file+file://${DEVENV_ROOT_FILE}"; then printf '%s\n' "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 fi diff --git a/flake.lock b/flake.lock index 76b655a0d..c26dd0ec0 100644 --- a/flake.lock +++ b/flake.lock @@ -39,11 +39,11 @@ "pre-commit-hooks": "pre-commit-hooks" }, "locked": { - "lastModified": 1724504184, - "narHash": "sha256-gP6000c2+zHKJHAxCD3BftvAjmb4CPAZamRAHNxN2MM=", + "lastModified": 1724763216, + "narHash": "sha256-oW2bwCrJpIzibCNK6zfIDaIQw765yMAuMSG2gyZfGv0=", "owner": "cachix", "repo": "devenv", - "rev": "51338b58fd666f448db7486ec145dbe52db9b829", + "rev": "1e4ef61205b9aa20fe04bf1c468b6a316281c4f1", "type": "github" }, "original": { @@ -52,6 +52,18 @@ "type": "github" } }, + "devenv-root": { + "flake": false, + "locked": { + "narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=", + "type": "file", + "url": "file:///dev/null" + }, + "original": { + "type": "file", + "url": "file:///dev/null" + } + }, "devenv_2": { "inputs": { "flake-compat": [ @@ -122,11 +134,11 @@ ] }, "locked": { - "lastModified": 1722555600, - "narHash": "sha256-XOQkdLafnb/p9ij77byFQjDf5m5QYl9b2REiVClC+x4=", + "lastModified": 1725024810, + "narHash": "sha256-ODYRm8zHfLTH3soTFWE452ydPYz2iTvr9T8ftDMUQ3E=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "8471fe90ad337a8074e957b69ca4d0089218391d", + "rev": "af510d4a62d071ea13925ce41c95e3dec816c01d", "type": "github" }, "original": { @@ -269,11 +281,11 @@ ] }, "locked": { - "lastModified": 1720642556, - "narHash": "sha256-qsnqk13UmREKmRT7c8hEnz26X3GFFyIQrqx4EaRc1Is=", + "lastModified": 1724996935, + "narHash": "sha256-njRK9vvZ1JJsP8oV2OgkBrpJhgQezI03S7gzskCcHos=", "owner": "nlewo", "repo": "nix2container", - "rev": "3853e5caf9ad24103b13aa6e0e8bcebb47649fe4", + "rev": "fa6bb0a1159f55d071ba99331355955ae30b3401", "type": "github" }, "original": { @@ -407,11 +419,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1724224976, - "narHash": "sha256-Z/ELQhrSd7bMzTO8r7NZgi9g5emh+aRKoCdaAv5fiO0=", + "lastModified": 1724819573, + "narHash": "sha256-GnR7/ibgIH1vhoy8cYdmXE6iyZqKqFxQSVkFgosBh6w=", "owner": "nixos", "repo": "nixpkgs", - "rev": "c374d94f1536013ca8e92341b540eba4c22f9c62", + "rev": "71e91c409d1e654808b2621f28a327acfdad8dc2", "type": "github" }, "original": { @@ -477,6 +489,7 @@ "root": { "inputs": { "devenv": "devenv", + "devenv-root": "devenv-root", "flake-parts": "flake-parts", "nix2container": "nix2container", "nixpkgs": "nixpkgs_3", diff --git a/flake.nix b/flake.nix index ab653ea56..86529cc1b 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,11 @@ inputs = { devenv.url = "github:cachix/devenv"; + devenv-root = { + url = "file+file:///dev/null"; + flake = false; + }; + flake-parts = { url = "github:hercules-ci/flake-parts"; inputs.nixpkgs-lib.follows = "nixpkgs"; @@ -53,9 +58,14 @@ # # Thank you! -Xarvex - name = "TagStudio"; + devenv.root = + let + devenvRoot = builtins.readFile inputs.devenv-root.outPath; + in + # If not overriden (/dev/null), --impure is necessary. + pkgs.lib.mkIf (devenvRoot != "") devenvRoot; - dotenv.disableHint = true; + name = "TagStudio"; # Derived from previous flake iteration. packages = (with pkgs; [ From 8945d115ce4170a21084f48d3294ad27a79c7e43 Mon Sep 17 00:00:00 2001 From: xarvex Date: Sat, 31 Aug 2024 13:16:27 -0500 Subject: [PATCH 77/79] chore(direnv): update .envrc - Cleanup direnv-root file - Set filetype for vi-like editors - Format and lint according to shellcheck and shfmt --- .envrc | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.envrc b/.envrc index 650d3bd6b..e84ab0f86 100644 --- a/.envrc +++ b/.envrc @@ -1,9 +1,13 @@ +# vi: ft=bash + if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w=" + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w=" fi -DEVENV_ROOT_FILE="$(mktemp)" -printf %s "${PWD}" > "${DEVENV_ROOT_FILE}" -if ! use flake . --override-input devenv-root "file+file://${DEVENV_ROOT_FILE}"; then +devenv_root_file="$(mktemp -t devenv-root-XXXXXXXX)" +printf %s "${PWD}" >"${devenv_root_file}" +if ! use flake . --override-input devenv-root "file+file://${devenv_root_file}"; then printf '%s\n' "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 fi +rm "${devenv_root_file}" +unset devenv_root_file From 632e793ec5a33e5ea1bd680ab552676401fc06c6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 31 Aug 2024 13:13:19 -0700 Subject: [PATCH 78/79] Revert "Merge branch 'main' into file-deletion" This reverts commit 80ebffaa1ed3e5c042b51e6c357eed86b01edf74, reversing changes made to c8dde27fa82dc5f6e765c80951175f75ffb3dfed. --- .envrc | 13 -- .gitignore | 3 - flake.lock | 545 ++--------------------------------------------------- flake.nix | 280 ++++++++++----------------- 4 files changed, 115 insertions(+), 726 deletions(-) delete mode 100644 .envrc diff --git a/.envrc b/.envrc deleted file mode 100644 index e84ab0f86..000000000 --- a/.envrc +++ /dev/null @@ -1,13 +0,0 @@ -# vi: ft=bash - -if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w=" -fi - -devenv_root_file="$(mktemp -t devenv-root-XXXXXXXX)" -printf %s "${PWD}" >"${devenv_root_file}" -if ! use flake . --override-input devenv-root "file+file://${devenv_root_file}"; then - printf '%s\n' "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 -fi -rm "${devenv_root_file}" -unset devenv_root_file diff --git a/.gitignore b/.gitignore index 459e6d880..4c9ce4015 100644 --- a/.gitignore +++ b/.gitignore @@ -253,6 +253,3 @@ compile_commands.json .TagStudio TagStudio.ini # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt - -.direnv -.devenv diff --git a/flake.lock b/flake.lock index c26dd0ec0..f83ab58f0 100644 --- a/flake.lock +++ b/flake.lock @@ -1,560 +1,41 @@ { "nodes": { - "cachix": { - "inputs": { - "devenv": "devenv_2", - "flake-compat": [ - "devenv", - "flake-compat" - ], - "nixpkgs": [ - "devenv", - "nixpkgs" - ], - "pre-commit-hooks": [ - "devenv", - "pre-commit-hooks" - ] - }, - "locked": { - "lastModified": 1712055811, - "narHash": "sha256-7FcfMm5A/f02yyzuavJe06zLa9hcMHsagE28ADcmQvk=", - "owner": "cachix", - "repo": "cachix", - "rev": "02e38da89851ec7fec3356a5c04bc8349cae0e30", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "cachix", - "type": "github" - } - }, - "devenv": { - "inputs": { - "cachix": "cachix", - "flake-compat": "flake-compat_2", - "nix": "nix_2", - "nixpkgs": "nixpkgs_2", - "pre-commit-hooks": "pre-commit-hooks" - }, - "locked": { - "lastModified": 1724763216, - "narHash": "sha256-oW2bwCrJpIzibCNK6zfIDaIQw765yMAuMSG2gyZfGv0=", - "owner": "cachix", - "repo": "devenv", - "rev": "1e4ef61205b9aa20fe04bf1c468b6a316281c4f1", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "devenv", - "type": "github" - } - }, - "devenv-root": { - "flake": false, - "locked": { - "narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=", - "type": "file", - "url": "file:///dev/null" - }, - "original": { - "type": "file", - "url": "file:///dev/null" - } - }, - "devenv_2": { - "inputs": { - "flake-compat": [ - "devenv", - "cachix", - "flake-compat" - ], - "nix": "nix", - "nixpkgs": "nixpkgs", - "poetry2nix": "poetry2nix", - "pre-commit-hooks": [ - "devenv", - "cachix", - "pre-commit-hooks" - ] - }, - "locked": { - "lastModified": 1708704632, - "narHash": "sha256-w+dOIW60FKMaHI1q5714CSibk99JfYxm0CzTinYWr+Q=", - "owner": "cachix", - "repo": "devenv", - "rev": "2ee4450b0f4b95a1b90f2eb5ffea98b90e48c196", - "type": "github" - }, - "original": { - "owner": "cachix", - "ref": "python-rewrite", - "repo": "devenv", - "type": "github" - } - }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1673956053, - "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "flake-compat_2": { - "flake": false, - "locked": { - "lastModified": 1696426674, - "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "flake-parts": { - "inputs": { - "nixpkgs-lib": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1725024810, - "narHash": "sha256-ODYRm8zHfLTH3soTFWE452ydPYz2iTvr9T8ftDMUQ3E=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "af510d4a62d071ea13925ce41c95e3dec816c01d", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "flake-parts", - "type": "github" - } - }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1689068808, - "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_2": { - "inputs": { - "systems": "systems_2" - }, - "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_3": { - "inputs": { - "systems": "systems_3" - }, - "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "devenv", - "pre-commit-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "nix": { - "inputs": { - "flake-compat": "flake-compat", - "nixpkgs": [ - "devenv", - "cachix", - "devenv", - "nixpkgs" - ], - "nixpkgs-regression": "nixpkgs-regression" - }, - "locked": { - "lastModified": 1712911606, - "narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=", - "owner": "domenkozar", - "repo": "nix", - "rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12", - "type": "github" - }, - "original": { - "owner": "domenkozar", - "ref": "devenv-2.21", - "repo": "nix", - "type": "github" - } - }, - "nix-github-actions": { - "inputs": { - "nixpkgs": [ - "devenv", - "cachix", - "devenv", - "poetry2nix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1688870561, - "narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=", - "owner": "nix-community", - "repo": "nix-github-actions", - "rev": "165b1650b753316aa7f1787f3005a8d2da0f5301", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-github-actions", - "type": "github" - } - }, - "nix2container": { - "inputs": { - "flake-utils": "flake-utils_3", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1724996935, - "narHash": "sha256-njRK9vvZ1JJsP8oV2OgkBrpJhgQezI03S7gzskCcHos=", - "owner": "nlewo", - "repo": "nix2container", - "rev": "fa6bb0a1159f55d071ba99331355955ae30b3401", - "type": "github" - }, - "original": { - "owner": "nlewo", - "repo": "nix2container", - "type": "github" - } - }, - "nix_2": { - "inputs": { - "flake-compat": [ - "devenv", - "flake-compat" - ], - "nixpkgs": [ - "devenv", - "nixpkgs" - ], - "nixpkgs-regression": "nixpkgs-regression_2" - }, - "locked": { - "lastModified": 1712911606, - "narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=", - "owner": "domenkozar", - "repo": "nix", - "rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12", - "type": "github" - }, - "original": { - "owner": "domenkozar", - "ref": "devenv-2.21", - "repo": "nix", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1692808169, - "narHash": "sha256-x9Opq06rIiwdwGeK2Ykj69dNc2IvUH1fY55Wm7atwrE=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9201b5ff357e781bf014d0330d18555695df7ba8", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-qt6": { - "locked": { - "lastModified": 1718428119, - "narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", - "type": "github" - } - }, - "nixpkgs-regression": { - "locked": { - "lastModified": 1643052045, - "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", - "type": "github" - } - }, - "nixpkgs-regression_2": { - "locked": { - "lastModified": 1643052045, - "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", - "owner": "NixOS", + "lastModified": 1718318537, + "narHash": "sha256-4Zu0RYRcAY/VWuu6awwq4opuiD//ahpc2aFHg2CWqFY=", + "owner": "nixos", "repo": "nixpkgs", - "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "rev": "e9ee548d90ff586a6471b4ae80ae9cfcbceb3420", "type": "github" }, "original": { - "owner": "NixOS", + "owner": "nixos", + "ref": "nixos-unstable", "repo": "nixpkgs", - "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", "type": "github" } }, - "nixpkgs-stable": { + "qt6Nixpkgs": { "locked": { - "lastModified": 1710695816, - "narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=", + "lastModified": 1716287118, + "narHash": "sha256-iUTrXABmJAkPRhwPB8GEP7k52OWHVSRtMzlKQ2kIrz4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "614b4613980a522ba49f0d194531beddbb7220d3", + "rev": "47da0aee5616a063015f10ea593688646f2377e4", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-23.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1713361204, - "narHash": "sha256-TA6EDunWTkc5FvDCqU3W2T3SFn0gRZqh6D/hJnM02MM=", - "owner": "cachix", - "repo": "devenv-nixpkgs", - "rev": "285676e87ad9f0ca23d8714a6ab61e7e027020c6", - "type": "github" - }, - "original": { - "owner": "cachix", - "ref": "rolling", - "repo": "devenv-nixpkgs", - "type": "github" - } - }, - "nixpkgs_3": { - "locked": { - "lastModified": 1724819573, - "narHash": "sha256-GnR7/ibgIH1vhoy8cYdmXE6iyZqKqFxQSVkFgosBh6w=", - "owner": "nixos", "repo": "nixpkgs", - "rev": "71e91c409d1e654808b2621f28a327acfdad8dc2", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "poetry2nix": { - "inputs": { - "flake-utils": "flake-utils", - "nix-github-actions": "nix-github-actions", - "nixpkgs": [ - "devenv", - "cachix", - "devenv", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1692876271, - "narHash": "sha256-IXfZEkI0Mal5y1jr6IRWMqK8GW2/f28xJenZIPQqkY0=", - "owner": "nix-community", - "repo": "poetry2nix", - "rev": "d5006be9c2c2417dafb2e2e5034d83fabd207ee3", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "poetry2nix", - "type": "github" - } - }, - "pre-commit-hooks": { - "inputs": { - "flake-compat": [ - "devenv", - "flake-compat" - ], - "flake-utils": "flake-utils_2", - "gitignore": "gitignore", - "nixpkgs": [ - "devenv", - "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" - }, - "locked": { - "lastModified": 1713775815, - "narHash": "sha256-Wu9cdYTnGQQwtT20QQMg7jzkANKQjwBD9iccfGKkfls=", - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "rev": "2ac4dcbf55ed43f3be0bae15e181f08a57af24a4", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "pre-commit-hooks.nix", + "rev": "47da0aee5616a063015f10ea593688646f2377e4", "type": "github" } }, "root": { "inputs": { - "devenv": "devenv", - "devenv-root": "devenv-root", - "flake-parts": "flake-parts", - "nix2container": "nix2container", - "nixpkgs": "nixpkgs_3", - "nixpkgs-qt6": "nixpkgs-qt6", - "systems": "systems_4" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_2": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_3": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_4": { - "locked": { - "lastModified": 1689347949, - "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=", - "owner": "nix-systems", - "repo": "default-linux", - "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default-linux", - "type": "github" + "nixpkgs": "nixpkgs", + "qt6Nixpkgs": "qt6Nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 86529cc1b..69adee170 100644 --- a/flake.nix +++ b/flake.nix @@ -1,187 +1,111 @@ { - description = "TagStudio"; + description = "Tag Studio Development Environment"; inputs = { - devenv.url = "github:cachix/devenv"; - - devenv-root = { - url = "file+file:///dev/null"; - flake = false; - }; - - flake-parts = { - url = "github:hercules-ci/flake-parts"; - inputs.nixpkgs-lib.follows = "nixpkgs"; - }; - - nix2container = { - url = "github:nlewo/nix2container"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - # Pinned to Qt version 6.7.1 - nixpkgs-qt6.url = "github:NixOS/nixpkgs/e6cea36f83499eb4e9cd184c8a8e823296b50ad5"; - - systems.url = "github:nix-systems/default-linux"; + qt6Nixpkgs = { + # Commit bumping to qt6.7.1 + url = "github:NixOS/nixpkgs/47da0aee5616a063015f10ea593688646f2377e4"; + }; }; - outputs = { flake-parts, nixpkgs, nixpkgs-qt6, self, systems, ... }@inputs: - flake-parts.lib.mkFlake { inherit inputs; } { - imports = [ inputs.devenv.flakeModule ]; - - systems = import systems; - - perSystem = { config, pkgs, system, ... }: - let - inherit (nixpkgs) lib; - - qt6Pkgs = import nixpkgs-qt6 { inherit system; }; - in - { - devenv.shells = rec { - default = tagstudio; - - tagstudio = - let - cfg = config.devenv.shells.tagstudio; - in - { - # NOTE: many things were simply transferred over from previous, - # there must be additional work in ensuring all relevant dependencies - # are in place (and no extraneous). I have already spent much - # work making this in the first place and just need to get it out - # there, especially after my promises. Would appreciate any help - # (possibly PRs!) on taking care of this. Otherwise, just expect - # this to get ironed out over time. - # - # Thank you! -Xarvex - - devenv.root = - let - devenvRoot = builtins.readFile inputs.devenv-root.outPath; - in - # If not overriden (/dev/null), --impure is necessary. - pkgs.lib.mkIf (devenvRoot != "") devenvRoot; - - name = "TagStudio"; - - # Derived from previous flake iteration. - packages = (with pkgs; [ - cmake - binutils - coreutils - dbus - fontconfig - freetype - gdb - glib - libGL - libGLU - libgcc - libxkbcommon - mypy - ruff - xorg.libxcb - zstd - ]) - ++ (with qt6Pkgs; [ - qt6.full - qt6.qtbase - qt6.qtwayland - qtcreator - ]); - - enterShell = - let - setQtEnv = pkgs.runCommand "set-qt-env" - { - buildInputs = with qt6Pkgs.qt6; [ - qtbase - ]; - - nativeBuildInputs = (with pkgs; [ - makeShellWrapper - ]) - ++ (with qt6Pkgs.qt6; [ - wrapQtAppsHook - ]); - } - '' - makeShellWrapper "$(type -p sh)" "$out" "''${qtWrapperArgs[@]}" - sed "/^exec/d" -i "$out" - ''; - in - '' - source ${setQtEnv} - ''; - - scripts.tagstudio.exec = '' - python ${cfg.devenv.root}/tagstudio/tag_studio.py - ''; - - env = { - QT_QPA_PLATFORM = "wayland;xcb"; - - # Derived from previous flake iteration. - # Not desired given LD_LIBRARY_PATH pollution. - # See supposed alternative below, further research required. - LD_LIBRARY_PATH = lib.makeLibraryPath ( - (with pkgs; [ - dbus - fontconfig - freetype - gcc-unwrapped - glib - libglvnd - libkrb5 - libpulseaudio - libva - libxkbcommon - openssl - stdenv.cc.cc.lib - wayland - xorg.libxcb - xorg.libXrandr - zlib - zstd - ]) - ++ (with qt6Pkgs.qt6; [ - qtbase - qtwayland - full - ]) - ); - }; - - languages.python = { - enable = true; - venv = { - enable = true; - quiet = true; - requirements = - let - excludeDeps = req: deps: builtins.concatStringsSep "\n" - (builtins.filter (line: !(lib.any (elem: lib.hasPrefix elem line) deps)) - (lib.splitString "\n" req)); - in - '' - ${builtins.readFile ./requirements.txt} - ${excludeDeps (builtins.readFile ./requirements-dev.txt) [ - "mypy" - "ruff" - ]} - ''; - }; - - # Should be able to replace LD_LIBRARY_PATH? - # Was not quite able to get working, - # will be consulting cachix community. -Xarvex - # libraries = with pkgs; [ ]; - }; - }; - }; - }; + outputs = { self, nixpkgs, qt6Nixpkgs }: + let + pkgs = nixpkgs.legacyPackages.x86_64-linux; + + qt6Pkgs = qt6Nixpkgs.legacyPackages.x86_64-linux; + in { + devShells.x86_64-linux.default = pkgs.mkShell { + name = "Tag Studio Virtual Environment"; + venvDir = "./.venv"; + + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.gcc-unwrapped + pkgs.zlib + pkgs.libglvnd + pkgs.glib + pkgs.stdenv.cc.cc + pkgs.fontconfig + pkgs.libxkbcommon + pkgs.xorg.libxcb + pkgs.freetype + pkgs.dbus + pkgs.zstd + # For PySide6 Multimedia + pkgs.libpulseaudio + pkgs.libkrb5 + + qt6Pkgs.qt6.qtwayland + qt6Pkgs.qt6.full + qt6Pkgs.qt6.qtbase + ]; + + buildInputs = with pkgs; [ + cmake + gdb + zstd + python312Full + python312Packages.pip + python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip + python312Packages.venvShellHook # Initializes a venv in $venvDir + ruff # Ruff cannot be installed via pip + mypy # MyPy cannot be installed via pip + + libgcc + glib + libxkbcommon + freetype + binutils + dbus + coreutils + libGL + libGLU + fontconfig + xorg.libxcb + + # this is for the shellhook portion + makeWrapper + bashInteractive + ] ++ [ + qt6Pkgs.qt6.qtbase + qt6Pkgs.qt6.full + qt6Pkgs.qt6.qtwayland + qt6Pkgs.qtcreator + + # this is for the shellhook portion + qt6Pkgs.qt6.wrapQtAppsHook + ]; + + # Run after the virtual environment is created + postVenvCreation = '' + unset SOURCE_DATE_EPOCH + + echo Installing dependencies into virtual environment + pip install -r requirements.txt + pip install -r requirements-dev.txt + # Hacky solution to not fight with other dev deps + # May show failure if skipped due to same version with nixpkgs + pip uninstall -y mypy ruff + ''; + + # set the environment variables that Qt apps expect + postShellHook = '' + unset SOURCE_DATE_EPOCH + + export QT_QPA_PLATFORM="wayland;xcb" + export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH + # export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/ + export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix} + bashdir=$(mktemp -d) + makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}" + + echo Activating Virtual Environment + source $venvDir/bin/activate + export PYTHONPATH=$PWD/$venvDir/${pkgs.python312Full.sitePackages}:$PYTHONPATH + + exec "$bashdir/bash" + ''; }; + }; } From 800a405b7e1128a49e6b964db4398035bbf226c3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 31 Aug 2024 14:14:22 -0700 Subject: [PATCH 79/79] ui: show perm deletion warning on all platforms --- tagstudio/src/qt/ts_qt.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index c42b4c383..d28f04126 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -914,17 +914,18 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> filename(Path | None): The filename to show if only one file is to be deleted. """ trash_term: str = "Trash" - perm_warning: str = "" if platform.system() == "Windows": trash_term = "Recycle Bin" - # NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin. - # This is done without any warning, so this message is currently the best way I've got to inform the user. - # https://github.com/arsenetar/send2trash/issues/28 - perm_warning = ( - f"

    " + f"WARNING! If this file can't be moved to the Recycle Bin, " + f"it will be permanently deleted!

    " - f"WARNING! If this file can't be moved to the Recycle Bin, " - f"it will be permanently deleted!

    " - ) + # NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin. + # This is done without any warning, so this message is currently the best way I've got to inform the user. + # https://github.com/arsenetar/send2trash/issues/28 + # This warning is applied to all platforms until at least macOS and Linux can be verified to + # not exhibit this same behavior. + perm_warning: str = ( + f"

    " + f"WARNING! If this file can't be moved to the {trash_term}, " + f"it will be permanently deleted!

    " + ) msg = QMessageBox() msg.setTextFormat(Qt.TextFormat.RichText)