feat(ui): add dynamic file thumb icons

This commit is contained in:
Travis Abendshien
2024-07-21 08:40:19 -07:00
parent 196c1ba7f3
commit 39324142f1
8 changed files with 201 additions and 82 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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",

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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