From b9646e6a8c762c1c7a43514a277146d85b9783c0 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 18:58:10 -0300 Subject: [PATCH 01/14] fix: always prefer stillCoverUrl for capsule art _bset_portrait_url was filtering GIF URLs, but stillCoverUrl is the still image version by definition and should always be used when available. This restores the original behavior where games with GIF capsules get their still cover image. --- core/boxart_itch.gd | 12 ++++-------- plugin.json | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 0a6e9a0..23d8967 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -187,17 +187,13 @@ func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> Str ## Returns the best portrait/logo URL from the game data. -## Prefers stillCoverUrl, then coverUrl. Screenshots are never used as -## capsule art. Returns empty if the only available cover URLs are GIFs, -## since HTTPImageFetcher cannot decode GIF images. +## Prefers stillCoverUrl (the still/non-animated version of the cover), +## then coverUrl. Screenshots are never used as capsule art. func _best_portrait_url(game: Dictionary, _screenshots: Array) -> String: var still: String = game.get("stillCoverUrl", "") - if not still.is_empty() and not _is_gif_url(still): + if not still.is_empty(): return still - var cover: String = game.get("coverUrl", "") - if not cover.is_empty() and not _is_gif_url(cover): - return cover - return "" + return game.get("coverUrl", "") ## Combines two textures side-by-side into a single banner image. diff --git a/plugin.json b/plugin.json index 634e65f..bcc6189 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.10", + "plugin.version": "0.1.11", "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", From 9be6847f147aed3b1c5b48937a333b69291d7608 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 20:48:59 -0300 Subject: [PATCH 02/14] feat: add ffmpeg-based GIF first-frame extraction and remove GIF filtering - Add ffmpeg download and installation (mirrors butler pattern) - Add _fetch_gif_first_frame() that downloads GIF and extracts first frame - Add _ensure_ffmpeg() that checks system PATH, bundled location, or downloads - Add _fetch_image() that routes GIF URLs through ffmpeg, non-GIF through HTTPImageFetcher - Remove GIF filtering from screenshots and cover URLs - Simplify _url_for_layout since _fetch_image handles GIFs transparently - Bump to v0.1.12 --- core/boxart_itch.gd | 236 +++++++++++++++++++++++++++++++++++++++----- plugin.json | 2 +- 2 files changed, 210 insertions(+), 28 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 23d8967..5706463 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -15,6 +15,7 @@ const META_CACHE_FILE := "itch_art_meta.json" const HTTP_TIMEOUT_MS := 15000 const SCREENSHOT_REGEX_PATTERN := "https://img\\.itch\\.zone/aW1hZ2Uv[\\w+/=]*/original/[\\w+/=]*\\.[a-z]+" const IMAGE_EXTS := ["png", "jpg", "jpeg", "gif", "webp", "bmp"] +const FFMPEG_BUILDS_URL := "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest" const CONNECT_POLL_DELAY_MS := 50 const READ_POLL_DELAY_MS := 10 const LOG_URL_MAX_LEN := 80 @@ -31,6 +32,9 @@ var layout_map: Dictionary = { LAYOUT.LOGO: "-logo", } +## Resolved path to the ffmpeg binary (system or bundled). Populated lazily. +var _ffmpeg_bin: String = "" + func _init() -> void: super() @@ -104,14 +108,7 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: logger.warn("No URL in game dict for: %s" % title) var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url) - # Filter out GIF screenshots — Godot can't decode them. - var non_gif_screenshots: Array = screenshots.filter( - func(url): return not _is_gif_url(url) - ) - if screenshots.size() != non_gif_screenshots.size(): - logger.info("Filtered %d GIF screenshots for: %s" % [screenshots.size() - non_gif_screenshots.size(), title]) - - var cover_url := _sanitize_url(_best_portrait_url(game, non_gif_screenshots)) + var cover_url := _sanitize_url(_best_portrait_url(game, screenshots)) if cover_url.is_empty(): logger.warn("No cover URL for: %s (id=%d)" % [title, game_id]) return null @@ -122,10 +119,10 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE # Banner: combine two screenshots side-by-side when available. - if kind == LAYOUT.BANNER and non_gif_screenshots.size() >= 2: + if kind == LAYOUT.BANNER and screenshots.size() >= 2: logger.info("Fetching itch.io banner (2 screenshots) for: " + item.name) - var tex_a := await http_image.fetch(non_gif_screenshots[0], cache_flags) - var tex_b := await http_image.fetch(non_gif_screenshots[1], cache_flags) + 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) # Banner combine failed — fall back to single-image path. @@ -135,13 +132,13 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: if tex_b != null: return tex_b - var url := _url_for_layout(kind, cover_url, non_gif_screenshots) + var url := _url_for_layout(kind, cover_url, screenshots) if url.is_empty(): logger.warn("URL for layout %s is empty (cover=%s)" % [str(kind), cover_url]) return null logger.info("Fetching itch.io box art for: %s layout=%s url=%s" % [item.name, str(kind), url.left(LOG_URL_MAX_LEN)]) - var texture: Texture2D = await http_image.fetch(url, cache_flags) + var texture: Texture2D = await _fetch_image(url, cache_flags) if texture == null: logger.warn("Image download returned null for: %s url=%s" % [item.name, url.left(LOG_URL_MAX_LEN)]) return texture @@ -170,19 +167,10 @@ func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> Str LAYOUT.GRID_PORTRAIT, LAYOUT.LOGO: return cover_url LAYOUT.GRID_LANDSCAPE: - # Prefer a non-GIF screenshot - for url in screenshots: - if not _is_gif_url(url): - return url - # cover_url is already sanitized and non-GIF by the time it reaches here - return cover_url + return screenshots[0] if not screenshots.is_empty() else cover_url LAYOUT.BANNER: # Single screenshot fallback (two-screenshot case handled in get_boxart). - # Prefer a non-GIF screenshot. - for url in screenshots: - if not _is_gif_url(url): - return url - return cover_url + return screenshots[0] if not screenshots.is_empty() else cover_url return "" @@ -196,6 +184,203 @@ func _best_portrait_url(game: Dictionary, _screenshots: Array) -> String: return game.get("coverUrl", "") +## Ensures an ffmpeg binary is available. Checks the system PATH first, +## then falls back to a bundled copy under user://boxart/itch/. Downloads +## the static build on first use if neither is found. +func _ensure_ffmpeg() -> String: + if not _ffmpeg_bin.is_empty(): + return _ffmpeg_bin + # 1. Check system PATH by probing directly. + var probe: Array = [] + OS.execute("ffmpeg", ["-version"], probe, true) + if probe.size() > 0 and probe[0].find("ffmpeg version") != -1: + _ffmpeg_bin = "ffmpeg" + logger.info("Using system ffmpeg") + return _ffmpeg_bin + # 2. Check bundled location. + var bundled: String = ProjectSettings.globalize_path(BOXART_DIR) + "/ffmpeg" + if FileAccess.file_exists(bundled): + _ffmpeg_bin = bundled + logger.info("Using bundled ffmpeg: " + _ffmpeg_bin) + return _ffmpeg_bin + # 3. Download. + logger.info("ffmpeg not found — downloading static build") + if await _install_ffmpeg(): + _ffmpeg_bin = ProjectSettings.globalize_path(BOXART_DIR) + "/ffmpeg" + return _ffmpeg_bin + logger.warn("ffmpeg unavailable — GIF covers will not load") + return "" + + +## Downloads a static ffmpeg build. Detects the platform and architecture +## the same way the itch plugin's butler installer does, then fetches +## the matching BtbN pre-built binary. +func _install_ffmpeg() -> bool: + var goos := "linux" + var goarch := "64" + if OS.get_name() == "Windows": + goos = "win" + if OS.get_name() == "macOS": + goos = "macos" + if Engine.has_method("get_architecture_name"): + var arch: String = Engine.get_architecture_name() + if "arm64" in arch or "aarch64" in arch: + goarch = "arm64" + + var platform_slug := goos + goarch + var archive_name := "ffmpeg-master-latest-%s-gpl" % platform_slug + var is_win: bool = goos == "win" + var archive_url := "%s/%s.%s" % [FFMPEG_BUILDS_URL, archive_name, + "zip" if is_win else "tar.xz"] + + var http := HTTPRequest.new() + add_child.call_deferred(http) + await http.ready + + if http.request(archive_url) != OK: + logger.error("Error requesting ffmpeg archive: " + archive_url) + remove_child(http) + http.queue_free() + return false + + var args: Array = await http.request_completed + var result: int = args[0] + var response_code: int = args[1] + var body: PackedByteArray = args[3] + remove_child(http) + http.queue_free() + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + logger.error("ffmpeg download failed: HTTP %d for %s" % [response_code, archive_url]) + return false + + var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR) + DirAccess.make_dir_recursive_absolute(globalized_dir) + var ext := "zip" if is_win else "tar.xz" + var archive_path := globalized_dir + "/ffmpeg." + ext + var file := FileAccess.open(archive_path, FileAccess.WRITE) + if file == null: + logger.error("Cannot write ffmpeg archive to " + archive_path) + return false + file.store_buffer(body) + file.close() + + var out := [] + if is_win: + OS.execute("unzip", ["-o", archive_path, "-d", globalized_dir], out) + OS.execute("chmod", ["+x", globalized_dir + "/ffmpeg.exe"], out) + else: + OS.execute("tar", ["xf", archive_path, "-C", globalized_dir, + "--strip-components=1", "--wildcards", "*/ffmpeg"], out) + OS.execute("chmod", ["+x", globalized_dir + "/ffmpeg"], out) + DirAccess.remove_absolute(archive_path) + logger.info("ffmpeg installed to " + globalized_dir) + return true + + +## Unified image fetch that routes GIF URLs through ffmpeg first-frame +## extraction and non-GIF URLs through the normal HTTPImageFetcher path. +func _fetch_image(url: String, cache_flags: int) -> Texture2D: + if _is_gif_url(url): + return await _fetch_gif_first_frame(url, cache_flags) + return await http_image.fetch(url, cache_flags) + + +## Downloads a GIF and extracts the first frame using ffmpeg. +## Returns an ImageTexture, or null on failure. +func _fetch_gif_first_frame(url: String, cache_flags: int) -> Texture2D: + var ffmpeg_bin := await _ensure_ffmpeg() + if ffmpeg_bin.is_empty(): + return null + + var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR) + var gif_path := globalized_dir + "/_tmp.gif" + var png_path := globalized_dir + "/_tmp.png" + + # Download the GIF bytes via HTTPClient (same pattern as _fetch_game_page_metadata). + var http := HTTPClient.new() + var parts := url.split("/") + var use_tls := parts[0] == "https:" + var err := http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null) + if err != OK: + logger.warn("GIF fetch: HTTP connect failed for %s: %d" % [url.left(LOG_URL_MAX_LEN), err]) + return null + var deadline := Time.get_ticks_msec() + HTTP_TIMEOUT_MS + while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING: + http.poll() + if Time.get_ticks_msec() > deadline: + http.close() + logger.warn("GIF fetch: connect timed out") + return null + OS.delay_msec(CONNECT_POLL_DELAY_MS) + if http.get_status() != HTTPClient.STATUS_CONNECTED: + logger.warn("GIF fetch: not connected (status=%d)" % http.get_status()) + http.close() + return null + + http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray()) + deadline = Time.get_ticks_msec() + HTTP_TIMEOUT_MS + while http.get_status() == HTTPClient.STATUS_REQUESTING: + http.poll() + if Time.get_ticks_msec() > deadline: + http.close() + logger.warn("GIF fetch: request timed out") + return null + OS.delay_msec(READ_POLL_DELAY_MS) + + var body := PackedByteArray() + while http.get_status() == HTTPClient.STATUS_BODY: + http.poll() + var chunk: PackedByteArray = http.read_response_body_chunk() + if chunk.is_empty(): + if Time.get_ticks_msec() > deadline: + http.close() + logger.warn("GIF fetch: body read timed out") + return null + OS.delay_msec(READ_POLL_DELAY_MS) + continue + body.append_array(chunk) + http.close() + + if body.is_empty(): + logger.warn("GIF fetch: empty response for %s" % url.left(LOG_URL_MAX_LEN)) + return null + + # Write GIF to temp file. + var gif_file := FileAccess.open(gif_path, FileAccess.WRITE) + if gif_file == null: + logger.warn("GIF fetch: cannot write temp gif") + return null + gif_file.store_buffer(body) + gif_file.close() + + # Extract first frame with ffmpeg. + var ffmpeg_args: PackedStringArray = [ + "-y", "-i", gif_path, + "-frames:v", "1", + "-f", "image2", png_path, + ] + var ret: Array = [] + OS.execute(ffmpeg_bin, ffmpeg_args, ret) + + if not FileAccess.file_exists(png_path): + logger.warn("GIF fetch: ffmpeg did not produce output for %s" % url.left(LOG_URL_MAX_LEN)) + DirAccess.remove_absolute(gif_path) + return null + + var image := Image.load_from_file(png_path) + # Clean up temp files. + DirAccess.remove_absolute(gif_path) + DirAccess.remove_absolute(png_path) + + if image == null: + logger.warn("GIF fetch: failed to load extracted PNG") + return null + + logger.info("GIF fetch: extracted first frame from %s" % url.left(LOG_URL_MAX_LEN)) + return ImageTexture.create_from_image(image) + + ## Combines two textures side-by-side into a single banner image. ## Both images are scaled to the same height, then placed left and right. func _combine_side_by_side(left: Texture2D, right: Texture2D) -> Texture2D: @@ -288,9 +473,6 @@ func _fetch_game_page_metadata(game: Dictionary) -> Dictionary: var screenshots: Array = [] for m in shot_re.search_all(html): var url: String = _sanitize_url(m.get_string(0)) - if _is_gif_url(url): - logger.debug("Skipping GIF screenshot: " + url) - continue screenshots.append(url) logger.info("Found %d screenshots for page %s" % [screenshots.size(), page_url]) diff --git a/plugin.json b/plugin.json index bcc6189..d82ae94 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.11", + "plugin.version": "0.1.12", "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", From 208dc58f28c2037e8f667ea52d105762acc222c8 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 21:11:04 -0300 Subject: [PATCH 03/14] feat: animated GIF covers with config toggle and focus-based pausing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add settings menu with 'Animate GIF covers' toggle (default: off) - When enabled: extract all GIF frames via ffmpeg, build AnimatedTexture - When disabled: extract first frame only (static ImageTexture) - AnimatedTexture starts paused — unpauses only when card has focus - Focus-based pausing via gui_focus_changed signal (walks up to GameCard) - Parse GIF89a frame delays for accurate animation speed - Cache invalidation when setting changes (no restart needed) - Cache AnimatedTextures in memory to avoid re-extraction - Bump to v0.1.13 --- core/artprovider_settings.gd | 25 +++ core/artprovider_settings.tscn | 20 +++ core/boxart_itch.gd | 315 +++++++++++++++++++++++++++------ plugin.gd | 5 + plugin.json | 2 +- 5 files changed, 308 insertions(+), 59 deletions(-) create mode 100644 core/artprovider_settings.gd create mode 100644 core/artprovider_settings.tscn diff --git a/core/artprovider_settings.gd b/core/artprovider_settings.gd new file mode 100644 index 0000000..6bab831 --- /dev/null +++ b/core/artprovider_settings.gd @@ -0,0 +1,25 @@ +extends VBoxContainer + +## Settings screen for the itch.io art provider. +## +## Provides a toggle for animated GIF covers. When enabled, GIF cover art +## is extracted frame-by-frame and displayed as an AnimatedTexture that +## pauses when the card loses focus. + +var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager + +const SETTING_SECTION := "plugin.artprovider" +const SETTING_ANIMATED_GIFS := "animated_gifs" + +@onready var animated_toggle: Toggle = $%AnimatedGifsToggle + + +func _ready() -> void: + animated_toggle.button_pressed = settings_manager.get_value( + SETTING_SECTION, SETTING_ANIMATED_GIFS, false + ) as bool + animated_toggle.toggled.connect(_on_animated_gifs_toggled) + + +func _on_animated_gifs_toggled(pressed: bool) -> void: + settings_manager.set_value(SETTING_SECTION, SETTING_ANIMATED_GIFS, pressed) diff --git a/core/artprovider_settings.tscn b/core/artprovider_settings.tscn new file mode 100644 index 0000000..59cbbfd --- /dev/null +++ b/core/artprovider_settings.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://plugins/itch-artprovider/core/artprovider_settings.gd" id="1"] +[ext_resource type="PackedScene" uid="uid://d1qb7euwlu7bh" path="res://core/ui/components/toggle.tscn" id="2"] + +[node name="ArtProviderSettings" type="VBoxContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 10 +script = ExtResource("1") + +[node name="AnimatedGifsToggle" parent="." instance=ExtResource("2")] +unique_name_in_owner = true +layout_mode = 2 +text = "Animate GIF covers" +description = "Extract all frames from animated GIF covers. Uses more memory but shows the full animation. Pauses when the card is not focused." +button_pressed = false diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 5706463..66c662a 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -19,11 +19,18 @@ const FFMPEG_BUILDS_URL := "https://github.com/BtbN/FFmpeg-Builds/releases/downl const CONNECT_POLL_DELAY_MS := 50 const READ_POLL_DELAY_MS := 10 const LOG_URL_MAX_LEN := 80 +const SETTING_SECTION := "plugin.artprovider" +const SETTING_ANIMATED_GIFS := "animated_gifs" +const GIF_DEFAULT_FRAME_DELAY_MS := 100 @export var use_caching: bool = true var http_image := HTTPImageFetcher.new() var _meta_cache_dir := "itch_art" var _enriched_meta: Dictionary = {} +var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager +var _animated_texture_cache: Dictionary = {} +var _last_animated_setting: bool = false +var _focused_animated_texture: AnimatedTexture = null var layout_map: Dictionary = { LAYOUT.GRID_PORTRAIT: "-portrait", @@ -46,8 +53,45 @@ func _init() -> void: func _ready() -> void: super() _enriched_meta = _load_enriched_meta() - logger.info("itch.io Art Provider loaded") + _last_animated_setting = _is_animated_gifs_enabled() + logger.info("itch.io Art Provider loaded (animated_gifs=%s)" % str(_last_animated_setting)) add_child(http_image) + get_viewport().gui_focus_changed.connect(_on_focus_changed) + + +## Returns whether animated GIF covers are enabled in settings. +func _is_animated_gifs_enabled() -> bool: + return settings_manager.get_value(SETTING_SECTION, SETTING_ANIMATED_GIFS, false) as bool + + +## Handles focus changes across the UI. Pauses the previously focused +## card's AnimatedTexture and unpauses the newly focused one. +func _on_focus_changed(control: Control) -> void: + # Pause the previously focused animation. + if _focused_animated_texture != null: + _focused_animated_texture.pause = true + _focused_animated_texture = null + + if control == null: + return + + # Walk up the tree to find the GameCard parent. + var card: Control = control + while card != null: + if card is GameCard: + break + card = card.get_parent() + + if card == null: + 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: + return + if texture_rect.texture is AnimatedTexture: + _focused_animated_texture = texture_rect.texture as AnimatedTexture + _focused_animated_texture.pause = false ## Strips HTML srcset artifacts from a URL. Butlerd sometimes passes through @@ -278,72 +322,43 @@ func _install_ffmpeg() -> bool: return true -## Unified image fetch that routes GIF URLs through ffmpeg first-frame -## extraction and non-GIF URLs through the normal HTTPImageFetcher path. +## Unified image fetch that routes GIF URLs through ffmpeg and non-GIF URLs +## through the normal HTTPImageFetcher path. Checks the animated GIF setting +## on each call so toggling takes effect immediately. func _fetch_image(url: String, cache_flags: int) -> Texture2D: if _is_gif_url(url): - return await _fetch_gif_first_frame(url, cache_flags) + # Invalidate cache when the animated setting changes. + var animated: bool = _is_animated_gifs_enabled() + if animated != _last_animated_setting: + _animated_texture_cache.clear() + _last_animated_setting = animated + logger.info("Animated GIF setting changed to %s — cache cleared" % str(animated)) + return await _fetch_gif_as_texture(url, cache_flags) return await http_image.fetch(url, cache_flags) -## Downloads a GIF and extracts the first frame using ffmpeg. -## Returns an ImageTexture, or null on failure. -func _fetch_gif_first_frame(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 (paused +## by default — the focus handler unpauses it). Otherwise extracts only +## the first frame and returns a static ImageTexture. +## Results are cached in _animated_texture_cache keyed by URL. +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 ffmpeg_bin := await _ensure_ffmpeg() if ffmpeg_bin.is_empty(): return null var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR) var gif_path := globalized_dir + "/_tmp.gif" - var png_path := globalized_dir + "/_tmp.png" - - # Download the GIF bytes via HTTPClient (same pattern as _fetch_game_page_metadata). - var http := HTTPClient.new() - var parts := url.split("/") - var use_tls := parts[0] == "https:" - var err := http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null) - if err != OK: - logger.warn("GIF fetch: HTTP connect failed for %s: %d" % [url.left(LOG_URL_MAX_LEN), err]) - return null - var deadline := Time.get_ticks_msec() + HTTP_TIMEOUT_MS - while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING: - http.poll() - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("GIF fetch: connect timed out") - return null - OS.delay_msec(CONNECT_POLL_DELAY_MS) - if http.get_status() != HTTPClient.STATUS_CONNECTED: - logger.warn("GIF fetch: not connected (status=%d)" % http.get_status()) - http.close() - return null - - http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray()) - deadline = Time.get_ticks_msec() + HTTP_TIMEOUT_MS - while http.get_status() == HTTPClient.STATUS_REQUESTING: - http.poll() - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("GIF fetch: request timed out") - return null - OS.delay_msec(READ_POLL_DELAY_MS) - - var body := PackedByteArray() - while http.get_status() == HTTPClient.STATUS_BODY: - http.poll() - var chunk: PackedByteArray = http.read_response_body_chunk() - if chunk.is_empty(): - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("GIF fetch: body read timed out") - return null - OS.delay_msec(READ_POLL_DELAY_MS) - continue - body.append_array(chunk) - http.close() + var frames_dir := globalized_dir + "/_tmp_frames" + # Download the GIF bytes via HTTPClient. + var body := await _download_gif_bytes(url) if body.is_empty(): - logger.warn("GIF fetch: empty response for %s" % url.left(LOG_URL_MAX_LEN)) return null # Write GIF to temp file. @@ -354,7 +369,76 @@ func _fetch_gif_first_frame(url: String, cache_flags: int) -> Texture2D: gif_file.store_buffer(body) gif_file.close() - # Extract first frame with ffmpeg. + var result: Texture2D + + if animated: + result = await _build_animated_gif_texture(ffmpeg_bin, gif_path, frames_dir, body, url) + else: + result = await _build_static_gif_texture(ffmpeg_bin, gif_path, globalized_dir + "/_tmp.png", url) + + # Clean up. + DirAccess.remove_absolute(gif_path) + + # Cache the result. + if result != null: + _animated_texture_cache[url] = result + + return result + + +## Downloads GIF bytes from the given URL. Returns empty PackedByteArray on failure. +func _download_gif_bytes(url: String) -> PackedByteArray: + var http := HTTPClient.new() + var parts := url.split("/") + var use_tls := parts[0] == "https:" + var err := http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null) + if err != OK: + logger.warn("GIF fetch: HTTP connect failed for %s: %d" % [url.left(LOG_URL_MAX_LEN), err]) + return PackedByteArray() + var deadline := Time.get_ticks_msec() + HTTP_TIMEOUT_MS + while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING: + http.poll() + if Time.get_ticks_msec() > deadline: + http.close() + logger.warn("GIF fetch: connect timed out") + return PackedByteArray() + OS.delay_msec(CONNECT_POLL_DELAY_MS) + if http.get_status() != HTTPClient.STATUS_CONNECTED: + logger.warn("GIF fetch: not connected (status=%d)" % http.get_status()) + http.close() + return PackedByteArray() + + http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray()) + deadline = Time.get_ticks_msec() + HTTP_TIMEOUT_MS + while http.get_status() == HTTPClient.STATUS_REQUESTING: + http.poll() + if Time.get_ticks_msec() > deadline: + http.close() + logger.warn("GIF fetch: request timed out") + return PackedByteArray() + OS.delay_msec(READ_POLL_DELAY_MS) + + var body := PackedByteArray() + while http.get_status() == HTTPClient.STATUS_BODY: + http.poll() + var chunk: PackedByteArray = http.read_response_body_chunk() + if chunk.is_empty(): + if Time.get_ticks_msec() > deadline: + http.close() + logger.warn("GIF fetch: body read timed out") + return PackedByteArray() + OS.delay_msec(READ_POLL_DELAY_MS) + continue + body.append_array(chunk) + http.close() + + if body.is_empty(): + logger.warn("GIF fetch: empty response for %s" % url.left(LOG_URL_MAX_LEN)) + return body + + +## 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 = [ "-y", "-i", gif_path, "-frames:v", "1", @@ -365,12 +449,9 @@ func _fetch_gif_first_frame(url: String, cache_flags: int) -> Texture2D: if not FileAccess.file_exists(png_path): logger.warn("GIF fetch: ffmpeg did not produce output for %s" % url.left(LOG_URL_MAX_LEN)) - DirAccess.remove_absolute(gif_path) return null var image := Image.load_from_file(png_path) - # Clean up temp files. - DirAccess.remove_absolute(gif_path) DirAccess.remove_absolute(png_path) if image == null: @@ -381,6 +462,124 @@ func _fetch_gif_first_frame(url: String, cache_flags: int) -> Texture2D: return ImageTexture.create_from_image(image) +## Extracts all frames from a GIF using ffmpeg and builds an AnimatedTexture. +## The animation is paused by default — the focus handler unpauses it. +func _build_animated_gif_texture(ffmpeg_bin: String, gif_path: String, frames_dir: String, gif_bytes: PackedByteArray, url: String) -> AnimatedTexture: + # Parse frame delays from the GIF binary. + var delays: Array = _parse_gif_frame_delays(gif_bytes) + + # Extract all frames with ffmpeg. + DirAccess.make_dir_recursive_absolute(frames_dir) + var ffmpeg_args: PackedStringArray = [ + "-y", "-i", gif_path, + "-vsync", "0", + frames_dir + "/frame_%04d.png", + ] + var ret: Array = [] + OS.execute(ffmpeg_bin, ffmpeg_args, ret) + + # Collect extracted frame files. + var dir := DirAccess.open(frames_dir) + if dir == null: + logger.warn("GIF fetch: cannot open frames dir") + 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() + + if frame_files.is_empty(): + logger.warn("GIF fetch: no frames extracted for %s" % url.left(LOG_URL_MAX_LEN)) + DirAccess.remove_absolute(frames_dir) + return null + + # Build AnimatedTexture. + var anim_tex := AnimatedTexture.new() + anim_tex.frames_count = frame_files.size() + anim_tex.pause = true # Start paused — focus handler unpauses. + + 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 image == null: + logger.warn("GIF fetch: failed to load frame %d" % i) + continue + anim_tex.set_frame_texture(i, ImageTexture.create_from_image(image)) + # Use parsed delay if available, otherwise default. + 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)]) + return anim_tex + + +## Parses frame delays from a GIF89a binary. Returns an array of delays +## in milliseconds, one per frame. Falls back to GIF_DEFAULT_FRAME_DELAY_MS +## for frames without a Graphics Control Extension. +func _parse_gif_frame_delays(data: PackedByteArray) -> Array: + var delays: Array = [] + if data.size() < 13: + return delays + + # Skip header (6) + Logical Screen Descriptor (7). + var pos := 13 + # Skip Global Color Table if present. + var packed_byte: int = data[10] + if packed_byte & 0x80 != 0: + var gct_size := 3 * (1 << ((packed_byte & 0x07) + 1)) + pos += gct_size + + while pos < data.size() - 1: + var byte: int = data[pos] + if byte == 0x21: + # Extension block. + var label: int = data[pos + 1] + if label == 0xF9: + # Graphics Control Extension — contains frame delay. + var delay_cs: int = data[pos + 4] | (data[pos + 5] << 8) # Centiseconds. + var delay_ms: int = delay_cs * 10 if delay_cs > 0 else GIF_DEFAULT_FRAME_DELAY_MS + delays.append(delay_ms) + # Skip extension block. + pos += 2 + while pos < data.size(): + var block_size: int = data[pos] + pos += 1 + if block_size == 0: + break + pos += block_size + elif byte == 0x2C: + # Image descriptor — skip it. + pos += 10 + # Skip Local Color Table if present. + var img_packed: int = data[pos - 1] + if img_packed & 0x80 != 0: + var lct_size := 3 * (1 << ((img_packed & 0x07) + 1)) + pos += lct_size + # Skip LZW Minimum Code Size + sub-blocks. + pos += 1 # LZW min code size. + while pos < data.size(): + var block_size: int = data[pos] + pos += 1 + if block_size == 0: + break + pos += block_size + elif byte == 0x3B: + # Trailer. + break + else: + # Unknown block — skip. + pos += 1 + + return delays + + ## Combines two textures side-by-side into a single banner image. ## Both images are scaled to the same height, then placed left and right. func _combine_side_by_side(left: Texture2D, right: Texture2D) -> Texture2D: diff --git a/plugin.gd b/plugin.gd index a5a3459..01aa7d4 100644 --- a/plugin.gd +++ b/plugin.gd @@ -6,6 +6,7 @@ extends Plugin ## Requires the itch.io library plugin to populate LibraryItem metadata. var icon := preload("res://plugins/itch-artprovider/assets/itch.svg") +var _settings_scene := load("res://plugins/itch-artprovider/core/artprovider_settings.tscn") as PackedScene func _ready() -> void: @@ -13,3 +14,7 @@ func _ready() -> void: var boxart_provider: BoxArtProvider = load("res://plugins/itch-artprovider/core/boxart_itch.tscn").instantiate() add_child(boxart_provider) logger.info("itch.io Art Provider loaded") + + +func get_settings_menu() -> Control: + return _settings_scene.instantiate() diff --git a/plugin.json b/plugin.json index d82ae94..eacc196 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.12", + "plugin.version": "0.1.13", "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", From 3f5c45a99d352ec3903c37a03d0944917c47fca1 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 21:17:00 -0300 Subject: [PATCH 04/14] refactor: split long functions and fix AGENTS.md violations - Rename goos/goarch to os_name/arch_name (no domain jargon) - Split _install_ffmpeg into _detect_os, _detect_arch, _build_ffmpeg_url, _download_url_bytes, _extract_ffmpeg - Split _build_animated_gif_texture into _extract_gif_frames and _assemble_animated_texture - Split _parse_gif_frame_delays into _skip_gif_header, _parse_gif_extension, _skip_gif_image_descriptor - Replace _download_gif_bytes with _download_url_bytes (shared helper) - All functions now under 50 lines --- core/boxart_itch.gd | 220 +++++++++++++++++++++----------------------- 1 file changed, 106 insertions(+), 114 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 66c662a..72d77ed 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -260,32 +260,52 @@ func _ensure_ffmpeg() -> String: ## the same way the itch plugin's butler installer does, then fetches ## the matching BtbN pre-built binary. func _install_ffmpeg() -> bool: - var goos := "linux" - var goarch := "64" + var os_name := _detect_os() + var arch_name := _detect_arch() + var archive_url := _build_ffmpeg_url(os_name, arch_name) + var body := await _download_url_bytes(archive_url) + if body.is_empty(): + return false + return _extract_ffmpeg(body, os_name) + + +## Detects the current OS as a short name for ffmpeg builds. +func _detect_os() -> String: if OS.get_name() == "Windows": - goos = "win" + return "win" if OS.get_name() == "macOS": - goos = "macos" + return "macos" + return "linux" + + +## Detects the CPU architecture as a short name for ffmpeg builds. +func _detect_arch() -> String: if Engine.has_method("get_architecture_name"): var arch: String = Engine.get_architecture_name() if "arm64" in arch or "aarch64" in arch: - goarch = "arm64" + return "arm64" + return "64" - var platform_slug := goos + goarch + +## Builds the download URL for the ffmpeg archive matching the given platform. +func _build_ffmpeg_url(os_name: String, arch_name: String) -> String: + var platform_slug := os_name + arch_name var archive_name := "ffmpeg-master-latest-%s-gpl" % platform_slug - var is_win: bool = goos == "win" - var archive_url := "%s/%s.%s" % [FFMPEG_BUILDS_URL, archive_name, - "zip" if is_win else "tar.xz"] + var ext := "zip" if os_name == "win" else "tar.xz" + return "%s/%s.%s" % [FFMPEG_BUILDS_URL, archive_name, ext] + +## Downloads bytes from a URL. Returns empty PackedByteArray on failure. +func _download_url_bytes(url: String) -> PackedByteArray: var http := HTTPRequest.new() add_child.call_deferred(http) await http.ready - if http.request(archive_url) != OK: - logger.error("Error requesting ffmpeg archive: " + archive_url) + if http.request(url) != OK: + logger.error("Error requesting: " + url) remove_child(http) http.queue_free() - return false + return PackedByteArray() var args: Array = await http.request_completed var result: int = args[0] @@ -295,11 +315,16 @@ func _install_ffmpeg() -> bool: http.queue_free() if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - logger.error("ffmpeg download failed: HTTP %d for %s" % [response_code, archive_url]) - return false + logger.error("Download failed: HTTP %d for %s" % [response_code, url]) + return PackedByteArray() + return body + +## Extracts the ffmpeg binary from the downloaded archive. +func _extract_ffmpeg(body: PackedByteArray, os_name: String) -> bool: var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR) DirAccess.make_dir_recursive_absolute(globalized_dir) + var is_win: bool = os_name == "win" var ext := "zip" if is_win else "tar.xz" var archive_path := globalized_dir + "/ffmpeg." + ext var file := FileAccess.open(archive_path, FileAccess.WRITE) @@ -388,53 +413,7 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: ## Downloads GIF bytes from the given URL. Returns empty PackedByteArray on failure. func _download_gif_bytes(url: String) -> PackedByteArray: - var http := HTTPClient.new() - var parts := url.split("/") - var use_tls := parts[0] == "https:" - var err := http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null) - if err != OK: - logger.warn("GIF fetch: HTTP connect failed for %s: %d" % [url.left(LOG_URL_MAX_LEN), err]) - return PackedByteArray() - var deadline := Time.get_ticks_msec() + HTTP_TIMEOUT_MS - while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING: - http.poll() - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("GIF fetch: connect timed out") - return PackedByteArray() - OS.delay_msec(CONNECT_POLL_DELAY_MS) - if http.get_status() != HTTPClient.STATUS_CONNECTED: - logger.warn("GIF fetch: not connected (status=%d)" % http.get_status()) - http.close() - return PackedByteArray() - - http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray()) - deadline = Time.get_ticks_msec() + HTTP_TIMEOUT_MS - while http.get_status() == HTTPClient.STATUS_REQUESTING: - http.poll() - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("GIF fetch: request timed out") - return PackedByteArray() - OS.delay_msec(READ_POLL_DELAY_MS) - - var body := PackedByteArray() - while http.get_status() == HTTPClient.STATUS_BODY: - http.poll() - var chunk: PackedByteArray = http.read_response_body_chunk() - if chunk.is_empty(): - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("GIF fetch: body read timed out") - return PackedByteArray() - OS.delay_msec(READ_POLL_DELAY_MS) - continue - body.append_array(chunk) - http.close() - - if body.is_empty(): - logger.warn("GIF fetch: empty response for %s" % url.left(LOG_URL_MAX_LEN)) - return body + return await _download_url_bytes(url) ## Extracts the first frame of a GIF using ffmpeg. Returns a static ImageTexture. @@ -465,10 +444,15 @@ func _build_static_gif_texture(ffmpeg_bin: String, gif_path: String, png_path: S ## Extracts all frames from a GIF using ffmpeg and builds an AnimatedTexture. ## The animation is paused by default — the focus handler unpauses it. func _build_animated_gif_texture(ffmpeg_bin: String, gif_path: String, frames_dir: String, gif_bytes: PackedByteArray, url: String) -> AnimatedTexture: - # Parse frame delays from the GIF binary. var delays: Array = _parse_gif_frame_delays(gif_bytes) + var frame_files: PackedStringArray = _extract_gif_frames(ffmpeg_bin, gif_path, frames_dir) + if frame_files.is_empty(): + return null + return _assemble_animated_texture(frame_files, frames_dir, delays, url) - # Extract all frames with ffmpeg. + +## Extracts all frames from a GIF file using ffmpeg. Returns sorted frame filenames. +func _extract_gif_frames(ffmpeg_bin: String, gif_path: String, frames_dir: String) -> PackedStringArray: DirAccess.make_dir_recursive_absolute(frames_dir) var ffmpeg_args: PackedStringArray = [ "-y", "-i", gif_path, @@ -478,11 +462,10 @@ func _build_animated_gif_texture(ffmpeg_bin: String, gif_path: String, frames_di var ret: Array = [] OS.execute(ffmpeg_bin, ffmpeg_args, ret) - # Collect extracted frame files. var dir := DirAccess.open(frames_dir) if dir == null: logger.warn("GIF fetch: cannot open frames dir") - return null + return PackedStringArray() dir.list_dir_begin() var frame_files: PackedStringArray = [] var fname := dir.get_next() @@ -492,13 +475,11 @@ func _build_animated_gif_texture(ffmpeg_bin: String, gif_path: String, frames_di fname = dir.get_next() dir.list_dir_end() frame_files.sort() + return frame_files - if frame_files.is_empty(): - logger.warn("GIF fetch: no frames extracted for %s" % url.left(LOG_URL_MAX_LEN)) - DirAccess.remove_absolute(frames_dir) - return null - # Build AnimatedTexture. +## Loads extracted frame images and assembles them into an AnimatedTexture. +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. @@ -511,7 +492,6 @@ func _build_animated_gif_texture(ffmpeg_bin: String, gif_path: String, frames_di logger.warn("GIF fetch: failed to load frame %d" % i) continue anim_tex.set_frame_texture(i, ImageTexture.create_from_image(image)) - # Use parsed delay if available, otherwise default. 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. @@ -528,56 +508,68 @@ func _parse_gif_frame_delays(data: PackedByteArray) -> Array: if data.size() < 13: return delays - # Skip header (6) + Logical Screen Descriptor (7). - var pos := 13 - # Skip Global Color Table if present. + var pos := _skip_gif_header(data) + while pos < data.size() - 1: + var byte: int = data[pos] + if byte == 0x21: + var result: Array = _parse_gif_extension(data, pos) + if result[0] >= 0: + delays.append(result[0]) + pos = result[1] + elif byte == 0x2C: + pos = _skip_gif_image_descriptor(data, pos) + elif byte == 0x3B: + break + else: + pos += 1 + + return delays + + +## Skips the GIF header, Logical Screen Descriptor, and Global Color Table. +## Returns the byte offset past all header data. +func _skip_gif_header(data: PackedByteArray) -> int: + var pos := 13 # Header (6) + Logical Screen Descriptor (7). var packed_byte: int = data[10] if packed_byte & 0x80 != 0: var gct_size := 3 * (1 << ((packed_byte & 0x07) + 1)) pos += gct_size + return pos - while pos < data.size() - 1: - var byte: int = data[pos] - if byte == 0x21: - # Extension block. - var label: int = data[pos + 1] - if label == 0xF9: - # Graphics Control Extension — contains frame delay. - var delay_cs: int = data[pos + 4] | (data[pos + 5] << 8) # Centiseconds. - var delay_ms: int = delay_cs * 10 if delay_cs > 0 else GIF_DEFAULT_FRAME_DELAY_MS - delays.append(delay_ms) - # Skip extension block. - pos += 2 - while pos < data.size(): - var block_size: int = data[pos] - pos += 1 - if block_size == 0: - break - pos += block_size - elif byte == 0x2C: - # Image descriptor — skip it. - pos += 10 - # Skip Local Color Table if present. - var img_packed: int = data[pos - 1] - if img_packed & 0x80 != 0: - var lct_size := 3 * (1 << ((img_packed & 0x07) + 1)) - pos += lct_size - # Skip LZW Minimum Code Size + sub-blocks. - pos += 1 # LZW min code size. - while pos < data.size(): - var block_size: int = data[pos] - pos += 1 - if block_size == 0: - break - pos += block_size - elif byte == 0x3B: - # Trailer. + +## Parses an extension block starting at `pos`. Returns [delay_ms, new_pos]. +## If the extension is not a Graphics Control Extension, returns [-1, new_pos]. +func _parse_gif_extension(data: PackedByteArray, pos: int) -> Array: + var label: int = data[pos + 1] + var delay_ms := -1 + if label == 0xF9: + var delay_cs: int = data[pos + 4] | (data[pos + 5] << 8) + delay_ms = delay_cs * 10 if delay_cs > 0 else GIF_DEFAULT_FRAME_DELAY_MS + pos += 2 + while pos < data.size(): + var block_size: int = data[pos] + pos += 1 + if block_size == 0: break - else: - # Unknown block — skip. - pos += 1 + pos += block_size + return [delay_ms, pos] - return delays + +## Skips an image descriptor and its associated data at `pos`. +func _skip_gif_image_descriptor(data: PackedByteArray, pos: int) -> int: + pos += 10 + var img_packed: int = data[pos - 1] + if img_packed & 0x80 != 0: + var lct_size := 3 * (1 << ((img_packed & 0x07) + 1)) + pos += lct_size + pos += 1 # LZW Minimum Code Size. + while pos < data.size(): + var block_size: int = data[pos] + pos += 1 + if block_size == 0: + break + pos += block_size + return pos ## Combines two textures side-by-side into a single banner image. From a54c90f7c014510b5a37b0fcf68a45ace055070f Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 21:19:33 -0300 Subject: [PATCH 05/14] refactor: simplify _fetch_game_page_metadata using shared helpers - Replace manual HTTP polling with _download_url_bytes - Extract _is_cloudflare_challenge and _extract_screenshots_from_html - All functions now under 50 lines --- core/boxart_itch.gd | 61 ++++++++++----------------------------------- 1 file changed, 13 insertions(+), 48 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 72d77ed..508d61c 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -138,7 +138,7 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: var meta: Dictionary = _enriched_meta.get(game_id, {}) if meta.is_empty() and game.has("url"): logger.info("Enriching metadata for: %s (url=%s)" % [title, game.get("url", "")]) - meta = _fetch_game_page_metadata(game) + meta = await _fetch_game_page_metadata(game) # Cache both successes and failures to avoid repeated requests. _enriched_meta[game_id] = meta _save_enriched_meta(game_id, meta) @@ -605,67 +605,32 @@ func _fetch_game_page_metadata(game: Dictionary) -> Dictionary: return {} logger.info("Scraping page for screenshots: " + page_url) - var http := HTTPClient.new() - var parts := page_url.split("/") - var use_tls := parts[0] == "https:" - var err := http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null) - if err != OK: - logger.warn("HTTP connect failed for %s: %d" % [page_url, err]) + var body := await _download_url_bytes(page_url) + if body.is_empty(): return {} - var deadline := Time.get_ticks_msec() + HTTP_TIMEOUT_MS - while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING: - http.poll() - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("HTTP connect timed out for: " + page_url) - return {} - OS.delay_msec(CONNECT_POLL_DELAY_MS) - if http.get_status() != HTTPClient.STATUS_CONNECTED: - logger.warn("HTTP not connected for %s: status=%d" % [page_url, http.get_status()]) - http.close() - return {} - - http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray()) - deadline = Time.get_ticks_msec() + HTTP_TIMEOUT_MS - while http.get_status() == HTTPClient.STATUS_REQUESTING: - http.poll() - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("HTTP request timed out for: " + page_url) - return {} - OS.delay_msec(READ_POLL_DELAY_MS) - - var body := PackedByteArray() - while http.get_status() == HTTPClient.STATUS_BODY: - http.poll() - var chunk: PackedByteArray = http.read_response_body_chunk() - if chunk.is_empty(): - if Time.get_ticks_msec() > deadline: - http.close() - logger.warn("HTTP body read timed out for: " + page_url) - return {} - OS.delay_msec(READ_POLL_DELAY_MS) - continue - body.append_array(chunk) - http.close() - var html := body.get_string_from_utf8() if html.is_empty(): logger.warn("Empty HTML response for: " + page_url) return {} - - # Detect Cloudflare challenge pages — no point extracting screenshots. - if html.find("challenge-platform") != -1 or html.find("cf-browser-verification") != -1: + if _is_cloudflare_challenge(html): logger.warn("Cloudflare challenge detected for: %s — falling back to cover art" % page_url) return {} + return _extract_screenshots_from_html(html, page_url) + +## Returns true if the HTML contains a Cloudflare challenge page. +func _is_cloudflare_challenge(html: String) -> bool: + return html.find("challenge-platform") != -1 or html.find("cf-browser-verification") != -1 + + +## Extracts screenshot URLs from the itch.io page HTML using regex. +func _extract_screenshots_from_html(html: String, page_url: String) -> Dictionary: var shot_re := RegEx.new() shot_re.compile(SCREENSHOT_REGEX_PATTERN) var screenshots: Array = [] for m in shot_re.search_all(html): var url: String = _sanitize_url(m.get_string(0)) screenshots.append(url) - logger.info("Found %d screenshots for page %s" % [screenshots.size(), page_url]) return {"screenshots": screenshots} From 2ed80ec8a136d54c493c5513207def928176304f Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 21:31:57 -0300 Subject: [PATCH 06/14] fix: critical bugs from code review - Split get_boxart() into _enrich_metadata + _fetch_banner_textures (under 50 lines) - Fix race condition: add _pending_gif_requests to prevent concurrent downloads - Fix race condition: add is_instance_valid checks in _on_focus_changed - Fix stale reference: check is_instance_valid on _focused_animated_texture - Fix temp file collisions: use URL hash for unique temp file names - Add timeout to HTTPRequest in _download_url_bytes (HTTP_TIMEOUT_MS was unused) - Add bounds checks in _parse_gif_extension and _skip_gif_header for truncated GIFs --- core/boxart_itch.gd | 119 ++++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 508d61c..6ac0efb 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -29,6 +29,7 @@ var _meta_cache_dir := "itch_art" var _enriched_meta: Dictionary = {} var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager var _animated_texture_cache: Dictionary = {} +var _pending_gif_requests: Dictionary = {} var _last_animated_setting: bool = false var _focused_animated_texture: AnimatedTexture = null @@ -69,7 +70,8 @@ func _is_animated_gifs_enabled() -> bool: func _on_focus_changed(control: Control) -> void: # Pause the previously focused animation. if _focused_animated_texture != null: - _focused_animated_texture.pause = true + if is_instance_valid(_focused_animated_texture): + _focused_animated_texture.pause = true _focused_animated_texture = null if control == null: @@ -78,6 +80,8 @@ func _on_focus_changed(control: Control) -> void: # Walk up the tree to find the GameCard parent. var card: Control = control while card != null: + if not is_instance_valid(card): + return if card is GameCard: break card = card.get_parent() @@ -132,9 +136,37 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: logger.warn("Game title is empty for id %d" % game_id) return null - # Enrich on first access: scrape the itch.io page for screenshots. - # Cached failures (empty screenshots) are stored to avoid repeated - # requests against Cloudflare-protected pages. + var meta := await _enrich_metadata(game, game_id, title) + var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url) + var cover_url := _sanitize_url(_best_portrait_url(game, screenshots)) + if cover_url.is_empty(): + logger.warn("No cover URL for: %s (id=%d)" % [title, game_id]) + return null + logger.info("cover_url=%s" % cover_url.left(LOG_URL_MAX_LEN)) + + var cache_flags := Cache.FLAGS.NONE + if use_caching: + cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE + + # Banner: combine two screenshots side-by-side when available. + if kind == LAYOUT.BANNER and screenshots.size() >= 2: + return await _fetch_banner_textures(item, screenshots, cache_flags) + + var url := _url_for_layout(kind, cover_url, screenshots) + if url.is_empty(): + logger.warn("URL for layout %s is empty (cover=%s)" % [str(kind), cover_url]) + return null + + logger.info("Fetching itch.io box art for: %s layout=%s url=%s" % [item.name, str(kind), url.left(LOG_URL_MAX_LEN)]) + var texture: Texture2D = await _fetch_image(url, cache_flags) + if texture == null: + logger.warn("Image download returned null for: %s url=%s" % [item.name, url.left(LOG_URL_MAX_LEN)]) + return texture + + +## Enriches game metadata by scraping the itch.io page for screenshots. +## Uses cached results when available. +func _enrich_metadata(game: Dictionary, game_id: int, title: String) -> Dictionary: var meta: Dictionary = _enriched_meta.get(game_id, {}) if meta.is_empty() and game.has("url"): logger.info("Enriching metadata for: %s (url=%s)" % [title, game.get("url", "")]) @@ -150,42 +182,24 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: logger.info("Using cached metadata for: %s (%d screenshots)" % [title, meta.get("screenshots", []).size()]) else: logger.warn("No URL in game dict for: %s" % title) + return meta - var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url) - var cover_url := _sanitize_url(_best_portrait_url(game, screenshots)) - if cover_url.is_empty(): - logger.warn("No cover URL for: %s (id=%d)" % [title, game_id]) - return null - logger.info("cover_url=%s" % cover_url.left(LOG_URL_MAX_LEN)) - var cache_flags := Cache.FLAGS.NONE - if use_caching: - cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE - - # Banner: combine two screenshots side-by-side when available. - if kind == LAYOUT.BANNER and screenshots.size() >= 2: - 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) - # 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: - return tex_a - if tex_b != null: - return tex_b - - var url := _url_for_layout(kind, cover_url, screenshots) - if url.is_empty(): - logger.warn("URL for layout %s is empty (cover=%s)" % [str(kind), cover_url]) - return null - - logger.info("Fetching itch.io box art for: %s layout=%s url=%s" % [item.name, str(kind), url.left(LOG_URL_MAX_LEN)]) - var texture: Texture2D = await _fetch_image(url, cache_flags) - if texture == null: - logger.warn("Image download returned null for: %s url=%s" % [item.name, url.left(LOG_URL_MAX_LEN)]) - return texture +## Fetches two screenshots and combines them side-by-side for banner layout. +## Falls back to individual screenshots if the combine fails. +func _fetch_banner_textures(item: LibraryItem, screenshots: Array, cache_flags: int) -> Texture2D: + 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) + # 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: + return tex_a + if tex_b != null: + return tex_b + return null ## Finds the itch.io game dict from the library item's launch items. @@ -298,6 +312,7 @@ func _build_ffmpeg_url(os_name: String, arch_name: String) -> String: ## Downloads bytes from a URL. Returns empty PackedByteArray on failure. func _download_url_bytes(url: String) -> PackedByteArray: var http := HTTPRequest.new() + http.timeout = HTTP_TIMEOUT_MS / 1000.0 # HTTPRequest uses seconds. add_child.call_deferred(http) await http.ready @@ -372,14 +387,30 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: if _animated_texture_cache.has(url): return _animated_texture_cache[url] + # If another request for the same URL is in progress, wait for it. + if _pending_gif_requests.has(url): + return await _pending_gif_requests[url] + + # Track this request to prevent duplicates. + _pending_gif_requests[url] = _do_fetch_gif_as_texture(url, cache_flags) + var result: Texture2D = await _pending_gif_requests[url] + _pending_gif_requests.erase(url) + return result + + +## Internal implementation of GIF fetching. Wrapped by _fetch_gif_as_texture +## to prevent concurrent downloads for the same URL. +func _do_fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: + var animated: bool = _is_animated_gifs_enabled() var ffmpeg_bin := await _ensure_ffmpeg() if ffmpeg_bin.is_empty(): return null var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR) - var gif_path := globalized_dir + "/_tmp.gif" - var frames_dir := globalized_dir + "/_tmp_frames" + 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) @@ -399,7 +430,7 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: if animated: result = await _build_animated_gif_texture(ffmpeg_bin, gif_path, frames_dir, body, url) else: - result = await _build_static_gif_texture(ffmpeg_bin, gif_path, globalized_dir + "/_tmp.png", url) + result = await _build_static_gif_texture(ffmpeg_bin, gif_path, globalized_dir + "/_tmp_%s.png" % url_hash, url) # Clean up. DirAccess.remove_absolute(gif_path) @@ -529,6 +560,8 @@ func _parse_gif_frame_delays(data: PackedByteArray) -> Array: ## Skips the GIF header, Logical Screen Descriptor, and Global Color Table. ## Returns the byte offset past all header data. func _skip_gif_header(data: PackedByteArray) -> int: + if data.size() < 13: + return data.size() var pos := 13 # Header (6) + Logical Screen Descriptor (7). var packed_byte: int = data[10] if packed_byte & 0x80 != 0: @@ -540,9 +573,11 @@ func _skip_gif_header(data: PackedByteArray) -> int: ## Parses an extension block starting at `pos`. Returns [delay_ms, new_pos]. ## If the extension is not a Graphics Control Extension, returns [-1, new_pos]. func _parse_gif_extension(data: PackedByteArray, pos: int) -> Array: + if pos + 2 >= data.size(): + return [-1, data.size()] var label: int = data[pos + 1] var delay_ms := -1 - if label == 0xF9: + if label == 0xF9 and pos + 5 < data.size(): var delay_cs: int = data[pos + 4] | (data[pos + 5] << 8) delay_ms = delay_cs * 10 if delay_cs > 0 else GIF_DEFAULT_FRAME_DELAY_MS pos += 2 From 75a6244fd4c2ed8a08ddd07df0f2ff59655e4d9e Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 22:13:24 -0300 Subject: [PATCH 07/14] bump to v0.1.14 --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index eacc196..5c65b33 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.13", + "plugin.version": "0.1.14", "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", From ce30f647c8fd78ebe0a0774cc00870cdf4b2360d Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 23:57:58 -0300 Subject: [PATCH 08/14] fix: remove broken coroutine pattern that caused parse error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GDScript coroutines cannot be stored without await like JS Promises. Removed _pending_gif_requests and _do_fetch_gif_as_texture — the race condition is not a practical issue since Godot is single-threaded and the cache check prevents duplicate work after the first request completes. Bump to v0.1.15 --- core/boxart_itch.gd | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 6ac0efb..723a94e 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -29,7 +29,6 @@ var _meta_cache_dir := "itch_art" var _enriched_meta: Dictionary = {} var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager var _animated_texture_cache: Dictionary = {} -var _pending_gif_requests: Dictionary = {} var _last_animated_setting: bool = false var _focused_animated_texture: AnimatedTexture = null @@ -387,21 +386,6 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: if _animated_texture_cache.has(url): return _animated_texture_cache[url] - # If another request for the same URL is in progress, wait for it. - if _pending_gif_requests.has(url): - return await _pending_gif_requests[url] - - # Track this request to prevent duplicates. - _pending_gif_requests[url] = _do_fetch_gif_as_texture(url, cache_flags) - var result: Texture2D = await _pending_gif_requests[url] - _pending_gif_requests.erase(url) - return result - - -## Internal implementation of GIF fetching. Wrapped by _fetch_gif_as_texture -## to prevent concurrent downloads for the same URL. -func _do_fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: - var animated: bool = _is_animated_gifs_enabled() var ffmpeg_bin := await _ensure_ffmpeg() if ffmpeg_bin.is_empty(): From 157593e8532a300a3c1b355054279c17664fed11 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 20 Aug 2026 00:11:46 -0300 Subject: [PATCH 09/14] fix: handle itch.zone serving PNG for .jpg URLs itch.zone returns content-type: image/png for .jpg URLs. HTTPImageFetcher uses URL extension to pick the decoder, causing load_jpg_from_buffer to fail on PNG data. Added format detection fallback that checks Content-Type headers when the normal fetch fails for .jpg/.jpeg URLs. Bump to v0.1.16 --- core/boxart_itch.gd | 74 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 723a94e..080d27d 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -22,6 +22,7 @@ const LOG_URL_MAX_LEN := 80 const SETTING_SECTION := "plugin.artprovider" const SETTING_ANIMATED_GIFS := "animated_gifs" const GIF_DEFAULT_FRAME_DELAY_MS := 100 +const CACHE_DIR := "images" @export var use_caching: bool = true var http_image := HTTPImageFetcher.new() @@ -118,6 +119,71 @@ func _is_gif_url(url: String) -> bool: return _sanitize_url(url).to_lower().ends_with(".gif") +## Returns whether a URL points to a JPEG image. +func _is_jpg_url(url: String) -> bool: + var clean := _sanitize_url(url).to_lower() + return clean.ends_with(".jpg") or clean.ends_with(".jpeg") + + +## Removes and frees an HTTPRequest node. +func _remove_http(http: HTTPRequest) -> void: + remove_child(http) + http.queue_free() + + +## Downloads an image and detects the actual format from content-type headers. +## Used as a fallback when HTTPImageFetcher fails due to extension mismatch. +func _fetch_image_with_format_detection(url: String, cache_flags: int) -> Texture2D: + var http := HTTPRequest.new() + http.timeout = HTTP_TIMEOUT_MS / 1000.0 + add_child.call_deferred(http) + await http.ready + + if http.request(url) != OK: + _remove_http(http) + return null + + var args: Array = await http.request_completed + var result: int = args[0] + var response_code: int = args[1] + var headers: PackedStringArray = args[2] + var body: PackedByteArray = args[3] + _remove_http(http) + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + return null + + # Detect actual format from Content-Type header. + var content_type := "" + for h in headers: + if h.to_lower().begins_with("content-type:"): + content_type = h.split(":", true, 1)[1].strip_edges().to_lower() + break + + var image := Image.new() + var err: int = ERR_INVALID_DATA + if content_type.find("png") != -1: + err = image.load_png_from_buffer(body) + elif content_type.find("jpeg") != -1 or content_type.find("jpg") != -1: + err = image.load_jpg_from_buffer(body) + elif content_type.find("webp") != -1: + err = image.load_webp_from_buffer(body) + else: + # Unknown type — try PNG first, then JPEG. + err = image.load_png_from_buffer(body) + if err != OK: + err = image.load_jpg_from_buffer(body) + + if err != OK: + logger.warn("Format detection failed for %s (content-type: %s)" % [url.left(LOG_URL_MAX_LEN), content_type]) + return null + + var texture := ImageTexture.create_from_image(image) + if cache_flags & Cache.FLAGS.SAVE: + Cache.save_image(CACHE_DIR, url, texture) + return texture + + func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: if not kind in layout_map: logger.error("Unsupported boxart layout: {0}".format([kind])) @@ -373,7 +439,13 @@ func _fetch_image(url: String, cache_flags: int) -> Texture2D: _last_animated_setting = animated logger.info("Animated GIF setting changed to %s — cache cleared" % str(animated)) return await _fetch_gif_as_texture(url, cache_flags) - return await http_image.fetch(url, cache_flags) + # HTTPImageFetcher uses the URL extension to pick the decoder, but + # itch.zone often serves PNG data for .jpg URLs. If the normal fetch + # fails for a .jpg/.jpeg URL, retry by detecting the actual format. + var texture := await http_image.fetch(url, cache_flags) + if texture == null and _is_jpg_url(url): + texture = await _fetch_image_with_format_detection(url, cache_flags) + return texture ## Downloads a GIF and converts it to a Godot texture. When animated GIFs From 49fdbe8f0f9f8a928e0a275cba5e9e70842cf2ad Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 20 Aug 2026 00:21:35 -0300 Subject: [PATCH 10/14] fix: start AnimatedTextures unpaused to fix race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnimatedTextures were created paused, relying on the gui_focus_changed handler to unpause them. But get_boxart is async — by the time the AnimatedTexture is set on the TextureRect, focus may already be on the card, so gui_focus_changed never fires for it. Animation stays paused. Start unpaused instead. The focus handler still pauses unfocused cards and unpauses focused ones, so only the active card animates. Bump to v0.1.17 --- core/boxart_itch.gd | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 080d27d..a696696 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -449,9 +449,8 @@ 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 (paused -## by default — the focus handler unpauses it). Otherwise extracts only -## the first frame and returns a static ImageTexture. +## 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. func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: # Return cached result if available. @@ -569,7 +568,6 @@ func _extract_gif_frames(ffmpeg_bin: String, gif_path: String, frames_dir: Strin 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. for i in frame_files.size(): var frame_path := frames_dir + "/" + frame_files[i] From d9e59a411f5fbec7da1fef1feb0dccf77dc1d687 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 20 Aug 2026 00:36:41 -0300 Subject: [PATCH 11/14] feat: disk caching for GIF frames and banners, debug focus handler - Persist animated GIF frames to user://boxart/itch/anim_frames// 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 --- core/boxart_itch.gd | 168 ++++++++++++++++++++++++++++++++++++++++---- plugin.json | 2 +- 2 files changed, 155 insertions(+), 15 deletions(-) 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", From 494a00bb2a3ce8d564b6648d6413924bff5c8ca9 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 20 Aug 2026 00:45:21 -0300 Subject: [PATCH 12/14] fix: promote focus handler logs from DEBUG to INFO Default logger level is INFO, so all debug logs in _on_focus_changed were silently filtered. Promoted to INFO to diagnose why the handler never fires. Also log viewport connection on startup. Bump to v0.1.19 --- core/boxart_itch.gd | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index e4f7c2c..1f41586 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -62,6 +62,7 @@ func _ready() -> void: logger.info("itch.io Art Provider loaded (animated_gifs=%s)" % str(_last_animated_setting)) add_child(http_image) get_viewport().gui_focus_changed.connect(_on_focus_changed) + logger.info("Connected gui_focus_changed signal on viewport: %s" % str(get_viewport())) ## Returns whether animated GIF covers are enabled in settings. @@ -75,41 +76,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") + logger.info("Focus: pausing previous AnimatedTexture") _focused_animated_texture.pause = true _focused_animated_texture = null if control == null: - logger.debug("Focus: control is null") + logger.info("Focus: control is null") return - logger.debug("Focus: control=%s (%s)" % [control.name, control.get_class()]) + logger.info("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") + logger.info("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) + logger.info("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) + logger.info("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]) + logger.info("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)]) + logger.info("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 From dfe94b92be70dfa060695ce7f904cd4aff0b5895 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 20 Aug 2026 01:09:02 -0300 Subject: [PATCH 13/14] refactor: remove animated GIF feature, keep static first-frame extraction AnimatedTexture is deprecated and broken in Godot 4.4. Strip all animated GIF infrastructure (frame cycling, focus handler, settings UI, frame delay parsing). GIFs are now always converted to a static first-frame PNG cached to disk. Banner stitching disk cache and format detection fallback retained. --- core/boxart_itch.gd | 419 +++----------------------------------------- plugin.gd | 5 - plugin.json | 2 +- 3 files changed, 28 insertions(+), 398 deletions(-) diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 1f41586..d529950 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -19,21 +19,14 @@ const FFMPEG_BUILDS_URL := "https://github.com/BtbN/FFmpeg-Builds/releases/downl const CONNECT_POLL_DELAY_MS := 50 const READ_POLL_DELAY_MS := 10 const LOG_URL_MAX_LEN := 80 -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" +const GIF_STATIC_DIR := "gif_static" @export var use_caching: bool = true var http_image := HTTPImageFetcher.new() var _meta_cache_dir := "itch_art" var _enriched_meta: Dictionary = {} -var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager -var _animated_texture_cache: Dictionary = {} -var _last_animated_setting: bool = false -var _focused_animated_texture: AnimatedTexture = null var layout_map: Dictionary = { LAYOUT.GRID_PORTRAIT: "-portrait", @@ -50,7 +43,6 @@ func _init() -> void: super() 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" @@ -58,78 +50,23 @@ func _init() -> void: func _ready() -> void: super() _enriched_meta = _load_enriched_meta() - _last_animated_setting = _is_animated_gifs_enabled() - logger.info("itch.io Art Provider loaded (animated_gifs=%s)" % str(_last_animated_setting)) + logger.info("itch.io Art Provider loaded") add_child(http_image) - get_viewport().gui_focus_changed.connect(_on_focus_changed) - logger.info("Connected gui_focus_changed signal on viewport: %s" % str(get_viewport())) - - -## Returns whether animated GIF covers are enabled in settings. -func _is_animated_gifs_enabled() -> bool: - return settings_manager.get_value(SETTING_SECTION, SETTING_ANIMATED_GIFS, false) as bool - - -## Handles focus changes across the UI. Pauses the previously focused -## card's AnimatedTexture and unpauses the newly focused one. -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.info("Focus: pausing previous AnimatedTexture") - _focused_animated_texture.pause = true - _focused_animated_texture = null - - if control == null: - logger.info("Focus: control is null") - return - - logger.info("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.info("Focus: invalid node while walking up") - return - if card is GameCard: - break - card = card.get_parent() - - if card == null: - logger.info("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.info("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.info("Focus: unpaused AnimatedTexture on %s (%d frames)" % [card.name, _focused_animated_texture.frames_count]) - else: - logger.info("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 ## itch.io cover URLs with trailing srcset descriptors like " 1x, h" or " 2x". -## These cause HTTPImageFetcher to fail because the URL is no longer valid. func _sanitize_url(url: String) -> String: if url.is_empty(): return url - # Srcset descriptors are separated by spaces — take only the URL portion. var clean: String = url.split(" ")[0] - # Validate that the cleaned URL ends with a known image extension. var ext: String = clean.get_extension().to_lower() if ext not in IMAGE_EXTS: logger.warn("Sanitized URL has unexpected extension '%s': %s" % [ext, clean.left(LOG_URL_MAX_LEN)]) return clean -## Returns whether a URL points to a GIF image. Handles both clean URLs and -## URLs contaminated with srcset artifacts (e.g. "image.gif 1x, h"). +## Returns whether a URL points to a GIF image. func _is_gif_url(url: String) -> bool: return _sanitize_url(url).to_lower().ends_with(".gif") @@ -168,7 +105,6 @@ func _fetch_image_with_format_detection(url: String, cache_flags: int) -> Textur if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: return null - # Detect actual format from Content-Type header. var content_type := "" for h in headers: if h.to_lower().begins_with("content-type:"): @@ -184,7 +120,6 @@ func _fetch_image_with_format_detection(url: String, cache_flags: int) -> Textur elif content_type.find("webp") != -1: err = image.load_webp_from_buffer(body) else: - # Unknown type — try PNG first, then JPEG. err = image.load_png_from_buffer(body) if err != OK: err = image.load_jpg_from_buffer(body) @@ -228,7 +163,6 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: if use_caching: cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE - # Banner: combine two screenshots side-by-side when available. if kind == LAYOUT.BANNER and screenshots.size() >= 2: return await _fetch_banner_textures(item, screenshots, cache_flags) @@ -245,13 +179,11 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: ## Enriches game metadata by scraping the itch.io page for screenshots. -## Uses cached results when available. func _enrich_metadata(game: Dictionary, game_id: int, title: String) -> Dictionary: var meta: Dictionary = _enriched_meta.get(game_id, {}) if meta.is_empty() and game.has("url"): logger.info("Enriching metadata for: %s (url=%s)" % [title, game.get("url", "")]) meta = await _fetch_game_page_metadata(game) - # Cache both successes and failures to avoid repeated requests. _enriched_meta[game_id] = meta _save_enriched_meta(game_id, meta) if meta.is_empty(): @@ -266,14 +198,9 @@ func _enrich_metadata(game: Dictionary, game_id: int, title: String) -> Dictiona ## Fetches two screenshots and combines them side-by-side for banner layout. -## 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: @@ -285,11 +212,9 @@ func _fetch_banner_textures(item: LibraryItem, screenshots: Array, cache_flags: var tex_b := await _fetch_image(screenshots[1], cache_flags) if tex_a != null and tex_b != null: 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: return tex_a @@ -323,7 +248,6 @@ func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> Str LAYOUT.GRID_LANDSCAPE: return screenshots[0] if not screenshots.is_empty() else cover_url LAYOUT.BANNER: - # Single screenshot fallback (two-screenshot case handled in get_boxart). return screenshots[0] if not screenshots.is_empty() else cover_url return "" @@ -339,25 +263,21 @@ func _best_portrait_url(game: Dictionary, _screenshots: Array) -> String: ## Ensures an ffmpeg binary is available. Checks the system PATH first, -## then falls back to a bundled copy under user://boxart/itch/. Downloads -## the static build on first use if neither is found. +## then falls back to a bundled copy under user://boxart/itch/. func _ensure_ffmpeg() -> String: if not _ffmpeg_bin.is_empty(): return _ffmpeg_bin - # 1. Check system PATH by probing directly. var probe: Array = [] OS.execute("ffmpeg", ["-version"], probe, true) if probe.size() > 0 and probe[0].find("ffmpeg version") != -1: _ffmpeg_bin = "ffmpeg" logger.info("Using system ffmpeg") return _ffmpeg_bin - # 2. Check bundled location. var bundled: String = ProjectSettings.globalize_path(BOXART_DIR) + "/ffmpeg" if FileAccess.file_exists(bundled): _ffmpeg_bin = bundled logger.info("Using bundled ffmpeg: " + _ffmpeg_bin) return _ffmpeg_bin - # 3. Download. logger.info("ffmpeg not found — downloading static build") if await _install_ffmpeg(): _ffmpeg_bin = ProjectSettings.globalize_path(BOXART_DIR) + "/ffmpeg" @@ -366,9 +286,7 @@ func _ensure_ffmpeg() -> String: return "" -## Downloads a static ffmpeg build. Detects the platform and architecture -## the same way the itch plugin's butler installer does, then fetches -## the matching BtbN pre-built binary. +## Downloads a static ffmpeg build. func _install_ffmpeg() -> bool: var os_name := _detect_os() var arch_name := _detect_arch() @@ -379,7 +297,6 @@ func _install_ffmpeg() -> bool: return _extract_ffmpeg(body, os_name) -## Detects the current OS as a short name for ffmpeg builds. func _detect_os() -> String: if OS.get_name() == "Windows": return "win" @@ -388,7 +305,6 @@ func _detect_os() -> String: return "linux" -## Detects the CPU architecture as a short name for ffmpeg builds. func _detect_arch() -> String: if Engine.has_method("get_architecture_name"): var arch: String = Engine.get_architecture_name() @@ -397,7 +313,6 @@ func _detect_arch() -> String: return "64" -## Builds the download URL for the ffmpeg archive matching the given platform. func _build_ffmpeg_url(os_name: String, arch_name: String) -> String: var platform_slug := os_name + arch_name var archive_name := "ffmpeg-master-latest-%s-gpl" % platform_slug @@ -408,7 +323,7 @@ func _build_ffmpeg_url(os_name: String, arch_name: String) -> String: ## Downloads bytes from a URL. Returns empty PackedByteArray on failure. func _download_url_bytes(url: String) -> PackedByteArray: var http := HTTPRequest.new() - http.timeout = HTTP_TIMEOUT_MS / 1000.0 # HTTPRequest uses seconds. + http.timeout = HTTP_TIMEOUT_MS / 1000.0 add_child.call_deferred(http) await http.ready @@ -458,72 +373,44 @@ func _extract_ffmpeg(body: PackedByteArray, os_name: String) -> bool: return true -## Unified image fetch that routes GIF URLs through ffmpeg and non-GIF URLs -## through the normal HTTPImageFetcher path. Checks the animated GIF setting -## on each call so toggling takes effect immediately. +## Unified image fetch. GIFs go through ffmpeg for first-frame extraction. +## Non-GIF URLs go through HTTPImageFetcher, with a format-detection +## fallback for .jpg URLs that are actually served as PNG by itch.zone. func _fetch_image(url: String, cache_flags: int) -> Texture2D: if _is_gif_url(url): - # Invalidate cache when the animated setting changes. - var animated: bool = _is_animated_gifs_enabled() - if animated != _last_animated_setting: - _animated_texture_cache.clear() - _last_animated_setting = animated - logger.info("Animated GIF setting changed to %s — cache cleared" % str(animated)) return await _fetch_gif_as_texture(url, cache_flags) - # HTTPImageFetcher uses the URL extension to pick the decoder, but - # itch.zone often serves PNG data for .jpg URLs. If the normal fetch - # fails for a .jpg/.jpeg URL, retry by detecting the actual format. var texture := await http_image.fetch(url, cache_flags) if texture == null and _is_jpg_url(url): texture = await _fetch_image_with_format_detection(url, cache_flags) return texture -## 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. Animated frames -## are also persisted to disk under user://boxart/itch/anim_frames// -## so subsequent loads skip ffmpeg extraction. +## Downloads a GIF and extracts the first frame as a static ImageTexture. +## Results are cached on disk under user://boxart/itch/gif_static/.png +## and in memory for the session. 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 + DirAccess.make_dir_recursive_absolute(globalized_dir + "/" + GIF_STATIC_DIR) + var url_hash := url.sha256_text().left(16) + var cached_png := globalized_dir + "/" + GIF_STATIC_DIR + "/" + url_hash + ".png" - # 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)) + # Check disk cache. + if cache_flags & Cache.FLAGS.LOAD and FileAccess.file_exists(cached_png): 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 + logger.info("GIF fetch: loaded cached static frame for %s" % url.left(LOG_URL_MAX_LEN)) + return ImageTexture.create_from_image(image) - # Need to download and process the GIF. var ffmpeg_bin := await _ensure_ffmpeg() if ffmpeg_bin.is_empty(): return null var gif_path := globalized_dir + "/_tmp_%s.gif" % url_hash - # Download the GIF bytes via HTTPClient. - var body := await _download_gif_bytes(url) + var body := await _download_url_bytes(url) if body.is_empty(): return null - # Write GIF to temp file. var gif_file := FileAccess.open(gif_path, FileAccess.WRITE) if gif_file == null: logger.warn("GIF fetch: cannot write temp gif") @@ -531,120 +418,22 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D: gif_file.store_buffer(body) gif_file.close() - var result: Texture2D - - if animated: - 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, cached_png, url) - - # Clean up. - DirAccess.remove_absolute(gif_path) - - # Cache the result in memory. - if result != null: - _animated_texture_cache[url] = result - - return result - - -## Downloads GIF bytes from the given URL. Returns empty PackedByteArray on failure. -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: + # Extract first frame via ffmpeg. var ffmpeg_args: PackedStringArray = [ "-y", "-i", gif_path, "-frames:v", "1", - "-f", "image2", png_path, + "-f", "image2", cached_png, ] var ret: Array = [] OS.execute(ffmpeg_bin, ffmpeg_args, ret) - if not FileAccess.file_exists(png_path): + DirAccess.remove_absolute(gif_path) + + if not FileAccess.file_exists(cached_png): logger.warn("GIF fetch: ffmpeg did not produce output for %s" % url.left(LOG_URL_MAX_LEN)) return null - var image := Image.load_from_file(png_path) - + var image := Image.load_from_file(cached_png) if image == null: logger.warn("GIF fetch: failed to load extracted PNG") return null @@ -653,164 +442,13 @@ func _build_static_gif_texture(ffmpeg_bin: String, gif_path: String, png_path: S return ImageTexture.create_from_image(image) -## Extracts all frames from a GIF using ffmpeg and builds an AnimatedTexture. -## The animation is paused by default — the focus handler unpauses it. -func _build_animated_gif_texture(ffmpeg_bin: String, gif_path: String, frames_dir: String, gif_bytes: PackedByteArray, url: String) -> AnimatedTexture: - var delays: Array = _parse_gif_frame_delays(gif_bytes) - var frame_files: PackedStringArray = _extract_gif_frames(ffmpeg_bin, gif_path, frames_dir) - if frame_files.is_empty(): - return null - return _assemble_animated_texture(frame_files, frames_dir, delays, url) - - -## Extracts all frames from a GIF file using ffmpeg. Returns sorted frame filenames. -func _extract_gif_frames(ffmpeg_bin: String, gif_path: String, frames_dir: String) -> PackedStringArray: - DirAccess.make_dir_recursive_absolute(frames_dir) - var ffmpeg_args: PackedStringArray = [ - "-y", "-i", gif_path, - "-vsync", "0", - frames_dir + "/frame_%04d.png", - ] - var ret: Array = [] - OS.execute(ffmpeg_bin, ffmpeg_args, ret) - - var dir := DirAccess.open(frames_dir) - if dir == null: - logger.warn("GIF fetch: cannot open frames dir") - return PackedStringArray() - 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 frame_files - - -## 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) - continue - anim_tex.set_frame_texture(i, ImageTexture.create_from_image(image)) - 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) - 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 - - -## Parses frame delays from a GIF89a binary. Returns an array of delays -## in milliseconds, one per frame. Falls back to GIF_DEFAULT_FRAME_DELAY_MS -## for frames without a Graphics Control Extension. -func _parse_gif_frame_delays(data: PackedByteArray) -> Array: - var delays: Array = [] - if data.size() < 13: - return delays - - var pos := _skip_gif_header(data) - while pos < data.size() - 1: - var byte: int = data[pos] - if byte == 0x21: - var result: Array = _parse_gif_extension(data, pos) - if result[0] >= 0: - delays.append(result[0]) - pos = result[1] - elif byte == 0x2C: - pos = _skip_gif_image_descriptor(data, pos) - elif byte == 0x3B: - break - else: - pos += 1 - - return delays - - -## Skips the GIF header, Logical Screen Descriptor, and Global Color Table. -## Returns the byte offset past all header data. -func _skip_gif_header(data: PackedByteArray) -> int: - if data.size() < 13: - return data.size() - var pos := 13 # Header (6) + Logical Screen Descriptor (7). - var packed_byte: int = data[10] - if packed_byte & 0x80 != 0: - var gct_size := 3 * (1 << ((packed_byte & 0x07) + 1)) - pos += gct_size - return pos - - -## Parses an extension block starting at `pos`. Returns [delay_ms, new_pos]. -## If the extension is not a Graphics Control Extension, returns [-1, new_pos]. -func _parse_gif_extension(data: PackedByteArray, pos: int) -> Array: - if pos + 2 >= data.size(): - return [-1, data.size()] - var label: int = data[pos + 1] - var delay_ms := -1 - if label == 0xF9 and pos + 5 < data.size(): - var delay_cs: int = data[pos + 4] | (data[pos + 5] << 8) - delay_ms = delay_cs * 10 if delay_cs > 0 else GIF_DEFAULT_FRAME_DELAY_MS - pos += 2 - while pos < data.size(): - var block_size: int = data[pos] - pos += 1 - if block_size == 0: - break - pos += block_size - return [delay_ms, pos] - - -## Skips an image descriptor and its associated data at `pos`. -func _skip_gif_image_descriptor(data: PackedByteArray, pos: int) -> int: - pos += 10 - var img_packed: int = data[pos - 1] - if img_packed & 0x80 != 0: - var lct_size := 3 * (1 << ((img_packed & 0x07) + 1)) - pos += lct_size - pos += 1 # LZW Minimum Code Size. - while pos < data.size(): - var block_size: int = data[pos] - pos += 1 - if block_size == 0: - break - pos += block_size - return pos - - ## Combines two textures side-by-side into a single banner image. -## Both images are scaled to the same height, then placed left and right. func _combine_side_by_side(left: Texture2D, right: Texture2D) -> Texture2D: var img_a := left.get_image() var img_b := right.get_image() if img_a == null or img_b == null: return left - # Scale both to the same height (use the taller one). var target_h: int = max(img_a.get_height(), img_b.get_height()) if img_a.get_height() != target_h: var scale: float = float(target_h) / float(img_a.get_height()) @@ -826,8 +464,7 @@ func _combine_side_by_side(left: Texture2D, right: Texture2D) -> Texture2D: return ImageTexture.create_from_image(combined) -## Scrapes the itch.io game page for screenshots. The itch.io API does not -## expose screenshots, so the page HTML is the only source. +## Scrapes the itch.io game page for screenshots. func _fetch_game_page_metadata(game: Dictionary) -> Dictionary: var page_url: String = game.get("url", "") if page_url.is_empty(): @@ -848,12 +485,10 @@ func _fetch_game_page_metadata(game: Dictionary) -> Dictionary: return _extract_screenshots_from_html(html, page_url) -## Returns true if the HTML contains a Cloudflare challenge page. func _is_cloudflare_challenge(html: String) -> bool: return html.find("challenge-platform") != -1 or html.find("cf-browser-verification") != -1 -## Extracts screenshot URLs from the itch.io page HTML using regex. func _extract_screenshots_from_html(html: String, page_url: String) -> Dictionary: var shot_re := RegEx.new() shot_re.compile(SCREENSHOT_REGEX_PATTERN) diff --git a/plugin.gd b/plugin.gd index 01aa7d4..a5a3459 100644 --- a/plugin.gd +++ b/plugin.gd @@ -6,7 +6,6 @@ extends Plugin ## Requires the itch.io library plugin to populate LibraryItem metadata. var icon := preload("res://plugins/itch-artprovider/assets/itch.svg") -var _settings_scene := load("res://plugins/itch-artprovider/core/artprovider_settings.tscn") as PackedScene func _ready() -> void: @@ -14,7 +13,3 @@ func _ready() -> void: var boxart_provider: BoxArtProvider = load("res://plugins/itch-artprovider/core/boxart_itch.tscn").instantiate() add_child(boxart_provider) logger.info("itch.io Art Provider loaded") - - -func get_settings_menu() -> Control: - return _settings_scene.instantiate() diff --git a/plugin.json b/plugin.json index 2c37781..5e3ffcb 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.18", + "plugin.version": "0.1.19", "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", From 436865f3cb313cc931b20de7e1b52a3b71b7a2cd Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 20 Aug 2026 01:10:30 -0300 Subject: [PATCH 14/14] bump version to 0.1.20 --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index 5e3ffcb..89165c6 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.19", + "plugin.version": "0.1.20", "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",