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
This commit is contained in:
Jose Falanga 2026-08-19 21:17:00 -03:00
parent 208dc58f28
commit 3f5c45a99d

View file

@ -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,25 +508,43 @@ 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.
## 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:
# 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.
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]
@ -554,30 +552,24 @@ func _parse_gif_frame_delays(data: PackedByteArray) -> Array:
if block_size == 0:
break
pos += block_size
elif byte == 0x2C:
# Image descriptor — skip it.
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
# 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.
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
elif byte == 0x3B:
# Trailer.
break
else:
# Unknown block — skip.
pos += 1
return delays
return pos
## Combines two textures side-by-side into a single banner image.