1
0
Fork 0
mirror of https://github.com/Orama-Interactive/Pixelorama.git synced 2025-01-18 09:09:47 +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:
20kdc 2022-12-23 18:08:46 +00:00 committed by GitHub
parent 44890243b4
commit 4658b1cfb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 674 additions and 105 deletions

View file

@ -1,5 +1,11 @@
# 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
- Upstream: https://github.com/Orama-Interactive/Keychain
@ -20,3 +26,4 @@ Files extracted from source:
- Upstream: https://github.com/gilzoide/godot-dockable-container
- 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)

View 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>

View 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

View file

@ -1,69 +1,43 @@
class_name APNGAnimationExporter
extends BaseAnimationExporter
class_name AImgIOAPNGExporter
extends AImgIOBaseExporter
# APNG exporter. To be clear, this is effectively magic.
var crc32_table := []
func _init():
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(
images: Array,
durations: Array,
frames: Array,
fps_hint: float,
progress_report_obj: Object,
progress_report_method,
progress_report_args
) -> PoolByteArray:
var result = open_chunk()
var frame_count := len(frames)
var result := AImgIOAPNGStream.new()
# Magic number
result.put_32(0x89504E47)
result.put_32(0x0D0A1A0A)
result.write_magic()
# From here on out, all data is written in "chunks".
# IHDR
var image: Image = images[0]
var chunk = open_chunk()
var image: Image = frames[0].content
var chunk := result.start_chunk()
chunk.put_32(image.get_width())
chunk.put_32(image.get_height())
chunk.put_32(0x08060000)
chunk.put_8(0)
write_chunk(result, "IHDR", chunk.data_array)
result.write_chunk("IHDR", chunk.data_array)
# acTL
chunk = open_chunk()
chunk.put_32(len(images))
chunk = result.start_chunk()
chunk.put_32(frame_count)
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)
var sequence = 0
for i in range(len(images)):
image = images[i]
var sequence := 0
for i in range(frame_count):
image = frames[i].content
# fcTL
chunk = open_chunk()
chunk = result.start_chunk()
chunk.put_32(sequence)
sequence += 1
# image w/h
@ -72,30 +46,36 @@ func export_animation(
# offset x/y
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
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
chunk = open_chunk()
chunk = result.start_chunk()
if i != 0:
chunk.put_32(sequence)
sequence += 1
# setup chunk interior...
var ichk = open_chunk()
var ichk := result.start_chunk()
write_padded_lines(ichk, image)
chunk.put_data(ichk.data_array.compress(File.COMPRESSION_DEFLATE))
# done with chunk interior
if i == 0:
write_chunk(result, "IDAT", chunk.data_array)
result.write_chunk("IDAT", chunk.data_array)
else:
write_chunk(result, "fdAT", chunk.data_array)
result.write_chunk("fdAT", chunk.data_array)
# Done with this frame!
progress_report_obj.callv(progress_report_method, progress_report_args)
# Final chunk.
write_chunk(result, "IEND", PoolByteArray())
return result.data_array
result.write_chunk("IEND", PoolByteArray())
return result.finish()
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.
# Precision is increased so we catch more complex cases.
# But you should always get perfection for integers.
var den = min(32767, max(fps_hint, 1))
var num = max(duration, 0) * den
var den := min(32767, max(fps_hint, 1))
var num := max(duration, 0) * den
# 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:
num = max(duration, 0) * den
den = fallback
@ -134,33 +114,17 @@ func write_delay(sp: StreamPeer, duration: float, fps_hint: float):
func write_padded_lines(sp: StreamPeer, img: Image):
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
var data = img.get_data()
var y = 0
var w = img.get_width()
var h = img.get_height()
var base = 0
var data := img.get_data()
var y := 0
var w := img.get_width()
var h := img.get_height()
var base := 0
while y < h:
var nl = base + (w * 4)
var line = data.subarray(base, nl - 1)
var nl := base + (w * 4)
var line := data.subarray(base, nl - 1)
sp.put_8(0)
sp.put_data(line)
y += 1
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)

View 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)

View 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)

View 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

View file

