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

APNG Exporter (#772)

* APNG: Initial refactorings of animation exporter internals

* APNG: Make ExportDialog actually able to handle multiple file formats

* APNG: Bugfix to FPS hint and such

* APNG: Refactoring: Fix file format propagation

* APNG: Make an "APNG exporter" which creates an empty PNG container

This was the testbed of the previous integration commits.

* APNG: The actual exporter!

* APNG: Remove random src/Main.tscn changes

* APNG: Format/lint

* APNG: Format & Lint, part II
This commit is contained in:
20kdc 2022-10-30 22:24:24 +00:00 committed by GitHub
parent ac3d2baf87
commit 82acf3f8b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 338 additions and 84 deletions

View file

@ -9,12 +9,22 @@
config_version=4
_global_script_classes=[ {
"base": "BaseAnimationExporter",
"class": "APNGAnimationExporter",
"language": "GDScript",
"path": "res://src/Classes/AnimationExporters/APNGAnimationExporter.gd"
}, {
"base": "Reference",
"class": "AnimationTag",
"language": "GDScript",
"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"
@ -49,6 +59,11 @@ _global_script_classes=[ {
"language": "GDScript",
"path": "res://src/Classes/Frame.gd"
}, {
"base": "BaseAnimationExporter",
"class": "GIFAnimationExporter",
"language": "GDScript",
"path": "res://src/Classes/AnimationExporters/GIFAnimationExporter.gd"
}, {
"base": "Control",
"class": "GradientEditNode",
"language": "GDScript",
@ -160,7 +175,9 @@ _global_script_classes=[ {
"path": "res://src/UI/Nodes/ValueSlider.gd"
} ]
_global_script_class_icons={
"APNGAnimationExporter": "",
"AnimationTag": "",
"BaseAnimationExporter": "",
"BaseCel": "",
"BaseLayer": "",
"BaseTool": "",
@ -168,6 +185,7 @@ _global_script_class_icons={
"Canvas": "",
"Drawer": "",
"Frame": "",
"GIFAnimationExporter": "",
"GradientEditNode": "",
"GroupCel": "",
"GroupLayer": "",

View file

@ -4,11 +4,8 @@ enum ExportTab { FRAME = 0, SPRITESHEET = 1, ANIMATION = 2 }
enum Orientation { ROWS = 0, COLUMNS = 1 }
enum AnimationType { MULTIPLE_FILES = 0, ANIMATED = 1 }
enum AnimationDirection { FORWARD = 0, BACKWARDS = 1, PING_PONG = 2 }
enum FileFormat { PNG = 0, GIF = 1 }
# Gif exporter
const GIFExporter = preload("res://addons/gdgifexporter/exporter.gd")
const MedianCutQuantization = preload("res://addons/gdgifexporter/quantization/median_cut.gd")
# See file_format_string, file_format_description, and ExportDialog.gd
enum FileFormat { PNG = 0, GIF = 1, APNG = 2 }
var current_tab: int = ExportTab.FRAME
# Frame options
@ -199,14 +196,20 @@ func export_processed_images(ignore_overwrites: bool, export_dialog: AcceptDialo
scale_processed_images()
if current_tab == ExportTab.ANIMATION and animation_type == AnimationType.ANIMATED:
var exporter: BaseAnimationExporter
if file_format == FileFormat.APNG:
exporter = APNGAnimationExporter.new()
else:
exporter = GIFAnimationExporter.new()
var details = {
"exporter": exporter, "export_dialog": export_dialog, "export_paths": export_paths
}
if OS.get_name() == "HTML5":
export_gif({"export_dialog": export_dialog, "export_paths": export_paths})
export_animated(details)
else:
if gif_export_thread.is_active():
gif_export_thread.wait_to_finish()
gif_export_thread.start(
self, "export_gif", {"export_dialog": export_dialog, "export_paths": export_paths}
)
gif_export_thread.start(self, "export_animated", details)
else:
for i in range(processed_images.size()):
if OS.get_name() == "HTML5":
@ -240,71 +243,63 @@ func export_processed_images(ignore_overwrites: bool, export_dialog: AcceptDialo
return true
func export_gif(args: Dictionary) -> void:
# Export progress popup
# One fraction per each frame, one fraction for write to disk
export_progress_fraction = 100 / processed_images.size()
export_progress = 0.0
args["export_dialog"].set_export_progress_bar(export_progress)
args["export_dialog"].toggle_export_progress_popup(true)
# Export and save gif
var exporter = GIFExporter.new(
processed_images[0].get_width(), processed_images[0].get_height()
)
func export_animated(args: Dictionary) -> void:
var exporter: BaseAnimationExporter = args["exporter"]
# This is an ExportDialog (which refers back here).
var export_dialog = args["export_dialog"]
# Array of Image
var sequence = []
# Array of float
var durations = []
match direction:
AnimationDirection.FORWARD:
for i in range(processed_images.size()):
write_frame_to_gif(
processed_images[i],
Global.current_project.frames[i].duration * (1 / Global.current_project.fps),
exporter,
args["export_dialog"]
)
sequence.push_back(processed_images[i])
durations.push_back(Global.current_project.frames[i].duration)
AnimationDirection.BACKWARDS:
for i in range(processed_images.size() - 1, -1, -1):
write_frame_to_gif(
processed_images[i],
Global.current_project.frames[i].duration * (1 / Global.current_project.fps),
exporter,
args["export_dialog"]
)
sequence.push_back(processed_images[i])
durations.push_back(Global.current_project.frames[i].duration)
AnimationDirection.PING_PONG:
export_progress_fraction = 100 / (processed_images.size() * 2)
for i in range(0, processed_images.size()):
write_frame_to_gif(
processed_images[i],
Global.current_project.frames[i].duration * (1 / Global.current_project.fps),
exporter,
args["export_dialog"]
)
sequence.push_back(processed_images[i])
durations.push_back(Global.current_project.frames[i].duration)
for i in range(processed_images.size() - 2, 0, -1):
write_frame_to_gif(
processed_images[i],
Global.current_project.frames[i].duration * (1 / Global.current_project.fps),
exporter,
args["export_dialog"]
)
sequence.push_back(processed_images[i])
durations.push_back(Global.current_project.frames[i].duration)
# Stuff we need to deal with across all images
for i in range(processed_images.size()):
durations[i] *= 1 / Global.current_project.fps
# Export progress popup
# One fraction per each frame, one fraction for write to disk
export_progress_fraction = 100.0 / len(sequence)
export_progress = 0.0
export_dialog.set_export_progress_bar(export_progress)
export_dialog.toggle_export_progress_popup(true)
# Export and save gif
var file_data = exporter.export_animation(
sequence,
durations,
Global.current_project.fps,
self,
"increase_export_progress",
[export_dialog]
)
if OS.get_name() == "HTML5":
JavaScript.download_buffer(
exporter.export_file_data(), args["export_paths"][0], "image/gif"
)
JavaScript.download_buffer(file_data, args["export_paths"][0], exporter.mime_type)
else:
var file: File = File.new()
file.open(args["export_paths"][0], File.WRITE)
file.store_buffer(exporter.export_file_data())
file.store_buffer(file_data)
file.close()
args["export_dialog"].toggle_export_progress_popup(false)
export_dialog.toggle_export_progress_popup(false)
Global.notification_label("File(s) exported")
func write_frame_to_gif(image: Image, wait_time: float, exporter: Reference, dialog: Node) -> void:
exporter.add_frame(image, wait_time, MedianCutQuantization)
increase_export_progress(dialog)
func increase_export_progress(export_dialog: Node) -> void:
export_progress += export_progress_fraction
export_dialog.set_export_progress_bar(export_progress)
@ -323,10 +318,24 @@ func scale_processed_images() -> void:
func file_format_string(format_enum: int) -> String:
match format_enum:
0: # PNG
FileFormat.PNG:
return ".png"
1: # GIF
FileFormat.GIF:
return ".gif"
FileFormat.APNG:
return ".apng"
_:
return ""
func file_format_description(format_enum: int) -> String:
match format_enum:
FileFormat.PNG:
return "PNG Image"
FileFormat.GIF:
return "GIF Image"
FileFormat.APNG:
return "APNG Image"
_:
return ""

View file

@ -0,0 +1,166 @@
class_name APNGAnimationExporter
extends BaseAnimationExporter
# 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,
fps_hint: float,
progress_report_obj: Object,
progress_report_method,
progress_report_args
) -> PoolByteArray:
var result = open_chunk()
# Magic number
result.put_32(0x89504E47)
result.put_32(0x0D0A1A0A)
# From here on out, all data is written in "chunks".
# IHDR
var image: Image = images[0]
var chunk = open_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)
# acTL
chunk = open_chunk()
chunk.put_32(len(images))
chunk.put_32(0)
write_chunk(result, "acTL", chunk.data_array)
# For each frame... (note: first frame uses IDAT)
var sequence = 0
for i in range(len(images)):
image = images[i]
# fcTL
chunk = open_chunk()
chunk.put_32(sequence)
sequence += 1
# image w/h
chunk.put_32(image.get_width())
chunk.put_32(image.get_height())
# offset x/y
chunk.put_32(0)
chunk.put_32(0)
write_delay(chunk, durations[i], fps_hint)
# dispose / blend
chunk.put_8(0)
chunk.put_8(0)
write_chunk(result, "fcTL", chunk.data_array)
# IDAT/fdAT
chunk = open_chunk()
if i != 0:
chunk.put_32(sequence)
sequence += 1
# setup chunk interior...
var ichk = open_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)
else:
write_chunk(result, "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
func write_delay(sp: StreamPeer, duration: float, fps_hint: float):
# Obvious bounds checking
duration = max(duration, 0)
fps_hint = min(32767, max(fps_hint, 1))
# The assumption behind this is that in most cases durations match the FPS hint.
# And in most cases the FPS hint is integer.
# 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
# If the FPS hint brings us out of range before we start, try some obvious integers
var fallback = 10000
while num > 32767:
num = max(duration, 0) * den
den = fallback
if fallback == 1:
break
fallback /= 10
# If the fallback plan failed, give up and set the duration to 1 second.
if num > 32767:
sp.put_16(1)
sp.put_16(1)
return
# Raise to highest safe precision
# This is what handles the more complicated cases (usually).
while num < 16384 and den < 16384:
num *= 2
den *= 2
# Write out
sp.put_16(int(round(num)))
sp.put_16(int(round(den)))
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.")
return
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)
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,22 @@
class_name BaseAnimationExporter
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.
# 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.
# 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,
_progress_report_args
) -> PoolByteArray:
return PoolByteArray()

View file

@ -0,0 +1,26 @@
class_name GIFAnimationExporter
extends BaseAnimationExporter
# Acts as the interface between Pixelorama's format-independent interface and gdgifexporter.
# Gif exporter
const GIFExporter = preload("res://addons/gdgifexporter/exporter.gd")
const MedianCutQuantization = preload("res://addons/gdgifexporter/quantization/median_cut.gd")
func _init():
mime_type = "image/gif"
func export_animation(
images: Array,
durations: 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)
progress_report_obj.callv(progress_report_method, progress_report_args)
return exporter.export_file_data()

View file

@ -61,10 +61,9 @@ func show_tab() -> void:
spritesheet_options.hide()
animation_options.hide()
set_file_format_selector()
match Export.current_tab:
Export.ExportTab.FRAME:
Export.file_format = Export.FileFormat.PNG
file_file_format.selected = Export.FileFormat.PNG
frame_timer.stop()
if not Export.was_exported:
Export.frame_number = Global.current_project.current_frame + 1
@ -76,12 +75,10 @@ func show_tab() -> void:
frame_options.show()
Export.ExportTab.SPRITESHEET:
create_frame_tag_list()
Export.file_format = Export.FileFormat.PNG
if not Export.was_exported:
Export.orientation = Export.Orientation.ROWS
Export.lines_count = int(ceil(sqrt(Export.number_of_frames)))
Export.process_spritesheet()
file_file_format.selected = Export.FileFormat.PNG
spritesheet_frames.select(Export.frame_current_tag)
frame_timer.stop()
spritesheet_orientation.selected = Export.orientation
@ -90,7 +87,6 @@ func show_tab() -> void:
spritesheet_lines_count_label.text = "Columns:"
spritesheet_options.show()
Export.ExportTab.ANIMATION:
set_file_format_selector()
Export.process_animation()
animation_options_animation_type.selected = Export.animation_type
animation_options_direction.selected = Export.direction
@ -184,19 +180,40 @@ func remove_previews() -> void:
func set_file_format_selector() -> void:
multiple_animations_directories.visible = false
match Export.animation_type:
Export.AnimationType.MULTIPLE_FILES:
Export.file_format = Export.FileFormat.PNG
file_file_format.selected = Export.FileFormat.PNG
frame_timer.stop()
animation_options_animation_options.hide()
multiple_animations_directories.pressed = Export.new_dir_for_each_frame_tag
multiple_animations_directories.visible = true
Export.AnimationType.ANIMATED:
Export.file_format = Export.FileFormat.GIF
file_file_format.selected = Export.FileFormat.GIF
animation_options_animation_options.show()
match Export.current_tab:
Export.ExportTab.FRAME:
_set_file_format_selector_suitable_file_formats([Export.FileFormat.PNG])
Export.ExportTab.SPRITESHEET:
_set_file_format_selector_suitable_file_formats([Export.FileFormat.PNG])
Export.ExportTab.ANIMATION:
multiple_animations_directories.visible = false
match Export.animation_type:
Export.AnimationType.MULTIPLE_FILES:
_set_file_format_selector_suitable_file_formats([Export.FileFormat.PNG])
frame_timer.stop()
animation_options_animation_options.hide()
multiple_animations_directories.pressed = Export.new_dir_for_each_frame_tag
multiple_animations_directories.visible = true
Export.AnimationType.ANIMATED:
_set_file_format_selector_suitable_file_formats(
[Export.FileFormat.GIF, Export.FileFormat.APNG]
)
animation_options_animation_options.show()
# Updates the suitable list of file formats. First is preferred.
# Note that if the current format is in the list, it stays for consistency.
func _set_file_format_selector_suitable_file_formats(formats: Array):
file_file_format.clear()
var needs_update = true
for i in formats:
if Export.file_format == i:
needs_update = false
var label = Export.file_format_string(i) + "; " + Export.file_format_description(i)
file_file_format.add_item(label, i)
if needs_update:
Export.file_format = formats[0]
file_file_format.selected = file_file_format.get_item_index(Export.file_format)
func create_frame_tag_list() -> void:
@ -364,7 +381,8 @@ func _on_FileDialog_dir_selected(dir: String) -> void:
Export.directory_path = dir
func _on_FileFormat_item_selected(id: int) -> void:
func _on_FileFormat_item_selected(idx: int) -> void:
var id = file_file_format.get_item_id(idx)
Global.current_project.file_format = id
Export.file_format = id

View file

@ -342,10 +342,6 @@ margin_right = 516.0
margin_bottom = 24.0
rect_min_size = Vector2( 130, 0 )
mouse_default_cursor_shape = 8
disabled = true
text = ".png; PNG Image"
items = [ ".png; PNG Image", null, false, 0, null, ".gif; GIF Image", null, false, 1, null ]
selected = 0
[node name="Popups" type="Node" parent="."]
@ -361,8 +357,7 @@ window_title = "Open a Directory"
resizable = true
mode = 2
access = 2
current_dir = "/home/variable/Documents/Godot/Godot projects/Pixelorama-play_improvements"
current_path = "/home/variable/Documents/Godot/Godot projects/Pixelorama-play_improvements/"
show_hidden_files = true
[node name="PathValidationAlert" type="AcceptDialog" parent="Popups"]
margin_left = 8.0