mirror of
https://github.com/Orama-Interactive/Pixelorama.git
synced 2025-01-18 17:19:50 +00:00
APNG Loading (#797)
* APNG loader: Import addon to take over APNG handling * APNG loader: Transition code to using the AImgIO addon * APNG loader: Can now open APNGs. * AImgIO: Update to fix bugs * APNG loader: HTML5 * APNG loader: gdformat/gdlint addon * APNG loader: OpenSave formatting fix * APNG Loader: Add ignore line to OpenSave because it's too big * Fix GIFAnimationExporter bug caused by the switch to the addon
This commit is contained in:
parent
44890243b4
commit
4658b1cfb6
|
@ -1,5 +1,11 @@
|
||||||
# Addons
|
# Addons
|
||||||
|
|
||||||
|
## aimgio
|
||||||
|
|
||||||
|
- Upstream: https://gitlab.com/20kdc/20kdc_godot_addons
|
||||||
|
- Version: Presently git commit b6a1411758c856ad543f4f661ca4105aa7c0ab6d
|
||||||
|
- License: [Unlicense](https://gitlab.com/20kdc/20kdc_godot_addons/-/blob/b6a1411758c856ad543f4f661ca4105aa7c0ab6d/addons/aimg_io/COPYING.txt)
|
||||||
|
|
||||||
## Keychain
|
## Keychain
|
||||||
|
|
||||||
- Upstream: https://github.com/Orama-Interactive/Keychain
|
- Upstream: https://github.com/Orama-Interactive/Keychain
|
||||||
|
@ -20,3 +26,4 @@ Files extracted from source:
|
||||||
- Upstream: https://github.com/gilzoide/godot-dockable-container
|
- Upstream: https://github.com/gilzoide/godot-dockable-container
|
||||||
- Version: Based on git commit e5df60ed1d53246e03dba36053ff009846ba5174 with a modification on dockable_container.gd (lines 187-191).
|
- Version: Based on git commit e5df60ed1d53246e03dba36053ff009846ba5174 with a modification on dockable_container.gd (lines 187-191).
|
||||||
- License: [CC0-1.0](https://github.com/gilzoide/godot-dockable-container/blob/main/LICENSE)
|
- License: [CC0-1.0](https://github.com/gilzoide/godot-dockable-container/blob/main/LICENSE)
|
||||||
|
|
||||||
|
|
25
addons/aimg_io/COPYING.txt
Normal file
25
addons/aimg_io/COPYING.txt
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
For more information, please refer to <http://unlicense.org>
|
||||||
|
|
8
addons/aimg_io/apng_crc32.tres
Normal file
8
addons/aimg_io/apng_crc32.tres
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
[gd_resource type="Resource" load_steps=2 format=2]
|
||||||
|
|
||||||
|
[ext_resource path="res://addons/aimg_io/crc32.gd" type="Script" id=1]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource( 1 )
|
||||||
|
reversed_polynomial = 3988292384
|
||||||
|
mask = 4294967295
|
|
@ -1,69 +1,43 @@
|
||||||
class_name APNGAnimationExporter
|
class_name AImgIOAPNGExporter
|
||||||
extends BaseAnimationExporter
|
extends AImgIOBaseExporter
|
||||||
# APNG exporter. To be clear, this is effectively magic.
|
# APNG exporter. To be clear, this is effectively magic.
|
||||||
|
|
||||||
var crc32_table := []
|
|
||||||
|
|
||||||
|
|
||||||
func _init():
|
func _init():
|
||||||
mime_type = "image/apng"
|
mime_type = "image/apng"
|
||||||
# Calculate CRC32 table.
|
|
||||||
var range8 = range(8)
|
|
||||||
for i in range(256):
|
|
||||||
var crc = i
|
|
||||||
for j in range8:
|
|
||||||
if (crc & 1) != 0:
|
|
||||||
crc = (crc >> 1) ^ 0xEDB88320
|
|
||||||
else:
|
|
||||||
crc >>= 1
|
|
||||||
crc32_table.push_back(crc & 0xFFFFFFFF)
|
|
||||||
|
|
||||||
|
|
||||||
# Performs the update step of CRC32 over some bytes.
|
|
||||||
# Note that this is not the whole story.
|
|
||||||
# The CRC must be initialized to 0xFFFFFFFF, then updated, then bitwise-inverted.
|
|
||||||
func crc32_data(crc: int, data: PoolByteArray):
|
|
||||||
var i = 0
|
|
||||||
var l = len(data)
|
|
||||||
while i < l:
|
|
||||||
var lb = data[i] ^ (crc & 0xFF)
|
|
||||||
crc = crc32_table[lb] ^ (crc >> 8)
|
|
||||||
i += 1
|
|
||||||
return crc
|
|
||||||
|
|
||||||
|
|
||||||
func export_animation(
|
func export_animation(
|
||||||
images: Array,
|
frames: Array,
|
||||||
durations: Array,
|
|
||||||
fps_hint: float,
|
fps_hint: float,
|
||||||
progress_report_obj: Object,
|
progress_report_obj: Object,
|
||||||
progress_report_method,
|
progress_report_method,
|
||||||
progress_report_args
|
progress_report_args
|
||||||
) -> PoolByteArray:
|
) -> PoolByteArray:
|
||||||
var result = open_chunk()
|
var frame_count := len(frames)
|
||||||
|
var result := AImgIOAPNGStream.new()
|
||||||
# Magic number
|
# Magic number
|
||||||
result.put_32(0x89504E47)
|
result.write_magic()
|
||||||
result.put_32(0x0D0A1A0A)
|
|
||||||
# From here on out, all data is written in "chunks".
|
# From here on out, all data is written in "chunks".
|
||||||
# IHDR
|
# IHDR
|
||||||
var image: Image = images[0]
|
var image: Image = frames[0].content
|
||||||
var chunk = open_chunk()
|
var chunk := result.start_chunk()
|
||||||
chunk.put_32(image.get_width())
|
chunk.put_32(image.get_width())
|
||||||
chunk.put_32(image.get_height())
|
chunk.put_32(image.get_height())
|
||||||
chunk.put_32(0x08060000)
|
chunk.put_32(0x08060000)
|
||||||
chunk.put_8(0)
|
chunk.put_8(0)
|
||||||
write_chunk(result, "IHDR", chunk.data_array)
|
result.write_chunk("IHDR", chunk.data_array)
|
||||||
# acTL
|
# acTL
|
||||||
chunk = open_chunk()
|
chunk = result.start_chunk()
|
||||||
chunk.put_32(len(images))
|
chunk.put_32(frame_count)
|
||||||
chunk.put_32(0)
|
chunk.put_32(0)
|
||||||
write_chunk(result, "acTL", chunk.data_array)
|
result.write_chunk("acTL", chunk.data_array)
|
||||||
# For each frame... (note: first frame uses IDAT)
|
# For each frame... (note: first frame uses IDAT)
|
||||||
var sequence = 0
|
var sequence := 0
|
||||||
for i in range(len(images)):
|
for i in range(frame_count):
|
||||||
image = images[i]
|
image = frames[i].content
|
||||||
# fcTL
|
# fcTL
|
||||||
chunk = open_chunk()
|
chunk = result.start_chunk()
|
||||||
chunk.put_32(sequence)
|
chunk.put_32(sequence)
|
||||||
sequence += 1
|
sequence += 1
|
||||||
# image w/h
|
# image w/h
|
||||||
|
@ -72,30 +46,36 @@ func export_animation(
|
||||||
# offset x/y
|
# offset x/y
|
||||||
chunk.put_32(0)
|
chunk.put_32(0)
|
||||||
chunk.put_32(0)
|
chunk.put_32(0)
|
||||||
write_delay(chunk, durations[i], fps_hint)
|
write_delay(chunk, frames[i].duration, fps_hint)
|
||||||
# dispose / blend
|
# dispose / blend
|
||||||
chunk.put_8(0)
|
chunk.put_8(0)
|
||||||
chunk.put_8(0)
|
chunk.put_8(0)
|
||||||
write_chunk(result, "fcTL", chunk.data_array)
|
# So depending on who you ask, there might be supposed to be a second
|
||||||
|
# checksum here. The problem is, if there is, it's not well-explained.
|
||||||
|
# Plus, actual readers don't seem to require it.
|
||||||
|
# And the W3C specification just copy/pastes the (bad) Mozilla spec.
|
||||||
|
# Dear Mozilla spec writers: If you wanted a second checksum,
|
||||||
|
# please indicate it's existence in the fcTL chunk structure.
|
||||||
|
result.write_chunk("fcTL", chunk.data_array)
|
||||||
# IDAT/fdAT
|
# IDAT/fdAT
|
||||||
chunk = open_chunk()
|
chunk = result.start_chunk()
|
||||||
if i != 0:
|
if i != 0:
|
||||||
chunk.put_32(sequence)
|
chunk.put_32(sequence)
|
||||||
sequence += 1
|
sequence += 1
|
||||||
# setup chunk interior...
|
# setup chunk interior...
|
||||||
var ichk = open_chunk()
|
var ichk := result.start_chunk()
|
||||||
write_padded_lines(ichk, image)
|
write_padded_lines(ichk, image)
|
||||||
chunk.put_data(ichk.data_array.compress(File.COMPRESSION_DEFLATE))
|
chunk.put_data(ichk.data_array.compress(File.COMPRESSION_DEFLATE))
|
||||||
# done with chunk interior
|
# done with chunk interior
|
||||||
if i == 0:
|
if i == 0:
|
||||||
write_chunk(result, "IDAT", chunk.data_array)
|
result.write_chunk("IDAT", chunk.data_array)
|
||||||
else:
|
else:
|
||||||
write_chunk(result, "fdAT", chunk.data_array)
|
result.write_chunk("fdAT", chunk.data_array)
|
||||||
# Done with this frame!
|
# Done with this frame!
|
||||||
progress_report_obj.callv(progress_report_method, progress_report_args)
|
progress_report_obj.callv(progress_report_method, progress_report_args)
|
||||||
# Final chunk.
|
# Final chunk.
|
||||||
write_chunk(result, "IEND", PoolByteArray())
|
result.write_chunk("IEND", PoolByteArray())
|
||||||
return result.data_array
|
return result.finish()
|
||||||
|
|
||||||
|
|
||||||
func write_delay(sp: StreamPeer, duration: float, fps_hint: float):
|
func write_delay(sp: StreamPeer, duration: float, fps_hint: float):
|
||||||
|
@ -107,10 +87,10 @@ func write_delay(sp: StreamPeer, duration: float, fps_hint: float):
|
||||||
# So it follows that num = 1 and den = fps.
|
# So it follows that num = 1 and den = fps.
|
||||||
# Precision is increased so we catch more complex cases.
|
# Precision is increased so we catch more complex cases.
|
||||||
# But you should always get perfection for integers.
|
# But you should always get perfection for integers.
|
||||||
var den = min(32767, max(fps_hint, 1))
|
var den := min(32767, max(fps_hint, 1))
|
||||||
var num = max(duration, 0) * den
|
var num := max(duration, 0) * den
|
||||||
# If the FPS hint brings us out of range before we start, try some obvious integers
|
# If the FPS hint brings us out of range before we start, try some obvious integers
|
||||||
var fallback = 10000
|
var fallback := 10000
|
||||||
while num > 32767:
|
while num > 32767:
|
||||||
num = max(duration, 0) * den
|
num = max(duration, 0) * den
|
||||||
den = fallback
|
den = fallback
|
||||||
|
@ -134,33 +114,17 @@ func write_delay(sp: StreamPeer, duration: float, fps_hint: float):
|
||||||
|
|
||||||
func write_padded_lines(sp: StreamPeer, img: Image):
|
func write_padded_lines(sp: StreamPeer, img: Image):
|
||||||
if img.get_format() != Image.FORMAT_RGBA8:
|
if img.get_format() != Image.FORMAT_RGBA8:
|
||||||
push_warning("Image format in APNGAnimationExporter should only ever be RGBA8.")
|
push_warning("Image format in AImgIOAPNGExporter should only ever be RGBA8.")
|
||||||
return
|
return
|
||||||
var data = img.get_data()
|
var data := img.get_data()
|
||||||
var y = 0
|
var y := 0
|
||||||
var w = img.get_width()
|
var w := img.get_width()
|
||||||
var h = img.get_height()
|
var h := img.get_height()
|
||||||
var base = 0
|
var base := 0
|
||||||
while y < h:
|
while y < h:
|
||||||
var nl = base + (w * 4)
|
var nl := base + (w * 4)
|
||||||
var line = data.subarray(base, nl - 1)
|
var line := data.subarray(base, nl - 1)
|
||||||
sp.put_8(0)
|
sp.put_8(0)
|
||||||
sp.put_data(line)
|
sp.put_data(line)
|
||||||
y += 1
|
y += 1
|
||||||
base = nl
|
base = nl
|
||||||
|
|
||||||
|
|
||||||
func open_chunk() -> StreamPeerBuffer:
|
|
||||||
var result = StreamPeerBuffer.new()
|
|
||||||
result.big_endian = true
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
func write_chunk(sp: StreamPeer, type: String, data: PoolByteArray):
|
|
||||||
sp.put_32(len(data))
|
|
||||||
var at = type.to_ascii()
|
|
||||||
sp.put_data(at)
|
|
||||||
sp.put_data(data)
|
|
||||||
var crc = crc32_data(0xFFFFFFFF, at)
|
|
||||||
crc = crc32_data(crc, data) ^ 0xFFFFFFFF
|
|
||||||
sp.put_32(crc)
|
|
80
addons/aimg_io/apng_import_plugin.gd
Normal file
80
addons/aimg_io/apng_import_plugin.gd
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
tool
|
||||||
|
class_name AImgIOAPNGImportPlugin
|
||||||
|
extends EditorImportPlugin
|
||||||
|
|
||||||
|
|
||||||
|
func get_importer_name() -> String:
|
||||||
|
return "aimgio.apng_animatedtexture"
|
||||||
|
|
||||||
|
|
||||||
|
func get_visible_name() -> String:
|
||||||
|
return "APNG as AnimatedTexture"
|
||||||
|
|
||||||
|
|
||||||
|
func get_save_extension() -> String:
|
||||||
|
return "res"
|
||||||
|
|
||||||
|
|
||||||
|
func get_resource_type() -> String:
|
||||||
|
return "AnimatedTexture"
|
||||||
|
|
||||||
|
|
||||||
|
func get_recognized_extensions() -> Array:
|
||||||
|
return ["png"]
|
||||||
|
|
||||||
|
|
||||||
|
func get_preset_count():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
func get_preset_name(_i):
|
||||||
|
return "Default"
|
||||||
|
|
||||||
|
|
||||||
|
func get_import_options(_i):
|
||||||
|
# GDLint workaround - it really does not want this string to exist due to length.
|
||||||
|
var hint = "Mipmaps,Repeat,Filter,Anisotropic Filter,Convert To Linear,Mirrored Repeat"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "image_texture_storage",
|
||||||
|
"default_value": 2,
|
||||||
|
"property_hint": PROPERTY_HINT_ENUM_SUGGESTION,
|
||||||
|
"hint_string": "Raw,Lossy,Lossless"
|
||||||
|
},
|
||||||
|
{"name": "image_texture_lossy_quality", "default_value": 0.7},
|
||||||
|
{
|
||||||
|
"name": "texture_flags",
|
||||||
|
"default_value": 7,
|
||||||
|
"property_hint": PROPERTY_HINT_FLAGS,
|
||||||
|
"hint_string": hint
|
||||||
|
},
|
||||||
|
# We don't know if Godot will change things somehow.
|
||||||
|
{"name": "texture_flags_add", "default_value": 0}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func get_option_visibility(_option, _options):
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func import(load_path: String, save_path: String, options, _platform_variants, _gen_files):
|
||||||
|
var res := AImgIOAPNGImporter.load_from_file(load_path)
|
||||||
|
if res[0] != null:
|
||||||
|
push_error("AImgIOPNGImporter: " + res[0])
|
||||||
|
return ERR_FILE_UNRECOGNIZED
|
||||||
|
var frames: Array = res[1]
|
||||||
|
var root: AnimatedTexture = AnimatedTexture.new()
|
||||||
|
var flags: int = options["texture_flags"]
|
||||||
|
flags |= options["texture_flags_add"]
|
||||||
|
root.flags = flags
|
||||||
|
root.frames = len(frames)
|
||||||
|
root.fps = 1
|
||||||
|
for i in range(len(frames)):
|
||||||
|
var f: AImgIOFrame = frames[i]
|
||||||
|
root.set_frame_delay(i, f.duration - 1.0)
|
||||||
|
var tx := ImageTexture.new()
|
||||||
|
tx.storage = options["image_texture_storage"]
|
||||||
|
tx.lossy_quality = options["image_texture_lossy_quality"]
|
||||||
|
tx.create_from_image(f.content, flags)
|
||||||
|
root.set_frame_texture(i, tx)
|
||||||
|
return ResourceSaver.save(save_path + ".res", root)
|
214
addons/aimg_io/apng_importer.gd
Normal file
214
addons/aimg_io/apng_importer.gd
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
tool
|
||||||
|
class_name AImgIOAPNGImporter
|
||||||
|
extends Reference
|
||||||
|
# Will NOT import regular, unanimated PNGs - use Image.load_png_from_buffer
|
||||||
|
# This is because we don't want to import the default image as a frame
|
||||||
|
# Therefore it just uses the rule:
|
||||||
|
# "fcTL chunk always precedes an APNG frame, even if that includes IDAT"
|
||||||
|
|
||||||
|
|
||||||
|
# Imports an APNG PoolByteArray into an animation as an Array of frames.
|
||||||
|
# Returns [error, frames] similar to some read functions.
|
||||||
|
# However, error is a string.
|
||||||
|
static func load_from_buffer(buffer: PoolByteArray) -> Array:
|
||||||
|
var stream := AImgIOAPNGStream.new(buffer)
|
||||||
|
var magic_str = stream.read_magic()
|
||||||
|
if magic_str != null:
|
||||||
|
# well, that was a nope
|
||||||
|
return [magic_str, null]
|
||||||
|
# Ok, so, before we continue, let's establish what is and is not okay
|
||||||
|
# for the target of this importer.
|
||||||
|
|
||||||
|
# Firstly, thankfully, we don't have to worry about colour profiles or any
|
||||||
|
# ancillary chunks that involve them.
|
||||||
|
# Godot doesn't care and caring would break images meant for non-colour use.
|
||||||
|
# If for some reason you care and consider this a hot take:
|
||||||
|
# + Just perceptual-intent sRGB at display driver / hardware level.
|
||||||
|
# + Anyone who really cares about colourimetric intent should NOT use RGB.
|
||||||
|
# (This said, the gAMA chunk has its uses in realistic usecases.
|
||||||
|
# Having a way to specify linear vs. non-linear RGB isn't inherently bad.)
|
||||||
|
# Secondly, we DO have to worry about tRNS.
|
||||||
|
# tRNS is an "optional" chunk to support.
|
||||||
|
# ...same as fcTL is "optional".
|
||||||
|
# The file would decode but actual meaningful data is lost.
|
||||||
|
# Thirdly, the size of an APNG frame is not necessarily the original size.
|
||||||
|
# So to convert an APNG frame to a PNG for reading, we need to stitch:
|
||||||
|
# IHDR (modified), PLTE (if present), tRNS (if present), IDAT (from fdAT),
|
||||||
|
# and IEND (generated).
|
||||||
|
var ihdr := PoolByteArray()
|
||||||
|
var plte := PoolByteArray()
|
||||||
|
var trns := PoolByteArray()
|
||||||
|
# stored full width/height for buffer
|
||||||
|
var width := 0
|
||||||
|
var height := 0
|
||||||
|
# parse chunks
|
||||||
|
var frames := []
|
||||||
|
while stream.read_chunk() == OK:
|
||||||
|
if stream.chunk_type == "IHDR":
|
||||||
|
ihdr = stream.chunk_data
|
||||||
|
# extract necessary information
|
||||||
|
if len(ihdr) < 8:
|
||||||
|
return ["IHDR not even large enough for W/H", null]
|
||||||
|
var sp := StreamPeerBuffer.new()
|
||||||
|
sp.data_array = ihdr
|
||||||
|
sp.big_endian = true
|
||||||
|
width = sp.get_32()
|
||||||
|
height = sp.get_32()
|
||||||
|
elif stream.chunk_type == "PLTE":
|
||||||
|
plte = stream.chunk_data
|
||||||
|
elif stream.chunk_type == "tRNS":
|
||||||
|
trns = stream.chunk_data
|
||||||
|
elif stream.chunk_type == "fcTL":
|
||||||
|
var f := BFrame.new()
|
||||||
|
var err = f.setup(stream.chunk_data)
|
||||||
|
if err != null:
|
||||||
|
return [err, null]
|
||||||
|
frames.push_back(f)
|
||||||
|
elif stream.chunk_type == "IDAT":
|
||||||
|
# add to last frame if any
|
||||||
|
# this uses the lack of the fcTL for the default image on purpose,
|
||||||
|
# while still handling frame 0 as IDAT properly
|
||||||
|
if len(frames) > 0:
|
||||||
|
var f: BFrame = frames[len(frames) - 1]
|
||||||
|
f.add_data(stream.chunk_data)
|
||||||
|
elif stream.chunk_type == "fdAT":
|
||||||
|
# it's just frame data
|
||||||
|
# we ignore seq. nums. if they're wrong, file's invalid
|
||||||
|
# so if there are issues, we don't have to support them
|
||||||
|
if len(frames) > 0:
|
||||||
|
var f: BFrame = frames[len(frames) - 1]
|
||||||
|
if len(stream.chunk_data) >= 4:
|
||||||
|
var data := stream.chunk_data.subarray(4, len(stream.chunk_data) - 1)
|
||||||
|
f.add_data(data)
|
||||||
|
# theoretically we *could* store the default frame somewhere, but *why*?
|
||||||
|
# just use Image functions if you want that
|
||||||
|
if len(frames) == 0:
|
||||||
|
return ["No frames", null]
|
||||||
|
# prepare initial operating buffer
|
||||||
|
var operating := Image.new()
|
||||||
|
operating.create(width, height, false, Image.FORMAT_RGBA8)
|
||||||
|
operating.fill(Color(0, 0, 0, 0))
|
||||||
|
var finished := []
|
||||||
|
for v in frames:
|
||||||
|
var fv: BFrame = v
|
||||||
|
# Ok, so to avoid having to deal with filters and stuff,
|
||||||
|
# what we do here is generate intermediary single-frame PNG files.
|
||||||
|
# Whoever specced APNG either managed to make a good format by accident,
|
||||||
|
# or had a very good understanding of the concerns of people who need
|
||||||
|
# to retrofit their PNG decoders into APNG decoders, because the fact
|
||||||
|
# you can even do this is *beautiful*.
|
||||||
|
var intermediary := fv.intermediary(ihdr, plte, trns)
|
||||||
|
var intermediary_img := Image.new()
|
||||||
|
if intermediary_img.load_png_from_buffer(intermediary) != OK:
|
||||||
|
return ["error during intermediary load - corrupt/bug?", null]
|
||||||
|
intermediary_img.convert(Image.FORMAT_RGBA8)
|
||||||
|
# dispose vars
|
||||||
|
var blit_target := operating
|
||||||
|
var copy_blit_target := true
|
||||||
|
# rectangles and such
|
||||||
|
var blit_src := Rect2(Vector2.ZERO, intermediary_img.get_size())
|
||||||
|
var blit_pos := Vector2(fv.x, fv.y)
|
||||||
|
var blit_tgt := Rect2(blit_pos, intermediary_img.get_size())
|
||||||
|
# early dispose ops
|
||||||
|
if fv.dispose_op == 2:
|
||||||
|
# previous
|
||||||
|
# we handle this by never actually writing to the operating buffer,
|
||||||
|
# but instead a copy (so we don't have to make another later)
|
||||||
|
blit_target = Image.new()
|
||||||
|
blit_target.copy_from(operating)
|
||||||
|
copy_blit_target = false
|
||||||
|
# actually blit
|
||||||
|
if fv.blend_op == 0:
|
||||||
|
blit_target.blit_rect(intermediary_img, blit_src, blit_pos)
|
||||||
|
else:
|
||||||
|
blit_target.blend_rect(intermediary_img, blit_src, blit_pos)
|
||||||
|
# insert as frame
|
||||||
|
var ffin := AImgIOFrame.new()
|
||||||
|
ffin.duration = fv.duration
|
||||||
|
if copy_blit_target:
|
||||||
|
var img := Image.new()
|
||||||
|
img.copy_from(operating)
|
||||||
|
ffin.content = img
|
||||||
|
else:
|
||||||
|
ffin.content = blit_target
|
||||||
|
finished.push_back(ffin)
|
||||||
|
# late dispose ops
|
||||||
|
if fv.dispose_op == 1:
|
||||||
|
# background
|
||||||
|
# this works as you expect
|
||||||
|
operating.fill_rect(blit_tgt, Color(0, 0, 0, 0))
|
||||||
|
return [null, finished]
|
||||||
|
|
||||||
|
|
||||||
|
# Imports an APNG file into an animation as an array of frames.
|
||||||
|
# Returns null on error.
|
||||||
|
static func load_from_file(path: String) -> Array:
|
||||||
|
var o := File.new()
|
||||||
|
if o.open(path, File.READ) != OK:
|
||||||
|
return [null, "Unable to open file: " + path]
|
||||||
|
var l = o.get_len()
|
||||||
|
var data = o.get_buffer(l)
|
||||||
|
o.close()
|
||||||
|
return load_from_buffer(data)
|
||||||
|
|
||||||
|
|
||||||
|
# Intermediate frame structure
|
||||||
|
class BFrame:
|
||||||
|
extends Reference
|
||||||
|
var dispose_op: int
|
||||||
|
var blend_op: int
|
||||||
|
var x: int
|
||||||
|
var y: int
|
||||||
|
var w: int
|
||||||
|
var h: int
|
||||||
|
var duration: float
|
||||||
|
var data: PoolByteArray
|
||||||
|
|
||||||
|
func setup(fctl: PoolByteArray):
|
||||||
|
if len(fctl) < 26:
|
||||||
|
return ""
|
||||||
|
var sp := StreamPeerBuffer.new()
|
||||||
|
sp.data_array = fctl
|
||||||
|
sp.big_endian = true
|
||||||
|
sp.get_32()
|
||||||
|
w = sp.get_32() & 0xFFFFFFFF
|
||||||
|
h = sp.get_32() & 0xFFFFFFFF
|
||||||
|
# theoretically these are supposed to be unsigned, but like...
|
||||||
|
# that just contributes to the assertion of it being inbounds, really.
|
||||||
|
# so since blitting will do the crop anyway, let's just be generous
|
||||||
|
x = sp.get_32()
|
||||||
|
y = sp.get_32()
|
||||||
|
var num := float(sp.get_16() & 0xFFFF)
|
||||||
|
var den := float(sp.get_16() & 0xFFFF)
|
||||||
|
if den == 0.0:
|
||||||
|
den = 100
|
||||||
|
duration = num / den
|
||||||
|
dispose_op = sp.get_8()
|
||||||
|
blend_op = sp.get_8()
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Creates an intermediary PNG.
|
||||||
|
# This can be loaded by Godot directly.
|
||||||
|
# This basically skips most of the APNG decoding process.
|
||||||
|
func intermediary(
|
||||||
|
ihdr: PoolByteArray, plte: PoolByteArray, trns: PoolByteArray
|
||||||
|
) -> PoolByteArray:
|
||||||
|
# Might be important to note this operates on a copy of ihdr (by-value).
|
||||||
|
var sp := StreamPeerBuffer.new()
|
||||||
|
sp.data_array = ihdr
|
||||||
|
sp.big_endian = true
|
||||||
|
sp.put_32(w)
|
||||||
|
sp.put_32(h)
|
||||||
|
var intermed := AImgIOAPNGStream.new()
|
||||||
|
intermed.write_magic()
|
||||||
|
intermed.write_chunk("IHDR", sp.data_array)
|
||||||
|
if len(plte) > 0:
|
||||||
|
intermed.write_chunk("PLTE", plte)
|
||||||
|
if len(trns) > 0:
|
||||||
|
intermed.write_chunk("tRNS", trns)
|
||||||
|
intermed.write_chunk("IDAT", data)
|
||||||
|
intermed.write_chunk("IEND", PoolByteArray())
|
||||||
|
return intermed.finish()
|
||||||
|
|
||||||
|
func add_data(d: PoolByteArray):
|
||||||
|
data.append_array(d)
|
95
addons/aimg_io/apng_stream.gd
Normal file
95
addons/aimg_io/apng_stream.gd
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
tool
|
||||||
|
class_name AImgIOAPNGStream
|
||||||
|
extends Reference
|
||||||
|
# APNG IO context. To be clear, this is still effectively magic.
|
||||||
|
|
||||||
|
# Quite critical we preload this. Preloading creates static variables.
|
||||||
|
# (Which GDScript doesn't really have, but we need since we have no tree access)
|
||||||
|
var crc32: AImgIOCRC32 = preload("apng_crc32.tres")
|
||||||
|
|
||||||
|
var chunk_type: String
|
||||||
|
var chunk_data: PoolByteArray
|
||||||
|
|
||||||
|
# The reason this must be a StreamPeerBuffer is simple:
|
||||||
|
# 1. We need to support in-memory IO for HTML5 to really work
|
||||||
|
# 2. We need get_available_bytes to be completely accurate in all* cases
|
||||||
|
# * A >2GB file doesn't count. Godot limitations.
|
||||||
|
# because get_32 can return arbitrary nonsense on error.
|
||||||
|
# It might have been worth trying something else if StreamPeerFile was a thing.
|
||||||
|
# Though even then that's betting the weirdness of corrupt files against the
|
||||||
|
# benefits of using less memory.
|
||||||
|
var _target: StreamPeerBuffer
|
||||||
|
|
||||||
|
|
||||||
|
func _init(t: PoolByteArray = PoolByteArray()):
|
||||||
|
crc32.ensure_ready()
|
||||||
|
_target = StreamPeerBuffer.new()
|
||||||
|
_target.big_endian = true
|
||||||
|
_target.data_array = t
|
||||||
|
|
||||||
|
|
||||||
|
# Reading
|
||||||
|
|
||||||
|
|
||||||
|
# Reads the magic number. Returns the method of failure or null for success.
|
||||||
|
func read_magic():
|
||||||
|
if _target.get_available_bytes() < 8:
|
||||||
|
return "Not enough bytes in magic number"
|
||||||
|
var a := _target.get_32() & 0xFFFFFFFF
|
||||||
|
if a != 0x89504E47:
|
||||||
|
return "Magic number start not 0x89504E47, but " + str(a)
|
||||||
|
a = _target.get_32() & 0xFFFFFFFF
|
||||||
|
if a != 0x0D0A1A0A:
|
||||||
|
return "Magic number end not 0x0D0A1A0A, but " + str(a)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
# Reads a chunk into chunk_type and chunk_data. Returns an error code.
|
||||||
|
func read_chunk() -> int:
|
||||||
|
if _target.get_available_bytes() < 8:
|
||||||
|
return ERR_FILE_EOF
|
||||||
|
var dlen := _target.get_32()
|
||||||
|
var a := char(_target.get_8())
|
||||||
|
var b := char(_target.get_8())
|
||||||
|
var c := char(_target.get_8())
|
||||||
|
var d := char(_target.get_8())
|
||||||
|
chunk_type = a + b + c + d
|
||||||
|
if _target.get_available_bytes() >= dlen:
|
||||||
|
chunk_data = _target.get_data(dlen)[1]
|
||||||
|
else:
|
||||||
|
return ERR_FILE_EOF
|
||||||
|
# we don't care what this reads anyway, so don't bother checking it
|
||||||
|
_target.get_32()
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
# Writing
|
||||||
|
|
||||||
|
|
||||||
|
# Writes the PNG magic number.
|
||||||
|
func write_magic():
|
||||||
|
_target.put_32(0x89504E47)
|
||||||
|
_target.put_32(0x0D0A1A0A)
|
||||||
|
|
||||||
|
|
||||||
|
# Creates a big-endian StreamPeerBuffer for writing PNG data into.
|
||||||
|
func start_chunk() -> StreamPeerBuffer:
|
||||||
|
var result := StreamPeerBuffer.new()
|
||||||
|
result.big_endian = true
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Writes a PNG chunk.
|
||||||
|
func write_chunk(type: String, data: PoolByteArray):
|
||||||
|
_target.put_32(len(data))
|
||||||
|
var at := type.to_ascii()
|
||||||
|
_target.put_data(at)
|
||||||
|
_target.put_data(data)
|
||||||
|
var crc := crc32.update(crc32.mask, at)
|
||||||
|
crc = crc32.end(crc32.update(crc, data))
|
||||||
|
_target.put_32(crc)
|
||||||
|
|
||||||
|
|
||||||
|
# Returns the data_array of the stream (to be used when you're done writing the file)
|
||||||
|
func finish() -> PoolByteArray:
|
||||||
|
return _target.data_array
|
|
@ -1,19 +1,18 @@
|
||||||
class_name BaseAnimationExporter
|
class_name AImgIOBaseExporter
|
||||||
extends Reference
|
extends Reference
|
||||||
# Represents a method for exporting animations.
|
# Represents a method for exporting animations.
|
||||||
# Please do NOT use project globals in this code.
|
|
||||||
|
|
||||||
var mime_type: String
|
var mime_type: String
|
||||||
|
|
||||||
|
|
||||||
# Exports an animation to a byte array of file data.
|
# Exports an animation to a byte array of file data.
|
||||||
|
# The frames must be AImgIOFrame.
|
||||||
# fps_hint is only a hint, animations may have higher FPSes than this.
|
# fps_hint is only a hint, animations may have higher FPSes than this.
|
||||||
# The durations array (with durations listed in seconds) is the true reference.
|
# The frame duration field (in seconds) is the true reference.
|
||||||
# progress_report_obj.callv(progress_report_method, progress_report_args) is
|
# progress_report_obj.callv(progress_report_method, progress_report_args) is
|
||||||
# called after each frame is handled.
|
# called after each frame is handled.
|
||||||
func export_animation(
|
func export_animation(
|
||||||
_frames: Array,
|
_frames: Array,
|
||||||
_durations: Array,
|
|
||||||
_fps_hint: float,
|
_fps_hint: float,
|
||||||
_progress_report_obj: Object,
|
_progress_report_obj: Object,
|
||||||
_progress_report_method,
|
_progress_report_method,
|
53
addons/aimg_io/crc32.gd
Normal file
53
addons/aimg_io/crc32.gd
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
tool
|
||||||
|
class_name AImgIOCRC32
|
||||||
|
extends Resource
|
||||||
|
# CRC32 implementation that uses a Resource for better caching
|
||||||
|
|
||||||
|
const INIT = 0xFFFFFFFF
|
||||||
|
|
||||||
|
# The reversed polynomial.
|
||||||
|
export var reversed_polynomial: int = 0xEDB88320
|
||||||
|
|
||||||
|
# The mask (and initialization value).
|
||||||
|
export var mask: int = 0xFFFFFFFF
|
||||||
|
|
||||||
|
var crc32_table = []
|
||||||
|
var _table_init_mutex: Mutex = Mutex.new()
|
||||||
|
var _table_initialized: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
# Ensures the CRC32's cached part is ready.
|
||||||
|
# Should be called very infrequently, definitely not in an inner loop.
|
||||||
|
func ensure_ready():
|
||||||
|
_table_init_mutex.lock()
|
||||||
|
if not _table_initialized:
|
||||||
|
# Calculate CRC32 table.
|
||||||
|
var range8 := range(8)
|
||||||
|
for i in range(256):
|
||||||
|
var crc := i
|
||||||
|
for j in range8:
|
||||||
|
if (crc & 1) != 0:
|
||||||
|
crc = (crc >> 1) ^ reversed_polynomial
|
||||||
|
else:
|
||||||
|
crc >>= 1
|
||||||
|
crc32_table.push_back(crc & mask)
|
||||||
|
_table_initialized = true
|
||||||
|
_table_init_mutex.unlock()
|
||||||
|
|
||||||
|
|
||||||
|
# Performs the update step of CRC32 over some bytes.
|
||||||
|
# Note that this is not the whole story.
|
||||||
|
# The CRC must be initialized to 0xFFFFFFFF, then updated, then bitwise-inverted.
|
||||||
|
func update(crc: int, data: PoolByteArray) -> int:
|
||||||
|
var i := 0
|
||||||
|
var l := len(data)
|
||||||
|
while i < l:
|
||||||
|
var lb := data[i] ^ (crc & 0xFF)
|
||||||
|
crc = crc32_table[lb] ^ (crc >> 8)
|
||||||
|
i += 1
|
||||||
|
return crc
|
||||||
|
|
||||||
|
|
||||||
|
# Finishes the CRC by XORing it with the mask.
|
||||||
|
func end(crc: int) -> int:
|
||||||
|
return crc ^ mask
|
14
addons/aimg_io/editor_plugin.gd
Normal file
14
addons/aimg_io/editor_plugin.gd
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
tool
|
||||||
|
extends EditorPlugin
|
||||||
|
|
||||||
|
var apng_importer
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_tree():
|
||||||
|
apng_importer = AImgIOAPNGImportPlugin.new()
|
||||||
|
add_import_plugin(apng_importer)
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree():
|
||||||
|
remove_import_plugin(apng_importer)
|
||||||
|
apng_importer = null
|
13
addons/aimg_io/frame.gd
Normal file
13
addons/aimg_io/frame.gd
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class_name AImgIOFrame
|
||||||
|
extends Reference
|
||||||
|
# Represents a variable-timed frame of an animation.
|
||||||
|
# Typically stuffed into an array.
|
||||||
|
|
||||||
|
# Content of the frame.
|
||||||
|
# WARNING: Exporters expect this to be FORMAT_RGBA8.
|
||||||
|
# This is because otherwise they'd have to copy it or convert it in place.
|
||||||
|
# Both of those are bad ideas, so thus this.
|
||||||
|
var content: Image
|
||||||
|
|
||||||
|
# Time in seconds this frame lasts for.
|
||||||
|
var duration: float = 0.1
|
6
addons/aimg_io/plugin.cfg
Normal file
6
addons/aimg_io/plugin.cfg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[plugin]
|
||||||
|
name="AImgIO"
|
||||||
|
description="Animated image I/O (as part of ISLE.APNG-LOAD)"
|
||||||
|
author="20kdc"
|
||||||
|
version="0.1"
|
||||||
|
script="editor_plugin.gd"
|
|
@ -9,10 +9,40 @@
|
||||||
config_version=4
|
config_version=4
|
||||||
|
|
||||||
_global_script_classes=[ {
|
_global_script_classes=[ {
|
||||||
"base": "BaseAnimationExporter",
|
"base": "AImgIOBaseExporter",
|
||||||
"class": "APNGAnimationExporter",
|
"class": "AImgIOAPNGExporter",
|
||||||
"language": "GDScript",
|
"language": "GDScript",
|
||||||
"path": "res://src/Classes/AnimationExporters/APNGAnimationExporter.gd"
|
"path": "res://addons/aimg_io/apng_exporter.gd"
|
||||||
|
}, {
|
||||||
|
"base": "EditorImportPlugin",
|
||||||
|
"class": "AImgIOAPNGImportPlugin",
|
||||||
|
"language": "GDScript",
|
||||||
|
"path": "res://addons/aimg_io/apng_import_plugin.gd"
|
||||||
|
}, {
|
||||||
|
"base": "Reference",
|
||||||
|
"class": "AImgIOAPNGImporter",
|
||||||
|
"language": "GDScript",
|
||||||
|
"path": "res://addons/aimg_io/apng_importer.gd"
|
||||||
|
}, {
|
||||||
|
"base": "Reference",
|
||||||
|
"class": "AImgIOAPNGStream",
|
||||||
|
"language": "GDScript",
|
||||||
|
"path": "res://addons/aimg_io/apng_stream.gd"
|
||||||
|
}, {
|
||||||
|
"base": "Reference",
|
||||||
|
"class": "AImgIOBaseExporter",
|
||||||
|
"language": "GDScript",
|
||||||
|
"path": "res://addons/aimg_io/base_exporter.gd"
|
||||||
|
}, {
|
||||||
|
"base": "Resource",
|
||||||
|
"class": "AImgIOCRC32",
|
||||||
|
"language": "GDScript",
|
||||||
|
"path": "res://addons/aimg_io/crc32.gd"
|
||||||
|
}, {
|
||||||
|
"base": "Reference",
|
||||||
|
"class": "AImgIOFrame",
|
||||||
|
"language": "GDScript",
|
||||||
|
"path": "res://addons/aimg_io/frame.gd"
|
||||||
}, {
|
}, {
|
||||||
"base": "Reference",
|
"base": "Reference",
|
||||||
"class": "AnimationTag",
|
"class": "AnimationTag",
|
||||||
|
@ -20,11 +50,6 @@ _global_script_classes=[ {
|
||||||
"path": "res://src/Classes/AnimationTag.gd"
|
"path": "res://src/Classes/AnimationTag.gd"
|
||||||
}, {
|
}, {
|
||||||
"base": "Reference",
|
"base": "Reference",
|
||||||
"class": "BaseAnimationExporter",
|
|
||||||
"language": "GDScript",
|
|
||||||
"path": "res://src/Classes/AnimationExporters/BaseAnimationExporter.gd"
|
|
||||||
}, {
|
|
||||||
"base": "Reference",
|
|
||||||
"class": "BaseCel",
|
"class": "BaseCel",
|
||||||
"language": "GDScript",
|
"language": "GDScript",
|
||||||
"path": "res://src/Classes/BaseCel.gd"
|
"path": "res://src/Classes/BaseCel.gd"
|
||||||
|
@ -64,7 +89,7 @@ _global_script_classes=[ {
|
||||||
"language": "GDScript",
|
"language": "GDScript",
|
||||||
"path": "res://src/Classes/Frame.gd"
|
"path": "res://src/Classes/Frame.gd"
|
||||||
}, {
|
}, {
|
||||||
"base": "BaseAnimationExporter",
|
"base": "AImgIOBaseExporter",
|
||||||
"class": "GIFAnimationExporter",
|
"class": "GIFAnimationExporter",
|
||||||
"language": "GDScript",
|
"language": "GDScript",
|
||||||
"path": "res://src/Classes/AnimationExporters/GIFAnimationExporter.gd"
|
"path": "res://src/Classes/AnimationExporters/GIFAnimationExporter.gd"
|
||||||
|
@ -190,9 +215,14 @@ _global_script_classes=[ {
|
||||||
"path": "res://src/UI/Nodes/ValueSlider.gd"
|
"path": "res://src/UI/Nodes/ValueSlider.gd"
|
||||||
} ]
|
} ]
|
||||||
_global_script_class_icons={
|
_global_script_class_icons={
|
||||||
"APNGAnimationExporter": "",
|
"AImgIOAPNGExporter": "",
|
||||||
|
"AImgIOAPNGImportPlugin": "",
|
||||||
|
"AImgIOAPNGImporter": "",
|
||||||
|
"AImgIOAPNGStream": "",
|
||||||
|
"AImgIOBaseExporter": "",
|
||||||
|
"AImgIOCRC32": "",
|
||||||
|
"AImgIOFrame": "",
|
||||||
"AnimationTag": "",
|
"AnimationTag": "",
|
||||||
"BaseAnimationExporter": "",
|
|
||||||
"BaseCel": "",
|
"BaseCel": "",
|
||||||
"BaseLayer": "",
|
"BaseLayer": "",
|
||||||
"BaseTool": "",
|
"BaseTool": "",
|
||||||
|
@ -280,7 +310,7 @@ window/per_pixel_transparency/enabled.Android=false
|
||||||
|
|
||||||
[editor_plugins]
|
[editor_plugins]
|
||||||
|
|
||||||
enabled=PoolStringArray( "res://addons/dockable_container/plugin.cfg", "res://addons/keychain/plugin.cfg" )
|
enabled=PoolStringArray( "res://addons/aimg_io/plugin.cfg", "res://addons/dockable_container/plugin.cfg", "res://addons/keychain/plugin.cfg" )
|
||||||
|
|
||||||
[importer_defaults]
|
[importer_defaults]
|
||||||
|
|
||||||
|
|
|
@ -192,9 +192,9 @@ func export_processed_images(
|
||||||
scale_processed_images()
|
scale_processed_images()
|
||||||
|
|
||||||
if is_single_file_format(project):
|
if is_single_file_format(project):
|
||||||
var exporter: BaseAnimationExporter
|
var exporter: AImgIOBaseExporter
|
||||||
if project.file_format == FileFormat.APNG:
|
if project.file_format == FileFormat.APNG:
|
||||||
exporter = APNGAnimationExporter.new()
|
exporter = AImgIOAPNGExporter.new()
|
||||||
else:
|
else:
|
||||||
exporter = GIFAnimationExporter.new()
|
exporter = GIFAnimationExporter.new()
|
||||||
var details := {
|
var details := {
|
||||||
|
@ -244,7 +244,7 @@ func export_processed_images(
|
||||||
|
|
||||||
func export_animated(args: Dictionary) -> void:
|
func export_animated(args: Dictionary) -> void:
|
||||||
var project: Project = args["project"]
|
var project: Project = args["project"]
|
||||||
var exporter: BaseAnimationExporter = args["exporter"]
|
var exporter: AImgIOBaseExporter = args["exporter"]
|
||||||
# This is an ExportDialog (which refers back here).
|
# This is an ExportDialog (which refers back here).
|
||||||
var export_dialog: ConfirmationDialog = args["export_dialog"]
|
var export_dialog: ConfirmationDialog = args["export_dialog"]
|
||||||
|
|
||||||
|
@ -255,9 +255,17 @@ func export_animated(args: Dictionary) -> void:
|
||||||
export_dialog.set_export_progress_bar(export_progress)
|
export_dialog.set_export_progress_bar(export_progress)
|
||||||
export_dialog.toggle_export_progress_popup(true)
|
export_dialog.toggle_export_progress_popup(true)
|
||||||
|
|
||||||
# Export and save gif
|
# Transform into AImgIO form
|
||||||
|
var frames := []
|
||||||
|
for i in range(len(processed_images)):
|
||||||
|
var frame: AImgIOFrame = AImgIOFrame.new()
|
||||||
|
frame.content = processed_images[i]
|
||||||
|
frame.duration = durations[i]
|
||||||
|
frames.push_back(frame)
|
||||||
|
|
||||||
|
# Export and save GIF/APNG
|
||||||
var file_data := exporter.export_animation(
|
var file_data := exporter.export_animation(
|
||||||
processed_images, durations, project.fps, self, "increase_export_progress", [export_dialog]
|
frames, project.fps, self, "increase_export_progress", [export_dialog]
|
||||||
)
|
)
|
||||||
|
|
||||||
if OS.get_name() == "HTML5":
|
if OS.get_name() == "HTML5":
|
||||||
|
|
|
@ -103,6 +103,15 @@ func load_image(load_directly := true):
|
||||||
var image_info: Dictionary = {}
|
var image_info: Dictionary = {}
|
||||||
match image_type:
|
match image_type:
|
||||||
"image/png":
|
"image/png":
|
||||||
|
if load_directly:
|
||||||
|
# In this case we can afford to try APNG,
|
||||||
|
# because we know we're sending it through OpenSave handling.
|
||||||
|
# Otherwise we could end up passing something incompatible.
|
||||||
|
var res := AImgIOAPNGImporter.load_from_buffer(image_data)
|
||||||
|
if res[0] == null:
|
||||||
|
# Success, pass to OpenSave.
|
||||||
|
OpenSave.handle_loading_aimg(image_name, res[1])
|
||||||
|
return
|
||||||
image_error = image.load_png_from_buffer(image_data)
|
image_error = image.load_png_from_buffer(image_data)
|
||||||
"image/jpeg":
|
"image/jpeg":
|
||||||
image_error = image.load_jpg_from_buffer(image_data)
|
image_error = image.load_jpg_from_buffer(image_data)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# gdlint: ignore=max-public-methods
|
||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
var current_save_paths := [] # Array of strings
|
var current_save_paths := [] # Array of strings
|
||||||
|
@ -49,6 +50,15 @@ func handle_loading_file(file: String) -> void:
|
||||||
Global.control.find_node("ShaderEffect").change_shader(shader, file_name)
|
Global.control.find_node("ShaderEffect").change_shader(shader, file_name)
|
||||||
|
|
||||||
else: # Image files
|
else: # Image files
|
||||||
|
# Attempt to load as APNG.
|
||||||
|
# Note that the APNG importer will *only* succeed for *animated* PNGs.
|
||||||
|
# This is intentional as still images should still act normally.
|
||||||
|
var apng_res := AImgIOAPNGImporter.load_from_file(file)
|
||||||
|
if apng_res[0] == null:
|
||||||
|
# No error - this is an APNG!
|
||||||
|
handle_loading_aimg(file, apng_res[1])
|
||||||
|
return
|
||||||
|
# Attempt to load as a regular image.
|
||||||
var image := Image.new()
|
var image := Image.new()
|
||||||
var err := image.load(file)
|
var err := image.load(file)
|
||||||
if err != OK: # An error occured
|
if err != OK: # An error occured
|
||||||
|
@ -72,6 +82,37 @@ func handle_loading_image(file: String, image: Image) -> void:
|
||||||
Global.dialog_open(true)
|
Global.dialog_open(true)
|
||||||
|
|
||||||
|
|
||||||
|
# For loading the output of AImgIO as a project
|
||||||
|
func handle_loading_aimg(path: String, frames: Array) -> void:
|
||||||
|
var project := Project.new([], path.get_file(), frames[0].content.get_size())
|
||||||
|
project.layers.append(PixelLayer.new(project))
|
||||||
|
Global.projects.append(project)
|
||||||
|
|
||||||
|
# Determine FPS as 1, unless all frames agree.
|
||||||
|
project.fps = 1
|
||||||
|
var first_duration = frames[0].duration
|
||||||
|
var frames_agree = true
|
||||||
|
for v in frames:
|
||||||
|
var aimg_frame: AImgIOFrame = v
|
||||||
|
if aimg_frame.duration != first_duration:
|
||||||
|
frames_agree = false
|
||||||
|
break
|
||||||
|
if frames_agree and (first_duration > 0.0):
|
||||||
|
project.fps = 1.0 / first_duration
|
||||||
|
# Convert AImgIO frames to Pixelorama frames
|
||||||
|
for v in frames:
|
||||||
|
var aimg_frame: AImgIOFrame = v
|
||||||
|
var frame := Frame.new()
|
||||||
|
if not frames_agree:
|
||||||
|
frame.duration = aimg_frame.duration * project.fps
|
||||||
|
var content := aimg_frame.content
|
||||||
|
content.convert(Image.FORMAT_RGBA8)
|
||||||
|
frame.cels.append(PixelCel.new(content, 1))
|
||||||
|
project.frames.append(frame)
|
||||||
|
|
||||||
|
set_new_imported_tab(project, path)
|
||||||
|
|
||||||
|
|
||||||
func open_pxo_file(path: String, untitled_backup: bool = false, replace_empty: bool = true) -> void:
|
func open_pxo_file(path: String, untitled_backup: bool = false, replace_empty: bool = true) -> void:
|
||||||
var file := File.new()
|
var file := File.new()
|
||||||
var err := file.open_compressed(path, File.READ, File.COMPRESSION_ZSTD)
|
var err := file.open_compressed(path, File.READ, File.COMPRESSION_ZSTD)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
class_name GIFAnimationExporter
|
class_name GIFAnimationExporter
|
||||||
extends BaseAnimationExporter
|
extends AImgIOBaseExporter
|
||||||
# Acts as the interface between Pixelorama's format-independent interface and gdgifexporter.
|
# Acts as the interface between the AImgIO format-independent interface and gdgifexporter.
|
||||||
|
# Note that if the interface needs changing for new features, do just change it!
|
||||||
|
|
||||||
# Gif exporter
|
# Gif exporter
|
||||||
const GIFExporter = preload("res://addons/gdgifexporter/exporter.gd")
|
const GIFExporter = preload("res://addons/gdgifexporter/exporter.gd")
|
||||||
|
@ -12,15 +13,17 @@ func _init():
|
||||||
|
|
||||||
|
|
||||||
func export_animation(
|
func export_animation(
|
||||||
images: Array,
|
frames: Array,
|
||||||
durations: Array,
|
|
||||||
_fps_hint: float,
|
_fps_hint: float,
|
||||||
progress_report_obj: Object,
|
progress_report_obj: Object,
|
||||||
progress_report_method,
|
progress_report_method,
|
||||||
progress_report_args
|
progress_report_args
|
||||||
) -> PoolByteArray:
|
) -> PoolByteArray:
|
||||||
var exporter = GIFExporter.new(images[0].get_width(), images[0].get_height())
|
var first_frame: AImgIOFrame = frames[0]
|
||||||
for i in range(images.size()):
|
var first_img := first_frame.content
|
||||||
exporter.add_frame(images[i], durations[i], MedianCutQuantization)
|
var exporter = GIFExporter.new(first_img.get_width(), first_img.get_height())
|
||||||
|
for v in frames:
|
||||||
|
var frame: AImgIOFrame = v
|
||||||
|
exporter.add_frame(frame.content, frame.duration, MedianCutQuantization)
|
||||||
progress_report_obj.callv(progress_report_method, progress_report_args)
|
progress_report_obj.callv(progress_report_method, progress_report_args)
|
||||||
return exporter.export_file_data()
|
return exporter.export_file_data()
|
||||||
|
|
Loading…
Reference in a new issue