diff --git a/project.godot b/project.godot index 58826830a..ab20b2bdd 100644 --- a/project.godot +++ b/project.godot @@ -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": "", diff --git a/src/Autoload/Export.gd b/src/Autoload/Export.gd index 92ada952e..44f9c3448 100644 --- a/src/Autoload/Export.gd +++ b/src/Autoload/Export.gd @@ -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 "" diff --git a/src/Classes/AnimationExporters/APNGAnimationExporter.gd b/src/Classes/AnimationExporters/APNGAnimationExporter.gd new file mode 100644 index 000000000..2c5244230 --- /dev/null +++ b/src/Classes/AnimationExporters/APNGAnimationExporter.gd @@ -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) diff --git a/src/Classes/AnimationExporters/BaseAnimationExporter.gd b/src/Classes/AnimationExporters/BaseAnimationExporter.gd new file mode 100644 index 000000000..86a9e2b7a --- /dev/null +++ b/src/Classes/AnimationExporters/BaseAnimationExporter.gd @@ -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() diff --git a/src/Classes/AnimationExporters/GIFAnimationExporter.gd b/src/Classes/AnimationExporters/GIFAnimationExporter.gd new file mode 100644 index 000000000..7739dbb04 --- /dev/null +++ b/src/Classes/AnimationExporters/GIFAnimationExporter.gd @@ -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() diff --git a/src/UI/Dialogs/ExportDialog.gd b/src/UI/Dialogs/ExportDialog.gd index 51630fcb9..9b4ddc64e 100644 --- a/src/UI/Dialogs/ExportDialog.gd +++ b/src/UI/Dialogs/ExportDialog.gd @@ -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 diff --git a/src/UI/Dialogs/ExportDialog.tscn b/src/UI/Dialogs/ExportDialog.tscn index 286971ad9..973278fcb 100644 --- a/src/UI/Dialogs/ExportDialog.tscn +++ b/src/UI/Dialogs/ExportDialog.tscn @@ -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