diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index a696696..e4f7c2c 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -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// +## 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 diff --git a/plugin.json b/plugin.json index 5c65b33..2c37781 100644 --- a/plugin.json +++ b/plugin.json @@ -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",