1
0
Fork 0
mirror of https://github.com/Orama-Interactive/Pixelorama.git synced 2025-02-13 09:13:07 +00:00
Pixelorama/src/Autoload/Export.gd
Emmanouil Papadeas 18e9e2ec56
Implement audio layers (#1149)
* Initial work on audio layers

* Load ogg audio files

* Fix playback position

* Support mp3 files

* Play audio at the appropriate position when the animation runs, and stop when the pause button is pressed

* Change audio cel textures for the cels where audio is playing

* Fix audio not playing at the appropriate position

* Don't play audio is layer is invisible

* Set the audio layer names to be the imported audio file names

* Import audio from videos

* Export videos with audio

Only works with mp3 for now

* Remove support for ogg audio files as they cannot be saved

At least until I find a way to save them. Wav files will be supported with Godot 4.4

* Fix adding/removing in-between frames breaking the visual indication of audio cels

* Minor code improvements

* Export audio in videos with custom delay

* Support frame delay

* Change the frame where the audio plays at

* Fix crashes when the audio layer has no track

* Remove unneeded cel properties for audio cels

* Pxo loading/saving

* Load audio files from the audio layer properties

* Change the audio driver to Dummy from the Preferences for performance reasons

* Clone audio layers, disable layer merge and FX buttons when an audio layer is selected

* Easily change the playback frame of an audio layer from the right click menu of cel buttons

* Update Translations.pot

* Some code improvements and documentation

* Stop audio from playing when looping, and the audio does not play at the first frame

* Update audio cel buttons when changing the audio of the layer

* Mute audio layer when hiding it mid-play

* Only plays the portion of the sound that corresponds to the specific frame so maybe we should do that as well

When the animation is not running. If it is running, play the sound properly.

* Some code changes to allow for potential negative frames placement for audio

This woud allow audio to be placed in negative frames, which essentially means that audio would start before the first frame. This is not yet supported, however, because I don't know how to make it work with FFMPEG.
2024-12-14 00:30:04 +02:00

794 lines
28 KiB
GDScript

extends Node
enum ExportTab { IMAGE, SPRITESHEET }
enum Orientation { COLUMNS, ROWS, TAGS_BY_COLUMN, TAGS_BY_ROW }
enum AnimationDirection { FORWARD, BACKWARDS, PING_PONG }
## See file_format_string, file_format_description, and ExportDialog.gd
enum FileFormat { PNG, WEBP, JPEG, GIF, APNG, MP4, AVI, OGV, MKV, WEBM }
enum { VISIBLE_LAYERS, SELECTED_LAYERS }
enum ExportFrames { ALL_FRAMES, SELECTED_FRAMES }
## This path is used to temporarily store png files that FFMPEG uses to convert them to video
const TEMP_PATH := "user://tmp"
## List of animated formats
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 [enum FileFormat] enums and their file extensions and short descriptions.
var file_format_dictionary := {
FileFormat.PNG: [".png", "PNG Image"],
FileFormat.WEBP: [".webp", "WebP Image"],
FileFormat.JPEG: [".jpg", "JPG Image"],
FileFormat.GIF: [".gif", "GIF Image"],
FileFormat.APNG: [".apng", "APNG Image"],
FileFormat.MP4: [".mp4", "MPEG-4 Video"],
FileFormat.AVI: [".avi", "AVI Video"],
FileFormat.OGV: [".ogv", "OGV Video"],
FileFormat.MKV: [".mkv", "Matroska Video"],
FileFormat.WEBM: [".webm", "WebM Video"],
}
## A dictionary of custom exporter generators (received from extensions)
var custom_file_formats := {}
var custom_exporter_generators := {}
var current_tab := ExportTab.IMAGE
## All frames and their layers processed/blended into images
var processed_images: Array[ProcessedImage] = []
## Dictionary of [Frame] and [Image] that contains all of the blended frames.
## Changes when [method cache_blended_frames] is called.
var blended_frames := {}
var export_json := false
var split_layers := false
var trim_images := false
var erase_unselected_area := false
# Spritesheet options
var orientation := Orientation.COLUMNS
var lines_count := 1 ## How many rows/columns before new line is added
# General options
var frame_current_tag := 0 ## Export only current frame tag
var export_layers := 0
var number_of_frames := 1
var direction := AnimationDirection.FORWARD
var resize := 100
var save_quality := 0.75 ## Used when saving jpg and webp images. Goes from 0 to 1.
var interpolation := Image.INTERPOLATE_NEAREST
var include_tag_in_filename := false
var new_dir_for_each_frame_tag := false ## We don't need to store this after export
var number_of_digits := 4
var separator_character := "_"
var stop_export := false ## Export coroutine signal
var file_exists_alert := "The following files already exist. Do you wish to overwrite them?\n%s"
# Export progress variables
var export_progress_fraction := 0.0
var export_progress := 0.0
@onready var gif_export_thread := Thread.new()
class ProcessedImage:
var image: Image
var frame_index: int
var duration: float
func _init(_image: Image, _frame_index: int, _duration := 1.0) -> void:
image = _image
frame_index = _frame_index
duration = _duration
func _exit_tree() -> void:
if gif_export_thread.is_started():
gif_export_thread.wait_to_finish()
func _multithreading_enabled() -> bool:
return ProjectSettings.get_setting("rendering/driver/threads/thread_model") == 2
func add_custom_file_format(
format_name: String, extension: String, exporter_generator: Object, tab: int, is_animated: bool
) -> int:
# Obtain a unique id
var id := Export.FileFormat.size()
for i in Export.custom_file_formats.size():
var format_id = id + i
if !Export.custom_file_formats.values().has(i):
id = format_id
# Add to custom_file_formats
custom_file_formats.merge({format_name: id})
custom_exporter_generators.merge({id: [exporter_generator, extension]})
if is_animated:
Export.animated_formats.append(id)
# Add to export dialog
match tab:
ExportTab.IMAGE:
Global.export_dialog.image_exports.append(id)
ExportTab.SPRITESHEET:
Global.export_dialog.spritesheet_exports.append(id)
_: # Both
Global.export_dialog.image_exports.append(id)
Global.export_dialog.spritesheet_exports.append(id)
return id
func remove_custom_file_format(id: int) -> void:
for key in custom_file_formats.keys():
if custom_file_formats[key] == id:
custom_file_formats.erase(key)
# remove exporter generator
Export.custom_exporter_generators.erase(id)
# remove from animated (if it is present there)
Export.animated_formats.erase(id)
# remove from export dialog
Global.export_dialog.image_exports.erase(id)
Global.export_dialog.spritesheet_exports.erase(id)
return
func external_export(project := Global.current_project) -> void:
cache_blended_frames(project)
process_data(project)
export_processed_images(true, Global.export_dialog, project)
func process_data(project := Global.current_project) -> void:
var frames := _calculate_frames(project)
if frames.size() > blended_frames.size():
cache_blended_frames(project)
match current_tab:
ExportTab.IMAGE:
process_animation(project)
ExportTab.SPRITESHEET:
process_spritesheet(project)
func cache_blended_frames(project := Global.current_project) -> void:
blended_frames.clear()
var frames := _calculate_frames(project)
for frame in frames:
var image := project.new_empty_image()
_blend_layers(image, frame)
blended_frames[frame] = image
func process_spritesheet(project := Global.current_project) -> void:
processed_images.clear()
# Range of frames determined by tags
var frames := _calculate_frames(project)
# Then store the size of frames for other functions
number_of_frames = frames.size()
# Used when the orientation is based off the animation tags
var tag_origins := {0: 0}
var frames_without_tag := number_of_frames
var spritesheet_columns := 1
var spritesheet_rows := 1
# If rows mode selected calculate columns count and vice versa
if orientation == Orientation.COLUMNS:
spritesheet_columns = frames_divided_by_spritesheet_lines()
spritesheet_rows = lines_count
elif orientation == Orientation.ROWS:
spritesheet_columns = lines_count
spritesheet_rows = frames_divided_by_spritesheet_lines()
else:
spritesheet_rows = project.animation_tags.size() + 1
if spritesheet_rows == 1:
spritesheet_columns = number_of_frames
else:
var max_tag_size := 1
for tag in project.animation_tags:
tag_origins[tag] = 0
frames_without_tag -= tag.get_size()
if tag.get_size() > max_tag_size:
max_tag_size = tag.get_size()
if frames_without_tag > max_tag_size:
max_tag_size = frames_without_tag
spritesheet_columns = max_tag_size
if frames_without_tag == 0:
# If all frames have a tag, remove the first row
spritesheet_rows -= 1
if orientation == Orientation.TAGS_BY_ROW:
# Switch rows and columns
var temp := spritesheet_rows
spritesheet_rows = spritesheet_columns
spritesheet_columns = temp
var width := project.size.x * spritesheet_columns
var height := project.size.y * spritesheet_rows
var whole_image := Image.create(width, height, false, project.get_image_format())
var origin := Vector2i.ZERO
var hh := 0
var vv := 0
for frame in frames:
if orientation == Orientation.ROWS:
if vv < spritesheet_columns:
origin.x = project.size.x * vv
vv += 1
else:
hh += 1
origin.x = 0
vv = 1
origin.y = project.size.y * hh
elif orientation == Orientation.COLUMNS:
if hh < spritesheet_rows:
origin.y = project.size.y * hh
hh += 1
else:
vv += 1
origin.y = 0
hh = 1
origin.x = project.size.x * vv
elif orientation == Orientation.TAGS_BY_COLUMN:
var frame_index := project.frames.find(frame)
var frame_has_tag := false
for i in project.animation_tags.size():
var tag := project.animation_tags[i]
if tag.has_frame(frame_index):
origin.x = project.size.x * tag_origins[tag]
if frames_without_tag == 0:
# If all frames have a tag, remove the first row
origin.y = project.size.y * i
else:
origin.y = project.size.y * (i + 1)
tag_origins[tag] += 1
frame_has_tag = true
break
if not frame_has_tag:
origin.x = project.size.x * tag_origins[0]
origin.y = 0
tag_origins[0] += 1
elif orientation == Orientation.TAGS_BY_ROW:
var frame_index := project.frames.find(frame)
var frame_has_tag := false
for i in project.animation_tags.size():
var tag := project.animation_tags[i]
if tag.has_frame(frame_index):
origin.y = project.size.y * tag_origins[tag]
if frames_without_tag == 0:
# If all frames have a tag, remove the first row
origin.x = project.size.x * i
else:
origin.x = project.size.x * (i + 1)
tag_origins[tag] += 1
frame_has_tag = true
break
if not frame_has_tag:
origin.y = project.size.y * tag_origins[0]
origin.x = 0
tag_origins[0] += 1
whole_image.blend_rect(blended_frames[frame], Rect2i(Vector2i.ZERO, project.size), origin)
processed_images.append(ProcessedImage.new(whole_image, 0))
func process_animation(project := Global.current_project) -> void:
processed_images.clear()
var frames := _calculate_frames(project)
for frame in frames:
if split_layers:
for cel in frame.cels:
var image := Image.new()
image.copy_from(cel.get_image())
var duration := frame.get_duration_in_seconds(project.fps)
processed_images.append(
ProcessedImage.new(image, project.frames.find(frame), duration)
)
else:
var image := project.new_empty_image()
image.copy_from(blended_frames[frame])
if erase_unselected_area and project.has_selection:
var crop := project.new_empty_image()
var selection_image = project.selection_map.return_cropped_copy(project.size)
crop.blit_rect_mask(
image, selection_image, Rect2i(Vector2i.ZERO, image.get_size()), Vector2i.ZERO
)
image.copy_from(crop)
if trim_images:
image = image.get_region(image.get_used_rect())
var duration := frame.get_duration_in_seconds(project.fps)
processed_images.append(ProcessedImage.new(image, project.frames.find(frame), duration))
func _calculate_frames(project := Global.current_project) -> Array[Frame]:
var tag_index := frame_current_tag - ExportFrames.size()
if tag_index >= project.animation_tags.size():
frame_current_tag = ExportFrames.ALL_FRAMES
var frames: Array[Frame] = []
if frame_current_tag >= ExportFrames.size(): # Export a specific tag
var frame_start: int = project.animation_tags[tag_index].from
var frame_end: int = project.animation_tags[tag_index].to
frames = project.frames.slice(frame_start - 1, frame_end, 1, true)
elif frame_current_tag == ExportFrames.SELECTED_FRAMES:
for cel in project.selected_cels:
var frame := project.frames[cel[0]]
if not frames.has(frame):
frames.append(frame)
else: # All frames
frames = project.frames.duplicate()
if direction == AnimationDirection.BACKWARDS:
frames.reverse()
elif direction == AnimationDirection.PING_PONG:
var inverted_frames := frames.duplicate()
inverted_frames.reverse()
inverted_frames.remove_at(0)
if inverted_frames.size() > 0:
inverted_frames.remove_at(inverted_frames.size() - 1)
frames.append_array(inverted_frames)
return frames
func export_processed_images(
ignore_overwrites: bool, export_dialog: ConfirmationDialog, project := Global.current_project
) -> bool:
# Stop export if directory path or file name are not valid
var dir_exists := DirAccess.dir_exists_absolute(project.export_directory_path)
var is_valid_filename := project.file_name.is_valid_filename()
if not dir_exists:
if is_valid_filename: # Directory path not valid, file name is valid
export_dialog.open_path_validation_alert_popup(0)
else: # Both directory path and file name are invalid
export_dialog.open_path_validation_alert_popup()
return false
if not is_valid_filename: # Directory path is valid, file name is invalid
export_dialog.open_path_validation_alert_popup(1)
return false
var multiple_files := false
if current_tab == ExportTab.IMAGE and not is_single_file_format(project):
multiple_files = true if processed_images.size() > 1 else false
# Check export paths
var export_paths: PackedStringArray = []
var paths_of_existing_files := ""
for i in processed_images.size():
stop_export = false
var frame_index := i + 1
var layer_index := -1
var actual_frame_index := processed_images[i].frame_index
if split_layers:
frame_index = i / project.layers.size() + 1
layer_index = posmod(i, project.layers.size())
var export_path := _create_export_path(
multiple_files, project, frame_index, layer_index, actual_frame_index
)
# If the user wants to create a new directory for each animation tag then check
# if directories exist, and create them if not
if multiple_files and new_dir_for_each_frame_tag:
var frame_tag_directory := DirAccess.open(export_path.get_base_dir())
if not DirAccess.dir_exists_absolute(export_path.get_base_dir()):
frame_tag_directory = DirAccess.open(project.export_directory_path)
frame_tag_directory.make_dir(export_path.get_base_dir().get_file())
if not ignore_overwrites: # Check if the files already exist
if FileAccess.file_exists(export_path):
if not paths_of_existing_files.is_empty():
paths_of_existing_files += "\n"
paths_of_existing_files += export_path
export_paths.append(export_path)
# Only get one export path if single file animated image is exported
if is_single_file_format(project):
break
if not paths_of_existing_files.is_empty(): # If files already exist
# Ask user if they want to overwrite the files
export_dialog.open_file_exists_alert_popup(tr(file_exists_alert) % paths_of_existing_files)
# Stops the function until the user decides if they want to overwrite
await export_dialog.resume_export_function
if stop_export: # User decided to stop export
return false
_scale_processed_images()
if export_json:
var json := JSON.stringify(project.serialize())
var json_file_name := project.name + ".json"
if OS.has_feature("web"):
var json_buffer := json.to_utf8_buffer()
JavaScriptBridge.download_buffer(json_buffer, json_file_name, "application/json")
else:
var json_path := project.export_directory_path.path_join(json_file_name)
var json_file := FileAccess.open(json_path, FileAccess.WRITE)
json_file.store_string(json)
# override if a custom export is chosen
if project.file_format in custom_exporter_generators.keys():
# Divert the path to the custom exporter instead
var custom_exporter: Object = custom_exporter_generators[project.file_format][0]
if custom_exporter.has_method("override_export"):
var result := true
var details := {
"processed_images": processed_images,
"export_dialog": export_dialog,
"export_paths": export_paths,
"project": project
}
if _multithreading_enabled() and is_single_file_format(project):
if gif_export_thread.is_started():
gif_export_thread.wait_to_finish()
var error = gif_export_thread.start(
Callable(custom_exporter, "override_export").bind(details)
)
if error == OK:
result = gif_export_thread.wait_to_finish()
else:
result = custom_exporter.call("override_export", details)
return result
if is_single_file_format(project):
if is_using_ffmpeg(project.file_format):
var video_exported := export_video(export_paths, project)
if not video_exported:
Global.popup_error(
tr("Video failed to export. Ensure that FFMPEG is installed correctly.")
)
return false
else:
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:
for i in range(processed_images.size()):
if OS.has_feature("web"):
if project.file_format == FileFormat.PNG:
JavaScriptBridge.download_buffer(
processed_images[i].image.save_png_to_buffer(),
export_paths[i].get_file(),
"image/png"
)
elif project.file_format == FileFormat.WEBP:
JavaScriptBridge.download_buffer(
processed_images[i].image.save_webp_to_buffer(),
export_paths[i].get_file(),
"image/webp"
)
elif project.file_format == FileFormat.JPEG:
JavaScriptBridge.download_buffer(
processed_images[i].image.save_jpg_to_buffer(save_quality),
export_paths[i].get_file(),
"image/jpeg"
)
else:
var err: Error
if project.file_format == FileFormat.PNG:
err = processed_images[i].image.save_png(export_paths[i])
elif project.file_format == FileFormat.WEBP:
err = processed_images[i].image.save_webp(export_paths[i])
elif project.file_format == FileFormat.JPEG:
err = processed_images[i].image.save_jpg(export_paths[i], save_quality)
if err != OK:
Global.popup_error(
tr("File failed to save. Error code %s (%s)") % [err, error_string(err)]
)
return false
Global.notification_label("File(s) exported")
# Store settings for quick export and when the dialog is opened again
var file_name_with_ext := project.file_name + file_format_string(project.file_format)
project.was_exported = true
if project.export_overwrite:
Global.top_menu_container.file_menu.set_item_text(
Global.FileMenu.EXPORT, tr("Overwrite") + " %s" % file_name_with_ext
)
else:
Global.top_menu_container.file_menu.set_item_text(
Global.FileMenu.EXPORT, tr("Export") + " %s" % file_name_with_ext
)
project.export_directory_path = export_paths[0].get_base_dir()
Global.config_cache.set_value("data", "current_dir", project.export_directory_path)
return true
## Uses FFMPEG to export a video
func export_video(export_paths: PackedStringArray, project: Project) -> bool:
DirAccess.make_dir_absolute(TEMP_PATH)
var video_duration := 0
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].image.save_png(temp_file_path)
input_file.store_line("file '" + temp_file_name + "'")
input_file.store_line("duration %s" % processed_images[i].duration)
video_duration += processed_images[i].duration
input_file.close()
# ffmpeg -y -f concat -i input.txt output_path
var ffmpeg_execute: PackedStringArray = [
"-y", "-f", "concat", "-i", input_file_path, export_paths[0]
]
var success := OS.execute(Global.ffmpeg_path, ffmpeg_execute, [], true)
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.popup_error(tr(fail_text))
_clear_temp_folder()
return false
# Find audio layers
var ffmpeg_combine_audio: PackedStringArray = ["-y"]
var audio_layer_count := 0
var max_audio_duration := 0
var adelay_string := ""
for layer in project.get_all_audio_layers():
if layer.audio is AudioStreamMP3:
var temp_file_name := str(audio_layer_count + 1).pad_zeros(number_of_digits) + ".mp3"
var temp_file_path := temp_path_real.path_join(temp_file_name)
var temp_audio_file := FileAccess.open(temp_file_path, FileAccess.WRITE)
temp_audio_file.store_buffer(layer.audio.data)
ffmpeg_combine_audio.append("-i")
ffmpeg_combine_audio.append(temp_file_path)
var delay := floori(layer.playback_position * 1000)
# [n]adelay=delay_in_ms:all=1[na]
adelay_string += (
"[%s]adelay=%s:all=1[%sa];" % [audio_layer_count, delay, audio_layer_count]
)
audio_layer_count += 1
if layer.get_audio_length() >= max_audio_duration:
max_audio_duration = layer.get_audio_length()
if audio_layer_count > 0:
# If we have audio layers, merge them all into one file.
for i in audio_layer_count:
adelay_string += "[%sa]" % i
var amix_inputs_string := "amix=inputs=%s[a]" % audio_layer_count
var final_filter_string := adelay_string + amix_inputs_string
var audio_file_path := temp_path_real.path_join("audio.mp3")
ffmpeg_combine_audio.append_array(
PackedStringArray(
["-filter_complex", final_filter_string, "-map", '"[a]"', audio_file_path]
)
)
# ffmpeg -i input1 -i input2 ... -i inputn -filter_complex amix=inputs=n output_path
var combined_audio_success := OS.execute(Global.ffmpeg_path, ffmpeg_combine_audio, [], true)
if combined_audio_success == 0 or combined_audio_success == 1:
var copied_video := temp_path_real.path_join("video." + export_paths[0].get_extension())
# Then mix the audio file with the video.
DirAccess.copy_absolute(export_paths[0], copied_video)
# ffmpeg -y -i video_file -i input_audio -c:v copy -map 0:v:0 -map 1:a:0 video_file
var ffmpeg_final_video: PackedStringArray = [
"-y", "-i", copied_video, "-i", audio_file_path
]
if max_audio_duration > video_duration:
ffmpeg_final_video.append("-shortest")
ffmpeg_final_video.append_array(
["-c:v", "copy", "-map", "0:v:0", "-map", "1:a:0", export_paths[0]]
)
OS.execute(Global.ffmpeg_path, ffmpeg_final_video, [], true)
_clear_temp_folder()
return true
func _clear_temp_folder() -> void:
var temp_dir := DirAccess.open(TEMP_PATH)
for file in temp_dir.get_files():
temp_dir.remove(file)
DirAccess.remove_absolute(TEMP_PATH)
func export_animated(args: Dictionary) -> void:
var project: Project = args["project"]
var exporter: AImgIOBaseExporter = args["exporter"]
# This is an ExportDialog (which refers back here).
var export_dialog: ConfirmationDialog = args["export_dialog"]
# Export progress popup
# One fraction per each frame, one fraction for write to disk
export_progress_fraction = 100.0 / processed_images.size()
export_progress = 0.0
export_dialog.set_export_progress_bar(export_progress)
export_dialog.toggle_export_progress_popup(true)
# Transform into AImgIO form
var frames := []
for i in range(processed_images.size()):
var frame: AImgIOFrame = AImgIOFrame.new()
frame.content = processed_images[i].image
frame.duration = processed_images[i].duration
frames.push_back(frame)
# Export and save GIF/APNG
var file_data := exporter.export_animation(
frames, project.fps, self, "_increase_export_progress", [export_dialog]
)
if OS.has_feature("web"):
JavaScriptBridge.download_buffer(file_data, args["export_paths"][0], exporter.mime_type)
else:
var file := FileAccess.open(args["export_paths"][0], FileAccess.WRITE)
file.store_buffer(file_data)
file.close()
export_dialog.toggle_export_progress_popup(false)
Global.notification_label("File(s) exported")
func _increase_export_progress(export_dialog: Node) -> void:
export_progress += export_progress_fraction
export_dialog.set_export_progress_bar(export_progress)
func _scale_processed_images() -> void:
var resize_f := resize / 100.0
for processed_image in processed_images:
if is_equal_approx(resize, 1.0):
continue
var image := processed_image.image
image.resize(image.get_size().x * resize_f, image.get_size().y * resize_f, interpolation)
func file_format_string(format_enum: int) -> String:
if file_format_dictionary.has(format_enum):
return file_format_dictionary[format_enum][0]
# If a file format description is not found, try generating one
if custom_exporter_generators.has(format_enum):
return custom_exporter_generators[format_enum][1]
return ""
func file_format_description(format_enum: int) -> String:
if file_format_dictionary.has(format_enum):
return file_format_dictionary[format_enum][1]
# If a file format description is not found, try generating one
for key in custom_file_formats.keys():
if custom_file_formats[key] == format_enum:
return str(key.capitalize())
return ""
func get_file_format_from_extension(file_extension: String) -> FileFormat:
if not file_extension.begins_with("."):
file_extension = "." + file_extension
for format: FileFormat in file_format_dictionary:
var extension: String = file_format_dictionary[format][0]
if file_extension.to_lower() == extension:
return format
return FileFormat.PNG
## 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, layer := -1, actual_frame_index := 0
) -> String:
var path := project.file_name
if path.contains("{name}"):
path = path.replace("{name}", project.name)
var path_extras := ""
# Only append frame number when there are multiple files exported
if multifile:
if layer > -1:
var layer_name := project.layers[layer].name
path_extras += "(%s) " % layer_name
path_extras += separator_character + str(frame).pad_zeros(number_of_digits)
var frame_tag_and_start_id := _get_processed_image_tag_name_and_start_id(
project, actual_frame_index
)
# Check if exported frame is in frame tag
if not frame_tag_and_start_id.is_empty():
var frame_tag: String = frame_tag_and_start_id[0]
var start_id: int = frame_tag_and_start_id[1]
# Remove unallowed characters in frame tag directory
var regex := RegEx.new()
regex.compile("[^a-zA-Z0-9_]+")
var frame_tag_dir := regex.sub(frame_tag, "", true)
if include_tag_in_filename:
# (actual_frame_index - start_id + 2) makes frames id to start from 1
var tag_frame_number := str(actual_frame_index - start_id + 2).pad_zeros(
number_of_digits
)
path_extras = (
separator_character + frame_tag_dir + separator_character + tag_frame_number
)
if new_dir_for_each_frame_tag:
path += path_extras
return project.export_directory_path.path_join(frame_tag_dir).path_join(
path + file_format_string(project.file_format)
)
path += path_extras
return project.export_directory_path.path_join(path + file_format_string(project.file_format))
func _get_processed_image_tag_name_and_start_id(project: Project, processed_image_id: int) -> Array:
var result_animation_tag_and_start_id := []
for animation_tag in project.animation_tags:
# Check if processed image is in frame tag and assign frame tag and start id if yes
# Then stop
if animation_tag.has_frame(processed_image_id):
result_animation_tag_and_start_id = [animation_tag.name, animation_tag.from]
break
return result_animation_tag_and_start_id
func _blend_layers(
image: Image, frame: Frame, origin := Vector2i.ZERO, project := Global.current_project
) -> void:
if export_layers - 2 >= project.layers.size():
export_layers = VISIBLE_LAYERS
if export_layers == VISIBLE_LAYERS:
var load_result_from_pxo := not project.save_path.is_empty() and not project.has_changed
if load_result_from_pxo:
# Attempt to read the image data directly from the pxo file, without having to blend
# This is mostly useful for when running Pixelorama in headless mode
# To handle exporting from a CLI
var zip_reader := ZIPReader.new()
var err := zip_reader.open(project.save_path)
if err == OK:
var frame_index := project.frames.find(frame) + 1
var image_path := "image_data/final_images/%s" % frame_index
if zip_reader.file_exists(image_path):
# "Include blended" must be toggled on when saving the pxo file
# in order for this to work.
var image_data := zip_reader.read_file(image_path)
var loaded_image := Image.create_from_data(
project.size.x,
project.size.y,
image.has_mipmaps(),
image.get_format(),
image_data
)
image.blend_rect(loaded_image, Rect2i(Vector2i.ZERO, project.size), origin)
else:
load_result_from_pxo = false
zip_reader.close()
else:
load_result_from_pxo = false
if not load_result_from_pxo:
DrawingAlgos.blend_layers(image, frame, origin, project)
elif export_layers == SELECTED_LAYERS:
DrawingAlgos.blend_layers(image, frame, origin, project, false, true)
else:
var layer := project.layers[export_layers - 2]
var layer_image := Image.new()
if layer is GroupLayer:
layer_image.copy_from(layer.blend_children(frame, Vector2i.ZERO))
else:
layer_image.copy_from(layer.display_effects(frame.cels[export_layers - 2]))
image.blend_rect(layer_image, Rect2i(Vector2i.ZERO, project.size), origin)
func frames_divided_by_spritesheet_lines() -> int:
return ceili(number_of_frames / float(lines_count))