diff --git a/pyproject.toml b/pyproject.toml index 15e53ca0e3..3715d9fa80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ include = ["src/tagstudio", "tests"] # Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md # Some rules exclusive to basedpyright: https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/ reportAny = false +reportExplicitAny = false reportIgnoreCommentWithoutRule = false reportImplicitStringConcatenation = false reportImportCycles = false diff --git a/src/tagstudio/previews/base_preview.py b/src/tagstudio/previews/base_preview.py index 773716e326..b8444bd5f6 100644 --- a/src/tagstudio/previews/base_preview.py +++ b/src/tagstudio/previews/base_preview.py @@ -18,11 +18,13 @@ class BasePreview: Attributes: _fallback_icon (str): The name of the fallback icon resource to use, if needed. + has_small_variant (bool): Whether `is_small` renders a different image than the preview. media_type_name (str): Used for identifying the MediaType. priority (int): Render priority over other Preview classes. """ _fallback_icon: str = "" + has_small_variant: bool = False media_type_name: str priority: int = 50 diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py index 70b59034f0..b3de56cb3c 100644 --- a/src/tagstudio/previews/file_renderer.py +++ b/src/tagstudio/previews/file_renderer.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: MIT -import contextlib import hashlib import importlib import inspect @@ -71,6 +70,7 @@ class FileRenderer: _rm: ResourceManager = ResourceManager() _cached_img_ext: str = ".webp" + _failed_render_filenames: set[Path] = set() # Used to skip loading icon for default icon files _preview_renderers: list[type[BasePreview]] = _get_preview_renderers() # Map of media group keys to preview renderer priorities. @@ -500,6 +500,19 @@ def _apply_edge( return im + @staticmethod + def _cached_thumb_name(filepath: Path, date_modified: float | None = None) -> Path: + """Return the cache filename for a file's thumbnail. + + Args: + filepath (Path): The path of the file to check for a cached image of. + date_modified (float | None): The file's stored date modified timestamp, if it has one. + """ + mod_time: str = "" if date_modified is None else str(date_modified) + hashable_str: str = f"{str(filepath)}{mod_time}" + hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8) + return Path(f"{hash_value}{FileRenderer._cached_img_ext}") + def render( self, cache: CacheManager | None, @@ -510,6 +523,7 @@ def render( theme: Theme = Theme.DARK, is_loading: bool = False, is_thumb: bool = False, + date_modified: float | None = None, ): """Render a thumbnail or preview image. @@ -522,6 +536,7 @@ def render( theme (Theme): A theme enum to determine the light/dark theme. is_loading (bool): Is this a loading graphic? is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants. + date_modified (float | None): The date modified timestamp of the file. Used for caches. update_on_ratio_change (bool): Should an updated ratio signal be sent? """ render_mask_and_edge: bool = True @@ -555,12 +570,13 @@ def render_unlinked( ) return im - def render_ignored(size: tuple[int, int], im: Image.Image) -> Image.Image: + def render_icon_overlay( + size: tuple[int, int], im: Image.Image, icon: Image.Image + ) -> Image.Image: icon_ratio: float = 5 padding_factor = 18 im_ = im - icon: Image.Image = self._rm.ignored icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) im_.paste( im=icon.resize( @@ -589,16 +605,25 @@ def fetch_cached_image(file_name: Path): ) return image + def render_fallback_preview() -> Image.Image: + """Render a fallback preview image. + + Will be an existing cached thumbnail if one exists, or the default preview icon, or else + the unlinked icon if the file is unlinked and there's no cached thumbnail. + """ + if not filepath.exists(): + cached_name = FileRenderer._cached_thumb_name(filepath, date_modified) + return fetch_cached_image(cached_name) or render_unlinked((512, 512), 2) + if filepath.is_dir(): + return render_unlinked((512, 512), 2) + return render_default((512, 512), 2) + image: Image.Image | None = None # Try to get a non-loading thumbnail for the grid. if not is_loading and is_thumb and filepath and filepath != Path("."): # Attempt to retrieve cached image from disk - mod_time: str = "" - with contextlib.suppress(Exception): - mod_time = str(filepath.stat().st_mtime_ns) - hashable_str: str = f"{str(filepath)}{mod_time}" - hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8) - file_name = Path(f"{hash_value}{FileRenderer._cached_img_ext}") + is_unlinked = not filepath.exists() + file_name = FileRenderer._cached_thumb_name(filepath, date_modified) image = fetch_cached_image(file_name) if not image and self.settings.generate_thumbs: @@ -621,13 +646,15 @@ def fetch_cached_image(file_name: Path): is_small=is_thumb, cache_filename=file_name, ) + if not image: + FileRenderer._failed_render_filenames.add(file_name) # If the normal renderer failed, fallback the defaults # (with native non-cached sizing!) if not image: image = ( render_unlinked((scaled_size, scaled_size), dpi_scale) - if not filepath.exists() or filepath.is_dir() + if is_unlinked or filepath.is_dir() else render_default((scaled_size, scaled_size), dpi_scale) ) render_mask_and_edge = False @@ -649,29 +676,52 @@ def fetch_cached_image(file_name: Path): if image and Ignore.matcher.is_ignored( filepath.relative_to(unwrap(self.lib.library_dir)) ): - image = render_ignored((scaled_size, scaled_size), image) + image = render_icon_overlay((scaled_size, scaled_size), image, self._rm.ignored) except TypeError: pass - # A loading thumbnail (cached in memory) + # Cached thumbnails of unlinked files get an unlinked icon overlay + if image and render_mask_and_edge and is_unlinked: + image = render_icon_overlay( + (scaled_size, scaled_size), image, self._rm.unlinked_stat + ) + + # A loading graphic, or a placeholder for a preview elif is_loading: - # Initialize "Loading" thumbnail - loading_thumb: Image.Image = self._get_icon( - "thumb_loading", theme_color, (scaled_size, scaled_size), theme, dpi_scale - ) - image = loading_thumb.resize( - (scaled_size, scaled_size), resample=Image.Resampling.BILINEAR - ) + if not is_thumb: + renderer = FileRenderer._find_renderer(filepath) + if renderer is None and is_compressed(filepath): + renderer = FileRenderer._find_renderer(Path(filepath.stem)) + file_name = FileRenderer._cached_thumb_name(filepath, date_modified) + if ( + not renderer + or not filepath.is_file() + or file_name in FileRenderer._failed_render_filenames + ): + image = render_fallback_preview() + elif renderer.has_small_variant: + # The thumb uses a different variant, so stay blank until the preview renders + image = Image.new("RGBA", (scaled_size, scaled_size)) + else: + image = fetch_cached_image(file_name) + if not image: + # Initialize "Loading" thumbnail + loading_thumb: Image.Image = self._get_icon( + "thumb_loading", theme_color, (scaled_size, scaled_size), theme, dpi_scale + ) + image = loading_thumb.resize( + (scaled_size, scaled_size), resample=Image.Resampling.BILINEAR + ) # A full preview image (never cached) elif not is_thumb: image = self._render(cache, filepath, size, dpi_scale, theme) - if not image: - image = ( - render_unlinked((512, 512), 2) - if not filepath.exists() or filepath.is_dir() - else render_default((512, 512), 2) - ) + file_name = FileRenderer._cached_thumb_name(filepath, date_modified) + if image: + FileRenderer._failed_render_filenames.discard(file_name) + else: + FileRenderer._failed_render_filenames.add(file_name) + image = render_fallback_preview() image = image.convert("RGBA") # If the image couldn't be rendered, use a default media image. @@ -713,9 +763,9 @@ def _render( if filepath and filepath.is_file(): try: - preview = FileRenderer._find_preview(filepath) - if preview: - image = preview.render( + renderer = FileRenderer._find_renderer(filepath) + if renderer: + image = renderer.render( filepath=filepath, is_small=is_small, theme=theme, @@ -738,20 +788,20 @@ def _render( return image @staticmethod - def _find_preview(filepath: Path) -> type[BasePreview] | None: + def _find_renderer(filepath: Path) -> type[BasePreview] | None: """Return the highest priority preview renderer for a file's extension, if any.""" ext = MediaTypes.get_ext(filepath) if filepath.suffix else filepath.stem.lower() - for preview in FileRenderer._preview_renderers: - media_type: MediaTypeGroup | None = getattr(MediaTypes, preview.media_type_name, None) + for renderer in FileRenderer._preview_renderers: + media_type: MediaTypeGroup | None = getattr(MediaTypes, renderer.media_type_name, None) if media_type is None: logger.error( f"[FileRenderer] " - f"Attribute '{preview.media_type_name}' not registered with MediaTypes", + f"Attribute '{renderer.media_type_name}' not registered with MediaTypes", ) return None if media_type.contains(ext, RENDER): - return preview + return renderer return None @@ -760,7 +810,7 @@ def _render_decompressed( filepath: Path, is_small: bool, theme: Theme, size: tuple[int, int], dpi_scale: float ) -> Image.Image | None: """Render a compressed file with the preview renderer for the uncompressed file.""" - preview = FileRenderer._find_preview(Path(filepath.stem)) + preview = FileRenderer._find_renderer(Path(filepath.stem)) if preview is None: return None diff --git a/src/tagstudio/previews/media_probe.py b/src/tagstudio/previews/media_probe.py new file mode 100644 index 0000000000..2653aaf3c5 --- /dev/null +++ b/src/tagstudio/previews/media_probe.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from functools import lru_cache +from pathlib import Path +from typing import Any + +import ffmpeg +import structlog +from PIL import Image, ImageSequence + +from tagstudio.core.media_types import MediaTypes +from tagstudio.core.query_lang.file_groups import SEARCH +from tagstudio.core.utils.stat import get_date_modified +from tagstudio.previews.vendored.probe import probe + +logger = structlog.get_logger(__name__) + + +def probe_file(filepath: Path) -> dict[str, Any] | None: + """Return ffprobe's output for a file, or `None` if it can't be probed. + + Results are cached by path and date modified timestamp. + """ + try: + mod_time = get_date_modified(filepath) + except OSError: + return None + return _cached_probe(filepath, mod_time) + + +@lru_cache(maxsize=256) +def _cached_probe(filepath: Path, _mod_time: float) -> dict[str, Any] | None: + try: + return probe(filepath) + except ffmpeg.Error: + return None + + +def _readable_streams(filepath: Path) -> list[dict[str, Any]]: + """Return a file's streams, or an empty list if it can't be probed or is DRM-protected.""" + streams: list[dict[str, Any]] = (probe_file(filepath) or {}).get("streams", []) + if any(stream.get("codec_tag_string") in ["drma", "drms", "drmi"] for stream in streams): + return [] + return streams + + +def get_video_stream(filepath: Path) -> dict[str, Any] | None: + """Return a file's first readable video stream, skipping embedded cover art.""" + return next( + ( + stream + for stream in _readable_streams(filepath) + if stream.get("codec_type") == "video" + and not stream.get("disposition", {}).get("attached_pic") + ), + None, + ) + + +def has_audio_stream(filepath: Path) -> bool: + """Return whether a file has a readable audio stream.""" + return any(stream.get("codec_type") == "audio" for stream in _readable_streams(filepath)) + + +def is_readable_video(filepath: Path | str) -> bool: + """Test if a video is in a readable format and has a picture to display. + + Examples of unreadable videos include files with undetermined codecs, DRM-protected content, + and audio-only files. + + Args: + filepath (Path | str): The filepath of the video to check. + """ + return get_video_stream(Path(filepath)) is not None + + +def _is_audio_or_video(ext: str) -> bool: + return MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains("audio", ext, SEARCH) + + +def get_animation_duration(image: Image.Image) -> float: + """Return the total duration of an animated image's frames, in seconds.""" + duration_ms = 0 + for frame in ImageSequence.Iterator(image): + frame.load() # NOTE: Some formats only read a frame's duration once it's loaded. + duration_ms += frame.info.get("duration", 0) + return duration_ms / 1000 + + +def get_duration(filepath: Path) -> float | None: + """Return a media file's duration in seconds, or `None` if it doesn't have one. + + Results are cached by path and date modified timestamp. + """ + try: + mod_time = get_date_modified(filepath) + except OSError: + return None + return _cached_duration(filepath, mod_time) + + +@lru_cache(maxsize=1024) +def _cached_duration(filepath: Path, _mod_time: float) -> float | None: + ext = filepath.suffix.lower() + try: + if _is_audio_or_video(ext): + format_info = (probe_file(filepath) or {}).get("format", {}) + return float(format_info["duration"]) if "duration" in format_info else None + if MediaTypes.contains("image.animated", ext, SEARCH): + with Image.open(filepath) as image: + if getattr(image, "is_animated", False): + return get_animation_duration(image) + except Exception as e: + logger.error("[MediaProbe] Could not read duration", filepath=filepath, error=e) + return None diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py index fefb768e9a..6391351f47 100644 --- a/src/tagstudio/previews/renderers/audio.py +++ b/src/tagstudio/previews/renderers/audio.py @@ -2,11 +2,9 @@ # SPDX-License-Identifier: MIT -import math from io import BytesIO from pathlib import Path from typing import override -from warnings import catch_warnings import numpy as np import structlog @@ -61,9 +59,7 @@ def render( dpi_scale: float, ) -> Image | None: - return cls.audio_album_thumb(filepath) or cls.audio_waveform_thumb( - filepath, theme, size, dpi_scale - ) + return cls.audio_album_thumb(filepath) or cls.audio_waveform_thumb(filepath, theme, size) @staticmethod def audio_album_thumb(filepath: Path) -> Image | None: @@ -107,62 +103,43 @@ def audio_album_thumb(filepath: Path) -> Image | None: return image @staticmethod - def audio_waveform_thumb( - filepath: Path, theme: Theme, size: tuple[int, int], dpi_scale: float - ) -> Image | None: + def audio_waveform_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image | None: """Render a waveform image from an audio file. Args: filepath (Path): The path of the file. theme (Theme): The system color theme. size (int): The size of the thumbnail. - dpi_scale (float): The screen pixel ratio. """ # BASE_SCALE used for drawing on a larger image and resampling down # to provide an antialiased effect. base_scale: int = 2 - samples_per_bar: int = 3 size_scaled: int = size[0] * base_scale # TODO: Allow for non-square sizes allow_small_min: bool = False im: Image | None = None try: - bar_count: int = min(math.floor((size[0] // dpi_scale) / 5), 64) + bar_count: int = 32 audio = AudioSegment.from_file(filepath, filepath.suffix.lower()[1:]) # pyright: ignore[reportUnknownVariableType] - data = np.frombuffer(buffer=audio._data, dtype=np.int16) - data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) - bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 - line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale - bar_height: float = (size_scaled) - (size_scaled // bar_margin) - - count: int = 0 - maximum_item: int = 0 - max_array: list[int] = [] - highest_line: int = 0 - - for i in range(-1, len(data_indices)): - d = data[math.ceil(data_indices[i]) - 1] - if count < samples_per_bar: - count = count + 1 - with catch_warnings(record=True): - if abs(d) > maximum_item: - maximum_item = int(abs(d)) - else: - max_array.append(maximum_item) - - if maximum_item > highest_line: - highest_line = maximum_item - - maximum_item = 0 - count = 1 - - line_ratio = max(highest_line / bar_height, 1) + data = np.frombuffer(buffer=audio._data, dtype=f" Image | None: + if not is_readable_video(filepath) and has_audio_stream(filepath): + return AudioPreview.render(filepath, is_small, theme, size, dpi_scale) return video_thumb(filepath) @@ -64,9 +67,6 @@ def video_thumb(filepath: Path) -> Image | None: try: if is_readable_video(filepath): video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) - # TODO: Move this check to is_readable_video() - if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: - raise cv2.error("File is invalid or has 0 frames") video.set( cv2.CAP_PROP_POS_FRAMES, (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), diff --git a/src/tagstudio/previews/video_tester.py b/src/tagstudio/previews/video_tester.py deleted file mode 100644 index fbd78b40b5..0000000000 --- a/src/tagstudio/previews/video_tester.py +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: MIT - - -from pathlib import Path - -import ffmpeg - -from tagstudio.previews.vendored.probe import probe - - -def is_readable_video(filepath: Path | str): - """Test if a video is in a readable format. - - Examples of unreadable videos include files with undetermined codecs and DRM-protected content. - - Args: - filepath (Path | str): The filepath of the video to check. - """ - try: - result = probe(Path(filepath)) - if not result: - return False - for stream in result["streams"]: - # DRM check - if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]: - return False - except ffmpeg.Error: - return False - return True diff --git a/src/tagstudio/qt/cache_manager.py b/src/tagstudio/qt/cache_manager.py index 5508a8ba23..dbcc761064 100644 --- a/src/tagstudio/qt/cache_manager.py +++ b/src/tagstudio/qt/cache_manager.py @@ -129,22 +129,24 @@ def get_file_path(self, file_name: Path) -> Path | None: def save_image(self, image: Image.Image, file_name: Path, mode: str = "RGBA"): """Save an image to the cache.""" - with self._lock as _lock: - cache_folder: CacheFolder = self._get_current_folder() - file_path = cache_folder.path / file_name - try: - image.save(file_path, mode=mode, quality=self.img_quality) - - size = file_path.stat().st_size + cache_folder: CacheFolder = self._get_current_folder() + file_path = cache_folder.path / file_name + try: + # Encoding happens outside the lock so cache lookups aren't blocked by it. + image.save(file_path, mode=mode, quality=self.img_quality) + + size = file_path.stat().st_size + with self._lock as _lock: cache_folder.size += size self.current_size += size self._cull_folders() - except FileNotFoundError: - logger.warn( - "[CacheManager] Failed to save cached image, was the folder deleted on disk?", - folder=file_path, - ) - if not cache_folder.path.exists(): + except FileNotFoundError: + logger.warn( + "[CacheManager] Failed to save cached image, was the folder deleted on disk?", + folder=file_path, + ) + with self._lock as _lock: + if not cache_folder.path.exists() and cache_folder in self.folders: self.folders.remove(cache_folder) def _create_folder(self) -> CacheFolder: diff --git a/src/tagstudio/qt/controllers/inspector.py b/src/tagstudio/qt/controllers/inspector.py index a9bfefd291..3b7416d5d2 100644 --- a/src/tagstudio/qt/controllers/inspector.py +++ b/src/tagstudio/qt/controllers/inspector.py @@ -256,9 +256,12 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._current_stats = None if update_preview: - stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath) - self._current_stats = stats - self.layout().file_attrs.update_stats(filepath, stats, entry) + self.layout().preview_thumb.display_file(filepath, entry.date_modified) + if self._current_stats is None: + self._current_stats = FileAttributeData() + self.layout().file_attrs.update_stats( + filepath, self._current_stats, entry, reserve_stat_space=True + ) self.layout().file_attrs.update_date_label(entry) self.layout().containers.update_from_entry(entry_id) self._set_selection_callback() diff --git a/src/tagstudio/qt/controllers/preview_thumb.py b/src/tagstudio/qt/controllers/preview_thumb.py index 32201b9869..c159910765 100644 --- a/src/tagstudio/qt/controllers/preview_thumb.py +++ b/src/tagstudio/qt/controllers/preview_thumb.py @@ -6,16 +6,17 @@ import math import time from enum import Enum, auto +from functools import partial from pathlib import Path from typing import TYPE_CHECKING, override -import cv2 import rawpy import structlog from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError -from PySide6.QtCore import QBuffer, QByteArray, QRectF, QSize, Qt, Signal +from PySide6.QtCore import QBuffer, QByteArray, QRectF, QSize, Qt, QThreadPool, Signal from PySide6.QtGui import QBrush, QMovie, QPainter, QPixmap, QResizeEvent +from PySide6.QtMultimedia import QMediaPlayer from PySide6.QtWidgets import QWidget from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore @@ -23,7 +24,12 @@ from tagstudio.core.query_lang.file_groups import SEARCH from tagstudio.core.utils.decompression import decompressed from tagstudio.core.utils.types import unwrap -from tagstudio.previews.video_tester import is_readable_video +from tagstudio.previews.media_probe import ( + get_duration, + get_video_stream, + has_audio_stream, + is_readable_video, +) from tagstudio.qt.mixed.file_attributes import FileAttributeData from tagstudio.qt.mixed.media_player import MediaPlayer from tagstudio.qt.qt_file_renderer import QtFileRenderer @@ -42,7 +48,7 @@ class _PreviewType(Enum): - """Enum for which of the Inspector's stacked pages should be shown for a file.""" + """Enum for which of the Inspector's stacked pages should be displayed for a file.""" ANIMATED = auto() AUDIO = auto() @@ -54,19 +60,25 @@ class _PreviewType(Enum): class PreviewThumb(QWidget): """The file preview thumbnail widget.""" + _animation_loaded = Signal(Path, object) + _video_probed = Signal(Path, object, object) check_ffmpeg = Signal(bool) stats_updated = Signal(Path, FileAttributeData) def __init__(self, driver: QtDriver): super().__init__() self._driver = driver - self._thumb_renderer = QtFileRenderer(driver.lib, driver.settings) + self._file_renderer = QtFileRenderer(driver.lib, driver.settings) + self._render_pool = QThreadPool(self) + self._render_pool.setMaxThreadCount(1) + self._animation_buffer: QBuffer = QBuffer() + self._animation_size: QSize = QSize() self._current_file: Path | None = None - self._gif_buffer: QBuffer = QBuffer() - self._gif_size: QSize = QSize() + self._date_modified: float | None = None self._image_ratio: float = 1.0 self._preview_size: tuple[int, int] = _DEFAULT_PREVIEW_SIZE + self._render_timestamp: float = 0.0 self._rendered_res: tuple[int, int] = (0, 0) self._should_render_on_resize: bool = False self._source_pixmap: QPixmap = QPixmap() @@ -88,15 +100,13 @@ def _connect_callbacks(self) -> None: view.delete_action.triggered.connect(self._delete_action_callback) view.button_wrapper.clicked.connect(self._button_wrapper_callback) - # QMediaPlayer loads duration asynchronously after setSource(). - view.media_player.player.durationChanged.connect( - self._media_player_duration_changed_callback - ) + view.media_player.player.errorOccurred.connect(self._media_player_error_callback) # Need to watch for this to resize the player appropriately. view.media_player.player.hasVideoChanged.connect(self._media_player_video_changed_callback) - self._thumb_renderer.updated.connect(self._thumb_renderer_updated_callback) - self._thumb_renderer.updated_ratio.connect(self._thumb_renderer_updated_ratio_callback) + self._file_renderer.updated.connect(self._file_renderer_updated_callback) + self._animation_loaded.connect(self._animation_loaded_callback) + self._video_probed.connect(self._video_probed_callback) def _open_file_action_callback(self) -> None: if self._current_file: @@ -123,20 +133,55 @@ def _button_wrapper_callback(self) -> None: def _media_player_video_changed_callback(self) -> None: self._update_image_size((self.size().width(), self.size().height())) - def _media_player_duration_changed_callback(self, duration_ms: int) -> None: + def _media_player_error_callback(self, _error: QMediaPlayer.Error, _message: str) -> None: + """Callback that fires when the media player encounters an error and can't play. + + Used to display a fallback image for videos if they can't play. + """ filepath = self.layout().media_player.filepath - if filepath is None or duration_ms <= 0: + if ( + filepath is not None + and filepath == self._current_file + and MediaTypes.contains("video", filepath.suffix.lower(), SEARCH) + ): + self._display_image(filepath, is_new_file=True) + + def _video_probed_callback( + self, filepath: Path, preview: _PreviewType | None, stats: FileAttributeData | None + ) -> None: + """Callback that fires once a video's probe results are back.""" + if filepath != self._current_file: return - self.stats_updated.emit( - filepath, - FileAttributeData(duration=duration_ms // 1000), - ) + if preview == _PreviewType.VIDEO and stats is not None: + # Display the video's size and duration + if stats.width and stats.height: + self._image_ratio = stats.width / stats.height + self._update_image_size((self.size().width(), self.size().height())) + self.stats_updated.emit(filepath, stats) + elif preview == _PreviewType.AUDIO: + # Display the album art/waveform and duration + self._display_audio(filepath, is_new_file=True, start_player=False) + else: + self._display_image(filepath, is_new_file=True) - def _thumb_renderer_updated_callback( - self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path + def _animation_loaded_callback( + self, filepath: Path, data: tuple[bytes, tuple[int, int]] | None ) -> None: + if filepath != self._current_file: + return + + if not (data and self._play_animation(*data)): + self._display_image(filepath, is_new_file=False) + + def _file_renderer_updated_callback( + self, timestamp: float, img: QPixmap, _size: QSize, path: Path + ) -> None: + if path != self._current_file or timestamp < self._render_timestamp: + return + self._source_pixmap = img + self._image_ratio = img.width() / img.height() self._update_image_size((self.size().width(), self.size().height()), refresh_icon=True) def _update_icon(self) -> None: @@ -163,9 +208,6 @@ def _update_icon(self) -> None: pixmap.setDevicePixelRatio(ratio) button.setIcon(pixmap) - def _thumb_renderer_updated_ratio_callback(self, ratio: float) -> None: - self._image_ratio = ratio - def _update_image_size(self, size: tuple[int, int], refresh_icon: bool = False) -> None: view = self.layout() scaled_width: float = size[0] @@ -192,20 +234,20 @@ def _update_image_size(self, size: tuple[int, int], refresh_icon: bool = False) if refresh_icon or view.button_wrapper.iconSize() != scaled_size: view.button_wrapper.setIconSize(scaled_size) self._update_icon() - view.preview_gif.setMaximumSize(scaled_size) - view.preview_gif.setMinimumSize(scaled_size) + view.preview_animation.setMaximumSize(scaled_size) + view.preview_animation.setMinimumSize(scaled_size) view.media_player.setMaximumSize(scaled_size) view.media_player.setMinimumSize(scaled_size) - for page in (view.preview_img_page, view.preview_gif_page, view.media_player_page): + for page in (view.preview_img_page, view.preview_animation_page, view.media_player_page): unwrap(page.layout()).activate() - movie = view.preview_gif.movie() + movie = view.preview_animation.movie() if movie: - gif_max = max(self._gif_size.width(), self._gif_size.height()) + max_side = max(self._animation_size.width(), self._animation_size.height()) display_max = max(scaled_size.width(), scaled_size.height()) * self.devicePixelRatio() - is_upscaling = gif_max < display_max + is_upscaling = max_side < display_max movie.setScaledSize(QSize() if is_upscaling else scaled_size) def _switch_preview(self, preview: _PreviewType | None) -> None: @@ -231,109 +273,149 @@ def _switch_preview(self, preview: _PreviewType | None) -> None: view.button_wrapper.hide() if preview == _PreviewType.ANIMATED: - view.preview_gif.show() - view.setCurrentWidget(view.preview_gif_page) - view.preview_gif_page.raise_() + view.preview_animation.show() + view.setCurrentWidget(view.preview_animation_page) + view.preview_animation_page.raise_() else: - if view.preview_gif.movie(): - view.preview_gif.movie().stop() - self._gif_buffer.close() - view.preview_gif.hide() + if view.preview_animation.movie(): + view.preview_animation.movie().stop() + self._animation_buffer.close() + view.preview_animation.hide() - def _render_thumb(self, filepath: Path) -> None: + def _render_preview(self, filepath: Path) -> None: self._should_render_on_resize = True + screen_size = self.screen().size() self._rendered_res = ( - math.ceil(self._preview_size[0] * _THUMB_SIZE_FACTOR), - math.ceil(self._preview_size[1] * _THUMB_SIZE_FACTOR), + min(math.ceil(self._preview_size[0] * _THUMB_SIZE_FACTOR), screen_size.width()), + min(math.ceil(self._preview_size[1] * _THUMB_SIZE_FACTOR), screen_size.height()), ) + self._render_timestamp = time.time() # TODO: Make driver update the cache manager reference here instead of passing the driver. - self._thumb_renderer.render( + self._render_pool.start( + partial( + self._file_renderer.render, + self._driver.cache_manager, + self._render_timestamp, + filepath, + self._rendered_res, + self.devicePixelRatio(), + date_modified=self._date_modified, + ) + ) + + def _render_placeholder(self, filepath: Path) -> None: + """Display the file's cached thumbnail, or a loading graphic if it has none. + + If a file is already known to not be renderable, it'll display the default icon for it + right away instead of a loading graphic. + Some filetypes shouldn't display a loading graphic ever, like fonts. + """ + self._file_renderer.render( self._driver.cache_manager, time.time(), filepath, - self._rendered_res, + _DEFAULT_PREVIEW_SIZE, self.devicePixelRatio(), + is_loading=True, + date_modified=self._date_modified, ) def _update_media_player(self, filepath: Path) -> None: """Display either audio or video.""" self.layout().media_player.play(filepath) - def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: + def _display_video(self, filepath: Path) -> None: + """Play a video, loading its stats in the background.""" self._should_render_on_resize = False self._switch_preview(_PreviewType.VIDEO) self._update_media_player(filepath) - stats = FileAttributeData() - - 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 + self._render_pool.start(lambda: self._probe_video(filepath)) + + def _probe_video(self, filepath: Path) -> None: + """Send whether a video should display as a video, as audio, or as an image.""" + if is_readable_video(filepath): + self._video_probed.emit(filepath, _PreviewType.VIDEO, self._get_video_stats(filepath)) + elif has_audio_stream(filepath): + self._video_probed.emit(filepath, _PreviewType.AUDIO, None) + else: + self._video_probed.emit(filepath, None, None) - def _display_audio(self, filepath: Path) -> FileAttributeData: + def _display_audio(self, filepath: Path, is_new_file: bool, start_player: bool = True) -> None: + """Display an audio file's art and duration, and play it unless `start_player` is False.""" + self._render_pool.start( + lambda: self.stats_updated.emit(filepath, self._get_duration_stats(filepath)) + ) self._switch_preview(_PreviewType.AUDIO) - self._render_thumb(filepath) - self._update_media_player(filepath) - return FileAttributeData() - - def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None: - """Update the animated image preview from a filepath.""" + if is_new_file: + self._render_placeholder(filepath) + self._render_preview(filepath) + if start_player: + self._update_media_player(filepath) + + def _play_animation(self, data: bytes, size: tuple[int, int]) -> bool: + """Play an animated image, returning `False` if it's not actually animated.""" self._should_render_on_resize = False view = self.layout() - stats = FileAttributeData() # Ensure that any movie and buffer from previous animations are cleared. - if view.preview_gif.movie(): - view.preview_gif.movie().stop() - self._gif_buffer.close() - - stats.width = size[0] - stats.height = size[1] + if view.preview_animation.movie(): + view.preview_animation.movie().stop() + self._animation_buffer.close() - self._image_ratio = stats.width / stats.height - self._gif_size = QSize(*size) + self._animation_size = QSize(*size) + self._image_ratio = self._animation_size.width() / self._animation_size.height() - self._gif_buffer.setData(gif_data) - movie = QMovie(self._gif_buffer, QByteArray()) - view.preview_gif.setMovie(movie) + self._animation_buffer.setData(data) + movie = QMovie(self._animation_buffer, QByteArray()) + view.preview_animation.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 + return False # The animation has more than 1 frame, continue displaying it as an animation self._switch_preview(_PreviewType.ANIMATED) - self.resizeEvent( - QResizeEvent( - QSize(stats.width, stats.height), - QSize(stats.width, stats.height), - ) - ) + self.resizeEvent(QResizeEvent(self._animation_size, self._animation_size)) movie.start() - stats.duration = movie.frameCount() // 60 + return True - return stats + def _display_animated_image(self, filepath: Path, is_new_file: bool) -> None: + """Display a placeholder while the animation loads, falling back to a still image. - def _display_image(self, filepath: Path): + The placeholder will be the cached first frame if it exists, or else a loading icon. + """ + self._should_render_on_resize = False + if is_new_file: + self._switch_preview(_PreviewType.IMAGE) + self._render_placeholder(filepath) + self._render_pool.start(lambda: self._load_animation(filepath)) + + def _load_animation(self, filepath: Path) -> None: + """Send an animated image to be played, then its stats once its duration is known.""" + animation_data = self._get_animation_data(filepath) + self._animation_loaded.emit(filepath, animation_data) + if animation_data is not None: + stats = self._get_duration_stats(filepath) + stats.width, stats.height = animation_data[1] + self.stats_updated.emit(filepath, stats) + + def _display_image(self, filepath: Path, is_new_file: bool) -> None: """Renders the given file as an image, no matter its media type.""" self._switch_preview(_PreviewType.IMAGE) - self._render_thumb(filepath) + if is_new_file: + self._render_placeholder(filepath) + self._render_pool.start( + lambda: self.stats_updated.emit(filepath, self._get_image_stats(filepath)) + ) + self._render_preview(filepath) def hide_preview(self) -> None: """Completely hide the file preview.""" + self._render_pool.clear() self._switch_preview(None) self._current_file = None self._should_render_on_resize = False @@ -347,7 +429,7 @@ def resizeEvent(self, event: QResizeEvent) -> None: and self._should_render_on_resize and self._rendered_res < self._preview_size ): - self._render_thumb(self._current_file) + self._render_preview(self._current_file) return super().resizeEvent(event) @@ -360,7 +442,7 @@ def current_file(self) -> Path | None: return self._current_file def _get_image_stats(self, filepath: Path) -> FileAttributeData: - """Get width and height of an image as dict.""" + """Get the width and height of an image.""" stats = FileAttributeData() with decompressed(filepath) as image_path: @@ -373,10 +455,10 @@ def _get_image_stats(self, filepath: Path) -> FileAttributeData: elif MediaTypes.contains("image.raster.raw", ext, SEARCH): try: with rawpy.imread(str(image_path)) 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 + sizes = raw.sizes + is_rotated = bool(sizes.flip & 4) + stats.width = sizes.height if is_rotated else sizes.width + stats.height = sizes.width if is_rotated else sizes.height except ( LibRawIOError, LibRawFileUnsupportedError, @@ -402,70 +484,76 @@ def _get_image_stats(self, filepath: Path) -> FileAttributeData: 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.""" + def _get_animation_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: + """Return an animated image's data and size, or `None` if it isn't animated.""" 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)) + with Image.open(filepath) as image: + if not getattr(image, "is_animated", False): + return None + + size = (image.width, image.height) + + # QMovie can't play APNGs, so convert them to the fastest lossless WebP + if ext == ".apng": + image_bytes_io = io.BytesIO() + image.save( + image_bytes_io, + "WEBP", + lossless=True, + quality=0, + method=0, + save_all=True, + loop=0, + ) + return (image_bytes_io.getvalue(), size) + + with open(filepath, "rb") as f: + return (f.read(), size) 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 _get_duration_stats(self, filepath: Path) -> FileAttributeData: + """Get the duration of a media file.""" + duration = get_duration(filepath) + return FileAttributeData(duration=None if duration is None else int(duration)) + + def _get_video_stats(self, filepath: Path) -> FileAttributeData: + """Get a video's dimensions and duration.""" + stats = self._get_duration_stats(filepath) + video = get_video_stream(filepath) + if video and video.get("width") and video.get("height"): + rotation = int(video.get("tags", {}).get("rotate", 0)) + for side_data in video.get("side_data_list", []): + rotation = int(side_data.get("rotation", rotation)) + is_sideways = rotation % 180 != 0 + stats.width = video["height"] if is_sideways else video["width"] + stats.height = video["width"] if is_sideways else video["height"] + return stats - def display_file(self, filepath: Path) -> FileAttributeData: - """Render a single file preview.""" + def display_file(self, filepath: Path, date_modified: float | None = None) -> None: + """Render a single file preview, sending its stats through `stats_updated`. + + `date_modified` is used to find unlinked files' cached thumbnails. + """ + is_new_file = filepath != self._current_file self._current_file = filepath + self._date_modified = date_modified + self._render_pool.clear() ext = filepath.suffix.lower() # Video - if MediaTypes.contains("video", ext, SEARCH) 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) + if MediaTypes.contains("video", ext, SEARCH): + self._display_video(filepath) # Audio elif MediaTypes.contains("audio", ext, SEARCH): - return self._display_audio(filepath) + self._display_audio(filepath, is_new_file) # Animated Images elif MediaTypes.contains("image.animated", ext, SEARCH): - 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) + self._display_animated_image(filepath, is_new_file) # Other Types (Including Images) else: - self._display_image(filepath) - return self._get_image_stats(filepath) + self._display_image(filepath, is_new_file) diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index a5c8ce697f..7598e71086 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -20,7 +20,7 @@ from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaTypes from tagstudio.core.query_lang.file_groups import SEARCH -from tagstudio.core.utils.decompression import get_display_ext +from tagstudio.core.utils.decompression import get_display_ext, get_inner_ext from tagstudio.core.utils.str_formatting import format_duration from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations @@ -121,6 +121,7 @@ def update_stats( filepath: Path | None = None, stats: FileAttributeData | None = None, entry: Entry | None = None, + reserve_stat_space: bool = False, ): """Render the panel widgets with the newest data from the Library.""" if not stats: @@ -168,10 +169,11 @@ def update_stats( ext_display: str = "" file_size: str = format_size(entry.file_size) if entry and entry.file_size else "" font_family: str = "" + is_linked = filepath.exists() # Attempt to populate the stat variables ext_display = ext.upper()[1:] or filepath.stem.upper() - if filepath and filepath.is_file() and MediaTypes.contains("font", ext, SEARCH): + if is_linked and MediaTypes.contains("font", ext, SEARCH): try: font = ImageFont.truetype(filepath) font_family = f"{font.getname()[0]} ({font.getname()[1]}) " @@ -201,7 +203,7 @@ def add_newline(stats_label_text: str) -> str: ) if file_size: stats_label_text += f" • {file_size}" - if not filepath.exists(): + if not is_linked: stats_label_text = ( f"{stats_label_text}" f" • " @@ -211,13 +213,27 @@ def add_newline(stats_label_text: str) -> str: elif file_size: stats_label_text += file_size + # HACK: Reserve space for stat lines that are still loading to prevent size flickering. + is_loading_stats = reserve_stat_space and is_linked + if stats.width is not None and stats.height is not None: stats_label_text = add_newline(stats_label_text) stats_label_text += f"{stats.width} x {stats.height} px" + elif is_loading_stats and ( + MediaTypes.contains("image.raster", get_inner_ext(filepath) or ext, SEARCH) + or MediaTypes.contains("video", ext, SEARCH) + ): + stats_label_text = add_newline(stats_label_text) + " " if stats.duration is not None: stats_label_text = add_newline(stats_label_text) stats_label_text += format_duration(stats.duration) + elif is_loading_stats and ( + MediaTypes.contains("video", ext, SEARCH) + or MediaTypes.contains("audio", ext, SEARCH) + or ext in {".apng", ".gif"} # Usually animated, unlike WebP and JPEG XL + ): + stats_label_text = add_newline(stats_label_text) + " " if font_family: stats_label_text = add_newline(stats_label_text) diff --git a/src/tagstudio/qt/qt_file_renderer.py b/src/tagstudio/qt/qt_file_renderer.py index 0823297bb5..838eda2fdb 100644 --- a/src/tagstudio/qt/qt_file_renderer.py +++ b/src/tagstudio/qt/qt_file_renderer.py @@ -17,7 +17,6 @@ class QtFileRenderer(QObject): """A Qt-specific wrapper for rendering image previews and thumbnails from files.""" updated = Signal(float, QPixmap, QSize, Path) - updated_ratio = Signal(float) def __init__(self, library: Library, settings: AppSettings) -> None: super().__init__() @@ -37,6 +36,7 @@ def render( pixel_ratio: float, is_loading: bool = False, is_thumb: bool = False, + date_modified: float | None = None, ): image, size, timestamp = self.renderer.render( @@ -48,13 +48,13 @@ def render( theme=self.theme, is_loading=is_loading, is_thumb=is_thumb, + date_modified=date_modified, ) qim = ImageQt.ImageQt(image) pixmap = QPixmap.fromImage(qim) pixmap.setDevicePixelRatio(pixel_ratio) - self.updated_ratio.emit(image.size[0] / image.size[1]) if pixmap: self.updated.emit(timestamp, pixmap, QSize(size[0], size[1]), filepath) else: diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py index 9754bdacce..e46fbc9aed 100644 --- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py +++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py @@ -313,6 +313,7 @@ def setGeometry(self, arg__1: QRect) -> None: ratio, False, True, + entry.date_modified, ), ) ) diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index b5842bff7c..cf4d5f0a97 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -48,19 +48,19 @@ def __init__(self, driver: QtDriver) -> None: self.preview_img_page = QWidget() self._stacked_page_setup(self.preview_img_page, self.button_wrapper) - self.preview_gif = QLabel() - self.preview_gif.setMinimumSize(*_DEFAULT_PREVIEW_SIZE) - self.preview_gif.setScaledContents(True) - 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.preview_animation = QLabel() + self.preview_animation.setMinimumSize(*_DEFAULT_PREVIEW_SIZE) + self.preview_animation.setScaledContents(True) + self.preview_animation.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_animation.setCursor(Qt.CursorShape.ArrowCursor) + self.preview_animation.addAction(self.open_file_action) + self.preview_animation.addAction(self.open_explorer_action) + self.preview_animation.addAction(self.delete_action) - self.preview_gif.setGraphicsEffect(CornerRadiusEffect(self.preview_gif, RADIUS)) + self.preview_animation.setGraphicsEffect(CornerRadiusEffect(self.preview_animation, RADIUS)) - self.preview_gif_page = QWidget() - self._stacked_page_setup(self.preview_gif_page, self.preview_gif) + self.preview_animation_page = QWidget() + self._stacked_page_setup(self.preview_animation_page, self.preview_animation) self.media_player = MediaPlayer(driver) self.media_player.addAction(self.open_file_action) @@ -72,7 +72,7 @@ def __init__(self, driver: QtDriver) -> None: self._stacked_page_setup(self.media_player_page, self.media_player) self.addWidget(self.preview_img_page) - self.addWidget(self.preview_gif_page) + self.addWidget(self.preview_animation_page) self.addWidget(self.media_player_page) def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: diff --git a/tests/previews/test_file_renderer.py b/tests/previews/test_file_renderer.py index 7d03adb51a..366f3ef8b6 100644 --- a/tests/previews/test_file_renderer.py +++ b/tests/previews/test_file_renderer.py @@ -69,7 +69,7 @@ def test_render_compressed_tarball( source = tmp_path / filename source.write_bytes(compress(_example_tar_bytes())) - assert FileRenderer._find_preview(source) is ArchivePreview + assert FileRenderer._find_renderer(source) is ArchivePreview image = ArchivePreview.render( source, is_small=True, theme=Theme.DARK, size=(128, 128), dpi_scale=1.0 )