Initial Commit

Initial commit of Code Base.
This commit is contained in:
Mario Steele 2025-06-12 14:31:14 -05:00
parent 293b1213e1
commit c11a4ebbc2
653 changed files with 36893 additions and 1 deletions

View file

@ -0,0 +1,52 @@
@tool
extends EditorImportPlugin
class_name GifImporterImagemagick
enum Presets { DEFAULT }
func _get_importer_name() -> String:
return "kani_dev.imagemagick"
func _get_visible_name() -> String:
return "SpriteFrames (ImageMagick)"
func _get_recognized_extensions() -> PackedStringArray:
return ["gif", "webp"]
func _get_save_extension() -> String:
return "res"
func _get_resource_type() -> String:
return "SpriteFrames"
func _get_priority() -> float:
return 100.0
func _get_preset_count() -> int:
return Presets.size()
func _get_preset_name(preset_index: int) -> String:
return "Default"
func _get_import_options(path: String, preset_index: int) -> Array[Dictionary]:
return []
func _get_import_order() -> int:
return 0
func _get_option_visibility(path: String, option_name: StringName, options: Dictionary) -> bool:
return true
func _import(source_file: String, save_path: String, options: Dictionary, platform_variants: Array[String], gen_files: Array[String]) -> Error:
var dumper = ImageMagickConverter.new()
var tex = await dumper.dump_and_convert(source_file, [], "")
if tex:
return ResourceSaver.save(
tex,
"%s.%s" % [save_path, _get_save_extension()],
ResourceSaver.SaverFlags.FLAG_COMPRESS
)
push_error("failed to import %s" % source_file)
return OK;

View file

@ -0,0 +1 @@
uid://chveatddlbp1x

View file

@ -0,0 +1,174 @@
@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()

View file

@ -0,0 +1 @@
uid://dydhjmb7b0ob0

View file

@ -0,0 +1,44 @@
@icon("res://addons/twitcher/assets/media-loader-icon.svg")
@tool
extends TwitchImageTransformer
## A image transformer that uses a thirdparty program called ImageMagick
## (https://imagemagick.org/script/download.php) to transform GIF's and similar files into
## SpriteFrames. Battleproof very stable but uses an external program.
class_name MagicImageTransformer
## Path to the imagemagick program on windows its normally just 'magick' when it is available in the PATH
@export_global_file var imagemagic_path: String:
set = update_imagemagic_path
var supported: bool
var converter: ImageMagickConverter
func is_supporting_animation() -> bool:
return true
func update_imagemagic_path(path: String) -> void:
if imagemagic_path == path: return
imagemagic_path = path
if imagemagic_path != "":
var out = []
var err = OS.execute(imagemagic_path, ['-version'], out)
if err == OK:
converter = ImageMagickConverter.new()
converter.fallback_texture = fallback_texture
converter.imagemagic_path = imagemagic_path
_log.i("Imagemagic detected: %s use it to transform gif/webm" % [ imagemagic_path ])
supported = true
return
_log.i("Imagemagic at '%s' path was not detected or has a wrong result code: %s \n %s" % [ imagemagic_path, err, "\n".join(out) ])
supported = false
func is_supported() -> bool:
return supported
func convert_image(path: String, buffer_in: PackedByteArray, output_path: String) -> SpriteFrames:
return await converter.dump_and_convert(path, buffer_in, output_path)

View file

@ -0,0 +1 @@
uid://dqd4kovdhhy8o

View file

@ -0,0 +1,53 @@
# Derived from https://github.com/jegor377/godot-gdgifexporter
@tool
extends EditorImportPlugin
class_name GifImporterNative
enum Presets { DEFAULT }
func _get_importer_name() -> String:
return "gif.animated.texture.plugin"
func _get_visible_name() -> String:
return "Sprite Frames (Native)"
func _get_recognized_extensions() -> PackedStringArray:
return ["gif"]
func _get_save_extension() -> String:
return "res"
func _get_resource_type() -> String:
return "SpriteFrames"
func _get_priority() -> float:
return 90.0;
func _get_preset_count() -> int:
return Presets.size()
func _get_preset_name(preset_index: int) -> String:
return "Default"
func _get_import_options(path: String, preset_index: int) -> Array[Dictionary]:
return []
func _get_import_order() -> int:
return 0
func _get_option_visibility(path: String, option_name: StringName, options: Dictionary) -> bool:
return true
func _import(source_file: String, save_path: String, options: Dictionary, platform_variants: Array[String], gen_files: Array[String]) -> Error:
var reader = GifReader.new()
var tex = reader.read(source_file)
if tex == null:
return FAILED
var filename = save_path + "." + _get_save_extension()
return ResourceSaver.save(
tex,
"%s.%s" % [save_path, _get_save_extension()],
ResourceSaver.SaverFlags.FLAG_COMPRESS
)

View file

@ -0,0 +1 @@
uid://cr3b26x6lrmf6

View file