@ -1,19 +1,18 @@
class_name BaseAnimationExporter
class_name AImgIOBaseExporter
extends Reference
# Represents a method for exporting animations.
# Please do NOT use project globals in this code.
var mime_type: String
# 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.
# 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
# called after each frame is handled.
func export_animation(
_frames: Array,
_durations: Array,
_fps_hint: float,
_progress_report_obj: Object,
_progress_report_method,

53
addons/aimg_io/crc32.gd Normal file
View 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

View 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
View 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

View 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"

View file

@ -9,10 +9,40 @@
config_version=4
_global_script_classes=[ {
"base": "BaseAnimationExporter",
"class": "APNGAnimationExporter",
"base": "AImgIOBaseExporter",
"class": "AImgIOAPNGExporter",
"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",
"class": "AnimationTag",
@ -20,11 +50,6 @@ _global_script_classes=[ {
"path": "res://src/Classes/AnimationTag.gd"
}, {
"base": "Reference",
"class": "BaseAnimationExporter",
"language": "GDScript",
"path": "res://src/Classes/AnimationExporters/BaseAnimationExporter.gd"
}, {
"base": "Reference",
"class": "BaseCel",
"language": "GDScript",
"path": "res://src/Classes/BaseCel.gd"
@ -64,7 +89,7 @@ _global_script_classes=[ {
"language": "GDScript",
"path": "res://src/Classes/Frame.gd"
}, {
"base": "BaseAnimationExporter",
"base": "AImgIOBaseExporter",
"class": "GIFAnimationExporter",
"language": "GDScript",
"path": "res://src/Classes/AnimationExporters/GIFAnimationExporter.gd"
@ -190,9 +215,14 @@ _global_script_classes=[ {
"path": "res://src/UI/Nodes/ValueSlider.gd"
} ]
_global_script_class_icons={
"APNGAnimationExporter": "",
"AImgIOAPNGExporter": "",
"AImgIOAPNGImportPlugin": "",
"AImgIOAPNGImporter": "",
"AImgIOAPNGStream": "",
"AImgIOBaseExporter": "",
"AImgIOCRC32": "",
"AImgIOFrame": "",
"AnimationTag": "",
"BaseAnimationExporter": "",
"BaseCel": "",
"BaseLayer": "",
"BaseTool": "",
@ -280,7 +310,7 @@ window/per_pixel_transparency/enabled.Android=false
[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]

View file

@ -192,9 +192,9 @@ func export_processed_images(
scale_processed_images()
if is_single_file_format(project):
var exporter: BaseAnimationExporter
var exporter: AImgIOBaseExporter
if project.file_format == FileFormat.APNG:
exporter = APNGAnimationExporter.new()
exporter = AImgIOAPNGExporter.new()
else:
exporter = GIFAnimationExporter.new()
var details := {
@ -244,7 +244,7 @@ func export_processed_images(
func export_animated(args: Dictionary) -> void:
var project: Project = args["project"]
var exporter: BaseAnimationExporter = args["exporter"]
var exporter: AImgIOBaseExporter = args["exporter"]
# This is an ExportDialog (which refers back here).
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.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(
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":

View file

@ -103,6 +103,15 @@ func load_image(load_directly := true):
var image_info: Dictionary = {}
match image_type:
"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/jpeg":
image_error = image.load_jpg_from_buffer(image_data)

View file

@ -1,3 +1,4 @@
# gdlint: ignore=max-public-methods
extends Node
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)
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 err := image.load(file)
if err != OK: # An error occured
@ -72,6 +82,37 @@ func handle_loading_image(file: String, image: Image) -> void:
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:
var file := File.new()
var err := file.open_compressed(path, File.READ, File.COMPRESSION_ZSTD)

View file

@ -1,6 +1,7 @@
class_name GIFAnimationExporter
extends BaseAnimationExporter
# Acts as the interface between Pixelorama's format-independent interface and gdgifexporter.
extends AImgIOBaseExporter
# 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
const GIFExporter = preload("res://addons/gdgifexporter/exporter.gd")
@ -12,15 +13,17 @@ func _init():
func export_animation(
images: Array,
durations: Array,
frames: Array,
_fps_hint: float,
progress_report_obj: Object,
progress_report_method,
progress_report_args
) -> PoolByteArray:
var exporter = GIFExporter.new(images[0].get_width(), images[0].get_height())
for i in range(images.size()):
exporter.add_frame(images[i], durations[i], MedianCutQuantization)
var first_frame: AImgIOFrame = frames[0]
var first_img := first_frame.content
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)
return exporter.export_file_data()