From eba55833929eec7049f70c6e3adf390ac4aae752 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 31 Dec 2024 03:27:27 -0800 Subject: [PATCH] refactor: modularize `file_attributes.py` --- .../src/qt/widgets/preview/file_attributes.py | 105 ++++---- .../src/qt/widgets/preview/preview_thumb.py | 252 +++++------------- tagstudio/src/qt/widgets/preview_panel.py | 13 +- 3 files changed, 126 insertions(+), 244 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 0fcc46ba..96151abc 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -6,13 +6,12 @@ import os import platform import typing from datetime import datetime as dt +from datetime import timedelta from pathlib import Path -import cv2 import structlog from humanfriendly import format_size -from PIL import ImageFont, UnidentifiedImageError -from PIL.Image import DecompressionBombError +from PIL import ImageFont from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( @@ -138,7 +137,7 @@ 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): + def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None): """Render the panel widgets with the newest data from the Library.""" logger.info("update_stats", selected=filepath) @@ -169,52 +168,68 @@ class FileAttributes(QWidget): # Translations.translate_qobject(self.open_file_action, "file.open_file") # self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) - # TODO: Do this all somewhere else, this is just here temporarily. - ext: str = filepath.suffix.lower() - try: - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" - f"{stats.get("width")} x {stats.get("height")} px" - ) - if MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): - try: + # Initialize the possible stat variables + 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", "") + if ext: + ext_display = ext.upper()[1:] + if filepath: + try: + file_size = format_size(filepath.stat().st_size) + + if MediaCategories.is_ext_in_category( + ext, MediaCategories.FONT_TYPES, mime_fallback=True + ): font = ImageFont.truetype(filepath) - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" - f"{font.getname()[0]} ({font.getname()[1]}) " - ) - except OSError: - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - ) - logger.info(f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}") - else: - self.dimensions_label.setText(f"{ext.upper()[1:]}") - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" + font_family = f"{font.getname()[0]} ({font.getname()[1]}) " + except (FileNotFoundError, OSError) as e: + logger.error( + "[FileAttributes] Could not process file stats", filepath=filepath, error=e ) - # self.update_date_label(filepath) - if not filepath.is_file(): - raise FileNotFoundError + # Format and display any stat variables + def add_newline(stats_label_text: str) -> str: + logger.info(stats_label_text[-2:]) + if stats_label_text and stats_label_text[-2:] != "\n": + return stats_label_text + "\n" + return stats_label_text - except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{ext.upper()[1:]}") - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - # self.update_date_label() - except ( - UnidentifiedImageError, - DecompressionBombError, # noqa: F821 - ) as e: - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - ) - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - # self.update_date_label(filepath) + if ext_display: + stats_label_text += ext_display + if file_size: + stats_label_text += f" • {file_size}" + elif file_size: + stats_label_text += file_size - return stats + if width_px_text and height_px_text: + stats_label_text = add_newline(stats_label_text) + stats_label_text += f"{width_px_text} x {height_px_text} px" + + if duration_text: + stats_label_text = add_newline(stats_label_text) + dur_str = str(timedelta(seconds=float(duration_text)))[:-7] + if dur_str.startswith("0:"): + dur_str = dur_str[2:] + if dur_str.startswith("0"): + dur_str = dur_str[1:] + stats_label_text += f"{dur_str}" + + if font_family: + stats_label_text = add_newline(stats_label_text) + stats_label_text += f"{font_family}" + + self.dimensions_label.setText(stats_label_text) def update_multi_selection(self, count: int): # Multiple Selected Items diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 23cb56fb..057551d9 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -51,13 +51,6 @@ class PreviewThumb(QWidget): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 - # self.panel_bg_color = ( - # Theme.COLOR_BG_DARK.value - # if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - # else Theme.COLOR_BG_LIGHT.value - # ) - - # self.image_container = QWidget() image_layout = QHBoxLayout(self) image_layout.setContentsMargins(0, 0, 0, 0) @@ -171,6 +164,22 @@ class PreviewThumb(QWidget): self.gif_buffer.close() self.preview_gif.hide() + def _display_fallback_image(self, filepath: Path, ext=str) -> dict: + """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, + ) + self.preview_img.show() + return self._update_image(filepath, ext) + def _update_image(self, filepath: Path, ext: str) -> dict: """Update the static image preview from a filepath.""" stats: dict = {} @@ -199,8 +208,8 @@ class PreviewThumb(QWidget): image = Image.open(str(filepath)) stats["width"] = image.width stats["height"] = image.height - except UnidentifiedImageError: - logger.error("welp", filepath=filepath) + except 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 ): @@ -219,51 +228,48 @@ class PreviewThumb(QWidget): self.preview_gif.movie().stop() self.gif_buffer.close() - 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) - anim_image: Image.Image = image - image_bytes_io: io.BytesIO = io.BytesIO() - anim_image.save( - image_bytes_io, - "GIF", - lossless=True, - save_all=True, - loop=0, - disposal=2, - ) - image_bytes_io.seek(0) - ba: bytes = image_bytes_io.read() - self.gif_buffer.setData(ba) - 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.switch_preview("image") - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, + 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) + anim_image: Image.Image = image + image_bytes_io: io.BytesIO = io.BytesIO() + anim_image.save( + image_bytes_io, + "GIF", + lossless=True, + save_all=True, + loop=0, + disposal=2, ) - self.preview_img.show() - return stats + image_bytes_io.seek(0) + ba: bytes = image_bytes_io.read() + self.gif_buffer.setData(ba) + movie = QMovie(self.gif_buffer, QByteArray()) + self.preview_gif.setMovie(movie) - # The animation has more than 1 frame, continue displaying it as an animation - self.switch_preview("animated") - self.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), + # 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(image.width, image.height), + QSize(image.width, image.height), + ) ) - ) - movie.start() - self.preview_gif.show() + movie.start() + self.preview_gif.show() + + stats["duration"] = movie.frameCount() // 60 + except UnidentifiedImageError as e: + logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) + return self._display_fallback_image(filepath, ext) - stats["duration"] = movie.frameCount() // 60 return stats def _update_video_legacy(self, filepath: Path) -> dict: @@ -302,20 +308,20 @@ class PreviewThumb(QWidget): self.media_player.show() self.media_player.play(filepath) - stats["duration"] = self.media_player.player.duration() + stats["duration"] = self.media_player.player.duration() * 1000 return stats - def update_preview(self, filepath: Path) -> dict: + def update_preview(self, filepath: Path, ext: str) -> dict: """Render a single file preview.""" stats: dict = {} - ext: str = filepath.suffix.lower() - stats["ext"] = ext + # Video (Legacy) if MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ) and is_readable_video(filepath): stats = self._update_video_legacy(filepath) + # Audio elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): @@ -329,11 +335,13 @@ class PreviewThumb(QWidget): 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, ext) @@ -360,142 +368,6 @@ class PreviewThumb(QWidget): return stats - # self.tag_callback = tag_callback if tag_callback else None - - # # update list of libraries - # self.fill_libs_widget(self.libs_layout) - - # self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - # self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - - # ratio = self.devicePixelRatio() - # self.thumb_renderer.render( - # time.time(), - # "", - # (512, 512), - # ratio, - # is_loading=True, - # update_on_ratio_change=True, - # ) - # if self.preview_img.is_connected: - # self.preview_img.clicked.disconnect() - # self.preview_img.show() - # self.preview_vid.stop() - # self.preview_vid.hide() - # self.media_player.hide() - # self.media_player.stop() - # self.preview_gif.hide() - # self.selected = list(self.driver.selected) - # self.add_field_button.setHidden(True) - - # reload entry and fill it into the grid again - # 1 Selected Entry - # selected_idx = self.driver.selected[0] - # item = self.driver.frame_content[selected_idx] - - # If a new selection is made, update the thumbnail and filepath. - # ratio = self.devicePixelRatio() - # self.thumb_renderer.render( - # time.time(), - # filepath, - # (512, 512), - # ratio, - # update_on_ratio_change=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) - - # # TODO: Do this all somewhere else, this is just here temporarily. - # ext: str = filepath.suffix.lower() - # try: - # if MediaCategories.is_ext_in_category( - # ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True - # ): - # if self.preview_gif.movie(): - # self.preview_gif.movie().stop() - # self.gif_buffer.close() - - # image: Image.Image = Image.open(filepath) - # anim_image: Image.Image = image - # image_bytes_io: io.BytesIO = io.BytesIO() - # anim_image.save( - # image_bytes_io, - # "GIF", - # lossless=True, - # save_all=True, - # loop=0, - # disposal=2, - # ) - # image_bytes_io.seek(0) - # ba: bytes = image_bytes_io.read() - - # self.gif_buffer.setData(ba) - # movie = QMovie(self.gif_buffer, QByteArray()) - # self.preview_gif.setMovie(movie) - # movie.start() - - # # self.resizeEvent( - # # QResizeEvent( - # # QSize(image.width, image.height), - # # QSize(image.width, image.height), - # # ) - # # ) - # self.preview_img.hide() - # self.preview_vid.hide() - # self.preview_gif.show() - - # image = None - # if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RASTER_TYPES): - # image = Image.open(str(filepath)) - # elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES): - # try: - # with rawpy.imread(str(filepath)) as raw: - # rgb = raw.postprocess() - # image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") - # except ( - # rawpy._rawpy.LibRawIOError, - # rawpy._rawpy.LibRawFileUnsupportedError, - # ): - # pass - # elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES): - # self.media_player.show() - # self.media_player.play(filepath) - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.VIDEO_TYPES - # ) and is_readable_video(filepath): - # video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) - # video.set( - # cv2.CAP_PROP_POS_FRAMES, - # (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - # ) - # success, frame = video.read() - # frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - # image = Image.fromarray(frame) - # if success: - # self.preview_img.hide() - # self.preview_vid.play(str(filepath), QSize(image.width, image.height)) - # # self.resizeEvent( - # # QResizeEvent( - # # QSize(image.width, image.height), - # # QSize(image.width, image.height), - # # ) - # # ) - # self.preview_vid.show() - - # except (FileNotFoundError, cv2.error, UnidentifiedImageError, DecompressionBombError) as e: - # if self.preview_img.is_connected: - # self.preview_img.clicked.disconnect() - # self.preview_img.clicked.connect(lambda checked=False, pth=filepath: open_file(pth)) - # self.preview_img.is_connected = True - # logger.error(f"Preview thumb error: {e} - {filepath}") - - # return stats - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 self.update_image_size((self.size().width(), self.size().height())) return super().resizeEvent(event) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 638eacb1..cb8a50f8 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -82,12 +82,6 @@ class PreviewPanel(QWidget): ) self.driver.frame_content[grid_idx] = result - # def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - # # self.thumb.update_image_size( - # # (self.thumb.image_container.size().width(), self.thumb.image_container.size().height()) - # # ) - # return super().resizeEvent(event) - def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" # No Items Selected @@ -101,10 +95,11 @@ class PreviewPanel(QWidget): elif len(self.driver.selected) == 1: entry: Entry = items[0] filepath: Path = self.lib.library_dir / entry.path + ext: str = filepath.suffix.lower() - stats: dict = self.thumb.update_preview(filepath) - logger.info(stats) - self.file_attrs.update_stats(filepath) + stats: dict = self.thumb.update_preview(filepath, ext) + logger.info("stats", stats=stats, ext=ext) + self.file_attrs.update_stats(filepath, ext, stats) self.file_attrs.update_date_label(filepath) # TODO: Render regular single selection # TODO: Return known attributes from thumb, and give those to field_attrs