@ -0,0 +1,128 @@
@tool
extends RefCounted
class_name GifReader
var lzw_module = preload("./gif-lzw/lzw.gd")
var lzw = lzw_module.new()
func read(source_file) -> SpriteFrames:
var file = FileAccess.open(source_file, FileAccess.READ)
if file == null:
return null
var data = file.get_buffer(file.get_length())
file.close()
return load_gif(data)
func load_gif(data):
var pos = 0
# Header 'GIF89a'
pos = pos + 6
# Logical Screen Descriptor
var width = get_int(data, pos)
var height = get_int(data, pos + 2)
var packed_info = data[pos + 4]
var background_color_index = data[pos + 5]
pos = pos + 7
# Global color table
var global_lut
if (packed_info & 0x80) != 0:
var lut_size = 1 << (1 + (packed_info & 0x07))
global_lut = get_lut(data, pos, lut_size)
pos = pos + 3 * lut_size
# Frames
var repeat = -1
var img = Image.new()
var frame_number = 0
var frame_delay = -1
var frame_anim_packed_info = -1
var frame_transparent_color = -1
var sprite_frame = SpriteFrames.new()
img = Image.create(width, height, false, Image.FORMAT_RGBA8)
while pos < data.size():
if data[pos] == 0x21: # Extension block
var ext_type = data[pos + 1]
pos = pos + 2 # 21 xx ...
match ext_type:
0xF9: # Graphic extension
var subblock = get_subblock(data, pos)
frame_anim_packed_info = subblock[0]
frame_delay = get_int(subblock, 1)
frame_transparent_color = subblock[3]
0xFF: # Application extension
var subblock = get_subblock(data, pos)
if subblock != null and subblock.get_string_from_ascii() == "NETSCAPE2.0":
subblock = get_subblock(data, pos + 1 + subblock.size())
repeat = get_int(subblock, 1)
_: # Miscelaneous extension
#print("extension ", data[pos + 1])
pass
var block_len = 0
while data[pos + block_len] != 0:
block_len = block_len + data[pos + block_len] + 1
pos = pos + block_len + 1
elif data[pos] == 0x2C: # Image data
var img_left = get_int(data, pos + 1)
var img_top = get_int(data, pos + 3)
var img_width = get_int(data, pos + 5)
var img_height = get_int(data, pos + 7)
var img_packed_info = get_int(data, pos + 9)
pos = pos + 10
# Local color table
var local_lut = global_lut
if (img_packed_info & 0x80) != 0:
var lut_size = 1 << (1 + (img_packed_info & 0x07))
local_lut = get_lut(data, pos, lut_size)
pos = pos + 3 * lut_size
# Image data
var min_code_size = data[pos]
pos = pos + 1
var colors = []
for i in range(0, 1 << min_code_size):
colors.append(i)
var block = PackedByteArray()
while data[pos] != 0:
block.append_array(data.slice(pos + 1, pos + data[pos] + 1))
pos = pos + data[pos] + 1
pos = pos + 1
var decompressed = lzw.decompress_lzw(block, min_code_size, colors)
var disposal = (frame_anim_packed_info >> 2) & 7 # 1 = Keep, 2 = Clear
var transparency = frame_anim_packed_info & 1
if disposal == 2:
if transparency == 0 and background_color_index != frame_transparent_color:
img.fill(local_lut[background_color_index])
else:
img.fill(Color(0,0,0,0))
var p = 0
for y in range(0, img_height):
for x in range(0, img_width):
var c = decompressed[p]
if transparency == 0 or c != frame_transparent_color:
img.set_pixel(img_left + x, img_top + y, local_lut[c])
p = p + 1
var frame = ImageTexture.create_from_image(img);
sprite_frame.add_frame(&"default", frame, frame_delay / 100.0);
frame_anim_packed_info = -1
frame_transparent_color = -1
frame_delay = -1
frame_number = frame_number + 1
elif data[pos] == 0x3B: # Trailer
pos = pos + 1
sprite_frame.set_animation_speed(&"default", 1);
return sprite_frame
func get_subblock(data: PackedByteArray, pos):
if data[pos] == 0:
return null
else:
return data.slice(pos + 1, pos + data[pos] + 1)
func get_lut(data, pos, size):
var colors = Array()
for i in range(0, size):
colors.append(Color(data[pos + i * 3] / 255.0, data[pos + 1 + i * 3] / 255.0, data[pos + 2 + i * 3] / 255.0))
return colors
func get_int(data, pos):
return data[pos] + (data[pos + 1] << 8)

View file

@ -0,0 +1 @@
uid://m6k2g1678fpc

View file

@ -0,0 +1,2 @@
# Known Bugs
- https://github.com/ImageMagick/ImageMagick/issues/4634 exists in this lib too. Images that are not correctly encoded makes problems

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Igor Santarek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,31 @@
@tool
extends RefCounted
class LSBLZWBitPacker:
var bit_index: int = 0
var stream: int = 0
var chunks: PackedByteArray = PackedByteArray([])
func put_byte():
chunks.append(stream & 0xff)
bit_index -= 8
stream >>= 8
func write_bits(value: int, bits_count: int) -> void:
value &= (1 << bits_count) - 1
value <<= bit_index
stream |= value
bit_index += bits_count
while bit_index >= 8:
put_byte()
func pack() -> PackedByteArray:
if bit_index != 0:
put_byte()
return chunks
func reset() -> void:
bit_index = 0
stream = 0
chunks = PackedByteArray([])

View file

@ -0,0 +1 @@
uid://rbrgncjo0m63

View file

