@tool extends RefCounted class_name ImageMagickConverter static var _log: TwitchLogger = TwitchLogger.new("ImageMagickConverter") ## Current conversion in progress (key: path | value: mutex) var converting: Dictionary = {} var fallback_texture: Texture2D = preload("res://addons/twitcher/assets/fallback_texture.tres") ## Path to the imagemagic executable for example 'magick' when its in windows path var imagemagic_path: String static var delete_mutex: Mutex = Mutex.new() static var folder_to_delete: Array[String] = [] ## After that amount of files it starts to delete them const DELETE_COUNT = 10 ## Converts a packed byte array to a SpirteFrames and writes it out to the destination path ## ## The byte array must represent an animated gif, webp, or any imagemagick supported format ## it dumps it into a binary resource consisting of PNG frames. ## ## The resource is automatically added to the ResourceLoader cache as the input path value func dump_and_convert(path: String, buffer_in: PackedByteArray = [], output = "%s.res" % path, parallel = false) -> SpriteFrames: var thread: Thread = Thread.new() var buffer: PackedByteArray = buffer_in.duplicate() var mutex: Mutex if parallel: mutex = converting.get(path, Mutex.new()) converting[path] = mutex var err = thread.start(_do_work.bind(path, buffer, output, mutex)) assert(err == OK, "could not start thread") # don't block the main thread while loading while not thread.is_started() or thread.is_alive(): await Engine.get_main_loop().process_frame var tex: SpriteFrames = thread.wait_to_finish() if parallel: mutex.unlock() converting.erase(path) if not output.is_empty(): _save_converted_file(tex, output) return tex func _do_work(path: String, buffer: PackedByteArray, output: String, mutex: Mutex) -> SpriteFrames: if mutex != null: mutex.lock() # load from cache if another thread already completed converting this same resource if not output.is_empty() and ResourceLoader.has_cached(output): return ResourceLoader.load(output) # dump the buffer if FileAccess.file_exists(path): _log.i("File found at %s, loading it instead of using the buffer." % path) buffer = FileAccess.get_file_as_bytes(path) else: DirAccess.make_dir_recursive_absolute(path.get_base_dir()) var f = FileAccess.open(path, FileAccess.WRITE) if f == null: _log.e("Can't open file %s cause of %s" %[ path, FileAccess.get_open_error()]) f.store_buffer(buffer) f.close() var frame_delays: Array[int] = _get_frame_delay(path) var folder_path: String = _create_temp_filename() if not _extract_images(path, folder_path): return _create_fallback_texture() var sprite_frames: SpriteFrames = _build_frames(folder_path, frame_delays) if not output.is_empty(): sprite_frames.take_over_path(output) # delete the temp directory delete_mutex.lock() folder_to_delete.append(folder_path) delete_mutex.unlock() _cleanup() return sprite_frames ## Saves the texture to the output path func _save_converted_file(tex: SpriteFrames, output: String): if not output.is_empty() and tex: ResourceSaver.save(tex, output, ResourceSaver.SaverFlags.FLAG_COMPRESS) tex.take_over_path(output) func _create_unique_key(length: int = 8) -> String: var uniq = "" for i in range(length): uniq += "%d" % [randi() % 10] return uniq ## Creates a folder to store the extracted images (needs the / at the end!) func _create_temp_filename() -> String: var folder_path: String = "" var uniq = _create_unique_key() if Engine.is_editor_hint(): folder_path = "res://.godot/magick_tmp/%s_%d/" % [uniq, Time.get_unix_time_from_system()] else: folder_path = "user://.magick_tmp/%s_%d/" % [uniq, Time.get_unix_time_from_system()] _log.i("Create temp folder") DirAccess.make_dir_recursive_absolute(folder_path) return folder_path ## Extracts all delays from the file in seconds func _get_frame_delay(file: String) -> Array[int]: var out = [] var glob_path = ProjectSettings.globalize_path(file) OS.execute(imagemagic_path, [ glob_path, "-format", "%T\\n", "info:" ], out) var frame_delays: Array[int] = [] for delay in out[0].split("\n"): # convert x100 to x1000(ms) frame_delays.append(delay.to_int() * 10) return frame_delays ## Extracts all images from the file and saves them to folder path func _extract_images(file: String, target_folder: String) -> bool: var out = [] var glob_file_path = ProjectSettings.globalize_path(file) var glob_extracted_file_path = ProjectSettings.globalize_path(target_folder + "%04d.png") var code = OS.execute(imagemagic_path, [ "convert", "-coalesce", glob_file_path, glob_extracted_file_path ], out, true) if code != 0: _log.e("unable to convert: %s" % "\n".join(out)) return false return true func _create_fallback_texture(): var sprite_frames = SpriteFrames.new() sprite_frames.add_frame(&"default", fallback_texture) return sprite_frames func _build_frames(folder_path: String, frame_delays: Array[int]): _log.i("Build Frames") var frames = DirAccess.get_files_at(folder_path) if len(frames) == 0: return _create_fallback_texture() var sprite_frames: SpriteFrames = SpriteFrames.new() for filepath in frames: var idx = filepath.substr(0, filepath.rfind(".")).to_int() var delay = frame_delays[idx] / 1000.0 var image = Image.new() var error = image.load(folder_path + filepath) if error != OK: return _create_fallback_texture() var frame = ImageTexture.create_from_image(image) sprite_frames.add_frame(&"default", frame, delay) sprite_frames.set_animation_speed(&"default", 1) return sprite_frames ## Cleans after DELETE_COUNT amount of folder entries func _cleanup(force: bool = false): if folder_to_delete.size() % DELETE_COUNT == 0 || force: delete_mutex.lock() for folder in folder_to_delete: var glob_folder = ProjectSettings.globalize_path(folder) OS.move_to_trash(glob_folder) folder_to_delete.clear() delete_mutex.unlock()