From d3a043202853007103a4b6c600eaf863179c5db4 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:05:11 -0700 Subject: [PATCH] feat: dynamically load preview renderers --- src/tagstudio/core/media_types.py | 1 + src/tagstudio/previews/base_preview.py | 9 +- src/tagstudio/previews/file_renderer.py | 86 +++++++++---------- src/tagstudio/previews/renderers/audio.py | 1 + src/tagstudio/previews/renderers/blender.py | 1 + src/tagstudio/previews/renderers/code.py | 1 + src/tagstudio/previews/renderers/ebook.py | 1 + .../previews/renderers/raw_image_preview.py | 1 + .../previews/renderers/vector_image.py | 1 + src/tagstudio/previews/renderers/video.py | 1 + 10 files changed, 54 insertions(+), 49 deletions(-) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 647aa270..9bd2062a 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -86,6 +86,7 @@ class MediaTypes(metaclass=SanitizedAttr): @staticmethod def register(group: MediaTypeGroup) -> None: + # TODO: Allow for updating existing attributes instead of always creating a new one. setattr(MediaTypes, group.name_key.replace(".", "_"), group) diff --git a/src/tagstudio/previews/base_preview.py b/src/tagstudio/previews/base_preview.py index 4d909bd2..4ff08ed6 100644 --- a/src/tagstudio/previews/base_preview.py +++ b/src/tagstudio/previews/base_preview.py @@ -12,10 +12,15 @@ from tagstudio.core.enums import Theme class BasePreview: - """A base preview renderer class.""" + """A base preview renderer class. + + Attributes: + media_type_name (str): Used for identifying the MediaType. + priority (int): Render priority over other Preview classes. + """ - # The attribute name used for identifying the MediaType used with the preview renderer. media_type_name: str + priority: int = 50 def __init__(self) -> None: pass diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py index 8ef7dc30..b1d61366 100644 --- a/src/tagstudio/previews/file_renderer.py +++ b/src/tagstudio/previews/file_renderer.py @@ -1,10 +1,13 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT import contextlib import hashlib +import importlib +import inspect import math +import pkgutil from copy import deepcopy from pathlib import Path @@ -25,27 +28,6 @@ from tagstudio.core.utils.types import unwrap from tagstudio.previews.base_preview import BasePreview from tagstudio.previews.effects import apply_overlay_color from tagstudio.previews.gradients import four_corner_gradient -from tagstudio.previews.renderers.apple_embedded_preview import AppleEmbeddedPreview -from tagstudio.previews.renderers.archive import ArchivePreview -from tagstudio.previews.renderers.audio import AudioPreview -from tagstudio.previews.renderers.blender import BlenderPreview -from tagstudio.previews.renderers.clip_studio import ClipStudioPaintPreview -from tagstudio.previews.renderers.code import CodePreview -from tagstudio.previews.renderers.ebook import EbookPreview -from tagstudio.previews.renderers.font import FontPreview -from tagstudio.previews.renderers.krita_preview import KritaPreview -from tagstudio.previews.renderers.medibang_paint import MediBangPaintPreview -from tagstudio.previews.renderers.open_document_preview import OpenDocumentPreview -from tagstudio.previews.renderers.paint_dot_net import PaintDotNetPreview -from tagstudio.previews.renderers.pdf import PdfPreview -from tagstudio.previews.renderers.powerpoint_preview import PowerPointPreview -from tagstudio.previews.renderers.raster_image import RasterImagePreview -from tagstudio.previews.renderers.raw_image_preview import RawImagePreview -from tagstudio.previews.renderers.text import ( - TextPreview, -) -from tagstudio.previews.renderers.vector_image import VectorImagePreview -from tagstudio.previews.renderers.video import VideoPreview from tagstudio.qt.app_settings import ( DEFAULT_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES, @@ -63,11 +45,45 @@ Image.MAX_IMAGE_PIXELS = None logger = structlog.get_logger(__name__) +# TODO: Allow user-created preview renderers from an external directory. +def _get_preview_renderers() -> list[type[BasePreview]]: + """Discover all BasePreview subclasses in src/tagstudio/previews/renderers. + + Classes are sorted by their priority (descending), falling back to alphabetical order. + """ + found: list[type[BasePreview]] = [] + from tagstudio.previews import renderers # pyright: ignore + + for module_info in sorted(pkgutil.iter_modules(renderers.__path__), key=lambda m: m.name): + module = importlib.import_module(f"{renderers.__name__}.{module_info.name}") + for _, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, BasePreview) + and obj is not BasePreview + and obj.__module__ == module.__name__ + ): + found.append(obj) + break + else: + logger.error("[FileRenderer] Could not load Preview Renderer", name=obj.__name__) + + found.sort(key=lambda cls: cls.priority, reverse=True) + return found + + class FileRenderer: """A class for rendering image previews and thumbnails from files.""" rm: ResourceManager = ResourceManager() cached_img_ext: str = ".webp" + preview_renderers: list[type[BasePreview]] = _get_preview_renderers() + for pr in preview_renderers: + logger.info( + "[FileRenderer] Loaded Preview Renderer", + name=pr.__name__, + media_type=pr.media_type_name, + priority=pr.priority, + ) def __init__(self, library: Library, settings: AppSettings) -> None: super().__init__() @@ -717,34 +733,10 @@ class FileRenderer: image: Image.Image | None = None is_savable_type: bool = True - # TODO: Dynamically import these from the renderers/ directory at runtime, - # And allow user-created ones from an external directory. - previews: list[type[BasePreview]] = [ - RawImagePreview, - VectorImagePreview, - RasterImagePreview, - VideoPreview, - AudioPreview, - CodePreview, - TextPreview, - PdfPreview, - ArchivePreview, - FontPreview, - BlenderPreview, - EbookPreview, - KritaPreview, - OpenDocumentPreview, - ClipStudioPaintPreview, - MediBangPaintPreview, - PaintDotNetPreview, - PowerPointPreview, - AppleEmbeddedPreview, - ] - if filepath and filepath.is_file(): try: ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower() - for preview in previews: + for preview in FileRenderer.preview_renderers: try: media_type: MediaTypeGroup = getattr(MediaTypes, preview.media_type_name) if media_type.contains(ext, Context.RENDER): diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py index 4800c00c..2c622e48 100644 --- a/src/tagstudio/previews/renderers/audio.py +++ b/src/tagstudio/previews/renderers/audio.py @@ -30,6 +30,7 @@ logger = structlog.get_logger(__name__) class AudioPreview(BasePreview): media_type_name = "audio" + priority = 70 @override @classmethod diff --git a/src/tagstudio/previews/renderers/blender.py b/src/tagstudio/previews/renderers/blender.py index 71ef8b4a..3e339aed 100644 --- a/src/tagstudio/previews/renderers/blender.py +++ b/src/tagstudio/previews/renderers/blender.py @@ -18,6 +18,7 @@ logger = structlog.get_logger(__name__) class BlenderPreview(BasePreview): media_type_name = "blender" + priority = 40 @override @classmethod diff --git a/src/tagstudio/previews/renderers/code.py b/src/tagstudio/previews/renderers/code.py index 55187094..fdf764bf 100644 --- a/src/tagstudio/previews/renderers/code.py +++ b/src/tagstudio/previews/renderers/code.py @@ -90,6 +90,7 @@ class CodeStyle(Style): class CodePreview(BasePreview): media_type_name = "code" + priority = 60 @override @classmethod diff --git a/src/tagstudio/previews/renderers/ebook.py b/src/tagstudio/previews/renderers/ebook.py index f1b30e43..7815f0f3 100644 --- a/src/tagstudio/previews/renderers/ebook.py +++ b/src/tagstudio/previews/renderers/ebook.py @@ -23,6 +23,7 @@ logger = structlog.get_logger(__name__) class EbookPreview(BasePreview): media_type_name = "ebook" + priority = 40 @override @classmethod diff --git a/src/tagstudio/previews/renderers/raw_image_preview.py b/src/tagstudio/previews/renderers/raw_image_preview.py index f6df8776..5a758cde 100644 --- a/src/tagstudio/previews/renderers/raw_image_preview.py +++ b/src/tagstudio/previews/renderers/raw_image_preview.py @@ -21,6 +21,7 @@ logger = structlog.get_logger(__name__) class RawImagePreview(BasePreview): media_type_name = "image.raw" + priority = 60 @override @classmethod diff --git a/src/tagstudio/previews/renderers/vector_image.py b/src/tagstudio/previews/renderers/vector_image.py index 1883d3b2..b6969d2c 100644 --- a/src/tagstudio/previews/renderers/vector_image.py +++ b/src/tagstudio/previews/renderers/vector_image.py @@ -25,6 +25,7 @@ logger = structlog.get_logger(__name__) class VectorImagePreview(BasePreview): media_type_name = "image.vector" + priority = 70 @override @classmethod diff --git a/src/tagstudio/previews/renderers/video.py b/src/tagstudio/previews/renderers/video.py index 930ea323..64106f42 100644 --- a/src/tagstudio/previews/renderers/video.py +++ b/src/tagstudio/previews/renderers/video.py @@ -21,6 +21,7 @@ logger = structlog.get_logger(__name__) class VideoPreview(BasePreview): media_type_name = "video" + priority = 70 @override @classmethod