From 068add29df3c7da3cf0b3d341d078ebd920ea7a2 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:30:18 -0700 Subject: [PATCH 1/8] refactor: split ThumbRenderer class --- pyproject.toml | 4 +- src/tagstudio/qt/file_renderer.py | 7 + src/tagstudio/qt/helpers/file_tester.py | 8 +- src/tagstudio/qt/previews/renderer.py | 1127 +---------------- src/tagstudio/renderers/apple.py | 56 + src/tagstudio/renderers/archive.py | 118 ++ src/tagstudio/renderers/audio.py | 149 +++ src/tagstudio/renderers/blender.py | 43 + src/tagstudio/renderers/clip_studio.py | 80 ++ src/tagstudio/renderers/ebook.py | 73 ++ src/tagstudio/renderers/font.py | 108 ++ src/tagstudio/renderers/krita.py | 35 + src/tagstudio/renderers/medibang_paint.py | 58 + src/tagstudio/renderers/microsoft_office.py | 38 + src/tagstudio/renderers/open_document.py | 35 + src/tagstudio/renderers/pdf.py | 65 + src/tagstudio/renderers/raster_image.py | 121 ++ src/tagstudio/renderers/source_engine.py | 30 + src/tagstudio/renderers/text.py | 55 + src/tagstudio/renderers/vector_image.py | 54 + .../vendored/blender_renderer.py | 0 .../previews => renderers}/vendored/probe.py | 0 .../vendored/pydub/audio_segment.py | 4 +- .../vendored/pydub/utils.py | 0 src/tagstudio/renderers/video.py | 55 + 25 files changed, 1244 insertions(+), 1079 deletions(-) create mode 100644 src/tagstudio/qt/file_renderer.py create mode 100644 src/tagstudio/renderers/apple.py create mode 100644 src/tagstudio/renderers/archive.py create mode 100644 src/tagstudio/renderers/audio.py create mode 100644 src/tagstudio/renderers/blender.py create mode 100644 src/tagstudio/renderers/clip_studio.py create mode 100644 src/tagstudio/renderers/ebook.py create mode 100644 src/tagstudio/renderers/font.py create mode 100644 src/tagstudio/renderers/krita.py create mode 100644 src/tagstudio/renderers/medibang_paint.py create mode 100644 src/tagstudio/renderers/microsoft_office.py create mode 100644 src/tagstudio/renderers/open_document.py create mode 100644 src/tagstudio/renderers/pdf.py create mode 100644 src/tagstudio/renderers/raster_image.py create mode 100644 src/tagstudio/renderers/source_engine.py create mode 100644 src/tagstudio/renderers/text.py create mode 100644 src/tagstudio/renderers/vector_image.py rename src/tagstudio/{qt/previews => renderers}/vendored/blender_renderer.py (100%) rename src/tagstudio/{qt/previews => renderers}/vendored/probe.py (100%) rename src/tagstudio/{qt/previews => renderers}/vendored/pydub/audio_segment.py (99%) rename src/tagstudio/{qt/previews => renderers}/vendored/pydub/utils.py (100%) create mode 100644 src/tagstudio/renderers/video.py diff --git a/pyproject.toml b/pyproject.toml index f2c897835..f999f1c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ filterwarnings = [ ignore = [ ".venv/**", "src/tagstudio/core/library/json/", - "src/tagstudio/qt/previews/vendored/pydub/", + "src/tagstudio/renderers/vendored/pydub/", ] include = ["src/tagstudio", "tests"] # Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md @@ -117,7 +117,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"] [tool.ruff.lint.per-file-ignores] "tests/**" = ["D", "E402"] -"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"] +"src/tagstudio/renderers/vendored/**" = ["B", "E", "N", "UP", "SIM115"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/src/tagstudio/qt/file_renderer.py b/src/tagstudio/qt/file_renderer.py new file mode 100644 index 000000000..2db3e1ce7 --- /dev/null +++ b/src/tagstudio/qt/file_renderer.py @@ -0,0 +1,7 @@ +from PySide6.QtCore import QObject + + +class FileRenderer(QObject): ... + + +"""A Qt-specific entry point for rendering file previews and thumbnails.""" diff --git a/src/tagstudio/qt/helpers/file_tester.py b/src/tagstudio/qt/helpers/file_tester.py index b9c4305a2..e7eb7fbcf 100644 --- a/src/tagstudio/qt/helpers/file_tester.py +++ b/src/tagstudio/qt/helpers/file_tester.py @@ -6,7 +6,7 @@ import ffmpeg -from tagstudio.qt.previews.vendored.probe import probe +from tagstudio.renderers.vendored.probe import probe def is_readable_video(filepath: Path | str): @@ -23,11 +23,7 @@ def is_readable_video(filepath: Path | str): return False for stream in result["streams"]: # DRM check - if stream.get("codec_tag_string") in [ - "drma", - "drms", - "drmi", - ]: + if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]: return False except ffmpeg.Error: return False diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 4a0625afb..d4285f18c 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -2,104 +2,67 @@ # SPDX-License-Identifier: GPL-3.0-only -import base64 import contextlib import hashlib import math -import os -import sqlite3 -import struct -import tarfile -import xml.etree.ElementTree as ET -import zipfile -import zlib from copy import deepcopy -from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Literal, cast -from warnings import catch_warnings -from xml.etree.ElementTree import Element - -import cv2 -import numpy as np -import py7zr -import py7zr.io -import rarfile -import rawpy -import srctools +from typing import TYPE_CHECKING + import structlog -from cv2.typing import MatLike -from mutagen import flac, id3, mp4 -from mutagen._util import MutagenError from PIL import ( Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, - ImageFont, - ImageOps, ImageQt, UnidentifiedImageError, ) from PIL.Image import DecompressionBombError -from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType] -from PySide6.QtCore import ( - QBuffer, - QFile, - QFileDevice, - QIODeviceBase, - QObject, - QSize, - QSizeF, - Qt, - Signal, -) -from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap -from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions -from PySide6.QtSvg import QSvgRenderer -from rawpy import ( - LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] - LibRawIOError, # pyright: ignore[reportPrivateImportUsage] -) +from PySide6.QtCore import QObject, QSize, Qt, Signal +from PySide6.QtGui import QGuiApplication, QPixmap +from typing_extensions import deprecated -from tagstudio.core.constants import ( - FONT_SAMPLE_SIZES, - FONT_SAMPLE_TEXT, -) from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaCategories, MediaType -from tagstudio.core.utils.encoding import detect_char_encoding from tagstudio.core.utils.types import unwrap from tagstudio.qt.global_settings import ( DEFAULT_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES, MIN_CACHED_THUMB_RES, ) -from tagstudio.qt.helpers.color_overlay import auto_theme_overlay -from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.helpers.gradients import four_corner_gradient -from tagstudio.qt.helpers.image_effects import replace_transparent_pixels -from tagstudio.qt.helpers.text_wrapper import wrap_full_text from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color -from tagstudio.qt.previews.vendored.blender_renderer import ( - blend_thumb, # pyright: ignore[reportUnknownVariableType] -) -from tagstudio.qt.previews.vendored.pydub.audio_segment import ( - _AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage] -) from tagstudio.qt.resource_manager import ResourceManager +from tagstudio.renderers.apple import apple_embedded_thumb +from tagstudio.renderers.archive import archive_thumb +from tagstudio.renderers.audio import audio_album_thumb, audio_waveform_thumb +from tagstudio.renderers.blender import blender +from tagstudio.renderers.clip_studio import clip_thumb, pdn_thumb +from tagstudio.renderers.ebook import epub_cover +from tagstudio.renderers.font import font_long_thumb, font_short_thumb +from tagstudio.renderers.krita import krita_thumb +from tagstudio.renderers.medibang_paint import mdp_thumb +from tagstudio.renderers.microsoft_office import powerpoint_thumb +from tagstudio.renderers.open_document import open_doc_thumb +from tagstudio.renderers.pdf import pdf_thumb +from tagstudio.renderers.raster_image import image_exr_thumb, image_raw_thumb, image_thumb +from tagstudio.renderers.source_engine import vtf_thumb +from tagstudio.renderers.text import text_thumb +from tagstudio.renderers.vector_image import image_vector_thumb +from tagstudio.renderers.video import video_thumb if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver ImageFile.LOAD_TRUNCATED_IMAGES = True -os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" + logger = structlog.get_logger(__name__) Image.MAX_IMAGE_PIXELS = None -register_heif_opener() + try: import pillow_jxl # noqa: F401 # pyright: ignore @@ -107,46 +70,6 @@ logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e) -class _SevenZipFile(py7zr.SevenZipFile): - """Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API.""" - - def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - super().__init__(filepath, mode) - - def read(self, name: str) -> bytes: - # SevenZipFile must be reset after every extraction - # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract - self.reset() - factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB - self.extract(targets=[name], factory=factory) - return factory.get(name).read() - - -class _TarFile: - """Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API.""" - - def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - self.tar: tarfile.TarFile - self.filepath = filepath - self.mode: Literal["r"] = mode - - def namelist(self) -> list[str]: - return self.tar.getnames() - - def read(self, name: str) -> bytes: - return unwrap(self.tar.extractfile(name)).read() - - def __enter__(self) -> "_TarFile": - self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__() - return self - - def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] - self.tar.__exit__(*args) - - -type _Archive = zipfile.ZipFile | rarfile.RarFile | _SevenZipFile | _TarFile - - class ThumbRenderer(QObject): """A class for rendering image and file thumbnails.""" @@ -201,6 +124,7 @@ def _get_resource_id(self, url: Path) -> str: 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: @@ -224,6 +148,7 @@ def _get_mask( 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]: @@ -281,6 +206,7 @@ def _get_icon( 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: @@ -311,6 +237,7 @@ def _render_mask( ) 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]: @@ -611,11 +538,9 @@ def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Imag 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], faded: bool = False ) -> Image.Image: """Apply a given edge effect to an image. @@ -647,929 +572,6 @@ def _apply_edge( return im - @staticmethod - def _audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: - """Return an album cover thumb from an audio file if a cover is present. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - """ - image: Image.Image | None = None - try: - if not filepath.is_file(): - raise FileNotFoundError - - artwork = None - if ext in [".mp3"]: - id3_tags: id3.ID3 = id3.ID3(filepath) - id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType] - if id3_covers: - artwork = Image.open(BytesIO(id3_covers[0].data)) - elif ext in [".flac"]: - flac_tags: flac.FLAC = flac.FLAC(filepath) - flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType] - if flac_covers: - artwork = Image.open(BytesIO(flac_covers[0].data)) - elif ext in [".mp4", ".m4a", ".aac"]: - mp4_tags: mp4.MP4 = mp4.MP4(filepath) - mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType] - if mp4_covers: - artwork = Image.open(BytesIO(mp4_covers[0])) - if artwork: - image = artwork - except ( - FileNotFoundError, - id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] - mp4.MP4MetadataError, - mp4.MP4StreamInfoError, - MutagenError, - ) as e: - logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) - return image - - @staticmethod - def _audio_waveform_thumb( - filepath: Path, ext: str, size: int, pixel_ratio: float - ) -> Image.Image | None: - """Render a waveform image from an audio file. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - size (tuple[int,int]): The size of the thumbnail. - pixel_ratio (float): The screen pixel ratio. - """ - # BASE_SCALE used for drawing on a larger image and resampling down - # to provide an antialiased effect. - base_scale: int = 2 - samples_per_bar: int = 3 - size_scaled: int = size * base_scale - allow_small_min: bool = False - im: Image.Image | None = None - - try: - bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) - audio = AudioSegment.from_file(filepath, ext[1:]) - data = np.frombuffer(buffer=audio._data, dtype=np.int16) - data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) - bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 - line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale - bar_height: float = (size_scaled) - (size_scaled // bar_margin) - - count: int = 0 - maximum_item: int = 0 - max_array: list[int] = [] - highest_line: int = 0 - - for i in range(-1, len(data_indices)): - d = data[math.ceil(data_indices[i]) - 1] - if count < samples_per_bar: - count = count + 1 - with catch_warnings(record=True): - if abs(d) > maximum_item: - maximum_item = int(abs(d)) - else: - max_array.append(maximum_item) - - if maximum_item > highest_line: - highest_line = maximum_item - - maximum_item = 0 - count = 1 - - line_ratio = max(highest_line / bar_height, 1) - - im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") - draw = ImageDraw.Draw(im) - - current_x = bar_margin - for item in max_array: - item_height = item / line_ratio - - # If small minimums are not allowed, raise all values - # smaller than the line width to the same value. - if not allow_small_min: - item_height = max(item_height, line_width) - - current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 - - draw.rounded_rectangle( - ( - current_x, - current_y, - (current_x + line_width), - (current_y + item_height), - ), - radius=100 * base_scale, - fill=("#FF0000"), - outline=("#FFFF00"), - width=max(math.ceil(line_width / 6), base_scale), - ) - - current_x = current_x + line_width + bar_margin - - im.resize((size, size), Image.Resampling.BILINEAR) - - except Exception as e: - logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) - - return im - - @staticmethod - def _blender(filepath: Path) -> Image.Image | None: - """Get an emended thumbnail from a Blender file, if a thumbnail is present. - - Args: - filepath (Path): The path of the file. - """ - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - im: Image.Image | None = None - try: - if (blend_image := blend_thumb(str(filepath))) is not None: - bg = Image.new("RGB", blend_image.size, color=bg_color) - bg.paste(blend_image, mask=blend_image.getchannel(3)) - im = bg - else: - logger.info( - f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " - "Doesn't have an embedded thumbnail." - ) - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _vtf_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for VTF (Valve Texture Format) images. - - Uses the srctools library for reading VTF files. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with open(filepath, "rb") as f: - vtf = srctools.VTF.read(f) - im = vtf.get(frame=0).to_PIL() - - except (ValueError, FileNotFoundError) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _open_doc_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an OpenDocument file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "Thumbnails/thumbnail.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im - - @staticmethod - def _krita_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Krita file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "preview.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im - - @staticmethod - def _powerpoint_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Microsoft PowerPoint file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "docProps/thumbnail.jpeg" - im: Image.Image | None = None - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im - - @staticmethod - def _epub_cover(filepath: Path, ext: str) -> Image.Image | None: - """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. - - Args: - filepath (Path): The path to the ePub file. - ext (str): The file extension. - - Returns: - Image: The cover specified in ComicInfo.xml, - the first image found in the ePub file, or None by default. - """ - im: Image.Image | None = None - try: - with ThumbRenderer.__open_archive(filepath, ext) as archive: - if "ComicInfo.xml" in archive.namelist(): - comic_info = ET.fromstring(archive.read("ComicInfo.xml")) - im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover") - if not im: - im = ThumbRenderer.__cover_from_comic_info( - archive, comic_info, "InnerCover" - ) - - if not im: - im = ThumbRenderer.__first_image(archive) - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - - return im - - @staticmethod - def __cover_from_comic_info( - archive: _Archive, comic_info: Element, cover_type: str - ) -> Image.Image | None: - """Extract the cover specified in ComicInfo.xml. - - Args: - archive (_Archive): The current ePub file. - comic_info (Element): The parsed ComicInfo.xml. - cover_type (str): The type of cover to load. - - Returns: - Image: The cover specified in ComicInfo.xml. - """ - im: Image.Image | None = None - - cover = comic_info.find(f"./*Page[@Type='{cover_type}']") - if cover is not None: - pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] - page_name = pages[int(unwrap(cover.get("Image")))] - ext = ThumbRenderer.__suffix(page_name) - if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): - image_data = archive.read(page_name) - im = ThumbRenderer.__load_raster_image(BytesIO(image_data)) - - return im - - @staticmethod - def _archive_thumb(filepath: Path, ext: str) -> Image.Image | None: - """Extract the first image found in the archive. - - Args: - filepath (Path): The path to the archive. - ext (str): The file extension. - - Returns: - Image: The first image found in the archive. - """ - im: Image.Image | None = None - try: - with ThumbRenderer.__open_archive(filepath, ext) as archive: - im = ThumbRenderer.__first_image(archive) - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - - return im - - @staticmethod - def __open_archive(filepath: Path, ext: str) -> _Archive: - """Open an archive with its corresponding archiver. - - Args: - filepath (Path): The path to the archive. - ext (str): The file extension. - - Returns: - _Archive: The opened archive. - """ - archiver: type[_Archive] = zipfile.ZipFile - if ext in {".7z", ".cb7", ".s7z"}: - archiver = _SevenZipFile - elif ext in {".cbr", ".rar"}: - archiver = rarfile.RarFile - elif ext in {".cbt", ".tar", ".tgz"}: - archiver = _TarFile - return archiver(filepath, "r") - - @staticmethod - def __first_image(archive: _Archive) -> Image.Image | None: - """Find and extract the first renderable image in the archive. - - Args: - archive (_Archive): The current archive. - - Returns: - Image: The first renderable image in the archive. - """ - for file_name in archive.namelist(): - ext = ThumbRenderer.__suffix(file_name) - if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): - image_data = archive.read(file_name) - return ThumbRenderer.__load_raster_image(BytesIO(image_data)) - - return None - - def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image | None: - """Render a small font preview ("Aa") thumbnail from a font file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - im: Image.Image | None = None - try: - bg = Image.new("RGB", (size, size), color="#000000") - raw = Image.new("RGB", (size * 3, size * 3), color="#000000") - draw = ImageDraw.Draw(raw) - font = ImageFont.truetype(filepath, size=size) - # NOTE: While a stroke effect is desired, the text - # method only allows for outer strokes, which looks - # a bit weird when rendering fonts. - draw.text( - (size // 8, size // 8), - "Aa", - font=font, - fill="#FF0000", - # stroke_width=math.ceil(size / 96), - # stroke_fill="#FFFF00", - ) - # NOTE: Change to getchannel(1) if using an outline. - data = np.asarray(raw.getchannel(0)) - - m, n = data.shape[:2] - col: np.ndarray = cast(np.ndarray, data.any(0)) - row: np.ndarray = cast(np.ndarray, data.any(1)) - cropped_data = np.asarray(raw)[ - row.argmax() : m - row[::-1].argmax(), - col.argmax() : n - col[::-1].argmax(), - ] - cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") - - margin: int = math.ceil(size // 16) - - orig_x, orig_y = cropped_im.size - new_x, new_y = (size, size) - if orig_x > orig_y: - new_x = size - new_y = math.ceil(size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = size - new_x = math.ceil(size * (orig_x / orig_y)) - - cropped_im = cropped_im.resize( - size=(new_x - (margin * 2), new_y - (margin * 2)), - resample=Image.Resampling.BILINEAR, - ) - bg.paste( - cropped_im, - box=(margin, margin + ((size - new_y) // 2)), - ) - im = self._apply_overlay_color(bg, UiColor.BLUE) - except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _font_long_thumb(filepath: Path, size: int) -> Image.Image | None: - """Render a large font preview ("Alphabet") thumbnail from a font file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - # Scale the sample font sizes to the preview image - # resolution,assuming the sizes are tuned for 256px. - im: Image.Image | None = None - try: - scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES] - bg = Image.new("RGBA", (size, size), color="#00000000") - draw = ImageDraw.Draw(bg) - lines_of_padding = 2 - y_offset = 0.0 - - for font_size in scaled_sizes: - font = ImageFont.truetype(filepath, size=font_size) - text_wrapped: str = wrap_full_text( - FONT_SAMPLE_TEXT, - font=font, - width=size, - draw=draw, - ) - draw.multiline_text((0, y_offset), text_wrapped, font=font) - y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox( - (0, 0), "A", font=font - )[-1] - im = auto_theme_overlay(bg, use_alpha=False) - except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_raw_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a RAW image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess(use_camera_wb=True) - im = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except ( - DecompressionBombError, - LibRawIOError, - LibRawFileUnsupportedError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_exr_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a EXR image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - # Load the EXR data to an array and rotate the color space from BGRA -> RGBA - raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED) - assert raw_array is not None - raw_array[..., :3] = raw_array[..., 2::-1] - - # Correct the gamma of the raw array - gamma = 2.2 - array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma) - array = (array_gamma * 255).astype(np.uint8) - - im = Image.fromarray(array, mode="RGBA") - - # Paste solid background - if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") - new_bg.paste(im, mask=im.getchannel(3)) - im = new_bg - - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a standard image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with filepath.open("rb") as file: - im = ThumbRenderer.__load_raster_image(BytesIO(file.read())) - except ( - FileNotFoundError, - UnidentifiedImageError, - DecompressionBombError, - NotImplementedError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_vector_thumb(filepath: Path, size: int) -> Image.Image: - """Render a thumbnail for a vector image, such as SVG. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - im: Image.Image | None = None - # Create an image to draw the svg to and a painter to do the drawing - q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32) - q_image.fill("#1e1e1e") - - # Create an svg renderer, then render to the painter - svg: QSvgRenderer = QSvgRenderer(str(filepath)) - - if not svg.isValid(): - raise UnidentifiedImageError - - painter: QPainter = QPainter(q_image) - svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) - svg.render(painter) - painter.end() - - # Write the image to a buffer as png - buffer: QBuffer = QBuffer() - buffer.open(QBuffer.OpenModeFlag.ReadWrite) - q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType] - - # Load the image from the buffer - im = Image.new("RGB", (size, size), color="#1e1e1e") - im.paste(Image.open(BytesIO(buffer.data().data()))) - im = im.convert(mode="RGB") - - buffer.close() - return im - - @staticmethod - def _apple_embedded_thumb(filepath: Path) -> Image.Image | None: - """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio). - - Args: - filepath (Path): The path of the file. - """ - thumb_files: list[str] = [ - "preview.jpg", - "QuickLook/Preview.heic", - "QuickLook/Thumbnail.jpg", - "QuickLook/Thumbnail.heic", - "QuickLook/Thumbnail.webp", - "QuickLook/Icon.webp", - ] - im: Image.Image | None = None - - def get_image(path: str) -> Image.Image | None: - thumb_im: Image.Image | None = None - # Read the specific file into memory - file_data = zip_file.read(path) - thumb_im = Image.open(BytesIO(file_data)) - return thumb_im - - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - thumb: Image.Image | None = None - - # Check if the file exists in the zip - for thumb_file in thumb_files: - if thumb_file in zip_file.namelist(): - thumb = get_image(thumb_file) - break - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - if thumb: - im = Image.new("RGB", thumb.size, color="#1e1e1e") - im.paste(thumb) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im - - @staticmethod - def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: # pyright: ignore[reportUnusedParameter] - """Render a thumbnail for an STL file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the icon. - """ - # TODO: Implement. - # The following commented code describes a method for rendering via - # matplotlib. - # This implementation did not play nice with multithreading. - im: Image.Image | None = None - # # Create a new plot - # matplotlib.use('agg') - # figure = plt.figure() - # axes = figure.add_subplot(projection='3d') - - # # Load the STL files and add the vectors to the plot - # your_mesh = mesh.Mesh.from_file(_filepath) - - # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) - # poly_collection.set_color((0,0,1)) # play with color - # scale = your_mesh.points.flatten() - # axes.auto_scale_xyz(scale, scale, scale) - # axes.add_collection3d(poly_collection) - # # plt.show() - # img_buf = io.BytesIO() - # plt.savefig(img_buf, format='png') - # im = Image.open(img_buf) - - return im - - @staticmethod - def _pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None: - """Render a thumbnail for a PDF or Adobe Illustator file. - - filepath (Path): The path of the file. - size (int): The size of the icon. - ext (str): The file extension. - """ - im: Image.Image | None = None - - file: QFile = QFile(filepath) - success: bool = file.open( - QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser - ) - if not success: - logger.error("Couldn't render thumbnail", filepath=filepath) - return im - document: QPdfDocument = QPdfDocument() - document.load(file) - file.close() - # Transform page_size in points to pixels with proper aspect ratio - page_size: QSizeF = document.pagePointSize(0) - ratio_hw: float = page_size.height() / page_size.width() - if ratio_hw >= 1: - page_size *= size / page_size.height() - else: - page_size *= size / page_size.width() - # Enlarge image for anti-aliasing - scale_factor = 2.5 if ext in {".pdf"} else 1 - page_size *= scale_factor - # Render image with no anti-aliasing for speed - render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() - render_options.setRenderFlags( - QPdfDocumentRenderOptions.RenderFlag.TextAliased - | QPdfDocumentRenderOptions.RenderFlag.ImageAliased - | QPdfDocumentRenderOptions.RenderFlag.PathAliased - ) - # Convert QImage to PIL Image - q_image: QImage = document.render(0, page_size.toSize(), render_options) - buffer: QBuffer = QBuffer() - buffer.open(QBuffer.OpenModeFlag.ReadWrite) - try: - q_image.save(buffer, "PNG") # pyright: ignore - im = Image.open(BytesIO(buffer.buffer().data())) - finally: - buffer.close() - # Replace transparent pixels with white (otherwise Background defaults to transparent) - return replace_transparent_pixels(im) - - @staticmethod - def _text_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a plaintext file. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - fg_color: str = ( - "#FFFFFF" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#111111" - ) - - try: - encoding = detect_char_encoding(filepath) - with open(filepath, encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color=bg_color) - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=fg_color) - im = bg - except ( - UnidentifiedImageError, - cv2.error, - DecompressionBombError, - UnicodeDecodeError, - OSError, - FileNotFoundError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _video_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a video file. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - frame: MatLike | None = None - try: - if is_readable_video(filepath): - video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) - # TODO: Move this check to is_readable_video() - if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set( - cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - ) - # NOTE: Depending on the video format, compression, and - # frame count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - max_frame_seek: int = 10 - for i in range( - 0, - min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))), - ): - success, frame = video.read() - if not success: - video.set(cv2.CAP_PROP_POS_FRAMES, i) - else: - break - if frame is not None: - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - im = Image.fromarray(frame) - except ( - UnidentifiedImageError, - cv2.error, - DecompressionBombError, - OSError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _mdp_thumb(filepath: Path) -> Image.Image | None: - """Extract the thumbnail from a .mdp file. - - Args: - filepath (Path): The path of the .mdp file. - - Returns: - Image: The embedded thumbnail. - """ - im: Image.Image | None = None - try: - with open(filepath, "rb") as f: - magic = struct.unpack("<7sx", f.read(8))[0] - if magic != b"mdipack": - return im - - bin_header = struct.unpack(" Image.Image | None: - """Extract the base64-encoded thumbnail from a .pdn file header. - - Args: - filepath (Path): The path of the .pdn file. - - Returns: - Image: the decoded PNG thumbnail or None by default. - """ - im: Image.Image | None = None - with open(filepath, "rb") as f: - try: - # First 4 bytes are the magic number - if f.read(4) != b"PDN3": - return im - - # Header length is a little-endian 24-bit int - header_size = struct.unpack(" Image.Image | None: - """Extract the thumbnail from the SQLite database embedded in a .clip file. - - Args: - filepath (Path): The path of the .clip file. - - Returns: - Image: The embedded thumbnail, if extractable. - """ - im: Image.Image | None = None - try: - with open(filepath, "rb") as f: - blob = f.read() - sqlite_index = blob.find(b"SQLite format 3") - if sqlite_index == -1: - return im - - with sqlite3.connect(":memory:") as conn: - conn.deserialize(blob[sqlite_index:]) - thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone() - if thumbnail: - im = Image.open(BytesIO(thumbnail[0])) - conn.close() - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - - return im - - @staticmethod - def __suffix(archive_path: str) -> str: - """Read the files' extension. - - See pathlib.Path.suffix. - - Args: - archive_path (str): The path of the file in the archive. - - Returns: - str: The file extension. - """ - i = archive_path.rfind(".") - if 0 < i < len(archive_path) - 1: - return archive_path[i:] - else: - return "" - - @staticmethod - def __load_raster_image(image_data: BytesIO) -> Image.Image: - """Load a raster image and add a background if it's transparent. - - Args: - image_data (BytesIO): The binary image data. - - Returns: - Image.Image: The loaded raster image, with a background if needed. - """ - im: Image.Image = Image.open(image_data) - if im.mode != "RGB" and im.mode != "RGBA": - im = im.convert(mode="RGBA") - if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") - new_bg.paste(im, mask=im.getchannel(3)) - im = new_bg - return unwrap(ImageOps.exif_transpose(im)) - def render( self, timestamp: float, @@ -1634,19 +636,11 @@ def render_ignored( im_ = im icon: Image.Image = self.rm.ignored - icon = icon.resize( - ( - math.ceil(size[0] // icon_ratio), - math.ceil(size[1] // icon_ratio), - ) - ) + icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) im_.paste( im=icon.resize( - ( - math.ceil(size[0] // icon_ratio), - math.ceil(size[1] // icon_ratio), - ) + (math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)) ), box=(size[0] // padding_factor, size[1] // padding_factor), mask=icon.getchannel(3), @@ -1666,9 +660,7 @@ def fetch_cached_image(file_name: Path): raise UnidentifiedImageError # pyright: ignore[reportUnreachable] except Exception as e: logger.error( - "[ThumbRenderer] Couldn't open cached thumbnail!", - path=cached_path, - error=e, + "[ThumbRenderer] Couldn't open cached thumbnail!", path=cached_path, error=e ) return image @@ -1782,12 +774,7 @@ def fetch_cached_image(file_name: Path): filepath, ) else: - self.updated.emit( - timestamp, - QPixmap(), - QSize(*base_size), - filepath, - ) + self.updated.emit(timestamp, QPixmap(), QSize(*base_size), filepath) def _render( self, @@ -1822,22 +809,22 @@ def _render( if MediaCategories.is_ext_in_category( ext, MediaCategories.EBOOK_TYPES, mime_fallback=True ): - image = self._epub_cover(_filepath, ext) + image = epub_cover(_filepath, ext) # Krita ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.KRITA_TYPES, mime_fallback=True ): - image = self._krita_thumb(_filepath) + image = krita_thumb(_filepath) # Clip Studio Paint ============================================ elif MediaCategories.is_ext_in_category( ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES ): - image = self._clip_thumb(_filepath) + image = clip_thumb(_filepath) # VTF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True ): - image = self._vtf_thumb(_filepath) + image = vtf_thumb(_filepath) # Images ======================================================= elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_TYPES, mime_fallback=True @@ -1846,59 +833,61 @@ def _render( if MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True ): - image = self._image_raw_thumb(_filepath) + image = image_raw_thumb(_filepath) # Vector Images -------------------------------------------- elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True ): - image = self._image_vector_thumb(_filepath, adj_size) + image = image_vector_thumb(_filepath, adj_size) # EXR Images ----------------------------------------------- elif ext in [".exr"]: - image = self._image_exr_thumb(_filepath) + image = image_exr_thumb(_filepath) # Normal Images -------------------------------------------- else: - image = self._image_thumb(_filepath) + image = image_thumb(_filepath) # Videos ======================================================= elif MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ): - image = self._video_thumb(_filepath) + image = video_thumb(_filepath) # PowerPoint Slideshow elif ext in {".pptx"}: - image = self._powerpoint_thumb(_filepath) + image = powerpoint_thumb(_filepath) # OpenDocument/OpenOffice ====================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True ): - image = self._open_doc_thumb(_filepath) + image = open_doc_thumb(_filepath) # Apple iWork Suite ============================================ elif ( MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES) or ext == ".pxd" ): - image = self._apple_embedded_thumb(_filepath) + image = apple_embedded_thumb(_filepath) # Plain Text =================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True ): - image = self._text_thumb(_filepath) + 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 = self._font_short_thumb(_filepath, adj_size) + image = font_short_thumb(_filepath, adj_size) + if image is not None: + image = self._apply_overlay_color(image, UiColor.BLUE) else: # Large (Full Alphabet) Preview - image = self._font_long_thumb(_filepath, adj_size) + image = font_long_thumb(_filepath, adj_size) # Audio ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): - image = self._audio_album_thumb(_filepath, ext) + image = audio_album_thumb(_filepath, ext) if image is None: - image = self._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) @@ -1906,21 +895,21 @@ def _render( elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True ): - image = self._blender(_filepath) + image = blender(_filepath) # PDF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.PDF_TYPES, mime_fallback=True ): - image = self._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 = self._archive_thumb(_filepath, ext) + image = archive_thumb(_filepath, ext) # MDIPACK ====================================================== elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES): - image = self._mdp_thumb(_filepath) + image = mdp_thumb(_filepath) # Paint.NET ==================================================== elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES): - image = self._pdn_thumb(_filepath) + image = pdn_thumb(_filepath) # No Rendered Thumbnail ======================================== if not image: raise NoRendererError diff --git a/src/tagstudio/renderers/apple.py b/src/tagstudio/renderers/apple.py new file mode 100644 index 000000000..2f93e941a --- /dev/null +++ b/src/tagstudio/renderers/apple.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import zipfile +from io import BytesIO +from pathlib import Path + +import structlog +from PIL import Image + +logger = structlog.get_logger(__name__) + + +def apple_embedded_thumb(filepath: Path) -> Image.Image | None: + """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio). + + Args: + filepath (Path): The path of the file. + """ + thumb_files: list[str] = [ + "preview.jpg", + "QuickLook/Preview.heic", + "QuickLook/Thumbnail.jpg", + "QuickLook/Thumbnail.heic", + "QuickLook/Thumbnail.webp", + "QuickLook/Icon.webp", + ] + im: Image.Image | None = None + + def get_image(path: str) -> Image.Image | None: + thumb_im: Image.Image | None = None + # Read the specific file into memory + file_data = zip_file.read(path) + thumb_im = Image.open(BytesIO(file_data)) + return thumb_im + + try: + with zipfile.ZipFile(filepath, "r") as zip_file: + thumb: Image.Image | None = None + + # Check if the file exists in the zip + for thumb_file in thumb_files: + if thumb_file in zip_file.namelist(): + thumb = get_image(thumb_file) + break + else: + logger.error("Couldn't render thumbnail", filepath=filepath) + + if thumb: + im = Image.new("RGB", thumb.size, color="#1e1e1e") + im.paste(thumb) + except zipfile.BadZipFile as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + + return im diff --git a/src/tagstudio/renderers/archive.py b/src/tagstudio/renderers/archive.py new file mode 100644 index 000000000..d6a5b5bb9 --- /dev/null +++ b/src/tagstudio/renderers/archive.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import tarfile +import zipfile +from io import BytesIO +from pathlib import Path +from typing import Literal + +import py7zr +import py7zr.io +import rarfile +import structlog +from PIL import Image + +from tagstudio.core.media_types import MediaCategories +from tagstudio.core.utils.types import unwrap +from tagstudio.renderers.raster_image import load_raster_image + +logger = structlog.get_logger(__name__) + +type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile + + +class SevenZipFile(py7zr.SevenZipFile): + """Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API.""" + + def __init__(self, filepath: Path, mode: Literal["r"]) -> None: + super().__init__(filepath, mode) + + def read(self, name: str) -> bytes: + # SevenZipFile must be reset after every extraction + # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract + self.reset() + factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB + self.extract(targets=[name], factory=factory) + return factory.get(name).read() + + +class TarFile: + """Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API.""" + + def __init__(self, filepath: Path, mode: Literal["r"]) -> None: + self.tar: tarfile.TarFile + self.filepath = filepath + self.mode: Literal["r"] = mode + + def namelist(self) -> list[str]: + return self.tar.getnames() + + def read(self, name: str) -> bytes: + return unwrap(self.tar.extractfile(name)).read() + + def __enter__(self) -> "TarFile": + self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__() + return self + + def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] + self.tar.__exit__(*args) + + +def archive_thumb(filepath: Path, ext: str) -> Image.Image | None: + """Extract the first image found in the archive. + + Args: + filepath (Path): The path to the archive. + ext (str): The file extension. + + Returns: + Image: The first image found in the archive. + """ + im: Image.Image | None = None + try: + with open_archive(filepath, ext) as archive: + im = first_image(archive) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + + return im + + +def open_archive(filepath: Path, ext: str) -> Archive: + """Open an archive with its corresponding archiver. + + Args: + filepath (Path): The path to the archive. + ext (str): The file extension. + + Returns: + Archive: The opened archive. + """ + archiver: type[Archive] = zipfile.ZipFile + if ext in {".7z", ".cb7", ".s7z"}: + archiver = SevenZipFile + elif ext in {".cbr", ".rar"}: + archiver = rarfile.RarFile + elif ext in {".cbt", ".tar", ".tgz"}: + archiver = TarFile + return archiver(filepath, "r") + + +def first_image(archive: Archive) -> Image.Image | None: + """Find and extract the first renderable image in the archive. + + Args: + archive (Archive): The current archive. + + Returns: + Image: The first renderable image in the archive. + """ + for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType] + ext = Path(file_name).suffix + if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): + image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType] + return load_raster_image(BytesIO(image_data)) + + return None diff --git a/src/tagstudio/renderers/audio.py b/src/tagstudio/renderers/audio.py new file mode 100644 index 000000000..ced8b9617 --- /dev/null +++ b/src/tagstudio/renderers/audio.py @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import math +from io import BytesIO +from pathlib import Path +from warnings import catch_warnings + +import numpy as np +import structlog +from mutagen import flac, id3, mp4 +from mutagen._util import MutagenError +from PIL import Image, ImageDraw + +from tagstudio.renderers.vendored.pydub.audio_segment import ( + _AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage] +) + +logger = structlog.get_logger(__name__) + + +def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + """ + image: Image.Image | None = None + try: + if not filepath.is_file(): + raise FileNotFoundError + + artwork = None + if ext in [".mp3"]: + id3_tags: id3.ID3 = id3.ID3(filepath) + id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType] + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) + elif ext in [".flac"]: + flac_tags: flac.FLAC = flac.FLAC(filepath) + flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType] + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) + elif ext in [".mp4", ".m4a", ".aac"]: + mp4_tags: mp4.MP4 = mp4.MP4(filepath) + mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType] + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + if artwork: + image = artwork + except ( + FileNotFoundError, + id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + MutagenError, + ) as e: + logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) + return image + + +def audio_waveform_thumb( + filepath: Path, ext: str, size: int, pixel_ratio: float +) -> Image.Image | None: + """Render a waveform image from an audio file. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + size (tuple[int,int]): The size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + """ + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + base_scale: int = 2 + samples_per_bar: int = 3 + size_scaled: int = size * base_scale + allow_small_min: bool = False + im: Image.Image | None = None + + try: + bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) + audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType] + data = np.frombuffer(buffer=audio._data, dtype=np.int16) + data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) + bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 + line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale + bar_height: float = (size_scaled) - (size_scaled // bar_margin) + + count: int = 0 + maximum_item: int = 0 + max_array: list[int] = [] + highest_line: int = 0 + + for i in range(-1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < samples_per_bar: + count = count + 1 + with catch_warnings(record=True): + if abs(d) > maximum_item: + maximum_item = int(abs(d)) + else: + max_array.append(maximum_item) + + if maximum_item > highest_line: + highest_line = maximum_item + + maximum_item = 0 + count = 1 + + line_ratio = max(highest_line / bar_height, 1) + + im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(im) + + current_x = bar_margin + for item in max_array: + item_height = item / line_ratio + + # If small minimums are not allowed, raise all values + # smaller than the line width to the same value. + if not allow_small_min: + item_height = max(item_height, line_width) + + current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 + + draw.rounded_rectangle( + ( + current_x, + current_y, + (current_x + line_width), + (current_y + item_height), + ), + radius=100 * base_scale, + fill=("#FF0000"), + outline=("#FFFF00"), + width=max(math.ceil(line_width / 6), base_scale), + ) + + current_x = current_x + line_width + bar_margin + + im.resize((size, size), Image.Resampling.BILINEAR) + + except Exception as e: + logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) + + return im diff --git a/src/tagstudio/renderers/blender.py b/src/tagstudio/renderers/blender.py new file mode 100644 index 000000000..919c52eb7 --- /dev/null +++ b/src/tagstudio/renderers/blender.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from pathlib import Path + +import structlog +from PIL import Image +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.renderers.vendored.blender_renderer import ( + blend_thumb, # pyright: ignore[reportUnknownVariableType] +) + +logger = structlog.get_logger(__name__) + + +def blender(filepath: Path) -> Image.Image | None: + """Get an emended thumbnail from a Blender file, if a thumbnail is present. + + Args: + filepath (Path): The path of the file. + """ + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + im: Image.Image | None = None + try: + if (blend_image := blend_thumb(str(filepath))) is not None: + bg = Image.new("RGB", blend_image.size, color=bg_color) + bg.paste(blend_image, mask=blend_image.getchannel(3)) + im = bg + else: + logger.info( + f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " + "Doesn't have an embedded thumbnail." + ) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im diff --git a/src/tagstudio/renderers/clip_studio.py b/src/tagstudio/renderers/clip_studio.py new file mode 100644 index 000000000..1f930e939 --- /dev/null +++ b/src/tagstudio/renderers/clip_studio.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import base64 +import sqlite3 +import struct +import xml.etree.ElementTree as ET +from io import BytesIO +from pathlib import Path + +import structlog +from PIL import Image + +logger = structlog.get_logger(__name__) + + +def clip_thumb(filepath: Path) -> Image.Image | None: + """Extract the thumbnail from the SQLite database embedded in a .clip file. + + Args: + filepath (Path): The path of the .clip file. + + Returns: + Image: The embedded thumbnail, if extractable. + """ + im: Image.Image | None = None + try: + with open(filepath, "rb") as f: + blob = f.read() + sqlite_index = blob.find(b"SQLite format 3") + if sqlite_index == -1: + return im + + with sqlite3.connect(":memory:") as conn: + conn.deserialize(blob[sqlite_index:]) + thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone() + if thumbnail: + im = Image.open(BytesIO(thumbnail[0])) + conn.close() + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + + return im + + +def pdn_thumb(filepath: Path) -> Image.Image | None: + """Extract the base64-encoded thumbnail from a .pdn file header. + + Args: + filepath (Path): The path of the .pdn file. + + Returns: + Image: the decoded PNG thumbnail or None by default. + """ + im: Image.Image | None = None + with open(filepath, "rb") as f: + try: + # First 4 bytes are the magic number + if f.read(4) != b"PDN3": + return im + + # Header length is a little-endian 24-bit int + header_size = struct.unpack(" Image.Image | None: + """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. + + Args: + filepath (Path): The path to the ePub file. + ext (str): The file extension. + + Returns: + Image: The cover specified in ComicInfo.xml, + the first image found in the ePub file, or None by default. + """ + im: Image.Image | None = None + try: + with open_archive(filepath, ext) as archive: + if "ComicInfo.xml" in archive.namelist(): + comic_info = ET.fromstring(archive.read("ComicInfo.xml")) + im = cover_from_comic_info(archive, comic_info, "FrontCover") + if not im: + im = cover_from_comic_info(archive, comic_info, "InnerCover") + + if not im: + im = first_image(archive) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + + return im + + +def cover_from_comic_info( + archive: Archive, comic_info: Element, cover_type: str +) -> Image.Image | None: + """Extract the cover specified in ComicInfo.xml. + + Args: + archive (Archive): The current ePub file. + comic_info (Element): The parsed ComicInfo.xml. + cover_type (str): The type of cover to load. + + Returns: + Image: The cover specified in ComicInfo.xml. + """ + im: Image.Image | None = None + + cover = comic_info.find(f"./*Page[@Type='{cover_type}']") + if cover is not None: + pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType] + page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType] + ext = Path(page_name).suffix + if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): + image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType] + im = load_raster_image(BytesIO(image_data)) + + return im diff --git a/src/tagstudio/renderers/font.py b/src/tagstudio/renderers/font.py new file mode 100644 index 000000000..ce2f13f8f --- /dev/null +++ b/src/tagstudio/renderers/font.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import math +from pathlib import Path +from typing import cast + +import numpy as np +import structlog +from PIL import Image, ImageDraw, ImageFont + +from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from tagstudio.qt.helpers.color_overlay import auto_theme_overlay +from tagstudio.qt.helpers.text_wrapper import wrap_full_text + +logger = structlog.get_logger(__name__) + + +def font_short_thumb(filepath: Path, size: int) -> Image.Image | None: + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + im: Image.Image | None = None + try: + bg = Image.new("RGB", (size, size), color="#000000") + raw = Image.new("RGB", (size * 3, size * 3), color="#000000") + draw = ImageDraw.Draw(raw) + font = ImageFont.truetype(filepath, size=size) + # NOTE: While a stroke effect is desired, the text + # method only allows for outer strokes, which looks + # a bit weird when rendering fonts. + draw.text( + (size // 8, size // 8), + "Aa", + font=font, + fill="#FF0000", + # stroke_width=math.ceil(size / 96), + # stroke_fill="#FFFF00", + ) + # NOTE: Change to getchannel(1) if using an outline. + data = np.asarray(raw.getchannel(0)) + + m, n = data.shape[:2] + col: np.ndarray = cast(np.ndarray, data.any(0)) + row: np.ndarray = cast(np.ndarray, data.any(1)) + cropped_data = np.asarray(raw)[ + row.argmax() : m - row[::-1].argmax(), + col.argmax() : n - col[::-1].argmax(), + ] + cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(size // 16) + + orig_x, orig_y = cropped_im.size + new_x, new_y = (size, size) + if orig_x > orig_y: + new_x = size + new_y = math.ceil(size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = size + new_x = math.ceil(size * (orig_x / orig_y)) + + cropped_im = cropped_im.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_im, + box=(margin, margin + ((size - new_y) // 2)), + ) + im = bg + except OSError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + +def font_long_thumb(filepath: Path, size: int) -> Image.Image | None: + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + im: Image.Image | None = None + try: + scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES] + bg = Image.new("RGBA", (size, size), color="#00000000") + draw = ImageDraw.Draw(bg) + lines_of_padding = 2 + y_offset = 0.0 + + for font_size in scaled_sizes: + font = ImageFont.truetype(filepath, size=font_size) + text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw) + draw.multiline_text((0, y_offset), text_wrapped, font=font) + y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox( + (0, 0), "A", font=font + )[-1] + im = auto_theme_overlay(bg, use_alpha=False) + except OSError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im diff --git a/src/tagstudio/renderers/krita.py b/src/tagstudio/renderers/krita.py new file mode 100644 index 000000000..bab9e9a0d --- /dev/null +++ b/src/tagstudio/renderers/krita.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import zipfile +from io import BytesIO +from pathlib import Path + +import structlog +from PIL import Image + +logger = structlog.get_logger(__name__) + + +def krita_thumb(filepath: Path) -> Image.Image | None: + """Extract and render a thumbnail for a Krita file. + + Args: + filepath (Path): The path of the file. + """ + file_path_within_zip = "preview.png" + im: Image.Image | None = None + with zipfile.ZipFile(filepath, "r") as zip_file: + # Check if the file exists in the zip + if file_path_within_zip in zip_file.namelist(): + # Read the specific file into memory + file_data = zip_file.read(file_path_within_zip) + thumb_im = Image.open(BytesIO(file_data)) + if thumb_im: + im = Image.new("RGB", thumb_im.size, color="#1e1e1e") + im.paste(thumb_im) + else: + logger.error("Couldn't render thumbnail", filepath=filepath) + + return im diff --git a/src/tagstudio/renderers/medibang_paint.py b/src/tagstudio/renderers/medibang_paint.py new file mode 100644 index 000000000..a973df281 --- /dev/null +++ b/src/tagstudio/renderers/medibang_paint.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import os +import struct +import xml.etree.ElementTree as ET +import zlib +from pathlib import Path + +import structlog +from PIL import Image + +from tagstudio.core.utils.types import unwrap + +logger = structlog.get_logger(__name__) + + +def mdp_thumb(filepath: Path) -> Image.Image | None: + """Extract the thumbnail from a .mdp file. + + Args: + filepath (Path): The path of the .mdp file. + + Returns: + Image: The embedded thumbnail. + """ + im: Image.Image | None = None + try: + with open(filepath, "rb") as f: + magic = struct.unpack("<7sx", f.read(8))[0] + if magic != b"mdipack": + return im + + bin_header = struct.unpack(" Image.Image | None: + """Extract and render a thumbnail for a Microsoft PowerPoint file. + + Args: + filepath (Path): The path of the file. + """ + file_path_within_zip = "docProps/thumbnail.jpeg" + im: Image.Image | None = None + try: + with zipfile.ZipFile(filepath, "r") as zip_file: + # Check if the file exists in the zip + if file_path_within_zip in zip_file.namelist(): + # Read the specific file into memory + file_data = zip_file.read(file_path_within_zip) + thumb_im = Image.open(BytesIO(file_data)) + if thumb_im: + im = Image.new("RGB", thumb_im.size, color="#1e1e1e") + im.paste(thumb_im) + else: + logger.error("Couldn't render thumbnail", filepath=filepath) + except zipfile.BadZipFile as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + + return im diff --git a/src/tagstudio/renderers/open_document.py b/src/tagstudio/renderers/open_document.py new file mode 100644 index 000000000..42f34763e --- /dev/null +++ b/src/tagstudio/renderers/open_document.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import zipfile +from io import BytesIO +from pathlib import Path + +import structlog +from PIL import Image + +logger = structlog.get_logger(__name__) + + +def open_doc_thumb(filepath: Path) -> Image.Image | None: + """Extract and render a thumbnail for an OpenDocument file. + + Args: + filepath (Path): The path of the file. + """ + file_path_within_zip = "Thumbnails/thumbnail.png" + im: Image.Image | None = None + with zipfile.ZipFile(filepath, "r") as zip_file: + # Check if the file exists in the zip + if file_path_within_zip in zip_file.namelist(): + # Read the specific file into memory + file_data = zip_file.read(file_path_within_zip) + thumb_im = Image.open(BytesIO(file_data)) + if thumb_im: + im = Image.new("RGB", thumb_im.size, color="#1e1e1e") + im.paste(thumb_im) + else: + logger.error("Couldn't render thumbnail", filepath=filepath) + + return im diff --git a/src/tagstudio/renderers/pdf.py b/src/tagstudio/renderers/pdf.py new file mode 100644 index 000000000..5e8ea1b9f --- /dev/null +++ b/src/tagstudio/renderers/pdf.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from io import BytesIO +from pathlib import Path + +import structlog +from PIL import ( + Image, +) +from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF +from PySide6.QtGui import QImage +from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions + +from tagstudio.qt.helpers.image_effects import replace_transparent_pixels + +logger = structlog.get_logger(__name__) + + +def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None: + """Render a thumbnail for a PDF or Adobe Illustrator file. + + filepath (Path): The path of the file. + size (int): The size of the icon. + ext (str): The file extension. + """ + im: Image.Image | None = None + + file: QFile = QFile(filepath) + success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser) + if not success: + logger.error("Couldn't render thumbnail", filepath=filepath) + return im + document: QPdfDocument = QPdfDocument() + document.load(file) + file.close() + # Transform page_size in points to pixels with proper aspect ratio + page_size: QSizeF = document.pagePointSize(0) + ratio_hw: float = page_size.height() / page_size.width() + if ratio_hw >= 1: + page_size *= size / page_size.height() + else: + page_size *= size / page_size.width() + # Enlarge image for anti-aliasing + scale_factor = 2.5 if ext in {".pdf"} else 1 + page_size *= scale_factor + # Render image with no anti-aliasing for speed + render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() + render_options.setRenderFlags( + QPdfDocumentRenderOptions.RenderFlag.TextAliased + | QPdfDocumentRenderOptions.RenderFlag.ImageAliased + | QPdfDocumentRenderOptions.RenderFlag.PathAliased + ) + # Convert QImage to PIL Image + q_image: QImage = document.render(0, page_size.toSize(), render_options) + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + try: + q_image.save(buffer, "PNG") # pyright: ignore + im = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + return replace_transparent_pixels(im) diff --git a/src/tagstudio/renderers/raster_image.py b/src/tagstudio/renderers/raster_image.py new file mode 100644 index 000000000..d91077a7b --- /dev/null +++ b/src/tagstudio/renderers/raster_image.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import os +from io import BytesIO +from pathlib import Path + +import cv2 +import numpy as np +import rawpy +import structlog +from PIL import Image, ImageOps, UnidentifiedImageError +from PIL.Image import DecompressionBombError +from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType] +from rawpy import ( + LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] + LibRawIOError, # pyright: ignore[reportPrivateImportUsage] +) + +from tagstudio.core.utils.types import unwrap + +register_heif_opener() +os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" + +logger = structlog.get_logger(__name__) + + +def image_thumb(filepath: Path) -> Image.Image | None: + """Render a thumbnail for a standard image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image | None = None + try: + with filepath.open("rb") as file: + im = load_raster_image(BytesIO(file.read())) + except ( + FileNotFoundError, + UnidentifiedImageError, + DecompressionBombError, + NotImplementedError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + +def image_exr_thumb(filepath: Path) -> Image.Image | None: + """Render a thumbnail for a EXR image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image | None = None + try: + # Load the EXR data to an array and rotate the color space from BGRA -> RGBA + raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED) + assert raw_array is not None + raw_array[..., :3] = raw_array[..., 2::-1] + + # Correct the gamma of the raw array + gamma = 2.2 + array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma) + array = (array_gamma * 255).astype(np.uint8) + + im = Image.fromarray(array, mode="RGBA") + + # Paste solid background + if im.mode == "RGBA": + new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg.paste(im, mask=im.getchannel(3)) + im = new_bg + + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + +def image_raw_thumb(filepath: Path) -> Image.Image | None: + """Render a thumbnail for a RAW image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image | None = None + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess(use_camera_wb=True) + im = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + except ( + DecompressionBombError, + LibRawIOError, + LibRawFileUnsupportedError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + +def load_raster_image(image_data: BytesIO) -> Image.Image: + """Load a raster image and add a background if it's transparent. + + Args: + image_data (BytesIO): The binary image data. + + Returns: + Image.Image: The loaded raster image, with a background if needed. + """ + im: Image.Image = Image.open(image_data) + if im.mode != "RGB" and im.mode != "RGBA": + im = im.convert(mode="RGBA") + if im.mode == "RGBA": + new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg.paste(im, mask=im.getchannel(3)) + im = new_bg + return unwrap(ImageOps.exif_transpose(im)) diff --git a/src/tagstudio/renderers/source_engine.py b/src/tagstudio/renderers/source_engine.py new file mode 100644 index 000000000..72ee3cea1 --- /dev/null +++ b/src/tagstudio/renderers/source_engine.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from pathlib import Path + +import srctools +import structlog +from PIL import Image + +logger = structlog.get_logger(__name__) + + +def vtf_thumb(filepath: Path) -> Image.Image | None: + """Extract and render a thumbnail for VTF (Valve Texture Format) images. + + Uses the srctools library for reading VTF files. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image | None = None + try: + with open(filepath, "rb") as f: + vtf = srctools.VTF.read(f) + im = vtf.get(frame=0).to_PIL() + + except (ValueError, FileNotFoundError) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im diff --git a/src/tagstudio/renderers/text.py b/src/tagstudio/renderers/text.py new file mode 100644 index 000000000..94028e05f --- /dev/null +++ b/src/tagstudio/renderers/text.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from pathlib import Path + +import cv2 +import structlog +from PIL import Image, ImageDraw, UnidentifiedImageError +from PIL.Image import DecompressionBombError +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.core.utils.encoding import detect_char_encoding + +logger = structlog.get_logger(__name__) + + +def text_thumb(filepath: Path) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image | None = None + + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + fg_color: str = ( + "#FFFFFF" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#111111" + ) + + try: + encoding = detect_char_encoding(filepath) + with open(filepath, encoding=encoding) as text_file: + text = text_file.read(256) + bg = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(bg) + draw.text((16, 16), text, fill=fg_color) + im = bg + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + FileNotFoundError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im diff --git a/src/tagstudio/renderers/vector_image.py b/src/tagstudio/renderers/vector_image.py new file mode 100644 index 000000000..a79e82ecf --- /dev/null +++ b/src/tagstudio/renderers/vector_image.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from io import BytesIO +from pathlib import Path + +import structlog +from PIL import ( + Image, + UnidentifiedImageError, +) +from PySide6.QtCore import QBuffer, Qt +from PySide6.QtGui import QImage, QPainter +from PySide6.QtSvg import QSvgRenderer + +logger = structlog.get_logger(__name__) + + +def image_vector_thumb(filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for a vector image, such as SVG. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + im: Image.Image | None = None + # Create an image to draw the svg to and a painter to do the drawing + q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32) + q_image.fill("#1e1e1e") + + # Create an svg renderer, then render to the painter + svg: QSvgRenderer = QSvgRenderer(str(filepath)) + + if not svg.isValid(): + raise UnidentifiedImageError + + painter: QPainter = QPainter(q_image) + svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + svg.render(painter) + painter.end() + + # Write the image to a buffer as png + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType] + + # Load the image from the buffer + im = Image.new("RGB", (size, size), color="#1e1e1e") + im.paste(Image.open(BytesIO(buffer.data().data()))) + im = im.convert(mode="RGB") + + buffer.close() + return im diff --git a/src/tagstudio/qt/previews/vendored/blender_renderer.py b/src/tagstudio/renderers/vendored/blender_renderer.py similarity index 100% rename from src/tagstudio/qt/previews/vendored/blender_renderer.py rename to src/tagstudio/renderers/vendored/blender_renderer.py diff --git a/src/tagstudio/qt/previews/vendored/probe.py b/src/tagstudio/renderers/vendored/probe.py similarity index 100% rename from src/tagstudio/qt/previews/vendored/probe.py rename to src/tagstudio/renderers/vendored/probe.py diff --git a/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py b/src/tagstudio/renderers/vendored/pydub/audio_segment.py similarity index 99% rename from src/tagstudio/qt/previews/vendored/pydub/audio_segment.py rename to src/tagstudio/renderers/vendored/pydub/audio_segment.py index e186a6568..fd7a4edd2 100644 --- a/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py +++ b/src/tagstudio/renderers/vendored/pydub/audio_segment.py @@ -43,7 +43,7 @@ ) from tagstudio.core.utils.silent_subprocess import silent_popen -from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json +from tagstudio.renderers.vendored.pydub.utils import _mediainfo_json basestring = str xrange = range @@ -256,7 +256,7 @@ def __init__(self, data=None, *args, **kwargs): self.sample_width = 4 self.frame_width = self.channels * self.sample_width - super(_AudioSegment, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) @property def raw_data(self): diff --git a/src/tagstudio/qt/previews/vendored/pydub/utils.py b/src/tagstudio/renderers/vendored/pydub/utils.py similarity index 100% rename from src/tagstudio/qt/previews/vendored/pydub/utils.py rename to src/tagstudio/renderers/vendored/pydub/utils.py diff --git a/src/tagstudio/renderers/video.py b/src/tagstudio/renderers/video.py new file mode 100644 index 000000000..548cfca29 --- /dev/null +++ b/src/tagstudio/renderers/video.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import math +from pathlib import Path + +import cv2 +import structlog +from cv2.typing import MatLike +from PIL import Image, UnidentifiedImageError +from PIL.Image import DecompressionBombError + +from tagstudio.qt.helpers.file_tester import is_readable_video + +logger = structlog.get_logger(__name__) + + +def video_thumb(filepath: Path) -> Image.Image | None: + """Render a thumbnail for a video file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image | None = None + frame: MatLike | None = None + try: + if is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + # TODO: Move this check to is_readable_video() + if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: + raise cv2.error("File is invalid or has 0 frames") + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + # NOTE: Depending on the video format, compression, and + # frame count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + max_frame_seek: int = 10 + for i in range( + 0, + min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))), + ): + success, frame = video.read() + if not success: + video.set(cv2.CAP_PROP_POS_FRAMES, i) + else: + break + if frame is not None: + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + im = Image.fromarray(frame) + except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im From 8995ef6caf3b90673bf1c7e275202536053f0e18 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:56:29 -0700 Subject: [PATCH 2/8] refactor: consolidate embedded archive thumbnail functions --- src/tagstudio/qt/file_renderer.py | 3 + src/tagstudio/qt/previews/renderer.py | 14 ++-- src/tagstudio/renderers/apple.py | 56 ------------- src/tagstudio/renderers/archive.py | 88 +++++++++++++++------ src/tagstudio/renderers/ebook.py | 8 +- src/tagstudio/renderers/krita.py | 35 -------- src/tagstudio/renderers/microsoft_office.py | 38 --------- src/tagstudio/renderers/open_document.py | 35 -------- src/tagstudio/renderers/pdf.py | 7 +- src/tagstudio/renderers/raster_image.py | 4 +- src/tagstudio/renderers/vector_image.py | 2 + 11 files changed, 87 insertions(+), 203 deletions(-) delete mode 100644 src/tagstudio/renderers/apple.py delete mode 100644 src/tagstudio/renderers/krita.py delete mode 100644 src/tagstudio/renderers/microsoft_office.py delete mode 100644 src/tagstudio/renderers/open_document.py diff --git a/src/tagstudio/qt/file_renderer.py b/src/tagstudio/qt/file_renderer.py index 2db3e1ce7..c7448ada8 100644 --- a/src/tagstudio/qt/file_renderer.py +++ b/src/tagstudio/qt/file_renderer.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from PySide6.QtCore import QObject diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index d4285f18c..d65463254 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -36,17 +36,19 @@ from tagstudio.qt.helpers.gradients import four_corner_gradient from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color from tagstudio.qt.resource_manager import ResourceManager -from tagstudio.renderers.apple import apple_embedded_thumb -from tagstudio.renderers.archive import archive_thumb +from tagstudio.renderers.archive import ( + apple_embedded_thumb, + archive_thumb, + krita_thumb, + open_doc_thumb, + powerpoint_thumb, +) from tagstudio.renderers.audio import audio_album_thumb, audio_waveform_thumb from tagstudio.renderers.blender import blender from tagstudio.renderers.clip_studio import clip_thumb, pdn_thumb from tagstudio.renderers.ebook import epub_cover from tagstudio.renderers.font import font_long_thumb, font_short_thumb -from tagstudio.renderers.krita import krita_thumb from tagstudio.renderers.medibang_paint import mdp_thumb -from tagstudio.renderers.microsoft_office import powerpoint_thumb -from tagstudio.renderers.open_document import open_doc_thumb from tagstudio.renderers.pdf import pdf_thumb from tagstudio.renderers.raster_image import image_exr_thumb, image_raw_thumb, image_thumb from tagstudio.renderers.source_engine import vtf_thumb @@ -58,10 +60,10 @@ from tagstudio.qt.ts_qt import QtDriver ImageFile.LOAD_TRUNCATED_IMAGES = True +Image.MAX_IMAGE_PIXELS = None logger = structlog.get_logger(__name__) -Image.MAX_IMAGE_PIXELS = None try: diff --git a/src/tagstudio/renderers/apple.py b/src/tagstudio/renderers/apple.py deleted file mode 100644 index 2f93e941a..000000000 --- a/src/tagstudio/renderers/apple.py +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import zipfile -from io import BytesIO -from pathlib import Path - -import structlog -from PIL import Image - -logger = structlog.get_logger(__name__) - - -def apple_embedded_thumb(filepath: Path) -> Image.Image | None: - """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio). - - Args: - filepath (Path): The path of the file. - """ - thumb_files: list[str] = [ - "preview.jpg", - "QuickLook/Preview.heic", - "QuickLook/Thumbnail.jpg", - "QuickLook/Thumbnail.heic", - "QuickLook/Thumbnail.webp", - "QuickLook/Icon.webp", - ] - im: Image.Image | None = None - - def get_image(path: str) -> Image.Image | None: - thumb_im: Image.Image | None = None - # Read the specific file into memory - file_data = zip_file.read(path) - thumb_im = Image.open(BytesIO(file_data)) - return thumb_im - - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - thumb: Image.Image | None = None - - # Check if the file exists in the zip - for thumb_file in thumb_files: - if thumb_file in zip_file.namelist(): - thumb = get_image(thumb_file) - break - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - if thumb: - im = Image.new("RGB", thumb.size, color="#1e1e1e") - im.paste(thumb) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im diff --git a/src/tagstudio/renderers/archive.py b/src/tagstudio/renderers/archive.py index d6a5b5bb9..cf66ac824 100644 --- a/src/tagstudio/renderers/archive.py +++ b/src/tagstudio/renderers/archive.py @@ -16,7 +16,7 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.core.utils.types import unwrap -from tagstudio.renderers.raster_image import load_raster_image +from tagstudio.renderers.raster_image import image_from_bytes logger = structlog.get_logger(__name__) @@ -60,27 +60,7 @@ def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType self.tar.__exit__(*args) -def archive_thumb(filepath: Path, ext: str) -> Image.Image | None: - """Extract the first image found in the archive. - - Args: - filepath (Path): The path to the archive. - ext (str): The file extension. - - Returns: - Image: The first image found in the archive. - """ - im: Image.Image | None = None - try: - with open_archive(filepath, ext) as archive: - im = first_image(archive) - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - - return im - - -def open_archive(filepath: Path, ext: str) -> Archive: +def open_archive(filepath: Path, ext: str = "") -> Archive: """Open an archive with its corresponding archiver. Args: @@ -100,7 +80,7 @@ def open_archive(filepath: Path, ext: str) -> Archive: return archiver(filepath, "r") -def first_image(archive: Archive) -> Image.Image | None: +def first_image_in_archive(archive: Archive) -> Image.Image | None: """Find and extract the first renderable image in the archive. Args: @@ -113,6 +93,66 @@ def first_image(archive: Archive) -> Image.Image | None: ext = Path(file_name).suffix if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType] - return load_raster_image(BytesIO(image_data)) + return image_from_bytes(BytesIO(image_data)) return None + + +def archive_thumb( + filepath: Path, ext: str = "", image_names: list[Path] | list[str] | None = None +) -> Image.Image | None: + """Extract an embedded preview image from an archive. + + Args: + filepath (Path): The path to the archive. + ext (str): The file extension. Used to help determine more specific archive type. + image_names: (list[Path] | list[str] | None): List of embedded image names to search for. + + Returns: + Image: The first image found in the archive. + """ + try: + with open_archive(filepath, ext) as archive: + # If no list of image names to search for was provided, default to the first image. + if not image_names: + return first_image_in_archive(archive) + + for image_name in image_names: + if image_name in archive.namelist(): + file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType] + return image_from_bytes(BytesIO(file_data)) + + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return None + + +def apple_embedded_thumb(filepath: Path) -> Image.Image | None: + """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio).""" + image_names: list[str] = [ + "preview.jpg", + "QuickLook/Preview.heic", + "QuickLook/Thumbnail.jpg", + "QuickLook/Thumbnail.heic", + "QuickLook/Thumbnail.webp", + "QuickLook/Icon.webp", + ] + return archive_thumb(filepath, image_names=image_names) + + +def krita_thumb(filepath: Path) -> Image.Image | None: + """Extract and render a thumbnail for a Krita file.""" + image_names = ["preview.png"] + return archive_thumb(filepath, image_names=image_names) + + +def open_doc_thumb(filepath: Path) -> Image.Image | None: + """Extract and render a thumbnail for an OpenDocument file.""" + image_names = ["Thumbnails/thumbnail.png"] + return archive_thumb(filepath, image_names=image_names) + + +def powerpoint_thumb(filepath: Path) -> Image.Image | None: + """Extract and render a thumbnail for a Microsoft PowerPoint file.""" + image_names = ["docProps/thumbnail.jpeg"] + return archive_thumb(filepath, image_names=image_names) diff --git a/src/tagstudio/renderers/ebook.py b/src/tagstudio/renderers/ebook.py index 7f8a5fea1..acc0ab35a 100644 --- a/src/tagstudio/renderers/ebook.py +++ b/src/tagstudio/renderers/ebook.py @@ -12,8 +12,8 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.core.utils.types import unwrap -from tagstudio.renderers.archive import Archive, first_image, open_archive -from tagstudio.renderers.raster_image import load_raster_image +from tagstudio.renderers.archive import Archive, first_image_in_archive, open_archive +from tagstudio.renderers.raster_image import image_from_bytes logger = structlog.get_logger(__name__) @@ -39,7 +39,7 @@ def epub_cover(filepath: Path, ext: str) -> Image.Image | None: im = cover_from_comic_info(archive, comic_info, "InnerCover") if not im: - im = first_image(archive) + im = first_image_in_archive(archive) except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) @@ -68,6 +68,6 @@ def cover_from_comic_info( ext = Path(page_name).suffix if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType] - im = load_raster_image(BytesIO(image_data)) + im = image_from_bytes(BytesIO(image_data)) return im diff --git a/src/tagstudio/renderers/krita.py b/src/tagstudio/renderers/krita.py deleted file mode 100644 index bab9e9a0d..000000000 --- a/src/tagstudio/renderers/krita.py +++ /dev/null @@ -1,35 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import zipfile -from io import BytesIO -from pathlib import Path - -import structlog -from PIL import Image - -logger = structlog.get_logger(__name__) - - -def krita_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Krita file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "preview.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im diff --git a/src/tagstudio/renderers/microsoft_office.py b/src/tagstudio/renderers/microsoft_office.py deleted file mode 100644 index 16933b5fa..000000000 --- a/src/tagstudio/renderers/microsoft_office.py +++ /dev/null @@ -1,38 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import zipfile -from io import BytesIO -from pathlib import Path - -import structlog -from PIL import Image - -logger = structlog.get_logger(__name__) - - -def powerpoint_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Microsoft PowerPoint file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "docProps/thumbnail.jpeg" - im: Image.Image | None = None - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im diff --git a/src/tagstudio/renderers/open_document.py b/src/tagstudio/renderers/open_document.py deleted file mode 100644 index 42f34763e..000000000 --- a/src/tagstudio/renderers/open_document.py +++ /dev/null @@ -1,35 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import zipfile -from io import BytesIO -from pathlib import Path - -import structlog -from PIL import Image - -logger = structlog.get_logger(__name__) - - -def open_doc_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an OpenDocument file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "Thumbnails/thumbnail.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im diff --git a/src/tagstudio/renderers/pdf.py b/src/tagstudio/renderers/pdf.py index 5e8ea1b9f..39e97b261 100644 --- a/src/tagstudio/renderers/pdf.py +++ b/src/tagstudio/renderers/pdf.py @@ -1,14 +1,15 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case. +# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import. + from io import BytesIO from pathlib import Path import structlog -from PIL import ( - Image, -) +from PIL import Image from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF from PySide6.QtGui import QImage from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions diff --git a/src/tagstudio/renderers/raster_image.py b/src/tagstudio/renderers/raster_image.py index d91077a7b..40b0445d1 100644 --- a/src/tagstudio/renderers/raster_image.py +++ b/src/tagstudio/renderers/raster_image.py @@ -35,7 +35,7 @@ def image_thumb(filepath: Path) -> Image.Image | None: im: Image.Image | None = None try: with filepath.open("rb") as file: - im = load_raster_image(BytesIO(file.read())) + im = image_from_bytes(BytesIO(file.read())) except ( FileNotFoundError, UnidentifiedImageError, @@ -102,7 +102,7 @@ def image_raw_thumb(filepath: Path) -> Image.Image | None: return im -def load_raster_image(image_data: BytesIO) -> Image.Image: +def image_from_bytes(image_data: BytesIO) -> Image.Image: """Load a raster image and add a background if it's transparent. Args: diff --git a/src/tagstudio/renderers/vector_image.py b/src/tagstudio/renderers/vector_image.py index a79e82ecf..dd4d117cd 100644 --- a/src/tagstudio/renderers/vector_image.py +++ b/src/tagstudio/renderers/vector_image.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case. +# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import. from io import BytesIO from pathlib import Path From d2bf445a13c8bb597f3c3499c4d926c89e498fb2 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:01:41 -0700 Subject: [PATCH 3/8] fix: move jxl optional import to raster_image.py --- src/tagstudio/qt/previews/renderer.py | 6 ------ src/tagstudio/renderers/raster_image.py | 9 +++++++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index d65463254..30c06a405 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -66,12 +66,6 @@ logger = structlog.get_logger(__name__) -try: - import pillow_jxl # noqa: F401 # pyright: ignore -except ImportError as e: - logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e) - - class ThumbRenderer(QObject): """A class for rendering image and file thumbnails.""" diff --git a/src/tagstudio/renderers/raster_image.py b/src/tagstudio/renderers/raster_image.py index 40b0445d1..654a2d156 100644 --- a/src/tagstudio/renderers/raster_image.py +++ b/src/tagstudio/renderers/raster_image.py @@ -20,11 +20,16 @@ from tagstudio.core.utils.types import unwrap +logger = structlog.get_logger(__name__) + +try: + import pillow_jxl # noqa: F401 # pyright: ignore +except ImportError as e: + logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e) + register_heif_opener() os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" -logger = structlog.get_logger(__name__) - def image_thumb(filepath: Path) -> Image.Image | None: """Render a thumbnail for a standard image type. From 0c21c45cb431f78b10b9bf52e0738ad249543a6d Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:07:46 -0700 Subject: [PATCH 4/8] refactor: name and license consistency, misc fixes --- src/tagstudio/core/media_types.py | 1 + src/tagstudio/qt/previews/renderer.py | 34 +++++++++---------- src/tagstudio/renderers/blender.py | 2 +- src/tagstudio/renderers/clip_studio.py | 2 +- src/tagstudio/renderers/ebook.py | 8 ++--- src/tagstudio/renderers/font.py | 4 +-- src/tagstudio/renderers/medibang_paint.py | 2 +- src/tagstudio/renderers/raster_image.py | 6 ++-- src/tagstudio/renderers/vector_image.py | 2 +- .../renderers/vendored/blender_renderer.py | 6 +--- src/tagstudio/renderers/vendored/probe.py | 2 +- .../renderers/vendored/pydub/audio_segment.py | 2 +- .../renderers/vendored/pydub/utils.py | 2 +- 13 files changed, 35 insertions(+), 38 deletions(-) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 83105755b..5f2864193 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -305,6 +305,7 @@ class MediaCategories: ".nef", ".nrw", ".orf", + ".r3d", ".raf", ".raw", ".rw2", diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 30c06a405..c5a8715b9 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -44,16 +44,16 @@ powerpoint_thumb, ) from tagstudio.renderers.audio import audio_album_thumb, audio_waveform_thumb -from tagstudio.renderers.blender import blender -from tagstudio.renderers.clip_studio import clip_thumb, pdn_thumb -from tagstudio.renderers.ebook import epub_cover -from tagstudio.renderers.font import font_long_thumb, font_short_thumb -from tagstudio.renderers.medibang_paint import mdp_thumb +from tagstudio.renderers.blender import blender_thumb +from tagstudio.renderers.clip_studio import clip_studio_thumb, pdn_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 from tagstudio.renderers.pdf import pdf_thumb -from tagstudio.renderers.raster_image import image_exr_thumb, image_raw_thumb, image_thumb +from tagstudio.renderers.raster_image import exr_image_thumb, raster_image_thumb, raw_image_thumb from tagstudio.renderers.source_engine import vtf_thumb from tagstudio.renderers.text import text_thumb -from tagstudio.renderers.vector_image import image_vector_thumb +from tagstudio.renderers.vector_image import vector_image_thumb from tagstudio.renderers.video import video_thumb if TYPE_CHECKING: @@ -805,7 +805,7 @@ def _render( if MediaCategories.is_ext_in_category( ext, MediaCategories.EBOOK_TYPES, mime_fallback=True ): - image = epub_cover(_filepath, ext) + image = epub_thumb(_filepath, ext) # Krita ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.KRITA_TYPES, mime_fallback=True @@ -815,7 +815,7 @@ def _render( elif MediaCategories.is_ext_in_category( ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES ): - image = clip_thumb(_filepath) + image = clip_studio_thumb(_filepath) # VTF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True @@ -829,18 +829,18 @@ def _render( if MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True ): - image = image_raw_thumb(_filepath) + image = raw_image_thumb(_filepath) # Vector Images -------------------------------------------- elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True ): - image = image_vector_thumb(_filepath, adj_size) + image = vector_image_thumb(_filepath, adj_size) # EXR Images ----------------------------------------------- elif ext in [".exr"]: - image = image_exr_thumb(_filepath) + image = exr_image_thumb(_filepath) # Normal Images -------------------------------------------- else: - image = image_thumb(_filepath) + image = raster_image_thumb(_filepath) # Videos ======================================================= elif MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True @@ -871,12 +871,12 @@ def _render( ): if is_grid_thumb: # Short (Aa) Preview - image = font_short_thumb(_filepath, adj_size) + image = font_small_thumb(_filepath, adj_size) if image is not None: image = self._apply_overlay_color(image, UiColor.BLUE) else: # Large (Full Alphabet) Preview - image = font_long_thumb(_filepath, adj_size) + image = font_full_preview(_filepath, adj_size) # Audio ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True @@ -891,7 +891,7 @@ def _render( elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True ): - image = blender(_filepath) + image = blender_thumb(_filepath) # PDF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.PDF_TYPES, mime_fallback=True @@ -902,7 +902,7 @@ def _render( image = archive_thumb(_filepath, ext) # MDIPACK ====================================================== elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES): - image = mdp_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) diff --git a/src/tagstudio/renderers/blender.py b/src/tagstudio/renderers/blender.py index 919c52eb7..9e08347d2 100644 --- a/src/tagstudio/renderers/blender.py +++ b/src/tagstudio/renderers/blender.py @@ -16,7 +16,7 @@ logger = structlog.get_logger(__name__) -def blender(filepath: Path) -> Image.Image | None: +def blender_thumb(filepath: Path) -> Image.Image | None: """Get an emended thumbnail from a Blender file, if a thumbnail is present. Args: diff --git a/src/tagstudio/renderers/clip_studio.py b/src/tagstudio/renderers/clip_studio.py index 1f930e939..a7c4ab4ce 100644 --- a/src/tagstudio/renderers/clip_studio.py +++ b/src/tagstudio/renderers/clip_studio.py @@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__) -def clip_thumb(filepath: Path) -> Image.Image | None: +def clip_studio_thumb(filepath: Path) -> Image.Image | None: """Extract the thumbnail from the SQLite database embedded in a .clip file. Args: diff --git a/src/tagstudio/renderers/ebook.py b/src/tagstudio/renderers/ebook.py index acc0ab35a..0dec57497 100644 --- a/src/tagstudio/renderers/ebook.py +++ b/src/tagstudio/renderers/ebook.py @@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__) -def epub_cover(filepath: Path, ext: str) -> Image.Image | None: +def epub_thumb(filepath: Path, ext: str) -> Image.Image | None: """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. Args: @@ -34,9 +34,9 @@ def epub_cover(filepath: Path, ext: str) -> Image.Image | None: with open_archive(filepath, ext) as archive: if "ComicInfo.xml" in archive.namelist(): comic_info = ET.fromstring(archive.read("ComicInfo.xml")) - im = cover_from_comic_info(archive, comic_info, "FrontCover") + im = _cover_from_comic_info(archive, comic_info, "FrontCover") if not im: - im = cover_from_comic_info(archive, comic_info, "InnerCover") + im = _cover_from_comic_info(archive, comic_info, "InnerCover") if not im: im = first_image_in_archive(archive) @@ -46,7 +46,7 @@ def epub_cover(filepath: Path, ext: str) -> Image.Image | None: return im -def cover_from_comic_info( +def _cover_from_comic_info( archive: Archive, comic_info: Element, cover_type: str ) -> Image.Image | None: """Extract the cover specified in ComicInfo.xml. diff --git a/src/tagstudio/renderers/font.py b/src/tagstudio/renderers/font.py index ce2f13f8f..a48e033d9 100644 --- a/src/tagstudio/renderers/font.py +++ b/src/tagstudio/renderers/font.py @@ -17,7 +17,7 @@ logger = structlog.get_logger(__name__) -def font_short_thumb(filepath: Path, size: int) -> Image.Image | None: +def font_small_thumb(filepath: Path, size: int) -> Image.Image | None: """Render a small font preview ("Aa") thumbnail from a font file. Args: @@ -78,7 +78,7 @@ def font_short_thumb(filepath: Path, size: int) -> Image.Image | None: return im -def font_long_thumb(filepath: Path, size: int) -> Image.Image | None: +def font_full_preview(filepath: Path, size: int) -> Image.Image | None: """Render a large font preview ("Alphabet") thumbnail from a font file. Args: diff --git a/src/tagstudio/renderers/medibang_paint.py b/src/tagstudio/renderers/medibang_paint.py index a973df281..d0beb1aad 100644 --- a/src/tagstudio/renderers/medibang_paint.py +++ b/src/tagstudio/renderers/medibang_paint.py @@ -16,7 +16,7 @@ logger = structlog.get_logger(__name__) -def mdp_thumb(filepath: Path) -> Image.Image | None: +def medibang_paint_thumb(filepath: Path) -> Image.Image | None: """Extract the thumbnail from a .mdp file. Args: diff --git a/src/tagstudio/renderers/raster_image.py b/src/tagstudio/renderers/raster_image.py index 654a2d156..3af649f18 100644 --- a/src/tagstudio/renderers/raster_image.py +++ b/src/tagstudio/renderers/raster_image.py @@ -31,7 +31,7 @@ os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" -def image_thumb(filepath: Path) -> Image.Image | None: +def raster_image_thumb(filepath: Path) -> Image.Image | None: """Render a thumbnail for a standard image type. Args: @@ -51,7 +51,7 @@ def image_thumb(filepath: Path) -> Image.Image | None: return im -def image_exr_thumb(filepath: Path) -> Image.Image | None: +def exr_image_thumb(filepath: Path) -> Image.Image | None: """Render a thumbnail for a EXR image type. Args: @@ -82,7 +82,7 @@ def image_exr_thumb(filepath: Path) -> Image.Image | None: return im -def image_raw_thumb(filepath: Path) -> Image.Image | None: +def raw_image_thumb(filepath: Path) -> Image.Image | None: """Render a thumbnail for a RAW image type. Args: diff --git a/src/tagstudio/renderers/vector_image.py b/src/tagstudio/renderers/vector_image.py index dd4d117cd..a0c2d2b38 100644 --- a/src/tagstudio/renderers/vector_image.py +++ b/src/tagstudio/renderers/vector_image.py @@ -19,7 +19,7 @@ logger = structlog.get_logger(__name__) -def image_vector_thumb(filepath: Path, size: int) -> Image.Image: +def vector_image_thumb(filepath: Path, size: int) -> Image.Image: """Render a thumbnail for a vector image, such as SVG. Args: diff --git a/src/tagstudio/renderers/vendored/blender_renderer.py b/src/tagstudio/renderers/vendored/blender_renderer.py index 1d83f13fd..ae57091a5 100644 --- a/src/tagstudio/renderers/vendored/blender_renderer.py +++ b/src/tagstudio/renderers/vendored/blender_renderer.py @@ -1,11 +1,7 @@ -#!/usr/bin/env python3 +# SPDX-FileCopyrightText: (c) 2017 The Blender Foundation # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only - -# - - ## This file is a modified script that gets the thumbnail data stored in a blend file diff --git a/src/tagstudio/renderers/vendored/probe.py b/src/tagstudio/renderers/vendored/probe.py index 450a3d28a..632afa0fd 100644 --- a/src/tagstudio/renderers/vendored/probe.py +++ b/src/tagstudio/renderers/vendored/probe.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening) +# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening) # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only # Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803 diff --git a/src/tagstudio/renderers/vendored/pydub/audio_segment.py b/src/tagstudio/renderers/vendored/pydub/audio_segment.py index fd7a4edd2..1a04ecdf9 100644 --- a/src/tagstudio/renderers/vendored/pydub/audio_segment.py +++ b/src/tagstudio/renderers/vendored/pydub/audio_segment.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro) +# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only # Vendored from pydub diff --git a/src/tagstudio/renderers/vendored/pydub/utils.py b/src/tagstudio/renderers/vendored/pydub/utils.py index d5ba13564..58bc79575 100644 --- a/src/tagstudio/renderers/vendored/pydub/utils.py +++ b/src/tagstudio/renderers/vendored/pydub/utils.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com +# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only # Vendored from pydub From cd6e427e02583d40161c347ce0f702bd7f349631 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:17:26 -0700 Subject: [PATCH 5/8] fix: remove file_renderer.py --- src/tagstudio/qt/file_renderer.py | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/tagstudio/qt/file_renderer.py diff --git a/src/tagstudio/qt/file_renderer.py b/src/tagstudio/qt/file_renderer.py deleted file mode 100644 index c7448ada8..000000000 --- a/src/tagstudio/qt/file_renderer.py +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - -from PySide6.QtCore import QObject - - -class FileRenderer(QObject): ... - - -"""A Qt-specific entry point for rendering file previews and thumbnails.""" 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 6/8] 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 c5a8715b9..9857a5079 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -7,31 +7,23 @@ 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.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.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 @@ 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 @@ def _get_resource_id(self, url: Path) -> str: 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 @@ def _get_mask( 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 @@ def _get_icon( 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 @@ def _get_icon( 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 @@ def _get_icon( 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 @@ def _render_mask( ) 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 @@ def _render_center_icon( 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 @@ def _render_center_icon( 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 @@ def _render_center_icon( size, resample=Image.Resampling.BILINEAR, ) - fg: Image.Image = Image.new( - "RGB", - size=size, - color="#00FF00", - ) + fg: Image.Image = Image.new("RGB", size=size, color="#00FF00") # Get icon by name icon = self.rm.get(name) @@ -388,10 +373,7 @@ def _render_center_icon( ) # Apply color overlay - im = self._apply_overlay_color( - im, - color, - ) + im = self._apply_overlay_color(im, color, theme) return im @@ -401,6 +383,7 @@ def _render_corner_icon( 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 @@ def _render_corner_icon( 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 @@ def _render_corner_icon( 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 @@ def _render_corner_icon( 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 @@ def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Imag 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 @@ def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Imag 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 @@ def _apply_edge( 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 @@ def _apply_edge( 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 @@ def render( """ 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 @@ def render_default(size: tuple[int, int], pixel_ratio: float) -> Image.Image: name=self._get_resource_id(filepath), color=theme_color, size=size, + theme=theme, pixel_ratio=pixel_ratio, ) return im @@ -616,6 +600,7 @@ def render_unlinked( 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 @@ def render_unlinked( ) 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 @@ def render_ignored( 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 @@ def fetch_cached_image(file_name: Path): 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 @@ def fetch_cached_image(file_name: Path): # 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 @@ def fetch_cached_image(file_name: Path): (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 @@ def fetch_cached_image(file_name: Path): 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 @@ def fetch_cached_image(file_name: Path): 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 @@ def fetch_cached_image(file_name: Path): 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 @@ def _render( """ 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 @@ def _render( 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 000000000..9a9e8fac1 --- /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 3f60698df..b95307ba3 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.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 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: 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 @@ def set_entries(self, entry_ids: list[int]): ( self._renderer.render, ( + self.driver.cache_manager, self._render_cutoff, Path(), base_size, @@ -300,7 +301,15 @@ def setGeometry(self, arg__1: QRect) -> None: 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 c0a7de1ca..6e4004269 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 b4847e61f..e89204b26 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.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 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: 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 @@ def __render_thumb(self, filepath: Path) -> None: 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 a7c4ab4ce..4c18e1a8f 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: From e050fc0db7b379074204ec5f662ed458a140cadf Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:35:10 -0700 Subject: [PATCH 7/8] refactor: cleanup vendored blender file --- src/tagstudio/renderers/blender.py | 4 +--- ...{blender_renderer.py => blender_thumbnailer.py} | 14 +++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) rename src/tagstudio/renderers/vendored/{blender_renderer.py => blender_thumbnailer.py} (86%) diff --git a/src/tagstudio/renderers/blender.py b/src/tagstudio/renderers/blender.py index 9e08347d2..b8de34795 100644 --- a/src/tagstudio/renderers/blender.py +++ b/src/tagstudio/renderers/blender.py @@ -9,9 +9,7 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication -from tagstudio.renderers.vendored.blender_renderer import ( - blend_thumb, # pyright: ignore[reportUnknownVariableType] -) +from tagstudio.renderers.vendored.blender_thumbnailer import blend_thumb logger = structlog.get_logger(__name__) diff --git a/src/tagstudio/renderers/vendored/blender_renderer.py b/src/tagstudio/renderers/vendored/blender_thumbnailer.py similarity index 86% rename from src/tagstudio/renderers/vendored/blender_renderer.py rename to src/tagstudio/renderers/vendored/blender_thumbnailer.py index ae57091a5..8886bcbdd 100644 --- a/src/tagstudio/renderers/vendored/blender_renderer.py +++ b/src/tagstudio/renderers/vendored/blender_thumbnailer.py @@ -1,29 +1,29 @@ -# SPDX-FileCopyrightText: (c) 2017 The Blender Foundation +# SPDX-FileCopyrightText: (c) 2017 Blender Foundation # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only -## This file is a modified script that gets the thumbnail data stored in a blend file - +"""Extract an embedded thumbnail from a Blender file.""" import gzip import os import struct from io import BufferedReader +from pathlib import Path from PIL import Image, ImageOps -def blend_extract_thumb(path) -> tuple[bytes | None, int, int]: +def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: rend = b"REND" test = b"TEST" - blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 + blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") head = blendfile.read(12) if head[0:2] == b"\x1f\x8b": # gzip magic blendfile.close() - blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 + blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) head = blendfile.read(12) if not head.startswith(b"BLENDER"): @@ -78,7 +78,7 @@ def blend_extract_thumb(path) -> tuple[bytes | None, int, int]: return image_buffer, x, y -def blend_thumb(file_in) -> Image.Image | None: +def blend_thumb(file_in: Path | str) -> Image.Image | None: buf, width, height = blend_extract_thumb(file_in) if buf is None: return None From ef94c6fdfc23083035ad0dbd92703576fface6e0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:06:12 -0700 Subject: [PATCH 8/8] refactor: rename ambiguous variables --- src/tagstudio/qt/previews/renderer.py | 179 ++++++++++++-------------- src/tagstudio/qt/qt_file_renderer.py | 10 +- 2 files changed, 85 insertions(+), 104 deletions(-) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 9857a5079..41fb1f45f 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -56,7 +56,7 @@ class FileRenderer: - """A class for rendering image and file thumbnails.""" + """A class for rendering image previews and thumbnails from files.""" rm: ResourceManager = ResourceManager() cached_img_ext: str = ".webp" @@ -107,6 +107,7 @@ def _get_resource_id(self, url: Path) -> str: return "file_generic" + # NOTE: This method will be replaced with frontend specific decorations (Qt painting) def _get_mask( self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False ) -> Image.Image: @@ -130,6 +131,7 @@ def _get_mask( self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item return item + # NOTE: This method will be replaced with frontend specific decorations (Qt painting) def _get_edge( self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: @@ -153,7 +155,7 @@ def _get_icon( color: UiColor, size: tuple[int, int], theme: Theme, - pixel_ratio: float = 1.0, + dpi_scale: float = 1.0, bg_image: Image.Image | None = None, draw_edge: bool = True, is_corner: bool = False, @@ -165,7 +167,7 @@ def _get_icon( 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. + dpi_scale (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. is_corner (bool): Flag for is the icon should render with the "corner" style @@ -174,23 +176,24 @@ def _get_icon( if name == "thumb_loading": draw_border = False - item: Image.Image | None = self.icons.get((name, color, *size, pixel_ratio)) + item: Image.Image | None = self.icons.get((name, color, *size, dpi_scale)) if not item: item_flat: Image.Image = ( - self._render_corner_icon(name, color, size, pixel_ratio, theme, bg_image) + self._render_corner_icon(name, color, size, dpi_scale, theme, bg_image) if is_corner else self._render_center_icon( - name, color, size, pixel_ratio, theme, draw_border, bg_image + name, color, size, dpi_scale, theme, draw_border, bg_image ) ) if draw_edge: - edge: tuple[Image.Image, Image.Image] = self._get_edge(size, pixel_ratio) + edge: tuple[Image.Image, Image.Image] = self._get_edge(size, dpi_scale) item = self._apply_edge(item_flat, edge, theme, faded=True) - self.icons[(name, color, *size, pixel_ratio)] = item + self.icons[(name, color, *size, dpi_scale)] = item else: item = item_flat return item + # NOTE: This method will be replaced with frontend specific decorations (Qt painting) def _render_mask( self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1 ) -> Image.Image: @@ -221,6 +224,7 @@ def _render_mask( ) return im + # NOTE: This method will be replaced with frontend specific decorations (Qt painting) def _render_edge( self, size: tuple[int, int], pixel_ratio: float ) -> tuple[Image.Image, Image.Image]: @@ -248,10 +252,7 @@ def _render_edge( outline="white", width=width, ) - im_hl = im_hl.resize( - size, - resample=Image.Resampling.BILINEAR, - ) + im_hl = im_hl.resize(size, resample=Image.Resampling.BILINEAR) # Shadow im_sh: Image.Image = Image.new( @@ -267,10 +268,7 @@ def _render_edge( outline="black", width=width, ) - im_sh = im_sh.resize( - size, - resample=Image.Resampling.BILINEAR, - ) + im_sh = im_sh.resize(size, resample=Image.Resampling.BILINEAR) return (im_hl, im_sh) @@ -347,10 +345,7 @@ def _render_center_icon( ) # Resize image to final size - im = im.resize( - size, - resample=Image.Resampling.BILINEAR, - ) + im = im.resize(size, resample=Image.Resampling.BILINEAR) fg: Image.Image = Image.new("RGB", size=size, color="#00FF00") # Get icon by name @@ -436,15 +431,8 @@ def _render_corner_icon( primary_color = colors.get(ColorType.PRIMARY) # Resize image to final size - im = im.resize( - size, - resample=Image.Resampling.BILINEAR, - ) - fg: Image.Image = Image.new( - "RGB", - size=size, - color=primary_color, - ) + im = im.resize(size, resample=Image.Resampling.BILINEAR) + fg: Image.Image = Image.new("RGB", size=size, color=primary_color) # Get icon by name icon = self.rm.get(name) @@ -453,21 +441,11 @@ def _render_corner_icon( icon = self.rm.file_generic # Resize icon to fit icon_ratio - icon = icon.resize( - ( - math.ceil(size[0] // icon_ratio), - math.ceil(size[1] // icon_ratio), - ) - ) + icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) # Paste icon im.paste( - im=fg.resize( - ( - math.ceil(size[0] // icon_ratio), - math.ceil(size[1] // icon_ratio), - ) - ), + im=fg.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))), box=(size[0] // padding_factor, size[1] // padding_factor), mask=icon.getchannel(3), ) @@ -516,6 +494,7 @@ def _apply_overlay_color(self, image: Image.Image, color: UiColor, theme: Theme) return bg + # NOTE: This method will be replaced with frontend specific decorations (Qt painting) def _apply_edge( self, image: Image.Image, @@ -557,11 +536,11 @@ def render( cache: CacheManager | None, timestamp: float, filepath: Path | str, - base_size: tuple[int, int], - pixel_ratio: float, + size: tuple[int, int], + dpi_scale: float, theme: Theme = Theme.DARK, is_loading: bool = False, - is_grid_thumb: bool = False, + is_thumb: bool = False, ): """Render a thumbnail or preview image. @@ -569,39 +548,38 @@ def render( 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. + size (tuple[int, int]): The unmodified base size of the thumbnail. + dpi_scale (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? + is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants. 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) + scaled_size = math.ceil(max(size[0], size[1]) * dpi_scale) theme_color: UiColor = UiColor.THEME_LIGHT if theme == Theme.LIGHT else UiColor.THEME_DARK if isinstance(filepath, str): filepath = Path(filepath) - def render_default(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + def render_default(size: tuple[int, int], dpi_scale: float) -> Image.Image: im = self._get_icon( name=self._get_resource_id(filepath), color=theme_color, size=size, theme=theme, - pixel_ratio=pixel_ratio, + dpi_scale=dpi_scale, ) return im def render_unlinked( - size: tuple[int, int], pixel_ratio: float, cached_im: Image.Image | None = None + size: tuple[int, int], dpi_scale: float, cached_im: Image.Image | None = None ) -> Image.Image: im = self._get_icon( name="broken_link_icon", color=UiColor.RED, size=size, theme=theme, - pixel_ratio=pixel_ratio, + dpi_scale=dpi_scale, bg_image=cached_im, draw_edge=not cached_im, is_corner=False, @@ -614,9 +592,7 @@ def render_ignored(size: tuple[int, int], im: Image.Image) -> Image.Image: im_ = im icon: Image.Image = self.rm.ignored - icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) - im_.paste( im=icon.resize( (math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)) @@ -640,13 +616,13 @@ def fetch_cached_image(file_name: Path): raise UnidentifiedImageError # pyright: ignore[reportUnreachable] except Exception as e: logger.error( - "[ThumbRenderer] Couldn't open cached thumbnail!", path=cached_path, error=e + "[FileRenderer] Couldn't open cached thumbnail!", path=cached_path, error=e ) 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 != Path("."): + if not is_loading and is_thumb and filepath and filepath != Path("."): # Attempt to retrieve cached image from disk mod_time: str = "" with contextlib.suppress(Exception): @@ -668,35 +644,35 @@ def fetch_cached_image(file_name: Path): # TODO: Audio waveforms are dynamically sized based on the base_size, so hardcoding # the resolution breaks that. image = self._render( - cache, - filepath, - (thumb_res, thumb_res), - 1, - theme, - is_grid_thumb, - save_to_file=file_name, + cache=cache, + filepath=filepath, + size=(thumb_res, thumb_res), + dpi_scale=1, + theme=theme, + is_thumb=is_thumb, + cache_filename=file_name, ) # If the normal renderer failed, fallback the defaults # (with native non-cached sizing!) if not image: image = ( - render_unlinked((adj_size, adj_size), pixel_ratio) + render_unlinked((scaled_size, scaled_size), dpi_scale) if not filepath.exists() or filepath.is_dir() - else render_default((adj_size, adj_size), pixel_ratio) + else render_default((scaled_size, scaled_size), dpi_scale) ) render_mask_and_edge = False # Apply the mask and edge if image: - image = self._resize_image(image, (adj_size, adj_size)) + image = self._resize_image(image, (scaled_size, scaled_size)) if render_mask_and_edge: - mask = self._get_mask((adj_size, adj_size), pixel_ratio) + mask = self._get_mask((scaled_size, scaled_size), dpi_scale) edge: tuple[Image.Image, Image.Image] = self._get_edge( - (adj_size, adj_size), pixel_ratio + (scaled_size, scaled_size), dpi_scale ) image = self._apply_edge( - four_corner_gradient(image, (adj_size, adj_size), mask), edge, theme + four_corner_gradient(image, (scaled_size, scaled_size), mask), edge, theme ) # Check if the file is supposed to be ignored and render an overlay if needed @@ -708,7 +684,7 @@ def fetch_cached_image(file_name: Path): filepath.relative_to(unwrap(self.lib.library_dir)) ) ): - image = render_ignored((adj_size, adj_size), image) + image = render_ignored((scaled_size, scaled_size), image) except TypeError: pass @@ -716,13 +692,15 @@ def fetch_cached_image(file_name: Path): elif is_loading: # Initialize "Loading" thumbnail loading_thumb: Image.Image = self._get_icon( - "thumb_loading", theme_color, (adj_size, adj_size), theme, pixel_ratio + "thumb_loading", theme_color, (scaled_size, scaled_size), theme, dpi_scale + ) + image = loading_thumb.resize( + (scaled_size, scaled_size), resample=Image.Resampling.BILINEAR ) - 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(cache, filepath, base_size, pixel_ratio, theme) + elif not is_thumb: + image = self._render(cache, filepath, size, dpi_scale, theme) if not image: image = ( render_unlinked((512, 512), 2) @@ -730,7 +708,7 @@ def fetch_cached_image(file_name: Path): else render_default((512, 512), 2) ) render_mask_and_edge = False - mask = self._get_mask(image.size, pixel_ratio, scale_radius=True) + mask = self._get_mask(image.size, dpi_scale, scale_radius=True) bg = Image.new("RGBA", image.size, (0, 0, 0, 0)) bg.paste(image, mask=mask.getchannel(0)) image = bg @@ -741,7 +719,7 @@ def fetch_cached_image(file_name: Path): return ( image, - (math.ceil(adj_size / pixel_ratio), math.ceil(image.size[1] / pixel_ratio)), + (math.ceil(scaled_size / dpi_scale), math.ceil(image.size[1] / dpi_scale)), timestamp, ) @@ -749,11 +727,11 @@ def _render( self, cache: CacheManager | None, filepath: str | Path, - base_size: tuple[int, int], - pixel_ratio: float, + size: tuple[int, int], + dpi_scale: float, theme: Theme = Theme.DARK, - is_grid_thumb: bool = False, - save_to_file: Path | None = None, + is_thumb: bool = False, + cache_filename: Path | None = None, ) -> Image.Image | None: """Render a thumbnail or preview image. @@ -761,18 +739,17 @@ def _render( 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. + size (tuple[int, int]): The unmodified base size of the thumbnail. + dpi_scale (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. + is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants. + cache_filename (Path | None): An optional filename to use to save to the cache. """ - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) + scaled_size = math.ceil(max(size[0], size[1]) * dpi_scale) image: Image.Image | None = None filepath_: Path = Path(filepath) - savable_media_type: bool = True + is_savable_type: bool = True if filepath_ and filepath_.is_file(): try: @@ -810,7 +787,7 @@ def _render( elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True ): - image = vector_image_thumb(filepath_, adj_size) + image = vector_image_thumb(filepath_, scaled_size) # EXR Images ------------------------------------------------------------------- elif ext in [".exr"]: image = exr_image_thumb(filepath_) @@ -845,22 +822,22 @@ def _render( elif MediaCategories.is_ext_in_category( ext, MediaCategories.FONT_TYPES, mime_fallback=True ): - if is_grid_thumb: + if is_thumb: # Short (Aa) Preview - image = font_small_thumb(filepath_, adj_size) + image = font_small_thumb(filepath_, scaled_size) if image is not None: 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_, scaled_size) # Audio ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): image = audio_album_thumb(filepath_, ext) if image is None: - image = audio_waveform_thumb(filepath_, ext, adj_size, pixel_ratio) - savable_media_type = False + image = audio_waveform_thumb(filepath_, ext, scaled_size, dpi_scale) + is_savable_type = False if image is not None: image = self._apply_overlay_color(image, UiColor.GREEN, theme) # Blender ====================================================== @@ -872,7 +849,7 @@ def _render( elif MediaCategories.is_ext_in_category( ext, MediaCategories.PDF_TYPES, mime_fallback=True ): - image = pdf_thumb(filepath_, adj_size, ext) + image = pdf_thumb(filepath_, scaled_size, ext) # Archives ===================================================== elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES): image = archive_thumb(filepath_, ext) @@ -887,10 +864,10 @@ def _render( raise NoRendererError if image: - image = self._resize_image(image, (adj_size, adj_size)) + image = self._resize_image(image, (scaled_size, scaled_size)) - if save_to_file and savable_media_type and image and cache: - cache.save_image(image, save_to_file, mode="RGBA") + if cache_filename and is_savable_type and image and cache: + cache.save_image(image, cache_filename, mode="RGBA") except ( AssertionError, @@ -899,7 +876,11 @@ def _render( UnidentifiedImageError, ValueError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + logger.error( + "[FileRenderer] Couldn't render thumbnail", + filepath=filepath, + error=type(e).__name__, + ) image = None except NoRendererError: image = None diff --git a/src/tagstudio/qt/qt_file_renderer.py b/src/tagstudio/qt/qt_file_renderer.py index 9a9e8fac1..b6751f005 100644 --- a/src/tagstudio/qt/qt_file_renderer.py +++ b/src/tagstudio/qt/qt_file_renderer.py @@ -14,11 +14,11 @@ class QtFileRenderer(QObject): + """A Qt-specific wrapper for rendering image previews and thumbnails from files.""" + 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) @@ -43,11 +43,11 @@ def render( cache=cache, timestamp=timestamp, filepath=filepath, - base_size=base_size, - pixel_ratio=pixel_ratio, + size=base_size, + dpi_scale=pixel_ratio, theme=self.theme, is_loading=is_loading, - is_grid_thumb=is_grid_thumb, + is_thumb=is_grid_thumb, ) qim = ImageQt.ImageQt(image) pixmap = QPixmap.fromImage(qim)