@ -0,0 +1,44 @@
@tool
extends RefCounted
class LSBLZWBitUnpacker:
var chunk_stream: PackedByteArray
var bit_index: int = 0
var byte: int
var byte_index: int = 0
func _init(_chunk_stream: PackedByteArray):
chunk_stream = _chunk_stream
get_byte()
func get_bit(value: int, index: int) -> int:
return (value >> index) & 1
func set_bit(value: int, index: int) -> int:
return value | (1 << index)
func get_byte():
byte = chunk_stream[byte_index]
byte_index += 1
bit_index = 0
func read_bits(bits_count: int) -> int:
var result: int = 0
var result_bit_index: int = 0
for _i in range(bits_count):
if get_bit(byte, bit_index) == 1:
result = set_bit(result, result_bit_index)
result_bit_index += 1
bit_index += 1
if chunk_stream.size() == byte_index && result_bit_index == bits_count:
return result;
if bit_index == 8:
get_byte()
return result
func remove_bits(bits_count: int) -> void:
read_bits(bits_count)

View file

@ -0,0 +1 @@
uid://cjogpplbpf7hp

View file

@ -0,0 +1,213 @@
@tool
extends RefCounted
var lsbbitpacker = preload("./lsbbitpacker.gd")
var lsbbitunpacker = preload("./lsbbitunpacker.gd")
class CodeEntry:
var sequence: PackedByteArray
var raw_array: PackedByteArray
func _init(_sequence : PackedByteArray) -> void:
raw_array = _sequence
sequence = _sequence
func add(other) -> CodeEntry:
return CodeEntry.new(raw_array + other.raw_array)
func to_string() -> String:
var result: String = ""
for element in sequence:
result += str(element) + ", "
return result.substr(0, result.length() - 2)
class CodeTable:
var entries: Dictionary = {}
var counter: int = 0
var lookup: Dictionary = {}
func add(entry: CodeEntry) -> int:
entries[counter] = entry
lookup[entry.raw_array] = counter
counter += 1
return counter
func find(entry: CodeEntry) -> int:
return lookup.get(entry.raw_array, -1)
func has_entry(entry: CodeEntry) -> bool:
return find(entry) != -1
func get_entry(index: int) -> CodeEntry:
return entries.get(index, null)
func to_string() -> String:
var result: String = "CodeTable:\n"
for id in entries:
result += str(id) + ": " + entries[id].to_string() + "\n"
result += "Counter: " + str(counter) + "\n"
return result
func log2(value: float) -> float:
return log(value) / log(2.0)
func get_bits_number_for(value: int) -> int:
if value == 0:
return 1
return int(ceil(log2(value + 1)))
func initialize_color_code_table(colors: PackedByteArray) -> CodeTable:
var result_code_table: CodeTable = CodeTable.new()
for color_id in colors:
# warning-ignore:return_value_discarded
result_code_table.add(CodeEntry.new([color_id]))
# move counter to the first available compression code index
var last_color_index: int = colors.size() - 1
var clear_code_index: int = pow(2, get_bits_number_for(last_color_index))
result_code_table.counter = clear_code_index + 2
return result_code_table
# compression and decompression done with source:
# http://www.matthewflickinger.com/lab/whatsinagif/lzw_image_data.asp
func compress_lzw(image: PackedByteArray, colors: PackedByteArray) -> Array:
# Initialize code table
var code_table: CodeTable = initialize_color_code_table(colors)
# Clear Code index is 2**<code size>
# <code size> is the amount of bits needed to write down all colors
# from color table. We use last color index because we can write
# all colors (for example 16 colors) with indexes from 0 to 15.
# Number 15 is in binary 0b1111, so we'll need 4 bits to write all
# colors down.
var last_color_index: int = colors.size() - 1
var clear_code_index: int = pow(2, get_bits_number_for(last_color_index))
var index_stream: PackedByteArray = image
var current_code_size: int = get_bits_number_for(clear_code_index)
var binary_code_stream = lsbbitpacker.LSBLZWBitPacker.new()
# initialize with Clear Code
binary_code_stream.write_bits(clear_code_index, current_code_size)
# Read first index from index stream.
var index_buffer: CodeEntry = CodeEntry.new([index_stream[0]])
var data_index: int = 1
# <LOOP POINT>
while data_index < index_stream.size():
# Get the next index from the index stream.
var k: CodeEntry = CodeEntry.new([index_stream[data_index]])
data_index += 1
# Is index buffer + k in our code table?
var new_index_buffer: CodeEntry = index_buffer.add(k)
if code_table.has_entry(new_index_buffer): # if YES
# Add k to the end of the index buffer
index_buffer = new_index_buffer
else: # if NO
# Add a row for index buffer + k into our code table
binary_code_stream.write_bits(code_table.find(index_buffer), current_code_size)
# We don't want to add new code to code table if we've exceeded 4095
# index.
var last_entry_index: int = code_table.counter - 1
if last_entry_index != 4095:
# Output the code for just the index buffer to our code stream
# warning-ignore:return_value_discarded
code_table.add(new_index_buffer)
else:
# if we exceeded 4095 index (code table is full), we should
# output Clear Code and reset everything.
binary_code_stream.write_bits(clear_code_index, current_code_size)
code_table = initialize_color_code_table(colors)
# get_bits_number_for(clear_code_index) is the same as
# LZW code size + 1
current_code_size = get_bits_number_for(clear_code_index)
# Detect when you have to save new codes in bigger bits boxes
# change current code size when it happens because we want to save
# flexible code sized codes
var new_code_size_candidate: int = get_bits_number_for(code_table.counter - 1)
if new_code_size_candidate > current_code_size:
current_code_size = new_code_size_candidate
# Index buffer is set to k
index_buffer = k
# Output code for contents of index buffer
binary_code_stream.write_bits(code_table.find(index_buffer), current_code_size)
# output end with End Of Information Code
binary_code_stream.write_bits(clear_code_index + 1, current_code_size)
var min_code_size: int = get_bits_number_for(clear_code_index) - 1
return [binary_code_stream.pack(), min_code_size]
# gdlint: ignore=max-line-length
func decompress_lzw(code_stream_data: PackedByteArray, min_code_size: int, colors: PackedByteArray) -> PackedByteArray:
var code_table: CodeTable = initialize_color_code_table(colors)
var index_stream: PackedByteArray = PackedByteArray([])
var binary_code_stream = lsbbitunpacker.LSBLZWBitUnpacker.new(code_stream_data)
var current_code_size: int = min_code_size + 1
var clear_code_index: int = pow(2, min_code_size)
# CODE is an index of code table, {CODE} is sequence inside
# code table with index CODE. The same goes for PREVCODE.
# let CODE be the first code in the code stream
var code: int = binary_code_stream.read_bits(current_code_size)
# Remove first Clear Code from stream. We don't need it.
if code == clear_code_index:
code = binary_code_stream.read_bits(current_code_size);
# output {CODE} to index stream
index_stream.append_array(code_table.get_entry(code).sequence)
# set PREVCODE = CODE
var prevcode: int = code
# <LOOP POINT>
while true:
# let CODE be the next code in the code stream
code = binary_code_stream.read_bits(current_code_size)
# Detect Clear Code. When detected reset everything and get next code.
if code == clear_code_index:
code_table = initialize_color_code_table(colors)
current_code_size = min_code_size + 1
code = binary_code_stream.read_bits(current_code_size)
index_stream.append_array(code_table.get_entry(code).sequence)
prevcode = code
continue
elif code == clear_code_index + 1: # Stop when detected EOI Code.
break
# is CODE in the code table?
var code_entry: CodeEntry = code_table.get_entry(code)
if code_entry != null: # if YES
# output {CODE} to index stream
index_stream.append_array(code_entry.sequence)
# let k be the first index in {CODE}
var k: CodeEntry = CodeEntry.new([code_entry.sequence[0]])
# warning-ignore:return_value_discarded
# add {PREVCODE} + k to the code table
code_table.add(code_table.get_entry(prevcode).add(k))
# set PREVCODE = CODE
prevcode = code
else: # if NO
# let k be the first index of {PREVCODE}
var prevcode_entry: CodeEntry = code_table.get_entry(prevcode)
var k: CodeEntry = CodeEntry.new([prevcode_entry.sequence[0]])
# output {PREVCODE} + k to index stream
index_stream.append_array(prevcode_entry.add(k).sequence)
# add {PREVCODE} + k to code table
# warning-ignore:return_value_discarded
code_table.add(prevcode_entry.add(k))
# set PREVCODE = CODE
prevcode = code
# Detect when we should increase current code size and increase it.
var new_code_size_candidate: int = get_bits_number_for(code_table.counter)
if new_code_size_candidate > current_code_size:
current_code_size = new_code_size_candidate
return index_stream

