From 9be6847f147aed3b1c5b48937a333b69291d7608 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Wed, 19 Aug 2026 20:48:59 -0300 Subject: [PATCH] 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",