diff --git a/docs/updates/roadmap.md b/docs/updates/roadmap.md index c9075c103..f4855d7f7 100644 --- a/docs/updates/roadmap.md +++ b/docs/updates/roadmap.md @@ -148,7 +148,7 @@ Features are broken up into the following priority levels, with nested prioritie - [x] Timeline scrubber [HIGH] - [ ] Fullscreen [MEDIUM] - [ ] Optimizations [HIGH] - - [ ] Thumbnail caching [HIGH] [#104](https://github.com/TagStudioDev/TagStudio/issues/104) + - [x] Thumbnail caching [HIGH] - [ ] File property indexes [HIGH] ## Version Milestones @@ -195,7 +195,7 @@ These version milestones are rough estimations for when the previous core featur - [ ] Library Settings [HIGH] - [ ] Stored in `.TagStudio` folder [HIGH] - [ ] Optimizations [HIGH] - - [ ] Thumbnail caching [HIGH] + - [x] Thumbnail caching [HIGH] ### 9.6 (Alpha) diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 988fb0033..f3750e720 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -155,12 +155,12 @@ "menu.edit.manage_tags": "Manage Tags", "menu.edit.new_tag": "New &Tag", "menu.edit": "Edit", + "menu.file.clear_recent_libraries": "Clear Recent", "menu.file.close_library": "&Close Library", "menu.file.new_library": "New Library", "menu.file.open_create_library": "&Open/Create Library", - "menu.file.open_recent_library": "Open Recent", - "menu.file.clear_recent_libraries": "Clear Recent", "menu.file.open_library": "Open Library", + "menu.file.open_recent_library": "Open Recent", "menu.file.refresh_directories": "&Refresh Directories", "menu.file.save_backup": "&Save Library Backup", "menu.file.save_library": "Save Library", @@ -177,9 +177,12 @@ "preview.no_selection": "No Items Selected", "select.all": "Select All", "select.clear": "Clear Selection", + "settings.clear_thumb_cache.title": "Clear Thumbnail Cache", "settings.open_library_on_start": "Open Library on Start", "settings.show_filenames_in_grid": "Show Filenames in Grid", "settings.show_recent_libraries": "Show Recent Libraries", + "sorting.direction.ascending": "Ascending", + "sorting.direction.descending": "Descending", "splash.opening_library": "Opening Library \"{library_path}\"...", "status.library_backup_in_progress": "Saving Library Backup...", "status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})", @@ -214,7 +217,5 @@ "view.size.4": "Extra Large", "window.message.error_opening_library": "Error opening library.", "window.title.error": "Error", - "window.title.open_create_library": "Open/Create Library", - "sorting.direction.ascending": "Ascending", - "sorting.direction.descending": "Descending" + "window.title.open_create_library": "Open/Create Library" } diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index baec4a758..6ff0d5f1b 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -9,6 +9,7 @@ TS_FOLDER_NAME: str = ".TagStudio" BACKUP_FOLDER_NAME: str = "backups" COLLAGE_FOLDER_NAME: str = "collages" +THUMB_CACHE_NAME: str = "thumbs" FONT_SAMPLE_TEXT: str = ( """ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]""" diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 74cdcbe4b..88c4e7e97 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -16,6 +16,7 @@ class SettingItems(str, enum.Enum): WINDOW_SHOW_LIBS = "window_show_libs" SHOW_FILENAMES = "show_filenames" AUTOPLAY = "autoplay_videos" + THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit" class Theme(str, enum.Enum): diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 37b58d050..551476888 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -166,7 +166,7 @@ class Library: def close(self): if self.engine: self.engine.dispose() - self.library_dir = None + self.library_dir: Path | None = None self.storage_path = None self.folder = None self.included_files = set() diff --git a/tagstudio/src/core/singleton.py b/tagstudio/src/core/singleton.py new file mode 100644 index 000000000..76b6bf081 --- /dev/null +++ b/tagstudio/src/core/singleton.py @@ -0,0 +1,20 @@ +# Based off example from Refactoring Guru: +# https://refactoring.guru/design-patterns/singleton/python/example#example-1 +# Adapted for TagStudio: https://github.com/CyanVoxel/TagStudio + +from threading import Lock + + +class Singleton(type): + """A thread-safe implementation of a Singleton.""" + + _instances: dict = {} + + _lock: Lock = Lock() + + def __call__(cls, *args, **kwargs): + with cls._lock: + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] diff --git a/tagstudio/src/qt/cache_manager.py b/tagstudio/src/qt/cache_manager.py new file mode 100644 index 000000000..1024f3ffb --- /dev/null +++ b/tagstudio/src/qt/cache_manager.py @@ -0,0 +1,192 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import contextlib +import math +import typing +from datetime import datetime as dt +from pathlib import Path + +import structlog +from PIL import ( + Image, +) +from src.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME +from src.core.singleton import Singleton + +# Only import for type checking/autocompletion, will not be imported at runtime. +if typing.TYPE_CHECKING: + from src.core.library import Library + +logger = structlog.get_logger(__name__) + + +class CacheManager(metaclass=Singleton): + FOLDER_SIZE = 10000000 # Each cache folder assumed to be 10 MiB + size_limit = 500000000 # 500 MiB default + + folder_dict: dict[Path, int] = {} + + def __init__(self): + self.lib: Library | None = None + self.last_lib_path: Path | None = None + + @staticmethod + def clear_cache(library_dir: Path) -> bool: + """Clear all files and folders within the cached folder. + + Returns: + bool: True if successfully deleted, else False. + """ + cleared = True + + if library_dir: + tree: Path = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME + + for folder in tree.glob("*"): + for file in folder.glob("*"): + # NOTE: On macOS with non-native file systems, this will commonly raise + # FileNotFound errors due to trying to delete "._" files that have + # already been deleted: https://bugs.python.org/issue29699 + with contextlib.suppress(FileNotFoundError): + file.unlink() + try: + folder.rmdir() + with contextlib.suppress(KeyError): + CacheManager.folder_dict.pop(folder) + except Exception as e: + logger.error( + "[CacheManager] Couldn't unlink empty cache folder!", + error=e, + folder=folder, + tree=tree, + ) + + for _ in tree.glob("*"): + cleared = False + + if cleared: + logger.info("[CacheManager] Cleared cache!") + else: + logger.error("[CacheManager] Couldn't delete cache!", tree=tree) + + return cleared + + def set_library(self, library): + """Set the TagStudio library for the cache manager.""" + self.lib = library + self.last_lib_path = self.lib.library_dir + if library.library_dir: + self.check_folder_status() + + def cache_dir(self) -> Path | None: + """Return the current cache directory, not including folder slugs.""" + if not self.lib.library_dir: + return None + return Path(self.lib.library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME) + + def save_image(self, image: Image.Image, path: Path, mode: str = "RGBA"): + """Save an image to the cache.""" + folder = self.get_current_folder() + if folder: + image_path: Path = folder / path + image.save(image_path, mode=mode) + with contextlib.suppress(KeyError): + CacheManager.folder_dict[folder] += image_path.stat().st_size + + def check_folder_status(self): + """Check the status of the cache folders. + + This includes registering existing ones and creating new ones if needed. + """ + if ( + (self.last_lib_path != self.lib.library_dir) + or not self.cache_dir() + or not self.cache_dir().exists() + ): + self.register_existing_folders() + + def create_folder() -> Path | None: + """Create a new cache folder.""" + if not self.lib.library_dir: + return None + folder_path = Path(self.cache_dir() / str(math.floor(dt.timestamp(dt.now())))) + logger.info("[CacheManager] Creating new folder", folder=folder_path) + try: + folder_path.mkdir(exist_ok=True) + except NotADirectoryError: + logger.error("[CacheManager] Not a directory", path=folder_path) + return folder_path + + # Get size of most recent folder, if any exist. + if CacheManager.folder_dict: + last_folder = sorted(CacheManager.folder_dict.keys())[-1] + + if CacheManager.folder_dict[last_folder] > CacheManager.FOLDER_SIZE: + new_folder = create_folder() + CacheManager.folder_dict[new_folder] = 0 + else: + new_folder = create_folder() + CacheManager.folder_dict[new_folder] = 0 + + def get_current_folder(self) -> Path: + """Get the current cache folder path that should be used.""" + self.check_folder_status() + self.cull_folders() + + return sorted(CacheManager.folder_dict.keys())[-1] + + def register_existing_folders(self): + """Scan and register any pre-existing cache folders with the most recent size.""" + self.last_lib_path = self.lib.library_dir + CacheManager.folder_dict.clear() + + # NOTE: The /dev/null check is a workaround for current test assumptions. + if self.last_lib_path and self.last_lib_path != Path("/dev/null"): + # Ensure thumbnail cache path exists. + self.cache_dir().mkdir(exist_ok=True) + # Registers any existing folders and counts the capacity of the most recent one. + for f in sorted(self.cache_dir().glob("*")): + if f.is_dir(): + # A folder is found. Add it to the class dict.BlockingIOError + CacheManager.folder_dict[f] = 0 + CacheManager.folder_dict = dict( + sorted(CacheManager.folder_dict.items(), key=lambda kv: kv[0]) + ) + + if CacheManager.folder_dict: + last_folder = sorted(CacheManager.folder_dict.keys())[-1] + for f in last_folder.glob("*"): + if not f.is_dir(): + with contextlib.suppress(KeyError): + CacheManager.folder_dict[last_folder] += f.stat().st_size + + def cull_folders(self): + """Remove folders and their cached context based on size or age limits.""" + # Ensure that the user's configured size limit isn't less than the internal folder size. + size_limit = max(CacheManager.size_limit, CacheManager.FOLDER_SIZE) + + if len(CacheManager.folder_dict) > (size_limit / CacheManager.FOLDER_SIZE): + f = sorted(CacheManager.folder_dict.keys())[0] + folder = self.cache_dir() / f + logger.info("[CacheManager] Removing folder due to size limit", folder=folder) + + for file in folder.glob("*"): + try: + file.unlink() + except Exception as e: + logger.error( + "[CacheManager] Couldn't cull file inside of folder!", + error=e, + file=file, + folder=folder, + ) + try: + folder.rmdir() + with contextlib.suppress(KeyError): + CacheManager.folder_dict.pop(f) + self.cull_folders() + except Exception as e: + logger.error("[CacheManager] Couldn't cull folder!", error=e, folder=folder) + pass diff --git a/tagstudio/src/qt/helpers/gradient.py b/tagstudio/src/qt/helpers/gradient.py index fe3f7c7de..b407b487d 100644 --- a/tagstudio/src/qt/helpers/gradient.py +++ b/tagstudio/src/qt/helpers/gradient.py @@ -6,7 +6,7 @@ def four_corner_gradient( - image: Image.Image, size: tuple[int, int], mask: Image.Image + image: Image.Image, size: tuple[int, int], mask: Image.Image | None = None ) -> Image.Image: if image.size != size: # Four-Corner Gradient Background. @@ -29,11 +29,17 @@ def four_corner_gradient( ) final = Image.new("RGBA", bg.size, (0, 0, 0, 0)) - final.paste(bg, mask=mask.getchannel(0)) + if mask: + final.paste(bg, mask=mask.getchannel(0)) + else: + final = bg else: final = Image.new("RGBA", size, (0, 0, 0, 0)) - final.paste(image, mask=mask.getchannel(0)) + if mask: + final.paste(image, mask=mask.getchannel(0)) + else: + final = image if final.mode != "RGBA": final = final.convert("RGBA") diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index d9929e7b4..273076797 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -81,7 +81,7 @@ def get(self, id: str) -> Any: pass except FileNotFoundError: path: Path = ResourceManager._res_folder / "resources" / res.get("path") - logger.error("[ResourceManager][ERROR]: Could not find resource: ", path) + logger.error("[ResourceManager][ERROR]: Could not find resource: ", path=path) return None def __getattr__(self, __name: str) -> Any: diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index d80e20390..d40c38613 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -21,7 +21,7 @@ # this import has side-effect of import PySide resources import src.qt.resources_rc # noqa: F401 import structlog -from humanfriendly import format_timespan +from humanfriendly import format_size, format_timespan from PySide6 import QtCore from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal from PySide6.QtGui import ( @@ -71,6 +71,7 @@ from src.core.ts_core import TagStudioCore from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol +from src.qt.cache_manager import CacheManager from src.qt.flowlayout import FlowLayout from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.helpers.function_iterator import FunctionIterator @@ -163,8 +164,8 @@ def __init__(self, backend, args): if self.args.config_file: path = Path(self.args.config_file) if not path.exists(): - logger.warning("Config File does not exist creating", path=path) - logger.info("Using Config File", path=path) + logger.warning("[Config] Config File does not exist creating", path=path) + logger.info("[Config] Using Config File", path=path) self.settings = QSettings(str(path), QSettings.Format.IniFormat) else: self.settings = QSettings( @@ -174,10 +175,28 @@ def __init__(self, backend, args): "TagStudio", ) logger.info( - "Config File not specified, using default one", + "[Config] Config File not specified, using default one", filename=self.settings.fileName(), ) + # NOTE: This should be a per-library setting rather than an application setting. + thumb_cache_size_limit: int = int( + str( + self.settings.value( + SettingItems.THUMB_CACHE_SIZE_LIMIT, + defaultValue=CacheManager.size_limit, + type=int, + ) + ) + ) + + CacheManager.size_limit = thumb_cache_size_limit + self.settings.setValue(SettingItems.THUMB_CACHE_SIZE_LIMIT, CacheManager.size_limit) + self.settings.sync() + logger.info( + f"[Config] Thumbnail cache size limit: {format_size(CacheManager.size_limit)}", + ) + def init_workers(self): """Init workers for rendering thumbnails.""" if not self.thumb_threads: @@ -450,6 +469,16 @@ def create_dupe_files_modal(): fix_dupe_files_action.triggered.connect(create_dupe_files_modal) tools_menu.addAction(fix_dupe_files_action) + tools_menu.addSeparator() + + # TODO: Move this to a settings screen. + clear_thumb_cache_action = QAction(menu_bar) + Translations.translate_qobject(clear_thumb_cache_action, "settings.clear_thumb_cache.title") + clear_thumb_cache_action.triggered.connect( + lambda: CacheManager.clear_cache(self.lib.library_dir) + ) + tools_menu.addAction(clear_thumb_cache_action) + # create_collage_action = QAction("Create Collage", menu_bar) # create_collage_action.triggered.connect(lambda: self.create_collage()) # tools_menu.addAction(create_collage_action) diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index adb05dfce..d3190af0e 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -202,7 +202,7 @@ def __init__( self.thumb_layout.addWidget(self.bottom_container) self.thumb_button = ThumbButton(self.thumb_container, thumb_size) - self.renderer = ThumbRenderer() + self.renderer = ThumbRenderer(self.lib) self.renderer.updated.connect( lambda timestamp, image, size, filename, ext: ( self.update_thumb(timestamp, image=image), diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index d73f81f33..30d0e3ae4 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -74,7 +74,7 @@ def __init__(self, library: Library, driver: "QtDriver"): self.preview_vid = VideoPlayer(driver) self.preview_vid.hide() - self.thumb_renderer = ThumbRenderer() + self.thumb_renderer = ThumbRenderer(self.lib) self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i))) self.thumb_renderer.updated_ratio.connect( lambda ratio: ( diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 4fea7f659..a773b059b 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -2,7 +2,8 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - +import contextlib +import hashlib import math import struct import zipfile @@ -44,11 +45,12 @@ from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from PySide6.QtSvg import QSvgRenderer -from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT, THUMB_CACHE_NAME, TS_FOLDER_NAME from src.core.exceptions import NoRendererError from src.core.media_types import MediaCategories, MediaType from src.core.palette import ColorType, UiColor, get_ui_color from src.core.utils.encoding import detect_char_encoding +from src.qt.cache_manager import CacheManager 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 @@ -72,12 +74,20 @@ class ThumbRenderer(QObject): """A class for rendering image and file thumbnails.""" rm: ResourceManager = ResourceManager() + cache: CacheManager = CacheManager() updated = Signal(float, QPixmap, QSize, Path, str) updated_ratio = Signal(float) - def __init__(self) -> None: + cached_img_res: int = 256 # TODO: Pull this from config + cached_img_ext: str = ".webp" # TODO: Pull this from config + + last_cache_folder: Path | None = None + + def __init__(self, library) -> None: """Initialize the class.""" super().__init__() + self.lib = library + ThumbRenderer.cache.set_library(self.lib) # Cached thumbnail elements. # Key: Size + Pixel Ratio Tuple + Radius Scale @@ -404,7 +414,7 @@ def _apply_edge( image: Image.Image, edge: tuple[Image.Image, Image.Image], faded: bool = False, - ): + ) -> Image.Image: """Apply a given edge effect to an image. Args: @@ -999,7 +1009,7 @@ def _video_thumb(self, filepath: Path) -> Image.Image: def render( self, timestamp: float, - filepath: str | Path, + filepath: Path | str, base_size: tuple[int, int], pixel_ratio: float, is_loading: bool = False, @@ -1017,56 +1027,216 @@ def render( 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? - """ + render_mask_and_edge: bool = True 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 - theme_color: UiColor = ( UiColor.THEME_LIGHT if QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Light else UiColor.THEME_DARK ) + if isinstance(filepath, str): + filepath = Path(filepath) - # Initialize "Loading" thumbnail - loading_thumb: Image.Image = self._get_icon( - "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio - ) - - def render_default() -> Image.Image: - if update_on_ratio_change: - self.updated_ratio.emit(1) + def render_default(size: tuple[int, int], pixel_ratio: float) -> Image.Image: im = self._get_icon( - name=self._get_resource_id(_filepath), + name=self._get_resource_id(filepath), color=theme_color, - size=(adj_size, adj_size), + size=size, pixel_ratio=pixel_ratio, ) return im - def render_unlinked() -> Image.Image: - if update_on_ratio_change: - self.updated_ratio.emit(1) + def render_unlinked(size: tuple[int, int], pixel_ratio: float) -> Image.Image: im = self._get_icon( name="broken_link_icon", color=UiColor.RED, - size=(adj_size, adj_size), + size=size, pixel_ratio=pixel_ratio, ) return im - if is_loading: - final = loading_thumb.resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) - qim = ImageQt.ImageQt(final) - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixel_ratio) - if update_on_ratio_change: - self.updated_ratio.emit(1) - elif _filepath: + def fetch_cached_image(folder: Path): + image: Image.Image | None = None + cached_path: Path | None = None + + if hash_value and self.lib.library_dir: + cached_path = ( + self.lib.library_dir + / TS_FOLDER_NAME + / THUMB_CACHE_NAME + / folder + / f"{hash_value}{ThumbRenderer.cached_img_ext}" + ) + if cached_path and cached_path.exists() and not cached_path.is_dir(): + try: + image = Image.open(cached_path) + if not image: + raise UnidentifiedImageError + ThumbRenderer.last_cache_folder = folder + except Exception as e: + logger.error( + "[ThumbRenderer] Couldn't open cached thumbnail!", + path=cached_path, + error=e, + ) + # If the cached thumbnail failed, try rendering a new one + image = self._render( + timestamp, + filepath, + (ThumbRenderer.cached_img_res, ThumbRenderer.cached_img_res), + 1, + is_grid_thumb, + save_to_file=cached_path, + ) + + return image + + image: Image.Image | None = None + # Try to get a non-loading thumbnail for the grid. + if not is_loading and is_grid_thumb and filepath and filepath != ".": + # Attempt to retrieve cached image from disk + mod_time: str = "" + with contextlib.suppress(Exception): + 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) + + # Check the last successful folder first. + if ThumbRenderer.last_cache_folder: + image = fetch_cached_image(ThumbRenderer.last_cache_folder) + + # If there was no last folder or the check failed, check all folders. + if not image: + thumb_folders: list[Path] = [] + try: + for f in (self.lib.library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME).glob("*"): + if f.is_dir() and f is not ThumbRenderer.last_cache_folder: + thumb_folders.append(f) + except TypeError: + logger.error( + "[ThumbRenderer] Couldn't check thumb cache folder, is the library closed?", + library_dir=self.lib.library_dir, + ) + + for folder in thumb_folders: + image = fetch_cached_image(folder) + if image: + ThumbRenderer.last_cache_folder = folder + break + if not image: + # Render from file, return result, and try to save a cached version. + # TODO: Audio waveforms are dynamically sized based on the base_size, so hardcoding + # the resolution breaks that. + image = self._render( + timestamp, + filepath, + (ThumbRenderer.cached_img_res, ThumbRenderer.cached_img_res), + 1, + is_grid_thumb, + save_to_file=Path(f"{hash_value}{ThumbRenderer.cached_img_ext}"), + ) + # If the normal renderer failed, fallback the the defaults + # (with native non-cached sizing!) + if not image: + image = ( + render_unlinked((adj_size, adj_size), pixel_ratio) + if not filepath.exists() + else render_default((adj_size, adj_size), pixel_ratio) + ) + render_mask_and_edge = False + + # Apply the mask and edge + if image: + image = self._resize_image(image, (adj_size, adj_size)) + if render_mask_and_edge: + 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 + ) + image = self._apply_edge( + four_corner_gradient(image, (adj_size, adj_size), mask), edge + ) + + # A loading thumbnail (cached in memory) + elif is_loading: + # Initialize "Loading" thumbnail + loading_thumb: Image.Image = self._get_icon( + "thumb_loading", theme_color, (adj_size, adj_size), 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) + if not image: + image = ( + render_unlinked((512, 512), 2) + if not filepath.exists() + else render_default((512, 512), 2) + ) + render_mask_and_edge = False + mask = self._get_mask(image.size, pixel_ratio, scale_radius=True) + bg = Image.new("RGBA", image.size, (0, 0, 0, 0)) + bg.paste(image, mask=mask.getchannel(0)) + image = bg + + # If the image couldn't be rendered, use a default media image. + 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, + filepath.suffix.lower(), + ) + else: + self.updated.emit( + timestamp, + QPixmap(), + QSize(*base_size), + filepath, + filepath.suffix.lower(), + ) + + def _render( + self, + timestamp: float, + filepath: str | Path, + base_size: tuple[int, int], + pixel_ratio: float, + is_grid_thumb: bool = False, + save_to_file: Path | None = None, + ) -> Image.Image | None: + """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_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. + + """ + adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) + image: Image.Image = None + _filepath: Path = Path(filepath) + savable_media_type: bool = True + + if _filepath: try: # Missing Files ================================================ if not _filepath.exists(): @@ -1121,6 +1291,7 @@ def render_unlinked() -> Image.Image: image = self._audio_album_thumb(_filepath, ext) if image is None: image = self._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) # Ebooks ======================================================= @@ -1147,42 +1318,14 @@ def render_unlinked() -> Image.Image: if not image: raise NoRendererError - orig_x, orig_y = image.size - new_x, new_y = (adj_size, adj_size) + if image: + image = self._resize_image(image, (adj_size, adj_size)) - if orig_x > orig_y: - new_x = adj_size - new_y = math.ceil(adj_size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = adj_size - new_x = math.ceil(adj_size * (orig_x / orig_y)) - - if update_on_ratio_change: - self.updated_ratio.emit(new_x / new_y) - - resampling_method = ( - Image.Resampling.NEAREST - if max(image.size[0], image.size[1]) < max(base_size[0], base_size[1]) - else Image.Resampling.BILINEAR - ) - image = image.resize((new_x, new_y), resample=resampling_method) - mask: Image.Image = None - if is_grid_thumb: - mask = self._get_mask((adj_size, adj_size), pixel_ratio) - edge: tuple[Image.Image, Image.Image] = self._get_edge( - (adj_size, adj_size), pixel_ratio - ) - final = self._apply_edge( - four_corner_gradient(image, (adj_size, adj_size), mask), - edge, - ) - else: - mask = self._get_mask(image.size, pixel_ratio, scale_radius=True) - final = Image.new("RGBA", image.size, (0, 0, 0, 0)) - final.paste(image, mask=mask.getchannel(0)) + if save_to_file and savable_media_type and image: + ThumbRenderer.cache.save_image(image, save_to_file, mode="RGBA") except FileNotFoundError: - final = render_unlinked() + image = None except ( UnidentifiedImageError, DecompressionBombError, @@ -1190,33 +1333,28 @@ def render_unlinked() -> Image.Image: ChildProcessError, ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - final = render_default() + image = None except NoRendererError: - final = render_default() + image = None - qim = ImageQt.ImageQt(final) - if image: - image.close() - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixel_ratio) + return image - if pixmap: - self.updated.emit( - timestamp, - pixmap, - QSize( - math.ceil(adj_size / pixel_ratio), - math.ceil(final.size[1] / pixel_ratio), - ), - _filepath, - _filepath.suffix.lower(), - ) + def _resize_image(self, image, size: tuple[int, int]) -> Image.Image: + orig_x, orig_y = image.size + new_x, new_y = size + + if orig_x > orig_y: + new_x = size[0] + new_y = math.ceil(size[1] * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = size[1] + new_x = math.ceil(size[0] * (orig_x / orig_y)) + + resampling_method = ( + Image.Resampling.NEAREST + if max(image.size[0], image.size[1]) < max(size) + else Image.Resampling.BILINEAR + ) + image = image.resize((new_x, new_y), resample=resampling_method) - else: - self.updated.emit( - timestamp, - QPixmap(), - QSize(*base_size), - _filepath, - _filepath.suffix.lower(), - ) + return image