From 4658b1cfb61883262f6a2b2691d3f14b0f9bc380 Mon Sep 17 00:00:00 2001 From: 20kdc Date: Fri, 23 Dec 2022 18:08:46 +0000 Subject: [PATCH] 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 --- addons/README.md | 7 + addons/aimg_io/COPYING.txt | 25 ++ addons/aimg_io/apng_crc32.tres | 8 + .../aimg_io/apng_exporter.gd | 118 ++++------ addons/aimg_io/apng_import_plugin.gd | 80 +++++++ addons/aimg_io/apng_importer.gd | 214 ++++++++++++++++++ addons/aimg_io/apng_stream.gd | 95 ++++++++ .../aimg_io/base_exporter.gd | 7 +- addons/aimg_io/crc32.gd | 53 +++++ addons/aimg_io/editor_plugin.gd | 14 ++ addons/aimg_io/frame.gd | 13 ++ addons/aimg_io/plugin.cfg | 6 + project.godot | 54 ++++- src/Autoload/Export.gd | 18 +- src/Autoload/HTML5FileExchange.gd | 9 + src/Autoload/OpenSave.gd | 41 ++++ .../GIFAnimationExporter.gd | 17 +- 17 files changed, 674 insertions(+), 105 deletions(-) create mode 100644 addons/aimg_io/COPYING.txt create mode 100644 addons/aimg_io/apng_crc32.tres rename src/Classes/AnimationExporters/APNGAnimationExporter.gd => addons/aimg_io/apng_exporter.gd (52%) create mode 100644 addons/aimg_io/apng_import_plugin.gd create mode 100644 addons/aimg_io/apng_importer.gd create mode 100644 addons/aimg_io/apng_stream.gd rename src/Classes/AnimationExporters/BaseAnimationExporter.gd => addons/aimg_io/base_exporter.gd (73%) create mode 100644 addons/aimg_io/crc32.gd create mode 100644 addons/aimg_io/editor_plugin.gd create mode 100644 addons/aimg_io/frame.gd create mode 100644 addons/aimg_io/plugin.cfg diff --git a/addons/README.md b/addons/README.md index 9b9cfe6a7..bcd004672 100644 --- a/addons/README.md +++ b/addons/README.md @@ -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) + diff --git a/addons/aimg_io/COPYING.txt b/addons/aimg_io/COPYING.txt new file mode 100644 index 000000000..a84c39566 --- /dev/null +++ b/addons/aimg_io/COPYING.txt @@ -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 + diff --git a/addons/aimg_io/apng_crc32.tres b/addons/aimg_io/apng_crc32.tres new file mode 100644 index 000000000..b379714a3 --- /dev/null +++ b/addons/aimg_io/apng_crc32.tres @@ -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 diff --git a/src/Classes/AnimationExporters/APNGAnimationExporter.gd b/addons/aimg_io/apng_exporter.gd similarity index 52% rename from src/Classes/AnimationExporters/APNGAnimationExporter.gd rename to addons/aimg_io/apng_exporter.gd index 2c5244230..060ee3603 100644 --- a/src/Classes/AnimationExporters/APNGAnimationExporter.gd +++ b/addons/aimg_io/apng_exporter.gd @@ -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) diff --git a/addons/aimg_io/apng_import_plugin.gd b/addons/aimg_io/apng_import_plugin.gd new file mode 100644 index 000000000..e58974a82 --- /dev/null +++ b/addons/aimg_io/apng_import_plugin.gd @@ -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) diff --git a/addons/aimg_io/apng_importer.gd b/addons/aimg_io/apng_importer.gd new file mode 100644 index 000000000..481603e7d --- /dev/null +++ b/addons/aimg_io/apng_importer.gd @@ -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) diff --git a/addons/aimg_io/apng_stream.gd b/addons/aimg_io/apng_stream.gd new file mode 100644 index 000000000..8035be0fe --- /dev/null +++ b/addons/aimg_io/apng_stream.gd @@ -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 diff --git a/src/Classes/AnimationExporters/BaseAnimationExporter.gd b/addons/aimg_io/base_exporter.gd similarity index 73% rename from src/Classes/AnimationExporters/BaseAnimationExporter.gd rename to addons/aimg_io/base_exporter.gd index 86a9e2b7a..dca521802 100644 --- a/src/Classes/AnimationExporters/BaseAnimationExporter.gd +++ b/addons/aimg_io/base_exporter.gd @@ -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, diff --git a/addons/aimg_io/crc32.gd b/addons/aimg_io/crc32.gd new file mode 100644 index 000000000..5c9a684cb --- /dev/null +++ b/addons/aimg_io/crc32.gd @@ -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 diff --git a/addons/aimg_io/editor_plugin.gd b/addons/aimg_io/editor_plugin.gd new file mode 100644 index 000000000..0892344d0 --- /dev/null +++ b/addons/aimg_io/editor_plugin.gd @@ -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 diff --git a/addons/aimg_io/frame.gd b/addons/aimg_io/frame.gd new file mode 100644 index 000000000..2c7fdbf33 --- /dev/null +++ b/addons/aimg_io/frame.gd @@ -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 diff --git a/addons/aimg_io/plugin.cfg b/addons/aimg_io/plugin.cfg new file mode 100644 index 000000000..cb2c2f0cd --- /dev/null +++ b/addons/aimg_io/plugin.cfg @@ -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" diff --git a/project.godot b/project.godot index 555ef7b69..373f5f50e 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/src/Autoload/Export.gd b/src/Autoload/Export.gd index cfa7d8bf4..886543955 100644 --- a/src/Autoload/Export.gd +++ b/src/Autoload/Export.gd @@ -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": diff --git a/src/Autoload/HTML5FileExchange.gd b/src/Autoload/HTML5FileExchange.gd index 3cbedf364..e19946e24 100644 --- a/src/Autoload/HTML5FileExchange.gd +++ b/src/Autoload/HTML5FileExchange.gd @@ -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) diff --git a/src/Autoload/OpenSave.gd b/src/Autoload/OpenSave.gd index 066f7f8ed..3078ac745 100644 --- a/src/Autoload/OpenSave.gd +++ b/src/Autoload/OpenSave.gd @@ -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) diff --git a/src/Classes/AnimationExporters/GIFAnimationExporter.gd b/src/Classes/AnimationExporters/GIFAnimationExporter.gd index 7739dbb04..d98477fc4 100644 --- a/src/Classes/AnimationExporters/GIFAnimationExporter.gd +++ b/src/Classes/AnimationExporters/GIFAnimationExporter.gd @@ -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()