From f91c99bb04475ecca21979cba2e8c301ccc6dff3 Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 01:35:21 -0800 Subject: [PATCH 01/21] better apng support and basic anim jxl support --- pyproject.toml | 1 + src/tagstudio/core/media_types.py | 2 + .../controllers/preview_thumb_controller.py | 70 ++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb095d849..a0c4a8d36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "typing_extensions~=4.13", "ujson~=5.10", "wcmatch==10.*", + "imagecodecs[all]", ] [project.optional-dependencies] diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 8659c389d..bd3fd71c0 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -288,6 +288,8 @@ class MediaCategories: } _IMAGE_ANIMATED_SET: set[str] = { ".apng", + ".png", + ".jxl", ".gif", ".webp", } diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index ecc5d96a2..46f94d583 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -2,15 +2,18 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import io +import time from pathlib import Path from typing import TYPE_CHECKING import cv2 import rawpy import structlog +import ffmpeg from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import QSize +from PySide6.QtGui import QMovie from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories @@ -22,6 +25,8 @@ if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver +import imagecodecs + logger = structlog.get_logger(__name__) Image.MAX_IMAGE_PIXELS = None @@ -71,6 +76,16 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: return stats + @staticmethod + def should_convert(ext, format_exts) -> bool: + if ext in QMovie.supportedFormats(): + return False + + if ext in format_exts: + return True + + return False + def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: """Loads an animated image and returns gif data and size, if successful.""" ext = filepath.suffix.lower() @@ -78,19 +93,70 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None try: image: Image.Image = Image.open(filepath) - if ext == ".apng": + pillow_converts = [ + ".apng", + ".png", + ] + + + if self.should_convert(ext, [".jxl"]): + ffprobe = ffmpeg.probe(filepath) + + if not ffprobe.get('format', {}).get('format_name', 'unknown') == "jpegxl_anim": + return False + + st = time.perf_counter_ns() + + with open(filepath, 'rb') as f: + jxl_data = f.read() + + frames_array = imagecodecs.jpegxl_decode(jxl_data) + + pil_frames = [Image.fromarray(frame) for frame in frames_array] + image_bytes_io = io.BytesIO() + pil_frames[0].save( + image_bytes_io, + "WEBP", + lossless=True, + append_images=pil_frames[1:], + save_all=True, + loop=0, + disposal=2, + ) + logger.debug( + f"[PreviewThumb] Coversion has taken {(time.perf_counter_ns() - st) / 1_000_000} ms", + ext=ext, + ) + + image.close() + image_bytes_io.seek(0) + return (image_bytes_io.read(), (image.width, image.height)) + + elif self.should_convert(ext, pillow_converts): + if hasattr(image, "n_frames"): + if image.n_frames <= 1: + return False + + image_bytes_io = io.BytesIO() + st = time.perf_counter_ns() image.save( image_bytes_io, - "GIF", + "WEBP", lossless=True, save_all=True, loop=0, disposal=2, ) + logger.debug( + f"[PreviewThumb] Coversion has taken {(time.perf_counter_ns() - st) / 1_000_000} ms", + ext=ext, + ) + image.close() image_bytes_io.seek(0) return (image_bytes_io.read(), (image.width, image.height)) + else: image.close() with open(filepath, "rb") as f: From ef1a50f6c0a7dfde90e5d3e8ed451d32bb04628b Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 14:55:29 -0800 Subject: [PATCH 02/21] jxl basic frametime support --- src/tagstudio/core/media_types.py | 1 + .../controllers/preview_thumb_controller.py | 107 +++++++++++++++--- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index bd3fd71c0..4ec6b2a71 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -287,6 +287,7 @@ class MediaCategories: ".woff2", } _IMAGE_ANIMATED_SET: set[str] = { + ".avif", ".apng", ".png", ".jxl", diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 46f94d583..adbb2921d 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -4,7 +4,10 @@ import io import time from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List +import subprocess +import re +import asyncio import cv2 import rawpy @@ -76,12 +79,64 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: return stats + + @staticmethod + def jxlinfo_frame_durations(path: str, jxlinfo_cmd: str = "jxlinfo") -> List[float]: + """ + Return per-frame durations (in seconds) by running `jxlinfo -v path`. + Requires `jxlinfo` (libjxl-tools) installed and on PATH. + """ + try: + p = subprocess.run( + [jxlinfo_cmd, "-v", path], + capture_output=True, + check=False, + text=True, + ) + except FileNotFoundError as e: + raise RuntimeError(f"{jxlinfo_cmd} not found. Install libjxl-tools.") from e + + out = p.stdout + "\n" + p.stderr + + dur_ms_matches = re.findall( + r"^frame:.*duration\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(ms)?", + out, + flags=re.IGNORECASE | re.MULTILINE + ) + + if dur_ms_matches: + durations = [] + for val, unit in dur_ms_matches: + num = float(val) + if unit and unit.lower() == "ms": + durations.append(num) + else: + if num > 5: + durations.append(num) + else: + durations.append(num * 1000) + return durations + + return [] + @staticmethod - def should_convert(ext, format_exts) -> bool: - if ext in QMovie.supportedFormats(): + def normalize_formats_to_exts(formats): + out = [] + for format in formats: + format = str(format) + format = format.lower() + if not format.startswith("."): + format = "." + format + + out.append(format) + + return out + + def should_convert(self, ext, format_exts) -> bool: + if ext in self.normalize_formats_to_exts([b.data().decode() for b in QMovie.supportedFormats()]): return False - if ext in format_exts: + if ext in self.normalize_formats_to_exts(format_exts): return True return False @@ -89,30 +144,47 @@ def should_convert(ext, format_exts) -> bool: def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: """Loads an animated image and returns gif data and size, if successful.""" ext = filepath.suffix.lower() + ext_mapping = { + ".apng": ".png", + } + ext = ext_mapping.get(ext, ext) try: image: Image.Image = Image.open(filepath) - pillow_converts = [ - ".apng", - ".png", - ] - + pillow_converts = Image.SAVE_ALL.keys() + pillow_converts = ["." + x.lower() for x in pillow_converts] if self.should_convert(ext, [".jxl"]): ffprobe = ffmpeg.probe(filepath) - if not ffprobe.get('format', {}).get('format_name', 'unknown') == "jpegxl_anim": + if ffprobe.get('format', {}).get('format_name', 'unknown') != "jpegxl_anim": return False - st = time.perf_counter_ns() - with open(filepath, 'rb') as f: - jxl_data = f.read() - frames_array = imagecodecs.jpegxl_decode(jxl_data) + st = time.perf_counter_ns() - pil_frames = [Image.fromarray(frame) for frame in frames_array] + async def get_durations(): + try: + return await asyncio.to_thread(self.jxlinfo_frame_durations, filepath) + except: + return 1000 / 30 + + async def decode_frames(): + def _load_and_decode(): + with open(filepath, "rb") as f: + data = f.read() + arr = imagecodecs.jpegxl_decode(data) + return [Image.fromarray(frame) for frame in arr] + return await asyncio.to_thread(_load_and_decode) + + async def run_parallel(): + return await asyncio.gather(get_durations(), decode_frames()) + + durs, pil_frames = asyncio.run( + run_parallel(), + ) image_bytes_io = io.BytesIO() pil_frames[0].save( @@ -121,6 +193,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None lossless=True, append_images=pil_frames[1:], save_all=True, + duration=durs, loop=0, disposal=2, ) @@ -157,10 +230,12 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None image_bytes_io.seek(0) return (image_bytes_io.read(), (image.width, image.height)) - else: + elif ext in self.normalize_formats_to_exts([b.data().decode() for b in QMovie.supportedFormats()]): image.close() with open(filepath, "rb") as f: return (f.read(), (image.width, image.height)) + else: + return False except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) From dbb9b166980ba04e2eac7fc53b917e17182b2063 Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 15:16:19 -0800 Subject: [PATCH 03/21] changed jxl to ffmpeg --- .../controllers/preview_thumb_controller.py | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index adbb2921d..aa97f0ee8 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -156,55 +156,42 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None pillow_converts = ["." + x.lower() for x in pillow_converts] if self.should_convert(ext, [".jxl"]): + + st = time.perf_counter_ns() ffprobe = ffmpeg.probe(filepath) - if ffprobe.get('format', {}).get('format_name', 'unknown') != "jpegxl_anim": + if ffprobe.get('format', {}).get('format_name', '') != "jpegxl_anim": return False - + probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms" st = time.perf_counter_ns() - async def get_durations(): - try: - return await asyncio.to_thread(self.jxlinfo_frame_durations, filepath) - except: - return 1000 / 30 - - async def decode_frames(): - def _load_and_decode(): - with open(filepath, "rb") as f: - data = f.read() - arr = imagecodecs.jpegxl_decode(data) - return [Image.fromarray(frame) for frame in arr] - return await asyncio.to_thread(_load_and_decode) - - async def run_parallel(): - return await asyncio.gather(get_durations(), decode_frames()) - - durs, pil_frames = asyncio.run( - run_parallel(), + out, _ = ( + ffmpeg + .input(filepath) + .output( + 'pipe:', + format='webp', + **{ + 'lossless': 1, + 'compression_level': 0, + 'loop': 0, + } + ) + .global_args("-hide_banner", "-loglevel", "error") + .run(capture_stdout=True) ) - image_bytes_io = io.BytesIO() - pil_frames[0].save( - image_bytes_io, - "WEBP", - lossless=True, - append_images=pil_frames[1:], - save_all=True, - duration=durs, - loop=0, - disposal=2, - ) logger.debug( f"[PreviewThumb] Coversion has taken {(time.perf_counter_ns() - st) / 1_000_000} ms", ext=ext, + ffprobe_time=probe_time, ) image.close() - image_bytes_io.seek(0) - return (image_bytes_io.read(), (image.width, image.height)) + + return (out, (image.width, image.height)) elif self.should_convert(ext, pillow_converts): if hasattr(image, "n_frames"): From 8c2ad34e7f67ac2b86137ef3042768470aef73ab Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 15:22:34 -0800 Subject: [PATCH 04/21] fixed formatting and unused imports --- pyproject.toml | 1 - .../controllers/preview_thumb_controller.py | 72 ++++--------------- 2 files changed, 15 insertions(+), 58 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0c4a8d36..cb095d849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ "typing_extensions~=4.13", "ujson~=5.10", "wcmatch==10.*", - "imagecodecs[all]", ] [project.optional-dependencies] diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index aa97f0ee8..354c08be0 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -4,15 +4,12 @@ import io import time from pathlib import Path -from typing import TYPE_CHECKING, List -import subprocess -import re -import asyncio +from typing import TYPE_CHECKING import cv2 +import ffmpeg import rawpy import structlog -import ffmpeg from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import QSize @@ -28,8 +25,6 @@ if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver -import imagecodecs - logger = structlog.get_logger(__name__) Image.MAX_IMAGE_PIXELS = None @@ -80,45 +75,6 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: return stats - @staticmethod - def jxlinfo_frame_durations(path: str, jxlinfo_cmd: str = "jxlinfo") -> List[float]: - """ - Return per-frame durations (in seconds) by running `jxlinfo -v path`. - Requires `jxlinfo` (libjxl-tools) installed and on PATH. - """ - try: - p = subprocess.run( - [jxlinfo_cmd, "-v", path], - capture_output=True, - check=False, - text=True, - ) - except FileNotFoundError as e: - raise RuntimeError(f"{jxlinfo_cmd} not found. Install libjxl-tools.") from e - - out = p.stdout + "\n" + p.stderr - - dur_ms_matches = re.findall( - r"^frame:.*duration\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(ms)?", - out, - flags=re.IGNORECASE | re.MULTILINE - ) - - if dur_ms_matches: - durations = [] - for val, unit in dur_ms_matches: - num = float(val) - if unit and unit.lower() == "ms": - durations.append(num) - else: - if num > 5: - durations.append(num) - else: - durations.append(num * 1000) - return durations - - return [] - @staticmethod def normalize_formats_to_exts(formats): out = [] @@ -133,13 +89,12 @@ def normalize_formats_to_exts(formats): return out def should_convert(self, ext, format_exts) -> bool: - if ext in self.normalize_formats_to_exts([b.data().decode() for b in QMovie.supportedFormats()]): + if ext in self.normalize_formats_to_exts( + [b.data().decode() for b in QMovie.supportedFormats()] + ): return False - if ext in self.normalize_formats_to_exts(format_exts): - return True - - return False + return ext in self.normalize_formats_to_exts(format_exts) def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: """Loads an animated image and returns gif data and size, if successful.""" @@ -184,7 +139,8 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None ) logger.debug( - f"[PreviewThumb] Coversion has taken {(time.perf_counter_ns() - st) / 1_000_000} ms", + f"[PreviewThumb] Coversion has taken { + (time.perf_counter_ns() - st) / 1_000_000} ms", ext=ext, ffprobe_time=probe_time, ) @@ -194,9 +150,8 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None return (out, (image.width, image.height)) elif self.should_convert(ext, pillow_converts): - if hasattr(image, "n_frames"): - if image.n_frames <= 1: - return False + if hasattr(image, "n_frames") and image.n_frames <= 1: + return False image_bytes_io = io.BytesIO() st = time.perf_counter_ns() @@ -209,7 +164,8 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None disposal=2, ) logger.debug( - f"[PreviewThumb] Coversion has taken {(time.perf_counter_ns() - st) / 1_000_000} ms", + f"[PreviewThumb] Coversion has taken { + (time.perf_counter_ns() - st) / 1_000_000} ms", ext=ext, ) @@ -217,7 +173,9 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None image_bytes_io.seek(0) return (image_bytes_io.read(), (image.width, image.height)) - elif ext in self.normalize_formats_to_exts([b.data().decode() for b in QMovie.supportedFormats()]): + elif ext in self.normalize_formats_to_exts( + [b.data().decode() for b in QMovie.supportedFormats()] + ): image.close() with open(filepath, "rb") as f: return (f.read(), (image.width, image.height)) From a5282986b702f0c00879b01d2b01a7ea7f2abeb5 Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 17:39:50 -0800 Subject: [PATCH 05/21] test alt jxlinfo path? --- .../controllers/preview_thumb_controller.py | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 354c08be0..061b4fa29 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import TYPE_CHECKING +import subprocess +import re import cv2 import ffmpeg import rawpy @@ -112,13 +114,42 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None if self.should_convert(ext, [".jxl"]): - st = time.perf_counter_ns() - ffprobe = ffmpeg.probe(filepath) + probe_time = "-1 ms" + try: + jxlinfo_cmd = "jxlinfo" - if ffprobe.get('format', {}).get('format_name', '') != "jpegxl_anim": - return False + st = time.perf_counter_ns() + p = subprocess.run( + [jxlinfo_cmd, "-v", filepath], + capture_output=True, + check=False, + text=True, + ) + + probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms (jxlinfo)" + + anim_type = re.findall( + r"^JPEG XL animation.*$", + p.stdout, + flags=re.IGNORECASE | re.MULTILINE + ) + if len(anim_type) < 1: + return False + + except Exception as e: + if isinstance(e, FileNotFoundError): + logger.debug(f"{jxlinfo_cmd} not found") + else: + logger.debug("Can't use jxlinfo command") + + + st = time.perf_counter_ns() + ffprobe = ffmpeg.probe(filepath) + + if ffprobe.get('format', {}).get('format_name', '') != "jpegxl_anim": + return False - probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms" + probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms (ffprobe)" st = time.perf_counter_ns() @@ -142,7 +173,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None f"[PreviewThumb] Coversion has taken { (time.perf_counter_ns() - st) / 1_000_000} ms", ext=ext, - ffprobe_time=probe_time, + probe_time=probe_time, ) image.close() From 159c739917d77b03e093e923e9b148dfc53f9827 Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 17:40:08 -0800 Subject: [PATCH 06/21] Revert "test alt jxlinfo path?" This reverts commit a5282986b702f0c00879b01d2b01a7ea7f2abeb5. --- .../controllers/preview_thumb_controller.py | 43 +++---------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 061b4fa29..354c08be0 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -6,8 +6,6 @@ from pathlib import Path from typing import TYPE_CHECKING -import subprocess -import re import cv2 import ffmpeg import rawpy @@ -114,42 +112,13 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None if self.should_convert(ext, [".jxl"]): - probe_time = "-1 ms" - try: - jxlinfo_cmd = "jxlinfo" - - st = time.perf_counter_ns() - p = subprocess.run( - [jxlinfo_cmd, "-v", filepath], - capture_output=True, - check=False, - text=True, - ) - - probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms (jxlinfo)" - - anim_type = re.findall( - r"^JPEG XL animation.*$", - p.stdout, - flags=re.IGNORECASE | re.MULTILINE - ) - if len(anim_type) < 1: - return False - - except Exception as e: - if isinstance(e, FileNotFoundError): - logger.debug(f"{jxlinfo_cmd} not found") - else: - logger.debug("Can't use jxlinfo command") - - - st = time.perf_counter_ns() - ffprobe = ffmpeg.probe(filepath) + st = time.perf_counter_ns() + ffprobe = ffmpeg.probe(filepath) - if ffprobe.get('format', {}).get('format_name', '') != "jpegxl_anim": - return False + if ffprobe.get('format', {}).get('format_name', '') != "jpegxl_anim": + return False - probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms (ffprobe)" + probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms" st = time.perf_counter_ns() @@ -173,7 +142,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None f"[PreviewThumb] Coversion has taken { (time.perf_counter_ns() - st) / 1_000_000} ms", ext=ext, - probe_time=probe_time, + ffprobe_time=probe_time, ) image.close() From 00f3af950ea42f714f74e42e996c9120fa76aaa0 Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 17:56:35 -0800 Subject: [PATCH 07/21] better file ext checking --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 354c08be0..ef67219b4 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -108,7 +108,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None image: Image.Image = Image.open(filepath) pillow_converts = Image.SAVE_ALL.keys() - pillow_converts = ["." + x.lower() for x in pillow_converts] + pillow_converts = self.normalize_formats_to_exts(pillow_converts) if self.should_convert(ext, [".jxl"]): From 5953f4f4bb1a3b1cef3fc09ef3acbdb6483352b5 Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 17:59:04 -0800 Subject: [PATCH 08/21] formatting --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index ef67219b4..154347e5c 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -107,8 +107,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None try: image: Image.Image = Image.open(filepath) - pillow_converts = Image.SAVE_ALL.keys() - pillow_converts = self.normalize_formats_to_exts(pillow_converts) + pillow_converts = self.normalize_formats_to_exts(Image.SAVE_ALL.keys()) if self.should_convert(ext, [".jxl"]): From 0602c1f7e40c8618597a3af997ace2362d139f15 Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 18:04:00 -0800 Subject: [PATCH 09/21] formatting --- .../controllers/preview_thumb_controller.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 154347e5c..723fc9dcd 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -110,16 +110,17 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None pillow_converts = self.normalize_formats_to_exts(Image.SAVE_ALL.keys()) if self.should_convert(ext, [".jxl"]): + image.close() - st = time.perf_counter_ns() + start = time.perf_counter_ns() ffprobe = ffmpeg.probe(filepath) if ffprobe.get('format', {}).get('format_name', '') != "jpegxl_anim": - return False + return None - probe_time = f"{(time.perf_counter_ns() - st) / 1_000_000} ms" + probe_time = f"{(time.perf_counter_ns() - start) / 1_000_000} ms" - st = time.perf_counter_ns() + start = time.perf_counter_ns() out, _ = ( ffmpeg @@ -139,21 +140,20 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None logger.debug( f"[PreviewThumb] Coversion has taken { - (time.perf_counter_ns() - st) / 1_000_000} ms", + (time.perf_counter_ns() - start) / 1_000_000} ms", ext=ext, ffprobe_time=probe_time, ) - image.close() return (out, (image.width, image.height)) elif self.should_convert(ext, pillow_converts): if hasattr(image, "n_frames") and image.n_frames <= 1: - return False + return None image_bytes_io = io.BytesIO() - st = time.perf_counter_ns() + start = time.perf_counter_ns() image.save( image_bytes_io, "WEBP", @@ -164,7 +164,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None ) logger.debug( f"[PreviewThumb] Coversion has taken { - (time.perf_counter_ns() - st) / 1_000_000} ms", + (time.perf_counter_ns() - start) / 1_000_000} ms", ext=ext, ) @@ -179,7 +179,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None with open(filepath, "rb") as f: return (f.read(), (image.width, image.height)) else: - return False + return None except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) From 65baa2cca3ec5856c55b45d922f26cd1af6dca2d Mon Sep 17 00:00:00 2001 From: BPplays Date: Fri, 12 Dec 2025 18:05:16 -0800 Subject: [PATCH 10/21] disposal=2 only needed for GIF not WEBP --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 723fc9dcd..e8152c492 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -160,7 +160,6 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None lossless=True, save_all=True, loop=0, - disposal=2, ) logger.debug( f"[PreviewThumb] Coversion has taken { From 6bef7f1b7b5ff81ac59b32e60205ad8562cfc38d Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 02:28:50 -0800 Subject: [PATCH 11/21] ruff format --- .../controllers/preview_thumb_controller.py | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index e8152c492..95c418490 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -74,7 +74,6 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: return stats - @staticmethod def normalize_formats_to_exts(formats): out = [] @@ -90,7 +89,7 @@ def normalize_formats_to_exts(formats): def should_convert(self, ext, format_exts) -> bool: if ext in self.normalize_formats_to_exts( - [b.data().decode() for b in QMovie.supportedFormats()] + [str(b.data(), encoding="utf-8") for b in QMovie.supportedFormats()] ): return False @@ -115,7 +114,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None start = time.perf_counter_ns() ffprobe = ffmpeg.probe(filepath) - if ffprobe.get('format', {}).get('format_name', '') != "jpegxl_anim": + if ffprobe.get("format", {}).get("format_name", "") != "jpegxl_anim": return None probe_time = f"{(time.perf_counter_ns() - start) / 1_000_000} ms" @@ -123,16 +122,15 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None start = time.perf_counter_ns() out, _ = ( - ffmpeg - .input(filepath) + ffmpeg.input(filepath) .output( - 'pipe:', - format='webp', + "pipe:", + format="webp", **{ - 'lossless': 1, - 'compression_level': 0, - 'loop': 0, - } + "lossless": 1, + "compression_level": 0, + "loop": 0, + }, ) .global_args("-hide_banner", "-loglevel", "error") .run(capture_stdout=True) @@ -140,12 +138,12 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None logger.debug( f"[PreviewThumb] Coversion has taken { - (time.perf_counter_ns() - start) / 1_000_000} ms", + (time.perf_counter_ns() - start) / 1_000_000 + } ms", ext=ext, ffprobe_time=probe_time, ) - return (out, (image.width, image.height)) elif self.should_convert(ext, pillow_converts): @@ -163,7 +161,8 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None ) logger.debug( f"[PreviewThumb] Coversion has taken { - (time.perf_counter_ns() - start) / 1_000_000} ms", + (time.perf_counter_ns() - start) / 1_000_000 + } ms", ext=ext, ) @@ -172,7 +171,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None return (image_bytes_io.read(), (image.width, image.height)) elif ext in self.normalize_formats_to_exts( - [b.data().decode() for b in QMovie.supportedFormats()] + [str(b.data(), encoding="utf-8") for b in QMovie.supportedFormats()] ): image.close() with open(filepath, "rb") as f: From 4fbd0f3cb3d27aeedcb73f6f940e8cc2966046f7 Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:03:21 -0800 Subject: [PATCH 12/21] changed from str() to .decode --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 95c418490..0ea123327 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -89,7 +89,7 @@ def normalize_formats_to_exts(formats): def should_convert(self, ext, format_exts) -> bool: if ext in self.normalize_formats_to_exts( - [str(b.data(), encoding="utf-8") for b in QMovie.supportedFormats()] + [b.data().decode("utf-8") for b in QMovie.supportedFormats()] ): return False @@ -171,7 +171,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None return (image_bytes_io.read(), (image.width, image.height)) elif ext in self.normalize_formats_to_exts( - [str(b.data(), encoding="utf-8") for b in QMovie.supportedFormats()] + [b.data().decode("utf-8") for b in QMovie.supportedFormats()] ): image.close() with open(filepath, "rb") as f: From 18843a4a231fd64c65c8c9f542908d946fb51728 Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:07:23 -0800 Subject: [PATCH 13/21] better error checking --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 0ea123327..5ac4b21bf 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -78,7 +78,12 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: def normalize_formats_to_exts(formats): out = [] for format in formats: - format = str(format) + if not isinstance(format, str): + logger.error( + "passed non-string to `normalize_formats_to_exts` skipping format" + ) + continue + format = format.lower() if not format.startswith("."): format = "." + format From 84555d522f4c1e0438f5bf2f3f09f3df64cb409a Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:09:01 -0800 Subject: [PATCH 14/21] better logs --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 5ac4b21bf..a61e25865 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -80,7 +80,8 @@ def normalize_formats_to_exts(formats): for format in formats: if not isinstance(format, str): logger.error( - "passed non-string to `normalize_formats_to_exts` skipping format" + "passed non-string to `normalize_formats_to_exts` skipping format", + item=format, ) continue From d6e91c77628162a230b7093b4f896b3c789ed90b Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:25:36 -0800 Subject: [PATCH 15/21] better typing, and formatting fixes --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index a61e25865..7578fec2d 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -4,7 +4,7 @@ import io import time from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable import cv2 import ffmpeg @@ -75,7 +75,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: return stats @staticmethod - def normalize_formats_to_exts(formats): + def normalize_formats_to_exts(formats: Iterable[str]) -> list[str]: out = [] for format in formats: if not isinstance(format, str): @@ -93,7 +93,7 @@ def normalize_formats_to_exts(formats): return out - def should_convert(self, ext, format_exts) -> bool: + def should_convert(self, ext: str, format_exts: Iterable[str]) -> bool: if ext in self.normalize_formats_to_exts( [b.data().decode("utf-8") for b in QMovie.supportedFormats()] ): @@ -153,7 +153,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None return (out, (image.width, image.height)) elif self.should_convert(ext, pillow_converts): - if hasattr(image, "n_frames") and image.n_frames <= 1: + if getattr(image, "n_frames", -1) <= 1: return None image_bytes_io = io.BytesIO() From 3f30c349f91b7d7437c58ec2275fbb32495dac6b Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:29:46 -0800 Subject: [PATCH 16/21] ruff format --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 7578fec2d..2c5a4b773 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -3,8 +3,9 @@ import io import time +from collections.abc import Iterable from pathlib import Path -from typing import TYPE_CHECKING, Iterable +from typing import TYPE_CHECKING import cv2 import ffmpeg @@ -153,7 +154,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None return (out, (image.width, image.height)) elif self.should_convert(ext, pillow_converts): - if getattr(image, "n_frames", -1) <= 1: + if getattr(image, "n_frames", -1) <= 1: return None image_bytes_io = io.BytesIO() From be34b1ab986dfcc395b79c3e9a5e0cc9b211aa1d Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:45:27 -0800 Subject: [PATCH 17/21] changes list to set --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 2c5a4b773..d0032a070 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -77,7 +77,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: @staticmethod def normalize_formats_to_exts(formats: Iterable[str]) -> list[str]: - out = [] + out: set[str] = set() for format in formats: if not isinstance(format, str): logger.error( @@ -90,7 +90,7 @@ def normalize_formats_to_exts(formats: Iterable[str]) -> list[str]: if not format.startswith("."): format = "." + format - out.append(format) + out.add(format) return out From f9d78747345e9b0435e5649aabb8f18a4f58e491 Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:45:52 -0800 Subject: [PATCH 18/21] fixed typing --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index d0032a070..9a2d31ac2 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -76,7 +76,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: return stats @staticmethod - def normalize_formats_to_exts(formats: Iterable[str]) -> list[str]: + def normalize_formats_to_exts(formats: Iterable[str]) -> set[str]: out: set[str] = set() for format in formats: if not isinstance(format, str): From ca786387e0af7783d4212653c93024ed7c6439dc Mon Sep 17 00:00:00 2001 From: BPplays Date: Sat, 13 Dec 2025 04:51:21 -0800 Subject: [PATCH 19/21] cast QMovie.supportedFormats .data to bytes, should be find and mypy was complaining --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 9a2d31ac2..36991a3e8 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -96,7 +96,7 @@ def normalize_formats_to_exts(formats: Iterable[str]) -> set[str]: def should_convert(self, ext: str, format_exts: Iterable[str]) -> bool: if ext in self.normalize_formats_to_exts( - [b.data().decode("utf-8") for b in QMovie.supportedFormats()] + [bytes(b.data()).decode("utf-8") for b in QMovie.supportedFormats()] ): return False @@ -178,7 +178,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None return (image_bytes_io.read(), (image.width, image.height)) elif ext in self.normalize_formats_to_exts( - [b.data().decode("utf-8") for b in QMovie.supportedFormats()] + [bytes(b.data()).decode("utf-8") for b in QMovie.supportedFormats()] ): image.close() with open(filepath, "rb") as f: From 87734e9e05031cea1030a9653775eb2773f05eff Mon Sep 17 00:00:00 2001 From: BPplays Date: Mon, 15 Dec 2025 22:35:30 -0800 Subject: [PATCH 20/21] added better check for if image is animated --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 36991a3e8..4d8d123a4 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -154,7 +154,8 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None return (out, (image.width, image.height)) elif self.should_convert(ext, pillow_converts): - if getattr(image, "n_frames", -1) <= 1: + if (not getattr(image, "is_animated", False)) or \ + getattr(image, "n_frames", -1) <= 1: return None image_bytes_io = io.BytesIO() From 2a4f5169485f3d00bf4131a5726294bf9c527902 Mon Sep 17 00:00:00 2001 From: BPplays Date: Mon, 15 Dec 2025 22:39:02 -0800 Subject: [PATCH 21/21] added format skip for ruff, i think it's better then ruff formatting --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 4d8d123a4..4d6e632f7 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -155,7 +155,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None elif self.should_convert(ext, pillow_converts): if (not getattr(image, "is_animated", False)) or \ - getattr(image, "n_frames", -1) <= 1: + getattr(image, "n_frames", -1) <= 1: # fmt: skip return None image_bytes_io = io.BytesIO()