From 39d733dc66eb3f9fdf79e6a1a39e8511be605916 Mon Sep 17 00:00:00 2001 From: Ethnogeny <111099761+050011-code@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:00:10 +1000 Subject: [PATCH 1/5] Update blender_thumbnailer.py to read Blender 5.x files Updates blender_thumbnailer.py to read Blender 5.x files. Also refactor blender_thumbnailer.py to improve readability and maintainability. (Declaring this bit as AI made) Handle file operations more safely. --- .../renderers/vendored/blender_thumbnailer.py | 394 +++++++++++++++--- 1 file changed, 342 insertions(+), 52 deletions(-) diff --git a/src/tagstudio/renderers/vendored/blender_thumbnailer.py b/src/tagstudio/renderers/vendored/blender_thumbnailer.py index 8886bcbdd..67913129e 100644 --- a/src/tagstudio/renderers/vendored/blender_thumbnailer.py +++ b/src/tagstudio/renderers/vendored/blender_thumbnailer.py @@ -1,91 +1,381 @@ -# SPDX-FileCopyrightText: (c) 2017 Blender Foundation -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +#!/usr/bin/env python3 + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +# + + +## This file is a modified script that gets the thumbnail data stored in a blend file -"""Extract an embedded thumbnail from a Blender file.""" import gzip +import logging import os import struct -from io import BufferedReader -from pathlib import Path - from PIL import Image, ImageOps -def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: - rend = b"REND" - test = b"TEST" - - blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") +def blend_extract_thumb(path): + REND = b"REND" + TEST = b"TEST" + ENDB = b"ENDB" - head = blendfile.read(12) + blendfile = None + raw_file = None - if head[0:2] == b"\x1f\x8b": # gzip magic - blendfile.close() - blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) - head = blendfile.read(12) + try: + # -------------------------------------------------------------- + # Open file. + # -------------------------------------------------------------- + raw_file = open(path, "rb") # noqa: SIM115 - if not head.startswith(b"BLENDER"): - blendfile.close() - return None, 0, 0 + # Legacy header = 12 bytes + # Blender 5+ = 17 bytes + head = raw_file.read(17) - is_64_bit = head[7] == b"-"[0] + # -------------------------------------------------------------- + # GZIP-compressed blend file. + # -------------------------------------------------------------- + if head[:2] == b"\x1f\x8b": + logging.info("GZIP blend file") - # true for PPC, false for X86 - is_big_endian = head[8] == b"V"[0] + raw_file.close() + raw_file = None - # blender pre 2.5 had no thumbs - if head[9:11] <= b"24": - return None, 0, 0 + blendfile = gzip.open(path, "rb") + head = blendfile.read(17) + else: + blendfile = raw_file - sizeof_bhead = 24 if is_64_bit else 20 - int_endian = ">i" if is_big_endian else "= 17 + and head[7:9].isdigit() + and head[9:13] == b"-01v" #format + ) + + if is_blender_5: + try: + header_size = int(head[7:9]) + version = int(head[13:17]) + except ValueError: + logging.info("Invalid Blender 5 header") + return None, 0, 0 + + logging.info( + "Blender 5+ header: size=%d version=%d", + header_size, + version, + ) + + if header_size < 17: + logging.info("Invalid Blender 5 header size") + return None, 0, 0 + + # We have already consumed 17 bytes. + if header_size > 17: + blendfile.seek(header_size - 17, os.SEEK_CUR) - if code == rend: - blendfile.seek(length, os.SEEK_CUR) + # ---------------------------------------------------------- + # Blender 5+ BHead + # + # 0-3 code + # 4-7 SDNA index (uint32) + # 8-15 old pointer (uint64) + # 16-23 block size (uint64) + # 24-31 count (uint64) + # + # Total = 32 bytes. + # ---------------------------------------------------------- + sizeof_bhead = 32 + large_bhead = True + + int_endian_pair = "= 4 and bhead[:4] == ENDB: + logging.info("Reached ENDB before TEST") + return None, 0, 0 + + if len(bhead) < sizeof_bhead: + logging.info( + "Truncated BHead at offset %d: got %d bytes, expected %d", + block_offset, + len(bhead), + sizeof_bhead, + ) + return None, 0, 0 + + code = bhead[:4] + + # ---------------------------------------------------------- + # Blender 5+ + # + # The block size is at offset 16 and is uint64. + # ---------------------------------------------------------- + if large_bhead: + length = struct.unpack_from( + " Image.Image | None: +def blend_thumb(file_in): buf, width, height = blend_extract_thumb(file_in) - if buf is None: - return None image = Image.frombuffer( "RGBA", (width, height), buf, ) image = ImageOps.flip(image) + width, height = image.size + ratio = height/width + image = image.resize((512, round(512*ratio)),Image.BICUBIC) return image From c7ac68fbc5fd666b6ce6271e181b75f7633a9bb3 Mon Sep 17 00:00:00 2001 From: Ethnogeny <111099761+050011-code@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:02 +1000 Subject: [PATCH 2/5] Add type hints and clean up blender_thumbnailer.py Refactor blender_thumbnailer.py to use type hints and improve readability. --- .../renderers/vendored/blender_thumbnailer.py | 98 ++++++++----------- 1 file changed, 43 insertions(+), 55 deletions(-) diff --git a/src/tagstudio/renderers/vendored/blender_thumbnailer.py b/src/tagstudio/renderers/vendored/blender_thumbnailer.py index 67913129e..fd3ea0f73 100644 --- a/src/tagstudio/renderers/vendored/blender_thumbnailer.py +++ b/src/tagstudio/renderers/vendored/blender_thumbnailer.py @@ -1,53 +1,36 @@ -#!/usr/bin/env python3 - -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - -# - - -## This file is a modified script that gets the thumbnail data stored in a blend file +# SPDX-FileCopyrightText: (c) 2017 Blender Foundation +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only +"""Extract an embedded thumbnail from a Blender file.""" import gzip -import logging import os import struct +from io import BufferedReader +from typing import BinaryIO +from pathlib import Path + from PIL import Image, ImageOps -def blend_extract_thumb(path): - REND = b"REND" - TEST = b"TEST" - ENDB = b"ENDB" +def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: + REND: bytes = b"REND" + TEST: bytes = b"TEST" + ENDB: bytes = b"ENDB" - blendfile = None - raw_file = None + blendfile: BinaryIO | None = None + raw_file: BinaryIO | None = None try: # -------------------------------------------------------------- # Open file. # -------------------------------------------------------------- - raw_file = open(path, "rb") # noqa: SIM115 + raw_file: BufferedReader = open(path, "rb") # Legacy header = 12 bytes # Blender 5+ = 17 bytes - head = raw_file.read(17) + head: bytes = raw_file.read(17) # -------------------------------------------------------------- # GZIP-compressed blend file. @@ -86,7 +69,7 @@ def blend_extract_thumb(path): # 12 = 'v' # 13-16 = Blender version # -------------------------------------------------------------- - is_blender_5 = ( + is_blender_5: bool = ( len(head) >= 17 and head[7:9].isdigit() and head[9:13] == b"-01v" #format @@ -94,8 +77,8 @@ def blend_extract_thumb(path): if is_blender_5: try: - header_size = int(head[7:9]) - version = int(head[13:17]) + header_size: int = int(head[7:9]) + version: int = int(head[13:17]) except ValueError: logging.info("Invalid Blender 5 header") return None, 0, 0 @@ -125,10 +108,10 @@ def blend_extract_thumb(path): # # Total = 32 bytes. # ---------------------------------------------------------- - sizeof_bhead = 32 - large_bhead = True + sizeof_bhead: int = 32 + large_bhead: bool = True - int_endian_pair = " Image.Image | None: buf, width, height = blend_extract_thumb(file_in) + if buf is None: + return None image = Image.frombuffer( "RGBA", (width, height), buf, ) image = ImageOps.flip(image) + # Upscale Image so it looks better at higher resolutions. width, height = image.size ratio = height/width image = image.resize((512, round(512*ratio)),Image.BICUBIC) From 4dff491efaec036b93b5fa9a95532630fffe681b Mon Sep 17 00:00:00 2001 From: Ethnogeny <111099761+050011-code@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:49:41 +1000 Subject: [PATCH 3/5] Clean up logging in blender_thumbnailer.py Removed unnecessary logging statements to clean up code. --- .../renderers/vendored/blender_thumbnailer.py | 96 +------------------ 1 file changed, 3 insertions(+), 93 deletions(-) diff --git a/src/tagstudio/renderers/vendored/blender_thumbnailer.py b/src/tagstudio/renderers/vendored/blender_thumbnailer.py index fd3ea0f73..8d948d937 100644 --- a/src/tagstudio/renderers/vendored/blender_thumbnailer.py +++ b/src/tagstudio/renderers/vendored/blender_thumbnailer.py @@ -36,8 +36,6 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: # GZIP-compressed blend file. # -------------------------------------------------------------- if head[:2] == b"\x1f\x8b": - logging.info("GZIP blend file") - raw_file.close() raw_file = None @@ -46,14 +44,10 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: else: blendfile = raw_file - logging.info("Head: %r", head) - if not head.startswith(b"BLENDER"): - logging.info("Header doesn't start with BLENDER") return None, 0, 0 if len(head) < 12: - logging.info("Header is too short") return None, 0, 0 # -------------------------------------------------------------- @@ -70,9 +64,7 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: # 13-16 = Blender version # -------------------------------------------------------------- is_blender_5: bool = ( - len(head) >= 17 - and head[7:9].isdigit() - and head[9:13] == b"-01v" #format + len(head) >= 17 and head[7:9].isdigit() and head[9:13] == b"-01v" # format ) if is_blender_5: @@ -80,17 +72,9 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: header_size: int = int(head[7:9]) version: int = int(head[13:17]) except ValueError: - logging.info("Invalid Blender 5 header") return None, 0, 0 - logging.info( - "Blender 5+ header: size=%d version=%d", - header_size, - version, - ) - if header_size < 17: - logging.info("Invalid Blender 5 header size") return None, 0, 0 # We have already consumed 17 bytes. @@ -135,19 +119,10 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: try: version: int = int(head[9:12]) except ValueError: - logging.info("Invalid legacy Blender version") return None, 0, 0 - logging.info( - "Legacy Blender header: version=%d 64bit=%s big_endian=%s", - version, - is_64_bit, - is_big_endian, - ) - # Blender pre-2.5 had no thumbnails. if version < 250: - logging.info("Blender version has no thumbnails") return None, 0, 0 sizeof_bhead: int = 24 if is_64_bit else 20 @@ -167,26 +142,11 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: bhead: bytes = blendfile.read(sizeof_bhead) - logging.debug( - "BHead at offset %d: read %d/%d bytes: %r", - block_offset, - len(bhead), - sizeof_bhead, - bhead[:4], - ) - # ENDB is a special partial BHead. if len(bhead) >= 4 and bhead[:4] == ENDB: - logging.info("Reached ENDB before TEST") return None, 0, 0 if len(bhead) < sizeof_bhead: - logging.info( - "Truncated BHead at offset %d: got %d bytes, expected %d", - block_offset, - len(bhead), - sizeof_bhead, - ) return None, 0, 0 code: bytes = bhead[:4] @@ -215,15 +175,6 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: 24, )[0] - logging.debug( - "Blender 5 BHead: offset=%d code=%r size=%d sdna=%d count=%d", - block_offset, - code, - length, - sdna, - count, - ) - # ---------------------------------------------------------- # Legacy Blender # @@ -240,27 +191,14 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: 4, )[0] - logging.debug( - "Legacy BHead: offset=%d code=%r size=%d", - block_offset, - code, - length, - ) - # ---------------------------------------------------------- # REND contains render information before TEST. # Skip its payload. # ---------------------------------------------------------- if code == REND: if length < 0: - logging.info("Invalid REND length: %d", length) return None, 0, 0 - logging.debug( - "Skipping REND payload: %d bytes", - length, - ) - blendfile.seek(length, os.SEEK_CUR) continue @@ -271,11 +209,6 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: # We need the TEST block. # -------------------------------------------------------------- if code != TEST: - logging.info( - "Expected TEST block, found %r at offset %d", - code, - block_offset, - ) return None, 0, 0 # -------------------------------------------------------------- @@ -288,7 +221,6 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: dimensions: bytes = blendfile.read(8) if len(dimensions) != 8: - logging.info("TEST block is missing dimensions") return None, 0, 0 try: @@ -299,34 +231,17 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: dimensions, ) except struct.error: - logging.info("Unable to unpack thumbnail dimensions") return None, 0, 0 - logging.info( - "Thumbnail dimensions: %dx%d", - x, - y, - ) - # The TEST block length includes the two 32-bit dimensions. image_length: int = length - 8 if x <= 0 or y <= 0: - logging.info( - "Invalid thumbnail dimensions: %dx%d", - x, - y, - ) return None, 0, 0 expected_length: int = x * y * 4 if image_length != expected_length: - logging.info( - "Thumbnail size mismatch: block=%d expected=%d", - image_length, - expected_length, - ) return None, 0, 0 # -------------------------------------------------------------- @@ -335,11 +250,6 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: image_buffer: bytes = blendfile.read(image_length) if len(image_buffer) != image_length: - logging.info( - "Thumbnail data truncated: got %d expected %d", - len(image_buffer), - image_length, - ) return None, 0, 0 return image_buffer, x, y @@ -364,6 +274,6 @@ def blend_thumb(file_in: Path | str) -> Image.Image | None: image = ImageOps.flip(image) # Upscale Image so it looks better at higher resolutions. width, height = image.size - ratio = height/width - image = image.resize((512, round(512*ratio)),Image.BICUBIC) + ratio = height / width + image = image.resize((512, round(512 * ratio)), Image.BICUBIC) return image From b5222ee85309153ff0e3b27cca7244340bdb737f Mon Sep 17 00:00:00 2001 From: Ethnogeny <111099761+050011-code@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:01:19 +1000 Subject: [PATCH 4/5] ruff format hopefully --- src/tagstudio/renderers/vendored/blender_thumbnailer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tagstudio/renderers/vendored/blender_thumbnailer.py b/src/tagstudio/renderers/vendored/blender_thumbnailer.py index 8d948d937..7066cecb8 100644 --- a/src/tagstudio/renderers/vendored/blender_thumbnailer.py +++ b/src/tagstudio/renderers/vendored/blender_thumbnailer.py @@ -7,9 +7,8 @@ import gzip import os import struct -from io import BufferedReader -from typing import BinaryIO from pathlib import Path +from typing import BinaryIO from PIL import Image, ImageOps @@ -19,14 +18,14 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: TEST: bytes = b"TEST" ENDB: bytes = b"ENDB" - blendfile: BinaryIO | None = None + blendfile: BinaryIO | gzip.GzipFile | None = None raw_file: BinaryIO | None = None try: # -------------------------------------------------------------- # Open file. # -------------------------------------------------------------- - raw_file: BufferedReader = open(path, "rb") + raw_file = open(path, "rb") # Legacy header = 12 bytes # Blender 5+ = 17 bytes @@ -95,6 +94,7 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: sizeof_bhead: int = 32 large_bhead: bool = True + int_endian: str = "<" int_endian_pair: str = " Date: Mon, 17 Aug 2026 12:06:41 +1000 Subject: [PATCH 5/5] Final format Updated image resizing method to use Image.Resampling.BICUBIC. --- .../renderers/vendored/blender_thumbnailer.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/tagstudio/renderers/vendored/blender_thumbnailer.py b/src/tagstudio/renderers/vendored/blender_thumbnailer.py index 7066cecb8..19cf3707c 100644 --- a/src/tagstudio/renderers/vendored/blender_thumbnailer.py +++ b/src/tagstudio/renderers/vendored/blender_thumbnailer.py @@ -138,8 +138,6 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: # Walk the BHeads until we find TEST. # -------------------------------------------------------------- while True: - block_offset: int = blendfile.tell() - bhead: bytes = blendfile.read(sizeof_bhead) # ENDB is a special partial BHead. @@ -163,18 +161,6 @@ def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]: 16, )[0] - sdna: int = struct.unpack_from( - " Image.Image | None: # Upscale Image so it looks better at higher resolutions. width, height = image.size ratio = height / width - image = image.resize((512, round(512 * ratio)), Image.BICUBIC) + image = image.resize((512, round(512 * ratio)), Image.Resampling.BICUBIC) return image