View file

@ -0,0 +1 @@
uid://c2gildpalbfq1

View file

@ -0,0 +1,28 @@
@icon("res://addons/twitcher/assets/media-loader-icon.svg")
@tool
extends TwitchImageTransformer
## Native GIF parser written in GDScript and ported to Godot 4. Most of the time stable but there
## are GIF's that may not work cause the file didn't follow the GIF specification.
class_name NativeImageTransformer
func is_supporting_animation() -> bool:
return true
func convert_image(path: String, buffer_in: PackedByteArray, output_path: String) -> SpriteFrames:
var reader = GifReader.new()
var tex: SpriteFrames;
if buffer_in.size() == 0:
tex = reader.read(path);
else:
tex = reader.load_gif(buffer_in)
_save_converted_file(tex, output_path);
return tex
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);

View file

@ -0,0 +1 @@
uid://ddwa0dm0qhc3s

View file

@ -0,0 +1,36 @@
extends RefCounted
## Definition to load or specify one specific badge
class_name TwitchBadgeDefinition
var badge_set: String
var badge_id: String
var scale: int
var channel: String
var _cache_id: String # This is maybe a bad idea, but solves the issue when the badge won't get found in cache and then it changes the channel to global during loading and so also the cache id
func scale_1() -> TwitchBadgeDefinition: scale = 1; return self;
func scale_2() -> TwitchBadgeDefinition: scale = 2; return self;
func scale_4() -> TwitchBadgeDefinition: scale = 4; return self;
func _init(set_id: String, id: String, badge_scale: int, badge_channel: String) -> void:
badge_set = set_id
badge_id = id
assert(badge_scale == 1 || badge_scale == 2 || badge_scale == 4)
scale = badge_scale
channel = badge_channel
_cache_id = "_".join([
channel,
badge_set,
badge_id,
scale
])
func _to_string() -> String:
return "Badge[%s/%s/%s]" % [channel, badge_set, badge_id]
func get_cache_id() -> String:
return _cache_id

View file

@ -0,0 +1 @@
uid://dwx6jbouei2tf

View file

