mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 06:10:51 +00:00
feat(ui): add thumbnail caching (#694)
* feat(ui): add thumbnail caching * feat: enforce total size limit for thumb cache * fix: add file modification date to thumb hash This change allows for modified files to have their cached thumbnail regenerated. * feat: add menu option to clear thumbnail cache * fix: check if `cached_path` is None further on * refactor: move configurable cache vars to class level * docs: complete roadmap items * feat: use cache size limit from config file TODO: Store this on a per-library basis and allow it to be configurable inside the program. * fix: move `last_cache_folder` to class level * chore: add/tweak type hints * refactor: use make `CacheManager` a singleton
This commit is contained in:
committed by
GitHub
parent
3606edf615
commit
4c337cb1a3
@@ -145,7 +145,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
|
||||
@@ -190,7 +190,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)
|
||||
|
||||
|
||||
@@ -159,12 +159,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",
|
||||
@@ -182,9 +182,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})",
|
||||
@@ -220,7 +223,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"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ VERSION_BRANCH: str = "EXPERIMENTAL" # Usually "" or "Pre-Release"
|
||||
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!?@$%(){}[]"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
20
tagstudio/src/core/singleton.py
Normal file
20
tagstudio/src/core/singleton.py
Normal file
@@ -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]
|
||||
192
tagstudio/src/qt/cache_manager.py
Normal file
192
tagstudio/src/qt/cache_manager.py
Normal file
@@ -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
|
||||
@@ -6,7 +6,7 @@ from PIL import Image
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -20,7 +20,7 @@ from queue import Queue
|
||||
# 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 (
|
||||
@@ -67,6 +67,7 @@ from src.core.media_types import MediaCategories
|
||||
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 @@ class QtDriver(DriverMixin, QObject):
|
||||
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)
|
||||
self.config_path = str(path)
|
||||
else:
|
||||
@@ -175,11 +176,29 @@ class QtDriver(DriverMixin, QObject):
|
||||
"TagStudio",
|
||||
)
|
||||
logger.info(
|
||||
"Config File not specified, using default one",
|
||||
"[Config] Config File not specified, using default one",
|
||||
filename=self.settings.fileName(),
|
||||
)
|
||||
self.config_path = 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:
|
||||
@@ -434,6 +453,16 @@ class QtDriver(DriverMixin, QObject):
|
||||
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)
|
||||
|
||||
@@ -202,7 +202,7 @@ class ItemThumb(FlowWidget):
|
||||
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),
|
||||
|
||||
@@ -74,7 +74,7 @@ class PreviewThumb(QWidget):
|
||||
|
||||
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: (
|
||||
|
||||
@@ -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
|
||||
@@ -43,11 +44,12 @@ from PySide6.QtCore import (
|
||||
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
|
||||
@@ -71,12 +73,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
|
||||
@@ -403,7 +413,7 @@ class ThumbRenderer(QObject):
|
||||
image: Image.Image,
|
||||
edge: tuple[Image.Image, Image.Image],
|
||||
faded: bool = False,
|
||||
):
|
||||
) -> Image.Image:
|
||||
"""Apply a given edge effect to an image.
|
||||
|
||||
Args:
|
||||
@@ -998,7 +1008,7 @@ class ThumbRenderer(QObject):
|
||||
def render(
|
||||
self,
|
||||
timestamp: float,
|
||||
filepath: str | Path,
|
||||
filepath: Path | str,
|
||||
base_size: tuple[int, int],
|
||||
pixel_ratio: float,
|
||||
is_loading: bool = False,
|
||||
@@ -1016,56 +1026,216 @@ class ThumbRenderer(QObject):
|
||||
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():
|
||||
@@ -1120,6 +1290,7 @@ class ThumbRenderer(QObject):
|
||||
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 =======================================================
|
||||
@@ -1146,42 +1317,14 @@ class ThumbRenderer(QObject):
|
||||
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,
|
||||
@@ -1189,33 +1332,28 @@ class ThumbRenderer(QObject):
|
||||
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
|
||||
|
||||
else:
|
||||
self.updated.emit(
|
||||
timestamp,
|
||||
QPixmap(),
|
||||
QSize(*base_size),
|
||||
_filepath,
|
||||
_filepath.suffix.lower(),
|
||||
)
|
||||
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)
|
||||
|
||||
return image
|
||||
|
||||
Reference in New Issue
Block a user