mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-01 15:49:09 +00:00
refactor: modularize file_attributes.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user