Initial Commit
Initial commit of Code Base.
This commit is contained in:
parent
293b1213e1
commit
c11a4ebbc2
653 changed files with 36893 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://chveatddlbp1x
|
||||
174
addons/twitcher/media/imagemagick/image_magick_converter.gd
Normal file
174
addons/twitcher/media/imagemagick/image_magick_converter.gd
Normal 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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dydhjmb7b0ob0
|
||||
44
addons/twitcher/media/magic_image_transformer.gd
Normal file
44
addons/twitcher/media/magic_image_transformer.gd
Normal 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)
|
||||
1
addons/twitcher/media/magic_image_transformer.gd.uid
Normal file
1
addons/twitcher/media/magic_image_transformer.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dqd4kovdhhy8o
|
||||
53
addons/twitcher/media/native/GIF2SpriteFramesPlugin.gd
Normal file
53
addons/twitcher/media/native/GIF2SpriteFramesPlugin.gd
Normal 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
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cr3b26x6lrmf6
|
||||
128
addons/twitcher/media/native/GIFReader.gd
Normal file
128
addons/twitcher/media/native/GIFReader.gd
Normal 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)
|
||||
1
addons/twitcher/media/native/GIFReader.gd.uid
Normal file
1
addons/twitcher/media/native/GIFReader.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://m6k2g1678fpc
|
||||
2
addons/twitcher/media/native/README.md
Normal file
2
addons/twitcher/media/native/README.md
Normal 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
|
||||
21
addons/twitcher/media/native/gif-lzw/LICENSE
Normal file
21
addons/twitcher/media/native/gif-lzw/LICENSE
Normal 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.
|
||||
31
addons/twitcher/media/native/gif-lzw/lsbbitpacker.gd
Normal file
31
addons/twitcher/media/native/gif-lzw/lsbbitpacker.gd
Normal 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([])
|
||||
1
addons/twitcher/media/native/gif-lzw/lsbbitpacker.gd.uid
Normal file
1
addons/twitcher/media/native/gif-lzw/lsbbitpacker.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://rbrgncjo0m63
|
||||
44
addons/twitcher/media/native/gif-lzw/lsbbitunpacker.gd
Normal file
44
addons/twitcher/media/native/gif-lzw/lsbbitunpacker.gd
Normal 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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cjogpplbpf7hp
|
||||
213
addons/twitcher/media/native/gif-lzw/lzw.gd
Normal file
213
addons/twitcher/media/native/gif-lzw/lzw.gd
Normal 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
|
||||
1
addons/twitcher/media/native/gif-lzw/lzw.gd.uid
Normal file
1
addons/twitcher/media/native/gif-lzw/lzw.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c2gildpalbfq1
|
||||
28
addons/twitcher/media/native_image_transformer.gd
Normal file
28
addons/twitcher/media/native_image_transformer.gd
Normal 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);
|
||||
1
addons/twitcher/media/native_image_transformer.gd.uid
Normal file
1
addons/twitcher/media/native_image_transformer.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ddwa0dm0qhc3s
|
||||
36
addons/twitcher/media/twitch_badge_definition.gd
Normal file
36
addons/twitcher/media/twitch_badge_definition.gd
Normal 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
|
||||
1
addons/twitcher/media/twitch_badge_definition.gd.uid
Normal file
1
addons/twitcher/media/twitch_badge_definition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dwx6jbouei2tf
|
||||
35
addons/twitcher/media/twitch_cheermote_definition.gd
Normal file
35
addons/twitcher/media/twitch_cheermote_definition.gd
Normal 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 ])
|
||||
1
addons/twitcher/media/twitch_cheermote_definition.gd.uid
Normal file
1
addons/twitcher/media/twitch_cheermote_definition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cntpsdb4qwtex
|
||||
31
addons/twitcher/media/twitch_emote_definition.gd
Normal file
31
addons/twitcher/media/twitch_emote_definition.gd
Normal 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]
|
||||
1
addons/twitcher/media/twitch_emote_definition.gd.uid
Normal file
1
addons/twitcher/media/twitch_emote_definition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dhyboroqtixko
|
||||
39
addons/twitcher/media/twitch_image_transformer.gd
Normal file
39
addons/twitcher/media/twitch_image_transformer.gd
Normal 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
|
||||
1
addons/twitcher/media/twitch_image_transformer.gd.uid
Normal file
1
addons/twitcher/media/twitch_image_transformer.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://6v8jnfjwbnhm
|
||||
435
addons/twitcher/media/twitch_media_loader.gd
Normal file
435
addons/twitcher/media/twitch_media_loader.gd
Normal 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
|
||||
1
addons/twitcher/media/twitch_media_loader.gd.uid
Normal file
1
addons/twitcher/media/twitch_media_loader.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d4lyup0vy1wtu
|
||||
Loading…
Add table
Add a link
Reference in a new issue