Compare commits

..

11 commits

Author SHA1 Message Date
436865f3cb bump version to 0.1.20
All checks were successful
Build plugin / build (push) Successful in 1m10s
2026-08-20 01:10:30 -03:00
dfe94b92be refactor: remove animated GIF feature, keep static first-frame extraction
Some checks failed
Build plugin / build (push) Has been cancelled
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.
2026-08-20 01:09:02 -03:00
494a00bb2a fix: promote focus handler logs from DEBUG to INFO
All checks were successful
Build plugin / build (push) Successful in 1m13s
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
2026-08-20 00:45:21 -03:00
d9e59a411f feat: disk caching for GIF frames and banners, debug focus handler
All checks were successful
Build plugin / build (push) Successful in 1m10s
- Persist animated GIF frames to user://boxart/itch/anim_frames/<hash>/
  so subsequent loads skip ffmpeg extraction entirely.
- Persist static GIF first-frame PNGs alongside for the same reason.
- Cache stitched banner images via OGPU Cache with a synthetic key
  from both screenshot URLs, avoiding re-stitch on every load.
- Add comprehensive debug logging to _on_focus_changed to diagnose
  why AnimatedTextures aren't playing (logs control name, type,
  GameCard ancestor, texture type).
- Fix plugin.json version to match git tag (was stuck at 0.1.14).

Bump to v0.1.18
2026-08-20 00:36:41 -03:00
49fdbe8f0f fix: start AnimatedTextures unpaused to fix race condition
All checks were successful
Build plugin / build (push) Successful in 1m10s
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
2026-08-20 00:21:35 -03:00
157593e853 fix: handle itch.zone serving PNG for .jpg URLs
All checks were successful
Build plugin / build (push) Successful in 1m10s
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
2026-08-20 00:11:46 -03:00
ce30f647c8 fix: remove broken coroutine pattern that caused parse error
All checks were successful
Build plugin / build (push) Successful in 1m9s
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
2026-08-19 23:57:58 -03:00
75a6244fd4 bump to v0.1.14
All checks were successful
Build plugin / build (push) Successful in 1m8s
2026-08-19 22:13:24 -03:00
2ed80ec8a1 fix: critical bugs from code review
All checks were successful
Build plugin / build (push) Successful in 1m6s
- 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
2026-08-19 21:31:57 -03:00
a54c90f7c0 refactor: simplify _fetch_game_page_metadata using shared helpers
All checks were successful
Build plugin / build (push) Successful in 1m5s
- Replace manual HTTP polling with _download_url_bytes
- Extract _is_cloudflare_challenge and _extract_screenshots_from_html
- All functions now under 50 lines
2026-08-19 21:19:33 -03:00
3f5c45a99d refactor: split long functions and fix AGENTS.md violations
All checks were successful
Build plugin / build (push) Successful in 1m8s
- 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
2026-08-19 21:17:00 -03:00
3 changed files with 197 additions and 380 deletions

View file

@ -19,18 +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 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",
@ -45,7 +41,8 @@ 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)
provider_id = "itch"
logger_name = "BoxArtItch"
@ -53,68 +50,90 @@ 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)
## 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
## 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")
## 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
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:
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]))
@ -132,25 +151,7 @@ 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: 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)
# Cache both successes and failures to avoid repeated requests.
_enriched_meta[game_id] = meta
_save_enriched_meta(game_id, meta)
if meta.is_empty():
logger.info("No screenshots available for: %s — will use cover art" % title)
else:
logger.info("Scraped %d screenshots for: %s" % [meta.get("screenshots", []).size(), title])
elif not meta.is_empty():
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)
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():
@ -162,19 +163,8 @@ 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:
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 await _fetch_banner_textures(item, screenshots, cache_flags)
var url := _url_for_layout(kind, cover_url, screenshots)
if url.is_empty():
@ -188,6 +178,51 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
return texture
## Enriches game metadata by scraping the itch.io page for screenshots.
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)
_enriched_meta[game_id] = meta
_save_enriched_meta(game_id, meta)
if meta.is_empty():
logger.info("No screenshots available for: %s — will use cover art" % title)
else:
logger.info("Scraped %d screenshots for: %s" % [meta.get("screenshots", []).size(), title])
elif not meta.is_empty():
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
## Fetches two screenshots and combines them side-by-side for banner layout.
func _fetch_banner_textures(item: LibraryItem, screenshots: Array, cache_flags: int) -> Texture2D:
var banner_key: String = screenshots[0] + "||" + screenshots[1]
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:
var stitched := _combine_side_by_side(tex_a, tex_b)
if cache_flags & Cache.FLAGS.SAVE and stitched != null:
Cache.save_image(BANNER_CACHE_DIR, banner_key, stitched)
return stitched
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.
func _find_game_dict(item: LibraryItem) -> Dictionary:
if item.launch_items.is_empty():
@ -213,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 ""
@ -229,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"
@ -256,36 +286,52 @@ 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 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)
func _detect_os() -> String:
if OS.get_name() == "Windows":
goos = "win"
return "win"
if OS.get_name() == "macOS":
goos = "macos"
return "macos"
return "linux"
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
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()
http.timeout = HTTP_TIMEOUT_MS / 1000.0
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 +341,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)
@ -322,46 +373,44 @@ func _install_ffmpeg() -> 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)
return await http_image.fetch(url, cache_flags)
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 (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.
## Downloads a GIF and extracts the first frame as a static ImageTexture.
## Results are cached on disk under user://boxart/itch/gif_static/<hash>.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 globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
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 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:
logger.info("GIF fetch: loaded cached static frame for %s" % url.left(LOG_URL_MAX_LEN))
return ImageTexture.create_from_image(image)
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 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")
@ -369,91 +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, 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:
# 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)
DirAccess.remove_absolute(png_path)
var image := Image.load_from_file(cached_png)
if image == null:
logger.warn("GIF fetch: failed to load extracted PNG")
return null
@ -462,133 +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:
# 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:
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())
@ -604,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():
@ -613,67 +472,30 @@ 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)
func _is_cloudflare_challenge(html: String) -> bool:
return html.find("challenge-platform") != -1 or html.find("cf-browser-verification") != -1
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}

View file

@ -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()

View file

@ -1,7 +1,7 @@
{
"plugin.id": "itch-artprovider",
"plugin.name": "itch.io Art Provider",
"plugin.version": "0.1.13",
"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",