1
0
Fork 0
mirror of https://github.com/Orama-Interactive/Pixelorama.git synced 2025-01-18 17:19:50 +00:00
Pixelorama/addons/aimg_io/apng_importer.gd

215 lines
7.3 KiB
GDScript3
Raw Normal View History

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)