From e2c9d30ed396d2e680ea7a9410d2100da8524e54 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:55:19 -0700 Subject: [PATCH] refactor: split Qt functions from thumb renderer --- src/tagstudio/qt/previews/renderer.py | 245 +++++++++---------- src/tagstudio/qt/qt_file_renderer.py | 60 +++++ src/tagstudio/qt/thumb_grid_layout.py | 15 +- src/tagstudio/qt/ts_qt.py | 2 +- src/tagstudio/qt/views/preview_thumb_view.py | 8 +- src/tagstudio/renderers/clip_studio.py | 2 +- 6 files changed, 189 insertions(+), 143 deletions(-) create mode 100644 src/tagstudio/qt/qt_file_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index c5a8715b..9857a507 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -7,31 +7,23 @@ import hashlib import math from copy import deepcopy from pathlib import Path -from typing import TYPE_CHECKING import structlog -from PIL import ( - Image, - ImageChops, - ImageDraw, - ImageEnhance, - ImageFile, - ImageQt, - UnidentifiedImageError, -) +from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, UnidentifiedImageError from PIL.Image import DecompressionBombError -from PySide6.QtCore import QObject, QSize, Qt, Signal -from PySide6.QtGui import QGuiApplication, QPixmap -from typing_extensions import deprecated from tagstudio.core.exceptions import NoRendererError +from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaCategories, MediaType from tagstudio.core.utils.types import unwrap +from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.global_settings import ( DEFAULT_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES, MIN_CACHED_THUMB_RES, + GlobalSettings, + Theme, ) from tagstudio.qt.helpers.gradients import four_corner_gradient from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color @@ -45,7 +37,7 @@ from tagstudio.renderers.archive import ( ) from tagstudio.renderers.audio import audio_album_thumb, audio_waveform_thumb from tagstudio.renderers.blender import blender_thumb -from tagstudio.renderers.clip_studio import clip_studio_thumb, pdn_thumb +from tagstudio.renderers.clip_studio import clip_studio_thumb, paint_dot_net_thumb from tagstudio.renderers.ebook import epub_thumb from tagstudio.renderers.font import font_full_preview, font_small_thumb from tagstudio.renderers.medibang_paint import medibang_paint_thumb @@ -56,9 +48,6 @@ from tagstudio.renderers.text import text_thumb from tagstudio.renderers.vector_image import vector_image_thumb from tagstudio.renderers.video import video_thumb -if TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - ImageFile.LOAD_TRUNCATED_IMAGES = True Image.MAX_IMAGE_PIXELS = None @@ -66,18 +55,16 @@ Image.MAX_IMAGE_PIXELS = None logger = structlog.get_logger(__name__) -class ThumbRenderer(QObject): +class FileRenderer: """A class for rendering image and file thumbnails.""" rm: ResourceManager = ResourceManager() - updated = Signal(float, QPixmap, QSize, Path) - updated_ratio = Signal(float) cached_img_ext: str = ".webp" - def __init__(self, driver: "QtDriver") -> None: - """Initialize the class.""" + def __init__(self, library: Library, settings: GlobalSettings) -> None: super().__init__() - self.driver = driver + self.lib = library + self.settings = settings # Cached thumbnail elements. # Key: Size + Pixel Ratio Tuple + Radius Scale @@ -120,7 +107,6 @@ class ThumbRenderer(QObject): return "file_generic" - @deprecated("This method will be replaced with Qt painting methods in the near future.") def _get_mask( self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False ) -> Image.Image: @@ -144,7 +130,6 @@ class ThumbRenderer(QObject): self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item return item - @deprecated("This method will be replaced with Qt painting methods in the near future.") def _get_edge( self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: @@ -167,6 +152,7 @@ class ThumbRenderer(QObject): name: str, color: UiColor, size: tuple[int, int], + theme: Theme, pixel_ratio: float = 1.0, bg_image: Image.Image | None = None, draw_edge: bool = True, @@ -178,6 +164,7 @@ class ThumbRenderer(QObject): 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. + theme (Theme): A theme enum to determine the light/dark theme. pixel_ratio (float): The screen pixel ratio. bg_image (Image.Image): Optional background image to go behind the icon. draw_edge (bool): Flag for is the raised edge should be drawn. @@ -190,19 +177,20 @@ class ThumbRenderer(QObject): item: Image.Image | None = self.icons.get((name, color, *size, pixel_ratio)) if not item: item_flat: Image.Image = ( - self._render_corner_icon(name, color, size, pixel_ratio, bg_image) + self._render_corner_icon(name, color, size, pixel_ratio, theme, bg_image) if is_corner - else self._render_center_icon(name, color, size, pixel_ratio, draw_border, bg_image) + else self._render_center_icon( + name, color, size, pixel_ratio, theme, draw_border, bg_image + ) ) if draw_edge: edge: tuple[Image.Image, Image.Image] = self._get_edge(size, pixel_ratio) - item = self._apply_edge(item_flat, edge, faded=True) + item = self._apply_edge(item_flat, edge, theme, faded=True) self.icons[(name, color, *size, pixel_ratio)] = item else: item = item_flat return item - @deprecated("This method will be replaced with Qt painting methods in the near future.") def _render_mask( self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1 ) -> Image.Image: @@ -233,7 +221,6 @@ class ThumbRenderer(QObject): ) return im - @deprecated("This method will be replaced with Qt painting methods in the near future.") def _render_edge( self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: @@ -293,6 +280,7 @@ class ThumbRenderer(QObject): color: UiColor, size: tuple[int, int], pixel_ratio: float, + theme: Theme, draw_border: bool = True, bg_image: Image.Image | None = None, ) -> Image.Image: @@ -303,6 +291,7 @@ class ThumbRenderer(QObject): color (UiColor): The color to use for the icon. size (tuple[int,int]): The size of the icon. pixel_ratio (float): The screen pixel ratio. + theme (Theme): A theme enum to determine the light/dark theme. draw_border (bool): Option to draw a border. bg_image (Image.Image): Optional background image to go behind the icon. """ @@ -362,11 +351,7 @@ class ThumbRenderer(QObject): 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 = self.rm.get(name) @@ -388,10 +373,7 @@ class ThumbRenderer(QObject): ) # Apply color overlay - im = self._apply_overlay_color( - im, - color, - ) + im = self._apply_overlay_color(im, color, theme) return im @@ -401,6 +383,7 @@ class ThumbRenderer(QObject): color: UiColor, size: tuple[int, int], pixel_ratio: float, + theme: Theme, bg_image: Image.Image | None = None, ) -> Image.Image: """Render a thumbnail icon with the icon in the upper-left corner. @@ -410,6 +393,7 @@ class ThumbRenderer(QObject): color (UiColor): The color to use for the icon. size (tuple[int,int]): The size of the icon. pixel_ratio (float): The screen pixel ratio. + theme (Theme): A theme enum to determine the light/dark theme. draw_border (bool): Option to draw a border. bg_image (Image.Image): Optional background image to go behind the icon. """ @@ -436,10 +420,7 @@ class ThumbRenderer(QObject): color="#000000", ) # Apply color overlay - bg = self._apply_overlay_color( - im, - color, - ) + bg = self._apply_overlay_color(im, color, theme) # Paste background color with rounded rectangle mask onto blank image im.paste( @@ -493,7 +474,7 @@ class ThumbRenderer(QObject): return im - def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Image: + def _apply_overlay_color(self, image: Image.Image, color: UiColor, theme: Theme) -> 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. @@ -501,20 +482,21 @@ class ThumbRenderer(QObject): Args: image (Image.Image): The image to apply an overlay to. color (UiColor): The name of the ColorType color to use. + theme (Theme): A theme enum to determine the light/dark theme. """ bg_color: str = ( get_ui_color(ColorType.DARK_ACCENT, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + if theme == Theme.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 + if theme == Theme.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 + if theme == Theme.DARK else get_ui_color(ColorType.LIGHT_ACCENT, color) ) @@ -534,9 +516,12 @@ class ThumbRenderer(QObject): return bg - @deprecated("This method will be replaced with Qt painting methods in the near future.") def _apply_edge( - self, image: Image.Image, edge: tuple[Image.Image, Image.Image], faded: bool = False + self, + image: Image.Image, + edge: tuple[Image.Image, Image.Image], + theme: Theme, + faded: bool = False, ) -> Image.Image: """Apply a given edge effect to an image. @@ -544,13 +529,12 @@ class ThumbRenderer(QObject): image (Image.Image): The image to apply the edge to. edge (tuple[Image.Image, Image.Image]): The edge images to apply. Item 0 is the inner highlight, and item 1 is the outer shadow. + theme (Theme): A theme enum to determine the light/dark theme. faded (bool): Whether to apply a faded version of the edge. Used for light themes. """ opacity: float = 1.0 if not faded else 0.8 - shade_reduction: float = ( - 0 if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else 0.3 - ) + shade_reduction: float = 0 if theme == Theme.DARK else 0.3 im: Image.Image = image im_hl, im_sh = deepcopy(edge) @@ -570,21 +554,24 @@ class ThumbRenderer(QObject): def render( self, + cache: CacheManager | None, timestamp: float, filepath: Path | str, base_size: tuple[int, int], pixel_ratio: float, + theme: Theme = Theme.DARK, is_loading: bool = False, is_grid_thumb: bool = False, - update_on_ratio_change: bool = False, ): """Render a thumbnail or preview image. Args: + cache (CacheManager | None): A cache manager instance. timestamp (float): The timestamp for which 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. + theme (Theme): A theme enum to determine the light/dark theme. 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? @@ -592,11 +579,7 @@ class ThumbRenderer(QObject): """ render_mask_and_edge: bool = True adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) - theme_color: UiColor = ( - UiColor.THEME_LIGHT - if QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Light - else UiColor.THEME_DARK - ) + theme_color: UiColor = UiColor.THEME_LIGHT if theme == Theme.LIGHT else UiColor.THEME_DARK if isinstance(filepath, str): filepath = Path(filepath) @@ -605,6 +588,7 @@ class ThumbRenderer(QObject): name=self._get_resource_id(filepath), color=theme_color, size=size, + theme=theme, pixel_ratio=pixel_ratio, ) return im @@ -616,6 +600,7 @@ class ThumbRenderer(QObject): name="broken_link_icon", color=UiColor.RED, size=size, + theme=theme, pixel_ratio=pixel_ratio, bg_image=cached_im, draw_edge=not cached_im, @@ -623,9 +608,7 @@ class ThumbRenderer(QObject): ) return im - def render_ignored( - size: tuple[int, int], pixel_ratio: float, im: Image.Image - ) -> Image.Image: + def render_ignored(size: tuple[int, int], im: Image.Image) -> Image.Image: icon_ratio: float = 5 padding_factor = 18 @@ -646,8 +629,9 @@ class ThumbRenderer(QObject): def fetch_cached_image(file_name: Path): image: Image.Image | None = None - assert self.driver.cache_manager is not None - cached_path = self.driver.cache_manager.get_file_path(file_name) + if not cache: + return image + cached_path = cache.get_file_path(file_name) if cached_path and cached_path.is_file(): try: @@ -669,11 +653,11 @@ class ThumbRenderer(QObject): mod_time = str(filepath.stat().st_mtime_ns) hashable_str: str = f"{str(filepath)}{mod_time}" hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8) - file_name = Path(f"{hash_value}{ThumbRenderer.cached_img_ext}") + file_name = Path(f"{hash_value}{FileRenderer.cached_img_ext}") image = fetch_cached_image(file_name) - if not image and self.driver.settings.generate_thumbs: - settings_res = self.driver.settings.cached_thumb_resolution + if not image and self.settings.generate_thumbs: + settings_res = self.settings.cached_thumb_resolution thumb_res = ( settings_res if settings_res >= MIN_CACHED_THUMB_RES and settings_res <= MAX_CACHED_THUMB_RES @@ -684,10 +668,11 @@ class ThumbRenderer(QObject): # TODO: Audio waveforms are dynamically sized based on the base_size, so hardcoding # the resolution breaks that. image = self._render( - timestamp, + cache, filepath, (thumb_res, thumb_res), 1, + theme, is_grid_thumb, save_to_file=file_name, ) @@ -711,7 +696,7 @@ class ThumbRenderer(QObject): (adj_size, adj_size), pixel_ratio ) image = self._apply_edge( - four_corner_gradient(image, (adj_size, adj_size), mask), edge + four_corner_gradient(image, (adj_size, adj_size), mask), edge, theme ) # Check if the file is supposed to be ignored and render an overlay if needed @@ -720,10 +705,10 @@ class ThumbRenderer(QObject): image and Ignore.compiled_patterns and Ignore.compiled_patterns.match( - filepath.relative_to(unwrap(self.driver.lib.library_dir)) + filepath.relative_to(unwrap(self.lib.library_dir)) ) ): - image = render_ignored((adj_size, adj_size), pixel_ratio, image) + image = render_ignored((adj_size, adj_size), image) except TypeError: pass @@ -731,13 +716,13 @@ class ThumbRenderer(QObject): elif is_loading: # Initialize "Loading" thumbnail loading_thumb: Image.Image = self._get_icon( - "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio + "thumb_loading", theme_color, (adj_size, adj_size), theme, pixel_ratio ) image = loading_thumb.resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) # A full preview image (never cached) elif not is_grid_thumb: - image = self._render(timestamp, filepath, base_size, pixel_ratio) + image = self._render(cache, filepath, base_size, pixel_ratio, theme) if not image: image = ( render_unlinked((512, 512), 2) @@ -754,40 +739,31 @@ class ThumbRenderer(QObject): if not image: image = Image.new("RGBA", (128, 128), color="#FF00FF") - # Convert the final image to a pixmap to emit. - qim = ImageQt.ImageQt(image) - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixel_ratio) - self.updated_ratio.emit(image.size[0] / image.size[1]) - if pixmap: - self.updated.emit( - timestamp, - pixmap, - QSize( - math.ceil(adj_size / pixel_ratio), - math.ceil(image.size[1] / pixel_ratio), - ), - filepath, - ) - else: - self.updated.emit(timestamp, QPixmap(), QSize(*base_size), filepath) + return ( + image, + (math.ceil(adj_size / pixel_ratio), math.ceil(image.size[1] / pixel_ratio)), + timestamp, + ) def _render( self, - timestamp: float, + cache: CacheManager | None, filepath: str | Path, base_size: tuple[int, int], pixel_ratio: float, + theme: Theme = Theme.DARK, is_grid_thumb: bool = False, save_to_file: Path | None = None, ) -> Image.Image | None: """Render a thumbnail or preview image. Args: + cache (CacheManager | None): A cache manager instance. timestamp (float): The timestamp for which 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. + theme (Theme): A theme enum to determine the light/dark theme. is_grid_thumb (bool): Is this a thumbnail for the thumbnail grid? Or else the Preview Pane? save_to_file(Path | None): A filepath to optionally save the output to. @@ -795,117 +771,117 @@ class ThumbRenderer(QObject): """ adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) image: Image.Image | None = None - _filepath: Path = Path(filepath) + filepath_: Path = Path(filepath) savable_media_type: bool = True - if _filepath and _filepath.is_file(): + if filepath_ and filepath_.is_file(): try: - ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower() - # Ebooks ======================================================= + ext: str = filepath_.suffix.lower() if filepath_.suffix else filepath_.stem.lower() + # eBooks =========================================================================== if MediaCategories.is_ext_in_category( ext, MediaCategories.EBOOK_TYPES, mime_fallback=True ): - image = epub_thumb(_filepath, ext) - # Krita ======================================================== + image = epub_thumb(filepath_, ext) + # Krita ============================================================================ elif MediaCategories.is_ext_in_category( ext, MediaCategories.KRITA_TYPES, mime_fallback=True ): - image = krita_thumb(_filepath) - # Clip Studio Paint ============================================ + image = krita_thumb(filepath_) + # Clip Studio Paint ================================================================ elif MediaCategories.is_ext_in_category( ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES ): - image = clip_studio_thumb(_filepath) - # VTF ========================================================== + image = clip_studio_thumb(filepath_) + # VTF ============================================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True ): - image = vtf_thumb(_filepath) - # Images ======================================================= + image = vtf_thumb(filepath_) + # Images =========================================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_TYPES, mime_fallback=True ): - # Raw Images ----------------------------------------------- + # Raw Images ------------------------------------------------------------------- if MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True ): - image = raw_image_thumb(_filepath) - # Vector Images -------------------------------------------- + image = raw_image_thumb(filepath_) + # Vector Images ---------------------------------------------------------------- elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True ): - image = vector_image_thumb(_filepath, adj_size) - # EXR Images ----------------------------------------------- + image = vector_image_thumb(filepath_, adj_size) + # EXR Images ------------------------------------------------------------------- elif ext in [".exr"]: - image = exr_image_thumb(_filepath) - # Normal Images -------------------------------------------- + image = exr_image_thumb(filepath_) + # Normal Images ---------------------------------------------------------------- else: - image = raster_image_thumb(_filepath) - # Videos ======================================================= + image = raster_image_thumb(filepath_) + # Videos =========================================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ): - image = video_thumb(_filepath) - # PowerPoint Slideshow + image = video_thumb(filepath_) + # PowerPoint ======================================================================= elif ext in {".pptx"}: - image = powerpoint_thumb(_filepath) - # OpenDocument/OpenOffice ====================================== + image = powerpoint_thumb(filepath_) + # OpenDocument/OpenOffice ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True ): - image = open_doc_thumb(_filepath) - # Apple iWork Suite ============================================ + image = open_doc_thumb(filepath_) + # Apple iWork + Creator Studio ===================================================== elif ( MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES) or ext == ".pxd" ): - image = apple_embedded_thumb(_filepath) - # Plain Text =================================================== + image = apple_embedded_thumb(filepath_) + # Plain Text ======================================================================= elif MediaCategories.is_ext_in_category( ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True ): - image = text_thumb(_filepath) - # Fonts ======================================================== + image = text_thumb(filepath_) + # Fonts ============================================================================ elif MediaCategories.is_ext_in_category( ext, MediaCategories.FONT_TYPES, mime_fallback=True ): if is_grid_thumb: # Short (Aa) Preview - image = font_small_thumb(_filepath, adj_size) + image = font_small_thumb(filepath_, adj_size) if image is not None: - image = self._apply_overlay_color(image, UiColor.BLUE) + image = self._apply_overlay_color(image, UiColor.BLUE, theme) else: # Large (Full Alphabet) Preview - image = font_full_preview(_filepath, adj_size) + image = font_full_preview(filepath_, adj_size) # Audio ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): - image = audio_album_thumb(_filepath, ext) + image = audio_album_thumb(filepath_, ext) if image is None: - image = audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) + image = audio_waveform_thumb(filepath_, ext, adj_size, pixel_ratio) savable_media_type = False if image is not None: - image = self._apply_overlay_color(image, UiColor.GREEN) + image = self._apply_overlay_color(image, UiColor.GREEN, theme) # Blender ====================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True ): - image = blender_thumb(_filepath) + image = blender_thumb(filepath_) # PDF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.PDF_TYPES, mime_fallback=True ): - image = pdf_thumb(_filepath, adj_size, ext) + image = pdf_thumb(filepath_, adj_size, ext) # Archives ===================================================== elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES): - image = archive_thumb(_filepath, ext) + image = archive_thumb(filepath_, ext) # MDIPACK ====================================================== elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES): - image = medibang_paint_thumb(_filepath) + image = medibang_paint_thumb(filepath_) # Paint.NET ==================================================== elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES): - image = pdn_thumb(_filepath) + image = paint_dot_net_thumb(filepath_) # No Rendered Thumbnail ======================================== if not image: raise NoRendererError @@ -913,9 +889,8 @@ class ThumbRenderer(QObject): if image: image = self._resize_image(image, (adj_size, adj_size)) - if save_to_file and savable_media_type and image: - assert self.driver.cache_manager is not None - self.driver.cache_manager.save_image(image, save_to_file, mode="RGBA") + if save_to_file and savable_media_type and image and cache: + cache.save_image(image, save_to_file, mode="RGBA") except ( AssertionError, diff --git a/src/tagstudio/qt/qt_file_renderer.py b/src/tagstudio/qt/qt_file_renderer.py new file mode 100644 index 00000000..9a9e8fac --- /dev/null +++ b/src/tagstudio/qt/qt_file_renderer.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from pathlib import Path + +from PIL import ImageQt +from PySide6.QtCore import QObject, QSize, Signal +from PySide6.QtGui import QGuiApplication, QPixmap, Qt + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.cache_manager import CacheManager +from tagstudio.qt.global_settings import GlobalSettings, Theme +from tagstudio.qt.previews.renderer import FileRenderer + + +class QtFileRenderer(QObject): + updated = Signal(float, QPixmap, QSize, Path) + updated_ratio = Signal(float) + + """A Qt-specific entry point for rendering file previews and thumbnails.""" + + def __init__(self, library: Library, settings: GlobalSettings) -> None: + super().__init__() + self.renderer = FileRenderer(library, settings) + self.theme = ( + Theme.DARK + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.LIGHT + ) + + def render( + self, + cache: CacheManager | None, + timestamp: float, + filepath: Path | str, + base_size: tuple[int, int], + pixel_ratio: float, + is_loading: bool = False, + is_grid_thumb: bool = False, + ): + + image, size, timestamp = self.renderer.render( + cache=cache, + timestamp=timestamp, + filepath=filepath, + base_size=base_size, + pixel_ratio=pixel_ratio, + theme=self.theme, + is_loading=is_loading, + is_grid_thumb=is_grid_thumb, + ) + qim = ImageQt.ImageQt(image) + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixel_ratio) + + self.updated_ratio.emit(image.size[0] / image.size[1]) + if pixmap: + self.updated.emit(timestamp, pixmap, QSize(size[0], size[1]), filepath) + else: + self.updated.emit(timestamp, QPixmap(), QSize(*base_size), filepath) diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index 3f60698d..b95307ba 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -17,7 +17,7 @@ from tagstudio.core.library.alchemy.enums import ItemType from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb -from tagstudio.qt.previews.renderer import ThumbRenderer +from tagstudio.qt.qt_file_renderer import QtFileRenderer if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -44,7 +44,7 @@ class ThumbGridLayout(QLayout): self._entry_items: dict[int, int] = {} self._render_results: dict[Path, Any] = {} - self._renderer: ThumbRenderer = ThumbRenderer(self.driver) + self._renderer: QtFileRenderer = QtFileRenderer(self.driver.lib, self.driver.settings) self._renderer.updated.connect(self._on_rendered) self._render_cutoff: float = 0.0 @@ -77,6 +77,7 @@ class ThumbGridLayout(QLayout): ( self._renderer.render, ( + self.driver.cache_manager, self._render_cutoff, Path(), base_size, @@ -300,7 +301,15 @@ class ThumbGridLayout(QLayout): self.driver.thumb_job_queue.put( ( self._renderer.render, - (timestamp, file_path, base_size, ratio, False, True), + ( + self.driver.cache_manager, + timestamp, + file_path, + base_size, + ratio, + False, + True, + ), ) ) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index c0a7de1c..6e400426 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -185,7 +185,7 @@ class QtDriver(DriverMixin, QObject): applied_theme: Theme lib: Library - cache_manager: CacheManager | None + cache_manager: CacheManager | None = None browsing_history: History[BrowsingState] diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index b4847e61..e89204b2 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -17,7 +17,7 @@ from tagstudio.core.media_types import MediaType from tagstudio.qt.mixed.file_attributes import FileAttributeData from tagstudio.qt.mixed.media_player import MediaPlayer from tagstudio.qt.platform_strings import open_file_str, trash_term -from tagstudio.qt.previews.renderer import ThumbRenderer +from tagstudio.qt.qt_file_renderer import QtFileRenderer from tagstudio.qt.translations import Translations from tagstudio.qt.views.stylesheets.rounded_pixmap_style import RoundedPixmapStyle @@ -45,6 +45,7 @@ class PreviewThumbView(QWidget): def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() + self._driver = driver self.__img_button_size = (266, 266) self.__image_ratio = 1.0 @@ -109,7 +110,7 @@ class PreviewThumbView(QWidget): self.__media_player_page = QWidget() self.__stacked_page_setup(self.__media_player_page, self.__media_player) - self.__thumb_renderer = ThumbRenderer(driver) + self.__thumb_renderer = QtFileRenderer(driver.lib, driver.settings) self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) @@ -232,12 +233,13 @@ class PreviewThumbView(QWidget): math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), ) + # TODO: Make driver update the cache manager reference here instead of passing the driver. self.__thumb_renderer.render( + self._driver.cache_manager, time.time(), filepath, self.__rendered_res, self.devicePixelRatio(), - update_on_ratio_change=True, ) def __update_media_player(self, filepath: Path) -> None: diff --git a/src/tagstudio/renderers/clip_studio.py b/src/tagstudio/renderers/clip_studio.py index a7c4ab4c..4c18e1a8 100644 --- a/src/tagstudio/renderers/clip_studio.py +++ b/src/tagstudio/renderers/clip_studio.py @@ -44,7 +44,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None: return im -def pdn_thumb(filepath: Path) -> Image.Image | None: +def paint_dot_net_thumb(filepath: Path) -> Image.Image | None: """Extract the base64-encoded thumbnail from a .pdn file header. Args: