diff --git a/pyproject.toml b/pyproject.toml index 4f06cf884..12e98ff6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,10 @@ dependencies = [ "ffmpeg-python~=0.2", "humanfriendly==10.*", "mutagen~=1.47", + "numexpr~=2.14.1", "numpy~=2.2", "opencv_python~=4.11", + "openexr~=3.4.3", "Pillow>=10.2,<=11", "pillow-avif-plugin~=1.5", "pillow-heif~=0.22", diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 8659c389d..c336891f0 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -41,6 +41,7 @@ class MediaType(str, Enum): FONT = "font" IMAGE_ANIMATED = "image_animated" IMAGE_RAW = "image_raw" + IMAGE_EXR = "image_exr" IMAGE_VECTOR = "image_vector" IMAGE = "image" INSTALLER = "installer" @@ -51,6 +52,7 @@ class MediaType(str, Enum): PACKAGE = "package" PDF = "pdf" PLAINTEXT = "plaintext" + POWERPOINT = "powerpoint" PRESENTATION = "presentation" PROGRAM = "program" SHADER = "shader" @@ -109,7 +111,6 @@ class MediaCategories: ".psd", } _AFFINITY_PHOTO_SET: set[str] = {".afphoto"} - _KRITA_SET: set[str] = {".kra", ".krz"} _ARCHIVE_SET: set[str] = { ".7z", ".gz", @@ -297,15 +298,25 @@ class MediaCategories: ".cr3", ".crw", ".dng", + ".erf", + ".mef", + ".mos", + ".mrw", ".nef", ".nrw", ".orf", + ".pef", ".raf", ".raw", ".rw2", ".srf", ".srf2", + ".sr2", + ".srw", + ".x3f", + ".3fr", } + _IMAGE_EXR_SET: set[str] = {".exr"} _IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"} _IMAGE_RASTER_SET: set[str] = { ".apng", @@ -334,6 +345,7 @@ class MediaCategories: } _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} _IWORK_SET: set[str] = {".key", ".pages", ".numbers"} + _KRITA_SET: set[str] = {".kra", ".krz"} _MATERIAL_SET: set[str] = {".mtl"} _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} _OPEN_DOCUMENT_SET: set[str] = { @@ -375,11 +387,11 @@ class MediaCategories: "license", "readme", } + _POWERPOINT_SET: set[str] = {".pptx"} _PRESENTATION_SET: set[str] = { ".key", ".odp", ".ppt", - ".pptx", } _PROGRAM_SET: set[str] = {".app", ".bin", ".exe"} _SOURCE_ENGINE_SET: set[str] = {".vtf"} @@ -500,6 +512,9 @@ class MediaCategories: is_iana=False, name="raw image", ) + IMAGE_EXR_TYPES = MediaCategory( + media_type=MediaType.IMAGE_EXR, extensions=_IMAGE_EXR_SET, is_iana=False, name="exr image" + ) IMAGE_VECTOR_TYPES = MediaCategory( media_type=MediaType.IMAGE_VECTOR, extensions=_IMAGE_VECTOR_SET, @@ -566,9 +581,15 @@ class MediaCategories: is_iana=False, name="plaintext", ) + POWERPOINT_TYPES = MediaCategory( + media_type=MediaType.POWERPOINT, + extensions=_POWERPOINT_SET, + is_iana=False, + name="powerpoint", + ) PRESENTATION_TYPES = MediaCategory( media_type=MediaType.PRESENTATION, - extensions=_PRESENTATION_SET, + extensions=_PRESENTATION_SET | _POWERPOINT_SET, is_iana=False, name="presentation", ) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index ecc5d96a2..8cc539217 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import cv2 +import OpenEXR import rawpy import structlog from PIL import Image, UnidentifiedImageError @@ -54,6 +55,15 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: FileNotFoundError, ): pass + elif MediaCategories.IMAGE_EXR_TYPES.contains(ext, mime_fallback=True): + try: + exr_file = OpenEXR.File(str(filepath)) + part = exr_file.parts[0] + logger.debug("[PreviewThumb]", part=part) + stats.width = part.width() + stats.height = part.height() + except Exception: + pass elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): try: image = Image.open(str(filepath)) @@ -69,6 +79,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): pass # TODO + logger.debug("[PreviewThumb]", stats=stats) return stats def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py new file mode 100644 index 000000000..5610b1cf4 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Literal + + +class ArchiveFile(ABC): + @abstractmethod + def __init__(self, path: Path, mode: Literal["r"]) -> None: + pass + + @abstractmethod + def get_name_list(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + def has_file_name(self, file_name: str) -> bool: + raise NotImplementedError + + @abstractmethod + def read(self, file_name: str) -> bytes: + raise NotImplementedError diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py new file mode 100644 index 000000000..46f4c1084 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py @@ -0,0 +1,52 @@ +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +import rarfile + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class RarFile(ArchiveFile): + """Wrapper around rarfile.RarFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__rar_file: rarfile.RarFile = rarfile.RarFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__rar_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__rar_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + return self.__rar_file.read(file_path) + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py new file mode 100644 index 000000000..bb4f4e16a --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py @@ -0,0 +1,59 @@ +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +import py7zr + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class SevenZipFile(ArchiveFile): + """Wrapper around py7zr.SevenZipFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__seven_zip_file: py7zr.SevenZipFile = py7zr.SevenZipFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__seven_zip_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__seven_zip_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + # py7zr.SevenZipFile must be reset after every extraction + # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract + self.__seven_zip_file.reset() + + factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB + + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + self.__seven_zip_file.extract(targets=[str(file_path)], factory=factory) + return factory.get(file_path).read() + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py new file mode 100644 index 000000000..256619595 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py @@ -0,0 +1,52 @@ +import tarfile +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class TarFile(ArchiveFile): + """Wrapper around tarfile.TarFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__tar_file: tarfile.TarFile = tarfile.TarFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__tar_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__tar_file.getnames(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + return unwrap(self.__tar_file.extractfile(str(file_path))).read() + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py new file mode 100644 index 000000000..3aa1a64a0 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py @@ -0,0 +1,51 @@ +import zipfile +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class ZipFile(ArchiveFile): + """Wrapper around zipfile.ZipFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__zip_file: zipfile.ZipFile = zipfile.ZipFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__zip_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__zip_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + return self.__zip_file.read(str(file_path)) + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/image_effects.py b/src/tagstudio/qt/helpers/image_effects.py index ca764744d..b4f960bfe 100644 --- a/src/tagstudio/qt/helpers/image_effects.py +++ b/src/tagstudio/qt/helpers/image_effects.py @@ -5,6 +5,10 @@ import numpy as np from PIL import Image +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color def replace_transparent_pixels( @@ -26,3 +30,45 @@ def replace_transparent_pixels( pixel_array = np.asarray(img.convert("RGBA")).copy() pixel_array[pixel_array[:, :, 3] == 0] = color return Image.fromarray(pixel_array) + + +def apply_overlay_color(image: Image.Image, color: UiColor) -> 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. + + Args: + image (Image.Image): The image to apply an overlay to. + color (UiColor): The name of the ColorType color to use. + """ + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.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 + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + overlay_color: str = ( + get_ui_color(ColorType.BORDER, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + + bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) + fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) + overlay: Image.Image = Image.new(image.mode, image.size, color=overlay_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(overlay, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image.Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index f47d534ac..5563cec61 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -7,76 +7,41 @@ import hashlib import math import os -import tarfile -import xml.etree.ElementTree as ET -import zipfile 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 +from typing import TYPE_CHECKING -import cv2 -import numpy as np import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport] -import py7zr -import py7zr.io -import rarfile -import rawpy -import srctools 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 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 PySide6.QtGui import QGuiApplication, QPixmap -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_IMAGE_RES -from tagstudio.qt.helpers.color_overlay import theme_fg_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 -from tagstudio.qt.previews.vendored.pydub.audio_segment import ( - _AudioSegment as AudioSegment, -) +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor +from tagstudio.qt.previews.renderer_type import RendererType +from tagstudio.qt.previews.renderers.base_renderer import RendererContext from tagstudio.qt.resource_manager import ResourceManager if TYPE_CHECKING: @@ -95,40 +60,6 @@ logger.exception('[ThumbRenderer] Could not import the "pillow_jxl" module') -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(tarfile.TarFile): - """Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API.""" - - def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - super().__init__(filepath, mode) - - def namelist(self) -> list[str]: - return self.getnames() - - def read(self, name: str) -> bytes: - return unwrap(self.extractfile(name)).read() - - -type _Archive_T = ( - type[zipfile.ZipFile] | type[rarfile.RarFile] | type[_SevenZipFile] | type[_TarFile] -) -type _Archive = zipfile.ZipFile | rarfile.RarFile | _SevenZipFile | _TarFile - - class ThumbRenderer(QObject): """A class for rendering image and file thumbnails.""" @@ -285,7 +216,7 @@ def _render_mask( im: Image.Image = Image.new( mode="L", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="black", ) draw = ImageDraw.Draw(im) @@ -316,7 +247,7 @@ def _render_edge( # Highlight im_hl: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) draw = ImageDraw.Draw(im_hl) @@ -335,7 +266,7 @@ def _render_edge( # Shadow im_sh: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) draw = ImageDraw.Draw(im_sh) @@ -380,7 +311,7 @@ def _render_center_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#FF000000", ) @@ -388,13 +319,13 @@ def _render_center_icon( bg: Image.Image bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#000000FF", ) # Use a background image if provided if bg_image: - bg_im = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg_im = Image.Image.resize(bg_image, size=([d * smooth_factor for d in size])) bg_im = ImageEnhance.Brightness(bg_im).enhance(0.3) # Reduce the brightness bg.paste(bg_im) @@ -455,7 +386,7 @@ def _render_center_icon( ) # Apply color overlay - im = self._apply_overlay_color( + im = apply_overlay_color( im, color, ) @@ -487,23 +418,23 @@ def _render_corner_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) bg: Image.Image # Use a background image if provided if bg_image: - bg = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg = Image.Image.resize(bg_image, size=([d * smooth_factor for d in size])) # Create solid background color else: bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#000000", ) # Apply color overlay - bg = self._apply_overlay_color( + bg = apply_overlay_color( im, color, ) @@ -561,47 +492,6 @@ def _render_corner_icon( return im - def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> 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. - - Args: - image (Image.Image): The image to apply an overlay to. - color (UiColor): The name of the ColorType color to use. - """ - bg_color: str = ( - get_ui_color(ColorType.DARK_ACCENT, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.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 - 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 - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - - bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) - fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) - ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) - - bg.paste(fg, (0, 0), mask=image.getchannel(0)) - bg.paste(ol, (0, 0), mask=image.getchannel(1)) - - if image.mode == "RGBA": - alpha_bg: Image.Image = bg.copy() - alpha_bg.convert("RGBA") - alpha_bg.putalpha(0) - alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) - bg = alpha_bg - - return bg - def _apply_edge( self, image: Image.Image, @@ -638,746 +528,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") - 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 - 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[reportAssignmentType] - 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: - blend_image = blend_thumb(str(filepath)) - - bg = Image.new("RGB", blend_image.size, color=bg_color) - bg.paste(blend_image, mask=blend_image.getchannel(3)) - im = bg - - except ( - AttributeError, - UnidentifiedImageError, - TypeError, - ) as e: - if str(e) == "expected string or buffer": - logger.info( - f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " - f"Doesn't have an embedded thumbnail. ({type(e).__name__})" - ) - - else: - 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 an 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: - archiver: _Archive_T = zipfile.ZipFile - if ext == ".cb7": - archiver = _SevenZipFile - elif ext == ".cbr": - archiver = rarfile.RarFile - elif ext == ".cbt": - archiver = _TarFile - - with archiver(filepath, "r") 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: - for file_name in archive.namelist(): - if file_name.lower().endswith( - (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") - ): - image_data = archive.read(file_name) - im = Image.open(BytesIO(image_data)) - break - 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")))] - if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): - image_data = archive.read(page_name) - im = Image.open(BytesIO(image_data)) - - return im - - 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, # pyright: ignore[reportArgumentType] - 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 = theme_fg_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, - rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] - ) 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) - 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: - im = Image.open(filepath) - 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 - im = unwrap(ImageOps.exif_transpose(im)) - 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") # type: ignore[call-overload] - - # 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 _iwork_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file. - - Args: - filepath (Path): The path of the file. - """ - preview_thumb_dir = "preview.jpg" - quicklook_thumb_dir = "QuickLook/Thumbnail.jpg" - 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 - if preview_thumb_dir in zip_file.namelist(): - thumb = get_image(preview_thumb_dir) - elif quicklook_thumb_dir in zip_file.namelist(): - thumb = get_image(quicklook_thumb_dir) - 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: - """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) -> Image.Image | None: - """Render a thumbnail for a PDF file. - - filepath (Path): The path of the file. - size (int): The size of the icon. - """ - 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 antialiasing - scale_factor = 2.5 - 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") # type: ignore # 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 - def render( self, timestamp: float, @@ -1391,7 +541,7 @@ def render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + 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. @@ -1601,7 +751,7 @@ def _render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + 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. @@ -1610,122 +760,54 @@ def _render( save_to_file(Path | None): A filepath to optionally save the output to. """ - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) - image: Image.Image | None = None + adj_size: int = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) _filepath: Path = Path(filepath) - savable_media_type: bool = True if _filepath and _filepath.is_file(): try: 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 = self._epub_cover(_filepath, ext) - # Krita ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.KRITA_TYPES, mime_fallback=True - ): - image = self._krita_thumb(_filepath) - # VTF ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True - ): - image = self._vtf_thumb(_filepath) - # Images ======================================================= - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_TYPES, mime_fallback=True - ): - # Raw Images ----------------------------------------------- - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ): - image = self._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) - # EXR Images ----------------------------------------------- - elif ext in [".exr"]: - image = self._image_exr_thumb(_filepath) - # Normal Images -------------------------------------------- - else: - image = self._image_thumb(_filepath) - # Videos ======================================================= - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ): - image = self._video_thumb(_filepath) - # PowerPoint Slideshow - elif ext in {".pptx"}: - image = self._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) - # Apple iWork Suite ============================================ - elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): - image = self._iwork_thumb(_filepath) - # Plain Text =================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True - ): - image = self._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) - else: - # Large (Full Alphabet) Preview - image = self._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) - if image is None: - image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) - savable_media_type = False - if image is not None: - image = self._apply_overlay_color(image, UiColor.GREEN) - # Blender ====================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.BLENDER_TYPES, mime_fallback=True - ): - image = self._blender(_filepath) - # PDF ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PDF_TYPES, mime_fallback=True - ): - image = self._pdf_thumb(_filepath, adj_size) - # No Rendered Thumbnail ======================================== - if not image: + + renderer_type: RendererType | None = RendererType.get_renderer_type(ext) + renderer_context: RendererContext = RendererContext( + path=_filepath, + extension=ext, + size=adj_size, + pixel_ratio=pixel_ratio, + is_grid_thumb=is_grid_thumb, + ) + + logger.debug( + "[ThumbRenderer]", + renderer_type=renderer_type, + renderer_context=renderer_context, + ) + + if not renderer_type: raise NoRendererError + image: Image.Image = renderer_type.renderer.render(renderer_context) + if image: image = self._resize_image(image, (adj_size, adj_size)) - if save_to_file and savable_media_type and image: + if save_to_file and renderer_type.is_savable_media_type and image: self.driver.cache_manager.save_image(image, save_to_file, mode="RGBA") + return image + except ( UnidentifiedImageError, DecompressionBombError, ValueError, ChildProcessError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - image = None + logger.error( + "[ThumbRenderer] Couldn't render thumbnail", filepath=filepath, error=e + ) except NoRendererError: - image = None + pass - return image + return None def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image: orig_x, orig_y = image.size diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py new file mode 100644 index 000000000..2642c9dc8 --- /dev/null +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -0,0 +1,73 @@ +from enum import Enum + +from tagstudio.core.media_types import MediaCategories +from tagstudio.qt.previews.renderers.audio_renderer import AudioRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.blender_renderer import BlenderRenderer +from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer +from tagstudio.qt.previews.renderers.exr_image_renderer import EXRImageRenderer +from tagstudio.qt.previews.renderers.font_renderer import FontRenderer +from tagstudio.qt.previews.renderers.iwork_renderer import IWorkRenderer +from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer +from tagstudio.qt.previews.renderers.open_doc_renderer import OpenDocRenderer +from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer +from tagstudio.qt.previews.renderers.powerpoint_renderer import PowerPointRenderer +from tagstudio.qt.previews.renderers.raster_image_renderer import RasterImageRenderer +from tagstudio.qt.previews.renderers.raw_image_renderer import RawImageRenderer +from tagstudio.qt.previews.renderers.text_renderer import TextRenderer +from tagstudio.qt.previews.renderers.vector_image_renderer import VectorImageRenderer +from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer +from tagstudio.qt.previews.renderers.vtf_renderer import VTFRenderer + + +class RendererType(Enum): + # Project files + KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer, True + + # Model files + BLENDER = "blender", MediaCategories.BLENDER_TYPES, BlenderRenderer, True + + # Media files + VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer, True + AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer, False + + # Document files + OPEN_DOC = "open_doc", MediaCategories.OPEN_DOCUMENT_TYPES, OpenDocRenderer, True + POWERPOINT = "powerpoint", MediaCategories.POWERPOINT_TYPES, PowerPointRenderer, True + PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer, True + EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer, True + IWORK = "iwork", MediaCategories.IWORK_TYPES, IWorkRenderer, True + + # Text files + TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer, True + FONT = "font", MediaCategories.FONT_TYPES, FontRenderer, True + + # Image files + VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer, True + RAW_IMAGE = "raw_image", MediaCategories.IMAGE_RAW_TYPES, RawImageRenderer, True + EXR_IMAGE = "exr_image", MediaCategories.IMAGE_EXR_TYPES, EXRImageRenderer, True + VECTOR_IMAGE = "vector_image", MediaCategories.IMAGE_VECTOR_TYPES, VectorImageRenderer, True + RASTER_IMAGE = "image", MediaCategories.IMAGE_RASTER_TYPES, RasterImageRenderer, True + + def __init__( + self, + name: str, + media_category: MediaCategories, + renderer: type[BaseRenderer], + is_savable_media_type: bool, + ): + self.__name: str = name + self.media_category: MediaCategories = media_category + self.renderer: type[BaseRenderer] = renderer + + self.is_savable_media_type = is_savable_media_type + + @staticmethod + def get_renderer_type(file_extension: str) -> "RendererType | None": + for renderer_type in RendererType.__members__.values(): + if MediaCategories.is_ext_in_category( + file_extension, renderer_type.media_category, mime_fallback=True + ): + return renderer_type + + return None diff --git a/src/tagstudio/qt/previews/renderers/audio_renderer.py b/src/tagstudio/qt/previews/renderers/audio_renderer.py new file mode 100644 index 000000000..4527cc863 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/audio_renderer.py @@ -0,0 +1,168 @@ +import math +from io import BytesIO +from warnings import catch_warnings + +import numpy as np +import structlog +from mutagen import MutagenError, flac, id3, mp4 +from PIL import ( + Image, + ImageDraw, +) +from pydub import AudioSegment + +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.models.palette import UiColor +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class AudioRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an audio file. + + Args: + context (RendererContext): The renderer context. + """ + rendered_image: Image.Image | None = _extract_album_cover(context) + + if rendered_image is None: + rendered_image = _render_audio_waveform(context) + if rendered_image is not None: + rendered_image = apply_overlay_color(rendered_image, UiColor.GREEN) + + return rendered_image + + +def _extract_album_cover(context: RendererContext) -> Image.Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + context (RendererContext): The renderer context. + """ + try: + if not context.path.is_file(): + raise FileNotFoundError + + artwork: Image.Image | None = None + + # Get cover from .mp3 tags + if context.extension in [".mp3"]: + id3_tags: id3.ID3 = id3.ID3(context.path) + id3_covers: list = id3_tags.getall("APIC") + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) + + # Get cover from .flac tags + elif context.extension in [".flac"]: + flac_tags: flac.FLAC = flac.FLAC(context.path) + flac_covers: list = flac_tags.pictures + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) + + # Get cover from .mp4 tags + elif context.extension in [".mp4", ".m4a", ".aac"]: + mp4_tags: mp4.MP4 = mp4.MP4(context.path) + mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + + return artwork + except ( + FileNotFoundError, + id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + MutagenError, + ) as e: + logger.error("[AudioRenderer] Couldn't read album artwork", path=context.path, error=e) + + return None + + +def _render_audio_waveform(context: RendererContext) -> Image.Image | None: + """Render a waveform image from an audio file. + + Args: + context (RendererContext): The renderer context. + """ + # 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 = context.size * base_scale + allow_small_min: bool = False + + try: + bar_count: int = min(math.floor((context.size // context.pixel_ratio) / 5), 64) + audio = AudioSegment.from_file(context.path, context.extension[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) + + rendered_image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(rendered_image) + + 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 + + rendered_image.resize((context.size, context.size), Image.Resampling.BILINEAR) + return rendered_image + + except Exception as e: + logger.error("[AudioRenderer] Couldn't render waveform", path=context.path.name, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/base_renderer.py b/src/tagstudio/qt/previews/renderers/base_renderer.py new file mode 100644 index 000000000..983d66758 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/base_renderer.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image + + +@dataclass(kw_only=True) +class RendererContext: + path: Path + extension: str + size: int + pixel_ratio: float + is_grid_thumb: bool + + +class BaseRenderer(ABC): + @abstractmethod + def __init__(self) -> None: + pass + + @staticmethod + @abstractmethod + def render(context: RendererContext) -> Image.Image | None: + raise NotImplementedError diff --git a/src/tagstudio/qt/previews/renderers/blender_renderer.py b/src/tagstudio/qt/previews/renderers/blender_renderer.py new file mode 100644 index 000000000..b0c9b7639 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/blender_renderer.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +# + + +## This file is a modified script that gets the thumbnail data stored in a blend file + + +import gzip +import os +import struct +from io import BufferedReader +from pathlib import Path + +import structlog +from PIL import Image, ImageOps, UnidentifiedImageError +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class BlenderRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Get an embedded thumbnail from a Blender file, if a thumbnail is present. + + Args: + context (RendererContext): The renderer context. + """ + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + + try: + buffer, width, height = _extract_embedded_thumbnail(context.path) + + if buffer is None: + return None + + embedded_thumbnail: Image.Image = Image.frombuffer( + "RGBA", + (width, height), + buffer, + ) + embedded_thumbnail = ImageOps.flip(embedded_thumbnail) + + rendered_image: Image.Image = Image.new("RGB", embedded_thumbnail.size, color=bg_color) + rendered_image.paste(embedded_thumbnail, mask=embedded_thumbnail.getchannel(3)) + return rendered_image + + except ( + AttributeError, + UnidentifiedImageError, + TypeError, + ) as e: + if str(e) == "expected string or buffer": + logger.info( + f"[BlenderRenderer] {context.path.name} " + f"doesn't have an embedded thumbnail. ({e})" + ) + + else: + logger.error("Couldn't render thumbnail", path=context.path, error=e) + + return None + + +def _extract_embedded_thumbnail(path: Path) -> tuple[bytes | None, int, int]: + rend = b"REND" + test = b"TEST" + + blender_file: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 + + header = blender_file.read(12) + + if header[0:2] == b"\x1f\x8b": # gzip magic + blender_file.close() + blender_file = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 + header = blender_file.read(12) + + if not header.startswith(b"BLENDER"): + blender_file.close() + return None, 0, 0 + + is_64_bit = header[7] == b"-"[0] + + # True for PPC, false for X86 + is_big_endian = header[8] == b"V"[0] + + # Blender pre-v2.5 had no thumbnails + if header[9:11] <= b"24": + return None, 0, 0 + + block_header_size = 24 if is_64_bit else 20 + int_endian = ">i" if is_big_endian else " None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. + + Args: + context (RendererContext): The renderer context. + + Returns: + Image: The cover specified in ComicInfo.xml, + the first image found in the ePub file, or None by default. + """ + try: + archive: ArchiveFile | None = None + match context.extension: + case ".cb7": + archive = SevenZipFile(context.path, "r") + case ".cbr": + archive = RarFile(context.path, "r") + case ".cbt": + archive = TarFile(context.path, "r") + case _: + archive = ZipFile(context.path, "r") + + rendered_image: Image.Image | None = None + + # Get the cover from the comic metadata, if present + if "ComicInfo.xml" in archive.get_name_list(): + comic_info: Element = ElementTree.fromstring(archive.read("ComicInfo.xml")) + rendered_image = _extract_cover(archive, comic_info, "FrontCover") + + if not rendered_image: + rendered_image = _extract_cover(archive, comic_info, "InnerCover") + + # Get the first image present + if not rendered_image: + for file_name in archive.get_name_list(): + if file_name.lower().endswith( + (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") + ): + image_data: bytes = archive.read(file_name) + rendered_image = Image.open(BytesIO(image_data)) + break + + return rendered_image + except Exception as e: + logger.error("[EBookRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None + + +def _extract_cover( + archive: ArchiveFile, comic_info: Element, cover_type: str +) -> Image.Image | None: + """Extract the cover specified in ComicInfo.xml. + + Args: + archive (ArchiveFile): 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. + """ + cover: Element | None = comic_info.find(f"./*Page[@Type='{cover_type}']") + if cover is not None: + pages: list[str] = [ + page_file for page_file in archive.get_name_list() if page_file != "ComicInfo.xml" + ] + page_name: str = pages[int(unwrap(cover.get("Image")))] + if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): + image_data: bytes = archive.read(page_name) + return Image.open(BytesIO(image_data)) + + return None diff --git a/src/tagstudio/qt/previews/renderers/exr_image_renderer.py b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py new file mode 100644 index 000000000..79b9423fb --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py @@ -0,0 +1,75 @@ +from pathlib import Path + +import Imath +import numexpr +import numpy +import OpenEXR +import structlog +from OpenEXR import InputFile +from PIL import ( + Image, + ImageOps, +) + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class EXRImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an EXR image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + rendered_image: Image.Image = exr_to_srgb(context.path) + return unwrap(ImageOps.exif_transpose(rendered_image)) + except Exception as e: + logger.error("[EXRImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None + + +# https://gist.github.com/arseniy-panfilov/4dc8fc5131277affe64619b1a9d00da0 +FLOAT = Imath.PixelType(Imath.PixelType.FLOAT) + + +def exr_to_array(path: Path) -> numpy.ndarray: + exr_file: InputFile = OpenEXR.InputFile(str(path)) + data_window = exr_file.header()["dataWindow"] + + channels = list(exr_file.header()["channels"].keys()) + channels_list: list[str] = [c for c in ("R", "G", "B", "A") if c in channels] + size: tuple[int, int] = ( + data_window.max.x - data_window.min.x + 1, + data_window.max.y - data_window.min.y + 1, + ) + + color_channels = exr_file.channels(channels_list, FLOAT) + channels_tuple = [numpy.frombuffer(channel, dtype="f") for channel in color_channels] + + return numpy.dstack(channels_tuple).reshape(size + (len(channels_tuple),)) + + +def encode_to_srgb(x): + a = 0.055 # noqa + return numexpr.evaluate("""where( + x <= 0.0031308, + x * 12.92, + (1 + a) * (x ** (1 / 2.4)) - a + )""") + + +def exr_to_srgb(exr_file) -> Image.Image: + array: numpy.ndarray = exr_to_array(exr_file) + result = encode_to_srgb(array) * 255.0 + present_channels: list[str] = ["R", "G", "B", "A"][: result.shape[2]] + channels: str = "".join(present_channels) + return Image.fromarray(result.astype("uint8"), channels) diff --git a/src/tagstudio/qt/previews/renderers/font_renderer.py b/src/tagstudio/qt/previews/renderers/font_renderer.py new file mode 100644 index 000000000..c1a2438df --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/font_renderer.py @@ -0,0 +1,132 @@ +import math +from typing import cast + +import numpy as np +import structlog +from PIL import ( + Image, + ImageDraw, + ImageFont, +) +from PIL.ImageFont import FreeTypeFont + +from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from tagstudio.qt.helpers.color_overlay import theme_fg_overlay +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.helpers.text_wrapper import wrap_full_text +from tagstudio.qt.models.palette import UiColor +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class FontRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + context (RendererContext): The renderer context. + """ + if context.is_grid_thumb: + return _font_short_thumb(context) + else: + return _font_long_thumb(context) + + +def _font_short_thumb(context: RendererContext) -> Image.Image | None: + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + context (RendererContext): The renderer context. + """ + try: + bg: Image.Image = Image.new("RGB", (context.size, context.size), color="#000000") + raw: Image.Image = Image.new("RGB", (context.size * 3, context.size * 3), color="#000000") + draw = ImageDraw.Draw(raw) + font: FreeTypeFont = ImageFont.truetype(context.path, size=context.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( + (context.size // 8, context.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_image: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(context.size // 16) + + orig_x, orig_y = cropped_image.size + new_x, new_y = (context.size, context.size) + if orig_x > orig_y: + new_x = context.size + new_y = math.ceil(context.size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = context.size + new_x = math.ceil(context.size * (orig_x / orig_y)) + + cropped_image = cropped_image.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_image, + box=(margin, margin + ((context.size - new_y) // 2)), + ) + return apply_overlay_color(bg, UiColor.BLUE) + except OSError as e: + logger.error("Couldn't render thumbnail", path=context.path, error=type(e).__name__) + + return None + + +def _font_long_thumb(context: RendererContext) -> Image.Image | None: + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + context (RendererContext): The renderer context. + """ + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + try: + scaled_sizes: list[int] = [math.floor(x * (context.size / 256)) for x in FONT_SAMPLE_SIZES] + bg: Image.Image = Image.new("RGBA", (context.size, context.size), color="#00000000") + draw = ImageDraw.Draw(bg) + lines_of_padding: int = 2 + y_offset: float = 0.0 + + for font_size in scaled_sizes: + font: FreeTypeFont = ImageFont.truetype(context.path, size=font_size) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, + font=font, # pyright: ignore[reportArgumentType] + width=context.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] + return theme_fg_overlay(bg, use_alpha=False) + except OSError as e: + logger.error("[FontRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/iwork_renderer.py b/src/tagstudio/qt/previews/renderers/iwork_renderer.py new file mode 100644 index 000000000..9f5e18cc2 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/iwork_renderer.py @@ -0,0 +1,51 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +preview_thumbnail_path_within_zip: str = "preview.jpg" +quicklook_thumbnail_path_within_zip: str = "QuickLook/Thumbnail.jpg" + + +class IWorkRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Preview thumbnail + if zip_file.has_file_name(preview_thumbnail_path_within_zip): + file_data: bytes = zip_file.read(preview_thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + # Quicklook thumbnail + elif zip_file.has_file_name(quicklook_thumbnail_path_within_zip): + file_data = zip_file.read(quicklook_thumbnail_path_within_zip) + embedded_thumbnail = Image.open(BytesIO(file_data)) + else: + raise FileNotFoundError + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + except Exception as e: + logger.error("[IWorkRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py new file mode 100644 index 000000000..4d97d911e --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -0,0 +1,43 @@ +from io import BytesIO + +import structlog +from PIL import Image + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + +thumbnail_path_within_zip: str = "preview.png" + + +class KritaRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for a Krita file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes = zip_file.read(thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error("[KritaRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/open_doc_renderer.py b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py new file mode 100644 index 000000000..24eceb152 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py @@ -0,0 +1,46 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +thumbnail_path_within_zip: str = "Thumbnails/thumbnail.png" + + +class OpenDocRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an OpenDocument file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes = zip_file.read(thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error("[OpenDocRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/pdf_renderer.py b/src/tagstudio/qt/previews/renderers/pdf_renderer.py new file mode 100644 index 000000000..dc9abf781 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/pdf_renderer.py @@ -0,0 +1,72 @@ +from io import BytesIO + +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 +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class PDFRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a PDF file. + + Args: + context (RendererContext): The renderer context. + """ + try: + file: QFile = QFile(context.path) + success: bool = file.open( + QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser + ) + + if not success: + raise FileNotFoundError + + 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 *= context.size / page_size.height() + else: + page_size *= context.size / page_size.width() + + # Enlarge image for antialiasing + scale_factor = 2.5 + page_size *= scale_factor + + # Render image with no antialiasing for speed + render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() + render_options.setRenderFlags(QPdfDocumentRenderOptions.RenderFlag.TextAliased) + + # 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") # type: ignore[call-overload,unused-ignore] # pyright: ignore + rendered_thumbnail = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + return replace_transparent_pixels(rendered_thumbnail) + + except FileNotFoundError as e: + logger.error("[PDFRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py new file mode 100644 index 000000000..0683b7d45 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py @@ -0,0 +1,45 @@ +from io import BytesIO + +import structlog +from PIL import Image + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + +thumbnail_path_within_zip: str = "docProps/thumbnail.jpeg" + + +class PowerPointRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for a Microsoft PowerPoint file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes = zip_file.read(thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error( + "[PowerPointRenderer] Couldn't render thumbnail", path=context.path, error=e + ) + + return None diff --git a/src/tagstudio/qt/previews/renderers/raster_image_renderer.py b/src/tagstudio/qt/previews/renderers/raster_image_renderer.py new file mode 100644 index 000000000..e0a245d25 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/raster_image_renderer.py @@ -0,0 +1,47 @@ +import structlog +from PIL import ( + Image, + ImageOps, + UnidentifiedImageError, +) +from PIL.Image import DecompressionBombError + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class RasterImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + rendered_image: Image.Image = Image.open(context.path) + + # Convert image to RGBA + if rendered_image.mode != "RGB" and rendered_image.mode != "RGBA": + rendered_image = rendered_image.convert(mode="RGBA") + + if rendered_image.mode == "RGBA": + new_bg: Image.Image = Image.new("RGB", rendered_image.size, color="#1e1e1e") + new_bg.paste(rendered_image, mask=rendered_image.getchannel(3)) + rendered_image = new_bg + + return unwrap(ImageOps.exif_transpose(rendered_image)) + except ( + FileNotFoundError, + UnidentifiedImageError, + DecompressionBombError, + NotImplementedError, + ) as e: + logger.error("[ImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/raw_image_renderer.py b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py new file mode 100644 index 000000000..ab8cbaf68 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py @@ -0,0 +1,38 @@ +import rawpy +import structlog +from PIL import ( + Image, +) +from PIL.Image import DecompressionBombError +from rawpy._rawpy import LibRawFileUnsupportedError, LibRawIOError + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class RawImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a RAW image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + with rawpy.imread(str(context.path)) as raw: + rgb = raw.postprocess(use_camera_wb=True) + rendered_image: Image.Image = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + return rendered_image + except (DecompressionBombError, LibRawIOError, LibRawFileUnsupportedError) as e: + logger.error("[RawImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/stl_model_renderer.py b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py new file mode 100644 index 000000000..e10b5178c --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py @@ -0,0 +1,43 @@ +import structlog +from PIL import Image + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class STLModelRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for an STL file. + + Args: + context (RendererContext): The renderer context. + """ + # 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 None diff --git a/src/tagstudio/qt/previews/renderers/text_renderer.py b/src/tagstudio/qt/previews/renderers/text_renderer.py new file mode 100644 index 000000000..ca333e552 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/text_renderer.py @@ -0,0 +1,60 @@ +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 +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class TextRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + context (RendererContext): The renderer context. + """ + 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: + # Read text file + encoding = detect_char_encoding(context.path) + with open(context.path, encoding=encoding) as text_file: + text = text_file.read(256) + + rendered_image: Image.Image = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(rendered_image) + draw.text((16, 16), text, fill=fg_color) + return rendered_image + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + FileNotFoundError, + ) as e: + logger.error("Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/vector_image_renderer.py b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py new file mode 100644 index 000000000..6c53dcfa6 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py @@ -0,0 +1,56 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, + UnidentifiedImageError, +) +from PySide6.QtCore import QBuffer, Qt +from PySide6.QtGui import QImage, QPainter +from PySide6.QtSvg import QSvgRenderer + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class VectorImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a vector image, such as SVG. + + Args: + context (RendererContext): The renderer context. + """ + # Create an image to draw the svg to and a painter to do the drawing + q_image: QImage = QImage(context.size, context.size, QImage.Format.Format_ARGB32) + q_image.fill("#1e1e1e") + + # Create an svg renderer, then render to the painter + svg: QSvgRenderer = QSvgRenderer(str(context.path)) + + 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") # type: ignore[call-overload,unused-ignore] + + # Load the image from the buffer + rendered_image: Image.Image = Image.new( + "RGB", (context.size, context.size), color="#1e1e1e" + ) + rendered_image.paste(Image.open(BytesIO(buffer.data().data()))) + rendered_image = rendered_image.convert(mode="RGB") + + buffer.close() + return rendered_image diff --git a/src/tagstudio/qt/previews/renderers/video_renderer.py b/src/tagstudio/qt/previews/renderers/video_renderer.py new file mode 100644 index 000000000..51f259980 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/video_renderer.py @@ -0,0 +1,65 @@ +import math + +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 +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class VideoRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a video file. + + Args: + context (RendererContext): The renderer context. + """ + try: + if is_readable_video(context.path): + video: cv2.VideoCapture = cv2.VideoCapture(str(context.path), 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 + frame: MatLike | None = None + + 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) + return Image.fromarray(frame) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: + logger.error("[VideoRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/vtf_renderer.py b/src/tagstudio/qt/previews/renderers/vtf_renderer.py new file mode 100644 index 000000000..d92bf7cd7 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/vtf_renderer.py @@ -0,0 +1,31 @@ +import srctools +import structlog +from PIL import Image + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class VTFRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for VTF (Valve Texture Format) images. + + Uses the srctools library for reading VTF files. + + Args: + context (RendererContext): The renderer context. + """ + try: + with open(context.path, "rb") as vtf_file: + vtf = srctools.VTF.read(vtf_file) + return vtf.get(frame=0).to_PIL() + + except (ValueError, FileNotFoundError) as e: + logger.error("[VTFRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/vendored/blender_renderer.py b/src/tagstudio/qt/previews/vendored/blender_renderer.py deleted file mode 100644 index 012c1503d..000000000 --- a/src/tagstudio/qt/previews/vendored/blender_renderer.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 - -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - -# - - -## This file is a modified script that gets the thumbnail data stored in a blend file - - -import gzip -import os -import struct -from io import BufferedReader - -from PIL import Image, ImageOps - - -def blend_extract_thumb(path): - rend = b"REND" - test = b"TEST" - - blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 - - 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 - head = blendfile.read(12) - - if not head.startswith(b"BLENDER"): - blendfile.close() - return None, 0, 0 - - is_64_bit = head[7] == b"-"[0] - - # true for PPC, false for X86 - is_big_endian = head[8] == b"V"[0] - - # blender pre 2.5 had no thumbs - if head[9:11] <= b"24": - return None, 0, 0 - - sizeof_bhead = 24 if is_64_bit else 20 - int_endian = ">i" if is_big_endian else "