extends Node enum CropMode { NONE, CONTENT, SELECTION } enum ExportTab { IMAGE, SPRITESHEET } enum Orientation { COLUMNS, ROWS, TAGS_BY_ROW, TAGS_BY_COLUMN } enum AnimationDirection { FORWARD, BACKWARDS, PING_PONG } ## See file_format_string, file_format_description, and ExportDialog.gd enum FileFormat { PNG, WEBP, JPEG, SVG, EXR, GIF, ASE, 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 var temp_path := OS.get_temp_dir().path_join("pixelorama_tmp") ## List of animated formats var animated_formats := [ FileFormat.GIF, FileFormat.ASE, 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: Dictionary[FileFormat, Array] = { FileFormat.PNG: [".png", "PNG Image"], FileFormat.WEBP: [".webp", "WebP Image"], FileFormat.JPEG: [".jpg", "JPG Image"], FileFormat.SVG: [".svg", "SVG Image"], FileFormat.EXR: [".exr", "EXR Image"], FileFormat.GIF: [".gif", "GIF Image"], FileFormat.ASE: [".aseprite", "Aseprite Project"], 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 := {} ## All frames and their layers processed/blended into images var processed_images: Array[ProcessedImage] = [] ## A dictionary that contains all of the blended frames. ## Changes when [method cache_blended_frames] is called. var blended_frames: Dictionary[Frame, Image] = {} 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 ExportProfile: var directory_path := "" var file_name := "untitled" var file_format := Export.FileFormat.PNG var current_tab := Export.ExportTab.IMAGE var export_json := false var split_layers := false var sheet_layers_as_separate_files := false var crop_mode := CropMode.NONE 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 repeat_count := 0 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 := "_" func deserialize(data: Dictionary): for key in data.keys(): var default = get(key) var value = str_to_var(data[key]) if default != null and typeof(default) == typeof(value): # property exists set(key, value) func serialize(): return { "directory_path": var_to_str(directory_path), "file_name": var_to_str(file_name), "file_format": var_to_str(file_format), "current_tab": var_to_str(current_tab), "export_json": var_to_str(export_json), "split_layers": var_to_str(split_layers), "sheet_layers_as_separate_files": var_to_str(sheet_layers_as_separate_files), "crop_mode": var_to_str(crop_mode), "erase_unselected_area": var_to_str(erase_unselected_area), "orientation": var_to_str(orientation), "lines_count": var_to_str(lines_count), "frame_current_tag": var_to_str(frame_current_tag), "export_layers": var_to_str(export_layers), "number_of_frames": var_to_str(number_of_frames), "direction": var_to_str(direction), "repeat_count": var_to_str(repeat_count), "resize": var_to_str(resize), "save_quality": var_to_str(save_quality), "interpolation": var_to_str(interpolation), "include_tag_in_filename": var_to_str(include_tag_in_filename), "new_dir_for_each_frame_tag": var_to_str(new_dir_for_each_frame_tag), "number_of_digits": var_to_str(number_of_digits), "separator_character": var_to_str(separator_character) } class ProcessedImage: var image: Image var frame_index: int var duration: float var layer_index: int func _init(_image: Image, _frame_index: int, _duration := 1.0, _layer_index := -1) -> void: image = _image frame_index = _frame_index duration = _duration layer_index = _layer_index 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() # Start with the least possible id for custom exporter for i in Export.custom_file_formats.size(): # Increment ids by 1 till we find one that isn't in use var format_id = id + i + 1 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) var export_profile := project.export_profile if frames.size() * (export_profile.repeat_count + 1) > blended_frames.size(): cache_blended_frames(project) match export_profile.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) # Add additional repeated animation (Doing it here instead of _calculate_frames() to save # compute power var export_profile := project.export_profile if export_profile.repeat_count > 0: var frames_copy := frames.duplicate() for _r in export_profile.repeat_count: frames.append_array(frames_copy) # Then store the size of frames for other functions export_profile.number_of_frames = frames.size() # Used when the orientation is based off the animation tags var tag_origins := {0: 0} var frames_without_tag := export_profile.number_of_frames var spritesheet_columns := 1 var spritesheet_rows := 1 # If rows mode selected calculate columns count and vice versa if export_profile.orientation == Orientation.COLUMNS: spritesheet_columns = frames_divided_by_spritesheet_lines() spritesheet_rows = export_profile.lines_count elif export_profile.orientation == Orientation.ROWS: spritesheet_columns = export_profile.lines_count spritesheet_rows = frames_divided_by_spritesheet_lines() else: spritesheet_rows = project.animation_tags.size() + 1 if spritesheet_rows == 1: spritesheet_columns = export_profile.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 export_profile.orientation == Orientation.TAGS_BY_COLUMN: # 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 splitter_array: Array[BaseLayer] = [] if export_profile.split_layers: splitter_array = _calculate_layers_to_export(project) var only_selected_cels := _export_only_selected_cels(project) var sprite_sheets: Array[Image] # Array of all apritesheets var sprite_sheet_layer_indices: Array[int] = [] # This is an imitation of a do-while loop. The loop ends early if split_layers is empty for split_l in splitter_array.size() + 1: var origin := Vector2i.ZERO var layer_tag_origins = tag_origins.duplicate() var hh := 0 var vv := 0 var sheet_image := Image.create_empty(width, height, false, project.get_image_format()) var sheet_is_valid := false for frame in frames: if export_profile.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 export_profile.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 export_profile.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.x = project.size.x * layer_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) layer_tag_origins[tag] += 1 frame_has_tag = true break if not frame_has_tag: origin.x = project.size.x * layer_tag_origins[0] origin.y = 0 layer_tag_origins[0] += 1 elif export_profile.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.y = project.size.y * layer_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) layer_tag_origins[tag] += 1 frame_has_tag = true break if not frame_has_tag: origin.y = project.size.y * layer_tag_origins[0] origin.x = 0 layer_tag_origins[0] += 1 if not export_profile.split_layers: sheet_image.blend_rect( blended_frames[frame], Rect2i(Vector2i.ZERO, project.size), origin ) sheet_is_valid = true elif split_l < splitter_array.size(): var layer: BaseLayer = splitter_array[split_l] if not only_selected_cels or _is_cel_selected(project, frame, layer.index): sheet_image.blend_rect( layer.display_effects(frame.cels[layer.index]), Rect2i(Vector2i.ZERO, project.size), origin ) sheet_is_valid = true if sheet_is_valid: sprite_sheets.append(sheet_image) if export_profile.split_layers and split_l < splitter_array.size(): sprite_sheet_layer_indices.append(splitter_array[split_l].index) else: sprite_sheet_layer_indices.append(-1) if splitter_array.is_empty(): break if not export_profile.sheet_layers_as_separate_files and sprite_sheets.size() > 1: var big_image := Image.create( width, height * sprite_sheets.size(), false, project.get_image_format() ) for i in sprite_sheets.size(): big_image.blend_rect( sprite_sheets[i], Rect2i(Vector2i.ZERO, Vector2i(width, height)), Vector2i(0, height * (sprite_sheets.size() - i - 1)) ) # Remove old sheets and add the stacked image sprite_sheets = [big_image] sprite_sheet_layer_indices = [-1] for i in sprite_sheets.size(): processed_images.append( ProcessedImage.new(sprite_sheets[i], 0, 1.0, sprite_sheet_layer_indices[i]) ) func process_animation(project := Global.current_project) -> void: processed_images.clear() var frames := _calculate_frames(project) var only_selected_cels := _export_only_selected_cels(project) var export_profile := project.export_profile for frame in frames: if export_profile.split_layers: for layer in _calculate_layers_to_export(project): if only_selected_cels and not _is_cel_selected(project, frame, layer.index): continue var cel := frame.cels[layer.index] var image := Image.new() image.copy_from(layer.display_effects(cel)) var duration := frame.get_duration_in_seconds(project.fps) processed_images.append( ProcessedImage.new(image, project.frames.find(frame), duration, layer.index) ) else: var image := project.new_empty_image() image.copy_from(blended_frames[frame]) if export_profile.erase_unselected_area and project.has_selection: var crop := project.new_empty_image() var selection_image := project.selection_map.return_cropped_copy( project, project.size ) crop.blit_rect_mask( image, selection_image, Rect2i(Vector2i.ZERO, image.get_size()), Vector2i.ZERO ) image.copy_from(crop) match export_profile.crop_mode: CropMode.CONTENT: if image.get_used_rect().has_area(): image = image.get_region(image.get_used_rect()) CropMode.SELECTION: if ( Global.current_project.has_selection and Global.current_project.selection_map.get_used_rect().has_area() ): image = image.get_region( Global.current_project.selection_map.get_used_rect() ) var duration := frame.get_duration_in_seconds(project.fps) processed_images.append(ProcessedImage.new(image, project.frames.find(frame), duration)) # Add additional repeated animation (Doing it here instead of _calculate_frames() to save # compute power var un_repeated_size: int = processed_images.size() for _r in export_profile.repeat_count: for i in un_repeated_size: processed_images.append( ProcessedImage.new( processed_images[i].image, processed_images[i].frame_index, processed_images[i].duration, processed_images[i].layer_index ) ) func _calculate_frames(project := Global.current_project) -> Array[Frame]: var export_profile := project.export_profile var tag_index = export_profile.frame_current_tag - ExportFrames.size() if tag_index >= project.animation_tags.size(): export_profile.frame_current_tag = ExportFrames.ALL_FRAMES var frames: Array[Frame] = [] if export_profile.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 export_profile.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 export_profile.direction == AnimationDirection.BACKWARDS: frames.reverse() elif export_profile.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 _calculate_layers_to_export(project := Global.current_project) -> Array[BaseLayer]: var layers_to_export: Array[BaseLayer] = [] var export_profile := project.export_profile for i in project.layers.size(): var layer := project.layers[i] if layer is GroupLayer or layer is AudioLayer: continue var include := false match export_profile.export_layers: VISIBLE_LAYERS: include = layer.is_visible_in_hierarchy() SELECTED_LAYERS: include = layer.is_visible_in_hierarchy() and _is_layer_selected(project, i) _: # A specific layer was chosen from the dropdown include = i == export_profile.export_layers - 2 if include: layers_to_export.append(layer) return layers_to_export func _is_layer_selected(project: Project, layer_index: int) -> bool: for selected_cel in project.selected_cels: if selected_cel[1] == layer_index: return true return false ## True when both "Selected frames" and "Selected layers" are chosen, in which case only the exact ## cels the user selected are exported, instead of every selected layer at every selected frame. func _export_only_selected_cels(project: Project) -> bool: return ( project.export_profile.frame_current_tag == ExportFrames.SELECTED_FRAMES and project.export_profile.export_layers == SELECTED_LAYERS ) func _is_cel_selected(project: Project, frame: Frame, layer_index: int) -> bool: return [project.frames.find(frame), layer_index] in project.selected_cels 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 export_profile := project.export_profile var dir_exists := DirAccess.dir_exists_absolute(export_profile.directory_path) var is_valid_filename := export_profile.file_name.is_valid_filename() if export_profile.directory_path.begins_with("content://"): dir_exists = true is_valid_filename = true 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 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 := "" # Frame counter for split-layer file names, bumped when the source frame changes. var split_frame_counter := 0 var previous_split_frame := -1 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 export_profile.split_layers: layer_index = processed_images[i].layer_index if export_profile.current_tab == ExportTab.IMAGE: if actual_frame_index != previous_split_frame: split_frame_counter += 1 previous_split_frame = actual_frame_index frame_index = split_frame_counter 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 export_profile.new_dir_for_each_frame_tag and not export_profile.current_tab == ExportTab.SPRITESHEET ): 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(export_profile.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(export_profile.resize, export_profile.interpolation) if export_profile.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 := export_profile.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 export_profile.file_format in custom_exporter_generators.keys(): # Divert the path to the custom exporter instead var custom_exporter: Object = custom_exporter_generators[export_profile.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(export_profile.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: if export_profile.file_format == FileFormat.ASE: AsepriteExporter.save_aseprite_file(project, export_paths[0]) else: var exporter: AImgIOBaseExporter if export_profile.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"): var buffer: PackedByteArray var file_name := export_paths[i].get_file() var mimetype := "" if export_profile.file_format == FileFormat.WEBP: buffer = processed_images[i].image.save_webp_to_buffer() mimetype = "image/webp" elif export_profile.file_format == FileFormat.JPEG: buffer = processed_images[i].image.save_jpg_to_buffer() mimetype = "image/jpeg" elif export_profile.file_format == FileFormat.SVG: var svg_str := image_to_svg(processed_images[i].image) buffer = svg_str.to_utf8_buffer() mimetype = "image/svg" else: buffer = processed_images[i].image.save_png_to_buffer() mimetype = "image/png" JavaScriptBridge.download_buffer(buffer, file_name, mimetype) else: var err: Error if export_profile.file_format == FileFormat.PNG: err = processed_images[i].image.save_png(export_paths[i]) elif export_profile.file_format == FileFormat.WEBP: err = processed_images[i].image.save_webp(export_paths[i]) elif export_profile.file_format == FileFormat.JPEG: err = processed_images[i].image.save_jpg( export_paths[i], export_profile.save_quality ) elif export_profile.file_format == FileFormat.SVG: var svg_str := image_to_svg(processed_images[i].image) var svg_file := FileAccess.open(export_paths[i], FileAccess.WRITE) err = FileAccess.get_open_error() svg_file.store_string(svg_str) elif export_profile.file_format == FileFormat.EXR: err = processed_images[i].image.save_exr(export_paths[i]) 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.export_profile.file_name + file_format_string(project.export_profile.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 ) # NOTE: export_profile.directory_path is already updated through ExportDialog.gd # we only need to update the current_dir here after the export Global.config_cache.set_value("data", "current_dir", export_profile.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 input_file_path := temp_path.path_join("input.txt") var input_file := FileAccess.open(input_file_path, FileAccess.WRITE) var export_profile := project.export_profile for i in range(processed_images.size()): var temp_file_name := str(i + 1).pad_zeros(export_profile.number_of_digits) + ".png" var temp_file_path := temp_path.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) if i == processed_images.size() - 1: # The last frame needs to be stored again after the duration line # in order for it not to be skipped. input_file.store_line("file '" + temp_file_name + "'") video_duration += processed_images[i].duration input_file.close() var ffmpeg_execute: PackedStringArray = [ "-y", "-f", "concat", "-safe", "0", "-i", input_file_path, "-fps_mode", "passthrough", 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 or layer.audio is AudioStreamWAV: var temp_file_name := str(audio_layer_count + 1).pad_zeros( export_profile.number_of_digits ) if layer.audio is AudioStreamMP3: temp_file_name += ".mp3" elif layer.audio is AudioStreamWAV: temp_file_name += ".wav" var temp_file_path := temp_path.path_join(temp_file_name) if layer.audio is AudioStreamMP3: var temp_audio_file := FileAccess.open(temp_file_path, FileAccess.WRITE) temp_audio_file.store_buffer(layer.audio.data) elif layer.audio is AudioStreamWAV: layer.audio.save_to_wav(temp_file_path) 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.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.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 if OS.has_feature("web"): var file_data := await exporter.export_animation( frames, project.fps, self, "_increase_export_progress", [export_dialog] ) JavaScriptBridge.download_buffer(file_data, args["export_paths"][0], exporter.mime_type) else: # Open the file for export var file := FileAccess.open(args["export_paths"][0], FileAccess.WRITE) if FileAccess.get_open_error() == OK: var buffer_data := await exporter.export_animation( frames, project.fps, self, "_increase_export_progress", [export_dialog], file ) # In order to save memory, some exporters (like GIF) auto saves the data to file as # soon as it is processed (usually, frame by frame). If the exporter does not have this # feature, then the buffer_data will not be empty and we have to save it manually after # all frames are processed. if not buffer_data.is_empty(): file.store_buffer(buffer_data) file.close() export_dialog.toggle_export_progress_popup(false) Global.notification_label("File(s) exported") func image_to_svg(source_image: Image) -> String: var svg_version := "1.1" var width := source_image.get_width() var height := source_image.get_height() var xmlns := "http://www.w3.org/2000/svg" var xml := ( """ """ % [svg_version, width, height, xmlns] ) for x in source_image.get_width(): for y in source_image.get_height(): var color := source_image.get_pixel(x, y) if is_zero_approx(color.a): continue var rect := Rect2(x, y, 1, 1) xml += ( "\n" % [rect.position.x, rect.position.y, rect.size.x, rect.size.y, color.to_html(false)] ) xml += "" return xml 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( resize_amount: int, interpolation := Image.INTERPOLATE_NEAREST ) -> void: var resize_f := resize_amount / 100.0 for processed_image in processed_images: if is_equal_approx(resize_amount, 1.0): continue var image := processed_image.image image.resize(image.get_size().x * resize_f, image.get_size().y * resize_f, interpolation) ## Returns the format string (.png, .jpg etc...). For formats added through extensions, a unique id ## is attached to the string, [param true_formats] returns the original extension format ## without the id attached func file_format_string(format_enum: int, true_formats := false) -> 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): if true_formats: return custom_exporter_generators[format_enum][1] # Else return "%s_id-%s" % [custom_exporter_generators[format_enum][1], str(format_enum)] 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 var custom_format_string := file_extension.split("-", false) if not custom_file_formats.is_empty(): var extension_id := custom_format_string[-1].to_int() if extension_id in custom_exporter_generators: @warning_ignore("int_as_enum_without_cast") return extension_id 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.export_profile.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 export_profile := project.export_profile var path := export_profile.file_name if path.contains("{name}"): path = path.replace("{name}", project.name) var path_extras := "" var separator_character := export_profile.separator_character # 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 var counter: String = ( str(layer).pad_zeros(export_profile.number_of_digits) if export_profile.current_tab == ExportTab.SPRITESHEET else str(frame).pad_zeros(export_profile.number_of_digits) ) path_extras += separator_character + counter 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 export_profile.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( export_profile.number_of_digits ) path_extras = ( separator_character + frame_tag_dir + separator_character + tag_frame_number ) if export_profile.new_dir_for_each_frame_tag: var directory_path := export_profile.directory_path if not frame_tag_dir.is_empty(): directory_path = directory_path.path_join(frame_tag_dir) if not DirAccess.dir_exists_absolute(directory_path): DirAccess.make_dir_recursive_absolute(directory_path) path += path_extras return directory_path.path_join( path + file_format_string(export_profile.file_format, true) ) path += path_extras if OS.get_name() == "Android": return ( export_profile.directory_path + "#" + path + file_format_string(export_profile.file_format, true) ) return export_profile.directory_path.path_join( path + file_format_string(export_profile.file_format, true) ) 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: var export_profile := project.export_profile if export_profile.export_layers - 2 >= project.layers.size(): export_profile.export_layers = VISIBLE_LAYERS if export_profile.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_profile.export_layers == SELECTED_LAYERS: DrawingAlgos.blend_layers(image, frame, origin, project, false, true) else: var layer := project.layers[export_profile.export_layers - 2] var layer_image := Image.new() if layer is GroupLayer: layer_image.copy_from(layer.blend_children(frame)) else: layer_image.copy_from( layer.display_effects(frame.cels[export_profile.export_layers - 2]) ) image.blend_rect(layer_image, Rect2i(Vector2i.ZERO, project.size), origin) func frames_divided_by_spritesheet_lines(project := Global.current_project) -> int: var export_profile := project.export_profile return ceili(export_profile.number_of_frames / float(export_profile.lines_count))