fix: critical bugs from code review
All checks were successful
Build plugin / build (push) Successful in 1m6s
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
This commit is contained in:
parent
a54c90f7c0
commit
2ed80ec8a1
1 changed files with 77 additions and 42 deletions
|
|
@ -29,6 +29,7 @@ var _meta_cache_dir := "itch_art"
|
||||||
var _enriched_meta: Dictionary = {}
|
var _enriched_meta: Dictionary = {}
|
||||||
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
||||||
var _animated_texture_cache: Dictionary = {}
|
var _animated_texture_cache: Dictionary = {}
|
||||||
|
var _pending_gif_requests: Dictionary = {}
|
||||||
var _last_animated_setting: bool = false
|
var _last_animated_setting: bool = false
|
||||||
var _focused_animated_texture: AnimatedTexture = null
|
var _focused_animated_texture: AnimatedTexture = null
|
||||||
|
|
||||||
|
|
@ -69,6 +70,7 @@ func _is_animated_gifs_enabled() -> bool:
|
||||||
func _on_focus_changed(control: Control) -> void:
|
func _on_focus_changed(control: Control) -> void:
|
||||||
# Pause the previously focused animation.
|
# Pause the previously focused animation.
|
||||||
if _focused_animated_texture != null:
|
if _focused_animated_texture != null:
|
||||||
|
if is_instance_valid(_focused_animated_texture):
|
||||||
_focused_animated_texture.pause = true
|
_focused_animated_texture.pause = true
|
||||||
_focused_animated_texture = null
|
_focused_animated_texture = null
|
||||||
|
|
||||||
|
|
@ -78,6 +80,8 @@ func _on_focus_changed(control: Control) -> void:
|
||||||
# Walk up the tree to find the GameCard parent.
|
# Walk up the tree to find the GameCard parent.
|
||||||
var card: Control = control
|
var card: Control = control
|
||||||
while card != null:
|
while card != null:
|
||||||
|
if not is_instance_valid(card):
|
||||||
|
return
|
||||||
if card is GameCard:
|
if card is GameCard:
|
||||||
break
|
break
|
||||||
card = card.get_parent()
|
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)
|
logger.warn("Game title is empty for id %d" % game_id)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
# Enrich on first access: scrape the itch.io page for screenshots.
|
var meta := await _enrich_metadata(game, game_id, title)
|
||||||
# Cached failures (empty screenshots) are stored to avoid repeated
|
var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url)
|
||||||
# requests against Cloudflare-protected pages.
|
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, {})
|
var meta: Dictionary = _enriched_meta.get(game_id, {})
|
||||||
if meta.is_empty() and game.has("url"):
|
if meta.is_empty() and game.has("url"):
|
||||||
logger.info("Enriching metadata for: %s (url=%s)" % [title, game.get("url", "")])
|
logger.info("Enriching metadata for: %s (url=%s)" % [title, game.get("url", "")])
|
||||||
|
|
@ -150,20 +182,12 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
|
||||||
logger.info("Using cached metadata for: %s (%d screenshots)" % [title, meta.get("screenshots", []).size()])
|
logger.info("Using cached metadata for: %s (%d screenshots)" % [title, meta.get("screenshots", []).size()])
|
||||||
else:
|
else:
|
||||||
logger.warn("No URL in game dict for: %s" % title)
|
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
|
## Fetches two screenshots and combines them side-by-side for banner layout.
|
||||||
if use_caching:
|
## Falls back to individual screenshots if the combine fails.
|
||||||
cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE
|
func _fetch_banner_textures(item: LibraryItem, screenshots: Array, cache_flags: int) -> Texture2D:
|
||||||
|
|
||||||
# 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)
|
logger.info("Fetching itch.io banner (2 screenshots) for: " + item.name)
|
||||||
var tex_a := await _fetch_image(screenshots[0], cache_flags)
|
var tex_a := await _fetch_image(screenshots[0], cache_flags)
|
||||||
var tex_b := await _fetch_image(screenshots[1], cache_flags)
|
var tex_b := await _fetch_image(screenshots[1], cache_flags)
|
||||||
|
|
@ -175,18 +199,8 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
|
||||||
return tex_a
|
return tex_a
|
||||||
if tex_b != null:
|
if tex_b != null:
|
||||||
return tex_b
|
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
|
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
|
|
||||||
|
|
||||||
|
|
||||||
## Finds the itch.io game dict from the library item's launch items.
|
## Finds the itch.io game dict from the library item's launch items.
|
||||||
func _find_game_dict(item: LibraryItem) -> Dictionary:
|
func _find_game_dict(item: LibraryItem) -> Dictionary:
|
||||||
|
|
@ -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.
|
## Downloads bytes from a URL. Returns empty PackedByteArray on failure.
|
||||||
func _download_url_bytes(url: String) -> PackedByteArray:
|
func _download_url_bytes(url: String) -> PackedByteArray:
|
||||||
var http := HTTPRequest.new()
|
var http := HTTPRequest.new()
|
||||||
|
http.timeout = HTTP_TIMEOUT_MS / 1000.0 # HTTPRequest uses seconds.
|
||||||
add_child.call_deferred(http)
|
add_child.call_deferred(http)
|
||||||
await http.ready
|
await http.ready
|
||||||
|
|
||||||
|
|
@ -372,14 +387,30 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
|
||||||
if _animated_texture_cache.has(url):
|
if _animated_texture_cache.has(url):
|
||||||
return _animated_texture_cache[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 animated: bool = _is_animated_gifs_enabled()
|
||||||
var ffmpeg_bin := await _ensure_ffmpeg()
|
var ffmpeg_bin := await _ensure_ffmpeg()
|
||||||
if ffmpeg_bin.is_empty():
|
if ffmpeg_bin.is_empty():
|
||||||
return null
|
return null
|
||||||
|
|
||||||
var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
|
var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
|
||||||
var gif_path := globalized_dir + "/_tmp.gif"
|
var url_hash := url.sha256_text().left(16)
|
||||||
var frames_dir := globalized_dir + "/_tmp_frames"
|
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.
|
# Download the GIF bytes via HTTPClient.
|
||||||
var body := await _download_gif_bytes(url)
|
var body := await _download_gif_bytes(url)
|
||||||
|
|
@ -399,7 +430,7 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
|
||||||
if animated:
|
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, frames_dir, body, url)
|
||||||
else:
|
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.
|
# Clean up.
|
||||||
DirAccess.remove_absolute(gif_path)
|
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.
|
## Skips the GIF header, Logical Screen Descriptor, and Global Color Table.
|
||||||
## Returns the byte offset past all header data.
|
## Returns the byte offset past all header data.
|
||||||
func _skip_gif_header(data: PackedByteArray) -> int:
|
func _skip_gif_header(data: PackedByteArray) -> int:
|
||||||
|
if data.size() < 13:
|
||||||
|
return data.size()
|
||||||
var pos := 13 # Header (6) + Logical Screen Descriptor (7).
|
var pos := 13 # Header (6) + Logical Screen Descriptor (7).
|
||||||
var packed_byte: int = data[10]
|
var packed_byte: int = data[10]
|
||||||
if packed_byte & 0x80 != 0:
|
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].
|
## 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].
|
## If the extension is not a Graphics Control Extension, returns [-1, new_pos].
|
||||||
func _parse_gif_extension(data: PackedByteArray, pos: int) -> Array:
|
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 label: int = data[pos + 1]
|
||||||
var delay_ms := -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)
|
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
|
delay_ms = delay_cs * 10 if delay_cs > 0 else GIF_DEFAULT_FRAME_DELAY_MS
|
||||||
pos += 2
|
pos += 2
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue