class_name KeetTextureCache
extends RefCounted
## LRU texture cache for decoded WebP previews.
## Decodes base64 WebP data into ImageTexture with eviction.
const MAX_ENTRIES: int = 100
const MAX_BYTES: int = 50 * 1024 * 1024 # 50 MB
var _cache: Dictionary = {} # key -> {tex: ImageTexture, size: int, access: int}
var _order: Array[String] = [] # LRU order (most recent at end)
var _total_bytes: int = 0
var _access_counter: int = 0
## Decode base64 WebP data and cache as ImageTexture.
## Returns null on decode failure.
func decode_and_cache(key: String, base64_webp: String) -> ImageTexture:
if _cache.has(key):
_touch(key)
return _cache[key].tex
var raw: PackedByteArray = Marshalls.base64_to_raw(base64_webp)
if raw.is_empty():
return null
var img := Image.new()
var err: Error = img.load_webp_from_buffer(raw)
if err != OK:
# Try PNG fallback (some previews may be PNG)
err = img.load_png_from_buffer(raw)
if err != OK:
# Try JPEG fallback
err = img.load_jpg_from_buffer(raw)
if err != OK:
return null
var tex := ImageTexture.create_from_image(img)
var entry_size: int = raw.size()
# Evict until we have room
while _total_bytes + entry_size > MAX_BYTES and _order.size() > 0:
_evict_oldest()
while _order.size() >= MAX_ENTRIES:
_evict_oldest()
_access_counter += 1
_cache[key] = {"tex": tex, "size": entry_size, "access": _access_counter}
_order.append(key)
_total_bytes += entry_size
return tex
## Decode raw image bytes (WebP/PNG/JPEG) and cache as ImageTexture.
## content_type hint avoids trying wrong formats (which spams Godot error log).
## Returns null on decode failure.
func decode_bytes_and_cache(key: String, raw: PackedByteArray, content_type: String = "") -> ImageTexture:
if _cache.has(key):
_touch(key)
return _cache[key].tex
if raw.is_empty():
return null
var img := Image.new()
var err: Error = _load_image_smart(img, raw, content_type)
if err != OK:
return null
var tex := ImageTexture.create_from_image(img)
var entry_size: int = raw.size()
while _total_bytes + entry_size > MAX_BYTES and _order.size() > 0:
_evict_oldest()
while _order.size() >= MAX_ENTRIES:
_evict_oldest()
_access_counter += 1
_cache[key] = {"tex": tex, "size": entry_size, "access": _access_counter}
_order.append(key)
_total_bytes += entry_size
return tex
## Get a cached texture by key, or null if not cached.
func get_cached(key: String) -> ImageTexture:
if _cache.has(key):
_touch(key)
return _cache[key].tex
return null
## Check if a key is cached.
func has_key(key: String) -> bool:
return _cache.has(key)
## Clear all cached textures.
func clear() -> void:
_cache.clear()
_order.clear()
_total_bytes = 0
_access_counter = 0
## Number of cached entries.
func count() -> int:
return _cache.size()
func _touch(key: String) -> void:
_access_counter += 1
if _cache.has(key):
_cache[key].access = _access_counter
var idx: int = _order.find(key)
if idx >= 0:
_order.remove_at(idx)
_order.append(key)
func _evict_oldest() -> void:
if _order.is_empty():
return
var oldest_key: String = _order[0]
_order.remove_at(0)
if _cache.has(oldest_key):
_total_bytes -= _cache[oldest_key].size
_cache.erase(oldest_key)
## Try correct image format first based on content_type, then fall back.
## This avoids Godot's internal error spam from trying wrong formats.
func _load_image_smart(img: Image, raw: PackedByteArray, content_type: String) -> Error:
if "jpeg" in content_type or "jpg" in content_type:
var err: Error = img.load_jpg_from_buffer(raw)
if err == OK: return OK
err = img.load_webp_from_buffer(raw)
if err == OK: return OK
return img.load_png_from_buffer(raw)
elif "png" in content_type:
var err: Error = img.load_png_from_buffer(raw)
if err == OK: return OK
err = img.load_webp_from_buffer(raw)
if err == OK: return OK
return img.load_jpg_from_buffer(raw)
else:
# Default: WebP first (most keet previews are WebP)
var err: Error = img.load_webp_from_buffer(raw)
if err == OK: return OK
err = img.load_png_from_buffer(raw)
if err == OK: return OK
return img.load_jpg_from_buffer(raw)