└── addons └── anim_track_converter ├── plugin.gd.uid ├── lib ├── editor_util.gd.uid ├── anim_track_converter.gd.uid ├── editor_util.gd └── anim_track_converter.gd ├── ui └── convert_dialogue │ ├── convert_dialogue.gd.uid │ ├── track_convert_select.gd.uid │ ├── convert_dialogue.gd │ ├── convert_dialogue.tscn │ └── track_convert_select.gd ├── plugin.cfg ├── LICENSE └── plugin.gd /addons/anim_track_converter/plugin.gd.uid: -------------------------------------------------------------------------------- 1 | uid://1qujtrq1qtul 2 | -------------------------------------------------------------------------------- /addons/anim_track_converter/lib/editor_util.gd.uid: -------------------------------------------------------------------------------- 1 | uid://c3hvft56dqnt2 2 | -------------------------------------------------------------------------------- /addons/anim_track_converter/lib/anim_track_converter.gd.uid: -------------------------------------------------------------------------------- 1 | uid://bpwliovcdtkbp 2 | -------------------------------------------------------------------------------- /addons/anim_track_converter/ui/convert_dialogue/convert_dialogue.gd.uid: -------------------------------------------------------------------------------- 1 | uid://ivpl402sjxiw 2 | -------------------------------------------------------------------------------- /addons/anim_track_converter/ui/convert_dialogue/track_convert_select.gd.uid: -------------------------------------------------------------------------------- 1 | uid://cris7i7it6f7v 2 | -------------------------------------------------------------------------------- /addons/anim_track_converter/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="Animation Track Converter" 4 | description="An addon that allows you to convert value tracks to bezier tracks in animations." 5 | author="Kasper Arnklit Frandsen" 6 | version="1.0.2" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /addons/anim_track_converter/ui/convert_dialogue/convert_dialogue.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends AcceptDialog 3 | 4 | const CustomEditorPlugin := preload("res://addons/anim_track_converter/plugin.gd") 5 | const EditorUtil := preload("res://addons/anim_track_converter/lib/editor_util.gd") 6 | const TrackConvertSelect := preload("res://addons/anim_track_converter/ui/convert_dialogue/track_convert_select.gd") 7 | 8 | const AnimTrackConverter = preload("res://addons/anim_track_converter/lib/anim_track_converter.gd") 9 | 10 | 11 | var _editor_plugin: CustomEditorPlugin 12 | var _editor_interface: EditorInterface 13 | var _anim_track_converter: AnimTrackConverter 14 | var _anim_player: AnimationPlayer 15 | 16 | @onready var track_convert_select: TrackConvertSelect = $TrackConvertVbox/TrackConvertSelect 17 | 18 | func init(editor_plugin: CustomEditorPlugin) -> void: 19 | _editor_plugin = editor_plugin 20 | _editor_interface = editor_plugin.get_editor_interface() 21 | _anim_track_converter = AnimTrackConverter.new(_editor_plugin) 22 | track_convert_select.init(_editor_plugin) 23 | -------------------------------------------------------------------------------- /addons/anim_track_converter/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kasper Arnklit Frandsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /addons/anim_track_converter/ui/convert_dialogue/convert_dialogue.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=3 format=3 uid="uid://bxbff0awma12i"] 2 | 3 | [ext_resource type="Script" uid="uid://ivpl402sjxiw" path="res://addons/anim_track_converter/ui/convert_dialogue/convert_dialogue.gd" id="1_rkcsd"] 4 | [ext_resource type="Script" uid="uid://cris7i7it6f7v" path="res://addons/anim_track_converter/ui/convert_dialogue/track_convert_select.gd" id="2_s13j8"] 5 | 6 | [node name="ConvertDialogue" type="AcceptDialog"] 7 | title = "Convert Value Tracks to Bezier Tracks..." 8 | position = Vector2i(0, 36) 9 | size = Vector2i(726, 400) 10 | ok_button_text = "Convert" 11 | script = ExtResource("1_rkcsd") 12 | 13 | [node name="TrackConvertVbox" type="VBoxContainer" parent="."] 14 | offset_left = 8.0 15 | offset_top = 8.0 16 | offset_right = 718.0 17 | offset_bottom = 351.0 18 | 19 | [node name="TrackConvertSelect" type="Tree" parent="TrackConvertVbox"] 20 | auto_translate_mode = 2 21 | layout_mode = 2 22 | size_flags_vertical = 3 23 | hide_root = true 24 | script = ExtResource("2_s13j8") 25 | 26 | [node name="WarningLabel" type="Label" parent="TrackConvertVbox"] 27 | modulate = Color(1, 0.870588, 0.4, 1) 28 | layout_mode = 2 29 | horizontal_alignment = 1 30 | -------------------------------------------------------------------------------- /addons/anim_track_converter/lib/editor_util.gd: -------------------------------------------------------------------------------- 1 | # Utility class for parsing and hacking the editor 2 | # This is mostly take from pocchi the dev's great animation refactor addon 3 | # https://github.com/poohcom1/godot-animation-player-refactor 4 | # Copyright (c) 2023 Pupass Chandanamattha 5 | 6 | ## Find menu button to add option to 7 | static func find_edit_menu_button(node: Node) -> MenuButton: 8 | var animation_editor := find_editor_control_with_class(node, "AnimationPlayerEditor") 9 | if animation_editor: 10 | return find_editor_control_with_class( 11 | animation_editor, 12 | "MenuButton", 13 | func(node): return node.text == "Edit" 14 | ) 15 | 16 | return null 17 | 18 | 19 | ## General utility to find a control in the editor using an iterative search 20 | static func find_editor_control_with_class( base: Control, p_class_name: StringName, condition := func(node: Node): return true) -> Node: 21 | if base.get_class() == p_class_name and condition.call(base): 22 | return base 23 | 24 | for child in base.get_children(): 25 | if not child is Control: 26 | continue 27 | 28 | var found = find_editor_control_with_class(child, p_class_name, condition) 29 | if found: 30 | return found 31 | 32 | return null 33 | 34 | 35 | # Finds the active animation player (either pinned or selected) 36 | static func find_active_anim_player(base_control: Control, scene_tree: Tree) -> AnimationPlayer: 37 | var find_anim_player_recursive: Callable 38 | 39 | var pin_icon := scene_tree.get_theme_icon("Pin", "EditorIcons") 40 | 41 | var stack: Array[TreeItem] = [] 42 | stack.append(scene_tree.get_root()) 43 | 44 | while not stack.is_empty(): 45 | var current := stack.pop_back() as TreeItem 46 | 47 | # Check for pin icon 48 | for i in current.get_button_count(0): 49 | if current.get_button(0, i) == pin_icon: 50 | var node := base_control.get_node_or_null(current.get_metadata(0)) 51 | if node is AnimationPlayer: 52 | return node 53 | 54 | if current.is_selected(0): 55 | var node := base_control.get_node_or_null(current.get_metadata(0)) 56 | if node is AnimationPlayer: 57 | return node 58 | 59 | for i in range(current.get_child_count() - 1, -1, -1): 60 | stack.push_back(current.get_child(i)) 61 | 62 | return null 63 | -------------------------------------------------------------------------------- /addons/anim_track_converter/lib/anim_track_converter.gd: -------------------------------------------------------------------------------- 1 | # Utility class to handle conversion logic 2 | 3 | const EditorUtil := preload("res://addons/anim_track_converter/lib/editor_util.gd") 4 | 5 | var _editor_plugin: EditorPlugin 6 | var _undo_redo: EditorUndoRedoManager 7 | 8 | 9 | func _init(editor_plugin: EditorPlugin) -> void: 10 | _editor_plugin = editor_plugin 11 | _undo_redo = editor_plugin.get_undo_redo() 12 | 13 | 14 | static func convert_track_to_bezier(p_anim: Animation, p_index: int, p_insert_at: int) -> int: 15 | if (p_insert_at < 0): 16 | p_insert_at = p_anim.get_track_count() 17 | 18 | var track_path: String = p_anim.track_get_path(p_index) 19 | 20 | var key_type = typeof(p_anim.track_get_key_value(p_index, 0)) 21 | var subindices = get_bezier_subindices_for_type(key_type) 22 | 23 | for i in subindices.size(): 24 | var subindex: String = subindices[i] 25 | var new_track_index: int = p_insert_at + i 26 | 27 | p_anim.add_track(Animation.TYPE_BEZIER, new_track_index) 28 | 29 | p_anim.track_set_path(new_track_index, track_path + subindex) 30 | 31 | var key_count: int = p_anim.track_get_key_count(p_index) 32 | 33 | for j in key_count: 34 | var key_time: float = p_anim.track_get_key_time(p_index, j) 35 | var key_value = p_anim.track_get_key_value(p_index, j) 36 | var key_sub_value = key_value if subindex.is_empty() else key_value[i] 37 | p_anim.bezier_track_insert_key(new_track_index, key_time, key_sub_value, Vector2(0.0, 0.0), Vector2(0.0, 0.0)) 38 | 39 | return subindices.size(); 40 | 41 | 42 | static func get_bezier_subindices_for_type(p_type) -> Array[String]: 43 | var subindices: Array[String]; 44 | match p_type: 45 | TYPE_INT: 46 | subindices.push_back("") 47 | TYPE_FLOAT: 48 | subindices.push_back("") 49 | TYPE_VECTOR2: 50 | subindices.push_back(":x") 51 | subindices.push_back(":y") 52 | TYPE_VECTOR3: 53 | subindices.push_back(":x") 54 | subindices.push_back(":y") 55 | subindices.push_back(":z") 56 | TYPE_QUATERNION: 57 | subindices.push_back(":x") 58 | subindices.push_back(":y") 59 | subindices.push_back(":z") 60 | subindices.push_back(":w") 61 | TYPE_COLOR: 62 | subindices.push_back(":r") 63 | subindices.push_back(":g") 64 | subindices.push_back(":b") 65 | subindices.push_back(":a") 66 | TYPE_PLANE: 67 | subindices.push_back(":x") 68 | subindices.push_back(":y") 69 | subindices.push_back(":z") 70 | subindices.push_back(":d") 71 | return subindices 72 | -------------------------------------------------------------------------------- /addons/anim_track_converter/ui/convert_dialogue/track_convert_select.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends Tree 3 | 4 | const TrackConverter := preload("res://addons/anim_track_converter/lib/anim_track_converter.gd") 5 | 6 | var _editor_plugin: EditorPlugin 7 | var _gui: Control 8 | 9 | 10 | func init(editor_plugin: EditorPlugin): 11 | _editor_plugin = editor_plugin 12 | _gui = editor_plugin.get_editor_interface().get_base_control() 13 | 14 | 15 | func generate_track_list(animation_player: AnimationPlayer) -> void: 16 | 17 | var warning_label = get_parent().get_node("WarningLabel") 18 | 19 | warning_label.text = "" 20 | 21 | if animation_player.assigned_animation == "RESET": 22 | warning_label.text = """Reset tracks cannot be converted manually, 23 | they will be automatically converted as needed.""" 24 | return 25 | 26 | var animation := animation_player.get_animation(animation_player.assigned_animation) 27 | 28 | if animation.get_track_count() == 0: 29 | warning_label.text = "No Value Tracks in animation." 30 | return 31 | 32 | var root: Node = animation_player.get_node(animation_player.root_node) 33 | 34 | var troot: TreeItem = create_item() 35 | 36 | for i in animation.get_track_count(): 37 | if animation.track_get_key_count(i) == 0: 38 | continue 39 | var path: NodePath = animation.track_get_path(i) 40 | var node: Node = null 41 | 42 | if root: 43 | node = root.get_node_or_null(path) 44 | 45 | var text: String 46 | 47 | var icon: Texture2D 48 | 49 | if node: 50 | icon = _gui.get_theme_icon(node.get_class(), "EditorIcons") 51 | 52 | text = node.get_name() 53 | 54 | var sub_path: String 55 | 56 | for t in path.get_subname_count(): 57 | text += "." 58 | text += path.get_subname(t) 59 | 60 | # Store full path instead for copying. 61 | path = NodePath(node.get_path().get_concatenated_names() + ":" + path.get_concatenated_subnames()) 62 | 63 | else: 64 | text = path 65 | var sep = text.find(":") 66 | if sep != -1: 67 | text = text.substr(sep, text.length()) 68 | 69 | var track_type: String 70 | match animation.track_get_type(i): 71 | Animation.TYPE_POSITION_3D: 72 | track_type = "Position" 73 | Animation.TYPE_ROTATION_3D: 74 | track_type = "Rotation" 75 | Animation.TYPE_SCALE_3D: 76 | track_type = "Scale" 77 | Animation.TYPE_BLEND_SHAPE: 78 | track_type = "BlendShape" 79 | Animation.TYPE_METHOD: 80 | continue 81 | Animation.TYPE_BEZIER: 82 | continue 83 | Animation.TYPE_AUDIO: 84 | continue 85 | Animation.TYPE_ANIMATION: 86 | continue 87 | 88 | if !track_type.is_empty(): 89 | text += track_type 90 | 91 | var it: TreeItem = create_item(troot) 92 | it.set_editable(0, true) 93 | it.set_selectable(0, true) 94 | it.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) 95 | it.set_icon(0, icon) 96 | it.set_text(0, text) 97 | var md := {} 98 | md["track_idx"] = i 99 | md["path"] = path 100 | it.set_metadata(0, md) 101 | -------------------------------------------------------------------------------- /addons/anim_track_converter/plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | const ConvertDialogue := preload("ui/convert_dialogue/convert_dialogue.gd") 5 | const AnimTrackConvert := preload("res://addons/anim_track_converter/lib/anim_track_converter.gd") 6 | 7 | const EditorUtil := preload("lib/editor_util.gd") 8 | 9 | var convert_dialogue: ConvertDialogue 10 | 11 | var edit_menu_button: MenuButton 12 | 13 | var _last_anim_player: AnimationPlayer 14 | const SCENE_TREE_IDX := 0 15 | var _scene_tree: Tree 16 | 17 | func _enter_tree() -> void: 18 | # Create dialogue 19 | convert_dialogue = load("res://addons/anim_track_converter/ui/convert_dialogue/convert_dialogue.tscn").instantiate() 20 | get_editor_interface().get_base_control().add_child(convert_dialogue) 21 | convert_dialogue.init(self) 22 | # Create menu button 23 | _add_convert_option() 24 | 25 | 26 | func _pop_up_convert() -> void: 27 | convert_dialogue.popup_centered() 28 | convert_dialogue.track_convert_select.clear() 29 | convert_dialogue.track_convert_select.generate_track_list(get_anim_player()) 30 | if not convert_dialogue.confirmed.is_connected(_convert): 31 | convert_dialogue.confirmed.connect(_convert) 32 | 33 | 34 | func _get_animation_track_reference_count(path: NodePath, lib: AnimationLibrary) -> int: 35 | var counter := 0 36 | for anim_name in lib.get_animation_list(): 37 | var anim := lib.get_animation(anim_name) 38 | for i in anim.get_track_count(): 39 | if path == anim.track_get_path(i) and anim.track_get_type(i) == Animation.TrackType.TYPE_VALUE: 40 | counter += 1 41 | return counter 42 | 43 | 44 | func _convert() -> void: 45 | var player : AnimationPlayer = _last_anim_player 46 | var anim_name = player.assigned_animation 47 | 48 | var lib := player.get_animation_library(player.find_animation_library(player.get_animation(anim_name))) 49 | var reset_lib := player.get_animation_library(player.find_animation_library(player.get_animation(StringName("RESET")))) 50 | 51 | var animation: Animation = player.get_animation(anim_name).duplicate() 52 | var reset_animation: Animation = player.get_animation(StringName("RESET")).duplicate() 53 | 54 | var tree_root : TreeItem = convert_dialogue.track_convert_select.get_root() 55 | if tree_root: 56 | 57 | var new_track_count := animation.get_track_count() 58 | var new_reset_track_count := reset_animation.get_track_count() 59 | 60 | # Loop through the selected tracks create new converted copies of the tracks and thier 61 | # reset tracks. 62 | var it = tree_root.get_first_child() 63 | while it: 64 | var md: Dictionary = it.get_metadata(0) 65 | var idx: int = md["track_idx"] 66 | if it.is_checked(0) and idx >= 0 and idx < animation.get_track_count(): 67 | var reset_idx = reset_animation.find_track(animation.track_get_path(idx), animation.track_get_type(idx)) 68 | new_track_count += AnimTrackConvert.convert_track_to_bezier(animation, idx, new_track_count) 69 | if reset_idx >= 0: 70 | new_reset_track_count += AnimTrackConvert.convert_track_to_bezier(reset_animation, reset_idx, new_reset_track_count) 71 | it = it.get_next() 72 | 73 | var unused_reset_tracks: Array[NodePath] 74 | 75 | # Go through the selected tracks in reverse and delete the orinal trakcs (but not the reset 76 | # tracks yet) 77 | # We add any deleted tracks to a list 78 | it = tree_root.get_child(-1) 79 | while(it): 80 | var md: Dictionary = it.get_metadata(0) 81 | var idx: int = md["track_idx"] 82 | if it.is_checked(0) and idx >= 0 and idx < animation.get_track_count(): 83 | unused_reset_tracks.append(animation.track_get_path(idx)) 84 | animation.remove_track(idx) 85 | it = it.get_prev() 86 | 87 | # We iterate through the list of deleted tracks and remove any that still have users 88 | # in other animations, meaning they should not be deleted from the reset animation 89 | for i in range(unused_reset_tracks.size() -1, -1, -1) : 90 | if _get_animation_track_reference_count(unused_reset_tracks[i], lib) > 2: 91 | unused_reset_tracks.remove_at(i) 92 | 93 | # We iterate through the remaining list and delete the reset tracks it contains. 94 | for i in range(reset_animation.get_track_count() -1, -1, -1): 95 | if reset_animation.track_get_type(i) == Animation.TYPE_VALUE: 96 | if unused_reset_tracks.has(reset_animation.track_get_path(i)): 97 | reset_animation.remove_track(i) 98 | 99 | 100 | var ur = get_undo_redo() 101 | 102 | ur.create_action("Convert animation to Bezier") 103 | 104 | ur.add_undo_method(lib, "add_animation", anim_name.split("/")[-1], player.get_animation(anim_name)) 105 | ur.add_do_method(lib, "add_animation", anim_name.split("/")[-1], animation) 106 | # 107 | ur.add_undo_method(reset_lib, "add_animation", StringName("RESET"), player.get_animation(StringName("RESET"))) 108 | ur.add_do_method(reset_lib, "add_animation", StringName("RESET"), reset_animation) 109 | 110 | ur.commit_action() 111 | 112 | 113 | func _exit_tree() -> void: 114 | if convert_dialogue and convert_dialogue.is_inside_tree(): 115 | get_editor_interface().get_base_control().remove_child(convert_dialogue) 116 | convert_dialogue.queue_free() 117 | 118 | _remove_convert_option() 119 | 120 | 121 | func _handles(object: Object) -> bool: 122 | if object is AnimationPlayer: 123 | _last_anim_player = object 124 | return false 125 | 126 | 127 | # Editor methods 128 | func get_anim_player() -> AnimationPlayer: 129 | # Check for pinned animation 130 | if not _scene_tree: 131 | var _scene_tree_editor = EditorUtil.find_editor_control_with_class( 132 | get_editor_interface().get_base_control(), 133 | "SceneTreeEditor" 134 | ) 135 | 136 | if not _scene_tree_editor: 137 | push_error("[Animation Converter] Could not find scene tree editor. Please report this.") 138 | return null 139 | 140 | _scene_tree = _scene_tree_editor.get_child(SCENE_TREE_IDX) 141 | 142 | if not _scene_tree: 143 | push_error("[Animation Converter] Could not find scene tree editor. Please report this.") 144 | return null 145 | 146 | var found_anim := EditorUtil.find_active_anim_player( 147 | get_editor_interface().get_base_control(), 148 | _scene_tree 149 | ) 150 | 151 | if found_anim: 152 | return found_anim 153 | 154 | # Get latest edited 155 | return _last_anim_player 156 | 157 | 158 | # Plugin buttons 159 | 160 | const TOOL_CONVERT := 999 161 | const TOOL_ANIM_LIBRARY := 1 162 | 163 | func _add_convert_option(): 164 | var base_control := get_editor_interface().get_base_control() 165 | if not edit_menu_button: 166 | edit_menu_button = EditorUtil.find_edit_menu_button(base_control) 167 | if not edit_menu_button: 168 | push_error("[Animation Converter] Could not find Edit menu button. Please report this issue.") 169 | return 170 | 171 | var edit_popup := edit_menu_button.get_popup() 172 | 173 | # Add convert item 174 | edit_popup.add_icon_item( 175 | base_control.get_theme_icon(&"Reload", &"EditorIcons"), 176 | "Convert Value Track to Bezier Track...", 177 | TOOL_CONVERT, 178 | ) 179 | 180 | edit_popup.notification(NOTIFICATION_TRANSLATION_CHANGED) 181 | 182 | edit_popup.id_pressed.connect(_on_menu_button_pressed) 183 | 184 | 185 | func _remove_convert_option(): 186 | if not edit_menu_button: 187 | return 188 | 189 | var base_control := get_editor_interface().get_base_control() 190 | 191 | var menu_popup := edit_menu_button.get_popup() 192 | menu_popup.remove_item(menu_popup.get_item_index(TOOL_CONVERT)) 193 | 194 | menu_popup.id_pressed.disconnect(_on_menu_button_pressed) 195 | 196 | if convert_dialogue.confirmed.is_connected(_convert): 197 | convert_dialogue.confirmed.disconnect(_convert) 198 | 199 | 200 | func _on_menu_button_pressed(id: int): 201 | if id == TOOL_CONVERT: 202 | _pop_up_convert() 203 | --------------------------------------------------------------------------------