Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
49845e9
Begin splitting out renderers
TrigamDev Nov 17, 2025
ef6c061
EBook renderer (and archive file wrappers)
TrigamDev Nov 17, 2025
e979043
Oops, remove that
TrigamDev Nov 17, 2025
4e52f84
VTF renderer
TrigamDev Nov 17, 2025
55c480c
Text renderer
TrigamDev Nov 17, 2025
9a72d93
Font renderer
TrigamDev Nov 18, 2025
7ef51b3
Audio renderer, use a context object for params for each renderer
TrigamDev Nov 18, 2025
a2c14ac
Blender renderer
TrigamDev Nov 18, 2025
89654b9
Blender renderer
TrigamDev Nov 18, 2025
46b44b2
PDF renderer
TrigamDev Nov 18, 2025
f1c60c8
PowerPoint renderer
TrigamDev Nov 18, 2025
fde3781
OpenDoc renderer
TrigamDev Nov 18, 2025
439f6ae
iWork renderer (and fix it straight up just not working)
TrigamDev Nov 18, 2025
a391724
Tweak archive file wrappers to handle cases where the file name in in…
TrigamDev Nov 18, 2025
7aa9d1f
Oops, forgot to remove that
TrigamDev Nov 18, 2025
2f98c5b
Image renderers (exr not working at the moment)
TrigamDev Nov 18, 2025
029ea8d
Better EXR image handling
TrigamDev Nov 18, 2025
a7201d9
Tweaks
TrigamDev Nov 18, 2025
d89a8c3
.. more tweaks
TrigamDev Nov 18, 2025
cc8b90e
Actually handle `NoRendererError`s maybe
TrigamDev Nov 18, 2025
75b043c
Tweak media types and better document them
TrigamDev Nov 23, 2025
b9e8202
Handle cases where a file extension has multiple possible renderers
TrigamDev Nov 24, 2025
da85ebe
Merge branch 'TagStudioDev:main' into refactor/thumbnail-renderers
TrigamDev Nov 24, 2025
1aa9bd9
Remove debug logs
TrigamDev Nov 24, 2025
b68e63a
Fix pyright errors
TrigamDev Nov 26, 2025
ff1b849
Merge branch 'main' into refactor/thumbnail-renderers
TrigamDev Nov 26, 2025
1cd0a7b
Fix more pyright errors
TrigamDev Nov 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 87 additions & 66 deletions docs/preview-support.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ dependencies = [
"ffmpeg-python~=0.2",
"humanfriendly==10.*",
"mutagen~=1.47",
"numexpr~=2.14.1",
"numpy~=2.2",
"opencv_python~=4.11",
"openexr~=3.4.3",
"pillow-avif-plugin~=1.5",
"Pillow>=10.2,<12",
"pillow-heif~=0.22",
"pillow-jxl-plugin~=1.3",
Expand Down
53 changes: 45 additions & 8 deletions src/tagstudio/core/media_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class MediaType(str, Enum):
FONT = "font"
IMAGE_ANIMATED = "image_animated"
IMAGE_RAW = "image_raw"
IMAGE_EXR = "image_exr"
IMAGE_VECTOR = "image_vector"
IMAGE = "image"
INSTALLER = "installer"
Expand All @@ -51,6 +52,7 @@ class MediaType(str, Enum):
PACKAGE = "package"
PDF = "pdf"
PLAINTEXT = "plaintext"
POWERPOINT = "powerpoint"
PRESENTATION = "presentation"
PROGRAM = "program"
SHADER = "shader"
Expand Down Expand Up @@ -109,7 +111,6 @@ class MediaCategories:
".psd",
}
_AFFINITY_PHOTO_SET: set[str] = {".afphoto"}
_KRITA_SET: set[str] = {".kra", ".krz"}
_ARCHIVE_SET: set[str] = {
".7z",
".gz",
Expand Down Expand Up @@ -296,16 +297,32 @@ class MediaCategories:
".cr2",
".cr3",
".crw",
".dcs",
".dcr",
".dng",
".drf",
".erf",
".k25",
".kdc",
".mdc",
".mef",
".mos",
".mrw",
".nef",
".nrw",
".orf",
".pef",
".raf",
".raw",
".rw2",
".srf",
".srf2",
".sr2",
".srw",
".x3f",
".3fr",
}
_IMAGE_EXR_SET: set[str] = {".exr"}
_IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"}
_IMAGE_RASTER_SET: set[str] = {
".apng",
Expand All @@ -316,24 +333,34 @@ class MediaCategories:
".heic",
".heif",
".icns",
".j2k",
".jpeg",
".jpg",
".jpe",
".jif",
".jfif",
".jp2",
".jfi",
".jpeg_large",
".jpeg",
".jpg_large",
".jpg",
".jp2",
".j2k",
".jpf",
".jpm",
".jpg2",
".j2c",
".jpc",
".jpx",
".mj2",
".jxl",
".png",
".psb",
".psd",
".tif",
".tiff",
".tif",
".webp",
}
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
_IWORK_SET: set[str] = {".key", ".pages", ".numbers"}
_KRITA_SET: set[str] = {".kra", ".krz"}
_MATERIAL_SET: set[str] = {".mtl"}
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
_OPEN_DOCUMENT_SET: set[str] = {
Expand Down Expand Up @@ -375,11 +402,11 @@ class MediaCategories:
"license",
"readme",
}
_POWERPOINT_SET: set[str] = {".pptx"}
_PRESENTATION_SET: set[str] = {
".key",
".odp",
".ppt",
".pptx",
}
_PROGRAM_SET: set[str] = {".app", ".bin", ".exe"}
_SOURCE_ENGINE_SET: set[str] = {".vtf"}
Expand Down Expand Up @@ -410,6 +437,7 @@ class MediaCategories:
".m4v",
".mkv",
".mov",
".movie",
".mp4",
".webm",
".wmv",
Expand Down Expand Up @@ -500,6 +528,9 @@ class MediaCategories:
is_iana=False,
name="raw image",
)
IMAGE_EXR_TYPES = MediaCategory(
media_type=MediaType.IMAGE_EXR, extensions=_IMAGE_EXR_SET, is_iana=False, name="exr image"
)
IMAGE_VECTOR_TYPES = MediaCategory(
media_type=MediaType.IMAGE_VECTOR,
extensions=_IMAGE_VECTOR_SET,
Expand Down Expand Up @@ -566,9 +597,15 @@ class MediaCategories:
is_iana=False,
name="plaintext",
)
POWERPOINT_TYPES = MediaCategory(
media_type=MediaType.POWERPOINT,
extensions=_POWERPOINT_SET,
is_iana=False,
name="powerpoint",
)
PRESENTATION_TYPES = MediaCategory(
media_type=MediaType.PRESENTATION,
extensions=_PRESENTATION_SET,
extensions=_PRESENTATION_SET | _POWERPOINT_SET,
is_iana=False,
name="presentation",
)
Expand Down
11 changes: 10 additions & 1 deletion src/tagstudio/qt/controllers/preview_thumb_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import TYPE_CHECKING

