refactor: split ThumbRenderer class

This commit is contained in:
Travis Abendshien
2026-07-24 17:30:18 -07:00
parent f24653e030
commit 068add29df
25 changed files with 1243 additions and 1078 deletions
+2 -2
View File
@@ -88,7 +88,7 @@ filterwarnings = [
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
"src/tagstudio/qt/previews/vendored/pydub/",
"src/tagstudio/renderers/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
@@ -117,7 +117,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "E402"]
"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
"src/tagstudio/renderers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
[tool.ruff.lint.pydocstyle]
convention = "google"
+7
View File
@@ -0,0 +1,7 @@
from PySide6.QtCore import QObject
class FileRenderer(QObject): ...
"""A Qt-specific entry point for rendering file previews and thumbnails."""
+2 -6
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import ffmpeg
from tagstudio.qt.previews.vendored.probe import probe
from tagstudio.renderers.vendored.probe import probe
def is_readable_video(filepath: Path | str):
@@ -23,11 +23,7 @@ def is_readable_video(filepath: Path | str):
return False
for stream in result["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
"drma",
"drms",
"drmi",
]:
if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]:
return False
except ffmpeg.Error:
return False
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import zipfile
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def apple_embedded_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio).
Args:
filepath (Path): The path of the file.
"""
thumb_files: list[str] = [
"preview.jpg",
"QuickLook/Preview.heic",
"QuickLook/Thumbnail.jpg",
"QuickLook/Thumbnail.heic",
"QuickLook/Thumbnail.webp",
"QuickLook/Icon.webp",
]
im: Image.Image | None = None
def get_image(path: str) -> Image.Image | None:
thumb_im: Image.Image | None = None
# Read the specific file into memory
file_data = zip_file.read(path)
thumb_im = Image.open(BytesIO(file_data))
return thumb_im
try:
with zipfile.ZipFile(filepath, "r") as zip_file:
thumb: Image.Image | None = None
# Check if the file exists in the zip
for thumb_file in thumb_files:
if thumb_file in zip_file.namelist():
thumb = get_image(thumb_file)
break
else:
logger.error("Couldn't render thumbnail", filepath=filepath)
if thumb:
im = Image.new("RGB", thumb.size, color="#1e1e1e")
im.paste(thumb)
except zipfile.BadZipFile as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
return im
+118
View File
@@ -0,0 +1,118 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import tarfile
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Literal
import py7zr
import py7zr.io
import rarfile
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.raster_image import load_raster_image
logger = structlog.get_logger(__name__)
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
class SevenZipFile(py7zr.SevenZipFile):
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
super().__init__(filepath, mode)
def read(self, name: str) -> bytes:
# SevenZipFile must be reset after every extraction
# See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract
self.reset()
factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB
self.extract(targets=[name], factory=factory)
return factory.get(name).read()
class TarFile:
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
self.tar: tarfile.TarFile
self.filepath = filepath
self.mode: Literal["r"] = mode
def namelist(self) -> list[str]:
return self.tar.getnames()
def read(self, name: str) -> bytes:
return unwrap(self.tar.extractfile(name)).read()
def __enter__(self) -> "TarFile":
self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__()
return self
def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
self.tar.__exit__(*args)
def archive_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Extract the first image found in the archive.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension.
Returns:
Image: The first image found in the archive.
"""
im: Image.Image | None = None
try:
with open_archive(filepath, ext) as archive:
im = first_image(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def open_archive(filepath: Path, ext: str) -> Archive:
"""Open an archive with its corresponding archiver.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension.
Returns:
Archive: The opened archive.
"""
archiver: type[Archive] = zipfile.ZipFile
if ext in {".7z", ".cb7", ".s7z"}:
archiver = SevenZipFile
elif ext in {".cbr", ".rar"}:
archiver = rarfile.RarFile
elif ext in {".cbt", ".tar", ".tgz"}:
archiver = TarFile
return archiver(filepath, "r")
def first_image(archive: Archive) -> Image.Image | None:
"""Find and extract the first renderable image in the archive.
Args:
archive (Archive): The current archive.
Returns:
Image: The first renderable image in the archive.
"""
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
ext = Path(file_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
return load_raster_image(BytesIO(image_data))
return None
+149
View File
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from io import BytesIO
from pathlib import Path
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 tagstudio.renderers.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
)
logger = structlog.get_logger(__name__)
def audio_album_thumb(filepath: Path, ext: str) -> 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
try:
if not filepath.is_file():
raise FileNotFoundError
artwork = None
if ext in [".mp3"]:
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"]:
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, # pyright: ignore[reportPrivateImportUsage]
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
def audio_waveform_thumb(
filepath: Path, ext: str, size: int, pixel_ratio: 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.
"""
# 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
try:
bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64)
audio = AudioSegment.from_file(filepath, ext[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)
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
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)
if maximum_item > highest_line:
highest_line = maximum_item
maximum_item = 0
count = 1
line_ratio = max(highest_line / bar_height, 1)
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
# 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_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
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),
)
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
+43
View File
@@ -0,0 +1,43 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import structlog
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.renderers.vendored.blender_renderer import (
blend_thumb, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
def blender(filepath: Path) -> Image.Image | None:
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
Args:
filepath (Path): The path of the file.
"""
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
im: Image.Image | None = None
try:
if (blend_image := blend_thumb(str(filepath))) is not None:
bg = Image.new("RGB", blend_image.size, color=bg_color)
bg.paste(blend_image, mask=blend_image.getchannel(3))
im = bg
else:
logger.info(
f"[ThumbRenderer][BLENDER][INFO] {filepath.name} "
"Doesn't have an embedded thumbnail."
)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+80
View File
@@ -0,0 +1,80 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import base64
import sqlite3
import struct
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def clip_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
Args:
filepath (Path): The path of the .clip file.
Returns:
Image: The embedded thumbnail, if extractable.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
blob = f.read()
sqlite_index = blob.find(b"SQLite format 3")
if sqlite_index == -1:
return im
with sqlite3.connect(":memory:") as conn:
conn.deserialize(blob[sqlite_index:])
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
if thumbnail:
im = Image.open(BytesIO(thumbnail[0]))
conn.close()
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def pdn_thumb(filepath: Path) -> Image.Image | None:
"""Extract the base64-encoded thumbnail from a .pdn file header.
Args:
filepath (Path): The path of the .pdn file.
Returns:
Image: the decoded PNG thumbnail or None by default.
"""
im: Image.Image | None = None
with open(filepath, "rb") as f:
try:
# First 4 bytes are the magic number
if f.read(4) != b"PDN3":
return im
# Header length is a little-endian 24-bit int
header_size = struct.unpack("<i", f.read(3) + b"\x00")[0]
thumb_element = ET.fromstring(f.read(header_size)).find("./*thumb")
if thumb_element is None:
return im
encoded_png = thumb_element.get("png")
if encoded_png:
decoded_png = base64.b64decode(encoded_png)
im = Image.open(BytesIO(decoded_png))
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+73
View File
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
from xml.etree.ElementTree import Element
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.archive import Archive, first_image, open_archive
from tagstudio.renderers.raster_image import load_raster_image
logger = structlog.get_logger(__name__)
def epub_cover(filepath: Path, ext: str) -> Image.Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
Args:
filepath (Path): The path to the ePub file.
ext (str): The file extension.
Returns:
Image: The cover specified in ComicInfo.xml,
the first image found in the ePub file, or None by default.
"""
im: Image.Image | None = None
try:
with open_archive(filepath, ext) as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
im = cover_from_comic_info(archive, comic_info, "FrontCover")
if not im:
im = cover_from_comic_info(archive, comic_info, "InnerCover")
if not im:
im = first_image(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def cover_from_comic_info(
archive: Archive, comic_info: Element, cover_type: str
) -> Image.Image | None:
"""Extract the cover specified in ComicInfo.xml.
Args:
archive (Archive): The current ePub file.
comic_info (Element): The parsed ComicInfo.xml.
cover_type (str): The type of cover to load.
Returns:
Image: The cover specified in ComicInfo.xml.
"""
im: Image.Image | None = None
cover = comic_info.find(f"./*Page[@Type='{cover_type}']")
if cover is not None:
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType]
page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType]
ext = Path(page_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
im = load_raster_image(BytesIO(image_data))
return im
+108
View File
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
from typing import cast
import numpy as np
import structlog
from PIL import Image, ImageDraw, ImageFont
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
logger = structlog.get_logger(__name__)
def font_short_thumb(filepath: Path, size: int) -> Image.Image | None:
"""Render a small font preview ("Aa") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
try:
bg = Image.new("RGB", (size, size), color="#000000")
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
draw = ImageDraw.Draw(raw)
font = ImageFont.truetype(filepath, size=size)
# NOTE: While a stroke effect is desired, the text
# method only allows for outer strokes, which looks
# a bit weird when rendering fonts.
draw.text(
(size // 8, size // 8),
"Aa",
font=font,
fill="#FF0000",
# stroke_width=math.ceil(size / 96),
# stroke_fill="#FFFF00",
)
# NOTE: Change to getchannel(1) if using an outline.
data = np.asarray(raw.getchannel(0))
m, n = data.shape[:2]
col: np.ndarray = cast(np.ndarray, data.any(0))
row: np.ndarray = cast(np.ndarray, data.any(1))
cropped_data = np.asarray(raw)[
row.argmax() : m - row[::-1].argmax(),
col.argmax() : n - col[::-1].argmax(),
]
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
margin: int = math.ceil(size // 16)
orig_x, orig_y = cropped_im.size
new_x, new_y = (size, size)
if orig_x > orig_y:
new_x = size
new_y = math.ceil(size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = size
new_x = math.ceil(size * (orig_x / orig_y))
cropped_im = cropped_im.resize(
size=(new_x - (margin * 2), new_y - (margin * 2)),
resample=Image.Resampling.BILINEAR,
)
bg.paste(
cropped_im,
box=(margin, margin + ((size - new_y) // 2)),
)
im = bg
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def font_long_thumb(filepath: Path, size: int) -> Image.Image | None:
"""Render a large font preview ("Alphabet") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
# Scale the sample font sizes to the preview image
# resolution,assuming the sizes are tuned for 256px.
im: Image.Image | None = None
try:
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
bg = Image.new("RGBA", (size, size), color="#00000000")
draw = ImageDraw.Draw(bg)
lines_of_padding = 2
y_offset = 0.0
for font_size in scaled_sizes:
font = ImageFont.truetype(filepath, size=font_size)
text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw)
draw.multiline_text((0, y_offset), text_wrapped, font=font)
y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox(
(0, 0), "A", font=font
)[-1]
im = auto_theme_overlay(bg, use_alpha=False)
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+35
View File
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import zipfile
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def krita_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Krita file.
Args:
filepath (Path): The path of the file.
"""
file_path_within_zip = "preview.png"
im: Image.Image | None = None
with zipfile.ZipFile(filepath, "r") as zip_file:
# Check if the file exists in the zip
if file_path_within_zip in zip_file.namelist():
# Read the specific file into memory
file_data = zip_file.read(file_path_within_zip)
thumb_im = Image.open(BytesIO(file_data))
if thumb_im:
im = Image.new("RGB", thumb_im.size, color="#1e1e1e")
im.paste(thumb_im)
else:
logger.error("Couldn't render thumbnail", filepath=filepath)
return im
+58
View File
@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
import struct
import xml.etree.ElementTree as ET
import zlib
from pathlib import Path
import structlog
from PIL import Image
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
def mdp_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from a .mdp file.
Args:
filepath (Path): The path of the .mdp file.
Returns:
Image: The embedded thumbnail.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
magic = struct.unpack("<7sx", f.read(8))[0]
if magic != b"mdipack":
return im
bin_header = struct.unpack("<LLL", f.read(12))
xml_header = ET.fromstring(f.read(bin_header[1]))
mdibin_count = len(xml_header.findall("./*Layer")) + 1
for _ in range(mdibin_count):
pac_header = struct.unpack("<3sxLLLL48s64s", f.read(132))
if not pac_header[6].startswith(b"thumb"):
f.seek(pac_header[3], os.SEEK_CUR)
continue
thumb_element = unwrap(xml_header.find("Thumb"))
dimensions = (
int(unwrap(thumb_element.get("width"))),
int(unwrap(thumb_element.get("height"))),
)
thumb_blob = f.read(pac_header[3])
if pac_header[2] == 1:
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
break
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import zipfile
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def powerpoint_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Microsoft PowerPoint file.
Args:
filepath (Path): The path of the file.
"""
file_path_within_zip = "docProps/thumbnail.jpeg"
im: Image.Image | None = None
try:
with zipfile.ZipFile(filepath, "r") as zip_file:
# Check if the file exists in the zip
if file_path_within_zip in zip_file.namelist():
# Read the specific file into memory
file_data = zip_file.read(file_path_within_zip)
thumb_im = Image.open(BytesIO(file_data))
if thumb_im:
im = Image.new("RGB", thumb_im.size, color="#1e1e1e")
im.paste(thumb_im)
else:
logger.error("Couldn't render thumbnail", filepath=filepath)
except zipfile.BadZipFile as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
return im
+35
View File
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import zipfile
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def open_doc_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for an OpenDocument file.
Args:
filepath (Path): The path of the file.
"""
file_path_within_zip = "Thumbnails/thumbnail.png"
im: Image.Image | None = None
with zipfile.ZipFile(filepath, "r") as zip_file:
# Check if the file exists in the zip
if file_path_within_zip in zip_file.namelist():
# Read the specific file into memory
file_data = zip_file.read(file_path_within_zip)
thumb_im = Image.open(BytesIO(file_data))
if thumb_im:
im = Image.new("RGB", thumb_im.size, color="#1e1e1e")
im.paste(thumb_im)
else:
logger.error("Couldn't render thumbnail", filepath=filepath)
return im
+65
View File
@@ -0,0 +1,65 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from io import BytesIO
from pathlib import Path
import structlog
from PIL import (
Image,
)
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
from PySide6.QtGui import QImage
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
logger = structlog.get_logger(__name__)
def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
"""Render a thumbnail for a PDF or Adobe Illustrator file.
filepath (Path): The path of the file.
size (int): The size of the icon.
ext (str): The file extension.
"""
im: Image.Image | None = None
file: QFile = QFile(filepath)
success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser)
if not success:
logger.error("Couldn't render thumbnail", filepath=filepath)
return im
document: QPdfDocument = QPdfDocument()
document.load(file)
file.close()
# Transform page_size in points to pixels with proper aspect ratio
page_size: QSizeF = document.pagePointSize(0)
ratio_hw: float = page_size.height() / page_size.width()
if ratio_hw >= 1:
page_size *= size / page_size.height()
else:
page_size *= size / page_size.width()
# Enlarge image for anti-aliasing
scale_factor = 2.5 if ext in {".pdf"} else 1
page_size *= scale_factor
# Render image with no anti-aliasing for speed
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
render_options.setRenderFlags(
QPdfDocumentRenderOptions.RenderFlag.TextAliased
| QPdfDocumentRenderOptions.RenderFlag.ImageAliased
| QPdfDocumentRenderOptions.RenderFlag.PathAliased
)
# Convert QImage to PIL Image
q_image: QImage = document.render(0, page_size.toSize(), render_options)
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
try:
q_image.save(buffer, "PNG") # pyright: ignore
im = Image.open(BytesIO(buffer.buffer().data()))
finally:
buffer.close()
# Replace transparent pixels with white (otherwise Background defaults to transparent)
return replace_transparent_pixels(im)
+121
View File
@@ -0,0 +1,121 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
from io import BytesIO
from pathlib import Path
import cv2
import numpy as np
import rawpy
import structlog
from PIL import Image, ImageOps, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
from rawpy import (
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
)
from tagstudio.core.utils.types import unwrap
register_heif_opener()
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
logger = structlog.get_logger(__name__)
def image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a standard image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with filepath.open("rb") as file:
im = load_raster_image(BytesIO(file.read()))
except (
FileNotFoundError,
UnidentifiedImageError,
DecompressionBombError,
NotImplementedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def image_exr_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a EXR image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
# Load the EXR data to an array and rotate the color space from BGRA -> RGBA
raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED)
assert raw_array is not None
raw_array[..., :3] = raw_array[..., 2::-1]
# Correct the gamma of the raw array
gamma = 2.2
array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma)
array = (array_gamma * 255).astype(np.uint8)
im = Image.fromarray(array, mode="RGBA")
# Paste solid background
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def image_raw_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a RAW image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess(use_camera_wb=True)
im = Image.frombytes(
"RGB",
(rgb.shape[1], rgb.shape[0]),
rgb,
decoder_name="raw",
)
except (
DecompressionBombError,
LibRawIOError,
LibRawFileUnsupportedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def load_raster_image(image_data: BytesIO) -> Image.Image:
"""Load a raster image and add a background if it's transparent.
Args:
image_data (BytesIO): The binary image data.
Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image.Image = Image.open(image_data)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
return unwrap(ImageOps.exif_transpose(im))
+30
View File
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import srctools
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def vtf_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
Uses the srctools library for reading VTF files.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
vtf = srctools.VTF.read(f)
im = vtf.get(frame=0).to_PIL()
except (ValueError, FileNotFoundError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+55
View File
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import cv2
import structlog
from PIL import Image, ImageDraw, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.core.utils.encoding import detect_char_encoding
logger = structlog.get_logger(__name__)
def text_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 = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
fg_color: str = (
"#FFFFFF"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#111111"
)
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
+54
View File
@@ -0,0 +1,54 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from io import BytesIO
from pathlib import Path
import structlog
from PIL import (
Image,
UnidentifiedImageError,
)
from PySide6.QtCore import QBuffer, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtSvg import QSvgRenderer
logger = structlog.get_logger(__name__)
def image_vector_thumb(filepath: Path, size: int) -> Image.Image:
"""Render a thumbnail for a vector image, such as SVG.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
# Create an image to draw the svg to and a painter to do the drawing
q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32)
q_image.fill("#1e1e1e")
# Create an svg renderer, then render to the painter
svg: QSvgRenderer = QSvgRenderer(str(filepath))
if not svg.isValid():
raise UnidentifiedImageError
painter: QPainter = QPainter(q_image)
svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio)
svg.render(painter)
painter.end()
# Write the image to a buffer as png
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType]
# Load the image from the buffer
im = Image.new("RGB", (size, size), color="#1e1e1e")
im.paste(Image.open(BytesIO(buffer.data().data())))
im = im.convert(mode="RGB")
buffer.close()
return im
@@ -43,7 +43,7 @@ from pydub.utils import (
)
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json
from tagstudio.renderers.vendored.pydub.utils import _mediainfo_json
basestring = str
xrange = range
@@ -256,7 +256,7 @@ class _AudioSegment:
self.sample_width = 4
self.frame_width = self.channels * self.sample_width
super(_AudioSegment, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@property
def raw_data(self):
+55
View File
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
import cv2
import structlog
from cv2.typing import MatLike
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from tagstudio.qt.helpers.file_tester import is_readable_video
logger = structlog.get_logger(__name__)
def video_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a video file.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
frame: MatLike | None = None
try:
if is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
# TODO: Move this check to is_readable_video()
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
raise cv2.error("File is invalid or has 0 frames")
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
# NOTE: Depending on the video format, compression, and
# frame count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
max_frame_seek: int = 10
for i in range(
0,
min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))),
):
success, frame = video.read()
if not success:
video.set(cv2.CAP_PROP_POS_FRAMES, i)
else:
break
if frame is not None:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
im = Image.fromarray(frame)
except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im