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:
Travis Abendshien
2025-01-27 11:39:10 -08:00
committed by GitHub
parent 3606edf615
commit 4c337cb1a3
12 changed files with 498 additions and 110 deletions

View File

@@ -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)

View File

@@ -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"
}

View File

@@ -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!?@$%(){}[]"""

View File

@@ -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):

View File

@@ -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()

View 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]

View 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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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),

View File

@@ -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: (

View File

@@ -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