refactor(preview_thumb): mvc split (#978)

* refactor: basic split

* fix: renaming and usage test didn't work for the tests

* fix: tests

* refactor: restructuring

* refactor: further separation and lots of related changes

* refactor: remove last reference to a widget from controller

* refactor: address todo

* fix: failing tests and mypy compaint

* refactor: move control logic to controller

* refactor: more readable button style

* refactor: move existing code to view

* refactor: move existing code to controller

* fix: imports

* refactor: make methods private by default

* refactor: privatise fields

* refactor: reduce code duplication

* refactor: consolidate and sort display methods

* refactor: remove needless setting of delete action text

* refactor: extract control logic from _display_file

* refactor: use MediaType for __switch_preview

* fix: import in preview_panel_view.py

* refactor: remove unnecessary wrapper on view side

* refactor: move image data retrieval to control side

* refactor: move audio / video specific code to the respective method

* refactor: remove superfluos methods

* refactor: this and that

* refactor: use proper type instead of dict for file stats

* refactor: extract gif parsing to controller

* refactor: extract video size extraction to controller

* doc: add rule of thumb to Qt MVC Style Guide

* doc: change rule of thumb from note to tip

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
This commit is contained in:
Jann Stute
2025-08-01 08:46:46 +02:00
committed by GitHub
parent 192af25f6f
commit a7d98e765b
10 changed files with 519 additions and 494 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -879,7 +879,7 @@ class QtDriver(DriverMixin, QObject):
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(

View File

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

View File

@@ -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.enums import Theme
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 @@ class PreviewPanelView(QWidget):
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 @@ class PreviewPanelView(QWidget):
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 @@ class PreviewPanelView(QWidget):
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

View File

@@ -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 @@ if typing.TYPE_CHECKING:
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 @@ class FileAttributes(QWidget):
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 @@ class FileAttributes(QWidget):
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 @@ class FileAttributes(QWidget):
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"):

View File

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