diff --git a/STYLE.md b/STYLE.md index 29e770e1f..81e0cf54f 100644 --- a/STYLE.md +++ b/STYLE.md @@ -78,5 +78,7 @@ Observe the following key aspects of this example: - Defines the interface the callbacks - Enforces that UI events be handled +> [!TIP] +> A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file. [^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/). diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index f13ce2a0d..ade4e8588 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -80,6 +80,21 @@ class MediaCategory: name: str is_iana: bool = False + def contains(self, ext: str, mime_fallback: bool = False) -> bool: + """Check if an extension is a member of this MediaCategory. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + if ext in self.extensions: + return True + elif mime_fallback and self.is_iana: + mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if mime_type is not None and mime_type.startswith(self.media_type.value): + return True + return False + class MediaCategories: """Contain pre-made MediaCategory objects as well as methods to interact with them.""" @@ -635,16 +650,11 @@ def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]: mime_fallback (bool): Flag to guess MIME type if no set matches are made. """ media_types: set[MediaType] = set() - # mime_guess: bool = False for cat in MediaCategories.ALL_CATEGORIES: - if ext in cat.extensions: + if cat.contains(ext, mime_fallback): media_types.add(cat.media_type) - elif mime_fallback and cat.is_iana: - mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] - if mime_type and mime_type.startswith(cat.media_type.value): - media_types.add(cat.media_type) - # mime_guess = True + return media_types @staticmethod @@ -656,10 +666,4 @@ def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = media_cat (MediaCategory): The MediaCategory to to check for extension membership. mime_fallback (bool): Flag to guess MIME type if no set matches are made. """ - if ext in media_cat.extensions: - return True - elif mime_fallback and media_cat.is_iana: - mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] - if mime_type and mime_type.startswith(media_cat.media_type.value): - return True - return False + return media_cat.contains(ext, mime_fallback) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py new file mode 100644 index 000000000..17b1592f9 --- /dev/null +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -0,0 +1,155 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import io +from pathlib import Path +from typing import TYPE_CHECKING + +import cv2 +import rawpy +import structlog +from PIL import Image, UnidentifiedImageError +from PIL.Image import DecompressionBombError +from PySide6.QtCore import QSize + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.media_types import MediaCategories +from tagstudio.qt.helpers.file_opener import open_file +from tagstudio.qt.helpers.file_tester import is_readable_video +from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData + +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) +Image.MAX_IMAGE_PIXELS = None + + +class PreviewThumb(PreviewThumbView): + __current_file: Path + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__(library, driver) + + self.__driver: QtDriver = driver + + def __get_image_stats(self, filepath: Path) -> FileAttributeData: + """Get width and height of an image as dict.""" + stats = FileAttributeData() + ext = filepath.suffix.lower() + + if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") + stats.width = image.width + stats.height = image.height + except ( + rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] + rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] + FileNotFoundError, + ): + pass + elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): + try: + image = Image.open(str(filepath)) + stats.width = image.width + stats.height = image.height + except ( + DecompressionBombError, + FileNotFoundError, + NotImplementedError, + UnidentifiedImageError, + ) as e: + logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) + elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): + pass # TODO + + return stats + + def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: + """Loads an animated image and returns gif data and size, if successful.""" + ext = filepath.suffix.lower() + + try: + image: Image.Image = Image.open(filepath) + + if ext == ".apng": + image_bytes_io = io.BytesIO() + image.save( + image_bytes_io, + "GIF", + lossless=True, + save_all=True, + loop=0, + disposal=2, + ) + image.close() + image_bytes_io.seek(0) + return (image_bytes_io.read(), (image.width, image.height)) + else: + image.close() + with open(filepath, "rb") as f: + return (f.read(), (image.width, image.height)) + + except (UnidentifiedImageError, FileNotFoundError) as e: + logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) + return None + + def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: + video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + return (success, QSize(image.width, image.height)) + + def display_file(self, filepath: Path) -> FileAttributeData: + """Render a single file preview.""" + self.__current_file = filepath + + ext = filepath.suffix.lower() + + # Video + if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( + filepath + ): + size: QSize | None = None + try: + success, size = self.__get_video_res(str(filepath)) + if not success: + size = None + except cv2.error as e: + logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) + + return self._display_video(filepath, size) + # Audio + elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): + return self._display_audio(filepath) + # Animated Images + elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): + if (ret := self.__get_gif_data(filepath)) and ( + stats := self._display_gif(ret[0], ret[1]) + ) is not None: + return stats + else: + self._display_image(filepath) + return self.__get_image_stats(filepath) + # Other Types (Including Images) + else: + self._display_image(filepath) + return self.__get_image_stats(filepath) + + def _open_file_action_callback(self): + open_file(self.__current_file) + + def _open_explorer_action_callback(self): + open_file(self.__current_file, file_manager=True) + + def _delete_action_callback(self): + if bool(self.__current_file): + self.__driver.delete_files_callback(self.__current_file) + + def _button_wrapper_callback(self): + open_file(self.__current_file) diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py index 8984dcef3..57a941f69 100644 --- a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -1,3 +1,6 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import typing from warnings import catch_warnings diff --git a/src/tagstudio/qt/helpers/qbutton_wrapper.py b/src/tagstudio/qt/helpers/qbutton_wrapper.py index 96b9b6d32..5539dc7cf 100644 --- a/src/tagstudio/qt/helpers/qbutton_wrapper.py +++ b/src/tagstudio/qt/helpers/qbutton_wrapper.py @@ -13,6 +13,8 @@ class QPushButtonWrapper(QPushButton): the warning that is triggered by disconnecting a signal that is not currently connected. """ + is_connected: bool + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.is_connected = False diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 1861df27e..2479a3959 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -879,7 +879,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.main_window.preview_panel.thumb_media_player_stop() + self.main_window.preview_panel.preview_thumb.media_player.stop() if delete_file(self.lib.library_dir / f): self.main_window.status_bar.showMessage( Translations.format( diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py new file mode 100644 index 000000000..ea2739f08 --- /dev/null +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -0,0 +1,314 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import time +from pathlib import Path +from typing import TYPE_CHECKING, override + +import structlog +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt +from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.media_types import MediaType +from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle +from tagstudio.qt.platform_strings import open_file_str, trash_term +from tagstudio.qt.translations import Translations +from tagstudio.qt.widgets.media_player import MediaPlayer +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData +from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer + +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class PreviewThumbView(QWidget): + """The Preview Panel Widget.""" + + __img_button_size: tuple[int, int] + __image_ratio: float + + def __init__(self, library: Library, driver: "QtDriver") -> None: + super().__init__() + + self.__img_button_size = (266, 266) + self.__image_ratio = 1.0 + + self.__image_layout = QStackedLayout(self) + self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) + self.__image_layout.setContentsMargins(0, 0, 0, 0) + + open_file_action = QAction(Translations["file.open_file"], self) + open_file_action.triggered.connect(self._open_file_action_callback) + open_explorer_action = QAction(open_file_str(), self) + open_explorer_action.triggered.connect(self._open_explorer_action_callback) + delete_action = QAction( + Translations.format("trash.context.singular", trash_term=trash_term()), + self, + ) + delete_action.triggered.connect(self._delete_action_callback) + + self.__button_wrapper = QPushButton() + self.__button_wrapper.setMinimumSize(*self.__img_button_size) + self.__button_wrapper.setFlat(True) + self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.__button_wrapper.addAction(open_file_action) + self.__button_wrapper.addAction(open_explorer_action) + self.__button_wrapper.addAction(delete_action) + self.__button_wrapper.clicked.connect(self._button_wrapper_callback) + + # In testing, it didn't seem possible to center the widgets directly + # on the QStackedLayout. Adding sublayouts allows us to center the widgets. + self.__preview_img_page = QWidget() + self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper) + + self.__preview_gif = QLabel() + self.__preview_gif.setMinimumSize(*self.__img_button_size) + self.__preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.__preview_gif.setCursor(Qt.CursorShape.ArrowCursor) + self.__preview_gif.addAction(open_file_action) + self.__preview_gif.addAction(open_explorer_action) + self.__preview_gif.addAction(delete_action) + self.__gif_buffer: QBuffer = QBuffer() + + self.__preview_gif_page = QWidget() + self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif) + + self.__media_player = MediaPlayer(driver) + self.__media_player.addAction(open_file_action) + self.__media_player.addAction(open_explorer_action) + self.__media_player.addAction(delete_action) + + # Need to watch for this to resize the player appropriately. + self.__media_player.player.hasVideoChanged.connect( + self.__media_player_video_changed_callback + ) + + self.__mp_max_size = QSize(*self.__img_button_size) + + self.__media_player_page = QWidget() + self.__stacked_page_setup(self.__media_player_page, self.__media_player) + + self.__thumb_renderer = ThumbRenderer(library) + self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) + self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) + + self.__image_layout.addWidget(self.__preview_img_page) + self.__image_layout.addWidget(self.__preview_gif_page) + self.__image_layout.addWidget(self.__media_player_page) + + self.setMinimumSize(*self.__img_button_size) + + self.hide_preview() + + def _open_file_action_callback(self): + raise NotImplementedError + + def _open_explorer_action_callback(self): + raise NotImplementedError + + def _delete_action_callback(self): + raise NotImplementedError + + def _button_wrapper_callback(self): + raise NotImplementedError + + def __media_player_video_changed_callback(self, video: bool) -> None: + self.__update_image_size((self.size().width(), self.size().height())) + + def __thumb_renderer_updated_callback( + self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path + ) -> None: + self.__button_wrapper.setIcon(img) + self.__mp_max_size = img.size() + + def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None: + self.__image_ratio = ratio + self.__update_image_size( + ( + self.size().width(), + self.size().height(), + ) + ) + + def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: + layout = QHBoxLayout(page) + layout.addWidget(widget) + layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) + layout.setContentsMargins(0, 0, 0, 0) + page.setLayout(layout) + + def __update_image_size(self, size: tuple[int, int]) -> None: + adj_width: float = size[0] + adj_height: float = size[1] + # Landscape + if self.__image_ratio > 1: + adj_height = size[0] * (1 / self.__image_ratio) + # Portrait + elif self.__image_ratio <= 1: + adj_width = size[1] * self.__image_ratio + + if adj_width > size[0]: + adj_height = adj_height * (size[0] / adj_width) + adj_width = size[0] + elif adj_height > size[1]: + adj_width = adj_width * (size[1] / adj_height) + adj_height = size[1] + + adj_size = QSize(int(adj_width), int(adj_height)) + + self.__img_button_size = (int(adj_width), int(adj_height)) + self.__button_wrapper.setMaximumSize(adj_size) + self.__button_wrapper.setIconSize(adj_size) + self.__preview_gif.setMaximumSize(adj_size) + self.__preview_gif.setMinimumSize(adj_size) + + if not self.__media_player.player.hasVideo(): + # ensure we do not exceed the thumbnail size + mp_width = ( + adj_size.width() + if adj_size.width() < self.__mp_max_size.width() + else self.__mp_max_size.width() + ) + mp_height = ( + adj_size.height() + if adj_size.height() < self.__mp_max_size.height() + else self.__mp_max_size.height() + ) + mp_size = QSize(mp_width, mp_height) + self.__media_player.setMinimumSize(mp_size) + self.__media_player.setMaximumSize(mp_size) + else: + # have video, so just resize as normal + self.__media_player.setMaximumSize(adj_size) + self.__media_player.setMinimumSize(adj_size) + + proxy_style = RoundedPixmapStyle(radius=8) + self.__preview_gif.setStyle(proxy_style) + self.__media_player.setStyle(proxy_style) + m = self.__preview_gif.movie() + if m: + m.setScaledSize(adj_size) + + def __switch_preview(self, preview: MediaType | None) -> None: + if preview in [MediaType.AUDIO, MediaType.VIDEO]: + self.__media_player.show() + self.__image_layout.setCurrentWidget(self.__media_player_page) + else: + self.__media_player.stop() + self.__media_player.hide() + + if preview in [MediaType.IMAGE, MediaType.AUDIO]: + self.__button_wrapper.show() + self.__image_layout.setCurrentWidget( + self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page + ) + else: + self.__button_wrapper.hide() + + if preview == MediaType.IMAGE_ANIMATED: + self.__preview_gif.show() + self.__image_layout.setCurrentWidget(self.__preview_gif_page) + else: + if self.__preview_gif.movie(): + self.__preview_gif.movie().stop() + self.__gif_buffer.close() + self.__preview_gif.hide() + + def __render_thumb(self, filepath: Path) -> None: + self.__thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + + def __update_media_player(self, filepath: Path) -> int: + """Display either audio or video. + + Returns the duration of the audio / video. + """ + self.__media_player.play(filepath) + return self.__media_player.player.duration() * 1000 + + def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: + self.__switch_preview(MediaType.VIDEO) + stats = FileAttributeData(duration=self.__update_media_player(filepath)) + + if size is not None: + stats.width = size.width() + stats.height = size.height() + + self.__image_ratio = stats.width / stats.height + self.resizeEvent( + QResizeEvent( + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), + ) + ) + + return stats + + def _display_audio(self, filepath: Path) -> FileAttributeData: + self.__switch_preview(MediaType.AUDIO) + self.__render_thumb(filepath) + return FileAttributeData(duration=self.__update_media_player(filepath)) + + def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None: + """Update the animated image preview from a filepath.""" + stats = FileAttributeData() + + # Ensure that any movie and buffer from previous animations are cleared. + if self.__preview_gif.movie(): + self.__preview_gif.movie().stop() + self.__gif_buffer.close() + + stats.width = size[0] + stats.height = size[1] + + self.__image_ratio = stats.width / stats.height + + self.__gif_buffer.setData(gif_data) + movie = QMovie(self.__gif_buffer, QByteArray()) + self.__preview_gif.setMovie(movie) + + # If the animation only has 1 frame, it isn't animated and shouldn't be treated as such + if movie.frameCount() <= 1: + return None + + # The animation has more than 1 frame, continue displaying it as an animation + self.__switch_preview(MediaType.IMAGE_ANIMATED) + self.resizeEvent( + QResizeEvent( + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), + ) + ) + movie.start() + + stats.duration = movie.frameCount() // 60 + + return stats + + def _display_image(self, filepath: Path): + """Renders the given file as an image, no matter its media type.""" + self.__switch_preview(MediaType.IMAGE) + self.__render_thumb(filepath) + + def hide_preview(self) -> None: + """Completely hide the file preview.""" + self.__switch_preview(None) + + @override + def resizeEvent(self, event: QResizeEvent) -> None: + self.__update_image_size((self.size().width(), self.size().height())) + return super().resizeEvent(event) + + @property + def media_player(self) -> MediaPlayer: + return self.__media_player diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 9d9fa6bd9..8bb819d44 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -1,3 +1,6 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import traceback import typing from pathlib import Path @@ -16,10 +19,10 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.preview.field_containers import FieldContainers -from tagstudio.qt.widgets.preview.file_attributes import FileAttributes -from tagstudio.qt.widgets.preview.preview_thumb import PreviewThumb +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData, FileAttributes if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -128,9 +131,6 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() - def thumb_media_player_stop(self): - self.__thumb.media_player.stop() - def set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. @@ -160,7 +160,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): filepath: Path = self.lib.library_dir / entry.path if update_preview: - stats: dict = self.__thumb.update_preview(filepath) + stats: FileAttributeData = self.__thumb.display_file(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) self._fields.update_from_entry(entry_id) @@ -206,3 +206,7 @@ def _file_attributes_widget(self) -> FileAttributes: # needed for the tests def field_containers_widget(self) -> FieldContainers: # needed for the tests """Getter for the field containers widget.""" return self._fields + + @property + def preview_thumb(self) -> PreviewThumb: + return self.__thumb diff --git a/src/tagstudio/qt/widgets/preview/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index 694ef91b1..842386dd6 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -6,6 +6,7 @@ import os import platform import typing +from dataclasses import dataclass from datetime import datetime as dt from datetime import timedelta from pathlib import Path @@ -29,6 +30,13 @@ logger = structlog.get_logger(__name__) +@dataclass +class FileAttributeData: + width: int | None = None + height: int | None = None + duration: int | None = None + + class FileAttributes(QWidget): """The Preview Panel Widget.""" @@ -131,10 +139,10 @@ def update_date_label(self, filepath: Path | None = None) -> None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None, stats: dict | None = None): + def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): """Render the panel widgets with the newest data from the Library.""" if not stats: - stats = {} + stats = FileAttributeData() if not filepath: self.layout().setSpacing(0) @@ -179,16 +187,9 @@ def update_stats(self, filepath: Path | None = None, stats: dict | None = None): stats_label_text = "" ext_display: str = "" file_size: str = "" - width_px_text: str = "" - height_px_text: str = "" - duration_text: str = "" font_family: str = "" # Attempt to populate the stat variables - width_px_text = stats.get("width", "") - height_px_text = stats.get("height", "") - duration_text = stats.get("duration", "") - font_family = stats.get("font_family", "") ext_display = ext.upper()[1:] or filepath.stem.upper() if filepath: try: @@ -217,14 +218,14 @@ def add_newline(stats_label_text: str) -> str: elif file_size: stats_label_text += file_size - if width_px_text and height_px_text: + if stats.width is not None and stats.height is not None: stats_label_text = add_newline(stats_label_text) - stats_label_text += f"{width_px_text} x {height_px_text} px" + stats_label_text += f"{stats.width} x {stats.height} px" - if duration_text: + if stats.duration is not None: stats_label_text = add_newline(stats_label_text) try: - dur_str = str(timedelta(seconds=float(duration_text)))[:-7] + dur_str = str(timedelta(seconds=float(stats.duration)))[:-7] if dur_str.startswith("0:"): dur_str = dur_str[2:] if dur_str.startswith("0"): diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py deleted file mode 100644 index 53b94a223..000000000 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ /dev/null @@ -1,460 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import io -import time -from pathlib import Path -from typing import TYPE_CHECKING, override -from warnings import catch_warnings - -import cv2 -import rawpy -import structlog -from PIL import Image, UnidentifiedImageError -from PIL.Image import DecompressionBombError -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt -from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories, MediaType -from tagstudio.qt.helpers.file_opener import FileOpenerHelper, open_file -from tagstudio.qt.helpers.file_tester import is_readable_video -from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper -from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle -from tagstudio.qt.platform_strings import open_file_str, trash_term -from tagstudio.qt.translations import Translations -from tagstudio.qt.widgets.media_player import MediaPlayer -from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer - -if TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) -Image.MAX_IMAGE_PIXELS = None - - -class PreviewThumb(QWidget): - """The Preview Panel Widget.""" - - def __init__(self, library: Library, driver: "QtDriver") -> None: - super().__init__() - - self.is_connected = False - self.lib = library - self.driver: QtDriver = driver - - self.img_button_size: tuple[int, int] = (266, 266) - self.image_ratio: float = 1.0 - - self.image_layout = QStackedLayout(self) - self.image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) - self.image_layout.setContentsMargins(0, 0, 0, 0) - - self.opener: FileOpenerHelper | None = None - self.open_file_action = QAction(Translations["file.open_file"], self) - self.open_explorer_action = QAction(open_file_str(), self) - self.delete_action = QAction( - Translations.format("trash.context.ambiguous", trash_term=trash_term()), - self, - ) - - self.preview_img = QPushButtonWrapper() - self.preview_img.setMinimumSize(*self.img_button_size) - self.preview_img.setFlat(True) - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.addAction(self.open_file_action) - self.preview_img.addAction(self.open_explorer_action) - self.preview_img.addAction(self.delete_action) - - # In testing, it didn't seem possible to center the widgets directly - # on the QStackedLayout. Adding sublayouts allows us to center the widgets. - self.preview_img_page = QWidget() - self._stacked_page_setup(self.preview_img_page, self.preview_img) - - self.preview_gif = QLabel() - self.preview_gif.setMinimumSize(*self.img_button_size) - self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) - self.preview_gif.addAction(self.open_file_action) - self.preview_gif.addAction(self.open_explorer_action) - self.preview_gif.addAction(self.delete_action) - self.gif_buffer: QBuffer = QBuffer() - - self.preview_gif_page = QWidget() - self._stacked_page_setup(self.preview_gif_page, self.preview_gif) - - self.media_player = MediaPlayer(driver) - self.media_player.addAction(self.open_file_action) - self.media_player.addAction(self.open_explorer_action) - self.media_player.addAction(self.delete_action) - - # Need to watch for this to resize the player appropriately. - self.media_player.player.hasVideoChanged.connect(self._has_video_changed) - - self.mp_max_size = QSize(*self.img_button_size) - - self.media_player_page = QWidget() - self._stacked_page_setup(self.media_player_page, self.media_player) - - self.thumb_renderer = ThumbRenderer(self.lib) - self.thumb_renderer.updated.connect( - lambda ts, i, s: ( - self.preview_img.setIcon(i), - self._set_mp_max_size(i.size()), - ) - ) - self.thumb_renderer.updated_ratio.connect( - lambda ratio: ( - self.set_image_ratio(ratio), - self.update_image_size( - ( - self.size().width(), - self.size().height(), - ), - ratio, - ), - ) - ) - - self.image_layout.addWidget(self.preview_img_page) - self.image_layout.addWidget(self.preview_gif_page) - self.image_layout.addWidget(self.media_player_page) - - self.setMinimumSize(*self.img_button_size) - - self.hide_preview() - - def _set_mp_max_size(self, size: QSize) -> None: - self.mp_max_size = size - - def _has_video_changed(self, video: bool) -> None: - self.update_image_size((self.size().width(), self.size().height())) - - def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: - layout = QHBoxLayout(page) - layout.addWidget(widget) - layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) - layout.setContentsMargins(0, 0, 0, 0) - page.setLayout(layout) - - def set_image_ratio(self, ratio: float) -> None: - self.image_ratio = ratio - - def update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: - if ratio: - self.set_image_ratio(ratio) - - adj_width: float = size[0] - adj_height: float = size[1] - # Landscape - if self.image_ratio > 1: - adj_height = size[0] * (1 / self.image_ratio) - # Portrait - elif self.image_ratio <= 1: - adj_width = size[1] * self.image_ratio - - if adj_width > size[0]: - adj_height = adj_height * (size[0] / adj_width) - adj_width = size[0] - elif adj_height > size[1]: - adj_width = adj_width * (size[1] / adj_height) - adj_height = size[1] - - adj_size = QSize(int(adj_width), int(adj_height)) - - self.img_button_size = (int(adj_width), int(adj_height)) - self.preview_img.setMaximumSize(adj_size) - self.preview_img.setIconSize(adj_size) - self.preview_gif.setMaximumSize(adj_size) - self.preview_gif.setMinimumSize(adj_size) - - if not self.media_player.player.hasVideo(): - # ensure we do not exceed the thumbnail size - mp_width = ( - adj_size.width() - if adj_size.width() < self.mp_max_size.width() - else self.mp_max_size.width() - ) - mp_height = ( - adj_size.height() - if adj_size.height() < self.mp_max_size.height() - else self.mp_max_size.height() - ) - mp_size = QSize(mp_width, mp_height) - self.media_player.setMinimumSize(mp_size) - self.media_player.setMaximumSize(mp_size) - else: - # have video, so just resize as normal - self.media_player.setMaximumSize(adj_size) - self.media_player.setMinimumSize(adj_size) - - proxy_style = RoundedPixmapStyle(radius=8) - self.preview_gif.setStyle(proxy_style) - self.media_player.setStyle(proxy_style) - m = self.preview_gif.movie() - if m: - m.setScaledSize(adj_size) - - def get_preview_size(self) -> tuple[int, int]: - return ( - self.size().width(), - self.size().height(), - ) - - def switch_preview(self, preview: str) -> None: - if preview in ["audio", "video"]: - self.media_player.show() - self.image_layout.setCurrentWidget(self.media_player_page) - else: - self.media_player.stop() - self.media_player.hide() - - if preview in ["image", "audio"]: - self.preview_img.show() - self.image_layout.setCurrentWidget( - self.preview_img_page if preview == "image" else self.media_player_page - ) - else: - self.preview_img.hide() - - if preview == "animated": - self.preview_gif.show() - self.image_layout.setCurrentWidget(self.preview_gif_page) - else: - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - self.preview_gif.hide() - - def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: - """Renders the given file as an image, no matter its media type. - - Useful for fallback scenarios. - """ - self.switch_preview("image") - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - return self._update_image(filepath) - - def _update_image(self, filepath: Path) -> dict[str, int]: - """Update the static image preview from a filepath.""" - stats: dict[str, int] = {} - ext = filepath.suffix.lower() - self.switch_preview("image") - - image: Image.Image | None = None - - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ): - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess() - image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") - stats["width"] = image.width - stats["height"] = image.height - except ( - rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] - FileNotFoundError, - ): - pass - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True - ): - try: - image = Image.open(str(filepath)) - stats["width"] = image.width - stats["height"] = image.height - except ( - DecompressionBombError, - FileNotFoundError, - NotImplementedError, - UnidentifiedImageError, - ) as e: - logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True - ): - pass - - return stats - - def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: - """Update the animated image preview from a filepath.""" - stats: dict[str, int] = {} - - # Ensure that any movie and buffer from previous animations are cleared. - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - - try: - image: Image.Image = Image.open(filepath) - stats["width"] = image.width - stats["height"] = image.height - - self.update_image_size((image.width, image.height), image.width / image.height) - if ext == ".apng": - image_bytes_io = io.BytesIO() - image.save( - image_bytes_io, - "GIF", - lossless=True, - save_all=True, - loop=0, - disposal=2, - ) - image.close() - image_bytes_io.seek(0) - self.gif_buffer.setData(image_bytes_io.read()) - else: - image.close() - with open(filepath, "rb") as f: - self.gif_buffer.setData(f.read()) - movie = QMovie(self.gif_buffer, QByteArray()) - self.preview_gif.setMovie(movie) - - # If the animation only has 1 frame, display it like a normal image. - if movie.frameCount() <= 1: - self._display_fallback_image(filepath, ext) - return stats - - # The animation has more than 1 frame, continue displaying it as an animation - self.switch_preview("animated") - self.resizeEvent( - QResizeEvent( - QSize(stats["width"], stats["height"]), - QSize(stats["width"], stats["height"]), - ) - ) - movie.start() - - stats["duration"] = movie.frameCount() // 60 - except (UnidentifiedImageError, FileNotFoundError) as e: - logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) - return self._display_fallback_image(filepath, ext) - - return stats - - def _get_video_res(self, filepath: str) -> tuple[bool, QSize]: - video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - return (success, QSize(image.width, image.height)) - - def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: - stats: dict[str, int] = {} - - self.media_player.play(filepath) - - if type == MediaType.VIDEO: - try: - success, size = self._get_video_res(str(filepath)) - if success: - self.update_image_size( - (size.width(), size.height()), size.width() / size.height() - ) - self.resizeEvent( - QResizeEvent( - QSize(size.width(), size.height()), - QSize(size.width(), size.height()), - ) - ) - - stats["width"] = size.width() - stats["height"] = size.height() - - except cv2.error as e: - logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - - self.switch_preview("video" if type == MediaType.VIDEO else "audio") - stats["duration"] = self.media_player.player.duration() * 1000 - return stats - - def update_preview(self, filepath: Path) -> dict[str, int]: - """Render a single file preview.""" - ext = filepath.suffix.lower() - stats: dict[str, int] = {} - - # Video - if MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ) and is_readable_video(filepath): - stats = self._update_media(filepath, MediaType.VIDEO) - - # Audio - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - ): - self._update_image(filepath) - stats = self._update_media(filepath, MediaType.AUDIO) - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - - # Animated Images - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True - ): - stats = self._update_animation(filepath, ext) - - # Other Types (Including Images) - else: - # TODO: Get thumb renderer to return this stuff to pass on - stats = self._update_image(filepath) - - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - - with catch_warnings(record=True): - self.preview_img.clicked.disconnect() - self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path)) - self.preview_img.is_connected = True - - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - - self.opener = FileOpenerHelper(filepath) - self.open_file_action.triggered.connect(self.opener.open_file) - self.open_explorer_action.triggered.connect(self.opener.open_explorer) - - with catch_warnings(record=True): - self.delete_action.triggered.disconnect() - - self.delete_action.setText( - Translations.format("trash.context.singular", trash_term=trash_term()) - ) - self.delete_action.triggered.connect( - lambda checked=False, f=filepath: self.driver.delete_files_callback(f) - ) - self.delete_action.setEnabled(bool(filepath)) - - return stats - - def hide_preview(self) -> None: - """Completely hide the file preview.""" - self.switch_preview("") - - @override - def resizeEvent(self, event: QResizeEvent) -> None: - self.update_image_size((self.size().width(), self.size().height())) - return super().resizeEvent(event)