2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,3 @@
'''Load image previews in parallel and in any resolution. Supports BIP files.'''
__version__ = '1.0.8.1'
@@ -0,0 +1,15 @@
from base64 import b64decode
class _BIPFormat:
'''BIP format info.'''
def __init__(self, magic: bytes):
self.magic = magic
BIP_FORMATS = {
'BIP2': _BIPFormat(magic=b'BIP2'),
}
MAGIC_LENGTH = max([len(spec.magic) for spec in BIP_FORMATS.values()])
@@ -0,0 +1,225 @@
import bpy
import bpy.utils.previews
from bpy.types import ImagePreview
from multiprocessing.dummy import Pool
from multiprocessing import cpu_count
from threading import Event
from queue import Queue
from traceback import print_exc
from time import time
from typing import ItemsView, Iterator, KeysView, ValuesView
from .utils import can_load, load_file, tag_redraw
class ImagePreviewCollection:
'''Dictionary-like class of previews.'''
def __init__(self, max_size: tuple = (128, 128), lazy_load: bool = True):
'''Create collection and start internal timer.'''
self._collection = bpy.utils.previews.new()
self._max_size = max_size
self._lazy_load = lazy_load
if self._lazy_load:
self._pool = Pool(processes=cpu_count())
self._event = None
self._queue = Queue()
if not bpy.app.timers.is_registered(self._timer):
bpy.app.timers.register(self._timer, persistent=True)
def __len__(self) -> int:
'''Return the amount of previews in the collection.'''
return len(self._collection)
def __iter__(self) -> Iterator[str]:
'''Return an iterator for the names in the collection.'''
return iter(self._collection)
def __contains__(self, key) -> bool:
'''Return whether preview name is in collection.'''
return key in self._collection
def __getitem__(self, key) -> ImagePreview:
'''Return preview with the given name.'''
return self._collection[key]
def pop(self, key: str) -> ImagePreview:
'''Remove preview with the given name and return it.'''
return self._collection.pop(key)
def get(self, key: str, default=None) -> ImagePreview:
'''Return preview with the given name, or default.'''
return self._collection.get(key, default)
def keys(self) -> KeysView[str]:
'''Return preview names.'''
return self._collection.keys()
def values(self) -> ValuesView[ImagePreview]:
'''Return previews.'''
return self._collection.values()
def items(self) -> ItemsView[str, ImagePreview]:
'''Return pairs of name and preview.'''
return self._collection.items()
def new_safe(self, name: str) -> ImagePreview:
'''Generate a new empty preview or return existing.'''
if name in self:
return self[name]
return self.new(name)
def new(self, name: str) -> ImagePreview:
'''Generate a new empty preview.'''
return self._collection.new(name)
def load_safe(
self,
name: str,
filepath: str,
filetype: str,
) -> ImagePreview:
'''Generate a new preview from the given filepath or return existing.'''
if name in self:
return self[name]
return self.load(name, filepath, filetype)
def load(self, name: str, filepath: str, filetype: str) -> ImagePreview:
'''Generate a new preview from the given filepath.'''
if filetype != 'IMAGE' or not can_load(filepath):
return self._load_fallback(name, filepath, filetype)
if not self._lazy_load:
return self._load_eager(name, filepath)
preview = self.new(name)
self._pool.apply_async(
func=self._load_async,
args=(name, filepath, self._get_event()),
error_callback=print,
)
return preview
def _load_fallback(
self,
name: str,
filepath: str,
filetype: str,
) -> ImagePreview:
'''Load preview using Blender's standard method.'''
preview = self._collection.load(name, filepath, filetype)
if not self._lazy_load:
preview.icon_size[:] # Force Blender to load this icon now.
preview.image_size[:] # Force Blender to load this image now.
return preview
def _load_eager(self, name: str, filepath: str) -> ImagePreview:
'''Load image contents from file and load preview.'''
data = load_file(filepath)
preview = self.new(name)
preview.icon_size = data['icon_size']
preview.icon_pixels = data['icon_pixels']
preview.image_size = data['image_size']
preview.image_pixels = data['image_pixels']
return preview
def _load_async(self, name: str, filepath: str, event: Event):
'''Load image contents from file and queue preview load.'''
if not event.is_set():
data = load_file(filepath)
if not event.is_set():
self._queue.put((name, data, event))
def _timer(self):
'''Load queued image contents into previews.'''
now = time()
redraw = False
delay = 0.1
while time() - now < 0.1:
try:
args = self._queue.get(block=False)
except:
break
try:
self._load_queued(*args)
except:
print_exc()
else:
redraw = True
else:
delay = 0.0
if redraw:
tag_redraw()
return delay
def _load_queued(self, name: str, data: dict, event: Event):
'''Load queued image contents into preview.'''
if not event.is_set():
if name in self:
preview = self[name]
preview.icon_size = data['icon_size']
preview.icon_pixels = data['icon_pixels']
preview.image_size = data['image_size']
preview.image_pixels = data['image_pixels']
def clear(self):
'''Clear all previews.'''
if self._lazy_load:
self._set_event()
with self._queue.mutex:
self._queue.queue.clear()
self._collection.clear()
def close(self):
'''Close the collection and clear all previews.'''
if self._lazy_load:
self._set_event()
if bpy.app.timers.is_registered(self._timer):
bpy.app.timers.unregister(self._timer)
self._collection.close()
def _get_event(self) -> Event:
'''Get the clear event, make one if necesssary.'''
if self._event is None:
self._event = Event()
return self._event
def _set_event(self):
'''Set the clear event, then remove the reference.'''
if self._event is not None:
self._event.set()
self._event = None
def new(
max_size: tuple = (128, 128),
lazy_load: bool = True,
) -> ImagePreviewCollection:
'''Return a new preview collection.'''
return ImagePreviewCollection(max_size, lazy_load)
def remove(collection: ImagePreviewCollection):
'''Remove the specified preview collection.'''
collection.close()
@@ -0,0 +1,80 @@
import bpy
import io
from zlib import decompress
from array import array
from .formats import BIP_FORMATS, MAGIC_LENGTH
def can_load(filepath: str) -> bool:
'''Return whether an image can be loaded.'''
# Read magic for format detection.
with open(filepath, 'rb') as file:
magic = file.read(MAGIC_LENGTH)
# We support BIP (currently only BIP2).
for spec in BIP_FORMATS.values():
if magic.startswith(spec.magic):
return True
return False
def load_file(filepath: str) -> dict:
'''Load image preview data from file.
Args:
filepath: The input file path.
Returns:
A dictionary with icon_size, icon_pixels, image_size, image_pixels.
Raises:
AssertionError: If pixel data type is not 32 bit.
AssertionError: If pixel count does not match size.
'''
with open(filepath, 'rb') as bip:
magic = bip.read(MAGIC_LENGTH)
if magic.startswith(BIP_FORMATS['BIP2'].magic):
bip.seek(len(BIP_FORMATS['BIP2'].magic), io.SEEK_SET)
count = int.from_bytes(bip.read(1), 'big')
assert count > 0, 'the file contains no images'
icon_size = [int.from_bytes(bip.read(2), 'big') for _ in range(2)]
icon_length = int.from_bytes(bip.read(4), 'big')
bip.seek(8 * (count - 2), io.SEEK_CUR)
image_size = [int.from_bytes(bip.read(2), 'big') for _ in range(2)]
image_length = int.from_bytes(bip.read(4), 'big')
icon_content = decompress(bip.read(icon_length))
bip.seek(-image_length, io.SEEK_END)
image_content = decompress(bip.read(image_length))
icon_pixels = array('i', icon_content)
assert icon_pixels.itemsize == 4, 'unexpected bytes per pixel'
length = icon_size[0] * icon_size[1]
assert len(icon_pixels) == length, 'unexpected amount of pixels'
image_pixels = array('i', image_content)
assert image_pixels.itemsize == 4, 'unexpected bytes per pixel'
length = image_size[0] * image_size[1]
assert len(image_pixels) == length, 'unexpected amount of pixels'
return {
'icon_size': icon_size,
'icon_pixels': icon_pixels,
'image_size': image_size,
'image_pixels': image_pixels,
}
raise ValueError('input is not a supported file format')
def tag_redraw():
'''Redraw every region in Blender.'''
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
for region in area.regions:
region.tag_redraw()