diff --git a/requirements.txt b/requirements.txt index f2b7a90ca..e1dff2fca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,20 @@ +chardet==5.2.0 +ffmpeg-python==0.2.0 humanfriendly==10.0 +mutagen==1.47.0 +numpy==1.26.4 +numpy==1.26.4 opencv_python>=4.8.0.74,<=4.9.0.80 +pillow-heif==0.16.0 +pillow-jxl-plugin==1.2.6 Pillow==10.3.0 -PySide6==6.7.1 +pydub==0.25.1 PySide6_Addons==6.7.1 PySide6_Essentials==6.7.1 -typing_extensions>=3.10.0.0,<=4.11.0 -ujson>=5.8.0,<=5.9.0 -numpy==1.26.4 +PySide6==6.7.1 rawpy==0.21.0 -pillow-heif==0.16.0 -chardet==5.2.0 -structlog==24.4.0 SQLAlchemy==2.0.34 -pydub==0.25.1 -mutagen==1.47.0 -numpy==1.26.4 -ffmpeg-python==0.2.0 -vtf2img==0.1.0 +structlog==24.4.0 +typing_extensions>=3.10.0.0,<=4.11.0 +ujson>=5.8.0,<=5.9.0 +vtf2img==0.1.0 \ No newline at end of file diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index 04ea5e872..5eb7dd693 100644 --- a/tagstudio/src/core/media_types.py +++ b/tagstudio/src/core/media_types.py @@ -189,10 +189,11 @@ class MediaCategories: ".woff2", } _IMAGE_ANIMATED_SET: set[str] = { + ".png", + ".jxl", ".apng", ".gif", ".webp", - ".jxl", } _IMAGE_RAW_SET: set[str] = { ".arw", diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index b38fc6c18..da5935220 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -1,6 +1,8 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import io import os import platform import sys @@ -10,13 +12,15 @@ from datetime import datetime as dt from pathlib import Path + import cv2 import rawpy import structlog +import enum from humanfriendly import format_size from PIL import Image, ImageFont, UnidentifiedImageError from PIL.Image import DecompressionBombError -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal, QThread from PySide6.QtGui import QAction, QGuiApplication, QMovie, QResizeEvent from PySide6.QtWidgets import ( QFrame, @@ -43,6 +47,9 @@ TextField, _FieldID, ) +import threading +import queue +from concurrent.futures import ThreadPoolExecutor from src.core.library.alchemy.library import Library from src.core.media_types import MediaCategories from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel, open_file @@ -75,11 +82,39 @@ def update_selected_entry(driver: "QtDriver"): assert results, f"Entry not found: {entry.id}" driver.frame_content[grid_idx] = next(results) +class ThreadWithCallback(threading.Thread): + callback_signal = Signal(object, tuple) + + def __init__(self, target, args=(), kwargs=None, callback_signal=None, callback_args=()): + super().__init__() + self.target = target + self.args = args + self.kwargs = kwargs if kwargs is not None else {} + self.callback_signal = callback_signal + self.callback_args = callback_args + + self._stop_event = threading.Event() + + def run(self): + result = self.target(*self.args, **self.kwargs) + + if self.callback_signal: + self.callback_signal.emit(result, *self.callback_args) + + + + +class previewType(enum.Enum): + IMG = enum.auto() + ANIM_IMG = enum.auto() + VID = enum.auto() + class PreviewPanel(QWidget): """The Preview Panel Widget.""" tags_updated = Signal() + conv_anim_on_complete_signal = Signal(io.BytesIO, Image.Image) def __init__(self, library: Library, driver: "QtDriver"): super().__init__() @@ -93,10 +128,19 @@ def __init__(self, library: Library, driver: "QtDriver"): self.selected: list[int] = [] # New way of tracking items self.tag_callback = None self.containers: list[FieldContainer] = [] + self.anim_img_conv_threads: list[ThreadWithCallback] = [] + self.conv_anim_on_complete_signal.connect(self.conv_anim_on_complete) self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 + self.anim_img_cache_map = {} + self.anim_img_cache_map_rev = {} + self.anim_img_cache_map_working = {} + self.anim_img_cache_index = 0 + self.anim_img_cache_index_size = 10 + self.anim_img_cache = [None] * self.anim_img_cache_index_size + self.label_bg_color = ( Theme.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark @@ -130,6 +174,8 @@ def __init__(self, library: Library, driver: "QtDriver"): self.open_file_action = QAction("Open file", self) self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) + self.base_preview_type = previewType.IMG + self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) self.preview_img.setFlat(True) @@ -137,14 +183,56 @@ def __init__(self, library: Library, driver: "QtDriver"): self.preview_img.addAction(self.open_file_action) self.preview_img.addAction(self.open_explorer_action) - 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.hide() - self.gif_buffer: QBuffer = QBuffer() + self.preview_anim_img = QLabel() + # self.preview_anim_img_bytes_io: io.BytesIO = io.BytesIO() + self.preview_anim_img.setMinimumSize(*self.img_button_size) + self.preview_anim_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_anim_img.setCursor(Qt.CursorShape.ArrowCursor) + self.preview_anim_img.addAction(self.open_file_action) + self.preview_anim_img.addAction(self.open_explorer_action) + self.preview_anim_img.hide() + self.anim_img_buffer: QBuffer = QBuffer() + + self.preview_anim_img_fmts = [] + + # this gets animated image types qmovie supports + qmovie_formats = QMovie.supportedFormats() + self.preview_anim_img_fmts = [ + ( + fmt_data.decode("utf-8") + if isinstance(fmt_data := fmt.data(), (bytes, bytearray)) + else "" + ) + for fmt in qmovie_formats + ] + + # extensions in preview_anim_img_fmts get ordered based on this + ani_img_priority_order = ["jxl", "apng", "png", "avif", "webp", "heif"] + + # this maps extensions to format names pillow supports + self.preview_anim_img_pil_map = {"apng": "png"} + + # this is extra pillow encoding args on a per image type basis + self.preview_anim_img_pil_map_args = {"webp": {"method": 0},"gif": {"disposal": 2}} + Image.init() + + # this gets animated image types pillow supports + pil_save_all = Image.SAVE_ALL.keys() + self.preview_anim_img_pil_anim_supported = [key.lower() for key in pil_save_all] + logger.info("pillow animated image types: " + str(self.preview_anim_img_pil_anim_supported)) + + # sorts based on ani_img_priority_order + self.preview_anim_img_fmts.sort( + key=lambda x: ani_img_priority_order.index(x) + if x in ani_img_priority_order + else len(ani_img_priority_order) + ) + + logger.info("supported qmovie image format(s): " + str(self.preview_anim_img_fmts)) + + # checks if image can be saved at all via pillow + pil_exts = Image.registered_extensions() + self.pil_save_exts = {ex for ex, f in pil_exts.items() if f in Image.SAVE} self.preview_vid = VideoPlayer(driver) self.preview_vid.hide() @@ -165,8 +253,8 @@ def __init__(self, library: Library, driver: "QtDriver"): image_layout.addWidget(self.preview_img) image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) - image_layout.addWidget(self.preview_gif) - image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) + image_layout.addWidget(self.preview_anim_img) + image_layout.setAlignment(self.preview_anim_img, Qt.AlignmentFlag.AlignCenter) image_layout.addWidget(self.preview_vid) image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) self.image_container.setMinimumSize(*self.img_button_size) @@ -294,6 +382,32 @@ def __init__(self, library: Library, driver: "QtDriver"): def remove_field_prompt(self, name: str) -> str: return f'Are you sure you want to remove field "{name}"?' + def add_anim_thread(self, new_thread: ThreadWithCallback): + for index, thread in enumerate(self.anim_img_conv_threads): + if not thread.is_alive(): + self.anim_img_conv_threads[index] = new_thread + new_thread.start() + return + self.anim_img_conv_threads.append(new_thread) + new_thread.start() + return + + + def add_cache_anim_img(self, data, filepath): + self.anim_img_cache[self.anim_img_cache_index] = data + if self.anim_img_cache_index in self.anim_img_cache_map_rev: + del self.anim_img_cache_map[self.anim_img_cache_map_rev[self.anim_img_cache_index]] + del self.anim_img_cache_map_rev[self.anim_img_cache_index] + + self.anim_img_cache_map[str(filepath)] = self.anim_img_cache_index + self.anim_img_cache_map_rev[self.anim_img_cache_index] = str(filepath) + + self.anim_img_cache_index += 1 + self.anim_img_cache_index %= self.anim_img_cache_index_size + + + + def fill_libs_widget(self, layout: QVBoxLayout): settings = self.driver.settings settings.beginGroup(SettingItems.LIBS_LIST) @@ -436,12 +550,12 @@ def update_image_size(self, size: tuple[int, int], ratio: float = None): self.preview_vid.resize_video(adj_size) self.preview_vid.setMaximumSize(adj_size) self.preview_vid.setMinimumSize(adj_size) - self.preview_gif.setMaximumSize(adj_size) - self.preview_gif.setMinimumSize(adj_size) + self.preview_anim_img.setMaximumSize(adj_size) + self.preview_anim_img.setMinimumSize(adj_size) proxy_style = RoundedPixmapStyle(radius=8) - self.preview_gif.setStyle(proxy_style) + self.preview_anim_img.setStyle(proxy_style) self.preview_vid.setStyle(proxy_style) - m = self.preview_gif.movie() + m = self.preview_anim_img.movie() if m: m.setScaledSize(adj_size) @@ -497,6 +611,162 @@ def update_date_label(self, filepath: Path | None = None) -> None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) + def set_preview_type(self, preview_type): + self.base_preview_type = preview_type + + if self.base_preview_type != previewType.IMG: + self.preview_img.hide() + + if self.base_preview_type != previewType.VID: + self.preview_vid.hide() + self.preview_vid.stop() + + if self.base_preview_type != previewType.ANIM_IMG: + self.preview_anim_img.hide() + if self.preview_anim_img.movie(): + self.preview_anim_img.movie().stop() + + if self.base_preview_type == previewType.IMG: + logger.info("base preview type: " + previewType.IMG.name) + self.preview_img.show() + + elif self.base_preview_type == previewType.ANIM_IMG: + logger.info("base preview type: " + previewType.ANIM_IMG.name) + self.preview_anim_img.show() + + elif self.base_preview_type == previewType.VID: + logger.info("base preview type: " + previewType.VID.name) + self.preview_vid.show() + + def get_anim_ext(self): + for fmt_ext in self.preview_anim_img_fmts: + fmt_ext = self.preview_anim_img_pil_map.get(fmt_ext, fmt_ext) + + if fmt_ext in self.preview_anim_img_pil_anim_supported: + if f".{fmt_ext}" in self.pil_save_exts: + return fmt_ext + + return None + + def conv_anim_on_complete(self, image_bytes_io, filepath): + logger.info("transcode done \\^o^/") + + image_bytes_io.seek(0) + self.add_cache_anim_img(image_bytes_io.read(), filepath) + + if str(filepath) in self.anim_img_cache_map_working: + del self.anim_img_cache_map_working[str(filepath)] + + + # self.update_widgets() + + # if self.preview_anim_img.movie(): + # self.preview_anim_img.movie().stop() + # self.anim_img_buffer.close() + # + # image_bytes_io.seek(0) + # self.anim_img_buffer.setData(image_bytes_io.read()) + # + # movie = QMovie(self.anim_img_buffer, QByteArray()) + # self.preview_anim_img.setMovie(movie) + # + # self.resizeEvent( + # QResizeEvent( + # QSize(image.width, image.height), + # QSize(image.width, image.height), + # ) + # ) + # + # movie.start() + # + # self.set_preview_type(previewType.ANIM_IMG) + + + + def conv_anim_img(self, anim_image, save_ext): + image_bytes_io: io.BytesIO = io.BytesIO() + + per_format_args = self.preview_anim_img_pil_map_args.get(save_ext, {}) + logger.info(f"transcoding \"{save_ext}\" with args: {per_format_args}") + + anim_image.save( + image_bytes_io, + format=save_ext, + lossless=True, + save_all=True, + loop=0, + **per_format_args, + ) + logger.info("finished image conversion function") + return image_bytes_io + + + def set_anim_img(self, filepath, ext, file_bytes, image): + logger.info(f'loading animated image: "{os.path.basename(filepath)}"') + + if self.preview_anim_img.movie(): + self.preview_anim_img.movie().stop() + self.anim_img_buffer.close() + + + if not ext.lstrip(".") in self.preview_anim_img_fmts: + if str(filepath) in self.anim_img_cache_map: + if self.preview_anim_img.movie(): + self.preview_anim_img.movie().stop() + self.anim_img_buffer.close() + + self.anim_img_buffer.setData(self.anim_img_cache[self.anim_img_cache_map[str(filepath)]]) + + movie = QMovie(self.anim_img_buffer, QByteArray()) + self.preview_anim_img.setMovie(movie) + + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + + movie.start() + + self.set_preview_type(previewType.ANIM_IMG) + + + elif not str(filepath) in self.anim_img_cache_map_working: + save_ext = self.get_anim_ext() + + logger.info( + f"\"{ext.lstrip('.')}\" not supported by qt qmovie, trancoding to: \"{save_ext}\"" + ) + + anim_image: Image.Image = image + + + self.add_anim_thread(ThreadWithCallback(target=self.conv_anim_img, args=(anim_image, save_ext), callback_signal=self.conv_anim_on_complete_signal, callback_args=(filepath,))) + + self.anim_img_cache_map_working[str(filepath)] = True + + + # self.anim_img_conv_threads.start() + else: + logger.info("working on transcoding but not done") + else: + self.anim_img_buffer.setData(file_bytes) + + movie = QMovie(self.anim_img_buffer, QByteArray()) + self.preview_anim_img.setMovie(movie) + + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + + movie.start() + + self.set_preview_type(previewType.ANIM_IMG) + def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" logger.info("update_widgets", selected=self.driver.selected) @@ -531,10 +801,9 @@ def update_widgets(self) -> bool: self.preview_img.clicked.disconnect() for c in self.containers: c.setHidden(True) - self.preview_img.show() - self.preview_vid.stop() - self.preview_vid.hide() - self.preview_gif.hide() + + self.set_preview_type(previewType.IMG) + self.selected = list(self.driver.selected) self.add_field_button.setHidden(True) @@ -562,11 +831,7 @@ def update_widgets(self) -> bool: # 1 Selected Entry selected_idx = self.driver.selected[0] item = self.driver.frame_content[selected_idx] - - self.preview_img.show() - self.preview_vid.stop() - self.preview_vid.hide() - self.preview_gif.hide() + self.set_preview_type(previewType.IMG) # If a new selection is made, update the thumbnail and filepath. if not self.selected or self.selected != self.driver.selected: @@ -601,28 +866,16 @@ def update_widgets(self) -> bool: # TODO: Do this all somewhere else, this is just here temporarily. ext: str = filepath.suffix.lower() try: - if filepath.suffix.lower() in [".gif"]: - with open(filepath, mode="rb") as file: - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - - ba = file.read() - self.gif_buffer.setData(ba) - movie = QMovie(self.gif_buffer, QByteArray()) - self.preview_gif.setMovie(movie) - movie.start() + if MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True + ): + with open(filepath, mode="rb") as f: + file_bytes: bytes = f.read() + image: Image.Image = Image.open(io.BytesIO(file_bytes)) + if hasattr(image, "n_frames"): + if image.n_frames > 1: + self.set_anim_img(filepath, ext, file_bytes, image) - image = Image.open(str(filepath)) - 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 ( @@ -655,7 +908,6 @@ def update_widgets(self) -> bool: frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image = Image.fromarray(frame) if success: - self.preview_img.hide() self.preview_vid.play(filepath, QSize(image.width, image.height)) self.resizeEvent( QResizeEvent( @@ -663,7 +915,7 @@ def update_widgets(self) -> bool: QSize(image.width, image.height), ) ) - self.preview_vid.show() + self.set_preview_type(previewType.VID) # Stats for specific file types are displayed here. if image and ( @@ -745,10 +997,8 @@ def update_widgets(self) -> bool: # Multiple Selected Items elif len(self.driver.selected) > 1: - self.preview_img.show() - self.preview_gif.hide() - self.preview_vid.stop() - self.preview_vid.hide() + self.set_preview_type(previewType.IMG) + self.update_date_label() if self.selected != self.driver.selected: self.file_label.setText(f"{len(self.driver.selected)} Items Selected") diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 7cac38fcd..d90f106c3 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -12,6 +12,7 @@ import cv2 import numpy as np +import pillow_jxl # noqa: F401 import rawpy import structlog from mutagen import MutagenError, flac, id3, mp4