@ -0,0 +1,35 @@
extends RefCounted
## Definition of a specific cheermote
class_name TwitchCheermoteDefinition
var prefix: String
var tier: String
var theme: String = "dark"
var type: String = "animated_format"
var scale: String = "1"
func _init(pre: String, tir: String) -> void:
prefix = pre
tier = tir
func theme_dark() -> TwitchCheermoteDefinition: theme = "dark"; return self;
func theme_light() -> TwitchCheermoteDefinition: theme = "light"; return self;
func type_animated() -> TwitchCheermoteDefinition: type = "animated_format"; return self;
func type_static() -> TwitchCheermoteDefinition: type = "static_format"; return self;
func scale_1() -> TwitchCheermoteDefinition: scale = "1"; return self;
func scale_2() -> TwitchCheermoteDefinition: scale = "2"; return self;
func scale_3() -> TwitchCheermoteDefinition: scale = "3"; return self;
func scale_4() -> TwitchCheermoteDefinition: scale = "4"; return self;
func scale_1_5() -> TwitchCheermoteDefinition: scale = "1.5"; return self;
func _to_string() -> String:
return "Cheer[%s/%s]" % [prefix, tier]
func get_id() -> String:
return "/" + "/".join([ prefix, tier, theme, type, scale ])

View file

@ -0,0 +1 @@
uid://cntpsdb4qwtex

View file

@ -0,0 +1,31 @@
extends RefCounted
## Used to define what emotes to load to be typesafe and don't request invalid data.
class_name TwitchEmoteDefinition
var id: String
var _scale: int
var _type: String
var _theme: String
func _init(emote_id: String) -> void:
id = emote_id
scale_1().type_default().theme_dark()
func scale_1() -> TwitchEmoteDefinition: _scale = 1; return self;
func scale_2() -> TwitchEmoteDefinition: _scale = 2; return self;
func scale_3() -> TwitchEmoteDefinition: _scale = 3; return self;
func type_default() -> TwitchEmoteDefinition: _type = "default"; return self;
func type_static() -> TwitchEmoteDefinition: _type = "static"; return self;
func type_animated() -> TwitchEmoteDefinition: _type = "animated"; return self;
func theme_dark() -> TwitchEmoteDefinition: _theme = "dark"; return self;
func theme_light() -> TwitchEmoteDefinition: _theme = "light"; return self;
func _to_string() -> String:
return "Emote[%s]" % id
## Returns its unique filename
func get_file_name() -> String:
return "%s_%s_%s_%s" % [_scale, _type, _theme, id]

View file

@ -0,0 +1 @@
uid://dhyboroqtixko

View file

@ -0,0 +1,39 @@
@icon("res://addons/twitcher/assets/media-loader-icon.svg")
@tool
extends Resource
## Most simple image transformer that doesn't support GIF's uses builtin functionalities of godot.
class_name TwitchImageTransformer
static var _log: TwitchLogger = TwitchLogger.new("TwitchImageTransformer")
## Used when the image can't be transformed
@export var fallback_texture: Texture2D = preload("res://addons/twitcher/assets/fallback_texture.tres")
func is_supporting_animation() -> bool:
return false
func is_supported() -> bool:
return true
func convert_image(path: String, buffer_in: PackedByteArray, output_path: String) -> SpriteFrames:
if ResourceLoader.has_cached(output_path):
return ResourceLoader.load(output_path)
var img := Image.new()
var err = img.load_png_from_buffer(buffer_in)
var sprite_frames = SpriteFrames.new()
var texture : Texture
if err == OK:
texture = ImageTexture.new()
texture.set_image(img)
sprite_frames.add_frame(&"default", texture)
ResourceSaver.save(sprite_frames, output_path, ResourceSaver.SaverFlags.FLAG_COMPRESS)
sprite_frames.take_over_path(path)
else:
sprite_frames.add_frame(&"default", fallback_texture)
_log.e("Can't load %s use fallback" % output_path)
return sprite_frames

View file

@ -0,0 +1 @@
uid://6v8jnfjwbnhm

View file

