From 43d241a5c2dfaaf46d24c31bf95bb7d382331297 Mon Sep 17 00:00:00 2001 From: Emmanouil Papadeas <35376950+OverloadedOrama@users.noreply.github.com> Date: Wed, 24 Jan 2024 02:00:17 +0200 Subject: [PATCH] Video exporting by calling FFMPEG externally (#980) * Basic mp4 exporting, needs ffmpeg * Add avi, ogv and mkv file exporting * Add webm exporting * Set ffmpeg path in the preferences * Show an error message if the video fails to export * Make sure to delete the temp files even if video exporting fails --- src/Autoload/Export.gd | 125 +++++++++++++++++++++---- src/Autoload/Global.gd | 2 + src/Preferences/PreferencesDialog.gd | 5 + src/Preferences/PreferencesDialog.tscn | 7 ++ src/UI/Dialogs/ExportDialog.gd | 17 +++- 5 files changed, 133 insertions(+), 23 deletions(-) diff --git a/src/Autoload/Export.gd b/src/Autoload/Export.gd index de28b3442..8d48e37b4 100644 --- a/src/Autoload/Export.gd +++ b/src/Autoload/Export.gd @@ -4,10 +4,24 @@ enum ExportTab { IMAGE = 0, SPRITESHEET = 1 } enum Orientation { ROWS = 0, COLUMNS = 1 } enum AnimationDirection { FORWARD = 0, BACKWARDS = 1, PING_PONG = 2 } ## See file_format_string, file_format_description, and ExportDialog.gd -enum FileFormat { PNG, WEBP, JPEG, GIF, APNG } +enum FileFormat { PNG, WEBP, JPEG, GIF, APNG, MP4, AVI, OGV, MKV, WEBM } + +const TEMP_PATH := "user://tmp" ## List of animated formats -var animated_formats := [FileFormat.GIF, FileFormat.APNG] +var animated_formats := [ + FileFormat.GIF, + FileFormat.APNG, + FileFormat.MP4, + FileFormat.AVI, + FileFormat.OGV, + FileFormat.MKV, + FileFormat.WEBM +] + +var ffmpeg_formats := [ + FileFormat.MP4, FileFormat.AVI, FileFormat.OGV, FileFormat.MKV, FileFormat.WEBM +] ## A dictionary of custom exporter generators (received from extensions) var custom_file_formats := {} @@ -262,23 +276,28 @@ func export_processed_images( return result if is_single_file_format(project): - var exporter: AImgIOBaseExporter - if project.file_format == FileFormat.APNG: - exporter = AImgIOAPNGExporter.new() + if is_using_ffmpeg(project.file_format): + var video_exported := export_video(export_paths) + if not video_exported: + return false else: - exporter = GIFAnimationExporter.new() - var details := { - "exporter": exporter, - "export_dialog": export_dialog, - "export_paths": export_paths, - "project": project - } - if not _multithreading_enabled(): - export_animated(details) - else: - if gif_export_thread.is_started(): - gif_export_thread.wait_to_finish() - gif_export_thread.start(export_animated.bind(details)) + var exporter: AImgIOBaseExporter + if project.file_format == FileFormat.APNG: + exporter = AImgIOAPNGExporter.new() + else: + exporter = GIFAnimationExporter.new() + var details := { + "exporter": exporter, + "export_dialog": export_dialog, + "export_paths": export_paths, + "project": project + } + if not _multithreading_enabled(): + export_animated(details) + else: + if gif_export_thread.is_started(): + gif_export_thread.wait_to_finish() + gif_export_thread.start(export_animated.bind(details)) else: var succeeded := true for i in range(processed_images.size()): @@ -334,6 +353,39 @@ func export_processed_images( return true +## Uses FFMPEG to export a video +func export_video(export_paths: PackedStringArray) -> bool: + DirAccess.make_dir_absolute(TEMP_PATH) + var temp_path_real := ProjectSettings.globalize_path(TEMP_PATH) + var input_file_path := temp_path_real.path_join("input.txt") + var input_file := FileAccess.open(input_file_path, FileAccess.WRITE) + for i in range(processed_images.size()): + var temp_file_name := str(i + 1).pad_zeros(number_of_digits) + ".png" + var temp_file_path := temp_path_real.path_join(temp_file_name) + processed_images[i].save_png(temp_file_path) + input_file.store_line("file '" + temp_file_name + "'") + input_file.store_line("duration %s" % durations[i]) + input_file.close() + var ffmpeg_execute: PackedStringArray = [ + "-y", "-f", "concat", "-i", input_file_path, export_paths[0] + ] + var output := [] + var success := OS.execute(Global.ffmpeg_path, ffmpeg_execute, output, true) + print(output) + var temp_dir := DirAccess.open(TEMP_PATH) + for file in temp_dir.get_files(): + temp_dir.remove(file) + DirAccess.remove_absolute(TEMP_PATH) + if success < 0 or success > 1: + var fail_text := """Video failed to export. Make sure you have FFMPEG installed + and have set the correct path in the preferences.""" + Global.error_dialog.set_text(tr(fail_text)) + Global.error_dialog.popup_centered() + Global.dialog_open(true) + return false + return true + + func export_animated(args: Dictionary) -> void: var project: Project = args["project"] var exporter: AImgIOBaseExporter = args["exporter"] @@ -397,6 +449,16 @@ func file_format_string(format_enum: int) -> String: return ".gif" FileFormat.APNG: return ".apng" + FileFormat.MP4: + return ".mp4" + FileFormat.AVI: + return ".avi" + FileFormat.OGV: + return ".ogv" + FileFormat.MKV: + return ".mkv" + FileFormat.WEBM: + return ".webm" _: # If a file format description is not found, try generating one if custom_exporter_generators.has(format_enum): @@ -418,6 +480,16 @@ func file_format_description(format_enum: int) -> String: return "GIF Image" FileFormat.APNG: return "APNG Image" + FileFormat.MP4: + return "MPEG-4 Video" + FileFormat.AVI: + return "AVI Video" + FileFormat.OGV: + return "OGV Video" + FileFormat.MKV: + return "Matroska Video" + FileFormat.WEBM: + return "WebM Video" _: # If a file format description is not found, try generating one for key in custom_file_formats.keys(): @@ -426,12 +498,25 @@ func file_format_description(format_enum: int) -> String: return "" -## True when exporting to .gif and .apng (and potentially video formats in the future) -## False when exporting to .png, and other non-animated formats in the future +## True when exporting to .gif, .apng and video +## False when exporting to .png, .jpg and static .webp func is_single_file_format(project := Global.current_project) -> bool: return animated_formats.has(project.file_format) +func is_using_ffmpeg(format: FileFormat) -> bool: + return ffmpeg_formats.has(format) + + +func is_ffmpeg_installed() -> bool: + if Global.ffmpeg_path.is_empty(): + return false + var ffmpeg_executed := OS.execute(Global.ffmpeg_path, []) + if ffmpeg_executed == 0 or ffmpeg_executed == 1: + return true + return false + + func _create_export_path(multifile: bool, project: Project, frame := 0) -> String: var path := project.file_name # Only append frame number when there are multiple files exported diff --git a/src/Autoload/Global.gd b/src/Autoload/Global.gd index 670b75594..daec1bfca 100644 --- a/src/Autoload/Global.gd +++ b/src/Autoload/Global.gd @@ -131,6 +131,8 @@ var show_y_symmetry_axis := false var open_last_project := false ## Found in Preferences. If [code]true[/code], asks for permission to quit on exit. var quit_confirmation := false +## Found in Preferences. Refers to the ffmpeg location path. +var ffmpeg_path := "" ## Found in Preferences. If [code]true[/code], the zoom is smooth. var smooth_zoom := true ## Found in Preferences. If [code]true[/code], the zoom is restricted to integral multiples of 100%. diff --git a/src/Preferences/PreferencesDialog.gd b/src/Preferences/PreferencesDialog.gd index 9720c3dbb..5b7a29d56 100644 --- a/src/Preferences/PreferencesDialog.gd +++ b/src/Preferences/PreferencesDialog.gd @@ -7,6 +7,7 @@ var preferences: Array[Preference] = [ Preference.new( "quit_confirmation", "Startup/StartupContainer/QuitConfirmation", "button_pressed" ), + Preference.new("ffmpeg_path", "Startup/StartupContainer/FFMPEGPath", "text"), Preference.new("shrink", "%ShrinkSlider", "value"), Preference.new("font_size", "Interface/InterfaceOptions/FontSizeSlider", "value"), Preference.new("dim_on_popup", "Interface/InterfaceOptions/DimCheckBox", "button_pressed"), @@ -202,6 +203,10 @@ func _ready() -> void: node.item_selected.connect( _on_Preference_value_changed.bind(pref, restore_default_button) ) + "text": + node.text_changed.connect( + _on_Preference_value_changed.bind(pref, restore_default_button) + ) var global_value = Global.get(pref.prop_name) if Global.config_cache.has_section_key("preferences", pref.prop_name): diff --git a/src/Preferences/PreferencesDialog.tscn b/src/Preferences/PreferencesDialog.tscn index c788f89a2..68d8b5a37 100644 --- a/src/Preferences/PreferencesDialog.tscn +++ b/src/Preferences/PreferencesDialog.tscn @@ -92,6 +92,13 @@ layout_mode = 2 mouse_default_cursor_shape = 2 text = "On" +[node name="FFMPEGPathLabel" type="Label" parent="HSplitContainer/VBoxContainer/ScrollContainer/RightSide/Startup/StartupContainer"] +layout_mode = 2 +text = "FFMPEG path" + +[node name="FFMPEGPath" type="LineEdit" parent="HSplitContainer/VBoxContainer/ScrollContainer/RightSide/Startup/StartupContainer"] +layout_mode = 2 + [node name="Language" type="VBoxContainer" parent="HSplitContainer/VBoxContainer/ScrollContainer/RightSide"] visible = false layout_mode = 2 diff --git a/src/UI/Dialogs/ExportDialog.gd b/src/UI/Dialogs/ExportDialog.gd index 56edb5125..3ec69bdb1 100644 --- a/src/UI/Dialogs/ExportDialog.gd +++ b/src/UI/Dialogs/ExportDialog.gd @@ -13,7 +13,12 @@ var image_exports: Array[Export.FileFormat] = [ Export.FileFormat.WEBP, Export.FileFormat.JPEG, Export.FileFormat.GIF, - Export.FileFormat.APNG + Export.FileFormat.APNG, + Export.FileFormat.MP4, + Export.FileFormat.AVI, + Export.FileFormat.OGV, + Export.FileFormat.MKV, + Export.FileFormat.WEBM, ] var spritesheet_exports: Array[Export.FileFormat] = [ Export.FileFormat.PNG, Export.FileFormat.WEBP, Export.FileFormat.JPEG @@ -188,6 +193,12 @@ func set_file_format_selector() -> void: match Export.current_tab: Export.ExportTab.IMAGE: _set_file_format_selector_suitable_file_formats(image_exports) + if Export.is_ffmpeg_installed(): + for format in Export.ffmpeg_formats: + file_format_options.set_item_disabled(format, false) + else: + for format in Export.ffmpeg_formats: + file_format_options.set_item_disabled(format, true) Export.ExportTab.SPRITESHEET: _set_file_format_selector_suitable_file_formats(spritesheet_exports) @@ -246,9 +257,9 @@ func update_dimensions_label() -> void: func open_path_validation_alert_popup(path_or_name: int = -1) -> void: # 0 is invalid path, 1 is invalid name - var error_text := "DirAccess path and file name are not valid!" + var error_text := "Directory path and file name are not valid!" if path_or_name == 0: - error_text = "DirAccess path is not valid!" + error_text = "Directory path is not valid!" elif path_or_name == 1: error_text = "File name is not valid!"