import cv2
import OpenEXR
import rawpy
import structlog
from PIL import Image, UnidentifiedImageError
Expand Down Expand Up @@ -49,11 +50,19 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData:
stats.width = image.width
stats.height = image.height
except (
rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue]
FileNotFoundError,
):
pass
elif MediaCategories.IMAGE_EXR_TYPES.contains(ext, mime_fallback=True):
try:
exr_file = OpenEXR.File(str(filepath))
part = exr_file.parts[0]
stats.width = part.width()
stats.height = part.height()
except Exception:
pass
elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True):
try:
image = Image.open(str(filepath))
Expand Down
21 changes: 21 additions & 0 deletions src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Literal


class ArchiveFile(ABC):
@abstractmethod
def __init__(self, path: Path, mode: Literal["r"]) -> None:
pass

@abstractmethod
def get_name_list(self) -> list[str]:
raise NotImplementedError

@abstractmethod
def has_file_name(self, file_name: str) -> bool:
raise NotImplementedError

@abstractmethod
def read(self, file_name: str) -> bytes | None:
raise NotImplementedError
52 changes: 52 additions & 0 deletions src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from pathlib import Path
from types import TracebackType
from typing import Literal, Self

import rarfile

from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile


class RarFile(ArchiveFile):
"""Wrapper around rarfile.RarFile."""

