further additions and changes

This commit is contained in:
Travis Abendshien
2026-08-20 10:23:30 -07:00
parent 5ada5c91f5
commit f66b8a88bd
5 changed files with 296 additions and 80 deletions
+202 -51
View File
@@ -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 = [
+31 -13
View File
@@ -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"]:
+25 -13
View File
@@ -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
@@ -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
+31
View File
@@ -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