From f66b8a88bdefc464a7f9185ca69063e5a8ad21e0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:23:30 -0700 Subject: [PATCH] further additions and changes --- src/tagstudio/core/media_types.py | 253 ++++++++++++++---- src/tagstudio/previews/file_renderer.py | 44 ++- src/tagstudio/previews/renderers/audio.py | 38 ++- .../previews/renderers/raster_image.py | 10 +- src/tagstudio/previews/renderers/text.py | 31 +++ 5 files changed, 296 insertions(+), 80 deletions(-) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index b06e20bb..324ca062 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -13,7 +13,7 @@ import structlog logger = structlog.get_logger(__name__) -class _Contexts(Enum): +class Context(Enum): """An enum representing the use for a media type.""" SEARCH = auto() @@ -23,13 +23,13 @@ class _Contexts(Enum): class _Type: """A complete description of a media type and its uses.""" - def __init__(self, exts: str | list[str], contexts: _Contexts | list[_Contexts]) -> None: + def __init__(self, exts: str | list[str], contexts: Context | list[Context]) -> None: if isinstance(exts, str): self.exts = [exts] else: self.exts = exts - if isinstance(contexts, _Contexts): + if isinstance(contexts, Context): self.contexts = [contexts] else: self.contexts = contexts @@ -47,23 +47,41 @@ class MediaTypeGroup: searchable_set: set[str] = set() renderable_set: set[str] = set() for type_ in self.types: - if _Contexts.SEARCH in type_.contexts: + if Context.SEARCH in type_.contexts: for ext in type_.exts: searchable_set.add(ext) - if _Contexts.RENDER in type_.contexts: + if Context.RENDER in type_.contexts: for ext in type_.exts: renderable_set.add(ext) self.searchable = frozenset(searchable_set) self.renderable = frozenset(renderable_set) + def contains(self, ext: str, context: Context) -> bool: + # logger.info(f"MIME Guess: {mimetypes.guess_type(Path(f'x{ext}'), strict=False)}") + return ( + context == Context.SEARCH + and ext in self.searchable + or context == Context.RENDER + and ext in self.renderable + ) + # return True + + # if self.is_iana: + # mime_type: str | None = mimetypes.guess_type(Path(f"x{ext}"), strict=False)[0] + # if mime_type is not None and mime_type.startswith(self.name_key): + # logger.info(f"MIME type found for {ext}: {mime_type}") + # return True + + # return False + class MediaTypes: - SEARCH, RENDER = _Contexts.SEARCH, _Contexts.RENDER + SEARCH, RENDER = Context.SEARCH, Context.RENDER # Adobe ---------------------------------------------------------------------------------------- - adobe_photoshop_types = MediaTypeGroup( + adobe_photoshop = MediaTypeGroup( "adobe.photoshop", [ _Type(".pdd", SEARCH), @@ -71,39 +89,120 @@ class MediaTypes: _Type(".psd", SEARCH), ], ) - - adobe_illustrator_types = MediaTypeGroup("adobe.illustrator", [_Type(".ai", SEARCH)]) - - pdf_types = MediaTypeGroup( + adobe_illustrator = MediaTypeGroup( + "adobe.illustrator", + [ + _Type(".ai", SEARCH), + ], + ) + pdf = MediaTypeGroup( "pdf", [ _Type(".pdf", [SEARCH, RENDER]), ], ) - adobe_types = MediaTypeGroup( - "type.adobe", adobe_photoshop_types.types + adobe_illustrator_types.types + pdf_types.types + adobe = MediaTypeGroup("adobe", adobe_photoshop.types + adobe_illustrator.types + pdf.types) + + # Affinity ------------------------------------------------------------------------------------- + affinity_photo = MediaTypeGroup( + "affinity.photo", + [_Type(".afphoto", [SEARCH, RENDER])], + ) + affinity_designer = MediaTypeGroup( + "affinity.designer", + [_Type(".afdesign", [SEARCH, RENDER])], + ) + affinity_publisher = MediaTypeGroup( + "affinity.publisher", + [_Type([".afpublisher", ".afpub"], [SEARCH, RENDER])], ) - # Raster Images -------------------------------------------------------------------------------- - raster_image_types = MediaTypeGroup( - "image.raster", + affinity = MediaTypeGroup( + "affinity", + affinity_photo.types + + affinity_designer.types + + affinity_publisher.types + + [_Type(".af", [SEARCH, RENDER])], + ) + + # MIDI ----------------------------------------------------------------------------------------- + midi = MediaTypeGroup("midi", [_Type([".mid", ".midi"], SEARCH)]) + + # Audio ---------------------------------------------------------------------------------------- + audio = MediaTypeGroup( + "audio", [ + _Type(".aac", [RENDER, SEARCH]), _Type( - [".jfif", ".jpeg_large", ".jpeg", ".jpg_large", ".jpg"], - [SEARCH, RENDER], + [".aif", ".aiff", ".aifc"], + [RENDER, SEARCH], ), - _Type(".psd", RENDER), + _Type(".caf", [RENDER, SEARCH]), + _Type(".flac", [RENDER, SEARCH]), + _Type(".m4a", [RENDER, SEARCH]), + _Type(".m4p", [RENDER, SEARCH]), + _Type(".mp3", [RENDER, SEARCH]), + _Type(".ogg", [RENDER, SEARCH]), + _Type(".wav", [RENDER, SEARCH]), + _Type(".wma", [RENDER, SEARCH]), + ] + + midi.types, + ) + + # RAW Images ----------------------------------------------------------------------------------- + raw_image = MediaTypeGroup( + "image.raw", + [ + _Type(".arw", [SEARCH, RENDER]), + _Type(".cr2", [SEARCH, RENDER]), + _Type(".cr3", [SEARCH, RENDER]), + _Type(".crw", [SEARCH, RENDER]), + _Type(".dng", [SEARCH, RENDER]), + _Type(".nef", [SEARCH, RENDER]), + _Type(".nrw", [SEARCH, RENDER]), + _Type(".orf", [SEARCH, RENDER]), + _Type(".r3d", [SEARCH, RENDER]), + _Type(".raf", [SEARCH, RENDER]), + _Type(".raw", [SEARCH, RENDER]), + _Type(".rw2", [SEARCH, RENDER]), + _Type(".srf", [SEARCH, RENDER]), + _Type(".srf2", [SEARCH, RENDER]), ], ) - # FIXME: Should the file renderer fallback to the search context if no render context is found, - # to use as a default preview? - # Because some files like .eps ot .pyc are never going to be rendered, but still should have - # default icons for the categories that they're in. - # OR should there be a new context? + # Raster Images -------------------------------------------------------------------------------- + raster_image = MediaTypeGroup( + "image.raster", + [ + _Type(".apng", [SEARCH, RENDER]), + _Type(".avif", [SEARCH, RENDER]), + _Type(".bmp", [SEARCH, RENDER]), + _Type(".exr", [SEARCH, RENDER]), + _Type(".gif", [SEARCH, RENDER]), + _Type( + [ + ".jfif", + ".jpeg_large", + ".jpeg", + ".jpg_large", + ".jpg", + ], + [SEARCH, RENDER], + ), + _Type(".jxl", [SEARCH, RENDER]), + _Type(".png", [SEARCH, RENDER]), + _Type(".psb", RENDER), + _Type(".psd", RENDER), + _Type(".webp", [SEARCH, RENDER]), + _Type([".heic", ".heif"], [SEARCH, RENDER]), + _Type([".j2k", ".jp2", ".jpg2"], [SEARCH, RENDER]), + _Type([".tif", ".tiff"], [SEARCH, RENDER]), + ], + ) - vector_image_types = MediaTypeGroup( + # Vector Images -------------------------------------------------------------------------------- + vector = MediaTypeGroup( "image.vector", [ _Type(".ai", RENDER), @@ -115,60 +214,112 @@ class MediaTypes: ], ) - binary_types = MediaTypeGroup( + binary = MediaTypeGroup( "binary", [ + _Type(".dll", [RENDER, SEARCH]), + _Type(".dylib", [RENDER, SEARCH]), + _Type(".exe", [RENDER, SEARCH]), + _Type(".o", [RENDER, SEARCH]), _Type(".pyc", [RENDER, SEARCH]), _Type(".pyd", [RENDER, SEARCH]), _Type(".pyo", [RENDER, SEARCH]), - _Type(".dll", [RENDER, SEARCH]), - _Type(".o", [RENDER, SEARCH]), - _Type(".dylib", [RENDER, SEARCH]), - _Type(".exe", [RENDER, SEARCH]), ], ) - python_types = MediaTypeGroup( + python = MediaTypeGroup( "python", [ _Type(".ipynb", [RENDER, SEARCH]), _Type(".py", [RENDER, SEARCH]), - _Type(".pyc", [SEARCH]), - _Type(".pyd", [SEARCH]), + _Type(".pyc", SEARCH), + _Type(".pyd", SEARCH), _Type(".pyi", [RENDER, SEARCH]), - _Type(".pyo", [SEARCH]), + _Type(".pyo", SEARCH), ], ) - javascript_types = MediaTypeGroup( + javascript = MediaTypeGroup( "javascript", [ - _Type(".cjs", [SEARCH]), - _Type(".js", [SEARCH]), - _Type(".jsx", [SEARCH]), - _Type(".mjs", [SEARCH]), + _Type(".cjs", [SEARCH, RENDER]), + _Type(".js", [SEARCH, RENDER]), + _Type(".jsx", [SEARCH, RENDER]), + _Type(".mjs", [SEARCH, RENDER]), ], ) - typescript_types = MediaTypeGroup( + typescript = MediaTypeGroup( "typescript", [ - _Type(".cts", [SEARCH]), - _Type(".mts", [SEARCH]), - _Type(".ts", [SEARCH]), - _Type(".tsx", [SEARCH]), + _Type(".cts", [SEARCH, RENDER]), + _Type(".mts", [SEARCH, RENDER]), + # _Type(".ts", [SEARCH, RENDER]), + _Type(".tsx", [SEARCH, RENDER]), ], ) - # TODO: Move to FileRenderer - unrenderable_types = binary_types.types # Eventually exclude .exe and stuff - - # NOTE: This is a subjective group used for grouping files together for searches - # and for creating color-on-black syntax highlighted previews. - code_types = MediaTypeGroup( - "type.code", python_types.types + javascript_types.types + typescript_types.types + # Shell Script --------------------------------------------------------------------------------- + shell = MediaTypeGroup( + "shell", + [ + _Type(".bat", [SEARCH, RENDER]), + _Type(".csh", [SEARCH, RENDER]), + _Type(".fish", [SEARCH, RENDER]), + _Type(".ps1", [SEARCH, RENDER]), + _Type(".sh", [SEARCH, RENDER]), + ], ) + # Markdown ------------------------------------------------------------------------------------- + markdown = MediaTypeGroup( + "markdown", + [ + _Type( + [ + ".markdown", + ".md", + ".mkd", + ".rmd", + ], + [SEARCH, RENDER], + ), + ], + ) + + # Plaintext ------------------------------------------------------------------------------------ + plaintext = MediaTypeGroup( + "plaintext", + [_Type([".txt", ".text"], [SEARCH, RENDER])] + markdown.types, + ) + + # Code ----------------------------------------------------------------------------------------- + code_types = MediaTypeGroup( + "type.code", + python.types + javascript.types + typescript.types + shell.types, + ) + + # Video ---------------------------------------------------------------------------------------- + video = MediaTypeGroup( + "video", + [ + _Type(".3gp", [SEARCH, RENDER]), + _Type(".avi", [SEARCH, RENDER]), + _Type(".flv", [SEARCH, RENDER]), + _Type(".gifv", [SEARCH, RENDER]), + _Type(".hevc", [SEARCH, RENDER]), + _Type(".m4p", [SEARCH, RENDER]), + _Type(".m4v", [SEARCH, RENDER]), + _Type(".mkv", [SEARCH, RENDER]), + _Type(".mov", [SEARCH, RENDER]), + _Type(".mp4", [SEARCH, RENDER]), + _Type(".webm", [SEARCH, RENDER]), + _Type(".wmv", [SEARCH, RENDER]), + ], + ) + + # ------------------ + @staticmethod def all_media_types(): static_methods = [ diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py index 810a1388..18c22bfb 100644 --- a/src/tagstudio/previews/file_renderer.py +++ b/src/tagstudio/previews/file_renderer.py @@ -17,7 +17,13 @@ from PIL.Image import DecompressionBombError from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.ignore import Ignore -from tagstudio.core.media_types import MediaCategories, MediaTypeGroup, MediaTypeOld, MediaTypes +from tagstudio.core.media_types import ( + Context, + MediaCategories, + MediaTypeGroup, + MediaTypeOld, + MediaTypes, +) from tagstudio.core.utils.types import unwrap from tagstudio.previews.gradients import four_corner_gradient from tagstudio.previews.renderers.archive import ( @@ -27,7 +33,7 @@ from tagstudio.previews.renderers.archive import ( open_doc_thumb, powerpoint_thumb, ) -from tagstudio.previews.renderers.audio import audio_album_thumb, audio_waveform_thumb +from tagstudio.previews.renderers.audio import audio_album_thumb, audio_thumb, audio_waveform_thumb from tagstudio.previews.renderers.blender import blender_thumb from tagstudio.previews.renderers.clip_studio import clip_studio_thumb from tagstudio.previews.renderers.ebook import epub_thumb @@ -41,7 +47,7 @@ from tagstudio.previews.renderers.raster_image import ( raw_image_thumb, ) from tagstudio.previews.renderers.source_engine import vtf_thumb -from tagstudio.previews.renderers.text import text_thumb +from tagstudio.previews.renderers.text import code_thumb, text_thumb from tagstudio.previews.renderers.vector_image import vector_image_thumb from tagstudio.previews.renderers.video import video_thumb from tagstudio.qt.app_settings import ( @@ -761,23 +767,35 @@ class FileRenderer: # Ordered groups of file renderers. # A file extension is rendered with the first group it's found in. render_groups: list[tuple[MediaTypeGroup, Callable[..., Image.Image | None]]] = [ - (MediaTypes.raster_image_types, partial(raster_image_thumb, filepath)), - (MediaTypes.vector_image_types, partial(vector_image_thumb, filepath, scaled_size)), - (MediaTypes.binary_types, partial(raster_image_thumb, filepath)), - (MediaTypes.python_types, partial(text_thumb, filepath)), - (MediaTypes.pdf_types, partial(pdf_thumb, filepath, scaled_size)), + (MediaTypes.raw_image, partial(raw_image_thumb, filepath)), + (MediaTypes.binary, partial(raster_image_thumb, filepath)), + (MediaTypes.python, partial(text_thumb, filepath)), + (MediaTypes.pdf, partial(pdf_thumb, filepath, scaled_size)), + (MediaTypes.raster_image, partial(raster_image_thumb, filepath)), + (MediaTypes.vector, partial(vector_image_thumb, filepath, scaled_size)), + (MediaTypes.code_types, partial(code_thumb, filepath)), + (MediaTypes.plaintext, partial(text_thumb, filepath)), + (MediaTypes.audio, partial(audio_thumb, filepath, scaled_size, dpi_scale)), + (MediaTypes.video, partial(video_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 ext in media_type.renderable: - logger.warning(f"{ext}: {media_type.renderable}") + if media_type.contains(ext, Context.RENDER): + logger.info(f"{ext} in: {media_type.renderable}") image = renderer() - continue + + # 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 + + break if not image: - logger.warning(f"No match for {ext}") + logger.warning(f"Could not render {ext}") raise NoRendererError if image: @@ -836,7 +854,7 @@ class FileRenderer: ): image = raw_image_thumb(filepath) # Vector Images ---------------------------------------------------------------- - elif ext in MediaTypes.vector_image_types.renderable: + elif ext in MediaTypes.vector.renderable: image = vector_image_thumb(filepath, scaled_size) # EXR Images ------------------------------------------------------------------- elif ext in [".exr"]: diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py index 6b981a7e..ba9c2181 100644 --- a/src/tagstudio/previews/renderers/audio.py +++ b/src/tagstudio/previews/renderers/audio.py @@ -20,30 +20,45 @@ from tagstudio.previews.vendored.pydub.audio_segment import ( logger = structlog.get_logger(__name__) -def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: +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. + + 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) + + return image + + +def audio_album_thumb(filepath: Path) -> Image.Image | None: """Return an album cover thumb from an audio file if a cover is present. Args: filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). """ image: Image.Image | None = None + ext = filepath.suffix.lower() try: if not filepath.is_file(): raise FileNotFoundError artwork = None - if ext in [".mp3"]: + 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"]: + 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"]: + 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: @@ -61,16 +76,13 @@ def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: return image -def audio_waveform_thumb( - filepath: Path, ext: str, size: int, pixel_ratio: float -) -> Image.Image | None: +def audio_waveform_thumb(filepath: Path, size: int, dpi_scale: float) -> Image.Image | None: """Render a waveform image from an audio file. Args: filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - size (tuple[int,int]): The size of the thumbnail. - pixel_ratio (float): The screen pixel ratio. + 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. @@ -81,8 +93,8 @@ def audio_waveform_thumb( im: Image.Image | None = None try: - bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) - audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType] + 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 diff --git a/src/tagstudio/previews/renderers/raster_image.py b/src/tagstudio/previews/renderers/raster_image.py index 3af649f1..67e9ccea 100644 --- a/src/tagstudio/previews/renderers/raster_image.py +++ b/src/tagstudio/previews/renderers/raster_image.py @@ -39,13 +39,17 @@ def raster_image_thumb(filepath: Path) -> Image.Image | None: """ im: Image.Image | None = None try: + if filepath.suffix.lower() == ".exr": + return exr_image_thumb(filepath) + with filepath.open("rb") as file: im = image_from_bytes(BytesIO(file.read())) except ( - FileNotFoundError, - UnidentifiedImageError, DecompressionBombError, + FileNotFoundError, NotImplementedError, + OSError, + UnidentifiedImageError, ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @@ -100,8 +104,8 @@ def raw_image_thumb(filepath: Path) -> Image.Image | None: ) except ( DecompressionBombError, - LibRawIOError, LibRawFileUnsupportedError, + LibRawIOError, ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im diff --git a/src/tagstudio/previews/renderers/text.py b/src/tagstudio/previews/renderers/text.py index 94028e05..fdeddfb5 100644 --- a/src/tagstudio/previews/renderers/text.py +++ b/src/tagstudio/previews/renderers/text.py @@ -53,3 +53,34 @@ def text_thumb(filepath: Path) -> Image.Image | None: ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im + + +def code_thumb(filepath: Path) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image | None = None + + bg_color: str = "#000000" + fg_color: str = "#00FF00" + + try: + encoding = detect_char_encoding(filepath) + with open(filepath, encoding=encoding) as text_file: + text = text_file.read(256) + bg = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(bg) + draw.text((16, 16), text, fill=fg_color) + im = bg + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + FileNotFoundError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im