def __init__(self, path: Path, mode: Literal["r"]) -> None:
super().__init__(path, mode)
self.path = path
self.__rar_file: rarfile.RarFile = rarfile.RarFile(path, mode)

def __enter__(self) -> Self:
return self

def __exit__(
self,
exception_type: type[BaseException] | None,
exception_value: BaseException | None,
exception_traceback: TracebackType | None,
) -> None:
self.__rar_file.close()

def get_name_list(self) -> list[str]:
without_own_file_name: map = map(
lambda file_name: file_name.replace(f"{self.path.name}/", ""),
self.__rar_file.namelist(),
)
without_empty_items: filter = filter(None, without_own_file_name)

return list(without_empty_items)

def has_file_name(self, file_name: str) -> bool:
return file_name in self.get_name_list()

def read(self, file_name: str) -> bytes | None:
search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)]
try:
for file_path in search_paths:
try:
return self.__rar_file.read(file_path)
except KeyError:
continue

return None
except KeyError as e:
raise e
60 changes: 60 additions & 0 deletions src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from pathlib import Path
from types import TracebackType
from typing import Literal, Self

import py7zr
from py7zr import io

from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile


class SevenZipFile(ArchiveFile):
"""Wrapper around py7zr.SevenZipFile."""

def __init__(self, path: Path, mode: Literal["r"]) -> None:
super().__init__(path, mode)
self.path = path
self.__seven_zip_file: py7zr.SevenZipFile = py7zr.SevenZipFile(path, mode)

def __enter__(self) -> Self:
return self

def __exit__(
self,
exception_type: type[BaseException] | None,
exception_value: BaseException | None,
exception_traceback: TracebackType | None,
) -> None:
self.__seven_zip_file.close()

def get_name_list(self) -> list[str]:
without_own_file_name: map = map(
lambda file_name: file_name.replace(f"{self.path.name}/", ""),
self.__seven_zip_file.namelist(),
)
without_empty_items: filter = filter(None, without_own_file_name)

return list(without_empty_items)

def has_file_name(self, file_name: str) -> bool:
return file_name in self.get_name_list()

def read(self, file_name: str) -> bytes | None:
# py7zr.SevenZipFile must be reset after every extraction
# See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract
self.__seven_zip_file.reset()

factory = io.BytesIOFactory(limit=10485760) # 10 MiB

search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)]
try:
for file_path in search_paths:
try:
self.__seven_zip_file.extract(targets=[str(file_path)], factory=factory)
return factory.get(file_path).read()
except KeyError:
continue

return None
except KeyError as e:
raise e
52 changes: 52 additions & 0 deletions src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import tarfile
from pathlib import Path
from types import TracebackType
from typing import Literal, Self

from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile


class TarFile(ArchiveFile):
"""Wrapper around tarfile.TarFile."""

def __init__(self, path: Path, mode: Literal["r"]) -> None:
super().__init__(path, mode)
self.path = path
self.__tar_file: tarfile.TarFile = tarfile.TarFile(path, mode)

def __enter__(self) -> Self:
return self

def __exit__(
self,
exception_type: type[BaseException] | None,
exception_value: BaseException | None,
exception_traceback: TracebackType | None,
) -> None:
self.__tar_file.close()

def get_name_list(self) -> list[str]:
without_own_file_name: map = map(
lambda file_name: file_name.replace(f"{self.path.name}/", ""),
self.__tar_file.getnames(),
)
without_empty_items: filter = filter(None, without_own_file_name)

return list(without_empty_items)

def has_file_name(self, file_name: str) -> bool:
return file_name in self.get_name_list()

def read(self, file_name: str) -> bytes | None:
search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)]
try:
for file_path in search_paths:
try:
return unwrap(self.__tar_file.extractfile(str(file_path))).read()
except KeyError:
continue

return None
except KeyError as e:
raise e
Loading