diff --git a/tagstudio/resources/qt/images/broken_link_icon.png b/tagstudio/resources/qt/images/broken_link_icon.png new file mode 100644 index 00000000..d4310970 Binary files /dev/null and b/tagstudio/resources/qt/images/broken_link_icon.png differ diff --git a/tagstudio/resources/qt/images/file_icons/generic.png b/tagstudio/resources/qt/images/file_icons/generic.png new file mode 100644 index 00000000..13685e3a Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/generic.png differ diff --git a/tagstudio/resources/qt/images/thumb_broken_512.png b/tagstudio/resources/qt/images/thumb_broken_512.png deleted file mode 100644 index 5022f2eb..00000000 Binary files a/tagstudio/resources/qt/images/thumb_broken_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_file_default_512.png b/tagstudio/resources/qt/images/thumb_file_default_512.png deleted file mode 100644 index 28dfbd43..00000000 Binary files a/tagstudio/resources/qt/images/thumb_file_default_512.png and /dev/null differ diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 0b36f953..b8e93d3a 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -279,11 +279,16 @@ _TAG_COLORS: dict = { _UI_COLORS: dict = { "": { - ColorType.PRIMARY: "#1e1e1e", - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: "#333333", + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", ColorType.LIGHT_ACCENT: "#FFFFFF", - ColorType.DARK_ACCENT: "#222222", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + "red": { + ColorType.PRIMARY: "#e22c3c", + ColorType.BORDER: "#e54252", + ColorType.LIGHT_ACCENT: "#f39caa", + ColorType.DARK_ACCENT: "#440d12", }, "green": { ColorType.PRIMARY: "#28bb48", diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index 0db8bb19..5d1d1851 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -5,6 +5,7 @@ import logging from pathlib import Path from typing import Any +from PIL import Image import ujson @@ -46,7 +47,7 @@ class ResourceManager: return cached_res else: res: dict = ResourceManager._map.get(id) - if res.get("mode") in ["r", "rb"]: + if res and res.get("mode") in ["r", "rb"]: with open( (Path(__file__).parents[2] / "resources" / res.get("path")), res.get("mode"), @@ -56,7 +57,12 @@ class ResourceManager: data = bytes(data) ResourceManager._cache[id] = data return data - elif res.get("mode") in ["qt"]: + elif res and res.get("mode") == "pil": + data = Image.open( + Path(__file__).parents[2] / "resources" / res.get("path") + ) + return data + elif res and res.get("mode") in ["qt"]: # TODO: Qt resource loading logic pass diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 1f8663d3..9f3d3e49 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -14,5 +14,13 @@ "volume_mute_icon": { "path": "qt/images/volume_mute.svg", "mode": "rb" + }, + "broken_link_icon": { + "path": "qt/images/broken_link_icon.png", + "mode": "pil" + }, + "file_generic": { + "path": "qt/images/file_icons/generic.png", + "mode": "pil" } } diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 469635ef..88933b9d 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -25,6 +25,7 @@ from pydub import AudioSegment, exceptions from mutagen import id3, flac, mp4, MutagenError from PySide6.QtCore import Qt, QObject, Signal, QSize from PySide6.QtGui import QGuiApplication, QPixmap +from src.qt.resource_manager import ResourceManager from src.qt.helpers.color_overlay import theme_fg_overlay from src.qt.helpers.gradient import four_corner_gradient_background from src.qt.helpers.text_wrapper import wrap_full_text @@ -44,6 +45,7 @@ from src.core.palette import ColorType, get_ui_color from src.qt.helpers.blender_thumbnailer import blend_thumb from src.qt.helpers.file_tester import is_readable_video + ImageFile.LOAD_TRUNCATED_IMAGES = True ERROR = "[ERROR]" @@ -56,36 +58,23 @@ register_avif_opener() class ThumbRenderer(QObject): - # finished = Signal() + rm: ResourceManager = ResourceManager() updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - # updatedImage = Signal(QPixmap) - # updatedSize = Signal(QSize) # Cached thumbnail elements. # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) thumb_masks: dict = {} thumb_borders: dict = {} + # Key: ("name", "color", 512, 512, 1.25) + icons: dict = {} + thumb_loading_512: Image.Image = Image.open( Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" ) thumb_loading_512.load() - thumb_broken_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png" - ) - thumb_broken_512.load() - - thumb_file_default_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_file_default_512.png" - ) - thumb_file_default_512.load() - - # thumb_debug: Image.Image = Image.open(Path( - # f'{Path(__file__).parents[2]}/resources/qt/images/temp.jpg')) - # thumb_debug.load() - # TODO: Make dynamic font sized given different pixel ratios font_pixel_ratio: float = 1 ext_font = ImageFont.truetype( @@ -106,22 +95,33 @@ class ThumbRenderer(QObject): return item @staticmethod - def _get_border(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + def _get_hl_border(size: tuple[int, int], pixel_ratio: float) -> Image.Image: """ Returns a thumbnail border given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ item: Image.Image = ThumbRenderer.thumb_borders.get((*size, pixel_ratio)) if not item: - item = ThumbRenderer._render_border(size, pixel_ratio) + item = ThumbRenderer._render_hl_border(size, pixel_ratio) ThumbRenderer.thumb_borders[(*size, pixel_ratio)] = item return item + @staticmethod + def _get_icon( + name: str, color: str, size: tuple[int, int], pixel_ratio: float + ) -> Image.Image: + item: Image.Image = ThumbRenderer.icons.get((name, color, *size, pixel_ratio)) + if not item: + item = ThumbRenderer._render_icon(name, color, size, pixel_ratio) + ThumbRenderer.thumb_borders[(name, *color, size, pixel_ratio)] = item + return item + @staticmethod def _render_mask(size: tuple[int, int], pixel_ratio) -> Image.Image: """Renders a thumbnail mask.""" - smooth_factor: int = math.ceil(2 * pixel_ratio) + smooth_factor: int = 2 radius_factor: int = 8 + im: Image.Image = Image.new( mode="L", size=tuple([d * smooth_factor for d in size]), # type: ignore @@ -140,9 +140,9 @@ class ThumbRenderer(QObject): return im @staticmethod - def _render_border(size: tuple[int, int], pixel_ratio) -> Image.Image: - """Renders a thumbnail border.""" - smooth_factor: int = math.ceil(2 * pixel_ratio) + def _render_hl_border(size: tuple[int, int], pixel_ratio) -> Image.Image: + """Renders a thumbnail highlight border.""" + smooth_factor: int = 2 radius_factor: int = 8 im: Image.Image = Image.new( mode="RGBA", @@ -163,6 +163,127 @@ class ThumbRenderer(QObject): ) return im + @staticmethod + def _render_icon( + name: str, color: str, size: tuple[int, int], pixel_ratio: float + ) -> Image.Image: + smooth_factor: int = math.ceil(2 * pixel_ratio) + radius_factor: int = 8 + icon_ratio: float = 1.75 + + # Create larger blank image based on smooth_factor + im: Image.Image = Image.new( + "RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + + # Create solid background color + bg: Image.Image = Image.new( + "RGB", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#000000", + ) + + # Paste background color with rounded rectangle mask onto blank image + im.paste( + bg, + (0, 0), + mask=ThumbRenderer._get_mask( + tuple([d * smooth_factor for d in size]), # type: ignore + (pixel_ratio * smooth_factor), + ), + ) + + # Draw rounded rectangle border + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + fill="black", + outline="#FF0000", + width=math.floor(pixel_ratio * 8), + ) + + # Resize image to final size + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + fg: Image.Image = Image.new("RGB", size=size, color="#00FF00") + + # Get icon by name + icon: Image.Image = ThumbRenderer.rm.get(name) + + # Resize icon to fit icon_ratio + icon = icon.resize( + (math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)) + ) + + # Paste icon centered + im.paste( + im=fg.resize( + (math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)) + ), + box=( + math.ceil((size[0] - (size[0] // icon_ratio)) // 2), + math.ceil((size[1] - (size[1] // icon_ratio)) // 2), + ), + mask=icon.getchannel(3), + ) + + # Apply color overlay + im = ThumbRenderer._apply_overlay_color( + im, + color, + ) + + return im + + @staticmethod + def _apply_overlay_color(image: Image.Image, color: str) -> Image.Image: + """Apply a gradient effect over an an image. + Red channel for foreground, green channel for outline, none for background.""" + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.PRIMARY, color) + ) + fg_color: str = ( + get_ui_color(ColorType.PRIMARY, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + ol_color: str = ( + get_ui_color(ColorType.BORDER, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + + bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) + fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) + ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(ol, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image.Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg + + @staticmethod + def get_mime_icon_resource(ext: str = "") -> str: + if ext in IMAGE_TYPES: + return "image_photo" + elif ext in VIDEO_TYPES: + return "doc_presentation" + return "" + def render( self, timestamp: float, @@ -271,17 +392,15 @@ class ThumbRenderer(QObject): # count, seeking halfway does not work and the thumb # must be pulled from the earliest available frame. video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image = Image.fromarray(frame) else: - image = self.thumb_file_default_512 + image = ThumbRenderer._get_icon( + name="file_generic", + color="red", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) # Plain Text =================================================== elif ext in PLAINTEXT_TYPES: @@ -308,7 +427,7 @@ class ThumbRenderer(QObject): _filepath, ext, adj_size, pixel_ratio ) if image is not None: - image = self._apply_overlay_color(image, "green") + image = ThumbRenderer._apply_overlay_color(image, "green") # 3D =========================================================== # elif extension == 'stl': @@ -355,16 +474,7 @@ class ThumbRenderer(QObject): f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" ) - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - # No Rendered Thumbnail ======================================== - else: - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - if not image: raise UnidentifiedImageError @@ -392,7 +502,7 @@ class ThumbRenderer(QObject): mask: Image.Image = ThumbRenderer._get_mask( (adj_size, adj_size), pixel_ratio ) - hl: Image.Image = ThumbRenderer._get_border( + hl: Image.Image = ThumbRenderer._get_hl_border( (adj_size, adj_size), pixel_ratio ) final = four_corner_gradient_background(image, adj_size, mask, hl) @@ -415,21 +525,37 @@ class ThumbRenderer(QObject): ) final = Image.new("RGBA", image.size, (0, 0, 0, 0)) final.paste(image, mask=rec.getchannel(0)) + except FileNotFoundError as e: + logging.info( + f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" + ) + if update_on_ratio_change: + self.updated_ratio.emit(1) + final = ThumbRenderer._get_icon( + name="broken_link_icon", + color="red", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) except ( UnidentifiedImageError, - FileNotFoundError, cv2.error, DecompressionBombError, UnicodeDecodeError, ) as e: - if e is not UnicodeDecodeError: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" - ) + # if e is not UnicodeDecodeError: + logging.info( + f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" + ) + if update_on_ratio_change: self.updated_ratio.emit(1) - final = ThumbRenderer.thumb_broken_512.resize( - (adj_size, adj_size), resample=resampling_method + final = ThumbRenderer._get_icon( + # name=ThumbRenderer.get_mime_icon_resource(_filepath.suffix.lower()), + name="file_generic", + color="", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, ) qim = ImageQt.ImageQt(final) if image: @@ -620,7 +746,7 @@ class ThumbRenderer(QObject): cropped_im, box=(margin, margin + ((size - new_y) // 2)), ) - return self._apply_overlay_color(bg, "purple") + return ThumbRenderer._apply_overlay_color(bg, "purple") def _font_preview_long(self, filepath: Path, size: int) -> Image.Image: """Renders a large font preview ("Alphabet") thumbnail from a font file.""" @@ -644,29 +770,3 @@ class ThumbRenderer(QObject): len(text_wrapped.split("\n")) + lines_of_padding ) * draw.textbbox((0, 0), "A", font=font)[-1] return theme_fg_overlay(bg, use_alpha=False) - - def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: - """Apply a gradient effect over an an image. - Red channel for foreground, green channel for outline, none for background.""" - bg_color: str = ( - get_ui_color(ColorType.DARK_ACCENT, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.PRIMARY, color) - ) - fg_color: str = ( - get_ui_color(ColorType.PRIMARY, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - ol_color: str = ( - get_ui_color(ColorType.BORDER, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - - bg: Image.Image = Image.new("RGB", image.size, color=bg_color) - fg: Image.Image = Image.new("RGB", image.size, color=fg_color) - ol: Image.Image = Image.new("RGB", image.size, color=ol_color) - bg.paste(fg, (0, 0), mask=image.getchannel(0)) - bg.paste(ol, (0, 0), mask=image.getchannel(1)) - return bg