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:
parent
44890243b4
commit
4658b1cfb6
|
@ -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)
|
||||
|
||||
|
|
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
|
||||
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)
|
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
|
||||
# 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
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
|
||||
|
||||
_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]
|
||||
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue