feat: disk caching for GIF frames and banners, debug focus handler

- Persist animated GIF frames to user://boxart/itch/anim_frames/<hash>/
  so subsequent loads skip ffmpeg extraction entirely.
- Persist static GIF first-frame PNGs alongside for the same reason.
- Cache stitched banner images via OGPU Cache with a synthetic key
  from both screenshot URLs, avoiding re-stitch on every load.
- Add comprehensive debug logging to _on_focus_changed to diagnose
  why AnimatedTextures aren't playing (logs control name, type,
  GameCard ancestor, texture type).
- Fix plugin.json version to match git tag (was stuck at 0.1.14).

Bump to v0.1.18
This commit is contained in:
Jose Falanga 2026-08-20 00:36:41 -03:00
parent 49fdbe8f0f
commit e703ef5d55
2 changed files with 155 additions and 15 deletions

View file

@ -23,6 +23,8 @@ const SETTING_SECTION := "plugin.artprovider"
const SETTING_ANIMATED_GIFS := "animated_gifs"
const GIF_DEFAULT_FRAME_DELAY_MS := 100
const CACHE_DIR := "images"
const ANIM_FRAMES_DIR := "anim_frames"
const BANNER_CACHE_DIR := "banners"
@export var use_caching: bool = true
var http_image := HTTPImageFetcher.new()
@ -46,7 +48,9 @@ var _ffmpeg_bin: String = ""
func _init() -> void:
super()
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BOXART_DIR))
var globalized := ProjectSettings.globalize_path(BOXART_DIR)
DirAccess.make_dir_recursive_absolute(globalized)
DirAccess.make_dir_recursive_absolute(globalized + "/" + ANIM_FRAMES_DIR)
provider_id = "itch"
logger_name = "BoxArtItch"
@ -71,31 +75,41 @@ func _on_focus_changed(control: Control) -> void:
# Pause the previously focused animation.
if _focused_animated_texture != null:
if is_instance_valid(_focused_animated_texture):
logger.debug("Focus: pausing previous AnimatedTexture")
_focused_animated_texture.pause = true
_focused_animated_texture = null
if control == null:
logger.debug("Focus: control is null")
return
logger.debug("Focus: control=%s (%s)" % [control.name, control.get_class()])
# Walk up the tree to find the GameCard parent.
var card: Control = control
while card != null:
if not is_instance_valid(card):
logger.debug("Focus: invalid node while walking up")
return
if card is GameCard:
break
card = card.get_parent()
if card == null:
logger.debug("Focus: no GameCard ancestor found for %s" % control.name)
return
# Check if the card's TextureRect has an AnimatedTexture.
var texture_rect := card.get_node_or_null("%TextureRect") as TextureRect
if texture_rect == null:
logger.debug("Focus: %%TextureRect not found in card %s" % card.name)
return
if texture_rect.texture is AnimatedTexture:
_focused_animated_texture = texture_rect.texture as AnimatedTexture
_focused_animated_texture.pause = false
logger.debug("Focus: unpaused AnimatedTexture on %s (%d frames)" % [card.name, _focused_animated_texture.frames_count])
else:
logger.debug("Focus: texture on %s is %s (not AnimatedTexture)" % [card.name, str(texture_rect.texture)])
## Strips HTML srcset artifacts from a URL. Butlerd sometimes passes through
@ -251,13 +265,29 @@ func _enrich_metadata(game: Dictionary, game_id: int, title: String) -> Dictiona
## Fetches two screenshots and combines them side-by-side for banner layout.
## Falls back to individual screenshots if the combine fails.
## The combined result is cached to disk via OGPU's Cache system so it is
## not re-stitched on every load. Falls back to individual screenshots if
## the combine fails.
func _fetch_banner_textures(item: LibraryItem, screenshots: Array, cache_flags: int) -> Texture2D:
# Build a synthetic cache key from both screenshot URLs.
var banner_key: String = screenshots[0] + "||" + screenshots[1]
# Check disk cache for a previously stitched banner.
if cache_flags & Cache.FLAGS.LOAD and Cache.is_cached(BANNER_CACHE_DIR, banner_key):
var cached := Cache.get_image(BANNER_CACHE_DIR, banner_key)
if cached != null:
logger.info("Banner: loaded cached stitched banner for %s" % item.name)
return cached
logger.info("Fetching itch.io banner (2 screenshots) for: " + item.name)
var tex_a := await _fetch_image(screenshots[0], cache_flags)
var tex_b := await _fetch_image(screenshots[1], cache_flags)
if tex_a != null and tex_b != null:
return _combine_side_by_side(tex_a, tex_b)
var stitched := _combine_side_by_side(tex_a, tex_b)
# Persist the stitched result to disk.
if cache_flags & Cache.FLAGS.SAVE and stitched != null:
Cache.save_image(BANNER_CACHE_DIR, banner_key, stitched)
return stitched
# Banner combine failed — fall back to single-image path.
logger.warn("Banner screenshot fetch failed: tex_a=%s tex_b=%s" % [str(tex_a != null), str(tex_b != null)])
if tex_a != null:
@ -451,21 +481,41 @@ func _fetch_image(url: String, cache_flags: int) -> Texture2D:
## Downloads a GIF and converts it to a Godot texture. When animated GIFs
## are enabled, extracts all frames and returns an AnimatedTexture. Otherwise
## extracts only the first frame and returns a static ImageTexture.
## Results are cached in _animated_texture_cache keyed by URL.
## Results are cached in _animated_texture_cache keyed by URL. Animated frames
## are also persisted to disk under user://boxart/itch/anim_frames/<hash>/
## so subsequent loads skip ffmpeg extraction.
func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
# Return cached result if available.
if _animated_texture_cache.has(url):
return _animated_texture_cache[url]
var animated: bool = _is_animated_gifs_enabled()
var url_hash := url.sha256_text().left(16)
var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
var cached_frames_dir := globalized_dir + "/" + ANIM_FRAMES_DIR + "/" + url_hash
# Check for disk-cached frames (animated) or PNG (static).
if animated and _has_cached_frames(cached_frames_dir):
logger.info("GIF fetch: loading %d cached frames from disk for %s" % [
_count_cached_frames(cached_frames_dir), url.left(LOG_URL_MAX_LEN)])
var delays := _load_cached_delays(cached_frames_dir)
return _assemble_from_disk_cache(cached_frames_dir, delays, url)
var cached_png := cached_frames_dir + ".png"
if not animated and FileAccess.file_exists(cached_png):
logger.info("GIF fetch: loading cached static frame from %s" % url.left(LOG_URL_MAX_LEN))
var image := Image.load_from_file(cached_png)
if image != null:
var texture := ImageTexture.create_from_image(image)
_animated_texture_cache[url] = texture
return texture
# Need to download and process the GIF.
var ffmpeg_bin := await _ensure_ffmpeg()
if ffmpeg_bin.is_empty():
return null
var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
var url_hash := url.sha256_text().left(16)
var gif_path := globalized_dir + "/_tmp_%s.gif" % url_hash
var frames_dir := globalized_dir + "/_tmp_%s_frames" % url_hash
# Download the GIF bytes via HTTPClient.
var body := await _download_gif_bytes(url)
@ -483,14 +533,14 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
var result: Texture2D
if animated:
result = await _build_animated_gif_texture(ffmpeg_bin, gif_path, frames_dir, body, url)
result = await _build_animated_gif_texture(ffmpeg_bin, gif_path, cached_frames_dir, body, url)
else:
result = await _build_static_gif_texture(ffmpeg_bin, gif_path, globalized_dir + "/_tmp_%s.png" % url_hash, url)
result = await _build_static_gif_texture(ffmpeg_bin, gif_path, cached_png, url)
# Clean up.
DirAccess.remove_absolute(gif_path)
# Cache the result.
# Cache the result in memory.
if result != null:
_animated_texture_cache[url] = result
@ -502,6 +552,82 @@ func _download_gif_bytes(url: String) -> PackedByteArray:
return await _download_url_bytes(url)
## Returns true if the given directory contains cached frame PNGs.
func _has_cached_frames(frames_dir: String) -> bool:
var dir := DirAccess.open(frames_dir)
if dir == null:
return false
dir.list_dir_begin()
var fname := dir.get_next()
var has_frames := false
while fname != "":
if fname.begins_with("frame_") and fname.ends_with(".png"):
has_frames = true
break
fname = dir.get_next()
dir.list_dir_end()
return has_frames
## Counts the number of cached frame PNGs in a directory.
func _count_cached_frames(frames_dir: String) -> int:
var dir := DirAccess.open(frames_dir)
if dir == null:
return 0
dir.list_dir_begin()
var count := 0
var fname := dir.get_next()
while fname != "":
if fname.begins_with("frame_") and fname.ends_with(".png"):
count += 1
fname = dir.get_next()
dir.list_dir_end()
return count
## Loads cached frame delays from a JSON file in the frames directory.
func _load_cached_delays(frames_dir: String) -> Array:
var delays_path := frames_dir + "/delays.json"
var file := FileAccess.open(delays_path, FileAccess.READ)
if file == null:
return []
var json := JSON.new()
var err := json.parse(file.get_as_text())
file.close()
if err != OK:
return []
if json.data is Array:
return json.data
return []
## Saves frame delays to a JSON file in the frames directory.
func _save_cached_delays(frames_dir: String, delays: Array) -> void:
var delays_path := frames_dir + "/delays.json"
var file := FileAccess.open(delays_path, FileAccess.WRITE)
if file == null:
return
file.store_string(JSON.stringify(delays))
file.close()
## Builds an AnimatedTexture from disk-cached frame PNGs.
func _assemble_from_disk_cache(frames_dir: String, delays: Array, url: String) -> AnimatedTexture:
var dir := DirAccess.open(frames_dir)
if dir == null:
return null
dir.list_dir_begin()
var frame_files: PackedStringArray = []
var fname := dir.get_next()
while fname != "":
if fname.begins_with("frame_") and fname.ends_with(".png"):
frame_files.append(fname)
fname = dir.get_next()
dir.list_dir_end()
frame_files.sort()
return _assemble_animated_texture(frame_files, frames_dir, delays, url)
## Extracts the first frame of a GIF using ffmpeg. Returns a static ImageTexture.
func _build_static_gif_texture(ffmpeg_bin: String, gif_path: String, png_path: String, url: String) -> Texture2D:
var ffmpeg_args: PackedStringArray = [
@ -517,7 +643,6 @@ func _build_static_gif_texture(ffmpeg_bin: String, gif_path: String, png_path: S
return null
var image := Image.load_from_file(png_path)
DirAccess.remove_absolute(png_path)
if image == null:
logger.warn("GIF fetch: failed to load extracted PNG")
@ -565,14 +690,20 @@ func _extract_gif_frames(ffmpeg_bin: String, gif_path: String, frames_dir: Strin
## Loads extracted frame images and assembles them into an AnimatedTexture.
## When frames_dir is a persistent cache dir, frames are kept on disk.
func _assemble_animated_texture(frame_files: PackedStringArray, frames_dir: String, delays: Array, url: String) -> AnimatedTexture:
var anim_tex := AnimatedTexture.new()
anim_tex.frames_count = frame_files.size()
anim_tex.pause = true # Start paused — focus handler unpauses.
var is_persistent := frames_dir.find("/" + ANIM_FRAMES_DIR + "/") != -1
var did_persist := false
for i in frame_files.size():
var frame_path := frames_dir + "/" + frame_files[i]
var image := Image.load_from_file(frame_path)
DirAccess.remove_absolute(frame_path)
if not is_persistent:
DirAccess.remove_absolute(frame_path)
if image == null:
logger.warn("GIF fetch: failed to load frame %d" % i)
continue
@ -580,8 +711,17 @@ func _assemble_animated_texture(frame_files: PackedStringArray, frames_dir: Stri
var delay_ms: int = delays[i] if i < delays.size() else GIF_DEFAULT_FRAME_DELAY_MS
anim_tex.set_frame_delay(i, delay_ms / 1000.0) # AnimatedTexture uses seconds.
DirAccess.remove_absolute(frames_dir)
logger.info("GIF fetch: built AnimatedTexture with %d frames from %s" % [frame_files.size(), url.left(LOG_URL_MAX_LEN)])
if not is_persistent:
DirAccess.remove_absolute(frames_dir)
else:
# Persist delays to disk for future reloads.
if not delays.is_empty():
_save_cached_delays(frames_dir, delays)
did_persist = true
logger.info("GIF fetch: built AnimatedTexture with %d frames from %s%s" % [
frame_files.size(), url.left(LOG_URL_MAX_LEN),
" (cached to disk)" if did_persist else ""])
return anim_tex

View file

@ -1,7 +1,7 @@
{
"plugin.id": "itch-artprovider",
"plugin.name": "itch.io Art Provider",
"plugin.version": "0.1.14",
"plugin.version": "0.1.18",
"plugin.min-api-version": "1.1.0",
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin",
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin",