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:
parent
49fdbe8f0f
commit
e703ef5d55
2 changed files with 155 additions and 15 deletions
|
|
@ -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,13 +690,19 @@ 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)
|
||||
if not is_persistent:
|
||||
DirAccess.remove_absolute(frame_path)
|
||||
if image == null:
|
||||
logger.warn("GIF fetch: failed to load frame %d" % i)
|
||||
|
|
@ -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.
|
||||
|
||||
if not is_persistent:
|
||||
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)])
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue