Skip to content

Commit 43d241a

Browse files
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
1 parent 204eff8 commit 43d241a

File tree

5 files changed

+133
-23
lines changed

5 files changed

+133
-23
lines changed

src/Autoload/Export.gd

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,24 @@ enum ExportTab { IMAGE = 0, SPRITESHEET = 1 }
44
enum Orientation { ROWS = 0, COLUMNS = 1 }
55
enum AnimationDirection { FORWARD = 0, BACKWARDS = 1, PING_PONG = 2 }
66
## See file_format_string, file_format_description, and ExportDialog.gd
7-
enum FileFormat { PNG, WEBP, JPEG, GIF, APNG }
7+
enum FileFormat { PNG, WEBP, JPEG, GIF, APNG, MP4, AVI, OGV, MKV, WEBM }
8+
9+
const TEMP_PATH := "user://tmp"
810

911
## List of animated formats
10-
var animated_formats := [FileFormat.GIF, FileFormat.APNG]
12+
var animated_formats := [
13+
FileFormat.GIF,
14+
FileFormat.APNG,
15+
FileFormat.MP4,
16+
FileFormat.AVI,
17+
FileFormat.OGV,
18+
FileFormat.MKV,
19+
FileFormat.WEBM
20+
]
21+
22+
var ffmpeg_formats := [
23+
FileFormat.MP4, FileFormat.AVI, FileFormat.OGV, FileFormat.MKV, FileFormat.WEBM
24+
]
1125

1226
## A dictionary of custom exporter generators (received from extensions)
1327
var custom_file_formats := {}
@@ -262,23 +276,28 @@ func export_processed_images(
262276
return result
263277

264278
if is_single_file_format(project):
265-
var exporter: AImgIOBaseExporter
266-
if project.file_format == FileFormat.APNG:
267-
exporter = AImgIOAPNGExporter.new()
268-
else:
269-
exporter = GIFAnimationExporter.new()
270-
var details := {
271-
"exporter": exporter,
272-
"export_dialog": export_dialog,
273-
"export_paths": export_paths,
274-
"project": project
275-
}
276-
if not _multithreading_enabled():
277-
export_animated(details)
279+
if is_using_ffmpeg(project.file_format):
280+
var video_exported := export_video(export_paths)
281+
if not video_exported:
282+
return false
278283
else:
279-
if gif_export_thread.is_started():
280-
gif_export_thread.wait_to_finish()
281-
gif_export_thread.start(export_animated.bind(details))
284+
var exporter: AImgIOBaseExporter
285+
if project.file_format == FileFormat.APNG:
286+
exporter = AImgIOAPNGExporter.new()
287+
else:
288+
exporter = GIFAnimationExporter.new()
289+
var details := {
290+
"exporter": exporter,
291+
"export_dialog": export_dialog,
292+
"export_paths": export_paths,
293+
"project": project
294+
}
295+
if not _multithreading_enabled():
296+
export_animated(details)
297+
else:
298+
if gif_export_thread.is_started():
299+
gif_export_thread.wait_to_finish()
300+
gif_export_thread.start(export_animated.bind(details))
282301
else:
283302
var succeeded := true
284303
for i in range(processed_images.size()):
@@ -334,6 +353,39 @@ func export_processed_images(
334353
return true
335354

336355

356+
## Uses FFMPEG to export a video
357+
func export_video(export_paths: PackedStringArray) -> bool:
358+
DirAccess.make_dir_absolute(TEMP_PATH)
359+
var temp_path_real := ProjectSettings.globalize_path(TEMP_PATH)
360+
var input_file_path := temp_path_real.path_join("input.txt")
361+
var input_file := FileAccess.open(input_file_path, FileAccess.WRITE)
362+
for i in range(processed_images.size()):
363+
var temp_file_name := str(i + 1).pad_zeros(number_of_digits) + ".png"
364+
var temp_file_path := temp_path_real.path_join(temp_file_name)
365+
processed_images[i].save_png(temp_file_path)
366+
input_file.store_line("file '" + temp_file_name + "'")
367+
input_file.store_line("duration %s" % durations[i])
368+
input_file.close()
369+
var ffmpeg_execute: PackedStringArray = [
370+
"-y", "-f", "concat", "-i", input_file_path, export_paths[0]
371+
]
372+
var output := []
373+
var success := OS.execute(Global.ffmpeg_path, ffmpeg_execute, output, true)
374+
print(output)
375+
var temp_dir := DirAccess.open(TEMP_PATH)
376+
for file in temp_dir.get_files():
377+
temp_dir.remove(file)
378+
DirAccess.remove_absolute(TEMP_PATH)
379+
if success < 0 or success > 1:
380+
var fail_text := """Video failed to export. Make sure you have FFMPEG installed
381+
and have set the correct path in the preferences."""
382+
Global.error_dialog.set_text(tr(fail_text))
383+
Global.error_dialog.popup_centered()
384+
Global.dialog_open(true)
385+
return false
386+
return true
387+
388+
337389
func export_animated(args: Dictionary) -> void:
338390
var project: Project = args["project"]
339391
var exporter: AImgIOBaseExporter = args["exporter"]
@@ -397,6 +449,16 @@ func file_format_string(format_enum: int) -> String:
397449
return ".gif"
398450
FileFormat.APNG:
399451
return ".apng"
452+
FileFormat.MP4:
453+
return ".mp4"
454+
FileFormat.AVI:
455+
return ".avi"
456+
FileFormat.OGV:
457+
return ".ogv"
458+
FileFormat.MKV:
459+
return ".mkv"
460+
FileFormat.WEBM:
461+
return ".webm"
400462
_:
401463
# If a file format description is not found, try generating one
402464
if custom_exporter_generators.has(format_enum):
@@ -418,6 +480,16 @@ func file_format_description(format_enum: int) -> String:
418480
return "GIF Image"
419481
FileFormat.APNG:
420482
return "APNG Image"
483+
FileFormat.MP4:
484+
return "MPEG-4 Video"
485+
FileFormat.AVI:
486+
return "AVI Video"
487+
FileFormat.OGV:
488+
return "OGV Video"
489+
FileFormat.MKV:
490+
return "Matroska Video"
491+
FileFormat.WEBM:
492+
return "WebM Video"
421493
_:
422494
# If a file format description is not found, try generating one
423495
for key in custom_file_formats.keys():
@@ -426,12 +498,25 @@ func file_format_description(format_enum: int) -> String:
426498
return ""
427499

428500

429-
## True when exporting to .gif and .apng (and potentially video formats in the future)
430-
## False when exporting to .png, and other non-animated formats in the future
501+
## True when exporting to .gif, .apng and video
502+
## False when exporting to .png, .jpg and static .webp
431503
func is_single_file_format(project := Global.current_project) -> bool:
432504
return animated_formats.has(project.file_format)
433505

434506

507+
func is_using_ffmpeg(format: FileFormat) -> bool:
508+
return ffmpeg_formats.has(format)
509+
510+
511+
func is_ffmpeg_installed() -> bool:
512+
if Global.ffmpeg_path.is_empty():
513+
return false
514+
var ffmpeg_executed := OS.execute(Global.ffmpeg_path, [])
515+
if ffmpeg_executed == 0 or ffmpeg_executed == 1:
516+
return true
517+
return false
518+
519+
435520
func _create_export_path(multifile: bool, project: Project, frame := 0) -> String:
436521
var path := project.file_name
437522
# Only append frame number when there are multiple files exported

src/Autoload/Global.gd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ var show_y_symmetry_axis := false
131131
var open_last_project := false
132132
## Found in Preferences. If [code]true[/code], asks for permission to quit on exit.
133133
var quit_confirmation := false
134+
## Found in Preferences. Refers to the ffmpeg location path.
135+
var ffmpeg_path := ""
134136
## Found in Preferences. If [code]true[/code], the zoom is smooth.
135137
var smooth_zoom := true
136138
## Found in Preferences. If [code]true[/code], the zoom is restricted to integral multiples of 100%.

src/Preferences/PreferencesDialog.gd

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ var preferences: Array[Preference] = [
77
Preference.new(
88
"quit_confirmation", "Startup/StartupContainer/QuitConfirmation", "button_pressed"
99
),
10+
Preference.new("ffmpeg_path", "Startup/StartupContainer/FFMPEGPath", "text"),
1011
Preference.new("shrink", "%ShrinkSlider", "value"),
1112
Preference.new("font_size", "Interface/InterfaceOptions/FontSizeSlider", "value"),
1213
Preference.new("dim_on_popup", "Interface/InterfaceOptions/DimCheckBox", "button_pressed"),
@@ -202,6 +203,10 @@ func _ready() -> void:
202203
node.item_selected.connect(
203204
_on_Preference_value_changed.bind(pref, restore_default_button)
204205
)
206+
"text":
207+
node.text_changed.connect(
208+
_on_Preference_value_changed.bind(pref, restore_default_button)
209+
)
205210

206211
var global_value = Global.get(pref.prop_name)
207212
if Global.config_cache.has_section_key("preferences", pref.prop_name):

src/Preferences/PreferencesDialog.tscn

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ layout_mode = 2
9292
mouse_default_cursor_shape = 2
9393
text = "On"
9494

95+
[node name="FFMPEGPathLabel" type="Label" parent="HSplitContainer/VBoxContainer/ScrollContainer/RightSide/Startup/StartupContainer"]
96+
layout_mode = 2
97+
text = "FFMPEG path"
98+
99+
[node name="FFMPEGPath" type="LineEdit" parent="HSplitContainer/VBoxContainer/ScrollContainer/RightSide/Startup/StartupContainer"]
100+
layout_mode = 2
101+
95102
[node name="Language" type="VBoxContainer" parent="HSplitContainer/VBoxContainer/ScrollContainer/RightSide"]
96103
visible = false
97104
layout_mode = 2

src/UI/Dialogs/ExportDialog.gd

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ var image_exports: Array[Export.FileFormat] = [
1313
Export.FileFormat.WEBP,
1414
Export.FileFormat.JPEG,
1515
Export.FileFormat.GIF,
16-
Export.FileFormat.APNG
16+
Export.FileFormat.APNG,
17+
Export.FileFormat.MP4,
18+
Export.FileFormat.AVI,
19+
Export.FileFormat.OGV,
20+
Export.FileFormat.MKV,
21+
Export.FileFormat.WEBM,
1722
]
1823
var spritesheet_exports: Array[Export.FileFormat] = [
1924
Export.FileFormat.PNG, Export.FileFormat.WEBP, Export.FileFormat.JPEG
@@ -188,6 +193,12 @@ func set_file_format_selector() -> void:
188193
match Export.current_tab:
189194
Export.ExportTab.IMAGE:
190195
_set_file_format_selector_suitable_file_formats(image_exports)
196+
if Export.is_ffmpeg_installed():
197+
for format in Export.ffmpeg_formats:
198+
file_format_options.set_item_disabled(format, false)
199+
else:
200+
for format in Export.ffmpeg_formats:
201+
file_format_options.set_item_disabled(format, true)
191202
Export.ExportTab.SPRITESHEET:
192203
_set_file_format_selector_suitable_file_formats(spritesheet_exports)
193204

@@ -246,9 +257,9 @@ func update_dimensions_label() -> void:
246257

247258
func open_path_validation_alert_popup(path_or_name: int = -1) -> void:
248259
# 0 is invalid path, 1 is invalid name
249-
var error_text := "DirAccess path and file name are not valid!"
260+
var error_text := "Directory path and file name are not valid!"
250261
if path_or_name == 0:
251-
error_text = "DirAccess path is not valid!"
262+
error_text = "Directory path is not valid!"
252263
elif path_or_name == 1:
253264
error_text = "File name is not valid!"
254265

0 commit comments

Comments
 (0)