continue renderer work

This commit is contained in:
Travis Abendshien
2026-08-21 22:05:49 -07:00
parent 22d1b70c33
commit 57bf2a5ea5
7 changed files with 407 additions and 389 deletions
+4 -3
View File
@@ -21,15 +21,18 @@ dependencies = [
"mutagen~=1.47",
"numpy~=2.2",
"opencv_python~=4.11",
"Pillow>=10.2,<12",
"pillow-heif~=1.5.0",
"pillow-jxl-plugin~=1.3",
"Pillow>=10.2,<12",
"py7zr~=1.1.3",
"pydantic~=2.10",
"pydub~=0.25",
"Pygments~=2.21",
"PySide6==6.11.2",
"rarfile==4.2",
"rawpy~=0.27",
"requests~=2.31.0",
"semver~=3.0.4",
"Send2Trash>=1.8,<3",
"SQLAlchemy~=2.0",
"srctools~=2.6",
@@ -38,8 +41,6 @@ dependencies = [
"typing_extensions~=4.13",
"ujson~=5.10",
"wcmatch==10.*",
"requests~=2.31.0",
"semver~=3.0.4",
]
[project.gui-scripts]
+7 -1
View File
@@ -417,6 +417,7 @@ audio = MediaTypeGroup(
]
+ midi.types,
)
MediaTypes.register(audio)
# RAW Images -----------------------------------------------------------------------------------
raw_image = MediaTypeGroup(
@@ -608,6 +609,12 @@ markup = MediaTypeGroup(
],
[SEARCH, RENDER],
),
_Type(
[
".plist",
],
[SEARCH, RENDER],
),
_Type(".toml", [SEARCH, RENDER]),
],
)
@@ -936,7 +943,6 @@ class MediaCategories:
".epub",
".fb2",
".ibook",
".inf",
".kfx",
".lit",
".mobi",
+50
View File
@@ -0,0 +1,50 @@
from tagstudio.core.enums import Theme
from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color
from PIL import ImageDraw, ImageFont, UnidentifiedImageError
from PIL.Image import DecompressionBombError, Image, Resampling
from PIL.Image import new as new_image
from PIL.Image import open as open_image
# TODO: Split out Qt color palette stuff from anything needed by the core.
def apply_overlay_color(image: Image, color: UiColor, theme: Theme) -> Image:
"""Apply a color overlay effect to an image based on its color channel data.
Red channel for foreground, green channel for outline, none for background.
Args:
image (Image.Image): The image to apply an overlay to.
color (UiColor): The name of the ColorType color to use.
theme (Theme): A theme enum to determine the light/dark theme.
"""
bg_color: str = (
get_ui_color(ColorType.DARK_ACCENT, color)
if theme == Theme.DARK
else get_ui_color(ColorType.PRIMARY, color)
)
fg_color: str = (
get_ui_color(ColorType.PRIMARY, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
ol_color: str = (
get_ui_color(ColorType.BORDER, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
bg: Image = new_image(image.mode, image.size, color=bg_color)
fg: Image = new_image(image.mode, image.size, color=fg_color)
ol: Image = new_image(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 = 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
+203 -244
View File
@@ -27,6 +27,7 @@ from tagstudio.core.media_types import (
)
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import BasePreview
from tagstudio.previews.effects import apply_overlay_color
from tagstudio.previews.gradients import four_corner_gradient
from tagstudio.previews.renderers.archive import (
apple_embedded_thumb,
@@ -35,7 +36,7 @@ from tagstudio.previews.renderers.archive import (
open_doc_thumb,
powerpoint_thumb,
)
from tagstudio.previews.renderers.audio import audio_album_thumb, audio_thumb, audio_waveform_thumb
from tagstudio.previews.renderers.audio import AudioPreview
from tagstudio.previews.renderers.blender import BlenderPreview, _blender_thumb
from tagstudio.previews.renderers.clip_studio import clip_studio_thumb
from tagstudio.previews.renderers.code import CodePreview
@@ -386,7 +387,7 @@ class FileRenderer:
)
# Apply color overlay
im = self._apply_overlay_color(im, color, theme)
im = apply_overlay_color(im, color, theme)
return im
@@ -433,7 +434,7 @@ class FileRenderer:
color="#000000",
)
# Apply color overlay
bg = self._apply_overlay_color(im, color, theme)
bg = apply_overlay_color(im, color, theme)
# Paste background color with rounded rectangle mask onto blank image
im.paste(
@@ -470,48 +471,6 @@ class FileRenderer:
return im
def _apply_overlay_color(self, image: Image.Image, color: UiColor, theme: Theme) -> Image.Image:
"""Apply a color overlay effect to an image based on its color channel data.
Red channel for foreground, green channel for outline, none for background.
Args:
image (Image.Image): The image to apply an overlay to.
color (UiColor): The name of the ColorType color to use.
theme (Theme): A theme enum to determine the light/dark theme.
"""
bg_color: str = (
get_ui_color(ColorType.DARK_ACCENT, color)
if theme == Theme.DARK
else get_ui_color(ColorType.PRIMARY, color)
)
fg_color: str = (
get_ui_color(ColorType.PRIMARY, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
ol_color: str = (
get_ui_color(ColorType.BORDER, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
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
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
def _apply_edge(
self,
@@ -776,7 +735,7 @@ class FileRenderer:
# And allow user-created ones from an external directory.
previews: list[type[BasePreview]] = [
# ArchivePreview,
# AudioPreview,
AudioPreview,
BlenderPreview,
# ClipStudioPaintPreview,
CodePreview,
@@ -819,216 +778,216 @@ class FileRenderer:
# -------------------------------- new old
render_groups: list[tuple[MediaTypeGroup, Callable[..., Image.Image | None]]] = [
(MediaTypes.raw_image, partial(raw_image_thumb, filepath)),
(MediaTypes.raster_image, partial(raster_image_thumb, filepath)),
(MediaTypes.vector_image, partial(vector_image_thumb, filepath, scaled_size)),
(
getattr(MediaTypes, CodePreview.media_type_name),
partial(CodePreview.render, filepath),
),
(
getattr(MediaTypes, TextPreview.media_type_name),
partial(TextPreview.render, filepath),
),
(MediaTypes.audio, partial(audio_thumb, filepath, scaled_size, dpi_scale)),
(MediaTypes.video, partial(video_thumb, filepath)),
(
MediaTypes.font,
partial(font_small_thumb if is_thumb else font_full_preview, filepath, scaled_size),
),
(MediaTypes.archive, partial(archive_thumb, filepath)),
(MediaTypes.pdf, partial(pdf_thumb, filepath, scaled_size)),
(MediaTypes.ebook, partial(epub_thumb, filepath)),
(MediaTypes.iwork, partial(apple_embedded_thumb, filepath)),
(MediaTypes.blender, partial(_blender_thumb, filepath)),
(MediaTypes.krita, partial(krita_thumb, filepath)),
(MediaTypes.clip_studio_paint, partial(clip_studio_thumb, filepath)),
(MediaTypes.paint_dot_net, partial(paint_dot_net_thumb, filepath)),
(MediaTypes.medibang_paint, partial(medibang_paint_thumb, filepath)),
(MediaTypes.binary, partial(raster_image_thumb, filepath)),
]
if filepath and filepath.is_file():
try:
ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower()
for media_type, renderer in render_groups:
if media_type.contains(ext, Context.RENDER):
logger.info(f"{ext} in: {media_type.renderable}")
image = renderer()
# render_groups: list[tuple[MediaTypeGroup, Callable[..., Image.Image | None]]] = [
# (MediaTypes.raw_image, partial(raw_image_thumb, filepath)),
# (MediaTypes.raster_image, partial(raster_image_thumb, filepath)),
# (MediaTypes.vector_image, partial(vector_image_thumb, filepath, scaled_size)),
# (
# getattr(MediaTypes, CodePreview.media_type_name),
# partial(CodePreview.render, filepath),
# ),
# (
# getattr(MediaTypes, TextPreview.media_type_name),
# partial(TextPreview.render, filepath),
# ),
# (MediaTypes.audio, partial(audio_thumb, filepath, scaled_size, dpi_scale)),
# (MediaTypes.video, partial(video_thumb, filepath)),
# (
# MediaTypes.font,
# partial(font_small_thumb if is_thumb else font_full_preview, filepath, scaled_size),
# ),
# (MediaTypes.archive, partial(archive_thumb, filepath)),
# (MediaTypes.pdf, partial(pdf_thumb, filepath, scaled_size)),
# (MediaTypes.ebook, partial(epub_thumb, filepath)),
# (MediaTypes.iwork, partial(apple_embedded_thumb, filepath)),
# (MediaTypes.blender, partial(_blender_thumb, filepath)),
# (MediaTypes.krita, partial(krita_thumb, filepath)),
# (MediaTypes.clip_studio_paint, partial(clip_studio_thumb, filepath)),
# (MediaTypes.paint_dot_net, partial(paint_dot_net_thumb, filepath)),
# (MediaTypes.medibang_paint, partial(medibang_paint_thumb, filepath)),
# (MediaTypes.binary, partial(raster_image_thumb, filepath)),
# ]
# if filepath and filepath.is_file():
# try:
# ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower()
# for media_type, renderer in render_groups:
# if media_type.contains(ext, Context.RENDER):
# logger.info(f"{ext} in: {media_type.renderable}")
# image = renderer()
# TODO: Remove the need for these extra steps
if image and ext in MediaTypes.audio.renderable:
# TODO: Differentiate between album art and waveform
image = self._apply_overlay_color(image, UiColor.GREEN, theme)
is_savable_type = False
elif image and ext in MediaTypes.font.renderable:
# TODO: Differentiate between ful preview and small preview
image = self._apply_overlay_color(image, UiColor.BLUE, theme)
# # TODO: Remove the need for these extra steps
# if image and ext in MediaTypes.audio.renderable:
# # TODO: Differentiate between album art and waveform
# image = self._apply_overlay_color(image, UiColor.GREEN, theme)
# is_savable_type = False
# elif image and ext in MediaTypes.font.renderable:
# # TODO: Differentiate between ful preview and small preview
# image = self._apply_overlay_color(image, UiColor.BLUE, theme)
break
# break
if not image:
logger.warning(f"Could not render {ext}")
raise NoRendererError
# if not image:
# logger.warning(f"Could not render {ext}")
# raise NoRendererError
if image:
image = self._resize_image(image, (scaled_size, scaled_size))
# if image:
# image = self._resize_image(image, (scaled_size, scaled_size))
if cache_filename and is_savable_type and image and cache:
cache.save_image(image, cache_filename, mode="RGBA")
except (
AssertionError,
ChildProcessError,
DecompressionBombError,
UnidentifiedImageError,
ValueError,
) as e:
logger.error(
"[FileRenderer] Couldn't render thumbnail",
filepath=filepath,
error=type(e).__name__,
)
image = None
except NoRendererError:
image = None
# if cache_filename and is_savable_type and image and cache:
# cache.save_image(image, cache_filename, mode="RGBA")
# except (
# AssertionError,
# ChildProcessError,
# DecompressionBombError,
# UnidentifiedImageError,
# ValueError,
# ) as e:
# logger.error(
# "[FileRenderer] Couldn't render thumbnail",
# filepath=filepath,
# error=type(e).__name__,
# )
# image = None
# except NoRendererError:
# image = None
return image
# return image
# ---------- old old
# # ---------- old old
if filepath and filepath.is_file():
try:
ext: str = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower()
# eBooks ===========================================================================
if MediaCategories.is_ext_in_category(
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
):
image = epub_thumb(filepath, ext)
# Krita ============================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
):
image = krita_thumb(filepath)
# Clip Studio Paint ================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES
):
image = clip_studio_thumb(filepath)
# VTF ==============================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
):
image = vtf_thumb(filepath)
# Images ===========================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
):
# Raw Images -------------------------------------------------------------------
if MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
):
image = raw_image_thumb(filepath)
# Vector Images ----------------------------------------------------------------
elif ext in MediaTypes.vector_image.renderable:
image = vector_image_thumb(filepath, scaled_size)
# EXR Images -------------------------------------------------------------------
elif ext in [".exr"]:
image = exr_image_thumb(filepath)
# Normal Images ----------------------------------------------------------------
else:
image = raster_image_thumb(filepath)
# Videos ===========================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
):
image = video_thumb(filepath)
# PowerPoint =======================================================================
elif ext in {".pptx"}:
image = powerpoint_thumb(filepath)
# OpenDocument/OpenOffice ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
):
image = open_doc_thumb(filepath)
# Apple iWork + Creator Studio =====================================================
elif (
MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES)
or ext == ".pxd"
):
image = apple_embedded_thumb(filepath)
# Plain Text =======================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True
):
image = text_thumb(filepath)
# Fonts ============================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.FONT_TYPES, mime_fallback=True
):
if is_thumb:
# Short (Aa) Preview
image = font_small_thumb(filepath, scaled_size)
if image is not None:
image = self._apply_overlay_color(image, UiColor.BLUE, theme)
else:
# Large (Full Alphabet) Preview
image = font_full_preview(filepath, scaled_size)
# Audio ========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
):
image = audio_album_thumb(filepath, ext)
if image is None:
image = audio_waveform_thumb(filepath, ext, scaled_size, dpi_scale)
is_savable_type = False
if image is not None:
image = self._apply_overlay_color(image, UiColor.GREEN, theme)
# Blender ======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
):
image = _blender_thumb(filepath)
# PDF ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = pdf_thumb(filepath, scaled_size, ext)
# Archives =====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
image = archive_thumb(filepath, ext=ext)
# MDIPACK ======================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES):
image = medibang_paint_thumb(filepath)
# Paint.NET ====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES):
image = paint_dot_net_thumb(filepath)
# No Rendered Thumbnail ========================================
if not image:
raise NoRendererError
# if filepath and filepath.is_file():
# try:
# ext: str = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower()
# # eBooks ===========================================================================
# if MediaCategories.is_ext_in_category(
# ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
# ):
# image = epub_thumb(filepath, ext)
# # Krita ============================================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.KRITA_TYPES, mime_fallback=True
# ):
# image = krita_thumb(filepath)
# # Clip Studio Paint ================================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES
# ):
# image = clip_studio_thumb(filepath)
# # VTF ==============================================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
# ):
# image = vtf_thumb(filepath)
# # Images ===========================================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
# ):
# # Raw Images -------------------------------------------------------------------
# if MediaCategories.is_ext_in_category(
# ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
# ):
# image = raw_image_thumb(filepath)
# # Vector Images ----------------------------------------------------------------
# elif ext in MediaTypes.vector_image.renderable:
# image = vector_image_thumb(filepath, scaled_size)
# # EXR Images -------------------------------------------------------------------
# elif ext in [".exr"]:
# image = exr_image_thumb(filepath)
# # Normal Images ----------------------------------------------------------------
# else:
# image = raster_image_thumb(filepath)
# # Videos ===========================================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
# ):
# image = video_thumb(filepath)
# # PowerPoint =======================================================================
# elif ext in {".pptx"}:
# image = powerpoint_thumb(filepath)
# # OpenDocument/OpenOffice ==========================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
# ):
# image = open_doc_thumb(filepath)
# # Apple iWork + Creator Studio =====================================================
# elif (
# MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES)
# or ext == ".pxd"
# ):
# image = apple_embedded_thumb(filepath)
# # Plain Text =======================================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True
# ):
# image = text_thumb(filepath)
# # Fonts ============================================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.FONT_TYPES, mime_fallback=True
# ):
# if is_thumb:
# # Short (Aa) Preview
# image = font_small_thumb(filepath, scaled_size)
# if image is not None:
# image = self._apply_overlay_color(image, UiColor.BLUE, theme)
# else:
# # Large (Full Alphabet) Preview
# image = font_full_preview(filepath, scaled_size)
# # Audio ========================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
# ):
# image = audio_album_thumb(filepath, ext)
# if image is None:
# image = audio_waveform_thumb(filepath, ext, scaled_size, dpi_scale)
# is_savable_type = False
# if image is not None:
# image = self._apply_overlay_color(image, UiColor.GREEN, theme)
# # Blender ======================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
# ):
# image = _blender_thumb(filepath)
# # PDF ==========================================================
# elif MediaCategories.is_ext_in_category(
# ext, MediaCategories.PDF_TYPES, mime_fallback=True
# ):
# image = pdf_thumb(filepath, scaled_size, ext)
# # Archives =====================================================
# elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
# image = archive_thumb(filepath, ext=ext)
# # MDIPACK ======================================================
# elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES):
# image = medibang_paint_thumb(filepath)
# # Paint.NET ====================================================
# elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES):
# image = paint_dot_net_thumb(filepath)
# # No Rendered Thumbnail ========================================
# if not image:
# raise NoRendererError
if image:
image = self._resize_image(image, (scaled_size, scaled_size))
# if image:
# image = self._resize_image(image, (scaled_size, scaled_size))
if cache_filename and is_savable_type and image and cache:
cache.save_image(image, cache_filename, mode="RGBA")
# if cache_filename and is_savable_type and image and cache:
# cache.save_image(image, cache_filename, mode="RGBA")
except (
AssertionError,
ChildProcessError,
DecompressionBombError,
UnidentifiedImageError,
ValueError,
) as e:
logger.error(
"[FileRenderer] Couldn't render thumbnail",
filepath=filepath,
error=type(e).__name__,
)
image = None
except NoRendererError:
image = None
# except (
# AssertionError,
# ChildProcessError,
# DecompressionBombError,
# UnidentifiedImageError,
# ValueError,
# ) as e:
# logger.error(
# "[FileRenderer] Couldn't render thumbnail",
# filepath=filepath,
# error=type(e).__name__,
# )
# image = None
# except NoRendererError:
# image = None
return image
# return image
def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image:
orig_x, orig_y = image.size
+132 -119
View File
@@ -5,157 +5,170 @@
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
from mutagen import flac, id3, mp4
from mutagen._util import MutagenError
from PIL import Image, ImageDraw
from PIL import ImageDraw, ImageFont, UnidentifiedImageError
from PIL.Image import DecompressionBombError, Image, Resampling
from PIL.Image import new as new_image
from PIL.Image import open as open_image
from tagstudio.core.enums import Theme
from tagstudio.previews.base_preview import BasePreview
from tagstudio.previews.effects import apply_overlay_color
from tagstudio.previews.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
)
from tagstudio.qt.views.styles.palette import UiColor
logger = structlog.get_logger(__name__)
def audio_thumb(filepath: Path, size: int, dpi_scale: float) -> Image.Image | None:
"""Return an album cover preview, or a waveform if cover art does not exist.
class AudioPreview(BasePreview):
media_type_name = "audio"
Args:
filepath (Path): The path of the file.
size (int): The size of the thumbnail.
dpi_scale (float): The screen pixel ratio.
"""
image = audio_album_thumb(filepath)
if not image:
image = audio_waveform_thumb(filepath, size, dpi_scale)
@override
@classmethod
def render(
cls,
filepath: Path,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return cls.audio_album_thumb(filepath) or cls.audio_waveform_thumb(
filepath, theme, size, dpi_scale
)
return image
@staticmethod
def audio_album_thumb(filepath: Path) -> Image | None:
"""Return an album cover thumb from an audio file if a cover is present.
Args:
filepath (Path): The path of the file.
"""
image: Image | None = None
ext = filepath.suffix.lower()
try:
if not filepath.is_file():
raise FileNotFoundError
def audio_album_thumb(filepath: Path) -> Image.Image | None:
"""Return an album cover thumb from an audio file if a cover is present.
artwork = None
if ext in {".mp3", ".aif", ".aiff"}:
id3_tags: id3.ID3 = id3.ID3(filepath)
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
if id3_covers:
artwork = open_image(BytesIO(id3_covers[0].data))
elif ext in {".flac"}:
flac_tags: flac.FLAC = flac.FLAC(filepath)
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
if flac_covers:
artwork = open_image(BytesIO(flac_covers[0].data))
elif ext in {".mp4", ".m4a", ".aac", ".alac"}:
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
if mp4_covers:
artwork = open_image(BytesIO(mp4_covers[0]))
if artwork:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError,
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
Args:
filepath (Path): The path of the file.
"""
image: Image.Image | None = None
ext = filepath.suffix.lower()
try:
if not filepath.is_file():
raise FileNotFoundError
@staticmethod
def audio_waveform_thumb(
filepath: Path, theme: Theme, size: tuple[int, int], dpi_scale: float
) -> Image | None:
"""Render a waveform image from an audio file.
artwork = None
if ext in {".mp3", ".aif", ".aiff"}:
id3_tags: id3.ID3 = id3.ID3(filepath)
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
if id3_covers:
artwork = Image.open(BytesIO(id3_covers[0].data))
elif ext in {".flac"}:
flac_tags: flac.FLAC = flac.FLAC(filepath)
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
if flac_covers:
artwork = Image.open(BytesIO(flac_covers[0].data))
elif ext in {".mp4", ".m4a", ".aac", ".alac"}:
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
if mp4_covers:
artwork = Image.open(BytesIO(mp4_covers[0]))
if artwork:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError,
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
Args:
filepath (Path): The path of the file.
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)
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)
def audio_waveform_thumb(filepath: Path, size: int, dpi_scale: float) -> Image.Image | None:
"""Render a waveform image from an audio file.
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
Args:
filepath (Path): The path of the file.
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 * base_scale
allow_small_min: bool = False
im: Image.Image | None = None
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)
try:
bar_count: int = min(math.floor((size // dpi_scale) / 5), 64)
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)
if maximum_item > highest_line:
highest_line = maximum_item
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
maximum_item = 0
count = 1
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)
line_ratio = max(highest_line / bar_height, 1)
if maximum_item > highest_line:
highest_line = maximum_item
im = new_image("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
maximum_item = 0
count = 1
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
line_ratio = max(highest_line / bar_height, 1)
# If small minimums are not allowed, raise all values
# smaller than the line width to the same value.
if not allow_small_min:
item_height = max(item_height, line_width)
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
draw.rounded_rectangle(
(
current_x,
current_y,
(current_x + line_width),
(current_y + item_height),
),
radius=100 * base_scale,
fill=("#FF0000"),
outline=("#FFFF00"),
width=max(math.ceil(line_width / 6), base_scale),
)
# If small minimums are not allowed, raise all values
# smaller than the line width to the same value.
if not allow_small_min:
item_height = max(item_height, line_width)
current_x = current_x + line_width + bar_margin
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
im.resize(size, Resampling.BILINEAR)
im = apply_overlay_color(im, UiColor.GREEN, theme)
draw.rounded_rectangle(
(
current_x,
current_y,
(current_x + line_width),
(current_y + item_height),
),
radius=100 * base_scale,
fill=("#FF0000"),
outline=("#FFFF00"),
width=max(math.ceil(line_width / 6), base_scale),
)
except Exception as e:
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
current_x = current_x + line_width + bar_margin
im.resize((size, size), Image.Resampling.BILINEAR)
except Exception as e:
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
return im
return im
+9 -22
View File
@@ -10,17 +10,9 @@ from typing import override
import structlog
from PIL import ImageFont, UnidentifiedImageError
from PIL.Image import (
DecompressionBombError,
Image,
Resampling,
)
from PIL.Image import (
new as new_image,
)
from PIL.Image import (
open as open_image,
)
from PIL.Image import DecompressionBombError, Image, Resampling
from PIL.Image import new as new_image
from PIL.Image import open as open_image
from pygments import highlight
from pygments.formatters import ImageFormatter
from pygments.lexers import PythonLexer
@@ -49,19 +41,19 @@ logger = structlog.get_logger(__name__)
class TextLightStyle(Style):
background = "#FFFFFF"
foreground = "#111111"
foreground = "#000000"
background_color = background
styles = {
Generic: foreground,
Text: foreground,
Literal: foreground,
String: foreground,
Generic: foreground + " bold",
Text: foreground + " bold",
Literal: foreground + " bold",
String: foreground + " bold",
}
class TextDarkStyle(Style):
background = "#1e1e1e"
background = "#111111"
foreground = "#FFFFFF"
background_color = background
@@ -81,10 +73,6 @@ class TextDarkStyle(Style):
}
# text_light_style = TextLightStyle()
# text_dark_style = TextDarkStyle()
class TextPreview(BasePreview):
media_type_name = "plaintext"
font = ImageFont.load_default(20)
@@ -105,7 +93,6 @@ class TextPreview(BasePreview):
)
@staticmethod
def text_thumb(
filepath: Path,
size: tuple[int, int],
@@ -6,7 +6,9 @@ from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.core.enums import Theme
from tagstudio.previews.gradients import linear_gradient
from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color
# TODO: Consolidate the built-in QT theme values with the values
# here, in enums.py, and in palette.py.