Compare commits

..

5 Commits

Author SHA1 Message Date
Travis Abendshien cd6e427e02 fix: remove file_renderer.py 2026-07-24 20:17:26 -07:00
Travis Abendshien 0c21c45cb4 refactor: name and license consistency, misc fixes 2026-07-24 20:07:46 -07:00
Travis Abendshien d2bf445a13 fix: move jxl optional import to raster_image.py 2026-07-24 19:01:41 -07:00
Travis Abendshien 8995ef6caf refactor: consolidate embedded archive thumbnail functions 2026-07-24 18:56:29 -07:00
Travis Abendshien 068add29df refactor: split ThumbRenderer class 2026-07-24 17:30:18 -07:00
27 changed files with 1143 additions and 1148 deletions
+5 -3
View File
@@ -13,7 +13,7 @@ on:
- .github/actions/setup-python/action.yml
- .github/workflows/build_docs.yml
- docs/**
- properdocs.yml
- mkdocs.yml
workflow_dispatch:
concurrency:
@@ -50,5 +50,7 @@ jobs:
path: .cache
restore-keys: |
mkdocs-material-
- name: Run properdocs
run: properdocs gh-deploy --force
- name: Run mkdocs
env:
DISABLE_MKDOCS_2_WARNING: 'true'
run: mkdocs gh-deploy --force
View File
+3 -7
View File
@@ -60,11 +60,7 @@ extra = [{ include-group = "pre-commit" }]
lint = [{ include-group = "pyright" }, { include-group = "ruff" }]
test = [{ include-group = "pytest" }]
mkdocs = [
"mkdocs-material[imaging]>=9.7",
"mkdocs-redirects~=1.2",
"properdocs",
]
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
pre-commit = ["pre-commit~=4.2"]
pyinstaller = ["Pyinstaller~=6.21"]
pyright = ["pyright~=1.1.409"]
@@ -92,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
@@ -121,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"
+1
View File
@@ -305,6 +305,7 @@ class MediaCategories:
".nef",
".nrw",
".orf",
".r3d",
".raf",
".raw",
".rw2",
+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
+158
View File
@@ -0,0 +1,158 @@
# 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 image_from_bytes
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 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_in_archive(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 image_from_bytes(BytesIO(image_data))
return None
def archive_thumb(
filepath: Path, ext: str = "", image_names: list[Path] | list[str] | None = None
) -> Image.Image | None:
"""Extract an embedded preview image from an archive.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension. Used to help determine more specific archive type.
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
Returns:
Image: The first image found in the archive.
"""
try:
with open_archive(filepath, ext) as archive:
# If no list of image names to search for was provided, default to the first image.
if not image_names:
return first_image_in_archive(archive)
for image_name in image_names:
if image_name in archive.namelist():
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(file_data))
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return None
def apple_embedded_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio)."""
image_names: list[str] = [
"preview.jpg",
"QuickLook/Preview.heic",
"QuickLook/Thumbnail.jpg",
"QuickLook/Thumbnail.heic",
"QuickLook/Thumbnail.webp",
"QuickLook/Icon.webp",
]
return archive_thumb(filepath, image_names=image_names)
def krita_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Krita file."""
image_names = ["preview.png"]
return archive_thumb(filepath, image_names=image_names)
def open_doc_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for an OpenDocument file."""
image_names = ["Thumbnails/thumbnail.png"]
return archive_thumb(filepath, image_names=image_names)
def powerpoint_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Microsoft PowerPoint file."""
image_names = ["docProps/thumbnail.jpeg"]
return archive_thumb(filepath, image_names=image_names)
+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_thumb(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_studio_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_in_archive, open_archive
from tagstudio.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
def epub_thumb(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_in_archive(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 = image_from_bytes(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_small_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_full_preview(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
+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 medibang_paint_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
+66
View File
@@ -0,0 +1,66 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
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)
+126
View File
@@ -0,0 +1,126 @@
# 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
logger = structlog.get_logger(__name__)
try:
import pillow_jxl # noqa: F401 # pyright: ignore
except ImportError as e:
logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e)
register_heif_opener()
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
def raster_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 = image_from_bytes(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 exr_image_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 raw_image_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 image_from_bytes(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
+56
View File
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
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 vector_image_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
@@ -1,11 +1,7 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) 2017 The Blender Foundation
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# <pep8 compliant>
## This file is a modified script that gets the thumbnail data stored in a blend file
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro)
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
@@ -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):
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com
# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
+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
+10 -10
View File
@@ -1,5 +1,5 @@
{
"about.app_cache_path": "Ruta de la Caché de la Aplicación",
"about.app_cache_path": "Ruta caché aplicación",
"about.config_path": "Ruta de Configuración",
"about.description": "TagStudio es una aplicación para organizar fotografías y archivos que utiliza un sistema de etiquetas subyacentes centrado en dar libertad y flexibilidad al usuario. Sin programas ni formatos propios, ni un mar de archivos y sin trastornar completamente la estructura de tu sistema de archivos.",
"about.documentation": "Documentación",
@@ -8,14 +8,14 @@
"about.title": "Acerca de TagStudio",
"about.version": "Versión",
"about.version.latest": "{built_version} (Última versión: {latest_version})",
"about.website": "Página Web",
"about.website": "Página web",
"app.git": "Commit de Git",
"app.pre_release": "Pre-Lanzamiento",
"app.title": "{base_title} - Biblioteca '{library_dir}'",
"color.color_border": "Usar Color Secundario para Borde",
"color.color_border": "Usar color secundario para el Borde",
"color.confirm_delete": "¿Estás seguro de que quieres eliminar el color \"{color_name}\"?",
"color.delete": "Eliminar Etiqueta",
"color.import_pack": "Importar Paquete de Colores",
"color.import_pack": "Importar paquete de colores",
"color.name": "Nombre",
"color.namespace.delete.prompt": "¿Estás seguro de que quieres eliminar el espacio de nombres de este color? ¡Esto eliminará todos los colores en el espacio de nombres junto con él!",
"color.namespace.delete.title": "Eliminar el espacio de nombres de color",
@@ -25,7 +25,7 @@
"color.primary_required": "Color primario (Obligatorio)",
"color.secondary": "Color secundario",
"color.title.no_color": "Sin color",
"color_manager.title": "Administrar Colores de Etiquetas",
"color_manager.title": "Administrar los colores de las etiquetas",
"dependency.missing.title": "{dependency} no encontrada",
"drop_import.description": "Los siguientes archivos igualan con las rutas de archivos que ya existen en la biblioteca",
"drop_import.duplicates_choice.plural": "Los siguientes {count} archivos igualan con las rutas de archivos que ya existen en la biblioteca.",
@@ -227,12 +227,12 @@
"language.tr": "Turco",
"language.zh_Hans": "Chino (simplificado)",
"language.zh_Hant": "Chino (tradicional)",
"library.missing": "Falta la Ubicación de la Biblioteca",
"library.missing": "Falta la ubicación",
"library.name": "Biblioteca",
"library.refresh.scanning.plural": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} archivos buscados, {found_count} nuevos archivos encontrados",
"library.refresh.scanning.singular": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} Archivos buscados, {found_count} Nuevos archivos encontrados",
"library.refresh.scanning_preparing": "Buscando archivos nuevos en los directorios...\nPreparando...",
"library.refresh.title": "Refrescando directorios",
"library.refresh.scanning_preparing": "Buscar archivos nuevos en los directorios...\nPreparando...",
"library.refresh.title": "Refrescar directorios",
"library.scan_library.title": "Escaneando la biblioteca",
"library_info.cleanup": "Limpieza",
"library_info.cleanup.backups": "Reespaldos de la Librería:",
@@ -387,7 +387,7 @@
"tag.is_category": "Es categoría",
"tag.is_hidden": "Está oculto",
"tag.name": "Nombre",
"tag.new": "Nueva Etiqueta",
"tag.new": "Nueva etiqueta",
"tag.parent_tags": "Etiquetas principales",
"tag.parent_tags.add": "Añadir etiquetas principales",
"tag.parent_tags.description": "Esta etiqueta se puede tratar como sustituto de cualquiera de las etiquetas padre en las búsquedas.",
@@ -417,7 +417,7 @@
"view.size.1": "Pequeño",
"view.size.2": "Medio",
"view.size.3": "Grande",
"view.size.4": "Extra Grande",
"view.size.4": "Extra grande",
"window.message.error_opening_library": "Error abriendo la biblioteca.",
"window.title.error": "Error",
"window.title.open_create_library": "Abrir/Crear biblioteca"
@@ -4,7 +4,6 @@
"about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui met en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.",
"about.documentation": "Documentation",
"about.module.found": "Trouvé",
"about.modules.title": "Modules optionnels",
"about.title": "À propos de TagStudio",
"about.version": "Version",
"about.version.latest": "{built_version} (Dernière version : {latest_version})",
@@ -197,36 +196,6 @@
"json_migration.title.new_lib": "<h2>Bibliothèque v9.5+</h2>",
"json_migration.title.old_lib": "<h2>Bibliothèque v9.4</h2>",
"landing.open_create_library": "Ouvrir/Créer une Bibliothèque {shortcut}",
"language.am": "Amharic",
"language.ceb": "Cébuano",
"language.cs": "Tchèque",
"language.da": "Danois",
"language.de": "Allemand",
"language.el": "Grec",
"language.en": "Anglais",
"language.es": "Espagnol",
"language.fi": "Finnois",
"language.fil": "Philippin",
"language.fr": "Français",
"language.hu": "Hongrois",
"language.is": "Islandais",
"language.it": "Italien",
"language.ja": "Japonais",
"language.nb_NO": "Norvégien (Bokmål)",
"language.nl": "Néerlandais",
"language.pl": "Polonais",
"language.pt": "Portugais",
"language.pt_BR": "Portugais (Brésil)",
"language.qpv": "Viossa",
"language.ro": "Roumain",
"language.ru": "Russe",
"language.sv": "Suédois",
"language.ta": "Tamil",
"language.th": "Thaï",
"language.tok": "Toki Pona",
"language.tr": "Turc",
"language.zh_Hans": "Chinois (Simplifier)",
"language.zh_Hant": "Chinois (Traditionnelle)",
"library.missing": "Emplacement Manquant",
"library.name": "Bibliothèque",
"library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers",
@@ -308,8 +277,6 @@
"select.all": "Tout Sélectionner",
"select.clear": "Effacer la Sélection",
"select.inverse": "Inverser la Sélection",
"settings.appearance": "Apparence",
"settings.cached_thumb_resolution.label": "Résolution des vignettes mises en cache",
"settings.clear_thumb_cache.title": "Effacer le cache des vignettes",
"settings.dateformat.english": "Anglais",
"settings.dateformat.international": "International",
@@ -325,8 +292,6 @@
"settings.infinite_scroll": "Défilement continu",
"settings.language": "Langage",
"settings.library": "Paramètres de la Bibliothèque",
"settings.localization": "Localisation",
"settings.media": "Médias",
"settings.open_library_on_start": "Ouvrir la Bibliothèque au Démarrage",
"settings.page_size": "Entités par page",
"settings.restart_required": "Veuillez redémarré TagStudio pour que les changements prenne effet.",
@@ -10,7 +10,6 @@
"about.version.latest": "{built_version} (legújabb kiadás: {latest_version})",
"about.website": "Honlap",
"app.git": "Git-véglegesítés",
"app.nightly": "Napi",
"app.pre_release": "Kísérleti verzió",
"app.title": "{base_title} Könyvtár: „{library_dir}”",
"color.color_border": "Másodlagos szín használata keretszínként",
@@ -1,11 +1,9 @@
{
"about.app_cache_path": "TagStudio缓存路径",
"about.config_path": "配置路径",
"about.description": "TagStudio是一款照片和文件组织应用程序,采用基于标签的系统,旨在为用户提供自由和灵活性。该应用程序不使用专有程序或格式,不会产生大量的辅助文件,也不会对您的文件系统结构造成彻底的颠覆。",
"about.documentation": "文档",
"about.module.found": "存在",
"about.title": "关于 TagStudio",
"about.version": "版本",
"about.website": "网站",
"app.git": "Git 提交更新",
"app.pre_release": "预发布版本",