diff --git a/pyproject.toml b/pyproject.toml index a0ec9bb5..08b88515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 900f5655..4dbed40f 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -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", diff --git a/src/tagstudio/previews/effects.py b/src/tagstudio/previews/effects.py new file mode 100644 index 00000000..8dcce647 --- /dev/null +++ b/src/tagstudio/previews/effects.py @@ -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 diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py index ecbc7ff9..142e1f34 100644 --- a/src/tagstudio/previews/file_renderer.py +++ b/src/tagstudio/previews/file_renderer.py @@ -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 diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py index ba9c2181..50692923 100644 --- a/src/tagstudio/previews/renderers/audio.py +++ b/src/tagstudio/previews/renderers/audio.py @@ -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 diff --git a/src/tagstudio/previews/renderers/text.py b/src/tagstudio/previews/renderers/text.py index 1460e63c..6a9d79cd 100644 --- a/src/tagstudio/previews/renderers/text.py +++ b/src/tagstudio/previews/renderers/text.py @@ -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], diff --git a/src/tagstudio/qt/views/styles/color_overlay.py b/src/tagstudio/qt/views/styles/color_overlay.py index b5097c0b..3a394547 100644 --- a/src/tagstudio/qt/views/styles/color_overlay.py +++ b/src/tagstudio/qt/views/styles/color_overlay.py @@ -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.