itchio-opengamepadui-artpro.../core/boxart_itch.gd
Jose Falanga 49fdbe8f0f
All checks were successful
Build plugin / build (push) Successful in 1m10s
fix: start AnimatedTextures unpaused to fix race condition
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

742 lines
26 KiB
GDScript

extends BoxArtProvider
## itch.io box art provider for OpenGamepadUI
##
## Fetches cover art and screenshots from itch.io game pages. Works alongside
## the itch.io library plugin to provide per-layout artwork: the cover for
## portrait/logo, full-res screenshots for landscape/banner.
##
## Enriches game metadata by scraping the itch.io page HTML for screenshots
## (the itch.io API does not expose them), caches the result in
## itch_art_meta.json, and uses HTTPImageFetcher for async image downloads.
const BOXART_DIR := "user://boxart/itch"
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
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()
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",
LAYOUT.GRID_LANDSCAPE: "-landscape",
LAYOUT.BANNER: "-banner",
LAYOUT.LOGO: "-logo",
}
## Resolved path to the ffmpeg binary (system or bundled). Populated lazily.
var _ffmpeg_bin: String = ""
func _init() -> void:
super()
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BOXART_DIR))
provider_id = "itch"
logger_name = "BoxArtItch"
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))
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:
if is_instance_valid(_focused_animated_texture):
_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 not is_instance_valid(card):
return
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").
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]))
return null
var game := _find_game_dict(item)
if game.is_empty():
logger.warn("No itch.io game metadata found for: " + item.name)
return null
var game_id := int(game.get("id", 0))
var title: String = game.get("title", "")
logger.info("get_boxart: title=%s id=%d layout=%s" % [title, game_id, str(kind)])
if title.is_empty():
logger.warn("Game title is empty for id %d" % game_id)
return null
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", "")])
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():
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.
## 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.
func _find_game_dict(item: LibraryItem) -> Dictionary:
if item.launch_items.is_empty():
logger.warn("Item has no launch items: " + item.name)
return {}
for launch_item in item.launch_items:
if launch_item._provider_id != "itch":
continue
var game: Dictionary = launch_item.metadata.get("game", {})
if not game.is_empty():
return game
logger.warn("No itch.io launch item found for: %s (providers: %s)" % [
item.name,
PackedStringArray(item.launch_items.map(func(l): return l._provider_id))
])
return {}
func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> String:
match kind:
LAYOUT.GRID_PORTRAIT, LAYOUT.LOGO:
return cover_url
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 ""
## Returns the best portrait/logo URL from the game data.
## 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():
return still
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 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":
return "win"
if OS.get_name() == "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:
return "arm64"
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
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 # HTTPRequest uses seconds.
add_child.call_deferred(http)
await http.ready
if http.request(url) != OK:
logger.error("Error requesting: " + url)
remove_child(http)
http.queue_free()
return PackedByteArray()
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("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)
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 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):
# 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.
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 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)
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")
return null
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_%s.png" % url_hash, 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:
return await _download_url_bytes(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 = [
"-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))
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")
return null
logger.info("GIF fetch: extracted first frame from %s" % url.left(LOG_URL_MAX_LEN))
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.
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()
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))
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
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())
img_a.resize(int(img_a.get_width() * scale), target_h, Image.INTERPOLATE_BILINEAR)
if img_b.get_height() != target_h:
var scale: float = float(target_h) / float(img_b.get_height())
img_b.resize(int(img_b.get_width() * scale), target_h, Image.INTERPOLATE_BILINEAR)
var combined := Image.create(img_a.get_width() + img_b.get_width(), target_h, false, img_a.get_format())
combined.blit_rect(img_a, Rect2i(0, 0, img_a.get_width(), target_h), Vector2i.ZERO)
combined.blit_rect(img_b, Rect2i(0, 0, img_b.get_width(), target_h), Vector2i(img_a.get_width(), 0))
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.
func _fetch_game_page_metadata(game: Dictionary) -> Dictionary:
var page_url: String = game.get("url", "")
if page_url.is_empty():
logger.warn("No page URL in game dict")
return {}
logger.info("Scraping page for screenshots: " + page_url)
var body := await _download_url_bytes(page_url)
if body.is_empty():
return {}
var html := body.get_string_from_utf8()
if html.is_empty():
logger.warn("Empty HTML response for: " + page_url)
return {}
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}
func _load_enriched_meta() -> Dictionary:
var raw: Variant = Cache.get_json(_meta_cache_dir, META_CACHE_FILE)
if typeof(raw) != TYPE_DICTIONARY:
return {}
var out := {}
for key in raw:
out[int(key)] = raw[key]
return out
func _save_enriched_meta(game_id: int, meta: Dictionary) -> void:
var raw: Variant = Cache.get_json(_meta_cache_dir, META_CACHE_FILE)
if typeof(raw) != TYPE_DICTIONARY:
raw = {}
raw[str(game_id)] = meta
Cache.save_json(_meta_cache_dir, META_CACHE_FILE, raw)