diff --git a/core/boxart_itch.gd b/core/boxart_itch.gd index 66c662a..72d77ed 100644 --- a/core/boxart_itch.gd +++ b/core/boxart_itch.gd @@ -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,56 +508,68 @@ 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. - 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. + +## 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: + 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 - else: - # Unknown block — skip. - pos += 1 + pos += block_size + return [delay_ms, pos] - return delays + +## 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.