@ -0,0 +1,435 @@
@icon("res://addons/twitcher/assets/media-loader-icon.svg")
@tool
extends Twitcher
## Will load badges, icons and profile images
class_name TwitchMediaLoader
static var _log: TwitchLogger = TwitchLogger.new("TwitchMediaLoader")
static var instance: TwitchMediaLoader
## Called when an emoji was succesfully loaded
signal emoji_loaded(definition: TwitchEmoteDefinition)
const FALLBACK_TEXTURE = preload("res://addons/twitcher/assets/fallback_texture.tres")
const FALLBACK_PROFILE = preload("res://addons/twitcher/assets/no_profile.png")
@export var api: TwitchAPI
@export var image_transformer: TwitchImageTransformer = TwitchImageTransformer.new():
set(val):
image_transformer = val
update_configuration_warnings()
@export var fallback_texture: Texture2D = FALLBACK_TEXTURE
@export var fallback_profile: Texture2D = FALLBACK_PROFILE
@export var image_cdn_host: String = "https://static-cdn.jtvnw.net"
## Will preload the whole badge and emote cache also to editor time (use it when you make a Editor Plugin with Twitch Support)
@export var load_cache_in_editor: bool
@export_global_dir var cache_emote: String = "user://emotes"
@export_global_dir var cache_badge: String = "user://badges"
@export_global_dir var cache_cheermote: String = "user://cheermote"
## All requests that are currently in progress
var _requests_in_progress : Array[StringName]
## Badge definition for global and the channel.
var _cached_badges : Dictionary = {}
## Emote definition for global and the channel.
var _cached_emotes : Dictionary = {}
## Key: String Cheer Prefix | Value: TwitchCheermote
var _cached_cheermotes: Dictionary[String, TwitchCheermote] = {}
## All cached emotes, badges, cheermotes
## Is needed that the garbage collector isn't deleting our cache.
var _cached_images : Array[SpriteFrames] = []
var _host_parser = RegEx.create_from_string("(https://.*?)/")
var static_image_transformer = TwitchImageTransformer.new()
var _client: BufferedHTTPClient
func _ready() -> void:
_client = BufferedHTTPClient.new()
_client.name = "TwitchMediaLoaderClient"
add_child(_client)
_load_cache()
if api == null: api = TwitchAPI.instance
func _enter_tree() -> void:
if instance == null: instance = self
func _exit_tree() -> void:
if instance == self: instance = null
## Loading all images from the directory into the memory cache
func _load_cache() -> void:
if Engine.is_editor_hint() || load_cache_in_editor:
_cache_directory(cache_emote)
_cache_directory(cache_badge)
func _cache_directory(path: String):
DirAccess.make_dir_recursive_absolute(path)
var files = DirAccess.get_files_at(path)
for file in files:
if file.ends_with(".res"):
var res_path = path.path_join(file)
var sprite_frames: SpriteFrames = ResourceLoader.load(res_path, "SpriteFrames")
var spriteframe_path = res_path.trim_suffix(".res")
sprite_frames.take_over_path(spriteframe_path)
_cached_images.append(sprite_frames)
#region Emotes
func preload_emotes(channel_id: String = "global") -> void:
if (!_cached_emotes.has(channel_id)):
var response
if channel_id == "global":
_log.i("Preload global emotes")
response = await api.get_global_emotes()
else:
_log.i("Preload channel(%s) emotes" % channel_id)
response = await api.get_channel_emotes(channel_id)
_cached_emotes[channel_id] = _map_emotes(response)
## Returns requested emotes.
## Key: EmoteID as String | Value: SpriteFrames
func get_emotes(emote_ids : Array[String]) -> Dictionary[String, SpriteFrames]:
_log.i("Get emotes: %s" % emote_ids)
var requests: Array[TwitchEmoteDefinition] = []
for id: String in emote_ids:
requests.append(TwitchEmoteDefinition.new(id))
var emotes: Dictionary[TwitchEmoteDefinition, SpriteFrames] = await get_emotes_by_definition(requests)
var result: Dictionary[String, SpriteFrames] = {}
# Remap the emotes to string value easier for processing
for requested_emote: TwitchEmoteDefinition in requests:
result[requested_emote.id] = emotes[requested_emote]
return result
## Returns requested emotes.
## Key: TwitchEmoteDefinition | Value: SpriteFrames
func get_emotes_by_definition(emote_definitions : Array[TwitchEmoteDefinition]) -> Dictionary[TwitchEmoteDefinition, SpriteFrames]:
var response: Dictionary[TwitchEmoteDefinition, SpriteFrames] = {}
var requests: Dictionary[TwitchEmoteDefinition, BufferedHTTPClient.RequestData] = {}
for emote_definition: TwitchEmoteDefinition in emote_definitions:
var original_file_cache_path: String = _get_emote_cache_path(emote_definition)
var spriteframe_path: String = _get_emote_cache_path_spriteframe(emote_definition)
if ResourceLoader.has_cached(spriteframe_path):
_log.d("Use cached emote %s" % emote_definition)
response[emote_definition] = ResourceLoader.load(spriteframe_path)
continue
if not image_transformer.is_supporting_animation():
emote_definition.type_static()
if _requests_in_progress.has(original_file_cache_path): continue
_requests_in_progress.append(original_file_cache_path)
_log.d("Request emote %s" % emote_definition)
var request : BufferedHTTPClient.RequestData = _load_emote(emote_definition)
requests[emote_definition] = request
for emote_definition : TwitchEmoteDefinition in requests:
var original_file_cache_path : String = _get_emote_cache_path(emote_definition)
var spriteframe_path : String = _get_emote_cache_path_spriteframe(emote_definition)
var request : BufferedHTTPClient.RequestData = requests[emote_definition]
var sprite_frames : SpriteFrames = await _convert_response(request, original_file_cache_path, spriteframe_path)
response[emote_definition] = sprite_frames
_cached_images.append(sprite_frames)
_requests_in_progress.erase(original_file_cache_path)
emoji_loaded.emit(emote_definition)
for emote_definition: TwitchEmoteDefinition in emote_definitions:
if not response.has(emote_definition):
var cache : String = _get_emote_cache_path_spriteframe(emote_definition)
response[emote_definition] = ResourceLoader.load(cache)
return response
## Returns the path where the raw emoji should be cached
func _get_emote_cache_path(emote_definition: TwitchEmoteDefinition) -> String:
var file_name : String = emote_definition.get_file_name()
return cache_emote.path_join(file_name)
## Returns the path where the converted spriteframe should be cached
func _get_emote_cache_path_spriteframe(emote_definition: TwitchEmoteDefinition) -> String:
var file_name : String = emote_definition.get_file_name() + ".res"
return cache_emote.path_join(file_name)
func _load_emote(emote_definition : TwitchEmoteDefinition) -> BufferedHTTPClient.RequestData:
var request_path : String = "/emoticons/v2/%s/%s/%s/%1.1f" % [emote_definition.id, emote_definition._type, emote_definition._theme, emote_definition._scale]
return _client.request(image_cdn_host + request_path, HTTPClient.METHOD_GET, {}, "")
func _map_emotes(result: Variant) -> Dictionary:
var mappings : Dictionary = {}
var emotes : Array = result.get("data")
if emotes == null:
return mappings
for emote in emotes:
mappings[emote.get("id")] = emote
return mappings
func get_cached_emotes(channel_id) -> Dictionary:
if not _cached_emotes.has(channel_id):
await preload_emotes(channel_id)
return _cached_emotes[channel_id]
#endregion
#region Badges
func preload_badges(channel_id: String = "global") -> void:
if not _cached_badges.has(channel_id):
var response: Variant # TwitchGetGlobalChatBadges.Response | TwitchGetChannelChatBadges.Response
if channel_id == "global":
_log.i("Preload global badges")
response = await(api.get_global_chat_badges())
else:
_log.i("Preload channel(%s) badges" % channel_id)
response = await(api.get_channel_chat_badges(channel_id))
_cached_badges[channel_id] = _cache_badges(response)
## Returns the requested badge either from cache or loads from web. Scale can be 1, 2 or 4.
## Key: TwitchBadgeDefinition | Value: SpriteFrames
func get_badges(badges: Array[TwitchBadgeDefinition]) -> Dictionary[TwitchBadgeDefinition, SpriteFrames]:
var response: Dictionary[TwitchBadgeDefinition, SpriteFrames] = {}
var requests: Dictionary[TwitchBadgeDefinition, BufferedHTTPClient.RequestData] = {}
for badge_definition : TwitchBadgeDefinition in badges:
var cache_id : String = badge_definition.get_cache_id()
var badge_path : String = cache_badge.path_join(cache_id)
if ResourceLoader.has_cached(badge_path):
_log.d("Use cached badge %s" % badge_definition)
response[badge_definition] = ResourceLoader.load(badge_path)
else:
_log.d("Request badge %s" % badge_definition)
var request : BufferedHTTPClient.RequestData = await _load_badge(badge_definition)
requests[badge_definition] = request
for badge_definition : TwitchBadgeDefinition in requests:
var request = requests[badge_definition]
var id : String = badge_definition.get_cache_id()
var cache_path : String = cache_badge.path_join(id)
var spriteframe_path : String = cache_badge.path_join(id) + ".res"
var sprite_frames : SpriteFrames = await _convert_response(request, cache_path, spriteframe_path)
response[badge_definition] = sprite_frames
_cached_images.append(sprite_frames)
return response
func _load_badge(badge_definition: TwitchBadgeDefinition) -> BufferedHTTPClient.RequestData:
var channel_id : String = badge_definition.channel
var badge_set : String = badge_definition.badge_set
var badge_id : String = badge_definition.badge_id
var scale : int = badge_definition.scale
var is_global_chanel : bool = channel_id == "global"
if not _cached_badges.has(channel_id):
await preload_badges(channel_id)
var channel_has_badge : bool = _cached_badges[channel_id].has(badge_set) && _cached_badges[channel_id][badge_set]["versions"].has(badge_id)
if not is_global_chanel and not channel_has_badge:
badge_definition.channel = "global"
return await _load_badge(badge_definition)
var request_path : String = _cached_badges[channel_id][badge_set]["versions"][badge_id]["image_url_%sx" % scale]
return _client.request(request_path, HTTPClient.METHOD_GET, {}, "")
## Maps the badges into a dict of category / versions / badge_id
func _cache_badges(result: Variant) -> Dictionary:
var mappings : Dictionary = {}
var badges : Array = result["data"]
for badge in badges:
if not mappings.has(badge["set_id"]):
mappings[badge["set_id"]] = {
"set_id": badge["set_id"],
"versions" : {}
}
for version in badge["versions"]:
mappings[badge["set_id"]]["versions"][version["id"]] = version
return mappings
func get_cached_badges(channel_id: String) -> Dictionary:
if(!_cached_badges.has(channel_id)):
await preload_badges(channel_id)
return _cached_badges[channel_id]
#endregion
#region Cheermote
class CheerResult extends RefCounted:
var cheermote: TwitchCheermote
var tier: TwitchCheermote.Tiers
var spriteframes: SpriteFrames
func _init(cheer: TwitchCheermote, t: TwitchCheermote.Tiers, sprites: SpriteFrames):
cheermote = cheer
tier = t
spriteframes = sprites
func preload_cheemote() -> void:
if not _cached_cheermotes.is_empty(): return
_log.i("Preload cheermotes")
var cheermote_response: TwitchGetCheermotes.Response = await api.get_cheermotes(null)
for data: TwitchCheermote in cheermote_response.data:
_log.d("- found %s" % data.prefix)
_cached_cheermotes[data.prefix] = data
func all_cheermotes() -> Array[TwitchCheermote]:
var cheermotes: Array[TwitchCheermote] = []
cheermotes.assign(_cached_cheermotes.values())
return cheermotes
## Resolves a info with spriteframes for a specific cheer definition contains also spriteframes for the given tier.
## Can be null when not found.
func get_cheer_info(cheermote_definition: TwitchCheermoteDefinition) -> CheerResult:
await preload_cheemote()
var cheermote : TwitchCheermote = _cached_cheermotes[cheermote_definition.prefix]
for cheertier: TwitchCheermote.Tiers in cheermote.tiers:
if cheertier.id == cheermote_definition.tier:
var sprite_frames: SpriteFrames = await _get_cheermote_sprite_frames(cheertier, cheermote_definition)
return CheerResult.new(cheermote, cheertier, sprite_frames)
return null
## Finds the tier depending on the given number
func find_cheer_tier(number: int, cheer_data: TwitchCheermote) -> TwitchCheermote.Tiers:
var current_tier: TwitchCheermote.Tiers = cheer_data.tiers[0]
for tier: TwitchCheermote.Tiers in cheer_data.tiers:
if tier.min_bits < number && current_tier.min_bits < tier.min_bits:
current_tier = tier
return current_tier
## Returns spriteframes mapped by tier for a cheermote
## Key: TwitchCheermote.Tiers | Value: SpriteFrames
func get_cheermotes(cheermote_definition: TwitchCheermoteDefinition) -> Dictionary[TwitchCheermote.Tiers, SpriteFrames]:
await preload_cheemote()
var response : Dictionary[TwitchCheermote.Tiers, SpriteFrames] = {}
var requests : Dictionary[TwitchCheermote.Tiers, BufferedHTTPClient.RequestData] = {}
var cheer : TwitchCheermote = _cached_cheermotes[cheermote_definition.prefix]
for tier : TwitchCheermote.Tiers in cheer.tiers:
var id = cheermote_definition.get_id()
if ResourceLoader.has_cached(id):
_log.d("Use cached cheer %s" % cheermote_definition)
response[tier] = ResourceLoader.load(id)
if not image_transformer.is_supporting_animation():
cheermote_definition.type_static()
else:
_log.d("Request cheer %s" % cheermote_definition)
requests[tier] = _request_cheermote(tier, cheermote_definition)
for tier: TwitchCheermote.Tiers in requests:
var id = cheermote_definition.get_id()
var request = requests[tier]
var sprite_frames = await _wait_for_cheeremote(request, id)
response[tier] = sprite_frames
return response
func _get_cheermote_sprite_frames(tier: TwitchCheermote.Tiers, cheermote_definition: TwitchCheermoteDefinition) -> SpriteFrames:
var id = cheermote_definition.get_id()
if ResourceLoader.has_cached(id):
return ResourceLoader.load(id)
else:
var request : BufferedHTTPClient.RequestData = _request_cheermote(tier, cheermote_definition)
if request == null:
var frames : SpriteFrames = SpriteFrames.new()
frames.add_frame("default", fallback_texture)
return frames
return await _wait_for_cheeremote(request, id)
func _wait_for_cheeremote(request: BufferedHTTPClient.RequestData, cheer_id: String) -> SpriteFrames:
var response : BufferedHTTPClient.ResponseData = await _client.wait_for_request(request)
var cache_path : String = cache_cheermote.path_join(cheer_id)
var sprite_frames : SpriteFrames = await image_transformer.convert_image(
cache_path,
response.response_data,
cache_path + ".res") as SpriteFrames
sprite_frames.take_over_path(cheer_id)
_cached_images.append(sprite_frames)
return sprite_frames
func _request_cheermote(cheer_tier: TwitchCheermote.Tiers, cheermote: TwitchCheermoteDefinition) -> BufferedHTTPClient.RequestData:
var img_path : String = cheer_tier.images[cheermote.theme][cheermote.type][cheermote.scale]
var host_result : RegExMatch = _host_parser.search(img_path)
if host_result == null: return null
var host : String = host_result.get_string(1)
return _client.request(img_path, HTTPClient.METHOD_GET, {}, "")
#endregion
#region Utilities
func _get_configuration_warnings() -> PackedStringArray:
if image_transformer == null || not image_transformer.is_supported():
return ["Image transformer is misconfigured"]
return []
func load_image(url: String) -> Image:
var request : BufferedHTTPClient.RequestData = _client.request(url, HTTPClient.METHOD_GET, {}, "")
var response : BufferedHTTPClient.ResponseData = await _client.wait_for_request(request)
var temp_file : FileAccess = FileAccess.create_temp(FileAccess.ModeFlags.WRITE_READ, "image_", url.get_extension(), true)
temp_file.store_buffer(response.response_data)
temp_file.flush()
var image : Image = Image.load_from_file(temp_file.get_path())
return image
## Get the image of an user
func load_profile_image(user: TwitchUser) -> ImageTexture:
if user == null: return fallback_profile
if ResourceLoader.has_cached(user.profile_image_url):
return ResourceLoader.load(user.profile_image_url)
var request := _client.request(user.profile_image_url, HTTPClient.METHOD_GET, {}, "")
var response_data := await _client.wait_for_request(request)
var texture : ImageTexture = ImageTexture.new()
var response := response_data.response_data
if not response.is_empty():
var img := Image.new()
var content_type = response_data.response_header["Content-Type"]
match content_type:
"image/png": img.load_png_from_buffer(response)
"image/jpeg": img.load_jpg_from_buffer(response)
_: return fallback_profile
texture.set_image(img)
else:
# Don't use `texture = fallback_profile` as texture cause the path will be taken over
# for caching purpose!
texture.set_image(fallback_profile.get_image())
texture.take_over_path(user.profile_image_url)
return texture
const GIF_HEADER: PackedByteArray = [71, 73, 70]
func _convert_response(request: BufferedHTTPClient.RequestData, cache_path: String, spriteframe_path: String) -> SpriteFrames:
var response = await _client.wait_for_request(request)
var response_data = response.response_data as PackedByteArray
var file_head = response_data.slice(0, 3)
# REMARK: don't use content-type... twitch doesn't check and sends PNGs with GIF content type.
if file_head == GIF_HEADER:
return await image_transformer.convert_image(cache_path, response_data, spriteframe_path) as SpriteFrames
else:
return await static_image_transformer.convert_image(cache_path, response_data, spriteframe_path) as SpriteFrames
#endregion

View file

@ -0,0 +1 @@
uid://d4lyup0vy1wtu